@gotgenes/pi-subagents 10.0.1 → 10.1.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/CHANGELOG.md +25 -0
- package/docs/architecture/architecture.md +51 -140
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/plans/0227-evolve-agent-record-into-agent.md +322 -0
- package/docs/retro/0227-evolve-agent-record-into-agent.md +37 -0
- package/docs/retro/0239-collapse-filter-active-tools.md +33 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +39 -89
- package/src/lifecycle/{agent-record.ts → agent.ts} +68 -10
- package/src/lifecycle/execution-state.ts +2 -2
- package/src/observation/notification.ts +8 -8
- package/src/observation/record-observer.ts +7 -7
- package/src/service/service-adapter.ts +8 -8
- package/src/tools/agent-tool.ts +4 -4
- package/src/tools/background-spawner.ts +2 -2
- package/src/tools/foreground-runner.ts +4 -4
- package/src/tools/get-result-tool.ts +2 -2
- package/src/tools/steer-tool.ts +4 -5
- package/src/types.ts +1 -1
- package/src/ui/agent-creation-wizard.ts +2 -2
- package/src/ui/agent-menu.ts +5 -5
- package/src/ui/conversation-viewer.ts +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-manager.ts
|
|
2
|
+
* agent-manager.ts - Tracks agents, background execution, resume support.
|
|
3
3
|
*
|
|
4
4
|
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
5
|
* Excess agents are queued and auto-started as running agents complete.
|
|
@@ -11,23 +11,23 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
11
11
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
13
13
|
import { debugLog } from "#src/debug";
|
|
14
|
-
import {
|
|
14
|
+
import { Agent } from "#src/lifecycle/agent";
|
|
15
15
|
import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
|
|
16
16
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
17
17
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
import { NotificationState } from "#src/observation/notification-state";
|
|
20
|
-
import {
|
|
20
|
+
import { subscribeAgentObserver } from "#src/observation/record-observer";
|
|
21
21
|
import type { RunConfig } from "#src/runtime";
|
|
22
22
|
import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* RunHandle
|
|
25
|
+
* RunHandle - per-run lifecycle object that owns cleanup state.
|
|
26
26
|
*
|
|
27
27
|
* Owns the observer unsubscribe and parent-signal detach handles acquired during
|
|
28
28
|
* a run. Exposes `complete()` and `fail()` as the only way to finish a run,
|
|
29
29
|
* eliminating mutable closure variables from `startAgent`.
|
|
30
|
-
* `fireOnFinished` is idempotent
|
|
30
|
+
* `fireOnFinished` is idempotent - safe to call from both success and error paths.
|
|
31
31
|
*/
|
|
32
32
|
class RunHandle {
|
|
33
33
|
private unsub?: () => void;
|
|
@@ -35,7 +35,7 @@ class RunHandle {
|
|
|
35
35
|
private onFinished?: () => void;
|
|
36
36
|
|
|
37
37
|
constructor(
|
|
38
|
-
private readonly record:
|
|
38
|
+
private readonly record: Agent,
|
|
39
39
|
private readonly worktrees: WorktreeManager,
|
|
40
40
|
onFinished?: () => void,
|
|
41
41
|
) {
|
|
@@ -55,7 +55,7 @@ class RunHandle {
|
|
|
55
55
|
this.unsub = unsub;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** Complete a run successfully
|
|
58
|
+
/** Complete a run successfully - clean up, transition record, fire onFinished. */
|
|
59
59
|
complete(result: RunResult): string {
|
|
60
60
|
this.releaseListeners();
|
|
61
61
|
|
|
@@ -81,7 +81,7 @@ class RunHandle {
|
|
|
81
81
|
return result.responseText;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/** Fail a run
|
|
84
|
+
/** Fail a run - mark error, best-effort worktree cleanup, fire onFinished. */
|
|
85
85
|
fail(err: unknown): void {
|
|
86
86
|
this.record.markError(err);
|
|
87
87
|
this.releaseListeners();
|
|
@@ -114,11 +114,11 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
|
|
|
114
114
|
|
|
115
115
|
/** Observer interface for agent lifecycle notifications. */
|
|
116
116
|
export interface AgentManagerObserver {
|
|
117
|
-
onAgentStarted(record:
|
|
118
|
-
onAgentCompleted(record:
|
|
119
|
-
onAgentCompacted(record:
|
|
117
|
+
onAgentStarted(record: Agent): void;
|
|
118
|
+
onAgentCompleted(record: Agent): void;
|
|
119
|
+
onAgentCompacted(record: Agent, info: CompactionInfo): void;
|
|
120
120
|
/** Fires synchronously after a background agent record is created (before startAgent). */
|
|
121
|
-
onAgentCreated(record:
|
|
121
|
+
onAgentCreated(record: Agent): void;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/** Default max concurrent background agents. */
|
|
@@ -129,7 +129,7 @@ export interface AgentManagerOptions {
|
|
|
129
129
|
worktrees: WorktreeManager;
|
|
130
130
|
exec: ShellExec;
|
|
131
131
|
registry: AgentTypeRegistry;
|
|
132
|
-
/** Injected getter for the concurrency limit
|
|
132
|
+
/** Injected getter for the concurrency limit - owned by SettingsManager. */
|
|
133
133
|
getMaxConcurrent?: () => number;
|
|
134
134
|
getRunConfig?: () => RunConfig;
|
|
135
135
|
observer?: AgentManagerObserver;
|
|
@@ -160,25 +160,25 @@ export interface AgentSpawnConfig {
|
|
|
160
160
|
thinkingLevel?: ThinkingLevel;
|
|
161
161
|
isBackground?: boolean;
|
|
162
162
|
/**
|
|
163
|
-
* Skip the maxConcurrent queue check for this spawn
|
|
163
|
+
* Skip the maxConcurrent queue check for this spawn - start immediately even
|
|
164
164
|
* if the configured concurrency limit would otherwise queue it. Useful for
|
|
165
165
|
* callers (e.g. cross-extension RPC) that must not be deferred by the queue.
|
|
166
166
|
*/
|
|
167
167
|
bypassQueue?: boolean;
|
|
168
|
-
/** Isolation mode
|
|
168
|
+
/** Isolation mode - "worktree" creates a temp git worktree for the agent. */
|
|
169
169
|
isolation?: IsolationMode;
|
|
170
170
|
/** Resolved invocation snapshot captured for UI display. */
|
|
171
171
|
invocation?: AgentInvocation;
|
|
172
|
-
/** Parent abort signal
|
|
172
|
+
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
173
173
|
signal?: AbortSignal;
|
|
174
|
-
/** Called when the agent session is created
|
|
175
|
-
onSessionCreated?: (session: AgentSession, record:
|
|
176
|
-
/** Parent session identity
|
|
174
|
+
/** Called when the agent session is created - receives the session and the agent's record. */
|
|
175
|
+
onSessionCreated?: (session: AgentSession, record: Agent) => void;
|
|
176
|
+
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
177
177
|
parentSession?: ParentSessionInfo;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
export class AgentManager {
|
|
181
|
-
private agents = new Map<string,
|
|
181
|
+
private agents = new Map<string, Agent>();
|
|
182
182
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
183
183
|
private readonly observer?: AgentManagerObserver;
|
|
184
184
|
private readonly runner: AgentRunner;
|
|
@@ -192,9 +192,6 @@ export class AgentManager {
|
|
|
192
192
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
193
193
|
/** Number of currently running background agents. */
|
|
194
194
|
private runningBackground = 0;
|
|
195
|
-
/** Steers buffered for agents whose session hasn’t been created yet. */
|
|
196
|
-
private pendingSteers = new Map<string, string[]>();
|
|
197
|
-
|
|
198
195
|
constructor(options: AgentManagerOptions) {
|
|
199
196
|
this.runner = options.runner;
|
|
200
197
|
this.worktrees = options.worktrees;
|
|
@@ -216,19 +213,6 @@ export class AgentManager {
|
|
|
216
213
|
this.drainQueue();
|
|
217
214
|
}
|
|
218
215
|
|
|
219
|
-
/**
|
|
220
|
-
* Buffer a steer message for an agent whose session isn’t ready yet.
|
|
221
|
-
* Returns false if the agent id is not tracked (already cleaned up or unknown).
|
|
222
|
-
* Called by steer-tool and service-adapter when record.execution is undefined.
|
|
223
|
-
*/
|
|
224
|
-
queueSteer(id: string, message: string): boolean {
|
|
225
|
-
if (!this.agents.has(id)) return false;
|
|
226
|
-
const steers = this.pendingSteers.get(id) ?? [];
|
|
227
|
-
steers.push(message);
|
|
228
|
-
this.pendingSteers.set(id, steers);
|
|
229
|
-
return true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
216
|
/**
|
|
233
217
|
* Spawn an agent and return its ID immediately (for background use).
|
|
234
218
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -241,7 +225,7 @@ export class AgentManager {
|
|
|
241
225
|
): string {
|
|
242
226
|
const id = randomUUID().slice(0, 17);
|
|
243
227
|
const abortController = new AbortController();
|
|
244
|
-
const record = new
|
|
228
|
+
const record = new Agent({
|
|
245
229
|
id,
|
|
246
230
|
type,
|
|
247
231
|
description: options.description,
|
|
@@ -263,12 +247,12 @@ export class AgentManager {
|
|
|
263
247
|
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
264
248
|
|
|
265
249
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
|
|
266
|
-
// Queue it
|
|
250
|
+
// Queue it - will be started when a running agent completes
|
|
267
251
|
this.queue.push({ id, args });
|
|
268
252
|
return id;
|
|
269
253
|
}
|
|
270
254
|
|
|
271
|
-
// startAgent can throw (e.g. strict worktree-isolation failure)
|
|
255
|
+
// startAgent can throw (e.g. strict worktree-isolation failure) - clean
|
|
272
256
|
// up the record so callers don't see an orphan in `listAgents()`.
|
|
273
257
|
try {
|
|
274
258
|
this.startAgent(id, record, args);
|
|
@@ -280,8 +264,8 @@ export class AgentManager {
|
|
|
280
264
|
}
|
|
281
265
|
|
|
282
266
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
283
|
-
private startAgent(id: string, record:
|
|
284
|
-
const worktreeCwd =
|
|
267
|
+
private startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
268
|
+
const worktreeCwd = record.setupWorktree(this.worktrees, options.isolation);
|
|
285
269
|
|
|
286
270
|
record.markRunning(Date.now());
|
|
287
271
|
if (options.isBackground) this.runningBackground++;
|
|
@@ -314,8 +298,8 @@ export class AgentManager {
|
|
|
314
298
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
315
299
|
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
316
300
|
record.execution = { session, outputFile };
|
|
317
|
-
|
|
318
|
-
handle.attachObserver(
|
|
301
|
+
record.flushPendingSteers(session);
|
|
302
|
+
handle.attachObserver(subscribeAgentObserver(session, record, {
|
|
319
303
|
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
320
304
|
}));
|
|
321
305
|
options.onSessionCreated?.(session, record);
|
|
@@ -325,34 +309,8 @@ export class AgentManager {
|
|
|
325
309
|
.catch((err: unknown) => { handle.fail(err); return ""; });
|
|
326
310
|
}
|
|
327
311
|
|
|
328
|
-
/** Create a worktree for isolated agents. Throws (strict) if isolation is requested but impossible. */
|
|
329
|
-
private setupWorktree(
|
|
330
|
-
id: string, record: AgentRecord, isolation: IsolationMode | undefined,
|
|
331
|
-
): string | undefined {
|
|
332
|
-
if (isolation !== "worktree") return undefined;
|
|
333
|
-
const wt = this.worktrees.create(id);
|
|
334
|
-
if (!wt) {
|
|
335
|
-
throw new Error(
|
|
336
|
-
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
337
|
-
'Initialize git and commit at least once, or omit `isolation`.',
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
record.worktreeState = new WorktreeState(wt);
|
|
341
|
-
return wt.path;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Flush any steers buffered before the session was ready. */
|
|
345
|
-
private flushPendingSteers(id: string, session: AgentSession): void {
|
|
346
|
-
const buffered = this.pendingSteers.get(id);
|
|
347
|
-
if (!buffered?.length) return;
|
|
348
|
-
for (const msg of buffered) {
|
|
349
|
-
session.steer(msg).catch(() => {});
|
|
350
|
-
}
|
|
351
|
-
this.pendingSteers.delete(id);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
312
|
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
355
|
-
private finalizeBackgroundRun(record:
|
|
313
|
+
private finalizeBackgroundRun(record: Agent): void {
|
|
356
314
|
this.runningBackground--;
|
|
357
315
|
try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
358
316
|
this.drainQueue();
|
|
@@ -367,7 +325,7 @@ export class AgentManager {
|
|
|
367
325
|
try {
|
|
368
326
|
this.startAgent(next.id, record, next.args);
|
|
369
327
|
} catch (err) {
|
|
370
|
-
// Late failure (e.g. strict worktree-isolation)
|
|
328
|
+
// Late failure (e.g. strict worktree-isolation) - surface on the record
|
|
371
329
|
// so the user/agent can see it via /agents, then keep draining.
|
|
372
330
|
record.markError(err);
|
|
373
331
|
this.observer?.onAgentCompleted(record);
|
|
@@ -384,7 +342,7 @@ export class AgentManager {
|
|
|
384
342
|
type: SubagentType,
|
|
385
343
|
prompt: string,
|
|
386
344
|
options: Omit<AgentSpawnConfig, "isBackground">,
|
|
387
|
-
): Promise<
|
|
345
|
+
): Promise<Agent> {
|
|
388
346
|
const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
|
|
389
347
|
const record = this.agents.get(id)!;
|
|
390
348
|
await record.promise;
|
|
@@ -398,14 +356,14 @@ export class AgentManager {
|
|
|
398
356
|
id: string,
|
|
399
357
|
prompt: string,
|
|
400
358
|
signal?: AbortSignal,
|
|
401
|
-
): Promise<
|
|
359
|
+
): Promise<Agent | undefined> {
|
|
402
360
|
const record = this.agents.get(id);
|
|
403
361
|
const session = record?.session;
|
|
404
362
|
if (!session) return undefined;
|
|
405
363
|
|
|
406
364
|
record.resetForResume(Date.now());
|
|
407
365
|
|
|
408
|
-
const unsubResume =
|
|
366
|
+
const unsubResume = subscribeAgentObserver(session, record, {
|
|
409
367
|
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
410
368
|
});
|
|
411
369
|
|
|
@@ -423,11 +381,11 @@ export class AgentManager {
|
|
|
423
381
|
return record;
|
|
424
382
|
}
|
|
425
383
|
|
|
426
|
-
getRecord(id: string):
|
|
384
|
+
getRecord(id: string): Agent | undefined {
|
|
427
385
|
return this.agents.get(id);
|
|
428
386
|
}
|
|
429
387
|
|
|
430
|
-
listAgents():
|
|
388
|
+
listAgents(): Agent[] {
|
|
431
389
|
return [...this.agents.values()].sort(
|
|
432
390
|
(a, b) => b.startedAt - a.startedAt,
|
|
433
391
|
);
|
|
@@ -444,18 +402,14 @@ export class AgentManager {
|
|
|
444
402
|
return true;
|
|
445
403
|
}
|
|
446
404
|
|
|
447
|
-
|
|
448
|
-
record.abortController?.abort();
|
|
449
|
-
record.markStopped();
|
|
450
|
-
return true;
|
|
405
|
+
return record.abort();
|
|
451
406
|
}
|
|
452
407
|
|
|
453
408
|
/** Dispose a record's session and remove it from the map. */
|
|
454
|
-
private removeRecord(id: string, record:
|
|
409
|
+
private removeRecord(id: string, record: Agent): void {
|
|
455
410
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
456
411
|
record.session?.dispose?.();
|
|
457
412
|
this.agents.delete(id);
|
|
458
|
-
this.pendingSteers.delete(id);
|
|
459
413
|
}
|
|
460
414
|
|
|
461
415
|
private cleanup() {
|
|
@@ -501,11 +455,7 @@ export class AgentManager {
|
|
|
501
455
|
this.queue = [];
|
|
502
456
|
// Abort running agents
|
|
503
457
|
for (const record of this.agents.values()) {
|
|
504
|
-
if (record.
|
|
505
|
-
record.abortController?.abort();
|
|
506
|
-
record.markStopped();
|
|
507
|
-
count++;
|
|
508
|
-
}
|
|
458
|
+
if (record.abort()) count++;
|
|
509
459
|
}
|
|
510
460
|
return count;
|
|
511
461
|
}
|
|
@@ -513,7 +463,7 @@ export class AgentManager {
|
|
|
513
463
|
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
514
464
|
// fallow-ignore-next-line unused-class-member
|
|
515
465
|
async waitForAll(): Promise<void> {
|
|
516
|
-
// Loop because drainQueue respects the concurrency limit
|
|
466
|
+
// Loop because drainQueue respects the concurrency limit - as running
|
|
517
467
|
// agents finish they start queued ones, which need awaiting too.
|
|
518
468
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
|
|
519
469
|
while (true) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent
|
|
2
|
+
* agent.ts — Agent class with encapsulated status-transition logic and per-agent behavior.
|
|
3
3
|
*
|
|
4
4
|
* Status transitions (status, result, error, startedAt, completedAt) are owned
|
|
5
5
|
* by the class and exposed via transition methods. External code reads these
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
9
9
|
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
10
10
|
*
|
|
11
|
+
* Behavior (abort, steer buffering, worktree setup) lives on the agent
|
|
12
|
+
* rather than on AgentManager — each agent manages its own lifecycle concerns.
|
|
13
|
+
*
|
|
11
14
|
* Phase-specific collaborators (execution, worktreeState, notification) are attached
|
|
12
15
|
* after construction as lifecycle information becomes available.
|
|
13
16
|
*/
|
|
@@ -16,11 +19,12 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
|
16
19
|
import type { ExecutionState } from "#src/lifecycle/execution-state";
|
|
17
20
|
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
18
21
|
import { addUsage } from "#src/lifecycle/usage";
|
|
19
|
-
import type {
|
|
22
|
+
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
23
|
+
import { WorktreeState } from "#src/lifecycle/worktree-state";
|
|
20
24
|
import type { NotificationState } from "#src/observation/notification-state";
|
|
21
|
-
import type { AgentInvocation, SubagentType } from "#src/types";
|
|
25
|
+
import type { AgentInvocation, IsolationMode, SubagentType } from "#src/types";
|
|
22
26
|
|
|
23
|
-
export type
|
|
27
|
+
export type AgentStatus =
|
|
24
28
|
| "queued"
|
|
25
29
|
| "running"
|
|
26
30
|
| "completed"
|
|
@@ -29,11 +33,11 @@ export type AgentRecordStatus =
|
|
|
29
33
|
| "stopped"
|
|
30
34
|
| "error";
|
|
31
35
|
|
|
32
|
-
export interface
|
|
36
|
+
export interface AgentInit {
|
|
33
37
|
id: string;
|
|
34
38
|
type: SubagentType;
|
|
35
39
|
description: string;
|
|
36
|
-
status?:
|
|
40
|
+
status?: AgentStatus;
|
|
37
41
|
startedAt?: number;
|
|
38
42
|
completedAt?: number;
|
|
39
43
|
result?: string;
|
|
@@ -43,7 +47,7 @@ export interface AgentRecordInit {
|
|
|
43
47
|
promise?: Promise<string>;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
export class
|
|
50
|
+
export class Agent {
|
|
47
51
|
// Identity — set once at construction
|
|
48
52
|
readonly id: string;
|
|
49
53
|
readonly type: SubagentType;
|
|
@@ -51,8 +55,8 @@ export class AgentRecord {
|
|
|
51
55
|
readonly invocation?: AgentInvocation;
|
|
52
56
|
|
|
53
57
|
// Transition state — encapsulated behind getters, mutated only via transition methods
|
|
54
|
-
private _status:
|
|
55
|
-
get status():
|
|
58
|
+
private _status: AgentStatus;
|
|
59
|
+
get status(): AgentStatus { return this._status; }
|
|
56
60
|
|
|
57
61
|
private _result?: string;
|
|
58
62
|
get result(): string | undefined { return this._result; }
|
|
@@ -86,6 +90,29 @@ export class AgentRecord {
|
|
|
86
90
|
worktreeState?: WorktreeState;
|
|
87
91
|
notification?: NotificationState;
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Create a git worktree for isolated execution, set worktreeState, and return the worktree path.
|
|
95
|
+
* Returns undefined if isolation is not "worktree".
|
|
96
|
+
* Throws if worktree creation fails (strict isolation).
|
|
97
|
+
*/
|
|
98
|
+
setupWorktree(worktrees: WorktreeManager, isolation: IsolationMode | undefined): string | undefined {
|
|
99
|
+
if (isolation !== "worktree") return undefined;
|
|
100
|
+
const wt = worktrees.create(this.id);
|
|
101
|
+
if (!wt) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
104
|
+
'Initialize git and commit at least once, or omit `isolation`.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
this.worktreeState = new WorktreeState(wt);
|
|
108
|
+
return wt.path;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Steer buffer — messages queued before the session is ready
|
|
112
|
+
private _pendingSteers: string[] = [];
|
|
113
|
+
/** Number of steer messages waiting to be delivered. */
|
|
114
|
+
get pendingSteerCount(): number { return this._pendingSteers.length; }
|
|
115
|
+
|
|
89
116
|
/** The active agent session, or undefined before the session is created. */
|
|
90
117
|
get session(): AgentSession | undefined {
|
|
91
118
|
return this.execution?.session;
|
|
@@ -96,7 +123,7 @@ export class AgentRecord {
|
|
|
96
123
|
return this.execution?.outputFile;
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
constructor(init:
|
|
126
|
+
constructor(init: AgentInit) {
|
|
100
127
|
this.id = init.id;
|
|
101
128
|
this.type = init.type;
|
|
102
129
|
this.description = init.description;
|
|
@@ -190,6 +217,37 @@ export class AgentRecord {
|
|
|
190
217
|
this._completedAt = completedAt ?? Date.now();
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Abort a running agent: fire AbortController and transition to stopped.
|
|
222
|
+
* Returns false if the agent is not running.
|
|
223
|
+
* Queue removal stays on AgentManager until #230 extracts ConcurrencyQueue.
|
|
224
|
+
*/
|
|
225
|
+
abort(): boolean {
|
|
226
|
+
if (this._status !== "running") return false;
|
|
227
|
+
this.abortController?.abort();
|
|
228
|
+
this.markStopped();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Buffer a steer message for delivery once the session is ready.
|
|
234
|
+
* Called when steer is requested before onSessionCreated fires.
|
|
235
|
+
*/
|
|
236
|
+
queueSteer(message: string): void {
|
|
237
|
+
this._pendingSteers.push(message);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Flush all buffered steer messages to the session and clear the buffer.
|
|
242
|
+
* Called from onSessionCreated once the session is available.
|
|
243
|
+
*/
|
|
244
|
+
flushPendingSteers(session: AgentSession): void {
|
|
245
|
+
for (const msg of this._pendingSteers) {
|
|
246
|
+
session.steer(msg).catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
this._pendingSteers = [];
|
|
249
|
+
}
|
|
250
|
+
|
|
193
251
|
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
194
252
|
resetForResume(startedAt: number): void {
|
|
195
253
|
this._status = "running";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* execution-state.ts — ExecutionState: execution-phase state for a running agent.
|
|
3
3
|
*
|
|
4
|
-
* Constructed and attached to
|
|
4
|
+
* Constructed and attached to Agent when onSessionCreated fires inside startAgent().
|
|
5
5
|
* Contains the session and output file — the two fields that become known once the
|
|
6
|
-
* runner creates the session. promise stays as a separate
|
|
6
|
+
* runner creates the session. promise stays as a separate Agent field because
|
|
7
7
|
* it is set at a different moment (after runner.run() returns).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { debugLog } from "#src/debug";
|
|
2
2
|
import { getLifetimeTotal, getSessionContextPercent } from "#src/lifecycle/usage";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Agent } from "#src/types";
|
|
4
4
|
import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
5
5
|
|
|
6
6
|
/** Details attached to custom notification messages for visual rendering. */
|
|
@@ -42,7 +42,7 @@ export function getStatusLabel(status: string, error?: string): string {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/** Format a structured task notification matching Claude Code's <task-notification> XML. */
|
|
45
|
-
export function formatTaskNotification(record:
|
|
45
|
+
export function formatTaskNotification(record: Agent, resultMaxLen: number): string {
|
|
46
46
|
const status = getStatusLabel(record.status, record.error);
|
|
47
47
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
48
48
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
@@ -75,7 +75,7 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
|
|
|
75
75
|
|
|
76
76
|
/** Build notification details for the custom message renderer. */
|
|
77
77
|
export function buildNotificationDetails(
|
|
78
|
-
record:
|
|
78
|
+
record: Agent,
|
|
79
79
|
resultMaxLen: number,
|
|
80
80
|
activity?: AgentActivityTracker,
|
|
81
81
|
): NotificationDetails {
|
|
@@ -100,8 +100,8 @@ export function buildNotificationDetails(
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/** Build event data for lifecycle events from an
|
|
104
|
-
export function buildEventData(record:
|
|
103
|
+
/** Build event data for lifecycle events from an Agent. */
|
|
104
|
+
export function buildEventData(record: Agent) {
|
|
105
105
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
106
106
|
const u = record.lifetimeUsage;
|
|
107
107
|
const total = getLifetimeTotal(u);
|
|
@@ -126,7 +126,7 @@ export function buildEventData(record: AgentRecord) {
|
|
|
126
126
|
|
|
127
127
|
export interface NotificationSystem {
|
|
128
128
|
cancelNudge: (key: string) => void;
|
|
129
|
-
sendCompletion: (record:
|
|
129
|
+
sendCompletion: (record: Agent) => void;
|
|
130
130
|
cleanupCompleted: (id: string) => void;
|
|
131
131
|
dispose: () => void;
|
|
132
132
|
}
|
|
@@ -154,7 +154,7 @@ export class NotificationManager implements NotificationSystem {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
sendCompletion(record:
|
|
157
|
+
sendCompletion(record: Agent): void {
|
|
158
158
|
this.agentActivity.delete(record.id);
|
|
159
159
|
this.markFinished(record.id);
|
|
160
160
|
this.scheduleNudge(record.id, () => this.emitIndividualNudge(record));
|
|
@@ -187,7 +187,7 @@ export class NotificationManager implements NotificationSystem {
|
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
private emitIndividualNudge(record:
|
|
190
|
+
private emitIndividualNudge(record: Agent): void {
|
|
191
191
|
if (record.notification?.resultConsumed) return;
|
|
192
192
|
|
|
193
193
|
const notification = formatTaskNotification(record, 500);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* record-observer.ts — Subscribes to session events and updates
|
|
2
|
+
* record-observer.ts — Subscribes to session events and updates Agent stats.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the scattered callback-wrapping logic in AgentManager's startAgent()
|
|
5
5
|
* and resume() with a single direct subscription.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { Agent } from "#src/lifecycle/agent";
|
|
8
9
|
import type { CompactionInfo } from "#src/lifecycle/agent-manager";
|
|
9
|
-
import type { AgentRecord } from "#src/lifecycle/agent-record";
|
|
10
10
|
import type { SubscribableSession } from "#src/types";
|
|
11
11
|
|
|
12
|
-
export interface
|
|
13
|
-
onCompact?: (record:
|
|
12
|
+
export interface AgentObserverOptions {
|
|
13
|
+
onCompact?: (record: Agent, info: CompactionInfo) => void;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -23,10 +23,10 @@ export interface RecordObserverOptions {
|
|
|
23
23
|
*
|
|
24
24
|
* @returns An unsubscribe function.
|
|
25
25
|
*/
|
|
26
|
-
export function
|
|
26
|
+
export function subscribeAgentObserver(
|
|
27
27
|
session: SubscribableSession,
|
|
28
|
-
record:
|
|
29
|
-
options?:
|
|
28
|
+
record: Agent,
|
|
29
|
+
options?: AgentObserverOptions,
|
|
30
30
|
): () => void {
|
|
31
31
|
return session.subscribe((event) => {
|
|
32
32
|
if (event.type === "tool_execution_end") {
|
|
@@ -8,17 +8,16 @@
|
|
|
8
8
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
9
9
|
import type { SpawnOptions, SubagentRecord, SubagentsService } from "#src/service/service";
|
|
10
10
|
import type { ModelRegistry } from "#src/session/model-resolver";
|
|
11
|
-
import type {
|
|
11
|
+
import type { Agent, SessionContext } from "#src/types";
|
|
12
12
|
|
|
13
13
|
/** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
|
|
14
14
|
export interface AgentManagerLike {
|
|
15
15
|
spawn(snapshot: ParentSnapshot, type: string, prompt: string, options: unknown): string;
|
|
16
|
-
getRecord(id: string):
|
|
17
|
-
listAgents():
|
|
16
|
+
getRecord(id: string): Agent | undefined;
|
|
17
|
+
listAgents(): Agent[];
|
|
18
18
|
abort(id: string): boolean;
|
|
19
19
|
waitForAll(): Promise<void>;
|
|
20
20
|
hasRunning(): boolean;
|
|
21
|
-
queueSteer(id: string, message: string): boolean;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/**
|
|
@@ -93,8 +92,9 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
93
92
|
}
|
|
94
93
|
const session = record.session;
|
|
95
94
|
if (!session) {
|
|
96
|
-
// Session not ready yet —
|
|
97
|
-
|
|
95
|
+
// Session not ready yet — buffer on the agent for delivery once initialized
|
|
96
|
+
record.queueSteer(message);
|
|
97
|
+
return true;
|
|
98
98
|
}
|
|
99
99
|
await session.steer(message);
|
|
100
100
|
return true;
|
|
@@ -110,10 +110,10 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* Convert an internal
|
|
113
|
+
* Convert an internal Agent to a serializable SubagentRecord.
|
|
114
114
|
* Uses an explicit allowlist — new fields must be opted in.
|
|
115
115
|
*/
|
|
116
|
-
export function toSubagentRecord(record:
|
|
116
|
+
export function toSubagentRecord(record: Agent): SubagentRecord {
|
|
117
117
|
const out: SubagentRecord = {
|
|
118
118
|
id: record.id,
|
|
119
119
|
type: record.type,
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { runForeground } from "#src/tools/foreground-runner";
|
|
|
11
11
|
import { buildDetails, buildTypeListText, textResult } from "#src/tools/helpers";
|
|
12
12
|
import { renderAgentResult } from "#src/tools/result-renderer";
|
|
13
13
|
import { type ModelInfo, resolveSpawnConfig } from "#src/tools/spawn-config";
|
|
14
|
-
import type {
|
|
14
|
+
import type { Agent } from "#src/types";
|
|
15
15
|
import { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
16
16
|
import { type UICtx } from "#src/ui/agent-widget";
|
|
17
17
|
import { type AgentDetails, getDisplayName } from "#src/ui/display";
|
|
@@ -33,9 +33,9 @@ export interface AgentActivityAccess {
|
|
|
33
33
|
/** Narrow manager interface — only the methods the Agent tool calls. */
|
|
34
34
|
export interface AgentToolManager {
|
|
35
35
|
spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string;
|
|
36
|
-
spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<
|
|
37
|
-
resume: (id: string, prompt: string, signal: AbortSignal) => Promise<
|
|
38
|
-
getRecord: (id: string) =>
|
|
36
|
+
spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<Agent>;
|
|
37
|
+
resume: (id: string, prompt: string, signal: AbortSignal) => Promise<Agent | undefined>;
|
|
38
|
+
getRecord: (id: string) => Agent | undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */
|
|
@@ -3,14 +3,14 @@ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
|
3
3
|
import type { AgentActivityAccess } from "#src/tools/agent-tool";
|
|
4
4
|
import { textResult } from "#src/tools/helpers";
|
|
5
5
|
import type { ResolvedSpawnConfig } from "#src/tools/spawn-config";
|
|
6
|
-
import type {
|
|
6
|
+
import type { Agent } from "#src/types";
|
|
7
7
|
import { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
8
8
|
import { subscribeUIObserver } from "#src/ui/ui-observer";
|
|
9
9
|
|
|
10
10
|
/** Narrow manager interface for the background spawner. */
|
|
11
11
|
export interface BackgroundManagerDeps {
|
|
12
12
|
spawn(snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig): string;
|
|
13
|
-
getRecord(id: string):
|
|
13
|
+
getRecord(id: string): Agent | undefined;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** Narrow widget interface for the background spawner. */
|