pi-better-subagents 0.10.0 → 0.12.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 +9 -3
- package/agent-commands.ts +1 -1
- package/config.ts +57 -5
- package/delegation.ts +2 -0
- package/docs/usage.md +42 -10
- package/health-surface.mjs +1 -0
- package/index.ts +165 -30
- package/navigator.mjs +3 -15
- package/package.json +1 -1
- package/settings-page.ts +128 -0
- package/shared-navigator.ts +22 -17
- package/shared-task-sandbox.ts +43 -32
package/README.md
CHANGED
|
@@ -36,9 +36,15 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
|
|
|
36
36
|
## Delegation Modes
|
|
37
37
|
|
|
38
38
|
`config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
|
|
39
|
-
`coordinator`. `/subagents`
|
|
40
|
-
|
|
41
|
-
session
|
|
39
|
+
`coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
|
|
40
|
+
for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
|
|
41
|
+
this session; Save as defaults persists both, and Reset to defaults clears
|
|
42
|
+
session overrides. `/subagents mode manual|adaptive|coordinator` and
|
|
43
|
+
`/subagents cap <number>` are command equivalents; `/subagents save` persists
|
|
44
|
+
both. Lowering the cap blocks new single and batch launches without stopping
|
|
45
|
+
running work. The navigator's `main` row retains its mode-only controls:
|
|
46
|
+
`m` cycles the session mode, `ctrl+s` saves that default, and `x`
|
|
47
|
+
does not stop it. Manual delegates only on explicit user or workflow request, even with a
|
|
42
48
|
plan. Adaptive delegates substantial independent work when useful. Coordinator
|
|
43
49
|
uses `agents_catalog` to discover current role descriptions and delegates every
|
|
44
50
|
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
package/agent-commands.ts
CHANGED
|
@@ -123,7 +123,7 @@ interface ParsedArgs {
|
|
|
123
123
|
|
|
124
124
|
export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">, deps: AgentCommandDeps = {}): void {
|
|
125
125
|
pi.registerCommand("agents", {
|
|
126
|
-
description: "List, inspect, create, reload, or import
|
|
126
|
+
description: "[list|show|create|reload|import-codex] — List, inspect, create, reload, or import agent definitions",
|
|
127
127
|
getArgumentCompletions(prefix: string) {
|
|
128
128
|
const commands = ["list", "inspect", "show", "create", "reload", "import-codex", "help"];
|
|
129
129
|
const first = prefix.trim().split(/\s+/)[0] ?? "";
|
package/config.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* `defaultTools` absent → the built-in SAFE_DEFAULT_TOOLS.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
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
|
|
16
|
+
import { isDelegationMode, type DelegationMode } from "./delegation.ts";
|
|
17
17
|
import type { TimingSettings } from "./timing.ts";
|
|
18
18
|
|
|
19
19
|
export interface SubagentConfig extends TimingSettings {
|
|
@@ -21,7 +21,7 @@ export interface SubagentConfig extends TimingSettings {
|
|
|
21
21
|
delegationMode?: DelegationMode | null;
|
|
22
22
|
defaultModel?: string | null;
|
|
23
23
|
defaultTools?: string | null;
|
|
24
|
-
/**
|
|
24
|
+
/** Default concurrency cap; /subagents cap overrides it for the current session. */
|
|
25
25
|
maxConcurrent?: number | null;
|
|
26
26
|
/**
|
|
27
27
|
* Tool name → extension package(s) that provide it. Drives which extension
|
|
@@ -74,30 +74,82 @@ export interface SubagentConfig extends TimingSettings {
|
|
|
74
74
|
/** Concurrency cap when config.json sets none. */
|
|
75
75
|
export const DEFAULT_MAX_CONCURRENT = 4;
|
|
76
76
|
|
|
77
|
+
export function isConcurrencyCap(value: unknown): value is number {
|
|
78
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeConcurrencyCap(value: unknown): number {
|
|
82
|
+
return isConcurrencyCap(value) ? value : DEFAULT_MAX_CONCURRENT;
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
/** Built-in default tool set when config.json sets nothing. */
|
|
78
86
|
export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch";
|
|
79
87
|
/** Safe default for a hermetic (clean) child where extension tools don't exist. */
|
|
80
88
|
export const SAFE_CLEAN_TOOLS = "read, bash";
|
|
81
89
|
|
|
82
90
|
let cached: SubagentConfig | undefined;
|
|
91
|
+
let configPathForTests: string | undefined;
|
|
83
92
|
|
|
84
93
|
/** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
|
|
85
94
|
export function setConfigForTests(next: SubagentConfig | undefined): void {
|
|
86
95
|
cached = next;
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/** Test seam. Save and load use this file instead of the package config.json. */
|
|
99
|
+
export function setConfigPathForTests(path: string | undefined): void {
|
|
100
|
+
configPathForTests = path;
|
|
101
|
+
cached = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function configPath(): string {
|
|
105
|
+
return configPathForTests ?? join(dirname(fileURLToPath(import.meta.url)), "config.json");
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
/** Load config.json from the extension directory. Missing/invalid → {}. */
|
|
90
109
|
export function loadConfig(): SubagentConfig {
|
|
91
110
|
if (cached) return cached;
|
|
92
111
|
try {
|
|
93
|
-
|
|
94
|
-
cached = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")) as SubagentConfig;
|
|
112
|
+
cached = JSON.parse(readFileSync(configPath(), "utf-8")) as SubagentConfig;
|
|
95
113
|
} catch {
|
|
96
114
|
cached = {};
|
|
97
115
|
}
|
|
98
116
|
return cached;
|
|
99
117
|
}
|
|
100
118
|
|
|
119
|
+
/** Write delegationMode into config.json, preserving every other key. */
|
|
120
|
+
export function writeDelegationMode(mode: DelegationMode, path = configPath()): void {
|
|
121
|
+
if (!isDelegationMode(mode)) throw new Error(`Invalid delegation mode: ${String(mode)}`);
|
|
122
|
+
let current: SubagentConfig = {};
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
125
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) current = parsed as SubagentConfig;
|
|
126
|
+
} catch { /* missing file starts as an empty config */ }
|
|
127
|
+
current.delegationMode = mode;
|
|
128
|
+
const pending = `${path}.${process.pid}.tmp`;
|
|
129
|
+
writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
|
|
130
|
+
renameSync(pending, path);
|
|
131
|
+
if (path === configPath()) cached = current;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Save human-facing defaults together, preserving unrelated configuration. */
|
|
135
|
+
export function writeSubagentSettings(settings: { delegationMode: DelegationMode; maxConcurrent: number }, path = configPath()): void {
|
|
136
|
+
if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
|
|
137
|
+
if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
|
|
138
|
+
let current: Record<string, unknown> = {};
|
|
139
|
+
try {
|
|
140
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
141
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Config must be a JSON object.");
|
|
142
|
+
current = parsed as Record<string, unknown>;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
145
|
+
}
|
|
146
|
+
Object.assign(current, settings);
|
|
147
|
+
const pending = `${path}.${process.pid}.tmp`;
|
|
148
|
+
writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
|
|
149
|
+
renameSync(pending, path);
|
|
150
|
+
if (path === configPath()) cached = current as SubagentConfig;
|
|
151
|
+
}
|
|
152
|
+
|
|
101
153
|
/** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
|
|
102
154
|
export function normalizeTools(list: string): string {
|
|
103
155
|
return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
|
package/delegation.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type DelegationMode = "manual" | "adaptive" | "coordinator";
|
|
2
2
|
|
|
3
|
+
export const DELEGATION_MODES = ["manual", "adaptive", "coordinator"] as const;
|
|
4
|
+
|
|
3
5
|
export const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
|
|
4
6
|
|
|
5
7
|
export function isDelegationMode(value: unknown): value is DelegationMode {
|
package/docs/usage.md
CHANGED
|
@@ -16,11 +16,33 @@ launch is the result · completion triggers fetch · the foreground never blocks
|
|
|
16
16
|
|
|
17
17
|
Set `delegationMode` in this package's `config.json` to `manual`, `adaptive`, or
|
|
18
18
|
`coordinator` (default `adaptive`). This controls foreground delegation guidance,
|
|
19
|
-
not child permissions or catalog definitions. `/subagents`
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
not child permissions or catalog definitions. `/subagents settings` (or simply
|
|
20
|
+
`/subagents`) opens the terminal settings page with mode and concurrent-subagent
|
|
21
|
+
cap controls, saved defaults, Save as defaults, and Reset to defaults. Changes
|
|
22
|
+
apply immediately to this session; Escape closes the page without undoing them.
|
|
23
|
+
Enter edits the cap as a positive whole number; Escape cancels an unfinished edit.
|
|
24
|
+
Ctrl+S saves both settings as defaults. Sandbox permissions remain in `/sandbox`.
|
|
25
|
+
|
|
26
|
+
Command equivalents also work outside the terminal UI:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
/subagents mode manual|adaptive|coordinator
|
|
30
|
+
/subagents cap 6
|
|
31
|
+
/subagents save
|
|
32
|
+
/subagents reset
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The cap defaults to 4 and applies to both single and batch launches. Lowering it
|
|
36
|
+
below the running count leaves existing work untouched and blocks new admissions
|
|
37
|
+
until capacity becomes available. Slots already reserved by an in-flight launch
|
|
38
|
+
remain admitted. Settings apply to this parent Pi process, not
|
|
39
|
+
all Pi sessions on the machine. Saving preserves unrelated config keys and
|
|
40
|
+
clears both session overrides. The command form asks for confirmation when a UI
|
|
41
|
+
is available. Reset restores saved defaults without changing config.
|
|
42
|
+
|
|
43
|
+
`/reload` retains session overrides; new sessions use config, while resuming or
|
|
44
|
+
navigating a branch restores its settings. On the navigator `main` sheet, `m`
|
|
45
|
+
cycles the session mode and `ctrl+s` saves only that mode default.
|
|
24
46
|
|
|
25
47
|
- **Manual:** no proactive delegation, including in plan mode. A user request or
|
|
26
48
|
explicit workflow requirement may still delegate.
|
|
@@ -380,8 +402,12 @@ top.
|
|
|
380
402
|
foreground model).
|
|
381
403
|
- `delegationMode` — foreground policy (`manual`, `adaptive`, or `coordinator`;
|
|
382
404
|
default `adaptive`). `/subagents mode ...` changes only the current session.
|
|
383
|
-
|
|
384
|
-
|
|
405
|
+
`/subagents save` saves mode and cap together as config defaults.
|
|
406
|
+
- `maxConcurrent` — positive whole-number concurrency cap (**default 4**).
|
|
407
|
+
`/subagents cap <number>` overrides it for this session. Both single and batch
|
|
408
|
+
admissions respect the cap; existing runs are never stopped by a cap change.
|
|
409
|
+
Use `/subagents settings` to edit both controls or `/subagents reset` to return
|
|
410
|
+
to saved defaults.
|
|
385
411
|
|
|
386
412
|
## The allowlist also decides what LOADS
|
|
387
413
|
|
|
@@ -517,9 +543,11 @@ is unchanged in every mode.
|
|
|
517
543
|
- While the main-window list is focused, the title hint changes to
|
|
518
544
|
`Enter to view · x to stop`; the selected row is marked with `›`. Press
|
|
519
545
|
`↓` from the bottom row to return to the input line.
|
|
520
|
-
- The Subagents lane pins
|
|
521
|
-
|
|
522
|
-
|
|
546
|
+
- The Subagents lane pins a `main` row above child runs. It shows the
|
|
547
|
+
foreground model, effort, active tool, context tokens, elapsed time, and
|
|
548
|
+
delegation mode. Enter opens its detail sheet. `m` cycles the session mode.
|
|
549
|
+
`ctrl+s` saves that mode as the config default. `x` still stops
|
|
550
|
+
a child run; it never stops main.
|
|
523
551
|
- `↑` moves to the previous row when multiple running rows are shown. `↓`
|
|
524
552
|
moves toward the input line, returning to normal input from the bottom row.
|
|
525
553
|
- `Enter` opens the selected run's live detail view. `x` stops the selected
|
|
@@ -637,6 +665,10 @@ health model:
|
|
|
637
665
|
orphaned, lost, and degraded running runs; #65 orphaned/lost result bodies kept.
|
|
638
666
|
- **Passive live widget** — healthy/quiet unchanged; degraded (and orphaned) may
|
|
639
667
|
show a short suffix. Still `setWidget` only — never focusable.
|
|
668
|
+
- **Navigator** — lifecycle status and operational health facts remain visible;
|
|
669
|
+
child tool/model incident summaries and history are omitted from rows and detail
|
|
670
|
+
chrome. Tool labels show just their name (for example, `bash`). The original
|
|
671
|
+
transcript, including error results, and transcript-read diagnostics remain available.
|
|
640
672
|
- **`callback:false`** suppresses coordinator follow-up only; human `ui.notify` and
|
|
641
673
|
TUI/passive visibility remain.
|
|
642
674
|
|
package/health-surface.mjs
CHANGED
|
@@ -162,6 +162,7 @@ export function formatNavigatorHealthFacts(obs) {
|
|
|
162
162
|
if (!obs || !isActionableHealth(obs)) return [];
|
|
163
163
|
const facts = [];
|
|
164
164
|
for (const f of surfaceableCompactFacts(obs)) {
|
|
165
|
+
if (obs.model?.state === "error" && f === obs.model.listWarning) continue;
|
|
165
166
|
if (f && f !== obs.status && !facts.includes(f)) facts.push(f);
|
|
166
167
|
}
|
|
167
168
|
if (obs.activity === "stale" && !facts.some((f) => /\bstale\b/i.test(f))) {
|
package/index.ts
CHANGED
|
@@ -35,7 +35,8 @@ import {
|
|
|
35
35
|
import { spawnDetached, type SpawnResult } from "./spawn.ts";
|
|
36
36
|
import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./parse.ts";
|
|
37
37
|
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
38
|
-
import { loadConfig, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS
|
|
38
|
+
import { loadConfig, writeDelegationMode, writeSubagentSettings, isConcurrencyCap, normalizeConcurrencyCap, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS } from "./config.ts";
|
|
39
|
+
import { createSubagentSettingsPage } from "./settings-page.ts";
|
|
39
40
|
import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
|
|
40
41
|
import {
|
|
41
42
|
decideTiming,
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
type TimingStopReason,
|
|
53
54
|
} from "./timing.ts";
|
|
54
55
|
import { readAppendedLines, type LogCursor } from "./log-cursor.ts";
|
|
55
|
-
import { DELEGATION_MODE_REQUEST, delegationPrompt, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
|
|
56
|
+
import { DELEGATION_MODES, DELEGATION_MODE_REQUEST, delegationPrompt, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
|
|
56
57
|
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
57
58
|
import { prepareTaskRuntime } from "./task-policy.ts";
|
|
58
59
|
import { canonicalizePath, takeRecoverySnapshot } from "./shared-sandbox-core.ts";
|
|
@@ -117,7 +118,7 @@ import {
|
|
|
117
118
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
118
119
|
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
119
120
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
120
|
-
import { collectRunFailures, failurePath,
|
|
121
|
+
import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
|
|
121
122
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
122
123
|
import {
|
|
123
124
|
text,
|
|
@@ -162,9 +163,13 @@ import {
|
|
|
162
163
|
import { defaultUserRoot, type CatalogSnapshot } from "./catalog-store.ts";
|
|
163
164
|
|
|
164
165
|
const SUBAGENT_MODE_ACTIONS: readonly AutocompleteItem[] = [
|
|
166
|
+
{ value: "settings", label: "settings", description: "Open delegation mode and concurrency settings" },
|
|
167
|
+
{ value: "cap", label: "cap <number>", description: "Set a positive concurrent subagent limit for this session" },
|
|
168
|
+
{ value: "reset", label: "reset", description: "Reset session mode and cap to saved defaults" },
|
|
165
169
|
{ value: "mode manual", label: "mode manual", description: "Delegate only when explicitly requested" },
|
|
166
170
|
{ value: "mode adaptive", label: "mode adaptive", description: "Delegate substantial independent work" },
|
|
167
171
|
{ value: "mode coordinator", label: "mode coordinator", description: "Delegate role-owned work by default" },
|
|
172
|
+
{ value: "save", label: "save", description: "Save the current mode and cap as defaults" },
|
|
168
173
|
];
|
|
169
174
|
|
|
170
175
|
export function subagentsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
@@ -1149,9 +1154,6 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
1149
1154
|
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
1150
1155
|
if (row.tool) bits.push(row.tool);
|
|
1151
1156
|
if (row.spend) bits.push(row.spend);
|
|
1152
|
-
const failure = failureView(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
|
|
1153
|
-
// Only a failure that needs action replaces the row's columns; history stays in the detail view.
|
|
1154
|
-
const firstFailure = failure.actionable ? failure.text.split("\n")[0] || "" : "";
|
|
1155
1157
|
return {
|
|
1156
1158
|
providerId: "subagents",
|
|
1157
1159
|
id: row.id,
|
|
@@ -1164,9 +1166,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
1164
1166
|
statusTone: statusTone(row.status),
|
|
1165
1167
|
kind: "subagent",
|
|
1166
1168
|
elapsed: row.elapsed,
|
|
1167
|
-
primary:
|
|
1168
|
-
|
|
1169
|
-
facts: [firstFailure, ...row.healthFacts].filter(Boolean).slice(0, 2),
|
|
1169
|
+
primary: bits.join(" · ") || "subagent run",
|
|
1170
|
+
facts: row.healthFacts,
|
|
1170
1171
|
sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
|
|
1171
1172
|
expiresAt: (() => {
|
|
1172
1173
|
const meta = metaById.get(row.id);
|
|
@@ -1179,6 +1180,15 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
1179
1180
|
});
|
|
1180
1181
|
}
|
|
1181
1182
|
|
|
1183
|
+
const DELEGATION_BLURB: Record<DelegationMode, string> = {
|
|
1184
|
+
manual: "only when you or a workflow asks",
|
|
1185
|
+
adaptive: "substantial independent work",
|
|
1186
|
+
coordinator: "every role-owned task",
|
|
1187
|
+
};
|
|
1188
|
+
let readDelegation = (): { mode: DelegationMode; source: "session" | "config" } => ({ mode: "adaptive", source: "config" });
|
|
1189
|
+
let cycleDelegation = (): void => {};
|
|
1190
|
+
let saveDelegationDefault: (mode: DelegationMode) => { ok: boolean; message: string } = () => ({ ok: false, message: "Saving the delegation default is unavailable." });
|
|
1191
|
+
|
|
1182
1192
|
function mainAgentWorkRow(now: number): BackgroundWorkRow {
|
|
1183
1193
|
let running = mainAgentStartedAt !== undefined;
|
|
1184
1194
|
try { running ||= uiCtx?.isIdle() === false; } catch { /* use event state */ }
|
|
@@ -1188,10 +1198,12 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
|
|
|
1188
1198
|
let contextTokens: number | null | undefined;
|
|
1189
1199
|
try { contextTokens = uiCtx?.getContextUsage()?.tokens; } catch { contextTokens = undefined; }
|
|
1190
1200
|
const tokens = typeof contextTokens === "number" ? `${fmtTokens(contextTokens)} tok` : undefined;
|
|
1201
|
+
const delegation = readDelegation();
|
|
1191
1202
|
const bits = [
|
|
1192
1203
|
effort ? `${model} ${effort}` : model,
|
|
1193
|
-
tool
|
|
1204
|
+
tool,
|
|
1194
1205
|
tokens,
|
|
1206
|
+
delegation.source === "session" ? `${delegation.mode} · session` : delegation.mode,
|
|
1195
1207
|
].filter((bit): bit is string => Boolean(bit));
|
|
1196
1208
|
return {
|
|
1197
1209
|
providerId: "subagents",
|
|
@@ -1210,15 +1222,43 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
|
|
|
1210
1222
|
};
|
|
1211
1223
|
}
|
|
1212
1224
|
|
|
1225
|
+
function mainAgentWorkDetail(now: number): BackgroundWorkDetail {
|
|
1226
|
+
const row = mainAgentWorkRow(now);
|
|
1227
|
+
const delegation = readDelegation();
|
|
1228
|
+
return {
|
|
1229
|
+
providerId: "subagents",
|
|
1230
|
+
id: "main",
|
|
1231
|
+
title: "main",
|
|
1232
|
+
status: row.status,
|
|
1233
|
+
statusTone: row.statusTone,
|
|
1234
|
+
subtitle: row.primary,
|
|
1235
|
+
metadata: [
|
|
1236
|
+
{ label: "kind", value: "main agent" },
|
|
1237
|
+
{ label: "model", value: row.effort ? `${row.model} · effort ${row.effort}` : (row.model || "-") },
|
|
1238
|
+
{ label: "tool", value: row.tool || "-" },
|
|
1239
|
+
{ label: "context", value: row.tokens || "-" },
|
|
1240
|
+
{ label: "elapsed", value: row.elapsed },
|
|
1241
|
+
{ label: "mode", value: `${delegation.mode} · ${delegation.source}` },
|
|
1242
|
+
],
|
|
1243
|
+
evidence: {
|
|
1244
|
+
label: "delegation",
|
|
1245
|
+
text: [
|
|
1246
|
+
`${delegation.mode}: ${DELEGATION_BLURB[delegation.mode]}.`,
|
|
1247
|
+
"m cycles this session. ctrl+s saves that mode as the next session's default.",
|
|
1248
|
+
"x does not stop the main agent.",
|
|
1249
|
+
].filter(Boolean).join(" "),
|
|
1250
|
+
},
|
|
1251
|
+
footerActions: ["m mode", "ctrl+s save"],
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1213
1255
|
function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
1256
|
+
if (id === "main") return mainAgentWorkDetail(now);
|
|
1214
1257
|
const detail = navigatorDetail(id, now);
|
|
1215
1258
|
if (!detail) return null;
|
|
1216
1259
|
void options;
|
|
1217
1260
|
const transcript = readRunTranscript(id);
|
|
1218
|
-
const view = failureView(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
|
|
1219
|
-
const failure = view.text;
|
|
1220
1261
|
const metadata = [
|
|
1221
|
-
...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
|
|
1222
1262
|
{ label: "provider", value: "Subagents" },
|
|
1223
1263
|
{ label: "id", value: detail.id },
|
|
1224
1264
|
...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
|
|
@@ -1235,11 +1275,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
1235
1275
|
title: detail.name || detail.id,
|
|
1236
1276
|
status: detail.status,
|
|
1237
1277
|
statusTone: statusTone(detail.status),
|
|
1238
|
-
subtitle:
|
|
1278
|
+
subtitle: detail.currentTool || undefined,
|
|
1239
1279
|
metadata,
|
|
1240
|
-
evidence: { label: "transcript", text:
|
|
1280
|
+
evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
|
|
1241
1281
|
transcript: transcript.entries,
|
|
1242
|
-
transcriptDiagnostic:
|
|
1282
|
+
transcriptDiagnostic: transcript.diagnostic,
|
|
1243
1283
|
footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
|
|
1244
1284
|
};
|
|
1245
1285
|
}
|
|
@@ -1307,6 +1347,16 @@ function ensureSubagentProvider(): void {
|
|
|
1307
1347
|
parentRow: (now) => mainAgentWorkRow(now),
|
|
1308
1348
|
listRows: (now) => subagentWorkRows(now),
|
|
1309
1349
|
detail: (id, now, options) => subagentWorkDetail(id, now, options),
|
|
1350
|
+
handleDetailInput: (id, data) => {
|
|
1351
|
+
if (id !== "main") return false;
|
|
1352
|
+
if (data === "m") {
|
|
1353
|
+
cycleDelegation();
|
|
1354
|
+
return true;
|
|
1355
|
+
}
|
|
1356
|
+
if (data !== "ctrl+s") return false;
|
|
1357
|
+
saveDelegationDefault(readDelegation().mode);
|
|
1358
|
+
return true;
|
|
1359
|
+
},
|
|
1310
1360
|
armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
|
|
1311
1361
|
close: (id) => {
|
|
1312
1362
|
const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
|
|
@@ -1449,15 +1499,70 @@ function catalogRoleSchema(purpose: string) {
|
|
|
1449
1499
|
|
|
1450
1500
|
export default function (pi: ExtensionAPI) {
|
|
1451
1501
|
let delegationOverride: DelegationMode | undefined;
|
|
1502
|
+
let capOverride: number | undefined;
|
|
1503
|
+
const activeConcurrencyCap = (): number => capOverride ?? normalizeConcurrencyCap(loadConfig().maxConcurrent);
|
|
1504
|
+
const changeCap = (cap: number): void => {
|
|
1505
|
+
if (!isConcurrencyCap(cap)) throw new Error("Concurrent subagents must be a positive whole number.");
|
|
1506
|
+
capOverride = cap;
|
|
1507
|
+
pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: cap });
|
|
1508
|
+
};
|
|
1509
|
+
const changeMode = (mode: DelegationMode): void => {
|
|
1510
|
+
delegationOverride = mode;
|
|
1511
|
+
pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode });
|
|
1512
|
+
};
|
|
1513
|
+
const resetSettings = (): void => {
|
|
1514
|
+
capOverride = undefined;
|
|
1515
|
+
delegationOverride = undefined;
|
|
1516
|
+
pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: null });
|
|
1517
|
+
pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
|
|
1518
|
+
};
|
|
1452
1519
|
const activeDelegationMode = (): DelegationMode => delegationOverride ?? normalizeDelegationMode(loadConfig().delegationMode);
|
|
1520
|
+
const saveSettings = (): { ok: boolean; message: string } => {
|
|
1521
|
+
try {
|
|
1522
|
+
writeSubagentSettings({ delegationMode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap() });
|
|
1523
|
+
resetSettings();
|
|
1524
|
+
return { ok: true, message: "Subagent defaults saved." };
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
return { ok: false, message: `Could not save subagent defaults: ${error instanceof Error ? error.message : String(error)}` };
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
readDelegation = () => ({
|
|
1530
|
+
mode: activeDelegationMode(),
|
|
1531
|
+
source: delegationOverride ? "session" : "config",
|
|
1532
|
+
});
|
|
1533
|
+
cycleDelegation = () => {
|
|
1534
|
+
const current = activeDelegationMode();
|
|
1535
|
+
const next = DELEGATION_MODES[(DELEGATION_MODES.indexOf(current) + 1) % DELEGATION_MODES.length]!;
|
|
1536
|
+
changeMode(next);
|
|
1537
|
+
};
|
|
1538
|
+
saveDelegationDefault = (mode) => {
|
|
1539
|
+
try {
|
|
1540
|
+
writeDelegationMode(mode);
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
return { ok: false, message: `Could not save the delegation default: ${error instanceof Error ? error.message : String(error)}` };
|
|
1543
|
+
}
|
|
1544
|
+
delegationOverride = undefined;
|
|
1545
|
+
pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
|
|
1546
|
+
return { ok: true, message: `Delegation default saved: ${mode}.` };
|
|
1547
|
+
};
|
|
1453
1548
|
const restoreDelegationMode = (ctx: ExtensionContext): void => {
|
|
1454
1549
|
delegationOverride = undefined;
|
|
1550
|
+
capOverride = undefined;
|
|
1455
1551
|
const branch = ctx.sessionManager?.getBranch?.();
|
|
1456
1552
|
if (!Array.isArray(branch)) return;
|
|
1457
1553
|
for (const entry of branch) {
|
|
1554
|
+
if (entry.type === "custom" && entry.customType === "pi-better-subagents-cap") {
|
|
1555
|
+
const data = entry.data as { version?: unknown; maxConcurrent?: unknown } | null;
|
|
1556
|
+
if (data?.version === 1) {
|
|
1557
|
+
if (data.maxConcurrent === null) capOverride = undefined;
|
|
1558
|
+
else if (isConcurrencyCap(data.maxConcurrent)) capOverride = data.maxConcurrent;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1458
1561
|
if (entry.type !== "custom" || entry.customType !== "pi-better-subagents-delegation") continue;
|
|
1459
1562
|
const data = entry.data as { version?: unknown; mode?: unknown } | null;
|
|
1460
|
-
if (data?.version
|
|
1563
|
+
if (data?.version !== 1) continue;
|
|
1564
|
+
if (data.mode === null) delegationOverride = undefined;
|
|
1565
|
+
else if (isDelegationMode(data.mode)) delegationOverride = data.mode;
|
|
1461
1566
|
}
|
|
1462
1567
|
};
|
|
1463
1568
|
const unsubscribeDelegationRequest = pi.events?.on?.(DELEGATION_MODE_REQUEST, (data: unknown) => {
|
|
@@ -1836,8 +1941,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1836
1941
|
const blankSelector = blankCatalogSelector(p);
|
|
1837
1942
|
if (blankSelector) throw new Error(blankSelector);
|
|
1838
1943
|
|
|
1839
|
-
const
|
|
1840
|
-
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
1944
|
+
const maxConcurrent = activeConcurrencyCap();
|
|
1841
1945
|
const countRunning = () =>
|
|
1842
1946
|
listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
|
|
1843
1947
|
// Shared with batch-spawn: reserve before any async work so an interleaved
|
|
@@ -1857,7 +1961,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1857
1961
|
}
|
|
1858
1962
|
if (catalog.jobs.length > 1) {
|
|
1859
1963
|
const extra = catalog.jobs.length - 1;
|
|
1860
|
-
if (extra > 0 && !gate.tryReserve(extra,
|
|
1964
|
+
if (extra > 0 && !gate.tryReserve(extra, activeConcurrencyCap())) {
|
|
1861
1965
|
gate.release(1);
|
|
1862
1966
|
reserved = 0;
|
|
1863
1967
|
throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
|
|
@@ -1975,7 +2079,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1975
2079
|
};
|
|
1976
2080
|
|
|
1977
2081
|
const cfg = loadConfig();
|
|
1978
|
-
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
1979
2082
|
const countRunning = () =>
|
|
1980
2083
|
listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
|
|
1981
2084
|
const launchAvailable = p.onCapacity === "launch-available";
|
|
@@ -2018,6 +2121,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2018
2121
|
// until each job commits (or the unused remainder is released) closes the
|
|
2019
2122
|
// interleaving oversubscribe class — a stale plan alone is not enough.
|
|
2020
2123
|
if (!launchAvailable) {
|
|
2124
|
+
const maxConcurrent = activeConcurrencyCap();
|
|
2021
2125
|
// planBatchLaunches still produces the public error text (incl. pending).
|
|
2022
2126
|
planBatchLaunches({
|
|
2023
2127
|
jobs: p.jobs,
|
|
@@ -2053,7 +2157,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2053
2157
|
const merged = mergeJobOptions(p.shared, job);
|
|
2054
2158
|
|
|
2055
2159
|
if (launchAvailable) {
|
|
2056
|
-
if (!gate.tryReserve(1,
|
|
2160
|
+
if (!gate.tryReserve(1, activeConcurrencyCap())) {
|
|
2057
2161
|
for (let j = i; j < p.jobs.length; j++) {
|
|
2058
2162
|
skipped.push({ job: j + 1, name: names[j] });
|
|
2059
2163
|
}
|
|
@@ -2166,22 +2270,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
2166
2270
|
if (typeof pi.registerCommand === "function") {
|
|
2167
2271
|
agentOperations.registerCommands(pi);
|
|
2168
2272
|
pi.registerCommand("subagents", {
|
|
2169
|
-
description: "
|
|
2273
|
+
description: "[settings|mode manual|adaptive|coordinator|cap <number>|save|reset] — Subagent settings",
|
|
2170
2274
|
getArgumentCompletions: subagentsArgumentCompletions,
|
|
2171
2275
|
async handler(args, ctx) {
|
|
2172
2276
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
2173
|
-
if (tokens.length === 0) {
|
|
2174
|
-
ctx.ui.
|
|
2277
|
+
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "settings")) {
|
|
2278
|
+
if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
|
|
2279
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
|
|
2280
|
+
get: () => ({
|
|
2281
|
+
mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
|
|
2282
|
+
modeSource: delegationOverride ? "session" : "config",
|
|
2283
|
+
capSource: capOverride !== undefined ? "session" : "config",
|
|
2284
|
+
defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
|
|
2285
|
+
defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
|
|
2286
|
+
}),
|
|
2287
|
+
changeMode, changeCap, save: saveSettings, reset: resetSettings,
|
|
2288
|
+
}, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
|
|
2289
|
+
} else {
|
|
2290
|
+
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
|
|
2291
|
+
}
|
|
2175
2292
|
return;
|
|
2176
2293
|
}
|
|
2177
2294
|
const requested = tokens[1];
|
|
2178
|
-
if (tokens.length ===
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2295
|
+
if (tokens.length === 1 && tokens[0] === "save") {
|
|
2296
|
+
if (ctx.hasUI && !(await ctx.ui.confirm("Save subagent defaults?", `Future sessions will start in ${activeDelegationMode()} with a cap of ${activeConcurrencyCap()}.`))) {
|
|
2297
|
+
ctx.ui.notify("Subagent defaults unchanged.", "info");
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const saved = saveSettings();
|
|
2301
|
+
ctx.ui.notify(saved.message, saved.ok ? "info" : "warning");
|
|
2302
|
+
} else if (tokens.length === 1 && tokens[0] === "reset") {
|
|
2303
|
+
resetSettings();
|
|
2304
|
+
ctx.ui.notify("Session subagent settings reset to saved defaults.", "info");
|
|
2305
|
+
} else if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
|
|
2306
|
+
changeMode(requested);
|
|
2307
|
+
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()} (current session).`, "info");
|
|
2308
|
+
} else if (tokens.length === 2 && tokens[0] === "cap") {
|
|
2309
|
+
const cap = /^\d+$/.test(requested!) ? Number(requested) : NaN;
|
|
2310
|
+
if (!isConcurrencyCap(cap)) {
|
|
2311
|
+
ctx.ui.notify("Concurrent subagents must be a positive whole number. Nothing changed.", "warning");
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
changeCap(cap);
|
|
2315
|
+
ctx.ui.notify(`Concurrent subagents: ${cap} (current session). Running subagents are unchanged.`, "info");
|
|
2182
2316
|
} else {
|
|
2183
|
-
ctx.ui.notify("Usage: /subagents [mode manual|adaptive|coordinator]", "warning");
|
|
2317
|
+
ctx.ui.notify("Usage: /subagents [settings|mode manual|adaptive|coordinator|cap <number>|save|reset]", "warning");
|
|
2184
2318
|
}
|
|
2319
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
2185
2320
|
},
|
|
2186
2321
|
});
|
|
2187
2322
|
}
|
package/navigator.mjs
CHANGED
|
@@ -543,7 +543,6 @@ export function buildDetailLines(detail, opts = {}) {
|
|
|
543
543
|
const bits = [];
|
|
544
544
|
if (c.last.reason) bits.push(c.last.reason);
|
|
545
545
|
if (c.last.aborted) bits.push("aborted");
|
|
546
|
-
if (c.last.errorMessage) bits.push(c.last.errorMessage);
|
|
547
546
|
if (bits.length) lines.push(` last ${bits.join(" · ")}`);
|
|
548
547
|
}
|
|
549
548
|
} else {
|
|
@@ -566,28 +565,17 @@ export function buildDetailLines(detail, opts = {}) {
|
|
|
566
565
|
lines.push(" idle");
|
|
567
566
|
}
|
|
568
567
|
|
|
569
|
-
// Model
|
|
568
|
+
// Model activity only; incident summaries stay in result tools and logs.
|
|
570
569
|
lines.push(sectionRule("model", width));
|
|
571
570
|
if (h?.model) {
|
|
572
571
|
const m = h.model;
|
|
573
|
-
lines.push(` state ${m.state}`);
|
|
574
|
-
if (m.listWarning) lines.push(` warning ${m.listWarning}`);
|
|
572
|
+
if (m.state !== "error") lines.push(` state ${m.state}`);
|
|
573
|
+
if (m.state !== "error" && m.listWarning) lines.push(` warning ${m.listWarning}`);
|
|
575
574
|
if (m.retry) {
|
|
576
575
|
const a = m.retry.attempt != null ? String(m.retry.attempt) : "?";
|
|
577
576
|
const max = m.retry.maxAttempts != null ? String(m.retry.maxAttempts) : "?";
|
|
578
577
|
lines.push(` retry ${a}/${max}`);
|
|
579
578
|
}
|
|
580
|
-
if (m.lastError) {
|
|
581
|
-
const age = m.lastError.at != null ? ` · ${fmtDetailTs(m.lastError.at, now)}` : "";
|
|
582
|
-
lines.push(` last error ${m.lastError.message}${age}`);
|
|
583
|
-
}
|
|
584
|
-
if (Array.isArray(m.errorHistory) && m.errorHistory.length > 0) {
|
|
585
|
-
const recent = m.errorHistory.slice(-3);
|
|
586
|
-
for (const e of recent) {
|
|
587
|
-
const age = e.at != null ? ` · ${fmtDetailTs(e.at, now)}` : "";
|
|
588
|
-
lines.push(` history ${e.message}${age}`);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
579
|
if (m.longModelCall) {
|
|
592
580
|
const age = m.longModelCall.ageMs != null ? ` · ${fmtDetailAge(m.longModelCall.ageMs)}` : "";
|
|
593
581
|
lines.push(` long call${age}`);
|
package/package.json
CHANGED
package/settings-page.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as Tui from "@earendil-works/pi-tui";
|
|
3
|
+
import { isConcurrencyCap } from "./config.ts";
|
|
4
|
+
import { DELEGATION_MODES, type DelegationMode } from "./delegation.ts";
|
|
5
|
+
|
|
6
|
+
export interface SubagentSettingsSnapshot {
|
|
7
|
+
mode: DelegationMode;
|
|
8
|
+
maxConcurrent: number;
|
|
9
|
+
modeSource: "session" | "config";
|
|
10
|
+
capSource: "session" | "config";
|
|
11
|
+
defaultMode: DelegationMode;
|
|
12
|
+
defaultCap: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SubagentSettingsHandlers {
|
|
16
|
+
get(): SubagentSettingsSnapshot;
|
|
17
|
+
changeMode(mode: DelegationMode): void;
|
|
18
|
+
changeCap(cap: number): void;
|
|
19
|
+
save(): { ok: boolean; message: string };
|
|
20
|
+
reset(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const labels = ["Delegation mode", "Concurrent subagents", "Save as defaults", "Reset to defaults"];
|
|
24
|
+
|
|
25
|
+
export function createSubagentSettingsPage(
|
|
26
|
+
theme: Theme,
|
|
27
|
+
handlers: SubagentSettingsHandlers,
|
|
28
|
+
requestRender: () => void,
|
|
29
|
+
close: () => void,
|
|
30
|
+
): Tui.Component & Tui.Focusable {
|
|
31
|
+
const input = new Tui.Input();
|
|
32
|
+
let focused = false;
|
|
33
|
+
let row = 0;
|
|
34
|
+
let editing = false;
|
|
35
|
+
let message = "";
|
|
36
|
+
let error = false;
|
|
37
|
+
function report(text: string, failed = false): void {
|
|
38
|
+
message = text;
|
|
39
|
+
error = failed;
|
|
40
|
+
requestRender();
|
|
41
|
+
}
|
|
42
|
+
function action(): void {
|
|
43
|
+
try {
|
|
44
|
+
const settings = handlers.get();
|
|
45
|
+
if (row === 0) {
|
|
46
|
+
const next = DELEGATION_MODES[(DELEGATION_MODES.indexOf(settings.mode) + 1) % DELEGATION_MODES.length]!;
|
|
47
|
+
handlers.changeMode(next);
|
|
48
|
+
report("Session mode updated.");
|
|
49
|
+
} else if (row === 1) {
|
|
50
|
+
editing = true;
|
|
51
|
+
input.setValue(String(settings.maxConcurrent));
|
|
52
|
+
input.handleInput("\x05");
|
|
53
|
+
input.focused = focused;
|
|
54
|
+
report("");
|
|
55
|
+
} else if (row === 2) {
|
|
56
|
+
const result = handlers.save();
|
|
57
|
+
report(result.message, !result.ok);
|
|
58
|
+
} else {
|
|
59
|
+
handlers.reset();
|
|
60
|
+
report("Session settings reset to saved defaults.");
|
|
61
|
+
}
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
report(cause instanceof Error ? cause.message : String(cause), true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
input.onSubmit = (value) => {
|
|
67
|
+
const cap = /^\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
|
|
68
|
+
if (!isConcurrencyCap(cap)) {
|
|
69
|
+
report("Enter a positive whole number.", true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
handlers.changeCap(cap);
|
|
74
|
+
editing = false;
|
|
75
|
+
input.focused = false;
|
|
76
|
+
report("Session cap updated.");
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
report(cause instanceof Error ? cause.message : String(cause), true);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
input.onEscape = () => {
|
|
82
|
+
editing = false;
|
|
83
|
+
input.focused = false;
|
|
84
|
+
report("");
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
get focused() { return focused; },
|
|
88
|
+
set focused(value: boolean) { focused = value; input.focused = editing && value; },
|
|
89
|
+
invalidate() { input.invalidate(); },
|
|
90
|
+
handleInput(data: string) {
|
|
91
|
+
if (editing) {
|
|
92
|
+
input.handleInput(data);
|
|
93
|
+
requestRender();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (Tui.matchesKey(data, Tui.Key.escape)) close();
|
|
97
|
+
else if (Tui.matchesKey(data, Tui.Key.up)) row = Math.max(0, row - 1);
|
|
98
|
+
else if (Tui.matchesKey(data, Tui.Key.down)) row = Math.min(labels.length - 1, row + 1);
|
|
99
|
+
else if (Tui.matchesKey(data, Tui.Key.enter) || data === " ") action();
|
|
100
|
+
else if (Tui.matchesKey(data, "ctrl+s")) {
|
|
101
|
+
const selected = row;
|
|
102
|
+
row = 2;
|
|
103
|
+
action();
|
|
104
|
+
row = selected;
|
|
105
|
+
}
|
|
106
|
+
requestRender();
|
|
107
|
+
},
|
|
108
|
+
render(width: number) {
|
|
109
|
+
const settings = handlers.get();
|
|
110
|
+
const values = [
|
|
111
|
+
`${settings.mode} (${settings.modeSource})`,
|
|
112
|
+
`${settings.maxConcurrent} (${settings.capSource})`,
|
|
113
|
+
"",
|
|
114
|
+
"",
|
|
115
|
+
];
|
|
116
|
+
const lines = [theme.fg("accent", theme.bold("Subagent settings")), ""];
|
|
117
|
+
labels.forEach((label, index) => {
|
|
118
|
+
const text = `${index === row ? ">" : " "} ${label}${values[index] ? `: ${values[index]}` : ""}`;
|
|
119
|
+
lines.push(index === row ? theme.fg("accent", theme.bold(text)) : text);
|
|
120
|
+
});
|
|
121
|
+
if (editing) lines.push("", ...input.render(Math.max(1, width)));
|
|
122
|
+
lines.push("", theme.fg("muted", `Saved defaults: ${settings.defaultMode}, cap ${settings.defaultCap}`));
|
|
123
|
+
if (message) lines.push(theme.fg(error ? "error" : "success", message));
|
|
124
|
+
lines.push("", theme.fg("dim", editing ? "Enter apply Esc cancel" : "Up/Down select Enter change Ctrl+S save Esc close"));
|
|
125
|
+
return lines.map((line) => Tui.truncateToWidth(line, Math.max(1, width), ""));
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/shared-navigator.ts
CHANGED
|
@@ -66,6 +66,8 @@ export type BackgroundWorkProvider = {
|
|
|
66
66
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
67
67
|
armCloseLabel(row: BackgroundWorkRow): string;
|
|
68
68
|
close(id: string): BackgroundWorkCloseOutcome;
|
|
69
|
+
/** Return true when this detail handled the key. The navigator refreshes the sheet. */
|
|
70
|
+
handleDetailInput?(id: string, data: string): boolean;
|
|
69
71
|
onVisibleChanged?(notify: () => void): () => void;
|
|
70
72
|
};
|
|
71
73
|
|
|
@@ -559,7 +561,7 @@ function rowSummary(row: InternalRow): string {
|
|
|
559
561
|
const parts: string[] = [];
|
|
560
562
|
if (row.statusTone !== "running" && row.kind !== "main agent") parts.push(singleLine(row.status));
|
|
561
563
|
if (row.model) parts.push(row.effort ? `${singleLine(row.model)} ${singleLine(row.effort)}` : singleLine(row.model));
|
|
562
|
-
if (row.tool) parts.push(
|
|
564
|
+
if (row.tool) parts.push(singleLine(row.tool));
|
|
563
565
|
if (row.tokens) parts.push(singleLine(row.tokens));
|
|
564
566
|
else if (row.primary) parts.push(singleLine(row.primary));
|
|
565
567
|
if (parts.length) return parts.join(" · ");
|
|
@@ -703,12 +705,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
703
705
|
return true;
|
|
704
706
|
}
|
|
705
707
|
if (deps.matchKey(data, "enter")) {
|
|
706
|
-
|
|
707
|
-
if (selected?.parentRow) {
|
|
708
|
-
unfocusMainList();
|
|
709
|
-
dismissOverlay();
|
|
710
|
-
}
|
|
711
|
-
else openNavigator();
|
|
708
|
+
openNavigator();
|
|
712
709
|
return true;
|
|
713
710
|
}
|
|
714
711
|
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
@@ -851,7 +848,8 @@ function createOverlayComponent(
|
|
|
851
848
|
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
852
849
|
const expandedSections = new Set<string>();
|
|
853
850
|
let detailId: string = initialDetailId;
|
|
854
|
-
|
|
851
|
+
const initialRow = initialRows.find((row) => row.navigatorId === detailId);
|
|
852
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? (initialRow ? fallbackDetail(initialRow) : null);
|
|
855
853
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
856
854
|
{
|
|
857
855
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
@@ -891,8 +889,7 @@ function createOverlayComponent(
|
|
|
891
889
|
}
|
|
892
890
|
|
|
893
891
|
function activateSelectedRow(): void {
|
|
894
|
-
|
|
895
|
-
else openDetail();
|
|
892
|
+
openDetail();
|
|
896
893
|
}
|
|
897
894
|
|
|
898
895
|
function clearCloseArm(): void {
|
|
@@ -922,10 +919,6 @@ function createOverlayComponent(
|
|
|
922
919
|
function openDetail(): void {
|
|
923
920
|
const row = selectedRow();
|
|
924
921
|
if (!row) return;
|
|
925
|
-
if (row.parentRow) {
|
|
926
|
-
close();
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
922
|
clearCloseArm();
|
|
930
923
|
detailId = row.navigatorId;
|
|
931
924
|
expandedSections.clear();
|
|
@@ -1073,6 +1066,15 @@ function createOverlayComponent(
|
|
|
1073
1066
|
requestRender();
|
|
1074
1067
|
}
|
|
1075
1068
|
}
|
|
1069
|
+
else if (data === "m" || data === "M" || data === "\x13" || deps.matchKey(data, "ctrl+s")) {
|
|
1070
|
+
const row = selectedRow();
|
|
1071
|
+
const provider = row ? state().providers.get(row.providerId) : undefined;
|
|
1072
|
+
const key = data === "m" || data === "M" ? "m" : "ctrl+s";
|
|
1073
|
+
if (!row || !provider?.handleDetailInput?.(row.id, key)) return;
|
|
1074
|
+
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
1075
|
+
refreshMainListWidget();
|
|
1076
|
+
requestRender();
|
|
1077
|
+
}
|
|
1076
1078
|
else if (data === "l" || data === "L") {
|
|
1077
1079
|
logTailRows = cycleLogTailRows(logTailRows);
|
|
1078
1080
|
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
@@ -1091,8 +1093,7 @@ function createOverlayComponent(
|
|
|
1091
1093
|
if (idx < 0) return;
|
|
1092
1094
|
overlayState.selected = idx;
|
|
1093
1095
|
state().mainListSelectedId = navigatorId;
|
|
1094
|
-
|
|
1095
|
-
else openDetail();
|
|
1096
|
+
openDetail();
|
|
1096
1097
|
},
|
|
1097
1098
|
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1098
1099
|
dismiss() { close(); },
|
|
@@ -1150,7 +1151,11 @@ function buildTranscriptDetailLines(
|
|
|
1150
1151
|
...detail.metadata.map((item) => ` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`),
|
|
1151
1152
|
];
|
|
1152
1153
|
const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
|
|
1153
|
-
if (detail.transcriptDiagnostic)
|
|
1154
|
+
if (detail.transcriptDiagnostic) {
|
|
1155
|
+
for (const diagnosticRow of detail.transcriptDiagnostic.split(/\r\n|\r|\n/)) {
|
|
1156
|
+
transcriptHeader.push(` ${dim(diagnosticRow, fg)}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1154
1159
|
// Metadata always renders; the transcript tail takes only the rows left over (the tail size is a cap, not a guarantee).
|
|
1155
1160
|
lines.push(...optionalMetadata);
|
|
1156
1161
|
if (options.maxRows !== undefined) {
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -6,6 +6,8 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
|
|
|
6
6
|
createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { unlink } from "node:fs/promises";
|
|
9
11
|
import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
|
|
10
12
|
import { homedir, tmpdir } from "node:os";
|
|
11
13
|
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
@@ -117,42 +119,51 @@ export function createTaskBashOperations(
|
|
|
117
119
|
const scratch = plan.policy.runtimeWrite?.[0];
|
|
118
120
|
const taskEnv = { ...process.env, ...options.env,
|
|
119
121
|
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
120
|
-
const wrapped = maybeBuildSandboxCommand({
|
|
121
|
-
policy: plan.policy, profilePath: plan.profilePath,
|
|
122
|
-
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
123
|
-
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
124
|
-
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
125
|
-
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
126
|
-
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
127
|
-
for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
|
|
128
122
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
129
123
|
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
130
124
|
throw new Error("Invalid timeout: must be a positive, supported number of seconds");
|
|
131
125
|
}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
126
|
+
// A sibling launch must never rewrite a profile another process is reading.
|
|
127
|
+
const profilePath = `${plan.profilePath}.${randomUUID()}.bash.sb`;
|
|
128
|
+
try {
|
|
129
|
+
const wrapped = maybeBuildSandboxCommand({
|
|
130
|
+
policy: plan.policy, profilePath,
|
|
131
|
+
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
132
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
133
|
+
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
134
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
135
|
+
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
136
|
+
for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
|
|
137
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
138
|
+
// No shell or caller-controlled loader environment runs before the boundary.
|
|
139
|
+
return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
140
|
+
const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
|
|
141
|
+
env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
|
|
142
|
+
let timedOut = false;
|
|
143
|
+
const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
|
|
144
|
+
const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
|
|
145
|
+
if (child.pid) trackDetachedChildPid(child.pid);
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
148
|
+
if (timer) clearTimeout(timer);
|
|
149
|
+
options.signal?.removeEventListener("abort", kill);
|
|
150
|
+
};
|
|
151
|
+
child.stdout.on("data", options.onData);
|
|
152
|
+
child.stderr.on("data", options.onData);
|
|
153
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
154
|
+
if (options.signal?.aborted) kill();
|
|
155
|
+
void waitForChildProcess(child).then((exitCode: number | null) => {
|
|
156
|
+
cleanup();
|
|
157
|
+
if (options.signal?.aborted) reject(new Error("aborted"));
|
|
158
|
+
else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
|
|
159
|
+
else resolve({ exitCode });
|
|
160
|
+
}, (error: unknown) => { cleanup(); reject(error); });
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
|
|
164
|
+
if (error.code !== "ENOENT") throw error;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
156
167
|
},
|
|
157
168
|
};
|
|
158
169
|
}
|