@yefengr/pi-subagents 0.0.0-stage → 0.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 +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +289 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifetime usage components, accumulated via `message_end` events. Survives
|
|
5
|
+
* compaction (which replaces session.state.messages and would reset any
|
|
6
|
+
* stats-derived sum). cacheRead is excluded because each turn's cacheRead is
|
|
7
|
+
* the cumulative cached prefix re-read on that one call — summing across
|
|
8
|
+
* turns counts the prefix N times. See issue #38.
|
|
9
|
+
*/
|
|
10
|
+
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
|
|
11
|
+
|
|
12
|
+
/** Sum of lifetime usage components, or 0 if undefined. */
|
|
13
|
+
export function getLifetimeTotal(u?: LifetimeUsage): number {
|
|
14
|
+
return u ? u.input + u.output + u.cacheWrite : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Add a usage delta into a target accumulator (mutates target). */
|
|
18
|
+
export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
|
|
19
|
+
into.input += delta.input;
|
|
20
|
+
into.output += delta.output;
|
|
21
|
+
into.cacheWrite += delta.cacheWrite;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Minimal shape we read from upstream `getSessionStats()`. */
|
|
25
|
+
export type SessionStatsLike = {
|
|
26
|
+
tokens: { input: number; output: number; cacheWrite: number };
|
|
27
|
+
contextUsage?: { percent: number | null };
|
|
28
|
+
};
|
|
29
|
+
export type SessionLike = { getSessionStats(): SessionStatsLike };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session-scoped token count: input + output + cacheWrite as reported by
|
|
33
|
+
* upstream `getSessionStats().tokens` for the *current* session window.
|
|
34
|
+
*
|
|
35
|
+
* RESETS at compaction — upstream replaces `session.state.messages` and the
|
|
36
|
+
* stats are derived from that array. For a lifetime total that survives
|
|
37
|
+
* compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
|
|
38
|
+
* from an independent accumulator fed by `message_end` events.
|
|
39
|
+
*
|
|
40
|
+
* Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
|
|
41
|
+
* and so counts the cumulative cached prefix N times across N turns
|
|
42
|
+
* (issue #38).
|
|
43
|
+
*/
|
|
44
|
+
export function getSessionTokens(session: SessionLike | undefined): number {
|
|
45
|
+
if (!session) return 0;
|
|
46
|
+
try {
|
|
47
|
+
const t = session.getSessionStats().tokens;
|
|
48
|
+
return t.input + t.output + t.cacheWrite;
|
|
49
|
+
} catch { return 0; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Context-window utilization (0–100), or null when unavailable
|
|
54
|
+
* (no model contextWindow, or post-compaction before the next response).
|
|
55
|
+
*/
|
|
56
|
+
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
|
|
57
|
+
if (!session) return null;
|
|
58
|
+
try { return session.getSessionStats().contextUsage?.percent ?? null; }
|
|
59
|
+
catch { return null; }
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace-bracket.ts — Owned prepare/dispose lifecycle for a child workspace.
|
|
3
|
+
*
|
|
4
|
+
* Captures the provider resolver (not the provider itself) so provider
|
|
5
|
+
* resolution stays lazy at run-start. The prepared Workspace is held
|
|
6
|
+
* privately; dispose() centralises the guard and addendum-unwrap so callers
|
|
7
|
+
* never reach through to workspace.dispose().resultAddendum directly.
|
|
8
|
+
*
|
|
9
|
+
* dispose() deliberately does NOT catch errors — the best-effort try/catch
|
|
10
|
+
* for failRun() belongs at the call site, preserving the per-caller semantics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Workspace,
|
|
15
|
+
WorkspaceDisposeOutcome,
|
|
16
|
+
WorkspacePrepareContext,
|
|
17
|
+
WorkspaceProvider,
|
|
18
|
+
} from "#src/lifecycle/workspace";
|
|
19
|
+
|
|
20
|
+
/** Owns the child workspace lifecycle: prepare at run-start, dispose at run-end. */
|
|
21
|
+
export class WorkspaceBracket {
|
|
22
|
+
private prepared?: Workspace;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns true when a workspace provider is currently registered.
|
|
28
|
+
* Use to guard the `await prepare(...)` call and avoid an unnecessary
|
|
29
|
+
* microtask boundary in the no-provider path.
|
|
30
|
+
*/
|
|
31
|
+
hasProvider(): boolean {
|
|
32
|
+
return this.resolveProvider() !== undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the registered provider and prepare the child workspace.
|
|
37
|
+
* Returns the workspace's cwd, or undefined when no provider is registered
|
|
38
|
+
* or the provider resolves to undefined.
|
|
39
|
+
*/
|
|
40
|
+
async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
|
|
41
|
+
const provider = this.resolveProvider();
|
|
42
|
+
if (!provider) return undefined;
|
|
43
|
+
this.prepared = await provider.prepare(ctx);
|
|
44
|
+
return this.prepared?.cwd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Dispose the prepared workspace (if any) and return the result addendum
|
|
49
|
+
* verbatim. Returns an empty string when no workspace was prepared or when
|
|
50
|
+
* the workspace returns no addendum.
|
|
51
|
+
*
|
|
52
|
+
* Throws propagate — wrap in try/catch at the call site when best-effort
|
|
53
|
+
* disposal is desired (e.g. failRun).
|
|
54
|
+
*/
|
|
55
|
+
dispose(outcome: WorkspaceDisposeOutcome): string {
|
|
56
|
+
if (!this.prepared) return "";
|
|
57
|
+
return this.prepared.dispose(outcome)?.resultAddendum ?? "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
|
|
3
|
+
*
|
|
4
|
+
* "Where does a child run, and what brackets the run?" is a strategy (git
|
|
5
|
+
* worktree, container, tmpdir, remote sandbox), not core behavior. The core
|
|
6
|
+
* needs only a working directory plus a disposal hook; the default — the
|
|
7
|
+
* parent's cwd, with no setup/teardown — is always correct.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the observational lifecycle events in child-lifecycle.ts, this is a
|
|
10
|
+
* *generative* seam: a registered provider returns a value the core consumes
|
|
11
|
+
* synchronously at run-start. The core has no knowledge of git or worktrees.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SubagentOutcome } from "#src/lifecycle/subagent";
|
|
15
|
+
import type { AgentInvocation, SubagentType } from "#src/types";
|
|
16
|
+
|
|
17
|
+
/** Context the core hands a provider when a child run starts. */
|
|
18
|
+
export interface WorkspacePrepareContext {
|
|
19
|
+
agentId: string;
|
|
20
|
+
agentType: SubagentType;
|
|
21
|
+
baseCwd: string;
|
|
22
|
+
invocation?: AgentInvocation;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Outcome the core reports to a workspace when the run ends. */
|
|
26
|
+
export interface WorkspaceDisposeOutcome {
|
|
27
|
+
outcome: SubagentOutcome;
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What dispose may hand back for the core to fold into the child result. */
|
|
32
|
+
export interface WorkspaceDisposeResult {
|
|
33
|
+
/** Appended verbatim to the child's result text — the provider owns the wording. */
|
|
34
|
+
resultAddendum?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A prepared working directory plus its bracketed teardown. Born complete. */
|
|
38
|
+
export interface Workspace {
|
|
39
|
+
/** The working directory — already exists when the workspace is handed back. */
|
|
40
|
+
readonly cwd: string;
|
|
41
|
+
dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The single generative seam: supplies a child's workspace. */
|
|
45
|
+
export interface WorkspaceProvider {
|
|
46
|
+
prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { debugLog } from "#src/debug";
|
|
2
|
+
import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
|
|
3
|
+
import type { CompactionInfo, Subagent } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fans out SubagentManager lifecycle notifications to multiple observers.
|
|
7
|
+
*
|
|
8
|
+
* Lets the manager keep its single-observer contract while several independent
|
|
9
|
+
* consumers (event/notification dispatch, the reactive widget) subscribe.
|
|
10
|
+
* Each delegate is isolated: a throw in one does not suppress the others.
|
|
11
|
+
*/
|
|
12
|
+
export class CompositeSubagentObserver implements SubagentManagerObserver {
|
|
13
|
+
private readonly delegates: SubagentManagerObserver[];
|
|
14
|
+
|
|
15
|
+
constructor(delegates: SubagentManagerObserver[]) {
|
|
16
|
+
this.delegates = [...delegates];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Register an additional observer (breaks the widget↔manager construction cycle). */
|
|
20
|
+
add(observer: SubagentManagerObserver): void {
|
|
21
|
+
this.delegates.push(observer);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onSubagentStarted(record: Subagent): void {
|
|
25
|
+
this.dispatch((o) => o.onSubagentStarted(record), "onSubagentStarted");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onSubagentCreated(record: Subagent): void {
|
|
29
|
+
this.dispatch((o) => o.onSubagentCreated(record), "onSubagentCreated");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onSubagentCompleted(record: Subagent): void {
|
|
33
|
+
this.dispatch((o) => o.onSubagentCompleted(record), "onSubagentCompleted");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onSubagentResumed(record: Subagent): void {
|
|
37
|
+
this.dispatch((o) => o.onSubagentResumed(record), "onSubagentResumed");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
|
|
41
|
+
this.dispatch((o) => o.onSubagentCompacted(record, info), "onSubagentCompacted");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private dispatch(call: (o: SubagentManagerObserver) => void, label: string): void {
|
|
45
|
+
for (const o of this.delegates) {
|
|
46
|
+
try {
|
|
47
|
+
call(o);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
debugLog(`CompositeSubagentObserver.${label}`, err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { debugLog } from "#src/debug";
|
|
2
|
+
import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
|
|
3
|
+
import { getLifetimeTotal } from "#src/lifecycle/usage";
|
|
4
|
+
import type { Subagent } from "#src/types";
|
|
5
|
+
import { getOutcomeLabel } from "#src/ui/display";
|
|
6
|
+
|
|
7
|
+
/** Details attached to custom notification messages for visual rendering. */
|
|
8
|
+
export interface NotificationDetails {
|
|
9
|
+
id: string;
|
|
10
|
+
description: string;
|
|
11
|
+
phase: SubagentPhase;
|
|
12
|
+
outcome?: SubagentOutcome;
|
|
13
|
+
toolUses: number;
|
|
14
|
+
invocationTurnCount: number;
|
|
15
|
+
lifetimeTurnCount: number;
|
|
16
|
+
maxTurns?: number;
|
|
17
|
+
totalTokens: number;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
outputFile?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
resultPreview: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- Pure helpers (exported for unit testing) ----
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Escape XML special characters to prevent injection in structured
|
|
28
|
+
* notifications. Quotes are escaped too, so values stay safe if they are ever
|
|
29
|
+
* placed in attribute position, not only element content.
|
|
30
|
+
*/
|
|
31
|
+
export function escapeXml(s: string): string {
|
|
32
|
+
return s
|
|
33
|
+
.replace(/&/g, "&")
|
|
34
|
+
.replace(/</g, "<")
|
|
35
|
+
.replace(/>/g, ">")
|
|
36
|
+
.replace(/"/g, """)
|
|
37
|
+
.replace(/'/g, "'");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Human-readable status label for agent completion. */
|
|
41
|
+
export function getStatusLabel(outcome: SubagentOutcome | undefined, error?: string): string {
|
|
42
|
+
if (outcome === "error") return `Error: ${error ?? "unknown"}`;
|
|
43
|
+
return getOutcomeLabel(outcome);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Format a structured <task-notification> XML block for the parent agent to parse. */
|
|
47
|
+
export function formatTaskNotification(record: Subagent, resultMaxLen: number): string {
|
|
48
|
+
if (record.stoppedWhileQueued) return formatNeverStartedNotification(record);
|
|
49
|
+
|
|
50
|
+
const status = getStatusLabel(record.outcome, record.error);
|
|
51
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
52
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
53
|
+
const contextPercent = record.getContextPercent();
|
|
54
|
+
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
55
|
+
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
56
|
+
|
|
57
|
+
const resultPreview = record.result
|
|
58
|
+
? record.result.length > resultMaxLen
|
|
59
|
+
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
60
|
+
: record.result
|
|
61
|
+
: "No output.";
|
|
62
|
+
|
|
63
|
+
const toolCallId = record.toolCallId;
|
|
64
|
+
const outputFile = record.outputFile;
|
|
65
|
+
return joinNotificationLines([
|
|
66
|
+
"<task-notification>",
|
|
67
|
+
`<task-id>${record.id}</task-id>`,
|
|
68
|
+
toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
|
|
69
|
+
outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
|
|
70
|
+
`<status>${escapeXml(status)}</status>`,
|
|
71
|
+
`<summary>Subagent "${escapeXml(record.description)}" ${record.outcome ?? record.phase}</summary>`,
|
|
72
|
+
`<result>${escapeXml(resultPreview)}</result>`,
|
|
73
|
+
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
74
|
+
"</task-notification>",
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Format the block for an agent stopped before the limiter admitted it. Such an
|
|
80
|
+
* agent never ran, so it has no result and no usage — reporting either (even as
|
|
81
|
+
* zeroes) would point the parent at work that does not exist.
|
|
82
|
+
*/
|
|
83
|
+
function formatNeverStartedNotification(record: Subagent): string {
|
|
84
|
+
const toolCallId = record.toolCallId;
|
|
85
|
+
return joinNotificationLines([
|
|
86
|
+
"<task-notification>",
|
|
87
|
+
`<task-id>${record.id}</task-id>`,
|
|
88
|
+
toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
|
|
89
|
+
"<status>Stopped before starting</status>",
|
|
90
|
+
`<summary>Subagent "${escapeXml(record.description)}" was stopped while queued and never started</summary>`,
|
|
91
|
+
"</task-notification>",
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Join notification lines, dropping the ones a conditional element omitted. */
|
|
96
|
+
function joinNotificationLines(lines: (string | null)[]): string {
|
|
97
|
+
return lines.filter(Boolean).join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build notification details for the custom message renderer. */
|
|
101
|
+
export function buildNotificationDetails(
|
|
102
|
+
record: Subagent,
|
|
103
|
+
resultMaxLen: number,
|
|
104
|
+
): NotificationDetails {
|
|
105
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: record.id,
|
|
109
|
+
description: record.description,
|
|
110
|
+
phase: record.phase,
|
|
111
|
+
...(record.outcome ? { outcome: record.outcome } : {}),
|
|
112
|
+
toolUses: record.toolUses,
|
|
113
|
+
invocationTurnCount: record.invocationTurnCount,
|
|
114
|
+
lifetimeTurnCount: record.lifetimeTurnCount,
|
|
115
|
+
maxTurns: record.maxTurns,
|
|
116
|
+
totalTokens,
|
|
117
|
+
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
118
|
+
outputFile: record.outputFile,
|
|
119
|
+
error: record.error,
|
|
120
|
+
resultPreview: buildResultPreview(record, resultMaxLen),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The renderer's preview text: the (truncated) result, or why there is none. */
|
|
125
|
+
function buildResultPreview(record: Subagent, resultMaxLen: number): string {
|
|
126
|
+
if (record.stoppedWhileQueued) return "Never started — stopped while queued.";
|
|
127
|
+
if (!record.result) return "No output.";
|
|
128
|
+
return record.result.length > resultMaxLen
|
|
129
|
+
? record.result.slice(0, resultMaxLen) + "…"
|
|
130
|
+
: record.result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Notification system factory ----
|
|
134
|
+
|
|
135
|
+
export interface NotificationSystem {
|
|
136
|
+
sendCompletion: (record: Subagent) => void;
|
|
137
|
+
dispose: () => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class NotificationManager implements NotificationSystem {
|
|
141
|
+
// pi.sendMessage is fire-and-forget: while the parent's agent run is active,
|
|
142
|
+
// a followUp is handed to a queue the extension cannot recall, yet it is only
|
|
143
|
+
// delivered when the run drains that queue at turn end. A parent that pulls
|
|
144
|
+
// the result in between would receive it twice. So nudges that arrive mid-run
|
|
145
|
+
// are withheld here — where record.consumed is still consultable — and
|
|
146
|
+
// flushed once the run settles.
|
|
147
|
+
private pendingNudges = new Map<string, Subagent>();
|
|
148
|
+
private parentRunActive = false;
|
|
149
|
+
private disposed = false;
|
|
150
|
+
|
|
151
|
+
constructor(
|
|
152
|
+
private sendMessage: (
|
|
153
|
+
msg: { customType: string; content: string; display: boolean; details?: unknown },
|
|
154
|
+
opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
155
|
+
) => void,
|
|
156
|
+
) {}
|
|
157
|
+
|
|
158
|
+
sendCompletion(record: Subagent): void {
|
|
159
|
+
// The session is gone. It is the aborts fired during shutdown that reach
|
|
160
|
+
// here, and with no parent run active a nudge would go straight out as an
|
|
161
|
+
// unrecallable followUp.
|
|
162
|
+
if (this.disposed) return;
|
|
163
|
+
// Consumption is domain state on the record; the nudge is a pure
|
|
164
|
+
// announcement. Skip if the parent already pulled the result (enqueue-time
|
|
165
|
+
// guard); emitIndividualNudge re-reads record.consumed when the nudge is
|
|
166
|
+
// actually emitted, which is what makes the flush a fresh re-check.
|
|
167
|
+
if (record.consumed) return;
|
|
168
|
+
if (this.parentRunActive) {
|
|
169
|
+
// Keyed by id, so a re-completion in the same run collapses to one nudge.
|
|
170
|
+
this.pendingNudges.set(record.id, record);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.emitIndividualNudge(record);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The parent's agent run became active; nudges are withheld until it settles. */
|
|
177
|
+
onParentAgentStart(): void {
|
|
178
|
+
this.parentRunActive = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The parent's agent run settled. Flush the nudges withheld during it, each
|
|
183
|
+
* re-checking consumption, so a result the parent pulled mid-run is dropped
|
|
184
|
+
* rather than announced a second time.
|
|
185
|
+
*/
|
|
186
|
+
onParentAgentSettled(): void {
|
|
187
|
+
this.parentRunActive = false;
|
|
188
|
+
const withheld = [...this.pendingNudges.values()];
|
|
189
|
+
this.pendingNudges.clear();
|
|
190
|
+
for (const record of withheld) {
|
|
191
|
+
try {
|
|
192
|
+
this.emitIndividualNudge(record);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
debugLog("notification render", err);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Terminal: the manager stops announcing anything, now and afterwards. */
|
|
200
|
+
dispose(): void {
|
|
201
|
+
this.disposed = true;
|
|
202
|
+
this.pendingNudges.clear();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private emitIndividualNudge(record: Subagent): void {
|
|
206
|
+
if (record.consumed) return;
|
|
207
|
+
|
|
208
|
+
const notification = formatTaskNotification(record, 500);
|
|
209
|
+
// A never-started agent has no transcript and nothing to collect.
|
|
210
|
+
const pointerLines = record.stoppedWhileQueued ? "" : this.buildPointerLines(record);
|
|
211
|
+
|
|
212
|
+
this.sendMessage(
|
|
213
|
+
{
|
|
214
|
+
customType: "subagent-notification",
|
|
215
|
+
content: notification + pointerLines,
|
|
216
|
+
display: true,
|
|
217
|
+
details: buildNotificationDetails(record, 500),
|
|
218
|
+
},
|
|
219
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The trailing pointer lines: where the transcript lives, and how to collect
|
|
225
|
+
* the result. The nudge only announces; the parent must pull to collect (and
|
|
226
|
+
* consume).
|
|
227
|
+
*/
|
|
228
|
+
private buildPointerLines(record: Subagent): string {
|
|
229
|
+
const outputFile = record.outputFile;
|
|
230
|
+
const transcriptLine = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
|
|
231
|
+
return `${transcriptLine}\nCall get_subagent_result("${record.id}") to collect the full result.`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record-observer.ts — Subscribes to session events and accumulates SubagentState stats.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the scattered callback-wrapping logic in SubagentManager's startAgent()
|
|
5
|
+
* and resume() with a single direct subscription. The observer targets the
|
|
6
|
+
* SubagentState value object directly, so it carries no dependency on Subagent;
|
|
7
|
+
* the caller forwards itself to its own lifecycle observer via onCompact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SubagentState } from "#src/lifecycle/subagent-state";
|
|
11
|
+
import type { CompactionInfo, SubscribableSession } from "#src/types";
|
|
12
|
+
|
|
13
|
+
export interface SubagentObserverOptions {
|
|
14
|
+
onCompact?: (info: CompactionInfo) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to session events and accumulate stats on the subagent state.
|
|
19
|
+
*
|
|
20
|
+
* Handles:
|
|
21
|
+
* - `tool_execution_start` → `state.addActiveTool(name)`
|
|
22
|
+
* - `tool_execution_end` → `state.removeActiveTool(name)`, `state.incrementToolUses()`
|
|
23
|
+
* - `message_start` → `state.resetResponseText()`
|
|
24
|
+
* - `message_update` (text_delta) → `state.appendResponseText(delta)`
|
|
25
|
+
* - `message_end` (assistant, with usage) → `state.addUsage(…)`
|
|
26
|
+
* - `turn_end` → `state.incrementTurnCount()`
|
|
27
|
+
* - `compaction_end` (not aborted) → `state.incrementCompactions()`, call `onCompact`
|
|
28
|
+
*
|
|
29
|
+
* @returns An unsubscribe function.
|
|
30
|
+
*/
|
|
31
|
+
export function subscribeSubagentObserver(
|
|
32
|
+
session: SubscribableSession,
|
|
33
|
+
state: SubagentState,
|
|
34
|
+
options?: SubagentObserverOptions,
|
|
35
|
+
): () => void {
|
|
36
|
+
return session.subscribe((event) => {
|
|
37
|
+
if (event.type === "tool_execution_start") {
|
|
38
|
+
state.addActiveTool(event.toolName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (event.type === "tool_execution_end") {
|
|
42
|
+
state.removeActiveTool(event.toolName);
|
|
43
|
+
state.incrementToolUses();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (event.type === "message_start") {
|
|
47
|
+
state.resetResponseText();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
event.type === "message_update" &&
|
|
52
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
53
|
+
) {
|
|
54
|
+
state.appendResponseText(event.assistantMessageEvent.delta);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (event.type === "turn_end") {
|
|
58
|
+
state.incrementTurnCount();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
62
|
+
const u = event.message.usage;
|
|
63
|
+
state.addUsage({
|
|
64
|
+
input: u.input,
|
|
65
|
+
output: u.output,
|
|
66
|
+
cacheWrite: u.cacheWrite,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
71
|
+
state.incrementCompactions();
|
|
72
|
+
options?.onCompact?.({
|
|
73
|
+
reason: event.reason,
|
|
74
|
+
tokensBefore: event.result.tokensBefore,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
|
|
3
|
+
import { getStatusLabel, type NotificationDetails } from "#src/observation/notification";
|
|
4
|
+
import { formatMs, formatTokens, formatTurns } from "#src/ui/display";
|
|
5
|
+
import { GLYPHS } from "#src/ui/glyphs";
|
|
6
|
+
|
|
7
|
+
/** Narrow theme interface — only the methods the renderer actually calls. */
|
|
8
|
+
interface RendererTheme {
|
|
9
|
+
fg(style: string, text: string): string;
|
|
10
|
+
bold(text: string): string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Narrow message interface — only the fields the renderer reads. */
|
|
14
|
+
interface RendererMessage {
|
|
15
|
+
details?: NotificationDetails;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Narrow render options — only the fields the renderer reads. */
|
|
19
|
+
interface RenderOptions {
|
|
20
|
+
expanded: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Pure helpers (exported for unit testing) ----
|
|
24
|
+
|
|
25
|
+
/** Resolved status→presentation product: icon glyph/style and status label. */
|
|
26
|
+
export interface StatusPresentation {
|
|
27
|
+
iconGlyph: string;
|
|
28
|
+
iconStyle: string;
|
|
29
|
+
statusText: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Decide the icon and status label for a notification's phase/outcome, once. */
|
|
33
|
+
export function resolveStatusPresentation(
|
|
34
|
+
phase: SubagentPhase,
|
|
35
|
+
outcome: SubagentOutcome | undefined,
|
|
36
|
+
error?: string,
|
|
37
|
+
): StatusPresentation {
|
|
38
|
+
if (outcome === "completed")
|
|
39
|
+
return { iconGlyph: GLYPHS.success, iconStyle: "success", statusText: getStatusLabel(outcome) };
|
|
40
|
+
if (outcome != null)
|
|
41
|
+
return { iconGlyph: GLYPHS.failure, iconStyle: "error", statusText: getStatusLabel(outcome, error) };
|
|
42
|
+
// Not terminal yet: queued/running notifications carry no outcome.
|
|
43
|
+
return { iconGlyph: GLYPHS.agentsActive, iconStyle: "dim", statusText: phase };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fields `buildStatsParts` reads from a `NotificationDetails`. */
|
|
47
|
+
type StatsSource = Pick<
|
|
48
|
+
NotificationDetails,
|
|
49
|
+
"invocationTurnCount" | "maxTurns" | "toolUses" | "totalTokens" | "durationMs"
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
/** Assemble the stats-line parts (turns, tool uses, tokens, duration), omitting zero fields. */
|
|
53
|
+
export function buildStatsParts(d: StatsSource): string[] {
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
if (d.invocationTurnCount > 0) parts.push(formatTurns(d.invocationTurnCount, d.maxTurns));
|
|
56
|
+
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
57
|
+
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
58
|
+
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
59
|
+
return parts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Content lines for the result preview: the whole result (capped at 30 lines)
|
|
64
|
+
* when expanded, or just the first line (capped at 80 columns) when collapsed.
|
|
65
|
+
*/
|
|
66
|
+
export function buildPreviewLines(resultPreview: string, expanded: boolean): string[] {
|
|
67
|
+
if (expanded) return resultPreview.split("\n").slice(0, 30);
|
|
68
|
+
return [resultPreview.split("\n")[0]?.slice(0, 80) ?? ""];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create the notification renderer callback for `pi.registerMessageRenderer`.
|
|
73
|
+
* Returns a factory so the renderer is independently testable without the Pi SDK.
|
|
74
|
+
*/
|
|
75
|
+
export function createNotificationRenderer() {
|
|
76
|
+
return (message: RendererMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
|
|
77
|
+
const d = message.details;
|
|
78
|
+
if (!d) return undefined;
|
|
79
|
+
|
|
80
|
+
const { iconGlyph, iconStyle, statusText } = resolveStatusPresentation(d.phase, d.outcome, d.error);
|
|
81
|
+
|
|
82
|
+
// Line 1: icon + agent description + status
|
|
83
|
+
let line = `${theme.fg(iconStyle, iconGlyph)} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
|
|
84
|
+
|
|
85
|
+
// Line 2: stats
|
|
86
|
+
const parts = buildStatsParts(d);
|
|
87
|
+
if (parts.length) {
|
|
88
|
+
line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Line 3: result preview (collapsed) or full (expanded)
|
|
92
|
+
const previewLines = buildPreviewLines(d.resultPreview, expanded);
|
|
93
|
+
if (expanded) {
|
|
94
|
+
for (const l of previewLines) line += "\n" + theme.fg("dim", ` ${l}`);
|
|
95
|
+
} else {
|
|
96
|
+
line += "\n " + theme.fg("dim", `${GLYPHS.subLine} ${previewLines[0] ?? ""}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Line 4: output file link (if present)
|
|
100
|
+
if (d.outputFile) {
|
|
101
|
+
line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Text(line, 0, 0);
|
|
105
|
+
};
|
|
106
|
+
}
|