@bridge4dev/runner 0.63.0 → 0.65.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.
@@ -0,0 +1,113 @@
1
+ import type { AgentEvent, AgentTask } from './types.js';
2
+ /** Longest a task title may be before the tray truncates it. */
3
+ export declare const TASK_TITLE_LIMIT = 120;
4
+ /** How many live tasks travel in one `agent_tasks` event. */
5
+ export declare const TASK_LIST_CAP = 20;
6
+ /**
7
+ * Floor between two `agent_tasks` publications.
8
+ *
9
+ * A twenty-agent fan-out changes membership dozens of times a minute and every
10
+ * running subagent adds a `task_progress` every ~30s. Each event is a row in
11
+ * `DevSessionEvent`, so the tray coalesces rather than narrating.
12
+ */
13
+ export declare const TASK_PUBLISH_INTERVAL_MS = 1500;
14
+ export type AgentTasksEvent = Extract<AgentEvent, {
15
+ type: 'agent_tasks';
16
+ }>;
17
+ export interface AgentTaskTrayOptions {
18
+ emit: (event: AgentTasksEvent) => void;
19
+ /**
20
+ * Read on every publication rather than told once: an adapter can end
21
+ * without passing through its own `stop()` (the Claude CLI exiting on its
22
+ * own), and a trailing timer must not fire into a closed output queue then.
23
+ */
24
+ isStopped: () => boolean;
25
+ }
26
+ export declare class AgentTaskTray {
27
+ private readonly opts;
28
+ private readonly tasks;
29
+ /** Ids currently in the agent's live set – the tray shows exactly these. */
30
+ private liveIds;
31
+ /**
32
+ * Which turn is being counted (#147).
33
+ *
34
+ * There are no `started` / `done` accumulators, and that is the whole fix.
35
+ * Two counters with different lifetimes could disagree, and did: the turn's
36
+ * end zeroed both while deliberately keeping the ROWS of tasks still running,
37
+ * so a background shell that outlived its turn was counted as finished in
38
+ * the next one without ever having been counted as started – «22 of 21
39
+ * done». Both numbers are derived from `tasks` in one pass, so `done <= total`
40
+ * holds by cardinality rather than by clamping.
41
+ */
42
+ private turnEpoch;
43
+ private publishTimer;
44
+ private publishedAt;
45
+ /** Last published snapshot, minus the ages – see `flush` (QA-111 M4). */
46
+ private lastFingerprint;
47
+ constructor(opts: AgentTaskTrayOptions);
48
+ has(id: string): boolean;
49
+ /**
50
+ * Replace the live set. REPLACE, not merge: the agent's own statement of
51
+ * what exists is the authority, so a missed start/stop pair cannot wedge a
52
+ * row in the tray forever.
53
+ */
54
+ setLive(ids: readonly string[]): void;
55
+ /**
56
+ * Drop a row and its place in the live set. Answers whether the live set
57
+ * changed, which is what decides that a frame is worth publishing.
58
+ */
59
+ forget(id: string): boolean;
60
+ /**
61
+ * Create-or-merge one task row, and count the TRANSITIONS while doing it.
62
+ *
63
+ * The counters live here rather than in the individual message handlers
64
+ * because «started» and «finished» are properties of the transition, not of
65
+ * whichever message happened to announce it (QA-111 M1). They were counted
66
+ * per-message, and both orderings the Claude SDK actually produces missed:
67
+ *
68
+ * `background_tasks_changed` → `task_started` → `total` stayed 0
69
+ * `task_updated{completed}` → `task_notification` → `done` stayed 0
70
+ *
71
+ * `startedAt` is stamped once and never moves.
72
+ */
73
+ touch(id: string, patch: Partial<AgentTask>): void;
74
+ /**
75
+ * The empty set, said once when the agent's process (re)starts (#113).
76
+ *
77
+ * The level is per PROCESS: the dashboard reads the newest `agent_tasks` in
78
+ * the stored feed, so without this a session killed mid-turn – the runner
79
+ * restarted, the machine rebooted – would come back showing the helpers of
80
+ * its previous life as though they were still running, right up until the
81
+ * next membership change.
82
+ */
83
+ announceEmpty(): void;
84
+ /**
85
+ * Publish the tray, at most once every `TASK_PUBLISH_INTERVAL_MS`.
86
+ *
87
+ * Every publication is a stored row in the session feed, and a wide fan-out
88
+ * changes membership dozens of times a minute. The trailing timer matters as
89
+ * much as the floor: the LAST change in a burst is the one that says the work
90
+ * is over, and dropping it would leave the tray running forever.
91
+ */
92
+ publish(): void;
93
+ /**
94
+ * The turn ended. Reset what belongs to the TURN – and only that.
95
+ *
96
+ * `done`/`total` are per-turn by definition and go back to zero. The live set
97
+ * does NOT: background work is precisely the work that outlives the turn that
98
+ * started it, which is the whole reason the tray exists. Caught on production
99
+ * (#113): the agent ended its turn with `sleep 40` still running in the
100
+ * background and a tray that cleared everything here showed nothing at
101
+ * exactly the moment somebody was reading the answer and wondering whether
102
+ * the deploy had finished.
103
+ *
104
+ * Finished rows are dropped here because only the live ids are ever rendered
105
+ * anyway, and keeping them would grow the map for the life of the session.
106
+ */
107
+ endTurn(): void;
108
+ /** A pending publication would fire into a closed output queue. */
109
+ close(): void;
110
+ private clearTimer;
111
+ private flush;
112
+ }
113
+ //# sourceMappingURL=agent-tasks.d.ts.map
@@ -0,0 +1,260 @@
1
+ import { clip } from './questions.js';
2
+ // The task tray every adapter publishes through (ticket #113, made common for
3
+ // #382).
4
+ //
5
+ // «Is anybody still working beside the conversation» is ONE rule: the
6
+ // supervisor counts the running rows of an `agent_tasks` frame, keeps the
7
+ // number on the session and sends it with every status, and every reader on
8
+ // the API and the dashboard asks `@devbridge/shared` about it. No reader
9
+ // branches on the agent. What differs between CLIs is only how each one SAYS
10
+ // that a helper started or stopped – so an adapter's own code decides
11
+ // membership, and this file decides everything about publishing it: the floor
12
+ // between frames, the de-duplication, the cap on the list, the per-turn
13
+ // counters and the frame at the end of a turn. It used to live inside the
14
+ // Claude adapter, which is why Codex never had it (#382).
15
+ //
16
+ // A new CLI joins by translating its own events into `touch` / `setLive` /
17
+ // `endTurn`, and nothing else: see `docs/devbridge/knowledge/10-agent-profiles.md`.
18
+ /** Longest a task title may be before the tray truncates it. */
19
+ export const TASK_TITLE_LIMIT = 120;
20
+ /** How many live tasks travel in one `agent_tasks` event. */
21
+ export const TASK_LIST_CAP = 20;
22
+ /**
23
+ * Floor between two `agent_tasks` publications.
24
+ *
25
+ * A twenty-agent fan-out changes membership dozens of times a minute and every
26
+ * running subagent adds a `task_progress` every ~30s. Each event is a row in
27
+ * `DevSessionEvent`, so the tray coalesces rather than narrating.
28
+ */
29
+ export const TASK_PUBLISH_INTERVAL_MS = 1_500;
30
+ const EMPTY_FINGERPRINT = JSON.stringify({ done: 0, total: 0, tasks: [] });
31
+ export class AgentTaskTray {
32
+ opts;
33
+ tasks = new Map();
34
+ /** Ids currently in the agent's live set – the tray shows exactly these. */
35
+ liveIds = [];
36
+ /**
37
+ * Which turn is being counted (#147).
38
+ *
39
+ * There are no `started` / `done` accumulators, and that is the whole fix.
40
+ * Two counters with different lifetimes could disagree, and did: the turn's
41
+ * end zeroed both while deliberately keeping the ROWS of tasks still running,
42
+ * so a background shell that outlived its turn was counted as finished in
43
+ * the next one without ever having been counted as started – «22 of 21
44
+ * done». Both numbers are derived from `tasks` in one pass, so `done <= total`
45
+ * holds by cardinality rather than by clamping.
46
+ */
47
+ turnEpoch = 0;
48
+ publishTimer = null;
49
+ publishedAt = 0;
50
+ /** Last published snapshot, minus the ages – see `flush` (QA-111 M4). */
51
+ lastFingerprint = '';
52
+ constructor(opts) {
53
+ this.opts = opts;
54
+ }
55
+ has(id) {
56
+ return this.tasks.has(id);
57
+ }
58
+ /**
59
+ * Replace the live set. REPLACE, not merge: the agent's own statement of
60
+ * what exists is the authority, so a missed start/stop pair cannot wedge a
61
+ * row in the tray forever.
62
+ */
63
+ setLive(ids) {
64
+ this.liveIds = [...ids];
65
+ }
66
+ /**
67
+ * Drop a row and its place in the live set. Answers whether the live set
68
+ * changed, which is what decides that a frame is worth publishing.
69
+ */
70
+ forget(id) {
71
+ this.tasks.delete(id);
72
+ const before = this.liveIds.length;
73
+ this.liveIds = this.liveIds.filter((live) => live !== id);
74
+ return this.liveIds.length !== before;
75
+ }
76
+ /**
77
+ * Create-or-merge one task row, and count the TRANSITIONS while doing it.
78
+ *
79
+ * The counters live here rather than in the individual message handlers
80
+ * because «started» and «finished» are properties of the transition, not of
81
+ * whichever message happened to announce it (QA-111 M1). They were counted
82
+ * per-message, and both orderings the Claude SDK actually produces missed:
83
+ *
84
+ * `background_tasks_changed` → `task_started` → `total` stayed 0
85
+ * `task_updated{completed}` → `task_notification` → `done` stayed 0
86
+ *
87
+ * `startedAt` is stamped once and never moves.
88
+ */
89
+ touch(id, patch) {
90
+ const existing = this.tasks.get(id);
91
+ if (existing) {
92
+ const settledAs = existing.status === 'running' ? null : existing.status;
93
+ Object.assign(existing, patch);
94
+ if (patch.title)
95
+ existing.title = clip(patch.title, TASK_TITLE_LIMIT);
96
+ if (patch.summary)
97
+ existing.summary = clip(patch.summary, TASK_TITLE_LIMIT);
98
+ // Settling is one-way. A task that has stopped cannot start again under
99
+ // the same id: the only messages that could say so are a late update
100
+ // carrying a status the adapter does not recognise, or a frame
101
+ // redelivered after a reconnect – neither of which is news that the work
102
+ // resumed. Letting either through would make the derived `done` count
103
+ // downwards on screen. An agent that genuinely hands the same helper new
104
+ // work (Codex does) says so by `forget` first: that is a new row.
105
+ if (settledAs && existing.status === 'running')
106
+ existing.status = settledAs;
107
+ return;
108
+ }
109
+ this.tasks.set(id, {
110
+ ...patch,
111
+ id,
112
+ kind: patch.kind ?? 'task',
113
+ title: clip(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
114
+ status: patch.status ?? 'running',
115
+ turnEpoch: this.turnEpoch,
116
+ startedAt: Date.now(),
117
+ // The only free-text field an agent writes with no length of its own:
118
+ // a subagent's closing `summary` runs to kilobytes, and twenty of them
119
+ // push the event past the payload cap, which replaces the WHOLE payload
120
+ // with `{truncated:true}` and blinks the tray out (QA-111 m1).
121
+ ...(patch.summary ? { summary: clip(patch.summary, TASK_TITLE_LIMIT) } : {}),
122
+ });
123
+ }
124
+ /**
125
+ * The empty set, said once when the agent's process (re)starts (#113).
126
+ *
127
+ * The level is per PROCESS: the dashboard reads the newest `agent_tasks` in
128
+ * the stored feed, so without this a session killed mid-turn – the runner
129
+ * restarted, the machine rebooted – would come back showing the helpers of
130
+ * its previous life as though they were still running, right up until the
131
+ * next membership change.
132
+ */
133
+ announceEmpty() {
134
+ this.lastFingerprint = EMPTY_FINGERPRINT;
135
+ this.opts.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
136
+ }
137
+ /**
138
+ * Publish the tray, at most once every `TASK_PUBLISH_INTERVAL_MS`.
139
+ *
140
+ * Every publication is a stored row in the session feed, and a wide fan-out
141
+ * changes membership dozens of times a minute. The trailing timer matters as
142
+ * much as the floor: the LAST change in a burst is the one that says the work
143
+ * is over, and dropping it would leave the tray running forever.
144
+ */
145
+ publish() {
146
+ if (this.opts.isStopped())
147
+ return;
148
+ const wait = TASK_PUBLISH_INTERVAL_MS - (Date.now() - this.publishedAt);
149
+ if (wait > 0) {
150
+ if (!this.publishTimer) {
151
+ this.publishTimer = setTimeout(() => {
152
+ this.publishTimer = null;
153
+ this.flush();
154
+ }, wait);
155
+ this.publishTimer.unref();
156
+ }
157
+ return;
158
+ }
159
+ this.flush();
160
+ }
161
+ /**
162
+ * The turn ended. Reset what belongs to the TURN – and only that.
163
+ *
164
+ * `done`/`total` are per-turn by definition and go back to zero. The live set
165
+ * does NOT: background work is precisely the work that outlives the turn that
166
+ * started it, which is the whole reason the tray exists. Caught on production
167
+ * (#113): the agent ended its turn with `sleep 40` still running in the
168
+ * background and a tray that cleared everything here showed nothing at
169
+ * exactly the moment somebody was reading the answer and wondering whether
170
+ * the deploy had finished.
171
+ *
172
+ * Finished rows are dropped here because only the live ids are ever rendered
173
+ * anyway, and keeping them would grow the map for the life of the session.
174
+ */
175
+ endTurn() {
176
+ this.clearTimer();
177
+ const live = new Set(this.liveIds);
178
+ for (const id of [...this.tasks.keys()]) {
179
+ if (!live.has(id))
180
+ this.tasks.delete(id);
181
+ }
182
+ this.turnEpoch += 1;
183
+ // Straight through `flush` rather than an empty frame of its own, and with
184
+ // the de-duplication disarmed for this one frame (plan `workflow-mode-fixes`
185
+ // S1 p.10). The turn's counters usually changed, but not always: a task
186
+ // started in an earlier turn and still running, and a turn that started
187
+ // nothing new, produce exactly the frame the last turn ended on – and the
188
+ // end of a turn is the moment the API and the tray most need to hear what
189
+ // is still running, whether or not it is news. The supervisor
190
+ // de-duplicates the COUNT on its own side; this frame is the tray's
191
+ // freshness, not the database's.
192
+ this.publishedAt = 0;
193
+ this.lastFingerprint = '';
194
+ this.flush();
195
+ }
196
+ /** A pending publication would fire into a closed output queue. */
197
+ close() {
198
+ this.clearTimer();
199
+ }
200
+ clearTimer() {
201
+ if (!this.publishTimer)
202
+ return;
203
+ clearTimeout(this.publishTimer);
204
+ this.publishTimer = null;
205
+ }
206
+ flush() {
207
+ if (this.opts.isStopped())
208
+ return;
209
+ this.publishedAt = Date.now();
210
+ const now = Date.now();
211
+ // Only what the agent still calls live. A task that finished keeps its row
212
+ // in `tasks` for the counters, but the tray is about NOW.
213
+ const tasks = this.liveIds
214
+ .map((id) => this.tasks.get(id))
215
+ .filter((t) => Boolean(t))
216
+ .slice(0, TASK_LIST_CAP)
217
+ // How long it has been running, measured HERE (QA-111 m2). `startedAt` is
218
+ // this machine's clock and the browser's is a different one – subtracting
219
+ // across them put the dev server's clock skew straight into the number,
220
+ // so a host ten minutes behind showed «10m 03s» on a task one second old.
221
+ .map(({ turnEpoch: _turnEpoch, ...task }) => ({
222
+ ...task,
223
+ ageMs: Math.max(0, now - task.startedAt),
224
+ }));
225
+ // Both numbers, one pass, one map (#147). `done` counts a SUBSET of what
226
+ // `total` counts, so `done <= total` is a property of set cardinality and
227
+ // cannot be broken by a message ordering, a redelivery, a re-title, a row
228
+ // being deleted or a turn boundary.
229
+ //
230
+ // Tasks that outlived an earlier turn keep that turn's epoch: they still
231
+ // render as live rows – background work outliving its turn is the whole
232
+ // point of the tray – but they belong to neither number here.
233
+ let total = 0;
234
+ let done = 0;
235
+ for (const task of this.tasks.values()) {
236
+ if (task.turnEpoch !== this.turnEpoch)
237
+ continue;
238
+ total += 1;
239
+ if (task.status !== 'running')
240
+ done += 1;
241
+ }
242
+ const payload = { type: 'agent_tasks', tasks, done, total };
243
+ // A frame that says exactly what the last one said is not worth a row in
244
+ // the session feed (QA-111 M4). Progress reports fire every ~30s per running
245
+ // subagent and usually carry nothing new, and with twenty of them that
246
+ // alone is the throttle's whole budget. `ageMs` is excluded from the
247
+ // comparison on purpose – it changes every time by definition, and including
248
+ // it would make every frame unique and the check pointless.
249
+ const fingerprint = JSON.stringify({
250
+ done: payload.done,
251
+ total: payload.total,
252
+ tasks: tasks.map(({ ageMs: _ageMs, ...rest }) => rest),
253
+ });
254
+ if (fingerprint === this.lastFingerprint)
255
+ return;
256
+ this.lastFingerprint = fingerprint;
257
+ this.opts.emit(payload);
258
+ }
259
+ }
260
+ //# sourceMappingURL=agent-tasks.js.map