/** * Pi extension for Zellij session status reporting. * * Install this file into ~/.pi/agent/extensions/falcode.ts. */ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import path from "node:path"; const DETECTION_SCRIPT_NAME = "detect-active-opencode.sh"; const DETECTION_SCRIPT_DEFAULT_NAME = "detect-active-opencode.default.sh"; const DETECTION_SCRIPT = `#!/bin/sh set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) STATE_DIR=\${FALCODE_STATE_DIR:-$SCRIPT_DIR} SNAPSHOT_FILE=\${FALCODE_SNAPSHOT_FILE:-$STATE_DIR/detect-active-opencode.snapshot.tsv} CACHE_FILE=\${FALCODE_CACHE_FILE:-$STATE_DIR/popup-cache.json} CURRENT_SESSION=\${FALCODE_CURRENT_SESSION:-} NOW_MS=\${FALCODE_NOW_MS:-$(python3 -c 'import time; print(int(time.time() * 1000))')} MAX_AGE_MS=\${FALCODE_MAX_PANE_STATE_AGE_MS:-180000} if [ ! -f "$SNAPSHOT_FILE" ] && [ -f "$CACHE_FILE" ]; then if python3 - "$CACHE_FILE" "$NOW_MS" "$MAX_AGE_MS" <<'PY' import json import sys cache_file = sys.argv[1] now_ms = int(sys.argv[2]) max_age_ms = int(sys.argv[3]) try: with open(cache_file, encoding="utf-8") as fh: payload = json.load(fh) except Exception: raise SystemExit(1) entries = payload.get("entries") generated_at_ms = int(payload.get("generated_at_ms") or 0) if not isinstance(entries, list): raise SystemExit(1) if generated_at_ms and now_ms - generated_at_ms > max_age_ms: raise SystemExit(1) json.dump(entries, sys.stdout, separators=(",", ":")) sys.stdout.write("\\n") PY then exit 0 fi fi tmp_input=$(mktemp "\${TMPDIR:-/tmp}/falcode-detect.XXXXXX") cleanup() { rm -f "$tmp_input" } trap cleanup EXIT HUP INT TERM has_snapshot=0 if [ -f "$SNAPSHOT_FILE" ]; then has_snapshot=1 cp "$SNAPSHOT_FILE" "$tmp_input" fi python3 - "$STATE_DIR" "$tmp_input" <<'PY' import json import pathlib import sys state_dir = pathlib.Path(sys.argv[1]) output_path = pathlib.Path(sys.argv[2]) def clean(value): if value is None: return "" return str(value).replace("\t", " ").replace("\n", " ").replace("\r", " ") def append_tracked(pane): session_name = clean(pane.get("session_name")) pane_id = pane.get("pane_id") status = clean(pane.get("status")) agent = clean(pane.get("agent")) if not session_name or pane_id is None or not status or not agent: return None cwd = clean(pane.get("cwd")) updated_at_ms = pane.get("updated_at_ms", 0) return f"tracked\t{session_name}\t{pane_id}\t{status}\t{agent}\t{cwd}\t{updated_at_ms}\n" records = [] seen_sessions = set() panes_dir = state_dir / "panes" if panes_dir.is_dir(): for pane_file in sorted(panes_dir.glob("*.json")): try: pane = json.loads(pane_file.read_text(encoding="utf-8")) except Exception: continue record = append_tracked(pane) if record is None: continue session_name = clean(pane.get("session_name")) if session_name and session_name not in seen_sessions: records.append(f"session\t{session_name}\n") seen_sessions.add(session_name) records.append(record) legacy_state = state_dir / "opencode-sessions.json" if legacy_state.is_file() and not records: try: data = json.loads(legacy_state.read_text(encoding="utf-8")) except Exception: data = {} for pane in (data.get("panes") or {}).values(): record = append_tracked(pane) if record is None: continue session_name = clean(pane.get("session_name")) if session_name and session_name not in seen_sessions: records.append(f"session\t{session_name}\n") seen_sessions.add(session_name) records.append(record) if records: with output_path.open("a", encoding="utf-8") as fh: for record in records: fh.write(record) PY awk -F '\t' -v current_session="$CURRENT_SESSION" -v now_ms="$NOW_MS" -v max_age_ms="$MAX_AGE_MS" -v has_snapshot="$has_snapshot" ' function decode_field(value) { gsub(/\\r/, " ", value) gsub(/\\n/, " ", value) return value } function json_escape(value) { gsub(/\\/, "\\\\", value) gsub(/"/, "\\\"", value) gsub(/\t/, " ", value) gsub(/\r/, " ", value) gsub(/\n/, " ", value) return value } function command_program(command, parts, first) { command = decode_field(command) sub(/^[[:space:]]+/, "", command) if (command == "") { return "" } split(command, parts, /[[:space:]]+/) first = parts[1] sub(/^.*\//, "", first) return tolower(first) } function is_pi_command(lower_command) { return lower_command == "pi" || lower_command ~ /^pi[[:space:]]/ || lower_command ~ /\\/pi$/ || lower_command ~ /\\/pi[[:space:]]/ || index(lower_command, "pi-coding-agent") } function agent_name(agent) { return agent == "claude" ? "Claude" : agent == "pi" ? "Pi" : "OpenCode" } function is_supported_agent(agent) { return agent == "opencode" || agent == "claude" || agent == "pi" } function is_agent_pane(title, command, lower_command, program) { lower_command = tolower(command) program = command_program(command) return program == "opencode" || program == "claude" || program == "pi" || program == "pi-coding-agent" || index(lower_command, "opencode") || index(lower_command, "claude") || is_pi_command(lower_command) } function print_entry(session_name, pane_id, pane_title, tab_position, tab_name, status, cwd, updated_at_ms, cwd_json) { if (!first_entry) { printf(",\\n") } printf(" {\\\"session_name\\\":\\\"%s\\\",\\\"pane_id\\\":%d,\\\"pane_title\\\":\\\"%s\\\",\\\"tab_position\\\":%d,\\\"tab_name\\\":\\\"%s\\\",\\\"status\\\":\\\"%s\\\",\\\"cwd\\\":", json_escape(session_name), pane_id + 0, json_escape(pane_title), tab_position + 0, json_escape(tab_name), json_escape(status)) if (cwd == "") { cwd_json = "null" } else { cwd_json = sprintf("\\\"%s\\\"", json_escape(cwd)) } printf("%s,\\\"updated_at_ms\\\":%d}", cwd_json, updated_at_ms + 0) first_entry = 0 } BEGIN { print "[" first_entry = 1 } { record_type = $1 if (record_type == "session" && NF >= 2) { known_sessions[decode_field($2)] = 1 next } if (record_type == "pane" && NF >= 7) { session_name = decode_field($2) pane_id = $3 + 0 key = session_name SUBSEP pane_id session_has_panes[session_name] = 1 pane_exists[key] = 1 pane_order[++pane_count] = key pane_tab_position[key] = $4 + 0 pane_tab_name[key] = decode_field($5) pane_title[key] = decode_field($6) pane_command[key] = decode_field($7) next } if (record_type == "tracked" && NF >= 7) { session_name = decode_field($2) pane_id = $3 + 0 status = decode_field($4) agent = decode_field($5) cwd = decode_field($6) updated_at_ms = $7 + 0 key = session_name SUBSEP pane_id if (!is_supported_agent(agent)) { next } if (!(key in tracked_updated_at_ms) || updated_at_ms > tracked_updated_at_ms[key]) { tracked_keys[key] = 1 tracked_updated_at_ms[key] = updated_at_ms tracked_status[key] = status tracked_agent[key] = agent tracked_cwd[key] = cwd } next } } END { for (key in tracked_keys) { split(key, parts, SUBSEP) session_name = parts[1] pane_id = parts[2] if (!(session_name in known_sessions)) { continue } if (tracked_updated_at_ms[key] != 0 && (now_ms - tracked_updated_at_ms[key]) > max_age_ms) { continue } if (has_snapshot == 1 && (session_name in session_has_panes)) { if (!(key in pane_exists)) { continue } } seen_panes[key] = 1 if (key in pane_exists) { print_entry(session_name, pane_id, pane_title[key], pane_tab_position[key], pane_tab_name[key], tracked_status[key], tracked_cwd[key], tracked_updated_at_ms[key]) } else { print_entry(session_name, pane_id, agent_name(tracked_agent[key]), 0, "", tracked_status[key], tracked_cwd[key], tracked_updated_at_ms[key]) } } for (i = 1; i <= pane_count; i++) { key = pane_order[i] if (seen_panes[key]) { continue } split(key, parts, SUBSEP) session_name = parts[1] pane_id = parts[2] if (session_name != current_session) { continue } if (!is_agent_pane(pane_title[key], pane_command[key])) { continue } print_entry(session_name, pane_id, pane_title[key], pane_tab_position[key], pane_tab_name[key], "waiting_user_input", "", 0) } if (!first_entry) { printf("\\n") } print "]" } ' "$tmp_input" `; const MAX_PANE_STATE_AGE_MS = 180_000; function ensureDetectionScript(stateRoot) { const scriptPath = path.join(stateRoot, DETECTION_SCRIPT_NAME); const defaultScriptPath = path.join(stateRoot, DETECTION_SCRIPT_DEFAULT_NAME); writeFileSync(defaultScriptPath, DETECTION_SCRIPT, { encoding: "utf8", mode: 0o755, }); try { readFileSync(scriptPath, "utf8"); return; } catch { writeFileSync(scriptPath, DETECTION_SCRIPT, { encoding: "utf8", mode: 0o755, }); } } function stableSessionKey() { const paneId = process.env.ZELLIJ_PANE_ID ?? "unknown-pane"; const sessionName = process.env.ZELLIJ_SESSION_NAME ?? "unknown-session"; return `${sessionName}:${paneId}`; } function resolveNotifyScript(stateRoot) { const override = process.env.FALCODE_NOTIFY_SCRIPT; // Ignore a stale/broken override (e.g. /bin/true on NixOS, where that path // doesn't exist) and fall through to the default no-op script. if (override && existsSync(override)) return override; const stateScript = path.join(stateRoot, "oc-notify.sh"); try { readFileSync(stateScript, "utf8"); return stateScript; } catch { // Fall through. } try { const extensionFile = realpathSync(fileURLToPath(import.meta.url)); return path.resolve(path.dirname(extensionFile), "..", "scripts", "oc-notify.sh"); } catch { return null; } } function notificationStatusFor(newStatus, prevStatus) { if (newStatus === "waiting_user_input" && prevStatus === "working") return "idle"; return null; } const ATTENTION_ACTIVE_STATES = new Set(["working"]); function sendAttentionPipe(event, paneId) { if (process.env.FALCODE_DISABLE_ATTENTION === "1") return; if (!event || paneId == null) return; try { const child = spawn( "zellij", ["pipe", "--name", `zellij-attention::${event}::${paneId}`], { detached: true, stdio: "ignore" }, ); child.on("error", () => { // Never let a spawn failure (e.g. zellij missing from PATH) // bubble up as an uncaughtException and kill pi. }); child.unref(); } catch { // Best-effort: zellij-attention plugin may not be installed. } } function attentionEventFor(newStatus, prevStatus) { const wasActive = ATTENTION_ACTIVE_STATES.has(prevStatus); const isActive = ATTENTION_ACTIVE_STATES.has(newStatus); if (!wasActive && isActive) { return process.env.FALCODE_ATTENTION_ENTER_EVENT ?? "waiting"; } if (wasActive && !isActive) { return process.env.FALCODE_ATTENTION_EXIT_EVENT ?? "completed"; } return null; } function fireNotification({ notifyScript, agent, status, sessionName, paneId, cwd }) { if (!notifyScript) return; const displayName = cwd ? path.basename(cwd) : agent === "pi" ? "Pi" : "OpenCode"; try { const child = spawn( notifyScript, [ "--agent", agent, "--pane-name", displayName, "--status", status, "--session", sessionName, "--pane-id", String(paneId), ], { detached: true, stdio: "ignore" }, ); child.on("error", () => { // Never let a spawn failure (e.g. FALCODE_NOTIFY_SCRIPT pointing at a // binary that doesn't exist, like /bin/true on NixOS) bubble up as an // uncaughtException and kill pi. }); child.unref(); } catch { // Best-effort. } } function cleanupStalePanes(panesDir) { const now = Date.now(); try { for (const file of readdirSync(panesDir)) { if (!file.endsWith(".json")) continue; const filePath = path.join(panesDir, file); try { const data = JSON.parse(readFileSync(filePath, "utf8")); const age = now - (data.updated_at_ms ?? 0); if (age > MAX_PANE_STATE_AGE_MS) { rmSync(filePath, { force: true }); } } catch { rmSync(filePath, { force: true }); } } } catch { // Nothing to clean. } } export default function (_pi) { const paneId = process.env.ZELLIJ_PANE_ID; const sessionName = process.env.ZELLIJ_SESSION_NAME; if (!paneId || !sessionName) { return; } const stateRoot = process.env.FALCODE_STATE_DIR ?? path.join(process.env.HOME ?? ".", ".local", "state", "falcode-zellij"); const panesDir = path.join(stateRoot, "panes"); const notifyScript = resolveNotifyScript(stateRoot); const stateFile = path.join( panesDir, `${sessionName.replace(/[^a-zA-Z0-9_-]/g, "_")}_${paneId}.json`, ); mkdirSync(panesDir, { recursive: true }); ensureDetectionScript(stateRoot); cleanupStalePanes(panesDir); const cwd = process.env.PWD ?? process.cwd(); let lastStatus = "waiting_user_input"; let stableId = stableSessionKey(); let initialized = false; function writeState(status) { const prevStatus = lastStatus; lastStatus = status; const payload = { agent: "pi", cwd, stable_id: stableId, pane_id: Number.parseInt(paneId, 10), session_name: sessionName, status, updated_at_ms: Date.now(), }; writeFileSync(stateFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); if (!initialized || status === prevStatus) return; const attentionEvent = attentionEventFor(status, prevStatus); if (attentionEvent) { sendAttentionPipe(attentionEvent, Number.parseInt(paneId, 10)); } const notifyStatus = notificationStatusFor(status, prevStatus); if (!notifyStatus) return; fireNotification({ notifyScript, agent: "pi", status: notifyStatus, sessionName, paneId: Number.parseInt(paneId, 10), cwd, }); } try { const existing = JSON.parse(readFileSync(stateFile, "utf8")); if (existing?.session_name === sessionName) { stableId = existing?.stable_id ?? stableId; } writeState(existing?.status ?? "waiting_user_input"); } catch { writeState("waiting_user_input"); } initialized = true; const heartbeat = setInterval(() => { writeState(lastStatus); }, 60_000); const cleanup = () => { clearInterval(heartbeat); rmSync(stateFile, { force: true }); }; process.once("exit", cleanup); _pi.on("agent_start", async () => { writeState("working"); }); _pi.on("agent_end", async () => { writeState("waiting_user_input"); }); _pi.on("session_shutdown", async () => { cleanup(); }); }