Leandro von Werra Agent Manager commited on
Commit
dce99f0
·
unverified ·
1 Parent(s): d1908de

Pin Claude, Codex, and OpenCode conversations from lifecycle events (#41)

Browse files

* Pin agent conversations from exact lifecycle events

* Harden deterministic conversation repinning

---------

Co-authored-by: Agent Manager <agents@agent-manager.local>

Dockerfile CHANGED
@@ -46,6 +46,12 @@ ENV LANG=C.UTF-8
46
  # Pinned to @latest so a factory reboot (no-cache rebuild) reinstalls the newest
47
  # published versions — that's what the "Relaunch & update" button triggers.
48
  RUN npm install -g @anthropic-ai/claude-code@latest @openai/codex@latest
 
 
 
 
 
 
49
  # Newer agents, best-effort so a publish hiccup can't break the image build;
50
  # the app marks any missing binary "unavailable" gracefully.
51
  RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed"
 
46
  # Pinned to @latest so a factory reboot (no-cache rebuild) reinstalls the newest
47
  # published versions — that's what the "Relaunch & update" button triggers.
48
  RUN npm install -g @anthropic-ai/claude-code@latest @openai/codex@latest
49
+ # This image is the administrator of its own Codex runtime. Install Agent
50
+ # Manager's lifecycle adapter as a managed hook so it runs deterministically
51
+ # without weakening trust for any user/project hooks.
52
+ COPY codex-requirements.toml /etc/codex/requirements.toml
53
+ COPY scripts/am-codex-repin-hook.sh /etc/codex/hooks/am-codex-repin-hook.sh
54
+ RUN chmod 755 /etc/codex/hooks/am-codex-repin-hook.sh
55
  # Newer agents, best-effort so a publish hiccup can't break the image build;
56
  # the app marks any missing binary "unavailable" gracefully.
57
  RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed"
codex-requirements.toml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Manager owns this container-level Codex policy. A managed hook needs no
2
+ # per-pane trust prompt, unlike a user hooks.json entry, and it is limited to
3
+ # reporting the exact root session selected by startup/resume/clear.
4
+ [features]
5
+ hooks = true
6
+
7
+ [hooks]
8
+ managed_dir = "/etc/codex/hooks"
9
+
10
+ [[hooks.SessionStart]]
11
+ matcher = "^(startup|resume|clear)$"
12
+
13
+ [[hooks.SessionStart.hooks]]
14
+ type = "command"
15
+ command = "/etc/codex/hooks/am-codex-repin-hook.sh"
16
+ timeout = 5
scripts/am-codex-repin-hook.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Managed Codex SessionStart hook. stdin contains the exact session_id,
3
+ # transcript_path, cwd and source (startup/resume/clear/compact).
4
+ [ "$AM_CLI" = "codex" ] || exit 0
5
+ [ -n "$AM_ID" ] || exit 0
6
+ case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
7
+ [ -n "$AM_RUN_ID" ] || exit 0
8
+ case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
9
+ case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac
10
+
11
+ # Usually the pane root is Codex's npm launcher and native Codex is its direct
12
+ # child. The resume compatibility command retains bash, making the node launcher
13
+ # a direct child and native Codex a grandchild. Accept that one known layer; a
14
+ # nested Codex has a tool shell above its launcher and cannot pass this check.
15
+ p=$$
16
+ trusted=0
17
+ codex_pid=0
18
+ hops=0
19
+ while [ "$p" -gt 1 ] 2>/dev/null && [ "$hops" -lt 64 ]; do
20
+ stat=$(cat "/proc/$p/stat" 2>/dev/null) || break
21
+ comm=${stat#*(}
22
+ comm=${comm%)*}
23
+ rest=${stat##*) }
24
+ rest=${rest#* }
25
+ ppid=${rest%% *}
26
+ case "$comm" in
27
+ codex*)
28
+ if [ "$p" = "$AM_PANE_PID" ] || [ "$ppid" = "$AM_PANE_PID" ]; then
29
+ trusted=1
30
+ else
31
+ parent_stat=$(cat "/proc/$ppid/stat" 2>/dev/null) || parent_stat=
32
+ parent_comm=${parent_stat#*(}
33
+ parent_comm=${parent_comm%)*}
34
+ parent_rest=${parent_stat##*) }
35
+ parent_rest=${parent_rest#* }
36
+ grandparent=${parent_rest%% *}
37
+ if [ "$parent_comm" = "node" ] && [ "$grandparent" = "$AM_PANE_PID" ]; then trusted=1; fi
38
+ fi
39
+ codex_pid=$p
40
+ break
41
+ ;;
42
+ esac
43
+ p=$ppid
44
+ hops=$((hops + 1))
45
+ done
46
+ [ "$trusted" -eq 1 ] || exit 0
47
+
48
+ d="${AM_REPIN_DIR:-/tmp/am-repin}"
49
+ mkdir -p "$d" 2>/dev/null || exit 0
50
+ {
51
+ printf '{"amId":"%s","runId":"%s","cli":"codex","codexPid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$codex_pid"
52
+ cat
53
+ printf '}'
54
+ } > "$d/$AM_ID.codex.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.codex.json.$$.tmp" "$d/$AM_ID.codex.json"
55
+ exit 0
scripts/am-opencode-repin.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const SAFE = /^[A-Za-z0-9_-]+$/;
6
+
7
+ function report(sessionID, cwd, source) {
8
+ const amId = process.env.AM_ID;
9
+ const runId = process.env.AM_RUN_ID;
10
+ if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return;
11
+ // The global plugin is also loaded by nested OpenCode processes. Only the
12
+ // process that replaced the PTY's login shell owns this pane.
13
+ if (String(process.pid) !== process.env.AM_PANE_PID) return;
14
+ if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return;
15
+ const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin');
16
+ const file = path.join(dir, `${amId}.opencode.json`);
17
+ const tmp = `${file}.${process.pid}.tmp`;
18
+ try {
19
+ // OpenCode dispatches generic event hooks without awaiting their Promise.
20
+ // Keep this tiny local write synchronous so /clear followed immediately by
21
+ // quit cannot terminate the process between mkdir/write/rename.
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(tmp, JSON.stringify({
24
+ amId,
25
+ runId,
26
+ cli: 'opencode',
27
+ pluginPid: process.pid,
28
+ payload: { session_id: sessionID, cwd, source },
29
+ }));
30
+ renameSync(tmp, file);
31
+ } catch { /* telemetry must never interfere with the user's prompt */ }
32
+ }
33
+
34
+ // OpenCode creates a new root session for /new (alias /clear). chat.message
35
+ // additionally follows an explicit switch to an existing session; runner.js
36
+ // verifies that id against the database and rejects child/subagent sessions.
37
+ export const AgentManagerRepin = async ({ directory }) => ({
38
+ event: async ({ event }) => {
39
+ if (event?.type !== 'session.created') return;
40
+ const info = event.properties?.info;
41
+ if (!info?.id || info.parentID) return;
42
+ report(info.id, info.directory || directory, 'session.created');
43
+ },
44
+ 'chat.message': async ({ sessionID }) => {
45
+ report(sessionID, directory, 'chat.message');
46
+ },
47
+ // Tool shells must not pass the pane's private attribution markers to an
48
+ // agent launched inside them. Empty values override OpenCode's process.env
49
+ // merge and make the nested plugin a no-op.
50
+ 'shell.env': async (_input, output) => {
51
+ output.env.AM_ID = '';
52
+ output.env.AM_RUN_ID = '';
53
+ output.env.AM_CLI = '';
54
+ output.env.AM_PANE_PID = '';
55
+ },
56
+ });
scripts/am-repin-hook.sh CHANGED
@@ -2,30 +2,40 @@
2
  # SessionStart breadcrumb for the manager's conversation re-pin (runner.js).
3
  #
4
  # Claude Code runs this inside the pane's process tree, so $AM_ID — set by the
5
- # manager on the tmux session — says WHICH pane the new conversation belongs
6
  # to. That attribution is the one thing the server cannot work out on its own
7
  # when several claude panes share a folder, and it is why a /clear there could
8
  # not be followed before (the folderIsShared refusal in runner.js).
9
  #
10
  # stdin is the hook payload: {session_id, transcript_path, cwd, source, ...}.
11
- # $CLAUDE_PID is the claude process that fired the event; the server verifies
12
- # it descends from the pane before trusting the breadcrumb, because nested
13
- # runs (`claude -p` from inside a pane) inherit $AM_ID and would otherwise
14
- # claim the pane with a throwaway conversation. The entrypoint check below
15
- # already drops those non-interactive runs; the pid check covers the rest.
16
  #
17
  # Breadcrumbs live on LOCAL disk on purpose: losing them at a restart is
18
  # harmless (the pin itself persists in sessions.json), and the relaunch's own
19
  # source:"resume" event immediately writes a fresh one.
20
  [ -n "$AM_ID" ] || exit 0
21
  case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
 
 
 
22
  [ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0
23
- case "$CLAUDE_PID" in '' | *[!0-9]*) CLAUDE_PID=0 ;; esac
 
 
 
 
 
 
 
 
24
  d="${AM_REPIN_DIR:-/tmp/am-repin}"
25
  mkdir -p "$d" 2>/dev/null || exit 0
26
  {
27
- printf '{"amId":"%s","claudePid":%d,"payload":' "$AM_ID" "$CLAUDE_PID"
28
  cat
29
  printf '}'
30
- } > "$d/$AM_ID.json.tmp" 2>/dev/null && mv -f "$d/$AM_ID.json.tmp" "$d/$AM_ID.json"
31
  exit 0
 
2
  # SessionStart breadcrumb for the manager's conversation re-pin (runner.js).
3
  #
4
  # Claude Code runs this inside the pane's process tree, so $AM_ID — set by the
5
+ # manager on the PTY — says WHICH pane the new conversation belongs
6
  # to. That attribution is the one thing the server cannot work out on its own
7
  # when several claude panes share a folder, and it is why a /clear there could
8
  # not be followed before (the folderIsShared refusal in runner.js).
9
  #
10
  # stdin is the hook payload: {session_id, transcript_path, cwd, source, ...}.
11
+ # $CLAUDE_PID is the claude process that fired the event. Only the pane root or
12
+ # its direct child is the managed interactive Claude; a nested Claude started
13
+ # by a tool is deeper in the process tree. Filter it here so it cannot overwrite
14
+ # the top-level crumb, then runner.js independently repeats the same check.
 
15
  #
16
  # Breadcrumbs live on LOCAL disk on purpose: losing them at a restart is
17
  # harmless (the pin itself persists in sessions.json), and the relaunch's own
18
  # source:"resume" event immediately writes a fresh one.
19
  [ -n "$AM_ID" ] || exit 0
20
  case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
21
+ [ -n "$AM_RUN_ID" ] || exit 0
22
+ case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
23
+ [ "$AM_CLI" = "claude" ] || exit 0
24
  [ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0
25
+ case "$CLAUDE_PID" in '' | *[!0-9]*) exit 0 ;; esac
26
+ case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac
27
+ if [ "$CLAUDE_PID" != "$AM_PANE_PID" ]; then
28
+ stat=$(cat "/proc/$CLAUDE_PID/stat" 2>/dev/null) || exit 0
29
+ rest=${stat##*) }
30
+ rest=${rest#* }
31
+ ppid=${rest%% *}
32
+ [ "$ppid" = "$AM_PANE_PID" ] || exit 0
33
+ fi
34
  d="${AM_REPIN_DIR:-/tmp/am-repin}"
35
  mkdir -p "$d" 2>/dev/null || exit 0
36
  {
37
+ printf '{"amId":"%s","runId":"%s","cli":"claude","claudePid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$CLAUDE_PID"
38
  cat
39
  printf '}'
40
+ } > "$d/$AM_ID.claude.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.claude.json.$$.tmp" "$d/$AM_ID.claude.json"
41
  exit 0
server/src/index.js CHANGED
@@ -16,7 +16,10 @@ import * as store from './sessions.js';
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
- import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
 
 
 
20
 
21
  // Control frames ride the terminal socket behind a leading NUL pair, which real
22
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
@@ -38,10 +41,14 @@ store.init();
38
  groups.init();
39
  order.init();
40
  demo.init();
41
- // Claude panes report conversation resets (e.g. /clear) through a SessionStart
42
- // hook, so the re-pin watcher can follow them even in shared folders where the
43
- // transcript scan must refuse to guess. Non-fatal if it can't be installed.
 
44
  installClaudeRepinHook();
 
 
 
45
 
46
  // One-time migration to the explicit-path model: sessions used to own a folder
47
  // named after them (renamed along with them), or inherit their group's shared
 
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
+ import {
20
+ attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning,
21
+ capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook, installOpencodeRepinPlugin,
22
+ } from './runner.js';
23
 
24
  // Control frames ride the terminal socket behind a leading NUL pair, which real
25
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
 
41
  groups.init();
42
  order.init();
43
  demo.init();
44
+ // Lifecycle adapters report conversation resets (e.g. /clear) with the exact
45
+ // id, so re-pin watchers can follow them even in shared folders where storage
46
+ // discovery must refuse to guess. Both installers are non-fatal; the existing
47
+ // fallback remains available if either cannot be installed.
48
  installClaudeRepinHook();
49
+ // OpenCode's global plugin reports the root session chosen by /new (/clear),
50
+ // and the next prompt after switching to an existing session.
51
+ installOpencodeRepinPlugin();
52
 
53
  // One-time migration to the explicit-path model: sessions used to own a folder
54
  // named after them (renamed along with them), or inherit their group's shared
server/src/runner.js CHANGED
@@ -1,12 +1,13 @@
1
  import os from 'node:os';
2
  import path from 'node:path';
 
3
  import pty from 'node-pty';
4
  import fs from 'node:fs';
5
  import fsp from 'node:fs/promises';
6
  import { remoteState, setPaused } from './remote.js';
7
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
8
  import { update, list } from './sessions.js';
9
- import { captureOpencodeSession, opencodeSessionExists, readTrace } from './traces.js';
10
  import {
11
  buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
12
  } from './snapshot.js';
@@ -662,11 +663,10 @@ async function hydrateTraceHistory(session, host) {
662
 
663
  // ---------- Codex conversation pinning ----------
664
  // Codex picks its own conversation id at launch and doesn't accept one up
665
- // front but it announces the pick immediately: a rollout file named
 
666
  // rollout-<ts>-<id>.jsonl appears under $CODEX_HOME/sessions with the cwd in
667
- // its first line. Capture that id shortly after launch and pin it on the
668
- // session, so restarts resume THIS agent's conversation — `resume --last`
669
- // would grab whichever Codex agent in the same folder ran last.
670
  const codexCapturing = new Map(); // id -> pending re-pin timer
671
 
672
  // Every harness's conversation pin is re-checked on this cadence for as long as
@@ -832,8 +832,8 @@ function scheduleCodexCapture(session, workdir) {
832
  }
833
 
834
  // opencode has no per-conversation handle we can pass on launch, so we can't
835
- // mint an id like Claude's --session-id. Instead, capture the ses_ row opencode
836
- // writes to its db and pin it — mirrors the codex approach. The row appears
837
  // only once the conversation has content (the user's first message), so retry
838
  // on a longer, sparser schedule than codex.
839
  // ---------- Claude conversation re-pinning ----------
@@ -982,21 +982,31 @@ function folderIsShared(sessionId, workdir, cli) {
982
  // honoured) and a /clear at any later point.
983
 
984
  // ---------- breadcrumbs: the pane tells us, so we don't have to guess ----------
985
- // The transcript scan above cannot attribute a new conversation when several
986
- // live claude panes share a folder folderIsShared refuses, and a /clear in
987
- // such a folder was never followed. But the pane itself KNOWS: a SessionStart
988
- // hook (installed into settings.json below, script at scripts/am-repin-hook.sh)
989
- // runs inside the pane's process tree, where $AM_ID names the pane and the
990
- // payload carries the new conversation's id. It drops that as a breadcrumb
991
- // here; the watcher consumes it and re-pins with no guessing at all. The scan
992
- // stays as the fallback for panes without a breadcrumb (hook newly installed,
993
- // crumb lost) and for codex/opencode, which have no hook mechanism.
 
 
 
 
994
  const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin';
 
 
 
 
 
 
995
 
996
  // Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb
997
  // can never flip a pin backwards after a later, scan-based re-pin.
998
- function takeClaudeBreadcrumb(sessionId) {
999
- const p = path.join(REPIN_DIR, `${sessionId}.json`);
1000
  let raw;
1001
  try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; }
1002
  try { fs.unlinkSync(p); } catch {}
@@ -1005,8 +1015,9 @@ function takeClaudeBreadcrumb(sessionId) {
1005
 
1006
  // The pane's root process — the PTY this server spawned for the session. After
1007
  // `exec claude` this IS claude; in the `claude --session-id … || exec claude`
1008
- // branch claude is a child of it. Either way the hook's $CLAUDE_PID must descend
1009
- // from it.
 
1010
  //
1011
  // This used to ask tmux for `#{pane_pid}`. Replacing tmux with a server-held grid
1012
  // left the call behind referencing two identifiers that no longer exist here
@@ -1022,40 +1033,98 @@ export function paneRootPid(sessionId) {
1022
  return Number.isInteger(pid) && pid > 1 ? pid : null;
1023
  }
1024
 
1025
- // Walk /proc ppid links. comm in /proc/<pid>/stat may contain spaces and
1026
- // parens, so split after the LAST ') '.
1027
- function pidHasAncestor(pid, ancestor, readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) {
1028
- for (let p = pid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) {
1029
- if (p === ancestor) return true;
1030
- let stat;
1031
- try { stat = readStat(p); } catch { return false; }
1032
- const tail = stat.slice(stat.lastIndexOf(') ') + 2).split(' ');
1033
- p = parseInt(tail[1], 10); // state ppid …
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  }
1035
  return false;
1036
  }
1037
 
1038
  // Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs.
1039
- // `facts` carries everything environmental: { workdir, pinned, claimed (Set of
1040
- // uuids other sessions pin), pidTrusted (bool: claudePid descends from the
1041
- // pane) }. Returns { repin: uuid } or { repin: null, why }.
1042
  export function breadcrumbVerdict(crumb, sessionId, facts) {
1043
  if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' };
1044
  if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' };
1045
- const uuid = crumb.payload?.session_id;
1046
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid || ''))
 
 
1047
  return { repin: null, why: 'no session_id' };
1048
  // A crumb written before a pane was moved to another folder must not follow
1049
  // it there — same folder-scoping rule the transcript scan applies.
1050
  if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' };
1051
- // Nested `claude -p` runs inherit $AM_ID and fire SessionStart too (verified
1052
- // on 2.1.220 — the docs say -p skips hooks; it does not). The hook filters
1053
- // on CLAUDE_CODE_ENTRYPOINT, and this is the backstop: only a process that
1054
- // descends from the pane speaks for the pane.
1055
- if (!facts.pidTrusted) return { repin: null, why: 'pid not in pane' };
1056
- if (facts.claimed?.has(uuid)) return { repin: null, why: 'claimed by another session' };
1057
- if (uuid === facts.pinned) return { repin: null, why: 'already pinned' };
1058
- return { repin: uuid };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  }
1060
 
1061
  // 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'
1092
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1093
  }
1094
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1095
  // Once the pane's SessionStart hook has proven itself, the transcript scan is a
1096
  // backstop rather than the mechanism, so it runs on this cadence instead of
1097
  // REPIN_MS. Now that the scan is awaited rather than synchronous this is no longer
@@ -1150,34 +1367,7 @@ function scheduleClaudeCapture(session, workdir) {
1150
  // two timers.
1151
  const tick = async () => {
1152
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return false; }
1153
- const pinned = currentPin();
1154
-
1155
- // Breadcrumb first: the pane's own SessionStart hook told us which
1156
- // conversation it is on, so no guessing — and no shared-folder refusal —
1157
- // is needed. Consumed on read; a rejected crumb falls through to the scan.
1158
- const crumb = takeClaudeBreadcrumb(session.id);
1159
- if (crumb) {
1160
- const root = paneRootPid(session.id);
1161
- const verdict = breadcrumbVerdict(crumb, session.id, {
1162
- workdir,
1163
- pinned,
1164
- claimed: claimedByOthers(),
1165
- pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root),
1166
- });
1167
- // A crumb that named this pane's own conversation — whether it moved the
1168
- // pin or was already on it (the `resume` no-op) — proves the hook fires
1169
- // here, so the scan can step down to a backstop. A crumb rejected for any
1170
- // other reason proves nothing about this pane: 'pid not in pane' is a
1171
- // nested `claude -p`, 'cwd mismatch' is someone else's run.
1172
- if (verdict.repin || verdict.why === 'already pinned') hookProven = true;
1173
- if (verdict.repin) {
1174
- console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1175
- update(session.id, { sessionUuid: verdict.repin });
1176
- return true;
1177
- }
1178
- if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`);
1179
- }
1180
-
1181
  if (folderIsShared(session.id, workdir, 'claude')) {
1182
  if (!warnedShared) {
1183
  warnedShared = true;
@@ -1418,14 +1608,21 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1418
  const folder = session.path ?? session.id;
1419
  const workdir = path.join(WORKSPACES_DIR, folder);
1420
  fs.mkdirSync(workdir, { recursive: true });
1421
- const full = commandFor(session);
 
 
 
 
1422
  const captureResize = cliById(session.cli)?.resizeMode === 'repaint';
 
1423
 
1424
  const env = {
1425
  ...TERM_ENV,
1426
  AM_SESSION: folder,
1427
  AM_NAME: session.name,
1428
  AM_ID: session.id,
 
 
1429
  AM_USER,
1430
  AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this
1431
  };
@@ -1451,6 +1648,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1451
  }
1452
  const host = {
1453
  id: session.id,
 
1454
  pty: term,
1455
  vt,
1456
  cols,
@@ -1533,6 +1731,10 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1533
  });
1534
 
1535
  term.onExit(() => {
 
 
 
 
1536
  hosts.delete(session.id);
1537
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1538
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
@@ -1550,6 +1752,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1550
  hosts.set(session.id, host);
1551
  if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
1552
  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
 
1553
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
1554
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
1555
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
 
1
  import os from 'node:os';
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
  import pty from 'node-pty';
5
  import fs from 'node:fs';
6
  import fsp from 'node:fs/promises';
7
  import { remoteState, setPaused } from './remote.js';
8
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
9
  import { update, list } from './sessions.js';
10
+ import { captureOpencodeSession, opencodeSessionExists, opencodeSessionInfo, readTrace } from './traces.js';
11
  import {
12
  buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
13
  } from './snapshot.js';
 
663
 
664
  // ---------- Codex conversation pinning ----------
665
  // Codex picks its own conversation id at launch and doesn't accept one up
666
+ // front. The managed SessionStart hook below is the primary source of its exact
667
+ // choice. This rollout discovery remains the fallback: a file named
668
  // rollout-<ts>-<id>.jsonl appears under $CODEX_HOME/sessions with the cwd in
669
+ // its first line, so an unshared pane can still recover when hooks are absent.
 
 
670
  const codexCapturing = new Map(); // id -> pending re-pin timer
671
 
672
  // Every harness's conversation pin is re-checked on this cadence for as long as
 
832
  }
833
 
834
  // opencode has no per-conversation handle we can pass on launch, so we can't
835
+ // mint an id like Claude's --session-id. Its plugin reports the exact ses_ id;
836
+ // this database discovery remains the unshared-folder fallback. A row appears
837
  // only once the conversation has content (the user's first message), so retry
838
  // on a longer, sparser schedule than codex.
839
  // ---------- Claude conversation re-pinning ----------
 
982
  // honoured) and a /clear at any later point.
983
 
984
  // ---------- breadcrumbs: the pane tells us, so we don't have to guess ----------
985
+ // Folder scans cannot attribute a new conversation when several live panes of
986
+ // one harness share a folder. The harness itself does know the exact id, so the
987
+ // three harnesses with lifecycle extension points report it directly:
988
+ //
989
+ // Claude SessionStart command hook
990
+ // Codex managed SessionStart command hook
991
+ // OpenCode global plugin (session.created and chat.message)
992
+ //
993
+ // Each PTY launch gets a fresh AM_RUN_ID. A breadcrumb must match both AM_ID and
994
+ // that nonce, so a delayed event from the pane's previous process can never
995
+ // move its replacement — and two panes in one folder can never claim each
996
+ // other's conversation. The existing transcript/rollout/database discovery
997
+ // stays below as a fallback for installations where an adapter is unavailable.
998
  const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin';
999
+ const breadcrumbCapturing = new Map(); // session id -> pending exact-event poll
1000
+ const BREADCRUMB_MS = 250;
1001
+ const BREADCRUMB_RETRY_MS = 500;
1002
+ const BREADCRUMB_RETRIES = 20;
1003
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
1004
+ const CONVERSATION_ID = { claude: UUID, codex: UUID, opencode: /^ses_[A-Za-z0-9_-]+$/ };
1005
 
1006
  // Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb
1007
  // can never flip a pin backwards after a later, scan-based re-pin.
1008
+ function takeBreadcrumb(sessionId, cli) {
1009
+ const p = path.join(REPIN_DIR, `${sessionId}.${cli}.json`);
1010
  let raw;
1011
  try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; }
1012
  try { fs.unlinkSync(p); } catch {}
 
1015
 
1016
  // The pane's root process — the PTY this server spawned for the session. After
1017
  // `exec claude` this IS claude; in the `claude --session-id … || exec claude`
1018
+ // branch claude is its direct child. Trusting ANY descendant is too broad: an
1019
+ // interactive agent started by the top-level agent's shell tool inherits AM_ID
1020
+ // too, and its lifecycle event must not move the pane's pin.
1021
  //
1022
  // This used to ask tmux for `#{pane_pid}`. Replacing tmux with a server-held grid
1023
  // left the call behind referencing two identifiers that no longer exist here
 
1033
  return Number.isInteger(pid) && pid > 1 ? pid : null;
1034
  }
1035
 
1036
+ // comm in /proc/<pid>/stat may itself contain spaces and parens, so split after
1037
+ // the LAST ') '. Tests inject readStat; production reads the live process tree.
1038
+ function procIdentity(pid, readStat) {
1039
+ let stat;
1040
+ try { stat = readStat(pid); } catch { return null; }
1041
+ const close = stat.lastIndexOf(') ');
1042
+ const open = stat.indexOf('(');
1043
+ if (open < 0 || close < open) return null;
1044
+ const tail = stat.slice(close + 2).split(' ');
1045
+ const ppid = parseInt(tail[1], 10); // state ppid …
1046
+ return Number.isInteger(ppid) ? { comm: stat.slice(open + 1, close), ppid } : null;
1047
+ }
1048
+
1049
+ export function pidIsPaneRootOrDirectChild(pid, root,
1050
+ readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) {
1051
+ if (!Number.isInteger(pid) || !Number.isInteger(root) || pid <= 1 || root <= 1) return false;
1052
+ if (pid === root) return true;
1053
+ return procIdentity(pid, readStat)?.ppid === root;
1054
+ }
1055
+
1056
+ // Usually the npm launcher replaces the pane root and starts Codex's native
1057
+ // binary as its direct child. The `resume --last || fresh` compatibility path
1058
+ // must retain bash, so there the npm launcher is the direct child and native
1059
+ // Codex is the grandchild. Accept that one known `node` launcher layer. A
1060
+ // nested Codex has a tool shell above its launcher and cannot pass this check.
1061
+ export function codexProcessPidTrusted(codexPid, root,
1062
+ readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) {
1063
+ for (let p = codexPid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) {
1064
+ const identity = procIdentity(p, readStat);
1065
+ if (!identity) return false;
1066
+ if (identity.comm.startsWith('codex')) {
1067
+ if (p === root || identity.ppid === root) return true;
1068
+ const launcher = procIdentity(identity.ppid, readStat);
1069
+ return launcher?.comm === 'node' && launcher.ppid === root;
1070
+ }
1071
+ p = identity.ppid;
1072
  }
1073
  return false;
1074
  }
1075
 
1076
  // Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs.
1077
+ // `facts` carries everything environmental: { cli, runId, workdir, pinned,
1078
+ // claimed (ids other sessions pin), pidTrusted }. Returns
1079
+ // { repin: conversationId } or { repin: null, why }.
1080
  export function breadcrumbVerdict(crumb, sessionId, facts) {
1081
  if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' };
1082
  if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' };
1083
+ if (crumb.cli !== facts.cli) return { repin: null, why: 'cli mismatch' };
1084
+ if (!facts.runId || crumb.runId !== facts.runId) return { repin: null, why: 'runId mismatch' };
1085
+ const conversationId = crumb.payload?.session_id;
1086
+ if (!CONVERSATION_ID[facts.cli]?.test(conversationId || ''))
1087
  return { repin: null, why: 'no session_id' };
1088
  // A crumb written before a pane was moved to another folder must not follow
1089
  // it there — same folder-scoping rule the transcript scan applies.
1090
  if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' };
1091
+ // Every supported adapter inherits the pane markers into child processes.
1092
+ // Only the top-level agent process may speak for the pane; nested agents can
1093
+ // otherwise re-pin their parent's Overview, trace and next resume target.
1094
+ if (!facts.pidTrusted) return { repin: null, why: 'pid not top-level pane agent' };
1095
+ if (facts.claimed?.has(conversationId)) return { repin: null, why: 'claimed by another session' };
1096
+ if (conversationId === facts.pinned) return { repin: null, why: 'already pinned' };
1097
+ return { repin: conversationId };
1098
+ }
1099
+
1100
+ // Codex gives the exact transcript path with SessionStart. Keep only paths that
1101
+ // are under this CODEX_HOME's sessions tree and whose rollout filename encodes
1102
+ // the reported id; the hook's payload is data, never an arbitrary resume path.
1103
+ export function codexRolloutForBreadcrumb(crumb) {
1104
+ const id = crumb?.payload?.session_id;
1105
+ const raw = crumb?.payload?.transcript_path;
1106
+ if (!UUID.test(id || '') || typeof raw !== 'string' || !raw) return null;
1107
+ const resolved = path.resolve(raw);
1108
+ const roots = [path.resolve(codexSessionsRoot())];
1109
+ const targets = [resolved];
1110
+ // CODEX_HOME/sessions is commonly a symlink to durable storage. Codex may
1111
+ // report either spelling, so compare both lexical and canonical paths while
1112
+ // retaining the same containment and exact-id checks.
1113
+ try { roots.push(fs.realpathSync(roots[0])); } catch {}
1114
+ try { targets.push(fs.realpathSync(resolved)); } catch {}
1115
+ const contained = roots.some((root) => targets.some((target) => {
1116
+ const rel = path.relative(root, target);
1117
+ return !!rel && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
1118
+ }));
1119
+ return contained && path.basename(resolved).endsWith(`-${id}.jsonl`) ? resolved : null;
1120
+ }
1121
+
1122
+ // SessionStart permits transcript_path=null. Resolve that case by the exact id
1123
+ // encoded in Codex's rollout filename — deterministic even in a shared folder.
1124
+ export function codexRolloutForId(id) {
1125
+ if (!UUID.test(id || '')) return null;
1126
+ const suffix = `-${id}.jsonl`;
1127
+ return codexRolloutsSince(0).find((item) => path.basename(item.p).endsWith(suffix))?.p || null;
1128
  }
1129
 
1130
  // Register the SessionStart hook in $CLAUDE_CONFIG_DIR/settings.json. Merge,
 
1161
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1162
  }
1163
 
1164
+ // OpenCode automatically loads global plugins from this directory. The plugin
1165
+ // is an app-owned file, so upgrades replace only that one file while all user
1166
+ // plugins and opencode.json settings remain untouched. This config directory
1167
+ // can be on the Space's FUSE bucket, whose rename semantics are unreliable;
1168
+ // install before launching OpenCode and write the app-owned file directly.
1169
+ export function installOpencodeRepinPlugin(source = '/app/scripts/am-opencode-repin.js') {
1170
+ const base = process.env.OPENCODE_CONFIG_DIR
1171
+ || path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || os.homedir(), '.config'), 'opencode');
1172
+ const dir = path.join(base, 'plugins');
1173
+ const file = path.join(dir, 'am-agent-manager.js');
1174
+ let body;
1175
+ try { body = fs.readFileSync(source, 'utf8'); }
1176
+ catch (e) { console.warn(`[opencode] repin plugin source unavailable: ${e.message}`); return false; }
1177
+ try {
1178
+ if (fs.readFileSync(file, 'utf8') === body) return true;
1179
+ } catch { /* install or upgrade it below */ }
1180
+ try {
1181
+ fs.mkdirSync(dir, { recursive: true });
1182
+ fs.writeFileSync(file, body);
1183
+ console.warn(`[opencode] repin plugin installed in ${file}`);
1184
+ return true;
1185
+ } catch (e) { console.warn(`[opencode] repin plugin install failed: ${e.message}`); return false; }
1186
+ }
1187
+
1188
+ function exactPinFacts(session, host, workdir, pinField) {
1189
+ return {
1190
+ cli: session.cli,
1191
+ runId: host.runId,
1192
+ workdir,
1193
+ pinned: (list().find((s) => s.id === session.id) || session)[pinField],
1194
+ claimed: new Set(list().filter((s) => s.id !== session.id && s[pinField]).map((s) => s[pinField])),
1195
+ };
1196
+ }
1197
+
1198
+ // Apply one event from the pane's own adapter. This path never asks which file
1199
+ // or database row is newest: the reported id is the lookup key, and local state
1200
+ // is used only to validate that exact key before persisting it.
1201
+ function applyBreadcrumb(session, host, workdir, crumb) {
1202
+ let facts;
1203
+ let patch;
1204
+ if (session.cli === 'claude') {
1205
+ facts = exactPinFacts(session, host, workdir, 'sessionUuid');
1206
+ const root = paneRootPid(session.id);
1207
+ facts.pidTrusted = !!root && (pidIsPaneRootOrDirectChild(crumb.claudePid, root)
1208
+ || (Number.isInteger(host.exactAgentPid) && host.exactAgentPid === crumb.claudePid));
1209
+ patch = (id) => ({ sessionUuid: id });
1210
+ } else if (session.cli === 'codex') {
1211
+ facts = exactPinFacts(session, host, workdir, 'codexSessionId');
1212
+ const root = paneRootPid(session.id);
1213
+ facts.pidTrusted = !!root && (codexProcessPidTrusted(crumb.codexPid, root)
1214
+ || (Number.isInteger(host.exactAgentPid) && host.exactAgentPid === crumb.codexPid));
1215
+ } else if (session.cli === 'opencode') {
1216
+ facts = exactPinFacts(session, host, workdir, 'opencodeSessionId');
1217
+ facts.pidTrusted = crumb.pluginPid === paneRootPid(session.id);
1218
+ } else {
1219
+ return { repin: null, why: 'unsupported cli' };
1220
+ }
1221
+
1222
+ const verdict = breadcrumbVerdict(crumb, session.id, facts);
1223
+ if (!verdict.repin && verdict.why !== 'already pinned') return verdict;
1224
+
1225
+ if (session.cli === 'codex') {
1226
+ const reported = crumb?.payload?.transcript_path;
1227
+ const rollout = codexRolloutForBreadcrumb(crumb)
1228
+ || (reported == null ? codexRolloutForId(crumb?.payload?.session_id) : null);
1229
+ if (!rollout) return {
1230
+ repin: null,
1231
+ why: reported == null ? 'rollout not available yet' : 'invalid transcript_path',
1232
+ retry: reported == null,
1233
+ };
1234
+ patch = (id) => ({ codexSessionId: id, codexRollout: rollout });
1235
+ // A same-id resume is normally a no-op, but an older pin may lack the path
1236
+ // required by commandFor. Exact lifecycle data repairs that incomplete pin.
1237
+ if (!verdict.repin && facts.pinned === crumb.payload.session_id) {
1238
+ const current = list().find((s) => s.id === session.id) || session;
1239
+ if (current.codexRollout !== rollout) update(session.id, patch(facts.pinned));
1240
+ }
1241
+ } else if (session.cli === 'opencode') {
1242
+ const row = opencodeSessionInfo(crumb?.payload?.session_id);
1243
+ if (!row) return { repin: null, why: 'session missing from database', retry: true };
1244
+ if (row.parentId) return { repin: null, why: 'subagent session' };
1245
+ if (row.directory !== workdir) return { repin: null, why: 'database cwd mismatch' };
1246
+ patch = (id) => ({ opencodeSessionId: id });
1247
+ }
1248
+
1249
+ // Remember only a process that passed both tree attribution and adapter data
1250
+ // validation. onExit runs after node-pty has reaped the process, so /proc may
1251
+ // already be gone when it performs the promised final breadcrumb read; a
1252
+ // later /clear crumb from this same long-lived agent remains attributable.
1253
+ host.exactAgentPid = session.cli === 'claude' ? crumb.claudePid
1254
+ : session.cli === 'codex' ? crumb.codexPid : crumb.pluginPid;
1255
+ if (verdict.repin || verdict.why === 'already pinned') host.exactRepinProven = true;
1256
+ if (verdict.repin) {
1257
+ console.warn(`[${session.cli}] re-pinning ${session.id}: ${facts.pinned || '(none)'} -> ${verdict.repin} (exact ${crumb.payload?.source || 'event'})`);
1258
+ update(session.id, patch(verdict.repin));
1259
+ }
1260
+ return verdict;
1261
+ }
1262
+
1263
+ function consumeBreadcrumb(session, host, workdir, force = false) {
1264
+ const fresh = takeBreadcrumb(session.id, session.cli);
1265
+ let pending = host.pendingExactBreadcrumb;
1266
+ if (fresh) {
1267
+ pending = { crumb: fresh, attempts: 0, nextAt: 0 };
1268
+ host.pendingExactBreadcrumb = null;
1269
+ }
1270
+ if (!pending || (!fresh && !force && Date.now() < pending.nextAt)) return;
1271
+ try {
1272
+ const verdict = applyBreadcrumb(session, host, workdir, pending.crumb);
1273
+ if (verdict.retry && pending.attempts < BREADCRUMB_RETRIES) {
1274
+ host.pendingExactBreadcrumb = {
1275
+ crumb: pending.crumb,
1276
+ attempts: pending.attempts + 1,
1277
+ nextAt: Date.now() + BREADCRUMB_RETRY_MS,
1278
+ };
1279
+ return;
1280
+ }
1281
+ host.pendingExactBreadcrumb = null;
1282
+ if (!verdict.repin && verdict.why !== 'already pinned')
1283
+ console.warn(`[${session.cli}] ${session.id}: exact breadcrumb rejected (${verdict.why})`);
1284
+ } catch (e) {
1285
+ host.pendingExactBreadcrumb = null;
1286
+ console.warn(`[${session.cli}] ${session.id}: exact breadcrumb failed (${e && e.message})`);
1287
+ }
1288
+ }
1289
+
1290
+ // Poll only a tiny file on local /tmp, frequently enough that a /clear followed
1291
+ // by an immediate pane exit still persists its new id. Expensive transcript,
1292
+ // rollout and database discovery retain their existing sparse cadence below.
1293
+ function scheduleBreadcrumbCapture(session, workdir) {
1294
+ if (!CONVERSATION_ID[session.cli]) return;
1295
+ const prev = breadcrumbCapturing.get(session.id);
1296
+ if (prev) clearTimeout(prev);
1297
+ const host = hosts.get(session.id);
1298
+ let armed = null;
1299
+ const tick = () => {
1300
+ if (hosts.get(session.id) !== host) {
1301
+ if (breadcrumbCapturing.get(session.id) === armed) breadcrumbCapturing.delete(session.id);
1302
+ return;
1303
+ }
1304
+ consumeBreadcrumb(session, host, workdir);
1305
+ armed = setTimeout(tick, BREADCRUMB_MS);
1306
+ if (armed.unref) armed.unref();
1307
+ breadcrumbCapturing.set(session.id, armed);
1308
+ };
1309
+ tick();
1310
+ }
1311
+
1312
  // Once the pane's SessionStart hook has proven itself, the transcript scan is a
1313
  // backstop rather than the mechanism, so it runs on this cadence instead of
1314
  // REPIN_MS. Now that the scan is awaited rather than synchronous this is no longer
 
1367
  // two timers.
1368
  const tick = async () => {
1369
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return false; }
1370
+ hookProven ||= !!host.exactRepinProven;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1371
  if (folderIsShared(session.id, workdir, 'claude')) {
1372
  if (!warnedShared) {
1373
  warnedShared = true;
 
1608
  const folder = session.path ?? session.id;
1609
  const workdir = path.join(WORKSPACES_DIR, folder);
1610
  fs.mkdirSync(workdir, { recursive: true });
1611
+ // The login shell knows its own PTY-root pid before any `exec`. Adapters use
1612
+ // this marker to discard nested agent lifecycle events BEFORE they can
1613
+ // overwrite the top-level pane's breadcrumb; runner validation repeats the
1614
+ // process-tree check before persisting anything.
1615
+ const full = `export AM_PANE_PID=$$; ${commandFor(session)}`;
1616
  const captureResize = cliById(session.cli)?.resizeMode === 'repaint';
1617
+ const runId = crypto.randomUUID();
1618
 
1619
  const env = {
1620
  ...TERM_ENV,
1621
  AM_SESSION: folder,
1622
  AM_NAME: session.name,
1623
  AM_ID: session.id,
1624
+ AM_RUN_ID: runId,
1625
+ AM_CLI: session.cli,
1626
  AM_USER,
1627
  AM_ROOT: WORKSPACES_DIR, // prompt shows $PWD relative to this
1628
  };
 
1648
  }
1649
  const host = {
1650
  id: session.id,
1651
+ runId,
1652
  pty: term,
1653
  vt,
1654
  cols,
 
1731
  });
1732
 
1733
  term.onExit(() => {
1734
+ // Do one final local read before releasing this launch's nonce. In
1735
+ // particular, `/clear` followed immediately by quit must still persist the
1736
+ // conversation that was on screen when the pane ended.
1737
+ consumeBreadcrumb(session, host, workdir, true);
1738
  hosts.delete(session.id);
1739
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1740
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
 
1752
  hosts.set(session.id, host);
1753
  if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
1754
  if (!session.everStarted) update(session.id, { everStarted: true, pendingPrompt: undefined });
1755
+ scheduleBreadcrumbCapture(session, workdir);
1756
  if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
1757
  if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
1758
  if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
server/src/traces.js CHANGED
@@ -414,12 +414,11 @@ function readOpencode() {
414
  return rows;
415
  }
416
 
417
- // Newest opencode conversation in `directory` created at/after `sinceMs` and
418
- // not already claimed by another session. The runner calls this shortly after
419
- // launch to PIN a session to its own `ses_…` id — opencode has no
420
- // per-conversation handle of its own (unlike codex's rollout uuid), so two
421
- // agents sharing a folder would otherwise cross-attribute. Read straight from
422
- // the db (not the memoized rows) so a just-created session is seen immediately.
423
  export function captureOpencodeSession(directory, sinceMs, claimed) {
424
  if (!DatabaseSync || !directory) return null;
425
  let db;
@@ -448,6 +447,22 @@ export function opencodeSessionExists(id) {
448
  } catch { return false; } finally { try { db.close(); } catch {} }
449
  }
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  // ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ----------
452
  // sessions carry cwd + token totals; messages carry role/content/tool_name.
453
  // Timestamps are float SECONDS — converted to ms for digest fields.
 
414
  return rows;
415
  }
416
 
417
+ // Fallback discovery for installations where the exact-event plugin is absent:
418
+ // newest opencode conversation in `directory` created at/after `sinceMs` and
419
+ // not already claimed by another session. This is deliberately used only for
420
+ // unshared folders; same-folder panes cannot safely attribute a newest row.
421
+ // Read straight from the db so a just-created session is seen immediately.
 
422
  export function captureOpencodeSession(directory, sinceMs, claimed) {
423
  if (!DatabaseSync || !directory) return null;
424
  let db;
 
447
  } catch { return false; } finally { try { db.close(); } catch {} }
448
  }
449
 
450
+ // Exact row metadata for a session id reported by the opencode plugin. The
451
+ // plugin tells us which id the pane is using; the database remains the local
452
+ // authority for its folder and whether it is a root conversation rather than a
453
+ // task/subagent child.
454
+ export function opencodeSessionInfo(id) {
455
+ if (!DatabaseSync || !id) return null;
456
+ let db;
457
+ try { db = new DatabaseSync(opencodeDbPath(), { readOnly: true }); } catch { return null; }
458
+ try {
459
+ const row = db.prepare(
460
+ 'select id, directory, parent_id as parentId from session where id = ?',
461
+ ).get(id);
462
+ return row ? { id: row.id, directory: row.directory || null, parentId: row.parentId || null } : null;
463
+ } catch { return null; } finally { try { db.close(); } catch {} }
464
+ }
465
+
466
  // ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ----------
467
  // sessions carry cwd + token totals; messages carry role/content/tool_name.
468
  // Timestamps are float SECONDS — converted to ms for digest fields.
server/test/opencode-resume.test.mjs CHANGED
@@ -8,23 +8,31 @@ import fs from 'node:fs';
8
  import path from 'node:path';
9
  import os from 'node:os';
10
  import { DatabaseSync } from 'node:sqlite';
 
11
 
12
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-resume-'));
13
  const XDG = path.join(TMP, 'xdg');
14
  const DATA = path.join(TMP, 'data');
 
15
  fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true });
16
  fs.mkdirSync(DATA, { recursive: true });
17
 
18
  process.env.XDG_DATA_HOME = XDG;
 
19
  process.env.DATA_DIR = DATA;
 
 
 
 
 
20
 
21
  // Only the columns the runner reads. opencode's real table has ~30 more; a
22
  // narrower one still proves the query, and drifts less.
23
  const DB = path.join(XDG, 'opencode', 'opencode.db');
24
  const db = new DatabaseSync(DB);
25
- db.exec('create table session (id text primary key, directory text not null, time_created integer not null)');
26
- const addRow = (id, directory, timeCreated) =>
27
- db.prepare('insert into session (id, directory, time_created) values (?, ?, ?)').run(id, directory, timeCreated);
28
 
29
  const sessions = await import('../src/sessions.js');
30
  const runner = await import('../src/runner.js');
@@ -42,11 +50,57 @@ const has = (name, hay, needle) => check(name, String(hay).includes(needle), tru
42
  const LIVE = 'ses_0325987abffej9UeLKjc55GHK8';
43
  const GONE = 'ses_099999999ffezzzzzzzzzzzzzzz';
44
  addRow(LIVE, '/data/workspaces/proj-a', 1770000000000);
 
45
 
46
  console.log('\nthe db decides whether a pin is still resumable');
47
  check('live row found', traces.opencodeSessionExists(LIVE), true);
48
  check('purged row not found', traces.opencodeSessionExists(GONE), false);
49
  check('no id is not a row', traces.opencodeSessionExists(null), false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  console.log('\na restart resumes the pinned conversation, not the folder\'s newest');
52
  const s = sessions.create({ name: 'oc', cli: 'opencode', path: 'proj-a' });
 
8
  import path from 'node:path';
9
  import os from 'node:os';
10
  import { DatabaseSync } from 'node:sqlite';
11
+ import { fileURLToPath } from 'node:url';
12
 
13
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-resume-'));
14
  const XDG = path.join(TMP, 'xdg');
15
  const DATA = path.join(TMP, 'data');
16
+ const REPIN = path.join(TMP, 'repin');
17
  fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true });
18
  fs.mkdirSync(DATA, { recursive: true });
19
 
20
  process.env.XDG_DATA_HOME = XDG;
21
+ process.env.XDG_CONFIG_HOME = path.join(TMP, 'config');
22
  process.env.DATA_DIR = DATA;
23
+ process.env.AM_REPIN_DIR = REPIN;
24
+ process.env.AM_ID = 'pane-1';
25
+ process.env.AM_RUN_ID = '11111111-2222-4333-8444-555555555555';
26
+ process.env.AM_CLI = 'opencode';
27
+ process.env.AM_PANE_PID = String(process.pid);
28
 
29
  // Only the columns the runner reads. opencode's real table has ~30 more; a
30
  // narrower one still proves the query, and drifts less.
31
  const DB = path.join(XDG, 'opencode', 'opencode.db');
32
  const db = new DatabaseSync(DB);
33
+ db.exec('create table session (id text primary key, directory text not null, parent_id text, time_created integer not null)');
34
+ const addRow = (id, directory, timeCreated, parentId = null) =>
35
+ db.prepare('insert into session (id, directory, parent_id, time_created) values (?, ?, ?, ?)').run(id, directory, parentId, timeCreated);
36
 
37
  const sessions = await import('../src/sessions.js');
38
  const runner = await import('../src/runner.js');
 
50
  const LIVE = 'ses_0325987abffej9UeLKjc55GHK8';
51
  const GONE = 'ses_099999999ffezzzzzzzzzzzzzzz';
52
  addRow(LIVE, '/data/workspaces/proj-a', 1770000000000);
53
+ addRow('ses_child', '/data/workspaces/proj-a', 1770000000001, LIVE);
54
 
55
  console.log('\nthe db decides whether a pin is still resumable');
56
  check('live row found', traces.opencodeSessionExists(LIVE), true);
57
  check('purged row not found', traces.opencodeSessionExists(GONE), false);
58
  check('no id is not a row', traces.opencodeSessionExists(null), false);
59
+ check('exact row keeps its directory', traces.opencodeSessionInfo(LIVE)?.directory, '/data/workspaces/proj-a');
60
+ check('exact row exposes subagent parent', traces.opencodeSessionInfo('ses_child')?.parentId, LIVE);
61
+ check('missing exact row is null', traces.opencodeSessionInfo(GONE), null);
62
+
63
+ console.log('\nthe global plugin reports exact root-session lifecycle events');
64
+ const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts');
65
+ const pluginSource = path.join(scripts, 'am-opencode-repin.js');
66
+ check('plugin installs globally', runner.installOpencodeRepinPlugin(pluginSource), true);
67
+ const installed = path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'plugins', 'am-agent-manager.js');
68
+ check('installed plugin is the app-owned source', fs.readFileSync(installed, 'utf8'), fs.readFileSync(pluginSource, 'utf8'));
69
+ check('plugin install is idempotent', runner.installOpencodeRepinPlugin(pluginSource), true);
70
+
71
+ const pluginBody = fs.readFileSync(pluginSource, 'utf8');
72
+ const pluginModule = await import(`data:text/javascript;base64,${Buffer.from(pluginBody).toString('base64')}`);
73
+ const hooks = await pluginModule.AgentManagerRepin({ directory: '/data/workspaces/proj-a' });
74
+ const CREATED = 'ses_0ccccccccffeccccccccccccccc';
75
+ const createdDispatch = hooks.event({ event: { type: 'session.created', properties: { info: {
76
+ id: CREATED, directory: '/data/workspaces/proj-a',
77
+ } } } });
78
+ check('created event writes before its Promise is awaited', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), true);
79
+ await createdDispatch;
80
+ let crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8'));
81
+ check('created event reports exact id', crumb.payload.session_id, CREATED);
82
+ check('created event carries launch nonce', crumb.runId, process.env.AM_RUN_ID);
83
+ check('created event carries top-level process id', crumb.pluginPid, process.pid);
84
+ fs.unlinkSync(path.join(REPIN, 'pane-1.opencode.json'));
85
+ await hooks.event({ event: { type: 'session.created', properties: { info: {
86
+ id: 'ses_child', directory: '/data/workspaces/proj-a', parentID: CREATED,
87
+ } } } });
88
+ check('subagent create ignored', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), false);
89
+ await hooks['chat.message']({ sessionID: LIVE });
90
+ crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8'));
91
+ check('message hook follows selected existing session', crumb.payload.session_id, LIVE);
92
+ const shellOutput = { env: { KEEP: 'yes' } };
93
+ await hooks['shell.env']({}, shellOutput);
94
+ check('shell keeps unrelated environment', shellOutput.env.KEEP, 'yes');
95
+ check('shell strips pane id', shellOutput.env.AM_ID, '');
96
+ check('shell strips pane process marker', shellOutput.env.AM_PANE_PID, '');
97
+ fs.unlinkSync(path.join(REPIN, 'pane-1.opencode.json'));
98
+ process.env.AM_PANE_PID = '999999999';
99
+ await hooks.event({ event: { type: 'session.created', properties: { info: {
100
+ id: 'ses_nested', directory: '/data/workspaces/proj-a',
101
+ } } } });
102
+ check('nested OpenCode process cannot report', fs.existsSync(path.join(REPIN, 'pane-1.opencode.json')), false);
103
+ process.env.AM_PANE_PID = String(process.pid);
104
 
105
  console.log('\na restart resumes the pinned conversation, not the folder\'s newest');
106
  const s = sessions.create({ name: 'oc', cli: 'opencode', path: 'proj-a' });
server/test/repin.test.mjs CHANGED
@@ -6,15 +6,22 @@
6
  import fs from 'node:fs';
7
  import path from 'node:path';
8
  import os from 'node:os';
 
 
9
 
10
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
11
  const CFG = path.join(TMP, 'cfg');
12
  const DATA = path.join(TMP, 'data');
 
 
13
  fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
14
  fs.mkdirSync(DATA, { recursive: true });
 
15
 
16
  process.env.CLAUDE_CONFIG_DIR = CFG;
17
  process.env.DATA_DIR = DATA;
 
 
18
 
19
  const sessions = await import('../src/sessions.js');
20
  const runner = await import('../src/runner.js');
@@ -78,13 +85,15 @@ check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACE
78
 
79
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
80
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
 
81
  const crumb = (over = {}) => ({
82
- amId: 's1', claudePid: 4242,
83
  payload: { session_id: E, cwd: WORKDIR, source: 'clear' },
84
  ...over,
85
  });
86
  const facts = (over = {}) => ({
87
- workdir: WORKDIR, pinned: A, claimed: new Set([B]), pidTrusted: true, ...over,
 
88
  });
89
  const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f);
90
 
@@ -93,12 +102,14 @@ check('clean crumb accepted', verdict(crumb(), facts()).repin, E);
93
 
94
  console.log('\na breadcrumb only speaks for the pane that wrote it');
95
  check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null);
 
 
96
  check('cwd mismatch rejected',
97
  verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
98
 
99
  console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
100
  check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
101
- check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not in pane');
102
 
103
  console.log('\nno-ops and garbage stay no-ops');
104
  check('already-pinned crumb is a no-op',
@@ -109,6 +120,103 @@ check('malformed session_id rejected',
109
  verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null);
110
  check('null crumb rejected', verdict(null, facts()).repin, null);
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  // ---------- the pane root: the fact every breadcrumb is trusted against ----------
113
  // paneRootPid used to shell out to `tmux list-panes`. The libghostty migration
114
  // removed tmux but left the call, referencing identifiers that no longer exist —
 
6
  import fs from 'node:fs';
7
  import path from 'node:path';
8
  import os from 'node:os';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
 
12
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
13
  const CFG = path.join(TMP, 'cfg');
14
  const DATA = path.join(TMP, 'data');
15
+ const REPIN = path.join(TMP, 'repin');
16
+ const CODEX = path.join(TMP, 'codex');
17
  fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
18
  fs.mkdirSync(DATA, { recursive: true });
19
+ fs.mkdirSync(path.join(CODEX, 'sessions', '2026', '08', '06'), { recursive: true });
20
 
21
  process.env.CLAUDE_CONFIG_DIR = CFG;
22
  process.env.DATA_DIR = DATA;
23
+ process.env.AM_REPIN_DIR = REPIN;
24
+ process.env.CODEX_HOME = CODEX;
25
 
26
  const sessions = await import('../src/sessions.js');
27
  const runner = await import('../src/runner.js');
 
85
 
86
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
87
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
88
+ const RUN = '11111111-2222-4333-8444-555555555555';
89
  const crumb = (over = {}) => ({
90
+ amId: 's1', runId: RUN, cli: 'claude', claudePid: 4242,
91
  payload: { session_id: E, cwd: WORKDIR, source: 'clear' },
92
  ...over,
93
  });
94
  const facts = (over = {}) => ({
95
+ cli: 'claude', runId: RUN, workdir: WORKDIR, pinned: A,
96
+ claimed: new Set([B]), pidTrusted: true, ...over,
97
  });
98
  const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f);
99
 
 
102
 
103
  console.log('\na breadcrumb only speaks for the pane that wrote it');
104
  check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null);
105
+ check('stale launch rejected', verdict(crumb({ runId: 'old-run' }), facts()).why, 'runId mismatch');
106
+ check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch');
107
  check('cwd mismatch rejected',
108
  verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
109
 
110
  console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
111
  check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
112
+ check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not top-level pane agent');
113
 
114
  console.log('\nno-ops and garbage stay no-ops');
115
  check('already-pinned crumb is a no-op',
 
120
  verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null);
121
  check('null crumb rejected', verdict(null, facts()).repin, null);
122
 
123
+ console.log('\nCodex and OpenCode use the same pane/run attribution contract');
124
+ const CODEX_ID = '12345678-1234-4234-8234-123456789abc';
125
+ const rollout = path.join(CODEX, 'sessions', '2026', '08', '06', `rollout-2026-08-06T00-00-00-${CODEX_ID}.jsonl`);
126
+ fs.writeFileSync(rollout, '{}\n');
127
+ const codexCrumb = {
128
+ amId: 's1', runId: RUN, cli: 'codex',
129
+ payload: { session_id: CODEX_ID, transcript_path: rollout, cwd: WORKDIR, source: 'clear' },
130
+ };
131
+ check('Codex exact id accepted', runner.breadcrumbVerdict(codexCrumb, 's1', {
132
+ cli: 'codex', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
133
+ }).repin, CODEX_ID);
134
+ check('Codex rollout path retained', runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
135
+ check('Codex null transcript resolves by exact id', runner.codexRolloutForId(CODEX_ID), rollout);
136
+ const CODEX_ALIAS = path.join(TMP, 'codex-alias');
137
+ fs.mkdirSync(CODEX_ALIAS);
138
+ fs.symlinkSync(path.join(CODEX, 'sessions'), path.join(CODEX_ALIAS, 'sessions'));
139
+ process.env.CODEX_HOME = CODEX_ALIAS;
140
+ check('Codex canonical path accepted through a symlinked sessions root',
141
+ runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
142
+ process.env.CODEX_HOME = CODEX;
143
+ check('Codex path outside CODEX_HOME rejected', runner.codexRolloutForBreadcrumb({
144
+ ...codexCrumb, payload: { ...codexCrumb.payload, transcript_path: `/tmp/rollout-x-${CODEX_ID}.jsonl` },
145
+ }), null);
146
+ check('OpenCode exact id accepted', runner.breadcrumbVerdict({
147
+ amId: 's1', runId: RUN, cli: 'opencode',
148
+ payload: { session_id: 'ses_1234567890abcdef', cwd: WORKDIR },
149
+ }, 's1', {
150
+ cli: 'opencode', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
151
+ }).repin, 'ses_1234567890abcdef');
152
+
153
+ console.log('\nonly the top-level agent process may emit a breadcrumb');
154
+ const proc = new Map([
155
+ [99, '99 (bash) S 1 0 0'],
156
+ [100, '100 (node) S 1 0 0'],
157
+ [101, '101 (claude) S 100 0 0'],
158
+ [102, '102 (nested claude) S 101 0 0'],
159
+ [110, '110 (codex-x64 (native)) S 100 0 0'],
160
+ [120, '120 (tool shell) S 110 0 0'],
161
+ [200, '200 (node) S 120 0 0'],
162
+ [210, '210 (codex-x64) S 200 0 0'],
163
+ ]);
164
+ const readStat = (pid) => {
165
+ if (!proc.has(pid)) throw new Error('gone');
166
+ return proc.get(pid);
167
+ };
168
+ check('pane root accepted', runner.pidIsPaneRootOrDirectChild(100, 100, readStat), true);
169
+ check('direct Claude child accepted', runner.pidIsPaneRootOrDirectChild(101, 100, readStat), true);
170
+ check('nested Claude rejected', runner.pidIsPaneRootOrDirectChild(102, 100, readStat), false);
171
+ check('top native Codex accepted', runner.codexProcessPidTrusted(110, 100, readStat), true);
172
+ proc.set(100, '100 (node) S 99 0 0');
173
+ check('Codex behind retained launch shell accepted', runner.codexProcessPidTrusted(110, 99, readStat), true);
174
+ check('nested native Codex rejected', runner.codexProcessPidTrusted(210, 100, readStat), false);
175
+ check('gone Codex pid rejected', runner.codexProcessPidTrusted(999, 100, readStat), false);
176
+
177
+ console.log('\nhook scripts preserve the pane and launch identity');
178
+ const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts');
179
+ const repoRoot = path.dirname(scripts);
180
+ const requirements = fs.readFileSync(path.join(repoRoot, 'codex-requirements.toml'), 'utf8');
181
+ check('Codex hook is managed (no user trust prompt)', requirements.includes('managed_dir = "/etc/codex/hooks"'), true);
182
+ check('managed policy runs the root-owned hook', requirements.includes('command = "/etc/codex/hooks/am-codex-repin-hook.sh"'), true);
183
+ const hookPayload = JSON.stringify({ session_id: E, transcript_path: '/tmp/t.jsonl', cwd: WORKDIR, source: 'clear' });
184
+ let hook = spawnSync('sh', [path.join(scripts, 'am-repin-hook.sh')], {
185
+ input: hookPayload,
186
+ env: {
187
+ ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'claude',
188
+ AM_PANE_PID: String(process.pid), CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_PID: String(process.pid),
189
+ },
190
+ });
191
+ check('Claude hook exits cleanly', hook.status, 0);
192
+ let written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.claude.json'), 'utf8'));
193
+ check('Claude hook writes run id', written.runId, RUN);
194
+ check('Claude hook writes exact session id', written.payload.session_id, E);
195
+
196
+ const codexShim = path.join(TMP, 'codex-test');
197
+ fs.symlinkSync('/bin/sh', codexShim);
198
+ const paneShim = path.join(TMP, 'pane-shell');
199
+ fs.symlinkSync('/bin/sh', paneShim);
200
+ const codexLauncher = path.join(TMP, 'codex-launcher.cjs');
201
+ fs.writeFileSync(codexLauncher, [
202
+ "const fs = require('node:fs');",
203
+ "const { spawnSync } = require('node:child_process');",
204
+ "const child = spawnSync(process.argv[2], ['-c', `sh '${process.argv[3]}'`], {",
205
+ " input: fs.readFileSync(0), env: process.env, stdio: ['pipe', 'inherit', 'inherit'],",
206
+ "});",
207
+ "process.exit(child.status ?? 1);",
208
+ ].join('\n'));
209
+ hook = spawnSync(paneShim, ['-c',
210
+ `export AM_PANE_PID=$$; node '${codexLauncher}' '${codexShim}' '${path.join(scripts, 'am-codex-repin-hook.sh')}'`], {
211
+ input: JSON.stringify(codexCrumb.payload),
212
+ env: { ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'codex' },
213
+ });
214
+ check('Codex hook exits cleanly', hook.status, 0);
215
+ written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.codex.json'), 'utf8'));
216
+ check('Codex hook writes run id', written.runId, RUN);
217
+ check('Codex hook writes exact session id', written.payload.session_id, CODEX_ID);
218
+ check('Codex hook records its live agent process', Number.isInteger(written.codexPid), true);
219
+
220
  // ---------- the pane root: the fact every breadcrumb is trusted against ----------
221
  // paneRootPid used to shell out to `tmux list-panes`. The libghostty migration
222
  // removed tmux but left the call, referencing identifiers that no longer exist —