@edspencer/paddock 0.68.0 → 0.69.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 (77) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/background-live.d.ts +124 -0
  3. package/packages/server/dist/background-live.js +226 -0
  4. package/packages/server/dist/ws-protocol.d.ts +61 -1
  5. package/packages/server/dist/ws-turn.d.ts +2 -0
  6. package/packages/server/dist/ws-turn.js +17 -1
  7. package/packages/server/dist/ws.js +72 -13
  8. package/packages/server/package.json +1 -1
  9. package/packages/web/dist/assets/{Chip-DIdRqcqU.js → Chip-B8UU8Zpr.js} +1 -1
  10. package/packages/web/dist/assets/DiscoverPage-vz1s8GYQ.js +2 -0
  11. package/packages/web/dist/assets/DiscoverView-CKN93BnT.js +2 -0
  12. package/packages/web/dist/assets/{InstanceConfigPage-D01x1BUg.js → InstanceConfigPage-COc2rsu6.js} +1 -1
  13. package/packages/web/dist/assets/{MarkdownRenderer-BCheNET2.js → MarkdownRenderer-BlUYSdHR.js} +1 -1
  14. package/packages/web/dist/assets/{ProjectMenu-BqlUQuxM.js → ProjectMenu-Davm1-P7.js} +1 -1
  15. package/packages/web/dist/assets/{ProjectRedirect-D8QDxYLG.js → ProjectRedirect-COrC7Yvg.js} +1 -1
  16. package/packages/web/dist/assets/ProjectView-WIODXM93.js +13 -0
  17. package/packages/web/dist/assets/{ProjectsGrid-yd18DXdV.js → ProjectsGrid-CfDehr5s.js} +1 -1
  18. package/packages/web/dist/assets/RootHome-DniHfYAw.js +2 -0
  19. package/packages/web/dist/assets/{Surfaces-DvRztndg.js → Surfaces-GzOkJJHQ.js} +1 -1
  20. package/packages/web/dist/assets/{arc-DFqBni1E.js → arc-_I2xwzbW.js} +1 -1
  21. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DXFeq3pL.js → architectureDiagram-3BPJPVTR-GW4T_vCB.js} +1 -1
  22. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DpeYsIwP.js → blockDiagram-GPEHLZMM-rpMzVG3O.js} +1 -1
  23. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Bw5r8wjW.js → c4Diagram-AAUBKEIU-CwxpNlW3.js} +1 -1
  24. package/packages/web/dist/assets/channel-nPAjQX8p.js +2 -0
  25. package/packages/web/dist/assets/{chunk-2J33WTMH-B0SAvGOr.js → chunk-2J33WTMH-BG9SyFJ-.js} +1 -1
  26. package/packages/web/dist/assets/{chunk-4BX2VUAB-BaUGs1hD.js → chunk-4BX2VUAB-DJlbnTd4.js} +1 -1
  27. package/packages/web/dist/assets/{chunk-55IACEB6-BDHRluF5.js → chunk-55IACEB6-C-VMlU4m.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-727SXJPM-BX93DBZO.js → chunk-727SXJPM-Wigu1170.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-AQP2D5EJ-3RiQGxW2.js → chunk-AQP2D5EJ-C5CFWtjs.js} +1 -1
  30. package/packages/web/dist/assets/{chunk-FMBD7UC4-DMkjv9SP.js → chunk-FMBD7UC4-hQaZU9Hi.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHiQfq4F.js → chunk-ND2GUHAM-AMO00QGi.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-QZHKN3VN-B1wCSHbM.js → chunk-QZHKN3VN-T3F7CT-B.js} +1 -1
  33. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuI-4XNG.js +2 -0
  34. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuI-4XNG.js +2 -0
  35. package/packages/web/dist/assets/{core-C-49RP59.js → core-D1JA8p71.js} +1 -1
  36. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIwwgSTZ.js → cose-bilkent-S5V4N54A-C_fPZY7X.js} +1 -1
  37. package/packages/web/dist/assets/{dagre-BM42HDAG-BN45kshV.js → dagre-BM42HDAG-nLjMp3c7.js} +1 -1
  38. package/packages/web/dist/assets/{diagram-2AECGRRQ-C5NekbfR.js → diagram-2AECGRRQ-qYzVcYYg.js} +1 -1
  39. package/packages/web/dist/assets/{diagram-5GNKFQAL-De7wdssd.js → diagram-5GNKFQAL-DVFKMWv0.js} +1 -1
  40. package/packages/web/dist/assets/{diagram-KO2AKTUF-CgG0UA0n.js → diagram-KO2AKTUF-B8T7qnCR.js} +1 -1
  41. package/packages/web/dist/assets/{diagram-LMA3HP47-DC-bDqxV.js → diagram-LMA3HP47-9-_-MiG8.js} +1 -1
  42. package/packages/web/dist/assets/{diagram-OG6HWLK6-XXfHDMhE.js → diagram-OG6HWLK6-D4eE55Vg.js} +1 -1
  43. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CHEd8JiS.js → erDiagram-TEJ5UH35-adlALhlq.js} +1 -1
  44. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BbuYxd15.js → flowDiagram-I6XJVG4X-jhJEXK0D.js} +1 -1
  45. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BkF77xn5.js → ganttDiagram-6RSMTGT7-CHGNR8Fk.js} +1 -1
  46. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BdL5zVez.js → gitGraphDiagram-PVQCEYII-tBQ_60zD.js} +1 -1
  47. package/packages/web/dist/assets/{index-DYccdR9x.css → index-CJT1g4_p.css} +1 -1
  48. package/packages/web/dist/assets/{index-C3MdCCzF.js → index-Djl4krRr.js} +14 -14
  49. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-vhTRATYg.js → infoDiagram-5YYISTIA-D_LJZit4.js} +1 -1
  50. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CxjN-7YF.js → ishikawaDiagram-YF4QCWOH-CLsVMtFo.js} +1 -1
  51. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DJ414TaX.js → journeyDiagram-JHISSGLW-DKLfsczG.js} +1 -1
  52. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gaQeFRvZ.js → kanban-definition-UN3LZRKU-CG1dRRin.js} +1 -1
  53. package/packages/web/dist/assets/{linear-CcvdFgxd.js → linear-Bc0TsEWL.js} +1 -1
  54. package/packages/web/dist/assets/{mermaid.core-Es6hedDB.js → mermaid.core-7NBz7_-Q.js} +4 -4
  55. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dk7qf3FR.js → mindmap-definition-RKZ34NQL-DD-2NsoA.js} +1 -1
  56. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CSWXwU6d.js → pieDiagram-4H26LBE5-BTYpRP3j.js} +1 -1
  57. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CBVdURbr.js → quadrantDiagram-W4KKPZXB-DScmlT0o.js} +1 -1
  58. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CbU18IZA.js → requirementDiagram-4Y6WPE33-BzLuYCY3.js} +1 -1
  59. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BeAlNgjE.js → sankeyDiagram-5OEKKPKP-CH9nUHfL.js} +1 -1
  60. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CKVKzxTs.js → sequenceDiagram-3UESZ5HK-vQjaE3Ay.js} +1 -1
  61. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-aSaO4W.js → stateDiagram-AJRCARHV-C76FMvr4.js} +1 -1
  62. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CWy9AQvb.js +2 -0
  63. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-CP9JHEBr.js → timeline-definition-PNZ67QCA-zjhYOHh7.js} +1 -1
  64. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dbtwwvvd.js → vennDiagram-CIIHVFJN-BpyreyjG.js} +1 -1
  65. package/packages/web/dist/assets/{wardley-L42UT6IY-NonvEhoT.js → wardley-L42UT6IY-CugQjZZ8.js} +1 -1
  66. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CDnpv9Ws.js → wardleyDiagram-YWT4CUSO-CTpxg9iQ.js} +1 -1
  67. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C1i-Wk0H.js → xychartDiagram-2RQKCTM6-COJLOF01.js} +1 -1
  68. package/packages/web/dist/index.html +2 -2
  69. package/packages/web/dist/sw.js +1 -1
  70. package/packages/web/dist/assets/DiscoverPage-BmCWnkjN.js +0 -2
  71. package/packages/web/dist/assets/DiscoverView-DwhtBdN2.js +0 -2
  72. package/packages/web/dist/assets/ProjectView-cevSYdcl.js +0 -13
  73. package/packages/web/dist/assets/RootHome-DZEkOjko.js +0 -2
  74. package/packages/web/dist/assets/channel-DU94S7yB.js +0 -2
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BcRv1OPp.js +0 -2
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BcRv1OPp.js +0 -2
  77. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-TptFh32C.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -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
+ }
@@ -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) {
@@ -57,6 +57,7 @@ import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
57
57
  import { consumeResumedTurn } from "./resume-drain.js";
58
58
  import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
59
59
  import { SessionHub } from "./session-hub.js";
60
+ import { BackgroundRegistry } from "./background-live.js";
60
61
  import { hasQueuedContent, partsOf, } from "./queued-message.js";
61
62
  import { resolveAttachmentsConfig } from "./attachments-config.js";
62
63
  import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
@@ -208,18 +209,13 @@ export function makeChatHandler(deps) {
208
209
  // broadcast to all clients — powering the per-chat sidebar streaming dots that
209
210
  // must update even for chats whose pane isn't mounted (issue #53).
210
211
  const clients = new Set();
211
- const activeFrame = (info) => ({
212
- type: "chat:active",
213
- payload: {
214
- projectSlug: info.projectSlug,
215
- sessionId: info.sessionId,
216
- jobId: info.jobId,
217
- running: info.running,
218
- startedAt: info.startedAt,
219
- },
220
- });
221
- hub.onActive = (info) => {
222
- const data = JSON.stringify(activeFrame(info));
212
+ /**
213
+ * Live background work per session (#604). Owned here because this is the
214
+ * layer that can broadcast; fed by the turn engine from both message lanes.
215
+ */
216
+ const background = new BackgroundRegistry();
217
+ const broadcast = (frame) => {
218
+ const data = JSON.stringify(frame);
223
219
  for (const c of clients) {
224
220
  if (c.readyState === c.OPEN) {
225
221
  try {
@@ -231,6 +227,57 @@ export function makeChatHandler(deps) {
231
227
  }
232
228
  }
233
229
  };
230
+ /**
231
+ * #604: a session is busy while it holds background work, even though the
232
+ * turn that launched it has returned. `SessionHub` tracks one turn and cannot
233
+ * know this, so the join happens here — the only place that sees both. Every
234
+ * consumer of `chat:active.running` (sidebar dots, the Home in-flight badge,
235
+ * the running-only filter, the client's own `streaming` gate) then reads the
236
+ * truth instead of "the primary turn ended".
237
+ */
238
+ const activeFrame = (info) => ({
239
+ type: "chat:active",
240
+ payload: {
241
+ projectSlug: info.projectSlug,
242
+ sessionId: info.sessionId,
243
+ jobId: info.jobId,
244
+ running: info.running || background.isBusy(info.sessionId),
245
+ startedAt: info.startedAt,
246
+ turnRunning: info.running,
247
+ },
248
+ });
249
+ /**
250
+ * Last `running` we published per session, so a background-set change only
251
+ * re-emits `chat:active` when it actually flips the answer. Without this every
252
+ * task start/stop would broadcast a redundant frame to every socket.
253
+ */
254
+ const lastPublishedRunning = new Map();
255
+ hub.onActive = (info) => {
256
+ const frame = activeFrame(info);
257
+ lastPublishedRunning.set(info.sessionId, frame.payload.running);
258
+ broadcast(frame);
259
+ };
260
+ background.onChange = (projectSlug, sessionId, tasks) => {
261
+ broadcast({ type: "chat:background", payload: { projectSlug, sessionId, tasks } });
262
+ // The set changing can flip a session between idle and busy while the hub's
263
+ // own turn state is unchanged — that transition is exactly #604.
264
+ const info = hub.activeInfo(sessionId);
265
+ const running = (info?.running ?? false) || tasks.length > 0;
266
+ if (lastPublishedRunning.get(sessionId) === running)
267
+ return;
268
+ lastPublishedRunning.set(sessionId, running);
269
+ broadcast({
270
+ type: "chat:active",
271
+ payload: {
272
+ projectSlug,
273
+ sessionId,
274
+ jobId: info?.jobId ?? null,
275
+ running,
276
+ startedAt: info?.startedAt,
277
+ turnRunning: info?.running ?? false,
278
+ },
279
+ });
280
+ };
234
281
  // Drive scheduler-fired session wakes onto the hub (Paddock#111 gap 3). When a
235
282
  // keeper scheduled a `ScheduleWakeup` / `/loop`, herdctl reaps the idle session
236
283
  // and later resumes it at fire time, handing us the live (managed) session with
@@ -332,6 +379,8 @@ export function makeChatHandler(deps) {
332
379
  };
333
380
  const onWakeMessage = async (m) => {
334
381
  // A sub-agent's nested steps never render top-level ({@link isSidechainMessage}).
382
+ // #604: task lifecycle is session-level — observe before the filter.
383
+ background.observe(slug, m);
335
384
  if (isSidechainMessage(m))
336
385
  return;
337
386
  if (messageProducedReply(m))
@@ -395,7 +444,7 @@ export function makeChatHandler(deps) {
395
444
  // wake cache, the shared startAgentTurn engine, and Layer-2/3 recovery. Relocated
396
445
  // as one scope so its cross-references stay intact; the socket layer below
397
446
  // consumes the returned surface.
398
- const engine = makeTurnEngine({ deps, hub });
447
+ const engine = makeTurnEngine({ deps, hub, background });
399
448
  const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
400
449
  /**
401
450
  * Build the chat operations for ONE connection: the handlers behind every
@@ -942,6 +991,9 @@ export function makeChatHandler(deps) {
942
991
  // human `chat:send` path, so it is where the duplication was actually
943
992
  // seen. Skipping also keeps a sub-agent's context out of the parent's
944
993
  // live meter (`foldTurnUsage` below would latch its max — the #398 shape).
994
+ // #604: task lifecycle is session-level, not rendered transcript —
995
+ // observe it before the sidechain filter.
996
+ background.observe(slug, m);
945
997
  if (isSidechainMessage(m))
946
998
  return;
947
999
  // Capture the session id as it arrives mid-stream (the translator
@@ -1182,6 +1234,9 @@ export function makeChatHandler(deps) {
1182
1234
  onMessage: async (m) => {
1183
1235
  // A slash-command turn can spawn a Task too; its nested steps never
1184
1236
  // render top-level ({@link isSidechainMessage}).
1237
+ // #604: task lifecycle is session-level, not rendered transcript —
1238
+ // observe it before the sidechain filter.
1239
+ background.observe(slug, m);
1185
1240
  if (isSidechainMessage(m))
1186
1241
  return;
1187
1242
  if (m.session_id) {
@@ -1384,6 +1439,10 @@ export function makeChatHandler(deps) {
1384
1439
  clients.add(socket);
1385
1440
  for (const info of hub.runningSessions())
1386
1441
  send(activeFrame(info));
1442
+ // #604: and on what background work is in flight, so a remounted pane's
1443
+ // pinned bar is populated from the first paint rather than after a poll.
1444
+ for (const s of background.snapshot())
1445
+ send({ type: "chat:background", payload: { ...s } });
1387
1446
  // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
1388
1447
  // client whose TCP has silently died (idle drop, sleep) fails to pong and is
1389
1448
  // terminated on the next tick — freeing server resources and letting the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paddock/server",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }