@yandy0725/pi-subagents 0.1.0 → 0.2.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/package.json +4 -4
- package/src/handlers/lifecycle.ts +16 -2
- package/src/index.ts +3 -1
- package/src/lifecycle/subagent-manager.ts +18 -0
- package/src/lifecycle/subagent.ts +4 -0
- package/src/observation/notification.ts +3 -0
- package/src/observation/renderer.ts +1 -0
- package/src/observation/subagent-events-observer.ts +3 -0
- package/src/service/service-adapter.ts +11 -0
- package/src/service/service.ts +2 -0
- package/src/session/recover-subagents.ts +109 -0
- package/src/tools/spawn-config.ts +10 -7
- package/src/types.ts +1 -1
- package/src/ui/agent-widget.ts +1 -0
- package/src/ui/display.ts +1 -1
- package/src/ui/session-navigation.ts +7 -2
- package/src/ui/session-navigator.ts +128 -20
- package/src/ui/widget-renderer.ts +4 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1
|
|
6
|
+
"version": "0.2.1",
|
|
7
7
|
"description": "Focused, in-process sub-agent core for pi — autonomous agents plus a typed API and lifecycle events",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-ai": ">=0.
|
|
37
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
38
|
-
"@earendil-works/pi-tui": ">=0.
|
|
36
|
+
"@earendil-works/pi-ai": ">=0.80.2",
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=0.80.2",
|
|
38
|
+
"@earendil-works/pi-tui": ">=0.80.2"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@sinclair/typebox": "^0.34.49"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EvictedSubagent } from "../lifecycle/subagent-manager";
|
|
1
2
|
import type { SessionContext } from "../types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,6 +13,8 @@ export interface LifecycleManager {
|
|
|
12
13
|
clearCompleted(): void;
|
|
13
14
|
abortAll(): void;
|
|
14
15
|
dispose(): void;
|
|
16
|
+
/** Repopulate evicted descriptors recovered from disk on session start. */
|
|
17
|
+
restoreEvicted(descriptors: readonly EvictedSubagent[]): void;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/** Narrow runtime interface — only the methods lifecycle handlers call. */
|
|
@@ -20,14 +23,20 @@ export interface LifecycleRuntime {
|
|
|
20
23
|
clearSessionContext(): void;
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/** Recovers evicted subagent descriptors from the parent session file. */
|
|
27
|
+
export type RecoverEvicted = (parentSessionFile: string | undefined) => EvictedSubagent[];
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* Handles session lifecycle events.
|
|
25
31
|
*
|
|
26
32
|
* Constructor deps:
|
|
27
33
|
* - `runtime` — owns session context state
|
|
28
|
-
* - `manager` — manages agent lifecycle (clear, abort, dispose)
|
|
34
|
+
* - `manager` — manages agent lifecycle (clear, abort, dispose, restore)
|
|
29
35
|
* - `disposeNotifications` — tears down the notification system on shutdown
|
|
30
36
|
* - `unpublishService` — unpublishes the SubagentsService symbol on shutdown
|
|
37
|
+
* - `recoverEvicted` — rebuilds navigable descriptors from the parent session
|
|
38
|
+
* file so `/subagents:sessions` works after resume/fork (the extension is
|
|
39
|
+
* re-instantiated per session, so the in-memory evicted map starts empty)
|
|
31
40
|
*/
|
|
32
41
|
export class SessionLifecycleHandler {
|
|
33
42
|
constructor(
|
|
@@ -35,11 +44,16 @@ export class SessionLifecycleHandler {
|
|
|
35
44
|
private readonly manager: LifecycleManager,
|
|
36
45
|
private readonly disposeNotifications: () => void,
|
|
37
46
|
private readonly unpublishService: () => void,
|
|
47
|
+
private readonly recoverEvicted: RecoverEvicted,
|
|
38
48
|
) {}
|
|
39
49
|
|
|
40
50
|
handleSessionStart(_event: unknown, ctx: unknown): void {
|
|
41
|
-
|
|
51
|
+
const sessionCtx = ctx as SessionContext;
|
|
52
|
+
this.runtime.setSessionContext(sessionCtx);
|
|
42
53
|
this.manager.clearCompleted();
|
|
54
|
+
// Re-discover persisted subagent sessions from the parent session file so
|
|
55
|
+
// they remain viewable after resume/fork (the in-memory map starts empty).
|
|
56
|
+
this.manager.restoreEvicted(this.recoverEvicted(sessionCtx.sessionManager?.getSessionFile()));
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
handleSessionBeforeSwitch(): void {
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Commands:
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
14
|
import {
|
|
15
15
|
createAgentSession,
|
|
16
16
|
DefaultResourceLoader,
|
|
@@ -37,6 +37,7 @@ import { detectEnv } from "./session/env";
|
|
|
37
37
|
|
|
38
38
|
import { resolveModel } from "./session/model-resolver";
|
|
39
39
|
import { buildAgentPrompt } from "./session/prompts";
|
|
40
|
+
import { recoverEvictedSubagents } from "./session/recover-subagents";
|
|
40
41
|
import { deriveSubagentSessionDir } from "./session/session-dir";
|
|
41
42
|
import { SettingsManager } from "./settings";
|
|
42
43
|
import { AgentTool } from "./tools/agent-tool";
|
|
@@ -124,6 +125,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
125
|
manager,
|
|
125
126
|
() => notifications.dispose(),
|
|
126
127
|
unpublishSubagentsService,
|
|
128
|
+
(parentSessionFile) => recoverEvictedSubagents(parentSessionFile, (path) => readFileSync(path, "utf8"), existsSync),
|
|
127
129
|
);
|
|
128
130
|
|
|
129
131
|
pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
|
|
@@ -36,6 +36,8 @@ export interface EvictedSubagent {
|
|
|
36
36
|
readonly startedAt: number;
|
|
37
37
|
readonly completedAt: number | undefined;
|
|
38
38
|
readonly toolUses: number;
|
|
39
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
40
|
+
readonly modelName?: string;
|
|
39
41
|
readonly outputFile: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -237,6 +239,21 @@ export class SubagentManager {
|
|
|
237
239
|
return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Repopulate evicted descriptors recovered from disk (the parent session's
|
|
244
|
+
* `subagents:record` entries) on session start/resume/fork — the in-memory
|
|
245
|
+
* map starts empty because the extension is re-instantiated per session.
|
|
246
|
+
*
|
|
247
|
+
* Descriptors whose id still has a live record are dropped: the live record
|
|
248
|
+
* (and its real-time transcript) wins over the stale on-disk descriptor.
|
|
249
|
+
*/
|
|
250
|
+
restoreEvicted(descriptors: readonly EvictedSubagent[]): void {
|
|
251
|
+
for (const descriptor of descriptors) {
|
|
252
|
+
if (this.agents.has(descriptor.id)) continue; // live record wins
|
|
253
|
+
this.evicted.set(descriptor.id, descriptor);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
240
257
|
abort(id: string): boolean {
|
|
241
258
|
const record = this.agents.get(id);
|
|
242
259
|
if (!record) return false;
|
|
@@ -348,6 +365,7 @@ function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagen
|
|
|
348
365
|
startedAt: record.startedAt,
|
|
349
366
|
completedAt: record.completedAt,
|
|
350
367
|
toolUses: record.toolUses,
|
|
368
|
+
modelName: record.invocation?.modelName,
|
|
351
369
|
outputFile,
|
|
352
370
|
};
|
|
353
371
|
}
|
|
@@ -129,6 +129,10 @@ export class Subagent {
|
|
|
129
129
|
get maxTurns(): number | undefined {
|
|
130
130
|
return this.execution.maxTurns;
|
|
131
131
|
}
|
|
132
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
133
|
+
get modelName(): string | undefined {
|
|
134
|
+
return this.invocation?.modelName;
|
|
135
|
+
}
|
|
132
136
|
|
|
133
137
|
readonly abortController: AbortController;
|
|
134
138
|
private _promise?: Promise<void>;
|
|
@@ -15,6 +15,8 @@ export interface NotificationDetails {
|
|
|
15
15
|
outputFile?: string;
|
|
16
16
|
error?: string;
|
|
17
17
|
resultPreview: string;
|
|
18
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
19
|
+
modelName?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
// ---- Pure helpers (exported for unit testing) ----
|
|
@@ -87,6 +89,7 @@ export function buildNotificationDetails(record: Subagent, resultMaxLen: number)
|
|
|
87
89
|
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
88
90
|
outputFile: record.outputFile,
|
|
89
91
|
error: record.error,
|
|
92
|
+
modelName: record.modelName,
|
|
90
93
|
resultPreview: record.result
|
|
91
94
|
? record.result.length > resultMaxLen
|
|
92
95
|
? record.result.slice(0, resultMaxLen) + "…"
|
|
@@ -36,6 +36,7 @@ export function createNotificationRenderer() {
|
|
|
36
36
|
|
|
37
37
|
// Line 2: stats
|
|
38
38
|
const parts: string[] = [];
|
|
39
|
+
if (d.modelName) parts.push(d.modelName);
|
|
39
40
|
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
40
41
|
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
41
42
|
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
@@ -62,6 +62,9 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
|
|
|
62
62
|
error: record.error,
|
|
63
63
|
startedAt: record.startedAt,
|
|
64
64
|
completedAt: record.completedAt,
|
|
65
|
+
toolUses: record.toolUses,
|
|
66
|
+
modelName: record.modelName,
|
|
67
|
+
outputFile: record.outputFile,
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
// Skip notification if result was already consumed via get_subagent_result.
|
|
@@ -9,6 +9,7 @@ import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
|
9
9
|
import type { WorkspaceProvider } from "../lifecycle/workspace";
|
|
10
10
|
import type { SpawnOptions, SubagentRecord, SubagentsService } from "../service/service";
|
|
11
11
|
import type { ModelRegistry } from "../session/model-resolver";
|
|
12
|
+
import { resolveModelName } from "../tools/spawn-config";
|
|
12
13
|
import type { SessionContext, Subagent } from "../types";
|
|
13
14
|
|
|
14
15
|
/** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
|
|
@@ -45,6 +46,7 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
let model: unknown;
|
|
49
|
+
let modelName: string | undefined;
|
|
48
50
|
if (options?.model) {
|
|
49
51
|
const registry = this.runtime.currentCtx.modelRegistry;
|
|
50
52
|
if (!registry) {
|
|
@@ -56,6 +58,11 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
56
58
|
}
|
|
57
59
|
model = resolved;
|
|
58
60
|
}
|
|
61
|
+
// Always compute display model name, even when same as parent
|
|
62
|
+
modelName = resolveModelName(
|
|
63
|
+
(model as { id?: string; name?: string } | undefined) ??
|
|
64
|
+
(this.runtime.currentCtx.model as { id?: string; name?: string } | undefined),
|
|
65
|
+
);
|
|
59
66
|
|
|
60
67
|
const description = options?.description ?? prompt.slice(0, 80);
|
|
61
68
|
const isBackground = !(options?.foreground ?? false);
|
|
@@ -69,6 +76,9 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
69
76
|
inheritContext: options?.inheritContext,
|
|
70
77
|
bypassQueue: options?.bypassQueue,
|
|
71
78
|
isBackground,
|
|
79
|
+
// Service API builds a display-minimal invocation (modelName only);
|
|
80
|
+
// the tool path additionally carries thinking/maxTurns/inherit tags.
|
|
81
|
+
invocation: modelName != null ? { modelName } : undefined,
|
|
72
82
|
});
|
|
73
83
|
}
|
|
74
84
|
|
|
@@ -126,6 +136,7 @@ export function toSubagentRecord(record: Subagent): SubagentRecord {
|
|
|
126
136
|
if (record.result !== undefined) out.result = record.result;
|
|
127
137
|
if (record.error !== undefined) out.error = record.error;
|
|
128
138
|
if (record.completedAt !== undefined) out.completedAt = record.completedAt;
|
|
139
|
+
if (record.modelName !== undefined) out.modelName = record.modelName;
|
|
129
140
|
|
|
130
141
|
return out;
|
|
131
142
|
}
|
package/src/service/service.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SubagentRecord {
|
|
|
48
48
|
completedAt?: number;
|
|
49
49
|
lifetimeUsage: LifetimeUsage;
|
|
50
50
|
compactionCount: number;
|
|
51
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
52
|
+
modelName?: string;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Options for spawning an agent via the service. */
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recover-subagents.ts — Rebuild navigable subagent descriptors from disk.
|
|
3
|
+
*
|
|
4
|
+
* When a session is resumed or forked, the in-memory `SubagentManager` starts
|
|
5
|
+
* empty (the extension is re-instantiated per session). The child sessions'
|
|
6
|
+
* transcripts still live on disk, and a `subagents:record` custom entry was
|
|
7
|
+
* appended to the *parent* session file on each completion — carrying the
|
|
8
|
+
* metadata (and, since this fix, the transcript `outputFile`) needed to label
|
|
9
|
+
* and source them. This module parses those entries back into `EvictedSubagent`
|
|
10
|
+
* descriptors so `/subagents:sessions` can show them again after resume/fork.
|
|
11
|
+
*
|
|
12
|
+
* Pure: takes the parent session file path, a `readFile` seam, and an optional
|
|
13
|
+
* `exists` seam (defaults to a readFile-based probe), returns descriptors. The
|
|
14
|
+
* default keeps the function free of direct `fs` calls; callers may pass
|
|
15
|
+
* `fs.existsSync` to avoid reading the whole file just to check existence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { type CustomEntry, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { EvictedSubagent } from "../lifecycle/subagent-manager";
|
|
21
|
+
import type { SubagentStatus } from "../lifecycle/subagent-state";
|
|
22
|
+
import type { SubagentType } from "../types";
|
|
23
|
+
import { deriveSubagentSessionDir } from "./session-dir";
|
|
24
|
+
|
|
25
|
+
/** The customType under which completed-subagent records are persisted. */
|
|
26
|
+
export const SUBAGENT_RECORD_CUSTOM_TYPE = "subagents:record";
|
|
27
|
+
|
|
28
|
+
/** Fields persisted on each `subagents:record` entry (see onSubagentCompleted). */
|
|
29
|
+
interface PersistedSubagentRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
type: SubagentType;
|
|
32
|
+
description: string;
|
|
33
|
+
status: SubagentStatus;
|
|
34
|
+
result?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
completedAt: number | undefined;
|
|
38
|
+
toolUses?: number;
|
|
39
|
+
modelName?: string;
|
|
40
|
+
outputFile?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the parent session file and rebuild `EvictedSubagent` descriptors from
|
|
45
|
+
* its `subagents:record` entries, most-recent-first. Records without an
|
|
46
|
+
* `outputFile` are dropped — their transcript is not navigable from disk.
|
|
47
|
+
*
|
|
48
|
+
* Returns `[]` when the parent session is not persisted (no file) or the file
|
|
49
|
+
* cannot be read, so callers can invoke unconditionally on session start.
|
|
50
|
+
*/
|
|
51
|
+
export function recoverEvictedSubagents(
|
|
52
|
+
parentSessionFile: string | undefined,
|
|
53
|
+
readFile: (path: string) => string,
|
|
54
|
+
exists: (path: string) => boolean = (path) => {
|
|
55
|
+
try {
|
|
56
|
+
readFile(path);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
): EvictedSubagent[] {
|
|
63
|
+
if (!parentSessionFile) return [];
|
|
64
|
+
let entries: ReturnType<typeof parseSessionEntries>;
|
|
65
|
+
try {
|
|
66
|
+
entries = parseSessionEntries(readFile(parentSessionFile));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const records = entries.filter(
|
|
72
|
+
(entry): entry is CustomEntry =>
|
|
73
|
+
entry.type === "custom" && (entry as CustomEntry).customType === SUBAGENT_RECORD_CUSTOM_TYPE,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const recovered: EvictedSubagent[] = [];
|
|
77
|
+
// Derive the tasks directory once for fallback outputFile construction.
|
|
78
|
+
// The empty cwd is harmless: when parentSessionFile is set, session-dir
|
|
79
|
+
// derives the path from it and ignores cwd entirely.
|
|
80
|
+
const tasksDir = deriveSubagentSessionDir(parentSessionFile, "");
|
|
81
|
+
for (const entry of records) {
|
|
82
|
+
const data = (entry.data ?? {}) as PersistedSubagentRecord;
|
|
83
|
+
// Try to determine outputFile: prefer the persisted one, else construct
|
|
84
|
+
// from the agent id and tasks directory (backwards compat for pre-fix records).
|
|
85
|
+
let outputFile: string | undefined = data.outputFile;
|
|
86
|
+
if (!outputFile) {
|
|
87
|
+
const constructed = join(tasksDir, `${data.id}.jsonl`);
|
|
88
|
+
// Confirm the session file is readable so the file-snapshot source
|
|
89
|
+
// works later. The `exists` seam defaults to a readFile probe (pure);
|
|
90
|
+
// callers may pass `fs.existsSync` to avoid reading the whole file.
|
|
91
|
+
if (!exists(constructed)) continue; // session file doesn't exist — not navigable
|
|
92
|
+
outputFile = constructed;
|
|
93
|
+
}
|
|
94
|
+
recovered.push({
|
|
95
|
+
id: data.id,
|
|
96
|
+
type: data.type,
|
|
97
|
+
description: data.description,
|
|
98
|
+
status: data.status,
|
|
99
|
+
startedAt: data.startedAt,
|
|
100
|
+
completedAt: data.completedAt,
|
|
101
|
+
toolUses: data.toolUses ?? 0,
|
|
102
|
+
modelName: data.modelName,
|
|
103
|
+
outputFile,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Most-recent-first, matching listEvicted()'s ordering.
|
|
107
|
+
recovered.sort((a, b) => b.startedAt - a.startedAt);
|
|
108
|
+
return recovered;
|
|
109
|
+
}
|
|
@@ -15,6 +15,14 @@ import { resolveInvocationModel } from "../session/model-resolver";
|
|
|
15
15
|
import type { AgentInvocation, SubagentType, ThinkingLevel } from "../types";
|
|
16
16
|
import { type AgentDetails, buildInvocationTags, getDisplayName, getPromptModeLabel } from "../ui/display";
|
|
17
17
|
|
|
18
|
+
/** Derive a short display model name from a model object (or undefined). */
|
|
19
|
+
export function resolveModelName(model: { id?: string; name?: string } | undefined): string | undefined {
|
|
20
|
+
if (!model) return undefined;
|
|
21
|
+
const raw = model.name ?? model.id;
|
|
22
|
+
if (!raw) return undefined;
|
|
23
|
+
return raw.replace(/^Claude\s+/i, "").toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
/** Model info extracted from the parent session context. */
|
|
19
27
|
export interface ModelInfo {
|
|
20
28
|
parentModel: { id: string; name?: string } | undefined;
|
|
@@ -103,13 +111,8 @@ export function resolveSpawnConfig(
|
|
|
103
111
|
const inheritContext = resolvedConfig.inheritContext;
|
|
104
112
|
const runInBackground = resolvedConfig.runInBackground;
|
|
105
113
|
|
|
106
|
-
// Compute display model name
|
|
107
|
-
const
|
|
108
|
-
const effectiveModelId = model?.id;
|
|
109
|
-
const modelName =
|
|
110
|
-
effectiveModelId && effectiveModelId !== parentModelId
|
|
111
|
-
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
112
|
-
: undefined;
|
|
114
|
+
// Compute display model name — always shown, even when same as parent.
|
|
115
|
+
const modelName = resolveModelName(model ?? modelInfo.parentModel);
|
|
113
116
|
|
|
114
117
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns);
|
|
115
118
|
|
package/src/types.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface AgentInvocation {
|
|
66
|
-
/** Short display name
|
|
66
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
67
67
|
modelName?: string;
|
|
68
68
|
thinking?: ThinkingLevel;
|
|
69
69
|
maxTurns?: number;
|
package/src/ui/agent-widget.ts
CHANGED
package/src/ui/display.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface AgentDetails {
|
|
|
28
28
|
activity?: string;
|
|
29
29
|
/** Current spinner frame index (for animated running indicator). */
|
|
30
30
|
spinnerFrame?: number;
|
|
31
|
-
/** Short model name
|
|
31
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
32
32
|
modelName?: string;
|
|
33
33
|
/** Notable config tags (e.g. ["thinking: high", "inherit context"]). */
|
|
34
34
|
tags?: string[];
|
|
@@ -38,6 +38,8 @@ export interface NavigableSubagent {
|
|
|
38
38
|
readonly activeTools: ReadonlyMap<string, string>;
|
|
39
39
|
readonly responseText: string;
|
|
40
40
|
readonly agentMessages: readonly SessionMessage[];
|
|
41
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
42
|
+
readonly modelName?: string;
|
|
41
43
|
isSessionReady(): boolean;
|
|
42
44
|
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
43
45
|
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
@@ -51,7 +53,7 @@ export interface NavigableSubagent {
|
|
|
51
53
|
*/
|
|
52
54
|
export type NavigationEntry =
|
|
53
55
|
| { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
|
|
54
|
-
| { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
|
|
56
|
+
| { readonly kind: "evicted"; readonly label: string; readonly outputFile: string; readonly modelName?: string };
|
|
55
57
|
|
|
56
58
|
/** The fields `buildLabel` reads — shared by a live record and an evicted descriptor. */
|
|
57
59
|
interface LabelFields {
|
|
@@ -61,6 +63,7 @@ interface LabelFields {
|
|
|
61
63
|
readonly startedAt: number;
|
|
62
64
|
readonly completedAt: number | undefined;
|
|
63
65
|
readonly toolUses: number;
|
|
66
|
+
readonly modelName?: string;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/** Running-agent streaming state, surfaced by a live source. */
|
|
@@ -100,6 +103,7 @@ export function listNavigableAgents(
|
|
|
100
103
|
(descriptor): NavigationEntry => ({
|
|
101
104
|
kind: "evicted",
|
|
102
105
|
outputFile: descriptor.outputFile,
|
|
106
|
+
modelName: descriptor.modelName,
|
|
103
107
|
label: buildLabel(descriptor, registry, true),
|
|
104
108
|
}),
|
|
105
109
|
);
|
|
@@ -142,6 +146,7 @@ export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
|
142
146
|
function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
|
|
143
147
|
const name = getDisplayName(fields.type, registry);
|
|
144
148
|
const duration = formatDuration(fields.startedAt, fields.completedAt);
|
|
149
|
+
const modelTag = fields.modelName ? ` · model:${fields.modelName}` : "";
|
|
145
150
|
const marker = evicted ? " · evicted (snapshot)" : "";
|
|
146
|
-
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
|
|
151
|
+
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${modelTag}${marker}`;
|
|
147
152
|
}
|
|
@@ -92,6 +92,8 @@ export interface TranscriptOverlayOptions {
|
|
|
92
92
|
done: (result: undefined) => void;
|
|
93
93
|
cwd: string;
|
|
94
94
|
markdownTheme: MarkdownTheme;
|
|
95
|
+
/** Short model name to show in the header, or undefined to omit. */
|
|
96
|
+
modelName?: string;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
@@ -117,15 +119,23 @@ export class SessionNavigatorHandler {
|
|
|
117
119
|
if (!entry) return;
|
|
118
120
|
|
|
119
121
|
let source: TranscriptSource;
|
|
122
|
+
let modelName: string | undefined;
|
|
120
123
|
try {
|
|
121
|
-
|
|
124
|
+
if (entry.kind === "live") {
|
|
125
|
+
source = liveSource(entry.record);
|
|
126
|
+
modelName = entry.record.modelName;
|
|
127
|
+
} else {
|
|
128
|
+
source = fileSnapshotSource(entry.outputFile, readFile);
|
|
129
|
+
modelName = entry.modelName;
|
|
130
|
+
}
|
|
122
131
|
} catch {
|
|
123
132
|
ui.notify("Could not read the session transcript file.", "error");
|
|
124
133
|
return;
|
|
125
134
|
}
|
|
126
135
|
const markdownTheme = getMarkdownTheme();
|
|
127
136
|
await ui.custom<undefined>(
|
|
128
|
-
(tui, theme, _keybindings, done) =>
|
|
137
|
+
(tui, theme, _keybindings, done) =>
|
|
138
|
+
new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme, modelName }),
|
|
129
139
|
{
|
|
130
140
|
overlay: true,
|
|
131
141
|
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
@@ -134,14 +144,34 @@ export class SessionNavigatorHandler {
|
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Minimum interval between two component rebuilds for a live (streaming) source.
|
|
149
|
+
*
|
|
150
|
+
* A running agent emits many events per second (text deltas, tool calls). Without
|
|
151
|
+
* throttling, each event rebuilt the whole component tree (markdown parsing
|
|
152
|
+
* included) and re-rendered it — starving the event loop so keystrokes (arrows,
|
|
153
|
+
* `q`) stopped responding. Coalescing bursts into at most one rebuild per tick
|
|
154
|
+
* keeps the overlay responsive while still streaming.
|
|
155
|
+
*/
|
|
156
|
+
const REBUILD_THROTTLE_MS = 120;
|
|
157
|
+
|
|
137
158
|
/**
|
|
138
159
|
* Read-only scrollable transcript overlay.
|
|
139
160
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* and
|
|
144
|
-
* `
|
|
161
|
+
* Two caches keep it responsive even for long, live-streaming transcripts:
|
|
162
|
+
*
|
|
163
|
+
* - `content` (a `Container` of Pi's per-entry components) is rebuilt only when
|
|
164
|
+
* the source changes, and rebuilds are *throttled* — a burst of streaming
|
|
165
|
+
* events coalesces into at most one rebuild per `REBUILD_THROTTLE_MS`, so
|
|
166
|
+
* markdown highlighting never re-runs on every token.
|
|
167
|
+
* - `renderedLines` caches the laid-out, width-wrapped lines. `render()` and
|
|
168
|
+
* `handleInput()` read the cache (O(1) slice / length) instead of
|
|
169
|
+
* re-rendering the whole container on every frame and every keystroke.
|
|
170
|
+
*
|
|
171
|
+
* The cache is invalidated (`linesDirty`) whenever the component tree changes,
|
|
172
|
+
* and recomputed lazily at the current width. This class owns scroll state,
|
|
173
|
+
* chrome, and the running-agent streaming indicator; the component mapping lives
|
|
174
|
+
* in `buildTranscriptComponents`.
|
|
145
175
|
*/
|
|
146
176
|
export class TranscriptOverlay implements Component {
|
|
147
177
|
private scrollOffset = 0;
|
|
@@ -155,21 +185,36 @@ export class TranscriptOverlay implements Component {
|
|
|
155
185
|
private readonly done: (result: undefined) => void;
|
|
156
186
|
private readonly cwd: string;
|
|
157
187
|
private readonly markdownTheme: MarkdownTheme;
|
|
188
|
+
private readonly modelName?: string;
|
|
158
189
|
private content: Container;
|
|
159
190
|
|
|
160
|
-
|
|
191
|
+
/** Throttle bookkeeping for coalescing live-source rebuilds. */
|
|
192
|
+
private rebuildTimer: ReturnType<typeof setTimeout> | undefined;
|
|
193
|
+
private lastRebuildAt = 0;
|
|
194
|
+
|
|
195
|
+
/** Cached laid-out lines + the width they were computed at; recomputed lazily when `linesDirty`. */
|
|
196
|
+
private renderedLines: string[] = [];
|
|
197
|
+
private renderedWidth = -1;
|
|
198
|
+
private linesDirty = true;
|
|
199
|
+
|
|
200
|
+
constructor({ tui, theme, source, done, cwd, markdownTheme, modelName }: TranscriptOverlayOptions) {
|
|
161
201
|
this.tui = tui;
|
|
162
202
|
this.theme = theme;
|
|
163
203
|
this.source = source;
|
|
164
204
|
this.done = done;
|
|
165
205
|
this.cwd = cwd;
|
|
166
206
|
this.markdownTheme = markdownTheme;
|
|
207
|
+
this.modelName = modelName;
|
|
167
208
|
this.content = this.rebuild();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
209
|
+
// Seed `lastRebuildAt` far in the past so the first source-change event
|
|
210
|
+
// always rebuilds immediately (leading-edge throttle). The constructor
|
|
211
|
+
// already built `content` from the snapshot at construction time, but
|
|
212
|
+
// the source may have accumulated events between construction and
|
|
213
|
+
// subscription — that first rebuild surfaces them without delay.
|
|
214
|
+
// Subsequent events inside the throttle window are coalesced into a
|
|
215
|
+
// single trailing rebuild.
|
|
216
|
+
this.lastRebuildAt = 0;
|
|
217
|
+
this.unsubscribe = source.subscribe(() => this.scheduleRebuild());
|
|
173
218
|
}
|
|
174
219
|
|
|
175
220
|
// fallow-ignore-next-line unused-class-member
|
|
@@ -180,29 +225,41 @@ export class TranscriptOverlay implements Component {
|
|
|
180
225
|
return;
|
|
181
226
|
}
|
|
182
227
|
|
|
183
|
-
const totalLines = this.
|
|
228
|
+
const totalLines = this.getRenderedLines(this.innerWidth()).length;
|
|
184
229
|
const viewportHeight = this.viewportHeight();
|
|
185
230
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
231
|
+
let scrolled = false;
|
|
186
232
|
|
|
187
233
|
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
188
234
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
189
|
-
|
|
235
|
+
// Streaming may add lines between handleInput and render,
|
|
236
|
+
// making maxScroll larger; unconditionally disable autoScroll
|
|
237
|
+
// so render() does not reset scrollOffset back to the bottom.
|
|
238
|
+
this.autoScroll = false;
|
|
239
|
+
scrolled = true;
|
|
190
240
|
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
191
241
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
192
|
-
this.autoScroll =
|
|
242
|
+
this.autoScroll = false;
|
|
243
|
+
scrolled = true;
|
|
193
244
|
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
194
245
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
195
246
|
this.autoScroll = false;
|
|
247
|
+
scrolled = true;
|
|
196
248
|
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
197
249
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
198
|
-
this.autoScroll =
|
|
250
|
+
this.autoScroll = false;
|
|
251
|
+
scrolled = true;
|
|
199
252
|
} else if (matchesKey(data, "home")) {
|
|
200
253
|
this.scrollOffset = 0;
|
|
201
254
|
this.autoScroll = false;
|
|
255
|
+
scrolled = true;
|
|
202
256
|
} else if (matchesKey(data, "end")) {
|
|
203
257
|
this.scrollOffset = maxScroll;
|
|
204
258
|
this.autoScroll = true;
|
|
259
|
+
scrolled = true;
|
|
205
260
|
}
|
|
261
|
+
|
|
262
|
+
if (scrolled) this.tui.requestRender();
|
|
206
263
|
}
|
|
207
264
|
|
|
208
265
|
render(width: number): string[] {
|
|
@@ -219,10 +276,11 @@ export class TranscriptOverlay implements Component {
|
|
|
219
276
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
220
277
|
|
|
221
278
|
lines.push(hrTop);
|
|
222
|
-
|
|
279
|
+
const title = this.modelName ? `Subagent session · ${this.modelName}` : "Subagent session";
|
|
280
|
+
lines.push(row(th.bold(title)));
|
|
223
281
|
lines.push(hrMid);
|
|
224
282
|
|
|
225
|
-
const contentLines = this.
|
|
283
|
+
const contentLines = this.getRenderedLines(innerW);
|
|
226
284
|
const viewportHeight = this.viewportHeight();
|
|
227
285
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
228
286
|
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
@@ -236,7 +294,7 @@ export class TranscriptOverlay implements Component {
|
|
|
236
294
|
? "100%"
|
|
237
295
|
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
238
296
|
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
239
|
-
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn ·
|
|
297
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · end follow · q close");
|
|
240
298
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
241
299
|
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
242
300
|
lines.push(hrBot);
|
|
@@ -247,11 +305,17 @@ export class TranscriptOverlay implements Component {
|
|
|
247
305
|
// fallow-ignore-next-line unused-class-member
|
|
248
306
|
invalidate(): void {
|
|
249
307
|
this.content.invalidate();
|
|
308
|
+
this.linesDirty = true;
|
|
309
|
+
this.renderedWidth = -1;
|
|
250
310
|
}
|
|
251
311
|
|
|
252
312
|
// fallow-ignore-next-line unused-class-member
|
|
253
313
|
dispose(): void {
|
|
254
314
|
this.closed = true;
|
|
315
|
+
if (this.rebuildTimer) {
|
|
316
|
+
clearTimeout(this.rebuildTimer);
|
|
317
|
+
this.rebuildTimer = undefined;
|
|
318
|
+
}
|
|
255
319
|
if (this.unsubscribe) {
|
|
256
320
|
this.unsubscribe();
|
|
257
321
|
this.unsubscribe = undefined;
|
|
@@ -269,6 +333,50 @@ export class TranscriptOverlay implements Component {
|
|
|
269
333
|
return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
|
|
270
334
|
}
|
|
271
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Coalesce a burst of source-change events into at most one component rebuild
|
|
338
|
+
* per `REBUILD_THROTTLE_MS`. The first event after an idle gap rebuilds
|
|
339
|
+
* immediately (so a freshly-picked agent paints without delay); subsequent
|
|
340
|
+
* events inside the gap are merged into a single trailing rebuild.
|
|
341
|
+
*/
|
|
342
|
+
private scheduleRebuild(): void {
|
|
343
|
+
if (this.closed) return;
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const elapsed = now - this.lastRebuildAt;
|
|
346
|
+
if (elapsed >= REBUILD_THROTTLE_MS) {
|
|
347
|
+
this.doRebuild();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (this.rebuildTimer) return; // a trailing rebuild is already pending
|
|
351
|
+
this.rebuildTimer = setTimeout(() => {
|
|
352
|
+
this.rebuildTimer = undefined;
|
|
353
|
+
this.doRebuild();
|
|
354
|
+
}, REBUILD_THROTTLE_MS - elapsed);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Rebuild the component tree, invalidate the line cache, and request a paint. */
|
|
358
|
+
private doRebuild(): void {
|
|
359
|
+
if (this.closed) return;
|
|
360
|
+
this.lastRebuildAt = Date.now();
|
|
361
|
+
this.content = this.rebuild();
|
|
362
|
+
this.linesDirty = true;
|
|
363
|
+
this.tui.requestRender();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Return the laid-out content lines at `innerW`, recomputing the cache only
|
|
368
|
+
* when the component tree changed (`linesDirty`) or the width changed.
|
|
369
|
+
* Cheap O(1) on the hot path (every render frame and every keystroke).
|
|
370
|
+
*/
|
|
371
|
+
private getRenderedLines(innerW: number): string[] {
|
|
372
|
+
if (innerW <= 0) return [];
|
|
373
|
+
if (!this.linesDirty && this.renderedWidth === innerW) return this.renderedLines;
|
|
374
|
+
this.renderedLines = this.buildContentLines(innerW);
|
|
375
|
+
this.renderedWidth = innerW;
|
|
376
|
+
this.linesDirty = false;
|
|
377
|
+
return this.renderedLines;
|
|
378
|
+
}
|
|
379
|
+
|
|
272
380
|
private buildContentLines(innerW: number): string[] {
|
|
273
381
|
if (innerW <= 0) return [];
|
|
274
382
|
const lines = this.content.render(innerW);
|
|
@@ -42,6 +42,8 @@ export interface WidgetAgent {
|
|
|
42
42
|
readonly responseText: string;
|
|
43
43
|
/** Context-window utilisation (0–100), or null when unavailable. */
|
|
44
44
|
readonly contextPercent: number | null;
|
|
45
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
46
|
+
readonly modelName?: string;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// ── Per-agent rendering ──────────────────────────────────────────────────────
|
|
@@ -74,6 +76,7 @@ export function renderFinishedLine(agent: WidgetAgent, registry: AgentConfigLook
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const parts: string[] = [];
|
|
79
|
+
if (agent.modelName) parts.push(agent.modelName);
|
|
77
80
|
parts.push(formatTurns(agent.turnCount, agent.maxTurns));
|
|
78
81
|
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
79
82
|
parts.push(duration);
|
|
@@ -98,6 +101,7 @@ export function renderRunningLines(
|
|
|
98
101
|
const tokenText = tokens > 0 ? formatSessionTokens(tokens, agent.contextPercent, theme, agent.compactionCount) : "";
|
|
99
102
|
|
|
100
103
|
const parts: string[] = [];
|
|
104
|
+
if (agent.modelName) parts.push(agent.modelName);
|
|
101
105
|
parts.push(formatTurns(agent.turnCount, agent.maxTurns));
|
|
102
106
|
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
103
107
|
if (tokenText) parts.push(tokenText);
|