Spaces:
Running
Running
Leandro von Werra Agent Manager commited on
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 +6 -0
- codex-requirements.toml +16 -0
- scripts/am-codex-repin-hook.sh +55 -0
- scripts/am-opencode-repin.js +56 -0
- scripts/am-repin-hook.sh +19 -9
- server/src/index.js +11 -4
- server/src/runner.js +274 -71
- server/src/traces.js +21 -6
- server/test/opencode-resume.test.mjs +57 -3
- server/test/repin.test.mjs +111 -3
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
|
| 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
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
-
#
|
| 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]*)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 42 |
-
//
|
| 43 |
-
//
|
|
|
|
| 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
|
|
|
|
| 666 |
// rollout-<ts>-<id>.jsonl appears under $CODEX_HOME/sessions with the cwd in
|
| 667 |
-
// its first line
|
| 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.
|
| 836 |
-
//
|
| 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 |
-
//
|
| 986 |
-
//
|
| 987 |
-
//
|
| 988 |
-
//
|
| 989 |
-
//
|
| 990 |
-
//
|
| 991 |
-
//
|
| 992 |
-
//
|
| 993 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 1009 |
-
//
|
|
|
|
| 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 |
-
//
|
| 1026 |
-
//
|
| 1027 |
-
function
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
}
|
| 1035 |
return false;
|
| 1036 |
}
|
| 1037 |
|
| 1038 |
// Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs.
|
| 1039 |
-
// `facts` carries everything environmental: {
|
| 1040 |
-
//
|
| 1041 |
-
//
|
| 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 |
-
|
| 1046 |
-
if (!
|
|
|
|
|
|
|
| 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 |
-
//
|
| 1052 |
-
//
|
| 1053 |
-
//
|
| 1054 |
-
|
| 1055 |
-
if (
|
| 1056 |
-
if (facts.
|
| 1057 |
-
|
| 1058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 418 |
-
//
|
| 419 |
-
//
|
| 420 |
-
//
|
| 421 |
-
//
|
| 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 |
-
|
|
|
|
| 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
|
| 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 —
|