otacon 0.1.3 → 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.
- package/README.md +18 -5
- package/dist/cli/browser.js +1 -1
- package/dist/cli/browser.js.map +1 -1
- package/dist/cli/client.js +3 -3
- package/dist/cli/client.js.map +1 -1
- package/dist/cli/commands/answer.js +1 -1
- package/dist/cli/commands/answer.js.map +1 -1
- package/dist/cli/commands/ask.js +3 -3
- package/dist/cli/commands/ask.js.map +1 -1
- package/dist/cli/commands/clean.js +14 -16
- package/dist/cli/commands/clean.js.map +1 -1
- package/dist/cli/commands/config.js +1 -1
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/doctor.js +4 -4
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/expose.js +7 -7
- package/dist/cli/commands/expose.js.map +1 -1
- package/dist/cli/commands/implement-done.js +1 -1
- package/dist/cli/commands/implement-done.js.map +1 -1
- package/dist/cli/commands/install.js +16 -10
- package/dist/cli/commands/install.js.map +1 -1
- package/dist/cli/commands/open.js +31 -7
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/progress.js +1 -1
- package/dist/cli/commands/progress.js.map +1 -1
- package/dist/cli/commands/resume.js +67 -0
- package/dist/cli/commands/resume.js.map +1 -0
- package/dist/cli/commands/start.js +18 -4
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.js +15 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/submit.js +5 -5
- package/dist/cli/commands/submit.js.map +1 -1
- package/dist/cli/commands/update.js +24 -17
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/commands/wait.js +1 -1
- package/dist/cli/commands/wait.js.map +1 -1
- package/dist/cli/install/assets.js +123 -48
- package/dist/cli/install/assets.js.map +1 -1
- package/dist/cli/install/locations.js +1 -1
- package/dist/cli/install/locations.js.map +1 -1
- package/dist/cli/install/tailscale.js +2 -2
- package/dist/cli/install/tailscale.js.map +1 -1
- package/dist/cli/install/wrapper.js +202 -0
- package/dist/cli/install/wrapper.js.map +1 -0
- package/dist/cli/main.js +5 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/output.js +2 -2
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/session.js +18 -7
- package/dist/cli/session.js.map +1 -1
- package/dist/cli/update.js +79 -38
- package/dist/cli/update.js.map +1 -1
- package/dist/daemon/activity.js +1 -1
- package/dist/daemon/activity.js.map +1 -1
- package/dist/daemon/anchor.js +1 -1
- package/dist/daemon/anchor.js.map +1 -1
- package/dist/daemon/app.js +370 -82
- package/dist/daemon/app.js.map +1 -1
- package/dist/daemon/approve.js +3 -3
- package/dist/daemon/approve.js.map +1 -1
- package/dist/daemon/capture/adapter.js +23 -0
- package/dist/daemon/capture/adapter.js.map +1 -0
- package/dist/daemon/capture/claude.js +274 -0
- package/dist/daemon/capture/claude.js.map +1 -0
- package/dist/daemon/capture/codex.js +413 -0
- package/dist/daemon/capture/codex.js.map +1 -0
- package/dist/daemon/capture/normalize.js +89 -0
- package/dist/daemon/capture/normalize.js.map +1 -0
- package/dist/daemon/capture/opencode.js +330 -0
- package/dist/daemon/capture/opencode.js.map +1 -0
- package/dist/daemon/capture/registry.js +33 -0
- package/dist/daemon/capture/registry.js.map +1 -0
- package/dist/daemon/capture/stream-store.js +121 -0
- package/dist/daemon/capture/stream-store.js.map +1 -0
- package/dist/daemon/capture/tailer.js +108 -0
- package/dist/daemon/capture/tailer.js.map +1 -0
- package/dist/daemon/desktop-notify.js +11 -5
- package/dist/daemon/desktop-notify.js.map +1 -1
- package/dist/daemon/diagrams.js +90 -0
- package/dist/daemon/diagrams.js.map +1 -0
- package/dist/daemon/diff.js +1 -2
- package/dist/daemon/diff.js.map +1 -1
- package/dist/daemon/linter/parse.js +45 -12
- package/dist/daemon/linter/parse.js.map +1 -1
- package/dist/daemon/linter/rules.js +20 -17
- package/dist/daemon/linter/rules.js.map +1 -1
- package/dist/daemon/main.js +1 -1
- package/dist/daemon/main.js.map +1 -1
- package/dist/daemon/notify.js +1 -1
- package/dist/daemon/notify.js.map +1 -1
- package/dist/daemon/presence.js +1 -1
- package/dist/daemon/presence.js.map +1 -1
- package/dist/daemon/queue.js +7 -7
- package/dist/daemon/queue.js.map +1 -1
- package/dist/daemon/store.js +66 -79
- package/dist/daemon/store.js.map +1 -1
- package/dist/daemon/threads.js +111 -25
- package/dist/daemon/threads.js.map +1 -1
- package/dist/daemon/transcript.js +1 -1
- package/dist/daemon/transcript.js.map +1 -1
- package/dist/daemon/ui.js +8 -6
- package/dist/daemon/ui.js.map +1 -1
- package/dist/daemon/viewers.js +37 -0
- package/dist/daemon/viewers.js.map +1 -0
- package/dist/shared/config.js +42 -8
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/gwt.js +1 -1
- package/dist/shared/gwt.js.map +1 -1
- package/dist/shared/paths.js +63 -36
- package/dist/shared/paths.js.map +1 -1
- package/dist/shared/question-spec.js +1 -1
- package/dist/shared/question-spec.js.map +1 -1
- package/dist/shared/types.js +7 -3
- package/dist/shared/types.js.map +1 -1
- package/dist/shared/version.js +1 -1
- package/dist/skills/otacon/SKILL.md +250 -0
- package/dist/ui/assets/{arc-Cp3sPd_U.js → arc-BUR2DxNA.js} +1 -1
- package/dist/ui/assets/architecture-7EHR7CIX-TTokq2IO.js +1 -0
- package/dist/ui/assets/{architectureDiagram-3BPJPVTR-C4F3heWP.js → architectureDiagram-3BPJPVTR-unLnkDyM.js} +1 -1
- package/dist/ui/assets/{blockDiagram-GPEHLZMM-DmJoG8ky.js → blockDiagram-GPEHLZMM-DHx8lNeL.js} +1 -1
- package/dist/ui/assets/{c4Diagram-AAUBKEIU-BDEf52Jp.js → c4Diagram-AAUBKEIU-BU9T562l.js} +1 -1
- package/dist/ui/assets/channel-BA6ChrT3.js +1 -0
- package/dist/ui/assets/{chunk-2J33WTMH-Dh_UFriv.js → chunk-2J33WTMH-BEb0myVl.js} +1 -1
- package/dist/ui/assets/{chunk-3OPIFGDE-c66RlAN8.js → chunk-3OPIFGDE-DESBG_RB.js} +1 -1
- package/dist/ui/assets/{chunk-4BX2VUAB-CrGJaCCg.js → chunk-4BX2VUAB-dt3F_E_5.js} +1 -1
- package/dist/ui/assets/{chunk-55IACEB6-BvwqEeyq.js → chunk-55IACEB6-BcyuZM7U.js} +1 -1
- package/dist/ui/assets/{chunk-5ZQYHXKU-BfF2IfQe.js → chunk-5ZQYHXKU-DqdwSJlO.js} +1 -1
- package/dist/ui/assets/{chunk-727SXJPM-DOEUwc9I.js → chunk-727SXJPM-B04SqNWj.js} +1 -1
- package/dist/ui/assets/{chunk-AQP2D5EJ-CVf2xV2Z.js → chunk-AQP2D5EJ-DpjCPBWN.js} +1 -1
- package/dist/ui/assets/{chunk-BSJP7CBP-D_EbTWTC.js → chunk-BSJP7CBP-BNOU3k5G.js} +1 -1
- package/dist/ui/assets/{chunk-CSCIHK7Q-CkxTSMAM.js → chunk-CSCIHK7Q-iWOtNZm_.js} +1 -1
- package/dist/ui/assets/{chunk-FMBD7UC4-DVShhFc7.js → chunk-FMBD7UC4-BHH_etky.js} +1 -1
- package/dist/ui/assets/{chunk-KSCS5N6A-DnHEEYpq.js → chunk-KSCS5N6A-DbRuazP3.js} +1 -1
- package/dist/ui/assets/{chunk-L5ZTLDWV-B_gjyajS.js → chunk-L5ZTLDWV-B2ZZFONi.js} +1 -1
- package/dist/ui/assets/{chunk-LZXEDZCA-Bsf7basG.js → chunk-LZXEDZCA-DryVpwAh.js} +2 -2
- package/dist/ui/assets/{chunk-ND2GUHAM-DGepGb8_.js → chunk-ND2GUHAM-2Bb1izqg.js} +1 -1
- package/dist/ui/assets/{chunk-NZK2D7GU-BJIlh-gB.js → chunk-NZK2D7GU-2DhvLbqL.js} +1 -1
- package/dist/ui/assets/{chunk-O5CBEL6O-cdVbOOCs.js → chunk-O5CBEL6O-B5oigO7D.js} +1 -1
- package/dist/ui/assets/chunk-QZHKN3VN-BDHgdxoT.js +1 -0
- package/dist/ui/assets/chunk-WU5MYG2G-FDJTP_wT.js +1 -0
- package/dist/ui/assets/{chunk-XPW4576I-DPW39hNO.js → chunk-XPW4576I-Dmq-O7bc.js} +1 -1
- package/dist/ui/assets/classDiagram-4FO5ZUOK-B5kZsiIt.js +1 -0
- package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-B5kZsiIt.js +1 -0
- package/dist/ui/assets/{cose-bilkent-S5V4N54A-DmMsC6om.js → cose-bilkent-S5V4N54A-oTsqU1DY.js} +1 -1
- package/dist/ui/assets/{dagre-BM42HDAG-BmVr7T7Z.js → dagre-BM42HDAG-CZykCU6B.js} +1 -1
- package/dist/ui/assets/{diagram-2AECGRRQ-e0VnXEyj.js → diagram-2AECGRRQ-BY7clIlO.js} +1 -1
- package/dist/ui/assets/{diagram-5GNKFQAL-SCJGYE6m.js → diagram-5GNKFQAL-BWdq4hV0.js} +1 -1
- package/dist/ui/assets/{diagram-KO2AKTUF-DDUZO1Mj.js → diagram-KO2AKTUF-CCGEaiZg.js} +1 -1
- package/dist/ui/assets/{diagram-LMA3HP47-5x1ypf6a.js → diagram-LMA3HP47-CVkepFYU.js} +1 -1
- package/dist/ui/assets/{diagram-OG6HWLK6-3nDhrQ20.js → diagram-OG6HWLK6-f-NjsfLG.js} +1 -1
- package/dist/ui/assets/{dist-NEinnePC.js → dist-ZqsueX9_.js} +1 -1
- package/dist/ui/assets/{erDiagram-TEJ5UH35-BNvwSDNZ.js → erDiagram-TEJ5UH35-D8Wn6QP3.js} +1 -1
- package/dist/ui/assets/eventmodeling-FCH6USID-BODDoY6e.js +1 -0
- package/dist/ui/assets/{flowDiagram-I6XJVG4X-Bt_wDUhb.js → flowDiagram-I6XJVG4X-BYnhla9k.js} +1 -1
- package/dist/ui/assets/{ganttDiagram-6RSMTGT7-DVcJ0rNX.js → ganttDiagram-6RSMTGT7-Dpu52ZRF.js} +1 -1
- package/dist/ui/assets/{gitGraph-WXDBUCRP-CO_SyAgP.js → gitGraph-WXDBUCRP-ou8xzQe1.js} +1 -1
- package/dist/ui/assets/{gitGraphDiagram-PVQCEYII-oDyO3lWI.js → gitGraphDiagram-PVQCEYII-BnTuFH7F.js} +1 -1
- package/dist/ui/assets/index-B2mL0c61.js +11 -0
- package/dist/ui/assets/index-sZ1TgAvb.css +1 -0
- package/dist/ui/assets/{info-J43DQDTF-Bn17NS7h.js → info-J43DQDTF-xgUHa7k0.js} +1 -1
- package/dist/ui/assets/{infoDiagram-5YYISTIA-HG1opLLT.js → infoDiagram-5YYISTIA-DJBudrwD.js} +1 -1
- package/dist/ui/assets/{ishikawaDiagram-YF4QCWOH-Di_yQwi8.js → ishikawaDiagram-YF4QCWOH-DpFHgERI.js} +1 -1
- package/dist/ui/assets/{journeyDiagram-JHISSGLW-DZtHvLeE.js → journeyDiagram-JHISSGLW-Cd32hdpY.js} +1 -1
- package/dist/ui/assets/{kanban-definition-UN3LZRKU-B_RCx3Km.js → kanban-definition-UN3LZRKU-Dm1V8lr0.js} +1 -1
- package/dist/ui/assets/{line-DenX-zXQ.js → line-CZu6_PMX.js} +1 -1
- package/dist/ui/assets/{linear-dly_ngoq.js → linear-2tkTX_U2.js} +1 -1
- package/dist/ui/assets/{mermaid-parser.core-CRmtm0s9.js → mermaid-parser.core-C4m04cRe.js} +2 -2
- package/dist/ui/assets/{mermaid.core-C_3KVfpx.js → mermaid.core-BPeg1ewg.js} +3 -3
- package/dist/ui/assets/{mindmap-definition-RKZ34NQL-wdzSyYO6.js → mindmap-definition-RKZ34NQL-BENqEkIK.js} +1 -1
- package/dist/ui/assets/{packet-YPE3B663-tUyFmR11.js → packet-YPE3B663-DNO0oBLH.js} +1 -1
- package/dist/ui/assets/{pie-LRSECV5Y-Cel48VVp.js → pie-LRSECV5Y-CWoz31oB.js} +1 -1
- package/dist/ui/assets/{pieDiagram-4H26LBE5-B55ypWtu.js → pieDiagram-4H26LBE5-CtmxhUGI.js} +1 -1
- package/dist/ui/assets/{plan-view-CMoo3_gE.js → plan-view-bZtdFbit.js} +4 -4
- package/dist/ui/assets/{quadrantDiagram-W4KKPZXB-9WcKQV0a.js → quadrantDiagram-W4KKPZXB-BO3IZNbx.js} +1 -1
- package/dist/ui/assets/{radar-GUYGQ44K-w5pk53Vr.js → radar-GUYGQ44K-AEfROz99.js} +1 -1
- package/dist/ui/assets/{requirementDiagram-4Y6WPE33-CzE82fXz.js → requirementDiagram-4Y6WPE33-D-wQ1szT.js} +1 -1
- package/dist/ui/assets/{sankeyDiagram-5OEKKPKP-DSGO39je.js → sankeyDiagram-5OEKKPKP-Bj4kD_AJ.js} +1 -1
- package/dist/ui/assets/{sequenceDiagram-3UESZ5HK-BKSDDr0S.js → sequenceDiagram-3UESZ5HK-M8QVVH74.js} +1 -1
- package/dist/ui/assets/{src-JXBGgRt-.js → src-DvptJAGq.js} +1 -1
- package/dist/ui/assets/{stateDiagram-AJRCARHV-DZHYA9aj.js → stateDiagram-AJRCARHV-BiPRs9rN.js} +1 -1
- package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-BYjicZlC.js +1 -0
- package/dist/ui/assets/{timeline-definition-PNZ67QCA-WqJFw7aE.js → timeline-definition-PNZ67QCA-DS0AGRKw.js} +1 -1
- package/dist/ui/assets/{treeView-BLDUP644-DoIQYMiz.js → treeView-BLDUP644-DJakvUbF.js} +1 -1
- package/dist/ui/assets/{treemap-LRROVOQU-BA9si_Mo.js → treemap-LRROVOQU-QXfc2Vwr.js} +1 -1
- package/dist/ui/assets/{vennDiagram-CIIHVFJN-BvWRUfFr.js → vennDiagram-CIIHVFJN-CBdJA0hv.js} +1 -1
- package/dist/ui/assets/{wardley-L42UT6IY-Cf9PU44t.js → wardley-L42UT6IY-DI9SdW45.js} +1 -1
- package/dist/ui/assets/{wardleyDiagram-YWT4CUSO-CGuxl7AU.js → wardleyDiagram-YWT4CUSO-RMmsKLRe.js} +1 -1
- package/dist/ui/assets/{xychartDiagram-2RQKCTM6-CNteNXpe.js → xychartDiagram-2RQKCTM6-DFXccP8B.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +7 -5
- package/dist/ui/assets/architecture-7EHR7CIX-BbwstElO.js +0 -1
- package/dist/ui/assets/channel-BMK3JFRf.js +0 -1
- package/dist/ui/assets/chunk-QZHKN3VN-DLNUTn7U.js +0 -1
- package/dist/ui/assets/chunk-WU5MYG2G-ipH3YPcA.js +0 -1
- package/dist/ui/assets/classDiagram-4FO5ZUOK-CvIMLlSD.js +0 -1
- package/dist/ui/assets/classDiagram-v2-Q7XG4LA2-CvIMLlSD.js +0 -1
- package/dist/ui/assets/eventmodeling-FCH6USID-BeFWDbla.js +0 -1
- package/dist/ui/assets/index-CJJIQ0dr.css +0 -1
- package/dist/ui/assets/index-Dh5CsH24.js +0 -11
- package/dist/ui/assets/stateDiagram-v2-BHNVJYJU-DnAfDlvZ.js +0 -1
package/dist/daemon/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// otacond's HTTP surface (
|
|
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
|
|
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` (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
204
|
-
* is
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
261
|
-
* (implement=true) it writes home only, and `path`
|
|
262
|
-
*
|
|
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 (
|
|
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
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
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
|
-
|
|
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
|
|
432
|
-
// late in-flight ack cannot recreate
|
|
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
|
-
|
|
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
|
|
442
|
-
// deregister and permanently drop the
|
|
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.
|
|
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
|
|
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 (
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
// (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
770
|
-
id:
|
|
977
|
+
const threads = drafts.map((draft, i) => ({
|
|
978
|
+
id: `t${firstThread + i + 1}`,
|
|
771
979
|
kind: "comment",
|
|
772
980
|
batch,
|
|
773
|
-
anchor:
|
|
774
|
-
|
|
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
|
-
// (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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).
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
1163
|
-
//
|
|
1164
|
-
|
|
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
|
|
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 (
|
|
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
|
});
|