@gotgenes/pi-subagents 6.7.0 → 6.8.1
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 +24 -0
- package/docs/architecture/architecture.md +30 -29
- package/docs/plans/0111-split-agent-record-lifecycle.md +582 -0
- package/docs/plans/0123-remove-vi-fn-cast-smell.md +179 -0
- package/docs/retro/0110-agent-activity-tracker.md +44 -0
- package/docs/retro/0111-split-agent-record-lifecycle.md +61 -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/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
|
+
}
|