@integrity-labs/agt-cli 0.28.1067 → 0.28.1069

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/bin/agt.js +5 -5
  2. package/dist/{chunk-PN7B4DLS.js → chunk-CFJONCHN.js} +11 -1
  3. package/dist/{chunk-PN7B4DLS.js.map → chunk-CFJONCHN.js.map} +1 -1
  4. package/dist/{chunk-NZF55T6D.js → chunk-CO2ODT4I.js} +11 -5
  5. package/dist/{chunk-NZF55T6D.js.map → chunk-CO2ODT4I.js.map} +1 -1
  6. package/dist/{chunk-OW6XCSOU.js → chunk-FHUYRSCF.js} +101 -11
  7. package/dist/chunk-FHUYRSCF.js.map +1 -0
  8. package/dist/{claude-pair-runtime-NCDM5KHM.js → claude-pair-runtime-Z7SMBMUG.js} +2 -2
  9. package/dist/lib/manager-worker.js +361 -36
  10. package/dist/lib/manager-worker.js.map +1 -1
  11. package/dist/mcp/direct-chat-channel.js +10 -0
  12. package/dist/mcp/index.js +10 -0
  13. package/dist/mcp/origami.js +10 -0
  14. package/dist/mcp/slack-channel.js +10 -0
  15. package/dist/mcp/telegram-channel.js +10 -0
  16. package/dist/pane-fatal-error-EL336LRO.js +127 -0
  17. package/dist/pane-fatal-error-EL336LRO.js.map +1 -0
  18. package/dist/{persistent-session-ELDC5JFR.js → persistent-session-52A2G7D7.js} +9 -3
  19. package/dist/{responsiveness-probe-HUUU2MFX.js → responsiveness-probe-SJCP4V4X.js} +3 -3
  20. package/dist/{session-auth-dead-BRMWKA3Y.js → session-auth-dead-JJPNV3YQ.js} +2 -2
  21. package/dist/transcript-mount-health-OHE6IVJV.js +79 -0
  22. package/dist/transcript-mount-health-OHE6IVJV.js.map +1 -0
  23. package/package.json +1 -1
  24. package/dist/chunk-OW6XCSOU.js.map +0 -1
  25. /package/dist/{claude-pair-runtime-NCDM5KHM.js.map → claude-pair-runtime-Z7SMBMUG.js.map} +0 -0
  26. /package/dist/{persistent-session-ELDC5JFR.js.map → persistent-session-52A2G7D7.js.map} +0 -0
  27. /package/dist/{responsiveness-probe-HUUU2MFX.js.map → responsiveness-probe-SJCP4V4X.js.map} +0 -0
  28. /package/dist/{session-auth-dead-BRMWKA3Y.js.map → session-auth-dead-JJPNV3YQ.js.map} +0 -0
@@ -38063,6 +38063,16 @@ var FLAG_REGISTRY = [
38063
38063
  // blocks customer email - so not `sensitive`, unlike the email-guard enable gates above.
38064
38064
  sensitive: false
38065
38065
  },
38066
+ {
38067
+ key: "approval-core-manager-console",
38068
+ description: "Console approval authority from the routed approver set (ENG-11206, ADR-0124 Phase 0a). When ON for an org, the Approvals page also lets a non-admin decide a request if they are in its resolved approver set: the approval_assignments rows, else the legacy routed_to JSON, else the rule 6 fallback (the agent's manager). The main effect: an agent's manager can decide its integration and broker approvals in the console, which non-admins cannot do today. Owners and admins are unaffected. OFF (default) keeps the console exactly as before: owners/admins, plus the person named in routed_to.person_id. Evaluated per-org. Ships dark.",
38069
+ flagType: "boolean",
38070
+ // Safe/default value is `false` (today's console authority). A flag-DB read error degrades to
38071
+ // this, so an outage can only narrow who may decide, never widen it.
38072
+ defaultValue: false,
38073
+ // It widens who may DECIDE an approval, so it is a control, not a display toggle.
38074
+ sensitive: true
38075
+ },
38066
38076
  {
38067
38077
  key: "slack-reply-binding",
38068
38078
  description: "Slack reply-target binding (ENG-7396 / WS2, cross-thread reply routing): the model names which inbound it answers (inbound_id) and the server resolves the destination from its own verified record. shadow = classify + count only, routing unchanged; warn = same routing plus a tool-result note so the model self-corrects; enforce = route a bound reply to the registry thread and REJECT a reply whose target matches no inbound this process delivered. The channel server can't evaluate flags, so the manager materializes the resolved value into the AGT_SLACK_REPLY_BINDING spawn env (the operator/canary override, ADR-0022). Ships shadow.",
package/dist/mcp/index.js CHANGED
@@ -30281,6 +30281,16 @@ var FLAG_REGISTRY = [
30281
30281
  // blocks customer email - so not `sensitive`, unlike the email-guard enable gates above.
30282
30282
  sensitive: false
30283
30283
  },
30284
+ {
30285
+ key: "approval-core-manager-console",
30286
+ description: "Console approval authority from the routed approver set (ENG-11206, ADR-0124 Phase 0a). When ON for an org, the Approvals page also lets a non-admin decide a request if they are in its resolved approver set: the approval_assignments rows, else the legacy routed_to JSON, else the rule 6 fallback (the agent's manager). The main effect: an agent's manager can decide its integration and broker approvals in the console, which non-admins cannot do today. Owners and admins are unaffected. OFF (default) keeps the console exactly as before: owners/admins, plus the person named in routed_to.person_id. Evaluated per-org. Ships dark.",
30287
+ flagType: "boolean",
30288
+ // Safe/default value is `false` (today's console authority). A flag-DB read error degrades to
30289
+ // this, so an outage can only narrow who may decide, never widen it.
30290
+ defaultValue: false,
30291
+ // It widens who may DECIDE an approval, so it is a control, not a display toggle.
30292
+ sensitive: true
30293
+ },
30284
30294
  {
30285
30295
  key: "slack-reply-binding",
30286
30296
  description: "Slack reply-target binding (ENG-7396 / WS2, cross-thread reply routing): the model names which inbound it answers (inbound_id) and the server resolves the destination from its own verified record. shadow = classify + count only, routing unchanged; warn = same routing plus a tool-result note so the model self-corrects; enforce = route a bound reply to the registry thread and REJECT a reply whose target matches no inbound this process delivered. The channel server can't evaluate flags, so the manager materializes the resolved value into the AGT_SLACK_REPLY_BINDING spawn env (the operator/canary override, ADR-0022). Ships shadow.",
@@ -44204,6 +44204,16 @@ var FLAG_REGISTRY = [
44204
44204
  // blocks customer email - so not `sensitive`, unlike the email-guard enable gates above.
44205
44205
  sensitive: false
44206
44206
  },
44207
+ {
44208
+ key: "approval-core-manager-console",
44209
+ description: "Console approval authority from the routed approver set (ENG-11206, ADR-0124 Phase 0a). When ON for an org, the Approvals page also lets a non-admin decide a request if they are in its resolved approver set: the approval_assignments rows, else the legacy routed_to JSON, else the rule 6 fallback (the agent's manager). The main effect: an agent's manager can decide its integration and broker approvals in the console, which non-admins cannot do today. Owners and admins are unaffected. OFF (default) keeps the console exactly as before: owners/admins, plus the person named in routed_to.person_id. Evaluated per-org. Ships dark.",
44210
+ flagType: "boolean",
44211
+ // Safe/default value is `false` (today's console authority). A flag-DB read error degrades to
44212
+ // this, so an outage can only narrow who may decide, never widen it.
44213
+ defaultValue: false,
44214
+ // It widens who may DECIDE an approval, so it is a control, not a display toggle.
44215
+ sensitive: true
44216
+ },
44207
44217
  {
44208
44218
  key: "slack-reply-binding",
44209
44219
  description: "Slack reply-target binding (ENG-7396 / WS2, cross-thread reply routing): the model names which inbound it answers (inbound_id) and the server resolves the destination from its own verified record. shadow = classify + count only, routing unchanged; warn = same routing plus a tool-result note so the model self-corrects; enforce = route a bound reply to the registry thread and REJECT a reply whose target matches no inbound this process delivered. The channel server can't evaluate flags, so the manager materializes the resolved value into the AGT_SLACK_REPLY_BINDING spawn env (the operator/canary override, ADR-0022). Ships shadow.",
@@ -38834,6 +38834,16 @@ var FLAG_REGISTRY = [
38834
38834
  // blocks customer email - so not `sensitive`, unlike the email-guard enable gates above.
38835
38835
  sensitive: false
38836
38836
  },
38837
+ {
38838
+ key: "approval-core-manager-console",
38839
+ description: "Console approval authority from the routed approver set (ENG-11206, ADR-0124 Phase 0a). When ON for an org, the Approvals page also lets a non-admin decide a request if they are in its resolved approver set: the approval_assignments rows, else the legacy routed_to JSON, else the rule 6 fallback (the agent's manager). The main effect: an agent's manager can decide its integration and broker approvals in the console, which non-admins cannot do today. Owners and admins are unaffected. OFF (default) keeps the console exactly as before: owners/admins, plus the person named in routed_to.person_id. Evaluated per-org. Ships dark.",
38840
+ flagType: "boolean",
38841
+ // Safe/default value is `false` (today's console authority). A flag-DB read error degrades to
38842
+ // this, so an outage can only narrow who may decide, never widen it.
38843
+ defaultValue: false,
38844
+ // It widens who may DECIDE an approval, so it is a control, not a display toggle.
38845
+ sensitive: true
38846
+ },
38837
38847
  {
38838
38848
  key: "slack-reply-binding",
38839
38849
  description: "Slack reply-target binding (ENG-7396 / WS2, cross-thread reply routing): the model names which inbound it answers (inbound_id) and the server resolves the destination from its own verified record. shadow = classify + count only, routing unchanged; warn = same routing plus a tool-result note so the model self-corrects; enforce = route a bound reply to the registry thread and REJECT a reply whose target matches no inbound this process delivered. The channel server can't evaluate flags, so the manager materializes the resolved value into the AGT_SLACK_REPLY_BINDING spawn env (the operator/canary override, ADR-0022). Ships shadow.",
@@ -38940,6 +38940,16 @@ var FLAG_REGISTRY = [
38940
38940
  // blocks customer email - so not `sensitive`, unlike the email-guard enable gates above.
38941
38941
  sensitive: false
38942
38942
  },
38943
+ {
38944
+ key: "approval-core-manager-console",
38945
+ description: "Console approval authority from the routed approver set (ENG-11206, ADR-0124 Phase 0a). When ON for an org, the Approvals page also lets a non-admin decide a request if they are in its resolved approver set: the approval_assignments rows, else the legacy routed_to JSON, else the rule 6 fallback (the agent's manager). The main effect: an agent's manager can decide its integration and broker approvals in the console, which non-admins cannot do today. Owners and admins are unaffected. OFF (default) keeps the console exactly as before: owners/admins, plus the person named in routed_to.person_id. Evaluated per-org. Ships dark.",
38946
+ flagType: "boolean",
38947
+ // Safe/default value is `false` (today's console authority). A flag-DB read error degrades to
38948
+ // this, so an outage can only narrow who may decide, never widen it.
38949
+ defaultValue: false,
38950
+ // It widens who may DECIDE an approval, so it is a control, not a display toggle.
38951
+ sensitive: true
38952
+ },
38943
38953
  {
38944
38954
  key: "slack-reply-binding",
38945
38955
  description: "Slack reply-target binding (ENG-7396 / WS2, cross-thread reply routing): the model names which inbound it answers (inbound_id) and the server resolves the destination from its own verified record. shadow = classify + count only, routing unchanged; warn = same routing plus a tool-result note so the model self-corrects; enforce = route a bound reply to the registry thread and REJECT a reply whose target matches no inbound this process delivered. The channel server can't evaluate flags, so the manager materializes the resolved value into the AGT_SLACK_REPLY_BINDING spawn env (the operator/canary override, ADR-0022). Ships shadow.",
@@ -0,0 +1,127 @@
1
+ import "./chunk-DGUM43GV.js";
2
+
3
+ // src/lib/pane-fatal-error.ts
4
+ function isRespawnKind(kind) {
5
+ return kind === "compaction-failed" || kind === "prompt-too-long";
6
+ }
7
+ var DEFAULTS = {
8
+ minCycles: 2,
9
+ maxRestartsInWindow: 3,
10
+ restartWindowMs: 6 * 60 * 60 * 1e3
11
+ };
12
+ var MIN_FATAL_TURNS = 2;
13
+ var RULE = /^\s*[─━]{8,}/;
14
+ var MODEL_HEAD = /^\s*⏺/;
15
+ var STATUS_HEAD = /^[✻✢✳✶✽✺✹·*]/;
16
+ var RESULT_LINE = /^\s+⎿\s*(.*)$/;
17
+ var COMPACTION_FAILED = /^(?:Prompt is too long · automatic compaction failed\b|Error during compaction\b)/;
18
+ var PROMPT_TOO_LONG = /^Prompt is too long\b.*\/clear to start fresh/;
19
+ var TRANSCRIPT_WRITES_FAILING = /Transcript writes are failing/;
20
+ function headType(line) {
21
+ if (MODEL_HEAD.test(line)) return "model";
22
+ if (line.length === 0 || /^\s/.test(line)) return null;
23
+ if (line.startsWith("\u2190")) return "turn";
24
+ if (line.startsWith("\u276F")) return line.slice(1).trim().length > 0 ? "turn" : "empty-prompt";
25
+ if (STATUS_HEAD.test(line)) return "status";
26
+ return "other";
27
+ }
28
+ function parseBlocks(lines) {
29
+ const blocks = [];
30
+ let turn = null;
31
+ for (const line of lines) {
32
+ const head = headType(line);
33
+ if (head === null) {
34
+ if (turn && !turn.sawContinuation && line.trim().length > 0) {
35
+ turn.sawContinuation = true;
36
+ const m = RESULT_LINE.exec(line);
37
+ turn.result = m ? m[1] : null;
38
+ }
39
+ continue;
40
+ }
41
+ turn = null;
42
+ if (head === "model") blocks.push({ type: "model" });
43
+ else if (head === "turn") {
44
+ turn = { type: "turn", result: null, sawContinuation: false };
45
+ blocks.push(turn);
46
+ } else blocks.push({ type: "neutral" });
47
+ }
48
+ return blocks;
49
+ }
50
+ function fatalKindOf(result) {
51
+ if (COMPACTION_FAILED.test(result)) return "compaction-failed";
52
+ if (PROMPT_TOO_LONG.test(result)) return "prompt-too-long";
53
+ return null;
54
+ }
55
+ function classifyPane(pane) {
56
+ const clean = { kind: null, fatalTurns: 0, transcriptBanner: false };
57
+ if (!pane) return clean;
58
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
59
+ const rules = [];
60
+ lines.forEach((l, i) => {
61
+ if (RULE.test(l)) rules.push(i);
62
+ });
63
+ const lastRule = rules.length > 0 ? rules[rules.length - 1] : -1;
64
+ const statusBar = lastRule >= 0 ? lines.slice(lastRule + 1) : [];
65
+ const transcriptEnd = rules.length >= 2 ? rules[rules.length - 2] : lastRule >= 0 ? lastRule : lines.length;
66
+ const transcriptBanner = statusBar.some((l) => TRANSCRIPT_WRITES_FAILING.test(l));
67
+ let fatalTurns = 0;
68
+ let lastFatal = null;
69
+ for (const block of parseBlocks(lines.slice(0, transcriptEnd))) {
70
+ if (block.type === "model") {
71
+ fatalTurns = 0;
72
+ lastFatal = null;
73
+ } else if (block.type === "turn" && block.result !== null) {
74
+ const kind2 = fatalKindOf(block.result);
75
+ if (kind2) {
76
+ fatalTurns += 1;
77
+ lastFatal = kind2;
78
+ } else {
79
+ fatalTurns = 0;
80
+ lastFatal = null;
81
+ }
82
+ }
83
+ }
84
+ const kind = fatalTurns >= MIN_FATAL_TURNS && lastFatal ? lastFatal : transcriptBanner ? "transcript-writes-failing" : null;
85
+ return { kind, fatalTurns, transcriptBanner };
86
+ }
87
+ function classifyPaneFatalError(pane) {
88
+ return classifyPane(pane).kind;
89
+ }
90
+ function parseMode(raw) {
91
+ const v = (raw ?? "").trim().toLowerCase();
92
+ if (v === "off" || v === "shadow" || v === "enforce") return v;
93
+ return "enforce";
94
+ }
95
+ function parseIntAtLeast(raw, fallback, floor) {
96
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
97
+ return Number.isInteger(n) && n >= floor ? n : fallback;
98
+ }
99
+ function resolvePaneFatalConfig(env = process.env) {
100
+ return {
101
+ mode: parseMode(env.AGT_PANE_FATAL_RESTART_MODE),
102
+ minCycles: parseIntAtLeast(env.AGT_PANE_FATAL_MIN_CYCLES, DEFAULTS.minCycles, 2),
103
+ maxRestartsInWindow: parseIntAtLeast(env.AGT_PANE_FATAL_MAX_RESTARTS, DEFAULTS.maxRestartsInWindow, 1),
104
+ restartWindowMs: parseIntAtLeast(env.AGT_PANE_FATAL_WINDOW_MINUTES, DEFAULTS.restartWindowMs / 6e4, 30) * 6e4
105
+ };
106
+ }
107
+ function decidePaneFatalAction(input, config) {
108
+ if (config.mode === "off" || input.kind === null) return "none";
109
+ if (input.consecutiveCycles < config.minCycles) return "none";
110
+ if (!isRespawnKind(input.kind)) return "warn";
111
+ if (config.mode === "shadow") return "would-restart";
112
+ const inWindow = input.recentRestartsMs.filter((t) => input.nowMs - t < config.restartWindowMs).length;
113
+ return inWindow >= config.maxRestartsInWindow ? "give-up" : "restart";
114
+ }
115
+ function pruneRestartHistory(history, nowMs, config) {
116
+ return history.filter((t) => nowMs - t < config.restartWindowMs);
117
+ }
118
+ export {
119
+ MIN_FATAL_TURNS,
120
+ classifyPane,
121
+ classifyPaneFatalError,
122
+ decidePaneFatalAction,
123
+ isRespawnKind,
124
+ pruneRestartHistory,
125
+ resolvePaneFatalConfig
126
+ };
127
+ //# sourceMappingURL=pane-fatal-error-EL336LRO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/pane-fatal-error.ts"],"sourcesContent":["/**\n * ENG-11278 — a session that answers every turn with a fatal error is not healthy.\n *\n * ## The incident\n *\n * Pedders host v8, 2026-10-02. marty sat for ~2h20m, and grace for hours, replying\n * to every inbound message with\n *\n * ⎿ Prompt is too long · automatic compaction failed: ENOENT: no such file or\n * directory, mkdir '/root/.claude/projects/-root--augmented-grace-project' ·\n * /clear to start fresh\n *\n * while manager.log said `decision=healthy … session_healthy_after=true` on every\n * poll. Nothing was going to change that: `isSessionHealthy` checks tmux + the\n * claude process (both fine), and the ENG-6153 wedge detector keys on UNDRAINED\n * inbound — but this session drains every message, it just answers each one with\n * the error. Its sibling condition, `⚠ Transcript writes are failing (ENOENT)`,\n * is drawn only in Claude Code's status bar (ENG-10957 found it by accident on\n * ada): no log line, no alert, health green.\n *\n * ## What this module decides\n *\n * Pure, no I/O, so the rules are unit-testable (same split as\n * `wedge-detection.ts`). The poll loop owns the pane capture, the per-agent\n * streak and the restart history, and performs the respawn.\n *\n * - {@link classifyPaneFatalError}: is the pane CURRENTLY showing a fatal,\n * self-perpetuating error? Returns a kind, never pane text.\n * - {@link decidePaneFatalAction}: given that verdict, the streak and the\n * recent restarts, what should the manager do this cycle?\n *\n * ## Reading the pane: blocks, not lines (Obi Wan 1436d0c3a9e5, d2dc0c460de5, 50a5cb896fad)\n *\n * The first version scanned every line for the fatal phrases and only excluded\n * `⏺` heads and `←` heads. That read the phrase anywhere else: on a continuation\n * line of the model's own reply, in a `⎿` tool result (a `Bash` that greps\n * manager.log), in a later line of an inbound message. Any of those could\n * force-respawn a working agent and page, and an untrusted sender could put\n * them there on purpose. So the pane is parsed the way Claude Code draws it.\n *\n * The capture is `tmux capture-pane -p -J` (wrapped lines joined), and splits\n * into three regions around the prompt box, whose rules (`────`) are always the\n * bottom of the screen:\n *\n * …transcript… <- turns, model output, status lines\n * ────────────── <- prompt box top\n * ❯ <what is being typed>\n * ────────────── <- prompt box bottom = the LAST rule on screen\n * ⏵⏵ bypass … · ⚠ Transcript writes are failing (ENOENT) <- status bar\n *\n * The transcript is a sequence of BLOCKS. A block head sits at column 0:\n * `⏺` model output (text or a tool call), `←` an inbound channel message, `❯`\n * a submitted prompt, a spinner glyph (`✻ Cogitated for 1m 29s · done 5:26 PM`)\n * for status. Everything indented under a head belongs to it, including the\n * `⎿` lines: a tool's output under its `⏺`, a tip under a spinner, and a turn's\n * RESULT under its `←`/`❯` head. Claude Code indents every continuation, and\n * `-J` stops a long line wrapping into a fake column-0 head, so a reply line\n * that happens to begin with `←` or `⎿` stays inside its `⏺` block.\n *\n * A genuine turn failure has exactly one shape — the turn's FIRST continuation\n * line is a `⎿` carrying Claude Code's error, attached directly to the inbound\n * or prompt that caused it (grace on v8, verbatim):\n *\n * ← telegram · 8837161157: Yes I think it’s best to start on standard…\n * ⎿ Prompt is too long · automatic compaction failed: ENOENT: … · /clear to start fresh\n *\n * Only that counts, and only when it is the state of the session NOW: at least\n * {@link MIN_FATAL_TURNS} consecutive turns ending that way with no model output\n * after the first of them (a wedged session fails EVERY turn; a recovered one\n * shows `⏺` after the old error). A single failed turn, a quoted phrase, or a\n * tool printing the log is never enough.\n *\n * The `Transcript writes are failing` banner is matched only in the status bar,\n * and on its own is NOT fatal: a session can show it and keep answering. It\n * yields `transcript-writes-failing`, which the decision maps to a warning\n * alert, never a respawn. Its root cause (an orphaned transcript bind) is\n * repaired by ENG-11277's mount probe.\n *\n * Hysteresis ({@link PaneFatalConfig.minCycles}) covers the remaining window, a\n * capture taken between an error and the model's next output.\n */\n\nexport type PaneFatalKind =\n /** `automatic compaction failed: …` or a manual `/compact` that errored. */\n | 'compaction-failed'\n /** `Prompt is too long · … · /clear to start fresh` without a compaction clause. */\n | 'prompt-too-long'\n /**\n * Claude Code's status-bar warning that the session transcript cannot be\n * written, with no failing turns. Warn-only: never a respawn on its own.\n */\n | 'transcript-writes-failing';\n\n/** Kinds that mean the session cannot answer at all, so a fresh respawn is the remedy. */\nexport function isRespawnKind(kind: PaneFatalKind | null): kind is 'compaction-failed' | 'prompt-too-long' {\n return kind === 'compaction-failed' || kind === 'prompt-too-long';\n}\n\nexport interface PaneFatalVerdict {\n /** Respawn kind when the session is failing every turn; else the banner kind; else null. */\n kind: PaneFatalKind | null;\n /** Consecutive most-recent turns whose result was a fatal error. */\n fatalTurns: number;\n /** `Transcript writes are failing` is in the status bar. */\n transcriptBanner: boolean;\n}\n\nexport type PaneFatalMode = 'off' | 'shadow' | 'enforce';\n\nexport interface PaneFatalConfig {\n mode: PaneFatalMode;\n /** Consecutive poll cycles a kind must be seen before acting. */\n minCycles: number;\n /** Max fatal-error respawns per agent inside {@link restartWindowMs}. */\n maxRestartsInWindow: number;\n /** Sliding window for {@link maxRestartsInWindow}. */\n restartWindowMs: number;\n}\n\nconst DEFAULTS = {\n minCycles: 2,\n maxRestartsInWindow: 3,\n restartWindowMs: 6 * 60 * 60 * 1000,\n} as const;\n\n/**\n * Consecutive failed turns required on one screen. Grace's pane shows the error\n * once per inbound; one failed turn is a bad turn, two in a row with nothing\n * from the model in between is a session that cannot answer.\n */\nexport const MIN_FATAL_TURNS = 2;\n\n/** A prompt-box rule. Claude Code draws it full width; 8 is a floor, not a guess at width. */\nconst RULE = /^\\s*[─━]{8,}/;\n/** `⏺` anywhere a line starts (indent tolerated): any model output resets the streak. */\nconst MODEL_HEAD = /^\\s*⏺/;\n/** Spinner / timing heads: `✻ Cogitated for 1m 29s · done 5:26 PM`, `* Churning…`. */\nconst STATUS_HEAD = /^[✻✢✳✶✽✺✹·*]/;\n/** A turn's result line: indented, then `⎿`. Captures the text after the glyph. */\nconst RESULT_LINE = /^\\s+⎿\\s*(.*)$/;\n/**\n * The error text Claude Code renders as a turn result, anchored at the START of\n * the `⎿` text so a result that merely contains the phrase does not match.\n */\nconst COMPACTION_FAILED = /^(?:Prompt is too long · automatic compaction failed\\b|Error during compaction\\b)/;\nconst PROMPT_TOO_LONG = /^Prompt is too long\\b.*\\/clear to start fresh/;\nconst TRANSCRIPT_WRITES_FAILING = /Transcript writes are failing/;\n\ntype Block =\n | { type: 'model' }\n | { type: 'turn'; result: string | null }\n | { type: 'neutral' };\n\nfunction headType(line: string): 'model' | 'turn' | 'empty-prompt' | 'status' | 'other' | null {\n if (MODEL_HEAD.test(line)) return 'model';\n if (line.length === 0 || /^\\s/.test(line)) return null; // continuation\n if (line.startsWith('←')) return 'turn';\n if (line.startsWith('❯')) return line.slice(1).trim().length > 0 ? 'turn' : 'empty-prompt';\n if (STATUS_HEAD.test(line)) return 'status';\n return 'other';\n}\n\nfunction parseBlocks(lines: readonly string[]): Block[] {\n const blocks: Block[] = [];\n let turn: { type: 'turn'; result: string | null; sawContinuation: boolean } | null = null;\n for (const line of lines) {\n const head = headType(line);\n if (head === null) {\n // Continuation of the current block. For a turn, only the FIRST non-blank\n // continuation line can be its result; anything after it is the message's\n // own text or later output, never Claude Code's verdict on the turn.\n if (turn && !turn.sawContinuation && line.trim().length > 0) {\n turn.sawContinuation = true;\n const m = RESULT_LINE.exec(line);\n turn.result = m ? m[1]! : null;\n }\n continue;\n }\n turn = null;\n if (head === 'model') blocks.push({ type: 'model' });\n else if (head === 'turn') {\n turn = { type: 'turn', result: null, sawContinuation: false };\n blocks.push(turn);\n } else blocks.push({ type: 'neutral' });\n }\n return blocks;\n}\n\nfunction fatalKindOf(result: string): 'compaction-failed' | 'prompt-too-long' | null {\n if (COMPACTION_FAILED.test(result)) return 'compaction-failed';\n if (PROMPT_TOO_LONG.test(result)) return 'prompt-too-long';\n return null;\n}\n\n/**\n * Parse a `tmux capture-pane -p -J` capture. Pure; never returns pane text.\n * An unreadable (null/empty) capture is \"can't tell\", which the caller treats\n * as healthy, never as a reason to respawn.\n */\nexport function classifyPane(pane: string | null | undefined): PaneFatalVerdict {\n const clean: PaneFatalVerdict = { kind: null, fatalTurns: 0, transcriptBanner: false };\n if (!pane) return clean;\n const lines = pane.split('\\n').map((l) => l.replace(/\\s+$/, ''));\n\n const rules: number[] = [];\n lines.forEach((l, i) => {\n if (RULE.test(l)) rules.push(i);\n });\n // No prompt box on screen: no status bar to read, so the banner fails closed.\n const lastRule = rules.length > 0 ? rules[rules.length - 1]! : -1;\n const statusBar = lastRule >= 0 ? lines.slice(lastRule + 1) : [];\n const transcriptEnd = rules.length >= 2 ? rules[rules.length - 2]! : lastRule >= 0 ? lastRule : lines.length;\n const transcriptBanner = statusBar.some((l) => TRANSCRIPT_WRITES_FAILING.test(l));\n\n let fatalTurns = 0;\n let lastFatal: 'compaction-failed' | 'prompt-too-long' | null = null;\n for (const block of parseBlocks(lines.slice(0, transcriptEnd))) {\n if (block.type === 'model') {\n fatalTurns = 0; // the model answered: whatever failed before is history\n lastFatal = null;\n } else if (block.type === 'turn' && block.result !== null) {\n const kind = fatalKindOf(block.result);\n if (kind) {\n fatalTurns += 1;\n lastFatal = kind;\n } else {\n fatalTurns = 0; // the turn produced a normal result (`/clear`, `/compact` ok)\n lastFatal = null;\n }\n }\n // A turn with no result yet is pending; neutral blocks say nothing.\n }\n\n const kind: PaneFatalKind | null =\n fatalTurns >= MIN_FATAL_TURNS && lastFatal\n ? lastFatal\n : transcriptBanner\n ? 'transcript-writes-failing'\n : null;\n return { kind, fatalTurns, transcriptBanner };\n}\n\n/** {@link classifyPane}'s kind alone. */\nexport function classifyPaneFatalError(pane: string | null | undefined): PaneFatalKind | null {\n return classifyPane(pane).kind;\n}\n\nfunction parseMode(raw: string | undefined): PaneFatalMode {\n const v = (raw ?? '').trim().toLowerCase();\n if (v === 'off' || v === 'shadow' || v === 'enforce') return v;\n // Default ENFORCE, unlike the ENG-6153 wedge detector's shadow default. That\n // detector infers a wedge from timing heuristics that can misread a long\n // turn; this one matches Claude Code's own fatal-error text, which no\n // working session displays as its latest result. The cost of the wrong\n // default is asymmetric too: shadow here is what v8 already had, an agent\n // answering every message with an error for hours while health read green.\n // The restart budget below bounds the downside of enforce.\n return 'enforce';\n}\n\nfunction parseIntAtLeast(raw: string | undefined, fallback: number, floor: number): number {\n const n = raw ? Number.parseInt(raw, 10) : NaN;\n return Number.isInteger(n) && n >= floor ? n : fallback;\n}\n\n/**\n * Config from the environment:\n * AGT_PANE_FATAL_RESTART_MODE off | shadow | enforce (default enforce)\n * AGT_PANE_FATAL_MIN_CYCLES default 2, floor 2 (one sighting never acts)\n * AGT_PANE_FATAL_MAX_RESTARTS default 3 per window, floor 1\n * AGT_PANE_FATAL_WINDOW_MINUTES default 360, floor 30\n */\nexport function resolvePaneFatalConfig(env: NodeJS.ProcessEnv = process.env): PaneFatalConfig {\n return {\n mode: parseMode(env.AGT_PANE_FATAL_RESTART_MODE),\n minCycles: parseIntAtLeast(env.AGT_PANE_FATAL_MIN_CYCLES, DEFAULTS.minCycles, 2),\n maxRestartsInWindow: parseIntAtLeast(env.AGT_PANE_FATAL_MAX_RESTARTS, DEFAULTS.maxRestartsInWindow, 1),\n restartWindowMs:\n parseIntAtLeast(env.AGT_PANE_FATAL_WINDOW_MINUTES, DEFAULTS.restartWindowMs / 60_000, 30) * 60_000,\n };\n}\n\nexport type PaneFatalAction =\n /** Nothing to do: no fatal kind, streak too short, or detection off. */\n | 'none'\n /**\n * The transcript-writes banner alone: the session is still answering, so do\n * NOT respawn it. Log and raise a warning recommending a restart (shadow and\n * enforce alike: a warning touches nothing).\n */\n | 'warn'\n /** Shadow mode: would restart. Log once per episode, do nothing. */\n | 'would-restart'\n /** Enforce: force a fresh respawn now. */\n | 'restart'\n /**\n * The restart budget is spent and the condition is back. A respawn has\n * demonstrably not cleared it, so another would be a restart loop: stop\n * restarting and escalate to a human instead.\n */\n | 'give-up';\n\n/**\n * What to do this cycle. `consecutiveCycles` INCLUDES this one; `recentRestartsMs`\n * are the timestamps of earlier fatal-error respawns of this agent.\n */\nexport function decidePaneFatalAction(\n input: {\n kind: PaneFatalKind | null;\n consecutiveCycles: number;\n recentRestartsMs: readonly number[];\n nowMs: number;\n },\n config: PaneFatalConfig,\n): PaneFatalAction {\n if (config.mode === 'off' || input.kind === null) return 'none';\n if (input.consecutiveCycles < config.minCycles) return 'none';\n if (!isRespawnKind(input.kind)) return 'warn';\n if (config.mode === 'shadow') return 'would-restart';\n const inWindow = input.recentRestartsMs.filter((t) => input.nowMs - t < config.restartWindowMs).length;\n return inWindow >= config.maxRestartsInWindow ? 'give-up' : 'restart';\n}\n\n/** Drop restart timestamps that have aged out of the window (bounded memory). */\nexport function pruneRestartHistory(\n history: readonly number[],\n nowMs: number,\n config: PaneFatalConfig,\n): number[] {\n return history.filter((t) => nowMs - t < config.restartWindowMs);\n}\n"],"mappings":"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","names":["kind"]}
@@ -9,6 +9,7 @@ import {
9
9
  SEND_KEYS_ENTER_DELAY_MS,
10
10
  TMUX_BOOTSTRAP_HOLD_CMD,
11
11
  TMUX_BOOTSTRAP_SESSION_PREFIX,
12
+ TRANSCRIPT_BIND_SENTINEL,
12
13
  TRANSCRIPT_MASK_DIR_PREFIX,
13
14
  _internals,
14
15
  applyPaneAgentIdVerdict,
@@ -27,6 +28,7 @@ import {
27
28
  egressAllowlistHostPath,
28
29
  egressMode,
29
30
  ensureClaudeStateLayout,
31
+ ensureTranscriptBindSources,
30
32
  findForeignAgentIdsInMcpConfig,
31
33
  findMissingSpawnEnv,
32
34
  getLastFailureContext,
@@ -62,11 +64,12 @@ import {
62
64
  stopPersistentSession,
63
65
  takeZombieDetection,
64
66
  tmuxBootstrapSessionName,
67
+ transcriptBindSourcesFor,
65
68
  writeDirectChatSessionState,
66
69
  writeEgressAllowlist,
67
70
  writePersistentClaudeWrapper
68
- } from "./chunk-OW6XCSOU.js";
69
- import "./chunk-PN7B4DLS.js";
71
+ } from "./chunk-FHUYRSCF.js";
72
+ import "./chunk-CFJONCHN.js";
70
73
  import "./chunk-OCMJ5E6J.js";
71
74
  import "./chunk-XWVM4KPK.js";
72
75
  import "./chunk-EL5MWGGH.js";
@@ -82,6 +85,7 @@ export {
82
85
  SEND_KEYS_ENTER_DELAY_MS,
83
86
  TMUX_BOOTSTRAP_HOLD_CMD,
84
87
  TMUX_BOOTSTRAP_SESSION_PREFIX,
88
+ TRANSCRIPT_BIND_SENTINEL,
85
89
  TRANSCRIPT_MASK_DIR_PREFIX,
86
90
  _internals,
87
91
  applyPaneAgentIdVerdict,
@@ -100,6 +104,7 @@ export {
100
104
  egressAllowlistHostPath,
101
105
  egressMode,
102
106
  ensureClaudeStateLayout,
107
+ ensureTranscriptBindSources,
103
108
  findForeignAgentIdsInMcpConfig,
104
109
  findMissingSpawnEnv,
105
110
  getLastFailureContext,
@@ -135,8 +140,9 @@ export {
135
140
  stopPersistentSession,
136
141
  takeZombieDetection,
137
142
  tmuxBootstrapSessionName,
143
+ transcriptBindSourcesFor,
138
144
  writeDirectChatSessionState,
139
145
  writeEgressAllowlist,
140
146
  writePersistentClaudeWrapper
141
147
  };
142
- //# sourceMappingURL=persistent-session-ELDC5JFR.js.map
148
+ //# sourceMappingURL=persistent-session-52A2G7D7.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  paneLogPath
3
- } from "./chunk-OW6XCSOU.js";
4
- import "./chunk-PN7B4DLS.js";
3
+ } from "./chunk-FHUYRSCF.js";
4
+ import "./chunk-CFJONCHN.js";
5
5
  import "./chunk-OCMJ5E6J.js";
6
6
  import "./chunk-XWVM4KPK.js";
7
7
  import "./chunk-EL5MWGGH.js";
@@ -802,4 +802,4 @@ export {
802
802
  readAndResetSlackReplyBindingClassifications,
803
803
  readAndResetSlackReplyTargetClassifications
804
804
  };
805
- //# sourceMappingURL=responsiveness-probe-HUUU2MFX.js.map
805
+ //# sourceMappingURL=responsiveness-probe-SJCP4V4X.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  sessionTranscriptDir
3
- } from "./chunk-PN7B4DLS.js";
3
+ } from "./chunk-CFJONCHN.js";
4
4
  import "./chunk-DGUM43GV.js";
5
5
 
6
6
  // src/lib/session-auth-dead.ts
@@ -204,4 +204,4 @@ export {
204
204
  decideSessionAuthState,
205
205
  probeSessionAuth
206
206
  };
207
- //# sourceMappingURL=session-auth-dead-BRMWKA3Y.js.map
207
+ //# sourceMappingURL=session-auth-dead-JJPNV3YQ.js.map
@@ -0,0 +1,79 @@
1
+ import "./chunk-DGUM43GV.js";
2
+
3
+ // src/lib/transcript-mount-health.ts
4
+ import { join } from "path";
5
+ function probeContainerTranscriptMounts(args) {
6
+ const { pid, stat } = args;
7
+ if (pid === null || !Number.isInteger(pid) || pid <= 0) return { state: "unknown", reason: "no container pid" };
8
+ const root = `/proc/${pid}/root`;
9
+ try {
10
+ stat(root);
11
+ } catch (err) {
12
+ return { state: "unknown", reason: `cannot read ${root}: ${err.code ?? "error"}` };
13
+ }
14
+ for (const bind of args.binds) {
15
+ let seen;
16
+ try {
17
+ seen = stat(join(root, bind.containerPath));
18
+ } catch (err) {
19
+ const code = err.code;
20
+ if (code === "ENOENT") return { state: "orphaned", path: bind.containerPath, reason: "missing" };
21
+ return { state: "unknown", reason: `stat ${bind.containerPath}: ${code ?? "error"}` };
22
+ }
23
+ if (!seen.isDirectory()) return { state: "orphaned", path: bind.containerPath, reason: "not-a-directory" };
24
+ if (seen.nlink === 0) return { state: "orphaned", path: bind.containerPath, reason: "unlinked" };
25
+ let host = null;
26
+ try {
27
+ host = stat(bind.hostPath);
28
+ } catch {
29
+ host = null;
30
+ }
31
+ if (!host || host.dev !== seen.dev || host.ino !== seen.ino) {
32
+ return { state: "orphaned", path: bind.containerPath, reason: "diverged" };
33
+ }
34
+ }
35
+ return { state: "ok" };
36
+ }
37
+ var TRANSCRIPT_MOUNT_ESCALATION_REFRESH_MS = 20 * 60 * 1e3;
38
+ function resolveTranscriptMountConfig(env = process.env) {
39
+ const raw = (env.AGT_TRANSCRIPT_MOUNT_CHECK_MODE ?? "").trim().toLowerCase();
40
+ const mode = raw === "off" || raw === "shadow" || raw === "enforce" ? raw : "enforce";
41
+ return { mode, minCycles: 2, maxRestartsInWindow: 3, restartWindowMs: 6 * 60 * 60 * 1e3 };
42
+ }
43
+ function decideTranscriptMountAction(input, config) {
44
+ if (config.mode === "off" || input.verdict.state !== "orphaned") return "none";
45
+ if (input.consecutiveCycles < config.minCycles) return "none";
46
+ if (config.mode === "shadow") return "would-restart";
47
+ const inWindow = input.recentRestartsMs.filter((t) => input.nowMs - t < config.restartWindowMs).length;
48
+ return inWindow >= config.maxRestartsInWindow ? "give-up" : "restart";
49
+ }
50
+ function bindSourceEscalations(refused) {
51
+ const out = [];
52
+ const planted = refused.filter((r) => r.reason !== "error");
53
+ const io = refused.filter((r) => r.reason === "error");
54
+ if (planted.length > 0) {
55
+ const what = planted.map((r) => `${r.path} (${r.reason})`).join(", ");
56
+ out.push({
57
+ cause: "layout",
58
+ action: "blocked",
59
+ message: `Transcript bind source was replaced with something the manager will not follow: ${what}. Refused to write through it; it is unprotected against the Claude Code retention sweep until a human removes it.`
60
+ });
61
+ }
62
+ if (io.length > 0) {
63
+ const what = io.map((r) => `${r.detail ?? "error"} on ${r.path}`).join(", ");
64
+ out.push({
65
+ cause: "io",
66
+ action: "alert",
67
+ message: `Could not create or refresh the transcript bind source sentinel: ${what}. The agent is still running, but its bind source is unprotected against the Claude Code retention sweep; check disk space and permissions on the host.`
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+ export {
73
+ TRANSCRIPT_MOUNT_ESCALATION_REFRESH_MS,
74
+ bindSourceEscalations,
75
+ decideTranscriptMountAction,
76
+ probeContainerTranscriptMounts,
77
+ resolveTranscriptMountConfig
78
+ };
79
+ //# sourceMappingURL=transcript-mount-health-OHE6IVJV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/transcript-mount-health.ts"],"sourcesContent":["/**\n * ENG-11277 — is a docker-isolated agent still writing its transcripts somewhere real?\n *\n * ## The incident\n *\n * Pedders host v8, 2026-10-02. After the host was re-paired onto a new Claude\n * account, marty, grace and sophie (all docker-isolated) started failing with\n *\n * Prompt is too long · automatic compaction failed: ENOENT: no such file or\n * directory, mkdir '/root/.claude/projects/-root--augmented-grace-project'\n * ⚠ Transcript writes are failing (ENOENT)\n *\n * while `/root/.claude/projects` existed on the host from any fresh shell, and a\n * respawn fixed it. The same banner hit ada on integritylabs-2 on 2026-09-24\n * with no account switch at all (ENG-10957), so the switch is not required.\n *\n * ## Why a path can exist and still ENOENT\n *\n * ENG-8854 gives every isolated agent two nested binds:\n *\n * host ~/.claude/projects/.agent-<agentId> -> container ~/.claude/projects\n * host ~/.claude/projects/<slug> -> container ~/.claude/projects/<slug>\n *\n * A bind pins the host DIRECTORY (its inode), not its path. If the host source\n * is unlinked while the container runs (rmdir'd, or rm -rf'd and recreated) the\n * container keeps a directory that no longer has a name anywhere. `mkdir` and\n * `open(O_CREAT)` inside an unlinked directory fail with exactly `ENOENT … mkdir\n * '<path>'` — and Claude Code writes the transcript and the compaction output\n * there, so both fail — while the same path resolves fine from the host, which\n * sees the new directory (or the untouched parent). Removing a mountpoint dir\n * from the host also detaches the nested bind inside the container (Linux >=\n * 3.18 unmounts on rmdir across namespaces), which leaves the slug path pointing\n * into the unlinked mask dir. Nothing on the host can repair a running\n * container's mounts; only a respawn rebinds them.\n *\n * ## Root cause (confirmed on v8)\n *\n * The mountpoint `.agent-<id>/<slug>` is EMPTY on the host (docker binds over\n * it) and the mask dir held nothing else. Claude Code's background\n * housekeeping (2.1.285 bundle; at most once per 24h, gated on\n * `<config>/.last-cleanup`; its only caller found is the interactive REPL's\n * first submit, so the sweeper is an interactive host-side `claude` sharing\n * `~/.claude` - the pair/login session, a human's shell) runs a projects sweep\n * over every directory under `~/.claude/projects`, `.agent-<id>` included:\n * - files directly in a project dir are unlinked by age only if the name is\n * on its transcript suffix list (.jsonl, .cast, .ccr-tip.json,\n * .precompact.json, .desktop-released.json, .dir-sync*.json + tmp forms);\n * any other name is skipped;\n * - a subdir whose name passes its session-name check (a path-safe,\n * LOWERCASE name with no ':' - which `-root--augmented-<agent>-project`\n * is) gets its known sidecar files aged out (tool-results/, mcp-tasks/,\n * subagents|workflows|remote-agents/, and named files such as\n * ccr-tip.json, custom-title.json, precompact.json) and is then rmdir'd\n * if empty, with NO age check; any other subdir is rmdir'd only if its\n * own lstat mtime is past `cleanupPeriodDays` (default 30);\n * - the project dir itself is then rmdir'd if empty, with NO age check.\n * So the first sweep that ran on v8 after the mountpoint existed removed it\n * and then the mask. On v8 the mask dir was absent on the host, the\n * container's projects dir read nlink 0, and `.last-cleanup` was stamped\n * 2026-10-02T04:24:12Z, the morning the host was re-paired (an interactive\n * `claude` on the host). `ensureTranscriptBindSources` keeps a sentinel\n * (`.agt-bind-source`, a name the sweep never unlinks) in each bind source, so\n * none of those rmdirs can succeed. This module is the backstop for whatever\n * removes a bind source anyway.\n *\n * ## What it checks, and why from inside\n *\n * Checking the HOST paths would miss the replace case (a recreated dir exists\n * and is healthy; the container is bound to the dead one). So the probe looks at\n * the CONTAINER's view through `/proc/<pid>/root`, which resolves inside the\n * container's mount namespace, and asks the kernel the one question that\n * matters: is the directory Claude writes into still linked (`nlink > 0`)?\n *\n * Pure apart from the injected `stat`, so the verdicts are unit-testable.\n */\n\nimport { join } from 'node:path';\n\nexport type TranscriptMountVerdict =\n | { state: 'ok' }\n /** The container's transcript dir is gone, unlinked, or no longer the host dir: writes go nowhere useful until respawn. */\n | { state: 'orphaned'; path: string; reason: 'missing' | 'unlinked' | 'not-a-directory' | 'diverged' }\n /** Could not look (no pid, /proc unreadable). Never a reason to act. */\n | { state: 'unknown'; reason: string };\n\nexport interface StatLike {\n isDirectory(): boolean;\n nlink: number;\n dev: number;\n ino: number;\n}\n\nexport type StatFn = (path: string) => StatLike;\n\n/** One bind: the path as the container sees it, and the host directory it must be. */\nexport interface TranscriptBind {\n containerPath: string;\n hostPath: string;\n}\n\n/**\n * Probe the container's view of its transcript binds.\n *\n * For each bind, the directory the container sees at `containerPath` (read\n * through `/proc/<pid>/root`) must be (1) present, (2) still linked, and (3) the\n * SAME directory as `hostPath` on the host (dev + inode). (3) is what catches a\n * source that was removed and recreated: the host path is healthy, the\n * container is bound to the dead one, and only an inode comparison tells them\n * apart. Order matters: pass the mask (parent) bind first, so an unlinked mask\n * root is reported as the cause even when the nested slug bind still resolves.\n */\nexport function probeContainerTranscriptMounts(args: {\n pid: number | null;\n binds: readonly TranscriptBind[];\n stat: StatFn;\n}): TranscriptMountVerdict {\n const { pid, stat } = args;\n if (pid === null || !Number.isInteger(pid) || pid <= 0) return { state: 'unknown', reason: 'no container pid' };\n const root = `/proc/${pid}/root`;\n // The pid itself must be readable, or a vanished process would read as\n // \"missing transcript dir\" and respawn a session that is merely restarting.\n try {\n stat(root);\n } catch (err) {\n return { state: 'unknown', reason: `cannot read ${root}: ${(err as NodeJS.ErrnoException).code ?? 'error'}` };\n }\n for (const bind of args.binds) {\n let seen: StatLike;\n try {\n seen = stat(join(root, bind.containerPath));\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return { state: 'orphaned', path: bind.containerPath, reason: 'missing' };\n return { state: 'unknown', reason: `stat ${bind.containerPath}: ${code ?? 'error'}` };\n }\n if (!seen.isDirectory()) return { state: 'orphaned', path: bind.containerPath, reason: 'not-a-directory' };\n if (seen.nlink === 0) return { state: 'orphaned', path: bind.containerPath, reason: 'unlinked' };\n let host: StatLike | null = null;\n try {\n host = stat(bind.hostPath);\n } catch {\n host = null; // host source gone: the container is on a dir nothing on the host reads\n }\n if (!host || host.dev !== seen.dev || host.ino !== seen.ino) {\n return { state: 'orphaned', path: bind.containerPath, reason: 'diverged' };\n }\n }\n return { state: 'ok' };\n}\n\n/**\n * How often a persisting critical transcript-mount condition (restart budget\n * spent, or a tampered bind source) is re-reported. Must stay under the\n * reaper-alert-sweeper's quiet window (AUGMENTED_REAPER_ALERT_QUIET_MINUTES,\n * default 30), or the alert auto-closes while the agent is still broken.\n */\nexport const TRANSCRIPT_MOUNT_ESCALATION_REFRESH_MS = 20 * 60 * 1000;\n\nexport type TranscriptMountMode = 'off' | 'shadow' | 'enforce';\n\nexport interface TranscriptMountConfig {\n mode: TranscriptMountMode;\n /** Consecutive orphaned verdicts before acting (a respawn mid-teardown can read oddly once). */\n minCycles: number;\n maxRestartsInWindow: number;\n restartWindowMs: number;\n}\n\n/**\n * AGT_TRANSCRIPT_MOUNT_CHECK_MODE off | shadow | enforce (default enforce).\n *\n * Enforce by default: the verdict is a kernel fact (the directory the agent\n * writes into has no name), not a heuristic, and the only remedy is a respawn.\n * Shadow is what every host had before this - three agents silently losing\n * transcripts for hours on v8. The restart budget bounds the downside.\n */\nexport function resolveTranscriptMountConfig(env: NodeJS.ProcessEnv = process.env): TranscriptMountConfig {\n const raw = (env.AGT_TRANSCRIPT_MOUNT_CHECK_MODE ?? '').trim().toLowerCase();\n const mode: TranscriptMountMode = raw === 'off' || raw === 'shadow' || raw === 'enforce' ? raw : 'enforce';\n return { mode, minCycles: 2, maxRestartsInWindow: 3, restartWindowMs: 6 * 60 * 60 * 1000 };\n}\n\nexport type TranscriptMountAction = 'none' | 'would-restart' | 'restart' | 'give-up';\n\nexport function decideTranscriptMountAction(\n input: {\n verdict: TranscriptMountVerdict;\n consecutiveCycles: number;\n recentRestartsMs: readonly number[];\n nowMs: number;\n },\n config: TranscriptMountConfig,\n): TranscriptMountAction {\n if (config.mode === 'off' || input.verdict.state !== 'orphaned') return 'none';\n if (input.consecutiveCycles < config.minCycles) return 'none';\n if (config.mode === 'shadow') return 'would-restart';\n const inWindow = input.recentRestartsMs.filter((t) => input.nowMs - t < config.restartWindowMs).length;\n return inWindow >= config.maxRestartsInWindow ? 'give-up' : 'restart';\n}\n\n/** Structural twin of persistent-session's TranscriptBindRefusal (kept here so this module stays pure). */\nexport interface BindSourceRefusal {\n path: string;\n reason: 'symlink' | 'not-a-directory' | 'not-a-regular-file' | 'error';\n detail?: string;\n}\n\nexport interface BindSourceEscalation {\n /** Escalation key and alert shape: `layout` = tampering (critical), `io` = host I/O (warning). */\n cause: 'layout' | 'io';\n action: 'blocked' | 'alert';\n message: string;\n}\n\n/**\n * Obi Wan 244fdbddc6ef: turn sentinel refusals into alerts that name the real\n * cause. A link / non-directory / non-regular file is something the agent\n * planted in a bind source it can write: refused, never followed, and a\n * respawn cannot fix it, so it is critical. An `error` is the HOST failing to\n * create or refresh the sentinel (EACCES, ENOSPC, EROFS, a dir that vanished\n * after mkdir): nothing was planted, and since this only runs while the\n * container's binds probe healthy, the agent is still working. What is lost is\n * the sentinel's protection against the retention sweep, so it is a warning\n * that points at disk and permissions. (A host that cannot create the bind\n * source at all fails the spawn or the probe, which page on their own.)\n */\nexport function bindSourceEscalations(refused: readonly BindSourceRefusal[]): BindSourceEscalation[] {\n const out: BindSourceEscalation[] = [];\n const planted = refused.filter((r) => r.reason !== 'error');\n const io = refused.filter((r) => r.reason === 'error');\n if (planted.length > 0) {\n const what = planted.map((r) => `${r.path} (${r.reason})`).join(', ');\n out.push({\n cause: 'layout',\n action: 'blocked',\n message:\n `Transcript bind source was replaced with something the manager will not follow: ${what}. ` +\n 'Refused to write through it; it is unprotected against the Claude Code retention sweep until a human removes it.',\n });\n }\n if (io.length > 0) {\n const what = io.map((r) => `${r.detail ?? 'error'} on ${r.path}`).join(', ');\n out.push({\n cause: 'io',\n action: 'alert',\n message:\n `Could not create or refresh the transcript bind source sentinel: ${what}. ` +\n 'The agent is still running, but its bind source is unprotected against the Claude Code retention sweep; ' +\n 'check disk space and permissions on the host.',\n });\n }\n return out;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@integrity-labs/agt-cli",
3
- "version": "0.28.1067",
3
+ "version": "0.28.1069",
4
4
  "description": "Augmented Team CLI — agent provisioning and management",
5
5
  "type": "module",
6
6
  "engines": {