pi-better-subagents 0.14.1 → 0.16.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 -1
- package/agent-commands.ts +13 -11
- package/agent-operations.ts +6 -1
- package/config.ts +53 -35
- package/docs/usage.md +32 -4
- package/index.ts +35 -15
- package/package.json +2 -1
- package/shared-callback-batcher.ts +199 -15
- package/shared-harness-settings.ts +114 -0
- package/shared-task-tools.ts +10 -5
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
|
|
|
35
35
|
|
|
36
36
|
## Delegation Modes
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
The user config sets `delegationMode` to `manual`, `adaptive` (default), or
|
|
39
39
|
`coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
|
|
40
40
|
for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
|
|
41
41
|
this session and persist across reloads; Ctrl+S saves both as defaults, and
|
|
@@ -51,6 +51,14 @@ uses `agents_catalog` to discover current role descriptions and delegates every
|
|
|
51
51
|
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
52
52
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
53
53
|
|
|
54
|
+
Saved defaults live in global `~/.pi/agent/settings.json` under
|
|
55
|
+
`piBetterHarness.subagents` (or under `PI_CODING_AGENT_DIR`) and survive package
|
|
56
|
+
reinstalls and upgrades. The old user config migrates on first use. Global values
|
|
57
|
+
override the package's `config.json`, which remains a compatibility fallback.
|
|
58
|
+
Save existing mode and cap choices with `/subagents save` or Ctrl+S. Put other
|
|
59
|
+
custom config keys in `piBetterHarness.subagents` as well; edits made only inside
|
|
60
|
+
the installed package can be lost on upgrade.
|
|
61
|
+
|
|
54
62
|
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
55
63
|
such as `role developer "Developer" … default openai/gpt-6.1-sol@high`. Roles are
|
|
56
64
|
listed and passed by short name (`role: "developer"`); the stored id
|
package/agent-commands.ts
CHANGED
|
@@ -137,21 +137,23 @@ export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">,
|
|
|
137
137
|
return items.length > 0 ? items : null;
|
|
138
138
|
},
|
|
139
139
|
async handler(args: string, ctx: ExtensionCommandContext) {
|
|
140
|
-
|
|
141
|
-
// context argument is the current session, not the one captured
|
|
142
|
-
// when the command was registered.
|
|
143
|
-
deps.propagateCommandContext?.(ctx);
|
|
144
|
-
return executeAgentsCommand(args, {
|
|
145
|
-
cwd: ctx.cwd,
|
|
146
|
-
hasUI: ctx.hasUI,
|
|
147
|
-
mode: ctx.mode,
|
|
148
|
-
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
149
|
-
ui: ctx.ui,
|
|
150
|
-
}, deps);
|
|
140
|
+
return executeAgentsCommandContext(args, ctx, deps);
|
|
151
141
|
},
|
|
152
142
|
});
|
|
153
143
|
}
|
|
154
144
|
|
|
145
|
+
/** Slash commands and settings shortcuts must resolve against the same live context. */
|
|
146
|
+
export function executeAgentsCommandContext(args: string, ctx: ExtensionCommandContext, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
147
|
+
deps.propagateCommandContext?.(ctx);
|
|
148
|
+
return executeAgentsCommand(args, {
|
|
149
|
+
cwd: ctx.cwd,
|
|
150
|
+
hasUI: ctx.hasUI,
|
|
151
|
+
mode: ctx.mode,
|
|
152
|
+
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
153
|
+
ui: ctx.ui,
|
|
154
|
+
}, deps);
|
|
155
|
+
}
|
|
156
|
+
|
|
155
157
|
export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
156
158
|
const parsed = parseCommandArgs(args);
|
|
157
159
|
if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
|
package/agent-operations.ts
CHANGED
|
@@ -18,17 +18,19 @@
|
|
|
18
18
|
import { agentsCatalogTool, type DiscoveryDeps } from "./agents-catalog-tool.ts";
|
|
19
19
|
import {
|
|
20
20
|
executeAgentsCommand,
|
|
21
|
+
executeAgentsCommandContext,
|
|
21
22
|
registerAgentCommands,
|
|
22
23
|
type AgentCommandDeps,
|
|
23
24
|
type AgentCommandHost,
|
|
24
25
|
type AgentCommandResult,
|
|
25
26
|
} from "./agent-commands.ts";
|
|
26
27
|
import { resolveRoleAssignment, type RoleAssignment, type RoleAssignmentDecision } from "./role-assignment.ts";
|
|
27
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
28
29
|
|
|
29
30
|
export interface AgentOperationsDeps extends AgentCommandDeps, DiscoveryDeps {}
|
|
30
31
|
|
|
31
32
|
export interface AgentOperationsHandle {
|
|
33
|
+
openCatalog(ctx: ExtensionCommandContext): Promise<void>;
|
|
32
34
|
registerCommands(pi: Pick<ExtensionAPI, "registerCommand">): void;
|
|
33
35
|
createDiscoveryTool(Type: Parameters<typeof agentsCatalogTool>[0]): ReturnType<typeof agentsCatalogTool>;
|
|
34
36
|
executeCommand(args: string, host: AgentCommandHost): Promise<AgentCommandResult>;
|
|
@@ -40,6 +42,9 @@ export interface AgentOperationsHandle {
|
|
|
40
42
|
|
|
41
43
|
export function createAgentOperations(deps: AgentOperationsDeps = {}): AgentOperationsHandle {
|
|
42
44
|
return {
|
|
45
|
+
async openCatalog(ctx) {
|
|
46
|
+
await executeAgentsCommandContext("", ctx, deps);
|
|
47
|
+
},
|
|
43
48
|
registerCommands(pi) {
|
|
44
49
|
registerAgentCommands(pi, deps);
|
|
45
50
|
},
|
package/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* User config overrides the shipped `config.json` defaults for every subagent,
|
|
3
|
+
* each overridable per `subagent_spawn` call. Saves never edit installed files.
|
|
4
4
|
*
|
|
5
5
|
* { "defaultModel": "xai/grok-4.5", "defaultTools": "read, bash, web_fetch" }
|
|
6
6
|
*
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
* `defaultTools` absent → the built-in SAFE_DEFAULT_TOOLS.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, readFileSync
|
|
11
|
+
import { existsSync, readFileSync } 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
16
|
import { isDelegationMode, type DelegationMode } from "./delegation.ts";
|
|
17
17
|
import type { TimingSettings } from "./timing.ts";
|
|
18
|
+
import { harnessSettingsPath, readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.ts";
|
|
18
19
|
|
|
19
20
|
export interface SubagentConfig extends TimingSettings {
|
|
20
21
|
/** Foreground delegation policy; /subagents mode overrides this for the current session. */
|
|
@@ -89,65 +90,82 @@ export const SAFE_CLEAN_TOOLS = "read, bash";
|
|
|
89
90
|
|
|
90
91
|
let cached: SubagentConfig | undefined;
|
|
91
92
|
let configPathForTests: string | undefined;
|
|
93
|
+
let bundledConfigPathForTests: string | undefined;
|
|
92
94
|
|
|
93
95
|
/** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
|
|
94
96
|
export function setConfigForTests(next: SubagentConfig | undefined): void {
|
|
95
97
|
cached = next;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/** Test seam.
|
|
99
|
-
export function setConfigPathForTests(path: string | undefined): void {
|
|
100
|
+
/** Test seam. Isolate user config, optionally supplying a package fallback. */
|
|
101
|
+
export function setConfigPathForTests(path: string | undefined, bundledPath?: string): void {
|
|
100
102
|
configPathForTests = path;
|
|
103
|
+
bundledConfigPathForTests = bundledPath;
|
|
101
104
|
cached = undefined;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
export function configPath(): string {
|
|
105
|
-
return
|
|
108
|
+
return harnessSettingsPath(configSeams());
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
function configSeams() {
|
|
112
|
+
return { agentDir: () => configPathForTests ? dirname(configPathForTests) : piAgentDir() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConfig(path: string): SubagentConfig {
|
|
116
|
+
return parseConfig(JSON.parse(readFileSync(path, "utf-8")));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseConfig(parsed: unknown): SubagentConfig {
|
|
120
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Config must be a JSON object.");
|
|
121
|
+
return parsed as SubagentConfig;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function savedConfig(): SubagentConfig {
|
|
125
|
+
const user = readHarnessSetting("subagents", configSeams(), configPathForTests ? undefined : {
|
|
126
|
+
path: join(piAgentDir(), "extensions", "pi-better-subagents-config.json"), parse: parseConfig,
|
|
127
|
+
});
|
|
128
|
+
return user === undefined ? {} : parseConfig(user);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bundledConfig(): SubagentConfig {
|
|
132
|
+
const path = bundledConfigPathForTests ?? (configPathForTests ? undefined : join(selfDir(), "config.json"));
|
|
133
|
+
if (!path) return {};
|
|
111
134
|
try {
|
|
112
|
-
|
|
135
|
+
return readConfig(path);
|
|
113
136
|
} catch {
|
|
114
|
-
|
|
137
|
+
return {};
|
|
115
138
|
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Saved user choices take precedence; missing/invalid files contribute no keys. */
|
|
142
|
+
export function loadConfig(): SubagentConfig {
|
|
143
|
+
if (cached) return cached;
|
|
144
|
+
let user: SubagentConfig = {};
|
|
145
|
+
try { user = savedConfig(); } catch { /* retain package fallback */ }
|
|
146
|
+
cached = { ...bundledConfig(), ...user };
|
|
116
147
|
return cached;
|
|
117
148
|
}
|
|
118
149
|
|
|
119
|
-
/**
|
|
150
|
+
/** Save the navigator's mode-only default without replacing the saved cap. */
|
|
120
151
|
export function writeDelegationMode(mode: DelegationMode, path = configPath()): void {
|
|
121
152
|
if (!isDelegationMode(mode)) throw new Error(`Invalid delegation mode: ${String(mode)}`);
|
|
122
|
-
|
|
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;
|
|
153
|
+
writeConfigSettings({ delegationMode: mode }, path);
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
/** Save human-facing defaults together, preserving unrelated configuration. */
|
|
135
157
|
export function writeSubagentSettings(settings: { delegationMode: DelegationMode; maxConcurrent: number }, path = configPath()): void {
|
|
136
158
|
if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
|
|
137
159
|
if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
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;
|
|
160
|
+
writeConfigSettings(settings, path);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function writeConfigSettings(settings: Partial<SubagentConfig>, path: string): void {
|
|
164
|
+
if (path === configPath()) savedConfig();
|
|
165
|
+
const current = updateHarnessSetting("subagents", previous => ({
|
|
166
|
+
...(previous === undefined ? {} : parseConfig(previous)), ...settings,
|
|
167
|
+
}), { agentDir: () => dirname(path) });
|
|
168
|
+
if (path === configPath()) cached = { ...bundledConfig(), ...current };
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
/** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
|
package/docs/usage.md
CHANGED
|
@@ -14,7 +14,8 @@ launch is the result · completion triggers fetch · the foreground never blocks
|
|
|
14
14
|
|
|
15
15
|
## Delegation Mode
|
|
16
16
|
|
|
17
|
-
Set `delegationMode` in
|
|
17
|
+
Set `delegationMode` in the user config described under
|
|
18
|
+
[Tool scoping](#tool-scoping-allowlist) to `manual`, `adaptive`, or
|
|
18
19
|
`coordinator` (default `adaptive`). This controls foreground delegation guidance,
|
|
19
20
|
not child permissions or catalog definitions. `/subagents settings` (or simply
|
|
20
21
|
`/subagents`) opens the terminal settings page with mode and concurrent-subagent
|
|
@@ -67,7 +68,7 @@ is present; without this extension it uses adaptive guidance.
|
|
|
67
68
|
`subagent_spawn` starts a detached `pi -p` child and returns immediately,
|
|
68
69
|
leaving the session free for the human. When the child finishes, it sends a
|
|
69
70
|
lightweight trigger; the foreground calls `subagent_result` and presents the
|
|
70
|
-
result (as a `followUp
|
|
71
|
+
result (as a `followUp` by default; optional busy steering is described below). The foreground
|
|
71
72
|
is nudged once, at completion — never on a wait/poll loop.
|
|
72
73
|
- **Subagents are autonomous; communication is one-way (parent → child).** The
|
|
73
74
|
parent front-loads everything the child needs into the spawn; the child runs to
|
|
@@ -247,7 +248,7 @@ given.
|
|
|
247
248
|
its output is durable in a log file.
|
|
248
249
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
249
250
|
terminal callbacks stay in the harness queue while the foreground agent is
|
|
250
|
-
busy. Once Pi is idle, they accumulate for 100 ms and share one
|
|
251
|
+
busy by default. Once Pi is idle, they accumulate for 100 ms and share one
|
|
251
252
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
252
253
|
with background-task completions from the same Pi host. Each row contains only
|
|
253
254
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
@@ -260,6 +261,23 @@ given.
|
|
|
260
261
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
261
262
|
pending. `callback:false` sends no model message; read the durable result later
|
|
262
263
|
with `subagent_result`.
|
|
264
|
+
- **Optional busy steering.** In `/harness-settings`, set **Completions while
|
|
265
|
+
busy** to **Steer active run** to receive ordinary completions during
|
|
266
|
+
foreground work. Changes autosave to the current session branch and apply
|
|
267
|
+
immediately; **Ctrl+S** saves the choice as the user default for future
|
|
268
|
+
sessions, including standalone callback packages. **Wait until idle** is the
|
|
269
|
+
default. The former `PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is
|
|
270
|
+
no longer supported. Completions spanning multiple accumulation windows during
|
|
271
|
+
one tool call stay together until the final active tool ends, then share one
|
|
272
|
+
compact `steer` notification without triggering a new run. Parallel and nested
|
|
273
|
+
tools are tracked together by both extensions. While busy with no active tool,
|
|
274
|
+
the normal accumulation window applies. Manual compaction and branch
|
|
275
|
+
summarization hold callbacks until an agent run starts or Pi becomes idle.
|
|
276
|
+
Idle delivery remains
|
|
277
|
+
`{ deliverAs: "followUp", triggerTurn: true }`. The same byte budget, queued overflow,
|
|
278
|
+
dedupe, delivery receipts, retry, ownership checks, and `callback:false` rules
|
|
279
|
+
apply to both modes; a completion handed off by steer is not sent again at
|
|
280
|
+
idle. Urgent health/failure alerts are unchanged.
|
|
263
281
|
- **The prompt guidelines forbid polling.** The foreground agent is told, in the
|
|
264
282
|
tool guidelines, that spawning is done and it must not loop on `output`/`result`
|
|
265
283
|
or sleep to wait.
|
|
@@ -421,7 +439,17 @@ Precedence, highest first: the per-call `tools` param → `config.json`
|
|
|
421
439
|
web_fetch`; just `read, bash` in a `clean` child). `exclude_tools` subtracts on
|
|
422
440
|
top.
|
|
423
441
|
|
|
424
|
-
|
|
442
|
+
User configuration lives in
|
|
443
|
+
`~/.pi/agent/settings.json` under `piBetterHarness.subagents` (under
|
|
444
|
+
`PI_CODING_AGENT_DIR` when set). The old user config migrates on first use.
|
|
445
|
+
Its keys override the package's `config.json`,
|
|
446
|
+
which is still read as a compatibility fallback. Settings saves write only the
|
|
447
|
+
user file, so saved mode and cap survive reinstalls and upgrades without freezing
|
|
448
|
+
other shipped defaults. To retain custom keys from an older package-local config,
|
|
449
|
+
copy those keys into the user file before upgrading. Already-overwritten values
|
|
450
|
+
cannot be recovered automatically.
|
|
451
|
+
|
|
452
|
+
The effective config also sets:
|
|
425
453
|
|
|
426
454
|
- `defaultModel` — model for spawns that don't specify one (`null` = inherit the
|
|
427
455
|
foreground model).
|
package/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execSync } from "node:child_process";
|
|
15
15
|
import { writeFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
16
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import * as PiTui from "@earendil-works/pi-tui";
|
|
19
19
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
@@ -2283,6 +2283,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
2283
2283
|
},
|
|
2284
2284
|
enrich: createLaunchEnricher(),
|
|
2285
2285
|
});
|
|
2286
|
+
const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
2287
|
+
if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
|
|
2288
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
|
|
2289
|
+
get: () => ({
|
|
2290
|
+
mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
|
|
2291
|
+
modeSource: delegationOverride ? "session" : "config",
|
|
2292
|
+
capSource: capOverride !== undefined ? "session" : "config",
|
|
2293
|
+
defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
|
|
2294
|
+
defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
|
|
2295
|
+
}),
|
|
2296
|
+
changeMode, changeCap, save: saveSettings, reset: resetSettings,
|
|
2297
|
+
}, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
|
|
2298
|
+
} else {
|
|
2299
|
+
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
const contribution = { id: "subagents", label: "Subagents", command: "/subagents settings", open: openSettings };
|
|
2303
|
+
const agentsContribution = { id: "agents", label: "Agents", command: "/agents", open: agentOperations.openCatalog };
|
|
2304
|
+
const registerSettings = () => {
|
|
2305
|
+
pi.events?.emit("harness-settings:register", contribution);
|
|
2306
|
+
pi.events?.emit("harness-settings:register", agentsContribution);
|
|
2307
|
+
};
|
|
2308
|
+
const unsubscribeSettingsRequest = pi.events?.on?.("harness-settings:request", registerSettings);
|
|
2309
|
+
registerSettings();
|
|
2310
|
+
|
|
2286
2311
|
if (typeof pi.registerCommand === "function") {
|
|
2287
2312
|
agentOperations.registerCommands(pi);
|
|
2288
2313
|
pi.registerCommand("subagents", {
|
|
@@ -2291,20 +2316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2291
2316
|
async handler(args, ctx) {
|
|
2292
2317
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
2293
2318
|
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "settings")) {
|
|
2294
|
-
|
|
2295
|
-
await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
|
|
2296
|
-
get: () => ({
|
|
2297
|
-
mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
|
|
2298
|
-
modeSource: delegationOverride ? "session" : "config",
|
|
2299
|
-
capSource: capOverride !== undefined ? "session" : "config",
|
|
2300
|
-
defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
|
|
2301
|
-
defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
|
|
2302
|
-
}),
|
|
2303
|
-
changeMode, changeCap, save: saveSettings, reset: resetSettings,
|
|
2304
|
-
}, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
|
|
2305
|
-
} else {
|
|
2306
|
-
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
|
|
2307
|
-
}
|
|
2319
|
+
await openSettings(ctx);
|
|
2308
2320
|
return;
|
|
2309
2321
|
}
|
|
2310
2322
|
const requested = tokens[1];
|
|
@@ -2346,18 +2358,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
2346
2358
|
// ---- live-status lifecycle -----------------------------------------
|
|
2347
2359
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2348
2360
|
setCallbackBatchContext(pi, ctx);
|
|
2361
|
+
getCallbackBatcher(pi).setForegroundRunning(true);
|
|
2349
2362
|
uiCtx = ctx;
|
|
2350
2363
|
mainAgentStartedAt = Date.now();
|
|
2351
2364
|
mainAgentTools.clear();
|
|
2352
2365
|
refreshBackgroundWorkNavigator(ctx);
|
|
2353
2366
|
});
|
|
2354
2367
|
|
|
2368
|
+
pi.on("agent_end", () => { getCallbackBatcher(pi).setForegroundRunning(false); });
|
|
2369
|
+
|
|
2355
2370
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
2371
|
+
getCallbackBatcher(pi).toolStarted(event.toolCallId);
|
|
2356
2372
|
mainAgentTools.set(event.toolCallId, event.toolName);
|
|
2357
2373
|
refreshBackgroundWorkNavigator(ctx);
|
|
2358
2374
|
});
|
|
2359
2375
|
|
|
2360
2376
|
pi.on("tool_execution_end", async (event, ctx) => {
|
|
2377
|
+
await getCallbackBatcher(pi).toolEnded(event.toolCallId);
|
|
2361
2378
|
mainAgentTools.delete(event.toolCallId);
|
|
2362
2379
|
refreshBackgroundWorkNavigator(ctx);
|
|
2363
2380
|
});
|
|
@@ -2378,6 +2395,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2378
2395
|
});
|
|
2379
2396
|
|
|
2380
2397
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2398
|
+
getCallbackBatcher(pi).setForegroundRunning(false);
|
|
2381
2399
|
setCallbackBatchContext(pi, ctx);
|
|
2382
2400
|
mainAgentStartedAt = undefined;
|
|
2383
2401
|
mainAgentTools.clear();
|
|
@@ -2385,6 +2403,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2385
2403
|
});
|
|
2386
2404
|
|
|
2387
2405
|
pi.on("session_tree", async (_event, ctx) => {
|
|
2406
|
+
setCallbackBatchContext(pi, ctx);
|
|
2388
2407
|
agentSessionSettings.restore(ctx);
|
|
2389
2408
|
try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
|
|
2390
2409
|
restoreDelegationMode(ctx);
|
|
@@ -2446,6 +2465,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2446
2465
|
|
|
2447
2466
|
// Tear down the timer and clear the widget when the session ends.
|
|
2448
2467
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
2468
|
+
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
2449
2469
|
// The foreground Pi session owns this work. Stop current-session
|
|
2450
2470
|
// running/orphaned children before dropping the session origin, so they
|
|
2451
2471
|
// cannot become live process groups with no coordinator.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@earendil-works/pi-tui": "*"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
+
"proper-lockfile": "^4.1.2",
|
|
64
65
|
"smol-toml": "1.9.0",
|
|
65
66
|
"yaml": "^2.9.1"
|
|
66
67
|
}
|
|
@@ -1,6 +1,119 @@
|
|
|
1
1
|
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
harnessSettingsPath,
|
|
6
|
+
readHarnessSetting,
|
|
7
|
+
updateHarnessSetting,
|
|
8
|
+
type HarnessSettingsSeams,
|
|
9
|
+
} from "./shared-harness-settings.ts";
|
|
10
|
+
|
|
2
11
|
export type CallbackSource = "subagent" | "background-task";
|
|
3
12
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
13
|
+
export type CallbackDeliveryMode = "hold" | "steer";
|
|
14
|
+
|
|
15
|
+
export interface CallbackSettingsContext {
|
|
16
|
+
sessionManager?: { getBranch?(): readonly unknown[]; getSessionId?(): string };
|
|
17
|
+
ui?: { notify(message: string, type: "error"): void };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CallbackSettingsSeams = HarnessSettingsSeams;
|
|
21
|
+
|
|
22
|
+
export interface CallbackSettings {
|
|
23
|
+
mode: CallbackDeliveryMode;
|
|
24
|
+
source: "session" | "default";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CALLBACK_SETTINGS_ENTRY = "pi-better-callback-settings";
|
|
28
|
+
const CALLBACK_PREFERENCES_FILE = "pi-better-callback-preferences.json";
|
|
29
|
+
|
|
30
|
+
function isDeliveryMode(mode: unknown): mode is CallbackDeliveryMode {
|
|
31
|
+
return mode === "hold" || mode === "steer";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validCallbackPreferences(value: unknown): value is { version: 1; mode: CallbackDeliveryMode } {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
36
|
+
const data = value as Record<string, unknown>;
|
|
37
|
+
return Object.keys(data).length === 2 && Object.hasOwn(data, "version") && Object.hasOwn(data, "mode")
|
|
38
|
+
&& data.version === 1 && isDeliveryMode(data.mode);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseCallbackPreferences(value: unknown): { version: 1; mode: CallbackDeliveryMode } {
|
|
42
|
+
if (!validCallbackPreferences(value)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function legacyCallbackPreferencesPath(seams: CallbackSettingsSeams): string {
|
|
47
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Branch history is authoritative; abandoned branches and invalid entries are ignored. */
|
|
51
|
+
export function getCallbackSettings(
|
|
52
|
+
ctx: CallbackSettingsContext,
|
|
53
|
+
seams: CallbackSettingsSeams = {},
|
|
54
|
+
): CallbackSettings {
|
|
55
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
56
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
57
|
+
const entry = branch[i] as { type?: unknown; customType?: unknown; data?: unknown } | null;
|
|
58
|
+
if (entry?.type === "custom" && entry.customType === CALLBACK_SETTINGS_ENTRY && validCallbackPreferences(entry.data)) {
|
|
59
|
+
return { mode: entry.data.mode, source: "session" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return getSessionDefault(ctx, seams);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettingsSeams): CallbackSettings {
|
|
66
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
67
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>();
|
|
68
|
+
const initial = ctx.sessionManager ? defaults.get(ctx.sessionManager) : undefined;
|
|
69
|
+
if (initial && initial.sessionId === sessionId) return { mode: initial.mode, source: "default" };
|
|
70
|
+
const snapshot = (mode: CallbackDeliveryMode): CallbackSettings => {
|
|
71
|
+
if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
|
|
72
|
+
return { mode, source: "default" };
|
|
73
|
+
};
|
|
74
|
+
const path = harnessSettingsPath(seams);
|
|
75
|
+
try {
|
|
76
|
+
const data = readHarnessSetting<{ version: 1; mode: CallbackDeliveryMode }>("callbacks", seams, {
|
|
77
|
+
path: legacyCallbackPreferencesPath(seams),
|
|
78
|
+
parse: parseCallbackPreferences,
|
|
79
|
+
});
|
|
80
|
+
return snapshot(data === undefined ? "hold" : parseCallbackPreferences(data).mode);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const prefix = (error as NodeJS.ErrnoException).code ? "Cannot read" : "Invalid";
|
|
83
|
+
throw new Error(`${prefix} callback default at ${path}: ${String(error)}`, { cause: error });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Saves only the future-session default; never changes the active branch or batcher. */
|
|
88
|
+
export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
|
|
89
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
|
|
90
|
+
const path = harnessSettingsPath(seams);
|
|
91
|
+
try {
|
|
92
|
+
updateHarnessSetting("callbacks", () => ({ version: 1, mode }), seams);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Persist first so a failed append cannot become a transient delivery-mode change. */
|
|
99
|
+
export function changeCallbackSetting(
|
|
100
|
+
host: CallbackBatchHost & { appendEntry(customType: string, data: unknown): void },
|
|
101
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
102
|
+
mode: CallbackDeliveryMode,
|
|
103
|
+
): void {
|
|
104
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
105
|
+
const data = { version: 1, mode };
|
|
106
|
+
try { host.appendEntry(CALLBACK_SETTINGS_ENTRY, data); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Pi advances its in-memory branch before disk persistence. Invalidate
|
|
109
|
+
// our payload so that failed entry cannot become a restored setting.
|
|
110
|
+
data.version = 0;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
114
|
+
batcher.setWhileBusy(mode);
|
|
115
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
116
|
+
}
|
|
4
117
|
|
|
5
118
|
export interface CallbackBatchHost {
|
|
6
119
|
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
@@ -70,6 +183,8 @@ export interface CallbackBatcherOptions {
|
|
|
70
183
|
retryMs?: number;
|
|
71
184
|
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
185
|
isAvailable?: () => boolean;
|
|
186
|
+
/** Internal initial mode. Session/default settings are restored with context. */
|
|
187
|
+
whileBusy?: CallbackDeliveryMode;
|
|
73
188
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
74
189
|
maxBytes?: number;
|
|
75
190
|
}
|
|
@@ -81,6 +196,14 @@ export interface CallbackBatcher {
|
|
|
81
196
|
setHost(host: CallbackBatchHost): void;
|
|
82
197
|
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
198
|
setAvailability(isAvailable: () => boolean): void;
|
|
199
|
+
/** Change delivery immediately without clearing pending callbacks or handoff receipts. */
|
|
200
|
+
setWhileBusy(mode: CallbackDeliveryMode): void;
|
|
201
|
+
/** Non-idle context operations are not foreground agent runs. */
|
|
202
|
+
setForegroundRunning(running: boolean): void;
|
|
203
|
+
/** Shared IDs make duplicate notifications from both extensions idempotent. */
|
|
204
|
+
toolStarted(toolCallId: string): void;
|
|
205
|
+
/** Await the final active tool's handoff before Pi checks its steering queue. */
|
|
206
|
+
toolEnded(toolCallId: string): Promise<boolean>;
|
|
84
207
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
85
208
|
cancel(): void;
|
|
86
209
|
pendingCount(): number;
|
|
@@ -104,6 +227,7 @@ interface PendingEvent {
|
|
|
104
227
|
interface SharedCallbackBatcherState {
|
|
105
228
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
229
|
bySession?: WeakMap<object, CallbackBatcher>;
|
|
230
|
+
sessionDefaults?: WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>;
|
|
107
231
|
}
|
|
108
232
|
|
|
109
233
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -131,7 +255,6 @@ const RETRIEVAL_FOOTER =
|
|
|
131
255
|
|
|
132
256
|
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
133
257
|
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
134
|
-
|
|
135
258
|
export function resolveCallbackBatchWindowMs(
|
|
136
259
|
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
137
260
|
): number {
|
|
@@ -436,26 +559,31 @@ export function createCallbackBatcher(
|
|
|
436
559
|
): CallbackBatcher {
|
|
437
560
|
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
438
561
|
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
562
|
+
let whileBusy = options.whileBusy ?? "hold";
|
|
439
563
|
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
440
564
|
const pending = new Map<string, PendingEvent>();
|
|
441
565
|
const inFlight = new Set<string>();
|
|
442
566
|
const urgentInFlight = new Set<string>();
|
|
443
567
|
const handedOff = new Map<string, number>();
|
|
568
|
+
const activeTools = new Set<string>();
|
|
444
569
|
let sequence = 0;
|
|
445
570
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
446
571
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
572
|
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
573
|
let gated = options.isAvailable !== undefined;
|
|
449
574
|
let awaitingRun = false;
|
|
575
|
+
let cancelled = false;
|
|
576
|
+
let foregroundRunning = false;
|
|
450
577
|
|
|
451
|
-
const
|
|
578
|
+
const deliveryMode = (): "followUp" | "steer" | undefined => {
|
|
579
|
+
if (cancelled) return undefined;
|
|
452
580
|
try {
|
|
453
581
|
if (!isAvailable()) {
|
|
454
582
|
awaitingRun = false;
|
|
455
|
-
return
|
|
583
|
+
return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
|
|
456
584
|
}
|
|
457
|
-
return !awaitingRun;
|
|
458
|
-
} catch { return
|
|
585
|
+
return !awaitingRun ? "followUp" : undefined;
|
|
586
|
+
} catch { return undefined; }
|
|
459
587
|
};
|
|
460
588
|
|
|
461
589
|
const cancelTimer = (): void => {
|
|
@@ -466,7 +594,7 @@ export function createCallbackBatcher(
|
|
|
466
594
|
const schedule = (delayMs: number): void => {
|
|
467
595
|
if (timer || pending.size === 0) return;
|
|
468
596
|
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
-
const delay =
|
|
597
|
+
const delay = deliveryMode() ? delayMs : retryMs;
|
|
470
598
|
timer = setTimeout(() => {
|
|
471
599
|
timer = undefined;
|
|
472
600
|
void api.flush();
|
|
@@ -485,7 +613,8 @@ export function createCallbackBatcher(
|
|
|
485
613
|
|
|
486
614
|
const performFlush = async (): Promise<boolean> => {
|
|
487
615
|
cancelTimer();
|
|
488
|
-
|
|
616
|
+
const mode = deliveryMode();
|
|
617
|
+
if (!mode) {
|
|
489
618
|
schedule(retryMs);
|
|
490
619
|
return false;
|
|
491
620
|
}
|
|
@@ -539,14 +668,16 @@ export function createCallbackBatcher(
|
|
|
539
668
|
try {
|
|
540
669
|
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
670
|
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
-
awaitingRun = gated;
|
|
671
|
+
awaitingRun = mode === "followUp" && gated;
|
|
543
672
|
await host.sendMessage(
|
|
544
673
|
{
|
|
545
674
|
customType: "background-completion-batch",
|
|
546
675
|
content: packed.text,
|
|
547
676
|
display: true,
|
|
548
677
|
},
|
|
549
|
-
|
|
678
|
+
// Newer Pi requires triggerTurn for steering too; false only appends
|
|
679
|
+
// a custom message at the turn boundary without continuing the agent.
|
|
680
|
+
{ deliverAs: mode, triggerTurn: true },
|
|
550
681
|
);
|
|
551
682
|
} catch {
|
|
552
683
|
awaitingRun = false;
|
|
@@ -632,15 +763,42 @@ export function createCallbackBatcher(
|
|
|
632
763
|
setAvailability(check) {
|
|
633
764
|
gated = true;
|
|
634
765
|
isAvailable = check;
|
|
635
|
-
|
|
766
|
+
cancelled = false;
|
|
767
|
+
deliveryMode();
|
|
636
768
|
cancelTimer();
|
|
637
769
|
schedule(windowMs);
|
|
638
770
|
},
|
|
771
|
+
setWhileBusy(mode) {
|
|
772
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
773
|
+
whileBusy = mode;
|
|
774
|
+
cancelTimer();
|
|
775
|
+
schedule(windowMs);
|
|
776
|
+
},
|
|
777
|
+
setForegroundRunning(running) {
|
|
778
|
+
foregroundRunning = running;
|
|
779
|
+
cancelTimer();
|
|
780
|
+
schedule(windowMs);
|
|
781
|
+
},
|
|
782
|
+
toolStarted(toolCallId) {
|
|
783
|
+
activeTools.add(toolCallId);
|
|
784
|
+
},
|
|
785
|
+
async toolEnded(toolCallId) {
|
|
786
|
+
const ended = activeTools.delete(toolCallId);
|
|
787
|
+
if (activeTools.size > 0 || whileBusy !== "steer" || cancelled) return false;
|
|
788
|
+
if (!ended) return flushPromise ?? false;
|
|
789
|
+
// A timer may still be completing a deferred flush. Recheck after it so
|
|
790
|
+
// the tool boundary cannot lose the pending batch to that older attempt.
|
|
791
|
+
if (flushPromise) await flushPromise;
|
|
792
|
+
return pending.size > 0 ? api.flush() : true;
|
|
793
|
+
},
|
|
639
794
|
deliverUrgent,
|
|
640
795
|
cancel() {
|
|
641
796
|
cancelTimer();
|
|
642
797
|
pending.clear();
|
|
643
798
|
awaitingRun = false;
|
|
799
|
+
activeTools.clear();
|
|
800
|
+
foregroundRunning = false;
|
|
801
|
+
cancelled = true;
|
|
644
802
|
},
|
|
645
803
|
pendingCount() {
|
|
646
804
|
return pending.size;
|
|
@@ -671,11 +829,39 @@ export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
|
671
829
|
batcher?.cancel();
|
|
672
830
|
}
|
|
673
831
|
|
|
674
|
-
/**
|
|
832
|
+
/** Restore current branch/default mode without resetting foreground tools or receipts. */
|
|
675
833
|
export function setCallbackBatchContext(
|
|
676
834
|
host: CallbackBatchHost,
|
|
677
|
-
ctx: { isIdle(): boolean
|
|
835
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
836
|
+
seams: CallbackSettingsSeams = {},
|
|
678
837
|
): void {
|
|
838
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
839
|
+
let mode: CallbackDeliveryMode = "hold";
|
|
840
|
+
try {
|
|
841
|
+
const settings = getCallbackSettings(ctx, seams);
|
|
842
|
+
if (settings.source === "session") {
|
|
843
|
+
// Capture the initial default even when an override currently hides it,
|
|
844
|
+
// so navigating to an unconfigured branch cannot inherit a later save.
|
|
845
|
+
try { getSessionDefault(ctx, seams); }
|
|
846
|
+
catch {
|
|
847
|
+
if (ctx.sessionManager) {
|
|
848
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap();
|
|
849
|
+
defaults.set(ctx.sessionManager, { sessionId: ctx.sessionManager.getSessionId?.(), mode: "hold" });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
mode = settings.mode;
|
|
854
|
+
} catch (error) {
|
|
855
|
+
try { ctx.ui?.notify(`Callback delivery restored to hold: ${String(error)}`, "error"); }
|
|
856
|
+
catch { /* A failing UI must not retain the prior session's delivery mode. */ }
|
|
857
|
+
}
|
|
858
|
+
batcher.setWhileBusy(mode);
|
|
859
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
860
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
861
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function bindCallbackBatcher(host: CallbackBatchHost, ctx: CallbackSettingsContext): CallbackBatcher {
|
|
679
865
|
const state = globalState();
|
|
680
866
|
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
867
|
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
@@ -683,9 +869,7 @@ export function setCallbackBatchContext(
|
|
|
683
869
|
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
870
|
state.byHost.set(host.events ?? host, batcher);
|
|
685
871
|
batcher.setHost(host);
|
|
686
|
-
|
|
687
|
-
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
-
batcher.setAvailability(() => ctx.isIdle());
|
|
872
|
+
return batcher;
|
|
689
873
|
}
|
|
690
874
|
|
|
691
875
|
function globalState(): SharedCallbackBatcherState {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Generated from packages/harness-settings/index.ts. Do not edit directly.
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
// Match Pi's settings lock without depending on a private SDK import.
|
|
9
|
+
const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
|
|
10
|
+
lockSync(path: string, options: { realpath: boolean }): () => void;
|
|
11
|
+
};
|
|
12
|
+
const NAMESPACE = "piBetterHarness";
|
|
13
|
+
export interface HarnessSettingsSeams { agentDir?: () => string }
|
|
14
|
+
|
|
15
|
+
export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
|
|
16
|
+
return join((seams.agentDir ?? getAgentDir)(), "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
25
|
+
try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function section(settings: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(path: string): () => void {
|
|
37
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
38
|
+
try { return lockfile.lockSync(path, { realpath: false }); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Cannot lock settings at ${path}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lockedRead(path: string): Record<string, unknown> {
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const release = acquire(path);
|
|
50
|
+
try { return readSettings(path); } finally { release(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDestination(path: string): string {
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = lstatSync(path); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
// Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
|
|
61
|
+
return stat.isSymbolicLink() ? realpathSync(path) : path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
|
|
65
|
+
const path = harnessSettingsPath(seams);
|
|
66
|
+
mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
|
|
67
|
+
const release = acquire(path);
|
|
68
|
+
let pending: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
// Read inside the lock so another session's settings are never replaced by a stale snapshot.
|
|
71
|
+
const settings = readSettings(path);
|
|
72
|
+
const values = section(settings);
|
|
73
|
+
const result = change(values);
|
|
74
|
+
settings[NAMESPACE] = values;
|
|
75
|
+
const destination = writeDestination(path);
|
|
76
|
+
pending = `${destination}.${randomUUID()}.tmp`;
|
|
77
|
+
writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
78
|
+
renameSync(pending, destination);
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readHarnessSetting<T = unknown>(
|
|
86
|
+
key: string,
|
|
87
|
+
seams: HarnessSettingsSeams = {},
|
|
88
|
+
legacy?: { path: string; parse: (value: unknown) => T },
|
|
89
|
+
): T | undefined {
|
|
90
|
+
const values = section(lockedRead(harnessSettingsPath(seams)));
|
|
91
|
+
if (Object.hasOwn(values, key)) return values[key] as T;
|
|
92
|
+
if (!legacy) return undefined;
|
|
93
|
+
let raw: string;
|
|
94
|
+
try { raw = readFileSync(legacy.path, "utf8"); }
|
|
95
|
+
catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const migrated = legacy.parse(JSON.parse(raw));
|
|
100
|
+
return mutate(seams, current => {
|
|
101
|
+
if (!Object.hasOwn(current, key)) current[key] = migrated;
|
|
102
|
+
return current[key] as T;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function updateHarnessSetting<T>(
|
|
107
|
+
key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
|
|
108
|
+
): T {
|
|
109
|
+
return mutate(seams, values => {
|
|
110
|
+
const next = update(values[key]);
|
|
111
|
+
values[key] = next;
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|
package/shared-task-tools.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - Guarded tools are harness adapters that go through the task's guarded file
|
|
6
6
|
* operations, so the file rules govern them. Today: apply_patch.
|
|
7
|
-
* - Trusted tools are
|
|
7
|
+
* - Trusted tools are extension tools admitted by name AND owning package.
|
|
8
8
|
* They run in the child Pi process, outside the file rules; a human ticks each
|
|
9
9
|
* one in /sandbox. Network tools are refused while Network is Off.
|
|
10
10
|
*/
|
|
@@ -36,15 +36,17 @@ export function defaultSubagentTools(): SubagentToolSettings {
|
|
|
36
36
|
|
|
37
37
|
/** Tools that reach the network; refused while the profile's Network access is Off. */
|
|
38
38
|
export function isNetworkTool(name: string): boolean {
|
|
39
|
-
return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript",
|
|
39
|
+
return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript", ...SSH_TOOL_NAMES].includes(name) ||
|
|
40
40
|
name.startsWith("mcp__");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Names never offered as trusted: builtins and the guarded adapters. */
|
|
44
44
|
export const RESERVED_TOOL_NAMES = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls", "powershell", "apply_patch", "process_list"]);
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/** Harness tools stay excluded except for the explicitly supported SSH tools. */
|
|
47
47
|
const HARNESS_PACKAGES = /(^|[/:])pi-better-[a-z-]+$/;
|
|
48
|
+
const SSH_PACKAGE = /(^|[/:])pi-better-ssh$/;
|
|
49
|
+
const SSH_TOOL_NAMES = ["remote_bash", "ssh_profile", "ssh_mux"];
|
|
48
50
|
|
|
49
51
|
/** Strict decoding: a malformed tool list must not silently admit anything. */
|
|
50
52
|
export function parseSubagentTools(value: unknown): SubagentToolSettings {
|
|
@@ -95,13 +97,16 @@ export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
|
|
|
95
97
|
return { name: tool.name, package: pkg, root: base };
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/** Candidate trusted tools
|
|
100
|
+
/** Candidate trusted tools, excluding core builtins, guarded names and non-SSH harness tools. */
|
|
99
101
|
export function discoverTrustedTools(tools: readonly ToolSource[]): DiscoveredTool[] {
|
|
100
102
|
const found: DiscoveredTool[] = [];
|
|
101
103
|
for (const tool of tools) {
|
|
102
104
|
if (RESERVED_TOOL_NAMES.includes(tool.name)) continue;
|
|
103
105
|
const owner = toolPackage(tool);
|
|
104
|
-
if (!owner
|
|
106
|
+
if (!owner) continue;
|
|
107
|
+
const harnessOwners = [owner.package, owner.root].map((id) => id.replace(/\/+$/, ""))
|
|
108
|
+
.filter((id) => HARNESS_PACKAGES.test(id));
|
|
109
|
+
if (harnessOwners.length && !(SSH_TOOL_NAMES.includes(tool.name) && harnessOwners.every((id) => SSH_PACKAGE.test(id)))) continue;
|
|
105
110
|
if (!found.some((t) => t.name === owner.name && t.package === owner.package)) {
|
|
106
111
|
found.push(tool.sourceInfo?.source === "builtin" ? { ...owner, source: "builtin" } : owner);
|
|
107
112
|
}
|