pi-subagents 0.45.2 → 0.46.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 +24 -0
- package/README.md +2 -0
- package/docs/agents.md +342 -0
- package/docs/configuration.md +320 -0
- package/docs/extension-api.md +308 -0
- package/docs/missions.md +117 -0
- package/docs/models.md +190 -0
- package/docs/observability.md +174 -0
- package/docs/tool-reference.md +343 -0
- package/docs/watchdog.md +176 -0
- package/docs/workflows.md +163 -0
- package/package.json +4 -2
- package/skills/pi-subagents/references/execution-controls.md +2 -2
- package/src/agents/agents.ts +17 -8
- package/src/agents/frontmatter.ts +7 -3
- package/src/agents/skills.ts +2 -9
- package/src/api/project-panes.ts +30 -0
- package/src/extension/config.ts +15 -1
- package/src/extension/index.ts +36 -16
- package/src/extension/schemas.ts +3 -2
- package/src/extension/subagent-guide.ts +39 -0
- package/src/extension/tool-description.ts +4 -4
- package/src/inspectors/herdr/project-panes.ts +457 -62
- package/src/missions/actions.ts +25 -2
- package/src/missions/lifecycle.ts +21 -2
- package/src/missions/store.ts +77 -1
- package/src/missions/types.ts +33 -0
- package/src/runs/background/async-execution.ts +7 -1
- package/src/runs/background/completion-replay.ts +267 -0
- package/src/runs/background/result-watcher.ts +12 -4
- package/src/runs/background/wait-completions.ts +39 -5
- package/src/runs/background/wait-subscriptions.ts +18 -3
- package/src/runs/foreground/execution.ts +4 -0
- package/src/runs/foreground/foreground-history.ts +137 -0
- package/src/runs/foreground/subagent-executor.ts +310 -44
- package/src/shared/fork-context.ts +13 -0
- package/src/shared/prompt-resources.ts +51 -0
- package/src/shared/types.ts +30 -1
- package/src/shared/utf8.ts +11 -0
- package/src/slash/prompt-workflows.ts +2 -15
- package/src/slash/slash-commands.ts +19 -1
- package/src/tui/fleet-status.ts +8 -2
- package/src/tui/fleet.ts +135 -25
- package/src/tui/render.ts +120 -7
- package/src/workflows/scripted-workflow.ts +167 -10
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseFrontmatter } from "../agents/frontmatter.ts";
|
|
5
|
+
import { getAgentDir, getProjectConfigDir } from "./utils.ts";
|
|
6
|
+
|
|
7
|
+
const PROMPT_REF_PATTERN = /^(package|user|project):([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
|
|
8
|
+
const PROMPT_VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
|
|
9
|
+
|
|
10
|
+
type PromptVariable = string | number | boolean;
|
|
11
|
+
|
|
12
|
+
export function getPromptDirectories(cwd: string) {
|
|
13
|
+
return {
|
|
14
|
+
package: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "prompts"),
|
|
15
|
+
user: path.join(getAgentDir(), "prompts"),
|
|
16
|
+
project: path.join(getProjectConfigDir(cwd), "prompts"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function promptVariables(vars: unknown): Record<string, PromptVariable> {
|
|
21
|
+
if (vars === undefined) return {};
|
|
22
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars)) throw new Error("prompts.render vars must be a plain object.");
|
|
23
|
+
const prototype = Object.getPrototypeOf(vars);
|
|
24
|
+
if (prototype !== null && prototype !== Object.prototype) throw new Error("prompts.render vars must be a plain object.");
|
|
25
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
26
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
27
|
+
throw new Error(`prompts.render variable '${name}' must be a string, number, or boolean.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return vars as Record<string, PromptVariable>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderWorkflowPrompt(ref: string, vars: unknown, cwd: string): string {
|
|
34
|
+
const match = ref.match(PROMPT_REF_PATTERN);
|
|
35
|
+
if (!match) throw new Error("prompts.render ref must use package:<name>, user:<name>, or project:<name>.");
|
|
36
|
+
const scope = match[1] as keyof ReturnType<typeof getPromptDirectories>;
|
|
37
|
+
const name = match[2]!;
|
|
38
|
+
const filePath = path.join(getPromptDirectories(cwd)[scope], `${name}.md`);
|
|
39
|
+
let content: string;
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.lstatSync(filePath).isFile()) throw new Error("not a regular file");
|
|
42
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`Could not read prompt fragment '${ref}': ${detail}`);
|
|
46
|
+
}
|
|
47
|
+
const variables = promptVariables(vars);
|
|
48
|
+
return parseFrontmatter(content).body.trim().replace(PROMPT_VARIABLE_PATTERN, (placeholder, variable: string) => {
|
|
49
|
+
return Object.hasOwn(variables, variable) ? String(variables[variable]) : placeholder;
|
|
50
|
+
});
|
|
51
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -969,6 +969,8 @@ export interface WaitCompletion {
|
|
|
969
969
|
mode?: string;
|
|
970
970
|
state?: string;
|
|
971
971
|
success?: boolean;
|
|
972
|
+
/** Versioned bounded output archive retained with the durable completion replay. */
|
|
973
|
+
archivePath?: string;
|
|
972
974
|
results?: WaitCompletionChild[];
|
|
973
975
|
}
|
|
974
976
|
|
|
@@ -1045,6 +1047,7 @@ export interface Details {
|
|
|
1045
1047
|
operation: "run" | "status";
|
|
1046
1048
|
key: string;
|
|
1047
1049
|
state: "started" | "completed" | "failed" | "reused";
|
|
1050
|
+
agent?: string;
|
|
1048
1051
|
runId?: string;
|
|
1049
1052
|
phase?: string;
|
|
1050
1053
|
label?: string;
|
|
@@ -1538,6 +1541,10 @@ export interface ForegroundChildControl {
|
|
|
1538
1541
|
|
|
1539
1542
|
export interface ForegroundRunControl {
|
|
1540
1543
|
runId: string;
|
|
1544
|
+
/** Workflow shell that owns this live foreground child, when applicable. */
|
|
1545
|
+
parentWorkflowRunId?: string;
|
|
1546
|
+
/** Stable workflow lane key for this live foreground child. */
|
|
1547
|
+
workflowKey?: string;
|
|
1541
1548
|
/** Originating parent session; required for public fleet projection. */
|
|
1542
1549
|
sessionId?: string;
|
|
1543
1550
|
mode: SubagentRunMode;
|
|
@@ -1780,12 +1787,34 @@ export interface ScheduledRunsConfig {
|
|
|
1780
1787
|
|
|
1781
1788
|
export type FleetViewPlacement = "aboveEditor" | "belowEditor";
|
|
1782
1789
|
|
|
1790
|
+
export const FLEET_KEYBINDING_ACTIONS = [
|
|
1791
|
+
"close",
|
|
1792
|
+
"scrollUp",
|
|
1793
|
+
"scrollDown",
|
|
1794
|
+
"selectUp",
|
|
1795
|
+
"selectDown",
|
|
1796
|
+
"selectFirst",
|
|
1797
|
+
"selectLast",
|
|
1798
|
+
"pageUp",
|
|
1799
|
+
"pageDown",
|
|
1800
|
+
"refresh",
|
|
1801
|
+
"steer",
|
|
1802
|
+
"inspect",
|
|
1803
|
+
"stop",
|
|
1804
|
+
"toggleTools",
|
|
1805
|
+
] as const;
|
|
1806
|
+
|
|
1807
|
+
export type FleetKeybindingAction = typeof FLEET_KEYBINDING_ACTIONS[number];
|
|
1808
|
+
export type FleetKeybindingsConfig = Partial<Record<FleetKeybindingAction, string[]>>;
|
|
1809
|
+
|
|
1783
1810
|
export interface ExtensionConfig {
|
|
1784
1811
|
asyncByDefault?: boolean;
|
|
1785
1812
|
/** Show the Claude Code-style navigable fleet. Defaults to true. */
|
|
1786
1813
|
fleetView?: boolean;
|
|
1787
1814
|
/** Place the persistent FleetView above or below the editor. Defaults to belowEditor. */
|
|
1788
1815
|
fleetViewPlacement?: FleetViewPlacement;
|
|
1816
|
+
/** Local keybindings for the full Fleet inspector. */
|
|
1817
|
+
fleetKeybindings?: FleetKeybindingsConfig;
|
|
1789
1818
|
/** Show the under-editor async runs widget. Defaults to true, including when FleetView is enabled. */
|
|
1790
1819
|
asyncWidget?: boolean;
|
|
1791
1820
|
/** Tool description variant registered for the parent-facing subagent tool. Defaults to full. */
|
|
@@ -1922,7 +1951,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
|
|
|
1922
1951
|
export const POLL_INTERVAL_MS = 250;
|
|
1923
1952
|
export const MAX_WIDGET_JOBS = 4;
|
|
1924
1953
|
export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
|
|
1925
|
-
export const SUBAGENT_ACTIONS = ["list", "get", "models", "children.list", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.attach-run", "mission.close", "worktree.discard", "refine", "refine.show", "refine.rollback", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
|
|
1954
|
+
export const SUBAGENT_ACTIONS = ["list", "get", "models", "children.list", "guide", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.resolve-decision", "mission.attach-run", "mission.close", "worktree.discard", "refine", "refine.show", "refine.rollback", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
|
|
1926
1955
|
|
|
1927
1956
|
export const DEFAULT_FORK_PREAMBLE =
|
|
1928
1957
|
"You are a delegated subagent running from a fork of the parent session. " +
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function decodeUtf8Tail(bytes: Buffer): string {
|
|
2
|
+
let start = 0;
|
|
3
|
+
while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
|
|
4
|
+
return bytes.subarray(start).toString("utf-8");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function utf8Tail(value: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
8
|
+
const bytes = Buffer.from(value, "utf-8");
|
|
9
|
+
if (bytes.length <= maxBytes) return { text: value, truncated: false };
|
|
10
|
+
return { text: decodeUtf8Tail(bytes.subarray(bytes.length - maxBytes)), truncated: true };
|
|
11
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { parseFrontmatter } from "../agents/frontmatter.ts";
|
|
6
5
|
import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
|
|
7
|
-
import {
|
|
6
|
+
import { getPromptDirectories } from "../shared/prompt-resources.ts";
|
|
8
7
|
|
|
9
8
|
interface PromptWorkflow {
|
|
10
9
|
name: string;
|
|
@@ -32,21 +31,9 @@ const RESERVED_COMMAND_NAMES = new Set([
|
|
|
32
31
|
"subagents-models",
|
|
33
32
|
]);
|
|
34
33
|
|
|
35
|
-
function packagePromptsDir(): string {
|
|
36
|
-
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "prompts");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function promptDirs(cwd: string): string[] {
|
|
40
|
-
return [
|
|
41
|
-
packagePromptsDir(),
|
|
42
|
-
path.join(getAgentDir(), "prompts"),
|
|
43
|
-
path.join(getProjectConfigDir(cwd), "prompts"),
|
|
44
|
-
];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
34
|
function readPromptFiles(cwd: string): string[] {
|
|
48
35
|
const files: string[] = [];
|
|
49
|
-
for (const dir of
|
|
36
|
+
for (const dir of Object.values(getPromptDirectories(cwd))) {
|
|
50
37
|
let entries: fs.Dirent[];
|
|
51
38
|
try {
|
|
52
39
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -22,6 +22,7 @@ import { SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
|
|
|
22
22
|
import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
|
|
23
23
|
import { registerPromptWorkflowCommands } from "./prompt-workflows.ts";
|
|
24
24
|
import { openSubagentsAdmin } from "./subagents-admin.ts";
|
|
25
|
+
import { SUBAGENT_GUIDE_TOPICS } from "../extension/subagent-guide.ts";
|
|
25
26
|
import { openSubagentFleet } from "../tui/fleet.ts";
|
|
26
27
|
import {
|
|
27
28
|
applySlashUpdate,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SLASH_SUBAGENT_UPDATE_EVENT,
|
|
41
42
|
DIRS,
|
|
42
43
|
type Details,
|
|
44
|
+
type FleetKeybindingsConfig,
|
|
43
45
|
type JsonSchemaObject,
|
|
44
46
|
type SingleResult,
|
|
45
47
|
type SubagentState,
|
|
@@ -628,6 +630,7 @@ function slashRunWorkflowScript(key: string, child: Record<string, unknown>): st
|
|
|
628
630
|
export function registerSlashCommands(
|
|
629
631
|
pi: ExtensionAPI,
|
|
630
632
|
state: SubagentState,
|
|
633
|
+
options: { fleetKeybindings?: FleetKeybindingsConfig } = {},
|
|
631
634
|
): void {
|
|
632
635
|
let fleetOpen = false;
|
|
633
636
|
const showFleet = async (ctx: ExtensionContext) => {
|
|
@@ -642,7 +645,7 @@ export function registerSlashCommands(
|
|
|
642
645
|
}
|
|
643
646
|
fleetOpen = true;
|
|
644
647
|
try {
|
|
645
|
-
await openSubagentFleet(ctx, state, { asyncDirRoot: DIRS.async, resultsDir: DIRS.results });
|
|
648
|
+
await openSubagentFleet(ctx, state, { asyncDirRoot: DIRS.async, resultsDir: DIRS.results, fleetKeybindings: options.fleetKeybindings });
|
|
646
649
|
} finally {
|
|
647
650
|
fleetOpen = false;
|
|
648
651
|
}
|
|
@@ -698,6 +701,21 @@ export function registerSlashCommands(
|
|
|
698
701
|
},
|
|
699
702
|
});
|
|
700
703
|
|
|
704
|
+
pi.registerCommand("subagents-guide", {
|
|
705
|
+
description: "Show a packaged subagents guide topic",
|
|
706
|
+
getArgumentCompletions: (prefix) => prefix.includes(" ") ? null : SUBAGENT_GUIDE_TOPICS
|
|
707
|
+
.filter((topic) => topic.startsWith(prefix))
|
|
708
|
+
.map((topic) => ({ value: topic, label: topic })),
|
|
709
|
+
handler: async (args, ctx) => {
|
|
710
|
+
const topic = args.trim();
|
|
711
|
+
if (topic.includes(" ")) {
|
|
712
|
+
ctx.ui.notify("Usage: /subagents-guide [topic]", "error");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
await runSlashSubagent(pi, ctx, { action: "guide", ...(topic ? { topic } : {}) });
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
|
|
701
719
|
pi.registerCommand("subagents-refine", {
|
|
702
720
|
description: "Generate a bounded project-local refinement overlay for one subagent",
|
|
703
721
|
getArgumentCompletions: makeAgentCompletions(state),
|
package/src/tui/fleet-status.ts
CHANGED
|
@@ -144,6 +144,12 @@ function isStaleExtensionContextError(error: unknown): boolean {
|
|
|
144
144
|
|| error.message.includes("Extension context no longer active"));
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
function foregroundDescription(control: { parentWorkflowRunId?: string; workflowKey?: string }, description: string | undefined): string | undefined {
|
|
148
|
+
if (!control.parentWorkflowRunId) return description;
|
|
149
|
+
const workflow = `workflow child: ${control.parentWorkflowRunId}${control.workflowKey ? ` (${control.workflowKey})` : ""}`;
|
|
150
|
+
return description ? `${workflow} · ${description}` : workflow;
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntry[] {
|
|
148
154
|
const entries: FleetStatusEntry[] = [];
|
|
149
155
|
for (const control of state.foregroundControls.values()) {
|
|
@@ -154,7 +160,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
|
|
|
154
160
|
key: `foreground-active:${control.runId}:${child.index}`,
|
|
155
161
|
agent: child.agent,
|
|
156
162
|
...(modelThinking ? { modelThinking } : {}),
|
|
157
|
-
description: child.description,
|
|
163
|
+
description: foregroundDescription(control, child.description),
|
|
158
164
|
startedAt: child.startedAt,
|
|
159
165
|
tokens: child.tokens ?? 0,
|
|
160
166
|
state: "running",
|
|
@@ -170,7 +176,7 @@ export function collectFleetStatusEntries(state: SubagentState): FleetStatusEntr
|
|
|
170
176
|
key: `foreground-active:${control.runId}:${control.currentIndex ?? 0}`,
|
|
171
177
|
agent: control.currentAgent ?? control.mode,
|
|
172
178
|
...(modelThinking ? { modelThinking } : {}),
|
|
173
|
-
description: control.description,
|
|
179
|
+
description: foregroundDescription(control, control.description),
|
|
174
180
|
startedAt: control.startedAt,
|
|
175
181
|
tokens: control.tokens ?? 0,
|
|
176
182
|
state: "running",
|
package/src/tui/fleet.ts
CHANGED
|
@@ -2,10 +2,11 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import { getMarkdownTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
5
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
6
6
|
import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
|
|
7
7
|
import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../shared/formatters.ts";
|
|
8
|
-
import { DIRS, type AsyncJobState, type Details, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
|
|
8
|
+
import { DIRS, type AsyncJobState, type Details, type FleetKeybindingAction, type FleetKeybindingsConfig, type ForegroundChildControl, type ForegroundResumeChild, type ForegroundResumeRun, type ForegroundRunControl, type SubagentState } from "../shared/types.ts";
|
|
9
|
+
import { decodeUtf8Tail } from "../shared/utf8.ts";
|
|
9
10
|
import { readStatus } from "../shared/utils.ts";
|
|
10
11
|
import { formatAsyncRunTranscript } from "../runs/background/fleet-view.ts";
|
|
11
12
|
import { listAsyncRuns, type AsyncRunSummary } from "../runs/background/async-status.ts";
|
|
@@ -21,6 +22,47 @@ const REFRESH_MS = 750;
|
|
|
21
22
|
const MAX_RECENT_ASYNC_RUNS = 20;
|
|
22
23
|
const MAX_FLEET_HISTORY_CANDIDATES = 100;
|
|
23
24
|
const TRANSCRIPT_LINES = 200;
|
|
25
|
+
const OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_FLEET_KEYBINDINGS: Record<FleetKeybindingAction, string[]> = {
|
|
28
|
+
close: ["escape", "ctrl+c", "q"],
|
|
29
|
+
scrollUp: ["K"],
|
|
30
|
+
scrollDown: ["J"],
|
|
31
|
+
selectUp: ["up", "k"],
|
|
32
|
+
selectDown: ["down", "j"],
|
|
33
|
+
selectFirst: ["home"],
|
|
34
|
+
selectLast: ["end"],
|
|
35
|
+
pageUp: ["pageUp"],
|
|
36
|
+
pageDown: ["pageDown"],
|
|
37
|
+
refresh: ["r", "R"],
|
|
38
|
+
steer: ["s"],
|
|
39
|
+
inspect: ["H"],
|
|
40
|
+
stop: ["D"],
|
|
41
|
+
toggleTools: ["x", "X", "ctrl+o"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ResolvedFleetKeybindings = Record<FleetKeybindingAction, string[]>;
|
|
45
|
+
|
|
46
|
+
export function resolveFleetKeybindings(config: FleetKeybindingsConfig | undefined): ResolvedFleetKeybindings {
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
Object.entries(DEFAULT_FLEET_KEYBINDINGS).map(([action, defaults]) => [action, config?.[action as FleetKeybindingAction] ?? defaults]),
|
|
49
|
+
) as ResolvedFleetKeybindings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function matchesFleetBinding(data: string, binding: string): boolean {
|
|
53
|
+
const key = /^[A-Z]$/.test(binding) ? `shift+${binding.toLowerCase()}` : binding;
|
|
54
|
+
return matchesKey(data, key as Parameters<typeof matchesKey>[1]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function matchesFleetAction(data: string, bindings: ResolvedFleetKeybindings, action: FleetKeybindingAction): boolean {
|
|
58
|
+
return bindings[action].some((binding) => matchesFleetBinding(data, binding));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bindingLabel(bindings: ResolvedFleetKeybindings, action: FleetKeybindingAction): string {
|
|
62
|
+
return bindings[action]
|
|
63
|
+
.map((binding) => binding === "up" ? "↑" : binding === "down" ? "↓" : binding === "escape" ? "Esc" : binding === "return" ? "Enter" : binding)
|
|
64
|
+
.join("/");
|
|
65
|
+
}
|
|
24
66
|
|
|
25
67
|
type Theme = ExtensionContext["ui"]["theme"];
|
|
26
68
|
type FleetTui = {
|
|
@@ -57,6 +99,7 @@ export interface FleetViewOptions {
|
|
|
57
99
|
refreshMs?: number;
|
|
58
100
|
initialKey?: string;
|
|
59
101
|
markdownTheme?: MarkdownTheme;
|
|
102
|
+
fleetKeybindings?: FleetKeybindingsConfig;
|
|
60
103
|
actions?: FleetActionHandlers;
|
|
61
104
|
}
|
|
62
105
|
|
|
@@ -246,6 +289,7 @@ function foregroundActiveDetail(item: Extract<FleetItem, { kind: "foreground-act
|
|
|
246
289
|
"Source: foreground",
|
|
247
290
|
`State: running`,
|
|
248
291
|
`Mode: ${control.mode}`,
|
|
292
|
+
control.parentWorkflowRunId ? `Workflow child of: ${control.parentWorkflowRunId}${control.workflowKey ? ` (${control.workflowKey})` : ""}` : undefined,
|
|
249
293
|
item.index !== undefined ? `Child: ${item.index} (${item.agent})` : `Agent: ${item.agent}`,
|
|
250
294
|
modelThinking ? `Model: ${modelThinking}` : undefined,
|
|
251
295
|
`Started: ${new Date(live.startedAt).toISOString()}`,
|
|
@@ -260,7 +304,59 @@ function foregroundActiveDetail(item: Extract<FleetItem, { kind: "foreground-act
|
|
|
260
304
|
return lines.filter((line): line is string => line !== undefined);
|
|
261
305
|
}
|
|
262
306
|
|
|
263
|
-
function
|
|
307
|
+
function pathWithin(base: string, candidate: string): boolean {
|
|
308
|
+
const resolvedBase = path.resolve(base);
|
|
309
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
310
|
+
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function trustedFileTail(filePath: string, trustedRoots: string[]): { text?: string; warning?: string; unavailable?: string } {
|
|
314
|
+
const resolvedPath = path.resolve(filePath);
|
|
315
|
+
if (trustedRoots.length === 0 || !trustedRoots.some((root) => pathWithin(root, resolvedPath))) return { warning: `output artifact is outside trusted roots: ${filePath}` };
|
|
316
|
+
let stat: fs.Stats;
|
|
317
|
+
try {
|
|
318
|
+
stat = fs.lstatSync(resolvedPath);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { unavailable: `output artifact unavailable: ${filePath}` };
|
|
321
|
+
return { warning: `output artifact could not be inspected: ${error instanceof Error ? error.message : String(error)}` };
|
|
322
|
+
}
|
|
323
|
+
if (stat.isSymbolicLink()) return { warning: `output artifact refused a symlink: ${filePath}` };
|
|
324
|
+
if (!stat.isFile()) return { warning: `output artifact is not a file: ${filePath}` };
|
|
325
|
+
try {
|
|
326
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
327
|
+
const realRoots = trustedRoots.filter((root) => fs.existsSync(root)).map((root) => fs.realpathSync(root));
|
|
328
|
+
if (!realRoots.some((root) => pathWithin(root, realPath))) return { warning: `output artifact resolves outside trusted roots: ${filePath}` };
|
|
329
|
+
const fd = fs.openSync(realPath, "r");
|
|
330
|
+
try {
|
|
331
|
+
const bytes = Math.min(stat.size, OUTPUT_TAIL_BYTES);
|
|
332
|
+
const buffer = Buffer.alloc(bytes);
|
|
333
|
+
fs.readSync(fd, buffer, 0, bytes, stat.size - bytes);
|
|
334
|
+
return { text: decodeUtf8Tail(buffer) };
|
|
335
|
+
} finally {
|
|
336
|
+
fs.closeSync(fd);
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
return { warning: `output artifact could not be read: ${error instanceof Error ? error.message : String(error)}` };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function foregroundRecentOutputLines(item: Extract<FleetItem, { kind: "foreground-recent" }>, state: SubagentState): string[] {
|
|
344
|
+
const outputPath = item.child.artifactPaths?.outputPath ?? item.child.savedOutputPath;
|
|
345
|
+
const output = item.child.finalOutput
|
|
346
|
+
? { text: item.child.finalOutput }
|
|
347
|
+
: outputPath
|
|
348
|
+
? trustedFileTail(path.isAbsolute(outputPath) ? outputPath : path.resolve(item.run.cwd, outputPath), uniquePaths([
|
|
349
|
+
fleetArtifactsRoot(state, item.run.cwd),
|
|
350
|
+
fleetArtifactsRoot(state, state.baseCwd),
|
|
351
|
+
]))
|
|
352
|
+
: undefined;
|
|
353
|
+
if (output?.warning) return [`(${output.warning})`];
|
|
354
|
+
if (output?.unavailable) return [`(${output.unavailable})`];
|
|
355
|
+
const outputLines = (output?.text ?? "").split(/\r?\n/).filter((line) => line.trim()).slice(-TRANSCRIPT_LINES);
|
|
356
|
+
return outputLines.length ? outputLines : ["(no recovered output available)"];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function foregroundRecentDetail(item: Extract<FleetItem, { kind: "foreground-recent" }>, state: SubagentState): string[] {
|
|
264
360
|
const { child, run } = item;
|
|
265
361
|
const outputPath = child.artifactPaths?.outputPath ?? child.savedOutputPath;
|
|
266
362
|
const modelThinking = formatModelThinking(child.model, child.thinking);
|
|
@@ -281,8 +377,7 @@ function foregroundRecentDetail(item: Extract<FleetItem, { kind: "foreground-rec
|
|
|
281
377
|
"",
|
|
282
378
|
"Result transcript tail",
|
|
283
379
|
];
|
|
284
|
-
|
|
285
|
-
lines.push(...(outputLines.length ? outputLines : ["(no recovered output available)"]));
|
|
380
|
+
lines.push(...foregroundRecentOutputLines(item, state));
|
|
286
381
|
return lines.filter((line): line is string => line !== undefined);
|
|
287
382
|
}
|
|
288
383
|
|
|
@@ -306,12 +401,12 @@ function asyncDetail(item: Extract<FleetItem, { kind: "async" }>): string[] {
|
|
|
306
401
|
].filter((line): line is string => line !== undefined);
|
|
307
402
|
}
|
|
308
403
|
|
|
309
|
-
function detailLines(item: FleetItem | undefined, error: string | undefined): string[] {
|
|
404
|
+
function detailLines(item: FleetItem | undefined, error: string | undefined, state: SubagentState): string[] {
|
|
310
405
|
if (!item) return [error ? `Fleet scan failed: ${error}` : "No current-session foreground or recent async children.", "", "New runs appear here automatically while this inspector remains open."];
|
|
311
406
|
const lines = item.kind === "foreground-active"
|
|
312
407
|
? foregroundActiveDetail(item)
|
|
313
408
|
: item.kind === "foreground-recent"
|
|
314
|
-
? foregroundRecentDetail(item)
|
|
409
|
+
? foregroundRecentDetail(item, state)
|
|
315
410
|
: asyncDetail(item);
|
|
316
411
|
if (error) lines.unshift(`Fleet scan warning: ${error}`, "");
|
|
317
412
|
return lines;
|
|
@@ -497,6 +592,7 @@ export class SubagentFleetComponent implements Component {
|
|
|
497
592
|
private readonly state: SubagentState;
|
|
498
593
|
private readonly done: (result: undefined) => void;
|
|
499
594
|
private readonly options: FleetViewOptions;
|
|
595
|
+
private readonly keybindings: ResolvedFleetKeybindings;
|
|
500
596
|
|
|
501
597
|
constructor(
|
|
502
598
|
tui: FleetTui,
|
|
@@ -511,6 +607,7 @@ export class SubagentFleetComponent implements Component {
|
|
|
511
607
|
this.state = state;
|
|
512
608
|
this.done = done;
|
|
513
609
|
this.options = options;
|
|
610
|
+
this.keybindings = resolveFleetKeybindings(options.fleetKeybindings);
|
|
514
611
|
this.selectedKey = options.initialKey;
|
|
515
612
|
this.refresh();
|
|
516
613
|
this.timer = setInterval(() => {
|
|
@@ -552,6 +649,19 @@ export class SubagentFleetComponent implements Component {
|
|
|
552
649
|
return { item };
|
|
553
650
|
}
|
|
554
651
|
|
|
652
|
+
private selectedHerdrInspectAction(): { runId: string; asyncDir: string; index?: number } | { reason: string } {
|
|
653
|
+
const item = this.snapshot.items[this.selected];
|
|
654
|
+
if (!item) return { reason: "No child is selected." };
|
|
655
|
+
if (item.kind === "async") {
|
|
656
|
+
if (!isActionableAsyncState(item.run.state) || !isActionableAsyncState(item.state)) return { reason: `Selected child is ${item.state}; controls require a running or queued async child.` };
|
|
657
|
+
return { runId: item.runId, asyncDir: item.run.asyncDir, ...(item.index !== undefined ? { index: item.index } : {}) };
|
|
658
|
+
}
|
|
659
|
+
if (item.kind !== "foreground-active" || !item.control.parentWorkflowRunId) return { reason: "Fleet controls are available for current-session top-level async runs only." };
|
|
660
|
+
const parent = this.state.asyncJobs.get(item.control.parentWorkflowRunId) ?? this.state.fleetJobs?.get(item.control.parentWorkflowRunId);
|
|
661
|
+
if (!parent || !isActionableAsyncState(parent.status)) return { reason: "The parent workflow is no longer available for Herdr inspection." };
|
|
662
|
+
return { runId: parent.asyncId, asyncDir: parent.asyncDir };
|
|
663
|
+
}
|
|
664
|
+
|
|
555
665
|
private actionLines(): string[] {
|
|
556
666
|
const lines: string[] = [];
|
|
557
667
|
if (this.actionBusy) lines.push(this.theme.fg("accent", "Action pending..."));
|
|
@@ -657,25 +767,25 @@ export class SubagentFleetComponent implements Component {
|
|
|
657
767
|
}
|
|
658
768
|
return;
|
|
659
769
|
}
|
|
660
|
-
if (
|
|
770
|
+
if (matchesFleetAction(data, this.keybindings, "close")) {
|
|
661
771
|
this.done(undefined);
|
|
662
772
|
return;
|
|
663
773
|
}
|
|
664
|
-
if (
|
|
665
|
-
if (
|
|
666
|
-
if (
|
|
667
|
-
if (
|
|
668
|
-
if (
|
|
669
|
-
if (
|
|
670
|
-
if (
|
|
671
|
-
if (
|
|
672
|
-
if (data.
|
|
774
|
+
if (matchesFleetAction(data, this.keybindings, "scrollUp")) return this.scrollDetail(-1);
|
|
775
|
+
if (matchesFleetAction(data, this.keybindings, "scrollDown")) return this.scrollDetail(1);
|
|
776
|
+
if (matchesFleetAction(data, this.keybindings, "selectUp")) return this.moveSelection(-1);
|
|
777
|
+
if (matchesFleetAction(data, this.keybindings, "selectDown")) return this.moveSelection(1);
|
|
778
|
+
if (matchesFleetAction(data, this.keybindings, "selectFirst")) return this.moveSelection(-this.snapshot.items.length);
|
|
779
|
+
if (matchesFleetAction(data, this.keybindings, "selectLast")) return this.moveSelection(this.snapshot.items.length);
|
|
780
|
+
if (matchesFleetAction(data, this.keybindings, "pageUp")) return this.scrollDetail(-this.detailViewportHeight);
|
|
781
|
+
if (matchesFleetAction(data, this.keybindings, "pageDown")) return this.scrollDetail(this.detailViewportHeight);
|
|
782
|
+
if (matchesFleetAction(data, this.keybindings, "refresh")) {
|
|
673
783
|
this.transcriptCache = undefined;
|
|
674
784
|
this.refresh();
|
|
675
785
|
this.tui.requestRender();
|
|
676
786
|
return;
|
|
677
787
|
}
|
|
678
|
-
if (data
|
|
788
|
+
if (matchesFleetAction(data, this.keybindings, "steer")) {
|
|
679
789
|
const target = this.selectedAsyncAction();
|
|
680
790
|
if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
|
|
681
791
|
else {
|
|
@@ -687,13 +797,13 @@ export class SubagentFleetComponent implements Component {
|
|
|
687
797
|
}
|
|
688
798
|
return;
|
|
689
799
|
}
|
|
690
|
-
if (data
|
|
691
|
-
const target = this.
|
|
800
|
+
if (matchesFleetAction(data, this.keybindings, "inspect")) {
|
|
801
|
+
const target = this.selectedHerdrInspectAction();
|
|
692
802
|
if ("reason" in target || !this.options.actions?.inspect) this.setActionNotice({ text: "reason" in target ? target.reason : "Herdr inspector controls are unavailable in this context.", isError: true });
|
|
693
|
-
else this.runAction(() => this.options.actions!.inspect!(
|
|
803
|
+
else this.runAction(() => this.options.actions!.inspect!(target));
|
|
694
804
|
return;
|
|
695
805
|
}
|
|
696
|
-
if (data
|
|
806
|
+
if (matchesFleetAction(data, this.keybindings, "stop")) {
|
|
697
807
|
const target = this.selectedAsyncAction();
|
|
698
808
|
if ("reason" in target || !this.options.actions) this.setActionNotice({ text: "reason" in target ? target.reason : "Fleet controls are unavailable in this context.", isError: true });
|
|
699
809
|
else {
|
|
@@ -705,7 +815,7 @@ export class SubagentFleetComponent implements Component {
|
|
|
705
815
|
}
|
|
706
816
|
return;
|
|
707
817
|
}
|
|
708
|
-
if (
|
|
818
|
+
if (matchesFleetAction(data, this.keybindings, "toggleTools")) {
|
|
709
819
|
this.expandedTools = !this.expandedTools;
|
|
710
820
|
this.transcriptCache = undefined;
|
|
711
821
|
this.tui.requestRender();
|
|
@@ -766,7 +876,7 @@ export class SubagentFleetComponent implements Component {
|
|
|
766
876
|
}
|
|
767
877
|
}
|
|
768
878
|
|
|
769
|
-
const raw = detailLines(selected, this.snapshot.error);
|
|
879
|
+
const raw = detailLines(selected, this.snapshot.error, this.state);
|
|
770
880
|
if (transcriptWarning) raw.unshift(`Transcript preview warning: ${transcriptWarning}`, "");
|
|
771
881
|
const lines: string[] = [];
|
|
772
882
|
for (const line of raw) {
|
|
@@ -823,7 +933,7 @@ export class SubagentFleetComponent implements Component {
|
|
|
823
933
|
}
|
|
824
934
|
lines.push(this.theme.fg("border", `├${"─".repeat(rosterWidth)}┴${"─".repeat(detailWidth)}┤`));
|
|
825
935
|
const position = this.snapshot.items.length ? `${this.selected + 1}/${this.snapshot.items.length}` : "0/0";
|
|
826
|
-
const footer = `
|
|
936
|
+
const footer = ` ${bindingLabel(this.keybindings, "selectUp")}/${bindingLabel(this.keybindings, "selectDown")} agent · ${bindingLabel(this.keybindings, "inspect")} Herdr · ${bindingLabel(this.keybindings, "steer")} steer · ${bindingLabel(this.keybindings, "stop")} stop · ${bindingLabel(this.keybindings, "toggleTools")} tools · ${bindingLabel(this.keybindings, "refresh")} refresh · ${bindingLabel(this.keybindings, "close")} close · ${position}`;
|
|
827
937
|
lines.push(this.theme.fg("border", "│") + fit(this.theme.fg("dim", footer), innerWidth) + this.theme.fg("border", "│"));
|
|
828
938
|
lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
829
939
|
return lines.map((line) => truncateToWidth(line, width));
|