@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.
- package/dist/adapters/agent-tasks.d.ts +113 -0
- package/dist/adapters/agent-tasks.js +260 -0
- package/dist/adapters/claude.js +45 -236
- package/dist/adapters/codex-subagents.d.ts +169 -0
- package/dist/adapters/codex-subagents.js +569 -0
- package/dist/adapters/codex.d.ts +4 -0
- package/dist/adapters/codex.js +194 -18
- package/dist/checkpoints.js +5 -13
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +23 -0
- package/dist/git.d.ts +36 -0
- package/dist/git.js +326 -4
- package/dist/index.js +5 -0
- package/dist/supervisor.d.ts +20 -0
- package/dist/supervisor.js +83 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|