harnery 0.31.6 → 0.33.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 (264) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +5 -1
  4. package/dist/commands/agents.d.ts +0 -9
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +280 -94
  7. package/dist/commands/browse-session.d.ts +21 -0
  8. package/dist/commands/browse-session.d.ts.map +1 -0
  9. package/dist/commands/browse-session.js +157 -0
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +205 -37
  12. package/dist/commands/checkpoint.js +1 -1
  13. package/dist/commands/claude-desktop.d.ts +2 -2
  14. package/dist/commands/claude-desktop.d.ts.map +1 -1
  15. package/dist/commands/claude-desktop.js +74 -3
  16. package/dist/commands/deinit.d.ts.map +1 -1
  17. package/dist/commands/deinit.js +4 -0
  18. package/dist/commands/docs.d.ts.map +1 -1
  19. package/dist/commands/docs.js +40 -0
  20. package/dist/commands/doctor.d.ts +4 -2
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +141 -19
  23. package/dist/commands/init.d.ts +2 -2
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +26 -5
  26. package/dist/commands/instructions.d.ts +4 -0
  27. package/dist/commands/instructions.d.ts.map +1 -0
  28. package/dist/commands/instructions.js +100 -0
  29. package/dist/commands/presence.d.ts.map +1 -1
  30. package/dist/commands/presence.js +4 -1
  31. package/dist/commands/tunnel.d.ts.map +1 -1
  32. package/dist/commands/tunnel.js +2 -3
  33. package/dist/core/agents/canonical-emit.d.ts +2 -3
  34. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  35. package/dist/core/agents/canonical-emit.js +6 -5
  36. package/dist/core/agents/cli.js +128 -89
  37. package/dist/core/agents/coord-client.d.ts +18 -3
  38. package/dist/core/agents/coord-client.d.ts.map +1 -1
  39. package/dist/core/agents/coord-client.js +5 -4
  40. package/dist/core/agents/finalization.d.ts +68 -0
  41. package/dist/core/agents/finalization.d.ts.map +1 -0
  42. package/dist/core/agents/finalization.js +443 -0
  43. package/dist/core/agents/git-hook.d.ts +51 -0
  44. package/dist/core/agents/git-hook.d.ts.map +1 -0
  45. package/dist/core/agents/git-hook.js +118 -0
  46. package/dist/core/agents/index.d.ts +1 -0
  47. package/dist/core/agents/index.d.ts.map +1 -1
  48. package/dist/core/agents/index.js +1 -0
  49. package/dist/core/agents/render/prompt-context.d.ts +12 -5
  50. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/prompt-context.js +70 -16
  52. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  53. package/dist/core/agents/render/session-context.js +23 -4
  54. package/dist/core/agents/rules/claim-conflict.js +3 -3
  55. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  56. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  57. package/dist/core/agents/rules/commit-conflict.js +21 -5
  58. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  59. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  60. package/dist/core/agents/rules/stop-hook.js +59 -23
  61. package/dist/core/agents/session-events.d.ts +8 -16
  62. package/dist/core/agents/session-events.d.ts.map +1 -1
  63. package/dist/core/agents/session-events.js +12 -26
  64. package/dist/core/agents/state/heartbeat-projector.d.ts +14 -0
  65. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  66. package/dist/core/agents/state/heartbeat-projector.js +98 -1
  67. package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
  68. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  69. package/dist/core/agents/state/heartbeat-writer.js +86 -17
  70. package/dist/core/agents/state/names.d.ts +22 -1
  71. package/dist/core/agents/state/names.d.ts.map +1 -1
  72. package/dist/core/agents/state/names.js +40 -2
  73. package/dist/core/agents/state/session-state.d.ts +44 -0
  74. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  75. package/dist/core/agents/state/session-state.js +81 -0
  76. package/dist/core/config.d.ts +30 -2
  77. package/dist/core/config.d.ts.map +1 -1
  78. package/dist/core/config.js +74 -11
  79. package/dist/core/governor/planning.d.ts.map +1 -1
  80. package/dist/core/governor/planning.js +12 -13
  81. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  82. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  83. package/dist/core/hooks/adapter/detect.js +4 -14
  84. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  85. package/dist/core/hooks/adapter/events.js +1 -0
  86. package/dist/core/hooks/adapter/output.d.ts +1 -1
  87. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  88. package/dist/core/hooks/adapter/output.js +5 -4
  89. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  90. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  91. package/dist/core/hooks/adapter/parse.js +2 -0
  92. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  93. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/wiring.js +32 -0
  95. package/dist/core/hooks/cli.d.ts +1 -2
  96. package/dist/core/hooks/cli.d.ts.map +1 -1
  97. package/dist/core/hooks/cli.js +258 -32
  98. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  99. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  100. package/dist/core/hooks/codex-authorization.js +199 -0
  101. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  102. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  103. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  104. package/dist/core/hooks/effects/index.d.ts +1 -1
  105. package/dist/core/hooks/effects/index.js +3 -3
  106. package/dist/core/hooks/events/schema.d.ts +57 -1
  107. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  108. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  109. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  110. package/dist/core/hooks/resolve/owner.js +15 -0
  111. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  112. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  113. package/dist/core/hooks/resolve/transcript.js +176 -1
  114. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  115. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  116. package/dist/core/hooks/session-name-presence.js +43 -0
  117. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  118. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  119. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  120. package/dist/core/presence/blob.d.ts +4 -0
  121. package/dist/core/presence/blob.d.ts.map +1 -1
  122. package/dist/core/presence/blob.js +12 -0
  123. package/dist/core/presence/index.js +15 -1
  124. package/dist/core/work/state.d.ts +4 -5
  125. package/dist/core/work/state.d.ts.map +1 -1
  126. package/dist/core/work/state.js +5 -8
  127. package/dist/core/workflow/index.d.ts +1 -1
  128. package/dist/core/workflow/index.d.ts.map +1 -1
  129. package/dist/core/workflow/proof.d.ts +2 -2
  130. package/dist/core/workflow/proof.d.ts.map +1 -1
  131. package/dist/core/workflow/proof.js +2 -2
  132. package/dist/core/workflow/run-state.d.ts +2 -2
  133. package/dist/core/workflow/run-state.d.ts.map +1 -1
  134. package/dist/core/workflow/run-state.js +2 -2
  135. package/dist/core/workflow/types.d.ts +3 -3
  136. package/dist/core/workflow/types.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  138. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  139. package/dist/core/workflow/workspaces/execution.js +5 -0
  140. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  141. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  142. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  143. package/dist/core/workflow/workspaces/local-git.js +1 -2
  144. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  145. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  146. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  147. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  148. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  149. package/dist/core/workflow/workspaces/validate.js +0 -2
  150. package/dist/lib/browser/client.d.ts +118 -1
  151. package/dist/lib/browser/client.d.ts.map +1 -1
  152. package/dist/lib/browser/client.js +435 -6
  153. package/dist/lib/browser/geometry.d.ts.map +1 -1
  154. package/dist/lib/browser/geometry.js +180 -37
  155. package/dist/lib/browser/index.d.ts +5 -1
  156. package/dist/lib/browser/index.d.ts.map +1 -1
  157. package/dist/lib/browser/index.js +5 -1
  158. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  159. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  160. package/dist/lib/browser/netscape-cookies.js +31 -0
  161. package/dist/lib/browser/proxy.d.ts +24 -0
  162. package/dist/lib/browser/proxy.d.ts.map +1 -0
  163. package/dist/lib/browser/proxy.js +84 -0
  164. package/dist/lib/browser/runts.d.ts +6 -0
  165. package/dist/lib/browser/runts.d.ts.map +1 -1
  166. package/dist/lib/browser/runts.js +21 -4
  167. package/dist/lib/browser/session-control.d.ts +112 -0
  168. package/dist/lib/browser/session-control.d.ts.map +1 -0
  169. package/dist/lib/browser/session-control.js +670 -0
  170. package/dist/lib/claude-desktop.d.ts +47 -0
  171. package/dist/lib/claude-desktop.d.ts.map +1 -1
  172. package/dist/lib/claude-desktop.js +169 -1
  173. package/dist/lib/docs-links.d.ts +108 -0
  174. package/dist/lib/docs-links.d.ts.map +1 -0
  175. package/dist/lib/docs-links.js +555 -0
  176. package/dist/lib/exec.d.ts +1 -1
  177. package/dist/lib/exec.js +1 -1
  178. package/dist/lib/identities/assume.d.ts +4 -2
  179. package/dist/lib/identities/assume.d.ts.map +1 -1
  180. package/dist/lib/identities/assume.js +15 -2
  181. package/dist/lib/instructions/bundle.d.ts +33 -0
  182. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  183. package/dist/lib/instructions/bundle.js +149 -0
  184. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  185. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  186. package/dist/lib/instructions/git-hooks.js +238 -0
  187. package/dist/lib/instructions/splice.d.ts +10 -4
  188. package/dist/lib/instructions/splice.d.ts.map +1 -1
  189. package/dist/lib/instructions/splice.js +19 -11
  190. package/package.json +2 -2
  191. package/schemas/config.schema.json +32 -0
  192. package/src/commander.ts +5 -1
  193. package/src/commands/agents.ts +341 -113
  194. package/src/commands/browse-session.ts +245 -0
  195. package/src/commands/browse.ts +287 -44
  196. package/src/commands/checkpoint.ts +1 -1
  197. package/src/commands/claude-desktop.ts +106 -5
  198. package/src/commands/deinit.ts +7 -1
  199. package/src/commands/docs.ts +56 -0
  200. package/src/commands/doctor.ts +155 -21
  201. package/src/commands/init.ts +28 -9
  202. package/src/commands/instructions.ts +129 -0
  203. package/src/commands/presence.ts +7 -1
  204. package/src/commands/tunnel.ts +2 -5
  205. package/src/core/agents/canonical-emit.ts +7 -6
  206. package/src/core/agents/cli.ts +143 -89
  207. package/src/core/agents/coord-client.ts +21 -5
  208. package/src/core/agents/finalization.ts +595 -0
  209. package/src/core/agents/git-hook.ts +126 -0
  210. package/src/core/agents/index.ts +1 -0
  211. package/src/core/agents/render/prompt-context.ts +96 -16
  212. package/src/core/agents/render/session-context.ts +29 -4
  213. package/src/core/agents/rules/claim-conflict.ts +3 -3
  214. package/src/core/agents/rules/commit-conflict.ts +35 -8
  215. package/src/core/agents/rules/stop-hook.ts +71 -23
  216. package/src/core/agents/session-events.ts +14 -41
  217. package/src/core/agents/state/heartbeat-projector.ts +108 -1
  218. package/src/core/agents/state/heartbeat-writer.ts +129 -16
  219. package/src/core/agents/state/names.ts +54 -2
  220. package/src/core/agents/state/session-state.ts +131 -0
  221. package/src/core/config.ts +92 -12
  222. package/src/core/governor/planning.ts +17 -12
  223. package/src/core/hooks/adapter/detect.ts +4 -13
  224. package/src/core/hooks/adapter/events.ts +1 -0
  225. package/src/core/hooks/adapter/output.ts +9 -4
  226. package/src/core/hooks/adapter/parse.ts +3 -0
  227. package/src/core/hooks/adapter/wiring.ts +43 -0
  228. package/src/core/hooks/cli.ts +296 -27
  229. package/src/core/hooks/codex-authorization.ts +253 -0
  230. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  231. package/src/core/hooks/effects/index.ts +5 -5
  232. package/src/core/hooks/events/schema.ts +71 -0
  233. package/src/core/hooks/resolve/owner.ts +22 -1
  234. package/src/core/hooks/resolve/transcript.ts +164 -1
  235. package/src/core/hooks/session-name-presence.ts +52 -0
  236. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  237. package/src/core/presence/blob.ts +18 -0
  238. package/src/core/presence/index.ts +18 -1
  239. package/src/core/work/state.ts +8 -12
  240. package/src/core/workflow/engine.ts +2 -2
  241. package/src/core/workflow/index.ts +0 -1
  242. package/src/core/workflow/proof.ts +4 -4
  243. package/src/core/workflow/run-state.ts +5 -5
  244. package/src/core/workflow/types.ts +3 -3
  245. package/src/core/workflow/workspaces/execution.ts +11 -4
  246. package/src/core/workflow/workspaces/index.ts +0 -3
  247. package/src/core/workflow/workspaces/local-git.ts +1 -2
  248. package/src/core/workflow/workspaces/paths.ts +2 -2
  249. package/src/core/workflow/workspaces/types.ts +0 -9
  250. package/src/core/workflow/workspaces/validate.ts +1 -5
  251. package/src/lib/browser/client.ts +528 -6
  252. package/src/lib/browser/geometry.ts +197 -37
  253. package/src/lib/browser/index.ts +39 -0
  254. package/src/lib/browser/netscape-cookies.ts +39 -0
  255. package/src/lib/browser/proxy.ts +105 -0
  256. package/src/lib/browser/runts.ts +27 -3
  257. package/src/lib/browser/session-control.ts +892 -0
  258. package/src/lib/claude-desktop.ts +232 -1
  259. package/src/lib/docs-links.ts +674 -0
  260. package/src/lib/exec.ts +1 -1
  261. package/src/lib/identities/assume.ts +22 -1
  262. package/src/lib/instructions/bundle.ts +199 -0
  263. package/src/lib/instructions/git-hooks.ts +259 -0
  264. package/src/lib/instructions/splice.ts +41 -11
@@ -1,4 +1,5 @@
1
- import { existsSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
2
3
 
3
4
  /**
4
5
  * Scan a CC-style JSONL transcript for the `┌─ agent-` status-box prefix in
@@ -20,6 +21,56 @@ export function scanStatusBoxPresent(transcriptPath: string | undefined): boolea
20
21
  return text.includes("┌─ agent-");
21
22
  }
22
23
 
24
+ /**
25
+ * Scan a CC-style JSONL transcript for a needle in ASSISTANT message text
26
+ * blocks only. Used by `turn.stop` to detect the suggested session name in the
27
+ * reply.
28
+ *
29
+ * The row filter is load-bearing: the transcript tail also carries the needle
30
+ * inside `tool_result` rows (the `agents set-task` JSON output) and hook
31
+ * context rows, so a raw `includes()` over the tail — the way the status-box
32
+ * scan works — would false-pass the moment set-task runs. Verified empirically
33
+ * 2026-08-09 against a live transcript.
34
+ *
35
+ * Same 256KB tail window as the other scans; the window's first line may be a
36
+ * truncated JSON row, which the per-line parse guard skips.
37
+ */
38
+ export function scanAssistantTextIncludes(
39
+ transcriptPath: string | undefined,
40
+ needle: string,
41
+ ): boolean {
42
+ if (!transcriptPath || !needle || !existsSync(transcriptPath)) return false;
43
+ const text = tailText(transcriptPath);
44
+ if (!text) return false;
45
+ for (const line of text.split("\n")) {
46
+ // Cheap gate before parsing: most lines don't contain the needle at all.
47
+ if (!line.includes(needle)) continue;
48
+ try {
49
+ const row = JSON.parse(line) as {
50
+ type?: unknown;
51
+ message?: { content?: unknown };
52
+ };
53
+ if (row.type !== "assistant") continue;
54
+ const content = row.message?.content;
55
+ if (typeof content === "string") {
56
+ if (content.includes(needle)) return true;
57
+ continue;
58
+ }
59
+ if (!Array.isArray(content)) continue;
60
+ for (const block of content) {
61
+ if (typeof block !== "object" || block === null) continue;
62
+ const b = block as { type?: unknown; text?: unknown };
63
+ if (b.type === "text" && typeof b.text === "string" && b.text.includes(needle)) {
64
+ return true;
65
+ }
66
+ }
67
+ } catch {
68
+ // Truncated/partial line (tail-window start or flush race); skip it.
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+
23
74
  /**
24
75
  * Resolve the agent's model from a CC-style JSONL transcript by reading the
25
76
  * most-recent assistant message's `message.model`. Claude Code's SessionStart
@@ -56,6 +107,118 @@ export function scanTranscriptModel(transcriptPath: string | undefined): string
56
107
  return undefined;
57
108
  }
58
109
 
110
+ const SESSION_FILE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
111
+ /** Skip absurdly large transcripts rather than buffering them. */
112
+ const FORK_SCAN_MAX_BYTES = 128 * 1024 * 1024;
113
+ /** How many message uuids to sample across the fork's copied prefix. */
114
+ const FORK_SAMPLE_SIZE = 24;
115
+
116
+ /**
117
+ * Detect the parent session of a forked CC conversation from its transcript.
118
+ *
119
+ * Empirical basis (verified 2026-08-05 against `claude --resume <id>
120
+ * --fork-session`): the fork copies the parent's rows into a new
121
+ * `<new-session-id>.jsonl`, rewriting every row's `sessionId` to the new id
122
+ * but PRESERVING each copied message row's `uuid`. So a sibling transcript in
123
+ * the same project directory that contains the fork's message uuids is an
124
+ * ancestor of the fork.
125
+ *
126
+ * Ancestor vs. parent: a grandparent (when the parent was itself a fork)
127
+ * shares the fork's early uuids too, but only the true parent contains the
128
+ * whole copied prefix. At detection time the fork's transcript is almost
129
+ * entirely copied prefix, so uuids sampled across the whole file separate the
130
+ * two: score candidates by containment, highest wins.
131
+ *
132
+ * Sibling forks force a second rule: a PRIOR fork of the same parent also
133
+ * contains the whole copied prefix, so it ties the true parent on score. The
134
+ * sibling additionally carries its own post-fork turns, which the parent
135
+ * lacks, so among tied scores the MINIMAL container (fewest message rows)
136
+ * wins. A wrong pick under this rule stays within the ancestry (grandparent
137
+ * at worst); a recency tie-break can name a non-ancestor sibling (observed
138
+ * 2026-08-05 with two probes forked off one parent).
139
+ *
140
+ * Fail-soft by design: any read error, an unflushed transcript, or no
141
+ * qualifying sibling returns undefined, which downgrades the caller to the
142
+ * no-lineage behavior. Never throws.
143
+ */
144
+ export function detectForkParent(
145
+ transcriptPath: string | undefined,
146
+ sessionId: string,
147
+ ): string | undefined {
148
+ try {
149
+ if (!transcriptPath || !sessionId || !existsSync(transcriptPath)) return undefined;
150
+ if (statSync(transcriptPath).size > FORK_SCAN_MAX_BYTES) return undefined;
151
+ const own = readFileSync(transcriptPath, "utf8");
152
+ const uuids = messageUuids(own);
153
+ if (uuids.length === 0) return undefined;
154
+ const sample = sampleEvenly(uuids, FORK_SAMPLE_SIZE);
155
+
156
+ const dir = dirname(transcriptPath);
157
+ const ownFile = basename(transcriptPath);
158
+ let best: { id: string; score: number; rows: number; mtimeMs: number } | null = null;
159
+ for (const file of readdirSync(dir)) {
160
+ if (file === ownFile || !SESSION_FILE_RE.test(file)) continue;
161
+ const candidateId = file.slice(0, -".jsonl".length);
162
+ if (candidateId === sessionId) continue;
163
+ const path = join(dir, file);
164
+ let stat: ReturnType<typeof statSync>;
165
+ try {
166
+ stat = statSync(path);
167
+ } catch {
168
+ continue;
169
+ }
170
+ if (stat.size > FORK_SCAN_MAX_BYTES) continue;
171
+ let text: string;
172
+ try {
173
+ text = readFileSync(path, "utf8");
174
+ } catch {
175
+ continue;
176
+ }
177
+ // Cheap gate before scoring: an ancestor must contain the fork's first
178
+ // copied message uuid (the copy always starts at the beginning).
179
+ if (!text.includes(sample[0]!)) continue;
180
+ let score = 0;
181
+ for (const u of sample) if (text.includes(u)) score++;
182
+ if (score < Math.max(1, Math.ceil(sample.length / 2))) continue;
183
+ const rows = messageUuids(text).length;
184
+ const wins =
185
+ !best ||
186
+ score > best.score ||
187
+ (score === best.score &&
188
+ (rows < best.rows || (rows === best.rows && stat.mtimeMs < best.mtimeMs)));
189
+ if (wins) best = { id: candidateId, score, rows, mtimeMs: stat.mtimeMs };
190
+ }
191
+ return best?.id;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ /** Message-row uuids (user/assistant rows only), in file order. */
198
+ function messageUuids(text: string): string[] {
199
+ const out: string[] = [];
200
+ for (const line of text.split("\n")) {
201
+ if (!line.includes('"uuid"')) continue;
202
+ try {
203
+ const row = JSON.parse(line) as { type?: unknown; uuid?: unknown };
204
+ if (row.type !== "user" && row.type !== "assistant") continue;
205
+ if (typeof row.uuid === "string" && row.uuid.length > 0) out.push(row.uuid);
206
+ } catch {
207
+ // Truncated/partial line (flush race); skip it.
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /** Up to <n> items spread evenly across the list, always including the first. */
214
+ function sampleEvenly(items: string[], n: number): string[] {
215
+ if (items.length <= n) return items;
216
+ const out: string[] = [];
217
+ const step = items.length / n;
218
+ for (let i = 0; i < n; i++) out.push(items[Math.floor(i * step)]!);
219
+ return out;
220
+ }
221
+
59
222
  /** Tail-read up to 256KB from the end of a file as UTF-8, or undefined on error. */
60
223
  function tailText(path: string): string | undefined {
61
224
  try {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Session-name presence for `turn.stop`.
3
+ *
4
+ * While the heartbeat carries a `suggested_session_name`, report whether the
5
+ * naming ritual is satisfied: either this turn's reply shows the name, or an
6
+ * earlier reply already did. Scanning covers assistant text blocks only (the
7
+ * raw transcript tail also carries the name inside the set-task tool_result,
8
+ * which must not count) plus the adapter-supplied last assistant message, for
9
+ * stops that arrive without a transcript.
10
+ *
11
+ * Two properties the stop-hook naming rule depends on:
12
+ *
13
+ * 1. Once a name is satisfied, every later stop keeps reporting `true` rather
14
+ * than omitting the field. The rule wants an in-window `turn.stop` carrying
15
+ * `session_name_present: true`; omitting it after the heartbeat stamp meant
16
+ * nothing could ever re-emit the flag, so every subsequent reply blocked --
17
+ * including the replies reproducing the exact name the rule asked for.
18
+ * 2. The result names WHICH suggested name it covered, so a projector rebuild
19
+ * attributes the sighting to that name instead of to whichever name is
20
+ * current during the replay. Without it, a re-minted name inherited an older
21
+ * name's sighting and looked satisfied without ever having been shown.
22
+ *
23
+ * Never throws: coordination telemetry must not take down a hook.
24
+ */
25
+
26
+ import { readHeartbeat, stampSessionNameSeen } from "../agents/state/heartbeat-writer.ts";
27
+
28
+ export interface SessionNamePresence {
29
+ session_name_present?: boolean;
30
+ session_name_present_for?: string;
31
+ }
32
+
33
+ export function sessionNamePresence(
34
+ coordRoot: string,
35
+ instanceId: string,
36
+ lastAssistantMessage: string,
37
+ scanAssistantText: (name: string) => boolean,
38
+ ): SessionNamePresence {
39
+ try {
40
+ const hb = readHeartbeat(coordRoot, instanceId);
41
+ const name = hb?.suggested_session_name;
42
+ if (!name) return {};
43
+ if (hb?.session_name_seen_for === name) {
44
+ return { session_name_present: true, session_name_present_for: name };
45
+ }
46
+ const present = scanAssistantText(name) || lastAssistantMessage.includes(name);
47
+ if (present) stampSessionNameSeen(coordRoot, instanceId, name);
48
+ return { session_name_present: present, session_name_present_for: name };
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
@@ -0,0 +1,160 @@
1
+ import { isWslUncPath } from "./codex-wsl-bridge.ts";
2
+ import type { Adapter } from "./events/schema.ts";
3
+
4
+ export interface CrossShellInspection {
5
+ adapter: Adapter;
6
+ cwd: unknown;
7
+ toolName: unknown;
8
+ toolInput: unknown;
9
+ }
10
+
11
+ const DENY_REASON =
12
+ "Unsafe Windows-to-WSL shell-string construction blocked. Use the host's argv-preserving " +
13
+ "WSL bridge for a single executable, or its literal-script mode for compound Bash. Do not " +
14
+ "invoke wsl.exe with bash -c or bash -lc.";
15
+
16
+ function shellCommand(toolName: unknown, toolInput: unknown): string | null {
17
+ if (typeof toolName !== "string") return null;
18
+ const normalized = toolName.toLowerCase();
19
+ if (
20
+ normalized !== "bash" &&
21
+ normalized !== "shell" &&
22
+ normalized !== "shell_command" &&
23
+ normalized !== "functions.shell_command"
24
+ ) {
25
+ return null;
26
+ }
27
+
28
+ let input = toolInput;
29
+ if (typeof input === "string") {
30
+ try {
31
+ input = JSON.parse(input) as unknown;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ if (!input || typeof input !== "object") return null;
37
+ const command = (input as Record<string, unknown>).command;
38
+ return typeof command === "string" ? command : null;
39
+ }
40
+
41
+ function quotedToken(command: string, start: number): { token: string; end: number } {
42
+ const quote = command[start]!;
43
+ let token = "";
44
+ for (let index = start + 1; index < command.length; index += 1) {
45
+ const char = command[index]!;
46
+ if (char === "`" && quote === '"' && index + 1 < command.length) {
47
+ token += command[index + 1]!;
48
+ index += 1;
49
+ continue;
50
+ }
51
+ if (char === quote) {
52
+ if (quote === "'" && command[index + 1] === "'") {
53
+ token += "'";
54
+ index += 1;
55
+ continue;
56
+ }
57
+ return { token, end: index + 1 };
58
+ }
59
+ token += char;
60
+ }
61
+ return { token, end: command.length };
62
+ }
63
+
64
+ /** Find direct PowerShell command invocations of wsl/wsl.exe outside quoted strings. */
65
+ function wslInvocationOffsets(command: string): number[] {
66
+ const offsets: number[] = [];
67
+ let expectCommand = true;
68
+ let callOperator = false;
69
+
70
+ for (let index = 0; index < command.length; ) {
71
+ const char = command[index]!;
72
+ if (/\s/.test(char)) {
73
+ if (char === "\n" || char === "\r") {
74
+ expectCommand = true;
75
+ callOperator = false;
76
+ }
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (char === "#" && expectCommand) {
81
+ const newline = command.indexOf("\n", index + 1);
82
+ index = newline === -1 ? command.length : newline + 1;
83
+ expectCommand = true;
84
+ callOperator = false;
85
+ continue;
86
+ }
87
+ if (";|{}=()".includes(char)) {
88
+ expectCommand = true;
89
+ callOperator = false;
90
+ index += 1;
91
+ continue;
92
+ }
93
+ if (char === "&") {
94
+ expectCommand = true;
95
+ callOperator = true;
96
+ index += command[index + 1] === "&" ? 2 : 1;
97
+ continue;
98
+ }
99
+ if (char === "'" || char === '"') {
100
+ const parsed = quotedToken(command, index);
101
+ if (expectCommand && callOperator && /^wsl(?:\.exe)?$/i.test(parsed.token)) {
102
+ offsets.push(index);
103
+ }
104
+ expectCommand = false;
105
+ callOperator = false;
106
+ index = parsed.end;
107
+ continue;
108
+ }
109
+
110
+ const start = index;
111
+ while (index < command.length && !/[\s;|{}=()&]/.test(command[index]!)) index += 1;
112
+ const token = command.slice(start, index);
113
+ if (expectCommand && /^wsl(?:\.exe)?$/i.test(token)) offsets.push(start);
114
+ expectCommand = false;
115
+ callOperator = false;
116
+ }
117
+
118
+ return offsets;
119
+ }
120
+
121
+ function statementEnd(command: string, start: number): number {
122
+ let quote: string | null = null;
123
+ for (let index = start; index < command.length; index += 1) {
124
+ const char = command[index]!;
125
+ if (quote) {
126
+ if (char === "`" && quote === '"') {
127
+ index += 1;
128
+ } else if (char === quote) {
129
+ if (quote === "'" && command[index + 1] === "'") index += 1;
130
+ else quote = null;
131
+ }
132
+ continue;
133
+ }
134
+ if (char === "'" || char === '"') quote = char;
135
+ else if (char === ";" || char === "\n" || char === "\r" || char === "|") return index;
136
+ else if (char === "&" && command[index + 1] === "&") return index;
137
+ }
138
+ return command.length;
139
+ }
140
+
141
+ function invokesBashCommandString(command: string): boolean {
142
+ for (const offset of wslInvocationOffsets(command)) {
143
+ const segment = command.slice(offset, statementEnd(command, offset));
144
+ if (/(?:^|\s)(?:\/bin\/)?bash(?=\s|$)[\s\S]*?\s-[a-z]*c[a-z]*(?=\s|$)/i.test(segment)) {
145
+ return true;
146
+ }
147
+ }
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Deny only the unsafe hybrid shape: Windows-native Codex, WSL UNC workspace,
153
+ * shell tool, and a direct wsl.exe -> bash -c/-lc command-string invocation.
154
+ */
155
+ export function unsafeCrossShellReason(input: CrossShellInspection): string | null {
156
+ if (input.adapter !== "codex" || !isWslUncPath(input.cwd)) return null;
157
+ const command = shellCommand(input.toolName, input.toolInput);
158
+ if (!command || !invokesBashCommandString(command)) return null;
159
+ return DENY_REASON;
160
+ }
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { resolveMachineLabel } from "../../lib/machine.ts";
12
+ import type { AgentActivity, TaskState } from "../agents/state/session-state.ts";
12
13
 
13
14
  /** Mirror of the local heartbeat freshness window (commands/agents.ts). */
14
15
  const FRESHNESS_SECS = 600;
@@ -24,6 +25,9 @@ export interface PresenceAgent {
24
25
  session_id?: string;
25
26
  platform?: string;
26
27
  task?: string;
28
+ activity: AgentActivity;
29
+ task_state: TaskState;
30
+ task_state_reason?: string;
27
31
  turn_summary?: string;
28
32
  files_touched?: string[];
29
33
  last_tool?: string;
@@ -78,6 +82,9 @@ export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): Bu
78
82
  session_id: strOr(hb.session_id),
79
83
  platform: strOr(hb.platform),
80
84
  task: strOr(hb.task),
85
+ activity: activityOrUnknown(hb.activity),
86
+ task_state: taskStateOrActive(hb.task_state),
87
+ task_state_reason: clamp(strOr(hb.task_state_reason), 160),
81
88
  turn_summary: clamp(strOr(hb.turn_summary), 160),
82
89
  files_touched: Array.isArray(hb.files_touched)
83
90
  ? (hb.files_touched as string[]).slice(0, MAX_FILES_PER_AGENT)
@@ -107,6 +114,9 @@ export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): Bu
107
114
  n: a.name ?? null,
108
115
  k: a.kind ?? null,
109
116
  t: a.task ?? null,
117
+ a: a.activity,
118
+ l: a.task_state,
119
+ r: a.task_state_reason ?? null,
110
120
  s: a.turn_summary ?? null,
111
121
  f: [...(a.files_touched ?? [])].sort(),
112
122
  }));
@@ -119,6 +129,14 @@ function strOr(v: unknown): string | undefined {
119
129
  return typeof v === "string" && v.length > 0 ? v : undefined;
120
130
  }
121
131
 
132
+ function activityOrUnknown(v: unknown): AgentActivity {
133
+ return v === "working" || v === "needs_input" || v === "idle" ? v : "unknown";
134
+ }
135
+
136
+ function taskStateOrActive(v: unknown): TaskState {
137
+ return v === "blocked" || v === "done" ? v : "active";
138
+ }
139
+
122
140
  function clamp(v: string | undefined, max: number): string | undefined {
123
141
  if (!v) return undefined;
124
142
  return v.length > max ? v.slice(0, max) : v;
@@ -263,7 +263,7 @@ export function readRemoteMachines(
263
263
  machine,
264
264
  published_at: blob.published_at,
265
265
  age_secs: ageSecs,
266
- agents: blob.agents,
266
+ agents: blob.agents.map(normalizePresenceAgent),
267
267
  });
268
268
  }
269
269
  out.sort((a, b) => a.machine.localeCompare(b.machine));
@@ -272,3 +272,20 @@ export function readRemoteMachines(
272
272
  return [];
273
273
  }
274
274
  }
275
+
276
+ function normalizePresenceAgent(agent: PresenceAgent): PresenceAgent {
277
+ const legacy = agent as Partial<PresenceAgent>;
278
+ return {
279
+ ...agent,
280
+ activity:
281
+ legacy.activity === "working" ||
282
+ legacy.activity === "needs_input" ||
283
+ legacy.activity === "idle"
284
+ ? legacy.activity
285
+ : "unknown",
286
+ task_state:
287
+ legacy.task_state === "blocked" || legacy.task_state === "done"
288
+ ? legacy.task_state
289
+ : "active",
290
+ };
291
+ }
@@ -21,7 +21,7 @@ import { readWorkflowRunManifest, workflowScriptDigest } from "../workflow/run-s
21
21
  import { WORKFLOW_TRANSCRIPT_EVENT_BYTES, workflowTranscriptPath } from "../workflow/transcript.ts";
22
22
  import { isWorkspaceBoundExecutionEvidence } from "../workflow/workspaces/validate.ts";
23
23
 
24
- export const WORK_INTENT_SCHEMA_VERSION = 1 as const;
24
+ export const WORK_INTENT_SCHEMA_VERSION = 2 as const;
25
25
  export const WORK_EVENT_SCHEMA_VERSION = 1 as const;
26
26
 
27
27
  const WORK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
@@ -77,12 +77,11 @@ export interface WorkIntent {
77
77
  dependencies: string[];
78
78
  workflow: { path: string; sha256: string };
79
79
  max_attempts: number;
80
- /** Ceiling on CONSECUTIVE uncharged attempts (ADR 0046), separate from
80
+ /** Ceiling on consecutive uncharged attempts (ADR 0046), separate from
81
81
  * max_attempts. An upstream outage produces uncharged attempt after uncharged
82
82
  * attempt; without a bound it would retry forever. At the bound the item stops
83
- * and reports it is blocked on an outside service. Optional for back-compat:
84
- * an intent written before ADR 0046 has none and falls back to the default. */
85
- max_uncharged_attempts?: number;
83
+ * and reports it is blocked on an outside service. */
84
+ max_uncharged_attempts: number;
86
85
  source?: { kind: "human" | "workflow" | "external"; ref?: string };
87
86
  created_at: string;
88
87
  }
@@ -551,7 +550,7 @@ function deriveWorkProjection(
551
550
  // Count trailing consecutive uncharged attempts in the current window. The
552
551
  // bound is the only brake on an outage that never ends, so at the limit the
553
552
  // item stops and names the outside service — distinct from work-blocked.
554
- const maxUncharged = intent.max_uncharged_attempts ?? DEFAULT_MAX_UNCHARGED_ATTEMPTS;
553
+ const maxUncharged = intent.max_uncharged_attempts;
555
554
  let trailingUncharged = 0;
556
555
  for (let index = currentAttempts.length - 1; index >= 0; index--) {
557
556
  if (currentAttempts[index]!.uncharged === undefined) break;
@@ -1041,12 +1040,9 @@ function validateWorkIntent(intent: WorkIntent, workId: string): void {
1041
1040
  !Number.isSafeInteger(intent.max_attempts) ||
1042
1041
  intent.max_attempts < 1 ||
1043
1042
  intent.max_attempts > 100 ||
1044
- // Optional for back-compat: absent on pre-ADR-0046 intents. When present it
1045
- // must be a valid bound.
1046
- (intent.max_uncharged_attempts !== undefined &&
1047
- (!Number.isSafeInteger(intent.max_uncharged_attempts) ||
1048
- intent.max_uncharged_attempts < 1 ||
1049
- intent.max_uncharged_attempts > 100))
1043
+ !Number.isSafeInteger(intent.max_uncharged_attempts) ||
1044
+ intent.max_uncharged_attempts < 1 ||
1045
+ intent.max_uncharged_attempts > 100
1050
1046
  ) {
1051
1047
  throw new Error(`work intent ${workId} has an unsupported or mismatched schema`);
1052
1048
  }
@@ -87,7 +87,7 @@ import {
87
87
  import type {
88
88
  WorkspaceAttestation,
89
89
  WorkspaceBinding,
90
- WorkspaceUnsupportedExecutionEvidence,
90
+ WorkspaceCompatibilityExecutionEvidence,
91
91
  } from "./workspaces/types.ts";
92
92
  import { isWorkspaceAttestation } from "./workspaces/validate.ts";
93
93
 
@@ -324,7 +324,7 @@ async function executeWorkflow(
324
324
  const fallbackMeta = normalizeWorkflowMeta(undefined, fallbackName);
325
325
  const workspaceProvider = isolation === "shared" ? undefined : opts.workspace?.provider;
326
326
  let workspaceBinding: WorkspaceBinding | undefined;
327
- let workspaceFallback: WorkspaceUnsupportedExecutionEvidence | undefined =
327
+ let workspaceFallback: WorkspaceCompatibilityExecutionEvidence | undefined =
328
328
  resumeState?.manifest.execution.workspace_fallback;
329
329
  try {
330
330
  workspaceBinding = await resolveWorkspaceBinding({
@@ -143,7 +143,6 @@ export type {
143
143
  WorkspaceProviderCapabilities,
144
144
  WorkspaceProviderRef,
145
145
  WorkspaceResourceState,
146
- WorkspaceUnsupportedExecutionEvidence,
147
146
  } from "./workspaces/index.ts";
148
147
  export {
149
148
  applyIntegration,
@@ -40,13 +40,13 @@ import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
40
40
  import type {
41
41
  WorkspaceAttestation,
42
42
  WorkspaceBinding,
43
+ WorkspaceCompatibilityExecutionEvidence,
43
44
  WorkspaceExecutionEvidence,
44
- WorkspaceUnsupportedExecutionEvidence,
45
45
  } from "./workspaces/index.ts";
46
46
  import {
47
47
  isWorkspaceBoundExecutionEvidence,
48
+ isWorkspaceCompatibilityExecutionEvidence,
48
49
  isWorkspaceExecutionEvidence,
49
- isWorkspaceUnsupportedExecutionEvidence,
50
50
  workspaceProofLifecycle,
51
51
  } from "./workspaces/validate.ts";
52
52
 
@@ -101,7 +101,7 @@ export interface BuildWorkflowProofInput {
101
101
  blocked?: { reason: string; decisionId?: string };
102
102
  workspaceBinding?: WorkspaceBinding;
103
103
  workspaceAttestation?: WorkspaceAttestation;
104
- workspaceFallback?: WorkspaceUnsupportedExecutionEvidence;
104
+ workspaceFallback?: WorkspaceCompatibilityExecutionEvidence;
105
105
  result?: unknown;
106
106
  error?: string;
107
107
  }
@@ -401,7 +401,7 @@ export function readWorkflowProof(coordRoot: string, runId: string): WorkflowPro
401
401
  ? isWorkspaceBoundExecutionEvidence(proofExecution, runId) &&
402
402
  stableDigest(proofExecution.binding) === stableDigest(manifestBinding)
403
403
  : manifestFallback !== undefined
404
- ? isWorkspaceUnsupportedExecutionEvidence(proofExecution, runId) &&
404
+ ? isWorkspaceCompatibilityExecutionEvidence(proofExecution, runId) &&
405
405
  stableDigest(proofExecution) === stableDigest(manifestFallback)
406
406
  : proofExecution === undefined;
407
407
  if (!executionMatches) {
@@ -33,12 +33,12 @@ import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
33
33
  import type {
34
34
  WorkspaceAllocationRequest,
35
35
  WorkspaceBinding,
36
- WorkspaceUnsupportedExecutionEvidence,
36
+ WorkspaceCompatibilityExecutionEvidence,
37
37
  } from "./workspaces/index.ts";
38
38
  import {
39
39
  assertWorkspaceManifestAuthority,
40
40
  isWorkspaceBinding,
41
- isWorkspaceUnsupportedExecutionEvidence,
41
+ isWorkspaceCompatibilityExecutionEvidence,
42
42
  } from "./workspaces/validate.ts";
43
43
 
44
44
  export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
@@ -70,7 +70,7 @@ export interface WorkflowRunManifest {
70
70
  policy?: NormalizedPolicy;
71
71
  specialists?: Record<string, WorkflowSpecialistProfile>;
72
72
  workspace_binding?: WorkspaceBinding;
73
- workspace_fallback?: WorkspaceUnsupportedExecutionEvidence;
73
+ workspace_fallback?: WorkspaceCompatibilityExecutionEvidence;
74
74
  };
75
75
  }
76
76
 
@@ -307,9 +307,9 @@ function validWorkspaceBinding(binding: WorkspaceBinding | undefined): boolean {
307
307
  }
308
308
 
309
309
  function validWorkspaceFallback(
310
- fallback: WorkspaceUnsupportedExecutionEvidence | undefined,
310
+ fallback: WorkspaceCompatibilityExecutionEvidence | undefined,
311
311
  ): boolean {
312
- return fallback === undefined || isWorkspaceUnsupportedExecutionEvidence(fallback);
312
+ return fallback === undefined || isWorkspaceCompatibilityExecutionEvidence(fallback);
313
313
  }
314
314
 
315
315
  function validSpecialists(
@@ -425,11 +425,11 @@ export type Spawner = (req: SpawnRequest) => Promise<SpawnResult>;
425
425
  /** The API surface injected into a workflow script's default export. Explicit
426
426
  * injection (no ambient globals): keeps scripts portable and unit-testable. */
427
427
  export interface WorkflowContext {
428
- /** Frozen durable-work assignment for work-linked runs. Standalone and
429
- * legacy-resumed workflows have no work context. */
428
+ /** Frozen durable-work assignment for work-linked runs. Standalone workflows
429
+ * have no work context. */
430
430
  work?: Readonly<WorkflowWorkContext>;
431
431
  /** Frozen attempt identity and, on retry, a bounded synopsis of prior
432
- * terminal evidence. Absent for standalone and legacy-resumed workflows. */
432
+ * terminal evidence. Absent for standalone workflows. */
433
433
  attempt?: Readonly<WorkflowAttemptContext>;
434
434
  /** Spawn one subagent; resolves to validated JSON (schema) or reply text. */
435
435
  agent: (prompt: string, opts?: AgentOpts) => Promise<unknown>;