pi-subagents 0.52.1 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -0
- package/docs/configuration.md +12 -2
- package/docs/extension-api.md +3 -1
- package/docs/models.md +17 -3
- package/docs/workflows.md +2 -0
- package/index.ts +10 -1
- package/package.json +2 -1
- package/prompts/council.md +51 -0
- package/skills/council-mode/SKILL.md +231 -0
- package/skills/pi-subagents/SKILL.md +5 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
- package/skills/pi-subagents/references/execution-controls.md +13 -0
- package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
- package/src/agents/agent-management.ts +107 -8
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +96 -37
- package/src/agents/builtin-names.ts +9 -0
- package/src/agents/runtime-agent-registry.ts +418 -0
- package/src/api/agents.ts +7 -0
- package/src/api/preflight.ts +8 -3
- package/src/extension/config.ts +3 -0
- package/src/extension/doctor.ts +11 -0
- package/src/extension/fanout-child.ts +3 -2
- package/src/extension/index.ts +20 -4
- package/src/extension/public-execution.ts +1 -1
- package/src/extension/rpc.ts +41 -1
- package/src/extension/schemas.ts +6 -3
- package/src/extension/tool-description.ts +2 -2
- package/src/extension/tool-result.ts +19 -0
- package/src/inspectors/herdr/client.ts +3 -3
- package/src/runs/background/async-execution.ts +12 -6
- package/src/runs/background/async-job-tracker.ts +4 -3
- package/src/runs/background/async-resume.ts +2 -1
- package/src/runs/background/async-retention.ts +1 -1
- package/src/runs/background/async-status-snapshot.ts +14 -5
- package/src/runs/background/auto-drain.ts +1 -0
- package/src/runs/background/chain-root-attachment.ts +5 -0
- package/src/runs/background/result-watcher.ts +9 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -0
- package/src/runs/background/subagent-runner.ts +32 -5
- package/src/runs/background/subagent-wait.ts +9 -5
- package/src/runs/background/terminal-run-index.ts +15 -6
- package/src/runs/background/wait-completions.ts +2 -0
- package/src/runs/background/wait-tool.ts +5 -3
- package/src/runs/foreground/execution.ts +34 -2
- package/src/runs/foreground/subagent-executor.ts +196 -52
- package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
- package/src/runs/shared/acceptance.ts +44 -1
- package/src/runs/shared/model-exclusions.ts +242 -0
- package/src/runs/shared/model-fallback.ts +72 -16
- package/src/runs/shared/model-scope.ts +106 -39
- package/src/runs/shared/pi-args.ts +34 -1
- package/src/runs/shared/subagent-control.ts +25 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
- package/src/shared/fork-context.ts +17 -1
- package/src/shared/model-info.ts +20 -0
- package/src/shared/settings.ts +2 -2
- package/src/shared/types.ts +47 -2
- package/src/slash/slash-commands.ts +20 -6
- package/src/slash/slash-live-state.ts +3 -3
- package/src/tui/fleet-status.ts +86 -1
- package/src/tui/fleet.ts +55 -2
- package/src/tui/render.ts +73 -3
- package/src/watchdog/permission-arbiter.ts +59 -51
- package/src/workflows/scripted-workflow.ts +100 -12
- package/src/workflows/workflow-receipt.ts +140 -0
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
|
|
17
17
|
|
|
18
|
-
export interface
|
|
18
|
+
export interface ModelScopeRule {
|
|
19
19
|
enforce?: boolean;
|
|
20
20
|
/** Reject inherited and fallback models outside the allowlist instead of warning. */
|
|
21
21
|
strict?: boolean;
|
|
@@ -23,6 +23,19 @@ export interface ModelScopeConfig {
|
|
|
23
23
|
allow?: string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface ModelScopeConfig extends ModelScopeRule {
|
|
27
|
+
/** Additional restrictions keyed by canonical agent name. */
|
|
28
|
+
agents?: Record<string, ModelScopeRule>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelScopeCheckRule extends ModelScopeRule {
|
|
32
|
+
origin?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResolvedModelScope extends ModelScopeCheckRule {
|
|
36
|
+
origin: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/** Where a resolved model originated, deciding enforcement severity. */
|
|
27
40
|
export type ModelSource = "explicit" | "inherited";
|
|
28
41
|
|
|
@@ -32,6 +45,7 @@ export interface ModelScopeViolation {
|
|
|
32
45
|
severity: "warn" | "error";
|
|
33
46
|
message: string;
|
|
34
47
|
allowedPatterns: string[];
|
|
48
|
+
origin: string;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
function stripThinkingSuffix(model: string): string {
|
|
@@ -61,7 +75,7 @@ export function matchesScopePattern(model: string, pattern: string): boolean {
|
|
|
61
75
|
*/
|
|
62
76
|
export function checkModelScope(
|
|
63
77
|
model: string | undefined,
|
|
64
|
-
scope:
|
|
78
|
+
scope: ModelScopeCheckRule | undefined,
|
|
65
79
|
source: ModelSource,
|
|
66
80
|
): ModelScopeViolation | undefined {
|
|
67
81
|
if (!model || !scope?.enforce) return undefined;
|
|
@@ -71,16 +85,89 @@ export function checkModelScope(
|
|
|
71
85
|
|
|
72
86
|
const baseModel = stripThinkingSuffix(model);
|
|
73
87
|
const severity: ModelScopeViolation["severity"] = source === "explicit" || scope.strict === true ? "error" : "warn";
|
|
88
|
+
const origin = scope.origin ?? "modelScope";
|
|
74
89
|
return {
|
|
75
90
|
model: baseModel,
|
|
76
91
|
severity,
|
|
77
92
|
allowedPatterns: allow,
|
|
93
|
+
origin,
|
|
78
94
|
message:
|
|
79
|
-
`Model '${baseModel}' is outside the configured subagent model scope. ` +
|
|
95
|
+
`Model '${baseModel}' is outside the configured subagent model scope (${origin}). ` +
|
|
80
96
|
`Allowed patterns: ${allow.join(", ")}.`,
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
100
|
+
function expandInheritPattern(pattern: string, parentModel: { provider: string; id: string } | undefined): string {
|
|
101
|
+
return pattern === "inherit" && parentModel ? `${parentModel.provider}/${parentModel.id}` : pattern;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertRecord(value: unknown, field: string, meta: { filePath: string }, expected = "an object"): Record<string, unknown> {
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
106
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected ${expected}.`);
|
|
107
|
+
}
|
|
108
|
+
return value as Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseAllowList(value: unknown, field: string, meta: { filePath: string }): string[] {
|
|
112
|
+
if (!Array.isArray(value)) {
|
|
113
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
|
|
114
|
+
}
|
|
115
|
+
const allow: string[] = [];
|
|
116
|
+
for (const entry of value) {
|
|
117
|
+
if (typeof entry !== "string") {
|
|
118
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
|
|
119
|
+
}
|
|
120
|
+
const trimmed = entry.trim();
|
|
121
|
+
if (trimmed) allow.push(trimmed);
|
|
122
|
+
}
|
|
123
|
+
if (allow.length === 0) {
|
|
124
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected a non-empty array of patterns.`);
|
|
125
|
+
}
|
|
126
|
+
return allow;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseScopeRule(input: Record<string, unknown>, field: string, meta: { filePath: string }): ModelScopeRule {
|
|
130
|
+
const rule: ModelScopeRule = {};
|
|
131
|
+
if ("enforce" in input) {
|
|
132
|
+
if (typeof input.enforce !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.enforce'; expected a boolean.`);
|
|
133
|
+
rule.enforce = input.enforce;
|
|
134
|
+
}
|
|
135
|
+
if ("strict" in input) {
|
|
136
|
+
if (typeof input.strict !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.strict'; expected a boolean.`);
|
|
137
|
+
rule.strict = input.strict;
|
|
138
|
+
}
|
|
139
|
+
if ("allow" in input) rule.allow = parseAllowList(input.allow, `${field}.allow`, meta);
|
|
140
|
+
return rule;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve the global and matching agent policies into independent launch-time checks. */
|
|
144
|
+
export function resolveModelScopesForAgent(
|
|
145
|
+
config: ModelScopeConfig | undefined,
|
|
146
|
+
agentName: string,
|
|
147
|
+
parentModel: { provider: string; id: string } | undefined,
|
|
148
|
+
): ResolvedModelScope[] {
|
|
149
|
+
if (!config) return [];
|
|
150
|
+
const scopes: ResolvedModelScope[] = [];
|
|
151
|
+
if (config.allow) {
|
|
152
|
+
scopes.push({
|
|
153
|
+
...(config.enforce !== undefined ? { enforce: config.enforce } : {}),
|
|
154
|
+
...(config.strict !== undefined ? { strict: config.strict } : {}),
|
|
155
|
+
allow: config.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
|
|
156
|
+
origin: "modelScope",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const agentScope = config.agents?.[agentName];
|
|
160
|
+
if (agentScope?.allow) {
|
|
161
|
+
scopes.push({
|
|
162
|
+
enforce: agentScope.enforce ?? config.enforce,
|
|
163
|
+
strict: agentScope.strict ?? config.strict,
|
|
164
|
+
allow: agentScope.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
|
|
165
|
+
origin: `modelScope.agents.${agentName}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return scopes;
|
|
169
|
+
}
|
|
170
|
+
|
|
84
171
|
/**
|
|
85
172
|
* Validate and normalize a raw `subagents.modelScope` value from settings.
|
|
86
173
|
* Throws a descriptive error for malformed configs (matching the surrounding
|
|
@@ -91,46 +178,26 @@ export function parseModelScopeConfig(
|
|
|
91
178
|
meta: { filePath: string },
|
|
92
179
|
): ModelScopeConfig | undefined {
|
|
93
180
|
if (value === undefined) return undefined;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if ("strict" in input) {
|
|
109
|
-
if (typeof input.strict !== "boolean") {
|
|
110
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.strict'; expected a boolean.`);
|
|
111
|
-
}
|
|
112
|
-
config.strict = input.strict;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if ("allow" in input) {
|
|
116
|
-
if (!Array.isArray(input.allow)) {
|
|
117
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
118
|
-
}
|
|
119
|
-
const allow: string[] = [];
|
|
120
|
-
for (const entry of input.allow) {
|
|
121
|
-
if (typeof entry !== "string") {
|
|
122
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
181
|
+
const input = assertRecord(value, "modelScope", meta);
|
|
182
|
+
const config: ModelScopeConfig = parseScopeRule(input, "modelScope", meta);
|
|
183
|
+
|
|
184
|
+
if ("agents" in input) {
|
|
185
|
+
const agents: Record<string, ModelScopeRule> = {};
|
|
186
|
+
for (const [rawName, rawScope] of Object.entries(assertRecord(input.agents, "modelScope.agents", meta, "an object keyed by agent name"))) {
|
|
187
|
+
const name = rawName.trim();
|
|
188
|
+
const field = `modelScope.agents.${name}`;
|
|
189
|
+
if (!name) throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.agents' key; expected a non-empty agent name.`);
|
|
190
|
+
const agentInput = assertRecord(rawScope, field, meta);
|
|
191
|
+
if ("agents" in agentInput) {
|
|
192
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.agents'; nested agent scopes are not supported.`);
|
|
123
193
|
}
|
|
124
|
-
|
|
125
|
-
if (trimmed) allow.push(trimmed);
|
|
126
|
-
}
|
|
127
|
-
if (allow.length === 0) {
|
|
128
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
|
|
194
|
+
agents[name] = parseScopeRule(agentInput, field, meta);
|
|
129
195
|
}
|
|
130
|
-
config.
|
|
196
|
+
config.agents = agents;
|
|
131
197
|
}
|
|
132
198
|
|
|
133
|
-
|
|
199
|
+
const hasAnyAllow = Boolean(config.allow?.length) || Object.values(config.agents ?? {}).some((scope) => Boolean(scope.allow?.length));
|
|
200
|
+
if (config.enforce === true && !hasAnyAllow) {
|
|
134
201
|
throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
|
|
135
202
|
}
|
|
136
203
|
|
|
@@ -191,6 +191,7 @@ export interface BuildPiArgsInput {
|
|
|
191
191
|
export interface BuildPiArgsResult {
|
|
192
192
|
args: string[];
|
|
193
193
|
env: Record<string, string | undefined>;
|
|
194
|
+
warnings: string[];
|
|
194
195
|
tempDir?: string;
|
|
195
196
|
toolDiagnosticPath?: string;
|
|
196
197
|
runtimeAcknowledgedExtensionsPath?: string;
|
|
@@ -251,6 +252,7 @@ export interface ResolvePiLaunchToolPlanInput {
|
|
|
251
252
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
252
253
|
inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
253
254
|
agentName?: string;
|
|
255
|
+
permissionRules?: PermissionRules;
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
export interface PiLaunchToolPlan {
|
|
@@ -271,6 +273,13 @@ export interface PiLaunchToolPlan {
|
|
|
271
273
|
extensionArgs: string[];
|
|
272
274
|
disableAmbientExtensions: boolean;
|
|
273
275
|
capabilityAudit?: SubagentCapabilityAudit;
|
|
276
|
+
/**
|
|
277
|
+
* Non-fatal footguns surfaced during plan resolution (currently: an agent
|
|
278
|
+
* `extensions: []` override, which disables ALL ambient extensions for the
|
|
279
|
+
* child rather than "adding nothing" — see the empty-extensions-override
|
|
280
|
+
* warning below). Callers may log these; they never change behavior.
|
|
281
|
+
*/
|
|
282
|
+
warnings: string[];
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
function extensionIdentifier(value: string): string {
|
|
@@ -288,6 +297,10 @@ function boundedExtensionIdentifiers(values: string[]): {
|
|
|
288
297
|
};
|
|
289
298
|
}
|
|
290
299
|
|
|
300
|
+
function hasPermissionRules(rules: PermissionRules | undefined): boolean {
|
|
301
|
+
return rules !== undefined && Object.keys(rules).length > 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
export function projectLaunchResolvedChildExtensions(
|
|
292
305
|
toolPlan: Pick<
|
|
293
306
|
PiLaunchToolPlan,
|
|
@@ -451,7 +464,9 @@ export function resolvePiLaunchToolPlan(
|
|
|
451
464
|
: [];
|
|
452
465
|
const permSystemExt = capabilityCeiling?.denyExtensions
|
|
453
466
|
? undefined
|
|
454
|
-
:
|
|
467
|
+
: hasPermissionRules(input.permissionRules)
|
|
468
|
+
? resolvePermissionSystemExtension()
|
|
469
|
+
: undefined;
|
|
455
470
|
const runtimeExtensions = [
|
|
456
471
|
PROMPT_RUNTIME_EXTENSION_PATH,
|
|
457
472
|
...(fanoutAuthorized ? [FANOUT_CHILD_EXTENSION_PATH] : []),
|
|
@@ -460,6 +475,21 @@ export function resolvePiLaunchToolPlan(
|
|
|
460
475
|
const disableAmbientExtensions =
|
|
461
476
|
capabilityCeiling?.denyExtensions === true ||
|
|
462
477
|
input.extensions !== undefined;
|
|
478
|
+
const warnings: string[] = [];
|
|
479
|
+
// An agent override that sets `extensions: []` reads like "add nothing", but
|
|
480
|
+
// any *defined* extensions list (including an empty one) disables every
|
|
481
|
+
// ambient extension for this child — not just skips adding extras. That
|
|
482
|
+
// silently strips load-bearing ambient extensions (e.g. a model provider
|
|
483
|
+
// extension), so a provider-qualified model pin becomes unresolvable in the
|
|
484
|
+
// child with no error pointing back at the override that caused it.
|
|
485
|
+
if (capabilityCeiling?.denyExtensions !== true && Array.isArray(input.extensions) && input.extensions.length === 0) {
|
|
486
|
+
const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
|
|
487
|
+
warnings.push(
|
|
488
|
+
`extensions: [] override${agentLabel} disables ALL ambient extensions for this child (not just "adds nothing"), `
|
|
489
|
+
+ "including any model-provider extension needed to resolve a provider-qualified model. "
|
|
490
|
+
+ "List the extensions this child actually needs instead of an empty array.",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
463
493
|
const configuredExtensions = capabilityCeiling?.denyExtensions
|
|
464
494
|
? []
|
|
465
495
|
: [
|
|
@@ -540,6 +570,7 @@ export function resolvePiLaunchToolPlan(
|
|
|
540
570
|
configuredExtensions,
|
|
541
571
|
extensionArgs,
|
|
542
572
|
disableAmbientExtensions,
|
|
573
|
+
warnings,
|
|
543
574
|
...(capabilityAudit ? { capabilityAudit } : {}),
|
|
544
575
|
};
|
|
545
576
|
}
|
|
@@ -587,6 +618,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
587
618
|
process.env[SUBAGENT_CAPABILITY_CEILING_ENV],
|
|
588
619
|
),
|
|
589
620
|
agentName: input.childAgentName,
|
|
621
|
+
permissionRules: input.permissionRules,
|
|
590
622
|
});
|
|
591
623
|
if (toolPlan.explicitToolAllowlist) {
|
|
592
624
|
args.push(
|
|
@@ -829,6 +861,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
829
861
|
return {
|
|
830
862
|
args,
|
|
831
863
|
env,
|
|
864
|
+
warnings: toolPlan.warnings,
|
|
832
865
|
tempDir,
|
|
833
866
|
toolDiagnosticPath,
|
|
834
867
|
runtimeAcknowledgedExtensionsPath,
|
|
@@ -17,6 +17,7 @@ const DEFAULT_NOTIFY_ON: ControlEventType[] = ["active_long_running", "needs_att
|
|
|
17
17
|
export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
|
|
18
18
|
enabled: true,
|
|
19
19
|
needsAttentionAfterMs: 60_000,
|
|
20
|
+
needsAttentionAfterMsIsExplicit: false,
|
|
20
21
|
activeNoticeAfterMs: 240_000,
|
|
21
22
|
failedToolAttemptsBeforeAttention: 3,
|
|
22
23
|
notifyOn: DEFAULT_NOTIFY_ON,
|
|
@@ -42,9 +43,13 @@ export function resolveControlConfig(
|
|
|
42
43
|
override?: ControlConfig,
|
|
43
44
|
): ResolvedControlConfig {
|
|
44
45
|
const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
|
|
45
|
-
const
|
|
46
|
-
|
|
46
|
+
const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
|
|
47
|
+
const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
|
|
48
|
+
const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
|
|
49
|
+
?? globalNeedsAttentionAfterMs
|
|
47
50
|
?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
|
|
51
|
+
const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
|
|
52
|
+
|| globalNeedsAttentionAfterMs !== undefined;
|
|
48
53
|
const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
|
|
49
54
|
?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
|
|
50
55
|
?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
|
|
@@ -64,6 +69,7 @@ export function resolveControlConfig(
|
|
|
64
69
|
return {
|
|
65
70
|
enabled,
|
|
66
71
|
needsAttentionAfterMs,
|
|
72
|
+
needsAttentionAfterMsIsExplicit,
|
|
67
73
|
activeNoticeAfterMs,
|
|
68
74
|
activeNoticeAfterTurns,
|
|
69
75
|
activeNoticeAfterTokens,
|
|
@@ -73,18 +79,34 @@ export function resolveControlConfig(
|
|
|
73
79
|
};
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
function scaledNeedsAttentionAfterMs(config: ResolvedControlConfig, thinking?: string | false): number {
|
|
83
|
+
if (config.needsAttentionAfterMsIsExplicit !== false) return config.needsAttentionAfterMs;
|
|
84
|
+
switch (thinking) {
|
|
85
|
+
case "medium":
|
|
86
|
+
return config.needsAttentionAfterMs * 2;
|
|
87
|
+
case "high":
|
|
88
|
+
return config.needsAttentionAfterMs * 5;
|
|
89
|
+
case "xhigh":
|
|
90
|
+
case "max":
|
|
91
|
+
return config.needsAttentionAfterMs * 10;
|
|
92
|
+
default:
|
|
93
|
+
return config.needsAttentionAfterMs;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
export function deriveActivityState(input: {
|
|
77
98
|
config: ResolvedControlConfig;
|
|
78
99
|
startedAt: number;
|
|
79
100
|
lastActivityAt?: number;
|
|
80
101
|
currentTool?: string;
|
|
102
|
+
thinking?: string | false;
|
|
81
103
|
now?: number;
|
|
82
104
|
}): ActivityState | undefined {
|
|
83
105
|
if (!input.config.enabled || input.currentTool) return undefined;
|
|
84
106
|
const now = input.now ?? Date.now();
|
|
85
107
|
const lastActivity = input.lastActivityAt ?? input.startedAt;
|
|
86
108
|
const ageMs = Math.max(0, now - lastActivity);
|
|
87
|
-
return ageMs > input.config.
|
|
109
|
+
return ageMs > scaledNeedsAttentionAfterMs(input.config, input.thinking) ? "needs_attention" : undefined;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
export function shouldEmitOpenToolAttention(input: {
|
|
@@ -21,7 +21,7 @@ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../
|
|
|
21
21
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
23
|
import { registerChildWatchdog } from "../../watchdog/register-child.ts";
|
|
24
|
-
import { CHILD_WATCHDOG_CONFIG_ENV } from "../../watchdog/child-status.ts";
|
|
24
|
+
import { CHILD_WATCHDOG_CONFIG_ENV, decodeChildWatchdogConfig } from "../../watchdog/child-status.ts";
|
|
25
25
|
import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
|
|
26
26
|
import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
|
|
27
27
|
import { resolveWaitToolConfig } from "../background/wait-config.ts";
|
|
@@ -296,14 +296,41 @@ export function registerPermissionGate(
|
|
|
296
296
|
const decision = permissionDecision(rules, toolName);
|
|
297
297
|
if (decision === "allow") return undefined;
|
|
298
298
|
if (decision === "deny") return { block: true, reason: `Blocked by pi-subagents permission rule: '${toolName}' is denied.` };
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
299
|
+
const rawWatchdogConfig = process.env[CHILD_WATCHDOG_CONFIG_ENV];
|
|
300
|
+
let timeoutMs = 30_000;
|
|
301
|
+
try {
|
|
302
|
+
timeoutMs = decodeChildWatchdogConfig(rawWatchdogConfig)?.agentEndTimeoutMs ?? timeoutMs;
|
|
303
|
+
} catch {
|
|
304
|
+
// The arbiter reports invalid configuration with the concrete decode error.
|
|
305
|
+
}
|
|
306
|
+
if (ctx.signal?.aborted) return { block: true, reason: "Blocked by pi-subagents permission rule: Watchdog permission decision was cancelled." };
|
|
307
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
308
|
+
let abort: (() => void) | undefined;
|
|
309
|
+
let result: WatchdogPermissionResult;
|
|
310
|
+
try {
|
|
311
|
+
result = await Promise.race([
|
|
312
|
+
requestPermission({
|
|
313
|
+
ctx,
|
|
314
|
+
toolName,
|
|
315
|
+
args: event.input ?? {},
|
|
316
|
+
rawWatchdogConfig,
|
|
317
|
+
auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
|
|
318
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
319
|
+
}),
|
|
320
|
+
new Promise<WatchdogPermissionResult>((resolve) => {
|
|
321
|
+
if (!ctx.signal) return;
|
|
322
|
+
abort = () => resolve({ approved: false, reason: "Watchdog permission decision was cancelled.", source: "watchdog" });
|
|
323
|
+
ctx.signal.addEventListener("abort", abort, { once: true });
|
|
324
|
+
}),
|
|
325
|
+
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Watchdog permission decision timed out after ${timeoutMs}ms.`)), timeoutMs); }),
|
|
326
|
+
]);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
329
|
+
return { block: true, reason: `Blocked by pi-subagents permission rule: Watchdog permission arbiter failed closed: ${reason}` };
|
|
330
|
+
} finally {
|
|
331
|
+
if (timeout) clearTimeout(timeout);
|
|
332
|
+
if (abort) ctx.signal?.removeEventListener("abort", abort);
|
|
333
|
+
}
|
|
307
334
|
if (result.approved) return undefined;
|
|
308
335
|
return { block: true, reason: `Blocked by pi-subagents permission rule: ${result.reason}` };
|
|
309
336
|
});
|
|
@@ -208,7 +208,23 @@ export function createForkContextResolver(
|
|
|
208
208
|
const openSession = options.openSession
|
|
209
209
|
?? sessionManager.openSession
|
|
210
210
|
?? ((file: string, dir?: string) => SessionManager.open(file, dir));
|
|
211
|
-
|
|
211
|
+
// Fork files must not land in the parent's top-level session directory.
|
|
212
|
+
// Pi's recent-session discovery (`pi -c` → findMostRecentSession) is
|
|
213
|
+
// non-recursive and picks the largest-mtime *.jsonl in that directory, so
|
|
214
|
+
// a still-running forked subagent — which keeps appending after the parent
|
|
215
|
+
// went idle — would hijack the next `pi -c` away from the conversation the
|
|
216
|
+
// user actually left. Nesting fork sessions in a per-parent directory keeps
|
|
217
|
+
// them invisible to that discovery; the `parentSession` header still
|
|
218
|
+
// records the tree relationship. The directory mirrors
|
|
219
|
+
// getSubagentSessionRoot() plus a "forks" level so fork files never sit
|
|
220
|
+
// loose next to run-N/ result directories. Derived from the file path
|
|
221
|
+
// rather than getSessionDir() so it also works when the manager cannot
|
|
222
|
+
// report its directory.
|
|
223
|
+
const sessionDir = path.join(
|
|
224
|
+
path.dirname(parentSessionFile),
|
|
225
|
+
path.basename(parentSessionFile, ".jsonl"),
|
|
226
|
+
"forks",
|
|
227
|
+
);
|
|
212
228
|
const cachedResolutions = new Map<number, ForkContextResolution>();
|
|
213
229
|
|
|
214
230
|
const resolveFork = (index = 0): ForkContextResolution => {
|
package/src/shared/model-info.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelCost } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
2
4
|
export type ThinkingLevel = typeof THINKING_LEVELS[number];
|
|
3
5
|
export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
|
|
@@ -9,6 +11,14 @@ export interface ModelInfo {
|
|
|
9
11
|
api?: string;
|
|
10
12
|
reasoning?: boolean;
|
|
11
13
|
thinkingLevelMap?: ThinkingLevelMap;
|
|
14
|
+
/** Context window in tokens, when the host model registry reports one. */
|
|
15
|
+
contextWindow?: number;
|
|
16
|
+
/** Maximum output tokens, when the host model registry reports one. */
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
/** Input modalities reported by the registry, e.g. ["text", "image"]. */
|
|
19
|
+
input?: string[];
|
|
20
|
+
/** Per-token pricing from the registry (USD per 1M tokens). */
|
|
21
|
+
cost?: ModelCost;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
interface RegistryModelLike {
|
|
@@ -17,6 +27,10 @@ interface RegistryModelLike {
|
|
|
17
27
|
api?: string;
|
|
18
28
|
reasoning?: boolean;
|
|
19
29
|
thinkingLevelMap?: ThinkingLevelMap;
|
|
30
|
+
contextWindow?: number;
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
input?: string[];
|
|
33
|
+
cost?: ModelCost;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export function toModelInfo(model: RegistryModelLike): ModelInfo {
|
|
@@ -27,6 +41,12 @@ export function toModelInfo(model: RegistryModelLike): ModelInfo {
|
|
|
27
41
|
api: model.api,
|
|
28
42
|
reasoning: model.reasoning,
|
|
29
43
|
thinkingLevelMap: model.thinkingLevelMap,
|
|
44
|
+
...(typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
|
|
45
|
+
...(typeof model.maxTokens === "number" && Number.isFinite(model.maxTokens) && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
|
|
46
|
+
...(Array.isArray(model.input) && model.input.length > 0 ? { input: [...model.input] } : {}),
|
|
47
|
+
...(model.cost && Number.isFinite(model.cost.input) && Number.isFinite(model.cost.output)
|
|
48
|
+
? { cost: { ...model.cost, ...(model.cost.tiers ? { tiers: model.cost.tiers.map((tier) => ({ ...tier })) } : {}) } }
|
|
49
|
+
: {}),
|
|
30
50
|
};
|
|
31
51
|
}
|
|
32
52
|
|
package/src/shared/settings.ts
CHANGED
|
@@ -282,7 +282,7 @@ export function resolveStepBehavior(
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
const outputMode = stepOverrides.outputMode ?? "inline";
|
|
285
|
+
const outputMode = stepOverrides.outputMode ?? agentConfig.outputMode ?? "inline";
|
|
286
286
|
const model = stepOverrides.model ?? agentConfig.model;
|
|
287
287
|
return { output, outputMode, reads, progress, skills, model };
|
|
288
288
|
}
|
|
@@ -465,7 +465,7 @@ export function resolveParallelBehaviors(
|
|
|
465
465
|
}
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
-
const outputMode = task.outputMode ?? "inline";
|
|
468
|
+
const outputMode = task.outputMode ?? config.outputMode ?? "inline";
|
|
469
469
|
const model = task.model ?? config.model;
|
|
470
470
|
return { output, outputMode, reads, progress, skills, model };
|
|
471
471
|
});
|
package/src/shared/types.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Message } from "@earendil-works/pi-ai";
|
|
|
8
8
|
import type { AgentConfig } from "../agents/agents.ts";
|
|
9
9
|
import type { FSWatcher } from "node:fs";
|
|
10
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type {
|
|
11
|
+
import type { ModelScopeRule } from "../runs/shared/model-scope.ts";
|
|
12
12
|
import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../runs/shared/capability-ceiling.ts";
|
|
13
13
|
import type { AuthorityPolicyConfig } from "../policy/authority.ts";
|
|
14
14
|
import type { GlobalMissionIndexRecord, MissionRecord, MissionStoreConfig } from "../missions/types.ts";
|
|
@@ -71,6 +71,29 @@ export interface WorkflowGraphSnapshot {
|
|
|
71
71
|
currentNodeId?: string;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export type WorkflowReceiptState = "complete" | "failed" | "paused" | "stopped";
|
|
75
|
+
|
|
76
|
+
type WorkflowReceiptEntryResumability =
|
|
77
|
+
| { latestRunId: string; resumability: { state: "resumable" } }
|
|
78
|
+
| { latestRunId?: string; resumability: { state: "not-resumable"; reason: string } };
|
|
79
|
+
|
|
80
|
+
export type WorkflowReceiptEntry = WorkflowReceiptEntryResumability & {
|
|
81
|
+
key: string;
|
|
82
|
+
agent?: string;
|
|
83
|
+
requestedContext?: "fresh" | "fork";
|
|
84
|
+
resolvedContext?: "fresh" | "fork" | "mixed";
|
|
85
|
+
outputReference?: string;
|
|
86
|
+
continuation: { runIds: string[] };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export interface WorkflowReceipt {
|
|
90
|
+
version: 1;
|
|
91
|
+
workflowRunId: string;
|
|
92
|
+
state: WorkflowReceiptState;
|
|
93
|
+
createdAt: number;
|
|
94
|
+
entries: Record<string, WorkflowReceiptEntry>;
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
export interface SavedOutputReference {
|
|
75
98
|
path: string;
|
|
76
99
|
bytes: number;
|
|
@@ -161,6 +184,7 @@ export interface ControlConfig {
|
|
|
161
184
|
export interface ResolvedControlConfig {
|
|
162
185
|
enabled: boolean;
|
|
163
186
|
needsAttentionAfterMs: number;
|
|
187
|
+
needsAttentionAfterMsIsExplicit?: boolean;
|
|
164
188
|
activeNoticeAfterMs: number;
|
|
165
189
|
activeNoticeAfterTurns?: number;
|
|
166
190
|
activeNoticeAfterTokens?: number;
|
|
@@ -529,6 +553,8 @@ export interface SteeringRecoveryDescriptor {
|
|
|
529
553
|
structuredOutputSchema?: JsonSchemaObject;
|
|
530
554
|
acceptance?: AcceptanceInput;
|
|
531
555
|
controlConfig?: ResolvedControlConfig;
|
|
556
|
+
/** Resolved launch context for this async child. */
|
|
557
|
+
context?: "fresh" | "fork";
|
|
532
558
|
/** Raw per-run bridge override. Omitted descriptors continue to use global config. */
|
|
533
559
|
intercomBridge?: IntercomBridgeConfig;
|
|
534
560
|
absoluteDeadlineAt?: number;
|
|
@@ -931,6 +957,13 @@ export interface SingleResult {
|
|
|
931
957
|
modelAttempts?: ModelAttempt[];
|
|
932
958
|
controlEvents?: ControlEvent[];
|
|
933
959
|
error?: string;
|
|
960
|
+
/**
|
|
961
|
+
* True when the dispatch failed because the input exceeded the model's
|
|
962
|
+
* context window. The model fallback loop stops immediately (retrying the
|
|
963
|
+
* same input on another model cannot succeed). Callers should treat this as
|
|
964
|
+
* a signal to reduce input size or re-decompose the task.
|
|
965
|
+
*/
|
|
966
|
+
contextOverflow?: boolean;
|
|
934
967
|
protocolError?: ProtocolOutputLimit;
|
|
935
968
|
sessionFile?: string;
|
|
936
969
|
skills?: string[];
|
|
@@ -996,6 +1029,7 @@ export interface WaitCompletionChild {
|
|
|
996
1029
|
outputState?: SubagentOutputState;
|
|
997
1030
|
error?: string;
|
|
998
1031
|
model?: string;
|
|
1032
|
+
contextOverflow?: boolean;
|
|
999
1033
|
artifactPaths?: Partial<ArtifactPaths>;
|
|
1000
1034
|
}
|
|
1001
1035
|
|
|
@@ -1099,6 +1133,8 @@ export interface Details {
|
|
|
1099
1133
|
}>;
|
|
1100
1134
|
emits: unknown[];
|
|
1101
1135
|
console: Array<{ level: "log" | "info" | "warn" | "error"; text: string }>;
|
|
1136
|
+
/** Terminal keyed child receipt. Async workflows also persist it beside status.json. */
|
|
1137
|
+
receipt?: WorkflowReceipt;
|
|
1102
1138
|
};
|
|
1103
1139
|
chatProgress?: {
|
|
1104
1140
|
mode: "off" | "live-card";
|
|
@@ -1468,6 +1504,8 @@ export interface AsyncStatus {
|
|
|
1468
1504
|
thinking?: string;
|
|
1469
1505
|
attemptedModels?: string[];
|
|
1470
1506
|
modelAttempts?: ModelAttempt[];
|
|
1507
|
+
/** True when the child input exceeded the model context window. */
|
|
1508
|
+
contextOverflow?: boolean;
|
|
1471
1509
|
totalCost?: CostSummary;
|
|
1472
1510
|
steering?: SteeringStatus;
|
|
1473
1511
|
error?: string;
|
|
@@ -1862,7 +1900,7 @@ export interface RunSyncOptions {
|
|
|
1862
1900
|
/** Current parent-session provider to prefer for ambiguous bare model ids */
|
|
1863
1901
|
preferredModelProvider?: string;
|
|
1864
1902
|
/** Optional subagent model-scope enforcement for fallback candidates */
|
|
1865
|
-
modelScope?:
|
|
1903
|
+
modelScope?: ModelScopeRule | ModelScopeRule[];
|
|
1866
1904
|
/** Skills to make available (overrides agent default if provided) */
|
|
1867
1905
|
skills?: string[];
|
|
1868
1906
|
structuredOutput?: {
|
|
@@ -2026,6 +2064,13 @@ export interface ExtensionConfig {
|
|
|
2026
2064
|
/** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
|
|
2027
2065
|
artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
|
|
2028
2066
|
intercomBridge?: IntercomBridgeConfig;
|
|
2067
|
+
/** Control how slow result-index scans are logged. Defaults to \"activity\".
|
|
2068
|
+
* - \"all\": log every slow scan, including scans that find nothing.
|
|
2069
|
+
* - \"activity\": log only slow scans that found or scheduled work. Silences
|
|
2070
|
+
* the periodic healthy rescan that inspects zero files while no async runs
|
|
2071
|
+
* are pending, which otherwise spams the session transcript.
|
|
2072
|
+
* - \"off\": never log slow result-index scans. */
|
|
2073
|
+
resultScanLogging?: "all" | "activity" | "off";
|
|
2029
2074
|
proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
|
|
2030
2075
|
scheduledRuns?: ScheduledRunsConfig;
|
|
2031
2076
|
/** Durable mission behavior. Missions are automatic by default; set enabled:false to disable auto-create. Explicit mission actions/fields still work. */
|