@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,751 @@
|
|
|
1
|
+
import { createSDKMessageHandler } from "@herdctl/chat";
|
|
2
|
+
import { keeperAgentName, keeperSlugFromAgent } from "./herdctl.js";
|
|
3
|
+
import { isKnownDriveMode, getContextLimit } from "./models.js";
|
|
4
|
+
import { buildInjectedMcpServers, createWakeInjectionCache, } from "./wake-injection.js";
|
|
5
|
+
import { resolveMaxSpawnDepth } from "./spawn-capability.js";
|
|
6
|
+
import { RecoveryEngine } from "./recovery.js";
|
|
7
|
+
import { extractSubagentLaunches, subagentLaunchFields } from "./subagents.js";
|
|
8
|
+
import { noticeFromMessage, errorNotice, messageProducedReply, suppressNoticeAfterReply, turnEffectivelySucceeded, } from "./turn-notice.js";
|
|
9
|
+
import { buildSelfMcpServerDef } from "./ws-self-mcp.js";
|
|
10
|
+
import { makeTriggerCluster } from "./ws-triggers.js";
|
|
11
|
+
import { initTurnUsage, foldTurnUsage, resolveTurnUsage, } from "./ws-protocol.js";
|
|
12
|
+
/**
|
|
13
|
+
* The recovery nudge injected by a manual Continue (issue #301, Layer 2) — and,
|
|
14
|
+
* later, by Layer 3 auto re-drive. It tells the keeper the truth (its background
|
|
15
|
+
* task was KILLED AT THE TURN BOUNDARY, not "stopped by the user" — cf #216) so it
|
|
16
|
+
* reacts sensibly: re-run the work in the FOREGROUND this turn, or report what
|
|
17
|
+
* happened. Kept terse; the killed `<task-notification>` is already in its context.
|
|
18
|
+
*/
|
|
19
|
+
export const RECOVERY_NUDGE = "[Paddock recovery] Your previous turn ended while a background task was still " +
|
|
20
|
+
"running, and that task was then KILLED at the turn boundary by the runtime — " +
|
|
21
|
+
"this is a known limitation (see herdctl#374), NOT a user cancellation. Nothing " +
|
|
22
|
+
"is running now. Please pick up where you left off: if you still need that work, " +
|
|
23
|
+
"re-run it in the FOREGROUND this turn (do not background it), otherwise summarise " +
|
|
24
|
+
"what happened and continue.";
|
|
25
|
+
/**
|
|
26
|
+
* Whether an SDK message is a sub-agent (sidechain) step — `parent_tool_use_id`
|
|
27
|
+
* points at the spawning `Task` tool_use. Present on the top-level SDK message
|
|
28
|
+
* and/or its nested `message` (assistant/user shapes differ). Sidechain steps are
|
|
29
|
+
* the sub-agent's own nested work, surfaced only via the subagents endpoint on
|
|
30
|
+
* card-expand — so no live stream renders them top-level, or the sub-agent's work
|
|
31
|
+
* scatters into phantom rows of the parent transcript.
|
|
32
|
+
*
|
|
33
|
+
* EVERY live turn path must call this. There are FIVE, each with its own
|
|
34
|
+
* `createSDKMessageHandler` translator, and for a long time only the first
|
|
35
|
+
* filtered:
|
|
36
|
+
* 1. {@link makeBackgroundTurnSink} — a backgrounded `Task`
|
|
37
|
+
* 2. this module's `startAgentTurn` onMessage — triggers / spawns / recovery
|
|
38
|
+
* 3. `ws.ts` onWakeMessage — a scheduled wake turn
|
|
39
|
+
* 4. `ws.ts` `chat:send` onMessage — a human turn (where it was seen)
|
|
40
|
+
* 5. `ws.ts` slash-command onMessage — `/foo` turns
|
|
41
|
+
*
|
|
42
|
+
* The gap survived because of a false premise recorded on {@link
|
|
43
|
+
* makeBackgroundTurnSink}: that only a BACKGROUND Task streams sidechain steps
|
|
44
|
+
* inline, a foreground one being routed to a separate session. It is not — under
|
|
45
|
+
* SDK streaming mode a foreground `Task` streams its steps inline on whichever
|
|
46
|
+
* turn stream launched it, so any unfiltered path duplicates them.
|
|
47
|
+
*
|
|
48
|
+
* Skipping is safe for session bookkeeping: a sidechain message carries the SAME
|
|
49
|
+
* `session_id` as the main chat (verified against on-disk transcripts), and the
|
|
50
|
+
* launching `tool_use` is itself main-lane, so the id always resolves first.
|
|
51
|
+
*/
|
|
52
|
+
export function isSidechainMessage(m) {
|
|
53
|
+
const anym = m;
|
|
54
|
+
return Boolean(anym.parent_tool_use_id ?? anym.message?.parent_tool_use_id);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the turn-execution engine bound to the handler's deps + shared hub, wiring
|
|
58
|
+
* the herdctl schedule/wake resolvers and the trigger event listeners.
|
|
59
|
+
*/
|
|
60
|
+
export function makeTurnEngine(engine) {
|
|
61
|
+
const { deps, hub } = engine;
|
|
62
|
+
// Trigger / schedule / event firing (ws-triggers.ts, #403). Built here so it can
|
|
63
|
+
// close over the shared startAgentTurn engine (a hoisted declaration below) and
|
|
64
|
+
// wire its herdctl schedule handler + onArchive/afterTurn listeners. ws.ts consumes
|
|
65
|
+
// emitAfterTurn / composePreloadedPrompt / fireTrigger from it.
|
|
66
|
+
const triggers = makeTriggerCluster(deps, startAgentTurn);
|
|
67
|
+
// The self-management MCP context (deps + shared closures), handed to the
|
|
68
|
+
// extracted buildSelfMcpServerDef (ws-self-mcp.ts, #403) for both the live
|
|
69
|
+
// injection path and the onChatCommand path. startAgentTurn / composePreloadedPrompt
|
|
70
|
+
// / fireTrigger are hoisted function declarations, so this can be assembled here.
|
|
71
|
+
const selfMcpCtx = {
|
|
72
|
+
deps,
|
|
73
|
+
hub,
|
|
74
|
+
startAgentTurn,
|
|
75
|
+
composePreloadedPrompt: triggers.composePreloadedPrompt,
|
|
76
|
+
fireTrigger: triggers.fireTrigger,
|
|
77
|
+
};
|
|
78
|
+
// ── Wake-time injected-MCP re-establishment (edspencer/herdctl#390) ──────────
|
|
79
|
+
// The single injection-policy context, shared by the live `startAgentTurn` path
|
|
80
|
+
// (below) AND the wake rebuild, so the two can never drift. See wake-injection.ts.
|
|
81
|
+
const injectionBuildCtx = {
|
|
82
|
+
cfg: deps.cfg,
|
|
83
|
+
saveAttachment: (bytes, name) => deps.attachments.save(bytes, name),
|
|
84
|
+
// Optional, mirroring the optional `runProvenance` dep: absent ⇒ the caller's
|
|
85
|
+
// `depth` is used unchanged (identical to the pre-extraction inline behaviour).
|
|
86
|
+
getProvenance: deps.runProvenance ? (id) => deps.runProvenance.get(id) : undefined,
|
|
87
|
+
getProjectHooksMcp: async (slug) => {
|
|
88
|
+
const tp = await deps.projects.get(slug).catch(() => null);
|
|
89
|
+
return tp?.hooksMcpEnabled;
|
|
90
|
+
},
|
|
91
|
+
buildSelfMcp: (p) => buildSelfMcpServerDef(selfMcpCtx, p),
|
|
92
|
+
};
|
|
93
|
+
/** Build one turn's injected servers via the shared policy (see wake-injection.ts). */
|
|
94
|
+
function buildInjection(args) {
|
|
95
|
+
return buildInjectedMcpServers(args, injectionBuildCtx);
|
|
96
|
+
}
|
|
97
|
+
// Rebuild a woken session's injection from scratch (cold-cache warm after a server
|
|
98
|
+
// restart, when the live-turn cache is empty). Resolves the project (a non-keeper
|
|
99
|
+
// agent or an unknown project ⇒ no injection) then delegates to the shared builder;
|
|
100
|
+
// the resume gates self-MCP on the chat's OWN recorded depth. Never throws (the
|
|
101
|
+
// cache also catches defensively).
|
|
102
|
+
const rebuildWakeInjection = async (entry) => {
|
|
103
|
+
const slug = keeperSlugFromAgent(entry.agent);
|
|
104
|
+
// `null` = not a keeper agent. `""` = the ROOT workspace, which is a perfectly
|
|
105
|
+
// good key — a falsy check here would drop every root chat.
|
|
106
|
+
if (slug === null)
|
|
107
|
+
return undefined;
|
|
108
|
+
let project;
|
|
109
|
+
try {
|
|
110
|
+
project = await deps.projects.get(slug);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined; // unknown/deleted project — nothing to inject
|
|
114
|
+
}
|
|
115
|
+
return buildInjection({
|
|
116
|
+
projectSlug: slug,
|
|
117
|
+
workingDir: project.workingDir,
|
|
118
|
+
resume: entry.sessionId,
|
|
119
|
+
// `origin` only feeds child provenance via `childOf` (which forces "spawned"),
|
|
120
|
+
// so its value is behaviourally irrelevant here; "scheduled" matches a wake root.
|
|
121
|
+
origin: "scheduled",
|
|
122
|
+
depth: 0,
|
|
123
|
+
maxSpawnDepth: resolveMaxSpawnDepth(project.maxSpawnDepth, deps.cfg.maxSpawnDepth),
|
|
124
|
+
currentSessionId: () => entry.sessionId,
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
// The cache/resolver. `wakeInjection.remember` is called on every live turn (the
|
|
128
|
+
// human socket path and `startAgentTurn`), so a session that self-schedules a wake
|
|
129
|
+
// is warm when it fires. herdctl calls `wakeInjection.resolve` synchronously on each
|
|
130
|
+
// wake fire — paired with `onSessionWake` above (which streams the woken turn), this
|
|
131
|
+
// re-establishes the injected MCP servers the woken subprocess would otherwise spawn
|
|
132
|
+
// WITHOUT, closing the "MCP flap". Registered here (rather than at the onSessionWake
|
|
133
|
+
// call site) so it sits beside the builder + cache it depends on.
|
|
134
|
+
const wakeInjection = createWakeInjectionCache({ rebuild: rebuildWakeInjection });
|
|
135
|
+
deps.herdctl.setResolveInjectedMcpServers((entry) => wakeInjection.resolve(entry));
|
|
136
|
+
// ── Gap B: live delivery of autonomous background-completion turns ───────────
|
|
137
|
+
// A session-mode turn that launches continuous background work is held open by
|
|
138
|
+
// the reaper (decideReap keepAlives while backgroundTasks > 0). When a task
|
|
139
|
+
// completes, the SDK hands the parent the result as a fresh autonomous
|
|
140
|
+
// re-invocation turn on the SAME session stream — AFTER the primary turn. herdctl
|
|
141
|
+
// `chatSession` keeps consuming that stream (consumeBackgroundTurns) and forwards
|
|
142
|
+
// each subsequent message to this sink, which renders it onto the live hub — the
|
|
143
|
+
// same delivery the scheduler-wake path uses (ws.ts onSessionWake) — so it appears
|
|
144
|
+
// without a refresh.
|
|
145
|
+
//
|
|
146
|
+
// Grouping (the sub-agent fix): a background *sub-agent* (`Task` run_in_background)
|
|
147
|
+
// streams for minutes and its nested `isSidechain` steps arrive INLINE on this
|
|
148
|
+
// re-invocation stream. So we:
|
|
149
|
+
//
|
|
150
|
+
// CORRECTION: this comment used to claim a foreground synchronous Task was
|
|
151
|
+
// different — that herdctl routed its nested steps to "a SEPARATE sidechain
|
|
152
|
+
// session, never the main turn stream". That is FALSE under SDK streaming mode,
|
|
153
|
+
// and the belief is exactly why only this sink filtered sidechain steps: a
|
|
154
|
+
// foreground Task duplicated all of its steps into the parent transcript live.
|
|
155
|
+
// The primary handler now filters too (see its `isSidechainMessage` guard).
|
|
156
|
+
// 1. SKIP sidechain messages (`parent_tool_use_id` set) from RENDERING — matching
|
|
157
|
+
// the foreground/history path, which never draws them top-level (they surface
|
|
158
|
+
// only via the subagents endpoint on card-expand). We still CONSUME them
|
|
159
|
+
// (consumeBackgroundTurns forwards everything to keep the stream + reaper
|
|
160
|
+
// lifecycle signals advancing) — we just don't render them.
|
|
161
|
+
// 2. Use ONE persistent TurnHandle + ONE translator for the WHOLE background
|
|
162
|
+
// stream (not a fresh one per `result`), so a Task `tool_use`↔`tool_result`
|
|
163
|
+
// pair through the translator's persistent `pendingToolUses` into ONE
|
|
164
|
+
// reconciled card, and successive re-invocations render as boundary-separated
|
|
165
|
+
// bubbles under one turn group — instead of scattering into phantom untitled
|
|
166
|
+
// "Agent" cards. The turn is finalized once, when the stream ends ({@link
|
|
167
|
+
// onDone}), so the streaming indicator clears.
|
|
168
|
+
const makeBackgroundTurnSink = (projectSlug) => {
|
|
169
|
+
let turn = null;
|
|
170
|
+
let translate = null;
|
|
171
|
+
let resolvedSession = null;
|
|
172
|
+
let producedReply = false;
|
|
173
|
+
let noticeEmitted = false;
|
|
174
|
+
let sawError = false;
|
|
175
|
+
// #429: sub-agent launches recovered live from the tool_use input, keyed by
|
|
176
|
+
// toolUseId, so the enriched card renders without a refresh (see subagentLaunchFields).
|
|
177
|
+
const launches = new Map();
|
|
178
|
+
const routing = () => ({
|
|
179
|
+
projectSlug,
|
|
180
|
+
sessionId: resolvedSession,
|
|
181
|
+
jobId: turn?.jobId ?? null,
|
|
182
|
+
});
|
|
183
|
+
const emitNotice = (notice) => {
|
|
184
|
+
if (!turn || noticeEmitted)
|
|
185
|
+
return;
|
|
186
|
+
if (suppressNoticeAfterReply(notice, producedReply))
|
|
187
|
+
return;
|
|
188
|
+
noticeEmitted = true;
|
|
189
|
+
turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
|
|
190
|
+
};
|
|
191
|
+
// Lazily open the single hub turn on the first RENDERED (main-agent) message, so
|
|
192
|
+
// a background stretch that is nothing but sub-agent sidechain steps opens no
|
|
193
|
+
// empty turn.
|
|
194
|
+
const ensureTurn = (sid) => {
|
|
195
|
+
if (turn)
|
|
196
|
+
return;
|
|
197
|
+
resolvedSession = sid;
|
|
198
|
+
turn = hub.startTurn(projectSlug, null, sid);
|
|
199
|
+
const t = turn;
|
|
200
|
+
translate = createSDKMessageHandler({
|
|
201
|
+
onText: (chunk) => {
|
|
202
|
+
if (chunk)
|
|
203
|
+
t.emit({ type: "chat:response", payload: { ...routing(), chunk } });
|
|
204
|
+
},
|
|
205
|
+
onBoundary: () => {
|
|
206
|
+
t.emit({ type: "chat:message_boundary", payload: routing() });
|
|
207
|
+
},
|
|
208
|
+
onToolStart: (start) => {
|
|
209
|
+
t.emit({
|
|
210
|
+
type: "chat:tool_start",
|
|
211
|
+
payload: {
|
|
212
|
+
...routing(),
|
|
213
|
+
toolName: start.toolName,
|
|
214
|
+
inputSummary: start.inputSummary,
|
|
215
|
+
toolUseId: start.toolUseId,
|
|
216
|
+
parentToolUseId: start.parentToolUseId,
|
|
217
|
+
...subagentLaunchFields(launches, start.toolName, start.toolUseId),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
onToolCall: (call) => {
|
|
222
|
+
t.emit({
|
|
223
|
+
type: "chat:tool_call",
|
|
224
|
+
payload: {
|
|
225
|
+
...routing(),
|
|
226
|
+
toolName: call.toolName,
|
|
227
|
+
inputSummary: call.inputSummary,
|
|
228
|
+
output: call.output,
|
|
229
|
+
isError: call.isError,
|
|
230
|
+
durationMs: call.durationMs,
|
|
231
|
+
toolUseId: call.toolUseId,
|
|
232
|
+
...subagentLaunchFields(launches, call.toolName, call.toolUseId),
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
const onMessage = async (m) => {
|
|
239
|
+
// (1) Skip sidechain sub-agent nested steps from rendering (see header). The
|
|
240
|
+
// attribution lives on the top-level SDK message OR its nested `message`.
|
|
241
|
+
if (isSidechainMessage(m))
|
|
242
|
+
return;
|
|
243
|
+
if (m.session_id)
|
|
244
|
+
resolvedSession = m.session_id;
|
|
245
|
+
// #429: recover any Task/Agent launch from this (main-agent) message's tool_use
|
|
246
|
+
// input so the enriched, expandable card renders live (before translate fires).
|
|
247
|
+
for (const l of extractSubagentLaunches(m))
|
|
248
|
+
launches.set(l.toolUseId, l);
|
|
249
|
+
// (2) One persistent turn+translator for the whole stream — never reset per
|
|
250
|
+
// `result`, so tool_use↔tool_result pairing and card reconciliation survive
|
|
251
|
+
// across re-invocation boundaries.
|
|
252
|
+
ensureTurn(m.session_id ?? resolvedSession);
|
|
253
|
+
if (m.session_id)
|
|
254
|
+
turn.setSession(m.session_id);
|
|
255
|
+
if (messageProducedReply(m))
|
|
256
|
+
producedReply = true;
|
|
257
|
+
const notice = noticeFromMessage(m);
|
|
258
|
+
if (notice)
|
|
259
|
+
emitNotice(notice);
|
|
260
|
+
if (m.type === "result" &&
|
|
261
|
+
((typeof m.subtype === "string" && m.subtype.startsWith("error")) || m.success === false)) {
|
|
262
|
+
sawError = true;
|
|
263
|
+
}
|
|
264
|
+
await translate(m);
|
|
265
|
+
};
|
|
266
|
+
// Finalize the single turn once the background stream ends (reaper reap). No-op
|
|
267
|
+
// if nothing was ever rendered (a sidechain-only stretch opened no turn).
|
|
268
|
+
const onDone = () => {
|
|
269
|
+
if (!turn)
|
|
270
|
+
return;
|
|
271
|
+
turn.emit({
|
|
272
|
+
type: "chat:complete",
|
|
273
|
+
payload: { ...routing(), sessionId: resolvedSession, success: !sawError },
|
|
274
|
+
});
|
|
275
|
+
turn.end();
|
|
276
|
+
turn = null;
|
|
277
|
+
translate = null;
|
|
278
|
+
};
|
|
279
|
+
return { onMessage, onDone };
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Kick off a keeper turn that is NOT driven by a socket — used by the
|
|
283
|
+
* self-management MCP write tools (issue #214 Phase 2: create_chat / fork_chat /
|
|
284
|
+
* send_message / fork_chat_batch fan-out). Routes the turn through the SAME
|
|
285
|
+
* shared {@link hub} as socket-driven turns, so a spawned chat streams live to
|
|
286
|
+
* any client viewing it, flips the sidebar running indicator (hub.onActive
|
|
287
|
+
* broadcast), and is re-attachable — full parity with a human-started turn,
|
|
288
|
+
* just with no originating socket (origin `null`).
|
|
289
|
+
*
|
|
290
|
+
* Returns a promise that resolves with the chat's sessionId AS SOON AS it is
|
|
291
|
+
* known (immediately for a resumed/forked id; on the first streamed session_id
|
|
292
|
+
* for a brand-new chat), while the turn itself runs to completion in the
|
|
293
|
+
* BACKGROUND (detached). So a fan-out can fire N of these and collect N ids
|
|
294
|
+
* without waiting for any child turn to finish; herdctl's own max-concurrency
|
|
295
|
+
* throttles how many actually run at once. Rejects if the turn errors (or a
|
|
296
|
+
* timeout elapses) before an id is known.
|
|
297
|
+
*
|
|
298
|
+
* The spawned turn always gets `send_file`, and — NEW in B1 (#262) — it ALSO
|
|
299
|
+
* gets the self-management MCP (including the WRITE tools, so `send_message`
|
|
300
|
+
* exists and a child can finally report back to its parent) when its depth is
|
|
301
|
+
* within `maxSpawnDepth`. The fork-bomb bound is now EXPLICIT: a turn running in
|
|
302
|
+
* a chat at depth `d` gets the tools iff `d <= maxSpawnDepth` (see
|
|
303
|
+
* spawn-capability.ts). `maxSpawnDepth = 0` reproduces the old behaviour exactly
|
|
304
|
+
* (send_file only). Every child spawned by a tool-equipped turn is stamped one
|
|
305
|
+
* hop deeper, so the bound descends and the tree can't run away. A resume runs
|
|
306
|
+
* in an EXISTING chat, so its capability is gated on THAT chat's own recorded
|
|
307
|
+
* depth (from {@link RunProvenanceStore}), not on the caller's describe-the-run
|
|
308
|
+
* `depth`. A human who later opens a spawned chat still gets the full tools via
|
|
309
|
+
* the regular socket path (any keeper chat may use them) — unchanged.
|
|
310
|
+
*/
|
|
311
|
+
async function startAgentTurn(opts) {
|
|
312
|
+
const { projectSlug, agentName, workingDir, resume, prompt, driveMode, fallbackModel, origin, depth, parent, maxSpawnDepth, sender } = opts;
|
|
313
|
+
let resolvedSession = resume ?? null;
|
|
314
|
+
let jobId = null;
|
|
315
|
+
let attributed = false;
|
|
316
|
+
const isNewChat = resume === null;
|
|
317
|
+
const turn = hub.startTurn(projectSlug, null, resume ?? null);
|
|
318
|
+
const seen = initTurnUsage();
|
|
319
|
+
// #429: sub-agent launches recovered live from the tool_use input (keyed by
|
|
320
|
+
// toolUseId), so the launching card shows the real type/title + is expandable
|
|
321
|
+
// without a refresh. Populated in onMessage before each translate; read by the
|
|
322
|
+
// onToolStart/onToolCall closures below via subagentLaunchFields.
|
|
323
|
+
const subagentLaunches = new Map();
|
|
324
|
+
// #329: whether this turn already surfaced a dead-end notice (usage limit /
|
|
325
|
+
// max-turns / error). At most one per turn — a synthetic session-limit turn
|
|
326
|
+
// repeats the message several times, and a failed turn can carry both an error
|
|
327
|
+
// `result` and a `success:false` completion. Emit the first, suppress the rest.
|
|
328
|
+
let noticeEmitted = false;
|
|
329
|
+
// #380: whether a COMPLETE assistant reply already streamed this turn. In
|
|
330
|
+
// session mode the SDK's terminal `result` (streamed live, never persisted)
|
|
331
|
+
// can carry an `error_*` subtype / `success:false` AFTER a good `end_turn`
|
|
332
|
+
// reply, painting a false "turn failed" banner beneath the answer. Once a
|
|
333
|
+
// reply is seen we suppress the error/max_turns dead-end — the same guard the
|
|
334
|
+
// history path (`scanTranscriptNotice`) already applies on reload. A
|
|
335
|
+
// `usage_limit` dead-end still surfaces (a session-limit stop is real).
|
|
336
|
+
let producedReply = false;
|
|
337
|
+
const emitNotice = (notice) => {
|
|
338
|
+
if (noticeEmitted)
|
|
339
|
+
return;
|
|
340
|
+
if (suppressNoticeAfterReply(notice, producedReply))
|
|
341
|
+
return;
|
|
342
|
+
noticeEmitted = true;
|
|
343
|
+
turn.emit({ type: "chat:notice", payload: { ...routing(), notice } });
|
|
344
|
+
};
|
|
345
|
+
const routing = () => ({
|
|
346
|
+
projectSlug,
|
|
347
|
+
sessionId: resolvedSession,
|
|
348
|
+
jobId,
|
|
349
|
+
});
|
|
350
|
+
// Per-message provenance (issue #290). Once the TARGET session id is known,
|
|
351
|
+
// record the injected prompt + its sender so a later transcript load can
|
|
352
|
+
// attribute the machine-added user turn (see message-provenance.ts). For an
|
|
353
|
+
// injection into an EXISTING chat we ALSO emit a `chat:injected` frame so a
|
|
354
|
+
// client currently viewing the recipient renders the injected user bubble live
|
|
355
|
+
// (Part 2) — a fresh/new chat has no established viewer, and opening it later
|
|
356
|
+
// hydrates the labelled turn from history, so we skip the live emit there to
|
|
357
|
+
// avoid a replay double-add. Fires at most once per turn.
|
|
358
|
+
let injectionHandled = false;
|
|
359
|
+
const handleInjection = (id) => {
|
|
360
|
+
if (injectionHandled || !sender)
|
|
361
|
+
return;
|
|
362
|
+
injectionHandled = true;
|
|
363
|
+
void deps.messageProvenance?.record(id, sender, prompt).catch(() => undefined);
|
|
364
|
+
if (resume !== null) {
|
|
365
|
+
turn.emit({
|
|
366
|
+
type: "chat:injected",
|
|
367
|
+
payload: {
|
|
368
|
+
projectSlug,
|
|
369
|
+
sessionId: id,
|
|
370
|
+
jobId,
|
|
371
|
+
sender,
|
|
372
|
+
content: prompt,
|
|
373
|
+
timestamp: new Date().toISOString(),
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
// A resume already knows its target session id — stamp/emit up front so the
|
|
379
|
+
// injected bubble precedes the assistant's streamed reply.
|
|
380
|
+
if (resume !== null)
|
|
381
|
+
handleInjection(resume);
|
|
382
|
+
const translate = createSDKMessageHandler({
|
|
383
|
+
onText: (chunk) => {
|
|
384
|
+
if (chunk)
|
|
385
|
+
turn.emit({ type: "chat:response", payload: { ...routing(), chunk } });
|
|
386
|
+
},
|
|
387
|
+
onBoundary: () => {
|
|
388
|
+
turn.emit({ type: "chat:message_boundary", payload: routing() });
|
|
389
|
+
},
|
|
390
|
+
onToolStart: (start) => {
|
|
391
|
+
turn.emit({
|
|
392
|
+
type: "chat:tool_start",
|
|
393
|
+
payload: {
|
|
394
|
+
...routing(),
|
|
395
|
+
toolName: start.toolName,
|
|
396
|
+
inputSummary: start.inputSummary,
|
|
397
|
+
toolUseId: start.toolUseId,
|
|
398
|
+
parentToolUseId: start.parentToolUseId,
|
|
399
|
+
...subagentLaunchFields(subagentLaunches, start.toolName, start.toolUseId),
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
onToolCall: (call) => {
|
|
404
|
+
turn.emit({
|
|
405
|
+
type: "chat:tool_call",
|
|
406
|
+
payload: {
|
|
407
|
+
...routing(),
|
|
408
|
+
toolName: call.toolName,
|
|
409
|
+
inputSummary: call.inputSummary,
|
|
410
|
+
output: call.output,
|
|
411
|
+
isError: call.isError,
|
|
412
|
+
durationMs: call.durationMs,
|
|
413
|
+
toolUseId: call.toolUseId,
|
|
414
|
+
...subagentLaunchFields(subagentLaunches, call.toolName, call.toolUseId),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
// Build this turn's injected MCP servers via the shared policy (send_file always;
|
|
420
|
+
// depth-gated self-MCP, #262/DD-3). Extracted so the wake resolver (#390) rebuilds
|
|
421
|
+
// the IDENTICAL set — see wake-injection.ts. A RESUME gates self-MCP on the chat's
|
|
422
|
+
// OWN recorded depth (resolved inside the builder); `currentSessionId` is late-bound
|
|
423
|
+
// to `resolvedSession` so the self-MCP write tools attribute against the live id.
|
|
424
|
+
const injectedMcpServers = await buildInjection({
|
|
425
|
+
projectSlug,
|
|
426
|
+
workingDir,
|
|
427
|
+
resume,
|
|
428
|
+
origin,
|
|
429
|
+
depth,
|
|
430
|
+
maxSpawnDepth,
|
|
431
|
+
currentSessionId: () => resolvedSession,
|
|
432
|
+
});
|
|
433
|
+
const drive = driveMode === "session"
|
|
434
|
+
? deps.herdctl.chatSession.bind(deps.herdctl)
|
|
435
|
+
: deps.herdctl.chat.bind(deps.herdctl);
|
|
436
|
+
// Gap B sink (session mode): one persistent sink renders every
|
|
437
|
+
// background-completion re-invocation onto a single hub turn (skipping sidechain
|
|
438
|
+
// sub-agent steps). Built once so its state (turn/translator) spans the stream.
|
|
439
|
+
const bgSink = driveMode === "session" ? makeBackgroundTurnSink(projectSlug) : null;
|
|
440
|
+
// Resolve the sessionId early; the caller returns it while the turn continues.
|
|
441
|
+
let resolveId;
|
|
442
|
+
let rejectId;
|
|
443
|
+
const idKnown = new Promise((res, rej) => {
|
|
444
|
+
resolveId = res;
|
|
445
|
+
rejectId = rej;
|
|
446
|
+
});
|
|
447
|
+
if (resume)
|
|
448
|
+
resolveId(resume);
|
|
449
|
+
const drivePromise = drive(agentName, {
|
|
450
|
+
prompt,
|
|
451
|
+
// herdctl's TriggerTypeSchema is a strict enum (manual|schedule|webhook|
|
|
452
|
+
// chat|discord|slack|web|fork); "agent" is NOT a member, so it fails job
|
|
453
|
+
// validation and the whole spawn errors out — which is why a spawned child
|
|
454
|
+
// could never be created against this core version. Use "manual" (the
|
|
455
|
+
// documented API/CLI-initiated value) until herdctl adds a first-class
|
|
456
|
+
// `spawned` trigger type (DD-6 / herdctl#377); provenance is carried by
|
|
457
|
+
// RunProvenanceStore (origin/depth), not by this field.
|
|
458
|
+
triggerType: "manual",
|
|
459
|
+
resume,
|
|
460
|
+
injectedMcpServers,
|
|
461
|
+
// Gap B: deliver autonomous background-completion turns live (session mode
|
|
462
|
+
// only; batch `chat` ignores this). See makeBackgroundTurnSink above.
|
|
463
|
+
onBackgroundMessage: bgSink?.onMessage,
|
|
464
|
+
onBackgroundDone: bgSink?.onDone,
|
|
465
|
+
onJobCreated: (id) => {
|
|
466
|
+
jobId = id;
|
|
467
|
+
turn.setJobId(id);
|
|
468
|
+
},
|
|
469
|
+
onMessage: async (m) => {
|
|
470
|
+
// A FOREGROUND sub-agent's nested steps arrive INLINE on this primary turn
|
|
471
|
+
// stream (SDK streaming mode), tagged with `parent_tool_use_id`. Rendering
|
|
472
|
+
// them here scattered every step of a `Task`/`Agent` run into top-level rows
|
|
473
|
+
// of the PARENT transcript — duplicating the very steps the sub-agent card
|
|
474
|
+
// already shows on expand, and polluting the live context meter (a sidechain
|
|
475
|
+
// assistant block's `contextTokens` is the SUB-AGENT's window, which
|
|
476
|
+
// `foldTurnUsage`'s max-snapshot would latch onto — the #398 failure mode).
|
|
477
|
+
//
|
|
478
|
+
// Both were live-only: a reload re-derives from history, which has always
|
|
479
|
+
// filtered sidechain steps, so the transcript "healed" on refresh and the
|
|
480
|
+
// duplication read as a streaming glitch.
|
|
481
|
+
//
|
|
482
|
+
// Skipping is safe for session bookkeeping: a sidechain message carries the
|
|
483
|
+
// SAME `session_id` as the main chat (verified against on-disk transcripts),
|
|
484
|
+
// and the launching `tool_use` message is itself main-stream, so it always
|
|
485
|
+
// resolves the id before any sidechain step arrives.
|
|
486
|
+
if (isSidechainMessage(m))
|
|
487
|
+
return;
|
|
488
|
+
if (m.session_id) {
|
|
489
|
+
resolvedSession = m.session_id;
|
|
490
|
+
// #390: remember this turn's injected servers so a later wake of this
|
|
491
|
+
// session replays them (closes the flap on the common self-schedule case).
|
|
492
|
+
wakeInjection.remember(m.session_id, injectedMcpServers);
|
|
493
|
+
if (isNewChat && !attributed) {
|
|
494
|
+
attributed = true;
|
|
495
|
+
await deps.herdctl.attributeRunningSession(m.session_id, agentName).catch(() => undefined);
|
|
496
|
+
// A1 (#261): stamp the NEW chat's provenance (e.g. create_chat →
|
|
497
|
+
// spawned, depth = parent+1) so #262 can depth-gate and #267 can
|
|
498
|
+
// badge it. Only a new chat is stamped here; a resume/message target
|
|
499
|
+
// (fork kickoff, send_message) keeps its own creation provenance.
|
|
500
|
+
//
|
|
501
|
+
// #509: carry the creating chat through too. This used to rebuild the
|
|
502
|
+
// marker from `origin`/`depth` alone, silently dropping the parent on
|
|
503
|
+
// the create_chat path — the dominant way children are made — so the
|
|
504
|
+
// chat tree had to infer every edge from the kickoff sender instead.
|
|
505
|
+
await deps.runProvenance
|
|
506
|
+
?.stamp(m.session_id, {
|
|
507
|
+
origin,
|
|
508
|
+
depth,
|
|
509
|
+
...(parent ? { parentSessionId: parent.sessionId, parentProject: parent.project } : {}),
|
|
510
|
+
})
|
|
511
|
+
.catch(() => undefined);
|
|
512
|
+
// #290: record the kickoff's sender for the NEW chat now that its id
|
|
513
|
+
// is known (no live emit — see handleInjection; the labelled turn
|
|
514
|
+
// hydrates from history when the chat is opened).
|
|
515
|
+
handleInjection(m.session_id);
|
|
516
|
+
}
|
|
517
|
+
turn.setSession(m.session_id);
|
|
518
|
+
resolveId(m.session_id);
|
|
519
|
+
}
|
|
520
|
+
// Capture per-turn usage: assistant blocks feed the context snapshot; the
|
|
521
|
+
// terminal `result` block's CUMULATIVE usage is kept apart so it can't
|
|
522
|
+
// inflate the live context meter (#398).
|
|
523
|
+
foldTurnUsage(seen, m);
|
|
524
|
+
// #329: surface a dead-end BEFORE translating — a synthetic usage-limit
|
|
525
|
+
// message or a terminal error/`error_max_turns` result. The translator
|
|
526
|
+
// drops every synthetic message (so nothing would ever render), which is
|
|
527
|
+
// exactly why these turns look dead. `noticeFromMessage` returns null for
|
|
528
|
+
// ordinary output and for suppressed "No response requested." placeholders.
|
|
529
|
+
// #380/#394: track whether this turn showed the user real prose so a
|
|
530
|
+
// trailing error/max_turns result (which races in AFTER the reply in
|
|
531
|
+
// session mode) is suppressed below. OR-accumulated across ALL of the
|
|
532
|
+
// turn's messages — a tool-heavy turn's prose rides on a `tool_use`
|
|
533
|
+
// message and its terminal `end_turn` is often thinking-only (#394), so
|
|
534
|
+
// the flag must survive later text-less messages, not just the last one.
|
|
535
|
+
if (messageProducedReply(m))
|
|
536
|
+
producedReply = true;
|
|
537
|
+
const notice = noticeFromMessage(m);
|
|
538
|
+
if (notice)
|
|
539
|
+
emitNotice(notice);
|
|
540
|
+
// #429: recover any Task/Agent launch from this message's tool_use input so
|
|
541
|
+
// the enriched card renders live — must run before translate fires onToolStart.
|
|
542
|
+
for (const l of extractSubagentLaunches(m))
|
|
543
|
+
subagentLaunches.set(l.toolUseId, l);
|
|
544
|
+
await translate(m);
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
// Detached completion: emit the terminal frame + end the hub turn no matter
|
|
548
|
+
// what. Never throws to the event loop (guards the fan-out from one bad turn).
|
|
549
|
+
void drivePromise
|
|
550
|
+
.then((result) => {
|
|
551
|
+
// #404: mirror the human-turn path — a session-mode turn that produced a real
|
|
552
|
+
// reply routinely ends with a trailing `error_*` / `success:false` result
|
|
553
|
+
// frame, so gating the post-turn sweep / recovery arm on raw `result.success`
|
|
554
|
+
// silently skips them. Trust the same `producedReply` signal the banner does.
|
|
555
|
+
const effectiveSuccess = turnEffectivelySucceeded(result.success, producedReply);
|
|
556
|
+
const finalSession = (effectiveSuccess ? (result.sessionId ?? resolvedSession) : resolvedSession) ?? null;
|
|
557
|
+
if (finalSession)
|
|
558
|
+
turn.setSession(finalSession);
|
|
559
|
+
const completeModel = seen.model ?? fallbackModel;
|
|
560
|
+
const u = resolveTurnUsage(seen);
|
|
561
|
+
const completeUsage = u
|
|
562
|
+
? {
|
|
563
|
+
inputTokens: u.inputTokens,
|
|
564
|
+
outputTokens: u.outputTokens,
|
|
565
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
566
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
567
|
+
contextTokens: u.inputTokens + u.cacheReadTokens + u.cacheCreationTokens,
|
|
568
|
+
contextLimit: getContextLimit(completeModel),
|
|
569
|
+
}
|
|
570
|
+
: undefined;
|
|
571
|
+
// #329: a turn that failed WITHOUT a terminal error `result` reaching the
|
|
572
|
+
// stream (a thrown/rejected drive: network/DNS reset, process crash) never
|
|
573
|
+
// triggered `noticeFromMessage`. Surface it here as an inline error notice
|
|
574
|
+
// so the failure is visible — before chat:complete flips streaming off.
|
|
575
|
+
if (!result.success)
|
|
576
|
+
emitNotice(errorNotice(result.error?.message));
|
|
577
|
+
turn.emit({
|
|
578
|
+
type: "chat:complete",
|
|
579
|
+
payload: {
|
|
580
|
+
...routing(),
|
|
581
|
+
sessionId: finalSession,
|
|
582
|
+
jobId: result.jobId ?? jobId,
|
|
583
|
+
success: result.success,
|
|
584
|
+
error: result.error?.message,
|
|
585
|
+
...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
turn.end();
|
|
589
|
+
try {
|
|
590
|
+
deps.herdctl.invalidateSessions(agentName);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* non-fatal */
|
|
594
|
+
}
|
|
595
|
+
// T5: post-turn curation via the `afterTurn` event (folded-in sweeper).
|
|
596
|
+
if (effectiveSuccess)
|
|
597
|
+
triggers.emitAfterTurn(projectSlug, finalSession);
|
|
598
|
+
// Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
|
|
599
|
+
// keeper turn that stayed alive — including a recovery re-drive itself, so a
|
|
600
|
+
// re-drive that hangs again is caught (bounded by the per-session retry cap).
|
|
601
|
+
if (effectiveSuccess && finalSession && driveMode === "session") {
|
|
602
|
+
recoveryEngine.armWatch({ slug: projectSlug, sessionId: finalSession });
|
|
603
|
+
}
|
|
604
|
+
if (!resolvedSession) {
|
|
605
|
+
rejectId(new Error(result.error?.message ?? "turn ended with no session id"));
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
.catch((err) => {
|
|
609
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
610
|
+
// #329: the drive itself rejected — surface the failure inline so the chat
|
|
611
|
+
// doesn't just look dead (deduped against any notice already emitted).
|
|
612
|
+
emitNotice(errorNotice(error));
|
|
613
|
+
turn.emit({
|
|
614
|
+
type: "chat:complete",
|
|
615
|
+
payload: { ...routing(), sessionId: resolvedSession, jobId, success: false, error },
|
|
616
|
+
});
|
|
617
|
+
turn.end();
|
|
618
|
+
rejectId(err instanceof Error ? err : new Error(error));
|
|
619
|
+
});
|
|
620
|
+
// Never hang the calling tool forever if the child never streams an id.
|
|
621
|
+
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("timed out waiting for spawned chat to start")), 60_000));
|
|
622
|
+
return Promise.race([idKnown, timeout]);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Sessions with a recovery nudge dispatch in flight (issue #352 double-dispatch
|
|
626
|
+
* guard). A resume of a still-in-flight session-mode chat interrupts and swallows
|
|
627
|
+
* the prior resume, so we never let two recovery/Continue dispatches for the same
|
|
628
|
+
* session overlap. Set synchronously the moment a nudge is dispatched (before the
|
|
629
|
+
* `sessionExists` await), cleared once the turn has started; `hub.isRunning` then
|
|
630
|
+
* carries the guard for the turn's lifetime.
|
|
631
|
+
*/
|
|
632
|
+
const injectingRecovery = new Set();
|
|
633
|
+
/**
|
|
634
|
+
* Inject the keeper-chat recovery nudge into a still-alive session (issue #301) —
|
|
635
|
+
* the ONE shared path behind both the manual Layer 2 "Continue" (`chat:continue`)
|
|
636
|
+
* and the Layer 3 automatic re-drive ({@link RecoveryEngine}). Re-drives the
|
|
637
|
+
* hung keeper via {@link startAgentTurn} with the {@link RECOVERY_NUDGE} and a
|
|
638
|
+
* `recovery` sender, exactly the message a human sends by hand to unstick it.
|
|
639
|
+
* No-op for a session that no longer exists. The gate on
|
|
640
|
+
* WHICH layer may call this lives in each caller (surfaceKilledTask vs
|
|
641
|
+
* autoReDrive); this helper is layer-agnostic.
|
|
642
|
+
*/
|
|
643
|
+
const injectRecoveryNudge = async (project, sessionId) => {
|
|
644
|
+
// Every workspace has a keeper, including the root — whose key is `""`. This
|
|
645
|
+
// guard used to read `if (!slug) return;`, which silently killed both the
|
|
646
|
+
// "Continue" nudge and auto-re-drive for every root chat.
|
|
647
|
+
const slug = project.slug;
|
|
648
|
+
// Single-flight double-dispatch guard (issue #352). Two dispatches resuming the
|
|
649
|
+
// SAME session at once is fatal under session-mode `chatSession(resume)`: the
|
|
650
|
+
// second resume interrupts the first, so one nudge is swallowed ("first message
|
|
651
|
+
// swallowed", #350/#347). A turn already running for this session (a human send,
|
|
652
|
+
// a queued-message drain, or a Continue click) means the keeper is not idle —
|
|
653
|
+
// yield to it. And `injectingRecovery` closes the async gap below (the
|
|
654
|
+
// `sessionExists` await) so two near-simultaneous recovery/Continue calls can't
|
|
655
|
+
// both get past this check before either registers its turn as running.
|
|
656
|
+
if (injectingRecovery.has(sessionId) || hub.isRunning(sessionId)) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
injectingRecovery.add(sessionId);
|
|
660
|
+
try {
|
|
661
|
+
// Only re-drive a real, existing session (a live kept-alive keeper chat).
|
|
662
|
+
if (!(await deps.herdctl.sessionExists(project, sessionId).catch(() => false)))
|
|
663
|
+
return;
|
|
664
|
+
const driveMode = project.driveMode && isKnownDriveMode(project.driveMode)
|
|
665
|
+
? project.driveMode
|
|
666
|
+
: deps.cfg.driveMode;
|
|
667
|
+
await startAgentTurn({
|
|
668
|
+
projectSlug: slug,
|
|
669
|
+
agentName: keeperAgentName(slug),
|
|
670
|
+
workingDir: project.workingDir,
|
|
671
|
+
resume: sessionId,
|
|
672
|
+
prompt: RECOVERY_NUDGE,
|
|
673
|
+
driveMode,
|
|
674
|
+
fallbackModel: project.model,
|
|
675
|
+
// A resume never re-stamps provenance (only new chats are stamped), so these
|
|
676
|
+
// describe-the-run values aren't persisted — the target keeps its own marker
|
|
677
|
+
// and its self-MCP is gated on THAT recorded depth. Describe it as a
|
|
678
|
+
// human-rooted run (matches the Layer 2 manual Continue path).
|
|
679
|
+
origin: "human",
|
|
680
|
+
depth: 0,
|
|
681
|
+
maxSpawnDepth: resolveMaxSpawnDepth(project.maxSpawnDepth, deps.cfg.maxSpawnDepth),
|
|
682
|
+
// #290 / #301: attribute the injected nudge to Paddock recovery so the history
|
|
683
|
+
// renders "⚠ continued after a background task was terminated" and emits a live
|
|
684
|
+
// chat:injected frame to any attached viewer.
|
|
685
|
+
sender: { kind: "recovery" },
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
// Clear the single-flight mark once the turn has STARTED (startAgentTurn
|
|
690
|
+
// resolves as soon as the session id is known — for a resume, immediately —
|
|
691
|
+
// by which point `hub.isRunning(sessionId)` is true and takes over the guard).
|
|
692
|
+
injectingRecovery.delete(sessionId);
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* Layer 3 automatic-recovery engine (issue #301). After each session-mode keeper
|
|
697
|
+
* turn completes (armed at the completion sites below), it tails the transcript;
|
|
698
|
+
* if a background task was killed at the turn boundary and the keeper doesn't wake
|
|
699
|
+
* on its own, it auto-injects the recovery nudge — guarded by the resolved
|
|
700
|
+
* `recovery.autoReDrive` flag (default OFF), a debounce window, and a per-session
|
|
701
|
+
* retry cap. Re-drive reuses the exact {@link injectRecoveryNudge} path as the
|
|
702
|
+
* manual Continue; a human message ({@link RecoveryEngine.onHumanMessage}) resets
|
|
703
|
+
* a session's guard so a later genuine hang recovers fresh.
|
|
704
|
+
*/
|
|
705
|
+
const recoveryEngine = new RecoveryEngine({
|
|
706
|
+
cfg: { recovery: deps.cfg.recovery },
|
|
707
|
+
getProject: (slug) => deps.projects.get(slug),
|
|
708
|
+
reDrive: (project, sessionId) => injectRecoveryNudge(project, sessionId),
|
|
709
|
+
// #352: a live turn on this session means the keeper isn't idle — the watch
|
|
710
|
+
// defers rather than surface a stale "idle" banner or fire a re-drive that
|
|
711
|
+
// would interrupt (and be swallowed by) the in-flight turn. `injectRecoveryNudge`
|
|
712
|
+
// holds the same guard for the recovery path's own dispatch window.
|
|
713
|
+
isBusy: (sessionId) => hub.isRunning(sessionId),
|
|
714
|
+
// #397: `hub.isRunning` only sees turns PADDOCK started — it is BLIND to a
|
|
715
|
+
// session herdctl's reaper is keeping alive for the just-killed background task
|
|
716
|
+
// (or its ~15s re-invocation grace). Auto re-drive resumes via a FRESH
|
|
717
|
+
// subprocess, so firing while that prior subprocess is still live spawned a
|
|
718
|
+
// COMPETING resume the SDK interrupted (`[Request interrupted by user]`). Thread
|
|
719
|
+
// the reaper's true liveness in so the engine defers until the session is
|
|
720
|
+
// genuinely idle. Null-safe (batch mode / no reaper → false = pre-#397 path).
|
|
721
|
+
sdkSessionLive: (sessionId) => deps.herdctl.isSdkSessionLive(sessionId),
|
|
722
|
+
// #347: when a background task is killed at the turn boundary, its
|
|
723
|
+
// notification is trapped in the SDK input queue — the client would never
|
|
724
|
+
// render the "keeper is idle" affordance until a refresh flushed it. On
|
|
725
|
+
// detection, broadcast a live frame to any attached socket so the banner +
|
|
726
|
+
// Continue appear immediately. Out-of-band (no live turn), so hub.broadcast.
|
|
727
|
+
surface: (project, sessionId, summary) => {
|
|
728
|
+
hub.broadcast(sessionId, {
|
|
729
|
+
type: "chat:killed_task",
|
|
730
|
+
payload: {
|
|
731
|
+
projectSlug: project.slug,
|
|
732
|
+
sessionId,
|
|
733
|
+
summary: summary ?? "A background task was terminated at the turn boundary.",
|
|
734
|
+
timestamp: new Date().toISOString(),
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
return {
|
|
740
|
+
startAgentTurn,
|
|
741
|
+
buildInjection,
|
|
742
|
+
wakeInjection,
|
|
743
|
+
selfMcpCtx,
|
|
744
|
+
injectRecoveryNudge,
|
|
745
|
+
recoveryEngine,
|
|
746
|
+
emitAfterTurn: triggers.emitAfterTurn,
|
|
747
|
+
composePreloadedPrompt: triggers.composePreloadedPrompt,
|
|
748
|
+
fireTrigger: triggers.fireTrigger,
|
|
749
|
+
makeBackgroundTurnSink,
|
|
750
|
+
};
|
|
751
|
+
}
|