@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,834 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached job-runner shim (plain ESM — must not depend on Pi's jiti loader).
4
+ *
5
+ * Usage: node job-runner.mjs <configPath>
6
+ *
7
+ * Owns one run: spawns a headless Pi worker (`pi --mode json -p --session <file> <prompt>`),
8
+ * streams its JSON events into events.jsonl, and routes every semantic-state
9
+ * mutation through the View State Coordinator as commands (issue #91): boot
10
+ * run_started, transient run_progress beats on the throttled hot path,
11
+ * auto-state classifications, run_finalized on exit, and the post-exit
12
+ * patch_fields (evidence mirrors / model summary). Evidence artifacts
13
+ * (events/stdout/stderr logs, evidence files, code-refs) stay runner-owned and
14
+ * are written directly. Survives the parent Pi process exiting/reloading.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+ import { appendLine, readJson } from "../src/core/atomic.mjs";
19
+ import { createRunStatus, finalizeRun, reduceEvent } from "../src/core/events.mjs";
20
+ import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
21
+ import { createStopLatch } from "../src/core/stop-latch.mjs";
22
+ import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualVerdict } from "../src/core/auto-state.mjs";
23
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
24
+ import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
25
+ import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
26
+ import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
27
+ import { newRunId } from "../src/core/ids.mjs";
28
+ import { launchRun } from "../src/core/launch.mjs";
29
+ import * as P from "../src/core/paths.mjs";
30
+ import { readState, readStatus, readMeta } from "../src/core/store.mjs";
31
+ import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
32
+ import { sendStateCommand, coordinatorDisabled } from "../src/core/coordinator-client.mjs";
33
+ import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
34
+ import { legacyFollowupBootstrap, legacyPersistState, legacyPlanReadyStateWrite, legacyWriteState, legacyWriteStatus } from "./job-runner-legacy.mjs";
35
+ import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
36
+
37
+ const WRITE_THROTTLE_MS = 250;
38
+
39
+ // Stop-intent latch (issue #153): a stop signal that arrives before the real
40
+ // stop path exists (config read → run_started → worker spawn → handler
41
+ // wiring) must not kill this process via Node's default action — the run
42
+ // would never reach a terminal state. The latch records it; bootstrapRun
43
+ // replays it into stop() once the handlers are wired. A signal during module
44
+ // import (before these handlers exist) still exits by default, but nothing
45
+ // has been published at that point, so no observable state is lost.
46
+ const stopLatch = createStopLatch();
47
+ process.on("SIGTERM", () => stopLatch.note("SIGTERM"));
48
+ process.on("SIGINT", () => stopLatch.note("SIGINT"));
49
+
50
+ /** @param {string[]} args */
51
+ function redactWorkerArgs(args) {
52
+ return args.map((arg, idx) => {
53
+ const prev = args[idx - 1];
54
+ if (["--api-key", "--token", "--password", "--secret"].includes(prev)) return "[redacted]";
55
+ if (/^(sk-|gho_|ghp_)/.test(arg)) return "[redacted]";
56
+ return arg;
57
+ });
58
+ }
59
+
60
+ function main() {
61
+ const configPath = process.argv[2];
62
+ if (!configPath) {
63
+ process.stderr.write("job-runner: missing config path\n");
64
+ process.exit(2);
65
+ }
66
+ /** @type {import("../src/core/types.mjs").RunConfig|null} */
67
+ const config = readJson(configPath, null);
68
+ if (!config) {
69
+ process.stderr.write(`job-runner: cannot read config ${configPath}\n`);
70
+ process.exit(2);
71
+ }
72
+
73
+ const { root, viewId, runId } = config;
74
+ const stdoutLog = P.stdoutPath(root, viewId, runId);
75
+ const stderrLog = P.stderrPath(root, viewId, runId);
76
+ const eventsLog = P.eventsPath(root, viewId, runId);
77
+
78
+ // Read the view meta once so code-ref extraction reuses it across every evidence write.
79
+ const meta = readMeta(root, viewId);
80
+
81
+ let status = createRunStatus(config, null, Date.now());
82
+ // The run starts working the moment the runner is up; seeding the boot status
83
+ // with "working" (instead of createRunStatus's "queued") avoids the stale
84
+ // queued frame flickering through run_started's first materialization.
85
+ status.semanticState = "working";
86
+ let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
87
+ // First observable write BY THE RUNNER (issue #153, A3): the stop latch was
88
+ // armed at module scope, before the runner publishes anything of its own.
89
+ // (The parent's mark_queued/pid.json predate the child; that pre-module shape
90
+ // is converged by service.reconcile() — A10.) Pinned by the stop-window
91
+ // integration test asserting this is diagnostics.jsonl's first entry.
92
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_armed", message: "Stop latch armed before any observable state", details: { signals: ["SIGTERM", "SIGINT"] } });
93
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
94
+ writeRunEvidence(root, evidence);
95
+ writeEvidence(root, evidence);
96
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
97
+ if (coordinatorDisabled()) {
98
+ legacyPersistState({ root, viewId, runId, status });
99
+ }
100
+ return bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog });
101
+ }
102
+
103
+ /**
104
+ * Async continuation of main: route the run_started bootstrap through the
105
+ * coordinator (the status file it creates is what every later run_progress
106
+ * beat patches onto), then spawn the worker and wire the event handlers.
107
+ * @param {{ root: string, viewId: string, runId: string, config: object, status: object, meta: object, evidence: object, stdoutLog: string, stderrLog: string, eventsLog: string }} ctx
108
+ */
109
+ async function bootstrapRun({ root, viewId, runId, config, status, meta, evidence, stdoutLog, stderrLog, eventsLog }) {
110
+ if (!coordinatorDisabled()) {
111
+ // Journaled bootstrap: creates the run's status.json (STATUS_BOOTSTRAP_KINDS)
112
+ // and pins the row to working/alive/currentRunId. Ambiguous outcomes never
113
+ // block the run: if the command was journaled, coordinator replay recovers
114
+ // it; otherwise dashboard reconcile converges the row.
115
+ const started = await sendStateCommand(root, {
116
+ type: "state_command",
117
+ viewId,
118
+ runId,
119
+ source: "job-runner",
120
+ kind: "run_started",
121
+ expectedRevision: null,
122
+ payload: { status: { ...status } },
123
+ });
124
+ if (started.status !== "applied" && started.reason !== "coordinator_disabled") {
125
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_started", "Run bootstrap", started.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: started.reason } });
126
+ }
127
+ }
128
+
129
+ // Test-only boot-window knob (issue #153): deterministically widen the gap
130
+ // between the observable run_started and the handler wiring so a stop can
131
+ // be delivered inside the window without timing luck. Inert when unset.
132
+ const bootWindowMs = Number(process.env.AGENT_BOARD_TEST_BOOT_WINDOW_MS || 0);
133
+ if (bootWindowMs > 0) await new Promise((resolveDelay) => setTimeout(resolveDelay, bootWindowMs));
134
+
135
+ /**
136
+ * One transient progress beat: ship the full in-memory status as a sparse
137
+ * patch — the coordinator merges it onto the materialized status and lets
138
+ * projectViewState recompute the row state (delegation, not copied rules).
139
+ *
140
+ * Fire-and-forget by design (the ONLY such command in the runner): the hot
141
+ * path is a periodic self-healing snapshot (~4/sec), a lost beat is
142
+ * superseded by the next one, and failures are silently ignored — logging
143
+ * them would spam diagnostics at beat frequency. Transient commands are
144
+ * never journaled, so an ambiguous outcome has no replay to await.
145
+ */
146
+ const sendProgressBeat = () => {
147
+ const { materializedRevision: _fileStamp, ...patch } = status;
148
+ void sendStateCommand(root, {
149
+ type: "state_command",
150
+ viewId,
151
+ runId,
152
+ source: "job-runner",
153
+ kind: "run_progress",
154
+ expectedRevision: null,
155
+ payload: { statusPatch: patch },
156
+ }).catch(() => {});
157
+ };
158
+
159
+ // Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
160
+ const args = [
161
+ ...config.piArgsPrefix,
162
+ "--mode",
163
+ "json",
164
+ "-p",
165
+ "--session",
166
+ config.sessionFile,
167
+ ];
168
+ if (config.model) args.push("--model", config.model);
169
+ if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
170
+ if (config.tools) args.push("--tools", config.tools);
171
+ args.push(encodePromptForCliArg(config.prompt));
172
+
173
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_spawn", message: "Worker spawned", details: { command: config.piCommand, args: redactWorkerArgs(args) } });
174
+ const worker = spawn(config.piCommand, args, {
175
+ cwd: config.cwd,
176
+ stdio: ["ignore", "pipe", "pipe"],
177
+ windowsHide: true,
178
+ env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" },
179
+ });
180
+
181
+ status.pid = worker.pid ?? null;
182
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_pid", message: "Worker pid recorded", details: { pid: status.pid } });
183
+ // The pid lands on disk via a transient progress beat (the worker's first
184
+ // events would carry it too, but a silent worker must still be observable).
185
+ sendProgressBeat();
186
+
187
+ let stoppedByUser = false;
188
+ let dirty = false;
189
+ let flushTimer = null;
190
+
191
+ const persist = (force = false) => {
192
+ void force;
193
+ status.evidenceSummary = summarizeEvidence(evidence);
194
+ persistEvidenceArtifacts();
195
+ if (coordinatorDisabled()) legacyPersistState({ root, viewId, runId, status });
196
+ else sendProgressBeat();
197
+ // Best-effort code-refs extraction shells out to git and can take hundreds of
198
+ // ms; it only depends on evidence + git, never on state.json.
199
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
200
+ dirty = false;
201
+ };
202
+
203
+ /**
204
+ * Persist only if the row hasn't taken a manual verdict since the last
205
+ * persist. projectViewState() overwrites the row state unconditionally, so a
206
+ * post-exit model pass must never persist its stale in-memory status over a
207
+ * fresh manual verdict.
208
+ */
209
+ const persistUnlessManual = (force = false) => {
210
+ const latestView = readState(root, viewId);
211
+ if (isManualVerdict(latestView)) return false;
212
+ persist(force);
213
+ return true;
214
+ };
215
+
216
+ /**
217
+ * Runner-owned evidence artifacts (evidence files + code-refs). Written
218
+ * directly — the coordinator owns state.json/status.json, not these.
219
+ */
220
+ const persistEvidenceArtifacts = () => {
221
+ status.evidenceSummary = summarizeEvidence(evidence);
222
+ writeRunEvidence(root, evidence);
223
+ writeEvidence(root, evidence);
224
+ updateCodeRefsFromEvidence(root, viewId, evidence, meta);
225
+ };
226
+
227
+ /**
228
+ * Refresh the evidence mirrors (status.evidenceSummary / state.review)
229
+ * through the coordinator: `patch_fields` carries only whitelisted mirror
230
+ * fields, and the coordinator's generic manual fence (source != user on a
231
+ * row carrying a manual verdict) is the authoritative guard — the old
232
+ * fresh-read pre-checks are no longer needed. Designed fences
233
+ * (manual_fence / no_change) are informational; ambiguous outcomes never
234
+ * fall back to a direct write.
235
+ */
236
+ const refreshEvidenceMirrors = async () => {
237
+ status.evidenceSummary = summarizeEvidence(evidence);
238
+ const result = await sendStateCommand(root, {
239
+ type: "state_command",
240
+ viewId,
241
+ runId: null,
242
+ source: "job-runner",
243
+ kind: "patch_fields",
244
+ expectedRevision: null,
245
+ payload: {
246
+ state: { review: status.evidenceSummary },
247
+ status: { evidenceSummary: status.evidenceSummary },
248
+ },
249
+ });
250
+ if (result.status === "applied") {
251
+ const fresh = readStatus(root, viewId, runId);
252
+ if (fresh) Object.assign(status, fresh);
253
+ return true;
254
+ }
255
+ if (result.reason === "manual_fence" || result.reason === "no_change") return false;
256
+ if (result.reason === "coordinator_disabled") {
257
+ // Legacy escape hatch: fresh-read + fence, pre-coordinator semantics.
258
+ const freshStatus = readStatus(root, viewId, runId);
259
+ if (freshStatus && !isManualVerdict(freshStatus)) {
260
+ freshStatus.evidenceSummary = status.evidenceSummary;
261
+ legacyWriteStatus(root, viewId, runId, freshStatus);
262
+ }
263
+ const freshState = readState(root, viewId);
264
+ if (freshState && !isManualVerdict(freshState)) {
265
+ freshState.review = status.evidenceSummary;
266
+ legacyWriteState(root, viewId, freshState);
267
+ }
268
+ return true;
269
+ }
270
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("evidence_mirror", "Evidence mirror", result.reason, "otherwise the next mirror refresh will converge"), details: { reason: result.reason } });
271
+ return false;
272
+ };
273
+
274
+ /**
275
+ * Materialize the run's terminal state through the View State Coordinator
276
+ * (issue #91, A8 path 3): the runner submits minimal facts and the
277
+ * coordinator computes terminal semantics via finalizeRun, so a manual
278
+ * completion landing before the command is fenced by the coordinator
279
+ * (manual_fence), not by a file re-read (#46 class). Evidence artifacts stay
280
+ * direct (runner-owned). Ambiguous outcomes (timeout / connection_reset)
281
+ * NEVER fall back to a direct write: the command may already be journaled,
282
+ * and the coordinator's boot replay is the recovery path.
283
+ * coordinator_disabled keeps the pre-coordinator direct persist.
284
+ * @param {{ exitCode: number|null, stoppedByUser: boolean }} facts
285
+ * @returns {Promise<boolean>} whether the final state is known materialized
286
+ */
287
+ const finalizeThroughCoordinator = async ({ exitCode, stoppedByUser: stopped }) => {
288
+ const payload = { exitCode, stoppedByUser: stopped };
289
+ if (status.endedAt != null) payload.endedAt = status.endedAt;
290
+ // The close path cancels the pending throttled flush after the final
291
+ // buffer flush, so a stopReason observed in the last burst (reduceEvent
292
+ // sets it in memory only) never reached disk. Overlay it onto the payload:
293
+ // finalizeSemanticState keys on stopReason alone for exit-0 exits, and the
294
+ // coordinator already supports the payload overlay (issue #91).
295
+ if (status.stopReason != null) payload.stopReason = status.stopReason;
296
+ if (status.latestAssistantPreview) payload.latestAssistantPreview = status.latestAssistantPreview;
297
+ if (status.lastAgentActivityAt != null) payload.lastAgentActivityAt = status.lastAgentActivityAt;
298
+ const result = await sendStateCommand(root, {
299
+ type: "state_command",
300
+ viewId,
301
+ runId,
302
+ source: "job-runner",
303
+ kind: "run_finalized",
304
+ expectedRevision: null,
305
+ payload,
306
+ });
307
+ if (result.status === "applied") {
308
+ const fresh = readStatus(root, viewId, runId);
309
+ if (fresh) Object.assign(status, fresh);
310
+ await refreshEvidenceMirrors();
311
+ return true;
312
+ }
313
+ if (result.reason === "coordinator_disabled") {
314
+ persistEvidenceArtifacts();
315
+ legacyPersistState({ root, viewId, runId, status });
316
+ return true;
317
+ }
318
+ if (result.reason === "stale_run") {
319
+ // Duplicate finalize or the run was already superseded — nothing to do.
320
+ return false;
321
+ }
322
+ appendDiagnostic(root, viewId, { source: "runner", runId, ...commandRejectDiagnostic("run_finalize", "Run finalization", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
323
+ return false;
324
+ };
325
+
326
+ const scheduleFlush = () => {
327
+ if (flushTimer) {
328
+ dirty = true;
329
+ return;
330
+ }
331
+ persist();
332
+ flushTimer = setTimeout(() => {
333
+ flushTimer = null;
334
+ if (dirty) scheduleFlush();
335
+ }, WRITE_THROTTLE_MS);
336
+ };
337
+
338
+ // ---- stdout: JSON event stream -----------------------------------------
339
+ let buffer = "";
340
+ const onLine = (line) => {
341
+ const trimmed = line.trim();
342
+ if (!trimmed) return;
343
+ appendLine(eventsLog, trimmed);
344
+ let event;
345
+ try {
346
+ event = JSON.parse(trimmed);
347
+ } catch {
348
+ appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "malformed_event", message: "Worker emitted malformed JSON", details: { preview: trimmed.slice(0, 160) } });
349
+ return;
350
+ }
351
+ // First line is the session header {type:"session",...}; nothing to reduce.
352
+ if (event?.type === "session") return;
353
+ const now = Date.now();
354
+ const statusChanged = reduceEvent(status, event, now);
355
+ const evidenceChanged = reduceEvidence(evidence, event, now);
356
+ if (statusChanged || evidenceChanged) scheduleFlush();
357
+ };
358
+
359
+ worker.stdout.on("data", (chunk) => {
360
+ const text = chunk.toString();
361
+ try {
362
+ appendLine(stdoutLog, text.replace(/\n$/, ""));
363
+ } catch {
364
+ /* ignore raw-log failures */
365
+ }
366
+ buffer += text;
367
+ const lines = buffer.split("\n");
368
+ buffer = lines.pop() ?? "";
369
+ for (const line of lines) onLine(line);
370
+ });
371
+
372
+ worker.stderr.on("data", (chunk) => {
373
+ const text = chunk.toString();
374
+ appendLine(stderrLog, text.replace(/\n$/, ""));
375
+ appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "worker_stderr", message: "Worker wrote to stderr", details: { preview: text.slice(0, 300) } });
376
+ });
377
+
378
+ // ---- termination handling ----------------------------------------------
379
+ const stop = () => {
380
+ stoppedByUser = true;
381
+ try {
382
+ if (worker.pid && !worker.killed) worker.kill("SIGTERM");
383
+ } catch {
384
+ /* ignore */
385
+ }
386
+ setTimeout(() => {
387
+ try {
388
+ if (worker.pid && !worker.killed) worker.kill("SIGKILL");
389
+ } catch {
390
+ /* ignore */
391
+ }
392
+ }, 4000).unref?.();
393
+ };
394
+ process.on("SIGTERM", stop);
395
+ process.on("SIGINT", stop);
396
+
397
+ // Replay a stop observed before the real handlers existed (issue #153).
398
+ // take() clears, so this fires at most once and cannot double-fire with a
399
+ // later real signal; stop() itself is worker.killed-guarded. The diagnostic
400
+ // is emitted first so the latch path stays observable even if stop() no-ops.
401
+ if (stopLatch.take() != null) {
402
+ appendDiagnostic(root, viewId, { source: "runner", runId, code: "stop_latch_replayed", message: "Replaying a stop observed before the handlers were wired", details: {} });
403
+ stop();
404
+ }
405
+
406
+ worker.on("error", async (err) => {
407
+ // Cancel any in-flight throttled flush before finalizing: if the timer
408
+ // callback lands during the sendStateCommand await below, the stale
409
+ // persist() would overwrite the coordinator-materialized terminal state
410
+ // and the close-path run_finalized would then bounce as stale_run
411
+ // (symmetric with the close path's cancel; issue #91 fix round 1).
412
+ if (flushTimer) {
413
+ clearTimeout(flushTimer);
414
+ flushTimer = null;
415
+ }
416
+ status.error = `Failed to launch worker: ${err instanceof Error ? err.message : String(err)}`;
417
+ appendDiagnostic(root, viewId, { source: "runner", runId, level: "error", code: "worker_error", message: status.error, details: {} });
418
+ finalizeRun(status, { exitCode: 1, stoppedByUser }, Date.now());
419
+ finalizeEvidence(evidence, status, Date.now());
420
+ persistEvidenceArtifacts();
421
+ await finalizeThroughCoordinator({ exitCode: 1, stoppedByUser });
422
+ process.exit(1);
423
+ });
424
+
425
+ worker.on("close", async (code) => {
426
+ if (buffer.trim()) onLine(buffer);
427
+ if (flushTimer) {
428
+ clearTimeout(flushTimer);
429
+ flushTimer = null;
430
+ }
431
+ finalizeRun(status, { exitCode: code ?? 0, stoppedByUser }, Date.now());
432
+ finalizeEvidence(evidence, status, Date.now());
433
+ appendDiagnostic(root, viewId, { source: "runner", runId, level: code ? "error" : "info", code: "worker_exit", message: `Worker exited with code ${code ?? 0}`, details: { code, stoppedByUser } });
434
+ // Persist the terminal state (with the heuristic summary) IMMEDIATELY so the
435
+ // dashboard flips to its final state at once. Then try to classify the final
436
+ // bucket and upgrade the summary with cheap model passes. Slow/unreachable
437
+ // model calls must never stall the row indefinitely.
438
+ //
439
+ // Issue #91 (A8 path 3): the terminal status/state materialize through the
440
+ // View State Coordinator (finalizeThroughCoordinator) — only evidence
441
+ // artifacts are written directly here. The in-flight hot-path flush was
442
+ // cancelled above, so no throttled write can race the coordinator's patch.
443
+ persistEvidenceArtifacts();
444
+ await finalizeThroughCoordinator({ exitCode: code ?? 0, stoppedByUser });
445
+ applyHeuristicAutoState(config, status, evidence)
446
+ .then((changed) => {
447
+ if (changed) {
448
+ finalizeEvidence(evidence, status, Date.now());
449
+ if (coordinatorDisabled()) persistUnlessManual(true);
450
+ else refreshEvidenceMirrors();
451
+ }
452
+ return maybeModelAutoState(config, status, evidence);
453
+ })
454
+ .then((changed) => {
455
+ if (changed) {
456
+ finalizeEvidence(evidence, status, Date.now());
457
+ if (coordinatorDisabled()) persistUnlessManual(true);
458
+ else refreshEvidenceMirrors();
459
+ }
460
+ return maybeModelSummary(config, status);
461
+ })
462
+ .then(async (changed) => {
463
+ if (!changed) return;
464
+ if (coordinatorDisabled()) persistUnlessManual(true);
465
+ else await patchSummaryThroughCoordinator(config, status);
466
+ })
467
+ .catch(() => {})
468
+ .then(async () => {
469
+ // The finalize chain must never prevent process.exit: a lock/fs failure
470
+ // here used to pin the runner as a 100% CPU zombie (issue #33). Both
471
+ // steps now await coordinator commands, so they run before the exit.
472
+ try {
473
+ await finalizeSteeringIfNeeded(config, status, evidence);
474
+ } catch (err) {
475
+ tryAppendDiagnostic(config, "finalize_steering_failed", err);
476
+ }
477
+ try {
478
+ await drainQueuedFollowUp(config, status);
479
+ } catch (err) {
480
+ tryAppendDiagnostic(config, "follow_up_drain_failed", err);
481
+ }
482
+ })
483
+ .finally(() => {
484
+ process.exit(stoppedByUser ? 0 : (code ?? 0));
485
+ });
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Route the plan-ready row flip through the coordinator (`plan_ready`): the
491
+ * decision layer carries the exact legacy patch (needs_input/exited/"Approve
492
+ * this plan?") and its manual fence replaces the old file re-read guard.
493
+ * recordPlanReady's steering.json write STAYS direct — steering is not a
494
+ * coordinator artifact. The cheap pre-check remains as an optimization; the
495
+ * coordinator's manual_fence is authoritative. Async because the command must
496
+ * land before the exit-chain process.exit.
497
+ * @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
498
+ async function finalizeSteeringIfNeeded(config, status, evidence) {
499
+ if (config.kind !== "plan" && config.kind !== "plan_change") return;
500
+ if (status.semanticState === "failed" || status.semanticState === "stopped") return;
501
+ // A manual verdict racing the exit chain must not be resurrected for
502
+ // approval: the user already closed this row. Same signal as the other
503
+ // post-exit guards (mark_completed/mark_holding write the verdict state
504
+ // + autoState null to state.json).
505
+ if (isManualVerdict(readState(config.root, config.viewId))) return;
506
+ recordPlanReady(config.root, config.viewId, {
507
+ runId: config.runId,
508
+ planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
509
+ });
510
+ if (coordinatorDisabled()) {
511
+ legacyPlanReadyStateWrite(config.root, config.viewId, config.runId);
512
+ return;
513
+ }
514
+ const result = await sendStateCommand(config.root, {
515
+ type: "state_command",
516
+ viewId: config.viewId,
517
+ runId: config.runId,
518
+ source: "job-runner",
519
+ kind: "plan_ready",
520
+ expectedRevision: null,
521
+ payload: { runId: config.runId },
522
+ });
523
+ if (result.status === "applied") return;
524
+ if (result.reason === "manual_fence" || result.reason === "no_change") return;
525
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("plan_ready", "Plan-ready", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
526
+ }
527
+
528
+ /** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
529
+ async function drainQueuedFollowUp(config, status) {
530
+ if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
531
+ // A manual verdict racing the exit chain must never be followed up: the
532
+ // user just closed this row, so don't launch a new run over it. The
533
+ // in-memory status may be stale (fresh-read guards skip classification), so
534
+ // check the authoritative state.json signal.
535
+ if (isManualVerdict(readState(config.root, config.viewId))) return;
536
+ if (config.kind === "plan" || config.kind === "plan_change") return;
537
+ const claimed = claimNextFollowUp(config.root, config.viewId);
538
+ if (!claimed.ok || !claimed.item) return;
539
+ const item = claimed.item;
540
+ const nextRunId = newRunId();
541
+ const nextConfig = {
542
+ ...config,
543
+ runId: nextRunId,
544
+ kind: runKindForFollowUp(item),
545
+ prompt: promptForFollowUp(config.root, config.viewId, item),
546
+ };
547
+ try {
548
+ const { pid } = launchRun(config.root, nextConfig, { runnerScript: fileURLToPath(import.meta.url) });
549
+ const nextStatus = createRunStatus(nextConfig, pid ?? null, Date.now());
550
+ // Bootstrap the follow-up run through the coordinator: command.runId is
551
+ // deliberately omitted (a parent-run runId would trip the generic stale-run
552
+ // guard against the just-finalized parent); payload.newRunId governs the
553
+ // state-side currentRunId. The new runner's own run_started then lands on
554
+ // top of this bootstrap with the real pid.
555
+ if (coordinatorDisabled()) {
556
+ legacyFollowupBootstrap(config.root, config.viewId, nextStatus);
557
+ } else {
558
+ const result = await sendStateCommand(config.root, {
559
+ type: "state_command",
560
+ viewId: config.viewId,
561
+ source: "job-runner",
562
+ kind: "followup_started",
563
+ expectedRevision: null,
564
+ payload: { newRunId: nextRunId, statusPatch: { ...nextStatus } },
565
+ });
566
+ if (result.reason === "manual_fence") {
567
+ // The user completed the row between the pre-check and this command.
568
+ // The fence preserved their verdict (legacy clobbered it); the child
569
+ // is already launched, so complete the item to avoid a double fire
570
+ // and surface the lost follow-up.
571
+ appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, level: "warn", code: "follow_up_fenced", message: "Manual completion fenced the follow-up bootstrap; the launched run continues but the row keeps its manual verdict", details: { kind: item.kind } });
572
+ completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
573
+ return;
574
+ }
575
+ if (result.status !== "applied" && result.reason !== "no_change" && result.reason !== "stale_run") {
576
+ appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, ...commandRejectDiagnostic("follow_up_bootstrap", "Follow-up bootstrap", result.reason, "otherwise the launched runner's own run_started converges the row"), details: { reason: result.reason } });
577
+ }
578
+ }
579
+ completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
580
+ appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, code: "follow_up_started", message: "Queued follow-up started by JSON runner", details: { kind: item.kind } });
581
+ } catch (err) {
582
+ releaseFollowUp(config.root, config.viewId, item.id);
583
+ appendDiagnostic(config.root, config.viewId, { source: "queue", level: "error", code: "follow_up_drain_failed", message: "JSON runner could not start queued follow-up", details: { error: err instanceof Error ? err.message : String(err) } });
584
+ }
585
+ }
586
+
587
+ /** @param {import("../src/core/types.mjs").RunConfig} config @param {string} code @param {unknown} err */
588
+ function tryAppendDiagnostic(config, code, err) {
589
+ try {
590
+ appendDiagnostic(config.root, config.viewId, {
591
+ source: "runner",
592
+ runId: config.runId,
593
+ level: "error",
594
+ code,
595
+ message: "Finalize step failed",
596
+ details: { error: err instanceof Error ? err.message : String(err) },
597
+ });
598
+ } catch {
599
+ /* root may be deleted — nothing to persist, exit anyway */
600
+ }
601
+ }
602
+
603
+ /** @param {import("../src/core/types.mjs").FollowUpItem} item */
604
+ function runKindForFollowUp(item) {
605
+ switch (item.kind) {
606
+ case "plan_request":
607
+ return "plan";
608
+ case "plan_change":
609
+ return "plan_change";
610
+ case "plan_approval":
611
+ return "plan_approval";
612
+ default:
613
+ return "reply";
614
+ }
615
+ }
616
+
617
+ /** @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
618
+ function latestEvidenceText(evidence) {
619
+ return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
620
+ }
621
+
622
+ /** @param {string} root @param {string} viewId @param {import("../src/core/types.mjs").FollowUpItem} item */
623
+ function promptForFollowUp(root, viewId, item) {
624
+ const steering = readSteering(root, viewId);
625
+ switch (item.kind) {
626
+ case "plan_request":
627
+ return buildPlanRequestPrompt(item.text);
628
+ case "plan_change":
629
+ return buildPlanChangesPrompt(steering.planText, item.text);
630
+ case "plan_approval":
631
+ return buildApprovePlanPrompt(steering.planText);
632
+ default:
633
+ return item.text;
634
+ }
635
+ }
636
+
637
+ function canAutoState(config, status, evidence) {
638
+ if (!autoStateEnabled()) return false;
639
+ if (config.kind === "plan" || config.kind === "plan_change") return false;
640
+ if (status.semanticState === "failed" || status.semanticState === "stopped" || status.processState === "alive") return false;
641
+ return Boolean((latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "").trim());
642
+ }
643
+
644
+ /**
645
+ * Submit one classification to the View State Coordinator (issue #91, A8 path 2).
646
+ * The coordinator owns semantic state: applied patches are materialized by it and
647
+ * this runner only refreshes its in-memory status from disk so any remaining
648
+ * direct persist (PR #1 hot path) starts from authoritative fields. Designed
649
+ * fences (manual_fence / no_change / stale_run) are informational, not errors.
650
+ * Ambiguous transport outcomes (timeout / connection_reset) never fall back to a
651
+ * direct write — the command may already be journaled, and the coordinator's
652
+ * boot replay is the recovery path.
653
+ * @param {import("../src/core/types.mjs").RunConfig} config
654
+ * @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply (fresh coordinator fields)
655
+ * @param {import("../src/core/types.mjs").AutoStateClassification} classification
656
+ * @returns {Promise<boolean>} whether the classification was applied
657
+ */
658
+ async function classifyThroughCoordinator(config, status, classification) {
659
+ const result = await sendStateCommand(config.root, {
660
+ type: "state_command",
661
+ viewId: config.viewId,
662
+ runId: config.runId,
663
+ source: "job-runner",
664
+ kind: "auto_state_classified",
665
+ expectedRevision: null,
666
+ payload: { classification },
667
+ });
668
+ if (result.status === "applied") {
669
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
670
+ const fresh = readStatus(config.root, config.viewId, config.runId);
671
+ if (fresh) Object.assign(status, fresh);
672
+ return true;
673
+ }
674
+ if (result.reason === "coordinator_disabled") {
675
+ // Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): apply locally; the
676
+ // caller's persistUnlessManual keeps the pre-coordinator fence for this path.
677
+ return applyAutoStateToStatus(status, classification, Date.now());
678
+ }
679
+ if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
680
+ // Designed fences — informational, not errors.
681
+ return false;
682
+ }
683
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
684
+ return false;
685
+ }
686
+
687
+ async function applyHeuristicAutoState(config, status, evidence) {
688
+ if (!canAutoState(config, status, evidence)) return false;
689
+ // Cheap pre-check kept as an optimization (avoids a pointless command);
690
+ // correctness no longer depends on it — the coordinator fences manual
691
+ // verdicts authoritatively (manual_fence).
692
+ const latestState = readState(config.root, config.viewId);
693
+ if (isManualVerdict(latestState)) return false;
694
+ const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
695
+ const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
696
+ return classifyThroughCoordinator(config, status, classification);
697
+ }
698
+
699
+ async function maybeModelAutoState(config, status, evidence) {
700
+ if (!canAutoState(config, status, evidence)) return false;
701
+ const model = autoStateModel();
702
+ if (!model) return false;
703
+ const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
704
+ const prompt = buildAutoStatePrompt(latest);
705
+ const out = await runOneShot(
706
+ config.piCommand,
707
+ [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
708
+ 15000,
709
+ );
710
+ // The user may have placed a manual verdict on the row during the model call.
711
+ // The cheap pre-check avoids a pointless command; the coordinator's manual_fence
712
+ // is the authoritative guard for races after this read.
713
+ const fresh = readStatus(config.root, config.viewId, config.runId);
714
+ if (!fresh || isManualVerdict(fresh)) return false;
715
+ Object.assign(status, fresh);
716
+ const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
717
+ return classifyThroughCoordinator(config, status, classification);
718
+ }
719
+
720
+ /** Default cheap model for terminal summaries. Override/disable via $AGENT_BOARD_SUMMARY_MODEL. */
721
+ const DEFAULT_SUMMARY_MODEL = "gpt-4o";
722
+
723
+ /**
724
+ * Cheap-model summary, ON BY DEFAULT (uses {@link DEFAULT_SUMMARY_MODEL}; override with
725
+ * $AGENT_BOARD_SUMMARY_MODEL=<model>, disable with $AGENT_BOARD_SUMMARY_MODEL=off). Overrides
726
+ * status.summary with a short model-generated line. On any failure (no API key, model
727
+ * unavailable, timeout) it silently keeps the heuristic summary already in status.summary.
728
+ * @param {import("../src/core/types.mjs").RunConfig} config
729
+ * @param {import("../src/core/types.mjs").RunStatus} status
730
+ * @returns {Promise<boolean>} whether the summary was upgraded.
731
+ */
732
+ async function maybeModelSummary(config, status) {
733
+ const configured = process.env.AGENT_BOARD_SUMMARY_MODEL ?? process.env.AGENT_VIEW_SUMMARY_MODEL;
734
+ if (configured === "off") return false;
735
+ const model = configured || DEFAULT_SUMMARY_MODEL;
736
+ if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
737
+ const source = status.latestAssistantPreview || status.summary;
738
+ if (!source) return false;
739
+ const prompt = `In 8 words or fewer, summarize what this coding agent just did. No quotes.\n\n${source}`;
740
+ const out = await runOneShot(
741
+ config.piCommand,
742
+ [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
743
+ 15000,
744
+ );
745
+ // The user may have placed a manual verdict on the row during the summary call.
746
+ // Updating the stale status and letting the caller persist would clobber the
747
+ // manual verdict, so bail out before touching the in-memory status.
748
+ if (isManualVerdict(readState(config.root, config.viewId))) return false;
749
+ const text = out.trim().split("\n").slice(-1)[0]?.trim();
750
+ if (text) {
751
+ status.summary = text.replace(/^["']|["']$/g, "").slice(0, 80);
752
+ return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ /**
758
+ * Route the post-exit model-summary upgrade through the coordinator as a
759
+ * `patch_fields` command (summary + latestAssistantPreview are whitelisted for
760
+ * the job-runner source). The generic manual fence replaces the old
761
+ * persistUnlessManual file re-read. runId stays null: the finished run's
762
+ * currentRunId still points at it, so the coordinator binds the status patch
763
+ * to the right file without tripping the stale-run guard.
764
+ * @param {import("../src/core/types.mjs").RunConfig} config
765
+ * @param {import("../src/core/types.mjs").RunStatus} status mutated in place on apply
766
+ * @returns {Promise<boolean>} whether the summary patch was applied
767
+ */
768
+ async function patchSummaryThroughCoordinator(config, status) {
769
+ const result = await sendStateCommand(config.root, {
770
+ type: "state_command",
771
+ viewId: config.viewId,
772
+ runId: null,
773
+ source: "job-runner",
774
+ kind: "patch_fields",
775
+ expectedRevision: null,
776
+ payload: {
777
+ state: { summary: status.summary, latestAssistantPreview: status.latestAssistantPreview },
778
+ status: { summary: status.summary },
779
+ },
780
+ });
781
+ if (result.status === "applied") {
782
+ const fresh = readStatus(config.root, config.viewId, config.runId);
783
+ if (fresh) Object.assign(status, fresh);
784
+ return true;
785
+ }
786
+ if (result.reason === "manual_fence" || result.reason === "no_change") return false;
787
+ appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, ...commandRejectDiagnostic("summary_patch", "Summary patch", result.reason, "otherwise dashboard reconcile will converge the row"), details: { reason: result.reason } });
788
+ return false;
789
+ }
790
+
791
+ /**
792
+ * Run a pi one-shot and return the concatenated assistant text from message_end events.
793
+ * @param {string} command
794
+ * @param {string[]} args
795
+ * @param {number} [timeoutMs]
796
+ * @returns {Promise<string>}
797
+ */
798
+ function runOneShot(command, args, timeoutMs = 20000) {
799
+ return new Promise((resolve) => {
800
+ let out = "";
801
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, env: { ...process.env, AGENT_BOARD_NO_SWEEP: "1" } });
802
+ let buf = "";
803
+ child.stdout.on("data", (c) => {
804
+ buf += c.toString();
805
+ const lines = buf.split("\n");
806
+ buf = lines.pop() ?? "";
807
+ for (const line of lines) {
808
+ try {
809
+ const e = JSON.parse(line);
810
+ if (e?.type === "message_end" && e.message?.role === "assistant") {
811
+ for (const b of e.message.content ?? []) {
812
+ if (b.type === "text") out += b.text;
813
+ }
814
+ }
815
+ } catch {
816
+ /* ignore */
817
+ }
818
+ }
819
+ });
820
+ child.on("close", () => resolve(out));
821
+ child.on("error", () => resolve(""));
822
+ // Safety timeout so a hung summarizer never blocks finalization forever.
823
+ setTimeout(() => {
824
+ try {
825
+ child.kill("SIGKILL");
826
+ } catch {
827
+ /* ignore */
828
+ }
829
+ resolve(out);
830
+ }, timeoutMs).unref?.();
831
+ });
832
+ }
833
+
834
+ main();