diff --git a/Dockerfile b/Dockerfile index e17b15a..19a5542 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,12 @@ ENV LANG=C.UTF-8 # Pinned to @latest so a factory reboot (no-cache rebuild) reinstalls the newest # published versions — that's what the "Relaunch & update" button triggers. RUN npm install -g @anthropic-ai/claude-code@latest @openai/codex@latest +# This image is the administrator of its own Codex runtime. Install Agent +# Manager's lifecycle adapter as a managed hook so it runs deterministically +# without weakening trust for any user/project hooks. +COPY codex-requirements.toml /etc/codex/requirements.toml +COPY scripts/am-codex-repin-hook.sh /etc/codex/hooks/am-codex-repin-hook.sh +RUN chmod 755 /etc/codex/hooks/am-codex-repin-hook.sh # Newer agents, best-effort so a publish hiccup can't break the image build; # the app marks any missing binary "unavailable" gracefully. RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed" diff --git a/codex-requirements.toml b/codex-requirements.toml new file mode 100644 index 0000000..3de5549 --- /dev/null +++ b/codex-requirements.toml @@ -0,0 +1,16 @@ +# Agent Manager owns this container-level Codex policy. A managed hook needs no +# per-pane trust prompt, unlike a user hooks.json entry, and it is limited to +# reporting the exact root session selected by startup/resume/clear. +[features] +hooks = true + +[hooks] +managed_dir = "/etc/codex/hooks" + +[[hooks.SessionStart]] +matcher = "^(startup|resume|clear)$" + +[[hooks.SessionStart.hooks]] +type = "command" +command = "/etc/codex/hooks/am-codex-repin-hook.sh" +timeout = 5 diff --git a/scripts/am-codex-repin-hook.sh b/scripts/am-codex-repin-hook.sh new file mode 100755 index 0000000..99032ef --- /dev/null +++ b/scripts/am-codex-repin-hook.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Managed Codex SessionStart hook. stdin contains the exact session_id, +# transcript_path, cwd and source (startup/resume/clear/compact). +[ "$AM_CLI" = "codex" ] || exit 0 +[ -n "$AM_ID" ] || exit 0 +case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac +[ -n "$AM_RUN_ID" ] || exit 0 +case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac +case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac + +# Usually the pane root is Codex's npm launcher and native Codex is its direct +# child. The resume compatibility command retains bash, making the node launcher +# a direct child and native Codex a grandchild. Accept that one known layer; a +# nested Codex has a tool shell above its launcher and cannot pass this check. +p=$$ +trusted=0 +codex_pid=0 +hops=0 +while [ "$p" -gt 1 ] 2>/dev/null && [ "$hops" -lt 64 ]; do + stat=$(cat "/proc/$p/stat" 2>/dev/null) || break + comm=${stat#*(} + comm=${comm%)*} + rest=${stat##*) } + rest=${rest#* } + ppid=${rest%% *} + case "$comm" in + codex*) + if [ "$p" = "$AM_PANE_PID" ] || [ "$ppid" = "$AM_PANE_PID" ]; then + trusted=1 + else + parent_stat=$(cat "/proc/$ppid/stat" 2>/dev/null) || parent_stat= + parent_comm=${parent_stat#*(} + parent_comm=${parent_comm%)*} + parent_rest=${parent_stat##*) } + parent_rest=${parent_rest#* } + grandparent=${parent_rest%% *} + if [ "$parent_comm" = "node" ] && [ "$grandparent" = "$AM_PANE_PID" ]; then trusted=1; fi + fi + codex_pid=$p + break + ;; + esac + p=$ppid + hops=$((hops + 1)) +done +[ "$trusted" -eq 1 ] || exit 0 + +d="${AM_REPIN_DIR:-/tmp/am-repin}" +mkdir -p "$d" 2>/dev/null || exit 0 +{ + printf '{"amId":"%s","runId":"%s","cli":"codex","codexPid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$codex_pid" + cat + printf '}' +} > "$d/$AM_ID.codex.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.codex.json.$$.tmp" "$d/$AM_ID.codex.json" +exit 0 diff --git a/scripts/am-opencode-repin.js b/scripts/am-opencode-repin.js new file mode 100644 index 0000000..31d52a0 --- /dev/null +++ b/scripts/am-opencode-repin.js @@ -0,0 +1,56 @@ +import { mkdirSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const SAFE = /^[A-Za-z0-9_-]+$/; + +function report(sessionID, cwd, source) { + const amId = process.env.AM_ID; + const runId = process.env.AM_RUN_ID; + if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return; + // The global plugin is also loaded by nested OpenCode processes. Only the + // process that replaced the PTY's login shell owns this pane. + if (String(process.pid) !== process.env.AM_PANE_PID) return; + if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return; + const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin'); + const file = path.join(dir, `${amId}.opencode.json`); + const tmp = `${file}.${process.pid}.tmp`; + try { + // OpenCode dispatches generic event hooks without awaiting their Promise. + // Keep this tiny local write synchronous so /clear followed immediately by + // quit cannot terminate the process between mkdir/write/rename. + mkdirSync(dir, { recursive: true }); + writeFileSync(tmp, JSON.stringify({ + amId, + runId, + cli: 'opencode', + pluginPid: process.pid, + payload: { session_id: sessionID, cwd, source }, + })); + renameSync(tmp, file); + } catch { /* telemetry must never interfere with the user's prompt */ } +} + +// OpenCode creates a new root session for /new (alias /clear). chat.message +// additionally follows an explicit switch to an existing session; runner.js +// verifies that id against the database and rejects child/subagent sessions. +export const AgentManagerRepin = async ({ directory }) => ({ + event: async ({ event }) => { + if (event?.type !== 'session.created') return; + const info = event.properties?.info; + if (!info?.id || info.parentID) return; + report(info.id, info.directory || directory, 'session.created'); + }, + 'chat.message': async ({ sessionID }) => { + report(sessionID, directory, 'chat.message'); + }, + // Tool shells must not pass the pane's private attribution markers to an + // agent launched inside them. Empty values override OpenCode's process.env + // merge and make the nested plugin a no-op. + 'shell.env': async (_input, output) => { + output.env.AM_ID = ''; + output.env.AM_RUN_ID = ''; + output.env.AM_CLI = ''; + output.env.AM_PANE_PID = ''; + }, +}); diff --git a/scripts/am-repin-hook.sh b/scripts/am-repin-hook.sh index b2d10e6..2448b8c 100755 --- a/scripts/am-repin-hook.sh +++ b/scripts/am-repin-hook.sh @@ -2,30 +2,40 @@ # SessionStart breadcrumb for the manager's conversation re-pin (runner.js). # # Claude Code runs this inside the pane's process tree, so $AM_ID — set by the -# manager on the tmux session — says WHICH pane the new conversation belongs +# manager on the PTY — says WHICH pane the new conversation belongs # to. That attribution is the one thing the server cannot work out on its own # when several claude panes share a folder, and it is why a /clear there could # not be followed before (the folderIsShared refusal in runner.js). # # stdin is the hook payload: {session_id, transcript_path, cwd, source, ...}. -# $CLAUDE_PID is the claude process that fired the event; the server verifies -# it descends from the pane before trusting the breadcrumb, because nested -# runs (`claude -p` from inside a pane) inherit $AM_ID and would otherwise -# claim the pane with a throwaway conversation. The entrypoint check below -# already drops those non-interactive runs; the pid check covers the rest. +# $CLAUDE_PID is the claude process that fired the event. Only the pane root or +# its direct child is the managed interactive Claude; a nested Claude started +# by a tool is deeper in the process tree. Filter it here so it cannot overwrite +# the top-level crumb, then runner.js independently repeats the same check. # # Breadcrumbs live on LOCAL disk on purpose: losing them at a restart is # harmless (the pin itself persists in sessions.json), and the relaunch's own # source:"resume" event immediately writes a fresh one. [ -n "$AM_ID" ] || exit 0 case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac +[ -n "$AM_RUN_ID" ] || exit 0 +case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac +[ "$AM_CLI" = "claude" ] || exit 0 [ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0 -case "$CLAUDE_PID" in '' | *[!0-9]*) CLAUDE_PID=0 ;; esac +case "$CLAUDE_PID" in '' | *[!0-9]*) exit 0 ;; esac +case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac +if [ "$CLAUDE_PID" != "$AM_PANE_PID" ]; then + stat=$(cat "/proc/$CLAUDE_PID/stat" 2>/dev/null) || exit 0 + rest=${stat##*) } + rest=${rest#* } + ppid=${rest%% *} + [ "$ppid" = "$AM_PANE_PID" ] || exit 0 +fi d="${AM_REPIN_DIR:-/tmp/am-repin}" mkdir -p "$d" 2>/dev/null || exit 0 { - printf '{"amId":"%s","claudePid":%d,"payload":' "$AM_ID" "$CLAUDE_PID" + printf '{"amId":"%s","runId":"%s","cli":"claude","claudePid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$CLAUDE_PID" cat printf '}' -} > "$d/$AM_ID.json.tmp" 2>/dev/null && mv -f "$d/$AM_ID.json.tmp" "$d/$AM_ID.json" +} > "$d/$AM_ID.claude.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.claude.json.$$.tmp" "$d/$AM_ID.claude.json" exit 0 diff --git a/server/src/index.js b/server/src/index.js index 70e2513..46791fe 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -16,7 +16,10 @@ import * as store from './sessions.js'; import * as groups from './groups.js'; import * as order from './order.js'; import * as demo from './demo.js'; -import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js'; +import { + attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, + capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook, installOpencodeRepinPlugin, +} from './runner.js'; // Control frames ride the terminal socket behind a leading NUL pair, which real // PTY output never begins with. Same sentinel the old copy-mode hint used, so the @@ -38,10 +41,14 @@ store.init(); groups.init(); order.init(); demo.init(); -// Claude panes report conversation resets (e.g. /clear) through a SessionStart -// hook, so the re-pin watcher can follow them even in shared folders where the -// transcript scan must refuse to guess. Non-fatal if it can't be installed. +// Lifecycle adapters report conversation resets (e.g. /clear) with the exact +// id, so re-pin watchers can follow them even in shared folders where storage +// discovery must refuse to guess. Both installers are non-fatal; the existing +// fallback remains available if either cannot be installed. installClaudeRepinHook(); +// OpenCode's global plugin reports the root session chosen by /new (/clear), +// and the next prompt after switching to an existing session. +installOpencodeRepinPlugin(); // One-time migration to the explicit-path model: sessions used to own a folder // named after them (renamed along with them), or inherit their group's shared diff --git a/server/src/runner.js b/server/src/runner.js index 5dbfcd1..cdd07fa 100644 --- a/server/src/runner.js +++ b/server/src/runner.js @@ -1,12 +1,13 @@ import os from 'node:os'; import path from 'node:path'; +import crypto from 'node:crypto'; import pty from 'node-pty'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import { remoteState, setPaused } from './remote.js'; import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js'; import { update, list } from './sessions.js'; -import { captureOpencodeSession, opencodeSessionExists, readTrace } from './traces.js'; +import { captureOpencodeSession, opencodeSessionExists, opencodeSessionInfo, readTrace } from './traces.js'; import { buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns, } from './snapshot.js'; @@ -662,11 +663,10 @@ async function hydrateTraceHistory(session, host) { // ---------- Codex conversation pinning ---------- // Codex picks its own conversation id at launch and doesn't accept one up -// front — but it announces the pick immediately: a rollout file named +// front. The managed SessionStart hook below is the primary source of its exact +// choice. This rollout discovery remains the fallback: a file named // rollout--.jsonl appears under $CODEX_HOME/sessions with the cwd in -// its first line. Capture that id shortly after launch and pin it on the -// session, so restarts resume THIS agent's conversation — `resume --last` -// would grab whichever Codex agent in the same folder ran last. +// its first line, so an unshared pane can still recover when hooks are absent. const codexCapturing = new Map(); // id -> pending re-pin timer // Every harness's conversation pin is re-checked on this cadence for as long as @@ -832,8 +832,8 @@ function scheduleCodexCapture(session, workdir) { } // opencode has no per-conversation handle we can pass on launch, so we can't -// mint an id like Claude's --session-id. Instead, capture the ses_ row opencode -// writes to its db and pin it — mirrors the codex approach. The row appears +// mint an id like Claude's --session-id. Its plugin reports the exact ses_ id; +// this database discovery remains the unshared-folder fallback. A row appears // only once the conversation has content (the user's first message), so retry // on a longer, sparser schedule than codex. // ---------- Claude conversation re-pinning ---------- @@ -982,21 +982,31 @@ function folderIsShared(sessionId, workdir, cli) { // honoured) and a /clear at any later point. // ---------- breadcrumbs: the pane tells us, so we don't have to guess ---------- -// The transcript scan above cannot attribute a new conversation when several -// live claude panes share a folder — folderIsShared refuses, and a /clear in -// such a folder was never followed. But the pane itself KNOWS: a SessionStart -// hook (installed into settings.json below, script at scripts/am-repin-hook.sh) -// runs inside the pane's process tree, where $AM_ID names the pane and the -// payload carries the new conversation's id. It drops that as a breadcrumb -// here; the watcher consumes it and re-pins with no guessing at all. The scan -// stays as the fallback for panes without a breadcrumb (hook newly installed, -// crumb lost) — and for codex/opencode, which have no hook mechanism. +// Folder scans cannot attribute a new conversation when several live panes of +// one harness share a folder. The harness itself does know the exact id, so the +// three harnesses with lifecycle extension points report it directly: +// +// Claude SessionStart command hook +// Codex managed SessionStart command hook +// OpenCode global plugin (session.created and chat.message) +// +// Each PTY launch gets a fresh AM_RUN_ID. A breadcrumb must match both AM_ID and +// that nonce, so a delayed event from the pane's previous process can never +// move its replacement — and two panes in one folder can never claim each +// other's conversation. The existing transcript/rollout/database discovery +// stays below as a fallback for installations where an adapter is unavailable. const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin'; +const breadcrumbCapturing = new Map(); // session id -> pending exact-event poll +const BREADCRUMB_MS = 250; +const BREADCRUMB_RETRY_MS = 500; +const BREADCRUMB_RETRIES = 20; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const CONVERSATION_ID = { claude: UUID, codex: UUID, opencode: /^ses_[A-Za-z0-9_-]+$/ }; // Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb // can never flip a pin backwards after a later, scan-based re-pin. -function takeClaudeBreadcrumb(sessionId) { - const p = path.join(REPIN_DIR, `${sessionId}.json`); +function takeBreadcrumb(sessionId, cli) { + const p = path.join(REPIN_DIR, `${sessionId}.${cli}.json`); let raw; try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; } try { fs.unlinkSync(p); } catch {} @@ -1005,8 +1015,9 @@ function takeClaudeBreadcrumb(sessionId) { // The pane's root process — the PTY this server spawned for the session. After // `exec claude` this IS claude; in the `claude --session-id … || exec claude` -// branch claude is a child of it. Either way the hook's $CLAUDE_PID must descend -// from it. +// branch claude is its direct child. Trusting ANY descendant is too broad: an +// interactive agent started by the top-level agent's shell tool inherits AM_ID +// too, and its lifecycle event must not move the pane's pin. // // This used to ask tmux for `#{pane_pid}`. Replacing tmux with a server-held grid // left the call behind referencing two identifiers that no longer exist here @@ -1022,40 +1033,98 @@ export function paneRootPid(sessionId) { return Number.isInteger(pid) && pid > 1 ? pid : null; } -// Walk /proc ppid links. comm in /proc//stat may contain spaces and -// parens, so split after the LAST ') '. -function pidHasAncestor(pid, ancestor, readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) { - for (let p = pid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) { - if (p === ancestor) return true; - let stat; - try { stat = readStat(p); } catch { return false; } - const tail = stat.slice(stat.lastIndexOf(') ') + 2).split(' '); - p = parseInt(tail[1], 10); // state ppid … +// comm in /proc//stat may itself contain spaces and parens, so split after +// the LAST ') '. Tests inject readStat; production reads the live process tree. +function procIdentity(pid, readStat) { + let stat; + try { stat = readStat(pid); } catch { return null; } + const close = stat.lastIndexOf(') '); + const open = stat.indexOf('('); + if (open < 0 || close < open) return null; + const tail = stat.slice(close + 2).split(' '); + const ppid = parseInt(tail[1], 10); // state ppid … + return Number.isInteger(ppid) ? { comm: stat.slice(open + 1, close), ppid } : null; +} + +export function pidIsPaneRootOrDirectChild(pid, root, + readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) { + if (!Number.isInteger(pid) || !Number.isInteger(root) || pid <= 1 || root <= 1) return false; + if (pid === root) return true; + return procIdentity(pid, readStat)?.ppid === root; +} + +// Usually the npm launcher replaces the pane root and starts Codex's native +// binary as its direct child. The `resume --last || fresh` compatibility path +// must retain bash, so there the npm launcher is the direct child and native +// Codex is the grandchild. Accept that one known `node` launcher layer. A +// nested Codex has a tool shell above its launcher and cannot pass this check. +export function codexProcessPidTrusted(codexPid, root, + readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) { + for (let p = codexPid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) { + const identity = procIdentity(p, readStat); + if (!identity) return false; + if (identity.comm.startsWith('codex')) { + if (p === root || identity.ppid === root) return true; + const launcher = procIdentity(identity.ppid, readStat); + return launcher?.comm === 'node' && launcher.ppid === root; + } + p = identity.ppid; } return false; } // Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs. -// `facts` carries everything environmental: { workdir, pinned, claimed (Set of -// uuids other sessions pin), pidTrusted (bool: claudePid descends from the -// pane) }. Returns { repin: uuid } or { repin: null, why }. +// `facts` carries everything environmental: { cli, runId, workdir, pinned, +// claimed (ids other sessions pin), pidTrusted }. Returns +// { repin: conversationId } or { repin: null, why }. export function breadcrumbVerdict(crumb, sessionId, facts) { if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' }; if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' }; - const uuid = crumb.payload?.session_id; - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid || '')) + if (crumb.cli !== facts.cli) return { repin: null, why: 'cli mismatch' }; + if (!facts.runId || crumb.runId !== facts.runId) return { repin: null, why: 'runId mismatch' }; + const conversationId = crumb.payload?.session_id; + if (!CONVERSATION_ID[facts.cli]?.test(conversationId || '')) return { repin: null, why: 'no session_id' }; // A crumb written before a pane was moved to another folder must not follow // it there — same folder-scoping rule the transcript scan applies. if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' }; - // Nested `claude -p` runs inherit $AM_ID and fire SessionStart too (verified - // on 2.1.220 — the docs say -p skips hooks; it does not). The hook filters - // on CLAUDE_CODE_ENTRYPOINT, and this is the backstop: only a process that - // descends from the pane speaks for the pane. - if (!facts.pidTrusted) return { repin: null, why: 'pid not in pane' }; - if (facts.claimed?.has(uuid)) return { repin: null, why: 'claimed by another session' }; - if (uuid === facts.pinned) return { repin: null, why: 'already pinned' }; - return { repin: uuid }; + // Every supported adapter inherits the pane markers into child processes. + // Only the top-level agent process may speak for the pane; nested agents can + // otherwise re-pin their parent's Overview, trace and next resume target. + if (!facts.pidTrusted) return { repin: null, why: 'pid not top-level pane agent' }; + if (facts.claimed?.has(conversationId)) return { repin: null, why: 'claimed by another session' }; + if (conversationId === facts.pinned) return { repin: null, why: 'already pinned' }; + return { repin: conversationId }; +} + +// Codex gives the exact transcript path with SessionStart. Keep only paths that +// are under this CODEX_HOME's sessions tree and whose rollout filename encodes +// the reported id; the hook's payload is data, never an arbitrary resume path. +export function codexRolloutForBreadcrumb(crumb) { + const id = crumb?.payload?.session_id; + const raw = crumb?.payload?.transcript_path; + if (!UUID.test(id || '') || typeof raw !== 'string' || !raw) return null; + const resolved = path.resolve(raw); + const roots = [path.resolve(codexSessionsRoot())]; + const targets = [resolved]; + // CODEX_HOME/sessions is commonly a symlink to durable storage. Codex may + // report either spelling, so compare both lexical and canonical paths while + // retaining the same containment and exact-id checks. + try { roots.push(fs.realpathSync(roots[0])); } catch {} + try { targets.push(fs.realpathSync(resolved)); } catch {} + const contained = roots.some((root) => targets.some((target) => { + const rel = path.relative(root, target); + return !!rel && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); + })); + return contained && path.basename(resolved).endsWith(`-${id}.jsonl`) ? resolved : null; +} + +// SessionStart permits transcript_path=null. Resolve that case by the exact id +// encoded in Codex's rollout filename — deterministic even in a shared folder. +export function codexRolloutForId(id) { + if (!UUID.test(id || '')) return null; + const suffix = `-${id}.jsonl`; + return codexRolloutsSince(0).find((item) => path.basename(item.p).endsWith(suffix))?.p || null; } // Register the SessionStart hook in $CLAUDE_CONFIG_DIR/settings.json. Merge, @@ -1092,6 +1161,154 @@ export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh' } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; } } +// OpenCode automatically loads global plugins from this directory. The plugin +// is an app-owned file, so upgrades replace only that one file while all user +// plugins and opencode.json settings remain untouched. This config directory +// can be on the Space's FUSE bucket, whose rename semantics are unreliable; +// install before launching OpenCode and write the app-owned file directly. +export function installOpencodeRepinPlugin(source = '/app/scripts/am-opencode-repin.js') { + const base = process.env.OPENCODE_CONFIG_DIR + || path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || os.homedir(), '.config'), 'opencode'); + const dir = path.join(base, 'plugins'); + const file = path.join(dir, 'am-agent-manager.js'); + let body; + try { body = fs.readFileSync(source, 'utf8'); } + catch (e) { console.warn(`[opencode] repin plugin source unavailable: ${e.message}`); return false; } + try { + if (fs.readFileSync(file, 'utf8') === body) return true; + } catch { /* install or upgrade it below */ } + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, body); + console.warn(`[opencode] repin plugin installed in ${file}`); + return true; + } catch (e) { console.warn(`[opencode] repin plugin install failed: ${e.message}`); return false; } +} + +function exactPinFacts(session, host, workdir, pinField) { + return { + cli: session.cli, + runId: host.runId, + workdir, + pinned: (list().find((s) => s.id === session.id) || session)[pinField], + claimed: new Set(list().filter((s) => s.id !== session.id && s[pinField]).map((s) => s[pinField])), + }; +} + +// Apply one event from the pane's own adapter. This path never asks which file +// or database row is newest: the reported id is the lookup key, and local state +// is used only to validate that exact key before persisting it. +function applyBreadcrumb(session, host, workdir, crumb) { + let facts; + let patch; + if (session.cli === 'claude') { + facts = exactPinFacts(session, host, workdir, 'sessionUuid'); + const root = paneRootPid(session.id); + facts.pidTrusted = !!root && (pidIsPaneRootOrDirectChild(crumb.claudePid, root) + || (Number.isInteger(host.exactAgentPid) && host.exactAgentPid === crumb.claudePid)); + patch = (id) => ({ sessionUuid: id }); + } else if (session.cli === 'codex') { + facts = exactPinFacts(session, host, workdir, 'codexSessionId'); + const root = paneRootPid(session.id); + facts.pidTrusted = !!root && (codexProcessPidTrusted(crumb.codexPid, root) + || (Number.isInteger(host.exactAgentPid) && host.exactAgentPid === crumb.codexPid)); + } else if (session.cli === 'opencode') { + facts = exactPinFacts(session, host, workdir, 'opencodeSessionId'); + facts.pidTrusted = crumb.pluginPid === paneRootPid(session.id); + } else { + return { repin: null, why: 'unsupported cli' }; + } + + const verdict = breadcrumbVerdict(crumb, session.id, facts); + if (!verdict.repin && verdict.why !== 'already pinned') return verdict; + + if (session.cli === 'codex') { + const reported = crumb?.payload?.transcript_path; + const rollout = codexRolloutForBreadcrumb(crumb) + || (reported == null ? codexRolloutForId(crumb?.payload?.session_id) : null); + if (!rollout) return { + repin: null, + why: reported == null ? 'rollout not available yet' : 'invalid transcript_path', + retry: reported == null, + }; + patch = (id) => ({ codexSessionId: id, codexRollout: rollout }); + // A same-id resume is normally a no-op, but an older pin may lack the path + // required by commandFor. Exact lifecycle data repairs that incomplete pin. + if (!verdict.repin && facts.pinned === crumb.payload.session_id) { + const current = list().find((s) => s.id === session.id) || session; + if (current.codexRollout !== rollout) update(session.id, patch(facts.pinned)); + } + } else if (session.cli === 'opencode') { + const row = opencodeSessionInfo(crumb?.payload?.session_id); + if (!row) return { repin: null, why: 'session missing from database', retry: true }; + if (row.parentId) return { repin: null, why: 'subagent session' }; + if (row.directory !== workdir) return { repin: null, why: 'database cwd mismatch' }; + patch = (id) => ({ opencodeSessionId: id }); + } + + // Remember only a process that passed both tree attribution and adapter data + // validation. onExit runs after node-pty has reaped the process, so /proc may + // already be gone when it performs the promised final breadcrumb read; a + // later /clear crumb from this same long-lived agent remains attributable. + host.exactAgentPid = session.cli === 'claude' ? crumb.claudePid + : session.cli === 'codex' ? crumb.codexPid : crumb.pluginPid; + if (verdict.repin || verdict.why === 'already pinned') host.exactRepinProven = true; + if (verdict.repin) { + console.warn(`[${session.cli}] re-pinning ${session.id}: ${facts.pinned || '(none)'} -> ${verdict.repin} (exact ${crumb.payload?.source || 'event'})`); + update(session.id, patch(verdict.repin)); + } + return verdict; +} + +function consumeBreadcrumb(session, host, workdir, force = false) { + const fresh = takeBreadcrumb(session.id, session.cli); + let pending = host.pendingExactBreadcrumb; + if (fresh) { + pending = { crumb: fresh, attempts: 0, nextAt: 0 }; + host.pendingExactBreadcrumb = null; + } + if (!pending || (!fresh && !force && Date.now() < pending.nextAt)) return; + try { + const verdict = applyBreadcrumb(session, host, workdir, pending.crumb); + if (verdict.retry && pending.attempts < BREADCRUMB_RETRIES) { + host.pendingExactBreadcrumb = { + crumb: pending.crumb, + attempts: pending.attempts + 1, + nextAt: Date.now() + BREADCRUMB_RETRY_MS, + }; + return; + } + host.pendingExactBreadcrumb = null; + if (!verdict.repin && verdict.why !== 'already pinned') + console.warn(`[${session.cli}] ${session.id}: exact breadcrumb rejected (${verdict.why})`); + } catch (e) { + host.pendingExactBreadcrumb = null; + console.warn(`[${session.cli}] ${session.id}: exact breadcrumb failed (${e && e.message})`); + } +} + +// Poll only a tiny file on local /tmp, frequently enough that a /clear followed +// by an immediate pane exit still persists its new id. Expensive transcript, +// rollout and database discovery retain their existing sparse cadence below. +function scheduleBreadcrumbCapture(session, workdir) { + if (!CONVERSATION_ID[session.cli]) return; + const prev = breadcrumbCapturing.get(session.id); + if (prev) clearTimeout(prev); + const host = hosts.get(session.id); + let armed = null; + const tick = () => { + if (hosts.get(session.id) !== host) { + if (breadcrumbCapturing.get(session.id) === armed) breadcrumbCapturing.delete(session.id); + return; + } + consumeBreadcrumb(session, host, workdir); + armed = setTimeout(tick, BREADCRUMB_MS); + if (armed.unref) armed.unref(); + breadcrumbCapturing.set(session.id, armed); + }; + tick(); +} + // Once the pane's SessionStart hook has proven itself, the transcript scan is a // backstop rather than the mechanism, so it runs on this cadence instead of // REPIN_MS. Now that the scan is awaited rather than synchronous this is no longer @@ -1150,34 +1367,7 @@ function scheduleClaudeCapture(session, workdir) { // two timers. const tick = async () => { if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return false; } - const pinned = currentPin(); - - // Breadcrumb first: the pane's own SessionStart hook told us which - // conversation it is on, so no guessing — and no shared-folder refusal — - // is needed. Consumed on read; a rejected crumb falls through to the scan. - const crumb = takeClaudeBreadcrumb(session.id); - if (crumb) { - const root = paneRootPid(session.id); - const verdict = breadcrumbVerdict(crumb, session.id, { - workdir, - pinned, - claimed: claimedByOthers(), - pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root), - }); - // A crumb that named this pane's own conversation — whether it moved the - // pin or was already on it (the `resume` no-op) — proves the hook fires - // here, so the scan can step down to a backstop. A crumb rejected for any - // other reason proves nothing about this pane: 'pid not in pane' is a - // nested `claude -p`, 'cwd mismatch' is someone else's run. - if (verdict.repin || verdict.why === 'already pinned') hookProven = true; - if (verdict.repin) { - console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`); - update(session.id, { sessionUuid: verdict.repin }); - return true; - } - if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`); - } - + hookProven ||= !!host.exactRepinProven; if (folderIsShared(session.id, workdir, 'claude')) { if (!warnedShared) { warnedShared = true; @@ -1418,14 +1608,21 @@ export function ensureRunning(session, cols = 120, rows = 34) { const folder = session.path ?? session.id; const workdir = path.join(WORKSPACES_DIR, folder); fs.mkdirSync(workdir, { recursive: true }); - const full = commandFor(session); + // The login shell knows its own PTY-root pid before any `exec`. Adapters use + // this marker to discard nested agent lifecycle events BEFORE they can + // overwrite the top-level pane's breadcrumb; runner validation repeats the + // process-tree check before persisting anything. + const full = `export AM_PANE_PID=$$; ${commandFor(session)}`; const captureResize = cliById(session.cli)?.resizeMode === 'repaint'; + const runId = crypto.randomUUID(); const env = { ...TERM_ENV, AM_SESSION: folder, AM_NAME: session.name, AM_ID: session.id, + AM_RUN_ID: runId, + AM_CLI: session.cli, AM_USER, AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this }; @@ -1451,6 +1648,7 @@ export function ensureRunning(session, cols = 120, rows = 34) { } const host = { id: session.id, + runId, pty: term, vt, cols, @@ -1533,6 +1731,10 @@ export function ensureRunning(session, cols = 120, rows = 34) { }); term.onExit(() => { + // Do one final local read before releasing this launch's nonce. In + // particular, `/clear` followed immediately by quit must still persist the + // conversation that was on screen when the pane ended. + consumeBreadcrumb(session, host, workdir, true); hosts.delete(session.id); if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; } if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; } @@ -1550,6 +1752,7 @@ export function ensureRunning(session, cols = 120, rows = 34) { hosts.set(session.id, host); if (!persistedHistory && captureResize) hydrateTraceHistory(session, host); if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined }); + scheduleBreadcrumbCapture(session, workdir); if (session.cli === 'codex') scheduleCodexCapture(session, workdir); if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir); if (session.cli === 'claude') scheduleClaudeCapture(session, workdir); diff --git a/server/src/traces.js b/server/src/traces.js index 26c538e..1138a71 100644 --- a/server/src/traces.js +++ b/server/src/traces.js @@ -414,12 +414,11 @@ function readOpencode() { return rows; } -// Newest opencode conversation in `directory` created at/after `sinceMs` and -// not already claimed by another session. The runner calls this shortly after -// launch to PIN a session to its own `ses_…` id — opencode has no -// per-conversation handle of its own (unlike codex's rollout uuid), so two -// agents sharing a folder would otherwise cross-attribute. Read straight from -// the db (not the memoized rows) so a just-created session is seen immediately. +// Fallback discovery for installations where the exact-event plugin is absent: +// newest opencode conversation in `directory` created at/after `sinceMs` and +// not already claimed by another session. This is deliberately used only for +// unshared folders; same-folder panes cannot safely attribute a newest row. +// Read straight from the db so a just-created session is seen immediately. export function captureOpencodeSession(directory, sinceMs, claimed) { if (!DatabaseSync || !directory) return null; let db; @@ -448,6 +447,22 @@ export function opencodeSessionExists(id) { } catch { return false; } finally { try { db.close(); } catch {} } } +// Exact row metadata for a session id reported by the opencode plugin. The +// plugin tells us which id the pane is using; the database remains the local +// authority for its folder and whether it is a root conversation rather than a +// task/subagent child. +export function opencodeSessionInfo(id) { + if (!DatabaseSync || !id) return null; + let db; + try { db = new DatabaseSync(opencodeDbPath(), { readOnly: true }); } catch { return null; } + try { + const row = db.prepare( + 'select id, directory, parent_id as parentId from session where id = ?', + ).get(id); + return row ? { id: row.id, directory: row.directory || null, parentId: row.parentId || null } : null; + } catch { return null; } finally { try { db.close(); } catch {} } +} + // ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ---------- // sessions carry cwd + token totals; messages carry role/content/tool_name. // Timestamps are float SECONDS — converted to ms for digest fields. diff --git a/server/test/opencode-resume.test.mjs b/server/test/opencode-resume.test.mjs index f6c4391..f533e0c 100644 --- a/server/test/opencode-resume.test.mjs +++ b/server/test/opencode-resume.test.mjs @@ -8,23 +8,31 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-resume-')); const XDG = path.join(TMP, 'xdg'); const DATA = path.join(TMP, 'data'); +const REPIN = path.join(TMP, 'repin'); fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true }); fs.mkdirSync(DATA, { recursive: true }); process.env.XDG_DATA_HOME = XDG; +process.env.XDG_CONFIG_HOME = path.join(TMP, 'config'); process.env.DATA_DIR = DATA; +process.env.AM_REPIN_DIR = REPIN; +process.env.AM_ID = 'pane-1'; +process.env.AM_RUN_ID = '11111111-2222-4333-8444-555555555555'; +process.env.AM_CLI = 'opencode'; +process.env.AM_PANE_PID = String(process.pid); // Only the columns the runner reads. opencode's real table has ~30 more; a // narrower one still proves the query, and drifts less. const DB = path.join(XDG, 'opencode', 'opencode.db'); const db = new DatabaseSync(DB); -db.exec('create table session (id text primary key, directory text not null, time_created integer not null)'); -const addRow = (id, directory, timeCreated) => - db.prepare('insert into session (id, directory, time_created) values (?, ?, ?)').run(id, directory, timeCreated); +db.exec('create table session (id text primary key, directory text not null, parent_id text, time_created integer not null)'); +const addRow = (id, directory, timeCreated, parentId = null) => + db.prepare('insert into session (id, directory, parent_id, time_created) values (?, ?, ?, ?)').run(id, directory, parentId, timeCreated); const sessions = await import('../src/sessions.js'); const runner = await import('../src/runner.js'); @@ -42,11 +50,57 @@ const has = (name, hay, needle) => check(name, String(hay).includes(needle), tru const LIVE = 'ses_0325987abffej9UeLKjc55GHK8'; const GONE = 'ses_099999999ffezzzzzzzzzzzzzzz'; addRow(LIVE, '/data/workspaces/proj-a', 1770000000000); +addRow('ses_child', '/data/workspaces/proj-a', 1770000000001, LIVE); console.log('\nthe db decides whether a pin is still resumable'); check('live row found', traces.opencodeSessionExists(LIVE), true); check('purged row not found', traces.opencodeSessionExists(GONE), false); check('no id is not a row', traces.opencodeSessionExists(null), false); +check('exact row keeps its directory', traces.opencodeSessionInfo(LIVE)?.directory, '/data/workspaces/proj-a'); +check('exact row exposes subagent parent', traces.opencodeSessionInfo('ses_child')?.parentId, LIVE); +check('missing exact row is null', traces.opencodeSessionInfo(GONE), null); + +console.log('\nthe global plugin reports exact root-session lifecycle events'); +const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts'); +const pluginSource = path.join(scripts, 'am-opencode-repin.js'); +check('plugin installs globally', runner.installOpencodeRepinPlugin(pluginSource), true); +const installed = path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'plugins', 'am-agent-manager.js'); +check('installed plugin is the app-owned source', fs.readFileSync(installed, 'utf8'), fs.readFileSync(pluginSource, 'utf8')); +check('plugin install is idempotent', runner.installOpencodeRepinPlugin(pluginSource), true); + +const pluginBody = fs.readFileSync(pluginSource, 'utf8'); +const pluginModule = await import(`data:text/javascript;base64,${Buffer.from(pluginBody).toString('base64')}`); +const hooks = await pluginModule.AgentManagerRepin({ directory: '/data/workspaces/proj-a' }); +const CREATED = 'ses_0ccccccccffeccccccccccccccc'; +const createdDispatch = hooks.event({ event: { type: 'session.created', properties: { info: { + id: CREATED, directory: '/data/workspaces/proj-a', +} } } }); +check('created event writes before its Promise is awaited', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), true); +await createdDispatch; +let crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8')); +check('created event reports exact id', crumb.payload.session_id, CREATED); +check('created event carries launch nonce', crumb.runId, process.env.AM_RUN_ID); +check('created event carries top-level process id', crumb.pluginPid, process.pid); +fs.unlinkSync(path.join(REPIN, 'pane-1.opencode.json')); +await hooks.event({ event: { type: 'session.created', properties: { info: { + id: 'ses_child', directory: '/data/workspaces/proj-a', parentID: CREATED, +} } } }); +check('subagent create ignored', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), false); +await hooks['chat.message']({ sessionID: LIVE }); +crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8')); +check('message hook follows selected existing session', crumb.payload.session_id, LIVE); +const shellOutput = { env: { KEEP: 'yes' } }; +await hooks['shell.env']({}, shellOutput); +check('shell keeps unrelated environment', shellOutput.env.KEEP, 'yes'); +check('shell strips pane id', shellOutput.env.AM_ID, ''); +check('shell strips pane process marker', shellOutput.env.AM_PANE_PID, ''); +fs.unlinkSync(path.join(REPIN, 'pane-1.opencode.json')); +process.env.AM_PANE_PID = '999999999'; +await hooks.event({ event: { type: 'session.created', properties: { info: { + id: 'ses_nested', directory: '/data/workspaces/proj-a', +} } } }); +check('nested OpenCode process cannot report', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), false); +process.env.AM_PANE_PID = String(process.pid); console.log('\na restart resumes the pinned conversation, not the folder\'s newest'); const s = sessions.create({ name: 'oc', cli: 'opencode', path: 'proj-a' }); diff --git a/server/test/repin.test.mjs b/server/test/repin.test.mjs index 31e8337..bd7bcd4 100644 --- a/server/test/repin.test.mjs +++ b/server/test/repin.test.mjs @@ -6,15 +6,22 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-')); const CFG = path.join(TMP, 'cfg'); const DATA = path.join(TMP, 'data'); +const REPIN = path.join(TMP, 'repin'); +const CODEX = path.join(TMP, 'codex'); fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true }); fs.mkdirSync(DATA, { recursive: true }); +fs.mkdirSync(path.join(CODEX, 'sessions', '2026', '08', '06'), { recursive: true }); process.env.CLAUDE_CONFIG_DIR = CFG; process.env.DATA_DIR = DATA; +process.env.AM_REPIN_DIR = REPIN; +process.env.CODEX_HOME = CODEX; const sessions = await import('../src/sessions.js'); const runner = await import('../src/runner.js'); @@ -78,13 +85,15 @@ check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACE // ---------- breadcrumbs: attribution the scan cannot do in shared folders ---------- const E = 'eeeeeeee-0000-0000-0000-000000000005'; +const RUN = '11111111-2222-4333-8444-555555555555'; const crumb = (over = {}) => ({ - amId: 's1', claudePid: 4242, + amId: 's1', runId: RUN, cli: 'claude', claudePid: 4242, payload: { session_id: E, cwd: WORKDIR, source: 'clear' }, ...over, }); const facts = (over = {}) => ({ - workdir: WORKDIR, pinned: A, claimed: new Set([B]), pidTrusted: true, ...over, + cli: 'claude', runId: RUN, workdir: WORKDIR, pinned: A, + claimed: new Set([B]), pidTrusted: true, ...over, }); const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f); @@ -93,12 +102,14 @@ check('clean crumb accepted', verdict(crumb(), facts()).repin, E); console.log('\na breadcrumb only speaks for the pane that wrote it'); check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null); +check('stale launch rejected', verdict(crumb({ runId: 'old-run' }), facts()).why, 'runId mismatch'); +check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch'); check('cwd mismatch rejected', verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null); console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)'); check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null); -check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not in pane'); +check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not top-level pane agent'); console.log('\nno-ops and garbage stay no-ops'); check('already-pinned crumb is a no-op', @@ -109,6 +120,103 @@ check('malformed session_id rejected', verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null); check('null crumb rejected', verdict(null, facts()).repin, null); +console.log('\nCodex and OpenCode use the same pane/run attribution contract'); +const CODEX_ID = '12345678-1234-4234-8234-123456789abc'; +const rollout = path.join(CODEX, 'sessions', '2026', '08', '06', `rollout-2026-08-06T00-00-00-${CODEX_ID}.jsonl`); +fs.writeFileSync(rollout, '{}\n'); +const codexCrumb = { + amId: 's1', runId: RUN, cli: 'codex', + payload: { session_id: CODEX_ID, transcript_path: rollout, cwd: WORKDIR, source: 'clear' }, +}; +check('Codex exact id accepted', runner.breadcrumbVerdict(codexCrumb, 's1', { + cli: 'codex', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true, +}).repin, CODEX_ID); +check('Codex rollout path retained', runner.codexRolloutForBreadcrumb(codexCrumb), rollout); +check('Codex null transcript resolves by exact id', runner.codexRolloutForId(CODEX_ID), rollout); +const CODEX_ALIAS = path.join(TMP, 'codex-alias'); +fs.mkdirSync(CODEX_ALIAS); +fs.symlinkSync(path.join(CODEX, 'sessions'), path.join(CODEX_ALIAS, 'sessions')); +process.env.CODEX_HOME = CODEX_ALIAS; +check('Codex canonical path accepted through a symlinked sessions root', + runner.codexRolloutForBreadcrumb(codexCrumb), rollout); +process.env.CODEX_HOME = CODEX; +check('Codex path outside CODEX_HOME rejected', runner.codexRolloutForBreadcrumb({ + ...codexCrumb, payload: { ...codexCrumb.payload, transcript_path: `/tmp/rollout-x-${CODEX_ID}.jsonl` }, +}), null); +check('OpenCode exact id accepted', runner.breadcrumbVerdict({ + amId: 's1', runId: RUN, cli: 'opencode', + payload: { session_id: 'ses_1234567890abcdef', cwd: WORKDIR }, +}, 's1', { + cli: 'opencode', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true, +}).repin, 'ses_1234567890abcdef'); + +console.log('\nonly the top-level agent process may emit a breadcrumb'); +const proc = new Map([ + [99, '99 (bash) S 1 0 0'], + [100, '100 (node) S 1 0 0'], + [101, '101 (claude) S 100 0 0'], + [102, '102 (nested claude) S 101 0 0'], + [110, '110 (codex-x64 (native)) S 100 0 0'], + [120, '120 (tool shell) S 110 0 0'], + [200, '200 (node) S 120 0 0'], + [210, '210 (codex-x64) S 200 0 0'], +]); +const readStat = (pid) => { + if (!proc.has(pid)) throw new Error('gone'); + return proc.get(pid); +}; +check('pane root accepted', runner.pidIsPaneRootOrDirectChild(100, 100, readStat), true); +check('direct Claude child accepted', runner.pidIsPaneRootOrDirectChild(101, 100, readStat), true); +check('nested Claude rejected', runner.pidIsPaneRootOrDirectChild(102, 100, readStat), false); +check('top native Codex accepted', runner.codexProcessPidTrusted(110, 100, readStat), true); +proc.set(100, '100 (node) S 99 0 0'); +check('Codex behind retained launch shell accepted', runner.codexProcessPidTrusted(110, 99, readStat), true); +check('nested native Codex rejected', runner.codexProcessPidTrusted(210, 100, readStat), false); +check('gone Codex pid rejected', runner.codexProcessPidTrusted(999, 100, readStat), false); + +console.log('\nhook scripts preserve the pane and launch identity'); +const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts'); +const repoRoot = path.dirname(scripts); +const requirements = fs.readFileSync(path.join(repoRoot, 'codex-requirements.toml'), 'utf8'); +check('Codex hook is managed (no user trust prompt)', requirements.includes('managed_dir = "/etc/codex/hooks"'), true); +check('managed policy runs the root-owned hook', requirements.includes('command = "/etc/codex/hooks/am-codex-repin-hook.sh"'), true); +const hookPayload = JSON.stringify({ session_id: E, transcript_path: '/tmp/t.jsonl', cwd: WORKDIR, source: 'clear' }); +let hook = spawnSync('sh', [path.join(scripts, 'am-repin-hook.sh')], { + input: hookPayload, + env: { + ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'claude', + AM_PANE_PID: String(process.pid), CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_PID: String(process.pid), + }, +}); +check('Claude hook exits cleanly', hook.status, 0); +let written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.claude.json'), 'utf8')); +check('Claude hook writes run id', written.runId, RUN); +check('Claude hook writes exact session id', written.payload.session_id, E); + +const codexShim = path.join(TMP, 'codex-test'); +fs.symlinkSync('/bin/sh', codexShim); +const paneShim = path.join(TMP, 'pane-shell'); +fs.symlinkSync('/bin/sh', paneShim); +const codexLauncher = path.join(TMP, 'codex-launcher.cjs'); +fs.writeFileSync(codexLauncher, [ + "const fs = require('node:fs');", + "const { spawnSync } = require('node:child_process');", + "const child = spawnSync(process.argv[2], ['-c', `sh '${process.argv[3]}'`], {", + " input: fs.readFileSync(0), env: process.env, stdio: ['pipe', 'inherit', 'inherit'],", + "});", + "process.exit(child.status ?? 1);", +].join('\n')); +hook = spawnSync(paneShim, ['-c', + `export AM_PANE_PID=$$; node '${codexLauncher}' '${codexShim}' '${path.join(scripts, 'am-codex-repin-hook.sh')}'`], { + input: JSON.stringify(codexCrumb.payload), + env: { ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'codex' }, +}); +check('Codex hook exits cleanly', hook.status, 0); +written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.codex.json'), 'utf8')); +check('Codex hook writes run id', written.runId, RUN); +check('Codex hook writes exact session id', written.payload.session_id, CODEX_ID); +check('Codex hook records its live agent process', Number.isInteger(written.codexPid), true); + // ---------- the pane root: the fact every breadcrumb is trusted against ---------- // paneRootPid used to shell out to `tmux list-panes`. The libghostty migration // removed tmux but left the call, referencing identifiers that no longer exist —