@pentoshi/clai 3.9.1 → 3.9.3

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 (334) hide show
  1. package/dist/agent/compaction-attempt.d.ts +22 -0
  2. package/dist/agent/compaction-attempt.js +69 -0
  3. package/dist/agent/compaction-attempt.js.map +1 -0
  4. package/dist/agent/compaction-summary.d.ts +27 -0
  5. package/dist/agent/compaction-summary.js +62 -0
  6. package/dist/agent/compaction-summary.js.map +1 -1
  7. package/dist/agent/context-breakdown.d.ts +2 -0
  8. package/dist/agent/context-breakdown.js +37 -0
  9. package/dist/agent/context-breakdown.js.map +1 -1
  10. package/dist/agent/context-manager.d.ts +7 -1
  11. package/dist/agent/context-manager.js +64 -15
  12. package/dist/agent/context-manager.js.map +1 -1
  13. package/dist/agent/continue-orient.d.ts +8 -0
  14. package/dist/agent/continue-orient.js +26 -3
  15. package/dist/agent/continue-orient.js.map +1 -1
  16. package/dist/agent/durable-envelope.d.ts +39 -0
  17. package/dist/agent/durable-envelope.js +195 -0
  18. package/dist/agent/durable-envelope.js.map +1 -0
  19. package/dist/agent/events.d.ts +21 -0
  20. package/dist/agent/loop-guard.d.ts +13 -5
  21. package/dist/agent/loop-guard.js +114 -1
  22. package/dist/agent/loop-guard.js.map +1 -1
  23. package/dist/agent/outcomes.d.ts +29 -0
  24. package/dist/agent/outcomes.js +118 -3
  25. package/dist/agent/outcomes.js.map +1 -1
  26. package/dist/agent/plan-decision.js +8 -3
  27. package/dist/agent/plan-decision.js.map +1 -1
  28. package/dist/agent/plan-tool.d.ts +5 -0
  29. package/dist/agent/plan-tool.js +166 -6
  30. package/dist/agent/plan-tool.js.map +1 -1
  31. package/dist/agent/reliability-policy.d.ts +11 -6
  32. package/dist/agent/reliability-policy.js +19 -13
  33. package/dist/agent/reliability-policy.js.map +1 -1
  34. package/dist/agent/request-budget.d.ts +24 -0
  35. package/dist/agent/request-budget.js +59 -0
  36. package/dist/agent/request-budget.js.map +1 -0
  37. package/dist/agent/responder-context.js +2 -2
  38. package/dist/agent/responder-context.js.map +1 -1
  39. package/dist/agent/responder-parent.d.ts +30 -0
  40. package/dist/agent/responder-parent.js +82 -0
  41. package/dist/agent/responder-parent.js.map +1 -0
  42. package/dist/agent/runner.d.ts +14 -1
  43. package/dist/agent/runner.js +850 -237
  44. package/dist/agent/runner.js.map +1 -1
  45. package/dist/agent/scope-context.d.ts +5 -0
  46. package/dist/agent/scope-context.js +7 -1
  47. package/dist/agent/scope-context.js.map +1 -1
  48. package/dist/agent/session-policy.js +1 -0
  49. package/dist/agent/session-policy.js.map +1 -1
  50. package/dist/agent/stop-summary.js +13 -7
  51. package/dist/agent/stop-summary.js.map +1 -1
  52. package/dist/agent/stream-recovery.d.ts +83 -0
  53. package/dist/agent/stream-recovery.js +270 -0
  54. package/dist/agent/stream-recovery.js.map +1 -0
  55. package/dist/agent/task-analyzer.js +7 -3
  56. package/dist/agent/task-analyzer.js.map +1 -1
  57. package/dist/agent/task-evidence.js +2 -0
  58. package/dist/agent/task-evidence.js.map +1 -1
  59. package/dist/agent/task-sync.d.ts +11 -0
  60. package/dist/agent/task-sync.js +31 -0
  61. package/dist/agent/task-sync.js.map +1 -1
  62. package/dist/agent/tool-call-parser.d.ts +6 -13
  63. package/dist/agent/tool-call-parser.js +186 -82
  64. package/dist/agent/tool-call-parser.js.map +1 -1
  65. package/dist/agent/tool-history.d.ts +8 -2
  66. package/dist/agent/tool-history.js +8 -1
  67. package/dist/agent/tool-history.js.map +1 -1
  68. package/dist/agent/turn-outcome.d.ts +4 -1
  69. package/dist/agent/turn-outcome.js +16 -2
  70. package/dist/agent/turn-outcome.js.map +1 -1
  71. package/dist/app/adapters/agent-event-adapter.js +27 -0
  72. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  73. package/dist/app/adapters/current-agent-adapter.js +1 -0
  74. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  75. package/dist/app/adapters/current-jobs-adapter.js +4 -3
  76. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  77. package/dist/app/adapters/current-store-adapter.js +4 -3
  78. package/dist/app/adapters/current-store-adapter.js.map +1 -1
  79. package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
  80. package/dist/app/adapters/current-updates-adapter.js +34 -5
  81. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  82. package/dist/app/adapters/quiet-meta-tools.js +2 -1
  83. package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
  84. package/dist/app/controllers/job-controller.d.ts +4 -3
  85. package/dist/app/controllers/job-controller.js +9 -6
  86. package/dist/app/controllers/job-controller.js.map +1 -1
  87. package/dist/app/controllers/plan-controller.js +14 -0
  88. package/dist/app/controllers/plan-controller.js.map +1 -1
  89. package/dist/app/controllers/responder-settlement.js +2 -2
  90. package/dist/app/controllers/responder-settlement.js.map +1 -1
  91. package/dist/app/controllers/session-compact-helper.d.ts +23 -1
  92. package/dist/app/controllers/session-compact-helper.js +117 -7
  93. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  94. package/dist/app/controllers/session-context-usage.d.ts +24 -0
  95. package/dist/app/controllers/session-context-usage.js +89 -0
  96. package/dist/app/controllers/session-context-usage.js.map +1 -0
  97. package/dist/app/controllers/session-controller.d.ts +18 -17
  98. package/dist/app/controllers/session-controller.js +127 -119
  99. package/dist/app/controllers/session-controller.js.map +1 -1
  100. package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
  101. package/dist/app/controllers/session-prompt-queue.js +20 -1
  102. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  103. package/dist/app/controllers/session-responder.js +16 -6
  104. package/dist/app/controllers/session-responder.js.map +1 -1
  105. package/dist/app/controllers/session-turn-request.d.ts +18 -0
  106. package/dist/app/controllers/session-turn-request.js +31 -0
  107. package/dist/app/controllers/session-turn-request.js.map +1 -0
  108. package/dist/app/controllers/turn-continuation.d.ts +14 -0
  109. package/dist/app/controllers/turn-continuation.js +46 -0
  110. package/dist/app/controllers/turn-continuation.js.map +1 -0
  111. package/dist/app/events/app-event.d.ts +21 -0
  112. package/dist/app/events/app-event.js +1 -0
  113. package/dist/app/events/app-event.js.map +1 -1
  114. package/dist/app/ports/agent-port.d.ts +7 -0
  115. package/dist/app/ports/jobs-port.d.ts +4 -3
  116. package/dist/app/ports/persistence-port.d.ts +11 -0
  117. package/dist/app/ports/updates-port.d.ts +8 -0
  118. package/dist/attachments/service.js +13 -2
  119. package/dist/attachments/service.js.map +1 -1
  120. package/dist/commands/providers.js +13 -10
  121. package/dist/commands/providers.js.map +1 -1
  122. package/dist/commands/update.d.ts +4 -0
  123. package/dist/commands/update.js +14 -0
  124. package/dist/commands/update.js.map +1 -1
  125. package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
  126. package/dist/llm/adapters/anthropic-tools.js +36 -5
  127. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  128. package/dist/llm/adapters/gemini-tools.d.ts +3 -1
  129. package/dist/llm/adapters/gemini-tools.js +5 -2
  130. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  131. package/dist/llm/adapters/ollama-tools.js +8 -3
  132. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  133. package/dist/llm/adapters/openai-tools.js +8 -1
  134. package/dist/llm/adapters/openai-tools.js.map +1 -1
  135. package/dist/llm/agentrouter.js +2 -0
  136. package/dist/llm/agentrouter.js.map +1 -1
  137. package/dist/llm/anthropic.d.ts +8 -0
  138. package/dist/llm/anthropic.js +68 -9
  139. package/dist/llm/anthropic.js.map +1 -1
  140. package/dist/llm/aws-mantle.js +60 -12
  141. package/dist/llm/aws-mantle.js.map +1 -1
  142. package/dist/llm/bynara.js +2 -0
  143. package/dist/llm/bynara.js.map +1 -1
  144. package/dist/llm/capabilities.d.ts +7 -0
  145. package/dist/llm/capabilities.js +22 -0
  146. package/dist/llm/capabilities.js.map +1 -1
  147. package/dist/llm/gemini.d.ts +3 -0
  148. package/dist/llm/gemini.js +71 -11
  149. package/dist/llm/gemini.js.map +1 -1
  150. package/dist/llm/groq.d.ts +20 -1
  151. package/dist/llm/groq.js +32 -4
  152. package/dist/llm/groq.js.map +1 -1
  153. package/dist/llm/http.d.ts +29 -0
  154. package/dist/llm/http.js +122 -29
  155. package/dist/llm/http.js.map +1 -1
  156. package/dist/llm/kimchi.js +2 -0
  157. package/dist/llm/kimchi.js.map +1 -1
  158. package/dist/llm/nvidia.js +2 -0
  159. package/dist/llm/nvidia.js.map +1 -1
  160. package/dist/llm/ollama.d.ts +5 -0
  161. package/dist/llm/ollama.js +53 -6
  162. package/dist/llm/ollama.js.map +1 -1
  163. package/dist/llm/openai.js +2 -0
  164. package/dist/llm/openai.js.map +1 -1
  165. package/dist/llm/openrouter.js +2 -0
  166. package/dist/llm/openrouter.js.map +1 -1
  167. package/dist/llm/qwen-cloud.js +2 -0
  168. package/dist/llm/qwen-cloud.js.map +1 -1
  169. package/dist/llm/router.d.ts +16 -0
  170. package/dist/llm/router.js +79 -23
  171. package/dist/llm/router.js.map +1 -1
  172. package/dist/llm/sampling.d.ts +32 -0
  173. package/dist/llm/sampling.js +48 -0
  174. package/dist/llm/sampling.js.map +1 -0
  175. package/dist/llm/stream-progress.d.ts +4 -0
  176. package/dist/llm/stream-progress.js +43 -0
  177. package/dist/llm/stream-progress.js.map +1 -0
  178. package/dist/llm/system-messages.d.ts +29 -0
  179. package/dist/llm/system-messages.js +51 -0
  180. package/dist/llm/system-messages.js.map +1 -0
  181. package/dist/llm/token-usage.d.ts +4 -1
  182. package/dist/llm/token-usage.js +56 -4
  183. package/dist/llm/token-usage.js.map +1 -1
  184. package/dist/llm/tool-protocol.js +30 -0
  185. package/dist/llm/tool-protocol.js.map +1 -1
  186. package/dist/os/process-tree.d.ts +15 -0
  187. package/dist/os/process-tree.js +77 -0
  188. package/dist/os/process-tree.js.map +1 -0
  189. package/dist/prompts/embedded.js +1 -1
  190. package/dist/prompts/embedded.js.map +1 -1
  191. package/dist/prompts/system.agent.md +13 -12
  192. package/dist/repl/prompt-line.d.ts +5 -0
  193. package/dist/repl/prompt-line.js +7 -1
  194. package/dist/repl/prompt-line.js.map +1 -1
  195. package/dist/repl.js +61 -41
  196. package/dist/repl.js.map +1 -1
  197. package/dist/safety/classifier.js +98 -44
  198. package/dist/safety/classifier.js.map +1 -1
  199. package/dist/safety/engagement-policy.d.ts +8 -0
  200. package/dist/safety/engagement-policy.js +52 -7
  201. package/dist/safety/engagement-policy.js.map +1 -1
  202. package/dist/safety/patterns.d.ts +44 -0
  203. package/dist/safety/patterns.js +168 -37
  204. package/dist/safety/patterns.js.map +1 -1
  205. package/dist/store/config.d.ts +9 -1
  206. package/dist/store/config.js +51 -1
  207. package/dist/store/config.js.map +1 -1
  208. package/dist/store/history-index.d.ts +12 -0
  209. package/dist/store/history-index.js +47 -18
  210. package/dist/store/history-index.js.map +1 -1
  211. package/dist/store/history.d.ts +5 -2
  212. package/dist/store/history.js +153 -62
  213. package/dist/store/history.js.map +1 -1
  214. package/dist/store/plan.d.ts +81 -5
  215. package/dist/store/plan.js +406 -46
  216. package/dist/store/plan.js.map +1 -1
  217. package/dist/store/redaction-cache.d.ts +12 -0
  218. package/dist/store/redaction-cache.js +51 -0
  219. package/dist/store/redaction-cache.js.map +1 -0
  220. package/dist/store/responder-settlement.d.ts +5 -1
  221. package/dist/store/responder-settlement.js +42 -30
  222. package/dist/store/responder-settlement.js.map +1 -1
  223. package/dist/store/scope.js +36 -27
  224. package/dist/store/scope.js.map +1 -1
  225. package/dist/store/task-transitions.d.ts +19 -0
  226. package/dist/store/task-transitions.js +46 -0
  227. package/dist/store/task-transitions.js.map +1 -0
  228. package/dist/tools/capabilities.js +33 -18
  229. package/dist/tools/capabilities.js.map +1 -1
  230. package/dist/tools/command-intent.d.ts +24 -0
  231. package/dist/tools/command-intent.js +158 -4
  232. package/dist/tools/command-intent.js.map +1 -1
  233. package/dist/tools/definitions.d.ts +5 -1
  234. package/dist/tools/definitions.js +162 -21
  235. package/dist/tools/definitions.js.map +1 -1
  236. package/dist/tools/fs.d.ts +16 -0
  237. package/dist/tools/fs.js +122 -46
  238. package/dist/tools/fs.js.map +1 -1
  239. package/dist/tools/image.js +1 -1
  240. package/dist/tools/image.js.map +1 -1
  241. package/dist/tools/jobs.d.ts +78 -4
  242. package/dist/tools/jobs.js +542 -115
  243. package/dist/tools/jobs.js.map +1 -1
  244. package/dist/tools/net-ping-sweep.d.ts +3 -1
  245. package/dist/tools/net-ping-sweep.js +64 -26
  246. package/dist/tools/net-ping-sweep.js.map +1 -1
  247. package/dist/tools/nmap-runner.js +5 -2
  248. package/dist/tools/nmap-runner.js.map +1 -1
  249. package/dist/tools/pdf.js +50 -7
  250. package/dist/tools/pdf.js.map +1 -1
  251. package/dist/tools/registry.js +154 -96
  252. package/dist/tools/registry.js.map +1 -1
  253. package/dist/tools/shell.d.ts +24 -2
  254. package/dist/tools/shell.js +113 -28
  255. package/dist/tools/shell.js.map +1 -1
  256. package/dist/tools/tool-types.d.ts +2 -0
  257. package/dist/tools/validate.d.ts +8 -5
  258. package/dist/tools/validate.js +177 -7
  259. package/dist/tools/validate.js.map +1 -1
  260. package/dist/tools/wordlists.js +69 -17
  261. package/dist/tools/wordlists.js.map +1 -1
  262. package/dist/tui/state.d.ts +4 -0
  263. package/dist/tui/state.js +88 -2
  264. package/dist/tui/state.js.map +1 -1
  265. package/dist/tui-v2/actions/keymap.js +3 -8
  266. package/dist/tui-v2/actions/keymap.js.map +1 -1
  267. package/dist/tui-v2/app/App.js +56 -5
  268. package/dist/tui-v2/app/App.js.map +1 -1
  269. package/dist/tui-v2/app/commands/config-commands.js +5 -2
  270. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  271. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  272. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  273. package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
  274. package/dist/tui-v2/app/plan-lifecycle.js +2 -1
  275. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  276. package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
  277. package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
  278. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  279. package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
  280. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  281. package/dist/tui-v2/components/plan/plan-view.js +9 -6
  282. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  283. package/dist/tui-v2/components/status/status-line.d.ts +15 -0
  284. package/dist/tui-v2/components/status/status-line.js +33 -25
  285. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  286. package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
  287. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  288. package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
  289. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  290. package/dist/tui-v2/components/transcript/intro-card.js +3 -0
  291. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  292. package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
  293. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  294. package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
  295. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  296. package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
  297. package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
  298. package/dist/tui-v2/composer/composer-editor.js +20 -12
  299. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  300. package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
  301. package/dist/tui-v2/rendering/incremental-strip.js +89 -0
  302. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
  303. package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
  304. package/dist/tui-v2/rendering/plan-view.js +71 -2
  305. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  306. package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
  307. package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
  308. package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
  309. package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
  310. package/dist/tui-v2/rendering/thinking-tail.js +26 -0
  311. package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
  312. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  313. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  314. package/dist/tui-v2/state/transcript-reducer.js +146 -25
  315. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  316. package/dist/tui-v2/state/transcript-types.d.ts +6 -0
  317. package/dist/tui-v2/state/transcript-types.js +17 -0
  318. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  319. package/dist/types.d.ts +29 -0
  320. package/dist/types.js.map +1 -1
  321. package/dist/ui/intro-header.d.ts +1 -0
  322. package/dist/ui/intro-header.js +10 -13
  323. package/dist/ui/intro-header.js.map +1 -1
  324. package/dist/ui/markdown.js +9 -2
  325. package/dist/ui/markdown.js.map +1 -1
  326. package/dist/ui/mentions.d.ts +5 -0
  327. package/dist/ui/mentions.js +66 -7
  328. package/dist/ui/mentions.js.map +1 -1
  329. package/dist/ui/thinking.d.ts +5 -1
  330. package/dist/ui/thinking.js +3 -2
  331. package/dist/ui/thinking.js.map +1 -1
  332. package/dist/version.generated.d.ts +2 -2
  333. package/dist/version.generated.js +2 -2
  334. package/package.json +2 -1
@@ -1,9 +1,10 @@
1
- import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, rm, stat, } from "node:fs/promises";
2
2
  import { fixOwner, fixOwnerSync, handlePermissionError, safeExists } from "../os/permissions.js";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { getConfig } from "./config.js";
6
6
  import { getPlanDir } from "./paths.js";
7
+ import { evaluateTaskTransition } from "./task-transitions.js";
7
8
  import { applyPlanOperation, validatePlanDag, } from "../agent/task-plan.js";
8
9
  /**
9
10
  * Session-scoped plan + task persistence.
@@ -86,6 +87,14 @@ async function loadDatabase() {
86
87
  updated_at TEXT NOT NULL
87
88
  );
88
89
  `);
90
+ // A real version column so writes can compare-and-set. Legacy
91
+ // rows default to 1 and are corrected on the first successful mutation.
92
+ try {
93
+ cachedDb.exec("ALTER TABLE plans ADD COLUMN version INTEGER NOT NULL DEFAULT 1;");
94
+ }
95
+ catch {
96
+ // Column already exists.
97
+ }
89
98
  return cachedDb;
90
99
  }
91
100
  catch (err) {
@@ -124,37 +133,59 @@ export function tasksFromTitles(titles) {
124
133
  }));
125
134
  }
126
135
  /**
127
- * Heal dependency edges so the checklist order is the authority.
136
+ * Heal dependency edges without inventing scheduling.
128
137
  *
129
- * After id remaps (merge/rewrite), auto-generated chains often point at the
130
- * wrong ids (e.g. t2 depends on t9). Drop self-deps and any dependency that
131
- * appears later in the list (forward edges). If nothing valid remains, chain
132
- * to the previous task in list order.
138
+ * Only genuinely broken edges are removed: self-references, ids that no longer
139
+ * exist, and edges that would close a cycle. Valid forward references are kept
140
+ * so an authored DAG keeps its parallelism. `dependencies: []` is an explicit
141
+ * statement of independence and is never replaced; only a legacy row with no
142
+ * dependency field at all falls back to the previous foreground task, and a
143
+ * responder child never becomes a blocker.
133
144
  *
134
145
  * Returns true when any task's dependencies changed.
135
146
  */
136
147
  export function normalizeTaskDependencies(tasks) {
137
148
  let changed = false;
138
- for (let i = 0; i < tasks.length; i++) {
139
- const task = tasks[i];
140
- const earlierTasks = tasks.slice(0, i);
141
- const earlier = new Set(earlierTasks.map((candidate) => candidate.id));
142
- const cleaned = [
143
- ...new Set((task.dependencies ?? []).filter((dep) => dep !== task.id && earlier.has(dep))),
144
- ];
145
- const previousManual = [...earlierTasks]
146
- .reverse()
147
- .find((candidate) => !candidate.responderOwned);
148
- const next = task.responderOwned
149
- ? cleaned
150
- : cleaned.length > 0
151
- ? cleaned
152
- : previousManual
153
- ? [previousManual.id]
154
- : [];
155
- const prev = task.dependencies ?? [];
156
- if (prev.length !== next.length ||
157
- prev.some((id, idx) => id !== next[idx])) {
149
+ const known = new Set(tasks.map((task) => task.id));
150
+ const byId = new Map(tasks.map((task) => [task.id, task]));
151
+ const wouldCycle = (from, to) => {
152
+ const seen = new Set();
153
+ const walk = (id) => {
154
+ if (id === from)
155
+ return true;
156
+ if (seen.has(id))
157
+ return false;
158
+ seen.add(id);
159
+ return (byId.get(id)?.dependencies ?? []).some(walk);
160
+ };
161
+ return walk(to);
162
+ };
163
+ for (const [index, task] of tasks.entries()) {
164
+ const declared = task.dependencies;
165
+ const cleaned = [];
166
+ for (const dependency of declared ?? []) {
167
+ if (dependency === task.id || !known.has(dependency))
168
+ continue;
169
+ if (cleaned.includes(dependency))
170
+ continue;
171
+ if (wouldCycle(task.id, dependency))
172
+ continue;
173
+ cleaned.push(dependency);
174
+ }
175
+ let next = cleaned;
176
+ if (declared === undefined && !task.responderOwned && cleaned.length === 0) {
177
+ const previousForeground = tasks
178
+ .slice(0, index)
179
+ .reverse()
180
+ .find((candidate) => !candidate.responderOwned);
181
+ if (previousForeground && !wouldCycle(task.id, previousForeground.id)) {
182
+ next = [previousForeground.id];
183
+ }
184
+ }
185
+ const previous = declared ?? [];
186
+ if (declared === undefined ||
187
+ previous.length !== next.length ||
188
+ previous.some((id, position) => id !== next[position])) {
158
189
  task.dependencies = next;
159
190
  changed = true;
160
191
  }
@@ -233,20 +264,202 @@ export function patchPlanMeta(plan, patch) {
233
264
  plan.meta = { ...(plan.meta ?? {}), ...patch };
234
265
  return plan;
235
266
  }
267
+ /**
268
+ * Every plan write is serialized in-process.
269
+ *
270
+ * The JSONL backend rewrites the whole file (read → modify → write) and the
271
+ * SQLite backend used an unconditional UPSERT, so two concurrent writers (a
272
+ * foreground task transition and an asynchronous responder settlement) could
273
+ * each save their own `v+1` derived from the same base version. The later write
274
+ * silently reverted the earlier one — reopening completed parents or re-yellowing
275
+ * settled children.
276
+ */
277
+ let planWriteQueue = Promise.resolve();
278
+ let planWriteDepth = 0;
279
+ function enqueuePlanWrite(task) {
280
+ // Re-entrancy: a queued mutation may load a plan that heals itself through
281
+ // savePlan. Waiting on the queue from inside the queue would deadlock.
282
+ if (planWriteDepth > 0)
283
+ return task();
284
+ const tracked = async () => {
285
+ planWriteDepth += 1;
286
+ try {
287
+ return await task();
288
+ }
289
+ finally {
290
+ planWriteDepth -= 1;
291
+ }
292
+ };
293
+ const run = planWriteQueue.then(tracked, tracked);
294
+ // Keep the chain alive regardless of individual failures.
295
+ planWriteQueue = run.then(() => undefined, () => undefined);
296
+ return run;
297
+ }
298
+ const jsonlLockDir = `${jsonlFile}.lock`;
299
+ const JSONL_LOCK_STALE_MS = 10_000;
300
+ let jsonlLockDepth = 0;
301
+ /** Cross-process advisory lock for the JSONL fallback (atomic mkdir). */
302
+ async function withJsonlLock(task) {
303
+ if (jsonlLockDepth > 0)
304
+ return task();
305
+ const deadline = Date.now() + 5_000;
306
+ try {
307
+ await mkdir(dirname(jsonlFile), { recursive: true });
308
+ }
309
+ catch {
310
+ // Directory creation is retried by appendJsonl.
311
+ }
312
+ for (;;) {
313
+ try {
314
+ await mkdir(jsonlLockDir);
315
+ break;
316
+ }
317
+ catch (err) {
318
+ if (err?.code !== "EEXIST")
319
+ throw err;
320
+ // Break a stale lock left behind by a crashed process.
321
+ try {
322
+ const info = await stat(jsonlLockDir);
323
+ if (Date.now() - info.mtimeMs > JSONL_LOCK_STALE_MS) {
324
+ await rm(jsonlLockDir, { recursive: true, force: true });
325
+ continue;
326
+ }
327
+ }
328
+ catch {
329
+ continue;
330
+ }
331
+ if (Date.now() > deadline)
332
+ break; // proceed rather than deadlock
333
+ await new Promise((resolve) => setTimeout(resolve, 15));
334
+ }
335
+ }
336
+ try {
337
+ jsonlLockDepth += 1;
338
+ return await task();
339
+ }
340
+ finally {
341
+ jsonlLockDepth -= 1;
342
+ await rm(jsonlLockDir, { recursive: true, force: true }).catch(() => undefined);
343
+ }
344
+ }
345
+ /**
346
+ * Persist unconditionally (no version check). Used for whole-plan replacement
347
+ * (creation, approval of a freshly built plan, migrations). Concurrent
348
+ * transitions must use {@link mutatePlan} instead.
349
+ */
236
350
  export async function savePlan(plan) {
237
351
  plan.updatedAt = new Date().toISOString();
238
352
  if (getConfig().privateMode)
239
353
  return; // never persist in private mode
240
- const db = await loadDatabase();
241
- if (db) {
242
- db.prepare(`INSERT INTO plans (session_id, goal, detail, tasks_json, status, kind, created_at, updated_at)
243
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
354
+ await enqueuePlanWrite(async () => {
355
+ const db = await loadDatabase();
356
+ if (db) {
357
+ writePlanRowSqlite(db, plan);
358
+ return;
359
+ }
360
+ await withJsonlLock(() => appendJsonl(plan));
361
+ });
362
+ }
363
+ function writePlanRowSqlite(db, plan) {
364
+ db.prepare(`INSERT INTO plans (session_id, goal, detail, tasks_json, status, kind, created_at, updated_at, version)
365
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
244
366
  ON CONFLICT(session_id) DO UPDATE SET
245
367
  goal=excluded.goal, detail=excluded.detail, tasks_json=excluded.tasks_json,
246
- status=excluded.status, kind=excluded.kind, updated_at=excluded.updated_at`).run(plan.sessionId, plan.goal, plan.detail, serializeTasksPayload(plan), plan.status, plan.kind, plan.createdAt, plan.updatedAt);
247
- return;
368
+ status=excluded.status, kind=excluded.kind, updated_at=excluded.updated_at,
369
+ version=excluded.version`).run(plan.sessionId, plan.goal, plan.detail, serializeTasksPayload(plan), plan.status, plan.kind, plan.createdAt, plan.updatedAt, plan.version ?? 1);
370
+ }
371
+ /** Compare-and-set write: only replaces a row still at `expectedVersion`. */
372
+ function casPlanRowSqlite(db, plan, expectedVersion) {
373
+ const info = db
374
+ .prepare(`UPDATE plans SET goal=?, detail=?, tasks_json=?, status=?, kind=?, updated_at=?, version=?
375
+ WHERE session_id=? AND version=?`)
376
+ .run(plan.goal, plan.detail, serializeTasksPayload(plan), plan.status, plan.kind, plan.updatedAt, plan.version ?? expectedVersion + 1, plan.sessionId, expectedVersion);
377
+ return (info?.changes ?? 0) > 0;
378
+ }
379
+ const PLAN_MUTATION_RETRIES = 5;
380
+ /**
381
+ * The authoritative plan mutation boundary.
382
+ *
383
+ * Loads the plan fresh, runs `reducer` on it, enforces domain invariants, then
384
+ * persists with a version compare-and-set. On a CAS conflict the reducer is
385
+ * re-run against the newer state (reducers must therefore be idempotent and
386
+ * expressed as "apply this transition", not "write this snapshot").
387
+ *
388
+ * Return `false` from the reducer to abort without writing.
389
+ */
390
+ export async function mutatePlan(sessionId, reducer, opts) {
391
+ if (getConfig().privateMode) {
392
+ // Nothing is persisted in private mode; apply to a transient copy so
393
+ // callers still see a consistent in-memory result.
394
+ const plan = await loadPlan(sessionId);
395
+ if (!plan)
396
+ return { ok: false, reason: "missing-plan" };
397
+ if (reducer(plan) === false)
398
+ return { ok: false, reason: "no-change" };
399
+ const repairs = enforcePlanInvariants(plan);
400
+ plan.version = (plan.version ?? 1) + 1;
401
+ plan.updatedAt = new Date().toISOString();
402
+ return { ok: true, plan, ...(repairs.length ? { repairs } : {}) };
248
403
  }
249
- await appendJsonl(plan);
404
+ const retries = opts?.retries ?? PLAN_MUTATION_RETRIES;
405
+ return enqueuePlanWrite(async () => {
406
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
407
+ const current = await loadPlan(sessionId);
408
+ if (!current)
409
+ return { ok: false, reason: "missing-plan" };
410
+ const baseVersion = current.version ?? 1;
411
+ if (opts?.expectedVersion !== undefined &&
412
+ opts.expectedVersion !== baseVersion) {
413
+ return { ok: false, reason: "version-conflict" };
414
+ }
415
+ const draft = clonePlan(current);
416
+ if (reducer(draft) === false) {
417
+ return { ok: false, reason: "no-change", plan: current };
418
+ }
419
+ const repairs = enforcePlanInvariants(draft);
420
+ const validation = validateSessionPlan(draft);
421
+ if (!validation.ok) {
422
+ return { ok: false, reason: "invalid", plan: current };
423
+ }
424
+ draft.version = baseVersion + 1;
425
+ draft.updatedAt = new Date().toISOString();
426
+ const db = await loadDatabase();
427
+ if (db) {
428
+ if (casPlanRowSqlite(db, draft, baseVersion)) {
429
+ return {
430
+ ok: true,
431
+ plan: draft,
432
+ ...(repairs.length ? { repairs } : {}),
433
+ };
434
+ }
435
+ continue; // another writer won; re-run the reducer on fresh state
436
+ }
437
+ const committed = await withJsonlLock(async () => {
438
+ const stored = (await readAllJsonl()).find((candidate) => candidate.sessionId === sessionId);
439
+ if ((stored?.version ?? 1) !== baseVersion)
440
+ return false;
441
+ await appendJsonl(draft);
442
+ return true;
443
+ });
444
+ if (committed) {
445
+ return { ok: true, plan: draft, ...(repairs.length ? { repairs } : {}) };
446
+ }
447
+ }
448
+ return { ok: false, reason: "version-conflict" };
449
+ });
450
+ }
451
+ function clonePlan(plan) {
452
+ return {
453
+ ...plan,
454
+ tasks: plan.tasks.map((task) => ({
455
+ ...task,
456
+ dependencies: [...(task.dependencies ?? [])],
457
+ resourceLocks: [...(task.resourceLocks ?? [])],
458
+ ...(task.evidence ? { evidence: { ...task.evidence } } : {}),
459
+ ...(task.aliases ? { aliases: [...task.aliases] } : {}),
460
+ })),
461
+ ...(plan.meta ? { meta: { ...plan.meta } } : {}),
462
+ };
250
463
  }
251
464
  async function appendJsonl(plan) {
252
465
  try {
@@ -283,6 +496,25 @@ async function readAllJsonl() {
283
496
  }
284
497
  }
285
498
  /** Upgrade legacy persisted plans without changing task ids or states. */
499
+ /**
500
+ * Fill in dependencies for legacy rows that never stored the field. The previous
501
+ * foreground task is the default; a responder child is display-linked to its
502
+ * parent and must never become a blocker.
503
+ */
504
+ function normalizeLegacyDependencies(tasks) {
505
+ return tasks.map((task, index) => {
506
+ const previousForeground = tasks
507
+ .slice(0, index)
508
+ .reverse()
509
+ .find((candidate) => !candidate.responderOwned);
510
+ const legacyDefault = task.responderOwned || !previousForeground ? [] : [previousForeground.id];
511
+ return {
512
+ ...task,
513
+ dependencies: [...(task.dependencies ?? legacyDefault)],
514
+ resourceLocks: [...(task.resourceLocks ?? [])],
515
+ };
516
+ });
517
+ }
286
518
  function normalizePersistedPlan(plan) {
287
519
  return {
288
520
  ...plan,
@@ -290,13 +522,7 @@ function normalizePersistedPlan(plan) {
290
522
  version: typeof plan.version === "number" && plan.version >= 1
291
523
  ? plan.version
292
524
  : 1,
293
- tasks: plan.tasks.map((task, index) => ({
294
- ...task,
295
- dependencies: [
296
- ...(task.dependencies ?? (index > 0 ? [plan.tasks[index - 1].id] : [])),
297
- ],
298
- resourceLocks: [...(task.resourceLocks ?? [])],
299
- })),
525
+ tasks: normalizeLegacyDependencies(plan.tasks),
300
526
  };
301
527
  }
302
528
  /** Heal plans that stored bare `t1`/`t2` strings as task titles. */
@@ -310,14 +536,18 @@ export async function loadPlan(sessionId) {
310
536
  const db = await loadDatabase();
311
537
  if (db) {
312
538
  const row = db
313
- .prepare("SELECT session_id, goal, detail, tasks_json, status, kind, created_at, updated_at FROM plans WHERE session_id = ?")
539
+ .prepare("SELECT session_id, goal, detail, tasks_json, status, kind, created_at, updated_at, version FROM plans WHERE session_id = ?")
314
540
  .get(sessionId);
315
541
  if (!row)
316
542
  return undefined;
317
543
  const { tasks, meta, version } = deserializeTasksPayload(row.tasks_json);
544
+ // The column is authoritative once present (CAS writes maintain it).
545
+ const rowVersion = typeof row.version === "number" && row.version >= 1
546
+ ? Math.max(row.version, version)
547
+ : version;
318
548
  const plan = {
319
549
  schemaVersion: 2,
320
- version,
550
+ version: rowVersion,
321
551
  sessionId: row.session_id,
322
552
  goal: row.goal,
323
553
  detail: row.detail,
@@ -332,7 +562,8 @@ export async function loadPlan(sessionId) {
332
562
  const needsNormalization = plan.tasks.some((task) => task.dependencies === undefined || task.resourceLocks === undefined);
333
563
  const normalized = normalizePersistedPlan(plan);
334
564
  const { plan: healed, healed: dirty } = healBareIdTasks(normalized);
335
- if (dirty || needsNormalization) {
565
+ const repairs = enforcePlanInvariants(healed);
566
+ if (dirty || needsNormalization || repairs.length > 0) {
336
567
  // Persist upgrades so resumed SQLite and JSONL plans share dependency semantics.
337
568
  await savePlan(healed);
338
569
  }
@@ -344,7 +575,8 @@ export async function loadPlan(sessionId) {
344
575
  const needsNormalization = found.tasks.some((task) => task.dependencies === undefined || task.resourceLocks === undefined);
345
576
  const normalized = normalizePersistedPlan(found);
346
577
  const { plan: healed, healed: dirty } = healBareIdTasks(normalized);
347
- if (dirty || needsNormalization)
578
+ const repairs = enforcePlanInvariants(healed);
579
+ if (dirty || needsNormalization || repairs.length > 0)
348
580
  await savePlan(healed);
349
581
  return healed;
350
582
  }
@@ -500,10 +732,123 @@ export function readyPlanTasks(plan) {
500
732
  (task.dependencies ?? []).every((dependency) => done.has(dependency)) &&
501
733
  !(task.resourceLocks ?? []).some((resource) => held.has(resource)));
502
734
  }
735
+ /** Foreground (non-responder) tasks that are currently `in_progress`. */
736
+ export function activeForegroundTasks(plan) {
737
+ return plan.tasks.filter((task) => !task.responderOwned && task.state === "in_progress");
738
+ }
739
+ /**
740
+ * `count(foreground tasks in_progress) <= 1` is a domain invariant,
741
+ * not a prompt convention. Applied on every {@link mutatePlan} commit and on
742
+ * load. The earliest dependency-valid active task is kept; later ones are
743
+ * demoted to `pending` with a repair note (evidence is preserved).
744
+ *
745
+ * A parent/child display relationship never implies a dependency, so responder
746
+ * children may run concurrently and are ignored here.
747
+ */
748
+ export function enforcePlanInvariants(plan) {
749
+ const repairs = [...repairOrphanParents(plan)];
750
+ const active = activeForegroundTasks(plan);
751
+ if (active.length <= 1)
752
+ return repairs;
753
+ const settled = new Set(plan.tasks
754
+ .filter((task) => task.state === "done" || task.state === "skipped")
755
+ .map((task) => task.id));
756
+ const dependencyValid = (task) => (task.dependencies ?? []).every((dependency) => settled.has(dependency));
757
+ const keep = active.find(dependencyValid) ?? active[0];
758
+ for (const task of active) {
759
+ if (task === keep)
760
+ continue;
761
+ task.state = "pending";
762
+ task.note = task.note
763
+ ? `${task.note} (reopened later: only one foreground task may be active)`
764
+ : "Demoted to pending: only one foreground task may be active at a time.";
765
+ repairs.push(`demoted ${task.id} to pending (single-active invariant)`);
766
+ }
767
+ return repairs;
768
+ }
769
+ /**
770
+ * Never leave a child pointing at a task that no longer exists. A revision that
771
+ * removes a parent with live children keeps the ownership audit in the note and
772
+ * detaches the display link instead of silently orphaning the row.
773
+ */
774
+ function repairOrphanParents(plan) {
775
+ const repairs = [];
776
+ const known = new Set(plan.tasks.map((task) => task.id));
777
+ for (const task of plan.tasks) {
778
+ if (!task.parentTaskId || known.has(task.parentTaskId))
779
+ continue;
780
+ const detached = task.parentTaskId;
781
+ delete task.parentTaskId;
782
+ task.note = task.note
783
+ ? `${task.note} (detached: parent ${detached} was removed)`
784
+ : `Detached responder work: parent ${detached} was removed by a revision.`;
785
+ repairs.push(`detached ${task.id} from removed parent ${detached}`);
786
+ }
787
+ return repairs;
788
+ }
789
+ /**
790
+ * Apply a foreground-authored plan snapshot onto fresh state.
791
+ *
792
+ * Whole-plan writes (plan.create, revisions, task.add reordering) are authored
793
+ * against a loaded copy. Replacing the stored plan with that copy dropped
794
+ * anything an asynchronous writer changed in the meantime. This applies the
795
+ * snapshot's foreground intent while treating responder children as owned by
796
+ * process settlement:
797
+ *
798
+ * - responder-owned rows keep their stored state/note/job linkage;
799
+ * - responder children created concurrently are retained;
800
+ * - stored evidence is kept when the snapshot has none for that task.
801
+ */
802
+ export function applyForegroundSnapshot(draft, snapshot) {
803
+ const stored = new Map(draft.tasks.map((task) => [task.id, task]));
804
+ const next = [];
805
+ for (const task of snapshot.tasks) {
806
+ const existing = stored.get(task.id);
807
+ if (existing?.responderOwned) {
808
+ next.push(existing);
809
+ continue;
810
+ }
811
+ next.push({
812
+ ...task,
813
+ ...(task.evidence === undefined && existing?.evidence
814
+ ? { evidence: existing.evidence }
815
+ : {}),
816
+ });
817
+ }
818
+ const kept = new Set(next.map((task) => task.id));
819
+ for (const task of draft.tasks) {
820
+ if (kept.has(task.id))
821
+ continue;
822
+ // Only responder children may appear from a concurrent writer; a foreground
823
+ // task missing from the snapshot was intentionally removed by the author.
824
+ if (!task.responderOwned)
825
+ continue;
826
+ const parentIndex = task.parentTaskId
827
+ ? next.findIndex((candidate) => candidate.id === task.parentTaskId)
828
+ : -1;
829
+ if (parentIndex >= 0)
830
+ next.splice(parentIndex + 1, 0, task);
831
+ else
832
+ next.push(task);
833
+ }
834
+ draft.tasks = next;
835
+ draft.goal = snapshot.goal;
836
+ draft.detail = snapshot.detail;
837
+ draft.status = snapshot.status;
838
+ draft.kind = snapshot.kind;
839
+ if (snapshot.meta)
840
+ draft.meta = { ...(draft.meta ?? {}), ...snapshot.meta };
841
+ }
842
+ /**
843
+ * Apply a task transition. Rejects transitions the table forbids so no
844
+ * caller can rewind terminal work; use a plan revision to supersede a task.
845
+ */
503
846
  export function markTask(plan, taskId, state, note) {
504
847
  const task = plan.tasks.find((t) => t.id === taskId);
505
848
  if (!task)
506
849
  return false;
850
+ if (!evaluateTaskTransition(task.state, state).allowed)
851
+ return false;
507
852
  task.state = state;
508
853
  if (note !== undefined)
509
854
  task.note = note;
@@ -522,9 +867,24 @@ export function markNextTask(plan, state) {
522
867
  plan.updatedAt = new Date().toISOString();
523
868
  return task;
524
869
  }
525
- function foregroundTasks(plan) {
870
+ /** Tasks the model owns. Responder children advance from process lifecycle. */
871
+ export function foregroundTasks(plan) {
526
872
  return plan.tasks.filter((task) => !task.responderOwned);
527
873
  }
874
+ /** Foreground work that is neither settled nor skipped, in plan order. */
875
+ export function foregroundRemaining(plan) {
876
+ return foregroundTasks(plan).filter((task) => task.state === "pending" || task.state === "in_progress");
877
+ }
878
+ /** The single active foreground task, or the next one to resume. */
879
+ export function foregroundActiveTask(plan) {
880
+ const remaining = foregroundRemaining(plan);
881
+ return (remaining.find((task) => task.state === "in_progress") ?? remaining[0]);
882
+ }
883
+ /** Responder children that are still running or awaiting analysis. */
884
+ export function responderOpenTasks(plan) {
885
+ return plan.tasks.filter((task) => task.responderOwned &&
886
+ (task.state === "pending" || task.state === "in_progress"));
887
+ }
528
888
  export function planProgress(plan) {
529
889
  const foreground = foregroundTasks(plan);
530
890
  const done = foreground.filter((task) => task.state === "done").length;