@jwilger/pi-development-system 0.48.0 → 0.50.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.
|
@@ -9,7 +9,6 @@ import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
|
9
9
|
import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
|
|
10
10
|
import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
|
|
11
11
|
import { defaultMatrix } from "../src/core/models.ts";
|
|
12
|
-
import { detectProfiles } from "../src/core/profile.ts";
|
|
13
12
|
import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
14
13
|
import { registerCommitGuard } from "../src/gates/commit-guard.ts";
|
|
15
14
|
import { registerGitGuard } from "../src/gates/git-guard.ts";
|
|
@@ -24,6 +23,7 @@ import { createReviewRecordTool, createReviewStartTool } from "../src/review/rev
|
|
|
24
23
|
import { registerCiCommand } from "../src/state/ci-command.ts";
|
|
25
24
|
import { loadConfig } from "../src/state/config.ts";
|
|
26
25
|
import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
|
|
26
|
+
import { detectProfiles } from "../src/state/profile-detect.ts";
|
|
27
27
|
import { createRouteTaskTool } from "../src/state/route-task-tool.ts";
|
|
28
28
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
29
29
|
import { registerTestEvidence } from "../src/state/test-evidence.ts";
|
package/package.json
CHANGED
package/src/core/profile.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { isParseError, type ParseError, type Profile, parseError } from "./types.ts";
|
|
1
|
+
import { type ParseError, type Profile, parseError } from "./types.ts";
|
|
4
2
|
|
|
5
3
|
export type { Profile };
|
|
6
4
|
|
|
7
5
|
const PROFILES: readonly Profile[] = ["rust", "typescript"];
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
/** Files whose presence marks a repo as using a language profile. */
|
|
8
|
+
export const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
|
|
10
9
|
{ profile: "rust", files: ["Cargo.toml"] },
|
|
11
10
|
{ profile: "typescript", files: ["package.json", "tsconfig.json"] },
|
|
12
11
|
];
|
|
@@ -24,26 +23,3 @@ export function parseProfiles(names: readonly string[]): Profile[] | ParseError
|
|
|
24
23
|
}
|
|
25
24
|
return out;
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
const exists = (path: string): Promise<boolean> =>
|
|
29
|
-
access(path).then(
|
|
30
|
-
() => true,
|
|
31
|
-
() => false,
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
/** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
|
|
35
|
-
export async function detectProfiles(
|
|
36
|
-
repoRoot: string,
|
|
37
|
-
override: readonly string[] = [],
|
|
38
|
-
): Promise<Profile[]> {
|
|
39
|
-
if (override.length > 0) {
|
|
40
|
-
const parsed = parseProfiles(override);
|
|
41
|
-
if (!isParseError(parsed)) return parsed;
|
|
42
|
-
}
|
|
43
|
-
const found: Profile[] = [];
|
|
44
|
-
for (const { profile, files } of MARKERS) {
|
|
45
|
-
const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
|
|
46
|
-
if (hits.some(Boolean)) found.push(profile);
|
|
47
|
-
}
|
|
48
|
-
return found;
|
|
49
|
-
}
|
package/src/core/test-runner.ts
CHANGED
|
@@ -72,8 +72,9 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
|
72
72
|
|
|
73
73
|
/** Exit code of a bash tool result: structured `exit_code`, else pi's status line, else error/not. */
|
|
74
74
|
export function exitCodeOf(result: ResultLike): number {
|
|
75
|
-
// `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED
|
|
76
|
-
|
|
75
|
+
// `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED;
|
|
76
|
+
// but with --workspaces one package may lack the script while another fails for real.
|
|
77
|
+
if (NO_TEST_SCRIPT.test(result.text) && !FAILURE_MARKERS.test(result.text)) return 0;
|
|
77
78
|
const reported = reportedExit(result);
|
|
78
79
|
if (reported !== 0) return reported;
|
|
79
80
|
// `npm test | tail` exits 0 whatever the tests did, even in the structured status; trust the output.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { MARKERS, parseProfiles } from "../core/profile.ts";
|
|
4
|
+
import { isParseError, type Profile } from "../core/types.ts";
|
|
5
|
+
|
|
6
|
+
const exists = (path: string): Promise<boolean> =>
|
|
7
|
+
access(path).then(
|
|
8
|
+
() => true,
|
|
9
|
+
() => false,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
|
|
13
|
+
export async function detectProfiles(
|
|
14
|
+
repoRoot: string,
|
|
15
|
+
override: readonly string[] = [],
|
|
16
|
+
): Promise<Profile[]> {
|
|
17
|
+
if (override.length > 0) {
|
|
18
|
+
const parsed = parseProfiles(override);
|
|
19
|
+
if (!isParseError(parsed)) return parsed;
|
|
20
|
+
}
|
|
21
|
+
const found: Profile[] = [];
|
|
22
|
+
for (const { profile, files } of MARKERS) {
|
|
23
|
+
const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
|
|
24
|
+
if (hits.some(Boolean)) found.push(profile);
|
|
25
|
+
}
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { parseSpawnOverrides } from "../../core/spawn-overrides.ts";
|
|
3
|
+
import { isParseError } from "../../core/types.ts";
|
|
4
|
+
import { DEFAULT_MANAGER_SETTINGS, type ManagerSettings } from "../prefs/settings.ts";
|
|
3
5
|
import type {
|
|
4
6
|
AgentDriver,
|
|
5
7
|
AgentType,
|
|
@@ -13,21 +15,18 @@ import type {
|
|
|
13
15
|
TranscriptObservation,
|
|
14
16
|
TranscriptSnapshot,
|
|
15
17
|
} from "../types.ts";
|
|
16
|
-
import { immutableTranscriptValue } from "./transcript.ts";
|
|
17
|
-
import { parseSpawnOverrides } from "../../core/spawn-overrides.ts";
|
|
18
|
-
import { isParseError } from "../../core/types.ts";
|
|
19
18
|
import { canonicalPath, inheritContext, isDescendant, parentPath } from "./paths.ts";
|
|
20
|
-
import {
|
|
19
|
+
import { immutableTranscriptValue } from "./transcript.ts";
|
|
21
20
|
|
|
22
21
|
interface Record {
|
|
23
22
|
view: ThreadView;
|
|
24
23
|
definition: AgentType;
|
|
25
24
|
inherited: AgentMessage[];
|
|
26
|
-
contextReady?: Promise<void
|
|
25
|
+
contextReady?: Promise<void> | undefined;
|
|
27
26
|
contextPending?: boolean;
|
|
28
27
|
startup: AbortController;
|
|
29
28
|
driver?: AgentDriver;
|
|
30
|
-
initializing?: Promise<AgentDriver
|
|
29
|
+
initializing?: Promise<AgentDriver> | undefined;
|
|
31
30
|
run?: Promise<void>;
|
|
32
31
|
started?: Promise<void>;
|
|
33
32
|
sessionLeafId?: string | null;
|
|
@@ -40,18 +39,26 @@ interface Record {
|
|
|
40
39
|
stopRequested: boolean;
|
|
41
40
|
observationRevision?: number;
|
|
42
41
|
driverGeneration?: number;
|
|
43
|
-
transcriptSource?: TranscriptSnapshot;
|
|
42
|
+
transcriptSource?: TranscriptSnapshot | undefined;
|
|
44
43
|
transcriptListeners?: Set<TranscriptListener>;
|
|
45
|
-
detachTranscript?: () => void;
|
|
46
|
-
observationError?: string;
|
|
44
|
+
detachTranscript?: (() => void) | undefined;
|
|
45
|
+
observationError?: string | undefined;
|
|
47
46
|
}
|
|
48
47
|
const active = (view: Pick<ThreadView, "state">) =>
|
|
49
48
|
view.state === "starting" || view.state === "running";
|
|
49
|
+
const settledState = (record: {
|
|
50
|
+
stopRequested: boolean;
|
|
51
|
+
pauseRequested: boolean;
|
|
52
|
+
}): ThreadView["state"] => {
|
|
53
|
+
if (record.stopRequested) return "stopped";
|
|
54
|
+
return record.pauseRequested ? "paused" : "completed";
|
|
55
|
+
};
|
|
56
|
+
const swallow = (): undefined => undefined;
|
|
50
57
|
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
51
58
|
|
|
52
59
|
function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
53
60
|
if (signal.aborted) {
|
|
54
|
-
|
|
61
|
+
work.catch(swallow);
|
|
55
62
|
return Promise.reject(signal.reason);
|
|
56
63
|
}
|
|
57
64
|
return new Promise((resolve, reject) => {
|
|
@@ -75,7 +82,10 @@ export class ThreadManager {
|
|
|
75
82
|
private records = new Map<string, Record>();
|
|
76
83
|
private disposed = false;
|
|
77
84
|
private epoch = 0;
|
|
78
|
-
|
|
85
|
+
private options: ManagerOptions;
|
|
86
|
+
constructor(options: ManagerOptions) {
|
|
87
|
+
this.options = options;
|
|
88
|
+
}
|
|
79
89
|
|
|
80
90
|
/** New limits affect future launches/resumes, never cancel existing work. */
|
|
81
91
|
setLimits(limits: ManagerSettings): void {
|
|
@@ -158,14 +168,14 @@ export class ThreadManager {
|
|
|
158
168
|
return {
|
|
159
169
|
view,
|
|
160
170
|
definition: structuredClone(record.definition),
|
|
161
|
-
...(!view.sessionFile
|
|
171
|
+
...(!(view.sessionFile || record.contextPending)
|
|
162
172
|
? { inherited: structuredClone(record.inherited) }
|
|
163
173
|
: {}),
|
|
164
174
|
};
|
|
165
175
|
});
|
|
166
176
|
}
|
|
167
177
|
restore(saved: SavedThread[]): void {
|
|
168
|
-
if (this.records.size) throw new Error("Restore requires an empty thread registry");
|
|
178
|
+
if (this.records.size > 0) throw new Error("Restore requires an empty thread registry");
|
|
169
179
|
const restored = new Map<string, Record>();
|
|
170
180
|
for (const item of saved) {
|
|
171
181
|
const savedView = structuredClone(item.view);
|
|
@@ -187,12 +197,12 @@ export class ThreadManager {
|
|
|
187
197
|
inputTokens: storedView.inputTokens ?? 0,
|
|
188
198
|
outputTokens: storedView.outputTokens ?? 0,
|
|
189
199
|
};
|
|
190
|
-
|
|
200
|
+
view.startedAt = undefined;
|
|
191
201
|
if (view.parent !== parentPath(path)) throw new Error(`Invalid saved parent for ${path}`);
|
|
192
202
|
if (active(view)) {
|
|
193
203
|
view.state = "paused";
|
|
194
204
|
view.status = "Interrupted by reload; send input to resume";
|
|
195
|
-
|
|
205
|
+
view.output = undefined;
|
|
196
206
|
}
|
|
197
207
|
restored.set(path, {
|
|
198
208
|
view,
|
|
@@ -201,9 +211,7 @@ export class ThreadManager {
|
|
|
201
211
|
// A reservation saved before its lexical parent opened has no snapshot yet.
|
|
202
212
|
contextPending: !view.sessionFile && item.inherited === undefined && view.parent !== null,
|
|
203
213
|
startup: new AbortController(),
|
|
204
|
-
...(Object.
|
|
205
|
-
? { sessionLeafId }
|
|
206
|
-
: {}),
|
|
214
|
+
...(Object.hasOwn(savedView, "sessionLeafId") ? { sessionLeafId } : {}),
|
|
207
215
|
pauseRequested: false,
|
|
208
216
|
stopRequested: false,
|
|
209
217
|
});
|
|
@@ -257,19 +265,15 @@ export class ThreadManager {
|
|
|
257
265
|
: level > maxLevels
|
|
258
266
|
)
|
|
259
267
|
throw new Error("Agent depth limit reached");
|
|
260
|
-
this.archiveFinished(this.options.maxThreads ?? DEFAULT_MANAGER_SETTINGS.maxThreads);
|
|
261
268
|
this.assertCapacity();
|
|
262
269
|
const type = structuredClone(this.options.getType(args.type));
|
|
263
270
|
if (overrides !== undefined) type.spawnOverrides = { ...overrides };
|
|
264
271
|
const parentRecord = parent && parent !== "/root" ? this.record(parent) : undefined;
|
|
265
272
|
this.assertStartableAncestors(path);
|
|
273
|
+
// Last, so a spawn refused by any check above drops nothing.
|
|
274
|
+
this.archiveFinished(this.options.maxThreads ?? DEFAULT_MANAGER_SETTINGS.maxThreads, path);
|
|
266
275
|
// Reservation is synchronous: subtree cancellation sees children even during lazy parent reopen.
|
|
267
|
-
const inherited =
|
|
268
|
-
parent === "/root"
|
|
269
|
-
? inheritContext(this.options.rootSnapshot())
|
|
270
|
-
: parentRecord?.driver
|
|
271
|
-
? inheritContext(parentRecord.driver.snapshot())
|
|
272
|
-
: [];
|
|
276
|
+
const inherited = this.inheritedFor(parent, parentRecord);
|
|
273
277
|
const now = Date.now();
|
|
274
278
|
const record: Record = {
|
|
275
279
|
view: {
|
|
@@ -298,7 +302,9 @@ export class ThreadManager {
|
|
|
298
302
|
this.records.set(path, record);
|
|
299
303
|
if (record.contextPending) this.prepareInheritedContext(record);
|
|
300
304
|
this.start(record, args.task);
|
|
301
|
-
return args.wait === false
|
|
305
|
+
return args.wait === false
|
|
306
|
+
? this.view(record)
|
|
307
|
+
: await this.wait(caller, path, undefined, signal);
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
async steer(caller: string, path: string, message: string): Promise<ThreadView> {
|
|
@@ -321,7 +327,9 @@ export class ThreadManager {
|
|
|
321
327
|
if (record.stopRequested && active(record.view))
|
|
322
328
|
throw new Error("Thread is stopping; wait for stopped state before resuming");
|
|
323
329
|
if (record.view.state === "running") {
|
|
324
|
-
|
|
330
|
+
const live = record.driver;
|
|
331
|
+
if (live === undefined) throw new Error("Running thread has no driver");
|
|
332
|
+
await live.steer(message);
|
|
325
333
|
this.touch(record);
|
|
326
334
|
return this.view(record);
|
|
327
335
|
}
|
|
@@ -351,12 +359,13 @@ export class ThreadManager {
|
|
|
351
359
|
const finish = (error?: Error) => {
|
|
352
360
|
if (timer) clearTimeout(timer);
|
|
353
361
|
signal?.removeEventListener("abort", cancel);
|
|
354
|
-
|
|
362
|
+
if (error) reject(error);
|
|
363
|
+
else resolve();
|
|
355
364
|
};
|
|
356
365
|
const cancel = () => finish(new Error("Waiting cancelled; thread remains running"));
|
|
357
366
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
358
367
|
if (timeoutMs !== undefined) timer = setTimeout(() => finish(), timeoutMs);
|
|
359
|
-
record.run
|
|
368
|
+
(record.run ?? Promise.resolve()).then(
|
|
360
369
|
() => finish(),
|
|
361
370
|
(error) => finish(new Error(errorText(error))),
|
|
362
371
|
);
|
|
@@ -404,7 +413,7 @@ export class ThreadManager {
|
|
|
404
413
|
await item.run;
|
|
405
414
|
item.view.state = "stopped";
|
|
406
415
|
item.view.status = "Stopped; session retained";
|
|
407
|
-
|
|
416
|
+
item.view.output = undefined;
|
|
408
417
|
this.touch(item);
|
|
409
418
|
}),
|
|
410
419
|
);
|
|
@@ -429,14 +438,15 @@ export class ThreadManager {
|
|
|
429
438
|
// Idle/restored history may be lazily loaded, but attachment never prompts/resumes a turn.
|
|
430
439
|
if (!active(record.view)) await this.ensureDriver(record, true);
|
|
431
440
|
this.assertLive();
|
|
432
|
-
const listeners =
|
|
441
|
+
const listeners = record.transcriptListeners ?? new Set<TranscriptListener>();
|
|
442
|
+
record.transcriptListeners = listeners;
|
|
433
443
|
const registered: TranscriptListener = (snapshot) => listener(snapshot);
|
|
434
444
|
listeners.add(registered);
|
|
435
445
|
if (record.driver) this.attachTranscript(record, record.driver);
|
|
436
446
|
else {
|
|
437
447
|
// A starting child gets a placeholder immediately; existing startup supplies the live source.
|
|
438
|
-
|
|
439
|
-
if (this.
|
|
448
|
+
this.ensureDriver(record, true).catch((error) => {
|
|
449
|
+
if (this.isShutDown() || !listeners.has(registered)) return;
|
|
440
450
|
record.observationError = errorText(error);
|
|
441
451
|
this.publishTranscript(record);
|
|
442
452
|
});
|
|
@@ -445,7 +455,7 @@ export class ThreadManager {
|
|
|
445
455
|
snapshot: this.transcriptSnapshot(record),
|
|
446
456
|
unsubscribe: () => {
|
|
447
457
|
listeners.delete(registered);
|
|
448
|
-
if (
|
|
458
|
+
if (listeners.size === 0) {
|
|
449
459
|
record.detachTranscript?.();
|
|
450
460
|
record.detachTranscript = undefined;
|
|
451
461
|
record.transcriptSource = undefined;
|
|
@@ -473,7 +483,7 @@ export class ThreadManager {
|
|
|
473
483
|
await Promise.all(
|
|
474
484
|
records.map(async (record) => {
|
|
475
485
|
await record.driver?.abort();
|
|
476
|
-
await record.initializing?.catch(
|
|
486
|
+
await record.initializing?.catch(swallow);
|
|
477
487
|
await record.run;
|
|
478
488
|
record.driver?.dispose();
|
|
479
489
|
}),
|
|
@@ -488,8 +498,8 @@ export class ThreadManager {
|
|
|
488
498
|
record.view.state = "starting";
|
|
489
499
|
record.view.status = "Starting";
|
|
490
500
|
record.view.startedAt = Date.now();
|
|
491
|
-
|
|
492
|
-
|
|
501
|
+
record.view.output = undefined;
|
|
502
|
+
record.view.error = undefined;
|
|
493
503
|
const epoch = this.epoch;
|
|
494
504
|
let markStarted!: () => void;
|
|
495
505
|
record.started = new Promise((resolve) => {
|
|
@@ -501,7 +511,7 @@ export class ThreadManager {
|
|
|
501
511
|
if (contextReady) await abortable(contextReady, record.startup.signal);
|
|
502
512
|
record.startup.signal.throwIfAborted();
|
|
503
513
|
const driver = await this.ensureDriver(record);
|
|
504
|
-
if (record.stopRequested || this.
|
|
514
|
+
if (record.stopRequested || this.isShutDown()) {
|
|
505
515
|
record.view.state = "stopped";
|
|
506
516
|
return;
|
|
507
517
|
}
|
|
@@ -511,11 +521,7 @@ export class ThreadManager {
|
|
|
511
521
|
this.touch(record);
|
|
512
522
|
markStarted();
|
|
513
523
|
await task;
|
|
514
|
-
record.view.state = record
|
|
515
|
-
? "stopped"
|
|
516
|
-
: record.pauseRequested
|
|
517
|
-
? "paused"
|
|
518
|
-
: "completed";
|
|
524
|
+
record.view.state = settledState(record);
|
|
519
525
|
if (record.view.state === "completed") {
|
|
520
526
|
record.view.output = driver.output();
|
|
521
527
|
record.view.status = "Completed; session retained";
|
|
@@ -531,7 +537,7 @@ export class ThreadManager {
|
|
|
531
537
|
record.liveOutputTokens = 0;
|
|
532
538
|
markStarted(); // Failed/cancelled initialization must also release callers waiting to steer.
|
|
533
539
|
this.touch(record);
|
|
534
|
-
if (!this.
|
|
540
|
+
if (!this.isShutDown() && epoch === this.epoch)
|
|
535
541
|
this.options.onEvent?.({
|
|
536
542
|
kind: "settled",
|
|
537
543
|
thread: this.view(record),
|
|
@@ -586,7 +592,7 @@ export class ThreadManager {
|
|
|
586
592
|
return this.options.createDriver(options);
|
|
587
593
|
})
|
|
588
594
|
.then(async (driver) => {
|
|
589
|
-
if (this.
|
|
595
|
+
if (this.isShutDown() || signal.aborted) {
|
|
590
596
|
await driver.abort();
|
|
591
597
|
driver.dispose();
|
|
592
598
|
throw new Error("Driver startup cancelled");
|
|
@@ -615,7 +621,8 @@ export class ThreadManager {
|
|
|
615
621
|
private attachTranscript(record: Record, driver: AgentDriver): void {
|
|
616
622
|
if (record.detachTranscript || !record.transcriptListeners?.size) return;
|
|
617
623
|
const observation = driver.observeTranscript?.((snapshot) => {
|
|
618
|
-
if (this.
|
|
624
|
+
if (this.isShutDown() || record.driver !== driver || !record.transcriptListeners?.size)
|
|
625
|
+
return;
|
|
619
626
|
record.transcriptSource = snapshot;
|
|
620
627
|
this.publishTranscript(record);
|
|
621
628
|
});
|
|
@@ -641,7 +648,7 @@ export class ThreadManager {
|
|
|
641
648
|
});
|
|
642
649
|
}
|
|
643
650
|
private publishTranscript(record: Record): void {
|
|
644
|
-
if (this.
|
|
651
|
+
if (this.isShutDown() || !record.transcriptListeners?.size) return;
|
|
645
652
|
record.observationRevision = (record.observationRevision ?? 0) + 1;
|
|
646
653
|
const snapshot = this.transcriptSnapshot(record);
|
|
647
654
|
for (const listener of record.transcriptListeners) {
|
|
@@ -655,7 +662,7 @@ export class ThreadManager {
|
|
|
655
662
|
private touch(record: Record): void {
|
|
656
663
|
record.view.updatedAt = Date.now();
|
|
657
664
|
this.publishTranscript(record);
|
|
658
|
-
if (!this.
|
|
665
|
+
if (!this.isShutDown()) this.options.onEvent?.({ kind: "change", thread: this.view(record) });
|
|
659
666
|
}
|
|
660
667
|
/** Fold driver-cumulative usage into persisted totals. Partials only refresh the live view. */
|
|
661
668
|
private applyUsage(
|
|
@@ -669,7 +676,8 @@ export class ThreadManager {
|
|
|
669
676
|
if (input === record.liveInputTokens && output === record.liveOutputTokens) return;
|
|
670
677
|
record.liveInputTokens = input;
|
|
671
678
|
record.liveOutputTokens = output;
|
|
672
|
-
if (!this.
|
|
679
|
+
if (!this.isShutDown())
|
|
680
|
+
this.options.onEvent?.({ kind: "metrics", thread: this.view(record) });
|
|
673
681
|
return;
|
|
674
682
|
}
|
|
675
683
|
record.view.inputTokens =
|
|
@@ -685,7 +693,7 @@ export class ThreadManager {
|
|
|
685
693
|
const startedAt = record.view.startedAt;
|
|
686
694
|
if (startedAt === undefined) return;
|
|
687
695
|
record.view.elapsedMs = (record.view.elapsedMs ?? 0) + Math.max(0, Date.now() - startedAt);
|
|
688
|
-
|
|
696
|
+
record.view.startedAt = undefined;
|
|
689
697
|
}
|
|
690
698
|
private prepareInheritedContext(record: Record, readOnly = false): Promise<void> | undefined {
|
|
691
699
|
if (!record.contextPending) return record.contextReady;
|
|
@@ -709,7 +717,7 @@ export class ThreadManager {
|
|
|
709
717
|
throw error;
|
|
710
718
|
});
|
|
711
719
|
record.contextReady = ready;
|
|
712
|
-
|
|
720
|
+
ready.catch(swallow);
|
|
713
721
|
return ready;
|
|
714
722
|
}
|
|
715
723
|
private sessionLeafId(record: Record): string | null | undefined {
|
|
@@ -730,7 +738,7 @@ export class ThreadManager {
|
|
|
730
738
|
sessionFile: record.driver ? record.driver.sessionFile : record.view.sessionFile,
|
|
731
739
|
});
|
|
732
740
|
// devsys: show per-spawn pins so agent_status reports what the thread was told to run on.
|
|
733
|
-
if (record.definition
|
|
741
|
+
if (record.definition.spawnOverrides) view.pinned = { ...record.definition.spawnOverrides };
|
|
734
742
|
if (record.liveInputTokens) view.inputTokens = (view.inputTokens ?? 0) + record.liveInputTokens;
|
|
735
743
|
if (record.liveOutputTokens)
|
|
736
744
|
view.outputTokens = (view.outputTokens ?? 0) + record.liveOutputTokens;
|
|
@@ -741,8 +749,16 @@ export class ThreadManager {
|
|
|
741
749
|
if (!record) throw new Error(`Unknown thread ${path}`);
|
|
742
750
|
return record;
|
|
743
751
|
}
|
|
752
|
+
private inheritedFor(parent: string | null, parentRecord: Record | undefined): AgentMessage[] {
|
|
753
|
+
if (parent === "/root") return inheritContext(this.options.rootSnapshot());
|
|
754
|
+
return parentRecord?.driver ? inheritContext(parentRecord.driver.snapshot()) : [];
|
|
755
|
+
}
|
|
756
|
+
/** A method, not a field read: the flag flips across awaits, which static narrowing cannot see. */
|
|
757
|
+
private isShutDown(): boolean {
|
|
758
|
+
return this.disposed;
|
|
759
|
+
}
|
|
744
760
|
private assertLive(): void {
|
|
745
|
-
if (this.
|
|
761
|
+
if (this.isShutDown()) throw new Error("Thread manager has shut down");
|
|
746
762
|
}
|
|
747
763
|
private assertCallerMaySteer(caller: string): void {
|
|
748
764
|
if (caller !== "/root" && this.record(caller).stopRequested)
|
|
@@ -765,16 +781,27 @@ export class ThreadManager {
|
|
|
765
781
|
* on their own history. Make room for one more thread by dropping the oldest finished
|
|
766
782
|
* thread that has no retained descendant; live and paused threads are never dropped.
|
|
767
783
|
* Throws only when every retained thread is still live, paused or a parent of one.
|
|
784
|
+
* `spawning` is the path about to be created: its ancestors are kept (a finished parent
|
|
785
|
+
* with no other child is still the new thread's parent).
|
|
768
786
|
*/
|
|
769
|
-
private archiveFinished(limit: number): void {
|
|
787
|
+
private archiveFinished(limit: number, spawning: string): void {
|
|
770
788
|
while (this.records.size >= limit) {
|
|
771
789
|
const paths = [...this.records.keys()];
|
|
772
790
|
const victim = paths.find((path) => {
|
|
773
791
|
const state = this.records.get(path)?.view.state;
|
|
774
792
|
const finished = state === "completed" || state === "failed" || state === "stopped";
|
|
775
|
-
return
|
|
793
|
+
return (
|
|
794
|
+
finished &&
|
|
795
|
+
!isDescendant(spawning, path) &&
|
|
796
|
+
!paths.some((other) => isDescendant(other, path))
|
|
797
|
+
);
|
|
776
798
|
});
|
|
777
799
|
if (victim === undefined) throw new Error("Total thread limit reached");
|
|
800
|
+
const record = this.records.get(victim);
|
|
801
|
+
// Release what the record holds: shutdown() only walks retained records.
|
|
802
|
+
record?.detachTranscript?.();
|
|
803
|
+
record?.transcriptListeners?.clear();
|
|
804
|
+
record?.driver?.dispose();
|
|
778
805
|
this.records.delete(victim);
|
|
779
806
|
}
|
|
780
807
|
}
|
package/src/subagents/types.ts
CHANGED
|
@@ -20,9 +20,9 @@ export interface AgentType {
|
|
|
20
20
|
/** devsys: per-spawn pin set by the coordinator via agent_spawn; beats preferences and inheritance. */
|
|
21
21
|
spawnOverrides?: { model?: string; thinkingLevel?: ThinkingLevel };
|
|
22
22
|
/** Pi semantic token for the type pill background and task path foreground. */
|
|
23
|
-
color?: string;
|
|
23
|
+
color?: string | undefined;
|
|
24
24
|
/** Optional literal Nerd Font glyph; displayed only when the labs setting is enabled. */
|
|
25
|
-
icon?: string;
|
|
25
|
+
icon?: string | undefined;
|
|
26
26
|
tools?: { allow?: string[]; block?: string[] };
|
|
27
27
|
systemPrompt: string;
|
|
28
28
|
filePath?: string;
|
|
@@ -50,20 +50,20 @@ export interface ThreadView {
|
|
|
50
50
|
parent: string | null;
|
|
51
51
|
owner: string;
|
|
52
52
|
type: string;
|
|
53
|
-
color?: string;
|
|
54
|
-
icon?: string;
|
|
53
|
+
color?: string | undefined;
|
|
54
|
+
icon?: string | undefined;
|
|
55
55
|
state: ThreadState;
|
|
56
56
|
task: string;
|
|
57
57
|
status: string;
|
|
58
|
-
output?: string;
|
|
59
|
-
error?: string;
|
|
58
|
+
output?: string | undefined;
|
|
59
|
+
error?: string | undefined;
|
|
60
60
|
createdAt: number;
|
|
61
61
|
updatedAt: number;
|
|
62
|
-
sessionFile?: string;
|
|
62
|
+
sessionFile?: string | undefined;
|
|
63
63
|
/** Frozen active time. Renderer adds `Date.now() - startedAt` while a run is active. */
|
|
64
64
|
elapsedMs?: number;
|
|
65
65
|
/** Current run start. Live only; omitted from saved views so reloads do not count offline time. */
|
|
66
|
-
startedAt?: number;
|
|
66
|
+
startedAt?: number | undefined;
|
|
67
67
|
/** Cumulative input tokens, including cache read/write. Live views include the in-progress message. */
|
|
68
68
|
inputTokens?: number;
|
|
69
69
|
/** Cumulative output tokens. Live views include the in-progress message. */
|
|
@@ -76,7 +76,7 @@ type DriverEvent =
|
|
|
76
76
|
| {
|
|
77
77
|
kind: "checkpoint";
|
|
78
78
|
text: string;
|
|
79
|
-
sessionFile?: string;
|
|
79
|
+
sessionFile?: string | undefined;
|
|
80
80
|
sessionLeafId?: string | null;
|
|
81
81
|
}
|
|
82
82
|
| {
|
|
@@ -106,7 +106,7 @@ export interface TranscriptSnapshot {
|
|
|
106
106
|
tools: readonly TranscriptToolState[];
|
|
107
107
|
inheritedCount: number;
|
|
108
108
|
thread?: ThreadView;
|
|
109
|
-
error?: string;
|
|
109
|
+
error?: string | undefined;
|
|
110
110
|
}
|
|
111
111
|
export interface TranscriptObservation {
|
|
112
112
|
snapshot: TranscriptSnapshot;
|
|
@@ -123,7 +123,7 @@ export interface AgentDriver {
|
|
|
123
123
|
abort(): Promise<void>;
|
|
124
124
|
dispose(): void;
|
|
125
125
|
sendUpdate(content: string): Promise<void> | void;
|
|
126
|
-
sessionFile?: string;
|
|
126
|
+
sessionFile?: string | undefined;
|
|
127
127
|
sessionLeafId?: string | null;
|
|
128
128
|
}
|
|
129
129
|
export interface DriverOptions {
|
|
@@ -134,7 +134,7 @@ export interface DriverOptions {
|
|
|
134
134
|
onEvent(event: DriverEvent): void;
|
|
135
135
|
shouldPause(): boolean;
|
|
136
136
|
parentPath: string | null;
|
|
137
|
-
sessionFile?: string;
|
|
137
|
+
sessionFile?: string | undefined;
|
|
138
138
|
/** Retained transcript of a nested lexical parent. Absent for /root and independent roots. */
|
|
139
139
|
parentSessionFile?: string;
|
|
140
140
|
sessionLeafId?: string | null;
|
|
@@ -145,15 +145,15 @@ export interface SavedThreadView {
|
|
|
145
145
|
parent: string | null;
|
|
146
146
|
owner: string;
|
|
147
147
|
type: string;
|
|
148
|
-
color?: string;
|
|
149
|
-
icon?: string;
|
|
148
|
+
color?: string | undefined;
|
|
149
|
+
icon?: string | undefined;
|
|
150
150
|
state: ThreadState;
|
|
151
151
|
task: string;
|
|
152
152
|
status: string;
|
|
153
|
-
output?: string;
|
|
154
|
-
error?: string;
|
|
153
|
+
output?: string | undefined;
|
|
154
|
+
error?: string | undefined;
|
|
155
155
|
createdAt: number;
|
|
156
|
-
sessionFile?: string;
|
|
156
|
+
sessionFile?: string | undefined;
|
|
157
157
|
sessionLeafId?: string | null;
|
|
158
158
|
/** Frozen cumulative active time. `startedAt` is intentionally not persisted. */
|
|
159
159
|
elapsedMs?: number;
|