Spaces:
Running
Sessions: let the pane say which conversation it is on (#23)
Browse filesPR #17 taught the re-pin watcher to follow /clear onto the successor
conversation, but it deliberately refuses in a folder shared by several live
claude sessions — a new transcript there cannot be attributed to a pane by
scanning. Folders like a busy project workspace ALWAYS have rivals, so /clear
there was never followed and a restart still resumed the pre-/clear thread.
Observed live: a pane /cleared on Aug 3, its successor absorbed a day of work,
and the Aug 4 restart brought back the abandoned thread and orphaned the rest.
Scanning cannot attribute, but the pane itself knows. A SessionStart hook
(scripts/am-repin-hook.sh, registered idempotently into settings.json at boot)
runs inside the pane's process tree, where $AM_ID names the pane and the
payload carries the new conversation's id. It drops a breadcrumb on local
disk; the watcher consumes it and re-pins with nothing to guess. The
shared-folder refusal now applies only to the scan fallback, which stays for
panes without a breadcrumb and for codex/opencode.
Breadcrumbs are trusted only after checks, because SessionStart fires more
widely than the docs admit (verified on 2.1.220 — print mode fires it too,
contradicting the documentation):
- $CLAUDE_PID must descend from the pane's tmux root. A nested `claude -p`
run inside a pane inherits $AM_ID and would otherwise claim the pane with a
throwaway conversation — worse than the bug. The hook's
CLAUDE_CODE_ENTRYPOINT=cli filter drops most of these; the pid check is the
backstop.
- The payload cwd must be the pane's folder, the id must not be pinned by
another session, and a no-matcher registration means source:"startup" also
replaces the "--session-id not honoured" heuristic with a fact, while
source:"resume" provably reports the unchanged id and stays a no-op.
Consumed-on-read so a stale crumb can never flip a pin backwards past a later
scan re-pin. Losing breadcrumbs at restart is harmless: the pin persists in
sessions.json and the relaunch's own resume event writes a fresh crumb —
which incidentally verifies the whole chain at every restart. The installer
merges into settings.json (never replaces — it also holds permissions and
model), is idempotent across boots, and refuses to touch a file it cannot
parse. Verified against a live config dir: adding hooks to an already-trusted
dir shows no trust prompt.
Co-authored-by: Claude <noreply@anthropic.com>
- scripts/am-repin-hook.sh +31 -0
- server/src/index.js +5 -1
- server/src/runner.js +131 -2
- server/test/repin.test.mjs +50 -0
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 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
|
|
@@ -16,7 +16,7 @@ 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 } 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
|
|
@@ -37,6 +37,10 @@ store.init();
|
|
| 37 |
groups.init();
|
| 38 |
order.init();
|
| 39 |
demo.init();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
// One-time migration to the explicit-path model: sessions used to own a folder
|
| 42 |
// 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 { 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
|
|
|
|
| 37 |
groups.init();
|
| 38 |
order.init();
|
| 39 |
demo.init();
|
| 40 |
+
// Claude panes report conversation resets (e.g. /clear) through a SessionStart
|
| 41 |
+
// hook, so the re-pin watcher can follow them even in shared folders where the
|
| 42 |
+
// transcript scan must refuse to guess. Non-fatal if it can't be installed.
|
| 43 |
+
installClaudeRepinHook();
|
| 44 |
|
| 45 |
// One-time migration to the explicit-path model: sessions used to own a folder
|
| 46 |
// named after them (renamed along with them), or inherit their group's shared
|
|
@@ -944,6 +944,111 @@ function folderIsShared(sessionId, workdir, cli) {
|
|
| 944 |
// session forward onto whatever conversation Claude is actually writing. One
|
| 945 |
// mechanism now covers both failure modes: the launch-time fallback (pin never
|
| 946 |
// honoured) and a /clear at any later point.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 947 |
function scheduleClaudeCapture(session, workdir) {
|
| 948 |
// A relaunch restarts the watch with a fresh window, so `since` can't drift
|
| 949 |
// older and start admitting pre-relaunch threads as candidates.
|
|
@@ -954,13 +1059,37 @@ function scheduleClaudeCapture(session, workdir) {
|
|
| 954 |
|
| 955 |
const tick = () => {
|
| 956 |
if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
if (folderIsShared(session.id, workdir, 'claude')) {
|
| 958 |
if (!warnedShared) {
|
| 959 |
warnedShared = true;
|
| 960 |
-
console.warn(`[claude] ${session.id}: folder shared with another live session —
|
| 961 |
}
|
| 962 |
} else {
|
| 963 |
-
const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
|
| 964 |
const hit = claudeCandidate(session.id, workdir, since);
|
| 965 |
if (hit && hit.uuid !== pinned) {
|
| 966 |
const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
|
|
|
|
| 944 |
// session forward onto whatever conversation Claude is actually writing. One
|
| 945 |
// mechanism now covers both failure modes: the launch-time fallback (pin never
|
| 946 |
// honoured) and a /clear at any later point.
|
| 947 |
+
|
| 948 |
+
// ---------- breadcrumbs: the pane tells us, so we don't have to guess ----------
|
| 949 |
+
// The transcript scan above cannot attribute a new conversation when several
|
| 950 |
+
// live claude panes share a folder — folderIsShared refuses, and a /clear in
|
| 951 |
+
// such a folder was never followed. But the pane itself KNOWS: a SessionStart
|
| 952 |
+
// hook (installed into settings.json below, script at scripts/am-repin-hook.sh)
|
| 953 |
+
// runs inside the pane's process tree, where $AM_ID names the pane and the
|
| 954 |
+
// payload carries the new conversation's id. It drops that as a breadcrumb
|
| 955 |
+
// here; the watcher consumes it and re-pins with no guessing at all. The scan
|
| 956 |
+
// stays as the fallback for panes without a breadcrumb (hook newly installed,
|
| 957 |
+
// crumb lost) — and for codex/opencode, which have no hook mechanism.
|
| 958 |
+
const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin';
|
| 959 |
+
|
| 960 |
+
// Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb
|
| 961 |
+
// can never flip a pin backwards after a later, scan-based re-pin.
|
| 962 |
+
function takeClaudeBreadcrumb(sessionId) {
|
| 963 |
+
const p = path.join(REPIN_DIR, `${sessionId}.json`);
|
| 964 |
+
let raw;
|
| 965 |
+
try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; }
|
| 966 |
+
try { fs.unlinkSync(p); } catch {}
|
| 967 |
+
try { return JSON.parse(raw); } catch { return null; }
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
// The pane's root process (what tmux spawned). After `exec claude` this IS
|
| 971 |
+
// claude; in the `claude --session-id … || exec claude` branch claude is a
|
| 972 |
+
// child of it. Either way the hook's $CLAUDE_PID must descend from it.
|
| 973 |
+
function paneRootPid(sessionId) {
|
| 974 |
+
try {
|
| 975 |
+
const out = execFileSync('tmux', ['list-panes', '-t', tmuxName(sessionId), '-F', '#{pane_pid}'],
|
| 976 |
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
|
| 977 |
+
const pid = parseInt(out.trim().split('\n')[0], 10);
|
| 978 |
+
return Number.isInteger(pid) && pid > 1 ? pid : null;
|
| 979 |
+
} catch { return null; }
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
// Walk /proc ppid links. comm in /proc/<pid>/stat may contain spaces and
|
| 983 |
+
// parens, so split after the LAST ') '.
|
| 984 |
+
function pidHasAncestor(pid, ancestor, readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) {
|
| 985 |
+
for (let p = pid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) {
|
| 986 |
+
if (p === ancestor) return true;
|
| 987 |
+
let stat;
|
| 988 |
+
try { stat = readStat(p); } catch { return false; }
|
| 989 |
+
const tail = stat.slice(stat.lastIndexOf(') ') + 2).split(' ');
|
| 990 |
+
p = parseInt(tail[1], 10); // state ppid …
|
| 991 |
+
}
|
| 992 |
+
return false;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
// Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs.
|
| 996 |
+
// `facts` carries everything environmental: { workdir, pinned, claimed (Set of
|
| 997 |
+
// uuids other sessions pin), pidTrusted (bool: claudePid descends from the
|
| 998 |
+
// pane) }. Returns { repin: uuid } or { repin: null, why }.
|
| 999 |
+
export function breadcrumbVerdict(crumb, sessionId, facts) {
|
| 1000 |
+
if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' };
|
| 1001 |
+
if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' };
|
| 1002 |
+
const uuid = crumb.payload?.session_id;
|
| 1003 |
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid || ''))
|
| 1004 |
+
return { repin: null, why: 'no session_id' };
|
| 1005 |
+
// A crumb written before a pane was moved to another folder must not follow
|
| 1006 |
+
// it there — same folder-scoping rule the transcript scan applies.
|
| 1007 |
+
if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' };
|
| 1008 |
+
// Nested `claude -p` runs inherit $AM_ID and fire SessionStart too (verified
|
| 1009 |
+
// on 2.1.220 — the docs say -p skips hooks; it does not). The hook filters
|
| 1010 |
+
// on CLAUDE_CODE_ENTRYPOINT, and this is the backstop: only a process that
|
| 1011 |
+
// descends from the pane speaks for the pane.
|
| 1012 |
+
if (!facts.pidTrusted) return { repin: null, why: 'pid not in pane' };
|
| 1013 |
+
if (facts.claimed?.has(uuid)) return { repin: null, why: 'claimed by another session' };
|
| 1014 |
+
if (uuid === facts.pinned) return { repin: null, why: 'already pinned' };
|
| 1015 |
+
return { repin: uuid };
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
// Register the SessionStart hook in $CLAUDE_CONFIG_DIR/settings.json. Merge,
|
| 1019 |
+
// never replace: the file also holds permissions/model/theme. Idempotent — a
|
| 1020 |
+
// second boot finds the entry and writes nothing. A file that exists but does
|
| 1021 |
+
// not parse is left alone (clobbering the user's settings to install a hook
|
| 1022 |
+
// would be a terrible trade), and every failure is non-fatal: without the
|
| 1023 |
+
// hook the watcher simply keeps today's behaviour.
|
| 1024 |
+
export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh') {
|
| 1025 |
+
const dir = process.env.CLAUDE_CONFIG_DIR;
|
| 1026 |
+
if (!dir) return false;
|
| 1027 |
+
const file = path.join(dir, 'settings.json');
|
| 1028 |
+
let cfg = {};
|
| 1029 |
+
try {
|
| 1030 |
+
cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
| 1031 |
+
} catch (e) {
|
| 1032 |
+
if (e.code !== 'ENOENT') { console.warn(`[claude] not installing repin hook: ${file} unreadable (${e.message})`); return false; }
|
| 1033 |
+
}
|
| 1034 |
+
if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) { console.warn(`[claude] not installing repin hook: ${file} is not an object`); return false; }
|
| 1035 |
+
const entries = Array.isArray(cfg.hooks?.SessionStart) ? cfg.hooks.SessionStart : [];
|
| 1036 |
+
const present = entries.some((m) => (m?.hooks || []).some((h) => String(h?.command || '').includes('am-repin-hook.sh')));
|
| 1037 |
+
if (present) return true;
|
| 1038 |
+
// No matcher: fire for every source. `startup` replaces the "--session-id
|
| 1039 |
+
// not honoured" heuristic with a fact, `resume` is a proven no-op (same id),
|
| 1040 |
+
// and `clear` is the case this exists for. Filtering happens server-side.
|
| 1041 |
+
cfg.hooks = cfg.hooks || {};
|
| 1042 |
+
cfg.hooks.SessionStart = [...entries, { hooks: [{ type: 'command', command: hookCmd, timeout: 5 }] }];
|
| 1043 |
+
try {
|
| 1044 |
+
const tmp = `${file}.am-tmp`;
|
| 1045 |
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
|
| 1046 |
+
fs.renameSync(tmp, file);
|
| 1047 |
+
console.warn(`[claude] repin hook installed in ${file}`);
|
| 1048 |
+
return true;
|
| 1049 |
+
} catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
function scheduleClaudeCapture(session, workdir) {
|
| 1053 |
// A relaunch restarts the watch with a fresh window, so `since` can't drift
|
| 1054 |
// older and start admitting pre-relaunch threads as candidates.
|
|
|
|
| 1059 |
|
| 1060 |
const tick = () => {
|
| 1061 |
if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
|
| 1062 |
+
const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
|
| 1063 |
+
|
| 1064 |
+
// Breadcrumb first: the pane's own SessionStart hook told us which
|
| 1065 |
+
// conversation it is on, so no guessing — and no shared-folder refusal —
|
| 1066 |
+
// is needed. Consumed on read; a rejected crumb falls through to the scan.
|
| 1067 |
+
const crumb = takeClaudeBreadcrumb(session.id);
|
| 1068 |
+
if (crumb) {
|
| 1069 |
+
const root = paneRootPid(session.id);
|
| 1070 |
+
const verdict = breadcrumbVerdict(crumb, session.id, {
|
| 1071 |
+
workdir,
|
| 1072 |
+
pinned,
|
| 1073 |
+
claimed: new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid)),
|
| 1074 |
+
pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root),
|
| 1075 |
+
});
|
| 1076 |
+
if (verdict.repin) {
|
| 1077 |
+
console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
|
| 1078 |
+
update(session.id, { sessionUuid: verdict.repin });
|
| 1079 |
+
const t = setTimeout(tick, REPIN_MS);
|
| 1080 |
+
if (t.unref) t.unref();
|
| 1081 |
+
claudeCapturing.set(session.id, t);
|
| 1082 |
+
return;
|
| 1083 |
+
}
|
| 1084 |
+
if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`);
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
if (folderIsShared(session.id, workdir, 'claude')) {
|
| 1088 |
if (!warnedShared) {
|
| 1089 |
warnedShared = true;
|
| 1090 |
+
console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
|
| 1091 |
}
|
| 1092 |
} else {
|
|
|
|
| 1093 |
const hit = claudeCandidate(session.id, workdir, since);
|
| 1094 |
if (hit && hit.uuid !== pinned) {
|
| 1095 |
const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
|
|
@@ -76,6 +76,56 @@ check('claimed uuid skipped', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid
|
|
| 76 |
console.log('\nnothing new in the window means no re-pin');
|
| 77 |
check('no candidate', runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
console.log(`\n${pass} passed, ${fail} failed`);
|
| 80 |
fs.rmSync(TMP, { recursive: true, force: true });
|
| 81 |
process.exit(fail ? 1 : 0);
|
|
|
|
| 76 |
console.log('\nnothing new in the window means no re-pin');
|
| 77 |
check('no candidate', runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
|
| 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 |
+
|
| 91 |
+
console.log('\na trusted breadcrumb re-pins, no folder-sharing question asked');
|
| 92 |
+
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',
|
| 105 |
+
verdict(crumb({ payload: { session_id: A, cwd: WORKDIR, source: 'resume' } }), facts()).why, 'already pinned');
|
| 106 |
+
check('claimed uuid left to its session',
|
| 107 |
+
verdict(crumb({ payload: { session_id: B, cwd: WORKDIR, source: 'clear' } }), facts()).why, 'claimed by another session');
|
| 108 |
+
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 |
+
// ---------- hook installer: merge, never replace; idempotent; refuse corrupt ----------
|
| 113 |
+
console.log('\ninstaller merges into existing settings and is idempotent');
|
| 114 |
+
const settings = path.join(CFG, 'settings.json');
|
| 115 |
+
fs.writeFileSync(settings, JSON.stringify({ model: 'opus', permissions: { allow: ['Bash'] } }));
|
| 116 |
+
check('installs into existing file', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
|
| 117 |
+
let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
|
| 118 |
+
check('other keys kept', s.model, 'opus');
|
| 119 |
+
check('hook entry present', s.hooks.SessionStart.length, 1);
|
| 120 |
+
check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
|
| 121 |
+
s = JSON.parse(fs.readFileSync(settings, 'utf8'));
|
| 122 |
+
check('no duplicate entry', s.hooks.SessionStart.length, 1);
|
| 123 |
+
|
| 124 |
+
console.log('\ninstaller refuses to clobber a corrupt settings file');
|
| 125 |
+
fs.writeFileSync(settings, '{ not json');
|
| 126 |
+
check('corrupt file left alone', runner.installClaudeRepinHook('/x.sh'), false);
|
| 127 |
+
check('corrupt content untouched', fs.readFileSync(settings, 'utf8'), '{ not json');
|
| 128 |
+
|
| 129 |
console.log(`\n${pass} passed, ${fail} failed`);
|
| 130 |
fs.rmSync(TMP, { recursive: true, force: true });
|
| 131 |
process.exit(fail ? 1 : 0);
|