@edspencer/paddock 0.0.1 → 0.56.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 +512 -0
- package/packages/server/dist/config.js +593 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -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 +572 -0
- package/packages/server/dist/herdctl.js +1218 -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 +135 -0
- package/packages/server/dist/instance-config.js +345 -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-4cQGTj9Y.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
- package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
- package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
- package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.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-DdsLM6ql.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
- package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.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-CGbZdrm8.js +71 -0
- package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.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-2Nlqs0oH.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-mjU1fMc4.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.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-BxKd91ho.js +2 -0
- package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.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-DNdqX7O5.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.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-OAAhWcfK.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.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,1024 @@
|
|
|
1
|
+
import { createSDKMessageHandler } from "@herdctl/chat";
|
|
2
|
+
import { extractSubagentLaunches, subagentLaunchFields, } from "./subagents.js";
|
|
3
|
+
import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
|
|
4
|
+
import { consumeResumedTurn } from "./resume-drain.js";
|
|
5
|
+
import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
|
|
6
|
+
import { SessionHub } from "./session-hub.js";
|
|
7
|
+
import { resolveAttachmentsConfig } from "./attachments-config.js";
|
|
8
|
+
import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
|
|
9
|
+
import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
|
|
10
|
+
import { SELF_MCP_SERVER_KEY } from "./self-mcp.js";
|
|
11
|
+
import { HUMAN_ROOT } from "./run-provenance.js";
|
|
12
|
+
import { resolveRecoveryConfig } from "./recovery-config.js";
|
|
13
|
+
import { noticeFromMessage, errorNotice, messageProducedReply, suppressNoticeAfterReply, turnEffectivelySucceeded, } from "./turn-notice.js";
|
|
14
|
+
import { resolveHooksMcpEnabled } from "./hook-config.js";
|
|
15
|
+
// --- protocol + usage math (extracted #403) ---------------------------------
|
|
16
|
+
// The wire protocol (message interfaces + unions + `isClientMessage`) and the
|
|
17
|
+
// per-turn token/usage helpers now live in ws-protocol.ts. Re-export the whole
|
|
18
|
+
// surface so external importers (and tests) that `import … from "./ws.js"`
|
|
19
|
+
// resolve unchanged, then pull in the handful this module uses directly.
|
|
20
|
+
export * from "./ws-protocol.js";
|
|
21
|
+
// forkKickoffPrompt + buildSelfMcpServerDef now live in ws-self-mcp.ts; re-export
|
|
22
|
+
// forkKickoffPrompt so external importers (and its test) resolve it via ws.js.
|
|
23
|
+
export { forkKickoffPrompt } from "./ws-self-mcp.js";
|
|
24
|
+
import { buildSelfMcpServerDef } from "./ws-self-mcp.js";
|
|
25
|
+
import { makeTurnEngine, isSidechainMessage } from "./ws-turn.js";
|
|
26
|
+
// RECOVERY_NUDGE now lives in ws-turn.ts; re-export so its test resolves via ws.js.
|
|
27
|
+
export { RECOVERY_NUDGE } from "./ws-turn.js";
|
|
28
|
+
import { extractLocalCommandOutput, initTurnUsage, foldTurnUsage, resolveTurnUsage, isClientMessage, } from "./ws-protocol.js";
|
|
29
|
+
/**
|
|
30
|
+
* Register the /ws route handler. Pure transport: it validates messages,
|
|
31
|
+
* resolves the target agent, and streams a real trigger back to the socket.
|
|
32
|
+
*/
|
|
33
|
+
// Server-side keepalive: how often to ping each client and, if the previous
|
|
34
|
+
// ping went unanswered, reap the dead socket. Protocol-level ping frames also
|
|
35
|
+
// keep intermediaries (proxies/NAT) from evicting an otherwise-idle connection.
|
|
36
|
+
// See issue #46.
|
|
37
|
+
const SERVER_PING_INTERVAL_MS = 30_000;
|
|
38
|
+
// Separator in a composite in-memory key; a NUL can't occur in an agent name or
|
|
39
|
+
// a UUID. Spelled as an escape, never a raw 0x00 byte — a literal NUL makes
|
|
40
|
+
// ripgrep/grep treat this file as binary and skip it during directory traversal
|
|
41
|
+
// (silently, exit 1, indistinguishable from "no matches"). See issue #570.
|
|
42
|
+
const KEY_SEP = "\u0000";
|
|
43
|
+
export function makeChatHandler(deps) {
|
|
44
|
+
// ONE hub shared across every socket this handler serves: it tracks each
|
|
45
|
+
// session's in-flight turn and fans its frames out to whichever socket(s) are
|
|
46
|
+
// currently attached, so a turn survives the death of the socket that started
|
|
47
|
+
// it (issue #54). See session-hub.ts.
|
|
48
|
+
const hub = new SessionHub();
|
|
49
|
+
// Per-session marker of the last queued message the server has already drained
|
|
50
|
+
// (#245), keyed `agent \0 sessionId` and stamped with that message's client
|
|
51
|
+
// timestamp. Lets an idle-drain skip a message it already sent — e.g. a stale
|
|
52
|
+
// localStorage copy a reloaded client re-asserts — instead of double-sending.
|
|
53
|
+
// In-memory (shared across this handler's sockets); a rare double-send only
|
|
54
|
+
// survives a server restart, when the persisted store is already empty anyway.
|
|
55
|
+
const lastFlushedTs = new Map();
|
|
56
|
+
// Every currently-connected socket, so a turn's start/stop transition can be
|
|
57
|
+
// broadcast to all clients — powering the per-chat sidebar streaming dots that
|
|
58
|
+
// must update even for chats whose pane isn't mounted (issue #53).
|
|
59
|
+
const clients = new Set();
|
|
60
|
+
const activeFrame = (info) => ({
|
|
61
|
+
type: "chat:active",
|
|
62
|
+
payload: {
|
|
63
|
+
projectSlug: info.projectSlug,
|
|
64
|
+
sessionId: info.sessionId,
|
|
65
|
+
jobId: info.jobId,
|
|
66
|
+
running: info.running,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
hub.onActive = (info) => {
|
|
70
|
+
const data = JSON.stringify(activeFrame(info));
|
|
71
|
+
for (const c of clients) {
|
|
72
|
+
if (c.readyState === c.OPEN) {
|
|
73
|
+
try {
|
|
74
|
+
c.send(data);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* a socket that throws on send is effectively gone */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
// Drive scheduler-fired session wakes onto the hub (Paddock#111 gap 3). When a
|
|
83
|
+
// keeper scheduled a `ScheduleWakeup` / `/loop`, herdctl reaps the idle session
|
|
84
|
+
// and later resumes it at fire time, handing us the live (managed) session with
|
|
85
|
+
// NO client watching. We stream it exactly like a human turn — same translator,
|
|
86
|
+
// same hub — so the autonomous work lands in the transcript, drives the sidebar
|
|
87
|
+
// streaming dot (via `hub.onActive`), and is replayable by a client that opens
|
|
88
|
+
// the chat later. We do NOT close the session: it's managed, so the reaper tears
|
|
89
|
+
// it down when it goes idle again (and re-captures any fresh wakeups).
|
|
90
|
+
deps.herdctl.onSessionWake(async (session, entry) => {
|
|
91
|
+
// Only KEEPER agents own chats. A wake from a sweeper/trigger/hook agent has
|
|
92
|
+
// no chat to drive, so there is nothing to route.
|
|
93
|
+
const slug = keeperSlugFromAgent(entry.agent);
|
|
94
|
+
// `null` = not a keeper. `""` = the ROOT workspace's own key, so this must
|
|
95
|
+
// stay an explicit null check or every root chat's wake is dropped.
|
|
96
|
+
if (slug === null)
|
|
97
|
+
return;
|
|
98
|
+
let resolvedSession = entry.sessionId ?? null;
|
|
99
|
+
const turn = hub.startTurn(slug, null, entry.sessionId);
|
|
100
|
+
// #353: DON'T stamp a creation origin here. A session wake is a *resume*, not
|
|
101
|
+
// a *creation* — `onSessionWake` only ever resumes an already-existing chat
|
|
102
|
+
// (a human's, or a spawn's, that armed a `ScheduleWakeup`/`/loop`), it never
|
|
103
|
+
// creates one. Genuinely schedule-*created* chats are already stamped
|
|
104
|
+
// `scheduled` at creation by `fireTriggerForProject` → `startAgentTurn`, so
|
|
105
|
+
// dropping the wake stamp loses nothing for them. The old
|
|
106
|
+
// `stampIfAbsent(SCHEDULED_ROOT)` here was a category error: a chat that
|
|
107
|
+
// predates provenance stamping (empty slot) and later arms a ScheduleWakeup
|
|
108
|
+
// would get falsely labelled `scheduled` on its first wake — mislabelling a
|
|
109
|
+
// human-rooted chat. Leaving a legacy/blank chat unstamped renders no badge
|
|
110
|
+
// (the correct outcome for a human chat) instead of a wrong one.
|
|
111
|
+
const routing = () => ({
|
|
112
|
+
projectSlug: slug,
|
|
113
|
+
sessionId: resolvedSession,
|
|
114
|
+
jobId: turn.jobId,
|
|
115
|
+
});
|
|
116
|
+
// #429: sub-agent launches recovered live from the tool_use input, keyed by
|
|
117
|
+
// toolUseId, so the wake turn's launching card renders enriched + expandable
|
|
118
|
+
// without a refresh (see subagentLaunchFields).
|
|
119
|
+
const wakeLaunches = new Map();
|
|
120
|
+
const translate = createSDKMessageHandler({
|
|
121
|
+
onText: (chunk) => {
|
|
122
|
+
if (chunk)
|
|
123
|
+
turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
|
|
124
|
+
},
|
|
125
|
+
onBoundary: () => {
|
|
126
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
127
|
+
},
|
|
128
|
+
onToolStart: (start) => {
|
|
129
|
+
turn.emit({
|
|
130
|
+
type: "chat:tool_start",
|
|
131
|
+
payload: {
|
|
132
|
+
...routing(),
|
|
133
|
+
toolName: start.toolName,
|
|
134
|
+
inputSummary: start.inputSummary,
|
|
135
|
+
toolUseId: start.toolUseId,
|
|
136
|
+
parentToolUseId: start.parentToolUseId,
|
|
137
|
+
...subagentLaunchFields(wakeLaunches, start.toolName, start.toolUseId),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
onToolCall: (call) => {
|
|
142
|
+
turn.emit({
|
|
143
|
+
type: "chat:tool_call",
|
|
144
|
+
payload: {
|
|
145
|
+
...routing(),
|
|
146
|
+
toolName: call.toolName,
|
|
147
|
+
inputSummary: call.inputSummary,
|
|
148
|
+
output: call.output,
|
|
149
|
+
isError: call.isError,
|
|
150
|
+
durationMs: call.durationMs,
|
|
151
|
+
toolUseId: call.toolUseId,
|
|
152
|
+
...subagentLaunchFields(wakeLaunches, call.toolName, call.toolUseId),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
// #329: surface a dead-end (usage limit / max-turns / error) on this wake
|
|
158
|
+
// turn too, at most once. Same rationale as the interactive path.
|
|
159
|
+
// #380/#394: once the turn has shown the user real prose, an error/max_turns
|
|
160
|
+
// dead-end is a false alarm — suppress it (matching the history path).
|
|
161
|
+
// `wakeProducedReply` is OR-accumulated across every message (a tool-heavy
|
|
162
|
+
// turn's prose rides on a `tool_use` message, not the terminal one). A
|
|
163
|
+
// `usage_limit` still surfaces.
|
|
164
|
+
let wakeNoticeEmitted = false;
|
|
165
|
+
let wakeProducedReply = false;
|
|
166
|
+
const emitWakeNotice = (notice) => {
|
|
167
|
+
if (wakeNoticeEmitted)
|
|
168
|
+
return;
|
|
169
|
+
if (suppressNoticeAfterReply(notice, wakeProducedReply))
|
|
170
|
+
return;
|
|
171
|
+
wakeNoticeEmitted = true;
|
|
172
|
+
turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
|
|
173
|
+
};
|
|
174
|
+
// Per-message wake handling, shared by the fast path and the drain path.
|
|
175
|
+
const onWakeSessionId = (id) => {
|
|
176
|
+
resolvedSession = id;
|
|
177
|
+
turn.setSession(id);
|
|
178
|
+
// #353: no provenance stamp on wake — see the note at the top of this
|
|
179
|
+
// handler. A resume must never write a creation origin.
|
|
180
|
+
};
|
|
181
|
+
const onWakeMessage = async (m) => {
|
|
182
|
+
// A sub-agent's nested steps never render top-level ({@link isSidechainMessage}).
|
|
183
|
+
if (isSidechainMessage(m))
|
|
184
|
+
return;
|
|
185
|
+
if (messageProducedReply(m))
|
|
186
|
+
wakeProducedReply = true;
|
|
187
|
+
const notice = noticeFromMessage(m);
|
|
188
|
+
if (notice)
|
|
189
|
+
emitWakeNotice(notice);
|
|
190
|
+
// #429: recover any Task/Agent launch before translate fires onToolStart.
|
|
191
|
+
for (const l of extractSubagentLaunches(m))
|
|
192
|
+
wakeLaunches.set(l.toolUseId, l);
|
|
193
|
+
await translate(m);
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
// Resume self-interrupt fix. A woken session that still holds a stale
|
|
197
|
+
// async-input backlog replays it as its OWN turn ahead of the wake turn; the
|
|
198
|
+
// old break-after-first-result then closed the CLI on the backlog turn's
|
|
199
|
+
// result, killing the wake turn. A wake is always a resume, so consume with
|
|
200
|
+
// the queue-drain break rule — break on a `result` only once the async queue
|
|
201
|
+
// has drained (after the LAST/real wake turn). No backlog → breaks after the
|
|
202
|
+
// first result (fast). See ./resume-drain.ts.
|
|
203
|
+
if (entry.sessionId) {
|
|
204
|
+
await consumeResumedTurn(session, {
|
|
205
|
+
residueProbe: () => deps.herdctl.residueDepthFor(entry.agent, entry.sessionId),
|
|
206
|
+
onMessage: onWakeMessage,
|
|
207
|
+
onSessionId: onWakeSessionId,
|
|
208
|
+
log: (msg) =>
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.log(`[resume-drain] wake ${entry.agent} (${entry.sessionId}): ${msg}`),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
for await (const m of session.messages) {
|
|
215
|
+
if (m.session_id)
|
|
216
|
+
onWakeSessionId(m.session_id);
|
|
217
|
+
await onWakeMessage(m);
|
|
218
|
+
if (m.type === "result")
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
turn.emit({
|
|
223
|
+
type: "chat:complete",
|
|
224
|
+
payload: { ...routing(), sessionId: resolvedSession, success: true },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
229
|
+
emitWakeNotice(errorNotice(error));
|
|
230
|
+
turn.emit({ type: "chat:complete", payload: { ...routing(), success: false, error } });
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
turn.end();
|
|
234
|
+
}
|
|
235
|
+
// Post-wake curation sweep, same as a human turn. Fires for EVERY workspace,
|
|
236
|
+
// the root included (its sweeper is `sweeper-_root`, registered at boot) — the
|
|
237
|
+
// emit is unconditional and skips nothing. T5: routed through the `afterTurn`
|
|
238
|
+
// event so the folded-in curator dispatches once.
|
|
239
|
+
emitAfterTurn(slug, resolvedSession ?? null);
|
|
240
|
+
});
|
|
241
|
+
// Turn-execution engine (ws-turn.ts, #403): the mutually-recursive core —
|
|
242
|
+
// trigger cluster, self-MCP injection context, per-turn injected-MCP builders +
|
|
243
|
+
// wake cache, the shared startAgentTurn engine, and Layer-2/3 recovery. Relocated
|
|
244
|
+
// as one scope so its cross-references stay intact; the socket layer below
|
|
245
|
+
// consumes the returned surface.
|
|
246
|
+
const engine = makeTurnEngine({ deps, hub });
|
|
247
|
+
const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
|
|
248
|
+
const handle = async function handle(socket) {
|
|
249
|
+
const send = (m) => {
|
|
250
|
+
if (socket.readyState === socket.OPEN)
|
|
251
|
+
socket.send(JSON.stringify(m));
|
|
252
|
+
};
|
|
253
|
+
// Register this socket for active-turn broadcasts, and immediately catch it
|
|
254
|
+
// up on which sessions are currently running (so the sidebar dots and a
|
|
255
|
+
// returning pane's Stop button reflect reality from the first paint).
|
|
256
|
+
clients.add(socket);
|
|
257
|
+
for (const info of hub.runningSessions())
|
|
258
|
+
send(activeFrame(info));
|
|
259
|
+
// Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
|
|
260
|
+
// client whose TCP has silently died (idle drop, sleep) fails to pong and is
|
|
261
|
+
// terminated on the next tick — freeing server resources and letting the
|
|
262
|
+
// client's own reconnect take over. Cleared when the socket closes.
|
|
263
|
+
let isAlive = true;
|
|
264
|
+
socket.on("pong", () => {
|
|
265
|
+
isAlive = true;
|
|
266
|
+
});
|
|
267
|
+
const heartbeat = setInterval(() => {
|
|
268
|
+
if (!isAlive) {
|
|
269
|
+
socket.terminate();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
isAlive = false;
|
|
273
|
+
try {
|
|
274
|
+
socket.ping();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
socket.terminate();
|
|
278
|
+
}
|
|
279
|
+
}, SERVER_PING_INTERVAL_MS);
|
|
280
|
+
socket.on("close", () => {
|
|
281
|
+
clearInterval(heartbeat);
|
|
282
|
+
// Drop this socket from every session fan-out set so the hub stops trying
|
|
283
|
+
// to write to it (a running turn keeps going for other attached sockets).
|
|
284
|
+
hub.unsubscribeSocket(socket);
|
|
285
|
+
clients.delete(socket);
|
|
286
|
+
});
|
|
287
|
+
socket.on("message", (raw) => {
|
|
288
|
+
let parsed;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(raw.toString());
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!isClientMessage(parsed)) {
|
|
297
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (parsed.type === "ping") {
|
|
301
|
+
send({ type: "pong" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (parsed.type === "chat:cancel") {
|
|
305
|
+
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (parsed.type === "chat:subscribe") {
|
|
309
|
+
onSubscribe(parsed);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (parsed.type === "chat:set_queue") {
|
|
313
|
+
void onSetQueue(parsed);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (parsed.type === "chat:command") {
|
|
317
|
+
void onChatCommand(parsed);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (parsed.type === "chat:continue") {
|
|
321
|
+
void onChatContinue(parsed);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
void onChatSend(parsed);
|
|
325
|
+
});
|
|
326
|
+
/**
|
|
327
|
+
* Manual keeper recovery (issue #301, Layer 2). Re-drive a hung keeper whose
|
|
328
|
+
* background task was killed at the turn boundary by injecting the recovery
|
|
329
|
+
* nudge into its still-alive session via {@link startAgentTurn} — the same
|
|
330
|
+
* workhorse the self-MCP `send_message` / schedule fires use, so the injected
|
|
331
|
+
* turn streams live, lists in the sidebar, and is attributable
|
|
332
|
+
* (`sender: recovery`). Server-authoritative gate: no-op unless the resolved
|
|
333
|
+
* `recovery.surfaceKilledTask` is on for this project, so a stale/rogue client
|
|
334
|
+
* can't re-drive when the operator disabled Layer 2.
|
|
335
|
+
*/
|
|
336
|
+
const onChatContinue = async (msg) => {
|
|
337
|
+
// `""` addresses the ROOT workspace, so only a MISSING key is a no-op.
|
|
338
|
+
const slug = msg.payload.projectSlug;
|
|
339
|
+
if (slug === undefined || slug === null)
|
|
340
|
+
return;
|
|
341
|
+
const sessionId = msg.payload.sessionId;
|
|
342
|
+
if (!sessionId)
|
|
343
|
+
return;
|
|
344
|
+
let project;
|
|
345
|
+
try {
|
|
346
|
+
project = await deps.projects.get(slug);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return; // unknown project — nothing to recover
|
|
350
|
+
}
|
|
351
|
+
// Gate on the resolved Layer 2 flag (per-project override else instance).
|
|
352
|
+
const recovery = resolveRecoveryConfig(project.recovery, deps.cfg.recovery);
|
|
353
|
+
if (!recovery.surfaceKilledTask)
|
|
354
|
+
return;
|
|
355
|
+
// Re-drive via the shared recovery path (also used by Layer 3 auto-recovery).
|
|
356
|
+
await injectRecoveryNudge(project, sessionId).catch((err) => {
|
|
357
|
+
send({
|
|
358
|
+
type: "chat:error",
|
|
359
|
+
payload: { projectSlug: slug, error: `Recovery failed: ${String(err)}` },
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
const onSubscribe = (msg) => {
|
|
364
|
+
const { sessionId, wantReplay, lastSeq } = msg.payload;
|
|
365
|
+
const result = hub.attach(sessionId, socket, {
|
|
366
|
+
wantReplay: wantReplay === true,
|
|
367
|
+
afterSeq: typeof lastSeq === "number" ? lastSeq : -1,
|
|
368
|
+
});
|
|
369
|
+
if (result.status === "resync") {
|
|
370
|
+
send({
|
|
371
|
+
type: "chat:resync",
|
|
372
|
+
payload: { projectSlug: result.projectSlug, sessionId },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
// Tell a (re)attaching pane whether its session has a live turn, so a chat
|
|
376
|
+
// the user navigated back to restores its Stop button + jobId and streaming
|
|
377
|
+
// indicator immediately — not only once the next frame happens to arrive
|
|
378
|
+
// (issues #52/#53).
|
|
379
|
+
const active = hub.activeInfo(sessionId);
|
|
380
|
+
if (active)
|
|
381
|
+
send(activeFrame(active));
|
|
382
|
+
};
|
|
383
|
+
// Server-authoritative queue drain (#245): auto-send a persisted queued
|
|
384
|
+
// message as the next turn, exactly once. Called (a) when a turn completes
|
|
385
|
+
// successfully and (b) when a queue is set while the session is idle — a queue
|
|
386
|
+
// that arrived (e.g. via the reconnect outbox) after the turn it was meant to
|
|
387
|
+
// follow already ended. `take` makes the read+clear atomic so the two callers
|
|
388
|
+
// can never both send it; the `lastFlushedTs` marker skips a message already
|
|
389
|
+
// drained (a stale client re-assert on reload) so it isn't sent twice.
|
|
390
|
+
const drainQueue = async (slug, sessionId) => {
|
|
391
|
+
if (!deps.queuedMessage)
|
|
392
|
+
return;
|
|
393
|
+
const agent = keeperAgentName(slug);
|
|
394
|
+
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
395
|
+
if (!queued?.text)
|
|
396
|
+
return;
|
|
397
|
+
const markerKey = `${agent}${KEY_SEP}${sessionId}`;
|
|
398
|
+
const already = (lastFlushedTs.get(markerKey) ?? 0) >= queued.createdAtMs;
|
|
399
|
+
// Tell every attached client (origin + reconnected sockets) to clear its copy
|
|
400
|
+
// of this message. When we're really sending it, carry the text so the client
|
|
401
|
+
// renders the sent bubble in-transcript; on a stale re-assert we only clear.
|
|
402
|
+
hub.broadcast(sessionId, {
|
|
403
|
+
type: "chat:queued_flushed",
|
|
404
|
+
payload: {
|
|
405
|
+
projectSlug: slug,
|
|
406
|
+
sessionId,
|
|
407
|
+
...(already ? {} : { text: queued.text }),
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
if (already)
|
|
411
|
+
return;
|
|
412
|
+
lastFlushedTs.set(markerKey, queued.createdAtMs);
|
|
413
|
+
// Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
|
|
414
|
+
// above the reply. Run it detached, like a human send. A leading-slash queued
|
|
415
|
+
// message is a slash command (e.g. "/compact"): route it through the command
|
|
416
|
+
// path so the CLI dispatches it — matching how the composer sends one live.
|
|
417
|
+
if (queued.text.startsWith("/")) {
|
|
418
|
+
void onChatCommand({
|
|
419
|
+
type: "chat:command",
|
|
420
|
+
payload: { projectSlug: slug, command: queued.text, sessionId },
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
void onChatSend({
|
|
425
|
+
type: "chat:send",
|
|
426
|
+
payload: { projectSlug: slug, sessionId, message: queued.text },
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
const onSetQueue = async (msg) => {
|
|
431
|
+
if (!deps.queuedMessage)
|
|
432
|
+
return; // feature disabled
|
|
433
|
+
// `""` addresses the ROOT workspace, so only a MISSING key is a no-op.
|
|
434
|
+
const slug = msg.payload.projectSlug;
|
|
435
|
+
if (slug === undefined || slug === null)
|
|
436
|
+
return;
|
|
437
|
+
const sessionId = msg.payload.sessionId ?? null;
|
|
438
|
+
const text = msg.payload.text ?? null;
|
|
439
|
+
// Every chat belongs to a workspace keeper.
|
|
440
|
+
const agent = keeperAgentName(slug);
|
|
441
|
+
if (!sessionId) {
|
|
442
|
+
// New chat: queue isn't stored until the session id exists. The client
|
|
443
|
+
// re-asserts it (with the same ts) once the id resolves, so it persists then.
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Store or clear the queued message.
|
|
447
|
+
if (text && text.trim().length > 0) {
|
|
448
|
+
await deps.queuedMessage
|
|
449
|
+
.set(agent, sessionId, { text, createdAtMs: msg.payload.ts ?? Date.now() })
|
|
450
|
+
.catch(() => undefined);
|
|
451
|
+
// If no turn is running, this queue arrived after the turn it was meant to
|
|
452
|
+
// follow already ended — drain it now rather than wait for a completion
|
|
453
|
+
// that won't come (the reported stranding bug, #245).
|
|
454
|
+
if (!hub.isRunning(sessionId))
|
|
455
|
+
await drainQueue(slug, sessionId);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const onChatSend = async (msg) => {
|
|
462
|
+
const slug = msg.payload.projectSlug;
|
|
463
|
+
const { message, sessionId, preloadContext, attachments: sentAttachments } = msg.payload;
|
|
464
|
+
const isNewChat = sessionId === undefined || sessionId === null;
|
|
465
|
+
// A genuine human message resets this session's Layer 3 recovery guard (issue
|
|
466
|
+
// #301) and cancels any in-flight watch, so the retry cap counts auto re-drives
|
|
467
|
+
// BETWEEN human messages and a later real hang is recovered fresh.
|
|
468
|
+
if (sessionId)
|
|
469
|
+
recoveryEngine.onHumanMessage(sessionId);
|
|
470
|
+
let jobId = null;
|
|
471
|
+
let resolvedSession = sessionId ?? null;
|
|
472
|
+
// One-shot guard: a brand-new chat is attributed to its agent the instant
|
|
473
|
+
// its session id first streams back, so it lists in the sidebar mid-turn
|
|
474
|
+
// instead of only after the turn completes (issue #100). Resumed chats are
|
|
475
|
+
// already attributed, so this only runs for a new chat.
|
|
476
|
+
let attributed = false;
|
|
477
|
+
// Track this turn in the session hub so its frames fan out to whichever
|
|
478
|
+
// socket(s) are attached — not just this one — and a reconnecting client
|
|
479
|
+
// can re-attach + replay the missed gap (issue #54). A resumed chat's id is
|
|
480
|
+
// known now (re-attachable from frame 0); a new chat registers once the id
|
|
481
|
+
// arrives mid-stream (see turn.setSession below).
|
|
482
|
+
const turn = hub.startTurn(slug, socket, sessionId ?? null);
|
|
483
|
+
// Per-turn usage + model captured off the SDK stream (last non-null wins).
|
|
484
|
+
// Held on a mutable record (not bare `let`s) so the values assigned inside
|
|
485
|
+
// the streaming callback are visible to control-flow analysis afterwards.
|
|
486
|
+
const seen = initTurnUsage();
|
|
487
|
+
// The model the turn will run on; resolved below once we know the target.
|
|
488
|
+
let effectiveModel = DEFAULT_MODEL;
|
|
489
|
+
// How this turn is driven (Paddock#111): the global default unless the
|
|
490
|
+
// workspace overrides it (resolved in the workspace branch below).
|
|
491
|
+
let driveMode = deps.cfg.driveMode;
|
|
492
|
+
// The agent's working directory, so the send_file tool can resolve a real
|
|
493
|
+
// `file_path` (and sandbox it). Resolved alongside the agent below.
|
|
494
|
+
let sendFileWorkingDir;
|
|
495
|
+
const routing = () => ({
|
|
496
|
+
projectSlug: slug,
|
|
497
|
+
sessionId: resolvedSession,
|
|
498
|
+
jobId,
|
|
499
|
+
});
|
|
500
|
+
// #329: surface a turn dead-end (usage limit / max-turns / error) inline, at
|
|
501
|
+
// most once per turn — a synthetic session-limit reply repeats several times,
|
|
502
|
+
// and a failed turn can carry both an error `result` and a `success:false`
|
|
503
|
+
// completion. Emit the first, suppress the rest.
|
|
504
|
+
let noticeEmitted = false;
|
|
505
|
+
// #380: whether a COMPLETE assistant reply already streamed this turn. In
|
|
506
|
+
// session mode the SDK's terminal `result` (streamed live, never persisted)
|
|
507
|
+
// can carry an `error_*` subtype / `success:false` AFTER a good `end_turn`
|
|
508
|
+
// reply, painting a false "turn failed" banner beneath the answer. Once a
|
|
509
|
+
// reply is seen we suppress the error/max_turns dead-end — the same guard the
|
|
510
|
+
// history path (`scanTranscriptNotice`) already applies on reload. A
|
|
511
|
+
// `usage_limit` dead-end still surfaces (a session-limit stop is real).
|
|
512
|
+
let producedReply = false;
|
|
513
|
+
const emitNotice = (notice) => {
|
|
514
|
+
if (noticeEmitted)
|
|
515
|
+
return;
|
|
516
|
+
if (suppressNoticeAfterReply(notice, producedReply))
|
|
517
|
+
return;
|
|
518
|
+
noticeEmitted = true;
|
|
519
|
+
turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
|
|
520
|
+
};
|
|
521
|
+
// #429: sub-agent launches recovered live from the tool_use input, keyed by
|
|
522
|
+
// toolUseId, so a launching card shows the real type/title + is expandable into
|
|
523
|
+
// its (streaming) steps without a refresh. Populated in onMessage before each
|
|
524
|
+
// translate; read by the onToolStart/onToolCall closures via subagentLaunchFields.
|
|
525
|
+
const subagentLaunches = new Map();
|
|
526
|
+
// @herdctl/chat's shared translator turns the SDKMessage stream into the
|
|
527
|
+
// three UI events we forward over the socket. Created fresh per turn (it
|
|
528
|
+
// holds per-turn tool-pairing state).
|
|
529
|
+
const translate = createSDKMessageHandler({
|
|
530
|
+
onText: (chunk) => {
|
|
531
|
+
if (chunk)
|
|
532
|
+
turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
|
|
533
|
+
},
|
|
534
|
+
onBoundary: () => {
|
|
535
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
536
|
+
},
|
|
537
|
+
onToolStart: (start) => {
|
|
538
|
+
turn.emit({
|
|
539
|
+
type: "chat:tool_start",
|
|
540
|
+
payload: {
|
|
541
|
+
...routing(),
|
|
542
|
+
toolName: start.toolName,
|
|
543
|
+
inputSummary: start.inputSummary,
|
|
544
|
+
toolUseId: start.toolUseId,
|
|
545
|
+
parentToolUseId: start.parentToolUseId,
|
|
546
|
+
...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
},
|
|
550
|
+
onToolCall: (call) => {
|
|
551
|
+
turn.emit({
|
|
552
|
+
type: "chat:tool_call",
|
|
553
|
+
payload: {
|
|
554
|
+
...routing(),
|
|
555
|
+
toolName: call.toolName,
|
|
556
|
+
inputSummary: call.inputSummary,
|
|
557
|
+
output: call.output,
|
|
558
|
+
isError: call.isError,
|
|
559
|
+
durationMs: call.durationMs,
|
|
560
|
+
toolUseId: call.toolUseId,
|
|
561
|
+
...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
try {
|
|
567
|
+
// Every workspace key resolves to a keeper (`keeper-<slug>`), including
|
|
568
|
+
// the root's `keeper-_root` (its key is `""`, encoded via `agentKeyFor`).
|
|
569
|
+
let agentName;
|
|
570
|
+
// Effective prompt — may be augmented with the project overview below.
|
|
571
|
+
let prompt = message;
|
|
572
|
+
// Whether this project agent gets the T3 unified trigger-management tools
|
|
573
|
+
// (resolved from the project's REUSED hooks-MCP `hooksMcpEnabled` override
|
|
574
|
+
// else the instance default).
|
|
575
|
+
let includeTriggers = false;
|
|
576
|
+
const requested = msg.payload.model;
|
|
577
|
+
{
|
|
578
|
+
// Verifies the project exists (throws if not); we keep the object so
|
|
579
|
+
// we can resolve its model + re-register the keeper for an override.
|
|
580
|
+
const project = await deps.projects.get(slug);
|
|
581
|
+
agentName = keeperAgentName(slug);
|
|
582
|
+
sendFileWorkingDir = project.dir;
|
|
583
|
+
// Project chat: a valid override wins, else the project's model. Then
|
|
584
|
+
// ensure the (shared) keeper is registered at that model before the
|
|
585
|
+
// trigger. NOTE single-user last-write-wins caveat (see herdctl.ts).
|
|
586
|
+
effectiveModel =
|
|
587
|
+
requested && isKnownModel(requested) ? requested : project.model;
|
|
588
|
+
await deps.herdctl.ensureAgentModel(project, effectiveModel);
|
|
589
|
+
// Per-project driveMode override wins over the global default
|
|
590
|
+
// (Paddock#111). An absent/invalid value inherits the global.
|
|
591
|
+
driveMode =
|
|
592
|
+
project.driveMode && isKnownDriveMode(project.driveMode)
|
|
593
|
+
? project.driveMode
|
|
594
|
+
: deps.cfg.driveMode;
|
|
595
|
+
// T3 trigger-management gate (REUSES the hooks-MCP gate): the per-project
|
|
596
|
+
// override wins, else the instance default. Only takes effect when the
|
|
597
|
+
// write tools are also on (the trigger tools live in the write block).
|
|
598
|
+
includeTriggers =
|
|
599
|
+
deps.cfg.selfMcpWriteEnabled &&
|
|
600
|
+
resolveHooksMcpEnabled(project.hooksMcpEnabled, deps.cfg.hooksMcpEnabled);
|
|
601
|
+
// Composer attachments (issue #328): validate the uploaded files against
|
|
602
|
+
// this project's effective attachment config, then prepend a
|
|
603
|
+
// `<paddock-attachments>` hint block pointing the keeper's Read tool at
|
|
604
|
+
// the stored files' absolute paths. Wrapped BEFORE preload so the whole
|
|
605
|
+
// thing nests inside the preload block. Invalid/missing/over-count
|
|
606
|
+
// attachments are dropped (defensive — the endpoint already gated them).
|
|
607
|
+
if (sentAttachments && sentAttachments.length > 0) {
|
|
608
|
+
const acfg = resolveAttachmentsConfig(project.attachments, deps.cfg.attachments);
|
|
609
|
+
if (acfg.enabled) {
|
|
610
|
+
const promptAtts = [];
|
|
611
|
+
for (const a of sentAttachments.slice(0, acfg.maxFilesPerMessage)) {
|
|
612
|
+
const abs = deps.attachments.absolutePath(a.id);
|
|
613
|
+
if (!abs || !(await deps.attachments.exists(a.id)))
|
|
614
|
+
continue;
|
|
615
|
+
promptAtts.push({
|
|
616
|
+
id: a.id,
|
|
617
|
+
filename: a.filename,
|
|
618
|
+
kind: inferAttachmentKind(a.filename),
|
|
619
|
+
path: abs,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
prompt = wrapAttachments(promptAtts, prompt);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Context preload (issues #1/#188): only for a NEW chat, only when
|
|
626
|
+
// asked. Shared with the self-MCP create_chat path (C2 / #264):
|
|
627
|
+
// injects BOTH OVERVIEW.md and CHANGELOG.md when the project has
|
|
628
|
+
// curated state, else leaves the prompt untouched. Wraps the (possibly
|
|
629
|
+
// attachment-wrapped) `prompt`, not the bare `message`.
|
|
630
|
+
if (isNewChat && preloadContext) {
|
|
631
|
+
prompt = await composePreloadedPrompt(slug, prompt);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Inject the Paddock send_file MCP tool for this turn. The tool returns a
|
|
635
|
+
// JSON envelope as its result `output`; the web renders it off the tool
|
|
636
|
+
// call itself (live + on reload), so no bespoke WS frame is needed. The
|
|
637
|
+
// working dir resolves a relative `file_path`; a real file's bytes are
|
|
638
|
+
// copied into the attachment store at send time (immutable snapshot).
|
|
639
|
+
const sendFile = sendFileServerDef({
|
|
640
|
+
workingDirectory: sendFileWorkingDir,
|
|
641
|
+
saveAttachment: (bytes, name) => deps.attachments.save(bytes, name),
|
|
642
|
+
});
|
|
643
|
+
const injectedMcpServers = {
|
|
644
|
+
[SEND_FILE_SERVER_KEY]: sendFile,
|
|
645
|
+
};
|
|
646
|
+
// Self-management MCP (issue #214): only on keeper turns, and only when
|
|
647
|
+
// the instance opts in via PADDOCK_SELF_MCP. A HUMAN turn is
|
|
648
|
+
// the ROOT of any spawn tree (origin human, depth 0), so its children are
|
|
649
|
+
// depth 1 — the same builder the spawned path uses, just seeded with
|
|
650
|
+
// HUMAN_ROOT. Write tools follow the instance write opt-in (B1 #262: the
|
|
651
|
+
// shared builder is extracted so both paths agree). Depth-0 human gating is
|
|
652
|
+
// unchanged from before B1 — the depth bound governs the spawned path only.
|
|
653
|
+
if (deps.cfg.selfMcpEnabled) {
|
|
654
|
+
injectedMcpServers[SELF_MCP_SERVER_KEY] = buildSelfMcpServerDef(selfMcpCtx, {
|
|
655
|
+
currentProjectSlug: slug,
|
|
656
|
+
currentSessionId: () => resolvedSession ?? sessionId ?? null,
|
|
657
|
+
parentProvenance: HUMAN_ROOT,
|
|
658
|
+
includeWrite: deps.cfg.selfMcpWriteEnabled,
|
|
659
|
+
includeTriggers,
|
|
660
|
+
// Project provisioning (#467) rides on the write block behind its own
|
|
661
|
+
// instance flag — no per-project override, so nothing to resolve here.
|
|
662
|
+
includeProjects: deps.cfg.selfMcpWriteEnabled && deps.cfg.selfMcpProjectsEnabled,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
// Session mode drives a persistent, herdctl-managed openChatSession so
|
|
666
|
+
// cross-turn autonomy (ScheduleWakeup / `/loop`) survives the turn
|
|
667
|
+
// boundary; batch mode keeps the legacy one-shot trigger. Both stream
|
|
668
|
+
// through the identical onMessage/onJobCreated contract (Paddock#111).
|
|
669
|
+
const drive = driveMode === "session"
|
|
670
|
+
? deps.herdctl.chatSession.bind(deps.herdctl)
|
|
671
|
+
: deps.herdctl.chat.bind(deps.herdctl);
|
|
672
|
+
// Gap B sink (session mode): one persistent sink groups every background
|
|
673
|
+
// re-invocation onto a single hub turn (skipping sidechain sub-agent
|
|
674
|
+
// steps). Built once so its turn/translator state spans the stream.
|
|
675
|
+
const bgSink = driveMode === "session" ? makeBackgroundTurnSink(slug) : null;
|
|
676
|
+
const result = await drive(agentName, {
|
|
677
|
+
prompt,
|
|
678
|
+
// omit -> agent-level fallback; explicit null -> new chat; id -> resume.
|
|
679
|
+
resume: sessionId ?? null,
|
|
680
|
+
triggerType: "web",
|
|
681
|
+
injectedMcpServers,
|
|
682
|
+
// Gap B: deliver autonomous background-completion turns live (session
|
|
683
|
+
// mode only; batch `chat` ignores this). The human turn holds the
|
|
684
|
+
// session open when it launches background work; the persistent sink
|
|
685
|
+
// renders each later re-invocation onto ONE hub turn (skipping sidechain
|
|
686
|
+
// sub-agent steps).
|
|
687
|
+
onBackgroundMessage: bgSink?.onMessage,
|
|
688
|
+
onBackgroundDone: bgSink?.onDone,
|
|
689
|
+
onJobCreated: (id) => {
|
|
690
|
+
jobId = id;
|
|
691
|
+
turn.setJobId(id);
|
|
692
|
+
},
|
|
693
|
+
onMessage: async (m) => {
|
|
694
|
+
// A FOREGROUND sub-agent streams its nested steps INLINE on this very
|
|
695
|
+
// stream, tagged `parent_tool_use_id`. They belong inside the sub-agent
|
|
696
|
+
// card (served by the subagents endpoint), never as top-level rows of
|
|
697
|
+
// the parent transcript — see {@link isSidechainMessage}. This is the
|
|
698
|
+
// human `chat:send` path, so it is where the duplication was actually
|
|
699
|
+
// seen. Skipping also keeps a sub-agent's context out of the parent's
|
|
700
|
+
// live meter (`foldTurnUsage` below would latch its max — the #398 shape).
|
|
701
|
+
if (isSidechainMessage(m))
|
|
702
|
+
return;
|
|
703
|
+
// Capture the session id as it arrives mid-stream (the translator
|
|
704
|
+
// only surfaces text/boundary/tool events, not routing metadata).
|
|
705
|
+
// Registering it with the hub makes the turn re-attachable by session.
|
|
706
|
+
if (m.session_id) {
|
|
707
|
+
resolvedSession = m.session_id;
|
|
708
|
+
// #390: remember this human turn's injected servers so a wake this
|
|
709
|
+
// chat self-schedules can replay them (the common flap scenario).
|
|
710
|
+
wakeInjection.remember(m.session_id, injectedMcpServers);
|
|
711
|
+
// For a NEW chat, attribute the session to its agent BEFORE the
|
|
712
|
+
// hub broadcasts `chat:active`, so any client refetching its chat
|
|
713
|
+
// list in response is guaranteed to see the now-listed chat — it
|
|
714
|
+
// no longer waits for the turn to complete (issue #100). Awaited
|
|
715
|
+
// once (a quick local job-record write); never fatal to the turn.
|
|
716
|
+
if (isNewChat && !attributed) {
|
|
717
|
+
attributed = true;
|
|
718
|
+
// Non-fatal: on failure the chat simply falls back to appearing
|
|
719
|
+
// once its turn completes (the prior behavior), never breaking
|
|
720
|
+
// the live stream.
|
|
721
|
+
await deps.herdctl
|
|
722
|
+
.attributeRunningSession(m.session_id, agentName)
|
|
723
|
+
.catch(() => undefined);
|
|
724
|
+
// A1 (#261): a human-started chat is the ROOT of any spawn tree —
|
|
725
|
+
// origin human, depth 0. Stamped once at creation; later turns on
|
|
726
|
+
// this chat never change its recorded provenance.
|
|
727
|
+
await deps.runProvenance?.stamp(m.session_id, HUMAN_ROOT).catch(() => undefined);
|
|
728
|
+
}
|
|
729
|
+
turn.setSession(m.session_id);
|
|
730
|
+
}
|
|
731
|
+
// Capture per-turn usage + model defensively. Assistant blocks feed
|
|
732
|
+
// the context snapshot (keeping the largest — issue #165); the terminal
|
|
733
|
+
// `result` block's CUMULATIVE usage is stashed separately so it can't
|
|
734
|
+
// inflate the live context meter (#398).
|
|
735
|
+
foldTurnUsage(seen, m);
|
|
736
|
+
// #329: surface a dead-end BEFORE translating — a synthetic usage-limit
|
|
737
|
+
// message or a terminal error/`error_max_turns` result. The translator
|
|
738
|
+
// silently drops every synthetic message (which is exactly why these
|
|
739
|
+
// turns look dead); `noticeFromMessage` returns null for ordinary output
|
|
740
|
+
// and for suppressed "No response requested." placeholders.
|
|
741
|
+
// #380: track a completed reply so a trailing error/max_turns result
|
|
742
|
+
// (which races in AFTER the reply in session mode) is suppressed below.
|
|
743
|
+
if (messageProducedReply(m))
|
|
744
|
+
producedReply = true;
|
|
745
|
+
const notice = noticeFromMessage(m);
|
|
746
|
+
if (notice)
|
|
747
|
+
emitNotice(notice);
|
|
748
|
+
// #429: recover any Task/Agent launch from this message's tool_use input
|
|
749
|
+
// so the enriched card renders live — before translate fires onToolStart.
|
|
750
|
+
for (const l of extractSubagentLaunches(m))
|
|
751
|
+
subagentLaunches.set(l.toolUseId, l);
|
|
752
|
+
// @herdctl/core's SDKMessage types `message` as `unknown` (wider);
|
|
753
|
+
// @herdctl/chat's translator declares a structurally narrower
|
|
754
|
+
// SDKMessage. Same runtime object — cast across the package boundary.
|
|
755
|
+
// (@herdctl/chat@0.4.1 pairs CLI tool results, so no shim needed.)
|
|
756
|
+
await translate(m);
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
// #404: session-mode turns that produced a real reply routinely end with a
|
|
760
|
+
// trailing `error_*` / `success:false` result frame (the #380/#394 banner
|
|
761
|
+
// asymmetry). Raw `result.success` is reply-unaware, so gating the post-turn
|
|
762
|
+
// side effects below on it silently strands a queued follow-up, skips the
|
|
763
|
+
// sweep, and drops the recovery arm. Trust the same `producedReply` signal
|
|
764
|
+
// the banner already does — a real reply supersedes a benign trailing failure.
|
|
765
|
+
const effectiveSuccess = turnEffectivelySucceeded(result.success, producedReply);
|
|
766
|
+
// Post-turn sweep (issues #2/#6): on a successful USER turn, enqueue a
|
|
767
|
+
// coalesced/debounced curation sweep. Out of band — never blocks or breaks
|
|
768
|
+
// chat, and can't recurse (the sweep uses a separate agent triggered off
|
|
769
|
+
// the user-chat path). Turn success is the ONLY gate: every workspace is
|
|
770
|
+
// swept, the root included (`sweeper-_root`).
|
|
771
|
+
// T5: routed through the `afterTurn` event so the folded-in `curate-overview`
|
|
772
|
+
// trigger is the single dispatch (no double-curation).
|
|
773
|
+
if (effectiveSuccess)
|
|
774
|
+
emitAfterTurn(slug, result.sessionId ?? resolvedSession ?? null);
|
|
775
|
+
// Force a session-list refresh so a brand-new chat surfaces immediately
|
|
776
|
+
// (rather than waiting out the discovery cache). Non-fatal.
|
|
777
|
+
try {
|
|
778
|
+
deps.herdctl.invalidateSessions(agentName);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
/* non-fatal: stale-by-30s at worst */
|
|
782
|
+
}
|
|
783
|
+
// Surface the model + usage so the UI can render the context meter.
|
|
784
|
+
// Omit both if no usage was observed this turn (§7).
|
|
785
|
+
const completeModel = seen.model ?? effectiveModel;
|
|
786
|
+
const seenUsage = resolveTurnUsage(seen);
|
|
787
|
+
const completeUsage = seenUsage
|
|
788
|
+
? {
|
|
789
|
+
inputTokens: seenUsage.inputTokens,
|
|
790
|
+
outputTokens: seenUsage.outputTokens,
|
|
791
|
+
cacheReadTokens: seenUsage.cacheReadTokens,
|
|
792
|
+
cacheCreationTokens: seenUsage.cacheCreationTokens,
|
|
793
|
+
contextTokens: seenUsage.inputTokens +
|
|
794
|
+
seenUsage.cacheReadTokens +
|
|
795
|
+
seenUsage.cacheCreationTokens,
|
|
796
|
+
contextLimit: getContextLimit(completeModel),
|
|
797
|
+
}
|
|
798
|
+
: undefined;
|
|
799
|
+
// Ensure the turn is keyed under its final session id before the terminal
|
|
800
|
+
// frame, so a client re-attaching right at the end gets the completion.
|
|
801
|
+
const finalSession = effectiveSuccess ? (result.sessionId ?? resolvedSession) : resolvedSession;
|
|
802
|
+
if (finalSession)
|
|
803
|
+
turn.setSession(finalSession);
|
|
804
|
+
// #329: a turn that failed WITHOUT a terminal error `result` reaching the
|
|
805
|
+
// stream (a thrown/rejected drive: network reset, process crash) never hit
|
|
806
|
+
// noticeFromMessage. Surface it inline so the failure is visible — before
|
|
807
|
+
// chat:complete flips streaming off (deduped against any notice already sent).
|
|
808
|
+
if (!result.success)
|
|
809
|
+
emitNotice(errorNotice(result.error?.message));
|
|
810
|
+
turn.emit({
|
|
811
|
+
type: "chat:complete",
|
|
812
|
+
payload: {
|
|
813
|
+
...routing(),
|
|
814
|
+
sessionId: finalSession,
|
|
815
|
+
jobId: result.jobId ?? jobId,
|
|
816
|
+
success: result.success,
|
|
817
|
+
error: result.error?.message,
|
|
818
|
+
...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
turn.end();
|
|
822
|
+
// After a SUCCESSFUL turn, auto-send any queued follow-up (#197/#245). A
|
|
823
|
+
// Stop/failed turn holds the queue for the user (no drain). drainQueue owns
|
|
824
|
+
// the take + client notify + next-turn kickoff, shared with the idle path.
|
|
825
|
+
if (effectiveSuccess && finalSession) {
|
|
826
|
+
await drainQueue(slug, finalSession);
|
|
827
|
+
}
|
|
828
|
+
// Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
|
|
829
|
+
// keeper turn. If this turn launched a background task that the runtime kills
|
|
830
|
+
// at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
|
|
831
|
+
// the engine auto-injects the recovery nudge — gated on the resolved
|
|
832
|
+
// `recovery.autoReDrive` (default OFF), a debounce window, and a retry cap.
|
|
833
|
+
if (effectiveSuccess && finalSession && driveMode === "session") {
|
|
834
|
+
recoveryEngine.armWatch({ slug, sessionId: finalSession });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
839
|
+
// The origin socket always gets the plain chat:error (its shape predates
|
|
840
|
+
// the hub and existing clients/tests rely on it). If the turn had already
|
|
841
|
+
// resolved a session, ALSO emit a terminal chat:complete through the hub
|
|
842
|
+
// so a client that re-attached after a mid-turn socket drop stops showing
|
|
843
|
+
// "streaming" instead of hanging with no terminal frame.
|
|
844
|
+
send({ type: "chat:error", payload: { projectSlug: slug, error } });
|
|
845
|
+
if (resolvedSession) {
|
|
846
|
+
// #329: also surface the failure as an inline notice on the hub so a
|
|
847
|
+
// re-attached client renders WHY the turn died (not just the origin
|
|
848
|
+
// socket's chat:error toast).
|
|
849
|
+
emitNotice(errorNotice(error));
|
|
850
|
+
turn.emit({
|
|
851
|
+
type: "chat:complete",
|
|
852
|
+
payload: { ...routing(), success: false, error },
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
turn.end();
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
/**
|
|
859
|
+
* Handle a slash command (`chat:command`) by driving herdctl's streaming
|
|
860
|
+
* session. Output is streamed back over the same events as a normal turn;
|
|
861
|
+
* a `compact_boundary` is surfaced as a synthetic assistant note so the UI
|
|
862
|
+
* shows a visible confirmation, and post-command usage refreshes the meter.
|
|
863
|
+
*/
|
|
864
|
+
const onChatCommand = async (msg) => {
|
|
865
|
+
const slug = msg.payload.projectSlug;
|
|
866
|
+
const { command, sessionId } = msg.payload;
|
|
867
|
+
let resolvedSession = sessionId ?? null;
|
|
868
|
+
const seen = initTurnUsage();
|
|
869
|
+
// Same hub-tracked turn as onChatSend so a slash-command turn also survives
|
|
870
|
+
// a mid-turn socket drop (issue #54). A command always targets an existing
|
|
871
|
+
// session, so it's re-attachable from its first frame.
|
|
872
|
+
const turn = hub.startTurn(slug, socket, sessionId ?? null);
|
|
873
|
+
const routing = () => ({
|
|
874
|
+
projectSlug: slug,
|
|
875
|
+
sessionId: resolvedSession,
|
|
876
|
+
jobId: null,
|
|
877
|
+
});
|
|
878
|
+
// #429: sub-agent launches recovered live from the tool_use input (a command
|
|
879
|
+
// like a custom slash-command can still spawn a Task/Agent), keyed by toolUseId.
|
|
880
|
+
const subagentLaunches = new Map();
|
|
881
|
+
const translate = createSDKMessageHandler({
|
|
882
|
+
onText: (chunk) => {
|
|
883
|
+
if (chunk)
|
|
884
|
+
turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
|
|
885
|
+
},
|
|
886
|
+
onBoundary: () => {
|
|
887
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
888
|
+
},
|
|
889
|
+
onToolStart: (start) => {
|
|
890
|
+
turn.emit({
|
|
891
|
+
type: "chat:tool_start",
|
|
892
|
+
payload: {
|
|
893
|
+
...routing(),
|
|
894
|
+
toolName: start.toolName,
|
|
895
|
+
inputSummary: start.inputSummary,
|
|
896
|
+
toolUseId: start.toolUseId,
|
|
897
|
+
parentToolUseId: start.parentToolUseId,
|
|
898
|
+
...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
},
|
|
902
|
+
onToolCall: (call) => {
|
|
903
|
+
turn.emit({
|
|
904
|
+
type: "chat:tool_call",
|
|
905
|
+
payload: {
|
|
906
|
+
...routing(),
|
|
907
|
+
toolName: call.toolName,
|
|
908
|
+
inputSummary: call.inputSummary,
|
|
909
|
+
output: call.output,
|
|
910
|
+
isError: call.isError,
|
|
911
|
+
durationMs: call.durationMs,
|
|
912
|
+
toolUseId: call.toolUseId,
|
|
913
|
+
...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
try {
|
|
919
|
+
// Commands need an existing chat to act on; the slug resolves to its
|
|
920
|
+
// project's keeper (verifying the project exists).
|
|
921
|
+
await deps.projects.get(slug); // throws if the project is unknown
|
|
922
|
+
const agentName = keeperAgentName(slug);
|
|
923
|
+
const { sessionId: finalSession } = await deps.herdctl.runCommand(agentName, {
|
|
924
|
+
command,
|
|
925
|
+
resume: resolvedSession,
|
|
926
|
+
onMessage: async (m) => {
|
|
927
|
+
// A slash-command turn can spawn a Task too; its nested steps never
|
|
928
|
+
// render top-level ({@link isSidechainMessage}).
|
|
929
|
+
if (isSidechainMessage(m))
|
|
930
|
+
return;
|
|
931
|
+
if (m.session_id) {
|
|
932
|
+
resolvedSession = m.session_id;
|
|
933
|
+
turn.setSession(m.session_id);
|
|
934
|
+
}
|
|
935
|
+
// Keep the largest context snapshot from assistant blocks (issue #165);
|
|
936
|
+
// the terminal `result` block's CUMULATIVE usage is stashed separately
|
|
937
|
+
// so it can't inflate the live context meter (#398).
|
|
938
|
+
foldTurnUsage(seen, m);
|
|
939
|
+
// Surface a compaction as a visible assistant note (the SDK reports
|
|
940
|
+
// it as a system/compact_boundary, which the text translator skips).
|
|
941
|
+
if (m.type === "system" && m.subtype === "compact_boundary") {
|
|
942
|
+
const pre = m.compact_metadata?.pre_tokens;
|
|
943
|
+
const detail = typeof pre === "number" ? ` (was ${pre.toLocaleString()} tokens)` : "";
|
|
944
|
+
turn.emit({
|
|
945
|
+
type: "chat:response",
|
|
946
|
+
payload: { ...routing(), chunk: `🗜️ Context compacted${detail}.` },
|
|
947
|
+
});
|
|
948
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// Surface a client-local command's output (`/context`, `/usage`, …). CC
|
|
952
|
+
// returns it as a `model:"<synthetic>"` assistant message the translator
|
|
953
|
+
// drops as a placeholder (and, on disk, a `system`/`local_command` entry
|
|
954
|
+
// the history parser drops), so re-emit its text as an assistant note
|
|
955
|
+
// (issue #158) instead of the turn reading as a silent no-op. Trivial
|
|
956
|
+
// placeholders (e.g. `/compact`'s "No response requested.") are filtered out.
|
|
957
|
+
const localOut = extractLocalCommandOutput(m);
|
|
958
|
+
if (localOut) {
|
|
959
|
+
turn.emit({ type: "chat:response", payload: { ...routing(), chunk: localOut } });
|
|
960
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// #429: recover any Task/Agent launch before translate fires onToolStart.
|
|
964
|
+
for (const l of extractSubagentLaunches(m))
|
|
965
|
+
subagentLaunches.set(l.toolUseId, l);
|
|
966
|
+
await translate(m);
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
if (finalSession)
|
|
970
|
+
resolvedSession = finalSession;
|
|
971
|
+
// Refresh the session list (a command can change history) — non-fatal.
|
|
972
|
+
try {
|
|
973
|
+
deps.herdctl.invalidateSessions(agentName);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
/* non-fatal */
|
|
977
|
+
}
|
|
978
|
+
const completeModel = seen.model ?? DEFAULT_MODEL;
|
|
979
|
+
const seenUsage = resolveTurnUsage(seen);
|
|
980
|
+
const completeUsage = seenUsage
|
|
981
|
+
? {
|
|
982
|
+
inputTokens: seenUsage.inputTokens,
|
|
983
|
+
outputTokens: seenUsage.outputTokens,
|
|
984
|
+
cacheReadTokens: seenUsage.cacheReadTokens,
|
|
985
|
+
cacheCreationTokens: seenUsage.cacheCreationTokens,
|
|
986
|
+
contextTokens: seenUsage.inputTokens + seenUsage.cacheReadTokens + seenUsage.cacheCreationTokens,
|
|
987
|
+
contextLimit: getContextLimit(completeModel),
|
|
988
|
+
}
|
|
989
|
+
: undefined;
|
|
990
|
+
if (resolvedSession)
|
|
991
|
+
turn.setSession(resolvedSession);
|
|
992
|
+
turn.emit({
|
|
993
|
+
type: "chat:complete",
|
|
994
|
+
payload: {
|
|
995
|
+
...routing(),
|
|
996
|
+
success: true,
|
|
997
|
+
...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
turn.end();
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1004
|
+
send({ type: "chat:error", payload: { projectSlug: slug, error } });
|
|
1005
|
+
if (resolvedSession) {
|
|
1006
|
+
turn.emit({ type: "chat:complete", payload: { ...routing(), success: false, error } });
|
|
1007
|
+
}
|
|
1008
|
+
turn.end();
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
};
|
|
1012
|
+
// The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
|
|
1013
|
+
// action fires a schedule-type trigger on demand through the exact same hub path a
|
|
1014
|
+
// cron fire uses, so the resulting chat is indistinguishable from a scheduled one.
|
|
1015
|
+
//
|
|
1016
|
+
// `managementOpsContext` (#312 M1) completes the hoist: it is the SAME shared
|
|
1017
|
+
// context the in-process self-MCP is built from (deps + hub + startAgentTurn +
|
|
1018
|
+
// composePreloadedPrompt + fireTrigger), handed out so a NON-socket caller can
|
|
1019
|
+
// build the management ops for itself — the `/mcp` transport in M2, and the
|
|
1020
|
+
// turn-spawning REST endpoints in #465. Exposing the CONTEXT rather than a
|
|
1021
|
+
// pre-built ops bag is deliberate: ops are built PER CALLER, because they close
|
|
1022
|
+
// over that caller's project/session/provenance and its policy principal.
|
|
1023
|
+
return { handle, fireTrigger, managementOpsContext: selfMcpCtx };
|
|
1024
|
+
}
|