@tekmidian/pai 0.36.1 → 0.37.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 +428 -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 +48 -7
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +456 -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 +796 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +483 -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-CEIHn_Ma.mjs} +693 -101
  69. package/dist/program-CEIHn_Ma.mjs.map +1 -0
  70. package/dist/providers-sXcK5bDZ.mjs +3067 -0
  71. package/dist/providers-sXcK5bDZ.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 +41 -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 +28 -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 +361 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +303 -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 +13 -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
@@ -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,9 +16,11 @@
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';
23
+ import { resetHandoverTriggerState } from '../../../session/context-handover-trigger.js';
22
24
 
23
25
  interface HookInput {
24
26
  session_id: string;
@@ -29,6 +31,7 @@ interface HookInput {
29
31
  }
30
32
 
31
33
  async function main() {
34
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
32
35
  let hookInput: HookInput | null = null;
33
36
 
34
37
  try {
@@ -53,6 +56,16 @@ async function main() {
53
56
  process.exit(0);
54
57
  }
55
58
 
59
+ // This hook fires exactly once per compaction for this session — the
60
+ // right moment to reset the handover-trigger marker (confirmed/pending
61
+ // thresholds) so the NEXT context-fill cycle gets its own handover
62
+ // instead of being judged "already done" forever. Unconditional: it must
63
+ // run even when no compact-state digest file was found below, since a
64
+ // compaction still happened. Never touches the separate handover CACHE
65
+ // file — that is what gets injected below and must survive this call.
66
+ // See resetHandoverTriggerState() for the full rationale.
67
+ resetHandoverTriggerState(hookInput.session_id);
68
+
56
69
  // Look for the state file saved by context-compression-hook during PreCompact
57
70
  const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);
58
71
 
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { isWorkerSession } from "../lib/worker-session.js";
3
4
  import { readFileSync, existsSync } from 'fs';
4
5
 
5
6
  async function delay(ms: number): Promise<void> {
@@ -145,6 +146,7 @@ function extractCompletionMessage(taskOutput: string): { message: string | null,
145
146
  }
146
147
 
147
148
  async function main() {
149
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
148
150
  console.error('SubagentStop hook started');
149
151
  // Read input from stdin with timeout
150
152
  let input = '';
@@ -6,6 +6,7 @@
6
6
  * Note: All context loading handled by PAI skill system (core identity in skill description, full context in SKILL.md)
7
7
  */
8
8
 
9
+ import { isWorkerSession } from "../lib/worker-session.js";
9
10
  import { execSync } from 'child_process';
10
11
 
11
12
  interface HookInput {
@@ -42,6 +43,7 @@ async function readStdinWithTimeout(timeout: number = 5000): Promise<string> {
42
43
  }
43
44
 
44
45
  async function main() {
46
+ if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
45
47
  try {
46
48
  // Read the hook input from stdin
47
49
  const input = await readStdinWithTimeout();
@@ -533,3 +533,20 @@ if [ "$context_pct" -gt 0 ] 2>/dev/null; then
533
533
  else
534
534
  printf "${LINE3_PRIMARY}${EMOJI_GEM} Context${RESET}${LINE3_PRIMARY}${SEPARATOR_COLOR}: ${RESET}${LINE3_ACCENT}...${ac_suffix}${usage_suffix}${RESET}\n"
535
535
  fi
536
+
537
+ # Line 4: workers launched from this session (running ones with their current
538
+ # step, plus today's finished count). Empty when this session has none.
539
+ # Prefers the standalone built script (plain node, no CLI startup); falls back
540
+ # to the pai CLI.
541
+ worker_status_cmd=""
542
+ if [ -x "${claude_dir}/worker-status-line.mjs" ]; then
543
+ worker_status_cmd="${claude_dir}/worker-status-line.mjs"
544
+ elif command -v pai >/dev/null 2>&1; then
545
+ worker_status_cmd="pai worker status-line"
546
+ fi
547
+ if [ -n "$worker_status_cmd" ]; then
548
+ worker_line="$("$worker_status_cmd" "${ITERM_SESSION_ID:-}" "${PWD:-}" 2>/dev/null)"
549
+ if [ -n "$worker_line" ]; then
550
+ printf "${LINE3_PRIMARY}🐝 ${RESET}${LINE3_ACCENT}%s${RESET}\n" "$worker_line"
551
+ fi
552
+ fi
@@ -1,20 +0,0 @@
1
- import "./db-Ca5qfsMC.mjs";
2
- import "./utils-9Err2RBW.mjs";
3
- import "./migrate-Cjzeefn9.mjs";
4
- import "./db-a1ixZQjr.mjs";
5
- import "./helpers-IjZkXBhj.mjs";
6
- import "./sync-BWbe8JTg.mjs";
7
- import "./embeddings-DOLZnT1X.mjs";
8
- import "./search-Rpk1cSBC.mjs";
9
- import "./context-handover-cache-PtNvj_8D.mjs";
10
- import "./indexer-D7MvSQPY.mjs";
11
- import "./config-B64vFg14.mjs";
12
- import "./factory-Bsp7xOpO.mjs";
13
- import { t as serve } from "./daemon-BZ93KRBo.mjs";
14
- import "./state-WaXhLr6R.mjs";
15
- import "./router-CsDm7HvK.mjs";
16
- import "./tools-ceiy7ANX.mjs";
17
- import "./detector--Gg5JRN5.mjs";
18
- import "./work-queue-worker-B8W8_3Rn.mjs";
19
-
20
- export { serve };
@@ -1,5 +0,0 @@
1
- import "./embeddings-DOLZnT1X.mjs";
2
- import "./search-Rpk1cSBC.mjs";
3
- import { t as detectTopicShift } from "./detector--Gg5JRN5.mjs";
4
-
5
- export { detectTopicShift };