@abelxiaoxing/cadence 2.0.2 → 2.0.3
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/README.md +45 -5
- package/docs/reuse-and-distribution.md +1 -1
- package/docs/service-management.md +15 -3
- package/package.json +5 -2
- package/src/agent-panel-cadence.ts +651 -0
- package/src/agent-panel-model.ts +521 -0
- package/src/agent-panel.ts +474 -0
- package/src/index.ts +12 -4
- package/src/native-client.ts +125 -14
- package/src/service/private-files.ts +18 -10
- package/src/service-cli.mjs +80 -26
- package/src/service-host.mjs +13 -11
- package/src/task-shell.ts +11 -8
- package/web/dist/assets/cadence.css +1 -1
- package/web/dist/assets/cadence.js +7 -7
- package/web/dist/manifest.json +6 -6
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/** Cadence service children on the background-agent panel. Observation only:
|
|
2
|
+
* a Pi TUI session attaches to the owner already running for its workspace
|
|
3
|
+
* (never starting one), polls the active roster and task snapshots, and
|
|
4
|
+
* announces each child run on Pi's `subagent:*` bus. Reads go through the
|
|
5
|
+
* typed ServiceClient; nothing here starts, cancels, retries or keeps work
|
|
6
|
+
* alive, and leaving the session only detaches. */
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { SUBAGENT_EVENTS, SUBAGENT_VIEW } from "./agent-panel-model.ts";
|
|
9
|
+
import { departure, NativeConnection } from "./native-client.ts";
|
|
10
|
+
import type { ServiceClient } from "./service-client.ts";
|
|
11
|
+
import {
|
|
12
|
+
type ContentRef,
|
|
13
|
+
type GraphReadRequest,
|
|
14
|
+
type HistoryRecord,
|
|
15
|
+
LIMITS,
|
|
16
|
+
type ListRequest,
|
|
17
|
+
type ReadCursor,
|
|
18
|
+
type RunProjection,
|
|
19
|
+
type Snapshot,
|
|
20
|
+
} from "./service-contract.ts";
|
|
21
|
+
|
|
22
|
+
/** The NativeConnection surface the feed uses; tests substitute it. */
|
|
23
|
+
export interface FeedConnection {
|
|
24
|
+
attach(cwd: string): Promise<FeedClient>;
|
|
25
|
+
refresh(): Promise<unknown>;
|
|
26
|
+
compatible(): boolean;
|
|
27
|
+
detach(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** Readonly ServiceClient reads only: no command, preflight or Cancel. */
|
|
30
|
+
export type FeedClient = Pick<
|
|
31
|
+
ServiceClient,
|
|
32
|
+
| "activeRuns"
|
|
33
|
+
| "snapshot"
|
|
34
|
+
| "graph"
|
|
35
|
+
| "messages"
|
|
36
|
+
| "tools"
|
|
37
|
+
| "contents"
|
|
38
|
+
| "detail"
|
|
39
|
+
>;
|
|
40
|
+
export interface CadenceFeedOptions {
|
|
41
|
+
connection?: () => FeedConnection;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
timing?: Partial<FeedTiming>;
|
|
44
|
+
}
|
|
45
|
+
export interface FeedTiming {
|
|
46
|
+
firstMs: number;
|
|
47
|
+
activeMs: number;
|
|
48
|
+
idleMs: number;
|
|
49
|
+
retryMs: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First look waits for Pi's startup render; workspace resolution runs Git. */
|
|
53
|
+
export const FEED_TIMING: FeedTiming = {
|
|
54
|
+
firstMs: 500,
|
|
55
|
+
activeMs: 2000,
|
|
56
|
+
idleMs: LIMITS.heartbeatMs,
|
|
57
|
+
retryMs: 15_000,
|
|
58
|
+
};
|
|
59
|
+
/** Retained reads are paged; a child larger than this stays in /cadence History. */
|
|
60
|
+
const MAX_PAGES = 50;
|
|
61
|
+
const MAX_DETAIL_STEPS = 8;
|
|
62
|
+
/** Characters of one field shown in the panel; the rest stays retained. */
|
|
63
|
+
const TEXT_CAP = 64 * 1024;
|
|
64
|
+
const CACHE_CHARS = 16 * 1024 * 1024;
|
|
65
|
+
/** Snapshots after an episode drained, while its children still look unsettled. */
|
|
66
|
+
const DRAIN_LOOKS = 5;
|
|
67
|
+
|
|
68
|
+
type Child = {
|
|
69
|
+
id: string;
|
|
70
|
+
/** Assignment ID: rows lead with it, since kinds repeat. */
|
|
71
|
+
label: string;
|
|
72
|
+
runId: string;
|
|
73
|
+
storeId: string;
|
|
74
|
+
/** Committed watermark of the latest snapshot that showed this child. */
|
|
75
|
+
seq: string;
|
|
76
|
+
lifecycle: RunProjection["lifecycle"];
|
|
77
|
+
ended: boolean;
|
|
78
|
+
activity: string;
|
|
79
|
+
/** Activity from the latest tools read, shown while the child is running. */
|
|
80
|
+
working?: string;
|
|
81
|
+
toolsSeq?: string;
|
|
82
|
+
transcriptSeq?: string;
|
|
83
|
+
/** A transcript read failed at this watermark; reopening the view retries. */
|
|
84
|
+
failedSeq?: string;
|
|
85
|
+
/** Final transcript messages already announced. */
|
|
86
|
+
sent: number;
|
|
87
|
+
};
|
|
88
|
+
type Tool = Extract<HistoryRecord, { kind: "tool" }>;
|
|
89
|
+
type Block = { index: number; ref: ContentRef };
|
|
90
|
+
type Built = { messages: object[]; partial?: object; tools: Tool[] };
|
|
91
|
+
|
|
92
|
+
/** Process-wide: children keep their announced state across sessions, like the
|
|
93
|
+
* panel's store, so a later session never announces one twice. */
|
|
94
|
+
class FeedState {
|
|
95
|
+
readonly children = new Map<string, Child>();
|
|
96
|
+
readonly purposes = new Map<string, Map<string, string>>();
|
|
97
|
+
readonly texts = new Map<string, string>();
|
|
98
|
+
cached = 0;
|
|
99
|
+
viewing: string | undefined;
|
|
100
|
+
remember(key: string, text: string): void {
|
|
101
|
+
if (this.cached + text.length > CACHE_CHARS) {
|
|
102
|
+
this.texts.clear();
|
|
103
|
+
this.cached = 0;
|
|
104
|
+
}
|
|
105
|
+
this.texts.set(key, text);
|
|
106
|
+
this.cached += text.length;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function registerCadenceAgentFeed(
|
|
111
|
+
pi: ExtensionAPI,
|
|
112
|
+
options: CadenceFeedOptions = {},
|
|
113
|
+
): void {
|
|
114
|
+
const state = new FeedState();
|
|
115
|
+
let feed: Feed | undefined;
|
|
116
|
+
pi.events.on(SUBAGENT_VIEW, (data) => {
|
|
117
|
+
const id = (data as { id?: unknown } | undefined)?.id;
|
|
118
|
+
state.viewing = typeof id === "string" ? id : undefined;
|
|
119
|
+
const child = state.viewing && state.children.get(state.viewing);
|
|
120
|
+
if (child) child.failedSeq = undefined;
|
|
121
|
+
feed?.wake();
|
|
122
|
+
});
|
|
123
|
+
const stop = () => {
|
|
124
|
+
const leaving = feed?.stop();
|
|
125
|
+
feed = undefined;
|
|
126
|
+
return leaving ?? Promise.resolve();
|
|
127
|
+
};
|
|
128
|
+
pi.on("session_start", (_event, ctx) => {
|
|
129
|
+
void stop();
|
|
130
|
+
if (
|
|
131
|
+
ctx.mode !== "tui" ||
|
|
132
|
+
!ctx.hasUI ||
|
|
133
|
+
process.env.CADENCE_AGENT_PANEL === "off"
|
|
134
|
+
)
|
|
135
|
+
return;
|
|
136
|
+
feed = new Feed(pi, ctx.cwd, state, options);
|
|
137
|
+
});
|
|
138
|
+
pi.on("session_shutdown", () => departure(stop()));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const label = (purpose: string | undefined, kind: string) =>
|
|
142
|
+
purpose
|
|
143
|
+
? purpose[0].toUpperCase() + purpose.slice(1)
|
|
144
|
+
: kind === "generate-plan"
|
|
145
|
+
? "Planner"
|
|
146
|
+
: "Child";
|
|
147
|
+
function lifecycleText(row: RunProjection): string {
|
|
148
|
+
switch (row.lifecycle) {
|
|
149
|
+
case "queued":
|
|
150
|
+
return row.reason
|
|
151
|
+
? `Queued · ${row.reason.replaceAll("-", " ")}`
|
|
152
|
+
: "Queued";
|
|
153
|
+
case "preparing":
|
|
154
|
+
return "Preparing";
|
|
155
|
+
case "closing":
|
|
156
|
+
return "Finishing";
|
|
157
|
+
default:
|
|
158
|
+
return "Working";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function workingText(tools: Tool[]): string {
|
|
162
|
+
const last = tools.at(-1);
|
|
163
|
+
if (!last) return "Working";
|
|
164
|
+
if (last.result === null) return `Running ${last.name}`;
|
|
165
|
+
const n = tools.length;
|
|
166
|
+
return `Working · ${n} tool call${n === 1 ? "" : "s"} (last: ${last.name})`;
|
|
167
|
+
}
|
|
168
|
+
/** Final or execution arguments are canonical JSON; anything else shows none. */
|
|
169
|
+
function parseArgs(text: string | undefined): object {
|
|
170
|
+
try {
|
|
171
|
+
const value = text === undefined ? {} : JSON.parse(text);
|
|
172
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
173
|
+
? value
|
|
174
|
+
: {};
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class Feed {
|
|
181
|
+
private client: FeedClient | undefined;
|
|
182
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
183
|
+
private busy = false;
|
|
184
|
+
private woken = false;
|
|
185
|
+
private stopped = false;
|
|
186
|
+
private readonly abort = new AbortController();
|
|
187
|
+
private readonly connection: FeedConnection;
|
|
188
|
+
private readonly now: () => number;
|
|
189
|
+
private readonly timing: FeedTiming;
|
|
190
|
+
/** Parent run → task, followed until a snapshot after it left the roster. */
|
|
191
|
+
private readonly episodes = new Map<
|
|
192
|
+
string,
|
|
193
|
+
{ taskId: string; drained: number }
|
|
194
|
+
>();
|
|
195
|
+
constructor(
|
|
196
|
+
private readonly pi: ExtensionAPI,
|
|
197
|
+
private readonly cwd: string,
|
|
198
|
+
private readonly state: FeedState,
|
|
199
|
+
options: CadenceFeedOptions,
|
|
200
|
+
) {
|
|
201
|
+
this.connection = options.connection?.() ?? new NativeConnection();
|
|
202
|
+
this.now = options.now ?? Date.now;
|
|
203
|
+
this.timing = { ...FEED_TIMING, ...options.timing };
|
|
204
|
+
this.schedule(this.timing.firstMs);
|
|
205
|
+
}
|
|
206
|
+
stop(): Promise<void> {
|
|
207
|
+
this.stopped = true;
|
|
208
|
+
clearTimeout(this.timer);
|
|
209
|
+
this.abort.abort();
|
|
210
|
+
// Detach this observer only; accepted work continues in the service.
|
|
211
|
+
return this.connection.detach().catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
/** The viewed agent changed: load its transcript now if attached. */
|
|
214
|
+
wake(): void {
|
|
215
|
+
if (this.busy) this.woken = true;
|
|
216
|
+
else if (this.client) this.schedule(0);
|
|
217
|
+
}
|
|
218
|
+
private schedule(ms: number): void {
|
|
219
|
+
clearTimeout(this.timer);
|
|
220
|
+
if (this.stopped) return;
|
|
221
|
+
this.timer = setTimeout(() => void this.tick(), ms);
|
|
222
|
+
// Observation never keeps Pi alive.
|
|
223
|
+
this.timer.unref?.();
|
|
224
|
+
}
|
|
225
|
+
private emit(event: keyof typeof SUBAGENT_EVENTS, data: object): void {
|
|
226
|
+
if (!this.stopped) this.pi.events.emit(SUBAGENT_EVENTS[event], data);
|
|
227
|
+
}
|
|
228
|
+
private async tick(): Promise<void> {
|
|
229
|
+
if (this.stopped || this.busy) return;
|
|
230
|
+
this.busy = true;
|
|
231
|
+
this.woken = false;
|
|
232
|
+
let next = this.timing.retryMs;
|
|
233
|
+
const attaching = !this.client;
|
|
234
|
+
try {
|
|
235
|
+
if (!this.client) {
|
|
236
|
+
const client = await this.connection.attach(this.cwd);
|
|
237
|
+
if (this.stopped) {
|
|
238
|
+
// stop() may have detached before this allocation existed.
|
|
239
|
+
await this.connection.detach().catch(() => {});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!this.connection.compatible()) throw new Error("incompatible");
|
|
243
|
+
this.client = client;
|
|
244
|
+
}
|
|
245
|
+
next = await this.observe(this.client);
|
|
246
|
+
} catch {
|
|
247
|
+
// Absent, stopped, restarted or incompatible owner: detach and look
|
|
248
|
+
// again later. Observation never starts one.
|
|
249
|
+
this.client = undefined;
|
|
250
|
+
await this.connection.detach().catch(() => {});
|
|
251
|
+
// Still no compatible owner a whole retry after losing it: the children
|
|
252
|
+
// followed so far can no longer be observed from this session.
|
|
253
|
+
if (attaching)
|
|
254
|
+
this.abandon([...this.episodes.keys()], "owner unavailable");
|
|
255
|
+
} finally {
|
|
256
|
+
this.busy = false;
|
|
257
|
+
}
|
|
258
|
+
this.schedule(this.woken ? 0 : next);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async observe(client: FeedClient): Promise<number> {
|
|
262
|
+
const signal = this.abort.signal;
|
|
263
|
+
const roster = await client.activeRuns(signal);
|
|
264
|
+
if (roster.kind === "error" || roster.value.status !== "complete") {
|
|
265
|
+
// stop() aborted this read; a session request now would race its detach.
|
|
266
|
+
if (this.stopped) return this.timing.activeMs;
|
|
267
|
+
// Throws when the owner is gone or restarted; otherwise look again soon.
|
|
268
|
+
await this.connection.refresh();
|
|
269
|
+
return this.timing.activeMs;
|
|
270
|
+
}
|
|
271
|
+
const active = new Set<string>();
|
|
272
|
+
for (const run of roster.value.runs) {
|
|
273
|
+
active.add(run.runId);
|
|
274
|
+
this.episodes.set(run.runId, { taskId: run.taskId, drained: 0 });
|
|
275
|
+
}
|
|
276
|
+
const ends: object[] = [];
|
|
277
|
+
for (const taskId of new Set(
|
|
278
|
+
[...this.episodes.values()].map((e) => e.taskId),
|
|
279
|
+
)) {
|
|
280
|
+
const reply = await client.snapshot({ kind: "task", id: taskId }, signal);
|
|
281
|
+
if (this.stopped) return this.timing.activeMs;
|
|
282
|
+
for (const [runId, episode] of this.episodes) {
|
|
283
|
+
if (episode.taskId !== taskId) continue;
|
|
284
|
+
// A refused snapshot drains a finished episode like an unsettled one.
|
|
285
|
+
const open =
|
|
286
|
+
reply.kind === "error" ||
|
|
287
|
+
(await this.follow(client, reply.value, runId, ends));
|
|
288
|
+
if (active.has(runId)) continue;
|
|
289
|
+
if (!open) this.episodes.delete(runId);
|
|
290
|
+
else if (++episode.drained >= DRAIN_LOOKS)
|
|
291
|
+
this.abandon([runId], "no settled row after the run ended");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await this.refreshActivity(client);
|
|
295
|
+
await this.refreshTranscript(client);
|
|
296
|
+
// Ends follow the transcript so its last streaming snapshot is replaced
|
|
297
|
+
// by the final message rather than kept beside it.
|
|
298
|
+
for (const end of ends) this.emit("end", end);
|
|
299
|
+
return this.episodes.size ? this.timing.activeMs : this.timing.idleMs;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Announce this episode's children; true while any still looks unsettled. */
|
|
303
|
+
private async follow(
|
|
304
|
+
client: FeedClient,
|
|
305
|
+
snapshot: Snapshot,
|
|
306
|
+
runId: string,
|
|
307
|
+
ends: object[],
|
|
308
|
+
): Promise<boolean> {
|
|
309
|
+
const rows = snapshot.runs.filter(
|
|
310
|
+
(r) => r.runId === runId && r.childRunId !== null,
|
|
311
|
+
);
|
|
312
|
+
// A check has no children: follow it only until its own row settles.
|
|
313
|
+
if (!rows.length)
|
|
314
|
+
return !snapshot.runs.some(
|
|
315
|
+
(r) => r.runId === runId && !r.childRunId && r.lifecycle === "settled",
|
|
316
|
+
);
|
|
317
|
+
const purposes = await this.purposes(client, snapshot, rows[0]);
|
|
318
|
+
let open = false;
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
const id = row.childRunId as string;
|
|
321
|
+
const settled = row.lifecycle === "settled";
|
|
322
|
+
open ||= !settled;
|
|
323
|
+
let child = this.state.children.get(id);
|
|
324
|
+
if (!child) {
|
|
325
|
+
child = {
|
|
326
|
+
id,
|
|
327
|
+
label: row.assignmentId ?? id,
|
|
328
|
+
runId,
|
|
329
|
+
storeId: snapshot.storeId,
|
|
330
|
+
seq: snapshot.committedSeq,
|
|
331
|
+
lifecycle: row.lifecycle,
|
|
332
|
+
ended: false,
|
|
333
|
+
activity: "",
|
|
334
|
+
sent: 0,
|
|
335
|
+
};
|
|
336
|
+
this.state.children.set(id, child);
|
|
337
|
+
this.emit("start", {
|
|
338
|
+
id,
|
|
339
|
+
label: child.label,
|
|
340
|
+
kind: label(purposes.get(row.assignmentId ?? ""), row.admission.kind),
|
|
341
|
+
// A child first seen settled ran before this session looked.
|
|
342
|
+
startedAt: settled ? null : this.now(),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
child.storeId = snapshot.storeId;
|
|
346
|
+
child.seq = snapshot.committedSeq;
|
|
347
|
+
child.lifecycle = row.lifecycle;
|
|
348
|
+
if (child.ended) continue;
|
|
349
|
+
if (settled) {
|
|
350
|
+
const outcome = row.outcome ?? "unknown";
|
|
351
|
+
// The row keeps its last activity after the end; make it the outcome.
|
|
352
|
+
this.activity(child, outcome);
|
|
353
|
+
child.ended = true;
|
|
354
|
+
ends.push({
|
|
355
|
+
id,
|
|
356
|
+
status:
|
|
357
|
+
outcome === "completed"
|
|
358
|
+
? "done"
|
|
359
|
+
: outcome === "canceled"
|
|
360
|
+
? "cancelled"
|
|
361
|
+
: "error",
|
|
362
|
+
endedAt: this.now(),
|
|
363
|
+
...(outcome === "completed" || outcome === "canceled"
|
|
364
|
+
? {}
|
|
365
|
+
: {
|
|
366
|
+
error: `${child.label} · ${outcome}${
|
|
367
|
+
row.reason ? ` (${row.reason.replaceAll("-", " ")})` : ""
|
|
368
|
+
}`,
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
this.activity(
|
|
374
|
+
child,
|
|
375
|
+
row.lifecycle === "running"
|
|
376
|
+
? (child.working ?? "Working")
|
|
377
|
+
: lifecycleText(row),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return open;
|
|
381
|
+
}
|
|
382
|
+
/** Stop following these episodes. Their unsettled children end as errors
|
|
383
|
+
* that say the outcome was not observed, instead of spinning as running for
|
|
384
|
+
* the rest of the session; /cadence History keeps the actual outcome. */
|
|
385
|
+
private abandon(runIds: string[], why: string): void {
|
|
386
|
+
for (const runId of runIds) {
|
|
387
|
+
this.episodes.delete(runId);
|
|
388
|
+
for (const child of this.state.children.values()) {
|
|
389
|
+
if (child.runId !== runId || child.ended) continue;
|
|
390
|
+
this.activity(child, "outcome not observed");
|
|
391
|
+
child.ended = true;
|
|
392
|
+
this.emit("end", {
|
|
393
|
+
id: child.id,
|
|
394
|
+
status: "error",
|
|
395
|
+
endedAt: this.now(),
|
|
396
|
+
error: `${child.label} · outcome not observed here (${why}); see /cadence History`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
private activity(child: Child, text: string): void {
|
|
402
|
+
const activity = `${child.label} · ${text}`;
|
|
403
|
+
if (child.ended || activity === child.activity) return;
|
|
404
|
+
child.activity = activity;
|
|
405
|
+
this.emit("activity", { id: child.id, activity });
|
|
406
|
+
}
|
|
407
|
+
/** Assignment purposes from the admitted graph, read once per episode. */
|
|
408
|
+
private async purposes(
|
|
409
|
+
client: FeedClient,
|
|
410
|
+
snapshot: Snapshot,
|
|
411
|
+
row: RunProjection,
|
|
412
|
+
): Promise<Map<string, string>> {
|
|
413
|
+
const cached = this.state.purposes.get(row.runId);
|
|
414
|
+
if (cached) return cached;
|
|
415
|
+
const purposes = new Map<string, string>();
|
|
416
|
+
const graph = row.admission.graph;
|
|
417
|
+
if (graph) {
|
|
418
|
+
let request: GraphReadRequest = {
|
|
419
|
+
storeId: snapshot.storeId,
|
|
420
|
+
scope: { kind: "run", id: row.runId },
|
|
421
|
+
snapshotSeq: snapshot.committedSeq,
|
|
422
|
+
graph,
|
|
423
|
+
};
|
|
424
|
+
for (let step = 0; step < MAX_DETAIL_STEPS; step++) {
|
|
425
|
+
const reply = await client.graph(request, this.abort.signal);
|
|
426
|
+
if (reply.kind === "error") break;
|
|
427
|
+
if (!("kind" in reply.value)) {
|
|
428
|
+
for (const a of reply.value.graph.assignments)
|
|
429
|
+
purposes.set(a.id, a.purpose);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
request = reply.value.next;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// A failed read falls back to a generic kind rather than rereading.
|
|
436
|
+
this.state.purposes.set(row.runId, purposes);
|
|
437
|
+
return purposes;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Latest tool for running children not shown in a view (that read covers it). */
|
|
441
|
+
private async refreshActivity(client: FeedClient): Promise<void> {
|
|
442
|
+
for (const child of this.state.children.values()) {
|
|
443
|
+
if (
|
|
444
|
+
child.ended ||
|
|
445
|
+
child.lifecycle !== "running" ||
|
|
446
|
+
child.id === this.state.viewing ||
|
|
447
|
+
child.toolsSeq === child.seq ||
|
|
448
|
+
// Another session's workspace: not this owner's to read.
|
|
449
|
+
!this.episodes.has(child.runId)
|
|
450
|
+
)
|
|
451
|
+
continue;
|
|
452
|
+
const tools = await this.records(client, "tools", child);
|
|
453
|
+
if (!tools) continue;
|
|
454
|
+
child.toolsSeq = child.seq;
|
|
455
|
+
child.working = workingText(tools as Tool[]);
|
|
456
|
+
this.activity(child, child.working);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
private async refreshTranscript(client: FeedClient): Promise<void> {
|
|
460
|
+
const id = this.state.viewing;
|
|
461
|
+
const child = id === undefined ? undefined : this.state.children.get(id);
|
|
462
|
+
if (
|
|
463
|
+
!child ||
|
|
464
|
+
child.transcriptSeq === child.seq ||
|
|
465
|
+
child.failedSeq === child.seq
|
|
466
|
+
)
|
|
467
|
+
return;
|
|
468
|
+
const seq = child.seq;
|
|
469
|
+
const built = await this.transcript(client, child);
|
|
470
|
+
if (this.stopped) return;
|
|
471
|
+
if (!built) {
|
|
472
|
+
child.failedSeq = seq;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
child.transcriptSeq = seq;
|
|
476
|
+
child.toolsSeq = seq;
|
|
477
|
+
child.working = workingText(built.tools);
|
|
478
|
+
if (child.lifecycle === "running") this.activity(child, child.working);
|
|
479
|
+
for (let i = child.sent; i < built.messages.length; i++)
|
|
480
|
+
this.emit("message", { id: child.id, message: built.messages[i] });
|
|
481
|
+
child.sent = Math.max(child.sent, built.messages.length);
|
|
482
|
+
if (built.partial)
|
|
483
|
+
this.emit("message", {
|
|
484
|
+
id: child.id,
|
|
485
|
+
message: built.partial,
|
|
486
|
+
streaming: true,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async records(
|
|
491
|
+
client: FeedClient,
|
|
492
|
+
reader: "messages" | "tools" | "contents",
|
|
493
|
+
child: Child,
|
|
494
|
+
): Promise<HistoryRecord[] | undefined> {
|
|
495
|
+
let request: ListRequest = {
|
|
496
|
+
reader,
|
|
497
|
+
storeId: child.storeId,
|
|
498
|
+
scope: { kind: "child", id: child.id },
|
|
499
|
+
snapshotSeq: child.seq,
|
|
500
|
+
after: null,
|
|
501
|
+
limit: LIMITS.maxPageEntries,
|
|
502
|
+
};
|
|
503
|
+
const records: HistoryRecord[] = [];
|
|
504
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
505
|
+
const reply = await client[reader](request, this.abort.signal);
|
|
506
|
+
if (reply.kind === "error") return undefined;
|
|
507
|
+
if (!("kind" in reply.value)) records.push(...reply.value.records);
|
|
508
|
+
if (!reply.value.next) return records;
|
|
509
|
+
request = reply.value.next;
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
/** One retained field's leading text, cached by its immutable reference. */
|
|
514
|
+
private async text(
|
|
515
|
+
client: FeedClient,
|
|
516
|
+
child: Child,
|
|
517
|
+
ref: ContentRef,
|
|
518
|
+
): Promise<string | undefined> {
|
|
519
|
+
const key = `${ref.recordId}:${ref.version}:${ref.field}:${ref.sha256}`;
|
|
520
|
+
const hit = this.state.texts.get(key);
|
|
521
|
+
if (hit !== undefined) return hit;
|
|
522
|
+
let cursor: ReadCursor | null = {
|
|
523
|
+
storeId: child.storeId,
|
|
524
|
+
scope: { kind: "child", id: child.id },
|
|
525
|
+
snapshotSeq: child.seq,
|
|
526
|
+
query: null,
|
|
527
|
+
recordId: ref.recordId,
|
|
528
|
+
recordVersion: ref.version,
|
|
529
|
+
field: ref.field,
|
|
530
|
+
offset: 0,
|
|
531
|
+
};
|
|
532
|
+
let text = "";
|
|
533
|
+
for (let step = 0; cursor && step < MAX_DETAIL_STEPS; step++) {
|
|
534
|
+
const reply = await client.detail(cursor, this.abort.signal);
|
|
535
|
+
if (reply.kind === "error") return undefined;
|
|
536
|
+
if (!("kind" in reply.value)) text += reply.value.text;
|
|
537
|
+
cursor = reply.value.next;
|
|
538
|
+
if (text.length >= TEXT_CAP) break;
|
|
539
|
+
}
|
|
540
|
+
// One detail page can exceed the cap on its own, with no cursor left.
|
|
541
|
+
if (cursor || text.length > TEXT_CAP)
|
|
542
|
+
text = `${text.slice(0, TEXT_CAP)}\n… (truncated; the full text is in /cadence History)`;
|
|
543
|
+
this.state.remember(key, text);
|
|
544
|
+
return text;
|
|
545
|
+
}
|
|
546
|
+
private async joined(
|
|
547
|
+
client: FeedClient,
|
|
548
|
+
child: Child,
|
|
549
|
+
blocks: Block[] | undefined,
|
|
550
|
+
): Promise<string | undefined> {
|
|
551
|
+
const parts: string[] = [];
|
|
552
|
+
for (const block of [...(blocks ?? [])].sort((a, b) => a.index - b.index)) {
|
|
553
|
+
const text = await this.text(client, child, block.ref);
|
|
554
|
+
if (text === undefined) return undefined;
|
|
555
|
+
parts.push(text);
|
|
556
|
+
}
|
|
557
|
+
return parts.join("\n");
|
|
558
|
+
}
|
|
559
|
+
/** The child's retained messages in Pi's message shapes: user prompt,
|
|
560
|
+
* assistant text with its tool calls, and tool results. A partial message
|
|
561
|
+
* ends the list as a streaming snapshot. */
|
|
562
|
+
private async transcript(
|
|
563
|
+
client: FeedClient,
|
|
564
|
+
child: Child,
|
|
565
|
+
): Promise<Built | undefined> {
|
|
566
|
+
const messages = await this.records(client, "messages", child);
|
|
567
|
+
const tools = (await this.records(client, "tools", child)) as
|
|
568
|
+
| Tool[]
|
|
569
|
+
| undefined;
|
|
570
|
+
const contents = await this.records(client, "contents", child);
|
|
571
|
+
if (!messages || !tools || !contents) return undefined;
|
|
572
|
+
const args = new Map<string, ContentRef>(),
|
|
573
|
+
results = new Map<string, Block[]>(),
|
|
574
|
+
finals = new Map<string, Block[]>(),
|
|
575
|
+
streams = new Map<string, Block[]>();
|
|
576
|
+
const push = (map: Map<string, Block[]>, key: string, block: Block) =>
|
|
577
|
+
map.set(key, [...(map.get(key) ?? []), block]);
|
|
578
|
+
for (const r of contents) {
|
|
579
|
+
if (r.kind !== "content") continue;
|
|
580
|
+
const owner = r.descriptor.owner;
|
|
581
|
+
if (owner.kind === "tool") {
|
|
582
|
+
if (
|
|
583
|
+
owner.stage === "execution-arguments" ||
|
|
584
|
+
(owner.stage === "arguments-final" && !args.has(owner.callId))
|
|
585
|
+
)
|
|
586
|
+
args.set(owner.callId, r.content);
|
|
587
|
+
else if (owner.stage === "result")
|
|
588
|
+
push(results, owner.callId, { index: owner.index, ref: r.content });
|
|
589
|
+
} else if (owner.kind === "message")
|
|
590
|
+
push(owner.stage === "final" ? finals : streams, owner.messageId, {
|
|
591
|
+
index: owner.index,
|
|
592
|
+
ref: r.content,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const calls = new Map<string, Tool[]>(),
|
|
596
|
+
byId = new Map<string, Tool>();
|
|
597
|
+
for (const tool of tools) {
|
|
598
|
+
byId.set(tool.owner.entity.id, tool);
|
|
599
|
+
const message = tool.declaration?.messageId;
|
|
600
|
+
if (message) calls.set(message, [...(calls.get(message) ?? []), tool]);
|
|
601
|
+
}
|
|
602
|
+
const built: Built = { messages: [], tools };
|
|
603
|
+
for (const m of messages) {
|
|
604
|
+
if (m.kind !== "message") continue;
|
|
605
|
+
const id = m.owner.entity.id;
|
|
606
|
+
// Only assistant messages carry a derived final text; a user prompt
|
|
607
|
+
// keeps its final blocks, a partial message its stream blocks.
|
|
608
|
+
const blocks =
|
|
609
|
+
m.role === "tool-result" && m.resultCallId
|
|
610
|
+
? results.get(m.resultCallId)
|
|
611
|
+
: (finals.get(id) ?? streams.get(id));
|
|
612
|
+
const text = m.finalText
|
|
613
|
+
? await this.text(client, child, m.finalText)
|
|
614
|
+
: await this.joined(client, child, blocks);
|
|
615
|
+
if (text === undefined) return undefined;
|
|
616
|
+
let message: object;
|
|
617
|
+
if (m.role === "user")
|
|
618
|
+
message = { role: "user", content: [{ type: "text", text }] };
|
|
619
|
+
else if (m.role === "assistant") {
|
|
620
|
+
const content: object[] = text ? [{ type: "text", text }] : [];
|
|
621
|
+
for (const tool of calls.get(id) ?? []) {
|
|
622
|
+
const ref = args.get(tool.owner.entity.id);
|
|
623
|
+
const raw = ref ? await this.text(client, child, ref) : undefined;
|
|
624
|
+
if (ref && raw === undefined) return undefined;
|
|
625
|
+
content.push({
|
|
626
|
+
type: "toolCall",
|
|
627
|
+
id: tool.owner.entity.id,
|
|
628
|
+
name: tool.name,
|
|
629
|
+
arguments: parseArgs(raw),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
message = { role: "assistant", content };
|
|
633
|
+
} else {
|
|
634
|
+
const tool = m.resultCallId ? byId.get(m.resultCallId) : undefined;
|
|
635
|
+
message = {
|
|
636
|
+
role: "toolResult",
|
|
637
|
+
toolCallId: m.resultCallId ?? "",
|
|
638
|
+
toolName: tool?.name ?? "tool",
|
|
639
|
+
content: [{ type: "text", text }],
|
|
640
|
+
isError: tool?.result?.isError === true,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (m.state !== "final") {
|
|
644
|
+
built.partial = message;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
built.messages.push(message);
|
|
648
|
+
}
|
|
649
|
+
return built;
|
|
650
|
+
}
|
|
651
|
+
}
|