@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.
- package/package.json +1 -1
- package/packages/server/dist/background-live.d.ts +124 -0
- package/packages/server/dist/background-live.js +226 -0
- 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-DIdRqcqU.js → Chip-B8UU8Zpr.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-vz1s8GYQ.js +2 -0
- package/packages/web/dist/assets/DiscoverView-CKN93BnT.js +2 -0
- package/packages/web/dist/assets/{InstanceConfigPage-D01x1BUg.js → InstanceConfigPage-COc2rsu6.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-BCheNET2.js → MarkdownRenderer-BlUYSdHR.js} +1 -1
- package/packages/web/dist/assets/{ProjectMenu-BqlUQuxM.js → ProjectMenu-Davm1-P7.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D8QDxYLG.js → ProjectRedirect-COrC7Yvg.js} +1 -1
- package/packages/web/dist/assets/ProjectView-WIODXM93.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-yd18DXdV.js → ProjectsGrid-CfDehr5s.js} +1 -1
- package/packages/web/dist/assets/RootHome-DniHfYAw.js +2 -0
- package/packages/web/dist/assets/{Surfaces-DvRztndg.js → Surfaces-GzOkJJHQ.js} +1 -1
- package/packages/web/dist/assets/{arc-DFqBni1E.js → arc-_I2xwzbW.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DXFeq3pL.js → architectureDiagram-3BPJPVTR-GW4T_vCB.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DpeYsIwP.js → blockDiagram-GPEHLZMM-rpMzVG3O.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Bw5r8wjW.js → c4Diagram-AAUBKEIU-CwxpNlW3.js} +1 -1
- package/packages/web/dist/assets/channel-nPAjQX8p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-B0SAvGOr.js → chunk-2J33WTMH-BG9SyFJ-.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BaUGs1hD.js → chunk-4BX2VUAB-DJlbnTd4.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BDHRluF5.js → chunk-55IACEB6-C-VMlU4m.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BX93DBZO.js → chunk-727SXJPM-Wigu1170.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-3RiQGxW2.js → chunk-AQP2D5EJ-C5CFWtjs.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-DMkjv9SP.js → chunk-FMBD7UC4-hQaZU9Hi.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CHiQfq4F.js → chunk-ND2GUHAM-AMO00QGi.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-B1wCSHbM.js → chunk-QZHKN3VN-T3F7CT-B.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuI-4XNG.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuI-4XNG.js +2 -0
- package/packages/web/dist/assets/{core-C-49RP59.js → core-D1JA8p71.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIwwgSTZ.js → cose-bilkent-S5V4N54A-C_fPZY7X.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BN45kshV.js → dagre-BM42HDAG-nLjMp3c7.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-C5NekbfR.js → diagram-2AECGRRQ-qYzVcYYg.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-De7wdssd.js → diagram-5GNKFQAL-DVFKMWv0.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CgG0UA0n.js → diagram-KO2AKTUF-B8T7qnCR.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DC-bDqxV.js → diagram-LMA3HP47-9-_-MiG8.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-XXfHDMhE.js → diagram-OG6HWLK6-D4eE55Vg.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CHEd8JiS.js → erDiagram-TEJ5UH35-adlALhlq.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BbuYxd15.js → flowDiagram-I6XJVG4X-jhJEXK0D.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BkF77xn5.js → ganttDiagram-6RSMTGT7-CHGNR8Fk.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BdL5zVez.js → gitGraphDiagram-PVQCEYII-tBQ_60zD.js} +1 -1
- package/packages/web/dist/assets/{index-DYccdR9x.css → index-CJT1g4_p.css} +1 -1
- package/packages/web/dist/assets/{index-C3MdCCzF.js → index-Djl4krRr.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-vhTRATYg.js → infoDiagram-5YYISTIA-D_LJZit4.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CxjN-7YF.js → ishikawaDiagram-YF4QCWOH-CLsVMtFo.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DJ414TaX.js → journeyDiagram-JHISSGLW-DKLfsczG.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gaQeFRvZ.js → kanban-definition-UN3LZRKU-CG1dRRin.js} +1 -1
- package/packages/web/dist/assets/{linear-CcvdFgxd.js → linear-Bc0TsEWL.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Es6hedDB.js → mermaid.core-7NBz7_-Q.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dk7qf3FR.js → mindmap-definition-RKZ34NQL-DD-2NsoA.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CSWXwU6d.js → pieDiagram-4H26LBE5-BTYpRP3j.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CBVdURbr.js → quadrantDiagram-W4KKPZXB-DScmlT0o.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CbU18IZA.js → requirementDiagram-4Y6WPE33-BzLuYCY3.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BeAlNgjE.js → sankeyDiagram-5OEKKPKP-CH9nUHfL.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CKVKzxTs.js → sequenceDiagram-3UESZ5HK-vQjaE3Ay.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-B-aSaO4W.js → stateDiagram-AJRCARHV-C76FMvr4.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CWy9AQvb.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-CP9JHEBr.js → timeline-definition-PNZ67QCA-zjhYOHh7.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dbtwwvvd.js → vennDiagram-CIIHVFJN-BpyreyjG.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-NonvEhoT.js → wardley-L42UT6IY-CugQjZZ8.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CDnpv9Ws.js → wardleyDiagram-YWT4CUSO-CTpxg9iQ.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C1i-Wk0H.js → xychartDiagram-2RQKCTM6-COJLOF01.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-BmCWnkjN.js +0 -2
- package/packages/web/dist/assets/DiscoverView-DwhtBdN2.js +0 -2
- package/packages/web/dist/assets/ProjectView-cevSYdcl.js +0 -13
- package/packages/web/dist/assets/RootHome-DZEkOjko.js +0 -2
- package/packages/web/dist/assets/channel-DU94S7yB.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BcRv1OPp.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BcRv1OPp.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-TptFh32C.js +0 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|