@tekmidian/pai 0.36.2 → 0.38.0

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 (157) hide show
  1. package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
  2. package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -4
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/program.d.mts.map +1 -1
  6. package/dist/cli/program.mjs +5 -4
  7. package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
  8. package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
  9. package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
  10. package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
  11. package/dist/daemon/index.mjs +8 -8
  12. package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
  13. package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
  14. package/dist/daemon-DsGGiIJM.mjs +20 -0
  15. package/dist/daemon-mcp/index.mjs +549 -1
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
  18. package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
  19. package/dist/detector-DtLExmHN.mjs +5 -0
  20. package/dist/factory-BXzqRYVZ.mjs +3 -0
  21. package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
  22. package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
  23. package/dist/hooks/context-compression-hook.mjs +164 -14
  24. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  25. package/dist/hooks/initialize-session.mjs +6 -0
  26. package/dist/hooks/initialize-session.mjs.map +3 -3
  27. package/dist/hooks/load-core-context.mjs +6 -0
  28. package/dist/hooks/load-core-context.mjs.map +3 -3
  29. package/dist/hooks/load-project-context.mjs +6 -0
  30. package/dist/hooks/load-project-context.mjs.map +3 -3
  31. package/dist/hooks/post-compact-inject.mjs +6 -0
  32. package/dist/hooks/post-compact-inject.mjs.map +3 -3
  33. package/dist/hooks/route-agents-to-worker.mjs +518 -0
  34. package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
  35. package/dist/hooks/status-line.mjs +400 -0
  36. package/dist/hooks/status-line.mjs.map +7 -0
  37. package/dist/hooks/stop-hook.mjs +6 -0
  38. package/dist/hooks/stop-hook.mjs.map +3 -3
  39. package/dist/hooks/subagent-stop-hook.mjs +6 -0
  40. package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
  41. package/dist/hooks/sync-todo-to-md.mjs +6 -0
  42. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  43. package/dist/hooks/update-tab-on-action.mjs +6 -0
  44. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  45. package/dist/hooks/update-tab-titles.mjs +6 -0
  46. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  47. package/dist/hooks/worker-proxy.mjs +858 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +545 -0
  50. package/dist/hooks/worker-status-line.mjs.map +7 -0
  51. package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
  52. package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
  53. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  54. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  55. package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
  56. package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
  57. package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
  58. package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
  59. package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
  60. package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
  61. package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
  62. package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
  63. package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
  64. package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
  65. package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
  66. package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
  67. package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
  68. package/dist/{program-C-fUghPv.mjs → program-Y0hAiVy8.mjs} +851 -101
  69. package/dist/program-Y0hAiVy8.mjs.map +1 -0
  70. package/dist/providers-DUshcB-d.mjs +4031 -0
  71. package/dist/providers-DUshcB-d.mjs.map +1 -0
  72. package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
  73. package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
  74. package/dist/query-feedback-DhyLOe5S.mjs +3 -0
  75. package/dist/router-1zi8jiNF.mjs +3 -0
  76. package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
  77. package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
  78. package/dist/skills/Worker/SKILL.md +63 -0
  79. package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
  80. package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
  81. package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
  82. package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
  83. package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
  84. package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
  85. package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
  86. package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
  87. package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
  88. package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
  89. package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
  90. package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
  91. package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
  92. package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
  94. package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
  96. package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
  97. package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
  98. package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
  99. package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
  100. package/docs/commands/README.md +29 -0
  101. package/docs/commands/backup.md +1 -1
  102. package/docs/commands/clear-names.md +1 -1
  103. package/docs/commands/daemon.md +1 -1
  104. package/docs/commands/db.md +1 -1
  105. package/docs/commands/end.md +1 -1
  106. package/docs/commands/help.md +1 -1
  107. package/docs/commands/identity.md +1 -1
  108. package/docs/commands/kg.md +1 -1
  109. package/docs/commands/mcp.md +1 -1
  110. package/docs/commands/memory.md +1 -1
  111. package/docs/commands/notify.md +1 -1
  112. package/docs/commands/observation.md +1 -1
  113. package/docs/commands/obsidian.md +1 -1
  114. package/docs/commands/pause.md +1 -1
  115. package/docs/commands/project.md +1 -1
  116. package/docs/commands/projects.md +1 -1
  117. package/docs/commands/registry.md +1 -1
  118. package/docs/commands/restore.md +1 -1
  119. package/docs/commands/session.md +1 -1
  120. package/docs/commands/sessions.md +1 -1
  121. package/docs/commands/setup.md +1 -1
  122. package/docs/commands/shell-init.md +1 -1
  123. package/docs/commands/skill.md +1 -1
  124. package/docs/commands/task.md +1 -1
  125. package/docs/commands/topic.md +1 -1
  126. package/docs/commands/update.md +1 -1
  127. package/docs/commands/worker.md +398 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +434 -0
  130. package/package.json +1 -1
  131. package/scripts/build-hooks.mjs +46 -1
  132. package/src/hooks/pre-compact.sh +1 -0
  133. package/src/hooks/session-autosave.sh +1 -0
  134. package/src/hooks/session-stop.sh +1 -0
  135. package/src/hooks/ts/lib/context-fill.test.ts +104 -0
  136. package/src/hooks/ts/lib/context-fill.ts +73 -0
  137. package/src/hooks/ts/lib/handover-evidence.ts +227 -0
  138. package/src/hooks/ts/lib/worker-session.test.ts +24 -0
  139. package/src/hooks/ts/lib/worker-session.ts +23 -0
  140. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
  141. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
  142. package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
  143. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
  144. package/src/hooks/ts/session-start/initialize-session.ts +2 -0
  145. package/src/hooks/ts/session-start/load-core-context.ts +2 -0
  146. package/src/hooks/ts/session-start/load-project-context.ts +2 -0
  147. package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
  148. package/src/hooks/ts/stop/stop-hook.ts +2 -0
  149. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
  150. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
  151. package/statusline-command.sh +17 -0
  152. package/dist/daemon-DRdoA489.mjs +0 -20
  153. package/dist/detector-DGAk1iBR.mjs +0 -5
  154. package/dist/factory-CrokPMk2.mjs +0 -3
  155. package/dist/program-C-fUghPv.mjs.map +0 -1
  156. package/dist/query-feedback-BBMBp96K.mjs +0 -3
  157. package/dist/router-BMkOb62X.mjs +0 -3
@@ -365,6 +365,51 @@ function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
365
365
  return paths;
366
366
  }
367
367
 
368
+ /** Placeholder model id the platform writes on synthetic assistant turns. */
369
+ const SYNTHETIC_MODEL = "<synthetic>";
370
+
371
+ /**
372
+ * Which family of model wrote a transcript. "claude" is the platform's own;
373
+ * "foreign" is any other provider routed through the same CLI (a different
374
+ * context window, so its compactions say nothing about ours); "unknown" is
375
+ * no usable model field at all.
376
+ */
377
+ export type TranscriptModelFamily = "claude" | "foreign" | "unknown";
378
+
379
+ export function modelFamily(model: string | null | undefined): TranscriptModelFamily {
380
+ if (typeof model !== "string" || model === "" || model === SYNTHETIC_MODEL) return "unknown";
381
+ return model.startsWith("claude-") ? "claude" : "foreign";
382
+ }
383
+
384
+ /**
385
+ * The family of the LAST assistant model in a transcript — scanned from the
386
+ * end so a long transcript costs one read and a few lines of parsing.
387
+ * Unreadable or model-less transcripts are "unknown", never "foreign":
388
+ * the callers that skip work on "foreign" must not skip it on doubt.
389
+ */
390
+ export function transcriptModelFamily(path: string): TranscriptModelFamily {
391
+ let raw: string;
392
+ try {
393
+ raw = readFileSync(path, "utf-8");
394
+ } catch {
395
+ return "unknown";
396
+ }
397
+ const lines = raw.split("\n");
398
+ for (let i = lines.length - 1; i >= 0; i--) {
399
+ const line = lines[i];
400
+ if (!line.includes('"model"')) continue;
401
+ try {
402
+ const entry = JSON.parse(line) as { type?: string; message?: { model?: unknown } };
403
+ if (entry.type !== "assistant") continue;
404
+ const model = entry.message?.model;
405
+ if (typeof model === "string" && model !== SYNTHETIC_MODEL) return modelFamily(model);
406
+ } catch {
407
+ continue;
408
+ }
409
+ }
410
+ return "unknown";
411
+ }
412
+
368
413
  /**
369
414
  * Every DISTINCT compact_boundary sample found across ALL of a project's
370
415
  * transcripts (live + archived), newest first by the event's OWN timestamp
@@ -383,6 +428,17 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
383
428
  continue;
384
429
  }
385
430
 
431
+ // The model governing each sample: the most recent assistant
432
+ // `message.model` seen in this file before the compact_boundary. A
433
+ // transcript written by a non-Claude model (a headless worker on another
434
+ // provider, with a different context window) compacts at a different
435
+ // size and must not shape THIS project's trigger — two such workers
436
+ // compacting at ~151k pulled a real project's trigger from ~784k to
437
+ // ~151k. Samples with no model seen yet are kept: older transcripts
438
+ // may lack the field.
439
+ let lastModel: string | null = null;
440
+ let foreignDiscards = 0;
441
+
386
442
  for (const line of raw.split("\n")) {
387
443
  if (!line.trim()) continue;
388
444
  let entry: {
@@ -391,23 +447,40 @@ function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBo
391
447
  timestamp?: string;
392
448
  uuid?: string;
393
449
  compactMetadata?: { preTokens?: number };
450
+ message?: { model?: unknown };
394
451
  };
395
452
  try {
396
453
  entry = JSON.parse(line);
397
454
  } catch {
398
455
  continue;
399
456
  }
457
+ if (entry.type === "assistant") {
458
+ const model = entry.message?.model;
459
+ if (typeof model === "string" && model !== SYNTHETIC_MODEL) lastModel = model;
460
+ continue;
461
+ }
400
462
  if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
401
463
  const preTokens = entry.compactMetadata?.preTokens;
402
464
  if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
403
465
  const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
404
466
  if (!Number.isFinite(timestampMs)) continue;
467
+ if (modelFamily(lastModel) === "foreign") {
468
+ foreignDiscards++;
469
+ continue;
470
+ }
405
471
 
406
472
  const key = entry.uuid ?? `${timestampMs}:${preTokens}`;
407
473
  if (!byKey.has(key)) {
408
474
  byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });
409
475
  }
410
476
  }
477
+
478
+ if (foreignDiscards > 0) {
479
+ console.error(
480
+ `[context-fill] ignored ${foreignDiscards} compaction sample(s) from a ` +
481
+ `non-Claude transcript (model=${lastModel}): ${path}`
482
+ );
483
+ }
411
484
  }
412
485
 
413
486
  return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Evidence binding for the model-written pre-compaction handover.
3
+ *
4
+ * The handover is prose written by a model about the session. Nothing else
5
+ * checks its claims, and the successor session cannot: by the time it reads
6
+ * the handover, the transcript it describes has been compacted away. This
7
+ * module does the one check that is cheap and deterministic — every
8
+ * checkable identifier in the handover (commit hashes, versions, paths,
9
+ * pids, timestamps, token counts) must appear verbatim in the transcript
10
+ * the handover was written from. Identifiers that do not are reported as
11
+ * unverified. Nothing is ever dropped or rewritten: the reader gets the
12
+ * handover unchanged plus one footer line saying how much of it is
13
+ * anchored in the record.
14
+ *
15
+ * Borrowed from the evidence-binding step of graph extractors that check an
16
+ * LLM's output against the chunk it was given. No model is called.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+ import { contentToText } from "./transcript-text.js";
21
+
22
+ export interface EvidenceResult {
23
+ /** Distinct checkable identifiers found in the handover. */
24
+ total: number;
25
+ /** Identifiers present verbatim (after normalisation) in the transcript. */
26
+ found: string[];
27
+ /** Identifiers absent from the transcript — unverified, not wrong. */
28
+ missing: string[];
29
+ /** One line for the reader, never empty. */
30
+ footer: string;
31
+ }
32
+
33
+ /** Minimum identifier length worth checking; shorter strings match by accident. */
34
+ const MIN_TOKEN_LENGTH = 4;
35
+ /** How many unverified identifiers the footer names before "+N more". */
36
+ const FOOTER_NAMED_MISSING = 5;
37
+ /** Length of the handover's opening used to recognise its own echo in a tool result. */
38
+ const ECHO_HEAD_LENGTH = 80;
39
+
40
+ /** Markers of text that was injected INTO the session rather than produced by it. */
41
+ const INJECTED_MARKERS = [
42
+ "HANDOVER FROM THE PREVIOUS SESSION",
43
+ "MODEL-WRITTEN HANDOVER",
44
+ "SESSION STATE RECOVERED AFTER COMPACTION",
45
+ ];
46
+
47
+ /** Tools whose input would contain the handover itself when it targets the handover file. */
48
+ const HANDOVER_WRITERS = new Set(["Write", "Edit", "NotebookEdit"]);
49
+
50
+ /**
51
+ * Identifier classes, in priority order. Each pattern yields either its
52
+ * whole match or capture group 1 when present. Kept deliberately literal:
53
+ * an identifier is something a reader could grep for.
54
+ */
55
+ const TOKEN_PATTERNS: RegExp[] = [
56
+ // git hashes: 7-40 hex chars with at least one letter (a plain number is not a hash)
57
+ /\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g,
58
+ // package@version
59
+ /(?:@[\w.-]+\/)?[\w.-]+@\d+\.\d+\.\d+(?:-[\w.]+)?/g,
60
+ // semver
61
+ /\bv?\d+\.\d+\.\d+(?:-[\w.]+)?\b/g,
62
+ // absolute paths (two or more segments)
63
+ /(?:^|[\s(`'"])(\/[\w.-]+(?:\/[\w.-]+)+)/gm,
64
+ // relative paths with a slash and an extension
65
+ /(?:^|[\s(`'"])((?:~\/|\.\.?\/)?[\w.-]+(?:\/[\w.-]+)+\.[a-z0-9]{1,5})\b/gm,
66
+ // process ids
67
+ /\bpid\s*[:=#]?\s*(\d{3,7})\b/gi,
68
+ // ISO dates and timestamps
69
+ /\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?)?\b/g,
70
+ // numbers with units — digits only are kept
71
+ /\b(\d[\d,]*)\s*(?:tokens?|ms|MB|GB)\b/g,
72
+ ];
73
+
74
+ /** Strip thousands separators so "784,000" and "784000" compare equal. */
75
+ function normalise(text: string): string {
76
+ return text.replace(/(\d),(?=\d{3}\b)/g, "$1");
77
+ }
78
+
79
+ function stripSystemReminders(text: string): string {
80
+ return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "");
81
+ }
82
+
83
+ /** Every distinct checkable identifier in a handover, in order of first appearance. */
84
+ export function extractCheckableTokens(markdown: string): string[] {
85
+ const seen = new Set<string>();
86
+ const out: string[] = [];
87
+ for (const pattern of TOKEN_PATTERNS) {
88
+ pattern.lastIndex = 0;
89
+ for (const match of markdown.matchAll(pattern)) {
90
+ const raw = (match[1] ?? match[0]).trim();
91
+ const token = normalise(raw);
92
+ if (token.length < MIN_TOKEN_LENGTH || seen.has(token)) continue;
93
+ seen.add(token);
94
+ out.push(token);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ interface TranscriptEntry {
101
+ type?: string;
102
+ message?: { content?: unknown };
103
+ }
104
+
105
+ interface ContentPart {
106
+ type?: string;
107
+ text?: string;
108
+ name?: string;
109
+ input?: { file_path?: unknown } & Record<string, unknown>;
110
+ content?: unknown;
111
+ }
112
+
113
+ /**
114
+ * Everything the session itself said, did, or was shown — minus the two
115
+ * places where the handover could verify against itself: the tool call
116
+ * that wrote the handover file, and injected blocks carried over from an
117
+ * earlier compaction.
118
+ */
119
+ function buildHaystack(
120
+ transcriptPaths: string[],
121
+ handoverPath: string | null,
122
+ handoverHead: string
123
+ ): string {
124
+ const parts: string[] = [];
125
+ const resolvedHandover = handoverPath ? resolve(handoverPath) : null;
126
+
127
+ const pushUserText = (text: string): void => {
128
+ if (INJECTED_MARKERS.some((m) => text.includes(m))) return;
129
+ parts.push(stripSystemReminders(text));
130
+ };
131
+
132
+ for (const path of transcriptPaths) {
133
+ let raw: string;
134
+ try {
135
+ raw = readFileSync(path, "utf-8");
136
+ } catch {
137
+ continue;
138
+ }
139
+ for (const line of raw.split("\n")) {
140
+ if (!line.trim()) continue;
141
+ let entry: TranscriptEntry;
142
+ try {
143
+ entry = JSON.parse(line) as TranscriptEntry;
144
+ } catch {
145
+ continue;
146
+ }
147
+ const content = entry.message?.content;
148
+ if (content === undefined) continue;
149
+
150
+ if (entry.type === "user") {
151
+ if (typeof content === "string") {
152
+ pushUserText(content);
153
+ continue;
154
+ }
155
+ if (!Array.isArray(content)) continue;
156
+ for (const part of content as ContentPart[]) {
157
+ if (part?.type === "text" && typeof part.text === "string") pushUserText(part.text);
158
+ else if (part?.type === "tool_result") {
159
+ const text = contentToText(part.content);
160
+ if (handoverHead && text.startsWith(handoverHead)) continue;
161
+ parts.push(text);
162
+ }
163
+ }
164
+ } else if (entry.type === "assistant" && Array.isArray(content)) {
165
+ for (const part of content as ContentPart[]) {
166
+ if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
167
+ else if (part?.type === "tool_use") {
168
+ const target = part.input?.file_path;
169
+ if (
170
+ resolvedHandover &&
171
+ HANDOVER_WRITERS.has(part.name ?? "") &&
172
+ typeof target === "string" &&
173
+ resolve(target) === resolvedHandover
174
+ ) {
175
+ continue;
176
+ }
177
+ parts.push(JSON.stringify(part.input ?? {}));
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return normalise(parts.join("\n"));
184
+ }
185
+
186
+ function footerFor(found: string[], missing: string[]): string {
187
+ const total = found.length + missing.length;
188
+ if (total === 0) return "Evidence: no checkable identifiers";
189
+ let footer = `Evidence: ${found.length}/${total} identifiers found in the transcript`;
190
+ if (missing.length > 0) {
191
+ const named = missing.slice(0, FOOTER_NAMED_MISSING).join(", ");
192
+ const more = missing.length - FOOTER_NAMED_MISSING;
193
+ footer += `; unverified: ${named}${more > 0 ? ` +${more} more` : ""}`;
194
+ }
195
+ return footer;
196
+ }
197
+
198
+ /**
199
+ * Check a handover's identifiers against the transcript(s) it was written
200
+ * from. Never throws: any failure degrades to a footer that says the check
201
+ * was unavailable, so the handover itself is always delivered.
202
+ *
203
+ * @param handoverMarkdown the handover text, returned to the reader unchanged
204
+ * @param handoverPath where the handover was written, if it was written
205
+ * to a file — its own Write/Edit is excluded as evidence
206
+ * @param transcriptPaths the session's transcript file(s)
207
+ */
208
+ export function bindHandoverEvidence(
209
+ handoverMarkdown: string,
210
+ handoverPath: string | null,
211
+ transcriptPaths: string[]
212
+ ): EvidenceResult {
213
+ try {
214
+ const tokens = extractCheckableTokens(handoverMarkdown);
215
+ if (tokens.length === 0) {
216
+ return { total: 0, found: [], missing: [], footer: footerFor([], []) };
217
+ }
218
+ const head = handoverMarkdown.trim().slice(0, ECHO_HEAD_LENGTH);
219
+ const haystack = buildHaystack(transcriptPaths, handoverPath, head);
220
+ const found: string[] = [];
221
+ const missing: string[] = [];
222
+ for (const token of tokens) (haystack.includes(token) ? found : missing).push(token);
223
+ return { total: tokens.length, found, missing, footer: footerFor(found, missing) };
224
+ } catch {
225
+ return { total: 0, found: [], missing: [], footer: "Evidence: check unavailable" };
226
+ }
227
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isWorkerSession } from "./worker-session.js";
3
+
4
+ describe("isWorkerSession", () => {
5
+ it("is true only for the exact launcher mark PAI_WORKER=1", () => {
6
+ expect(isWorkerSession({ PAI_WORKER: "1" })).toBe(true);
7
+ expect(isWorkerSession({ PAI_WORKER: "true" })).toBe(false);
8
+ expect(isWorkerSession({ PAI_WORKER: "" })).toBe(false);
9
+ expect(isWorkerSession({})).toBe(false);
10
+ });
11
+
12
+ it("reads process.env by default", () => {
13
+ const prev = process.env.PAI_WORKER;
14
+ try {
15
+ process.env.PAI_WORKER = "1";
16
+ expect(isWorkerSession()).toBe(true);
17
+ delete process.env.PAI_WORKER;
18
+ expect(isWorkerSession()).toBe(false);
19
+ } finally {
20
+ if (prev === undefined) delete process.env.PAI_WORKER;
21
+ else process.env.PAI_WORKER = prev;
22
+ }
23
+ });
24
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Worker-session detection.
3
+ *
4
+ * A disposable headless worker (a `claude -p` run started by an orchestrating
5
+ * session, possibly against a different model provider with a different
6
+ * context window) shares the project directory and the hook configuration
7
+ * with the real session that spawned it. Left alone, the hooks treat it as a
8
+ * session in its own right: they inject project context into it, create and
9
+ * rename a numbered session note for it, autosave it, and enqueue a
10
+ * model-written handover for it. Its compactions also land in the project's
11
+ * transcript folder, where they are indistinguishable from a real session's
12
+ * and drag the measured compaction trigger down (observed 2026-09-17: two
13
+ * workers compacting at ~151k tokens pulled a project's trigger from ~784k
14
+ * to ~151k, and the real session's handover fired at ~50k tokens).
15
+ *
16
+ * The launcher marks such sessions with `PAI_WORKER=1`. Every hook that does
17
+ * per-session bookkeeping returns immediately when this predicate is true.
18
+ * Deliberately NOT guarded: the security validator (a worker's shell
19
+ * commands must still be checked) and observability capture.
20
+ */
21
+ export function isWorkerSession(env: NodeJS.ProcessEnv = process.env): boolean {
22
+ return env.PAI_WORKER === "1";
23
+ }
@@ -10,6 +10,7 @@
10
10
  * IMPORTANT: This hook PRESERVES user content. It only updates "Current Session".
11
11
  */
12
12
 
13
+ import { isWorkerSession } from "../lib/worker-session.js";
13
14
  import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
14
15
  import { join } from 'path';
15
16
  import {
@@ -131,6 +132,7 @@ ${formatSessionTodos(todos)}`;
131
132
  }
132
133
 
133
134
  async function main() {
135
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
134
136
  try {
135
137
  const chunks: Buffer[] = [];
136
138
  for await (const chunk of process.stdin) {
@@ -7,6 +7,7 @@
7
7
  * PAI-safe: No sensitive data, just tool descriptions
8
8
  */
9
9
 
10
+ import { isWorkerSession } from "../lib/worker-session.js";
10
11
  import { execSync } from 'child_process';
11
12
 
12
13
  // Action tools that warrant a tab title update
@@ -122,6 +123,7 @@ function generateTitle(toolName: string, input: HookInput['tool_input']): string
122
123
  }
123
124
 
124
125
  async function main() {
126
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
125
127
  try {
126
128
  const input = await readStdinWithTimeout();
127
129
  const data: HookInput = JSON.parse(input);
@@ -13,6 +13,7 @@
13
13
  * and work items from earlier in the session.
14
14
  */
15
15
 
16
+ import { isWorkerSession } from "../lib/worker-session.js";
16
17
  import { existsSync, readFileSync, writeFileSync } from 'fs';
17
18
  import { basename, dirname, join } from 'path';
18
19
  import { tmpdir } from 'os';
@@ -34,6 +35,7 @@ import {
34
35
  import { getContextFill, formatContextFill } from '../lib/context-fill.js';
35
36
  import { contentToText, isNoiseFilePath, preferCwdFiles } from '../lib/transcript-text.js';
36
37
  import { readContextHandoverCache } from '../lib/context-handover-cache.js';
38
+ import { bindHandoverEvidence } from '../lib/handover-evidence.js';
37
39
 
38
40
  interface HookInput {
39
41
  session_id: string;
@@ -515,6 +517,7 @@ function enqueueSessionSummary(payload: {
515
517
  // ---------------------------------------------------------------------------
516
518
 
517
519
  async function main() {
520
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
518
521
  // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
519
522
  if (isProbeSession()) {
520
523
  process.exit(0);
@@ -714,11 +717,13 @@ async function main() {
714
717
  // -------------------------------------------------------------------
715
718
  let sourceLabel: string;
716
719
  let handoverBlock: string;
720
+ let handoverSummary: string | null = null;
717
721
  try {
718
722
  const cachedHandover = readContextHandoverCache(hookInput.session_id);
719
723
  const handoverIsFresh =
720
724
  cachedHandover !== null &&
721
725
  (!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt));
726
+ handoverSummary = handoverIsFresh ? cachedHandover!.summary : null;
722
727
 
723
728
  sourceLabel = handoverIsFresh
724
729
  ? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` +
@@ -739,6 +744,18 @@ async function main() {
739
744
  handoverBlock = '';
740
745
  }
741
746
 
747
+ // Evidence binding: which of the handover's identifiers the transcript
748
+ // actually contains. One footer line, content untouched; its own
749
+ // failure changes nothing else about the injection.
750
+ if (handoverSummary !== null && typeof hookInput.transcript_path === 'string') {
751
+ try {
752
+ const evidence = bindHandoverEvidence(handoverSummary, null, [hookInput.transcript_path]);
753
+ handoverBlock += `\n${evidence.footer}`;
754
+ } catch (err) {
755
+ console.error(`Evidence check skipped: ${err}`);
756
+ }
757
+ }
758
+
742
759
  const injection = [
743
760
  '<system-reminder>',
744
761
  `SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * route-agents-to-worker.ts — PreToolUse hook on the Agent tool.
5
+ *
6
+ * Denies every in-process subagent when worker providers are configured, so no
7
+ * worker ever runs on Anthropic. The deny reason tells the orchestrator how to
8
+ * delegate through `pai worker run` instead — same idea as the old
9
+ * route-agents-to-glm.sh, but provider-neutral and ledgered in the workers
10
+ * log dir.
11
+ *
12
+ * Allows (and does not touch) when:
13
+ * - the tool is not the Agent tool (this hook is Agent-matched, but stay safe)
14
+ * - ALLOW_ANTHROPIC_AGENTS=1 is in the environment (one-session bypass)
15
+ * - workers are off or no provider is configured
16
+ *
17
+ * Every decision is appended to the routing ledger read by `pai worker log`.
18
+ */
19
+
20
+ import { readWorkersSection } from "../../../workers/config.js";
21
+ import { workersLogDir, ledgerPath } from "../../../workers/paths.js";
22
+ import { appendLedger } from "../../../workers/ledger.js";
23
+
24
+ interface HookInput {
25
+ session_id?: string;
26
+ cwd?: string;
27
+ tool_name?: string;
28
+ tool_input?: { description?: string; prompt?: string; subagent_type?: string } | string;
29
+ }
30
+
31
+ const ALLOW = JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } });
32
+
33
+ function deny(reason: string): string {
34
+ return JSON.stringify({
35
+ hookSpecificOutput: {
36
+ hookEventName: "PreToolUse",
37
+ permissionDecision: "deny",
38
+ permissionDecisionReason: reason,
39
+ },
40
+ });
41
+ }
42
+
43
+ function shortLabel(input: HookInput): string {
44
+ const desc =
45
+ typeof input.tool_input === "object" && input.tool_input !== null
46
+ ? input.tool_input.description ?? ""
47
+ : "";
48
+ return desc.replace(/\s+/g, " ").trim().slice(0, 60);
49
+ }
50
+
51
+ async function main(): Promise<void> {
52
+ let text = "";
53
+ try {
54
+ for await (const chunk of process.stdin) text += chunk;
55
+ } catch {
56
+ process.stdout.write(ALLOW);
57
+ return;
58
+ }
59
+ if (!text.trim()) {
60
+ process.stdout.write(ALLOW);
61
+ return;
62
+ }
63
+
64
+ let input: HookInput;
65
+ try {
66
+ input = JSON.parse(text) as HookInput;
67
+ } catch {
68
+ process.stdout.write(ALLOW);
69
+ return;
70
+ }
71
+
72
+ if (input.tool_name && input.tool_name !== "Agent") {
73
+ process.stdout.write(ALLOW);
74
+ return;
75
+ }
76
+
77
+ const label = shortLabel(input);
78
+ const cwd = input.cwd ?? process.cwd();
79
+
80
+ const ledger = (event: string): void => {
81
+ try {
82
+ appendLedger(ledgerPath(workersLogDir(readWorkersSection().workers)), event, {
83
+ cwd,
84
+ ...(label ? { label } : {}),
85
+ });
86
+ } catch {
87
+ // no config / no log dir yet — the decision below still stands
88
+ }
89
+ };
90
+
91
+ if (process.env.ALLOW_ANTHROPIC_AGENTS === "1") {
92
+ ledger("ALLOWED-ANTHROPIC-AGENT");
93
+ process.stdout.write(ALLOW);
94
+ return;
95
+ }
96
+
97
+ let workers: ReturnType<typeof readWorkersSection>["workers"];
98
+ try {
99
+ workers = readWorkersSection().workers;
100
+ } catch {
101
+ // broken workers config must not break the session
102
+ process.stdout.write(ALLOW);
103
+ return;
104
+ }
105
+ if (!workers.enabled || Object.keys(workers.providers).length === 0) {
106
+ process.stdout.write(ALLOW);
107
+ return;
108
+ }
109
+
110
+ ledger("DENIED-ANTHROPIC-AGENT");
111
+ process.stdout.write(
112
+ deny(
113
+ "Agent tool is disabled: subagents run on the configured worker provider, not Anthropic. " +
114
+ "Delegate with Bash instead, in the background:\n\n" +
115
+ `pai worker run --label "${label || "task"}" --role research -p '<full, self-contained task spec>' ` +
116
+ "--allowedTools 'Read,Edit,Write,Bash,Grep,Glob' --output-format json\n\n" +
117
+ "- Run it with run_in_background: true and always with a timeout.\n" +
118
+ "- Use --role spotcheck (or implement) as the task demands.\n" +
119
+ "- Web research: add WebSearch,WebFetch to --allowedTools.\n" +
120
+ "- The answer is in the `result` field of the JSON it prints. Review the diff yourself.\n" +
121
+ "- pai worker ps lists running workers; pai worker follow <id> shows one live.\n" +
122
+ "Keep only orchestration, review and synthesis in this session. " +
123
+ "To run subagents on Anthropic for one session: start it with ALLOW_ANTHROPIC_AGENTS=1."
124
+ )
125
+ );
126
+ }
127
+
128
+ main().catch(() => process.stdout.write(ALLOW));
@@ -20,6 +20,7 @@
20
20
  * initialize-session.ts, load-core-context.ts, load-project-context.ts
21
21
  */
22
22
 
23
+ import { isWorkerSession } from "../lib/worker-session.js";
23
24
  import { existsSync, statSync, readFileSync, writeFileSync } from 'fs';
24
25
  import { join } from 'path';
25
26
  import { tmpdir } from 'os';
@@ -99,6 +100,7 @@ async function testStopHook() {
99
100
  }
100
101
 
101
102
  async function main() {
103
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
102
104
  try {
103
105
  // Check if this is a subagent session - if so, exit silently
104
106
  const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || '';
@@ -25,12 +25,14 @@
25
25
  * - Gives your AI immediate access to your complete personal context
26
26
  */
27
27
 
28
+ import { isWorkerSession } from "../lib/worker-session.js";
28
29
  import { readFileSync, existsSync } from 'fs';
29
30
  import { join } from 'path';
30
31
  import { PAI_DIR, SKILLS_DIR } from '../lib/pai-paths';
31
32
  import { isProbeSession } from '../lib/project-utils';
32
33
 
33
34
  async function main() {
35
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
34
36
  try {
35
37
  // Check if this is a subagent session - if so, exit silently
36
38
  const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || '';
@@ -16,6 +16,7 @@
16
16
  * - Sending ntfy.sh notifications
17
17
  */
18
18
 
19
+ import { isWorkerSession } from "../lib/worker-session.js";
19
20
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
20
21
  import { join, basename, dirname, resolve } from 'path';
21
22
  import { homedir } from 'os';
@@ -141,6 +142,7 @@ interface HookInput {
141
142
  }
142
143
 
143
144
  async function main() {
145
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
144
146
  console.error('\nload-project-context.ts starting...');
145
147
 
146
148
  // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)
@@ -16,6 +16,7 @@
16
16
  * SessionStart(compact) → THIS HOOK reads that file → stdout → context
17
17
  */
18
18
 
19
+ import { isWorkerSession } from "../lib/worker-session.js";
19
20
  import { readFileSync, existsSync, unlinkSync } from 'fs';
20
21
  import { join } from 'path';
21
22
  import { tmpdir } from 'os';
@@ -30,6 +31,7 @@ interface HookInput {
30
31
  }
31
32
 
32
33
  async function main() {
34
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
33
35
  let hookInput: HookInput | null = null;
34
36
 
35
37
  try {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { isWorkerSession } from "../lib/worker-session.js";
3
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
4
5
  import { join, basename, dirname } from 'path';
5
6
  import { connect } from 'net';
@@ -704,6 +705,7 @@ function debug(msg: string): void {
704
705
  }
705
706
 
706
707
  async function main() {
708
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
707
709
  if (isProbeSession()) {
708
710
  process.exit(0);
709
711
  }