@edspencer/paddock 0.0.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionHub — session-scoped fan-out + re-attach for in-flight chat turns.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves (issue #54): a turn's stream used to be bound to the ONE
|
|
5
|
+
* socket that sent `chat:send`. Frames were written straight to that socket and
|
|
6
|
+
* silently dropped whenever it wasn't OPEN. So if the socket died mid-turn (idle
|
|
7
|
+
* half-open drop, laptop sleep, wifi change, the client's own auto-reconnect
|
|
8
|
+
* swapping sockets), every remaining frame of the running turn was lost — the
|
|
9
|
+
* live stream stalled and only a full page reload (which re-hydrates from the
|
|
10
|
+
* persisted transcript) recovered it.
|
|
11
|
+
*
|
|
12
|
+
* The hub decouples a turn from any single socket:
|
|
13
|
+
*
|
|
14
|
+
* - Every emitted frame is buffered on the turn and stamped with a per-turn,
|
|
15
|
+
* monotonic `seq`.
|
|
16
|
+
* - Frames fan out to the turn's ORIGIN socket PLUS every socket currently
|
|
17
|
+
* subscribed to the turn's session (dead sockets are skipped, not dropped-
|
|
18
|
+
* on-the-floor-permanently — a live one still gets the frame).
|
|
19
|
+
* - A reconnecting client re-`attach`es to the session and replays exactly the
|
|
20
|
+
* frames it missed (everything with `seq` greater than the last it applied),
|
|
21
|
+
* so the stream resumes seamlessly with no gap and no duplication.
|
|
22
|
+
*
|
|
23
|
+
* A just-completed turn's buffer is retained briefly ({@link COMPLETED_TTL_MS})
|
|
24
|
+
* so a client that reconnects right at the end still receives the tail frames
|
|
25
|
+
* (including the terminal `chat:complete`) rather than hanging "streaming"
|
|
26
|
+
* forever.
|
|
27
|
+
*
|
|
28
|
+
* The hub is transport-shaped but transport-agnostic: it only needs a socket it
|
|
29
|
+
* can check `readyState` on and `send` a JSON string to (the `HubSocket`
|
|
30
|
+
* minimal interface), so it is unit-testable without a real WebSocket.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Max frames retained per turn. A turn is a dozen assistant messages + tool
|
|
34
|
+
* calls → low hundreds of frames typically; 4000 is generous headroom while
|
|
35
|
+
* bounding memory. Overflow trims the oldest frames and a re-attach that needs
|
|
36
|
+
* them falls back to a transcript resync (correctness preserved, live-ness lost
|
|
37
|
+
* only in the pathological case).
|
|
38
|
+
*/
|
|
39
|
+
const MAX_FRAMES = 4000;
|
|
40
|
+
/** How long a completed turn's buffer lingers so an end-of-turn reconnect still gets the tail. */
|
|
41
|
+
export const COMPLETED_TTL_MS = 60_000;
|
|
42
|
+
export class SessionHub {
|
|
43
|
+
/** sessionId → the current (running or recently-completed) turn for that session. */
|
|
44
|
+
bySession = new Map();
|
|
45
|
+
/** sessionId → sockets attached to it (fan-out targets beyond the origin). */
|
|
46
|
+
subscribers = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Notified on every running-state transition (turn start / stop) so the WS
|
|
49
|
+
* layer can broadcast a `chat:active` signal to clients — powering the Stop
|
|
50
|
+
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
51
|
+
*/
|
|
52
|
+
onActive = null;
|
|
53
|
+
/**
|
|
54
|
+
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
55
|
+
* chat / a slash command) so the turn is re-attachable from its very first
|
|
56
|
+
* frame; omit it for a brand-new chat and call {@link TurnHandle.setSession}
|
|
57
|
+
* once the id arrives on the stream.
|
|
58
|
+
*/
|
|
59
|
+
startTurn(projectSlug, origin, sessionId) {
|
|
60
|
+
const turn = {
|
|
61
|
+
projectSlug,
|
|
62
|
+
sessionId: null,
|
|
63
|
+
jobId: null,
|
|
64
|
+
origin,
|
|
65
|
+
frames: [],
|
|
66
|
+
baseSeq: 0,
|
|
67
|
+
nextSeq: 0,
|
|
68
|
+
running: true,
|
|
69
|
+
evictTimer: null,
|
|
70
|
+
};
|
|
71
|
+
const handle = new TurnHandle(this, turn);
|
|
72
|
+
if (sessionId)
|
|
73
|
+
this.register(turn, sessionId);
|
|
74
|
+
return handle;
|
|
75
|
+
}
|
|
76
|
+
/** Add a socket to a session's fan-out set (idempotent). */
|
|
77
|
+
subscribe(sessionId, socket) {
|
|
78
|
+
let set = this.subscribers.get(sessionId);
|
|
79
|
+
if (!set) {
|
|
80
|
+
set = new Set();
|
|
81
|
+
this.subscribers.set(sessionId, set);
|
|
82
|
+
}
|
|
83
|
+
set.add(socket);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Attach a (re)connecting socket to a session and, when `wantReplay`, catch it
|
|
87
|
+
* up on a still-live turn by replaying every buffered frame after `afterSeq`.
|
|
88
|
+
*
|
|
89
|
+
* `wantReplay` MUST be false for a fresh mount (one that also hydrates the
|
|
90
|
+
* transcript over REST) so buffered frames don't duplicate the transcript; it
|
|
91
|
+
* is true only for a genuine reconnect of a socket that was mid-turn, where
|
|
92
|
+
* `afterSeq` is the last seq the client applied and the replay is exactly the
|
|
93
|
+
* gap. Replays are written straight to `socket` (not the whole fan-out).
|
|
94
|
+
*/
|
|
95
|
+
attach(sessionId, socket, opts) {
|
|
96
|
+
this.subscribe(sessionId, socket);
|
|
97
|
+
const turn = this.bySession.get(sessionId);
|
|
98
|
+
if (!turn || !opts.wantReplay)
|
|
99
|
+
return { status: "none" };
|
|
100
|
+
const from = opts.afterSeq + 1;
|
|
101
|
+
if (from < turn.baseSeq) {
|
|
102
|
+
// The gap the client needs has been trimmed — it must re-hydrate instead.
|
|
103
|
+
return { status: "resync", projectSlug: turn.projectSlug };
|
|
104
|
+
}
|
|
105
|
+
let n = 0;
|
|
106
|
+
for (const f of turn.frames) {
|
|
107
|
+
if (f.seq >= from) {
|
|
108
|
+
SessionHub.write(socket, f.msg);
|
|
109
|
+
n++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { status: "replayed", frames: n };
|
|
113
|
+
}
|
|
114
|
+
/** Drop a socket from every session's fan-out set (call on socket close). */
|
|
115
|
+
unsubscribeSocket(socket) {
|
|
116
|
+
for (const [id, set] of this.subscribers) {
|
|
117
|
+
if (set.delete(socket) && set.size === 0)
|
|
118
|
+
this.subscribers.delete(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Whether a session currently has a RUNNING turn (used by the active-turns signal). */
|
|
122
|
+
isRunning(sessionId) {
|
|
123
|
+
return this.bySession.get(sessionId)?.running === true;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Send a one-off control frame to every socket attached to a session — its
|
|
127
|
+
* (recently-)tracked turn's origin plus every subscribed socket (#245). Unlike
|
|
128
|
+
* {@link emit} this is not seq-stamped or buffered; it's for out-of-band signals
|
|
129
|
+
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
130
|
+
* socket after the origin died.
|
|
131
|
+
*/
|
|
132
|
+
broadcast(sessionId, msg) {
|
|
133
|
+
const set = new Set();
|
|
134
|
+
const turn = this.bySession.get(sessionId);
|
|
135
|
+
if (turn?.origin)
|
|
136
|
+
set.add(turn.origin);
|
|
137
|
+
const subs = this.subscribers.get(sessionId);
|
|
138
|
+
if (subs)
|
|
139
|
+
for (const s of subs)
|
|
140
|
+
set.add(s);
|
|
141
|
+
for (const s of set)
|
|
142
|
+
SessionHub.write(s, msg);
|
|
143
|
+
}
|
|
144
|
+
// --- internals, driven by TurnHandle -------------------------------------
|
|
145
|
+
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
146
|
+
activeInfo(sessionId) {
|
|
147
|
+
const turn = this.bySession.get(sessionId);
|
|
148
|
+
if (!turn || !turn.running)
|
|
149
|
+
return null;
|
|
150
|
+
return { sessionId, projectSlug: turn.projectSlug, jobId: turn.jobId, running: true };
|
|
151
|
+
}
|
|
152
|
+
/** Snapshot of every session with a running turn (for a newly-connected client). */
|
|
153
|
+
runningSessions() {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const [sessionId, turn] of this.bySession) {
|
|
156
|
+
if (turn.running) {
|
|
157
|
+
out.push({ sessionId, projectSlug: turn.projectSlug, jobId: turn.jobId, running: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/** Register a turn under its resolved session id, evicting any prior turn there. */
|
|
163
|
+
register(turn, sessionId) {
|
|
164
|
+
if (turn.sessionId === sessionId)
|
|
165
|
+
return;
|
|
166
|
+
if (turn.sessionId)
|
|
167
|
+
this.bySession.delete(turn.sessionId);
|
|
168
|
+
turn.sessionId = sessionId;
|
|
169
|
+
const prev = this.bySession.get(sessionId);
|
|
170
|
+
if (prev && prev !== turn)
|
|
171
|
+
this.evict(prev);
|
|
172
|
+
this.bySession.set(sessionId, turn);
|
|
173
|
+
// A turn becomes visible-as-running the moment its session id is known.
|
|
174
|
+
if (turn.running)
|
|
175
|
+
this.fireActive(turn);
|
|
176
|
+
}
|
|
177
|
+
/** Stamp `seq`, buffer (with trim), and fan out to origin + subscribers. */
|
|
178
|
+
emit(turn, base) {
|
|
179
|
+
const seq = turn.nextSeq++;
|
|
180
|
+
const stamped = {
|
|
181
|
+
type: base.type,
|
|
182
|
+
payload: { ...base.payload, seq },
|
|
183
|
+
};
|
|
184
|
+
turn.frames.push({ seq, msg: stamped });
|
|
185
|
+
if (turn.frames.length > MAX_FRAMES) {
|
|
186
|
+
turn.frames.shift();
|
|
187
|
+
turn.baseSeq = turn.frames[0]?.seq ?? seq;
|
|
188
|
+
}
|
|
189
|
+
for (const socket of this.recipients(turn))
|
|
190
|
+
SessionHub.write(socket, stamped);
|
|
191
|
+
}
|
|
192
|
+
/** Mark a turn finished; retain its buffer briefly for an end-of-turn reconnect. */
|
|
193
|
+
end(turn) {
|
|
194
|
+
turn.running = false;
|
|
195
|
+
if (turn.sessionId) {
|
|
196
|
+
this.fireActive(turn);
|
|
197
|
+
turn.evictTimer = setTimeout(() => this.evict(turn), COMPLETED_TTL_MS);
|
|
198
|
+
turn.evictTimer.unref?.();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Re-broadcast a running turn's active state once its jobId becomes known, so
|
|
203
|
+
* clients can arm the Stop button before the first content frame (Paddock#111).
|
|
204
|
+
* No-op if the turn isn't running or its session id isn't resolved yet (a new
|
|
205
|
+
* chat learns the jobId together with its session id on the first frame).
|
|
206
|
+
*/
|
|
207
|
+
jobIdResolved(turn) {
|
|
208
|
+
if (turn.running)
|
|
209
|
+
this.fireActive(turn);
|
|
210
|
+
}
|
|
211
|
+
fireActive(turn) {
|
|
212
|
+
if (!this.onActive || !turn.sessionId)
|
|
213
|
+
return;
|
|
214
|
+
this.onActive({
|
|
215
|
+
sessionId: turn.sessionId,
|
|
216
|
+
projectSlug: turn.projectSlug,
|
|
217
|
+
jobId: turn.jobId,
|
|
218
|
+
running: turn.running,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
recipients(turn) {
|
|
222
|
+
const set = new Set();
|
|
223
|
+
// A client-less woken turn (Paddock#111) has no origin; frames are still
|
|
224
|
+
// buffered for replay, they just have no live origin socket to fan out to.
|
|
225
|
+
if (turn.origin)
|
|
226
|
+
set.add(turn.origin);
|
|
227
|
+
if (turn.sessionId) {
|
|
228
|
+
const subs = this.subscribers.get(turn.sessionId);
|
|
229
|
+
if (subs)
|
|
230
|
+
for (const s of subs)
|
|
231
|
+
set.add(s);
|
|
232
|
+
}
|
|
233
|
+
return set;
|
|
234
|
+
}
|
|
235
|
+
evict(turn) {
|
|
236
|
+
if (turn.evictTimer) {
|
|
237
|
+
clearTimeout(turn.evictTimer);
|
|
238
|
+
turn.evictTimer = null;
|
|
239
|
+
}
|
|
240
|
+
if (turn.sessionId && this.bySession.get(turn.sessionId) === turn) {
|
|
241
|
+
this.bySession.delete(turn.sessionId);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
static write(socket, msg) {
|
|
245
|
+
if (socket.readyState !== socket.OPEN)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
socket.send(JSON.stringify(msg));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* a socket that throws on send is effectively gone; the reconnect path recovers it */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A per-turn handle the WS layer drives: stamp/fan-out frames via {@link emit},
|
|
257
|
+
* learn the session id via {@link setSession}, and finish via {@link end}.
|
|
258
|
+
*/
|
|
259
|
+
export class TurnHandle {
|
|
260
|
+
hub;
|
|
261
|
+
turn;
|
|
262
|
+
constructor(hub, turn) {
|
|
263
|
+
this.hub = hub;
|
|
264
|
+
this.turn = turn;
|
|
265
|
+
}
|
|
266
|
+
get jobId() {
|
|
267
|
+
return this.turn.jobId;
|
|
268
|
+
}
|
|
269
|
+
setJobId(id) {
|
|
270
|
+
this.turn.jobId = id;
|
|
271
|
+
// Push the now-known jobId to attached clients immediately (via chat:active),
|
|
272
|
+
// so the Stop button is ARMED even while the model is still "thinking" and no
|
|
273
|
+
// content frame has yet carried the jobId in its routing metadata. Without
|
|
274
|
+
// this, Stop silently no-ops during the (often long) pre-output phase because
|
|
275
|
+
// the client has no jobId to cancel — the original "Stop does nothing" bug
|
|
276
|
+
// (Paddock#111). Fixes both drive modes (batch + session).
|
|
277
|
+
this.hub.jobIdResolved(this.turn);
|
|
278
|
+
}
|
|
279
|
+
/** Register (or move) this turn under its now-known session id. Idempotent. */
|
|
280
|
+
setSession(sessionId) {
|
|
281
|
+
this.hub.register(this.turn, sessionId);
|
|
282
|
+
}
|
|
283
|
+
/** Buffer + fan out one frame to every live socket attached to this turn's session. */
|
|
284
|
+
emit(base) {
|
|
285
|
+
this.hub.emit(this.turn, base);
|
|
286
|
+
}
|
|
287
|
+
/** Finish the turn (buffer retained briefly for a late reconnect). */
|
|
288
|
+
end() {
|
|
289
|
+
this.hub.end(this.turn);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn capability — depth-gated self-MCP injection (issue #262 / DD-3, DD-5).
|
|
3
|
+
*
|
|
4
|
+
* A1 (#261) threaded an `origin` + spawn `depth` marker through every
|
|
5
|
+
* server-initiated turn and persisted it to {@link RunProvenanceStore}. B1 (this
|
|
6
|
+
* ticket) reads that `depth` to decide whether a SPAWNED turn is handed the
|
|
7
|
+
* self-management MCP — crucially its WRITE tools, so `send_message` exists and a
|
|
8
|
+
* child can finally report back to its parent (impossible today, where a spawned
|
|
9
|
+
* chat gets `send_file` only).
|
|
10
|
+
*
|
|
11
|
+
* ── The bound ────────────────────────────────────────────────────────────────
|
|
12
|
+
* A spawned/scheduled turn running in a chat at depth `d` receives the self-MCP
|
|
13
|
+
* iff `d <= maxSpawnDepth`:
|
|
14
|
+
*
|
|
15
|
+
* maxSpawnDepth = 0 → no spawned child gets the self-MCP (send_file only).
|
|
16
|
+
* This reproduces EXACTLY today's behaviour.
|
|
17
|
+
* maxSpawnDepth = 1 → depth-1 children DO get it (a manager's children can
|
|
18
|
+
* report back AND spawn), but depth-2 grandchildren do NOT.
|
|
19
|
+
* This is the DEFAULT — the manager→children→report-back
|
|
20
|
+
* pattern works out of the box while the tree can't run away.
|
|
21
|
+
* maxSpawnDepth = n → the tree may grow n spawn-hops deep before the tools stop.
|
|
22
|
+
*
|
|
23
|
+
* The human/scheduled ROOT (depth 0) is NOT gated here — it keeps today's
|
|
24
|
+
* instance-flag gating (`selfMcpEnabled`/`selfMcpWriteEnabled`) on the socket path.
|
|
25
|
+
* The gate only governs the server-initiated (`startAgentTurn`) path.
|
|
26
|
+
*
|
|
27
|
+
* ── Why `<=` and not `<` ─────────────────────────────────────────────────────
|
|
28
|
+
* The gate is evaluated at the CHILD, using the child's OWN depth `d` (that's the
|
|
29
|
+
* depth `startAgentTurn` knows). "A depth-`d` child may act" with `d <= maxSpawnDepth`
|
|
30
|
+
* is the same bound as the equivalent parent-side phrasing "a depth-`(d-1)` parent
|
|
31
|
+
* may spawn" with `(d-1) < maxSpawnDepth`, since a child's depth is always its
|
|
32
|
+
* parent's depth + 1. We evaluate it at the child, so the comparison is `<=`.
|
|
33
|
+
* With the default `1`, a depth-1 child satisfies `1 <= 1` and gets the tools;
|
|
34
|
+
* its depth-2 grandchild fails `2 <= 1` and does not — the bound descends because
|
|
35
|
+
* every child stamped by a tool-equipped parent is one hop deeper.
|
|
36
|
+
*
|
|
37
|
+
* ── Config (DD-5, the `driveMode` pattern) ───────────────────────────────────
|
|
38
|
+
* `maxSpawnDepth` is an instance default (`PADDOCK_MAX_SPAWN_DEPTH`, YAML later
|
|
39
|
+
* per #270) with a per-project override (`project.yaml` → Settings). An absent /
|
|
40
|
+
* invalid override inherits the instance default, resolved at dispatch by
|
|
41
|
+
* {@link resolveMaxSpawnDepth} — never baked into the DTO (mirrors how `driveMode`
|
|
42
|
+
* resolves against `cfg.driveMode`).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default max spawn depth (Ed, DD-3 decision 2): `1`. A manager's direct children
|
|
46
|
+
* get the self-MCP write tools (report-back + spawn), grandchildren do not.
|
|
47
|
+
*/
|
|
48
|
+
export declare const DEFAULT_MAX_SPAWN_DEPTH = 1;
|
|
49
|
+
/**
|
|
50
|
+
* Hard upper bound on `maxSpawnDepth` (guards the UI slider + PATCH validation +
|
|
51
|
+
* config loader). Deep trees are deliberately allowed but not unboundedly — a
|
|
52
|
+
* runaway config can't ask for a thousand-deep fan-out. 8 hops is far past any
|
|
53
|
+
* real manager pattern.
|
|
54
|
+
*/
|
|
55
|
+
export declare const MAX_SPAWN_DEPTH_LIMIT = 8;
|
|
56
|
+
/**
|
|
57
|
+
* Whether `n` is a valid `maxSpawnDepth`: a non-negative integer within bounds.
|
|
58
|
+
* `0` is valid (it disables spawned self-MCP entirely — today's behaviour).
|
|
59
|
+
*/
|
|
60
|
+
export declare function isValidMaxSpawnDepth(n: unknown): n is number;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the effective `maxSpawnDepth` for a dispatch: a valid per-project
|
|
63
|
+
* override wins; otherwise inherit the instance default. Mirrors how `driveMode`
|
|
64
|
+
* resolves (`project.driveMode ?? cfg.driveMode`) — the override is carried
|
|
65
|
+
* on disk only when set, so an absent value transparently inherits the instance
|
|
66
|
+
* (and, later, a YAML file) default. A malformed override is ignored rather than
|
|
67
|
+
* fatal (defensive: a hand-edited `project.yaml` can't wedge dispatch).
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveMaxSpawnDepth(override: number | undefined, instanceDefault: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* The core gate: does a spawned/scheduled turn running in a chat at `depth`
|
|
72
|
+
* receive the self-MCP? `depth <= maxSpawnDepth` (see the module header for why
|
|
73
|
+
* `<=`). Both operands are read defensively so a corrupt marker can't throw at the
|
|
74
|
+
* dispatch hot path — a negative/NaN depth is treated as never-injectable.
|
|
75
|
+
*/
|
|
76
|
+
export declare function spawnedTurnGetsSelfMcp(depth: number, maxSpawnDepth: number): boolean;
|
|
77
|
+
/** The full injection decision for a server-initiated (spawned) turn. */
|
|
78
|
+
export interface SpawnedSelfMcpDecision {
|
|
79
|
+
/** Inject the self-MCP at all (read tools, and — if {@link includeWrite} — write). */
|
|
80
|
+
inject: boolean;
|
|
81
|
+
/** Include the WRITE tools (create/fork/`send_message`) so the child can report back. */
|
|
82
|
+
includeWrite: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The COMPLETE gate for whether a spawned/scheduled turn is handed the self-MCP,
|
|
86
|
+
* factored out so the exact rule is unit-tested rather than buried in ws.ts.
|
|
87
|
+
* A spawned turn gets the self-MCP iff the instance opted in
|
|
88
|
+
* (`selfMcpEnabled`) AND its depth is within the bound
|
|
89
|
+
* ({@link spawnedTurnGetsSelfMcp}). The WRITE tools are further gated by the
|
|
90
|
+
* instance write opt-in — in practice always on when a spawn is reachable (a spawn
|
|
91
|
+
* only happens because a parent already had the write tools), but honoured so an
|
|
92
|
+
* operator who disabled writes gets read-only spawned children too.
|
|
93
|
+
*/
|
|
94
|
+
export declare function spawnedSelfMcpDecision(params: {
|
|
95
|
+
selfMcpEnabled: boolean;
|
|
96
|
+
selfMcpWriteEnabled: boolean;
|
|
97
|
+
depth: number;
|
|
98
|
+
maxSpawnDepth: number;
|
|
99
|
+
}): SpawnedSelfMcpDecision;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn capability — depth-gated self-MCP injection (issue #262 / DD-3, DD-5).
|
|
3
|
+
*
|
|
4
|
+
* A1 (#261) threaded an `origin` + spawn `depth` marker through every
|
|
5
|
+
* server-initiated turn and persisted it to {@link RunProvenanceStore}. B1 (this
|
|
6
|
+
* ticket) reads that `depth` to decide whether a SPAWNED turn is handed the
|
|
7
|
+
* self-management MCP — crucially its WRITE tools, so `send_message` exists and a
|
|
8
|
+
* child can finally report back to its parent (impossible today, where a spawned
|
|
9
|
+
* chat gets `send_file` only).
|
|
10
|
+
*
|
|
11
|
+
* ── The bound ────────────────────────────────────────────────────────────────
|
|
12
|
+
* A spawned/scheduled turn running in a chat at depth `d` receives the self-MCP
|
|
13
|
+
* iff `d <= maxSpawnDepth`:
|
|
14
|
+
*
|
|
15
|
+
* maxSpawnDepth = 0 → no spawned child gets the self-MCP (send_file only).
|
|
16
|
+
* This reproduces EXACTLY today's behaviour.
|
|
17
|
+
* maxSpawnDepth = 1 → depth-1 children DO get it (a manager's children can
|
|
18
|
+
* report back AND spawn), but depth-2 grandchildren do NOT.
|
|
19
|
+
* This is the DEFAULT — the manager→children→report-back
|
|
20
|
+
* pattern works out of the box while the tree can't run away.
|
|
21
|
+
* maxSpawnDepth = n → the tree may grow n spawn-hops deep before the tools stop.
|
|
22
|
+
*
|
|
23
|
+
* The human/scheduled ROOT (depth 0) is NOT gated here — it keeps today's
|
|
24
|
+
* instance-flag gating (`selfMcpEnabled`/`selfMcpWriteEnabled`) on the socket path.
|
|
25
|
+
* The gate only governs the server-initiated (`startAgentTurn`) path.
|
|
26
|
+
*
|
|
27
|
+
* ── Why `<=` and not `<` ─────────────────────────────────────────────────────
|
|
28
|
+
* The gate is evaluated at the CHILD, using the child's OWN depth `d` (that's the
|
|
29
|
+
* depth `startAgentTurn` knows). "A depth-`d` child may act" with `d <= maxSpawnDepth`
|
|
30
|
+
* is the same bound as the equivalent parent-side phrasing "a depth-`(d-1)` parent
|
|
31
|
+
* may spawn" with `(d-1) < maxSpawnDepth`, since a child's depth is always its
|
|
32
|
+
* parent's depth + 1. We evaluate it at the child, so the comparison is `<=`.
|
|
33
|
+
* With the default `1`, a depth-1 child satisfies `1 <= 1` and gets the tools;
|
|
34
|
+
* its depth-2 grandchild fails `2 <= 1` and does not — the bound descends because
|
|
35
|
+
* every child stamped by a tool-equipped parent is one hop deeper.
|
|
36
|
+
*
|
|
37
|
+
* ── Config (DD-5, the `driveMode` pattern) ───────────────────────────────────
|
|
38
|
+
* `maxSpawnDepth` is an instance default (`PADDOCK_MAX_SPAWN_DEPTH`, YAML later
|
|
39
|
+
* per #270) with a per-project override (`project.yaml` → Settings). An absent /
|
|
40
|
+
* invalid override inherits the instance default, resolved at dispatch by
|
|
41
|
+
* {@link resolveMaxSpawnDepth} — never baked into the DTO (mirrors how `driveMode`
|
|
42
|
+
* resolves against `cfg.driveMode`).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default max spawn depth (Ed, DD-3 decision 2): `1`. A manager's direct children
|
|
46
|
+
* get the self-MCP write tools (report-back + spawn), grandchildren do not.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_MAX_SPAWN_DEPTH = 1;
|
|
49
|
+
/**
|
|
50
|
+
* Hard upper bound on `maxSpawnDepth` (guards the UI slider + PATCH validation +
|
|
51
|
+
* config loader). Deep trees are deliberately allowed but not unboundedly — a
|
|
52
|
+
* runaway config can't ask for a thousand-deep fan-out. 8 hops is far past any
|
|
53
|
+
* real manager pattern.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_SPAWN_DEPTH_LIMIT = 8;
|
|
56
|
+
/**
|
|
57
|
+
* Whether `n` is a valid `maxSpawnDepth`: a non-negative integer within bounds.
|
|
58
|
+
* `0` is valid (it disables spawned self-MCP entirely — today's behaviour).
|
|
59
|
+
*/
|
|
60
|
+
export function isValidMaxSpawnDepth(n) {
|
|
61
|
+
return (typeof n === "number" &&
|
|
62
|
+
Number.isInteger(n) &&
|
|
63
|
+
n >= 0 &&
|
|
64
|
+
n <= MAX_SPAWN_DEPTH_LIMIT);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the effective `maxSpawnDepth` for a dispatch: a valid per-project
|
|
68
|
+
* override wins; otherwise inherit the instance default. Mirrors how `driveMode`
|
|
69
|
+
* resolves (`project.driveMode ?? cfg.driveMode`) — the override is carried
|
|
70
|
+
* on disk only when set, so an absent value transparently inherits the instance
|
|
71
|
+
* (and, later, a YAML file) default. A malformed override is ignored rather than
|
|
72
|
+
* fatal (defensive: a hand-edited `project.yaml` can't wedge dispatch).
|
|
73
|
+
*/
|
|
74
|
+
export function resolveMaxSpawnDepth(override, instanceDefault) {
|
|
75
|
+
if (isValidMaxSpawnDepth(override))
|
|
76
|
+
return override;
|
|
77
|
+
return isValidMaxSpawnDepth(instanceDefault) ? instanceDefault : DEFAULT_MAX_SPAWN_DEPTH;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The core gate: does a spawned/scheduled turn running in a chat at `depth`
|
|
81
|
+
* receive the self-MCP? `depth <= maxSpawnDepth` (see the module header for why
|
|
82
|
+
* `<=`). Both operands are read defensively so a corrupt marker can't throw at the
|
|
83
|
+
* dispatch hot path — a negative/NaN depth is treated as never-injectable.
|
|
84
|
+
*/
|
|
85
|
+
export function spawnedTurnGetsSelfMcp(depth, maxSpawnDepth) {
|
|
86
|
+
if (typeof depth !== "number" || !Number.isFinite(depth) || depth < 0)
|
|
87
|
+
return false;
|
|
88
|
+
if (typeof maxSpawnDepth !== "number" || !Number.isFinite(maxSpawnDepth))
|
|
89
|
+
return false;
|
|
90
|
+
return depth <= maxSpawnDepth;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The COMPLETE gate for whether a spawned/scheduled turn is handed the self-MCP,
|
|
94
|
+
* factored out so the exact rule is unit-tested rather than buried in ws.ts.
|
|
95
|
+
* A spawned turn gets the self-MCP iff the instance opted in
|
|
96
|
+
* (`selfMcpEnabled`) AND its depth is within the bound
|
|
97
|
+
* ({@link spawnedTurnGetsSelfMcp}). The WRITE tools are further gated by the
|
|
98
|
+
* instance write opt-in — in practice always on when a spawn is reachable (a spawn
|
|
99
|
+
* only happens because a parent already had the write tools), but honoured so an
|
|
100
|
+
* operator who disabled writes gets read-only spawned children too.
|
|
101
|
+
*/
|
|
102
|
+
export function spawnedSelfMcpDecision(params) {
|
|
103
|
+
const inject = params.selfMcpEnabled &&
|
|
104
|
+
spawnedTurnGetsSelfMcp(params.depth, params.maxSpawnDepth);
|
|
105
|
+
return { inject, includeWrite: inject && params.selfMcpWriteEnabled };
|
|
106
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare class StarStore {
|
|
2
|
+
private readonly stateFile;
|
|
3
|
+
/** In-memory set of starred keys (loaded once, written through on change). */
|
|
4
|
+
private starred;
|
|
5
|
+
/**
|
|
6
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
7
|
+
* Caching only the resolved `starred` set would let several toggles that begin
|
|
8
|
+
* before the first `fs.readFile` resolves each build their own set and race on
|
|
9
|
+
* the assignment — a lost update (last writer wins, dropping the others' keys).
|
|
10
|
+
*/
|
|
11
|
+
private loadPromise;
|
|
12
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
13
|
+
private writing;
|
|
14
|
+
constructor(dataDir: string);
|
|
15
|
+
/** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
16
|
+
private ensureLoaded;
|
|
17
|
+
/** Whether a chat is starred. Non-throwing: any load error reads as false. */
|
|
18
|
+
isStarred(agent: string, sessionId: string): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Set (or clear) a chat's starred flag, persisting the change. Idempotent.
|
|
21
|
+
* Returns whether the flag actually CHANGED, mirroring `ArchiveStore.setArchived`.
|
|
22
|
+
*/
|
|
23
|
+
setStarred(agent: string, sessionId: string, starred: boolean): Promise<boolean>;
|
|
24
|
+
/** Write-through, serialised so overlapping toggles can't corrupt the file. */
|
|
25
|
+
private persist;
|
|
26
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StarStore — a Paddock-side sidecar for the per-chat "starred" (pinned) flag (#373).
|
|
3
|
+
*
|
|
4
|
+
* Starring pins a chat to the top of its list without touching its transcript —
|
|
5
|
+
* the flag is purely presentational metadata, exactly like `archived` (#95).
|
|
6
|
+
* Starring is ORTHOGONAL to archiving: a chat can be starred whether or not it's
|
|
7
|
+
* archived, and within BOTH the active list and the Archived section starred
|
|
8
|
+
* chats float to the top of their own population (the client does the ordering).
|
|
9
|
+
*
|
|
10
|
+
* This is a near-verbatim copy of {@link ArchiveStore} — the same tiny JSON
|
|
11
|
+
* sidecar pattern SweepService uses for its watermark state. Keyed by
|
|
12
|
+
* `<agent>\0<sessionId>` (NUL-separated) so chats under different agents can
|
|
13
|
+
* never collide — a NUL can occur in neither an agent name (`keeper-<slug>`)
|
|
14
|
+
* nor a UUID. Only starred entries are stored (an unstar deletes the
|
|
15
|
+
* key), keeping the file compact. Unlike ArchiveStore there is NO lifecycle event
|
|
16
|
+
* — starring fires no hooks.
|
|
17
|
+
*/
|
|
18
|
+
import { promises as fs } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
const STATE_FILE = "star-state.json";
|
|
21
|
+
/** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
|
|
22
|
+
const KEY_SEP = "\u0000";
|
|
23
|
+
/** Compose the storage key for an (agent, session) pair. */
|
|
24
|
+
function keyOf(agent, sessionId) {
|
|
25
|
+
return `${agent}${KEY_SEP}${sessionId}`;
|
|
26
|
+
}
|
|
27
|
+
export class StarStore {
|
|
28
|
+
stateFile;
|
|
29
|
+
/** In-memory set of starred keys (loaded once, written through on change). */
|
|
30
|
+
starred = null;
|
|
31
|
+
/**
|
|
32
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
33
|
+
* Caching only the resolved `starred` set would let several toggles that begin
|
|
34
|
+
* before the first `fs.readFile` resolves each build their own set and race on
|
|
35
|
+
* the assignment — a lost update (last writer wins, dropping the others' keys).
|
|
36
|
+
*/
|
|
37
|
+
loadPromise = null;
|
|
38
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
39
|
+
writing = Promise.resolve();
|
|
40
|
+
constructor(dataDir) {
|
|
41
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
42
|
+
}
|
|
43
|
+
/** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
44
|
+
ensureLoaded() {
|
|
45
|
+
if (this.starred)
|
|
46
|
+
return Promise.resolve(this.starred);
|
|
47
|
+
if (!this.loadPromise) {
|
|
48
|
+
this.loadPromise = (async () => {
|
|
49
|
+
let keys = [];
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (Array.isArray(parsed))
|
|
54
|
+
keys = parsed.filter((k) => typeof k === "string");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* missing or unreadable — start empty */
|
|
58
|
+
}
|
|
59
|
+
this.starred = new Set(keys);
|
|
60
|
+
return this.starred;
|
|
61
|
+
})();
|
|
62
|
+
}
|
|
63
|
+
return this.loadPromise;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a chat is starred. Non-throwing: any load error reads as false. */
|
|
66
|
+
async isStarred(agent, sessionId) {
|
|
67
|
+
const set = await this.ensureLoaded().catch(() => new Set());
|
|
68
|
+
return set.has(keyOf(agent, sessionId));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Set (or clear) a chat's starred flag, persisting the change. Idempotent.
|
|
72
|
+
* Returns whether the flag actually CHANGED, mirroring `ArchiveStore.setArchived`.
|
|
73
|
+
*/
|
|
74
|
+
async setStarred(agent, sessionId, starred) {
|
|
75
|
+
const set = await this.ensureLoaded();
|
|
76
|
+
const key = keyOf(agent, sessionId);
|
|
77
|
+
if (starred === set.has(key))
|
|
78
|
+
return false; // no-op — avoid a needless write
|
|
79
|
+
if (starred)
|
|
80
|
+
set.add(key);
|
|
81
|
+
else
|
|
82
|
+
set.delete(key);
|
|
83
|
+
await this.persist(set);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
/** Write-through, serialised so overlapping toggles can't corrupt the file. */
|
|
87
|
+
persist(set) {
|
|
88
|
+
this.writing = this.writing.then(async () => {
|
|
89
|
+
const json = JSON.stringify([...set], null, 2);
|
|
90
|
+
await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
|
|
91
|
+
});
|
|
92
|
+
return this.writing;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function start(): Promise<void>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process lifecycle for a Paddock server: bind the port and wire signal-driven
|
|
3
|
+
* shutdown. Split out of `index.ts` so the `paddock` CLI (src/cli/paddock.ts)
|
|
4
|
+
* and the plain `node dist/index.js` entrypoint share one implementation.
|
|
5
|
+
*
|
|
6
|
+
* Config is resolved INSIDE `buildApp()`, not at module load, so a caller may
|
|
7
|
+
* still mutate `process.env` right up until this function is invoked. The CLI
|
|
8
|
+
* depends on that to apply its own defaults (e.g. a data dir under $HOME).
|
|
9
|
+
*/
|
|
10
|
+
import { buildApp } from "./app.js";
|
|
11
|
+
export async function start() {
|
|
12
|
+
const { app, cfg, close } = await buildApp();
|
|
13
|
+
const shutdown = async () => {
|
|
14
|
+
await close();
|
|
15
|
+
process.exit(0);
|
|
16
|
+
};
|
|
17
|
+
process.on("SIGINT", shutdown);
|
|
18
|
+
process.on("SIGTERM", shutdown);
|
|
19
|
+
await app.listen({ port: cfg.port, host: cfg.host });
|
|
20
|
+
app.log.info(`paddock-server listening on http://${cfg.host}:${cfg.port}`);
|
|
21
|
+
}
|