pi-better-subagents 0.12.0 → 0.13.1
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 +2 -1
- package/agent-commands.ts +35 -7
- package/agent-session-settings.ts +89 -0
- package/agents-catalog-tool.ts +6 -2
- package/catalog-resolver.ts +10 -6
- package/catalog-runtime.ts +8 -2
- package/catalog-store.ts +3 -0
- package/catalog-ui.ts +115 -9
- package/docs/agent-catalog-lifecycle.md +23 -0
- package/docs/agent-catalog-operations.md +11 -1
- package/docs/usage.md +15 -8
- package/index.ts +22 -1
- package/package.json +1 -1
- package/settings-page.ts +34 -22
- package/shared-callback-batcher.ts +67 -4
package/README.md
CHANGED
|
@@ -38,7 +38,8 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
|
|
|
38
38
|
`config.json` 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
|
-
this session
|
|
41
|
+
this session and persist across reloads; Ctrl+S saves both as defaults, and
|
|
42
|
+
Reset to defaults clears
|
|
42
43
|
session overrides. `/subagents mode manual|adaptive|coordinator` and
|
|
43
44
|
`/subagents cap <number>` are command equivalents; `/subagents save` persists
|
|
44
45
|
both. Lowering the cap blocks new single and batch launches without stopping
|
package/agent-commands.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type CodexDocument,
|
|
48
48
|
} from "./codex-import.ts";
|
|
49
49
|
import { resolveRoleAssignment } from "./role-assignment.ts";
|
|
50
|
+
import { saveAgentSessionDefaults, withAgentSessionSettings, type AgentSessionOverrides, type AgentSessionSettings } from "./agent-session-settings.ts";
|
|
50
51
|
|
|
51
52
|
const MODEL_PATTERN = /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
52
53
|
const TIER_PATTERN = /^[a-z][a-z0-9-]{0,32}$/;
|
|
@@ -68,6 +69,9 @@ export interface AgentCommandDeps {
|
|
|
68
69
|
now?: () => string;
|
|
69
70
|
/** Resolution unit injects availability, actual model/effort, and launchability. */
|
|
70
71
|
enrich?: LaunchEnricher;
|
|
72
|
+
sessionSettings?: AgentSessionSettings;
|
|
73
|
+
/** Available foreground registry models, as provider/id. */
|
|
74
|
+
editableModels?: () => readonly string[];
|
|
71
75
|
/**
|
|
72
76
|
* Live slash-command context, before list/inspect/create/reload/import.
|
|
73
77
|
* Lifecycle refreshes the foreground model and settings here. Direct
|
|
@@ -112,6 +116,7 @@ interface CatalogLocation {
|
|
|
112
116
|
projectTrusted: boolean;
|
|
113
117
|
projectConfigDirName?: string;
|
|
114
118
|
bundledRoot?: string;
|
|
119
|
+
sessionSettings?: AgentSessionOverrides;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
interface ParsedArgs {
|
|
@@ -149,6 +154,7 @@ export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">,
|
|
|
149
154
|
|
|
150
155
|
export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
151
156
|
const parsed = parseCommandArgs(args);
|
|
157
|
+
if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
|
|
152
158
|
if (parsed.unknown.length > 0 || parsed.missing.length > 0) {
|
|
153
159
|
const detail = [
|
|
154
160
|
...parsed.unknown.map((flag) => `Unknown flag --${flag}.`),
|
|
@@ -161,8 +167,8 @@ export async function executeAgentsCommand(args: string, host: AgentCommandHost,
|
|
|
161
167
|
}
|
|
162
168
|
const command = parsed.positionals[0]!;
|
|
163
169
|
const loc = location(host, deps);
|
|
164
|
-
if (command === "list") return finishInspection(host, await runList(parsed, loc, deps));
|
|
165
|
-
if (command === "show" || command === "inspect") return finishInspection(host, await runShow(command, parsed, host, loc, deps));
|
|
170
|
+
if (command === "list") return finishInspection(host, await runList(parsed, loc, deps), deps);
|
|
171
|
+
if (command === "show" || command === "inspect") return finishInspection(host, await runShow(command, parsed, host, loc, deps), deps);
|
|
166
172
|
if (command === "reload") return finish(host, await runReload(parsed, loc, deps));
|
|
167
173
|
if (command === "create") return finish(host, await runCreate(parsed, host, loc, deps));
|
|
168
174
|
if (command === "import-codex") return finish(host, await runImport(parsed, host, loc, deps));
|
|
@@ -215,7 +221,7 @@ async function runList(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentComm
|
|
|
215
221
|
|
|
216
222
|
async function runReload(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentCommandDeps): Promise<AgentCommandResult> {
|
|
217
223
|
if (parsed.positionals.length > 1) return result("error", "reload", false, "reload does not take extra arguments.");
|
|
218
|
-
const listed = presentCatalog(refreshCatalog(loc), deps.enrich);
|
|
224
|
+
const listed = presentCatalog(withAgentSessionSettings(refreshCatalog(loc), loc.sessionSettings), deps.enrich);
|
|
219
225
|
return {
|
|
220
226
|
...result("ok", "reload", false, `Reloaded revision ${listed.revision} for inspection. The next launch reads the files again on its own; this command is not a prerequisite.\n${listed.text}`),
|
|
221
227
|
diagnostics: listed.diagnostics,
|
|
@@ -662,11 +668,12 @@ function location(host: AgentCommandHost, deps: AgentCommandDeps): CatalogLocati
|
|
|
662
668
|
projectTrusted: host.isProjectTrusted(),
|
|
663
669
|
projectConfigDirName: deps.projectConfigDirName,
|
|
664
670
|
bundledRoot: deps.bundledRoot,
|
|
671
|
+
sessionSettings: deps.sessionSettings?.snapshot(),
|
|
665
672
|
};
|
|
666
673
|
}
|
|
667
674
|
|
|
668
675
|
function readCatalog(loc: CatalogLocation): CatalogSnapshot {
|
|
669
|
-
return loadCatalog(loc);
|
|
676
|
+
return withAgentSessionSettings(loadCatalog(loc), loc.sessionSettings);
|
|
670
677
|
}
|
|
671
678
|
|
|
672
679
|
function untrusted(command: string): AgentCommandResult {
|
|
@@ -707,16 +714,37 @@ function finish(host: AgentCommandHost, commandResult: AgentCommandResult): Agen
|
|
|
707
714
|
return commandResult;
|
|
708
715
|
}
|
|
709
716
|
|
|
710
|
-
async function finishInspection(host: AgentCommandHost, commandResult: AgentCommandResult): Promise<AgentCommandResult> {
|
|
717
|
+
async function finishInspection(host: AgentCommandHost, commandResult: AgentCommandResult, deps: AgentCommandDeps): Promise<AgentCommandResult> {
|
|
711
718
|
if (commandResult.ok && host.mode === "tui" && host.hasUI && host.ui.custom) {
|
|
712
719
|
const catalog = commandResult.data && typeof commandResult.data === "object" && "entries" in commandResult.data
|
|
713
720
|
? commandResult.data as ReturnType<typeof presentCatalog> : undefined;
|
|
714
721
|
const view = catalog ? undefined : commandResult.data as OperationView;
|
|
715
|
-
const entries = catalog ?? { entries: [view], diagnostics: commandResult.diagnostics, revision: "", text: "" };
|
|
722
|
+
const entries = catalog ?? { entries: [view!], diagnostics: commandResult.diagnostics, revision: "", text: "" };
|
|
716
723
|
try {
|
|
717
724
|
host.ui.setWidget?.("agents-catalog", undefined);
|
|
718
725
|
await host.ui.custom<void>((tui, theme, _keys, done) =>
|
|
719
|
-
createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id
|
|
726
|
+
createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id, deps.sessionSettings ? {
|
|
727
|
+
get: () => presentCatalog(readCatalog(location(host, deps)), deps.enrich),
|
|
728
|
+
models: () => deps.editableModels?.() ?? [],
|
|
729
|
+
settings: () => deps.sessionSettings!.snapshot(),
|
|
730
|
+
change: (id, key, value) => deps.sessionSettings!.change(id, key, value),
|
|
731
|
+
save(id) {
|
|
732
|
+
const loc = location(host, deps);
|
|
733
|
+
const saved = saveAgentSessionDefaults(loadCatalog(loc), id, deps.sessionSettings!.snapshot(), loc);
|
|
734
|
+
if (saved.ok) {
|
|
735
|
+
commandResult.wrote = true;
|
|
736
|
+
commandResult.path = saved.path;
|
|
737
|
+
commandResult.scope = saved.scope;
|
|
738
|
+
}
|
|
739
|
+
return { ok: saved.ok, message: saved.ok ? `Defaults saved (${saved.scope}): ${id}.` : saved.diagnostics.map((item) => item.message).join(" ") };
|
|
740
|
+
},
|
|
741
|
+
} : undefined), { overlay: true });
|
|
742
|
+
if (deps.sessionSettings) {
|
|
743
|
+
const current = readCatalog(location(host, deps));
|
|
744
|
+
const refreshed = view ? presentCatalogEntry(current, view.id, deps.enrich) : presentCatalog(current, deps.enrich);
|
|
745
|
+
commandResult.data = refreshed;
|
|
746
|
+
commandResult.message = refreshed.text;
|
|
747
|
+
}
|
|
720
748
|
return commandResult;
|
|
721
749
|
} catch {
|
|
722
750
|
// A non-interactive host may expose custom() without supporting overlays.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Branch-local catalog preferences. Null removes a definition's own value. */
|
|
2
|
+
import { EFFORT_LEVELS, type CatalogDefinition, type PortablePreferences, type ThinkingLevel } from "./catalog-schema.ts";
|
|
3
|
+
import { contentDigest, saveDefinition, type CatalogSnapshot, type LoadCatalogInput, type SaveResult } from "./catalog-store.ts";
|
|
4
|
+
|
|
5
|
+
export const AGENT_SESSION_SETTINGS_ENTRY = "pi-better-subagents-agent-settings";
|
|
6
|
+
export type AgentSettingKey = "model" | "effort";
|
|
7
|
+
export type AgentSessionOverrides = Readonly<Record<string, Readonly<{ model?: string | null; effort?: ThinkingLevel | null }>>>;
|
|
8
|
+
export interface AgentSessionContext {
|
|
9
|
+
sessionManager?: { getBranch?(): readonly unknown[] };
|
|
10
|
+
}
|
|
11
|
+
export interface AgentSessionSettings {
|
|
12
|
+
restore(ctx: AgentSessionContext): void;
|
|
13
|
+
snapshot(): AgentSessionOverrides;
|
|
14
|
+
change(id: string, key: AgentSettingKey, value: string | null): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function valid(id: unknown, key: unknown, value: unknown): boolean {
|
|
19
|
+
return typeof id === "string" && /^(role|agent)\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(id) &&
|
|
20
|
+
(key === "model" || key === "effort") && (value === null || typeof value === "string" &&
|
|
21
|
+
(key === "model" ? /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(value)
|
|
22
|
+
: (EFFORT_LEVELS as readonly string[]).includes(value)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The host must restore from the current context before constructing catalog hosts. */
|
|
26
|
+
export function createAgentSessionSettings(pi: { appendEntry(customType: string, data: unknown): void }): AgentSessionSettings {
|
|
27
|
+
let overrides: AgentSessionOverrides = Object.freeze({});
|
|
28
|
+
const apply = (id: string, key: AgentSettingKey, value: string | null) => {
|
|
29
|
+
overrides = Object.freeze({ ...overrides, [id]: Object.freeze({ ...overrides[id], [key]: value }) });
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
snapshot: () => overrides,
|
|
33
|
+
clear() { overrides = Object.freeze({}); },
|
|
34
|
+
restore(ctx) {
|
|
35
|
+
overrides = Object.freeze({});
|
|
36
|
+
const branch = ctx.sessionManager?.getBranch?.();
|
|
37
|
+
if (!Array.isArray(branch)) return;
|
|
38
|
+
for (const entry of branch) {
|
|
39
|
+
if (!entry || entry.type !== "custom" || entry.customType !== AGENT_SESSION_SETTINGS_ENTRY) continue;
|
|
40
|
+
const data = entry.data;
|
|
41
|
+
if (data?.version !== 1 || !valid(data.id, data.key, data.value)) continue;
|
|
42
|
+
apply(data.id, data.key, data.value);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
change(id, key, value) {
|
|
46
|
+
if (!valid(id, key, value)) throw new Error("Choose a catalog id and a valid model or effort. Nothing changed.");
|
|
47
|
+
// Append first: a persistence failure must not become an ephemeral edit.
|
|
48
|
+
pi.appendEntry(AGENT_SESSION_SETTINGS_ENTRY, { version: 1, id, key, value });
|
|
49
|
+
apply(id, key, value);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Copy only preferences; never repair or bypass a catalog validation failure. */
|
|
55
|
+
export function sessionPreferences(definition: CatalogDefinition, settings: AgentSessionOverrides = {}): PortablePreferences {
|
|
56
|
+
const own = { ...(definition.kind === "role" ? definition.defaults : definition.overrides) };
|
|
57
|
+
const patch = settings[definition.id];
|
|
58
|
+
for (const key of ["model", "effort"] as const) {
|
|
59
|
+
if (!patch || !Object.hasOwn(patch, key)) continue;
|
|
60
|
+
if (patch[key] === null) delete own[key];
|
|
61
|
+
else if (patch[key] !== undefined) Object.assign(own, { [key]: patch[key] });
|
|
62
|
+
}
|
|
63
|
+
return own;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A launch batch retains this immutable overlay, even if the UI changes later. */
|
|
67
|
+
export function withAgentSessionSettings(snapshot: CatalogSnapshot, settings?: AgentSessionOverrides): CatalogSnapshot {
|
|
68
|
+
if (!settings || Object.keys(settings).length === 0) return snapshot;
|
|
69
|
+
const ordered = Object.fromEntries(Object.keys(settings).sort().map((id) => [id, Object.freeze({
|
|
70
|
+
...(Object.hasOwn(settings[id]!, "model") ? { model: settings[id]!.model } : {}),
|
|
71
|
+
...(Object.hasOwn(settings[id]!, "effort") ? { effort: settings[id]!.effort } : {}),
|
|
72
|
+
})]));
|
|
73
|
+
const digest = contentDigest(JSON.stringify([snapshot.digest, ordered]));
|
|
74
|
+
return Object.freeze({ ...snapshot, digest, revision: digest, sessionSettings: Object.freeze(ordered) });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Save selected own preferences only. Never flatten inherited role settings. */
|
|
78
|
+
export function saveAgentSessionDefaults(snapshot: CatalogSnapshot, id: string, settings: AgentSessionOverrides, location: LoadCatalogInput): SaveResult {
|
|
79
|
+
const entry = snapshot.roles.get(id) ?? snapshot.agents.get(id);
|
|
80
|
+
if (!entry?.definition || !entry.structurallyValid || entry.duplicate) {
|
|
81
|
+
return { ok: false, diagnostics: [{ severity: "error", code: "agents-save", message: `Cannot save ${id}: repair the catalog definition first. Session edits are unchanged.`, blocking: true, structural: false }] };
|
|
82
|
+
}
|
|
83
|
+
const definition = structuredClone(entry.definition);
|
|
84
|
+
const preferences = sessionPreferences(definition, settings);
|
|
85
|
+
if (definition.kind === "role") definition.defaults = preferences;
|
|
86
|
+
else definition.overrides = preferences;
|
|
87
|
+
// The store validates and writes atomically, including trust and symlink checks.
|
|
88
|
+
return saveDefinition({ ...location, definition, scope: entry.scope === "project" ? "project" : "user", replace: entry.scope !== "bundled" });
|
|
89
|
+
}
|
package/agents-catalog-tool.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { presentCatalog, presentCatalogEntry, type LaunchEnricher } from "./agent-inspection.ts";
|
|
7
7
|
import { canonicalCatalogId } from "./catalog-schema.ts";
|
|
8
8
|
import { defaultUserRoot, loadCatalog } from "./catalog-store.ts";
|
|
9
|
+
import { withAgentSessionSettings, type AgentSessionOverrides, type AgentSessionSettings } from "./agent-session-settings.ts";
|
|
9
10
|
|
|
10
11
|
type TypeModule = {
|
|
11
12
|
Object: (value: unknown) => unknown;
|
|
@@ -19,6 +20,7 @@ export interface DiscoveryHost {
|
|
|
19
20
|
userRoot: string;
|
|
20
21
|
projectConfigDirName?: string;
|
|
21
22
|
bundledRoot?: string;
|
|
23
|
+
sessionSettings?: AgentSessionOverrides;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface DiscoveryDeps {
|
|
@@ -26,6 +28,7 @@ export interface DiscoveryDeps {
|
|
|
26
28
|
bundledRoot?: string;
|
|
27
29
|
projectConfigDirName?: string;
|
|
28
30
|
enrich?: LaunchEnricher;
|
|
31
|
+
sessionSettings?: AgentSessionSettings;
|
|
29
32
|
resolveHost?: (ctx: { cwd: string; isProjectTrusted(): boolean }) => DiscoveryHost;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -67,15 +70,16 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
67
70
|
return toolError(`agents_catalog action must be list or inspect, got ${JSON.stringify(action ?? "")}. It does not create, import, reload, or launch. Nothing was written.`);
|
|
68
71
|
}
|
|
69
72
|
const host = resolveHost(ctx, deps);
|
|
73
|
+
const snapshot = withAgentSessionSettings(loadCatalog(host), host.sessionSettings ?? deps.sessionSettings?.snapshot());
|
|
70
74
|
if (action === "list") {
|
|
71
|
-
const listed = presentCatalog(
|
|
75
|
+
const listed = presentCatalog(snapshot, deps.enrich);
|
|
72
76
|
return {
|
|
73
77
|
content: [{ type: "text" as const, text: listed.text }],
|
|
74
78
|
details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
|
|
75
79
|
};
|
|
76
80
|
}
|
|
77
81
|
if (!params.id?.trim()) return toolError("inspect needs a role name or agent id. Call agents_catalog with action list to see them. Nothing was written.");
|
|
78
|
-
const view = presentCatalogEntry(
|
|
82
|
+
const view = presentCatalogEntry(snapshot, canonicalCatalogId(params.id), deps.enrich);
|
|
79
83
|
return {
|
|
80
84
|
content: [{ type: "text" as const, text: view.text }],
|
|
81
85
|
details: { action, wrote: false, view },
|
package/catalog-resolver.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ThinkingLevel,
|
|
19
19
|
} from "./catalog-schema.ts";
|
|
20
20
|
import type { CatalogEntry, CatalogScope, CatalogSnapshot } from "./catalog-store.ts";
|
|
21
|
+
import { sessionPreferences } from "./agent-session-settings.ts";
|
|
21
22
|
|
|
22
23
|
export interface FieldValue<T> {
|
|
23
24
|
value: T | null;
|
|
@@ -223,6 +224,7 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
|
|
|
223
224
|
return blockedEntry(snapshot, entry, entry.diagnostics);
|
|
224
225
|
}
|
|
225
226
|
const definition = entry.definition;
|
|
227
|
+
const preferences = sessionPreferences(definition, snapshot.sessionSettings);
|
|
226
228
|
const launchable = entry.schemaLaunchable;
|
|
227
229
|
const diagnostics = launchable
|
|
228
230
|
? [...entry.diagnostics]
|
|
@@ -245,9 +247,9 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
|
|
|
245
247
|
description: definition.description,
|
|
246
248
|
instructionMode: "role",
|
|
247
249
|
instructions: definition.body,
|
|
248
|
-
model: fieldFromRole(
|
|
249
|
-
effort: fieldFromRole(
|
|
250
|
-
tier: fieldFromRole(
|
|
250
|
+
model: fieldFromRole(preferences.model),
|
|
251
|
+
effort: fieldFromRole(preferences.effort),
|
|
252
|
+
tier: fieldFromRole(preferences.tier),
|
|
251
253
|
launchable,
|
|
252
254
|
diagnostics,
|
|
253
255
|
source: originOf(entry),
|
|
@@ -332,6 +334,8 @@ function baseEffective(
|
|
|
332
334
|
} | undefined,
|
|
333
335
|
): EffectiveDefinition {
|
|
334
336
|
const mode = agent.instructionMode;
|
|
337
|
+
const overrides = sessionPreferences(agent, snapshot.sessionSettings);
|
|
338
|
+
const defaults = inheritance ? sessionPreferences(inheritance.role, snapshot.sessionSettings) : {};
|
|
335
339
|
const instructions = inheritance
|
|
336
340
|
? combineInstructions(inheritance.role.body, agent.body, mode)
|
|
337
341
|
: agent.body;
|
|
@@ -343,9 +347,9 @@ function baseEffective(
|
|
|
343
347
|
roleId: agent.roleId,
|
|
344
348
|
instructionMode: mode,
|
|
345
349
|
instructions,
|
|
346
|
-
model: inheritance ? inheritField(
|
|
347
|
-
effort: inheritance ? inheritField(
|
|
348
|
-
tier: inheritance ? inheritField(
|
|
350
|
+
model: inheritance ? inheritField(overrides, defaults, "model") : absentField(),
|
|
351
|
+
effort: inheritance ? inheritField(overrides, defaults, "effort") : absentField(),
|
|
352
|
+
tier: inheritance ? inheritField(overrides, defaults, "tier") : absentField(),
|
|
349
353
|
launchable: inheritance?.launchable ?? false,
|
|
350
354
|
diagnostics,
|
|
351
355
|
source: originOf(entry),
|
package/catalog-runtime.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { allocateCatalogLabel } from "./catalog-identity.ts";
|
|
|
23
23
|
import { resolveRoleAssignment, type RoleAssignment } from "./role-assignment.ts";
|
|
24
24
|
import { configureTierPolicy, DEFAULT_TIER_POLICY, type TierPolicy, type TierSpec } from "./tier-policy.ts";
|
|
25
25
|
import { canonicalAgentId, canonicalRoleId, shortRoleName, type Diagnostic, type ThinkingLevel } from "./catalog-schema.ts";
|
|
26
|
+
import { withAgentSessionSettings, type AgentSessionOverrides } from "./agent-session-settings.ts";
|
|
26
27
|
|
|
27
28
|
export interface CatalogHost {
|
|
28
29
|
cwd: string;
|
|
@@ -38,6 +39,9 @@ export interface CatalogHost {
|
|
|
38
39
|
select?: (title: string, options: string[]) => Promise<string | undefined>;
|
|
39
40
|
/** Passed through only when the caller already has a registry root. Omitted in production. */
|
|
40
41
|
registryDir?: string;
|
|
42
|
+
sessionSettings?: AgentSessionOverrides;
|
|
43
|
+
/** Live UI edits; read once when admitting a launch snapshot. */
|
|
44
|
+
getSessionSettings?: () => AgentSessionOverrides;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export interface CatalogJobFields {
|
|
@@ -142,17 +146,19 @@ export function noteCatalogHost(next: CatalogHost): void {
|
|
|
142
146
|
foregroundModel: "foregroundModel" in next ? next.foregroundModel : previous?.foregroundModel,
|
|
143
147
|
configuredDefaultModel: "configuredDefaultModel" in next ? next.configuredDefaultModel : previous?.configuredDefaultModel,
|
|
144
148
|
tiers: "tiers" in next ? next.tiers : previous?.tiers,
|
|
149
|
+
sessionSettings: next.sessionSettings,
|
|
150
|
+
getSessionSettings: next.getSessionSettings,
|
|
145
151
|
};
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
export function loadLaunchSnapshot(host: CatalogHost): CatalogSnapshot {
|
|
149
|
-
return loadCatalog({
|
|
155
|
+
return withAgentSessionSettings(loadCatalog({
|
|
150
156
|
cwd: host.cwd,
|
|
151
157
|
projectTrusted: host.projectTrusted,
|
|
152
158
|
userRoot: host.userRoot ?? defaultUserRoot(),
|
|
153
159
|
bundledRoot: host.bundledRoot,
|
|
154
160
|
projectConfigDirName: host.projectConfigDirName,
|
|
155
|
-
});
|
|
161
|
+
}), host.getSessionSettings?.() ?? host.sessionSettings);
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export function createLaunchEnricher(): LaunchEnricher {
|
package/catalog-store.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
+
import type { AgentSessionOverrides } from "./agent-session-settings.ts";
|
|
31
32
|
import {
|
|
32
33
|
CATALOG_SCHEMA_VERSION,
|
|
33
34
|
DiagnosticCodes,
|
|
@@ -108,6 +109,8 @@ export interface CatalogSnapshot {
|
|
|
108
109
|
/** Same-scope duplicates. They occupy the id without selecting a filesystem-order winner. */
|
|
109
110
|
blocked: readonly CatalogEntry[];
|
|
110
111
|
diagnostics: readonly Diagnostic[];
|
|
112
|
+
/** Immutable, branch-local preferences; never written by loadCatalog. */
|
|
113
|
+
sessionSettings?: AgentSessionOverrides;
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export interface SaveResult {
|
package/catalog-ui.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { OperationField, OperationList, OperationView } from "./agent-inspection.ts";
|
|
3
|
+
import { EFFORT_LEVELS } from "./catalog-schema.ts";
|
|
4
|
+
import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
|
|
3
5
|
|
|
4
|
-
type Theme = { fg(color: string, text: string): string };
|
|
6
|
+
type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string; inverse?(text: string): string };
|
|
7
|
+
|
|
8
|
+
export interface CatalogEditor {
|
|
9
|
+
get(): OperationList;
|
|
10
|
+
models(): readonly string[];
|
|
11
|
+
settings(): AgentSessionOverrides;
|
|
12
|
+
change(id: string, key: AgentSettingKey, value: string | null): void;
|
|
13
|
+
save(id: string): { ok: boolean; message: string };
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
function stateLabel(entry: OperationView): string {
|
|
7
17
|
if (entry.launchable === false) return "blocked";
|
|
@@ -84,11 +94,65 @@ export function createCatalogComponent(
|
|
|
84
94
|
requestRender: () => void,
|
|
85
95
|
close: () => void,
|
|
86
96
|
initialId?: string,
|
|
97
|
+
editor?: CatalogEditor,
|
|
87
98
|
) {
|
|
88
99
|
let selectedId = initialId ?? catalog.entries[0]?.id;
|
|
89
|
-
let mode: "list" | "detail" | "prompt" = initialId ? "detail" : "list";
|
|
100
|
+
let mode: "list" | "detail" | "prompt" | "settings" = initialId ? "detail" : "list";
|
|
90
101
|
let query = "";
|
|
91
102
|
let scroll = 0;
|
|
103
|
+
let settingRow = 0;
|
|
104
|
+
let picker: SelectList | undefined;
|
|
105
|
+
let filterPicker: ((filter: string) => void) | undefined;
|
|
106
|
+
let pickerKey: AgentSettingKey | undefined;
|
|
107
|
+
const search = new Input();
|
|
108
|
+
let focused = false;
|
|
109
|
+
let message = "";
|
|
110
|
+
let error = false;
|
|
111
|
+
const highlight = (text: string, width: number) => theme.bg?.("selectedBg", theme.bold?.(cell(text, width)) ?? cell(text, width)) ?? theme.fg("accent", text);
|
|
112
|
+
function report(text: string, failed = false) { message = text; error = failed; requestRender(); }
|
|
113
|
+
function refresh() { if (editor) catalog = editor.get(); }
|
|
114
|
+
function save() {
|
|
115
|
+
if (!editor || !selected()) return;
|
|
116
|
+
try {
|
|
117
|
+
const result = editor.save(selected()!.id);
|
|
118
|
+
refresh();
|
|
119
|
+
report(result.message, !result.ok);
|
|
120
|
+
} catch (cause) { report(String(cause instanceof Error ? cause.message : cause), true); }
|
|
121
|
+
}
|
|
122
|
+
function edit() {
|
|
123
|
+
const current = selected();
|
|
124
|
+
if (!current || !editor) return;
|
|
125
|
+
if (!current.definitionValid) { report("Repair the catalog definition before editing.", true); return; }
|
|
126
|
+
const key: AgentSettingKey = settingRow === 0 ? "model" : "effort";
|
|
127
|
+
const values = key === "model" ? [...new Set([...(editor.models()), ...(current.fields?.model.value ? [current.fields.model.value] : [])])].sort() : [...EFFORT_LEVELS];
|
|
128
|
+
const items = [{ value: "", label: "Inherit" }, ...values.map((value) => ({ value, label: value }))];
|
|
129
|
+
pickerKey = key;
|
|
130
|
+
search.setValue("");
|
|
131
|
+
search.focused = focused && key === "model";
|
|
132
|
+
const pickerTheme: ConstructorParameters<typeof SelectList>[2] = {
|
|
133
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
134
|
+
selectedText: (text) => theme.inverse?.(text) ?? theme.fg("accent", text),
|
|
135
|
+
description: (text) => theme.fg("dim", text),
|
|
136
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
137
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
138
|
+
};
|
|
139
|
+
const onSelect = (item: { value: string }) => {
|
|
140
|
+
try {
|
|
141
|
+
editor.change(current.id, key, item.value || null);
|
|
142
|
+
refresh();
|
|
143
|
+
picker = undefined;
|
|
144
|
+
search.focused = false;
|
|
145
|
+
report("Session settings updated.");
|
|
146
|
+
} catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
|
|
147
|
+
};
|
|
148
|
+
filterPicker = (filter) => {
|
|
149
|
+
picker = new SelectList(items.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase())), 12, pickerTheme);
|
|
150
|
+
picker.onCancel = () => { picker = undefined; search.focused = false; };
|
|
151
|
+
picker.onSelect = onSelect;
|
|
152
|
+
};
|
|
153
|
+
filterPicker("");
|
|
154
|
+
picker!.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === current.fields?.[key].value)));
|
|
155
|
+
}
|
|
92
156
|
const filtered = () => catalog.entries.filter((entry) =>
|
|
93
157
|
[entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
|
|
94
158
|
.some((value) => value?.toLowerCase().includes(query.toLowerCase())));
|
|
@@ -100,6 +164,8 @@ export function createCatalogComponent(
|
|
|
100
164
|
let lastDetailWidth = 80;
|
|
101
165
|
|
|
102
166
|
return {
|
|
167
|
+
get focused() { return focused; },
|
|
168
|
+
set focused(value: boolean) { focused = value; search.focused = value && !!picker && pickerKey === "model"; },
|
|
103
169
|
render(width: number): string[] {
|
|
104
170
|
const safeWidth = Math.max(1, width);
|
|
105
171
|
lastDetailWidth = safeWidth;
|
|
@@ -109,9 +175,32 @@ export function createCatalogComponent(
|
|
|
109
175
|
const detailLines = current ? detailRows(current, safeWidth) : [];
|
|
110
176
|
const selectedIndex = rows.findIndex((entry) => entry.id === selectedId);
|
|
111
177
|
const start = Math.max(0, Math.min(selectedIndex - 6, rows.length - 14));
|
|
112
|
-
|
|
178
|
+
if (picker) return [
|
|
179
|
+
theme.fg("accent", `Agents / ${pickerKey}`), "",
|
|
180
|
+
...(pickerKey === "model" ? search.render(safeWidth) : []),
|
|
181
|
+
...picker.render(safeWidth),
|
|
182
|
+
...(message ? [theme.fg(error ? "error" : "muted", message)] : []),
|
|
183
|
+
].map(line);
|
|
184
|
+
const lines = mode === "settings" && current ? [
|
|
185
|
+
theme.fg("accent", `Agents / ${current.identity.name || current.id}`), "",
|
|
186
|
+
...["Model", "Effort"].flatMap((label, index) => {
|
|
187
|
+
const key = index === 0 ? "model" : "effort";
|
|
188
|
+
const field = current.fields?.[key];
|
|
189
|
+
const own = editor?.settings()[current.id];
|
|
190
|
+
const ownSession = Object.hasOwn(own ?? {}, key);
|
|
191
|
+
const roleSession = field?.inherited && Object.hasOwn(editor?.settings()[current.role?.id ?? ""] ?? {}, key);
|
|
192
|
+
const origin = ownSession && own?.[key] === null ? "inherit / session" : ownSession ? "session" : roleSession ? "inherited / session" : field?.inherited ? "inherited" : "catalog";
|
|
193
|
+
const value = `${field?.value ?? "(unset)"} ${origin}`;
|
|
194
|
+
const text = `${settingRow === index ? "> " : " "}${label.padEnd(18)} ${value}`;
|
|
195
|
+
if (safeWidth < 42) {
|
|
196
|
+
const title = `${settingRow === index ? "> " : " "}${label}`;
|
|
197
|
+
return [settingRow === index ? highlight(title, safeWidth) : title, ...wrapLine(value, Math.max(1, safeWidth - 2)).map((line) => ` ${line}`)];
|
|
198
|
+
}
|
|
199
|
+
return [settingRow === index ? highlight(text, safeWidth) : text];
|
|
200
|
+
}),
|
|
201
|
+
] : mode !== "list" && current ? [
|
|
113
202
|
theme.fg("accent", mode === "prompt" ? "Agents / prompt" : "Agents / inspect"),
|
|
114
|
-
theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc close" : "p prompt · ← list · ↑↓ scroll · Esc close
|
|
203
|
+
theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc close" : `${editor ? "e settings · " : ""}p prompt · ← list · ↑↓ scroll · Esc close`),
|
|
115
204
|
"",
|
|
116
205
|
...detailLines.slice(scroll, scroll + 22).map((text, i) =>
|
|
117
206
|
i === 0 && scroll === 0 ? theme.fg("accent", text) : text),
|
|
@@ -127,25 +216,37 @@ export function createCatalogComponent(
|
|
|
127
216
|
const active = selectedId === entry.id;
|
|
128
217
|
const marker = active ? "›" : " ";
|
|
129
218
|
const text = `${marker} ${catalogColumns(entry, safeWidth)}`;
|
|
130
|
-
return active ?
|
|
219
|
+
return active ? highlight(text, safeWidth) : text;
|
|
131
220
|
}),
|
|
132
221
|
...(rows.length === 0 ? [" No matching definitions"] : []),
|
|
133
222
|
...(rows.length > 14 ? [theme.fg("dim", ` ${start + 1}-${Math.min(start + 14, rows.length)} of ${rows.length}`)] : []),
|
|
134
223
|
...catalog.diagnostics.slice(0, 2).map((diagnostic) => theme.fg("warning", `! ${diagnostic.message}`)),
|
|
135
224
|
...(catalog.diagnostics.length > 2 ? [theme.fg("warning", `! ${catalog.diagnostics.length - 2} more catalog diagnostics`)] : []),
|
|
136
225
|
];
|
|
226
|
+
if (editor && mode !== "prompt") lines.push("", theme.fg("dim", mode === "settings" ? "Up/Down Select · Enter Change · ctrl+s Save defaults · Esc Back" : "ctrl+s Save selected defaults"));
|
|
227
|
+
if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
|
|
137
228
|
return lines.map(line);
|
|
138
229
|
},
|
|
139
230
|
handleInput(data: string): void {
|
|
140
231
|
const rows = filtered();
|
|
232
|
+
if (picker) {
|
|
233
|
+
if (pickerKey === "model" && !(["up", "down", "enter", "escape"] as const).some((key) => matchesKey(data, key))) {
|
|
234
|
+
search.handleInput(data);
|
|
235
|
+
filterPicker?.(search.getValue());
|
|
236
|
+
} else picker.handleInput(data);
|
|
237
|
+
requestRender();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (editor && matchesKey(data, "ctrl+s")) { save(); return; }
|
|
141
241
|
if (matchesKey(data, "escape") || matchesKey(data, "left")) {
|
|
142
|
-
if (mode === "prompt") { mode = "detail"; scroll = 0; }
|
|
242
|
+
if (mode === "prompt" || mode === "settings") { mode = "detail"; scroll = 0; }
|
|
143
243
|
else if (mode === "detail") { mode = "list"; scroll = 0; }
|
|
144
244
|
else if (query) { query = ""; selectedId = catalog.entries[0]?.id; }
|
|
145
245
|
else { close(); return; }
|
|
146
246
|
} else if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
147
247
|
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
148
|
-
if (mode
|
|
248
|
+
if (mode === "settings") settingRow = Math.max(0, Math.min(1, settingRow + delta));
|
|
249
|
+
else if (mode !== "list" && selected()) scroll = Math.max(0, Math.min(detailRows(selected()!, lastDetailWidth).length - 1, scroll + delta));
|
|
149
250
|
else if (rows.length) selectedId = rows[Math.max(0, Math.min(rows.length - 1, rows.findIndex((entry) => entry.id === selectedId) + delta))]?.id;
|
|
150
251
|
} else if (matchesKey(data, "enter") && mode === "list" && selected()) {
|
|
151
252
|
mode = "detail";
|
|
@@ -153,6 +254,11 @@ export function createCatalogComponent(
|
|
|
153
254
|
} else if ((data === "p" || data === "P") && mode === "detail" && selected()) {
|
|
154
255
|
mode = "prompt";
|
|
155
256
|
scroll = 0;
|
|
257
|
+
} else if (editor && (data === "e" || data === "E") && mode === "detail" && selected()) {
|
|
258
|
+
mode = "settings";
|
|
259
|
+
settingRow = 0;
|
|
260
|
+
} else if (mode === "settings" && (matchesKey(data, "enter") || data === " ")) {
|
|
261
|
+
try { edit(); } catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
|
|
156
262
|
} else if (mode === "list" && matchesKey(data, "backspace")) {
|
|
157
263
|
query = query.slice(0, -1);
|
|
158
264
|
selectedId = filtered()[0]?.id;
|
|
@@ -162,6 +268,6 @@ export function createCatalogComponent(
|
|
|
162
268
|
}
|
|
163
269
|
requestRender();
|
|
164
270
|
},
|
|
165
|
-
invalidate() {},
|
|
271
|
+
invalidate() { picker?.invalidate(); search.invalidate(); },
|
|
166
272
|
};
|
|
167
273
|
}
|
|
@@ -39,3 +39,26 @@ Navigator rows keep the display name, model, and effort. Details include the run
|
|
|
39
39
|
## Inspection
|
|
40
40
|
|
|
41
41
|
The extension registers `/agents` and `agents_catalog` through `createAgentOperations`. The launch enricher calls `assessCatalog` / `assessSelection` with the current model registry, project trust, and user catalog root. It reports the existing spawn controls and `grantedByCatalog: false`. If the registry or snapshot is not the one just loaded, launchability stays unknown instead of being reported as launchable.
|
|
42
|
+
|
|
43
|
+
## Session Settings
|
|
44
|
+
|
|
45
|
+
`createAgentSessionSettings` restores model and effort overrides from the active
|
|
46
|
+
Pi session branch. The extension restores before catalog-dependent work and on
|
|
47
|
+
`session_start` and `session_tree`; switch and shutdown clear transient state
|
|
48
|
+
without appending reset entries. Edits survive reload, resume, and fork, while
|
|
49
|
+
new sessions start from catalog defaults.
|
|
50
|
+
|
|
51
|
+
The catalog host supplies a live `getSessionSettings` getter so inspection
|
|
52
|
+
reflects edits made while the overlay is open. `loadLaunchSnapshot` reads that
|
|
53
|
+
getter once and freezes the overlay for each batch. Later edits cannot alter
|
|
54
|
+
already-admitted jobs. Model choices come from the current Pi model registry.
|
|
55
|
+
|
|
56
|
+
Ctrl+S saves only the selected definition's own preferences, never flattened
|
|
57
|
+
inherited settings. Bundled roles are copied to user scope; existing user and
|
|
58
|
+
project definitions are saved in their own scope with the store's trust,
|
|
59
|
+
validation, and atomic-write checks. Plain `loadCatalog` remains disk-only so
|
|
60
|
+
saving selects the current winning definition rather than a stale overlay.
|
|
61
|
+
|
|
62
|
+
Headless callers can inject a session controller or immutable
|
|
63
|
+
`CatalogHost.sessionSettings`. Hosts without these hooks retain inspection-only
|
|
64
|
+
UI and the prior launch behavior.
|
|
@@ -6,7 +6,17 @@ Commands, the read-only discovery tool, and the Codex importer for `pi-agent/v1`
|
|
|
6
6
|
|
|
7
7
|
`/agents` is one Pi command. Dialogs use `ctx.ui.select`, `confirm`, `input`, and `editor`. `ctx.hasUI` is false in print and JSON mode. When a choice or confirmation is required there, the command returns `clarification-needed` and writes nothing.
|
|
8
8
|
|
|
9
|
-
In an interactive TUI, `/agents list
|
|
9
|
+
In an interactive TUI, `/agents`, `/agents list`, and `/agents show` open a focused catalog view instead of leaving the listing in a persistent widget. Rows align name, effective model, source, and launch state; narrow terminals retain name and model. The model is the resolved actual model when available, otherwise the requested or inherited preference, or `not resolved`. Type to filter by id, name, description, or base role; use Up/Down and Enter to inspect. The detail view puts launchability and its reason first, then source, inherited/overridden settings, and diagnostics. Press `p` in detail to read the complete effective instructions (role plus agent in add mode, agent text in replace mode); this is not the task-specific launch prompt. Up/Down scrolls detail or prompt; Left or Escape returns to detail, then list, clears the filter, then closes. `/agents show <id>` opens that definition directly. Print/JSON callers still receive the complete text and structured result; bare `/agents` still returns help outside the custom TUI.
|
|
10
|
+
|
|
11
|
+
### Model and effort edits
|
|
12
|
+
|
|
13
|
+
When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices come from the foreground registry, plus the current preference if unavailable; type to filter model names. Effort choices use the existing catalog effort levels. Escape cancels an unselected choice. Only these two fields are editable; instructions, base role, tier, and execution controls are unchanged.
|
|
14
|
+
|
|
15
|
+
A selected value immediately appends a custom entry to the current session branch. It does not write a catalog file. Reloading the extension or session restores the active branch's edits. Branches share their common ancestor settings but subsequent edits do not cross to siblings. Role edits flow into named agents that inherit that field. Choosing `Inherit` removes the selected definition's own value, including a saved agent override, instead of storing an explicit null preference. For a role with no parent, that removes its default. Later role/file edits remain live.
|
|
16
|
+
|
|
17
|
+
Ctrl+S in list, detail, or settings (or Enter on Save as defaults) explicitly saves the selected definition's own model and effort changes. It never copies inherited role fields into an agent. Existing personal/project definitions stay in their winning scope; a bundled definition becomes a personal whole-definition shadow copy, leaving the package file untouched. Project trust, duplicate/invalid-definition checks, validation, and atomic catalog writes still apply. Both successful and failed saves leave branch edits intact. A failed save reports the error without changing the catalog defaults.
|
|
18
|
+
|
|
19
|
+
New launches and `agents_catalog` discovery use the same session overlay. Explicit per-run model/effort choices still win, a batch retains one immutable snapshot, and active/completed runs do not change. Role effort remains an inherited default with the existing nearest-supported adjustment; named-agent effort remains explicit and blocks when unsupported. The lifecycle wiring required to enable this surface is in [agent-catalog-lifecycle.md](./agent-catalog-lifecycle.md#session-settings-integration).
|
|
10
20
|
|
|
11
21
|
| Invocation | Effect |
|
|
12
22
|
| --- | --- |
|
package/docs/usage.md
CHANGED
|
@@ -18,8 +18,10 @@ Set `delegationMode` in this package's `config.json` to `manual`, `adaptive`, or
|
|
|
18
18
|
`coordinator` (default `adaptive`). This controls foreground delegation guidance,
|
|
19
19
|
not child permissions or catalog definitions. `/subagents settings` (or simply
|
|
20
20
|
`/subagents`) opens the terminal settings page with mode and concurrent-subagent
|
|
21
|
-
cap controls
|
|
22
|
-
|
|
21
|
+
cap controls and Reset to defaults. The page uses the same selected-row and
|
|
22
|
+
active-cell styling as `/sandbox`, with one editable value column.
|
|
23
|
+
Edits automatically persist for this session, including across reloads; Escape
|
|
24
|
+
closes the page without undoing them.
|
|
23
25
|
Enter edits the cap as a positive whole number; Escape cancels an unfinished edit.
|
|
24
26
|
Ctrl+S saves both settings as defaults. Sandbox permissions remain in `/sandbox`.
|
|
25
27
|
|
|
@@ -244,13 +246,17 @@ given.
|
|
|
244
246
|
Its context can't clog the parent, its crash can't corrupt parent state, and
|
|
245
247
|
its output is durable in a log file.
|
|
246
248
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
247
|
-
terminal callbacks
|
|
249
|
+
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
|
|
248
251
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
249
252
|
with background-task completions from the same Pi host. Each row contains only
|
|
250
253
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
251
254
|
results and logs stay in durable tools and are never embedded in the callback.
|
|
252
255
|
Set `PI_BETTER_CALLBACK_BATCH_MS` to `0` through `5000` milliseconds to tune
|
|
253
|
-
the accumulation window; invalid values use 100 ms.
|
|
256
|
+
the accumulation window; invalid values use 100 ms. `agent_settled` schedules
|
|
257
|
+
the aggregate without starting a model run inside the lifecycle handler.
|
|
258
|
+
Completions arriving during that model run, including bounded overflow,
|
|
259
|
+
stay queued for the next available boundary. A failed handoff keeps the
|
|
254
260
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
255
261
|
pending. `callback:false` sends no model message; read the durable result later
|
|
256
262
|
with `subagent_result`.
|
|
@@ -647,10 +653,11 @@ only after a successful handoff and dedupe across reloads and repeated health ti
|
|
|
647
653
|
Persisted unmarked orphaned/lost states are recovered on the health ticker after
|
|
648
654
|
`/reload` even when process evidence does not produce a fresh transition.
|
|
649
655
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
follow-ups
|
|
653
|
-
|
|
656
|
+
Ordinary completion batching does not depend on Pi's `followUpMode`: events
|
|
657
|
+
stay in the harness until availability rather than becoming separate queued
|
|
658
|
+
follow-ups during the foreground run. Urgent health/failure alerts retain their
|
|
659
|
+
existing immediate follow-up behavior. This extension does not change Pi core
|
|
660
|
+
or Pi's default follow-up mode.
|
|
654
661
|
|
|
655
662
|
### Surfacing health (tools + passive widget)
|
|
656
663
|
|
package/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ import {
|
|
|
116
116
|
getSharedCapacityGate,
|
|
117
117
|
} from "./capacity.mjs";
|
|
118
118
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
119
|
-
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
119
|
+
import { cancelCallbackBatch, getCallbackBatcher, setCallbackBatchContext } from "./shared-callback-batcher.ts";
|
|
120
120
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
121
121
|
import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
|
|
122
122
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
@@ -146,6 +146,7 @@ import {
|
|
|
146
146
|
import { enforceRegistrySizeCapOnce, runDailyCleanupOnce } from "./cleanup.ts";
|
|
147
147
|
import { assertThinkingLevel, parseModelThinking, type ThinkingLevel } from "./thinking.ts";
|
|
148
148
|
import { createAgentOperations } from "./agent-operations.ts";
|
|
149
|
+
import { createAgentSessionSettings } from "./agent-session-settings.ts";
|
|
149
150
|
import {
|
|
150
151
|
clarifyCatalogRequest,
|
|
151
152
|
createLaunchEnricher,
|
|
@@ -1498,6 +1499,8 @@ function catalogRoleSchema(purpose: string) {
|
|
|
1498
1499
|
}
|
|
1499
1500
|
|
|
1500
1501
|
export default function (pi: ExtensionAPI) {
|
|
1502
|
+
const agentSessionSettings = createAgentSessionSettings(pi);
|
|
1503
|
+
let catalogModelRegistry: ExtensionContext["modelRegistry"] | undefined;
|
|
1501
1504
|
let delegationOverride: DelegationMode | undefined;
|
|
1502
1505
|
let capOverride: number | undefined;
|
|
1503
1506
|
const activeConcurrencyCap = (): number => capOverride ?? normalizeConcurrencyCap(loadConfig().maxConcurrent);
|
|
@@ -1588,8 +1591,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1588
1591
|
}
|
|
1589
1592
|
|
|
1590
1593
|
function catalogHostFrom(ctx: ExtensionContext): CatalogHost {
|
|
1594
|
+
if (typeof ctx.sessionManager?.getBranch === "function") agentSessionSettings.restore(ctx);
|
|
1595
|
+
catalogModelRegistry = ctx.modelRegistry;
|
|
1591
1596
|
const cfg = loadConfig();
|
|
1592
1597
|
return {
|
|
1598
|
+
sessionSettings: agentSessionSettings.snapshot(),
|
|
1599
|
+
getSessionSettings: () => agentSessionSettings.snapshot(),
|
|
1593
1600
|
cwd: ctx.cwd,
|
|
1594
1601
|
projectTrusted: typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false,
|
|
1595
1602
|
userRoot: defaultUserRoot(),
|
|
@@ -2245,6 +2252,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2245
2252
|
|
|
2246
2253
|
const agentOperations = createAgentOperations({
|
|
2247
2254
|
projectConfigDirName,
|
|
2255
|
+
sessionSettings: agentSessionSettings,
|
|
2256
|
+
editableModels: () => catalogModelRegistry?.getAvailable().map((model) => `${model.provider}/${model.id}`) ?? [],
|
|
2248
2257
|
propagateCommandContext(commandCtx) {
|
|
2249
2258
|
noteCatalogHost(catalogHostFrom(commandCtx));
|
|
2250
2259
|
},
|
|
@@ -2255,6 +2264,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2255
2264
|
hasUI: full.hasUI === true,
|
|
2256
2265
|
model: full.model,
|
|
2257
2266
|
modelRegistry: full.modelRegistry,
|
|
2267
|
+
sessionManager: full.sessionManager,
|
|
2258
2268
|
ui: full.ui ?? { select: async () => undefined },
|
|
2259
2269
|
isProjectTrusted: () => full.isProjectTrusted(),
|
|
2260
2270
|
} as ExtensionContext));
|
|
@@ -2263,6 +2273,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2263
2273
|
projectTrusted: full.isProjectTrusted(),
|
|
2264
2274
|
userRoot: defaultUserRoot(),
|
|
2265
2275
|
projectConfigDirName,
|
|
2276
|
+
sessionSettings: agentSessionSettings.snapshot(),
|
|
2266
2277
|
};
|
|
2267
2278
|
},
|
|
2268
2279
|
enrich: createLaunchEnricher(),
|
|
@@ -2329,6 +2340,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2329
2340
|
|
|
2330
2341
|
// ---- live-status lifecycle -----------------------------------------
|
|
2331
2342
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2343
|
+
setCallbackBatchContext(pi, ctx);
|
|
2332
2344
|
uiCtx = ctx;
|
|
2333
2345
|
mainAgentStartedAt = Date.now();
|
|
2334
2346
|
mainAgentTools.clear();
|
|
@@ -2361,12 +2373,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
2361
2373
|
});
|
|
2362
2374
|
|
|
2363
2375
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2376
|
+
setCallbackBatchContext(pi, ctx);
|
|
2364
2377
|
mainAgentStartedAt = undefined;
|
|
2365
2378
|
mainAgentTools.clear();
|
|
2366
2379
|
refreshBackgroundWorkNavigator(ctx);
|
|
2367
2380
|
});
|
|
2368
2381
|
|
|
2369
2382
|
pi.on("session_tree", async (_event, ctx) => {
|
|
2383
|
+
agentSessionSettings.restore(ctx);
|
|
2384
|
+
try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
|
|
2370
2385
|
restoreDelegationMode(ctx);
|
|
2371
2386
|
});
|
|
2372
2387
|
|
|
@@ -2374,6 +2389,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2374
2389
|
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
2375
2390
|
// "no background resources at load" rule.
|
|
2376
2391
|
pi.on("session_start", async (_event, ctx) => {
|
|
2392
|
+
setCallbackBatchContext(pi, ctx);
|
|
2393
|
+
agentSessionSettings.restore(ctx);
|
|
2377
2394
|
restoreDelegationMode(ctx);
|
|
2378
2395
|
uiCtx = ctx;
|
|
2379
2396
|
try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
|
|
@@ -2413,6 +2430,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2413
2430
|
});
|
|
2414
2431
|
|
|
2415
2432
|
pi.on("session_before_switch", () => {
|
|
2433
|
+
agentSessionSettings.clear();
|
|
2434
|
+
catalogModelRegistry = undefined;
|
|
2416
2435
|
activeCallbackOrigin = undefined;
|
|
2417
2436
|
cancelCallbackBatch(pi);
|
|
2418
2437
|
mainAgentStartedAt = undefined;
|
|
@@ -2426,6 +2445,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2426
2445
|
// running/orphaned children before dropping the session origin, so they
|
|
2427
2446
|
// cannot become live process groups with no coordinator.
|
|
2428
2447
|
stopCurrentSessionSubagents(ctx);
|
|
2448
|
+
agentSessionSettings.clear();
|
|
2449
|
+
catalogModelRegistry = undefined;
|
|
2429
2450
|
activeCallbackOrigin = undefined;
|
|
2430
2451
|
if (typeof unsubscribeDelegationRequest === "function") unsubscribeDelegationRequest();
|
|
2431
2452
|
cancelCallbackBatch(pi);
|
package/package.json
CHANGED
package/settings-page.ts
CHANGED
|
@@ -20,7 +20,12 @@ export interface SubagentSettingsHandlers {
|
|
|
20
20
|
reset(): void;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const labels = ["Delegation mode", "Concurrent subagents", "
|
|
23
|
+
const labels = ["Delegation mode", "Concurrent subagents", "Reset to defaults"];
|
|
24
|
+
|
|
25
|
+
function cell(text: string, width: number): string {
|
|
26
|
+
const clipped = Tui.truncateToWidth(text, width, "");
|
|
27
|
+
return clipped + " ".repeat(Math.max(0, width - Tui.visibleWidth(clipped)));
|
|
28
|
+
}
|
|
24
29
|
|
|
25
30
|
export function createSubagentSettingsPage(
|
|
26
31
|
theme: Theme,
|
|
@@ -52,9 +57,6 @@ export function createSubagentSettingsPage(
|
|
|
52
57
|
input.handleInput("\x05");
|
|
53
58
|
input.focused = focused;
|
|
54
59
|
report("");
|
|
55
|
-
} else if (row === 2) {
|
|
56
|
-
const result = handlers.save();
|
|
57
|
-
report(result.message, !result.ok);
|
|
58
60
|
} else {
|
|
59
61
|
handlers.reset();
|
|
60
62
|
report("Session settings reset to saved defaults.");
|
|
@@ -98,31 +100,41 @@ export function createSubagentSettingsPage(
|
|
|
98
100
|
else if (Tui.matchesKey(data, Tui.Key.down)) row = Math.min(labels.length - 1, row + 1);
|
|
99
101
|
else if (Tui.matchesKey(data, Tui.Key.enter) || data === " ") action();
|
|
100
102
|
else if (Tui.matchesKey(data, "ctrl+s")) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
action();
|
|
104
|
-
row = selected;
|
|
103
|
+
const result = handlers.save();
|
|
104
|
+
report(result.message, !result.ok);
|
|
105
105
|
}
|
|
106
106
|
requestRender();
|
|
107
107
|
},
|
|
108
108
|
render(width: number) {
|
|
109
109
|
const settings = handlers.get();
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
const w = Math.max(1, Math.floor(width));
|
|
111
|
+
const compact = w < 40;
|
|
112
|
+
const labelWidth = 26;
|
|
113
|
+
const valueWidth = Math.max(1, w - labelWidth - 1);
|
|
114
|
+
const current = [settings.mode, String(settings.maxConcurrent)];
|
|
115
|
+
const highlight = (text: string, selected: boolean): string => selected
|
|
116
|
+
? theme.bg("selectedBg", theme.bold(cell(text, w))) : text;
|
|
117
|
+
const marker = (selected: boolean) => selected ? "> " : " ";
|
|
118
|
+
const lines: string[] = [theme.fg("accent", theme.bold(" Subagent settings")), ""];
|
|
117
119
|
labels.forEach((label, index) => {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
+
const selected = index === row;
|
|
121
|
+
if (index === 2) lines.push("");
|
|
122
|
+
const title = theme.fg(selected ? "accent" : "text", marker(selected) + label);
|
|
123
|
+
if (index >= 2) {
|
|
124
|
+
lines.push(highlight(title, selected));
|
|
125
|
+
} else if (compact) {
|
|
126
|
+
lines.push(highlight(title, selected));
|
|
127
|
+
const active = editing && index === 1 ? input.render(Math.max(1, w - 2))[0]! : current[index]!;
|
|
128
|
+
lines.push(" " + (selected && !editing ? theme.inverse(active) : active));
|
|
129
|
+
} else {
|
|
130
|
+
const active = editing && index === 1 ? input.render(valueWidth)[0]! : cell(current[index]!, valueWidth);
|
|
131
|
+
lines.push(highlight(cell(title, labelWidth) + " " +
|
|
132
|
+
(selected && !editing ? theme.inverse(active) : theme.fg("text", active)), selected));
|
|
133
|
+
}
|
|
120
134
|
});
|
|
121
|
-
if (
|
|
122
|
-
lines.push("", theme.fg("
|
|
123
|
-
|
|
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), ""));
|
|
135
|
+
if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
|
|
136
|
+
lines.push("", theme.fg("dim", editing ? "Enter Apply · Esc Cancel" : "Up/Down Select · Space/Enter Change · ctrl+s Save default · Esc Back"));
|
|
137
|
+
return lines.map((line) => Tui.truncateToWidth(line, w, ""));
|
|
126
138
|
},
|
|
127
139
|
};
|
|
128
140
|
}
|
|
@@ -3,6 +3,8 @@ export type CallbackSource = "subagent" | "background-task";
|
|
|
3
3
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
4
|
|
|
5
5
|
export interface CallbackBatchHost {
|
|
6
|
+
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
7
|
+
events?: object;
|
|
6
8
|
sendMessage(
|
|
7
9
|
message: { customType: string; content: string; display: boolean },
|
|
8
10
|
options: Record<string, unknown>,
|
|
@@ -66,6 +68,8 @@ export interface UrgentCallbackEvent {
|
|
|
66
68
|
export interface CallbackBatcherOptions {
|
|
67
69
|
windowMs?: number;
|
|
68
70
|
retryMs?: number;
|
|
71
|
+
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
|
+
isAvailable?: () => boolean;
|
|
69
73
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
70
74
|
maxBytes?: number;
|
|
71
75
|
}
|
|
@@ -73,6 +77,10 @@ export interface CallbackBatcherOptions {
|
|
|
73
77
|
export interface CallbackBatcher {
|
|
74
78
|
enqueue(event: CallbackBatchEvent): boolean;
|
|
75
79
|
flush(): Promise<boolean>;
|
|
80
|
+
/** Refresh the API wrapper after reload without losing handoff receipts. */
|
|
81
|
+
setHost(host: CallbackBatchHost): void;
|
|
82
|
+
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
|
+
setAvailability(isAvailable: () => boolean): void;
|
|
76
84
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
77
85
|
cancel(): void;
|
|
78
86
|
pendingCount(): number;
|
|
@@ -95,6 +103,7 @@ interface PendingEvent {
|
|
|
95
103
|
|
|
96
104
|
interface SharedCallbackBatcherState {
|
|
97
105
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
|
+
bySession?: WeakMap<object, CallbackBatcher>;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -435,6 +444,19 @@ export function createCallbackBatcher(
|
|
|
435
444
|
let sequence = 0;
|
|
436
445
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
437
446
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
|
+
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
|
+
let gated = options.isAvailable !== undefined;
|
|
449
|
+
let awaitingRun = false;
|
|
450
|
+
|
|
451
|
+
const available = (): boolean => {
|
|
452
|
+
try {
|
|
453
|
+
if (!isAvailable()) {
|
|
454
|
+
awaitingRun = false;
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return !awaitingRun;
|
|
458
|
+
} catch { return false; }
|
|
459
|
+
};
|
|
438
460
|
|
|
439
461
|
const cancelTimer = (): void => {
|
|
440
462
|
if (timer) clearTimeout(timer);
|
|
@@ -443,10 +465,12 @@ export function createCallbackBatcher(
|
|
|
443
465
|
|
|
444
466
|
const schedule = (delayMs: number): void => {
|
|
445
467
|
if (timer || pending.size === 0) return;
|
|
468
|
+
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
+
const delay = available() ? delayMs : retryMs;
|
|
446
470
|
timer = setTimeout(() => {
|
|
447
471
|
timer = undefined;
|
|
448
472
|
void api.flush();
|
|
449
|
-
}, Math.max(
|
|
473
|
+
}, Math.max(1, delay));
|
|
450
474
|
timer.unref?.();
|
|
451
475
|
};
|
|
452
476
|
|
|
@@ -461,6 +485,10 @@ export function createCallbackBatcher(
|
|
|
461
485
|
|
|
462
486
|
const performFlush = async (): Promise<boolean> => {
|
|
463
487
|
cancelTimer();
|
|
488
|
+
if (!available()) {
|
|
489
|
+
schedule(retryMs);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
464
492
|
const snapshot = [...pending.entries()]
|
|
465
493
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
466
494
|
pending.clear();
|
|
@@ -509,6 +537,9 @@ export function createCallbackBatcher(
|
|
|
509
537
|
const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
|
|
510
538
|
|
|
511
539
|
try {
|
|
540
|
+
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
|
+
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
+
awaitingRun = gated;
|
|
512
543
|
await host.sendMessage(
|
|
513
544
|
{
|
|
514
545
|
customType: "background-completion-batch",
|
|
@@ -518,6 +549,7 @@ export function createCallbackBatcher(
|
|
|
518
549
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
519
550
|
);
|
|
520
551
|
} catch {
|
|
552
|
+
awaitingRun = false;
|
|
521
553
|
for (const [key] of deliverable) inFlight.delete(key);
|
|
522
554
|
const retryItems = [...deliverable, ...pending.entries()]
|
|
523
555
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
@@ -596,10 +628,19 @@ export function createCallbackBatcher(
|
|
|
596
628
|
const api: CallbackBatcher = {
|
|
597
629
|
enqueue,
|
|
598
630
|
flush,
|
|
631
|
+
setHost(next) { host = next; },
|
|
632
|
+
setAvailability(check) {
|
|
633
|
+
gated = true;
|
|
634
|
+
isAvailable = check;
|
|
635
|
+
available();
|
|
636
|
+
cancelTimer();
|
|
637
|
+
schedule(windowMs);
|
|
638
|
+
},
|
|
599
639
|
deliverUrgent,
|
|
600
640
|
cancel() {
|
|
601
641
|
cancelTimer();
|
|
602
642
|
pending.clear();
|
|
643
|
+
awaitingRun = false;
|
|
603
644
|
},
|
|
604
645
|
pendingCount() {
|
|
605
646
|
return pending.size;
|
|
@@ -613,16 +654,38 @@ export function getCallbackBatcher(
|
|
|
613
654
|
options: CallbackBatcherOptions = {},
|
|
614
655
|
): CallbackBatcher {
|
|
615
656
|
const state = globalState();
|
|
616
|
-
const key = host
|
|
657
|
+
const key = host.events ?? host;
|
|
617
658
|
const existing = state.byHost.get(key);
|
|
618
|
-
if (existing)
|
|
659
|
+
if (existing) {
|
|
660
|
+
existing.setHost(host);
|
|
661
|
+
return existing;
|
|
662
|
+
}
|
|
619
663
|
const created = createCallbackBatcher(host, options);
|
|
620
664
|
state.byHost.set(key, created);
|
|
621
665
|
return created;
|
|
622
666
|
}
|
|
623
667
|
|
|
624
668
|
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
625
|
-
globalState().byHost.get(host
|
|
669
|
+
const batcher = globalState().byHost.get(host.events ?? host);
|
|
670
|
+
batcher?.setAvailability(() => false);
|
|
671
|
+
batcher?.cancel();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Called on session_start, agent_start and agent_settled by both extensions. */
|
|
675
|
+
export function setCallbackBatchContext(
|
|
676
|
+
host: CallbackBatchHost,
|
|
677
|
+
ctx: { isIdle(): boolean; sessionManager?: object },
|
|
678
|
+
): void {
|
|
679
|
+
const state = globalState();
|
|
680
|
+
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
|
+
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
682
|
+
const batcher = shared ?? getCallbackBatcher(host);
|
|
683
|
+
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
|
+
state.byHost.set(host.events ?? host, batcher);
|
|
685
|
+
batcher.setHost(host);
|
|
686
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
687
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
626
689
|
}
|
|
627
690
|
|
|
628
691
|
function globalState(): SharedCallbackBatcherState {
|