@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,377 @@
1
+ /**
2
+ * Automatic terminal-state classification for finished agent turns.
3
+ *
4
+ * The dashboard has three user-facing post-run buckets:
5
+ * - needs_input → agent is waiting on the user
6
+ * - idle → in progress / worth revisiting
7
+ * - completed → done
8
+ *
9
+ * This module is pure (no model/network I/O). Runners may feed model JSON into
10
+ * parseAutoStateModelOutput(); otherwise heuristicAutoState() gives a safe fallback.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { deriveSummary } from "./derive.mjs";
14
+ import { detectNeedsInput, firstSentence, splitSentences, truncate } from "./heuristics.mjs";
15
+
16
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
17
+ /** @typedef {"needs_input"|"in_progress"|"done"} AutoStateKind */
18
+ /** @typedef {"model"|"heuristic"} AutoStateSource */
19
+ /** @typedef {"high"|"medium"|"low"} AutoStateConfidence */
20
+
21
+ const AUTO_STATE_KINDS = new Set(["needs_input", "in_progress", "done"]);
22
+ const CONFIDENCES = new Set(["high", "medium", "low"]);
23
+ const DEFAULT_AUTO_STATE_MODEL = "gpt-4o";
24
+
25
+ /** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
26
+ export function autoStateEnabled(env = process.env) {
27
+ const raw = env.AGENT_BOARD_AUTO_STATE ?? env.AGENT_VIEW_AUTO_STATE;
28
+ return !isOff(raw);
29
+ }
30
+
31
+ /** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
32
+ export function autoStateModel(env = process.env) {
33
+ const configured = env.AGENT_BOARD_AUTO_STATE_MODEL ?? env.AGENT_VIEW_AUTO_STATE_MODEL;
34
+ if (isOff(configured)) return null;
35
+ return configured || DEFAULT_AUTO_STATE_MODEL;
36
+ }
37
+
38
+ /** @param {string|undefined} value */
39
+ function isOff(value) {
40
+ return typeof value === "string" && /^(0|false|off|no)$/i.test(value.trim());
41
+ }
42
+
43
+ /**
44
+ * Whether automatic `done` classification is disabled.
45
+ * Default (env unset): disabled — completed is only ever set by the user.
46
+ * Set AGENT_BOARD_AUTO_STATE_NO_DONE to 0/false/off/no to restore auto-done.
47
+ * @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
48
+ */
49
+ export function autoStateDoneDisabled(env = process.env) {
50
+ const raw = env.AGENT_BOARD_AUTO_STATE_NO_DONE;
51
+ if (typeof raw !== "string" || !raw.trim()) return true;
52
+ return !isOff(raw);
53
+ }
54
+
55
+ /** @param {AutoStateKind} kind @returns {SemanticState} */
56
+ export function semanticStateForAutoKind(kind) {
57
+ switch (kind) {
58
+ case "needs_input":
59
+ return "needs_input";
60
+ case "done":
61
+ return "completed";
62
+ default:
63
+ return "idle";
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Prompt used by runners for the cheap classifier pass.
69
+ * @param {string} latestAssistantText
70
+ * @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env]
71
+ */
72
+ export function buildAutoStatePrompt(latestAssistantText, env = process.env) {
73
+ const text = truncate(String(latestAssistantText || "").trim(), 6000);
74
+ const doneLine = autoStateDoneDisabled(env)
75
+ ? ""
76
+ : "- done: the requested work is complete, final answer given, no user input required.\n";
77
+ const doneNote = autoStateDoneDisabled(env)
78
+ ? "The user marks completed manually in the dashboard, so completion signals (done/completed/finished) must be classified as in_progress.\n"
79
+ : "";
80
+ const states = autoStateDoneDisabled(env) ? "needs_input|in_progress" : "needs_input|in_progress|done";
81
+ return `Classify the LAST assistant response for a coding-agent dashboard.\n\nChoose exactly one state:\n- needs_input: the assistant asks the user for a decision, clarification, approval, credentials, or is blocked waiting for the user.\n- in_progress: work is partial, next steps remain, verification is pending/failed, or the assistant says it will continue later.\n${doneLine}${doneNote}\nReturn ONLY minified JSON with this shape:\n{"state":"${states}","confidence":"high|medium|low","reason":"short reason <=18 words","question":"user-facing question or null"}\n\nLast assistant response:\n${text}`;
82
+ }
83
+
84
+ /**
85
+ * Parse and normalize model JSON. Returns null if the response is unusable.
86
+ * @param {string} raw
87
+ * @param {{ latestAssistantText?: string, now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
88
+ */
89
+ export function parseAutoStateModelOutput(raw, opts = {}) {
90
+ const obj = extractJsonObject(raw);
91
+ if (!obj) return null;
92
+ let kind = normalizeKind(obj.state ?? obj.kind ?? obj.status);
93
+ if (!kind) return null;
94
+ let downgraded = false;
95
+ if (kind === "done" && autoStateDoneDisabled(opts.env ?? process.env)) {
96
+ kind = "in_progress";
97
+ downgraded = true;
98
+ }
99
+ const confidence = normalizeConfidence(obj.confidence);
100
+ const latest = opts.latestAssistantText ?? "";
101
+ const nb = detectNeedsInput(latest);
102
+ const question = kind === "needs_input" ? cleanQuestion(obj.question) || nb.question || latestQuestion(latest) : null;
103
+ return makeClassification(kind, {
104
+ source: "model",
105
+ confidence,
106
+ reason: downgraded
107
+ ? "Model reported done but auto-done is disabled"
108
+ : cleanReason(obj.reason) || defaultReason(kind),
109
+ question,
110
+ now: opts.now,
111
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
112
+ latestAssistantText: latest,
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Conservative fallback classifier. It should be useful, but never clever enough to
118
+ * overrule strong question/error signals.
119
+ * @param {string} latestAssistantText
120
+ * @param {{ now?: number, lastAgentActivityAt?: number|null, env?: NodeJS.ProcessEnv|Record<string,string|undefined> }} [opts]
121
+ */
122
+ export function heuristicAutoState(latestAssistantText, opts = {}) {
123
+ const text = String(latestAssistantText || "").trim();
124
+ const nb = detectNeedsInput(text);
125
+ if (nb.needsInput) {
126
+ return makeClassification("needs_input", {
127
+ source: "heuristic",
128
+ confidence: "high",
129
+ reason: "Assistant asked for user input",
130
+ question: nb.question,
131
+ now: opts.now,
132
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
133
+ latestAssistantText: text,
134
+ });
135
+ }
136
+
137
+ const lower = text.toLowerCase();
138
+ const pending = hasPendingSignal(lower);
139
+ const done = hasDoneSignal(lower);
140
+ const env = opts.env ?? process.env;
141
+ if (done && !pending && !autoStateDoneDisabled(env)) {
142
+ return makeClassification("done", {
143
+ source: "heuristic",
144
+ confidence: hasStrongDoneSignal(lower) ? "high" : "medium",
145
+ reason: "Assistant reported the work is complete",
146
+ now: opts.now,
147
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
148
+ latestAssistantText: text,
149
+ });
150
+ }
151
+ if (done && !pending) {
152
+ // Auto-done disabled: completion signals stay out of the completed bucket;
153
+ // the user marks completed manually from the dashboard.
154
+ return makeClassification("in_progress", {
155
+ source: "heuristic",
156
+ confidence: hasStrongDoneSignal(lower) ? "medium" : "low",
157
+ reason: "Assistant reported completion but auto-done is disabled",
158
+ now: opts.now,
159
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
160
+ latestAssistantText: text,
161
+ });
162
+ }
163
+ if (pending) {
164
+ return makeClassification("in_progress", {
165
+ source: "heuristic",
166
+ confidence: "medium",
167
+ reason: "Assistant mentioned remaining work or pending verification",
168
+ now: opts.now,
169
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
170
+ latestAssistantText: text,
171
+ });
172
+ }
173
+ return makeClassification("in_progress", {
174
+ source: "heuristic",
175
+ confidence: "low",
176
+ reason: "No clear completion or input signal",
177
+ now: opts.now,
178
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
179
+ latestAssistantText: text,
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Pick a model classification when valid, otherwise fall back to heuristics.
185
+ * @param {string} modelOutput
186
+ * @param {string} latestAssistantText
187
+ * @param {{ now?: number, lastAgentActivityAt?: number|null }} [opts]
188
+ */
189
+ export function autoStateFromModelOrHeuristic(modelOutput, latestAssistantText, opts = {}) {
190
+ return parseAutoStateModelOutput(modelOutput, { latestAssistantText, ...opts }) ?? heuristicAutoState(latestAssistantText, opts);
191
+ }
192
+
193
+ /**
194
+ * Whether a row/status carries a manual verdict (issue #145): the user placed a
195
+ * terminal judgment ("completed" or "holding") that no automated writer may
196
+ * overwrite. mark_completed and mark_holding both clear autoState on
197
+ * state.json and status.json, so `autoState == null` combined with a verdict
198
+ * state is the persisted signal. Wide on purpose (spec D15): no call site
199
+ * needs a completed-only predicate.
200
+ * @param {{ semanticState?: string, autoState?: unknown|null }|null|undefined} state
201
+ */
202
+ export function isManualVerdict(state) {
203
+ return Boolean(state && (state.semanticState === "completed" || state.semanticState === "holding") && state.autoState == null);
204
+ }
205
+
206
+ /**
207
+ * Mutate a RunStatus with a classification. Returns true if state/metadata changed.
208
+ * @param {import("./types.mjs").RunStatus} status
209
+ * @param {import("./types.mjs").AutoStateClassification} classification
210
+ * @param {number} [now]
211
+ */
212
+ export function applyAutoStateToStatus(status, classification, now = Date.now()) {
213
+ if (!classification || status.processState === "alive") return false;
214
+ if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
215
+ // Manual verdicts are user judgments and must never be overwritten.
216
+ if (isManualVerdict(status)) return false;
217
+ const nextState = semanticStateForAutoKind(classification.kind);
218
+ const before = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
219
+ status.semanticState = nextState;
220
+ status.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
221
+ if (nextState === "needs_input") {
222
+ status.question = classification.question || detectNeedsInput(status.latestAssistantPreview).question || latestQuestion(status.latestAssistantPreview);
223
+ } else {
224
+ status.question = null;
225
+ }
226
+ status.summary = deriveSummary(status);
227
+ const after = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
228
+ return before !== after;
229
+ }
230
+
231
+ /**
232
+ * Mutate a ViewState with a classification. Returns true if state/metadata changed.
233
+ * @param {import("./types.mjs").ViewState} state
234
+ * @param {import("./types.mjs").AutoStateClassification} classification
235
+ * @param {number} [now]
236
+ */
237
+ export function applyAutoStateToViewState(state, classification, now = Date.now()) {
238
+ if (!classification || state.processState === "alive") return false;
239
+ if (state.semanticState === "failed" || state.semanticState === "stopped") return false;
240
+ // Manual verdicts are user judgments and must never be overwritten.
241
+ if (isManualVerdict(state)) return false;
242
+ const nextState = semanticStateForAutoKind(classification.kind);
243
+ const before = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
244
+ state.semanticState = nextState;
245
+ state.needsInput = nextState === "needs_input";
246
+ state.hasError = nextState === "failed";
247
+ state.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
248
+ if (nextState === "needs_input") {
249
+ state.question = classification.question || detectNeedsInput(state.latestAssistantPreview).question || latestQuestion(state.latestAssistantPreview);
250
+ } else {
251
+ state.question = null;
252
+ }
253
+ state.error = nextState === "failed" ? state.error : null;
254
+ state.updatedAt = now;
255
+ state.summary = deriveSummary({
256
+ processState: state.processState,
257
+ semanticState: state.semanticState,
258
+ currentTool: state.latestTool,
259
+ question: state.question,
260
+ error: state.error,
261
+ latestAssistantPreview: state.latestAssistantPreview,
262
+ });
263
+ const after = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
264
+ return before !== after;
265
+ }
266
+
267
+ /**
268
+ * @param {AutoStateKind} kind
269
+ * @param {{ source: AutoStateSource, confidence?: AutoStateConfidence, reason?: string, question?: string|null, now?: number, lastAgentActivityAt?: number|null, latestAssistantText?: string }} opts
270
+ * @returns {import("./types.mjs").AutoStateClassification}
271
+ */
272
+ function makeClassification(kind, opts) {
273
+ const now = opts.now ?? Date.now();
274
+ const semanticState = semanticStateForAutoKind(kind);
275
+ return {
276
+ version: 1,
277
+ kind,
278
+ semanticState,
279
+ confidence: opts.confidence ?? "low",
280
+ source: opts.source,
281
+ reason: truncate(String(opts.reason || defaultReason(kind)).replace(/\s+/g, " ").trim(), 120),
282
+ question: opts.question ? truncate(String(opts.question).replace(/\s+/g, " ").trim(), 240) : null,
283
+ classifiedAt: now,
284
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
285
+ textHash: textHash(opts.latestAssistantText ?? ""),
286
+ };
287
+ }
288
+
289
+ /** @param {string} raw */
290
+ function extractJsonObject(raw) {
291
+ const text = String(raw || "").trim();
292
+ if (!text) return null;
293
+ const candidates = [text];
294
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
295
+ if (fenced) candidates.unshift(fenced[1].trim());
296
+ const first = text.indexOf("{");
297
+ const last = text.lastIndexOf("}");
298
+ if (first >= 0 && last > first) candidates.unshift(text.slice(first, last + 1));
299
+ for (const candidate of candidates) {
300
+ try {
301
+ const parsed = JSON.parse(candidate);
302
+ if (parsed && typeof parsed === "object") return parsed;
303
+ } catch {
304
+ /* try next */
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** @param {any} value @returns {AutoStateKind|null} */
311
+ function normalizeKind(value) {
312
+ const s = String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
313
+ if (AUTO_STATE_KINDS.has(s)) return /** @type {AutoStateKind} */ (s);
314
+ if (["needsinput", "input", "input_required", "requires_input", "awaiting_input", "blocked", "question"].includes(s)) return "needs_input";
315
+ if (["progress", "ongoing", "continue", "continuing", "partial", "not_done", "todo"].includes(s)) return "in_progress";
316
+ if (["complete", "completed", "finished", "finish", "success", "resolved"].includes(s)) return "done";
317
+ return null;
318
+ }
319
+
320
+ /** @param {any} value @returns {AutoStateConfidence} */
321
+ function normalizeConfidence(value) {
322
+ const s = String(value ?? "medium").toLowerCase().trim();
323
+ return CONFIDENCES.has(s) ? /** @type {AutoStateConfidence} */ (s) : "medium";
324
+ }
325
+
326
+ /** @param {any} value */
327
+ function cleanReason(value) {
328
+ return String(value ?? "").replace(/\s+/g, " ").trim();
329
+ }
330
+
331
+ /** @param {any} value */
332
+ function cleanQuestion(value) {
333
+ const s = String(value ?? "").replace(/\s+/g, " ").trim();
334
+ if (!s || /^null$/i.test(s) || /^none$/i.test(s)) return null;
335
+ return s;
336
+ }
337
+
338
+ /** @param {AutoStateKind} kind */
339
+ function defaultReason(kind) {
340
+ switch (kind) {
341
+ case "needs_input":
342
+ return "Assistant is waiting for user input";
343
+ case "done":
344
+ return "Assistant reported completion";
345
+ default:
346
+ return "Assistant appears to have more work remaining";
347
+ }
348
+ }
349
+
350
+ /** @param {string} text */
351
+ function latestQuestion(text) {
352
+ const sentences = splitSentences(text);
353
+ return [...sentences].reverse().find((s) => /\?\s*$/.test(s)) ?? null;
354
+ }
355
+
356
+ /** @param {string} lower */
357
+ function hasStrongDoneSignal(lower) {
358
+ return /(^|\b)(done|completed|complete|finished|resolved|all set|implemented|shipped)(\b|[.!])/i.test(lower) || /tests? (pass|passed|passing)/i.test(lower);
359
+ }
360
+
361
+ /** @param {string} lower */
362
+ function hasDoneSignal(lower) {
363
+ return hasStrongDoneSignal(lower)
364
+ || /\b(final answer|summary:|i'?ve (fixed|updated|added|implemented)|successfully|ready to go)\b/i.test(lower)
365
+ || firstSentence(lower).length > 0 && /^done[.!]/i.test(firstSentence(lower));
366
+ }
367
+
368
+ /** @param {string} lower */
369
+ function hasPendingSignal(lower) {
370
+ return /\b(todo|next steps?|remaining|still need|need to|not yet|pending|in progress|continue|will continue|i'?ll continue|i will|follow up|not run|couldn'?t run|unable to run|blocked|waiting for)\b/i.test(lower)
371
+ || /\btests? (fail|failed|failing|pending|not run|weren'?t run|couldn'?t be run)\b/i.test(lower);
372
+ }
373
+
374
+ /** @param {string} text */
375
+ function textHash(text) {
376
+ return createHash("sha1").update(String(text || "")).digest("hex").slice(0, 12);
377
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Background-workflow run detection for warm-host eviction (issue #230).
3
+ *
4
+ * A warm host whose session owns a `running` pi-workflow run must not be swept:
5
+ * eviction revokes the host, the child pi gets SIGTERM, and the in-flight agent
6
+ * step turns into a `skipped/interrupted` event — the run ends `paused` while the
7
+ * user is never told. The sweeper cannot see this from row state because
8
+ * `workflow({ background: true })` returns `started` before the step finishes, so
9
+ * the row falls back to `idle` and looks evictable.
10
+ *
11
+ * This module is the decision core plus its thin IO shell: parse a run head
12
+ * record, match it to a row's session file, classify the protection outcome, and
13
+ * enumerate/probe the on-disk run store (`scanBackgroundRuns`/`probeBackgroundRun`).
14
+ * Design: docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md
15
+ */
16
+ import { readdirSync, readFileSync, statSync } from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+
20
+ /** Timestamp tolerance for FS mtime granularity and cross-process clock skew
21
+ * when comparing a run's `updatedAt` against the host start time (spec D1). */
22
+ export const CLOCK_EPSILON_MS = 2000;
23
+
24
+ /**
25
+ * @typedef {Object} RunHead
26
+ * @property {string} runId
27
+ * @property {string} status
28
+ * @property {string|null} parentSessionFile legacy records carry none
29
+ * @property {number|null} startedAtMs
30
+ * @property {number|null} updatedAtMs
31
+ * @property {boolean} hasCheckpoint
32
+ */
33
+
34
+ /** @typedef {"protecting"|"paused"|"irrelevant"} RunVerdict */
35
+
36
+ /**
37
+ * Root directory of pi-dynamic-workflows: `$AGENT_BOARD_WORKFLOW_HOME` (explicit
38
+ * override) > `$PI_CODING_AGENT_DIR/workflows` > `<home>/.pi/workflows`.
39
+ * Both `env` and `home` are injectable so callers and tests never depend on the
40
+ * ambient environment or the real homedir.
41
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [o]
42
+ * @returns {string}
43
+ */
44
+ export function resolveWorkflowHome({ env = process.env, home = os.homedir() } = {}) {
45
+ const expand = (p) => (p.startsWith("~") ? home + p.slice(1) : p);
46
+ const explicit = env.AGENT_BOARD_WORKFLOW_HOME;
47
+ if (typeof explicit === "string" && explicit.length > 0) return expand(explicit);
48
+ const agentDir = env.PI_CODING_AGENT_DIR;
49
+ const base = typeof agentDir === "string" && agentDir.length > 0 ? expand(agentDir) : joinPath(home, ".pi");
50
+ return joinPath(base, "workflows");
51
+ }
52
+
53
+ /**
54
+ * Parse a run head record into the fields the protection decision needs, or
55
+ * `null` when it is not a workflow run. A v2 record nests the run fields under
56
+ * `index` but keeps `summary` (and therefore the checkpoint) as a sibling at the
57
+ * record root; legacy flat records carry `checkpoint` at the root themselves and
58
+ * can never match a row because they have no `parentSessionFile` (spec non-goal:
59
+ * no legacy session matching).
60
+ * Never throws: the input is external, unversioned JSON.
61
+ * @param {unknown} raw parsed JSON
62
+ * @returns {RunHead|null}
63
+ */
64
+ export function parseRunHead(raw) {
65
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
66
+ const record = /** @type {Record<string, any>} */ (raw);
67
+ const source = record.format === "pi-workflow-run-v2" ? record.index : record;
68
+ if (typeof source !== "object" || source === null || Array.isArray(source)) return null;
69
+ if (typeof source.runId !== "string") return null;
70
+ return {
71
+ runId: source.runId,
72
+ status: typeof source.status === "string" ? source.status : "",
73
+ parentSessionFile: typeof source.parentSessionFile === "string" ? source.parentSessionFile : null,
74
+ startedAtMs: toMs(source.startedAt),
75
+ updatedAtMs: toMs(source.updatedAt),
76
+ hasCheckpoint: Boolean(record.summary?.checkpoint ?? source.checkpoint),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Whether two spellings point at the same session file. win32 compares
82
+ * case-insensitively (the row meta and the workflow record disagree on drive
83
+ * letter and segment casing; they also mix `\` and `/`); POSIX stays
84
+ * case-sensitive. `platform` is injectable because CI only runs Linux, so the
85
+ * win32 branch would otherwise be untested (spec A5).
86
+ * @param {string} a
87
+ * @param {string} b
88
+ * @param {{ platform?: string }} [o]
89
+ * @returns {boolean}
90
+ */
91
+ export function isSameSessionFile(a, b, { platform = process.platform } = {}) {
92
+ if (typeof a !== "string" || typeof b !== "string" || a.length === 0 || b.length === 0) return false;
93
+ const impl = platform === "win32" ? path.win32 : path.posix;
94
+ const na = impl.resolve(a);
95
+ const nb = impl.resolve(b);
96
+ return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
97
+ }
98
+
99
+ /**
100
+ * Protection verdict for one run against one row (spec D1/D2).
101
+ *
102
+ * Order matters: a run belonging to another session is always irrelevant (a
103
+ * running or paused run of a sibling row must never keep this host alive);
104
+ * `paused` is reported separately because a paused run needs no host at all and
105
+ * only drives the "run was interrupted" notice; a running run protects only when
106
+ * it was updated after the host started, so a stale `running` record left behind
107
+ * by a previous host incarnation cannot exempt the row forever.
108
+ * @param {RunHead|null|undefined} run
109
+ * @param {{ sessionFile: string|null|undefined, hostStartedAt: number|null|undefined }} o
110
+ * @returns {RunVerdict}
111
+ */
112
+ export function classifyRunForRow(run, { sessionFile, hostStartedAt }) {
113
+ if (!run || !run.parentSessionFile) return "irrelevant";
114
+ if (!isSameSessionFile(run.parentSessionFile, sessionFile)) return "irrelevant";
115
+ if (run.status === "paused") return "paused";
116
+ if (run.status !== "running") return "irrelevant";
117
+ if (hostStartedAt == null || run.updatedAtMs == null) return "protecting";
118
+ if (run.updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS) return "protecting";
119
+ return "irrelevant";
120
+ }
121
+
122
+ /**
123
+ * Strongest verdict across a scanned head list: protecting > paused >
124
+ * irrelevant. Returns the run that produced it so callers can name its runId in
125
+ * a diagnostic or a row notice; `run` is null for "irrelevant".
126
+ * @param {Array<RunHead|null|undefined>} runs
127
+ * @param {{ sessionFile: string|null|undefined, hostStartedAt: number|null|undefined }} o
128
+ * @returns {{ state: RunVerdict, run: RunHead|null }}
129
+ */
130
+ export function classifyRunsForRow(runs, o) {
131
+ let state = /** @type {RunVerdict} */ ("irrelevant");
132
+ let match = null;
133
+ for (const run of Array.isArray(runs) ? runs : []) {
134
+ const verdict = classifyRunForRow(run, o);
135
+ if (verdict === "protecting") return { state: "protecting", run };
136
+ if (verdict === "paused" && state === "irrelevant") {
137
+ state = "paused";
138
+ match = run;
139
+ }
140
+ }
141
+ return { state, run: match };
142
+ }
143
+
144
+ /**
145
+ * @typedef {RunHead & { path: string, mtimeMs: number }} RunHeadWithPath
146
+ */
147
+
148
+ /**
149
+ * Enumerate every run head under `<workflowHome>/projects/<project>/runs/`.
150
+ * Only `*.json` regular files are candidates (upstream keeps `.bak`,
151
+ * `.events.jsonl`, `.log` and `.result-*` siblings next to the head), and
152
+ * `minMtimeMs` drops heads too old to belong to a live host — a prefilter only,
153
+ * the protection verdict still comes from `classifyRunsForRow`.
154
+ *
155
+ * Failure policy (spec D6): an absent store is normal and yields `ok:true`; any
156
+ * incomplete scan yields `ok:false` so the caller logs a `warm_prune_probe_failed`
157
+ * diagnostic instead of silently guessing. An incomplete scan is any unreadable
158
+ * directory or head file, including corrupt JSON — the eviction outcome is the
159
+ * same either way (a run that cannot be read protects nothing), but only
160
+ * `ok:false` marks the coverage as incomplete (it does not disable protection).
161
+ * Heads that were read successfully are still returned and still participate in
162
+ * the protection classification; a run that could not be read simply has no
163
+ * evidence and protects nothing. A head file that parses but is not a run head
164
+ * (`parseRunHead` → null) is not an error: it is simply not a candidate.
165
+ * The store root is stat-ed before the child paths are enumerated because on
166
+ * win32 a child path under a regular file reports `ENOENT` exactly like a
167
+ * missing directory does: a missing `workflowHome` is "no store yet", a
168
+ * `workflowHome` that is not a directory is a read failure.
169
+ * @param {{ workflowHome: string, minMtimeMs?: number }} o
170
+ * @returns {{ ok: boolean, heads: Array<RunHeadWithPath>, error: string|null }}
171
+ */
172
+ export function scanBackgroundRuns({ workflowHome, minMtimeMs = 0 }) {
173
+ const heads = [];
174
+ let error = null;
175
+ const noteError = (message) => {
176
+ error ??= message;
177
+ };
178
+
179
+ let projects;
180
+ try {
181
+ if (!statSync(workflowHome).isDirectory()) {
182
+ return { ok: false, heads: [], error: `workflow home is not a directory: ${workflowHome}` };
183
+ }
184
+ projects = readdirSync(joinPath(workflowHome, "projects"), { withFileTypes: true });
185
+ } catch (e) {
186
+ if (e?.code === "ENOENT") return { ok: true, heads: [], error: null };
187
+ return { ok: false, heads: [], error: errorMessage(e) };
188
+ }
189
+
190
+ for (const project of projects) {
191
+ if (!project.isDirectory()) continue;
192
+ const runsDir = joinPath(workflowHome, "projects", project.name, "runs");
193
+ let entries;
194
+ try {
195
+ entries = readdirSync(runsDir, { withFileTypes: true });
196
+ } catch (e) {
197
+ if (e?.code !== "ENOENT") noteError(`${runsDir}: ${errorMessage(e)}`);
198
+ continue;
199
+ }
200
+ for (const entry of entries) {
201
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
202
+ const filePath = joinPath(runsDir, entry.name);
203
+ try {
204
+ const mtimeMs = statSync(filePath).mtimeMs;
205
+ if (mtimeMs < minMtimeMs) continue;
206
+ const head = parseRunHead(JSON.parse(readFileSync(filePath, "utf8")));
207
+ if (head) heads.push({ ...head, path: filePath, mtimeMs });
208
+ } catch (e) {
209
+ noteError(`${filePath}: ${errorMessage(e)}`);
210
+ }
211
+ }
212
+ }
213
+
214
+ return { ok: error === null, heads, error };
215
+ }
216
+
217
+ /**
218
+ * Fresh protection probe for one row: the scan prefiltered to runs that could
219
+ * have been updated by this host incarnation, classified against the row's
220
+ * session file. Each field answers one question and is filled independently
221
+ * (design §5.1: a running/paused split): `running` is the run that must keep the
222
+ * host alive (spec D1), `paused` is the run whose interruption the reconcile
223
+ * pass announces (spec D2). They coexist because one session may hold an older
224
+ * paused run while a new one is running, and each consumer reads one field.
225
+ * `ok:false` only means the store could not be read (spec D6).
226
+ * @param {{ sessionFile: string|null, hostStartedAt: number|null, workflowHome: string }} o
227
+ * @returns {{ ok: boolean, running: RunHeadWithPath|null, paused: RunHeadWithPath|null, error: string|null }}
228
+ */
229
+ export function probeBackgroundRun({ sessionFile, hostStartedAt, workflowHome }) {
230
+ const minMtimeMs = hostStartedAt == null ? 0 : hostStartedAt - CLOCK_EPSILON_MS;
231
+ const scan = scanBackgroundRuns({ workflowHome, minMtimeMs });
232
+ if (!scan.ok) return { ok: false, running: null, paused: null, error: scan.error };
233
+ const verdict = classifyRunsForRow(scan.heads, { sessionFile, hostStartedAt });
234
+ const running = verdict.state === "protecting" ? verdict.run : null;
235
+ // Newest paused run wins, because it is the one most likely to be the run the
236
+ // just-ended host left behind; `path` only breaks ties deterministically.
237
+ const paused = scan.heads
238
+ .filter((head) => classifyRunForRow(head, { sessionFile, hostStartedAt }) === "paused")
239
+ .sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0) || a.path.localeCompare(b.path))[0] ?? null;
240
+ return { ok: true, running, paused, error: scan.error };
241
+ }
242
+
243
+ /**
244
+ * Join segments with the separator style of the base path. The ambient
245
+ * `path.join` uses the host separator, which would turn POSIX-style injected
246
+ * inputs (tests, Linux CI) into backslash paths when the suite runs on Windows,
247
+ * so the style is read from the base instead of the platform.
248
+ * @param {string} base
249
+ * @param {...string} parts
250
+ * @returns {string}
251
+ */
252
+ function joinPath(base, ...parts) {
253
+ const impl = /^[A-Za-z]:/.test(base) || base.includes("\\") ? path.win32 : path.posix;
254
+ return impl.join(base, ...parts);
255
+ }
256
+
257
+ /**
258
+ * @param {unknown} e
259
+ * @returns {string}
260
+ */
261
+ function errorMessage(e) {
262
+ return e instanceof Error ? e.message : String(e);
263
+ }
264
+
265
+ /**
266
+ * @param {unknown} value
267
+ * @returns {number|null}
268
+ */
269
+ function toMs(value) {
270
+ if (typeof value !== "string") return null;
271
+ const ms = Date.parse(value);
272
+ return Number.isFinite(ms) ? ms : null;
273
+ }