Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ ENV LANG=C.UTF-8
# Pinned to @latest so a factory reboot (no-cache rebuild) reinstalls the newest
# published versions — that's what the "Relaunch & update" button triggers.
RUN npm install -g @anthropic-ai/claude-code@latest @openai/codex@latest
# This image is the administrator of its own Codex runtime. Install Agent
# Manager's lifecycle adapter as a managed hook so it runs deterministically
# without weakening trust for any user/project hooks.
COPY codex-requirements.toml /etc/codex/requirements.toml
COPY scripts/am-codex-repin-hook.sh /etc/codex/hooks/am-codex-repin-hook.sh
RUN chmod 755 /etc/codex/hooks/am-codex-repin-hook.sh
# Newer agents, best-effort so a publish hiccup can't break the image build;
# the app marks any missing binary "unavailable" gracefully.
RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed"
Expand Down
16 changes: 16 additions & 0 deletions codex-requirements.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Agent Manager owns this container-level Codex policy. A managed hook needs no
# per-pane trust prompt, unlike a user hooks.json entry, and it is limited to
# reporting the exact root session selected by startup/resume/clear.
[features]
hooks = true

[hooks]
managed_dir = "/etc/codex/hooks"

[[hooks.SessionStart]]
matcher = "^(startup|resume|clear)$"

[[hooks.SessionStart.hooks]]
type = "command"
command = "/etc/codex/hooks/am-codex-repin-hook.sh"
timeout = 5
55 changes: 55 additions & 0 deletions scripts/am-codex-repin-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/sh
# Managed Codex SessionStart hook. stdin contains the exact session_id,
# transcript_path, cwd and source (startup/resume/clear/compact).
[ "$AM_CLI" = "codex" ] || exit 0
[ -n "$AM_ID" ] || exit 0
case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
[ -n "$AM_RUN_ID" ] || exit 0
case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac

# Usually the pane root is Codex's npm launcher and native Codex is its direct
# child. The resume compatibility command retains bash, making the node launcher
# a direct child and native Codex a grandchild. Accept that one known layer; a
# nested Codex has a tool shell above its launcher and cannot pass this check.
p=$$
trusted=0
codex_pid=0
hops=0
while [ "$p" -gt 1 ] 2>/dev/null && [ "$hops" -lt 64 ]; do
stat=$(cat "/proc/$p/stat" 2>/dev/null) || break
comm=${stat#*(}
comm=${comm%)*}
rest=${stat##*) }
rest=${rest#* }
ppid=${rest%% *}
case "$comm" in
codex*)
if [ "$p" = "$AM_PANE_PID" ] || [ "$ppid" = "$AM_PANE_PID" ]; then
trusted=1
else
parent_stat=$(cat "/proc/$ppid/stat" 2>/dev/null) || parent_stat=
parent_comm=${parent_stat#*(}
parent_comm=${parent_comm%)*}
parent_rest=${parent_stat##*) }
parent_rest=${parent_rest#* }
grandparent=${parent_rest%% *}
if [ "$parent_comm" = "node" ] && [ "$grandparent" = "$AM_PANE_PID" ]; then trusted=1; fi
fi
codex_pid=$p
break
;;
esac
p=$ppid
hops=$((hops + 1))
done
[ "$trusted" -eq 1 ] || exit 0

d="${AM_REPIN_DIR:-/tmp/am-repin}"
mkdir -p "$d" 2>/dev/null || exit 0
{
printf '{"amId":"%s","runId":"%s","cli":"codex","codexPid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$codex_pid"
cat
printf '}'
} > "$d/$AM_ID.codex.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.codex.json.$$.tmp" "$d/$AM_ID.codex.json"
exit 0
56 changes: 56 additions & 0 deletions scripts/am-opencode-repin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const SAFE = /^[A-Za-z0-9_-]+$/;

function report(sessionID, cwd, source) {
const amId = process.env.AM_ID;
const runId = process.env.AM_RUN_ID;
if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return;
// The global plugin is also loaded by nested OpenCode processes. Only the
// process that replaced the PTY's login shell owns this pane.
if (String(process.pid) !== process.env.AM_PANE_PID) return;
if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return;
const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin');
const file = path.join(dir, `${amId}.opencode.json`);
const tmp = `${file}.${process.pid}.tmp`;
try {
// OpenCode dispatches generic event hooks without awaiting their Promise.
// Keep this tiny local write synchronous so /clear followed immediately by
// quit cannot terminate the process between mkdir/write/rename.
mkdirSync(dir, { recursive: true });
writeFileSync(tmp, JSON.stringify({
amId,
runId,
cli: 'opencode',
pluginPid: process.pid,
payload: { session_id: sessionID, cwd, source },
}));
renameSync(tmp, file);
} catch { /* telemetry must never interfere with the user's prompt */ }
}

// OpenCode creates a new root session for /new (alias /clear). chat.message
// additionally follows an explicit switch to an existing session; runner.js
// verifies that id against the database and rejects child/subagent sessions.
export const AgentManagerRepin = async ({ directory }) => ({
event: async ({ event }) => {
if (event?.type !== 'session.created') return;
const info = event.properties?.info;
if (!info?.id || info.parentID) return;
report(info.id, info.directory || directory, 'session.created');
},
'chat.message': async ({ sessionID }) => {
report(sessionID, directory, 'chat.message');
},
// Tool shells must not pass the pane's private attribution markers to an
// agent launched inside them. Empty values override OpenCode's process.env
// merge and make the nested plugin a no-op.
'shell.env': async (_input, output) => {
output.env.AM_ID = '';
output.env.AM_RUN_ID = '';
output.env.AM_CLI = '';
output.env.AM_PANE_PID = '';
},
});
28 changes: 19 additions & 9 deletions scripts/am-repin-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,40 @@
# SessionStart breadcrumb for the manager's conversation re-pin (runner.js).
#
# Claude Code runs this inside the pane's process tree, so $AM_ID — set by the
# manager on the tmux session — says WHICH pane the new conversation belongs
# manager on the PTY — says WHICH pane the new conversation belongs
# to. That attribution is the one thing the server cannot work out on its own
# when several claude panes share a folder, and it is why a /clear there could
# not be followed before (the folderIsShared refusal in runner.js).
#
# stdin is the hook payload: {session_id, transcript_path, cwd, source, ...}.
# $CLAUDE_PID is the claude process that fired the event; the server verifies
# it descends from the pane before trusting the breadcrumb, because nested
# runs (`claude -p` from inside a pane) inherit $AM_ID and would otherwise
# claim the pane with a throwaway conversation. The entrypoint check below
# already drops those non-interactive runs; the pid check covers the rest.
# $CLAUDE_PID is the claude process that fired the event. Only the pane root or
# its direct child is the managed interactive Claude; a nested Claude started
# by a tool is deeper in the process tree. Filter it here so it cannot overwrite
# the top-level crumb, then runner.js independently repeats the same check.
#
# Breadcrumbs live on LOCAL disk on purpose: losing them at a restart is
# harmless (the pin itself persists in sessions.json), and the relaunch's own
# source:"resume" event immediately writes a fresh one.
[ -n "$AM_ID" ] || exit 0
case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
[ -n "$AM_RUN_ID" ] || exit 0
case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
[ "$AM_CLI" = "claude" ] || exit 0
[ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0
case "$CLAUDE_PID" in '' | *[!0-9]*) CLAUDE_PID=0 ;; esac
case "$CLAUDE_PID" in '' | *[!0-9]*) exit 0 ;; esac
case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac
if [ "$CLAUDE_PID" != "$AM_PANE_PID" ]; then
stat=$(cat "/proc/$CLAUDE_PID/stat" 2>/dev/null) || exit 0
rest=${stat##*) }
rest=${rest#* }
ppid=${rest%% *}
[ "$ppid" = "$AM_PANE_PID" ] || exit 0
fi
d="${AM_REPIN_DIR:-/tmp/am-repin}"
mkdir -p "$d" 2>/dev/null || exit 0
{
printf '{"amId":"%s","claudePid":%d,"payload":' "$AM_ID" "$CLAUDE_PID"
printf '{"amId":"%s","runId":"%s","cli":"claude","claudePid":%d,"payload":' "$AM_ID" "$AM_RUN_ID" "$CLAUDE_PID"
cat
printf '}'
} > "$d/$AM_ID.json.tmp" 2>/dev/null && mv -f "$d/$AM_ID.json.tmp" "$d/$AM_ID.json"
} > "$d/$AM_ID.claude.json.$$.tmp" 2>/dev/null && mv -f "$d/$AM_ID.claude.json.$$.tmp" "$d/$AM_ID.claude.json"
exit 0
15 changes: 11 additions & 4 deletions server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import * as store from './sessions.js';
import * as groups from './groups.js';
import * as order from './order.js';
import * as demo from './demo.js';
import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
import {
attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning,
capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook, installOpencodeRepinPlugin,
} from './runner.js';

// Control frames ride the terminal socket behind a leading NUL pair, which real
// PTY output never begins with. Same sentinel the old copy-mode hint used, so the
Expand All @@ -38,10 +41,14 @@ store.init();
groups.init();
order.init();
demo.init();
// Claude panes report conversation resets (e.g. /clear) through a SessionStart
// hook, so the re-pin watcher can follow them even in shared folders where the
// transcript scan must refuse to guess. Non-fatal if it can't be installed.
// Lifecycle adapters report conversation resets (e.g. /clear) with the exact
// id, so re-pin watchers can follow them even in shared folders where storage
// discovery must refuse to guess. Both installers are non-fatal; the existing
// fallback remains available if either cannot be installed.
installClaudeRepinHook();
// OpenCode's global plugin reports the root session chosen by /new (/clear),
// and the next prompt after switching to an existing session.
installOpencodeRepinPlugin();

// One-time migration to the explicit-path model: sessions used to own a folder
// named after them (renamed along with them), or inherit their group's shared
Expand Down
Loading