@united-workforce/cli 0.3.0 → 0.5.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 (319) hide show
  1. package/README.md +45 -11
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +17 -7
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  8. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  10. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  11. package/dist/__tests__/build-step-entry.test.js +173 -0
  12. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  16. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  17. package/dist/__tests__/concurrency.test.d.ts +2 -0
  18. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  19. package/dist/__tests__/concurrency.test.js +196 -0
  20. package/dist/__tests__/concurrency.test.js.map +1 -0
  21. package/dist/__tests__/config.test.js +26 -302
  22. package/dist/__tests__/config.test.js.map +1 -1
  23. package/dist/__tests__/current-role.test.js +7 -6
  24. package/dist/__tests__/current-role.test.js.map +1 -1
  25. package/dist/__tests__/e2e-mock-agent.test.js +43 -30
  26. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  27. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  28. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  29. package/dist/__tests__/format-text-default.test.js +43 -0
  30. package/dist/__tests__/format-text-default.test.js.map +1 -0
  31. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  32. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  33. package/dist/__tests__/format-text-registry.test.js +158 -0
  34. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  35. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  36. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  37. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  38. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  39. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  40. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  41. package/dist/__tests__/log-text-renderer.test.js +265 -0
  42. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  43. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  44. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  45. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  46. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  47. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  48. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  49. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  50. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  51. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  52. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  53. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  54. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  55. package/dist/__tests__/pid-recycling.test.js +273 -0
  56. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  57. package/dist/__tests__/prompt.test.js +365 -2
  58. package/dist/__tests__/prompt.test.js.map +1 -1
  59. package/dist/__tests__/resolve-head-hash.test.js +12 -4
  60. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  61. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  62. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  63. package/dist/__tests__/setup-complexity.test.js +2 -168
  64. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  65. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  66. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  67. package/dist/__tests__/setup-no-llm.test.js +52 -0
  68. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  69. package/dist/__tests__/solve-issue-tea-worktree.test.js +27 -28
  70. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  71. package/dist/__tests__/step-ask.test.d.ts +2 -0
  72. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  73. package/dist/__tests__/step-ask.test.js +507 -0
  74. package/dist/__tests__/step-ask.test.js.map +1 -0
  75. package/dist/__tests__/step-show-json.test.js +1 -0
  76. package/dist/__tests__/step-show-json.test.js.map +1 -1
  77. package/dist/__tests__/step-timing.test.js +2 -0
  78. package/dist/__tests__/step-timing.test.js.map +1 -1
  79. package/dist/__tests__/store-global-cas.test.js +2 -2
  80. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  81. package/dist/__tests__/store-unified-threads.test.js +28 -26
  82. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  83. package/dist/__tests__/thread-cancel-status.test.js +25 -19
  84. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  85. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  86. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  87. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  88. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  89. package/dist/__tests__/thread-list-filters.test.js +354 -17
  90. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  91. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  92. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  93. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  94. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  95. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  96. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  97. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  98. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  99. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  100. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  101. package/dist/__tests__/thread-poke.test.js +422 -0
  102. package/dist/__tests__/thread-poke.test.js.map +1 -0
  103. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  104. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  105. package/dist/__tests__/thread-resume.test.js +21 -15
  106. package/dist/__tests__/thread-resume.test.js.map +1 -1
  107. package/dist/__tests__/thread-show-status.test.js +17 -28
  108. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  109. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  110. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  111. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  112. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  113. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  114. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  115. package/dist/__tests__/thread-suspend-step.test.js +13 -16
  116. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  117. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  118. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  119. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  120. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  121. package/dist/__tests__/thread-test-helpers.js +13 -0
  122. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  123. package/dist/__tests__/thread.test.js +15 -13
  124. package/dist/__tests__/thread.test.js.map +1 -1
  125. package/dist/__tests__/validate-semantic.test.js +105 -23
  126. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  127. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  128. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  129. package/dist/__tests__/workflow-list-recursive.test.js +286 -0
  130. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  131. package/dist/__tests__/workflow-resolution.test.js +46 -28
  132. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  133. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  134. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  135. package/dist/__tests__/workflow-show-resolution.test.js +213 -0
  136. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  137. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  138. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  139. package/dist/__tests__/workflow-validate.test.js +707 -0
  140. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  141. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  142. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  143. package/dist/__tests__/write-envelope.test.js +201 -0
  144. package/dist/__tests__/write-envelope.test.js.map +1 -0
  145. package/dist/background/background.d.ts +22 -1
  146. package/dist/background/background.d.ts.map +1 -1
  147. package/dist/background/background.js +83 -6
  148. package/dist/background/background.js.map +1 -1
  149. package/dist/background/index.d.ts +1 -1
  150. package/dist/background/index.d.ts.map +1 -1
  151. package/dist/background/index.js +1 -1
  152. package/dist/background/index.js.map +1 -1
  153. package/dist/background/types.d.ts +1 -0
  154. package/dist/background/types.d.ts.map +1 -1
  155. package/dist/cli.js +120 -62
  156. package/dist/cli.js.map +1 -1
  157. package/dist/commands/config.d.ts +3 -1
  158. package/dist/commands/config.d.ts.map +1 -1
  159. package/dist/commands/config.js +17 -31
  160. package/dist/commands/config.js.map +1 -1
  161. package/dist/commands/prompt.d.ts.map +1 -1
  162. package/dist/commands/prompt.js +57 -31
  163. package/dist/commands/prompt.js.map +1 -1
  164. package/dist/commands/setup.d.ts +12 -39
  165. package/dist/commands/setup.d.ts.map +1 -1
  166. package/dist/commands/setup.js +72 -303
  167. package/dist/commands/setup.js.map +1 -1
  168. package/dist/commands/step.d.ts +44 -1
  169. package/dist/commands/step.d.ts.map +1 -1
  170. package/dist/commands/step.js +255 -11
  171. package/dist/commands/step.js.map +1 -1
  172. package/dist/commands/thread.d.ts +16 -3
  173. package/dist/commands/thread.d.ts.map +1 -1
  174. package/dist/commands/thread.js +423 -142
  175. package/dist/commands/thread.js.map +1 -1
  176. package/dist/commands/workflow.d.ts +9 -1
  177. package/dist/commands/workflow.d.ts.map +1 -1
  178. package/dist/commands/workflow.js +126 -6
  179. package/dist/commands/workflow.js.map +1 -1
  180. package/dist/concurrency/concurrency.d.ts +34 -0
  181. package/dist/concurrency/concurrency.d.ts.map +1 -0
  182. package/dist/concurrency/concurrency.js +216 -0
  183. package/dist/concurrency/concurrency.js.map +1 -0
  184. package/dist/concurrency/index.d.ts +3 -0
  185. package/dist/concurrency/index.d.ts.map +1 -0
  186. package/dist/concurrency/index.js +2 -0
  187. package/dist/concurrency/index.js.map +1 -0
  188. package/dist/concurrency/types.d.ts +19 -0
  189. package/dist/concurrency/types.d.ts.map +1 -0
  190. package/dist/concurrency/types.js +2 -0
  191. package/dist/concurrency/types.js.map +1 -0
  192. package/dist/format.d.ts +69 -2
  193. package/dist/format.d.ts.map +1 -1
  194. package/dist/format.js +198 -1
  195. package/dist/format.js.map +1 -1
  196. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  197. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  198. package/dist/moderator/evaluate.d.ts.map +1 -1
  199. package/dist/moderator/evaluate.js +4 -16
  200. package/dist/moderator/evaluate.js.map +1 -1
  201. package/dist/moderator/index.d.ts +1 -2
  202. package/dist/moderator/index.d.ts.map +1 -1
  203. package/dist/moderator/index.js +0 -1
  204. package/dist/moderator/index.js.map +1 -1
  205. package/dist/moderator/types.d.ts +6 -10
  206. package/dist/moderator/types.d.ts.map +1 -1
  207. package/dist/moderator/types.js +1 -3
  208. package/dist/moderator/types.js.map +1 -1
  209. package/dist/output-mappers.d.ts +122 -0
  210. package/dist/output-mappers.d.ts.map +1 -0
  211. package/dist/output-mappers.js +134 -0
  212. package/dist/output-mappers.js.map +1 -0
  213. package/dist/schemas.d.ts +6 -1
  214. package/dist/schemas.d.ts.map +1 -1
  215. package/dist/schemas.js +34 -5
  216. package/dist/schemas.js.map +1 -1
  217. package/dist/store.d.ts +28 -9
  218. package/dist/store.d.ts.map +1 -1
  219. package/dist/store.js +75 -16
  220. package/dist/store.js.map +1 -1
  221. package/dist/text-renderers.d.ts +30 -0
  222. package/dist/text-renderers.d.ts.map +1 -0
  223. package/dist/text-renderers.js +251 -0
  224. package/dist/text-renderers.js.map +1 -0
  225. package/dist/validate-semantic.d.ts.map +1 -1
  226. package/dist/validate-semantic.js +95 -61
  227. package/dist/validate-semantic.js.map +1 -1
  228. package/dist/validate.d.ts +6 -0
  229. package/dist/validate.d.ts.map +1 -1
  230. package/dist/validate.js +24 -0
  231. package/dist/validate.js.map +1 -1
  232. package/examples/brainstorm.yaml +130 -0
  233. package/examples/debate.yaml +169 -0
  234. package/examples/socratic-questioning.yaml +112 -0
  235. package/package.json +9 -10
  236. package/src/__tests__/adapter-json-roundtrip.test.ts +16 -7
  237. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  238. package/src/__tests__/build-step-entry.test.ts +203 -0
  239. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  240. package/src/__tests__/concurrency.test.ts +266 -0
  241. package/src/__tests__/config.test.ts +33 -321
  242. package/src/__tests__/current-role.test.ts +7 -6
  243. package/src/__tests__/e2e-mock-agent.test.ts +65 -30
  244. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  245. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  246. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  247. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  248. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  249. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  250. package/src/__tests__/format-text-default.test.ts +49 -0
  251. package/src/__tests__/format-text-registry.test.ts +173 -0
  252. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  253. package/src/__tests__/log-text-renderer.test.ts +294 -0
  254. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  255. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  256. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  257. package/src/__tests__/pid-recycling.test.ts +329 -0
  258. package/src/__tests__/prompt.test.ts +443 -2
  259. package/src/__tests__/resolve-head-hash.test.ts +11 -4
  260. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  261. package/src/__tests__/setup-complexity.test.ts +1 -203
  262. package/src/__tests__/setup-no-llm.test.ts +68 -0
  263. package/src/__tests__/solve-issue-tea-worktree.test.ts +27 -31
  264. package/src/__tests__/step-ask.test.ts +677 -0
  265. package/src/__tests__/step-show-json.test.ts +1 -0
  266. package/src/__tests__/step-timing.test.ts +2 -0
  267. package/src/__tests__/store-global-cas.test.ts +2 -2
  268. package/src/__tests__/store-unified-threads.test.ts +30 -27
  269. package/src/__tests__/thread-cancel-status.test.ts +27 -20
  270. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  271. package/src/__tests__/thread-list-filters.test.ts +443 -17
  272. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  273. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  274. package/src/__tests__/thread-poke.test.ts +554 -0
  275. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  276. package/src/__tests__/thread-resume.test.ts +20 -15
  277. package/src/__tests__/thread-show-status.test.ts +17 -29
  278. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  279. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  280. package/src/__tests__/thread-suspend-step.test.ts +13 -16
  281. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  282. package/src/__tests__/thread-test-helpers.ts +15 -1
  283. package/src/__tests__/thread.test.ts +14 -14
  284. package/src/__tests__/validate-semantic.test.ts +118 -33
  285. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  286. package/src/__tests__/workflow-resolution.test.ts +48 -29
  287. package/src/__tests__/workflow-show-resolution.test.ts +286 -0
  288. package/src/__tests__/workflow-validate.test.ts +828 -0
  289. package/src/__tests__/write-envelope.test.ts +257 -0
  290. package/src/background/background.ts +88 -6
  291. package/src/background/index.ts +2 -0
  292. package/src/background/types.ts +1 -0
  293. package/src/cli.ts +184 -77
  294. package/src/commands/config.ts +16 -33
  295. package/src/commands/prompt.ts +57 -31
  296. package/src/commands/setup.ts +80 -358
  297. package/src/commands/step.ts +339 -12
  298. package/src/commands/thread.ts +511 -171
  299. package/src/commands/workflow.ts +155 -4
  300. package/src/concurrency/concurrency.ts +245 -0
  301. package/src/concurrency/index.ts +10 -0
  302. package/src/concurrency/types.ts +19 -0
  303. package/src/format.ts +282 -2
  304. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  305. package/src/moderator/evaluate.ts +5 -17
  306. package/src/moderator/index.ts +1 -6
  307. package/src/moderator/types.ts +6 -14
  308. package/src/output-mappers.ts +254 -0
  309. package/src/schemas.ts +51 -5
  310. package/src/store.ts +86 -20
  311. package/src/text-renderers.ts +355 -0
  312. package/src/validate-semantic.ts +125 -73
  313. package/src/validate.ts +27 -0
  314. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  315. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  316. package/dist/__tests__/setup-validate.test.js +0 -108
  317. package/dist/__tests__/setup-validate.test.js.map +0 -1
  318. package/src/__tests__/setup-validate.test.ts +0 -148
  319. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { access, readFile } from "node:fs/promises";
3
- import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
4
4
  import type { VarStore } from "@ocas/core";
5
5
  import { validate } from "@ocas/core";
6
6
  import type {
@@ -22,6 +22,7 @@ import type {
22
22
  import {
23
23
  createThreadIndexEntry,
24
24
  markThreadSuspended,
25
+ SUSPEND_STATUS,
25
26
  updateThreadHead,
26
27
  } from "@united-workforce/protocol";
27
28
  import {
@@ -34,12 +35,21 @@ import type { AdapterOutput } from "@united-workforce/util-agent";
34
35
  import { getEnvPath, loadWorkflowConfig } from "@united-workforce/util-agent";
35
36
  import { config as loadDotenv } from "dotenv";
36
37
  import { parse } from "yaml";
37
- import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
38
+ import {
39
+ createMarker,
40
+ deleteMarker,
41
+ getProcessStartTime,
42
+ isMarkerValid,
43
+ isThreadRunning,
44
+ readMarker,
45
+ } from "../background/index.js";
46
+ import { acquireSlot, DEFAULT_MAX_RUNNING, installSlotCleanup } from "../concurrency/index.js";
38
47
  import { createIncludeTag } from "../include.js";
39
- import { evaluate, isSuspendResult } from "../moderator/index.js";
48
+ import { evaluate } from "../moderator/index.js";
40
49
  import {
41
50
  completeThread,
42
51
  createUwfStore,
52
+ findRegistryName,
43
53
  getThread,
44
54
  loadActiveThreads,
45
55
  loadHistoryThreads,
@@ -47,9 +57,11 @@ import {
47
57
  resolveWorkflowHash,
48
58
  setThread,
49
59
  type UwfStore,
60
+ type WorkflowRegistry,
50
61
  } from "../store.js";
51
62
  import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
52
63
  import { validateWorkflow } from "../validate-semantic.js";
64
+ import { getConfigPath, getNestedValue, loadConfig, parseDotPath } from "./config.js";
53
65
  import {
54
66
  type ChainState,
55
67
  collectOrderedSteps,
@@ -64,53 +76,49 @@ const END_ROLE = "$END";
64
76
  const START_ROLE = "$START";
65
77
  export const THREAD_READ_DEFAULT_QUOTA = 4000;
66
78
 
67
- function buildStepOutputFromEvaluation(
79
+ /**
80
+ * Read the suspend reason from an agent output if it is an engine-level suspend
81
+ * (coroutine yield). Returns the reason string when `$status === "$SUSPEND"`,
82
+ * or `null` otherwise. A suspend output with no `reason` yields an empty string.
83
+ */
84
+ function readSuspendReason(lastOutput: Record<string, unknown>): string | null {
85
+ if (lastOutput[STATUS_KEY] !== SUSPEND_STATUS) {
86
+ return null;
87
+ }
88
+ const reason = lastOutput.reason;
89
+ return typeof reason === "string" ? reason : "";
90
+ }
91
+
92
+ function buildSuspendStepOutput(
68
93
  workflowHash: CasRef,
69
94
  threadId: ThreadId,
70
95
  head: CasRef,
71
- status: ThreadStatus,
72
- evaluation: ReturnType<typeof evaluate>,
73
- background: boolean | null,
96
+ suspendedRole: string,
97
+ suspendMessage: string,
74
98
  ): StepOutput {
75
- const done = status === "completed";
76
- let currentRole: string | null = null;
77
- let suspendedRole: string | null = null;
78
- let suspendMessage: string | null = null;
79
- if (evaluation.ok) {
80
- if (isSuspendResult(evaluation.value)) {
81
- suspendedRole = evaluation.value.suspendedRole;
82
- suspendMessage = evaluation.value.prompt;
83
- } else if (evaluation.value.role !== END_ROLE) {
84
- currentRole = evaluation.value.role;
85
- }
86
- }
87
99
  return {
88
100
  workflow: workflowHash,
89
101
  thread: threadId,
90
102
  head,
91
- status,
92
- currentRole,
103
+ status: "suspended",
104
+ currentRole: null,
93
105
  suspendedRole,
94
106
  suspendMessage,
95
- done,
96
- background,
107
+ done: false,
108
+ background: null,
109
+ error: null,
97
110
  };
98
111
  }
99
112
 
100
- function resolveSuspendFieldsFromGraph(
113
+ function resolveSuspendFieldsFromOutput(
101
114
  uwf: UwfStore,
102
115
  head: CasRef,
103
- workflowRef: CasRef,
104
116
  ): { suspendedRole: string | null; suspendMessage: string | null } {
105
117
  const chain = walkChain(uwf, head);
106
118
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
107
- const workflow = loadWorkflowPayload(uwf, workflowRef);
108
- const result = evaluate(workflow.graph, lastRole, lastOutput);
109
- if (result.ok && isSuspendResult(result.value)) {
110
- return {
111
- suspendedRole: result.value.suspendedRole,
112
- suspendMessage: result.value.prompt,
113
- };
119
+ const reason = readSuspendReason(lastOutput);
120
+ if (reason !== null) {
121
+ return { suspendedRole: lastRole, suspendMessage: reason };
114
122
  }
115
123
  return { suspendedRole: null, suspendMessage: null };
116
124
  }
@@ -120,7 +128,6 @@ function resolveSuspendFieldsForShow(
120
128
  status: ThreadStatus,
121
129
  uwf: UwfStore,
122
130
  head: CasRef,
123
- workflowRef: CasRef,
124
131
  ): { suspendedRole: string | null; suspendMessage: string | null } {
125
132
  if (status !== "suspended") {
126
133
  return { suspendedRole: null, suspendMessage: null };
@@ -128,10 +135,10 @@ function resolveSuspendFieldsForShow(
128
135
  if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
129
136
  return { suspendedRole: entry.suspendedRole, suspendMessage: entry.suspendMessage };
130
137
  }
131
- const fromGraph = resolveSuspendFieldsFromGraph(uwf, head, workflowRef);
138
+ const fromOutput = resolveSuspendFieldsFromOutput(uwf, head);
132
139
  return {
133
- suspendedRole: entry.suspendedRole ?? fromGraph.suspendedRole,
134
- suspendMessage: entry.suspendMessage ?? fromGraph.suspendMessage,
140
+ suspendedRole: entry.suspendedRole ?? fromOutput.suspendedRole,
141
+ suspendMessage: entry.suspendMessage ?? fromOutput.suspendMessage,
135
142
  };
136
143
  }
137
144
 
@@ -155,7 +162,6 @@ async function resolveActiveThreadStatus(
155
162
  threadId: ThreadId,
156
163
  uwf: UwfStore,
157
164
  head: CasRef,
158
- workflowRef: CasRef,
159
165
  ): Promise<ThreadStatus> {
160
166
  const runningMarker = await isThreadRunning(storageRoot, threadId);
161
167
  if (runningMarker !== null) {
@@ -163,10 +169,8 @@ async function resolveActiveThreadStatus(
163
169
  }
164
170
 
165
171
  const chain = walkChain(uwf, head);
166
- const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
167
- const workflow = loadWorkflowPayload(uwf, workflowRef);
168
- const result = evaluate(workflow.graph, lastRole, lastOutput);
169
- if (result.ok && isSuspendResult(result.value)) {
172
+ const { lastOutput } = resolveEvaluateArgs(uwf, chain);
173
+ if (readSuspendReason(lastOutput) !== null) {
170
174
  return "suspended";
171
175
  }
172
176
 
@@ -180,12 +184,15 @@ async function resolveActiveThreadStatus(
180
184
  function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
181
185
  const chain = walkChain(uwf, head);
182
186
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
187
+ if (readSuspendReason(lastOutput) !== null) {
188
+ return null;
189
+ }
183
190
  const workflow = loadWorkflowPayload(uwf, workflowRef);
184
191
  const result = evaluate(workflow.graph, lastRole, lastOutput);
185
192
  if (!result.ok) {
186
193
  return null;
187
194
  }
188
- if (isSuspendResult(result.value) || result.value.role === END_ROLE) {
195
+ if (result.value.role === END_ROLE) {
189
196
  return null;
190
197
  }
191
198
  return result.value.role;
@@ -195,10 +202,12 @@ const PL_THREAD_START = "7HNQ4B2X";
195
202
  const PL_MODERATOR = "M3K8V9T1";
196
203
  const PL_AGENT_SPAWN = "R5J2W8N4";
197
204
  const PL_AGENT_DONE = "C6P9E3H7";
205
+ const PL_AGENT_ERROR = "Z3F7K8M2";
198
206
  const PL_THREAD_ARCHIVED = "F4D8Q2K5";
199
207
  const PL_STEP_ERROR = "B8T5N1V6";
200
208
  const PL_BACKGROUND_START = "X7Q4W9M2";
201
209
  const PL_THREAD_RESUME = "K2R7M4N8";
210
+ const PL_THREAD_POKE = "P4Q9R3X7";
202
211
 
203
212
  type ResumeStepConfig = {
204
213
  role: string;
@@ -246,18 +255,19 @@ async function workflowFileExists(dir: string, name: string, ext: string): Promi
246
255
  }
247
256
 
248
257
  /**
249
- * Search for a workflow file in a given directory (checks both .workflow/ and .workflows/).
258
+ * Search for a workflow file in a given directory (checks both .workflows/ and .workflow/).
259
+ * `.workflows/` (primary) takes priority over `.workflow/` (legacy fallback).
250
260
  */
251
261
  async function findWorkflowInDir(dir: string, name: string): Promise<string | null> {
252
- // Check .workflow/ directory first (preferred)
262
+ // Check .workflows/ directory first (primary)
253
263
  for (const ext of [".yaml", ".yml"]) {
254
- const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
264
+ const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
255
265
  if (result !== null) {
256
266
  return result;
257
267
  }
258
268
  }
259
269
  for (const indexName of ["index.yaml", "index.yml"]) {
260
- const candidate = resolvePath(dir, ".workflow", name, indexName);
270
+ const candidate = resolvePath(dir, ".workflows", name, indexName);
261
271
  try {
262
272
  await access(candidate);
263
273
  return candidate;
@@ -266,15 +276,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
266
276
  }
267
277
  }
268
278
 
269
- // Check .workflows/ directory as fallback (legacy)
279
+ // Check .workflow/ directory as fallback (legacy)
270
280
  for (const ext of [".yaml", ".yml"]) {
271
- const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
281
+ const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
272
282
  if (result !== null) {
273
283
  return result;
274
284
  }
275
285
  }
276
286
  for (const indexName of ["index.yaml", "index.yml"]) {
277
- const candidate = resolvePath(dir, ".workflows", name, indexName);
287
+ const candidate = resolvePath(dir, ".workflow", name, indexName);
278
288
  try {
279
289
  await access(candidate);
280
290
  return candidate;
@@ -286,8 +296,21 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
286
296
  return null;
287
297
  }
288
298
 
299
+ /** Check if a directory contains a .git marker (directory or file). */
300
+ async function hasGitMarker(dir: string): Promise<boolean> {
301
+ try {
302
+ await access(join(dir, ".git"));
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
289
309
  /**
290
- * Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
310
+ * Traverse parent directories looking for a workflow named `name` under
311
+ * `.workflows/` (primary) or `.workflow/` (legacy fallback). Within each
312
+ * directory the lookup checks flat YAML files (`<name>.yaml`/`.yml`) and
313
+ * folder-based layouts (`<name>/index.yaml`/`.yml`).
291
314
  * Returns the absolute path if found, otherwise null.
292
315
  * Stops at filesystem root or .git directory.
293
316
  */
@@ -301,6 +324,11 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
301
324
  return found;
302
325
  }
303
326
 
327
+ // Stop at .git boundary (repo root)
328
+ if (await hasGitMarker(currentDir)) {
329
+ break;
330
+ }
331
+
304
332
  // Stop at filesystem root
305
333
  if (currentDir === root) {
306
334
  break;
@@ -492,8 +520,8 @@ export async function cmdThreadShow(
492
520
  fail(`failed to resolve workflow from head: ${activeHead}`);
493
521
  }
494
522
 
495
- // Determine if this is a completed/cancelled thread
496
- if (entry.status === "completed" || entry.status === "cancelled") {
523
+ // Determine if this is an ended/cancelled thread
524
+ if (entry.status === "end" || entry.status === "cancelled") {
497
525
  const hint = null;
498
526
  return {
499
527
  workflow,
@@ -505,14 +533,15 @@ export async function cmdThreadShow(
505
533
  suspendMessage: null,
506
534
  done: true,
507
535
  background: null,
536
+ error: null,
508
537
  hint,
509
538
  };
510
539
  }
511
540
 
512
541
  // Active thread
513
- const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, activeHead, workflow);
542
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, activeHead);
514
543
  const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
515
- const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead, workflow);
544
+ const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead);
516
545
 
517
546
  const hint =
518
547
  status === "suspended"
@@ -529,6 +558,7 @@ export async function cmdThreadShow(
529
558
  suspendMessage: suspendFields.suspendMessage,
530
559
  done: false,
531
560
  background: null,
561
+ error: null,
532
562
  hint,
533
563
  };
534
564
  }
@@ -538,6 +568,8 @@ export type ThreadListItemWithStatus = ThreadListItem & {
538
568
  currentRole: string | null;
539
569
  /** Display label with status marker for suspended threads */
540
570
  statusDisplay: string;
571
+ /** Resolved workflow name from registry, or null if orphaned (hash not in registry) */
572
+ workflowName: string | null;
541
573
  };
542
574
 
543
575
  export type ThreadShowOutput = StepOutput & {
@@ -550,13 +582,23 @@ async function threadListItemFromActive(
550
582
  uwf: UwfStore,
551
583
  threadId: ThreadId,
552
584
  head: CasRef,
585
+ registry: WorkflowRegistry,
553
586
  ): Promise<ThreadListItemWithStatus | null> {
554
587
  const workflow = resolveWorkflowFromHead(uwf, head);
555
588
  if (workflow === null) {
556
- return null;
589
+ // Head CAS node missing or unrecognized — treat as corrupt rather than silently skipping
590
+ return {
591
+ thread: threadId,
592
+ workflow: "" as CasRef,
593
+ head,
594
+ status: "corrupt",
595
+ currentRole: null,
596
+ statusDisplay: "corrupt",
597
+ workflowName: null,
598
+ };
557
599
  }
558
600
 
559
- const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workflow);
601
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head);
560
602
  const statusDisplay = status === "suspended" ? `${status} [suspended]` : status;
561
603
 
562
604
  return {
@@ -566,6 +608,7 @@ async function threadListItemFromActive(
566
608
  status,
567
609
  currentRole: resolveCurrentRole(uwf, head, workflow),
568
610
  statusDisplay,
611
+ workflowName: findRegistryName(registry, workflow),
569
612
  };
570
613
  }
571
614
 
@@ -573,12 +616,33 @@ async function collectActiveThreads(
573
616
  storageRoot: string,
574
617
  uwf: UwfStore,
575
618
  index: ThreadsIndex,
619
+ registry: WorkflowRegistry,
576
620
  ): Promise<ThreadListItemWithStatus[]> {
577
621
  const items: ThreadListItemWithStatus[] = [];
578
622
  for (const [threadId, entry] of Object.entries(index)) {
579
- const item = await threadListItemFromActive(storageRoot, uwf, threadId as ThreadId, entry.head);
580
- if (item !== null) {
581
- items.push(item);
623
+ try {
624
+ const item = await threadListItemFromActive(
625
+ storageRoot,
626
+ uwf,
627
+ threadId as ThreadId,
628
+ entry.head,
629
+ registry,
630
+ );
631
+ if (item !== null) {
632
+ items.push(item);
633
+ }
634
+ } catch (err) {
635
+ const message = err instanceof Error ? err.message : String(err);
636
+ process.stderr.write(`warning: thread ${threadId} is corrupt: ${message}\n`);
637
+ items.push({
638
+ thread: threadId as ThreadId,
639
+ workflow: "" as CasRef,
640
+ head: entry.head,
641
+ status: "corrupt",
642
+ currentRole: null,
643
+ statusDisplay: "corrupt",
644
+ workflowName: null,
645
+ });
582
646
  }
583
647
  }
584
648
  return items;
@@ -587,6 +651,7 @@ async function collectActiveThreads(
587
651
  function collectCompletedThreads(
588
652
  uwf: UwfStore,
589
653
  activeIds: Set<ThreadId>,
654
+ registry: WorkflowRegistry,
590
655
  ): ThreadListItemWithStatus[] {
591
656
  const items: ThreadListItemWithStatus[] = [];
592
657
  const history = loadHistoryThreads(uwf.varStore);
@@ -594,16 +659,31 @@ function collectCompletedThreads(
594
659
  for (const [threadId, entry] of Object.entries(history)) {
595
660
  if (!activeIds.has(threadId as ThreadId) && !seen.has(threadId as ThreadId)) {
596
661
  seen.add(threadId as ThreadId);
597
- const status = entry.status;
598
- const workflow = resolveWorkflowFromHead(uwf, entry.head);
599
- items.push({
600
- thread: threadId as ThreadId,
601
- workflow: workflow ?? "",
602
- head: entry.head,
603
- status,
604
- currentRole: null,
605
- statusDisplay: status,
606
- });
662
+ try {
663
+ const status = entry.status;
664
+ const workflow = resolveWorkflowFromHead(uwf, entry.head);
665
+ items.push({
666
+ thread: threadId as ThreadId,
667
+ workflow: workflow ?? "",
668
+ head: entry.head,
669
+ status,
670
+ currentRole: null,
671
+ statusDisplay: status,
672
+ workflowName: workflow !== null ? findRegistryName(registry, workflow) : null,
673
+ });
674
+ } catch (err) {
675
+ const message = err instanceof Error ? err.message : String(err);
676
+ process.stderr.write(`warning: completed thread ${threadId} is corrupt: ${message}\n`);
677
+ items.push({
678
+ thread: threadId as ThreadId,
679
+ workflow: "" as CasRef,
680
+ head: entry.head,
681
+ status: "corrupt",
682
+ currentRole: null,
683
+ statusDisplay: "corrupt",
684
+ workflowName: null,
685
+ });
686
+ }
607
687
  }
608
688
  }
609
689
  return items;
@@ -649,27 +729,35 @@ export async function cmdThreadList(
649
729
  beforeMs: number | null,
650
730
  skip: number | null,
651
731
  take: number | null,
732
+ showAll: boolean = false,
652
733
  ): Promise<ThreadListItemWithStatus[]> {
653
734
  const uwf = await createUwfStore(storageRoot);
654
735
  const index = loadActiveThreads(uwf.varStore);
736
+ const registry = loadWorkflowRegistry(uwf.varStore);
737
+
738
+ // Resolve the effective filter:
739
+ // - explicit --status wins (showAll has no effect)
740
+ // - otherwise: --all → no filter; default → ["idle", "running"]
741
+ const effectiveFilter: ThreadStatus[] | null =
742
+ statusFilter !== null ? statusFilter : showAll ? null : ["idle", "running", "corrupt"];
655
743
 
656
744
  // Collect active threads
657
- let items = await collectActiveThreads(storageRoot, uwf, index);
745
+ let items = await collectActiveThreads(storageRoot, uwf, index, registry);
658
746
 
659
747
  // Collect completed threads (if relevant for status filter)
660
748
  const includeCompleted =
661
- statusFilter === null ||
662
- statusFilter.includes("completed") ||
663
- statusFilter.includes("cancelled");
749
+ effectiveFilter === null ||
750
+ effectiveFilter.includes("end") ||
751
+ effectiveFilter.includes("cancelled");
664
752
  if (includeCompleted) {
665
753
  const activeIds = new Set(items.map((i) => i.thread));
666
- const completedItems = collectCompletedThreads(uwf, activeIds);
754
+ const completedItems = collectCompletedThreads(uwf, activeIds, registry);
667
755
  items = items.concat(completedItems);
668
756
  }
669
757
 
670
758
  // Apply status filter
671
- if (statusFilter !== null) {
672
- items = items.filter((item) => statusFilter.includes(item.status));
759
+ if (effectiveFilter !== null) {
760
+ items = items.filter((item) => effectiveFilter.includes(item.status));
673
761
  }
674
762
 
675
763
  // Apply time range filters
@@ -906,6 +994,15 @@ type EvaluateLastOutput = Record<string, unknown>;
906
994
 
907
995
  const STATUS_KEY = "$status";
908
996
 
997
+ /**
998
+ * Strip YAML frontmatter (---...---) from a raw markdown string,
999
+ * returning only the body portion.
1000
+ */
1001
+ function stripFrontmatter(raw: string): string {
1002
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
1003
+ return match ? raw.slice(match[0].length).trim() : raw.trim();
1004
+ }
1005
+
909
1006
  function resolveEvaluateArgs(
910
1007
  uwf: UwfStore,
911
1008
  chain: ChainState,
@@ -925,6 +1022,13 @@ function resolveEvaluateArgs(
925
1022
  ? (raw as Record<string, unknown>)
926
1023
  : {};
927
1024
 
1025
+ // Inject _body — the markdown body (after frontmatter) from the last step's
1026
+ // assistant output. Workflow edge prompts can reference it via {{ _body }}.
1027
+ const content = extractLastAssistantContent(uwf, lastStep.detail);
1028
+ if (content !== null) {
1029
+ base._body = stripFrontmatter(content);
1030
+ }
1031
+
928
1032
  return {
929
1033
  lastRole: lastStep.role,
930
1034
  lastOutput: base,
@@ -934,10 +1038,10 @@ function resolveEvaluateArgs(
934
1038
  function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
935
1039
  const node = uwf.store.cas.get(workflowRef);
936
1040
  if (node === null) {
937
- fail(`workflow CAS node not found: ${workflowRef}`);
1041
+ throw new Error(`workflow CAS node not found: ${workflowRef}`);
938
1042
  }
939
1043
  if (node.type !== uwf.schemas.workflow) {
940
- fail(`node ${workflowRef} is not a Workflow`);
1044
+ throw new Error(`node ${workflowRef} is not a Workflow`);
941
1045
  }
942
1046
  return node.payload as WorkflowPayload;
943
1047
  }
@@ -985,18 +1089,14 @@ function resolveAgentConfig(
985
1089
  return agentConfig;
986
1090
  }
987
1091
 
988
- function spawnAgent(
989
- plog: ProcessLogger,
1092
+ function executeAgentCommand(
990
1093
  agent: AgentConfig,
991
- threadId: ThreadId,
992
- role: string,
993
- edgePrompt: string,
1094
+ argv: readonly string[],
994
1095
  cwd: string,
995
- ): AdapterOutput {
996
- const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
997
- let stdout: string;
1096
+ plog: ProcessLogger,
1097
+ ): string {
998
1098
  try {
999
- stdout = execFileSync(agent.command, argv, {
1099
+ return execFileSync(agent.command, argv, {
1000
1100
  encoding: "utf8",
1001
1101
  stdio: ["ignore", "pipe", "pipe"],
1002
1102
  maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
@@ -1019,14 +1119,22 @@ function spawnAgent(
1019
1119
  const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
1020
1120
  failStep(plog, `agent command failed (${agent.command})${detail}`);
1021
1121
  }
1122
+ }
1022
1123
 
1124
+ function parseAgentOutput(stdout: string, plog: ProcessLogger): unknown {
1023
1125
  const line = stdout.trim().split("\n").pop()?.trim() ?? "";
1024
- let parsed: unknown;
1025
1126
  try {
1026
- parsed = JSON.parse(line);
1127
+ return JSON.parse(line);
1027
1128
  } catch {
1028
1129
  failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
1029
1130
  }
1131
+ }
1132
+
1133
+ function validateAndNormalizeOutput(
1134
+ parsed: unknown,
1135
+ line: string,
1136
+ plog: ProcessLogger,
1137
+ ): AdapterOutput {
1030
1138
  const obj = parsed as Record<string, unknown>;
1031
1139
  if (
1032
1140
  typeof obj !== "object" ||
@@ -1036,11 +1144,44 @@ function spawnAgent(
1036
1144
  ) {
1037
1145
  failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
1038
1146
  }
1147
+ // Normalize isError / errorMessage so downstream code can rely on them.
1148
+ // Legacy adapters that don't emit these fields default to isError=false.
1149
+ if (obj.isError !== undefined && typeof obj.isError !== "boolean") {
1150
+ failStep(plog, `agent stdout JSON has non-boolean isError: ${line}`);
1151
+ }
1152
+ if (obj.isError === undefined) {
1153
+ obj.isError = false;
1154
+ }
1155
+ if (
1156
+ obj.errorMessage !== undefined &&
1157
+ obj.errorMessage !== null &&
1158
+ typeof obj.errorMessage !== "string"
1159
+ ) {
1160
+ failStep(plog, `agent stdout JSON has non-string errorMessage: ${line}`);
1161
+ }
1162
+ if (obj.errorMessage === undefined) {
1163
+ obj.errorMessage = null;
1164
+ }
1039
1165
  return obj as unknown as AdapterOutput;
1040
1166
  }
1041
1167
 
1168
+ function spawnAgent(
1169
+ plog: ProcessLogger,
1170
+ agent: AgentConfig,
1171
+ threadId: ThreadId,
1172
+ role: string,
1173
+ edgePrompt: string,
1174
+ cwd: string,
1175
+ ): AdapterOutput {
1176
+ const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
1177
+ const stdout = executeAgentCommand(agent, argv, cwd, plog);
1178
+ const line = stdout.trim().split("\n").pop()?.trim() ?? "";
1179
+ const parsed = parseAgentOutput(stdout, plog);
1180
+ return validateAndNormalizeOutput(parsed, line, plog);
1181
+ }
1182
+
1042
1183
  function archiveThread(uwf: UwfStore, threadId: ThreadId, _workflow: CasRef, _head: CasRef): void {
1043
- completeThread(uwf.varStore, threadId, "completed");
1184
+ completeThread(uwf.varStore, threadId, "end");
1044
1185
  }
1045
1186
 
1046
1187
  export async function cmdThreadResume(
@@ -1064,15 +1205,15 @@ export async function cmdThreadResume(
1064
1205
  const chain = walkChain(uwf, headHash);
1065
1206
  const workflowHash = chain.start.workflow;
1066
1207
 
1067
- // Check entry.status first for completed/cancelled (like in cmdThreadShow)
1208
+ // Check entry.status first for end/cancelled (like in cmdThreadShow)
1068
1209
  let status: ThreadStatus;
1069
- if (entry.status === "completed" || entry.status === "cancelled") {
1210
+ if (entry.status === "end" || entry.status === "cancelled") {
1070
1211
  status = entry.status;
1071
1212
  } else {
1072
- status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, headHash, workflowHash);
1213
+ status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, headHash);
1073
1214
  }
1074
1215
 
1075
- if (status !== "suspended" && status !== "completed") {
1216
+ if (status !== "suspended" && status !== "end") {
1076
1217
  fail(`thread cannot be resumed: ${threadId} (status: ${status})`);
1077
1218
  }
1078
1219
 
@@ -1082,7 +1223,7 @@ export async function cmdThreadResume(
1082
1223
  });
1083
1224
 
1084
1225
  if (status === "suspended") {
1085
- const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash, workflowHash);
1226
+ const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash);
1086
1227
  if (suspendFields.suspendedRole === null) {
1087
1228
  fail(`thread is suspended but suspendedRole is missing: ${threadId}`);
1088
1229
  }
@@ -1104,21 +1245,18 @@ export async function cmdThreadResume(
1104
1245
  });
1105
1246
  }
1106
1247
 
1107
- // status === "completed"
1248
+ // status === "end"
1108
1249
  const workflow = loadWorkflowPayload(uwf, workflowHash);
1109
1250
  const startResult = evaluate(workflow.graph, START_ROLE, { [STATUS_KEY]: "resume" });
1110
1251
  if (!startResult.ok) {
1111
1252
  fail(`failed to evaluate $START: ${startResult.error.message}`);
1112
1253
  }
1113
- if (isSuspendResult(startResult.value)) {
1114
- fail("workflow cannot start with $SUSPEND");
1115
- }
1116
1254
  if (startResult.value.role === END_ROLE) {
1117
1255
  fail("workflow cannot start with $END");
1118
1256
  }
1119
1257
 
1120
1258
  const startRole = startResult.value.role;
1121
- const completedResumePrompt = buildResumePrompt(startResult.value.prompt, supplement);
1259
+ const endResumePrompt = buildResumePrompt(startResult.value.prompt, supplement);
1122
1260
 
1123
1261
  const updatedEntry = { ...entry, status: "idle" as const, completedAt: null };
1124
1262
  setThread(uwf.varStore, threadId, updatedEntry);
@@ -1131,16 +1269,180 @@ export async function cmdThreadResume(
1131
1269
 
1132
1270
  return cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, {
1133
1271
  role: startRole,
1134
- prompt: completedResumePrompt,
1272
+ prompt: endResumePrompt,
1135
1273
  });
1136
1274
  }
1137
1275
 
1276
+ /**
1277
+ * Validate that a thread can be poked. Returns the existing entry and the head StepNode payload.
1278
+ * Fails (process exit) when the thread is missing, running, completed, cancelled, or has no
1279
+ * StepNode at its head.
1280
+ */
1281
+ async function validatePokePreconditions(
1282
+ storageRoot: string,
1283
+ uwf: UwfStore,
1284
+ threadId: ThreadId,
1285
+ ): Promise<{ entry: ThreadIndexEntry; oldHead: CasRef; oldHeadPayload: StepNodePayload }> {
1286
+ const runningMarker = await isThreadRunning(storageRoot, threadId);
1287
+ if (runningMarker !== null) {
1288
+ fail(`thread already executing in background (PID: ${runningMarker.pid})`);
1289
+ }
1290
+
1291
+ const entry = getThread(uwf.varStore, threadId);
1292
+ if (entry === null) {
1293
+ fail(`thread not active: ${threadId}`);
1294
+ }
1295
+
1296
+ if (entry.status === "end" || entry.status === "cancelled") {
1297
+ fail(`thread cannot be poked: ${threadId} (status: ${entry.status})`);
1298
+ }
1299
+
1300
+ const oldHead = entry.head;
1301
+ const oldHeadNode = uwf.store.cas.get(oldHead);
1302
+ if (oldHeadNode === null) {
1303
+ fail(`CAS node not found: ${oldHead}`);
1304
+ }
1305
+ if (oldHeadNode.type !== uwf.schemas.stepNode) {
1306
+ fail("thread cannot be poked: no step to replace (head is StartNode)");
1307
+ }
1308
+
1309
+ return { entry, oldHead, oldHeadPayload: oldHeadNode.payload as StepNodePayload };
1310
+ }
1311
+
1312
+ /**
1313
+ * Resolve the next role from the post-poke chain state, used for the StepOutput.currentRole field.
1314
+ * Returns null when the next role is $END, evaluation fails, or the result is a suspend.
1315
+ */
1316
+ function resolveCurrentRoleFromChain(
1317
+ uwfAfter: UwfStore,
1318
+ workflow: WorkflowPayload,
1319
+ replacedHash: CasRef,
1320
+ ): string | null {
1321
+ const chainAfter = walkChain(uwfAfter, replacedHash);
1322
+ const { lastRole, lastOutput } = resolveEvaluateArgs(uwfAfter, chainAfter);
1323
+ if (readSuspendReason(lastOutput) !== null) {
1324
+ return null;
1325
+ }
1326
+ const afterResult = evaluate(workflow.graph, lastRole, lastOutput);
1327
+ if (!afterResult.ok) {
1328
+ return null;
1329
+ }
1330
+ if (afterResult.value.role === END_ROLE) {
1331
+ return null;
1332
+ }
1333
+ return afterResult.value.role;
1334
+ }
1335
+
1336
+ /**
1337
+ * Poke a thread: re-run the agent on the head step with a supplementary prompt,
1338
+ * replacing the head step's output. The new step's `prev` points to the OLD head's
1339
+ * `prev` — semantically replacing (not appending to) the head. The moderator is NOT
1340
+ * re-evaluated for routing; the role of the head step is re-used.
1341
+ */
1342
+ export async function cmdThreadPoke(
1343
+ storageRoot: string,
1344
+ threadId: ThreadId,
1345
+ prompt: string,
1346
+ agentOverride: string | null,
1347
+ ): Promise<StepOutput> {
1348
+ const uwf = await createUwfStore(storageRoot);
1349
+ const { entry, oldHeadPayload } = await validatePokePreconditions(storageRoot, uwf, threadId);
1350
+
1351
+ const chain = walkChain(uwf, entry.head);
1352
+ const workflowHash = chain.start.workflow;
1353
+ const threadCwd = chain.start.cwd;
1354
+
1355
+ const plog = createProcessLogger({
1356
+ storageRoot,
1357
+ context: { thread: threadId, workflow: workflowHash },
1358
+ });
1359
+
1360
+ // Resolve the agent: --agent override wins; otherwise read from old head step's `agent` field.
1361
+ const config = await loadWorkflowConfig(storageRoot);
1362
+ const workflow = loadWorkflowPayload(uwf, workflowHash);
1363
+ const role = oldHeadPayload.role;
1364
+ const agent =
1365
+ agentOverride !== null
1366
+ ? resolveAgentConfig(config, workflow, role, agentOverride)
1367
+ : parseAgentOverride(oldHeadPayload.agent);
1368
+
1369
+ const effectiveCwd = oldHeadPayload.cwd !== "" ? oldHeadPayload.cwd : threadCwd;
1370
+
1371
+ plog.log(PL_THREAD_POKE, `poke role=${role} agent=${agent.command}`, null);
1372
+ plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
1373
+ args: [...agent.args, threadId, role].join(" "),
1374
+ });
1375
+
1376
+ loadDotenv({ path: getEnvPath(storageRoot) });
1377
+
1378
+ // Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
1379
+ // the active thread head). After the agent returns, we rewrite that node's prev so
1380
+ // that the new head replaces the old head instead of appending after it.
1381
+ const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1382
+ const agentStepHash = agentResult.stepHash as CasRef;
1383
+
1384
+ plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
1385
+
1386
+ const uwfAfter = await createUwfStore(storageRoot);
1387
+ const agentNode = uwfAfter.store.cas.get(agentStepHash);
1388
+ if (agentNode === null || agentNode.type !== uwfAfter.schemas.stepNode) {
1389
+ failStep(plog, `agent returned hash that is not a StepNode: ${agentStepHash}`);
1390
+ }
1391
+ const agentPayload = agentNode.payload as StepNodePayload;
1392
+
1393
+ // Rewrite the new step so that its `prev` points to the OLD head's prev (replace semantics).
1394
+ const replacedPayload: StepNodePayload = {
1395
+ ...agentPayload,
1396
+ prev: oldHeadPayload.prev,
1397
+ };
1398
+ const replacedHash = await uwfAfter.store.cas.put(uwfAfter.schemas.stepNode, replacedPayload);
1399
+ const replacedNode = uwfAfter.store.cas.get(replacedHash);
1400
+ if (replacedNode === null || !validate(uwfAfter.store, replacedNode)) {
1401
+ failStep(plog, "rewritten StepNode failed schema validation");
1402
+ }
1403
+
1404
+ // Update thread head to the replaced step. Status becomes idle (no moderator re-route).
1405
+ setThread(uwfAfter.varStore, threadId, updateThreadHead(entry, replacedHash));
1406
+
1407
+ return {
1408
+ workflow: workflowHash,
1409
+ thread: threadId,
1410
+ head: replacedHash,
1411
+ status: "idle",
1412
+ currentRole: resolveCurrentRoleFromChain(uwfAfter, workflow, replacedHash),
1413
+ suspendedRole: null,
1414
+ suspendMessage: null,
1415
+ done: false,
1416
+ background: null,
1417
+ error: null,
1418
+ };
1419
+ }
1420
+
1138
1421
  export function validateCount(count: number): void {
1139
1422
  if (count < 1 || !Number.isInteger(count)) {
1140
1423
  throw new Error(`--count must be a positive integer, got: ${count}`);
1141
1424
  }
1142
1425
  }
1143
1426
 
1427
+ /**
1428
+ * Resolve the effective maxRunning limit.
1429
+ * Priority: config file > DEFAULT_MAX_RUNNING (2).
1430
+ */
1431
+ async function resolveMaxRunning(storageRoot: string): Promise<number> {
1432
+ try {
1433
+ const configPath = getConfigPath(storageRoot);
1434
+ const config = loadConfig(configPath);
1435
+ const path = parseDotPath("concurrency.maxRunning");
1436
+ const value = getNestedValue(config, path);
1437
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
1438
+ return value;
1439
+ }
1440
+ } catch {
1441
+ // Config file missing or invalid — fall through to default
1442
+ }
1443
+ return DEFAULT_MAX_RUNNING;
1444
+ }
1445
+
1144
1446
  export async function cmdThreadExec(
1145
1447
  storageRoot: string,
1146
1448
  threadId: ThreadId,
@@ -1151,11 +1453,12 @@ export async function cmdThreadExec(
1151
1453
  ): Promise<StepOutput[]> {
1152
1454
  validateCount(count);
1153
1455
 
1154
- // Check if thread is already running in background (unless we ARE the background worker)
1456
+ // Reject concurrent exec on the same thread (unless we ARE the background worker,
1457
+ // which hasn't created its own marker yet at this point).
1155
1458
  if (!backgroundWorker) {
1156
1459
  const runningMarker = await isThreadRunning(storageRoot, threadId);
1157
1460
  if (runningMarker !== null) {
1158
- fail(`thread already executing in background (PID: ${runningMarker.pid})`);
1461
+ fail(`thread ${threadId} is already being executed by PID ${runningMarker.pid}`);
1159
1462
  }
1160
1463
  }
1161
1464
 
@@ -1170,17 +1473,22 @@ export async function cmdThreadExec(
1170
1473
  return cmdThreadStepBackground(storageRoot, threadId, agentOverride, count, plog, workflowHash);
1171
1474
  }
1172
1475
 
1173
- // If we're the background worker, create marker before execution
1174
- let markerCreated = false;
1175
- if (backgroundWorker) {
1176
- await createMarker(storageRoot, {
1177
- thread: threadId,
1178
- workflow: workflowHash,
1179
- pid: process.pid,
1180
- startedAt: Date.now(),
1181
- });
1182
- markerCreated = true;
1183
- }
1476
+ // Create running marker so `thread list` shows "running" during execution
1477
+ // and concurrent `exec` on the same thread is rejected (see check above).
1478
+ await createMarker(storageRoot, {
1479
+ thread: threadId,
1480
+ workflow: workflowHash,
1481
+ pid: process.pid,
1482
+ startedAt: Date.now(),
1483
+ processStartTime: getProcessStartTime(process.pid),
1484
+ });
1485
+
1486
+ // Resolve concurrency limit: config > default
1487
+ const effectiveMaxRunning = await resolveMaxRunning(storageRoot);
1488
+
1489
+ // Acquire concurrency slot (blocks if at capacity)
1490
+ const slotHandle = await acquireSlot(storageRoot, effectiveMaxRunning);
1491
+ const uninstallCleanup = installSlotCleanup(slotHandle);
1184
1492
 
1185
1493
  try {
1186
1494
  const results: StepOutput[] = [];
@@ -1193,10 +1501,9 @@ export async function cmdThreadExec(
1193
1501
  }
1194
1502
  return results;
1195
1503
  } finally {
1196
- // Cleanup marker if we created one
1197
- if (markerCreated) {
1198
- await deleteMarker(storageRoot, threadId);
1199
- }
1504
+ uninstallCleanup();
1505
+ await slotHandle.release();
1506
+ await deleteMarker(storageRoot, threadId);
1200
1507
  }
1201
1508
  }
1202
1509
 
@@ -1264,6 +1571,7 @@ async function cmdThreadStepBackground(
1264
1571
  suspendMessage: null,
1265
1572
  done: false,
1266
1573
  background: true,
1574
+ error: null,
1267
1575
  },
1268
1576
  ];
1269
1577
  }
@@ -1296,6 +1604,16 @@ async function resolveModeratorStepTarget(
1296
1604
  plog: ProcessLogger,
1297
1605
  ): Promise<StepOutput | AgentStepTarget> {
1298
1606
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
1607
+
1608
+ // Intercept an already-suspended head before the moderator: a thread whose
1609
+ // head step yielded `$status: "$SUSPEND"` stays suspended (idempotent re-exec).
1610
+ const suspendReason = readSuspendReason(lastOutput);
1611
+ if (suspendReason !== null) {
1612
+ await ensureThreadSuspendMetadata(uwf.varStore, threadId, entry, lastRole, suspendReason);
1613
+ plog.log(PL_MODERATOR, `moderator action=suspend suspendedRole=${lastRole}`, null);
1614
+ return buildSuspendStepOutput(workflowHash, threadId, headHash, lastRole, suspendReason);
1615
+ }
1616
+
1299
1617
  const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
1300
1618
  if (!nextResult.ok) {
1301
1619
  failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
@@ -1303,32 +1621,10 @@ async function resolveModeratorStepTarget(
1303
1621
 
1304
1622
  plog.log(
1305
1623
  PL_MODERATOR,
1306
- `moderator ${
1307
- isSuspendResult(nextResult.value)
1308
- ? `action=suspend suspendedRole=${nextResult.value.suspendedRole}`
1309
- : `role=${nextResult.value.role}`
1310
- } prompt=${nextResult.value.prompt}`,
1624
+ `moderator role=${nextResult.value.role} prompt=${nextResult.value.prompt}`,
1311
1625
  null,
1312
1626
  );
1313
1627
 
1314
- if (isSuspendResult(nextResult.value)) {
1315
- await ensureThreadSuspendMetadata(
1316
- uwf.varStore,
1317
- threadId,
1318
- entry,
1319
- nextResult.value.suspendedRole,
1320
- nextResult.value.prompt,
1321
- );
1322
- return buildStepOutputFromEvaluation(
1323
- workflowHash,
1324
- threadId,
1325
- headHash,
1326
- "suspended",
1327
- nextResult,
1328
- null,
1329
- );
1330
- }
1331
-
1332
1628
  if (nextResult.value.role === END_ROLE) {
1333
1629
  plog.log(PL_THREAD_ARCHIVED, `thread archived head=${headHash}`, null);
1334
1630
  archiveThread(uwf, threadId, workflowHash, headHash);
@@ -1336,12 +1632,13 @@ async function resolveModeratorStepTarget(
1336
1632
  workflow: workflowHash,
1337
1633
  thread: threadId,
1338
1634
  head: headHash,
1339
- status: "completed",
1635
+ status: "end",
1340
1636
  currentRole: null,
1341
1637
  suspendedRole: null,
1342
1638
  suspendMessage: null,
1343
1639
  done: true,
1344
1640
  background: null,
1641
+ error: null,
1345
1642
  };
1346
1643
  }
1347
1644
 
@@ -1369,29 +1666,27 @@ async function finalizeAgentStep(
1369
1666
  uwfAfter,
1370
1667
  chainAfter,
1371
1668
  );
1372
- const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
1373
- if (!afterResult.ok) {
1374
- failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
1375
- }
1376
1669
 
1377
- if (isSuspendResult(afterResult.value)) {
1670
+ // Intercept `$status: "$SUSPEND"` before the moderator (coroutine yield): the
1671
+ // step is already in CAS and the head has advanced — mark the thread suspended
1672
+ // and return without routing through the graph.
1673
+ const suspendReason = readSuspendReason(lastOutputAfter);
1674
+ if (suspendReason !== null) {
1378
1675
  setThread(
1379
1676
  uwfAfter.varStore,
1380
1677
  threadId,
1381
1678
  markThreadSuspended(
1382
1679
  getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(newHead),
1383
- afterResult.value.suspendedRole,
1384
- afterResult.value.prompt,
1680
+ lastRoleAfter,
1681
+ suspendReason,
1385
1682
  ),
1386
1683
  );
1387
- return buildStepOutputFromEvaluation(
1388
- workflowHash,
1389
- threadId,
1390
- newHead,
1391
- "suspended",
1392
- afterResult,
1393
- null,
1394
- );
1684
+ return buildSuspendStepOutput(workflowHash, threadId, newHead, lastRoleAfter, suspendReason);
1685
+ }
1686
+
1687
+ const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
1688
+ if (!afterResult.ok) {
1689
+ failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
1395
1690
  }
1396
1691
 
1397
1692
  const done = afterResult.value.role === END_ROLE;
@@ -1400,7 +1695,7 @@ async function finalizeAgentStep(
1400
1695
  archiveThread(uwfAfter, threadId, workflowHash, newHead);
1401
1696
  }
1402
1697
 
1403
- const status: ThreadStatus = done ? "completed" : "idle";
1698
+ const status: ThreadStatus = done ? "end" : "idle";
1404
1699
  const currentRole = done ? null : afterResult.value.role;
1405
1700
 
1406
1701
  return {
@@ -1413,6 +1708,7 @@ async function finalizeAgentStep(
1413
1708
  suspendMessage: null,
1414
1709
  done,
1415
1710
  background: null,
1711
+ error: null,
1416
1712
  };
1417
1713
  }
1418
1714
 
@@ -1476,6 +1772,31 @@ async function cmdThreadStepOnce(
1476
1772
  failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
1477
1773
  }
1478
1774
 
1775
+ // Recoverable failure: agent persisted a failed StepNode (e.g. frontmatter
1776
+ // validation exhausted retries) but the engine MUST NOT advance head. The
1777
+ // moderator graph is also untouched — the same role will be replayed on the
1778
+ // next exec (until eventual success records `previousAttempts` linking the
1779
+ // failed step hashes).
1780
+ if (agentResult.isError === true) {
1781
+ plog.log(
1782
+ PL_AGENT_ERROR,
1783
+ `agent reported recoverable failure stepHash=${newHead} message=${agentResult.errorMessage ?? ""}`,
1784
+ null,
1785
+ );
1786
+ return {
1787
+ workflow: workflowHash,
1788
+ thread: threadId,
1789
+ head: headHash,
1790
+ status: "idle",
1791
+ currentRole: role,
1792
+ suspendedRole: null,
1793
+ suspendMessage: null,
1794
+ done: false,
1795
+ background: null,
1796
+ error: { stepHash: newHead, message: agentResult.errorMessage ?? "agent reported error" },
1797
+ };
1798
+ }
1799
+
1479
1800
  return finalizeAgentStep(storageRoot, threadId, workflowHash, workflow, newHead, uwfAfter, plog);
1480
1801
  }
1481
1802
 
@@ -1526,7 +1847,9 @@ export type CancelOutput = {
1526
1847
  };
1527
1848
 
1528
1849
  /**
1529
- * Stop background execution of a thread (but keep thread active)
1850
+ * Stop background execution of a thread (but keep thread active).
1851
+ * Validates process identity before sending signals to prevent killing
1852
+ * unrelated processes when PIDs are recycled.
1530
1853
  */
1531
1854
  export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Promise<StopOutput> {
1532
1855
  const uwf = await createUwfStore(storageRoot);
@@ -1535,15 +1858,26 @@ export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Pr
1535
1858
  fail(`thread not active: ${threadId}`);
1536
1859
  }
1537
1860
 
1538
- // Check if thread is running in background and terminate it
1539
- const runningMarker = await isThreadRunning(storageRoot, threadId);
1540
- if (runningMarker === null) {
1861
+ // Read the raw marker to check process identity
1862
+ const marker = await readMarker(storageRoot, threadId);
1863
+ if (marker === null) {
1541
1864
  process.stderr.write(`Warning: thread ${threadId} is not currently running\n`);
1542
1865
  return { thread: threadId, stopped: false };
1543
1866
  }
1544
1867
 
1868
+ // Validate that the marker's PID still belongs to the same process
1869
+ if (!isMarkerValid(marker)) {
1870
+ // Stale marker — PID was recycled or process died. Do NOT send a signal.
1871
+ process.stderr.write(
1872
+ `Warning: thread ${threadId} was not actually running (stale marker cleaned up)\n`,
1873
+ );
1874
+ await deleteMarker(storageRoot, threadId);
1875
+ return { thread: threadId, stopped: false };
1876
+ }
1877
+
1878
+ // Process identity confirmed — safe to send SIGTERM
1545
1879
  try {
1546
- process.kill(runningMarker.pid, "SIGTERM");
1880
+ process.kill(marker.pid, "SIGTERM");
1547
1881
  } catch {
1548
1882
  // Process may have already exited, ignore error
1549
1883
  }
@@ -1553,7 +1887,9 @@ export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Pr
1553
1887
  }
1554
1888
 
1555
1889
  /**
1556
- * Cancel a thread (stop execution + move to history)
1890
+ * Cancel a thread (stop execution + move to history).
1891
+ * Validates process identity before sending signals to prevent killing
1892
+ * unrelated processes when PIDs are recycled.
1557
1893
  */
1558
1894
  export async function cmdThreadCancel(
1559
1895
  storageRoot: string,
@@ -1565,14 +1901,18 @@ export async function cmdThreadCancel(
1565
1901
  fail(`thread not active: ${threadId}`);
1566
1902
  }
1567
1903
 
1568
- // Check if thread is running in background and terminate it
1569
- const runningMarker = await isThreadRunning(storageRoot, threadId);
1570
- if (runningMarker !== null) {
1571
- try {
1572
- process.kill(runningMarker.pid, "SIGTERM");
1573
- } catch {
1574
- // Process may have already exited, ignore error
1904
+ // Read the raw marker and validate process identity before sending signals
1905
+ const marker = await readMarker(storageRoot, threadId);
1906
+ if (marker !== null) {
1907
+ if (isMarkerValid(marker)) {
1908
+ // Process identity confirmed — safe to send SIGTERM
1909
+ try {
1910
+ process.kill(marker.pid, "SIGTERM");
1911
+ } catch {
1912
+ // Process may have already exited, ignore error
1913
+ }
1575
1914
  }
1915
+ // Always delete the marker (stale or not) — cancellation proceeds
1576
1916
  await deleteMarker(storageRoot, threadId);
1577
1917
  }
1578
1918