pi-better-subagents 0.5.1 → 0.6.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/README.md +13 -1
- package/agent-commands.ts +26 -7
- package/agent-inspection.ts +4 -1
- package/batch.mjs +7 -0
- package/callback-fields.ts +4 -1
- package/catalog-ui.ts +167 -0
- package/child-incidents.ts +19 -5
- package/child-steer.ts +102 -0
- package/cleanup.ts +18 -2
- package/config.json +1 -0
- package/config.ts +5 -1
- package/delegation.ts +22 -0
- package/docs/agent-catalog-operations.md +2 -0
- package/docs/agent-catalog.md +9 -0
- package/docs/failure-observations.md +17 -4
- package/failures.ts +38 -5
- package/incident-model.ts +64 -53
- package/index.ts +314 -16
- package/list.mjs +4 -1
- package/output-payload.ts +151 -56
- package/package.json +4 -4
- package/parse.ts +6 -0
- package/permission-policy.ts +6 -4
- package/registry.ts +62 -1
- package/roles/role.architect.md +3 -3
- package/roles/role.developer.md +3 -3
- package/roles/role.explorer.md +3 -4
- package/roles/role.product-manager.md +3 -4
- package/roles/role.researcher.md +3 -4
- package/roles/role.reviewer.md +3 -3
- package/shared-callback-batcher.ts +34 -3
- package/shared-failure-observations.ts +378 -47
- package/shared-log-utils.ts +114 -0
- package/shared-navigator.ts +12 -10
- package/shared-sandbox-core.ts +660 -17
- package/shared-task-sandbox.ts +10 -5
- package/spawn.ts +3 -1
- package/task-policy.ts +17 -5
- package/timing.ts +425 -0
- package/tools.ts +52 -14
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
16
16
|
- Explicit tool allowlists for child sessions.
|
|
17
17
|
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
|
|
18
18
|
- Live background-work navigator for active runs.
|
|
19
|
+
- Harness-owned run timing: every run gets a soft deadline (30 min, then a wrap-up steer into the child and one parent wake, stopped after 5 min grace), a hard ceiling (90 min), and a no-progress wake (10 min). Override per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `stuck_minutes`, or globally in `config.json` / `PI_SUBAGENT_*_MINUTES`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-stuck).
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
@@ -29,7 +30,18 @@ Try it for one run:
|
|
|
29
30
|
pi -e npm:pi-better-subagents
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to
|
|
33
|
+
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Write: tasks write across home and temp but can remove files outside the workspace only in temp, hidden home directories, and worktree folders (Linux keeps ordinary home folders read-only instead). Currently confined children admit `read`, `write`, `edit`, and `bash`; unsupported requested tools are reported as unavailable. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
|
|
34
|
+
|
|
35
|
+
## Delegation Modes
|
|
36
|
+
|
|
37
|
+
`config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
|
|
38
|
+
`coordinator`. `/subagents` displays the active mode, and
|
|
39
|
+
`/subagents mode manual|adaptive|coordinator` changes it for the current
|
|
40
|
+
session without changing config. Manual delegates only on explicit user or workflow request, even with a
|
|
41
|
+
plan. Adaptive delegates substantial independent work when useful. Coordinator
|
|
42
|
+
uses `agents_catalog` to discover current role descriptions and delegates every
|
|
43
|
+
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
44
|
+
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
33
45
|
|
|
34
46
|
## When To Use
|
|
35
47
|
|
package/agent-commands.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node
|
|
|
10
10
|
import { isAbsolute, resolve } from "node:path";
|
|
11
11
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { presentCatalog, presentCatalogEntry, type LaunchEnricher, type OperationView } from "./agent-inspection.ts";
|
|
13
|
+
import { createCatalogComponent } from "./catalog-ui.ts";
|
|
13
14
|
import {
|
|
14
15
|
CATALOG_SCHEMA_VERSION,
|
|
15
16
|
DiagnosticCodes,
|
|
@@ -80,6 +81,7 @@ export interface AgentCommandUi {
|
|
|
80
81
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
81
82
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
82
83
|
setWidget?(key: string, content: string[] | undefined): void;
|
|
84
|
+
custom?<T>(factory: (tui: { requestRender(): void }, theme: { fg(color: string, text: string): string }, keybindings: unknown, done: (value: T) => void) => unknown, options?: { overlay: boolean }): Promise<T>;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export interface AgentCommandHost {
|
|
@@ -157,8 +159,8 @@ export async function executeAgentsCommand(args: string, host: AgentCommandHost,
|
|
|
157
159
|
}
|
|
158
160
|
const command = parsed.positionals[0]!;
|
|
159
161
|
const loc = location(host, deps);
|
|
160
|
-
if (command === "list") return
|
|
161
|
-
if (command === "show" || command === "inspect") return
|
|
162
|
+
if (command === "list") return finishInspection(host, await runList(parsed, loc, deps));
|
|
163
|
+
if (command === "show" || command === "inspect") return finishInspection(host, await runShow(command, parsed, host, loc, deps));
|
|
162
164
|
if (command === "reload") return finish(host, await runReload(parsed, loc, deps));
|
|
163
165
|
if (command === "create") return finish(host, await runCreate(parsed, host, loc, deps));
|
|
164
166
|
if (command === "import-codex") return finish(host, await runImport(parsed, host, loc, deps));
|
|
@@ -232,6 +234,10 @@ async function runShow(
|
|
|
232
234
|
if (!host.hasUI) {
|
|
233
235
|
return clarification(command, `${command} needs an id. UI is unavailable, so none was selected. Run /agents list and pass the id. Nothing was written.`);
|
|
234
236
|
}
|
|
237
|
+
if (host.mode === "tui" && host.ui.custom) {
|
|
238
|
+
const listed = presentCatalog(readCatalog(loc), deps.enrich);
|
|
239
|
+
return { ...result("ok", command, false, listed.text), diagnostics: listed.diagnostics, data: listed };
|
|
240
|
+
}
|
|
235
241
|
const ids = listCatalog(readCatalog(loc)).map((entry) => entry.id);
|
|
236
242
|
if (ids.length === 0) return result("error", command, false, "The catalog has no definitions to inspect.");
|
|
237
243
|
const selected = await host.ui.select("Inspect a role or agent", ids);
|
|
@@ -696,14 +702,27 @@ function finish(host: AgentCommandHost, commandResult: AgentCommandResult): Agen
|
|
|
696
702
|
} catch {
|
|
697
703
|
/* Notification is not a write. */
|
|
698
704
|
}
|
|
699
|
-
try {
|
|
700
|
-
host.ui.setWidget?.("agents-catalog", commandResult.message.split("\n").slice(0, 40));
|
|
701
|
-
} catch {
|
|
702
|
-
/* Widget rendering is TUI-only. */
|
|
703
|
-
}
|
|
704
705
|
return commandResult;
|
|
705
706
|
}
|
|
706
707
|
|
|
708
|
+
async function finishInspection(host: AgentCommandHost, commandResult: AgentCommandResult): Promise<AgentCommandResult> {
|
|
709
|
+
if (commandResult.ok && host.mode === "tui" && host.hasUI && host.ui.custom) {
|
|
710
|
+
const catalog = commandResult.data && typeof commandResult.data === "object" && "entries" in commandResult.data
|
|
711
|
+
? commandResult.data as ReturnType<typeof presentCatalog> : undefined;
|
|
712
|
+
const view = catalog ? undefined : commandResult.data as OperationView;
|
|
713
|
+
const entries = catalog ?? { entries: [view], diagnostics: commandResult.diagnostics, revision: "", text: "" };
|
|
714
|
+
try {
|
|
715
|
+
host.ui.setWidget?.("agents-catalog", undefined);
|
|
716
|
+
await host.ui.custom<void>((tui, theme, _keys, done) =>
|
|
717
|
+
createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id), { overlay: true });
|
|
718
|
+
return commandResult;
|
|
719
|
+
} catch {
|
|
720
|
+
// A non-interactive host may expose custom() without supporting overlays.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return finish(host, commandResult);
|
|
724
|
+
}
|
|
725
|
+
|
|
707
726
|
function tokenize(input: string): string[] {
|
|
708
727
|
const tokens: string[] = [];
|
|
709
728
|
for (const match of input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
|
package/agent-inspection.ts
CHANGED
|
@@ -207,7 +207,10 @@ export function presentCatalog(snapshot: CatalogSnapshot, enrich?: LaunchEnriche
|
|
|
207
207
|
`Catalog revision ${snapshot.digest}.`,
|
|
208
208
|
"Definition validity is not launchability. Model availability is decided only by the injected resolver.",
|
|
209
209
|
...snapshot.diagnostics.map((diagnostic) => formatDiagnostic(diagnostic)),
|
|
210
|
-
...entries.
|
|
210
|
+
...entries.flatMap((entry) => [
|
|
211
|
+
entry.text.split("\n")[0] ?? entry.id,
|
|
212
|
+
` description: ${JSON.stringify(entry.identity.description ?? "")}`,
|
|
213
|
+
]),
|
|
211
214
|
];
|
|
212
215
|
return { revision: snapshot.digest, entries, diagnostics: [...snapshot.diagnostics], text: lines.join("\n") };
|
|
213
216
|
}
|
package/batch.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { BUILTIN_TOOLS } from "./extensions.mjs";
|
|
11
11
|
import { SAFE_CLEAN_TOOLS } from "./config.ts";
|
|
12
12
|
import { assertThinkingLevel } from "./thinking.ts";
|
|
13
|
+
import { assertTimingParams } from "./timing.ts";
|
|
13
14
|
|
|
14
15
|
const VALID_CAPACITY_MODES = new Set(["reject", "launch-available"]);
|
|
15
16
|
|
|
@@ -35,6 +36,11 @@ export function mergeJobOptions(shared, job) {
|
|
|
35
36
|
git_clone_workspace: job.git_clone_workspace ?? shared?.git_clone_workspace,
|
|
36
37
|
approve: job.approve ?? shared?.approve,
|
|
37
38
|
allow_nested: job.allow_nested ?? shared?.allow_nested,
|
|
39
|
+
// Harness timing; an explicit null means "not set", so it falls through to shared.
|
|
40
|
+
deadline_minutes: job.deadline_minutes ?? shared?.deadline_minutes,
|
|
41
|
+
grace_minutes: job.grace_minutes ?? shared?.grace_minutes,
|
|
42
|
+
max_minutes: job.max_minutes ?? shared?.max_minutes,
|
|
43
|
+
stuck_minutes: job.stuck_minutes ?? shared?.stuck_minutes,
|
|
38
44
|
// A per-job agent or role replaces the shared selector pair. Mixing
|
|
39
45
|
// shared.agent with job.role would invent an ambiguous run.
|
|
40
46
|
...catalogSelector(shared, job),
|
|
@@ -97,6 +103,7 @@ export function validateBatchPlan({ shared, jobs, onCapacity, config }) {
|
|
|
97
103
|
|
|
98
104
|
const merged = mergeJobOptions(shared, job);
|
|
99
105
|
assertThinkingLevel(merged.thinking, `job ${i + 1} thinking`);
|
|
106
|
+
assertTimingParams(merged, `job ${i + 1} `);
|
|
100
107
|
if (merged.clean === true) {
|
|
101
108
|
// Validate the effective tool allowlist, which resolves in this order:
|
|
102
109
|
// per-job tools → shared tools → config.defaultTools → clean-safe built-ins.
|
package/callback-fields.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { CallbackBatchEvent, UrgentCallbackEvent } from "./shared-callback-
|
|
|
10
10
|
import { activeFailures, pendingAttentionNote, pendingAttentionRows, requiresAction, terminalFailureParts,
|
|
11
11
|
type FailureState } from "./shared-failure-observations.ts";
|
|
12
12
|
import type { RunMeta } from "./registry.ts";
|
|
13
|
+
import { describeTiming } from "./timing.ts";
|
|
13
14
|
|
|
14
15
|
export function runLabel(meta: Pick<RunMeta, "id" | "name">): string {
|
|
15
16
|
return meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
@@ -43,11 +44,13 @@ function observationStatus(state: FailureState): string | undefined {
|
|
|
43
44
|
export function completionCallbackFields(meta: RunMeta, state: FailureState, incidents: readonly string[] = []): CompletionFields {
|
|
44
45
|
const status = observationStatus(state);
|
|
45
46
|
const { failureRows, incidentCount, notes } = terminalFields(state, incidents);
|
|
47
|
+
// Harness timing reason (deadline / ceiling / stuck) is a lifecycle fact, shown next to the status.
|
|
48
|
+
const timing = describeTiming({ ...meta, status: meta.status });
|
|
46
49
|
return {
|
|
47
50
|
source: "subagent",
|
|
48
51
|
id: meta.id,
|
|
49
52
|
label: runLabel(meta),
|
|
50
|
-
status: status
|
|
53
|
+
status: [meta.status, timing?.short, status].filter(Boolean).join("; "),
|
|
51
54
|
detailTool: "subagent_result",
|
|
52
55
|
outcome: meta.status,
|
|
53
56
|
failureRows,
|
package/catalog-ui.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { OperationField, OperationList, OperationView } from "./agent-inspection.ts";
|
|
3
|
+
|
|
4
|
+
type Theme = { fg(color: string, text: string): string };
|
|
5
|
+
|
|
6
|
+
function stateLabel(entry: OperationView): string {
|
|
7
|
+
if (entry.launchable === false) return "blocked";
|
|
8
|
+
if (entry.launchable === true) return "ready";
|
|
9
|
+
return "unresolved";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fieldLine(label: string, field: OperationField): string {
|
|
13
|
+
return `${label.padEnd(8)} ${field.value ?? "(unset)"} ${field.source === "role-default" ? "inherited" : field.source === "agent-override" ? "override" : "absent"}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function modelLabel(entry: OperationView): string {
|
|
17
|
+
return entry.actualModel ?? entry.requestedModel ?? entry.fields?.model.value ?? "not resolved";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cell(value: string, width: number): string {
|
|
21
|
+
const clipped = truncateToWidth(value.replace(/[\r\n\t]+/g, " "), width);
|
|
22
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function catalogColumns(entry: OperationView | null, width: number): string {
|
|
26
|
+
const available = Math.max(1, width - 2);
|
|
27
|
+
const sourceWidth = available >= 70 ? 11 : 0;
|
|
28
|
+
const stateWidth = available >= 48 ? 10 : 0;
|
|
29
|
+
const gaps = (sourceWidth ? 2 : 0) + (stateWidth ? 2 : 0) + 2;
|
|
30
|
+
const modelWidth = Math.min(Math.max(1, available - gaps - 1), Math.min(28, Math.max(20, Math.floor(available * 0.3))));
|
|
31
|
+
const nameWidth = Math.max(1, available - modelWidth - sourceWidth - stateWidth - gaps);
|
|
32
|
+
const name = entry ? entry.identity.name || entry.id : "NAME";
|
|
33
|
+
const model = entry ? modelLabel(entry) : "MODEL";
|
|
34
|
+
const source = entry ? entry.identity.scope ?? "unknown" : "SOURCE";
|
|
35
|
+
const status = entry ? stateLabel(entry) : "STATE";
|
|
36
|
+
return [cell(name, nameWidth), cell(model, modelWidth), ...(sourceWidth ? [cell(source, sourceWidth)] : []), ...(stateWidth ? [cell(status, stateWidth)] : [])].join(" ").trimEnd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wrapLine(text: string, width: number): string[] {
|
|
40
|
+
const rows: string[] = [];
|
|
41
|
+
let row = "";
|
|
42
|
+
for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
|
|
43
|
+
if (row && visibleWidth(row + segment) > width) {
|
|
44
|
+
rows.push(row);
|
|
45
|
+
row = "";
|
|
46
|
+
}
|
|
47
|
+
row += segment;
|
|
48
|
+
}
|
|
49
|
+
rows.push(row);
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function catalogDetailLines(entry: OperationView): string[] {
|
|
54
|
+
const lines = [
|
|
55
|
+
`${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`,
|
|
56
|
+
entry.id,
|
|
57
|
+
"",
|
|
58
|
+
`Status ${stateLabel(entry)} · ${entry.launchabilityReason}`,
|
|
59
|
+
`Source ${entry.identity.scope ?? "unknown"} · ${entry.identity.path ?? "(none)"}`,
|
|
60
|
+
...(entry.identity.description ? [`About ${entry.identity.description}`] : []),
|
|
61
|
+
...(entry.role ? [`Base role ${entry.role.id ?? "(missing)"}${entry.role.found ? "" : " · missing"}`] : []),
|
|
62
|
+
...(entry.instructionMode ? [`Instructions ${entry.instructionMode}`] : []),
|
|
63
|
+
"",
|
|
64
|
+
"Configuration",
|
|
65
|
+
...(entry.fields ? [
|
|
66
|
+
fieldLine("Model", entry.fields.model),
|
|
67
|
+
fieldLine("Effort", entry.fields.effort),
|
|
68
|
+
fieldLine("Tier", entry.fields.tier),
|
|
69
|
+
] : []),
|
|
70
|
+
`Actual ${entry.actualModel ?? "not resolved"}${entry.actualEffort ? ` · ${entry.actualEffort}` : ""}`,
|
|
71
|
+
...(entry.validation.length ? ["", "Diagnostics", ...entry.validation.map((item) => `${item.blocking ? "!" : "·"} ${item.message}`)] : []),
|
|
72
|
+
...(entry.shadowed.length ? ["", "Shadowed sources", ...entry.shadowed.map((source) => `${source.scope} ${source.path}`)] : []),
|
|
73
|
+
...(entry.restrictions.length ? ["", "Execution restrictions", ...entry.restrictions.map((item) => `${item.honored ? "honored" : "not honored"} ${item.name}`)] : []),
|
|
74
|
+
"",
|
|
75
|
+
"Effective prompt",
|
|
76
|
+
"Press p to read the complete resolved instructions.",
|
|
77
|
+
];
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createCatalogComponent(
|
|
82
|
+
catalog: OperationList,
|
|
83
|
+
theme: Theme,
|
|
84
|
+
requestRender: () => void,
|
|
85
|
+
close: () => void,
|
|
86
|
+
initialId?: string,
|
|
87
|
+
) {
|
|
88
|
+
let selectedId = initialId ?? catalog.entries[0]?.id;
|
|
89
|
+
let mode: "list" | "detail" | "prompt" = initialId ? "detail" : "list";
|
|
90
|
+
let query = "";
|
|
91
|
+
let scroll = 0;
|
|
92
|
+
const filtered = () => catalog.entries.filter((entry) =>
|
|
93
|
+
[entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
|
|
94
|
+
.some((value) => value?.toLowerCase().includes(query.toLowerCase())));
|
|
95
|
+
const selected = () => filtered().find((entry) => entry.id === selectedId);
|
|
96
|
+
const detailRows = (entry: OperationView, width: number) => (mode === "prompt"
|
|
97
|
+
? [`${entry.identity.name || entry.id} · effective instructions`, "", ...(entry.instructions?.split("\n") ?? ["(none)"])]
|
|
98
|
+
: catalogDetailLines(entry))
|
|
99
|
+
.flatMap((text) => text ? wrapLine(text, Math.max(1, width - 2)) : [""]);
|
|
100
|
+
let lastDetailWidth = 80;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
render(width: number): string[] {
|
|
104
|
+
const safeWidth = Math.max(1, width);
|
|
105
|
+
lastDetailWidth = safeWidth;
|
|
106
|
+
const line = (text: string) => truncateToWidth(text, safeWidth);
|
|
107
|
+
const rows = filtered();
|
|
108
|
+
const current = selected();
|
|
109
|
+
const detailLines = current ? detailRows(current, safeWidth) : [];
|
|
110
|
+
const selectedIndex = rows.findIndex((entry) => entry.id === selectedId);
|
|
111
|
+
const start = Math.max(0, Math.min(selectedIndex - 6, rows.length - 14));
|
|
112
|
+
const lines = mode !== "list" && current ? [
|
|
113
|
+
theme.fg("accent", mode === "prompt" ? "Agents / prompt" : "Agents / inspect"),
|
|
114
|
+
theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc close" : "p prompt · ← list · ↑↓ scroll · Esc close"),
|
|
115
|
+
"",
|
|
116
|
+
...detailLines.slice(scroll, scroll + 22).map((text, i) =>
|
|
117
|
+
i === 0 && scroll === 0 ? theme.fg("accent", text) : text),
|
|
118
|
+
"",
|
|
119
|
+
theme.fg("dim", `Showing ${scroll + 1}-${Math.min(scroll + 22, detailLines.length)} of ${detailLines.length} lines`),
|
|
120
|
+
] : [
|
|
121
|
+
theme.fg("accent", `Agents · ${catalog.entries.length} definitions`),
|
|
122
|
+
theme.fg("dim", "Type to filter · ↑↓ select · Enter inspect · Esc close"),
|
|
123
|
+
`Search ${query || "(all roles and agents)"}`,
|
|
124
|
+
"",
|
|
125
|
+
theme.fg("dim", ` ${catalogColumns(null, safeWidth)}`),
|
|
126
|
+
...rows.slice(start, start + 14).map((entry) => {
|
|
127
|
+
const active = selectedId === entry.id;
|
|
128
|
+
const marker = active ? "›" : " ";
|
|
129
|
+
const text = `${marker} ${catalogColumns(entry, safeWidth)}`;
|
|
130
|
+
return active ? theme.fg("accent", text) : text;
|
|
131
|
+
}),
|
|
132
|
+
...(rows.length === 0 ? [" No matching definitions"] : []),
|
|
133
|
+
...(rows.length > 14 ? [theme.fg("dim", ` ${start + 1}-${Math.min(start + 14, rows.length)} of ${rows.length}`)] : []),
|
|
134
|
+
...catalog.diagnostics.slice(0, 2).map((diagnostic) => theme.fg("warning", `! ${diagnostic.message}`)),
|
|
135
|
+
...(catalog.diagnostics.length > 2 ? [theme.fg("warning", `! ${catalog.diagnostics.length - 2} more catalog diagnostics`)] : []),
|
|
136
|
+
];
|
|
137
|
+
return lines.map(line);
|
|
138
|
+
},
|
|
139
|
+
handleInput(data: string): void {
|
|
140
|
+
const rows = filtered();
|
|
141
|
+
if (matchesKey(data, "escape") || matchesKey(data, "left")) {
|
|
142
|
+
if (mode === "prompt") { mode = "detail"; scroll = 0; }
|
|
143
|
+
else if (mode === "detail") { mode = "list"; scroll = 0; }
|
|
144
|
+
else if (query) { query = ""; selectedId = catalog.entries[0]?.id; }
|
|
145
|
+
else { close(); return; }
|
|
146
|
+
} else if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
147
|
+
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
148
|
+
if (mode !== "list" && selected()) scroll = Math.max(0, Math.min(detailRows(selected()!, lastDetailWidth).length - 1, scroll + delta));
|
|
149
|
+
else if (rows.length) selectedId = rows[Math.max(0, Math.min(rows.length - 1, rows.findIndex((entry) => entry.id === selectedId) + delta))]?.id;
|
|
150
|
+
} else if (matchesKey(data, "enter") && mode === "list" && selected()) {
|
|
151
|
+
mode = "detail";
|
|
152
|
+
scroll = 0;
|
|
153
|
+
} else if ((data === "p" || data === "P") && mode === "detail" && selected()) {
|
|
154
|
+
mode = "prompt";
|
|
155
|
+
scroll = 0;
|
|
156
|
+
} else if (mode === "list" && matchesKey(data, "backspace")) {
|
|
157
|
+
query = query.slice(0, -1);
|
|
158
|
+
selectedId = filtered()[0]?.id;
|
|
159
|
+
} else if (mode === "list" && data.length === 1 && !/\p{C}/u.test(data)) {
|
|
160
|
+
query += data;
|
|
161
|
+
selectedId = filtered()[0]?.id;
|
|
162
|
+
}
|
|
163
|
+
requestRender();
|
|
164
|
+
},
|
|
165
|
+
invalidate() {},
|
|
166
|
+
};
|
|
167
|
+
}
|
package/child-incidents.ts
CHANGED
|
@@ -17,19 +17,33 @@ import { DISPOSITION_TOOL, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, describeO
|
|
|
17
17
|
|
|
18
18
|
const { createBashToolDefinition } = PiCodingAgent;
|
|
19
19
|
|
|
20
|
+
// Each intent field also admits `null`, which means "not declared" (withoutAbsentIntent). Models send
|
|
21
|
+
// optional fields as explicit null; accepting it in the schema means Pi hands execute the same raw
|
|
22
|
+
// arguments the process log and session record keep, on every Pi version (0.82 rejected a null
|
|
23
|
+
// before execute, 0.87 silently drops it), so the child and the parent's replay read one input.
|
|
24
|
+
// `anyOf` rather than a `type` array: Pi 0.82's TypeBox compiler crashes on `["array", "null"]` + `items`.
|
|
25
|
+
const nullable = (schema: Record<string, unknown>) => ({ anyOf: [schema, { type: "null" }] });
|
|
20
26
|
const intentProperties = {
|
|
21
|
-
operationId: { type: "string", pattern: INTENT_ID_PATTERN,
|
|
27
|
+
operationId: { ...nullable({ type: "string", pattern: INTENT_ID_PATTERN }),
|
|
22
28
|
description: "Optional stable id for one logical operation. Reuse it on a modified retry (changed scope, timeout, or flags) so a later success recovers the earlier failure." },
|
|
23
|
-
attemptId: { type: "string", pattern: INTENT_ID_PATTERN,
|
|
29
|
+
attemptId: { ...nullable({ type: "string", pattern: INTENT_ID_PATTERN }),
|
|
24
30
|
description: "Optional unique id for this execution, for use as evidence in failure_disposition." },
|
|
25
|
-
expectedExitCodes: { type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES,
|
|
31
|
+
expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES }),
|
|
26
32
|
// Distinctness is enforced by readCommandIntent, not `uniqueItems`: OpenAI rejects that keyword
|
|
27
33
|
// in function schemas, which failed every confined child's first request.
|
|
28
34
|
description: "Optional distinct non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). Declared before the command runs; the final shell exit code is what is classified." },
|
|
29
35
|
};
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Every tool call the child session has recorded, in append order, across all branches (#325).
|
|
39
|
+
* The parent scans the child's whole process log, not one session branch, so the child reads the
|
|
40
|
+
* whole session too: after a branch switch or compaction the two views still agree. Session
|
|
41
|
+
* entries are a superset of the log's tool starts (an assistant message is recorded before its
|
|
42
|
+
* tools run), so a reuse the parent sees is always one the child also saw and refused.
|
|
43
|
+
*/
|
|
31
44
|
function sessionToolCalls(ctx: ExtensionContext | undefined): Array<{ type: string; [key: string]: unknown }> {
|
|
32
|
-
const
|
|
45
|
+
const manager = ctx?.sessionManager;
|
|
46
|
+
const branch = (manager?.getEntries?.() ?? manager?.getBranch?.() ?? []) as any[];
|
|
33
47
|
const rows: Array<{ type: string; [key: string]: unknown }> = [];
|
|
34
48
|
let pendingEnds: any[] = [];
|
|
35
49
|
const flush = () => { rows.push(...pendingEnds); pendingEnds = []; };
|
|
@@ -52,7 +66,7 @@ function sessionToolCalls(ctx: ExtensionContext | undefined): Array<{ type: stri
|
|
|
52
66
|
return rows;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
|
-
/** Rebuild the child's view of its own incidents from the durable session branch. */
|
|
69
|
+
/** Rebuild the child's view of its own incidents from the durable session record (every branch). */
|
|
56
70
|
export function childIncidentView(ctx: ExtensionContext | undefined, cwd: string): { model: IncidentModel; state: FailureState } {
|
|
57
71
|
let state = emptyFailureState();
|
|
58
72
|
let at = 0;
|
package/child-steer.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-side harness control: deliver a parent steer into the running child session.
|
|
3
|
+
*
|
|
4
|
+
* Loaded into every subagent child (`--extension`) by the parent. It registers no
|
|
5
|
+
* tools and performs no command I/O. The parent writes a request file (path in
|
|
6
|
+
* PI_SUBAGENT_STEER_FILE) under the run directory; this extension polls it and
|
|
7
|
+
* hands the text to Pi's own steering queue (`sendUserMessage` with
|
|
8
|
+
* `deliverAs: "steer"`), which the agent loop reads after the current tool call
|
|
9
|
+
* finishes. When the steer actually enters the conversation (its user
|
|
10
|
+
* `message_end`), it writes a receipt file next to the request, so the parent
|
|
11
|
+
* can start the grace period from real delivery rather than from the moment the
|
|
12
|
+
* steer was queued behind a long tool call. A file, not a log line: in print
|
|
13
|
+
* mode an extension's stdout writes are not flushed to the log until exit.
|
|
14
|
+
* This extension runs in the trusted Pi process; the child's confined tools
|
|
15
|
+
* cannot write the run directory, so they cannot forge a receipt.
|
|
16
|
+
*
|
|
17
|
+
* Each request id is delivered at most once per child process, and only while
|
|
18
|
+
* the agent is working: an idle print-mode child is about to exit, and a new
|
|
19
|
+
* prompt there would start work instead of steering it.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
export const STEER_FILE_ENV = "PI_SUBAGENT_STEER_FILE";
|
|
24
|
+
const POLL_MS = 2_000;
|
|
25
|
+
|
|
26
|
+
type SteerApi = {
|
|
27
|
+
on(event: string, handler: (event: any, ctx: { isIdle?: () => boolean }) => unknown): void;
|
|
28
|
+
sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Where the child records that a steer entered its conversation. */
|
|
32
|
+
export function steerReceiptPath(requestPath: string): string {
|
|
33
|
+
return `${requestPath}.delivered`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readSteerReceipt(requestPath: string): { id: string; at: number } | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(readFileSync(steerReceiptPath(requestPath), "utf8")) as { id?: unknown; at?: unknown };
|
|
39
|
+
return typeof value?.id === "string" && typeof value.at === "number" && Number.isFinite(value.at) ? { id: value.id, at: value.at } : undefined;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readSteerRequest(path: string): { id: string; text: string } | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as { id?: unknown; text?: unknown };
|
|
48
|
+
return typeof value?.id === "string" && typeof value.text === "string" && value.text.trim() !== ""
|
|
49
|
+
? { id: value.id, text: value.text }
|
|
50
|
+
: undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function childSteer(pi: SteerApi): void {
|
|
57
|
+
const path = process.env[STEER_FILE_ENV];
|
|
58
|
+
if (!path) return;
|
|
59
|
+
const delivered = new Set<string>();
|
|
60
|
+
/** Queued steer text → request id, until the steer shows up as a user message. */
|
|
61
|
+
const queued = new Map<string, string>();
|
|
62
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
63
|
+
let isIdle: (() => boolean) | undefined;
|
|
64
|
+
const check = () => {
|
|
65
|
+
const request = readSteerRequest(path);
|
|
66
|
+
if (!request || delivered.has(request.id)) return;
|
|
67
|
+
try { if (isIdle?.() === true) return; } catch { return; }
|
|
68
|
+
delivered.add(request.id);
|
|
69
|
+
try {
|
|
70
|
+
queued.set(request.text, request.id);
|
|
71
|
+
pi.sendUserMessage(request.text, { deliverAs: "steer" });
|
|
72
|
+
} catch {
|
|
73
|
+
queued.delete(request.text);
|
|
74
|
+
delivered.delete(request.id);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
pi.on("message_end", (event) => {
|
|
78
|
+
const message = event?.message;
|
|
79
|
+
if (message?.role !== "user" || queued.size === 0) return;
|
|
80
|
+
const content = message.content;
|
|
81
|
+
const text = typeof content === "string" ? content
|
|
82
|
+
: Array.isArray(content) ? content.filter((part: any) => part?.type === "text").map((part: any) => part.text).join("\n") : "";
|
|
83
|
+
const id = queued.get(text);
|
|
84
|
+
if (id === undefined) return;
|
|
85
|
+
queued.delete(text);
|
|
86
|
+
try {
|
|
87
|
+
const receipt = steerReceiptPath(path);
|
|
88
|
+
writeFileSync(`${receipt}.tmp`, JSON.stringify({ id, at: Date.now() }));
|
|
89
|
+
renameSync(`${receipt}.tmp`, receipt);
|
|
90
|
+
} catch { /* the parent falls back to grace from the request */ }
|
|
91
|
+
});
|
|
92
|
+
pi.on("session_start", (_event, ctx) => {
|
|
93
|
+
isIdle = typeof ctx?.isIdle === "function" ? () => ctx.isIdle!() : undefined;
|
|
94
|
+
if (timer) clearInterval(timer);
|
|
95
|
+
timer = setInterval(check, POLL_MS);
|
|
96
|
+
timer.unref?.();
|
|
97
|
+
});
|
|
98
|
+
pi.on("session_shutdown", () => {
|
|
99
|
+
if (timer) clearInterval(timer);
|
|
100
|
+
timer = undefined;
|
|
101
|
+
});
|
|
102
|
+
}
|
package/cleanup.ts
CHANGED
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
baseDir,
|
|
13
13
|
listMetas,
|
|
14
14
|
ownedByThisParent,
|
|
15
|
+
orphanedTaskRuntimeProvenance,
|
|
15
16
|
readMeta,
|
|
16
17
|
removeMetaArtifacts,
|
|
18
|
+
removeRunDirectory,
|
|
19
|
+
removeTaskRuntimeProvenance,
|
|
17
20
|
runDir,
|
|
18
21
|
sessionsDir,
|
|
19
22
|
type RunMeta,
|
|
@@ -166,6 +169,17 @@ function safeStatMtime(path: string): number | undefined {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
/** A metadata-less run directory, with its provenance record (#325). */
|
|
173
|
+
function removeRun(id: string, errors: string[]): boolean {
|
|
174
|
+
try {
|
|
175
|
+
removeRunDirectory(id);
|
|
176
|
+
return true;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
errors.push(`${runDir(id)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
function removePath(path: string, errors: string[]): boolean {
|
|
170
184
|
try {
|
|
171
185
|
rmSync(path, { recursive: true, force: true });
|
|
@@ -197,8 +211,10 @@ function cleanRunDirs(cutoff: number, errors: string[]): number {
|
|
|
197
211
|
}
|
|
198
212
|
|
|
199
213
|
const mtimeMs = safeStatMtime(dir);
|
|
200
|
-
if (mtimeMs !== undefined && mtimeMs < cutoff &&
|
|
214
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removeRun(id, errors)) removed += 1;
|
|
201
215
|
}
|
|
216
|
+
// Provenance whose run is already gone (#325).
|
|
217
|
+
for (const id of orphanedTaskRuntimeProvenance()) removeTaskRuntimeProvenance(id);
|
|
202
218
|
return removed;
|
|
203
219
|
}
|
|
204
220
|
|
|
@@ -382,7 +398,7 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
|
|
|
382
398
|
const removed: string[] = [];
|
|
383
399
|
for (const id of plan.remove) {
|
|
384
400
|
const meta = readMeta(id);
|
|
385
|
-
if (meta ? removeMetaArtifacts(meta) :
|
|
401
|
+
if (meta ? removeMetaArtifacts(meta) : removeRun(id, errors)) removed.push(id);
|
|
386
402
|
else if (meta) errors.push(`${runDir(id)}: could not remove run artifacts`);
|
|
387
403
|
}
|
|
388
404
|
|
package/config.json
CHANGED
package/config.ts
CHANGED
|
@@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { SELF_SPEC } from "./extensions.ts";
|
|
16
|
+
import type { DelegationMode } from "./delegation.ts";
|
|
17
|
+
import type { TimingSettings } from "./timing.ts";
|
|
16
18
|
|
|
17
|
-
export interface SubagentConfig {
|
|
19
|
+
export interface SubagentConfig extends TimingSettings {
|
|
20
|
+
/** Foreground delegation policy; /subagents mode overrides this for the current session. */
|
|
21
|
+
delegationMode?: DelegationMode | null;
|
|
18
22
|
defaultModel?: string | null;
|
|
19
23
|
defaultTools?: string | null;
|
|
20
24
|
/** Max subagents allowed to run at once. */
|
package/delegation.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type DelegationMode = "manual" | "adaptive" | "coordinator";
|
|
2
|
+
|
|
3
|
+
export const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
|
|
4
|
+
|
|
5
|
+
export function isDelegationMode(value: unknown): value is DelegationMode {
|
|
6
|
+
return value === "manual" || value === "adaptive" || value === "coordinator";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeDelegationMode(value: unknown): DelegationMode {
|
|
10
|
+
return isDelegationMode(value) ? value : "adaptive";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function delegationPrompt(mode: DelegationMode): string {
|
|
14
|
+
switch (mode) {
|
|
15
|
+
case "manual":
|
|
16
|
+
return "Delegation mode: manual. Do not proactively delegate work. Use subagents only when the user explicitly asks for delegation or an active workflow explicitly requires it. A structured plan or plan mode does not override this restriction; perform planned work in the foreground unless explicitly required otherwise.";
|
|
17
|
+
case "coordinator":
|
|
18
|
+
return "Delegation mode: coordinator. The foreground coordinates, integrates, and verifies. Call agents_catalog to discover current available roles and inspect their descriptions. Delegate every nontrivial task covered by an available role according to its current description; do not assume bundled roles are unchanged. Keep orchestration, cross-role decisions, unowned or ambiguous work, integration, and final verification in the foreground. Inspect results and failures before concluding; do not poll for running children.";
|
|
19
|
+
case "adaptive":
|
|
20
|
+
return "Delegation mode: adaptive. Delegate bounded, substantial independent work when a suitable subagent is available and useful; continue unblocked foreground work. Keep small, tightly coupled, or interactive work in the foreground. Inspect and integrate delegated results before final verification; do not poll.";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -6,6 +6,8 @@ Commands, the read-only discovery tool, and the Codex importer for `pi-agent/v1`
|
|
|
6
6
|
|
|
7
7
|
`/agents` is one Pi command. Dialogs use `ctx.ui.select`, `confirm`, `input`, and `editor`. `ctx.hasUI` is false in print and JSON mode. When a choice or confirmation is required there, the command returns `clarification-needed` and writes nothing.
|
|
8
8
|
|
|
9
|
+
In an interactive TUI, `/agents list` and `/agents show` open a focused catalog view instead of leaving the listing in a persistent widget. Rows align name, effective model, source, and launch state; narrow terminals retain name and model. The model is the resolved actual model when available, otherwise the requested or inherited preference, or `not resolved`. Type to filter by id, name, description, or base role; use Up/Down and Enter to inspect. The detail view puts launchability and its reason first, then source, inherited/overridden settings, and diagnostics. Press `p` in detail to read the complete effective instructions (role plus agent in add mode, agent text in replace mode); this is not the task-specific launch prompt. Up/Down scrolls detail or prompt; Left or Escape returns to detail, then list, clears the filter, then closes. `/agents show <id>` opens that definition directly. Print/JSON callers still receive the complete text and structured result.
|
|
10
|
+
|
|
9
11
|
| Invocation | Effect |
|
|
10
12
|
| --- | --- |
|
|
11
13
|
| `/agents list` | Fresh read of project, personal, and bundled definitions. One bad file does not hide the others. |
|
package/docs/agent-catalog.md
CHANGED
|
@@ -105,6 +105,15 @@ Model availability, same-tier fallback, and nearest-effort adjustment are not de
|
|
|
105
105
|
| `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
|
|
106
106
|
| `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
|
|
107
107
|
|
|
108
|
+
Bundled descriptions are routing boundaries for coordinator mode. Researcher owns
|
|
109
|
+
external and documentary evidence; Explorer maps repository code paths; Product
|
|
110
|
+
Manager owns user-visible requirements; Architect owns technical boundaries;
|
|
111
|
+
Developer owns implementation and focused tests; Reviewer owns independent change
|
|
112
|
+
review. Each role's instruction body names excluded work and the neighboring role
|
|
113
|
+
that owns it. Project and personal definitions may replace these bundled roles, so
|
|
114
|
+
coordinators inspect the current effective descriptions rather than assuming this
|
|
115
|
+
table is unchanged.
|
|
116
|
+
|
|
108
117
|
The product table's short model names are the model ids. The provider prefix is `openai`, matching explicit `provider/model` ids already used at launch. No cross-provider substitute is implied. Tier candidate lists are not invented here.
|
|
109
118
|
|
|
110
119
|
## Parser
|