@gotgenes/pi-subagents 6.7.0 → 6.8.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 +16 -0
- package/docs/architecture/architecture.md +30 -29
- package/docs/plans/0111-split-agent-record-lifecycle.md +582 -0
- package/docs/retro/0110-agent-activity-tracker.md +44 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +41 -21
- package/src/agent-record.ts +45 -34
- package/src/execution-state.ts +17 -0
- package/src/index.ts +2 -1
- package/src/notification-state.ts +27 -0
- package/src/notification.ts +9 -6
- package/src/record-observer.ts +6 -7
- package/src/service-adapter.ts +8 -7
- package/src/tools/agent-tool.ts +6 -4
- package/src/tools/get-result-tool.ts +7 -5
- package/src/tools/steer-tool.ts +8 -6
- package/src/ui/agent-menu.ts +2 -2
- package/src/worktree-state.ts +35 -0
package/src/agent-manager.ts
CHANGED
|
@@ -13,11 +13,13 @@ import { AgentRecord } from "./agent-record.js";
|
|
|
13
13
|
import type { AgentRunner } from "./agent-runner.js";
|
|
14
14
|
import { AgentTypeRegistry } from "./agent-types.js";
|
|
15
15
|
import { debugLog } from "./debug.js";
|
|
16
|
+
import type { ExecutionState } from "./execution-state.js";
|
|
16
17
|
import { buildParentSnapshot } from "./parent-snapshot.js";
|
|
17
18
|
import { subscribeRecordObserver } from "./record-observer.js";
|
|
18
19
|
import type { RunConfig } from "./runtime.js";
|
|
19
20
|
import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
20
21
|
import type { WorktreeManager } from "./worktree.js";
|
|
22
|
+
import { WorktreeState } from "./worktree-state.js";
|
|
21
23
|
|
|
22
24
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
23
25
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
@@ -92,6 +94,8 @@ export class AgentManager {
|
|
|
92
94
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
93
95
|
/** Number of currently running background agents. */
|
|
94
96
|
private runningBackground = 0;
|
|
97
|
+
/** Steers buffered for agents whose session hasn’t been created yet. */
|
|
98
|
+
private pendingSteers = new Map<string, string[]>();
|
|
95
99
|
|
|
96
100
|
constructor(options: AgentManagerOptions) {
|
|
97
101
|
this.runner = options.runner;
|
|
@@ -116,6 +120,19 @@ export class AgentManager {
|
|
|
116
120
|
this.drainQueue();
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Buffer a steer message for an agent whose session isn’t ready yet.
|
|
125
|
+
* Returns false if the agent id is not tracked (already cleaned up or unknown).
|
|
126
|
+
* Called by steer-tool and service-adapter when record.execution is undefined.
|
|
127
|
+
*/
|
|
128
|
+
queueSteer(id: string, message: string): boolean {
|
|
129
|
+
if (!this.agents.has(id)) return false;
|
|
130
|
+
const steers = this.pendingSteers.get(id) ?? [];
|
|
131
|
+
steers.push(message);
|
|
132
|
+
this.pendingSteers.set(id, steers);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
/**
|
|
120
137
|
* Spawn an agent and return its ID immediately (for background use).
|
|
121
138
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -173,7 +190,7 @@ export class AgentManager {
|
|
|
173
190
|
'Initialize git and commit at least once, or omit `isolation`.',
|
|
174
191
|
);
|
|
175
192
|
}
|
|
176
|
-
record.
|
|
193
|
+
record.worktreeState = new WorktreeState(wt);
|
|
177
194
|
worktreeCwd = wt.path;
|
|
178
195
|
}
|
|
179
196
|
|
|
@@ -207,17 +224,18 @@ export class AgentManager {
|
|
|
207
224
|
signal: record.abortController!.signal,
|
|
208
225
|
registry: this.registry,
|
|
209
226
|
onSessionCreated: (session) => {
|
|
210
|
-
record.session = session;
|
|
211
227
|
// Capture the session file path early so it's available for display
|
|
212
228
|
// before the run completes (e.g. in background agent status messages).
|
|
213
|
-
const
|
|
214
|
-
|
|
229
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
230
|
+
// Set the execution-state collaborator — born complete at session creation.
|
|
231
|
+
record.execution = { session, outputFile };
|
|
215
232
|
// Flush any steers that arrived before the session was ready
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
const buffered = this.pendingSteers.get(id);
|
|
234
|
+
if (buffered?.length) {
|
|
235
|
+
for (const msg of buffered) {
|
|
218
236
|
session.steer(msg).catch(() => {});
|
|
219
237
|
}
|
|
220
|
-
|
|
238
|
+
this.pendingSteers.delete(id);
|
|
221
239
|
}
|
|
222
240
|
// Subscribe record observer for stats accumulation
|
|
223
241
|
unsubRecordObserver = subscribeRecordObserver(session, record, {
|
|
@@ -232,9 +250,9 @@ export class AgentManager {
|
|
|
232
250
|
|
|
233
251
|
// Clean up worktree before transition so the final result includes branch text
|
|
234
252
|
let finalResult = responseText;
|
|
235
|
-
if (record.
|
|
236
|
-
const wtResult = this.worktrees.cleanup(record.
|
|
237
|
-
record.
|
|
253
|
+
if (record.worktreeState) {
|
|
254
|
+
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
255
|
+
record.worktreeState.recordCleanup(wtResult);
|
|
238
256
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
239
257
|
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
240
258
|
}
|
|
@@ -245,8 +263,8 @@ export class AgentManager {
|
|
|
245
263
|
else if (steered) record.markSteered(finalResult);
|
|
246
264
|
else record.markCompleted(finalResult);
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
266
|
+
// Update execution collaborator with final session/outputFile from runner
|
|
267
|
+
record.execution = { session, outputFile: sessionFile ?? record.execution?.outputFile };
|
|
250
268
|
|
|
251
269
|
if (options.isBackground) {
|
|
252
270
|
this.runningBackground--;
|
|
@@ -262,10 +280,11 @@ export class AgentManager {
|
|
|
262
280
|
detach();
|
|
263
281
|
|
|
264
282
|
// Best-effort worktree cleanup on error
|
|
265
|
-
if (record.
|
|
283
|
+
if (record.worktreeState) {
|
|
266
284
|
try {
|
|
267
|
-
const wtResult = this.worktrees.cleanup(record.
|
|
268
|
-
record.
|
|
285
|
+
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
286
|
+
record.worktreeState.recordCleanup(wtResult);
|
|
287
|
+
|
|
269
288
|
} catch (err) { debugLog("cleanupWorktree on agent error", err); }
|
|
270
289
|
}
|
|
271
290
|
|
|
@@ -322,16 +341,17 @@ export class AgentManager {
|
|
|
322
341
|
signal?: AbortSignal,
|
|
323
342
|
): Promise<AgentRecord | undefined> {
|
|
324
343
|
const record = this.agents.get(id);
|
|
325
|
-
|
|
344
|
+
const session = record?.execution?.session;
|
|
345
|
+
if (!session) return undefined;
|
|
326
346
|
|
|
327
347
|
record.resetForResume(Date.now());
|
|
328
348
|
|
|
329
|
-
const unsubResume = subscribeRecordObserver(
|
|
349
|
+
const unsubResume = subscribeRecordObserver(session, record, {
|
|
330
350
|
onCompact: (r, info) => this.onCompact?.(r, info),
|
|
331
351
|
});
|
|
332
352
|
|
|
333
353
|
try {
|
|
334
|
-
const responseText = await this.runner.resume(
|
|
354
|
+
const responseText = await this.runner.resume(session, prompt, {
|
|
335
355
|
signal,
|
|
336
356
|
});
|
|
337
357
|
record.markCompleted(responseText);
|
|
@@ -373,9 +393,9 @@ export class AgentManager {
|
|
|
373
393
|
|
|
374
394
|
/** Dispose a record's session and remove it from the map. */
|
|
375
395
|
private removeRecord(id: string, record: AgentRecord): void {
|
|
376
|
-
record.session?.dispose?.();
|
|
377
|
-
record.session = undefined;
|
|
396
|
+
record.execution?.session?.dispose?.();
|
|
378
397
|
this.agents.delete(id);
|
|
398
|
+
this.pendingSteers.delete(id);
|
|
379
399
|
}
|
|
380
400
|
|
|
381
401
|
private cleanup() {
|
|
@@ -448,7 +468,7 @@ export class AgentManager {
|
|
|
448
468
|
// Clear queue
|
|
449
469
|
this.queue = [];
|
|
450
470
|
for (const record of this.agents.values()) {
|
|
451
|
-
record.session?.dispose();
|
|
471
|
+
record.execution?.session?.dispose();
|
|
452
472
|
}
|
|
453
473
|
this.agents.clear();
|
|
454
474
|
// Prune any orphaned git worktrees (crash recovery)
|
package/src/agent-record.ts
CHANGED
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
* by the class and exposed via transition methods. External code reads these
|
|
6
6
|
* fields through public properties but cannot write them directly.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
9
|
+
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
10
|
+
*
|
|
11
|
+
* Phase-specific collaborators (execution, worktreeState, notification) are attached
|
|
12
|
+
* after construction as lifecycle information becomes available.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import type {
|
|
15
|
+
import type { ExecutionState } from "./execution-state.js";
|
|
16
|
+
import type { NotificationState } from "./notification-state.js";
|
|
12
17
|
import type { AgentInvocation, SubagentType } from "./types.js";
|
|
13
18
|
import type { LifetimeUsage } from "./usage.js";
|
|
19
|
+
import { addUsage } from "./usage.js";
|
|
20
|
+
import type { WorktreeState } from "./worktree-state.js";
|
|
14
21
|
|
|
15
22
|
export type AgentRecordStatus =
|
|
16
23
|
| "queued"
|
|
@@ -30,19 +37,9 @@ export interface AgentRecordInit {
|
|
|
30
37
|
completedAt?: number;
|
|
31
38
|
result?: string;
|
|
32
39
|
error?: string;
|
|
33
|
-
toolUses?: number;
|
|
34
|
-
lifetimeUsage?: LifetimeUsage;
|
|
35
|
-
compactionCount?: number;
|
|
36
40
|
abortController?: AbortController;
|
|
37
41
|
invocation?: AgentInvocation;
|
|
38
|
-
session?: AgentSession;
|
|
39
42
|
promise?: Promise<string>;
|
|
40
|
-
resultConsumed?: boolean;
|
|
41
|
-
pendingSteers?: string[];
|
|
42
|
-
worktree?: { path: string; branch: string };
|
|
43
|
-
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
44
|
-
toolCallId?: string;
|
|
45
|
-
outputFile?: string;
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
export class AgentRecord {
|
|
@@ -68,19 +65,25 @@ export class AgentRecord {
|
|
|
68
65
|
private _completedAt?: number;
|
|
69
66
|
get completedAt(): number | undefined { return this._completedAt; }
|
|
70
67
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
// Stats — accumulated via mutation methods, readable via getters
|
|
69
|
+
private _toolUses: number;
|
|
70
|
+
get toolUses(): number { return this._toolUses; }
|
|
71
|
+
|
|
72
|
+
private _lifetimeUsage: LifetimeUsage;
|
|
73
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> { return this._lifetimeUsage; }
|
|
74
|
+
|
|
75
|
+
private _compactionCount: number;
|
|
76
|
+
get compactionCount(): number { return this._compactionCount; }
|
|
77
|
+
|
|
78
|
+
/** AbortController for cancelling this agent. Set at construction; used only by AgentManager. */
|
|
79
|
+
readonly abortController?: AbortController;
|
|
80
|
+
/** Promise for the full agent run (including post-processing). Set once by AgentManager. */
|
|
77
81
|
promise?: Promise<string>;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
outputFile?: string;
|
|
82
|
+
|
|
83
|
+
// Phase-specific collaborators — each born complete when their info becomes available
|
|
84
|
+
execution?: ExecutionState;
|
|
85
|
+
worktreeState?: WorktreeState;
|
|
86
|
+
notification?: NotificationState;
|
|
84
87
|
|
|
85
88
|
constructor(init: AgentRecordInit) {
|
|
86
89
|
this.id = init.id;
|
|
@@ -94,18 +97,26 @@ export class AgentRecord {
|
|
|
94
97
|
this._startedAt = init.startedAt ?? Date.now();
|
|
95
98
|
this._completedAt = init.completedAt;
|
|
96
99
|
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
+
this._toolUses = 0;
|
|
101
|
+
this._lifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
|
|
102
|
+
this._compactionCount = 0;
|
|
100
103
|
this.abortController = init.abortController;
|
|
101
|
-
this.session = init.session;
|
|
102
104
|
this.promise = init.promise;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
108
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
108
|
+
incrementToolUses(): void {
|
|
109
|
+
this._toolUses++;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
|
|
113
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
|
|
114
|
+
addUsage(this._lifetimeUsage, delta);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
118
|
+
incrementCompactions(): void {
|
|
119
|
+
this._compactionCount++;
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
/** Transition to running state. Sets status and startedAt. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execution-state.ts — ExecutionState: execution-phase state for a running agent.
|
|
3
|
+
*
|
|
4
|
+
* Constructed and attached to AgentRecord when onSessionCreated fires inside startAgent().
|
|
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 AgentRecord field because
|
|
7
|
+
* it is set at a different moment (after runner.run() returns).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export interface ExecutionState {
|
|
13
|
+
/** The active agent session — available from the moment the session is created. */
|
|
14
|
+
readonly session: AgentSession;
|
|
15
|
+
/** Path to the agent's session JSONL file, or undefined if not yet available. */
|
|
16
|
+
readonly outputFile: string | undefined;
|
|
17
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
// Skip notification if result was already consumed via get_subagent_result
|
|
91
|
-
if (record.resultConsumed) {
|
|
91
|
+
if (record.notification?.resultConsumed) {
|
|
92
92
|
notifications.cleanupCompleted(record.id);
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
@@ -215,6 +215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
215
|
getRecord: (id) => manager.getRecord(id),
|
|
216
216
|
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
217
217
|
steerAgent: (session, message) => steerAgent(session, message),
|
|
218
|
+
queueSteer: (id, message) => manager.queueSteer(id, message),
|
|
218
219
|
})));
|
|
219
220
|
|
|
220
221
|
// ---- /agents interactive menu ----
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notification-state.ts — NotificationState: notification-scoped tracking per background agent.
|
|
3
|
+
*
|
|
4
|
+
* Constructed once when agent-tool assigns the tool call ID (background agents only).
|
|
5
|
+
* Foreground agents never get a NotificationState — record.notification stays undefined.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class NotificationState {
|
|
9
|
+
/** The tool call ID that spawned this background agent. Used in task-notification XML. */
|
|
10
|
+
readonly toolCallId: string;
|
|
11
|
+
|
|
12
|
+
private _resultConsumed = false;
|
|
13
|
+
|
|
14
|
+
constructor(toolCallId: string) {
|
|
15
|
+
this.toolCallId = toolCallId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether the parent agent has already consumed this result (suppresses duplicate notifications). */
|
|
19
|
+
get resultConsumed(): boolean {
|
|
20
|
+
return this._resultConsumed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mark the result as consumed — suppresses the completion notification. */
|
|
24
|
+
markConsumed(): void {
|
|
25
|
+
this._resultConsumed = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/notification.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
|
|
|
31
31
|
const status = getStatusLabel(record.status, record.error);
|
|
32
32
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
33
33
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
34
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
34
|
+
const contextPercent = getSessionContextPercent(record.execution?.session);
|
|
35
35
|
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
36
36
|
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
37
37
|
|
|
@@ -41,11 +41,13 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
|
|
|
41
41
|
: record.result
|
|
42
42
|
: "No output.";
|
|
43
43
|
|
|
44
|
+
const toolCallId = record.notification?.toolCallId;
|
|
45
|
+
const outputFile = record.execution?.outputFile;
|
|
44
46
|
return [
|
|
45
47
|
"<task-notification>",
|
|
46
48
|
`<task-id>${record.id}</task-id>`,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
|
|
50
|
+
outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
|
|
49
51
|
`<status>${escapeXml(status)}</status>`,
|
|
50
52
|
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
51
53
|
`<result>${escapeXml(resultPreview)}</result>`,
|
|
@@ -73,7 +75,7 @@ export function buildNotificationDetails(
|
|
|
73
75
|
maxTurns: activity?.maxTurns,
|
|
74
76
|
totalTokens,
|
|
75
77
|
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
76
|
-
outputFile: record.outputFile,
|
|
78
|
+
outputFile: record.execution?.outputFile,
|
|
77
79
|
error: record.error,
|
|
78
80
|
resultPreview: record.result
|
|
79
81
|
? record.result.length > resultMaxLen
|
|
@@ -154,10 +156,11 @@ export function createNotificationSystem(deps: NotificationDeps): NotificationSy
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function emitIndividualNudge(record: AgentRecord) {
|
|
157
|
-
if (record.resultConsumed) return;
|
|
159
|
+
if (record.notification?.resultConsumed) return;
|
|
158
160
|
|
|
159
161
|
const notification = formatTaskNotification(record, 500);
|
|
160
|
-
const
|
|
162
|
+
const outputFile = record.execution?.outputFile;
|
|
163
|
+
const footer = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
|
|
161
164
|
|
|
162
165
|
deps.sendMessage(
|
|
163
166
|
{
|
package/src/record-observer.ts
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import type { CompactionInfo } from "./agent-manager.js";
|
|
9
9
|
import type { AgentRecord } from "./agent-record.js";
|
|
10
|
-
import { addUsage } from "./usage.js";
|
|
11
10
|
|
|
12
11
|
/** Narrow session interface — only the subscribe method needed by the observer. */
|
|
13
12
|
interface SubscribableSession {
|
|
@@ -22,9 +21,9 @@ export interface RecordObserverOptions {
|
|
|
22
21
|
* Subscribe to session events and accumulate stats on the agent record.
|
|
23
22
|
*
|
|
24
23
|
* Handles:
|
|
25
|
-
* - `tool_execution_end` → `record.
|
|
26
|
-
* - `message_end` (assistant, with usage) → `addUsage(
|
|
27
|
-
* - `compaction_end` (not aborted) → `record.
|
|
24
|
+
* - `tool_execution_end` → `record.incrementToolUses()`
|
|
25
|
+
* - `message_end` (assistant, with usage) → `record.addUsage(…)`
|
|
26
|
+
* - `compaction_end` (not aborted) → `record.incrementCompactions()`, call `onCompact`
|
|
28
27
|
*
|
|
29
28
|
* @returns An unsubscribe function.
|
|
30
29
|
*/
|
|
@@ -35,13 +34,13 @@ export function subscribeRecordObserver(
|
|
|
35
34
|
): () => void {
|
|
36
35
|
return session.subscribe((event: any) => {
|
|
37
36
|
if (event.type === "tool_execution_end") {
|
|
38
|
-
record.
|
|
37
|
+
record.incrementToolUses();
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
42
41
|
const u = event.message.usage;
|
|
43
42
|
if (u) {
|
|
44
|
-
addUsage(
|
|
43
|
+
record.addUsage({
|
|
45
44
|
input: u.input ?? 0,
|
|
46
45
|
output: u.output ?? 0,
|
|
47
46
|
cacheWrite: u.cacheWrite ?? 0,
|
|
@@ -50,7 +49,7 @@ export function subscribeRecordObserver(
|
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
53
|
-
record.
|
|
52
|
+
record.incrementCompactions();
|
|
54
53
|
options?.onCompact?.(record, {
|
|
55
54
|
reason: event.reason,
|
|
56
55
|
tokensBefore: event.result.tokensBefore,
|
package/src/service-adapter.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface AgentManagerLike {
|
|
|
17
17
|
abort(id: string): boolean;
|
|
18
18
|
waitForAll(): Promise<void>;
|
|
19
19
|
hasRunning(): boolean;
|
|
20
|
+
queueSteer(id: string, message: string): boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/** Dependencies injected into the adapter factory. */
|
|
@@ -85,13 +86,12 @@ export function createSubagentsService(deps: AdapterDeps): SubagentsService {
|
|
|
85
86
|
if (!record || record.status !== "running") {
|
|
86
87
|
return false;
|
|
87
88
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return true;
|
|
89
|
+
const session = record.execution?.session;
|
|
90
|
+
if (!session) {
|
|
91
|
+
// Session not ready yet — queue via manager for delivery once initialized
|
|
92
|
+
return manager.queueSteer(id, message);
|
|
93
93
|
}
|
|
94
|
-
await
|
|
94
|
+
await session.steer(message);
|
|
95
95
|
return true;
|
|
96
96
|
},
|
|
97
97
|
|
|
@@ -124,7 +124,8 @@ export function toSubagentRecord(record: AgentRecord): SubagentRecord {
|
|
|
124
124
|
if (record.result !== undefined) out.result = record.result;
|
|
125
125
|
if (record.error !== undefined) out.error = record.error;
|
|
126
126
|
if (record.completedAt !== undefined) out.completedAt = record.completedAt;
|
|
127
|
-
|
|
127
|
+
const worktreeResult = record.worktreeState?.cleanupResult;
|
|
128
|
+
if (worktreeResult !== undefined) out.worktreeResult = worktreeResult;
|
|
128
129
|
|
|
129
130
|
return out;
|
|
130
131
|
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { AgentTypeRegistry } from "../agent-types.js";
|
|
|
7
7
|
import { resolveAgentInvocationConfig } from "../invocation-config.js";
|
|
8
8
|
import { resolveInvocationModel } from "../model-resolver.js";
|
|
9
9
|
|
|
10
|
+
import { NotificationState } from "../notification-state.js";
|
|
10
11
|
import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
|
|
11
12
|
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
12
13
|
import {
|
|
@@ -381,7 +382,7 @@ Guidelines:
|
|
|
381
382
|
`Agent not found: "${params.resume}". It may have been cleaned up.`,
|
|
382
383
|
);
|
|
383
384
|
}
|
|
384
|
-
if (!existing.session) {
|
|
385
|
+
if (!existing.execution?.session) {
|
|
385
386
|
return textResult(
|
|
386
387
|
`Agent "${params.resume}" has no active session to resume.`,
|
|
387
388
|
);
|
|
@@ -430,7 +431,8 @@ Guidelines:
|
|
|
430
431
|
|
|
431
432
|
const record = deps.manager.getRecord(id);
|
|
432
433
|
if (record) {
|
|
433
|
-
|
|
434
|
+
// Born complete: notification-state object owns toolCallId + resultConsumed.
|
|
435
|
+
record.notification = new NotificationState(toolCallId);
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
deps.agentActivity.set(id, bgState);
|
|
@@ -451,7 +453,7 @@ Guidelines:
|
|
|
451
453
|
`Agent ID: ${id}\n` +
|
|
452
454
|
`Type: ${displayName}\n` +
|
|
453
455
|
`Description: ${params.description}\n` +
|
|
454
|
-
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
456
|
+
(record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
|
|
455
457
|
(isQueued
|
|
456
458
|
? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
|
|
457
459
|
: "") +
|
|
@@ -525,7 +527,7 @@ Guidelines:
|
|
|
525
527
|
fgState.setSession(session);
|
|
526
528
|
unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
|
|
527
529
|
for (const a of deps.manager.listAgents()) {
|
|
528
|
-
if (a.session === session) {
|
|
530
|
+
if (a.execution?.session === session) {
|
|
529
531
|
fgId = a.id;
|
|
530
532
|
deps.agentActivity.set(a.id, fgState);
|
|
531
533
|
deps.widget.ensureTimer();
|
|
@@ -54,7 +54,9 @@ export function createGetResultTool(deps: GetResultDeps) {
|
|
|
54
54
|
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
55
55
|
// Setting the flag here prevents a redundant follow-up notification.
|
|
56
56
|
if (params.wait && record.status === "running" && record.promise) {
|
|
57
|
-
|
|
57
|
+
// Pre-mark consumed BEFORE awaiting — onComplete fires inside .then() and
|
|
58
|
+
// always runs before this await resumes. Prevents a redundant notification.
|
|
59
|
+
record.notification?.markConsumed();
|
|
58
60
|
deps.cancelNudge(params.agent_id);
|
|
59
61
|
await record.promise;
|
|
60
62
|
}
|
|
@@ -62,7 +64,7 @@ export function createGetResultTool(deps: GetResultDeps) {
|
|
|
62
64
|
const displayName = getDisplayName(record.type, deps.registry);
|
|
63
65
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
64
66
|
const tokens = formatLifetimeTokens(record);
|
|
65
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
67
|
+
const contextPercent = getSessionContextPercent(record.execution?.session);
|
|
66
68
|
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
67
69
|
if (tokens) statsParts.push(tokens);
|
|
68
70
|
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
@@ -84,13 +86,13 @@ export function createGetResultTool(deps: GetResultDeps) {
|
|
|
84
86
|
|
|
85
87
|
// Mark result as consumed — suppresses the completion notification
|
|
86
88
|
if (record.status !== "running" && record.status !== "queued") {
|
|
87
|
-
record.
|
|
89
|
+
record.notification?.markConsumed();
|
|
88
90
|
deps.cancelNudge(params.agent_id);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// Verbose: include full conversation
|
|
92
|
-
if (params.verbose && record.session) {
|
|
93
|
-
const conversation = deps.getConversation(record.session);
|
|
94
|
+
if (params.verbose && record.execution?.session) {
|
|
95
|
+
const conversation = deps.getConversation(record.execution.session);
|
|
94
96
|
if (conversation) {
|
|
95
97
|
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
96
98
|
}
|
package/src/tools/steer-tool.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface SteerToolDeps {
|
|
|
9
9
|
getRecord: (id: string) => AgentRecord | undefined;
|
|
10
10
|
emitEvent: (name: string, data: unknown) => void;
|
|
11
11
|
steerAgent: (session: AgentSession, message: string) => Promise<void>;
|
|
12
|
+
/** Buffer a steer for an agent whose session isn't ready yet. */
|
|
13
|
+
queueSteer: (id: string, message: string) => boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/** Create the steer_subagent tool definition (without Pi SDK wrapper). */
|
|
@@ -46,10 +48,10 @@ export function createSteerTool(deps: SteerToolDeps) {
|
|
|
46
48
|
`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
|
|
47
49
|
);
|
|
48
50
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
record.
|
|
51
|
+
const session = record.execution?.session;
|
|
52
|
+
if (!session) {
|
|
53
|
+
// Session not ready yet — queue via manager for delivery once initialized
|
|
54
|
+
deps.queueSteer(record.id, params.message);
|
|
53
55
|
deps.emitEvent("subagents:steered", { id: record.id, message: params.message });
|
|
54
56
|
return textResult(
|
|
55
57
|
`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`,
|
|
@@ -57,10 +59,10 @@ export function createSteerTool(deps: SteerToolDeps) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
try {
|
|
60
|
-
await deps.steerAgent(
|
|
62
|
+
await deps.steerAgent(session, params.message);
|
|
61
63
|
deps.emitEvent("subagents:steered", { id: record.id, message: params.message });
|
|
62
64
|
const tokens = formatLifetimeTokens(record);
|
|
63
|
-
const contextPercent = getSessionContextPercent(
|
|
65
|
+
const contextPercent = getSessionContextPercent(session);
|
|
64
66
|
const stateParts: string[] = [];
|
|
65
67
|
if (tokens) stateParts.push(tokens);
|
|
66
68
|
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
package/src/ui/agent-menu.ts
CHANGED
|
@@ -197,7 +197,8 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
async function viewAgentConversation(ctx: ExtensionContext, record: AgentRecord) {
|
|
200
|
-
|
|
200
|
+
const session = record.execution?.session;
|
|
201
|
+
if (!session) {
|
|
201
202
|
ctx.ui.notify(
|
|
202
203
|
`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`,
|
|
203
204
|
"info",
|
|
@@ -208,7 +209,6 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
|
|
|
208
209
|
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import(
|
|
209
210
|
"./conversation-viewer.js"
|
|
210
211
|
);
|
|
211
|
-
const session = record.session;
|
|
212
212
|
const activity = deps.agentActivity.get(record.id);
|
|
213
213
|
|
|
214
214
|
await ctx.ui.custom<undefined>(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-state.ts — WorktreeState: lifecycle-phase object for worktree-isolated agents.
|
|
3
|
+
*
|
|
4
|
+
* Constructed once when the worktree is set up (before the run begins).
|
|
5
|
+
* Only exists for agents with isolation: "worktree".
|
|
6
|
+
* cleanupResult is recorded once at completion or error — it is not set at construction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WorktreeCleanupResult, WorktreeInfo } from "./worktree.js";
|
|
10
|
+
|
|
11
|
+
export type { WorktreeCleanupResult, WorktreeInfo };
|
|
12
|
+
|
|
13
|
+
export class WorktreeState {
|
|
14
|
+
/** Absolute path to the worktree directory. */
|
|
15
|
+
readonly path: string;
|
|
16
|
+
/** Branch name created for this worktree. */
|
|
17
|
+
readonly branch: string;
|
|
18
|
+
|
|
19
|
+
private _cleanupResult?: WorktreeCleanupResult;
|
|
20
|
+
|
|
21
|
+
constructor(info: WorktreeInfo) {
|
|
22
|
+
this.path = info.path;
|
|
23
|
+
this.branch = info.branch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of the worktree cleanup — undefined until recordCleanup is called. */
|
|
27
|
+
get cleanupResult(): WorktreeCleanupResult | undefined {
|
|
28
|
+
return this._cleanupResult;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Record the cleanup result. Called once on agent completion or error. */
|
|
32
|
+
recordCleanup(result: WorktreeCleanupResult): void {
|
|
33
|
+
this._cleanupResult = result;
|
|
34
|
+
}
|
|
35
|
+
}
|