falcode: fix spawn error handlers (crash fix) + real oc-notify.sh notifier

- falcode.ts: add child.on('error') handlers so a missing notify binary
  (e.g. /bin/true on NixOS) can no longer take down pi via uncaughtException
- scripts/oc-notify.sh: real notifier (notify-send + ntfy) replacing the no-op
- oc-notify.sh also deployed to ~/.local/state/falcode-zellij/ on all machines
This commit is contained in:
2026-08-04 06:31:26 +10:00
parent 6d15e14550
commit 021d4cc2d6
2 changed files with 592 additions and 0 deletions

537
extensions/falcode.ts Normal file
View File

@@ -0,0 +1,537 @@
/**
* 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();
});
}

55
scripts/oc-notify.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/sh
# falcode agent-completion notifier.
#
# Spawned by the falcode pi extension (falcode.ts -> fireNotification) when an
# agent's turn finishes (working -> waiting_user_input) with:
# --agent <pi|opencode> --pane-name <cwd-basename> --status idle
# --session <zellij-session> --pane-id <n>
#
# Sends a desktop popup (notify-send) and an ntfy push so Sam knows the agent
# finished. Must never exit non-zero in a way that could disturb the caller.
agent=""
pane_name=""
status=""
session=""
pane_id=""
while [ $# -gt 0 ]; do
case "$1" in
--agent) agent="$2"; shift 2 ;;
--pane-name) pane_name="$2"; shift 2 ;;
--status) status="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--pane-id) pane_id="$2"; shift 2 ;;
*) shift ;;
esac
done
# Only notify on agent-finish transitions.
[ "$status" = "idle" ] || exit 0
[ -z "$pane_name" ] && pane_name="$agent"
[ -z "$pane_name" ] && pane_name="pi"
title="$pane_name finished"
body="$agent agent done"
[ -n "$session" ] && body="$body · $session"
# 1) Desktop popup
if command -v notify-send >/dev/null 2>&1; then
notify-send -a "Pi" "$title" "$body" >/dev/null 2>&1 || true
fi
# 2) ntfy push (phone/desktop clients). PI_NTFY_TOPIC comes from
# ~/.config/environment.d/10-secrets.conf; fall back to the lab topic.
topic="${PI_NTFY_TOPIC:-http://192.168.20.35:8099/PI_NTFY_TOPIC}"
if command -v curl >/dev/null 2>&1; then
curl -s -o /dev/null \
-H "Title: $title" \
-H "Tags: white_check_mark" \
-d "$body" \
"$topic" >/dev/null 2>&1 || true
fi
exit 0