@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.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/agent-errors.js +7 -1
  3. package/packages/server/dist/background-live.d.ts +124 -0
  4. package/packages/server/dist/background-live.js +226 -0
  5. package/packages/server/dist/cli/paddock.js +12 -7
  6. package/packages/server/dist/config.d.ts +7 -5
  7. package/packages/server/dist/config.js +0 -1
  8. package/packages/server/dist/instance-config.js +1 -2
  9. package/packages/server/dist/recovery-config.d.ts +0 -6
  10. package/packages/server/dist/recovery-config.js +0 -4
  11. package/packages/server/dist/ws-protocol.d.ts +61 -1
  12. package/packages/server/dist/ws-turn.d.ts +2 -0
  13. package/packages/server/dist/ws-turn.js +17 -1
  14. package/packages/server/dist/ws.js +72 -13
  15. package/packages/server/package.json +1 -1
  16. package/packages/web/dist/assets/{Chip-NMeHUE94.js → Chip-C8a-t439.js} +1 -1
  17. package/packages/web/dist/assets/DiscoverPage-C1khyCvD.js +2 -0
  18. package/packages/web/dist/assets/{DiscoverView-Bt4FUTjB.js → DiscoverView-DS_Z9xKH.js} +1 -1
  19. package/packages/web/dist/assets/{InstanceConfigPage-DIQJtIYC.js → InstanceConfigPage-B1ei1FDo.js} +1 -1
  20. package/packages/web/dist/assets/{MarkdownRenderer-BVMZB_mP.js → MarkdownRenderer-DCj_ZwVG.js} +1 -1
  21. package/packages/web/dist/assets/{ProjectMenu-D9Nsqjxy.js → ProjectMenu-B7dGZSFB.js} +1 -1
  22. package/packages/web/dist/assets/{ProjectRedirect-B8rJ9qqe.js → ProjectRedirect-bstvMvM9.js} +1 -1
  23. package/packages/web/dist/assets/ProjectView-iuiUIoIl.js +13 -0
  24. package/packages/web/dist/assets/{ProjectsGrid-CDqvd4bj.js → ProjectsGrid-DKvySDUQ.js} +1 -1
  25. package/packages/web/dist/assets/{RootHome-CmiJG9wb.js → RootHome-CD_37qb-.js} +1 -1
  26. package/packages/web/dist/assets/{Surfaces-c410wTbw.js → Surfaces-1dBayrsj.js} +1 -1
  27. package/packages/web/dist/assets/{arc-DN2jEPE9.js → arc-BJPb3ViJ.js} +1 -1
  28. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BLMFd81z.js → architectureDiagram-3BPJPVTR-PkLOC5wV.js} +1 -1
  29. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BweEECqE.js → blockDiagram-GPEHLZMM-IPduhNH-.js} +1 -1
  30. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DpN_9YFO.js → c4Diagram-AAUBKEIU-dKTYV1Vq.js} +1 -1
  31. package/packages/web/dist/assets/channel-BhrgMiqg.js +2 -0
  32. package/packages/web/dist/assets/{chunk-2J33WTMH-BxWEBoli.js → chunk-2J33WTMH-B5C6geyg.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-4BX2VUAB-C5nkcPRR.js → chunk-4BX2VUAB-1BRhFRhF.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-55IACEB6-B8P3CNX8.js → chunk-55IACEB6-DNv-sTiI.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-727SXJPM-D3yuzl-m.js → chunk-727SXJPM-CWezuVkx.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-AQP2D5EJ-CzWxcEIw.js → chunk-AQP2D5EJ-Bt4yEQnu.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-FMBD7UC4-CCB91otB.js → chunk-FMBD7UC4-lb_8q4JY.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-ND2GUHAM-BymMfuY4.js → chunk-ND2GUHAM-DOwgGlQ_.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-QZHKN3VN-CezHuNcf.js → chunk-QZHKN3VN-BanKpY4q.js} +1 -1
  40. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Ho1IMdjg.js +2 -0
  41. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Ho1IMdjg.js +2 -0
  42. package/packages/web/dist/assets/{core-LjceHm3Z.js → core-CAsyoNAa.js} +1 -1
  43. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BWhmWR_V.js → cose-bilkent-S5V4N54A-sVaVkwMI.js} +1 -1
  44. package/packages/web/dist/assets/{dagre-BM42HDAG-BTXWnJYV.js → dagre-BM42HDAG-C9pKHSMo.js} +1 -1
  45. package/packages/web/dist/assets/{diagram-2AECGRRQ-BY2LZdf0.js → diagram-2AECGRRQ-CHdrmCfX.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-5GNKFQAL-BZ78Imy8.js → diagram-5GNKFQAL-4UTa2Dfa.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-KO2AKTUF-BV4Ax_0D.js → diagram-KO2AKTUF-NidpfVwW.js} +1 -1
  48. package/packages/web/dist/assets/{diagram-LMA3HP47-1kcTal-d.js → diagram-LMA3HP47-ictQj03T.js} +1 -1
  49. package/packages/web/dist/assets/{diagram-OG6HWLK6-CX3RfHlO.js → diagram-OG6HWLK6-B7dTLMHC.js} +1 -1
  50. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BBS46D95.js → erDiagram-TEJ5UH35-C2QYYzJ8.js} +1 -1
  51. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CpPHahLZ.js → flowDiagram-I6XJVG4X-D2rcjSZ9.js} +1 -1
  52. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CxL_QCuL.js → ganttDiagram-6RSMTGT7-BQcXTYzg.js} +1 -1
  53. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BeTkPHaG.js → gitGraphDiagram-PVQCEYII-DwB2Vqxa.js} +1 -1
  54. package/packages/web/dist/assets/index-BNzTD4hA.css +1 -0
  55. package/packages/web/dist/assets/{index-DjhUG9sA.js → index-BeuEs7dL.js} +14 -14
  56. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CiMnl36I.js → infoDiagram-5YYISTIA-BoK4Mawg.js} +1 -1
  57. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CjASKQeF.js → ishikawaDiagram-YF4QCWOH-DPP9svhg.js} +1 -1
  58. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BZzehSqh.js → journeyDiagram-JHISSGLW-TrggodYk.js} +1 -1
  59. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Cn-l0UIy.js → kanban-definition-UN3LZRKU-Bh69hRSR.js} +1 -1
  60. package/packages/web/dist/assets/{linear-DyELsH8s.js → linear-C1iYMpBz.js} +1 -1
  61. package/packages/web/dist/assets/{mermaid.core-C1adXzqy.js → mermaid.core-BZDABcuq.js} +4 -4
  62. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DP6iSaA_.js → mindmap-definition-RKZ34NQL-DTJON3dS.js} +1 -1
  63. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DHpN8Fse.js → pieDiagram-4H26LBE5-BxtFV74I.js} +1 -1
  64. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DAfc21Kd.js → quadrantDiagram-W4KKPZXB-D2Ar8kmO.js} +1 -1
  65. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-X3FfIiGh.js → requirementDiagram-4Y6WPE33-DiLSfeSQ.js} +1 -1
  66. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Ch319LBM.js → sankeyDiagram-5OEKKPKP-D_CRQc7j.js} +1 -1
  67. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CAyajDJc.js → sequenceDiagram-3UESZ5HK-DjtfcRNV.js} +1 -1
  68. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C1G4309_.js → stateDiagram-AJRCARHV-CzEtmYLV.js} +1 -1
  69. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-pM8fIOir.js +2 -0
  70. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Bj5ihSj4.js → timeline-definition-PNZ67QCA-DXKspEG3.js} +1 -1
  71. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-WIZuVHvV.js → vennDiagram-CIIHVFJN-B_tXRMHH.js} +1 -1
  72. package/packages/web/dist/assets/{wardley-L42UT6IY-CwRQf2fI.js → wardley-L42UT6IY-DNaNoaJm.js} +1 -1
  73. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bug_Zcav.js → wardleyDiagram-YWT4CUSO-BuVDpC8s.js} +1 -1
  74. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Cw5xgfwo.js → xychartDiagram-2RQKCTM6-Cqtew2PA.js} +1 -1
  75. package/packages/web/dist/index.html +2 -2
  76. package/packages/web/dist/sw.js +1 -1
  77. package/packages/web/dist/assets/DiscoverPage-LY0CRBVz.js +0 -2
  78. package/packages/web/dist/assets/ProjectView-D-21ULHU.js +0 -13
  79. package/packages/web/dist/assets/channel-B_SiD-tA.js +0 -2
  80. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Detq0lSJ.js +0 -2
  81. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Detq0lSJ.js +0 -2
  82. package/packages/web/dist/assets/index-DYccdR9x.css +0 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C0-BEM28.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.68.1",
3
+ "version": "0.70.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -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 triggers)",
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: they run herdctl's SDK runtime, which resolves
82
- * the binary from the Claude Agent SDK's own platform package via
83
- * `require.resolve` and never consults PATH. But the post-turn sweeper and all
84
- * triggers are one-shot `trigger()` calls on the CLI runtime, which does
85
- * `execa("claude", …)` — by name, without execa's `preferLocal`, so a local
86
- * install is invisible to it unless PATH says otherwise.
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/trigger fails (logged, non-fatal).
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__{list,set,remove}_hook` tools that let a project agent
391
- * declare/edit/delete its own event hooks. A sibling of {@link selfMcpWriteEnabled}:
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 hook tools live on the same injected server, appended in the write
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 whole new projects, cloning a repo when repo-backed (needs self-MCP write).", type: "boolean", envVars: ["PADDOCK_SELF_MCP_PROJECTS"], default: false, editable: true, coerce: asBool },
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) {