otacon 0.1.2 → 0.1.4

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 (201) hide show
  1. package/README.md +18 -5
  2. package/dist/cli/browser.js +1 -1
  3. package/dist/cli/browser.js.map +1 -1
  4. package/dist/cli/client.js +13 -2
  5. package/dist/cli/client.js.map +1 -1
  6. package/dist/cli/commands/answer.js +1 -1
  7. package/dist/cli/commands/answer.js.map +1 -1
  8. package/dist/cli/commands/ask.js +3 -3
  9. package/dist/cli/commands/ask.js.map +1 -1
  10. package/dist/cli/commands/clean.js +14 -16
  11. package/dist/cli/commands/clean.js.map +1 -1
  12. package/dist/cli/commands/config.js +1 -1
  13. package/dist/cli/commands/config.js.map +1 -1
  14. package/dist/cli/commands/doctor.js +4 -4
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/expose.js +7 -7
  17. package/dist/cli/commands/expose.js.map +1 -1
  18. package/dist/cli/commands/implement-done.js +1 -1
  19. package/dist/cli/commands/implement-done.js.map +1 -1
  20. package/dist/cli/commands/install.js +16 -10
  21. package/dist/cli/commands/install.js.map +1 -1
  22. package/dist/cli/commands/open.js +31 -7
  23. package/dist/cli/commands/open.js.map +1 -1
  24. package/dist/cli/commands/progress.js +1 -1
  25. package/dist/cli/commands/progress.js.map +1 -1
  26. package/dist/cli/commands/resume.js +67 -0
  27. package/dist/cli/commands/resume.js.map +1 -0
  28. package/dist/cli/commands/start.js +24 -3
  29. package/dist/cli/commands/start.js.map +1 -1
  30. package/dist/cli/commands/status.js +15 -4
  31. package/dist/cli/commands/status.js.map +1 -1
  32. package/dist/cli/commands/submit.js +5 -5
  33. package/dist/cli/commands/submit.js.map +1 -1
  34. package/dist/cli/commands/update.js +83 -0
  35. package/dist/cli/commands/update.js.map +1 -0
  36. package/dist/cli/commands/wait.js +1 -1
  37. package/dist/cli/commands/wait.js.map +1 -1
  38. package/dist/cli/install/assets.js +123 -48
  39. package/dist/cli/install/assets.js.map +1 -1
  40. package/dist/cli/install/locations.js +1 -1
  41. package/dist/cli/install/locations.js.map +1 -1
  42. package/dist/cli/install/tailscale.js +2 -2
  43. package/dist/cli/install/tailscale.js.map +1 -1
  44. package/dist/cli/install/wrapper.js +202 -0
  45. package/dist/cli/install/wrapper.js.map +1 -0
  46. package/dist/cli/main.js +10 -3
  47. package/dist/cli/main.js.map +1 -1
  48. package/dist/cli/output.js +2 -2
  49. package/dist/cli/output.js.map +1 -1
  50. package/dist/cli/session.js +18 -7
  51. package/dist/cli/session.js.map +1 -1
  52. package/dist/cli/update.js +236 -0
  53. package/dist/cli/update.js.map +1 -0
  54. package/dist/daemon/activity.js +1 -1
  55. package/dist/daemon/activity.js.map +1 -1
  56. package/dist/daemon/anchor.js +1 -1
  57. package/dist/daemon/anchor.js.map +1 -1
  58. package/dist/daemon/app.js +370 -82
  59. package/dist/daemon/app.js.map +1 -1
  60. package/dist/daemon/approve.js +3 -3
  61. package/dist/daemon/approve.js.map +1 -1
  62. package/dist/daemon/capture/adapter.js +23 -0
  63. package/dist/daemon/capture/adapter.js.map +1 -0
  64. package/dist/daemon/capture/claude.js +274 -0
  65. package/dist/daemon/capture/claude.js.map +1 -0
  66. package/dist/daemon/capture/codex.js +413 -0
  67. package/dist/daemon/capture/codex.js.map +1 -0
  68. package/dist/daemon/capture/normalize.js +89 -0
  69. package/dist/daemon/capture/normalize.js.map +1 -0
  70. package/dist/daemon/capture/opencode.js +330 -0
  71. package/dist/daemon/capture/opencode.js.map +1 -0
  72. package/dist/daemon/capture/registry.js +33 -0
  73. package/dist/daemon/capture/registry.js.map +1 -0
  74. package/dist/daemon/capture/stream-store.js +121 -0
  75. package/dist/daemon/capture/stream-store.js.map +1 -0
  76. package/dist/daemon/capture/tailer.js +108 -0
  77. package/dist/daemon/capture/tailer.js.map +1 -0
  78. package/dist/daemon/desktop-notify.js +11 -5
  79. package/dist/daemon/desktop-notify.js.map +1 -1
  80. package/dist/daemon/diagrams.js +90 -0
  81. package/dist/daemon/diagrams.js.map +1 -0
  82. package/dist/daemon/diff.js +1 -2
  83. package/dist/daemon/diff.js.map +1 -1
  84. package/dist/daemon/linter/parse.js +45 -12
  85. package/dist/daemon/linter/parse.js.map +1 -1
  86. package/dist/daemon/linter/rules.js +20 -17
  87. package/dist/daemon/linter/rules.js.map +1 -1
  88. package/dist/daemon/main.js +1 -1
  89. package/dist/daemon/main.js.map +1 -1
  90. package/dist/daemon/notify.js +1 -1
  91. package/dist/daemon/notify.js.map +1 -1
  92. package/dist/daemon/presence.js +1 -1
  93. package/dist/daemon/presence.js.map +1 -1
  94. package/dist/daemon/queue.js +7 -7
  95. package/dist/daemon/queue.js.map +1 -1
  96. package/dist/daemon/store.js +66 -79
  97. package/dist/daemon/store.js.map +1 -1
  98. package/dist/daemon/threads.js +111 -25
  99. package/dist/daemon/threads.js.map +1 -1
  100. package/dist/daemon/transcript.js +1 -1
  101. package/dist/daemon/transcript.js.map +1 -1
  102. package/dist/daemon/ui.js +13 -6
  103. package/dist/daemon/ui.js.map +1 -1
  104. package/dist/daemon/viewers.js +37 -0
  105. package/dist/daemon/viewers.js.map +1 -0
  106. package/dist/shared/config.js +52 -7
  107. package/dist/shared/config.js.map +1 -1
  108. package/dist/shared/gwt.js +1 -1
  109. package/dist/shared/gwt.js.map +1 -1
  110. package/dist/shared/paths.js +68 -33
  111. package/dist/shared/paths.js.map +1 -1
  112. package/dist/shared/question-spec.js +1 -1
  113. package/dist/shared/question-spec.js.map +1 -1
  114. package/dist/shared/types.js +7 -3
  115. package/dist/shared/types.js.map +1 -1
  116. package/dist/shared/version.js +1 -1
  117. package/dist/skills/otacon/SKILL.md +250 -0
  118. package/dist/ui/assets/{arc-KT3ZnaMp.js → arc-BUR2DxNA.js} +1 -1
  119. package/dist/ui/assets/architecture-7EHR7CIX-TTokq2IO.js +1 -0
  120. package/dist/ui/assets/{architectureDiagram-3BPJPVTR-DLu0UM7N.js → architectureDiagram-3BPJPVTR-unLnkDyM.js} +1 -1
  121. package/dist/ui/assets/{blockDiagram-GPEHLZMM-B8wApEWC.js → blockDiagram-GPEHLZMM-DHx8lNeL.js} +1 -1
  122. package/dist/ui/assets/{c4Diagram-AAUBKEIU-BNS5gmQS.js → c4Diagram-AAUBKEIU-BU9T562l.js} +1 -1
  123. package/dist/ui/assets/channel-BA6ChrT3.js +1 -0
  124. package/dist/ui/assets/{chunk-2J33WTMH-CTY2etwY.js → chunk-2J33WTMH-BEb0myVl.js} +1 -1
  125. package/dist/ui/assets/{chunk-3OPIFGDE-DZM4Sz84.js → chunk-3OPIFGDE-DESBG_RB.js} +1 -1
  126. package/dist/ui/assets/{chunk-4BX2VUAB-sGwrrXIO.js → chunk-4BX2VUAB-dt3F_E_5.js} +1 -1
  127. package/dist/ui/assets/{chunk-55IACEB6-CGlNp76o.js → chunk-55IACEB6-BcyuZM7U.js} +1 -1
  128. package/dist/ui/assets/{chunk-5ZQYHXKU-5zebJ3jW.js → chunk-5ZQYHXKU-DqdwSJlO.js} +1 -1
  129. package/dist/ui/assets/{chunk-727SXJPM-DelmUpvy.js → chunk-727SXJPM-B04SqNWj.js} +1 -1
  130. package/dist/ui/assets/{chunk-AQP2D5EJ-DMVzBf3M.js → chunk-AQP2D5EJ-DpjCPBWN.js} +1 -1
  131. package/dist/ui/assets/{chunk-BSJP7CBP-CZHrcpSu.js → chunk-BSJP7CBP-BNOU3k5G.js} +1 -1
  132. package/dist/ui/assets/{chunk-CSCIHK7Q-C1efTp0O.js → chunk-CSCIHK7Q-iWOtNZm_.js} +1 -1
  133. package/dist/ui/assets/{chunk-FMBD7UC4-B6axGwgn.js → chunk-FMBD7UC4-BHH_etky.js} +1 -1
  134. package/dist/ui/assets/{chunk-KSCS5N6A-DSaxbrm0.js → chunk-KSCS5N6A-DbRuazP3.js} +1 -1
  135. package/dist/ui/assets/{chunk-L5ZTLDWV-D9ZKdVGx.js → chunk-L5ZTLDWV-B2ZZFONi.js} +1 -1
  136. package/dist/ui/assets/{chunk-LZXEDZCA-DI5_1s00.js → chunk-LZXEDZCA-DryVpwAh.js} +2 -2
  137. package/dist/ui/assets/{chunk-ND2GUHAM-DQQ4RVLE.js → chunk-ND2GUHAM-2Bb1izqg.js} +1 -1
  138. package/dist/ui/assets/{chunk-NZK2D7GU-9zJJaDpb.js → chunk-NZK2D7GU-2DhvLbqL.js} +1 -1
  139. package/dist/ui/assets/{chunk-O5CBEL6O-DUOshAt2.js → chunk-O5CBEL6O-B5oigO7D.js} +1 -1
  140. package/dist/ui/assets/chunk-QZHKN3VN-BDHgdxoT.js +1 -0
  141. package/dist/ui/assets/chunk-WU5MYG2G-FDJTP_wT.js +1 -0
  142. package/dist/ui/assets/{chunk-XPW4576I-DQVL_GAl.js → chunk-XPW4576I-Dmq-O7bc.js} +1 -1
  143. package/dist/ui/assets/classDiagram-4FO5ZUOK-B5kZsiIt.js +1 -0
  144. package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-B5kZsiIt.js +1 -0
  145. package/dist/ui/assets/{cose-bilkent-S5V4N54A-C7m3VlSW.js → cose-bilkent-S5V4N54A-oTsqU1DY.js} +1 -1
  146. package/dist/ui/assets/{dagre-BM42HDAG-CoU_T6EY.js → dagre-BM42HDAG-CZykCU6B.js} +1 -1
  147. package/dist/ui/assets/{diagram-2AECGRRQ-C_R7oNKE.js → diagram-2AECGRRQ-BY7clIlO.js} +1 -1
  148. package/dist/ui/assets/{diagram-5GNKFQAL-UhE2W3Yi.js → diagram-5GNKFQAL-BWdq4hV0.js} +1 -1
  149. package/dist/ui/assets/{diagram-KO2AKTUF-D4KZCRld.js → diagram-KO2AKTUF-CCGEaiZg.js} +1 -1
  150. package/dist/ui/assets/{diagram-LMA3HP47-CsOe8-S6.js → diagram-LMA3HP47-CVkepFYU.js} +1 -1
  151. package/dist/ui/assets/{diagram-OG6HWLK6-Bkbs2V54.js → diagram-OG6HWLK6-f-NjsfLG.js} +1 -1
  152. package/dist/ui/assets/{dist-BcowSnUl.js → dist-ZqsueX9_.js} +1 -1
  153. package/dist/ui/assets/{erDiagram-TEJ5UH35-DJXsykZz.js → erDiagram-TEJ5UH35-D8Wn6QP3.js} +1 -1
  154. package/dist/ui/assets/eventmodeling-FCH6USID-BODDoY6e.js +1 -0
  155. package/dist/ui/assets/{flowDiagram-I6XJVG4X-Chguj9Xz.js → flowDiagram-I6XJVG4X-BYnhla9k.js} +1 -1
  156. package/dist/ui/assets/{ganttDiagram-6RSMTGT7-DXdpFesp.js → ganttDiagram-6RSMTGT7-Dpu52ZRF.js} +1 -1
  157. package/dist/ui/assets/{gitGraph-WXDBUCRP-Doq1CcxB.js → gitGraph-WXDBUCRP-ou8xzQe1.js} +1 -1
  158. package/dist/ui/assets/{gitGraphDiagram-PVQCEYII-uxGwHEPv.js → gitGraphDiagram-PVQCEYII-BnTuFH7F.js} +1 -1
  159. package/dist/ui/assets/index-B2mL0c61.js +11 -0
  160. package/dist/ui/assets/index-sZ1TgAvb.css +1 -0
  161. package/dist/ui/assets/{info-J43DQDTF-CgWT_d3M.js → info-J43DQDTF-xgUHa7k0.js} +1 -1
  162. package/dist/ui/assets/{infoDiagram-5YYISTIA-B3oDA2Ct.js → infoDiagram-5YYISTIA-DJBudrwD.js} +1 -1
  163. package/dist/ui/assets/{ishikawaDiagram-YF4QCWOH-C6l0lvC3.js → ishikawaDiagram-YF4QCWOH-DpFHgERI.js} +1 -1
  164. package/dist/ui/assets/{journeyDiagram-JHISSGLW-COf53NwC.js → journeyDiagram-JHISSGLW-Cd32hdpY.js} +1 -1
  165. package/dist/ui/assets/{kanban-definition-UN3LZRKU-DaOPRBld.js → kanban-definition-UN3LZRKU-Dm1V8lr0.js} +1 -1
  166. package/dist/ui/assets/{line-new3jLu3.js → line-CZu6_PMX.js} +1 -1
  167. package/dist/ui/assets/{linear-yA22knFB.js → linear-2tkTX_U2.js} +1 -1
  168. package/dist/ui/assets/{mermaid-parser.core-D8JVCOKU.js → mermaid-parser.core-C4m04cRe.js} +2 -2
  169. package/dist/ui/assets/{mermaid.core-BCrPyVBK.js → mermaid.core-BPeg1ewg.js} +3 -3
  170. package/dist/ui/assets/{mindmap-definition-RKZ34NQL-CVcJTWsW.js → mindmap-definition-RKZ34NQL-BENqEkIK.js} +1 -1
  171. package/dist/ui/assets/{packet-YPE3B663-DlSwpK-G.js → packet-YPE3B663-DNO0oBLH.js} +1 -1
  172. package/dist/ui/assets/{pie-LRSECV5Y-CyMpbo4C.js → pie-LRSECV5Y-CWoz31oB.js} +1 -1
  173. package/dist/ui/assets/{pieDiagram-4H26LBE5-Dy7day5R.js → pieDiagram-4H26LBE5-CtmxhUGI.js} +1 -1
  174. package/dist/ui/assets/{plan-view-CE2ha0qY.js → plan-view-bZtdFbit.js} +4 -4
  175. package/dist/ui/assets/{quadrantDiagram-W4KKPZXB-CVjVgbaQ.js → quadrantDiagram-W4KKPZXB-BO3IZNbx.js} +1 -1
  176. package/dist/ui/assets/{radar-GUYGQ44K-DLPfv0jT.js → radar-GUYGQ44K-AEfROz99.js} +1 -1
  177. package/dist/ui/assets/{requirementDiagram-4Y6WPE33-y_0KOdN8.js → requirementDiagram-4Y6WPE33-D-wQ1szT.js} +1 -1
  178. package/dist/ui/assets/{sankeyDiagram-5OEKKPKP-8T3b7meL.js → sankeyDiagram-5OEKKPKP-Bj4kD_AJ.js} +1 -1
  179. package/dist/ui/assets/{sequenceDiagram-3UESZ5HK-Cn8DZiEr.js → sequenceDiagram-3UESZ5HK-M8QVVH74.js} +1 -1
  180. package/dist/ui/assets/{src-BieOuieI.js → src-DvptJAGq.js} +1 -1
  181. package/dist/ui/assets/{stateDiagram-AJRCARHV-nPEVGd3E.js → stateDiagram-AJRCARHV-BiPRs9rN.js} +1 -1
  182. package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-BYjicZlC.js +1 -0
  183. package/dist/ui/assets/{timeline-definition-PNZ67QCA-Dj_qK0VJ.js → timeline-definition-PNZ67QCA-DS0AGRKw.js} +1 -1
  184. package/dist/ui/assets/{treeView-BLDUP644-G5Bsebqu.js → treeView-BLDUP644-DJakvUbF.js} +1 -1
  185. package/dist/ui/assets/{treemap-LRROVOQU-anUEJzTb.js → treemap-LRROVOQU-QXfc2Vwr.js} +1 -1
  186. package/dist/ui/assets/{vennDiagram-CIIHVFJN-D71ne3dS.js → vennDiagram-CIIHVFJN-CBdJA0hv.js} +1 -1
  187. package/dist/ui/assets/{wardley-L42UT6IY-DrJilmE_.js → wardley-L42UT6IY-DI9SdW45.js} +1 -1
  188. package/dist/ui/assets/{wardleyDiagram-YWT4CUSO-B66G4ayp.js → wardleyDiagram-YWT4CUSO-RMmsKLRe.js} +1 -1
  189. package/dist/ui/assets/{xychartDiagram-2RQKCTM6-CReSRxNG.js → xychartDiagram-2RQKCTM6-DFXccP8B.js} +1 -1
  190. package/dist/ui/index.html +2 -2
  191. package/package.json +7 -5
  192. package/dist/ui/assets/architecture-7EHR7CIX-Cw3I1lil.js +0 -1
  193. package/dist/ui/assets/channel-CkOta24Z.js +0 -1
  194. package/dist/ui/assets/chunk-QZHKN3VN-DqRxzBM_.js +0 -1
  195. package/dist/ui/assets/chunk-WU5MYG2G-B1Mk3aBp.js +0 -1
  196. package/dist/ui/assets/classDiagram-4FO5ZUOK-BH-5P4jH.js +0 -1
  197. package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-BH-5P4jH.js +0 -1
  198. package/dist/ui/assets/eventmodeling-FCH6USID-DSDI1f5T.js +0 -1
  199. package/dist/ui/assets/index-BsTJ9Ul_.css +0 -1
  200. package/dist/ui/assets/index-D-TSanrw.js +0 -11
  201. package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-CKrff-KJ.js +0 -1
@@ -1,4 +1,4 @@
1
- // otacond's HTTP surface (DESIGN.md §6 "HTTP API sketch"), as a Hono app
1
+ // otacond's HTTP surface (review loop and daemon API), as a Hono app
2
2
  // factory so tests drive it via app.request() with no socket.
3
3
  //
4
4
  // Long-poll delivery honors at-least-once (DECISIONS.md "SessionQueue API"):
@@ -12,28 +12,34 @@
12
12
  import { Hono } from "hono";
13
13
  import { isAbsolute, join } from "node:path";
14
14
  import { CONFIG_SCHEMA, loadConfig, readScopeValues, validateScopeInput, } from "../shared/config.js";
15
- import { globalConfigPath, otaconPort, repoConfigPath, repoLocalConfigPath, } from "../shared/paths.js";
15
+ import { expandTilde, globalConfigPath, otaconPort, repoConfigPath, repoLocalConfigPath, } from "../shared/paths.js";
16
16
  import { parseQuestionSpec } from "../shared/question-spec.js";
17
17
  import { TERMINAL_STATUSES } from "../shared/types.js";
18
18
  import { VERSION } from "../shared/version.js";
19
19
  import { appendActivity, latestNote, readActivity } from "./activity.js";
20
+ import { normalize } from "./capture/normalize.js";
21
+ import { appendStreamEvents, readStream, StreamSeq } from "./capture/stream-store.js";
22
+ import { Tailer } from "./capture/tailer.js";
20
23
  import { composeArtifact, localDate, pickHomePath, pickProjectRelPath } from "./approve.js";
21
24
  import { createDesktopNotifier } from "./desktop-notify.js";
25
+ import { validateDiagrams } from "./diagrams.js";
22
26
  import { diffPlans } from "./diff.js";
23
27
  import { lint } from "./linter/index.js";
28
+ import { slugify } from "./linter/parse.js";
24
29
  import { Notifier } from "./notify.js";
25
30
  import { Presence } from "./presence.js";
26
31
  import { SessionQueue } from "./queue.js";
27
32
  import { writeFileAtomic } from "./store.js";
28
- import { answerQuestion, appendThreads, applyRevisionToThreads, commentThreadStates, openCommentThreads, readThreads, } from "./threads.js";
33
+ import { answerQuestion, appendThreads, applyRevisionToThreads, commentThreadStates, openCommentThreads, readThreads, resolveThread, } from "./threads.js";
29
34
  import { answerEntry, appendEntries, appendEntry, readTranscript } from "./transcript.js";
30
35
  import { registerUiRoutes } from "./ui.js";
36
+ import { Viewers } from "./viewers.js";
31
37
  /** Hard ceiling on ?wait= (seconds); agents ask for 540 under their 600s Bash cap. */
32
38
  const MAX_WAIT_SECONDS = 600;
33
39
  const badRequest = (c, message) => c.json({ error: { code: "E_BAD_REQUEST", message } }, 400);
34
40
  const notFound = (c, message) => c.json({ error: { code: "E_NOT_FOUND", message } }, 404);
35
41
  const timeoutEvent = (c) => c.json({ event: "timeout" });
36
- // A session in a terminal state is over (DESIGN.md §6, §12 status machine):
42
+ // A session in a terminal state is over according to the status machine:
37
43
  // every state-mutating verb refuses — the CLI's pointer rules guard its side,
38
44
  // but curl/UI/--session calls must hit the same wall. Each route checks *after*
39
45
  // its body await (see sessionEnded in createApp): a pre-await snapshot goes
@@ -86,7 +92,7 @@ function parseAnchor(raw) {
86
92
  return anchor;
87
93
  }
88
94
  /**
89
- * Validate the submit body's `resolutions` (DESIGN.md §6): an object with
95
+ * Validate the submit body's `resolutions` (review loop and daemon API): an object with
90
96
  * only `changelog` (string) and `threads` (string → string). Strict — an
91
97
  * unknown key is a typo that would silently drop resolutions, so it refuses.
92
98
  * undefined/null = none provided ({}); any other bad shape = undefined.
@@ -161,7 +167,7 @@ export function createApp(options) {
161
167
  const status = store.getSession(id)?.status;
162
168
  return status !== undefined && TERMINAL_STATUSES.includes(status);
163
169
  };
164
- // Agent presence (DESIGN.md §6): ephemeral, in-memory liveness only — the
170
+ // Agent presence (review loop and daemon API): ephemeral, in-memory liveness only — the
165
171
  // epoch-ms of each session's last agent contact. Every mutating verb and each
166
172
  // `wait` park bumps it; the summary exposes it (plus `parked`) and the UI
167
173
  // derives live/offline from its recency, so the daemon needs no timer. A
@@ -190,25 +196,98 @@ export function createApp(options) {
190
196
  const publishQueue = (id, pending) => notifier.publish({ type: "queue", session: id, data: { session: id, pending } });
191
197
  const publishThread = (id, thread) => notifier.publish({ type: "thread", session: id, data: { session: id, thread } });
192
198
  const publishGrill = (id, entry) => notifier.publish({ type: "grill", session: id, data: { session: id, entry } });
193
- // Desktop attention banners (DESIGN.md §6). Presence tracks which sessions
199
+ const publishStream = (id, events) => notifier.publish({ type: "stream", session: id, data: { session: id, events } });
200
+ // Monotonic per-session seq source for the live-activity stream (the
201
+ // automatic, cross-agent activity stream): one StreamSeq per session id,
202
+ // seeded lazily from stream.jsonl's max seq so a daemon restart never re-mints
203
+ // a live seq, then incremented in memory. The daemon owns the single writer.
204
+ const streamSeqs = new Map();
205
+ const nextStreamSeq = (id) => {
206
+ let seq = streamSeqs.get(id);
207
+ if (seq === undefined) {
208
+ seq = new StreamSeq();
209
+ streamSeqs.set(id, seq);
210
+ }
211
+ return seq.next(store.streamPath(id));
212
+ };
213
+ // How many newest stream events the per-session SSE snapshot serves: the
214
+ // session's configured cap (so a repo override applies). The store already
215
+ // bounds the file at the cap on append, so this is belt-and-suspenders — but
216
+ // it keeps the snapshot honest if the cap was lowered since the last trim.
217
+ const loadStreamCap = (id) => {
218
+ const repo = store.getSession(id)?.repo;
219
+ return (repo ? loadConfig(repo) : loadConfig()).stream.cap;
220
+ };
221
+ // Per-session transcript tailers (the automatic, cross-agent activity stream):
222
+ // while a session is active, its tailer watches the coding agent's own
223
+ // transcript and feeds new tool/text/thinking activity through the SAME Phase
224
+ // 1 pipeline the progress route uses — `nextStreamSeq` for the seq,
225
+ // `appendStreamEvents` (capped), and `publishStream` for the SSE frame — so a
226
+ // captured event and a manual `otacon progress` highlight are indistinguishable
227
+ // downstream. A repo whose agent has no adapter attaches no tailer and runs on
228
+ // the progress floor (the registry returns null). Tailers are injectable via
229
+ // options.makeTailer so a test can drive `tick()` without a real fs poll.
230
+ const tailers = new Map();
231
+ const makeTailer = options.makeTailer ?? ((deps) => new Tailer(deps));
232
+ const startTailer = (session) => {
233
+ if (tailers.has(session.id))
234
+ return; // idempotent — already watching
235
+ if (TERMINAL_STATUSES.includes(session.status))
236
+ return; // over: nothing to tail
237
+ const tailer = makeTailer({
238
+ repoRoot: session.repo,
239
+ nextSeq: () => nextStreamSeq(session.id),
240
+ append: (events) => appendStreamEvents(store.streamPath(session.id), events, loadStreamCap(session.id)),
241
+ publish: (events) => publishStream(session.id, events),
242
+ config: () => loadConfig(session.repo).stream,
243
+ });
244
+ tailers.set(session.id, tailer);
245
+ tailer.start();
246
+ };
247
+ const stopTailer = (id) => {
248
+ const tailer = tailers.get(id);
249
+ if (tailer === undefined)
250
+ return;
251
+ tailer.stop();
252
+ tailers.delete(id);
253
+ };
254
+ // Re-attach tailers to sessions that were already active when the daemon
255
+ // started (a restart mid-build): the registry survives the restart, so the
256
+ // live transcript is still being written. New sessions wire their tailer at
257
+ // creation; terminal ones are skipped by startTailer's guard.
258
+ for (const session of store.listSessions())
259
+ startTailer(session);
260
+ // Desktop attention banners (review loop and daemon API). Presence tracks which sessions
194
261
  // have a *visible* review open; the notify sink fires the native macOS banner
195
262
  // (a no-op off darwin). Both are injectable for tests.
196
263
  const presence = options.presence ?? new Presence();
197
264
  const notify = options.notify ?? createDesktopNotifier();
265
+ // Live browser tabs watching this daemon (any session or the index), tracked
266
+ // by an explicit SPA heartbeat with a TTL so `otacon open` can skip launching a
267
+ // duplicate tab (DECISIONS.md "reuse an existing open tab"). A heartbeat rather
268
+ // than an SSE-connection count because the dogfood daemon runs under Bun, whose
269
+ // node:http does not detect a client disconnect, so a connection count leaks;
270
+ // the TTL self-heals a closed/crashed tab under both Node and Bun. Ephemeral,
271
+ // in-memory: a restart starts at 0, and live tabs re-beat on their next ping.
272
+ const viewers = options.viewers ?? new Viewers();
198
273
  /**
199
274
  * Fire a desktop banner for an attention moment unless the user is already
200
275
  * watching this session's review (presence) or has disabled them (config,
201
276
  * loaded fresh per session.repo so a repo override applies). The whole thing
202
277
  * is wrapped: a spawn or config error must never break the submit/ask response
203
- * — it is swallowed to stderr (DESIGN.md §13: zero-API-spend untouched; this
204
- * is a local OS call).
278
+ * — it is swallowed to stderr (this is a local OS call, so the zero model-network-call
279
+ * invariant is untouched).
205
280
  */
206
281
  const maybeNotify = (session, moment) => {
207
282
  try {
208
- if (!loadConfig(session.repo).notifications.desktop)
283
+ if (!loadConfig(session.repo).notifications.desktop) {
284
+ process.stderr.write(`otacond: notify skip session=${session.id} reason=config-disabled\n`);
209
285
  return;
210
- if (presence.isWatched(session.id))
286
+ }
287
+ if (presence.isWatched(session.id)) {
288
+ process.stderr.write(`otacond: notify skip session=${session.id} reason=watched\n`);
211
289
  return;
290
+ }
212
291
  const message = moment.kind === "revision"
213
292
  ? `Revision r${moment.revision} ready for review`
214
293
  : moment.kind === "questions"
@@ -216,6 +295,7 @@ export function createApp(options) {
216
295
  : moment.text.length > 80
217
296
  ? `${moment.text.slice(0, 79)}…`
218
297
  : moment.text;
298
+ process.stderr.write(`otacond: notify dispatch session=${session.id} kind=${moment.kind} title=${JSON.stringify(session.title)} message=${JSON.stringify(message)}\n`);
219
299
  notify({
220
300
  title: session.title,
221
301
  message,
@@ -252,14 +332,16 @@ export function createApp(options) {
252
332
  return c.json(event.payload);
253
333
  }
254
334
  /**
255
- * Finalize an approval (DESIGN.md §6 step 6/7, §12). Writes the composed
335
+ * Finalize an approval. Writes the composed
256
336
  * artifact (with the comment-&-approve `## Review notes` when `reviewNotes` are
257
337
  * present). It ALWAYS writes the canonical home copy
258
- * (`~/.otacon/sessions/<id>/`, the permanent archive).
338
+ * (`~/.otacon/sessions/<id>/`, the session's home dir: removed when the
339
+ * session is deleted, so not a durable archive).
259
340
  * On **Save** (implement=false) it ALSO writes a project copy under the repo's
260
- * configured `plans.dir`, and the event `path` points there. On **Implement**
261
- * (implement=true) it writes home only, and `path` equals `home`. The home
262
- * write is the crash-safe finalize point file(s) before the status flip.
341
+ * configured `plans.dir` (the durable copy), and the event `path` points
342
+ * there. On **Implement** (implement=true) it writes home only, and `path`
343
+ * equals `home` (the durable copy then rides in the PR). The home write is the
344
+ * crash-safe finalize point: file(s) before the status flip.
263
345
  * Then flip the session to `approved` or `implementing`, disarm any deferred
264
346
  * approval, and queue the `approved` wake-up. Shared by plain/force approve and
265
347
  * the deferred fold-in submit so the artifact and event shapes are identical on
@@ -283,9 +365,27 @@ export function createApp(options) {
283
365
  writeFileAtomic(join(session.repo, relPath), artifact);
284
366
  path = relPath;
285
367
  }
368
+ // On Implement, record the build's worktree + branch in the same write that
369
+ // flips to `implementing` (one registry write). Deterministic from the
370
+ // title slug + the configured worktree.dir, so a later `/otacon` run from
371
+ // inside that worktree can match it back to this session and reopen it.
372
+ let implPatch = {};
373
+ if (opts.implement) {
374
+ const slug = slugify(session.title) || "plan";
375
+ const wtDir = expandTilde(loadConfig(session.repo).worktree.dir);
376
+ const worktree = join(wtDir, slug);
377
+ const branch = `otacon/impl-${slug}`;
378
+ implPatch = { impl: { worktree, branch } };
379
+ }
286
380
  const updated = store.updateSession(session.id, {
287
381
  status: opts.implement ? "implementing" : "approved",
382
+ ...implPatch,
288
383
  });
384
+ // Save (approved) is terminal — the agent stops, so tear the tailer down.
385
+ // Implement keeps the session live (`implementing`), so the tailer keeps
386
+ // streaming the build's activity until implement-done flips it terminal.
387
+ if (!opts.implement)
388
+ stopTailer(session.id);
289
389
  // Disarm after the flip: a crash between them leaves a stale flag on an
290
390
  // already-terminal/building session (harmless — no further submit finalizes),
291
391
  // never a finalizing session that lost its flag (which would re-open review).
@@ -312,8 +412,8 @@ export function createApp(options) {
312
412
  });
313
413
  app.onError((error, c) => c.json({ error: { code: "E_INTERNAL", message: error.message } }, 500));
314
414
  app.notFound((c) => c.json({ error: { code: "E_NOT_FOUND", message: `no route: ${c.req.method} ${c.req.path}` } }, 404));
315
- app.get("/api/health", (c) => c.json({ app: "otacond", version: VERSION, pid: process.pid }));
316
- // The Settings UI's config surface (DESIGN.md §6). GET returns the full
415
+ app.get("/api/health", (c) => c.json({ app: "otacond", version: VERSION, pid: process.pid, viewers: viewers.count() }));
416
+ // The Settings UI's config surface (review loop and daemon API). GET returns the full
317
417
  // schema plus each scope's current sparse, coerced values. The `user` scope
318
418
  // (~/.otacon/config.json) is always present; the project scopes only when an
319
419
  // absolute `repo` is named — User config needs no repo, so an absent/empty/
@@ -395,6 +495,10 @@ export function createApp(options) {
395
495
  return badRequest(c, "quick must be a boolean");
396
496
  }
397
497
  const session = store.createSession({ title, repo, branch, quick });
498
+ // Attach the transcript tailer now: the agent is already working in `repo`,
499
+ // so its live transcript may already exist (and if not, the tailer re-locates
500
+ // until it appears). A repo whose agent has no adapter attaches nothing.
501
+ startTailer(session);
398
502
  publishSession(session);
399
503
  return c.json(session, 201);
400
504
  });
@@ -404,51 +508,46 @@ export function createApp(options) {
404
508
  return notFound(c, `unknown session: ${c.req.param("id")}`);
405
509
  return c.json(summarize(session));
406
510
  });
407
- // DELETE removes a session from the registry, status-branched on whether its
408
- // plan is already preserved (DESIGN.md §6, §12). **Terminal** (approved, plus
409
- // implemented/implement_failed once a build finishes): its plan + transcript
410
- // are in the home archive (~/.otacon/sessions/<id>/, never touched here), so the
411
- // working dir is *archived* to .otacon/archive/ (recoverable) `otacon clean`
412
- // and the UI's delete of an
413
- // over session both take this path. Gated on TERMINAL_STATUSES so this split
414
- // agrees with the UI's `over` (which passes `approved={isOver(status)}` to the
415
- // confirm sheet) otherwise an `implemented` delete would promise archival
416
- // and silently hard-delete. **Non-terminal** (draft/in_review/revising, and a
417
- // live `implementing` build): the working dir is *hard-removed* (permanent),
418
- // and any parked agent is woken with a terminal `deleted` event so its `wait`
419
- // loop stops cleanly. Both publish the same terminal `removed` frame; the
420
- // response carries `archivedTo` (the archive path, or null for a hard-delete).
511
+ // DELETE permanently removes a session: it deregisters from the registry and
512
+ // `rmSync`s its home dir `~/.otacon/sessions/<id>/` outright, for ALL statuses
513
+ // (UI delete and `otacon clean` both drive this route). No archive: nothing
514
+ // is recoverable from otacon itself; the durable copies are the Save copy
515
+ // under the project's `plans.dir` and (for Implement plans) the PR. The only
516
+ // status branch left is waking a parked agent: a live (non-terminal) session
517
+ // may have an agent parked on `wait`, so it is woken with a terminal `deleted`
518
+ // event before deregistering, so its loop stops cleanly. Both branches publish
519
+ // the same terminal `removed` frame.
421
520
  app.delete("/api/sessions/:id", (c) => {
422
521
  const session = sessionFor(c);
423
522
  if (!session)
424
523
  return notFound(c, `unknown session: ${c.req.param("id")}`);
425
524
  const queue = queueFor(session.id);
426
525
  const pendingEvents = queue.size;
427
- let archivedTo = null;
526
+ stopTailer(session.id); // the session is going away — stop watching its transcript
428
527
  if (TERMINAL_STATUSES.includes(session.status)) {
429
528
  // Deregister first — it can throw (registry flush), and an early queue
430
529
  // eviction would orphan in-flight ack tracking for a session that is in
431
- // fact still registered. Close the evicted instance before the move so a
432
- // late in-flight ack cannot recreate .otacon/<id>/ next to the archive.
530
+ // fact still registered. Close the evicted instance before the removal so
531
+ // a late in-flight ack cannot recreate ~/.otacon/sessions/<id>/.
433
532
  store.deleteSession(session.id);
434
533
  queue.close();
435
534
  queues.delete(session.id);
436
- archivedTo = store.archiveSessionDir(session.repo, session.id);
535
+ store.removeSessionDir(session.id);
437
536
  }
438
537
  else {
439
538
  // Wake any parked agent BEFORE deregistering so its respondEvent still
440
539
  // resolves against a registered session; closeWith sets the queue closed
441
- // first, so the hard-remove below can't be recreated by a late ack. Then
442
- // deregister and permanently drop the working dir (no committed value).
540
+ // first, so the removal below can't be recreated by a late ack. Then
541
+ // deregister and permanently drop the home dir.
443
542
  queue.closeWith({ event: "deleted", session: session.id });
444
543
  queues.delete(session.id);
445
544
  store.deleteSession(session.id);
446
- store.removeSessionDir(session.repo, session.id);
545
+ store.removeSessionDir(session.id);
447
546
  }
448
547
  // Terminal frame: the index and switcher drop the session live, and an
449
548
  // open review tab flips to its closed state instead of error-limbo.
450
549
  notifier.publish({ type: "removed", session: session.id, data: { session: session.id } });
451
- return c.json({ ok: true, session: session.id, repo: session.repo, pendingEvents, archivedTo });
550
+ return c.json({ ok: true, session: session.id, repo: session.repo, pendingEvents });
452
551
  });
453
552
  app.get("/api/sessions/:id/events", (c) => {
454
553
  const session = sessionFor(c);
@@ -505,7 +604,7 @@ export function createApp(options) {
505
604
  if (!settled) {
506
605
  // Genuinely parked (the queue was empty at take()): broadcast
507
606
  // parked=true + the refreshed lastContactAt so the live dot reaches the
508
- // UI within one park slice (DESIGN.md §6).
607
+ // UI within one park slice (review loop and daemon API).
509
608
  publishSession(session);
510
609
  timer = setTimeout(() => settle(timeoutEvent(c)), waitSeconds * 1000);
511
610
  signal.addEventListener("abort", onAbort);
@@ -517,13 +616,13 @@ export function createApp(options) {
517
616
  if (!session)
518
617
  return notFound(c, `unknown session: ${c.req.param("id")}`);
519
618
  // Raw markdown body, or {"plan": "...", "resolutions": {...}} JSON — the
520
- // CLI sends resolutions.json's content along (DESIGN.md §6). The raw path
619
+ // CLI sends resolutions.json's content along (review loop and daemon API). The raw path
521
620
  // carries no resolutions, so L5 still rejects it when threads are open.
522
621
  let content = await c.req.text();
523
622
  if (sessionEnded(session.id))
524
623
  return sessionOver(c, session.id);
525
624
  // A submit cannot land mid-build. `implementing` is non-terminal (it re-opens
526
- // progress/ask/wait/answer, DESIGN.md §6) so it slips past sessionEnded — but
625
+ // progress/ask/wait/answer, review loop and daemon API) so it slips past sessionEnded — but
527
626
  // submit is not in that verb set, and a revision here would clobber the
528
627
  // approved plan. This also serializes the double-finalize race: a comment-&-
529
628
  // approve fold-in that flips to `implementing` is the winner, and a second
@@ -572,14 +671,19 @@ export function createApp(options) {
572
671
  changelog: resolutions.changelog,
573
672
  },
574
673
  });
575
- if (!result.ok) {
576
- return c.json({ ok: false, errors: result.errors, warnings: result.warnings }, 422);
674
+ // L8 diagram render gate runs alongside the structural linter so the agent
675
+ // gets diagram + structural failures in one pass (fewer round-trips). It
676
+ // fails open, so it only ever adds errors — never blocks on its own infra.
677
+ const diagramErrors = await validateDiagrams(content);
678
+ const errors = [...result.errors, ...diagramErrors];
679
+ if (errors.length > 0) {
680
+ return c.json({ ok: false, errors, warnings: result.warnings }, 422);
577
681
  }
578
682
  const changelog = (resolutions.changelog ?? "").trim() === "" ? null : resolutions.changelog;
579
683
  const revision = store.saveRevision(session.id, content, result.warnings, changelog ?? undefined);
580
684
  // The accepted revision settles its threads: resolutions land on their
581
685
  // threads, every anchor is re-located in the new text, lost ones orphan
582
- // (DESIGN.md §4, §9). SSE upserts keep the rail live.
686
+ // (plan structure, lint, and anchoring, threaded review and revision). SSE upserts keep the rail live.
583
687
  const changedThreads = applyRevisionToThreads(store.threadsPath(session.id), {
584
688
  plan: content,
585
689
  replies,
@@ -597,7 +701,7 @@ export function createApp(options) {
597
701
  for (const thread of changedThreads)
598
702
  publishThread(session.id, thread);
599
703
  };
600
- // Deferred approval (comment & approve, DESIGN.md §6, §12): a send-to-agent
704
+ // Deferred approval (comment & approve): a send-to-agent
601
705
  // approve armed `pendingApproval` and parked the session in `finalizing`.
602
706
  // This clean submit is the agent's fold-in pass — L5 has just vouched that
603
707
  // every open comment carries a resolution — so finalize now instead of
@@ -612,7 +716,7 @@ export function createApp(options) {
612
716
  thread: t.id,
613
717
  section: t.anchor?.section ?? null,
614
718
  body: t.body,
615
- resolution: t.resolution?.body ?? "",
719
+ reply: t.reply?.body ?? "",
616
720
  }));
617
721
  const { path, home } = finalizeApproval(session, {
618
722
  revision,
@@ -650,7 +754,7 @@ export function createApp(options) {
650
754
  resolved: Object.keys(replies),
651
755
  });
652
756
  });
653
- // The user's side of re-review bookkeeping (DESIGN.md §9 layer 3): the UI's
757
+ // The user's side of re-review bookkeeping (threaded review and revision layer 3): the UI's
654
758
  // "mark reviewed" / banner-dismiss POSTs here; comment flushes mark it
655
759
  // implicitly. Monotonic — see Store.markReviewed.
656
760
  app.post("/api/sessions/:id/reviewed", async (c) => {
@@ -670,7 +774,56 @@ export function createApp(options) {
670
774
  publishSession(session); // summary re-reads state, so the frame carries it
671
775
  return c.json({ ok: true, session: session.id, lastReviewedRevision });
672
776
  });
673
- // The review screen reports its visibility here (DESIGN.md §6): {visible:true}
777
+ // Reopen a finished (terminal) session for another review round
778
+ // (resurrect-plan-amend): a `/otacon` run from inside the build worktree flips
779
+ // the session back to `revising` so the agent amends the approved plan in
780
+ // place instead of spawning a second worktree. The baseline is pinned at the
781
+ // approved revision (markReviewed → lastReviewedRevision = revision), so the
782
+ // next submit diffs against what was approved. `prUrl` and `impl` are kept
783
+ // intact: the amendment still belongs to the same build. A non-terminal
784
+ // session is refused E_NOT_REOPENABLE (there is nothing finished to reopen).
785
+ app.post("/api/sessions/:id/reopen", (c) => {
786
+ const session = sessionFor(c);
787
+ if (!session)
788
+ return notFound(c, `unknown session: ${c.req.param("id")}`);
789
+ // Agent-driven verb (a `/otacon` run): bump liveness like its siblings so the
790
+ // reopened session reads live, not offline-until-next-call.
791
+ bumpContact(session.id);
792
+ if (!TERMINAL_STATUSES.includes(session.status)) {
793
+ return c.json({
794
+ error: {
795
+ code: "E_NOT_REOPENABLE",
796
+ message: `session ${session.id} is ${session.status}, not reopenable (must be a finished session)`,
797
+ },
798
+ }, 409);
799
+ }
800
+ const state = store.readState(session.id);
801
+ const revision = state.revision;
802
+ if (revision === 0) {
803
+ // A terminal session always has an approved revision; guard anyway.
804
+ return c.json({
805
+ error: {
806
+ code: "E_NOT_REOPENABLE",
807
+ message: `session ${session.id} has no revisions to reopen`,
808
+ },
809
+ }, 409);
810
+ }
811
+ // Pin the diff baseline at the approved revision (monotonic), so the next
812
+ // submit shows just the amendment.
813
+ store.markReviewed(session.id, revision);
814
+ const updated = store.updateSession(session.id, { status: "revising" });
815
+ publishSession(updated); // the index + an open tab move it back to active
816
+ return c.json({
817
+ ok: true,
818
+ session: session.id,
819
+ status: "revising",
820
+ revision,
821
+ lastReviewedRevision: revision,
822
+ impl: updated.impl ?? null,
823
+ prUrl: updated.prUrl ?? null,
824
+ });
825
+ });
826
+ // The review screen reports its visibility here (review loop and daemon API): {visible:true}
674
827
  // when shown + on a heartbeat, {visible:false} on blur/unload. The daemon
675
828
  // suppresses a desktop banner only while a review is visible — a hidden or
676
829
  // backgrounded tab (its SSE stream still open) does NOT suppress. No status
@@ -690,7 +843,24 @@ export function createApp(options) {
690
843
  presence.markHidden(session.id);
691
844
  return c.json({ ok: true, session: session.id, visible: body.visible });
692
845
  });
693
- // Structural diff between two stored revisions (DESIGN.md §6, §9 layer 3).
846
+ // A browser tab reports its liveness here (open-tab reuse, DECISIONS.md "reuse
847
+ // an existing open tab"): one beat per tab on mount and a ~30s heartbeat, plus
848
+ // a `gone:true` beacon on tab close. Daemon-wide (NOT session-scoped): one tab,
849
+ // via the app-shell sidebar, reaches every session, so `otacon open` only needs
850
+ // to know whether ANY tab from this daemon is live. The 90s TTL self-expires a
851
+ // crashed/closed tab whose `gone` beacon never arrived.
852
+ app.post("/api/viewers/heartbeat", async (c) => {
853
+ const body = (await readJsonBody(c)) ?? {};
854
+ if (typeof body.clientId !== "string" || body.clientId.length === 0) {
855
+ return badRequest(c, "clientId must be a non-empty string");
856
+ }
857
+ if (body.gone)
858
+ viewers.drop(body.clientId);
859
+ else
860
+ viewers.beat(body.clientId);
861
+ return c.json({ ok: true });
862
+ });
863
+ // Structural diff between two stored revisions, defaulting to the last-reviewed baseline.
694
864
  // Defaults: to = latest, from = last-reviewed (?from= selects any other
695
865
  // baseline; 0 = the empty plan, so a never-reviewed session shows all-new).
696
866
  app.get("/api/sessions/:id/diff", (c) => {
@@ -742,13 +912,49 @@ export function createApp(options) {
742
912
  if (!Array.isArray(rawItems) || rawItems.length === 0) {
743
913
  return badRequest(c, "items must be a non-empty array");
744
914
  }
915
+ // A batch may mix new comments and follow-ups: an item with `replyTo` (a
916
+ // comment thread id) continues that conversation INSTEAD of carrying its own
917
+ // anchor — it inherits the root's anchor and orphan state, and a client
918
+ // anchor on it is ignored; an item without `replyTo` parses its own anchor.
919
+ const existing = readThreads(store.threadsPath(session.id));
745
920
  const drafts = [];
746
921
  for (const raw of rawItems) {
747
- const anchor = parseAnchor(raw?.anchor);
748
- if (typeof raw?.body !== "string" || raw.body.trim() === "" || anchor === undefined) {
922
+ if (typeof raw?.body !== "string" || raw.body.trim() === "") {
749
923
  return badRequest(c, "each item needs a non-empty body and a valid anchor (or null)");
750
924
  }
751
- drafts.push({ anchor, body: raw.body });
925
+ const replyToRaw = raw.replyTo;
926
+ if (replyToRaw === undefined) {
927
+ const anchor = parseAnchor(raw.anchor);
928
+ if (anchor === undefined) {
929
+ return badRequest(c, "each item needs a non-empty body and a valid anchor (or null)");
930
+ }
931
+ drafts.push({ anchor, body: raw.body });
932
+ continue;
933
+ }
934
+ if (typeof replyToRaw !== "string" || replyToRaw === "") {
935
+ return badRequest(c, "replyTo must name a comment thread id (t<n>)");
936
+ }
937
+ const parent = existing.find((t) => t.id === replyToRaw && t.kind === "comment");
938
+ if (!parent) {
939
+ return c.json({
940
+ error: {
941
+ code: "E_UNKNOWN_COMMENT",
942
+ message: `session ${session.id} has no comment ${replyToRaw}`,
943
+ },
944
+ }, 404);
945
+ }
946
+ // Resolve the root so a whole chain shares one key — "follow up on a
947
+ // follow-up" collapses to the same root, whose anchor (and orphan state)
948
+ // the new turn inherits and travels with.
949
+ const rootId = parent.replyTo ?? parent.id;
950
+ const root = existing.find((t) => t.id === rootId && t.kind === "comment");
951
+ const source = root ?? parent;
952
+ drafts.push({
953
+ anchor: source.anchor,
954
+ ...(source.anchorState === "orphaned" ? { anchorState: "orphaned" } : {}),
955
+ replyTo: rootId,
956
+ body: raw.body,
957
+ });
752
958
  }
753
959
  // Ids are minted only after the whole batch validates, in one counter
754
960
  // write — a rejected batch burns neither ids nor disk writes.
@@ -760,23 +966,27 @@ export function createApp(options) {
760
966
  const firstThread = counters.thread - drafts.length;
761
967
  const items = drafts.map((draft, i) => ({
762
968
  thread: `t${firstThread + i + 1}`,
763
- ...draft,
969
+ anchor: draft.anchor,
970
+ body: draft.body,
971
+ ...(draft.replyTo !== undefined ? { replyTo: draft.replyTo } : {}),
764
972
  }));
765
973
  const batch = `b${counters.batch}`;
766
- // Each item becomes a persistent thread (DESIGN.md §9) — the rail's
974
+ // Each item becomes a persistent thread (threaded review and revision) — the rail's
767
975
  // source of truth; the queued event is only the agent's wake-up copy.
768
976
  const createdAt = new Date().toISOString();
769
- const threads = items.map((item) => ({
770
- id: item.thread,
977
+ const threads = drafts.map((draft, i) => ({
978
+ id: `t${firstThread + i + 1}`,
771
979
  kind: "comment",
772
980
  batch,
773
- anchor: item.anchor,
774
- body: item.body,
981
+ anchor: draft.anchor,
982
+ ...(draft.anchorState === "orphaned" ? { anchorState: "orphaned" } : {}),
983
+ body: draft.body,
775
984
  createdAt,
985
+ ...(draft.replyTo !== undefined ? { replyTo: draft.replyTo } : {}),
776
986
  }));
777
987
  appendThreads(store.threadsPath(session.id), threads);
778
988
  // Flushing a batch is the implicit "I reviewed this revision" signal
779
- // (DESIGN.md §9 layer 3) — the diff baseline moves with it.
989
+ // (threaded review and revision layer 3) — the diff baseline moves with it.
780
990
  store.markReviewed(session.id, store.readState(session.id).revision);
781
991
  // Comments are revision requests (DECISIONS.md "Status transitions"); flip
782
992
  // status before the enqueue wakes a parked agent.
@@ -800,7 +1010,7 @@ export function createApp(options) {
800
1010
  if (typeof body.body !== "string" || body.body.trim() === "") {
801
1011
  return badRequest(c, "question needs a non-empty body");
802
1012
  }
803
- // A follow-up (DESIGN.md §9) names the question it continues with `replyTo`
1013
+ // A follow-up (threaded review and revision) names the question it continues with `replyTo`
804
1014
  // and inherits that conversation's anchor — so a client anchor is ignored on
805
1015
  // a follow-up; a root question parses its own anchor (or null = whole-plan).
806
1016
  let anchor;
@@ -850,7 +1060,7 @@ export function createApp(options) {
850
1060
  ...(replyTo !== undefined ? { replyTo } : {}),
851
1061
  };
852
1062
  appendThreads(store.threadsPath(session.id), [thread]);
853
- // Questions leave the plan — and the status — untouched (DESIGN.md §9).
1063
+ // Questions leave the plan — and the status — untouched (threaded review and revision).
854
1064
  const payload = {
855
1065
  event: "question",
856
1066
  session: session.id,
@@ -864,7 +1074,7 @@ export function createApp(options) {
864
1074
  publishThread(session.id, thread);
865
1075
  return c.json({ ok: true, id, seq: counters.eventSeq }, 202);
866
1076
  });
867
- // The agent's side of a user question (otacon answer, DESIGN.md §6, §9):
1077
+ // The agent's side of a user question (`otacon answer`):
868
1078
  // the answer lands on the thread — the plan and the status stay untouched —
869
1079
  // and the UI's "answering…" placeholder resolves over SSE.
870
1080
  app.post("/api/sessions/:id/questions/:qid/answer", async (c) => {
@@ -896,17 +1106,46 @@ export function createApp(options) {
896
1106
  answeredAt: thread.answer.answeredAt,
897
1107
  });
898
1108
  });
1109
+ // The reviewer's side of closing a thread (the Resolve verb): {resolved:true}
1110
+ // stamps the close (carrying the session's current revision) on the conversation
1111
+ // root, {resolved:false} reopens it. Resolve doubles as the comment-withdraw
1112
+ // path — a resolved comment no longer owes a reply (L5 skips it) and no longer
1113
+ // counts unresolved at approve. Refused on a terminal session (like the
1114
+ // questions route); 404 on an unknown thread id.
1115
+ app.post("/api/sessions/:id/threads/:tid/resolve", async (c) => {
1116
+ const session = sessionFor(c);
1117
+ if (!session)
1118
+ return notFound(c, `unknown session: ${c.req.param("id")}`);
1119
+ const body = (await readJsonBody(c)) ?? {};
1120
+ if (sessionEnded(session.id))
1121
+ return sessionOver(c, session.id);
1122
+ if (typeof body.resolved !== "boolean") {
1123
+ return badRequest(c, "resolved must be a boolean");
1124
+ }
1125
+ const tid = c.req.param("tid") ?? "";
1126
+ const thread = resolveThread(store.threadsPath(session.id), tid, body.resolved, store.readState(session.id).revision);
1127
+ if (!thread) {
1128
+ return c.json({
1129
+ error: {
1130
+ code: "E_UNKNOWN_THREAD",
1131
+ message: `session ${session.id} has no thread ${tid}`,
1132
+ },
1133
+ }, 404);
1134
+ }
1135
+ publishThread(session.id, thread);
1136
+ return c.json({ ok: true }, 202);
1137
+ });
899
1138
  app.get("/api/sessions/:id/threads", (c) => {
900
1139
  const session = sessionFor(c);
901
1140
  if (!session)
902
1141
  return notFound(c, `unknown session: ${c.req.param("id")}`);
903
1142
  return c.json({ session: session.id, threads: readThreads(store.threadsPath(session.id)) });
904
1143
  });
905
- // The agent's grill question (otacon ask, DESIGN.md §6, §8): persisted in
1144
+ // The agent's grill question (`otacon ask`): persisted in
906
1145
  // the transcript and pushed to the UI as a card; no agent event is queued —
907
1146
  // the asker goes straight back to `otacon wait` for the answer. Accepts a
908
1147
  // single question body or a batch (`{questions:[…]}`) of independent
909
- // questions — independent siblings the agent posts in one call (§8); they
1148
+ // questions — independent siblings the agent posts in one call (interview questions); they
910
1149
  // render as ordinary cards, each answered instantly.
911
1150
  app.post("/api/sessions/:id/ask", async (c) => {
912
1151
  const session = sessionFor(c);
@@ -939,7 +1178,7 @@ export function createApp(options) {
939
1178
  for (const entry of entries)
940
1179
  publishGrill(session.id, entry);
941
1180
  publishSession(store.getSession(session.id) ?? session);
942
- // A batch coalesces to one banner — N questions need answering (DESIGN.md §6).
1181
+ // A batch coalesces to one banner — N questions need answering (review loop and daemon API).
943
1182
  maybeNotify(session, entries.length === 1
944
1183
  ? { kind: "question", text: specs[0].question }
945
1184
  : { kind: "questions", count: entries.length });
@@ -958,7 +1197,7 @@ export function createApp(options) {
958
1197
  maybeNotify(session, { kind: "question", text: spec.question });
959
1198
  return c.json({ ok: true, session: session.id, id: entry.id }, 201);
960
1199
  });
961
- // The user's side of a grill question (DESIGN.md §6, §8): the answer lands
1200
+ // The user's side of a grill question: the answer lands
962
1201
  // on the transcript entry and an `answer` event wakes the parked agent.
963
1202
  app.post("/api/sessions/:id/answers", async (c) => {
964
1203
  const session = sessionFor(c);
@@ -987,12 +1226,12 @@ export function createApp(options) {
987
1226
  // The answer must fit the question's shape: chips for option questions
988
1227
  // (one chip, or 1+ under --multi), free text for optionless ones. A
989
1228
  // non-empty custom answer with no chip is valid on option questions too
990
- // (native-AskUserQuestion "Other" parity, DESIGN.md §8) — and text may
1229
+ // (native-AskUserQuestion "Other" parity, interview questions) — and text may
991
1230
  // still ride a chosen chip as a note.
992
1231
  const customText = typeof text === "string" && text.trim() !== "";
993
1232
  const noChips = choice === undefined && choices === undefined;
994
1233
  if (noChips) {
995
- // "Other" parity (DESIGN.md §8): a non-empty custom answer with no chip
1234
+ // "Other" parity (interview questions): a non-empty custom answer with no chip
996
1235
  // is valid on ANY question shape — the one branch-independent rule, so it
997
1236
  // lives here, not re-stated per shape. Only the hint names the shape.
998
1237
  if (!customText) {
@@ -1027,6 +1266,10 @@ export function createApp(options) {
1027
1266
  ...(customText ? { text: text } : {}),
1028
1267
  answeredAt: new Date().toISOString(),
1029
1268
  };
1269
+ // Snapshot the pre-overwrite answer BEFORE answerEntry mutates the entry: a
1270
+ // re-answer carries `revised` + `prior` so the agent reconciles supersession.
1271
+ const prior = asked.answer;
1272
+ const wasAnswered = prior !== undefined;
1030
1273
  // Re-answering overwrites (at-least-once: a duplicate POST is legitimate);
1031
1274
  // the agent sees a second answer event with the same question id.
1032
1275
  const updated = answerEntry(store.transcriptPath(session.id), question, answer);
@@ -1037,6 +1280,16 @@ export function createApp(options) {
1037
1280
  ...(answer.choice !== undefined ? { choice: answer.choice } : {}),
1038
1281
  ...(answer.choices !== undefined ? { choices: answer.choices } : {}),
1039
1282
  ...(answer.text !== undefined ? { text: answer.text } : {}),
1283
+ ...(wasAnswered
1284
+ ? {
1285
+ revised: true,
1286
+ prior: {
1287
+ ...(prior.choice !== undefined ? { choice: prior.choice } : {}),
1288
+ ...(prior.choices !== undefined ? { choices: prior.choices } : {}),
1289
+ ...(prior.text !== undefined ? { text: prior.text } : {}),
1290
+ },
1291
+ }
1292
+ : {}),
1040
1293
  };
1041
1294
  queue.enqueue(payload, store.bumpCounter(session.id, "eventSeq"));
1042
1295
  publishQueue(session.id, queue.size);
@@ -1055,12 +1308,16 @@ export function createApp(options) {
1055
1308
  transcript: readTranscript(store.transcriptPath(session.id)),
1056
1309
  });
1057
1310
  });
1058
- // The agent's narration (otacon progress, DESIGN.md §6, §8): a non-blocking
1311
+ // The agent's narration (`otacon progress`): a non-blocking
1059
1312
  // progress note appended to the capped activity feed and pushed to the UI as
1060
1313
  // an `activity` frame (the per-session log) plus a `session` frame (the
1061
- // chip's latestActivity). No agent event is queued like `ask`, this is
1062
- // UI-only telemetry, never a wake-up. The note is trimmed to the configured
1063
- // max so long narration never fails or bloats payloads.
1314
+ // chip's latestActivity). The same note is ALSO normalized into a `highlight`
1315
+ // StreamEvent and appended to the live-activity stream (the automatic,
1316
+ // cross-agent activity stream) so a manual narration sits inline with the
1317
+ // captured activity; a `stream` frame pushes it to the UI. No agent event is
1318
+ // queued — like `ask`, this is UI-only telemetry, never a wake-up. The note
1319
+ // is trimmed to the configured max so long narration never fails or bloats
1320
+ // payloads.
1064
1321
  app.post("/api/sessions/:id/progress", async (c) => {
1065
1322
  const session = sessionFor(c);
1066
1323
  if (!session)
@@ -1072,18 +1329,26 @@ export function createApp(options) {
1072
1329
  if (typeof raw !== "string" || raw.trim() === "") {
1073
1330
  return badRequest(c, "note must be a non-empty string");
1074
1331
  }
1075
- const { activity } = loadConfig(session.repo);
1332
+ const { activity, stream } = loadConfig(session.repo);
1076
1333
  const trimmed = raw.trim();
1334
+ const at = new Date().toISOString();
1077
1335
  const text = trimmed.length > activity.noteMaxChars
1078
1336
  ? `${trimmed.slice(0, Math.max(1, activity.noteMaxChars - 1)).trimEnd()}…`
1079
1337
  : trimmed;
1080
- const note = appendActivity(store.activityPath(session.id), text, activity.cap, new Date().toISOString());
1338
+ const note = appendActivity(store.activityPath(session.id), text, activity.cap, at);
1339
+ // The same note flows into the new stream as a `highlight` event: the
1340
+ // normalizer redacts + truncates the body (its own caps), the daemon stamps
1341
+ // seq and `at`. The activity-feed text above keeps its own (shorter) cap —
1342
+ // the index draft chip still reads `latestActivity`.
1343
+ const event = normalize({ kind: "highlight", label: trimmed, detail: trimmed }, stream, nextStreamSeq(session.id), at);
1344
+ appendStreamEvents(store.streamPath(session.id), [event], stream.cap);
1081
1345
  bumpContact(session.id);
1082
1346
  notifier.publish({ type: "activity", session: session.id, data: { session: session.id, note } });
1347
+ publishStream(session.id, [event]);
1083
1348
  publishSession(session); // latestActivity for the chip; fresh contact for the dot
1084
1349
  return c.json({ ok: true, session: session.id, note: text });
1085
1350
  });
1086
- // Approve ends the planning session (DESIGN.md §6 step 6/7, §12). Writes the
1351
+ // Approve ends the planning session. Writes the
1087
1352
  // composed artifact (final revision, status: approved, grill transcript
1088
1353
  // appended). The canonical copy ALWAYS lands in the home store
1089
1354
  // (~/.otacon/sessions/<id>/). **Save** (plain Approve, implement=false) ALSO
@@ -1159,9 +1424,30 @@ export function createApp(options) {
1159
1424
  : body.implement === true;
1160
1425
  const threads = readThreads(store.threadsPath(session.id));
1161
1426
  const openComments = openCommentThreads(threads);
1162
- // Unresolved = comment threads with no resolution + user questions with no
1163
- // answer the same open items the rail shows.
1164
- const unresolved = threads.filter((t) => t.kind === "comment" ? t.resolution === undefined : t.answer === undefined).length;
1427
+ // Unresolved is counted **per conversation**, not per turn: a multi-turn
1428
+ // comment or question conversation contributes at most 1. Both kinds carry
1429
+ // `replyTo` (a follow-up keys on its root). A conversation is unresolved when
1430
+ // its root is not reviewer-`resolved` AND it still owes attention — a
1431
+ // **comment** conversation always owes it (you must Resolve it; a landed
1432
+ // reply is a response, not a close), a **question** conversation owes it only
1433
+ // while some turn is unanswered. So: a responded-but-unresolved comment
1434
+ // conversation counts (once); a reviewer-resolved one does not; an unanswered
1435
+ // ask counts; an unanswered-but-resolved ask does not.
1436
+ const resolvedRoots = new Set(threads.filter((t) => t.resolved).map((t) => t.id));
1437
+ const rootOf = (t) => t.replyTo ?? t.id;
1438
+ const roots = new Set(threads.map(rootOf));
1439
+ let unresolved = 0;
1440
+ for (const root of roots) {
1441
+ if (resolvedRoots.has(root))
1442
+ continue;
1443
+ const turns = threads.filter((t) => rootOf(t) === root);
1444
+ const isComment = turns.some((t) => t.kind === "comment");
1445
+ const owesAttention = isComment
1446
+ ? true
1447
+ : turns.some((t) => t.kind === "question" && t.answer === undefined);
1448
+ if (owesAttention)
1449
+ unresolved += 1;
1450
+ }
1165
1451
  // Comment & approve: defer the finalize and hand the agent every open comment
1166
1452
  // thread for one solo fold-in pass — its next clean submit finalizes. Only
1167
1453
  // when there is something to fold in, and not already finalizing (a force then
@@ -1223,7 +1509,7 @@ export function createApp(options) {
1223
1509
  implement,
1224
1510
  });
1225
1511
  });
1226
- // Approve & Implement's outcome report (DESIGN.md §6, §12): once the agent has
1512
+ // Approve & Implement's outcome report: once the agent has
1227
1513
  // built the approved plan it reports here. `failed:true` flips the session
1228
1514
  // `implement_failed`, otherwise `implemented` (both terminal). A `pr` URL is
1229
1515
  // persisted on the registry session so the home card can surface the link.
@@ -1257,6 +1543,7 @@ export function createApp(options) {
1257
1543
  status,
1258
1544
  ...(typeof pr === "string" ? { prUrl: pr } : {}),
1259
1545
  });
1546
+ stopTailer(session.id); // build is over (both outcomes terminal): stop tailing
1260
1547
  publishSession(updated); // the chip flips + the PR link appears live
1261
1548
  return c.json({ ok: true, session: updated, status, prUrl: updated.prUrl });
1262
1549
  });
@@ -1273,7 +1560,7 @@ export function createApp(options) {
1273
1560
  }
1274
1561
  // Default is the raw markdown (byte-identical read-back; the CLI/curl
1275
1562
  // path). The web UI asks for JSON to get the lint warnings the revision
1276
- // was accepted with alongside it (DESIGN.md §6).
1563
+ // was accepted with alongside it (review loop and daemon API).
1277
1564
  if (c.req.header("accept")?.toLowerCase().includes("application/json")) {
1278
1565
  const payload = {
1279
1566
  session: session.id,
@@ -1300,6 +1587,7 @@ export function createApp(options) {
1300
1587
  getThreads: (id) => readThreads(store.threadsPath(id)),
1301
1588
  getTranscript: (id) => readTranscript(store.transcriptPath(id)),
1302
1589
  getActivity: (id) => readActivity(store.activityPath(id)),
1590
+ getStream: (id) => readStream(store.streamPath(id), loadStreamCap(id)),
1303
1591
  uiDir: options.uiDir,
1304
1592
  heartbeatMs: options.sseHeartbeatMs,
1305
1593
  });