@edspencer/paddock 0.68.1 → 0.70.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/package.json +1 -1
- package/packages/server/dist/agent-errors.js +7 -1
- package/packages/server/dist/background-live.d.ts +124 -0
- package/packages/server/dist/background-live.js +226 -0
- package/packages/server/dist/cli/paddock.js +12 -7
- package/packages/server/dist/config.d.ts +7 -5
- package/packages/server/dist/config.js +0 -1
- package/packages/server/dist/instance-config.js +1 -2
- package/packages/server/dist/recovery-config.d.ts +0 -6
- package/packages/server/dist/recovery-config.js +0 -4
- package/packages/server/dist/ws-protocol.d.ts +61 -1
- package/packages/server/dist/ws-turn.d.ts +2 -0
- package/packages/server/dist/ws-turn.js +17 -1
- package/packages/server/dist/ws.js +72 -13
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{Chip-NMeHUE94.js → Chip-C8a-t439.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-C1khyCvD.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-Bt4FUTjB.js → DiscoverView-DS_Z9xKH.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DIQJtIYC.js → InstanceConfigPage-B1ei1FDo.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-BVMZB_mP.js → MarkdownRenderer-DCj_ZwVG.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-D9Nsqjxy.js → ProjectMenu-B7dGZSFB.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-B8rJ9qqe.js → ProjectRedirect-bstvMvM9.js} +1 -1
- package/packages/web/dist/assets/ProjectView-iuiUIoIl.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CDqvd4bj.js → ProjectsGrid-DKvySDUQ.js} +1 -1
- package/packages/web/dist/assets/{RootHome-CmiJG9wb.js → RootHome-CD_37qb-.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-c410wTbw.js → Surfaces-1dBayrsj.js} +1 -1
- package/packages/web/dist/assets/{arc-DN2jEPE9.js → arc-BJPb3ViJ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BLMFd81z.js → architectureDiagram-3BPJPVTR-PkLOC5wV.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BweEECqE.js → blockDiagram-GPEHLZMM-IPduhNH-.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DpN_9YFO.js → c4Diagram-AAUBKEIU-dKTYV1Vq.js} +1 -1
- package/packages/web/dist/assets/channel-BhrgMiqg.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BxWEBoli.js → chunk-2J33WTMH-B5C6geyg.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-C5nkcPRR.js → chunk-4BX2VUAB-1BRhFRhF.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-B8P3CNX8.js → chunk-55IACEB6-DNv-sTiI.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-D3yuzl-m.js → chunk-727SXJPM-CWezuVkx.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-CzWxcEIw.js → chunk-AQP2D5EJ-Bt4yEQnu.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CCB91otB.js → chunk-FMBD7UC4-lb_8q4JY.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BymMfuY4.js → chunk-ND2GUHAM-DOwgGlQ_.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CezHuNcf.js → chunk-QZHKN3VN-BanKpY4q.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Ho1IMdjg.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Ho1IMdjg.js +2 -0
- package/packages/web/dist/assets/{core-LjceHm3Z.js → core-CAsyoNAa.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BWhmWR_V.js → cose-bilkent-S5V4N54A-sVaVkwMI.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BTXWnJYV.js → dagre-BM42HDAG-C9pKHSMo.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BY2LZdf0.js → diagram-2AECGRRQ-CHdrmCfX.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BZ78Imy8.js → diagram-5GNKFQAL-4UTa2Dfa.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BV4Ax_0D.js → diagram-KO2AKTUF-NidpfVwW.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-1kcTal-d.js → diagram-LMA3HP47-ictQj03T.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CX3RfHlO.js → diagram-OG6HWLK6-B7dTLMHC.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BBS46D95.js → erDiagram-TEJ5UH35-C2QYYzJ8.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CpPHahLZ.js → flowDiagram-I6XJVG4X-D2rcjSZ9.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CxL_QCuL.js → ganttDiagram-6RSMTGT7-BQcXTYzg.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BeTkPHaG.js → gitGraphDiagram-PVQCEYII-DwB2Vqxa.js} +1 -1
- package/packages/web/dist/assets/index-BNzTD4hA.css +1 -0
- package/packages/web/dist/assets/{index-DjhUG9sA.js → index-BeuEs7dL.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CiMnl36I.js → infoDiagram-5YYISTIA-BoK4Mawg.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CjASKQeF.js → ishikawaDiagram-YF4QCWOH-DPP9svhg.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BZzehSqh.js → journeyDiagram-JHISSGLW-TrggodYk.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Cn-l0UIy.js → kanban-definition-UN3LZRKU-Bh69hRSR.js} +1 -1
- package/packages/web/dist/assets/{linear-DyELsH8s.js → linear-C1iYMpBz.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-C1adXzqy.js → mermaid.core-BZDABcuq.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DP6iSaA_.js → mindmap-definition-RKZ34NQL-DTJON3dS.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DHpN8Fse.js → pieDiagram-4H26LBE5-BxtFV74I.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DAfc21Kd.js → quadrantDiagram-W4KKPZXB-D2Ar8kmO.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-X3FfIiGh.js → requirementDiagram-4Y6WPE33-DiLSfeSQ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Ch319LBM.js → sankeyDiagram-5OEKKPKP-D_CRQc7j.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CAyajDJc.js → sequenceDiagram-3UESZ5HK-DjtfcRNV.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C1G4309_.js → stateDiagram-AJRCARHV-CzEtmYLV.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-pM8fIOir.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Bj5ihSj4.js → timeline-definition-PNZ67QCA-DXKspEG3.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-WIZuVHvV.js → vennDiagram-CIIHVFJN-B_tXRMHH.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CwRQf2fI.js → wardley-L42UT6IY-DNaNoaJm.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bug_Zcav.js → wardleyDiagram-YWT4CUSO-BuVDpC8s.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cw5xgfwo.js → xychartDiagram-2RQKCTM6-Cqtew2PA.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-LY0CRBVz.js +0 -2
- package/packages/web/dist/assets/ProjectView-D-21ULHU.js +0 -13
- package/packages/web/dist/assets/channel-B_SiD-tA.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Detq0lSJ.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Detq0lSJ.js +0 -2
- package/packages/web/dist/assets/index-DYccdR9x.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C0-BEM28.js +0 -2
package/package.json
CHANGED
|
@@ -44,8 +44,14 @@ const KNOWN_CAUSES = [
|
|
|
44
44
|
// Anchored to `claude` specifically. A bare /ENOENT/ would claim a missing
|
|
45
45
|
// CLI for any file the engine failed to open, which is a confident wrong
|
|
46
46
|
// answer — worse than the raw error it replaces.
|
|
47
|
+
//
|
|
48
|
+
// Name only what actually reads PATH (#771): the CLI runtime. The sweeper
|
|
49
|
+
// is always on it; triggers and chat turns only on `driveMode: batch`. An
|
|
50
|
+
// earlier wording said "the sweeper and triggers", which sent an operator
|
|
51
|
+
// on the default `session` mode to check the wrong thing.
|
|
47
52
|
pattern: /ENOENT[^\n]{0,40}claude|claude: (command )?not found/i,
|
|
48
|
-
cause: "the `claude` CLI is not on PATH (needed by the sweeper and
|
|
53
|
+
cause: "the `claude` CLI is not on PATH (needed by the sweeper, and by " +
|
|
54
|
+
"triggers/turns on `driveMode: batch`)",
|
|
49
55
|
},
|
|
50
56
|
];
|
|
51
57
|
/**
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live background-work registry (#604).
|
|
3
|
+
*
|
|
4
|
+
* Paddock's only notion of "this chat is busy" is `SessionHub`'s single `Turn`
|
|
5
|
+
* per session. That is wrong for background work in two directions: a turn ends
|
|
6
|
+
* while its background tasks keep running (this issue), and a background task
|
|
7
|
+
* outlives the `Turn` record entirely, so the task set cannot live on it.
|
|
8
|
+
*
|
|
9
|
+
* The Claude Agent SDK already publishes the set we need. This module is the
|
|
10
|
+
* server-side landing point for it:
|
|
11
|
+
*
|
|
12
|
+
* - `system/background_tasks_changed` — a LEVEL signal carrying every live
|
|
13
|
+
* task after a membership change. Consumed with REPLACE semantics, per the
|
|
14
|
+
* SDK's own guidance: "consumers that only need 'is background work
|
|
15
|
+
* running' should replace their set with each payload rather than pairing
|
|
16
|
+
* edges, so a missed bookend cannot wedge a stale running indicator."
|
|
17
|
+
* #528 was exactly such a wedge, so this is load-bearing, not stylistic.
|
|
18
|
+
*
|
|
19
|
+
* - `system/task_started` / `system/task_progress` — EDGE signals carrying the
|
|
20
|
+
* detail the level payload omits (`tool_use_id`, `subagent_type`,
|
|
21
|
+
* `workflow_name`, `last_tool_name`, step counts). Folded onto the level set
|
|
22
|
+
* as enrichment only: an edge NEVER adds a task the level has not shown us,
|
|
23
|
+
* because the edges are not guaranteed to pair and a leaked edge would
|
|
24
|
+
* resurrect a finished task.
|
|
25
|
+
*
|
|
26
|
+
* - `system/task_notification` / `system/task_updated` — terminal edges. These
|
|
27
|
+
* evict eagerly rather than waiting for the next level, so a completed task
|
|
28
|
+
* leaves the bar promptly; a level signal that still lists it will simply
|
|
29
|
+
* put it back.
|
|
30
|
+
*
|
|
31
|
+
* Per-process caveat, straight from the SDK: the level is "per-process: nothing
|
|
32
|
+
* is emitted at startup, so consumers must reset to the empty set whenever the
|
|
33
|
+
* session's CLI process (re)starts". {@link BackgroundRegistry.clear} is that
|
|
34
|
+
* reset. Nothing here tries to reconstruct a set from disk — an empty bar after
|
|
35
|
+
* a restart is correct, because Paddock stops the fleet with
|
|
36
|
+
* `waitForJobs: false` and the tasks are genuinely dead.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* One live background task, as broadcast to clients.
|
|
40
|
+
*
|
|
41
|
+
* `type` is the SDK's friendly label (`shell` | `subagent` | `monitor` |
|
|
42
|
+
* `workflow`), falling back to the raw discriminant for kinds we do not know
|
|
43
|
+
* about yet — deliberately a plain `string` rather than a union so a new SDK
|
|
44
|
+
* task type renders as an unlabelled row instead of being dropped.
|
|
45
|
+
*/
|
|
46
|
+
export interface LiveBackgroundTask {
|
|
47
|
+
id: string;
|
|
48
|
+
type: string;
|
|
49
|
+
description: string;
|
|
50
|
+
/** Epoch-ms this task was first observed. Stamped here; the SDK sends no start time. */
|
|
51
|
+
startedAt: number;
|
|
52
|
+
/** Links the task to its launching tool card, when an edge has told us. */
|
|
53
|
+
toolUseId?: string;
|
|
54
|
+
/** `subagent` only. */
|
|
55
|
+
agentType?: string;
|
|
56
|
+
/** `shell` only. */
|
|
57
|
+
command?: string;
|
|
58
|
+
/** `workflow` only. */
|
|
59
|
+
workflowName?: string;
|
|
60
|
+
/** `monitor` / MCP-task only. */
|
|
61
|
+
server?: string;
|
|
62
|
+
tool?: string;
|
|
63
|
+
/** Latest tool the task ran, from `task_progress`. */
|
|
64
|
+
lastToolName?: string;
|
|
65
|
+
/** Steps the task has taken, from `task_progress.usage.tool_uses`. */
|
|
66
|
+
toolUses?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Ambient/housekeeping work. The SDK asks consumers to hide these from the
|
|
69
|
+
* inline transcript while still allowing a tasks panel to show them, so it is
|
|
70
|
+
* carried on the wire and filtered at the point of display.
|
|
71
|
+
*/
|
|
72
|
+
skipTranscript?: boolean;
|
|
73
|
+
}
|
|
74
|
+
export type BackgroundChangeListener = (projectSlug: string, sessionId: string, tasks: LiveBackgroundTask[]) => void;
|
|
75
|
+
/**
|
|
76
|
+
* The registry. One instance per server, owned by the WS layer so it can
|
|
77
|
+
* broadcast, and fed by the turn engine from both the foreground and background
|
|
78
|
+
* message lanes.
|
|
79
|
+
*/
|
|
80
|
+
export declare class BackgroundRegistry {
|
|
81
|
+
private now;
|
|
82
|
+
private bySession;
|
|
83
|
+
/** Fired whenever a session's live set changes. Set by the WS layer. */
|
|
84
|
+
onChange: BackgroundChangeListener | null;
|
|
85
|
+
/** Injectable clock, so tests can assert `startedAt` without sleeping. */
|
|
86
|
+
constructor(now?: () => number);
|
|
87
|
+
/** Live tasks for a session, newest last. Empty array when idle or unknown. */
|
|
88
|
+
list(sessionId: string): LiveBackgroundTask[];
|
|
89
|
+
/** Every session with at least one live task — the connect-time snapshot. */
|
|
90
|
+
snapshot(): {
|
|
91
|
+
projectSlug: string;
|
|
92
|
+
sessionId: string;
|
|
93
|
+
tasks: LiveBackgroundTask[];
|
|
94
|
+
}[];
|
|
95
|
+
/** Does this session have live background work? The #604 predicate. */
|
|
96
|
+
isBusy(sessionId: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Forget a session's set. Called when the session's process restarts or its
|
|
99
|
+
* stream ends — see the per-process caveat in the module header.
|
|
100
|
+
*/
|
|
101
|
+
clear(sessionId: string): void;
|
|
102
|
+
/**
|
|
103
|
+
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
104
|
+
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
105
|
+
* (assistant text, tool calls) that are not task lifecycle at all.
|
|
106
|
+
*/
|
|
107
|
+
observe(projectSlug: string, m: unknown): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* REPLACE the session's set from a level signal, preserving the enrichment we
|
|
110
|
+
* have already folded onto tasks that survive. Without the merge, every level
|
|
111
|
+
* signal would blank the `tool_use_id` and step counts that only the edges
|
|
112
|
+
* carry, making rows flicker between detailed and bare.
|
|
113
|
+
*/
|
|
114
|
+
private replace;
|
|
115
|
+
/**
|
|
116
|
+
* Fold edge detail onto an EXISTING task. Deliberately a no-op for an unknown
|
|
117
|
+
* id: the level signal is the sole authority on membership, so an edge can
|
|
118
|
+
* never create a row (and a late edge can never resurrect a finished one).
|
|
119
|
+
*/
|
|
120
|
+
private enrich;
|
|
121
|
+
/** Drop a task on a terminal edge, ahead of the level that will confirm it. */
|
|
122
|
+
private evict;
|
|
123
|
+
private emit;
|
|
124
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live background-work registry (#604).
|
|
3
|
+
*
|
|
4
|
+
* Paddock's only notion of "this chat is busy" is `SessionHub`'s single `Turn`
|
|
5
|
+
* per session. That is wrong for background work in two directions: a turn ends
|
|
6
|
+
* while its background tasks keep running (this issue), and a background task
|
|
7
|
+
* outlives the `Turn` record entirely, so the task set cannot live on it.
|
|
8
|
+
*
|
|
9
|
+
* The Claude Agent SDK already publishes the set we need. This module is the
|
|
10
|
+
* server-side landing point for it:
|
|
11
|
+
*
|
|
12
|
+
* - `system/background_tasks_changed` — a LEVEL signal carrying every live
|
|
13
|
+
* task after a membership change. Consumed with REPLACE semantics, per the
|
|
14
|
+
* SDK's own guidance: "consumers that only need 'is background work
|
|
15
|
+
* running' should replace their set with each payload rather than pairing
|
|
16
|
+
* edges, so a missed bookend cannot wedge a stale running indicator."
|
|
17
|
+
* #528 was exactly such a wedge, so this is load-bearing, not stylistic.
|
|
18
|
+
*
|
|
19
|
+
* - `system/task_started` / `system/task_progress` — EDGE signals carrying the
|
|
20
|
+
* detail the level payload omits (`tool_use_id`, `subagent_type`,
|
|
21
|
+
* `workflow_name`, `last_tool_name`, step counts). Folded onto the level set
|
|
22
|
+
* as enrichment only: an edge NEVER adds a task the level has not shown us,
|
|
23
|
+
* because the edges are not guaranteed to pair and a leaked edge would
|
|
24
|
+
* resurrect a finished task.
|
|
25
|
+
*
|
|
26
|
+
* - `system/task_notification` / `system/task_updated` — terminal edges. These
|
|
27
|
+
* evict eagerly rather than waiting for the next level, so a completed task
|
|
28
|
+
* leaves the bar promptly; a level signal that still lists it will simply
|
|
29
|
+
* put it back.
|
|
30
|
+
*
|
|
31
|
+
* Per-process caveat, straight from the SDK: the level is "per-process: nothing
|
|
32
|
+
* is emitted at startup, so consumers must reset to the empty set whenever the
|
|
33
|
+
* session's CLI process (re)starts". {@link BackgroundRegistry.clear} is that
|
|
34
|
+
* reset. Nothing here tries to reconstruct a set from disk — an empty bar after
|
|
35
|
+
* a restart is correct, because Paddock stops the fleet with
|
|
36
|
+
* `waitForJobs: false` and the tasks are genuinely dead.
|
|
37
|
+
*/
|
|
38
|
+
/** Terminal task statuses — a task in one of these is no longer live. */
|
|
39
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "killed"]);
|
|
40
|
+
const str = (v) => (typeof v === "string" && v ? v : undefined);
|
|
41
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
42
|
+
/**
|
|
43
|
+
* Is this an SDK system message we care about? Structural rather than a typed
|
|
44
|
+
* narrow, because the SDK's `SDKMessage` union gains subtypes faster than we
|
|
45
|
+
* pin its version, and an unknown subtype must be ignored, not crash the turn.
|
|
46
|
+
*/
|
|
47
|
+
function systemSubtype(m) {
|
|
48
|
+
if (!m || typeof m !== "object")
|
|
49
|
+
return null;
|
|
50
|
+
const o = m;
|
|
51
|
+
if (o.type !== "system")
|
|
52
|
+
return null;
|
|
53
|
+
return typeof o.subtype === "string" ? o.subtype : null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The registry. One instance per server, owned by the WS layer so it can
|
|
57
|
+
* broadcast, and fed by the turn engine from both the foreground and background
|
|
58
|
+
* message lanes.
|
|
59
|
+
*/
|
|
60
|
+
export class BackgroundRegistry {
|
|
61
|
+
now;
|
|
62
|
+
bySession = new Map();
|
|
63
|
+
/** Fired whenever a session's live set changes. Set by the WS layer. */
|
|
64
|
+
onChange = null;
|
|
65
|
+
/** Injectable clock, so tests can assert `startedAt` without sleeping. */
|
|
66
|
+
constructor(now = Date.now) {
|
|
67
|
+
this.now = now;
|
|
68
|
+
}
|
|
69
|
+
/** Live tasks for a session, newest last. Empty array when idle or unknown. */
|
|
70
|
+
list(sessionId) {
|
|
71
|
+
const e = this.bySession.get(sessionId);
|
|
72
|
+
return e ? [...e.tasks.values()] : [];
|
|
73
|
+
}
|
|
74
|
+
/** Every session with at least one live task — the connect-time snapshot. */
|
|
75
|
+
snapshot() {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const [sessionId, e] of this.bySession) {
|
|
78
|
+
if (e.tasks.size > 0)
|
|
79
|
+
out.push({ projectSlug: e.projectSlug, sessionId, tasks: [...e.tasks.values()] });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** Does this session have live background work? The #604 predicate. */
|
|
84
|
+
isBusy(sessionId) {
|
|
85
|
+
const e = this.bySession.get(sessionId);
|
|
86
|
+
return !!e && e.tasks.size > 0;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Forget a session's set. Called when the session's process restarts or its
|
|
90
|
+
* stream ends — see the per-process caveat in the module header.
|
|
91
|
+
*/
|
|
92
|
+
clear(sessionId) {
|
|
93
|
+
const e = this.bySession.get(sessionId);
|
|
94
|
+
if (!e || e.tasks.size === 0)
|
|
95
|
+
return;
|
|
96
|
+
e.tasks.clear();
|
|
97
|
+
this.emit(sessionId, e);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Feed one raw SDK message. Returns true when the live set changed, so the
|
|
101
|
+
* caller can avoid re-broadcasting on the overwhelming majority of messages
|
|
102
|
+
* (assistant text, tool calls) that are not task lifecycle at all.
|
|
103
|
+
*/
|
|
104
|
+
observe(projectSlug, m) {
|
|
105
|
+
const subtype = systemSubtype(m);
|
|
106
|
+
if (!subtype)
|
|
107
|
+
return false;
|
|
108
|
+
const o = m;
|
|
109
|
+
const sessionId = str(o.session_id);
|
|
110
|
+
if (!sessionId)
|
|
111
|
+
return false;
|
|
112
|
+
switch (subtype) {
|
|
113
|
+
case "background_tasks_changed":
|
|
114
|
+
return this.replace(projectSlug, sessionId, Array.isArray(o.tasks) ? o.tasks : []);
|
|
115
|
+
case "task_started":
|
|
116
|
+
return this.enrich(sessionId, str(o.task_id), {
|
|
117
|
+
toolUseId: str(o.tool_use_id),
|
|
118
|
+
agentType: str(o.subagent_type),
|
|
119
|
+
workflowName: str(o.workflow_name),
|
|
120
|
+
description: str(o.description),
|
|
121
|
+
skipTranscript: o.skip_transcript === true ? true : undefined,
|
|
122
|
+
});
|
|
123
|
+
case "task_progress": {
|
|
124
|
+
const usage = (o.usage ?? {});
|
|
125
|
+
return this.enrich(sessionId, str(o.task_id), {
|
|
126
|
+
toolUseId: str(o.tool_use_id),
|
|
127
|
+
agentType: str(o.subagent_type),
|
|
128
|
+
lastToolName: str(o.last_tool_name),
|
|
129
|
+
toolUses: num(usage.tool_uses),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
case "task_notification":
|
|
133
|
+
// Always terminal — the SDK only emits this on completed/failed/stopped.
|
|
134
|
+
return this.evict(sessionId, str(o.task_id));
|
|
135
|
+
case "task_updated": {
|
|
136
|
+
const patch = (o.patch ?? {});
|
|
137
|
+
const status = str(patch.status);
|
|
138
|
+
if (status && TERMINAL_STATUSES.has(status))
|
|
139
|
+
return this.evict(sessionId, str(o.task_id));
|
|
140
|
+
// A non-terminal patch (e.g. paused) is still enrichment.
|
|
141
|
+
return this.enrich(sessionId, str(o.task_id), { description: str(patch.description) });
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* REPLACE the session's set from a level signal, preserving the enrichment we
|
|
149
|
+
* have already folded onto tasks that survive. Without the merge, every level
|
|
150
|
+
* signal would blank the `tool_use_id` and step counts that only the edges
|
|
151
|
+
* carry, making rows flicker between detailed and bare.
|
|
152
|
+
*/
|
|
153
|
+
replace(projectSlug, sessionId, raw) {
|
|
154
|
+
const prev = this.bySession.get(sessionId);
|
|
155
|
+
const next = new Map();
|
|
156
|
+
for (const t of raw) {
|
|
157
|
+
const rt = (t ?? {});
|
|
158
|
+
const id = str(rt.task_id);
|
|
159
|
+
if (!id)
|
|
160
|
+
continue;
|
|
161
|
+
const existing = prev?.tasks.get(id);
|
|
162
|
+
next.set(id, {
|
|
163
|
+
...existing,
|
|
164
|
+
id,
|
|
165
|
+
type: str(rt.task_type) ?? existing?.type ?? "task",
|
|
166
|
+
description: str(rt.description) ?? existing?.description ?? "",
|
|
167
|
+
startedAt: existing?.startedAt ?? this.now(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (prev && sameSet(prev.tasks, next)) {
|
|
171
|
+
// Membership and detail unchanged — keep the projectSlug fresh but stay quiet.
|
|
172
|
+
prev.projectSlug = projectSlug;
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const entry = { projectSlug, tasks: next };
|
|
176
|
+
this.bySession.set(sessionId, entry);
|
|
177
|
+
this.emit(sessionId, entry);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Fold edge detail onto an EXISTING task. Deliberately a no-op for an unknown
|
|
182
|
+
* id: the level signal is the sole authority on membership, so an edge can
|
|
183
|
+
* never create a row (and a late edge can never resurrect a finished one).
|
|
184
|
+
*/
|
|
185
|
+
enrich(sessionId, taskId, patch) {
|
|
186
|
+
if (!taskId)
|
|
187
|
+
return false;
|
|
188
|
+
const e = this.bySession.get(sessionId);
|
|
189
|
+
const cur = e?.tasks.get(taskId);
|
|
190
|
+
if (!e || !cur)
|
|
191
|
+
return false;
|
|
192
|
+
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
193
|
+
const keys = Object.keys(defined);
|
|
194
|
+
if (keys.length === 0 || keys.every((k) => cur[k] === defined[k]))
|
|
195
|
+
return false;
|
|
196
|
+
e.tasks.set(taskId, { ...cur, ...defined });
|
|
197
|
+
this.emit(sessionId, e);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
/** Drop a task on a terminal edge, ahead of the level that will confirm it. */
|
|
201
|
+
evict(sessionId, taskId) {
|
|
202
|
+
if (!taskId)
|
|
203
|
+
return false;
|
|
204
|
+
const e = this.bySession.get(sessionId);
|
|
205
|
+
if (!e || !e.tasks.delete(taskId))
|
|
206
|
+
return false;
|
|
207
|
+
this.emit(sessionId, e);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
emit(sessionId, e) {
|
|
211
|
+
this.onChange?.(e.projectSlug, sessionId, [...e.tasks.values()]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Do two task maps carry the same ids AND the same rendered detail? */
|
|
215
|
+
function sameSet(a, b) {
|
|
216
|
+
if (a.size !== b.size)
|
|
217
|
+
return false;
|
|
218
|
+
for (const [id, ta] of a) {
|
|
219
|
+
const tb = b.get(id);
|
|
220
|
+
if (!tb)
|
|
221
|
+
return false;
|
|
222
|
+
if (ta.type !== tb.type || ta.description !== tb.description)
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
@@ -78,14 +78,19 @@ function readVersion() {
|
|
|
78
78
|
/**
|
|
79
79
|
* Put our bundled `claude` on PATH for child processes.
|
|
80
80
|
*
|
|
81
|
-
* Chat turns do NOT need this
|
|
82
|
-
* the binary from the Claude Agent SDK's
|
|
83
|
-
* `require.resolve` and never consults PATH.
|
|
84
|
-
*
|
|
85
|
-
* `execa("claude", …)` — by
|
|
86
|
-
* install is invisible to it
|
|
81
|
+
* Chat turns do NOT need this on the default `driveMode: session`: they run
|
|
82
|
+
* herdctl's SDK runtime, which resolves the binary from the Claude Agent SDK's
|
|
83
|
+
* own platform package via `require.resolve` and never consults PATH. The
|
|
84
|
+
* post-turn sweeper is the one unconditional user: always a one-shot
|
|
85
|
+
* `trigger()` call on the CLI runtime, which does `execa("claude", …)` — by
|
|
86
|
+
* name, without execa's `preferLocal`, so a local install is invisible to it
|
|
87
|
+
* unless PATH says otherwise. Triggers join it there only on `driveMode:
|
|
88
|
+
* batch`: a trigger resolves its drive mode exactly like a chat does
|
|
89
|
+
* (per-project override else `cfg.driveMode` — `resolveDriveMode` in
|
|
90
|
+
* ws-triggers.ts), so on a stock instance no trigger consults PATH either.
|
|
87
91
|
*
|
|
88
|
-
* Without this, chats work and every sweep
|
|
92
|
+
* Without this, chats work and every sweep fails (logged, non-fatal) — plus
|
|
93
|
+
* every trigger and turn on `driveMode: batch`.
|
|
89
94
|
*/
|
|
90
95
|
function addBundledBinsToPath() {
|
|
91
96
|
const owner = findUp(path.join("node_modules", ".bin"));
|
|
@@ -386,11 +386,14 @@ export interface PaddockConfig {
|
|
|
386
386
|
warnings: string[];
|
|
387
387
|
};
|
|
388
388
|
/**
|
|
389
|
-
* Instance default for the hook-management MCP (Epic G / G5, GG-4) — the
|
|
390
|
-
* `mcp__paddock_manage__{
|
|
391
|
-
*
|
|
389
|
+
* Instance default for the hook-management MCP (Epic G / G5, GG-4) — now the
|
|
390
|
+
* unified TRIGGER tools `mcp__paddock_manage__{list_triggers,set_trigger,
|
|
391
|
+
* remove_trigger,run_trigger}` that let a project agent declare/edit/delete/fire
|
|
392
|
+
* its own triggers. Epic T / T3 collapsed the former `*_hook` and `*_schedule`
|
|
393
|
+
* verbs into these; the flag and its env var kept the `hooks` spelling.
|
|
394
|
+
* A sibling of {@link selfMcpWriteEnabled}:
|
|
392
395
|
* OFF by default (opt-in), and only surfaces when the self-MCP write tools are also
|
|
393
|
-
* present (the
|
|
396
|
+
* present (the trigger tools live on the same injected server, appended in the write
|
|
394
397
|
* block). Driven by `PADDOCK_HOOKS_MCP`; accepts 1/true/yes. A per-project
|
|
395
398
|
* `hooksMcpEnabled` override wins at dispatch (resolved via
|
|
396
399
|
* {@link import("./hook-config.js").resolveHooksMcpEnabled}), the same
|
|
@@ -580,7 +583,6 @@ export interface PaddockConfigFile {
|
|
|
580
583
|
autoReDrive?: boolean | string;
|
|
581
584
|
debounceMs?: number | string;
|
|
582
585
|
maxRetries?: number | string;
|
|
583
|
-
limboTimeoutMs?: number | string;
|
|
584
586
|
};
|
|
585
587
|
/**
|
|
586
588
|
* Inbound composer-attachment config (issue #328). Every field optional; a
|
|
@@ -456,7 +456,6 @@ function loadRecoveryConfig(file) {
|
|
|
456
456
|
autoReDrive: loadRecoveryBool("PADDOCK_RECOVERY_AUTODRIVE", f.autoReDrive, DEFAULT_RECOVERY.autoReDrive),
|
|
457
457
|
debounceMs: loadRecoveryInt("PADDOCK_RECOVERY_DEBOUNCE_MS", f.debounceMs, DEFAULT_RECOVERY.debounceMs),
|
|
458
458
|
maxRetries: loadRecoveryInt("PADDOCK_RECOVERY_MAX_RETRIES", f.maxRetries, DEFAULT_RECOVERY.maxRetries),
|
|
459
|
-
limboTimeoutMs: loadRecoveryInt("PADDOCK_RECOVERY_LIMBO_MS", f.limboTimeoutMs, DEFAULT_RECOVERY.limboTimeoutMs),
|
|
460
459
|
};
|
|
461
460
|
}
|
|
462
461
|
/**
|
|
@@ -254,7 +254,7 @@ export const FIELDS = [
|
|
|
254
254
|
{ key: "environmentPrompt", group: "capabilities", label: "Environment prompt", help: "Appended to every keeper turn's system prompt: what the agent should know about rendering into Paddock rather than a terminal. Clear it to append nothing.", type: "text", envVars: ["PADDOCK_ENVIRONMENT_PROMPT"], envShadowWhenDefined: true, default: DEFAULT_ENVIRONMENT_PROMPT, editable: true, coerce: promptText },
|
|
255
255
|
{ key: "selfMcpEnabled", group: "capabilities", label: "Self-management MCP (read)", help: "Let keepers list/read projects and other chats.", type: "boolean", envVars: ["PADDOCK_SELF_MCP"], default: false, editable: true, coerce: asBool },
|
|
256
256
|
{ key: "selfMcpWriteEnabled", group: "capabilities", label: "Self-management MCP (write)", help: "Let keepers create/fork/message chats (needs read enabled too).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_WRITE"], default: false, editable: true, coerce: asBool },
|
|
257
|
-
{ key: "selfMcpProjectsEnabled", group: "capabilities", label: "Self-management MCP (projects)", help: "Let keepers create
|
|
257
|
+
{ key: "selfMcpProjectsEnabled", group: "capabilities", label: "Self-management MCP (projects)", help: "Let keepers create new projects and promote existing ones — including a git clone of a repo URL the agent supplies (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_PROJECTS"], default: false, editable: true, coerce: asBool },
|
|
258
258
|
{ key: "maxSpawnDepth", group: "capabilities", label: "Max spawn depth", help: "How deep a spawn tree may grow before children lose the self-MCP.", type: "number", envVars: ["PADDOCK_MAX_SPAWN_DEPTH"], default: DEFAULT_MAX_SPAWN_DEPTH, editable: true, coerce: spawnDepth },
|
|
259
259
|
{ key: "scheduleMutationEnabled", group: "capabilities", label: "Schedule mutation", help: "Allow programmatic schedule add/remove at runtime.", type: "boolean", envVars: ["PADDOCK_SCHEDULE_MUTATION"], default: false, editable: true, coerce: asBool },
|
|
260
260
|
{ key: "hooksMcpEnabled", group: "capabilities", label: "Hooks MCP", help: "Let agents declare/edit their own event hooks (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_HOOKS_MCP"], default: false, editable: true, coerce: asBool },
|
|
@@ -264,7 +264,6 @@ export const FIELDS = [
|
|
|
264
264
|
{ key: "recovery.autoReDrive", group: "recovery", label: "Auto re-drive", type: "boolean", envVars: ["PADDOCK_RECOVERY_AUTODRIVE"], default: DEFAULT_RECOVERY.autoReDrive, editable: true, coerce: asBool },
|
|
265
265
|
{ key: "recovery.debounceMs", group: "recovery", label: "Debounce (ms)", type: "number", envVars: ["PADDOCK_RECOVERY_DEBOUNCE_MS"], default: DEFAULT_RECOVERY.debounceMs, editable: true, coerce: nonNegInt },
|
|
266
266
|
{ key: "recovery.maxRetries", group: "recovery", label: "Max retries", type: "number", envVars: ["PADDOCK_RECOVERY_MAX_RETRIES"], default: DEFAULT_RECOVERY.maxRetries, editable: true, coerce: nonNegInt },
|
|
267
|
-
{ key: "recovery.limboTimeoutMs", group: "recovery", label: "Limbo timeout (ms)", type: "number", envVars: ["PADDOCK_RECOVERY_LIMBO_MS"], default: DEFAULT_RECOVERY.limboTimeoutMs, editable: true, coerce: nonNegInt },
|
|
268
267
|
// Attachments (issue #328).
|
|
269
268
|
{ key: "attachments.enabled", group: "attachments", label: "Enabled", type: "boolean", envVars: ["PADDOCK_ATTACHMENTS_ENABLED"], default: DEFAULT_ATTACHMENTS.enabled, editable: true, coerce: asBool },
|
|
270
269
|
{ key: "attachments.maxFileSizeMb", group: "attachments", label: "Max file size (MB)", type: "number", envVars: ["PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB"], default: DEFAULT_ATTACHMENTS.maxFileSizeMb, editable: true, coerce: posInt },
|
|
@@ -67,12 +67,6 @@ export interface RecoveryConfig {
|
|
|
67
67
|
* `PADDOCK_RECOVERY_MAX_RETRIES`.
|
|
68
68
|
*/
|
|
69
69
|
maxRetries: number;
|
|
70
|
-
/**
|
|
71
|
-
* Layer 2 backstop — if a kept-alive keeper session shows no activity for this
|
|
72
|
-
* many ms after a killed-task notification, surface it as stuck. `0` disables
|
|
73
|
-
* the backstop (the default). Env `PADDOCK_RECOVERY_LIMBO_MS`.
|
|
74
|
-
*/
|
|
75
|
-
limboTimeoutMs: number;
|
|
76
70
|
}
|
|
77
71
|
/**
|
|
78
72
|
* A per-project recovery override as stored in `project.yaml` — every field
|
|
@@ -43,7 +43,6 @@ export const DEFAULT_RECOVERY = Object.freeze({
|
|
|
43
43
|
autoReDrive: false,
|
|
44
44
|
debounceMs: 5000,
|
|
45
45
|
maxRetries: 1,
|
|
46
|
-
limboTimeoutMs: 0,
|
|
47
46
|
});
|
|
48
47
|
/** The `<status>` values a killed-at-turn-boundary task notification carries. */
|
|
49
48
|
const TERMINATED_STATUSES = new Set(["killed", "stopped"]);
|
|
@@ -81,8 +80,6 @@ export function sanitizeRecoveryOverride(value) {
|
|
|
81
80
|
out.debounceMs = o.debounceMs;
|
|
82
81
|
if (isNonNegativeInt(o.maxRetries))
|
|
83
82
|
out.maxRetries = o.maxRetries;
|
|
84
|
-
if (isNonNegativeInt(o.limboTimeoutMs))
|
|
85
|
-
out.limboTimeoutMs = o.limboTimeoutMs;
|
|
86
83
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
87
84
|
}
|
|
88
85
|
/**
|
|
@@ -100,6 +97,5 @@ export function resolveRecoveryConfig(override, instanceDefault) {
|
|
|
100
97
|
autoReDrive: clean.autoReDrive ?? instanceDefault.autoReDrive,
|
|
101
98
|
debounceMs: clean.debounceMs ?? instanceDefault.debounceMs,
|
|
102
99
|
maxRetries: clean.maxRetries ?? instanceDefault.maxRetries,
|
|
103
|
-
limboTimeoutMs: clean.limboTimeoutMs ?? instanceDefault.limboTimeoutMs,
|
|
104
100
|
};
|
|
105
101
|
}
|
|
@@ -408,6 +408,22 @@ export interface ChatActiveMessage {
|
|
|
408
408
|
* a client built against an older server still parses the frame.
|
|
409
409
|
*/
|
|
410
410
|
startedAt?: number;
|
|
411
|
+
/**
|
|
412
|
+
* Whether a MODEL TURN is in flight, as distinct from `running`, which since
|
|
413
|
+
* #604 also counts background work the turn left behind.
|
|
414
|
+
*
|
|
415
|
+
* The two answer different questions and must not be conflated. `running`
|
|
416
|
+
* drives status readouts — the sidebar dot, the fleet strip, the Home badge,
|
|
417
|
+
* the running-only filter — where background work genuinely means "this chat
|
|
418
|
+
* is busy". `turnRunning` drives the composer lock and the working indicator,
|
|
419
|
+
* where it does not: a `Monitor` can run for an hour, and locking the
|
|
420
|
+
* composer for that hour would make the chat unusable while telling the user
|
|
421
|
+
* the model was still thinking.
|
|
422
|
+
*
|
|
423
|
+
* Optional on the wire so an older client still parses the frame; such a
|
|
424
|
+
* client falls back to `running` and behaves as it did before.
|
|
425
|
+
*/
|
|
426
|
+
turnRunning?: boolean;
|
|
411
427
|
};
|
|
412
428
|
}
|
|
413
429
|
/**
|
|
@@ -539,6 +555,50 @@ export interface ChatKilledTaskMessage {
|
|
|
539
555
|
timestamp: string;
|
|
540
556
|
};
|
|
541
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* One live background task on a session (#604). Mirrors the SDK's
|
|
560
|
+
* `BackgroundTaskSummary` plus the detail its edge signals carry.
|
|
561
|
+
*/
|
|
562
|
+
export interface LiveBackgroundTaskWire {
|
|
563
|
+
id: string;
|
|
564
|
+
/** `shell` | `subagent` | `monitor` | `workflow`, or a raw discriminant we do not know. */
|
|
565
|
+
type: string;
|
|
566
|
+
description: string;
|
|
567
|
+
startedAt: number;
|
|
568
|
+
toolUseId?: string;
|
|
569
|
+
agentType?: string;
|
|
570
|
+
command?: string;
|
|
571
|
+
workflowName?: string;
|
|
572
|
+
server?: string;
|
|
573
|
+
tool?: string;
|
|
574
|
+
lastToolName?: string;
|
|
575
|
+
toolUses?: number;
|
|
576
|
+
skipTranscript?: boolean;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* A session's live background work (#604) — the tasks still running after the
|
|
580
|
+
* turn that launched them has returned.
|
|
581
|
+
*
|
|
582
|
+
* A LEVEL frame with REPLACE semantics: `tasks` is the complete set, and an
|
|
583
|
+
* empty array means "nothing running". Clients must swap their set rather than
|
|
584
|
+
* pair start/stop edges, so a dropped frame cannot wedge a stale indicator —
|
|
585
|
+
* the same contract the SDK states for `background_tasks_changed` upstream, and
|
|
586
|
+
* the reason #528's permanent "running" state cannot recur here.
|
|
587
|
+
*
|
|
588
|
+
* Broadcast on every membership change and replayed to a newly-connected socket,
|
|
589
|
+
* so a remount or reload learns what is in flight without polling. Nothing is
|
|
590
|
+
* reconstructed from disk: the signal is per-process, so after a server restart
|
|
591
|
+
* the set is empty until the next change — which is correct, because Paddock
|
|
592
|
+
* stops the fleet with `waitForJobs: false` and those tasks are dead.
|
|
593
|
+
*/
|
|
594
|
+
export interface ChatBackgroundMessage {
|
|
595
|
+
type: "chat:background";
|
|
596
|
+
payload: {
|
|
597
|
+
projectSlug: string;
|
|
598
|
+
sessionId: string;
|
|
599
|
+
tasks: LiveBackgroundTaskWire[];
|
|
600
|
+
};
|
|
601
|
+
}
|
|
542
602
|
/**
|
|
543
603
|
* A keeper turn dead-ended without a normal reply (issue #329): a synthetic
|
|
544
604
|
* subscription/usage-limit hit, the max-turns cap, or an error (network / API
|
|
@@ -556,6 +616,6 @@ export interface ChatNoticeMessage {
|
|
|
556
616
|
export interface PongMessage {
|
|
557
617
|
type: "pong";
|
|
558
618
|
}
|
|
559
|
-
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
|
|
619
|
+
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatBackgroundMessage | ChatNoticeMessage | PongMessage;
|
|
560
620
|
export declare function isClientMessage(data: unknown): data is ClientMessage;
|
|
561
621
|
export {};
|
|
@@ -3,6 +3,7 @@ import type { Project } from "./projects.js";
|
|
|
3
3
|
import { SessionHub } from "./session-hub.js";
|
|
4
4
|
import { createWakeInjectionCache, type InjectedMcpBuildArgs } from "./wake-injection.js";
|
|
5
5
|
import { RecoveryEngine } from "./recovery.js";
|
|
6
|
+
import type { BackgroundRegistry } from "./background-live.js";
|
|
6
7
|
import type { ChatHandlerDeps, StartAgentTurn, ChatHandlerContext } from "./ws-context.js";
|
|
7
8
|
/**
|
|
8
9
|
* The recovery nudge injected by a manual Continue (issue #301, Layer 2) — and,
|
|
@@ -70,4 +71,5 @@ export interface TurnEngine {
|
|
|
70
71
|
export declare function makeTurnEngine(engine: {
|
|
71
72
|
deps: ChatHandlerDeps;
|
|
72
73
|
hub: SessionHub;
|
|
74
|
+
background: BackgroundRegistry;
|
|
73
75
|
}): TurnEngine;
|
|
@@ -72,7 +72,7 @@ export function isSidechainMessage(m) {
|
|
|
72
72
|
* the herdctl schedule/wake resolvers and the trigger event listeners.
|
|
73
73
|
*/
|
|
74
74
|
export function makeTurnEngine(engine) {
|
|
75
|
-
const { deps, hub } = engine;
|
|
75
|
+
const { deps, hub, background } = engine;
|
|
76
76
|
// Trigger / schedule / event firing (ws-triggers.ts, #403). Built here so it can
|
|
77
77
|
// close over the shared startAgentTurn engine (a hoisted declaration below) and
|
|
78
78
|
// wire its herdctl schedule handler + onArchive/afterTurn listeners. ws.ts consumes
|
|
@@ -277,6 +277,10 @@ export function makeTurnEngine(engine) {
|
|
|
277
277
|
});
|
|
278
278
|
};
|
|
279
279
|
const onMessage = async (m) => {
|
|
280
|
+
// #604: observe task lifecycle before the sidechain filter — see the note on
|
|
281
|
+
// the foreground path. This lane is where a background task most often
|
|
282
|
+
// finishes, since the turn that launched it has already returned.
|
|
283
|
+
background.observe(projectSlug, m);
|
|
280
284
|
// (1) Skip sidechain sub-agent nested steps from rendering (see header). The
|
|
281
285
|
// attribution lives on the top-level SDK message OR its nested `message`.
|
|
282
286
|
if (isSidechainMessage(m))
|
|
@@ -316,6 +320,12 @@ export function makeTurnEngine(engine) {
|
|
|
316
320
|
deps.herdctl.unregisterBackgroundTurn(bgJobId);
|
|
317
321
|
bgJobId = null;
|
|
318
322
|
registeredSession = null;
|
|
323
|
+
// #604: the stream ending IS the reap — the session's process is gone, so
|
|
324
|
+
// its background set can no longer change and anything still listed is dead.
|
|
325
|
+
// The SDK's level signal is per-process and emits nothing at startup, so
|
|
326
|
+
// this reset is what stops a stale row surviving into the next session.
|
|
327
|
+
if (resolvedSession)
|
|
328
|
+
background.clear(resolvedSession);
|
|
319
329
|
if (!turn)
|
|
320
330
|
return;
|
|
321
331
|
turn.emit({
|
|
@@ -533,6 +543,12 @@ export function makeTurnEngine(engine) {
|
|
|
533
543
|
// SAME `session_id` as the main chat (verified against on-disk transcripts),
|
|
534
544
|
// and the launching `tool_use` message is itself main-stream, so it always
|
|
535
545
|
// resolves the id before any sidechain step arrives.
|
|
546
|
+
//
|
|
547
|
+
// #604: the SDK's task-lifecycle system messages are session-level rather
|
|
548
|
+
// than rendered transcript, so they are observed BEFORE this filter — the
|
|
549
|
+
// registry must see a sub-agent's own lifecycle even though its steps are
|
|
550
|
+
// deliberately not rendered here.
|
|
551
|
+
background.observe(projectSlug, m);
|
|
536
552
|
if (isSidechainMessage(m))
|
|
537
553
|
return;
|
|
538
554
|
if (m.session_id) {
|