@narumitw/pi-subagents 0.49.2 → 0.51.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.
Files changed (81) hide show
  1. package/README.md +313 -53
  2. package/package.json +11 -8
  3. package/src/adaptive-scheduler.ts +196 -0
  4. package/src/admission-benchmark.ts +95 -0
  5. package/src/admission-policy.ts +78 -0
  6. package/src/agent-projection.ts +53 -0
  7. package/src/agents.ts +58 -1
  8. package/src/auto-transport.ts +114 -0
  9. package/src/blocking-status.ts +63 -0
  10. package/src/capabilities.ts +145 -0
  11. package/src/capability-grant.ts +115 -0
  12. package/src/capability-router.ts +107 -0
  13. package/src/completion-delivery.ts +257 -0
  14. package/src/config-status.ts +221 -0
  15. package/src/config-ui.ts +215 -236
  16. package/src/consult-resources.ts +4 -27
  17. package/src/consult.ts +9 -1
  18. package/src/create-stateful-transport.ts +55 -0
  19. package/src/delegation-contract.ts +417 -0
  20. package/src/execution-plan.ts +322 -0
  21. package/src/execution-profiles.ts +95 -0
  22. package/src/execution-ui.ts +320 -0
  23. package/src/execution.ts +848 -158
  24. package/src/in-process-transport.ts +269 -25
  25. package/src/inspect-render.ts +101 -1
  26. package/src/inspect.ts +296 -3
  27. package/src/integration-controller.ts +98 -0
  28. package/src/limits.ts +3 -0
  29. package/src/orchestration-metrics.ts +78 -0
  30. package/src/outcome.ts +61 -0
  31. package/src/panel-child-group.ts +35 -0
  32. package/src/panel-contract.ts +343 -0
  33. package/src/panel-evidence.ts +59 -0
  34. package/src/panel-execution.ts +772 -0
  35. package/src/panel-failure.ts +56 -0
  36. package/src/panel-planning.ts +175 -0
  37. package/src/panel-prompts.ts +132 -0
  38. package/src/panel-reconciliation.ts +57 -0
  39. package/src/panel-render.ts +103 -0
  40. package/src/parallel-limit-ui.ts +112 -0
  41. package/src/params.ts +172 -3
  42. package/src/persistence.ts +182 -32
  43. package/src/prompt-resources.ts +38 -0
  44. package/src/registry-types.ts +175 -0
  45. package/src/registry.ts +466 -143
  46. package/src/render.ts +72 -6
  47. package/src/result-contract.ts +416 -0
  48. package/src/retained-semantic-state.ts +100 -0
  49. package/src/rpc-timeout-finalization.ts +207 -0
  50. package/src/rpc-transport-metadata.ts +65 -0
  51. package/src/rpc-transport.ts +990 -0
  52. package/src/rpc-turn-capture.ts +142 -0
  53. package/src/runner-result.ts +55 -0
  54. package/src/runner-usage.ts +48 -0
  55. package/src/runner.ts +325 -73
  56. package/src/semantic-snapshot.ts +214 -0
  57. package/src/settings.ts +254 -35
  58. package/src/spawn-idempotency.ts +61 -0
  59. package/src/stateful-config.ts +13 -0
  60. package/src/stateful-guidance.ts +1 -0
  61. package/src/stateful-lifecycle.ts +45 -2
  62. package/src/stateful-limit-ui.ts +246 -0
  63. package/src/stateful-limits.ts +96 -0
  64. package/src/stateful-prompt.ts +11 -2
  65. package/src/stateful-render.ts +48 -3
  66. package/src/stateful.ts +467 -357
  67. package/src/subagents.ts +114 -46
  68. package/src/subprocess-transport.ts +64 -5
  69. package/src/supervision.ts +103 -0
  70. package/src/timeout-checkpoint.ts +305 -0
  71. package/src/timeout-finalization.ts +75 -0
  72. package/src/transport-types.ts +68 -0
  73. package/src/transport-ui.ts +169 -0
  74. package/src/transport.ts +16 -4
  75. package/src/turn-budget.ts +109 -0
  76. package/src/verification-policy.ts +17 -0
  77. package/src/work-item-ledger.ts +682 -0
  78. package/src/work-item-persistence.ts +218 -0
  79. package/src/workflow-planning.ts +150 -0
  80. package/src/workflow-ui.ts +61 -0
  81. package/src/workspace.ts +69 -12
@@ -0,0 +1,214 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export const SEMANTIC_SNAPSHOT_VERSION = "pi-subagents:semantic-snapshot:v1" as const;
10
+ const SEMANTIC_COMPONENT_KEYS = [
11
+ "agent",
12
+ "rolePrompt",
13
+ "tools",
14
+ "model",
15
+ "thinkingLevel",
16
+ "transport",
17
+ "trust",
18
+ "repository",
19
+ "artifacts",
20
+ "workflowGeneration",
21
+ "schedulerPolicy",
22
+ ] as const;
23
+
24
+ export interface SemanticSnapshotInput {
25
+ agentName: string;
26
+ agentManifest?: unknown;
27
+ rolePrompt: string;
28
+ tools?: string[];
29
+ model?: string;
30
+ thinkingLevel?: string;
31
+ transport: string;
32
+ trust: { kind: string; projectTrusted: boolean };
33
+ repository: { kind: string; generation: string };
34
+ artifacts: Record<string, string>;
35
+ workflowGeneration: number;
36
+ schedulerPolicy: string;
37
+ }
38
+
39
+ export interface SemanticSnapshot {
40
+ version: typeof SEMANTIC_SNAPSHOT_VERSION;
41
+ digest: string;
42
+ components: {
43
+ agent: string;
44
+ rolePrompt: string;
45
+ tools: string;
46
+ model: string;
47
+ thinkingLevel: string;
48
+ transport: string;
49
+ trust: string;
50
+ repository: string;
51
+ artifacts: string;
52
+ workflowGeneration: string;
53
+ schedulerPolicy: string;
54
+ };
55
+ }
56
+
57
+ export interface RepositoryGeneration {
58
+ kind: "git-commit" | "git-dirty" | "filesystem";
59
+ generation: string;
60
+ }
61
+
62
+ export interface SemanticCompatibility {
63
+ status: "compatible" | "warning" | "needs-revalidation" | "rejected";
64
+ changedComponents: string[];
65
+ }
66
+
67
+ export async function captureRepositoryGeneration(cwd: string): Promise<RepositoryGeneration> {
68
+ const resolved = await fs.promises.realpath(path.resolve(cwd));
69
+ try {
70
+ const result = await execFileAsync(
71
+ "git",
72
+ ["-C", resolved, "status", "--porcelain=v2", "--branch", "--untracked-files=all"],
73
+ { encoding: "utf8", timeout: 2_000, maxBuffer: 256 * 1024 },
74
+ );
75
+ const lines = result.stdout.split("\n");
76
+ const head = lines
77
+ .find((line) => line.startsWith("# branch.oid "))
78
+ ?.slice("# branch.oid ".length)
79
+ .trim();
80
+ if (!head || head === "(initial)") throw new Error("Repository has no stable HEAD");
81
+ const status = lines.filter((line) => line && !line.startsWith("# ")).join("\n");
82
+ return status
83
+ ? { kind: "git-dirty", generation: digest({ head, status }) }
84
+ : { kind: "git-commit", generation: head };
85
+ } catch {
86
+ return {
87
+ kind: "filesystem",
88
+ generation: digest({ path: resolved, failClosedNonce: randomUUID() }),
89
+ };
90
+ }
91
+ }
92
+
93
+ export async function captureSemanticResourceGeneration(paths: string[]): Promise<string> {
94
+ const entries: Array<{ path: string; digest: string }> = [];
95
+ let totalBytes = 0;
96
+ const visit = async (candidate: string, label: string): Promise<void> => {
97
+ if (entries.length >= 256 || totalBytes > 1024 * 1024) {
98
+ throw new Error("Semantic resource snapshot exceeds bounds");
99
+ }
100
+ let stat: fs.Stats;
101
+ try {
102
+ stat = await fs.promises.lstat(candidate);
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
105
+ throw error;
106
+ }
107
+ if (stat.isSymbolicLink()) return;
108
+ if (stat.isDirectory()) {
109
+ const children = (await fs.promises.readdir(candidate)).sort();
110
+ for (const child of children) await visit(path.join(candidate, child), `${label}/${child}`);
111
+ return;
112
+ }
113
+ if (!stat.isFile()) return;
114
+ totalBytes += stat.size;
115
+ if (totalBytes > 1024 * 1024) throw new Error("Semantic resource snapshot exceeds bounds");
116
+ entries.push({ path: label, digest: digestBytes(await fs.promises.readFile(candidate)) });
117
+ };
118
+ try {
119
+ for (const [index, candidate] of paths.entries()) {
120
+ await visit(path.resolve(candidate), `resource-${index}`);
121
+ }
122
+ return digest(entries);
123
+ } catch {
124
+ return digest({ failClosedNonce: randomUUID() });
125
+ }
126
+ }
127
+
128
+ export function captureSemanticSnapshot(input: SemanticSnapshotInput): SemanticSnapshot {
129
+ if (!Number.isSafeInteger(input.workflowGeneration) || input.workflowGeneration < 0) {
130
+ throw new Error("workflowGeneration must be a non-negative safe integer");
131
+ }
132
+ const components = {
133
+ agent: digest({ name: input.agentName, manifest: input.agentManifest ?? null }),
134
+ rolePrompt: digest(input.rolePrompt),
135
+ tools: digest([...(input.tools ?? [])].sort()),
136
+ model: digest(input.model ?? null),
137
+ thinkingLevel: digest(input.thinkingLevel ?? null),
138
+ transport: digest(input.transport),
139
+ trust: digest(input.trust),
140
+ repository: digest(input.repository),
141
+ artifacts: digest(input.artifacts),
142
+ workflowGeneration: digest(input.workflowGeneration),
143
+ schedulerPolicy: digest(input.schedulerPolicy),
144
+ };
145
+ return {
146
+ version: SEMANTIC_SNAPSHOT_VERSION,
147
+ digest: digest(components),
148
+ components,
149
+ };
150
+ }
151
+
152
+ export function isSemanticSnapshot(value: unknown): value is SemanticSnapshot {
153
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
154
+ const snapshot = value as Partial<SemanticSnapshot>;
155
+ return (
156
+ snapshot.version === SEMANTIC_SNAPSHOT_VERSION &&
157
+ typeof snapshot.digest === "string" &&
158
+ /^[a-f0-9]{64}$/u.test(snapshot.digest) &&
159
+ Boolean(snapshot.components) &&
160
+ Object.keys(snapshot.components ?? {}).length === SEMANTIC_COMPONENT_KEYS.length &&
161
+ SEMANTIC_COMPONENT_KEYS.every((key) =>
162
+ /^[a-f0-9]{64}$/u.test(snapshot.components?.[key] ?? ""),
163
+ ) &&
164
+ digest(snapshot.components) === snapshot.digest
165
+ );
166
+ }
167
+
168
+ export function evaluateSemanticCompatibility(
169
+ previous: SemanticSnapshot,
170
+ current: SemanticSnapshot,
171
+ ): SemanticCompatibility {
172
+ if (
173
+ previous.version !== SEMANTIC_SNAPSHOT_VERSION ||
174
+ current.version !== SEMANTIC_SNAPSHOT_VERSION
175
+ ) {
176
+ return { status: "rejected", changedComponents: ["version"] };
177
+ }
178
+ if (!isSemanticSnapshot(previous) || !isSemanticSnapshot(current)) {
179
+ return { status: "rejected", changedComponents: ["invalid-snapshot"] };
180
+ }
181
+ if (previous.digest === current.digest) {
182
+ return { status: "compatible", changedComponents: [] };
183
+ }
184
+ const changedComponents = SEMANTIC_COMPONENT_KEYS.filter(
185
+ (key) => previous.components[key] !== current.components[key],
186
+ ).sort();
187
+ const warningOnly = changedComponents.every((key) => key === "schedulerPolicy");
188
+ return {
189
+ status: warningOnly ? "warning" : "needs-revalidation",
190
+ changedComponents,
191
+ };
192
+ }
193
+
194
+ function digest(value: unknown): string {
195
+ return digestBytes(stableStringify(value));
196
+ }
197
+
198
+ function digestBytes(value: string | Buffer): string {
199
+ return createHash("sha256").update(value).digest("hex");
200
+ }
201
+
202
+ function stableStringify(value: unknown): string {
203
+ return JSON.stringify(sortValue(value));
204
+ }
205
+
206
+ function sortValue(value: unknown): unknown {
207
+ if (Array.isArray(value)) return value.map(sortValue);
208
+ if (!value || typeof value !== "object") return value;
209
+ return Object.fromEntries(
210
+ Object.entries(value as Record<string, unknown>)
211
+ .sort(([left], [right]) => left.localeCompare(right))
212
+ .map(([key, entry]) => [key, sortValue(entry)]),
213
+ );
214
+ }
package/src/settings.ts CHANGED
@@ -16,8 +16,21 @@ import {
16
16
  type SubagentAgentConfig,
17
17
  type SubagentSettings,
18
18
  type SubagentThinkingLevel,
19
+ type SubagentTransportKind,
19
20
  } from "./agents.js";
20
- import { MAX_SUBAGENT_TIMEOUT_MS } from "./limits.js";
21
+ import {
22
+ DEFAULT_MAX_PARALLEL_TASKS,
23
+ MAX_CONFIGURABLE_PARALLEL_TASKS,
24
+ MAX_SUBAGENT_TIMEOUT_MS,
25
+ } from "./limits.js";
26
+ import {
27
+ isValidStatefulLimit,
28
+ resolveStatefulLimits,
29
+ STATEFUL_LIMIT_FIELDS,
30
+ type StatefulLimitField,
31
+ type StatefulLimits,
32
+ statefulLimitDefinition,
33
+ } from "./stateful-limits.js";
21
34
 
22
35
  export function hasOwn(obj: object, key: PropertyKey): boolean {
23
36
  return Object.hasOwn(obj, key);
@@ -39,10 +52,6 @@ function isPositiveInteger(value: unknown): value is number {
39
52
  return isPositiveNumber(value) && Number.isSafeInteger(value);
40
53
  }
41
54
 
42
- function isNonNegativeInteger(value: unknown): value is number {
43
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
44
- }
45
-
46
55
  export function normalizeAgentSettings(value: unknown): SubagentAgentConfig | undefined {
47
56
  if (!isPlainObject(value)) return undefined;
48
57
 
@@ -100,13 +109,27 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
100
109
  if (typeof value.blocking.enabled !== "boolean") return undefined;
101
110
  blocking.enabled = value.blocking.enabled;
102
111
  }
112
+ if (hasOwn(value.blocking, "maxParallelTasks")) {
113
+ if (
114
+ !isPositiveInteger(value.blocking.maxParallelTasks) ||
115
+ value.blocking.maxParallelTasks > MAX_CONFIGURABLE_PARALLEL_TASKS
116
+ ) {
117
+ return undefined;
118
+ }
119
+ blocking.maxParallelTasks = value.blocking.maxParallelTasks;
120
+ }
103
121
  settings.blocking = blocking;
104
122
  }
105
123
  if (hasOwn(value, "stateful")) {
106
124
  if (!isPlainObject(value.stateful)) return undefined;
107
125
  const runtime: NonNullable<SubagentSettings["stateful"]> = {};
108
126
  if (hasOwn(value.stateful, "transport")) {
109
- if (value.stateful.transport !== "subprocess" && value.stateful.transport !== "in-process") {
127
+ if (
128
+ value.stateful.transport !== "subprocess" &&
129
+ value.stateful.transport !== "in-process" &&
130
+ value.stateful.transport !== "rpc" &&
131
+ value.stateful.transport !== "auto"
132
+ ) {
110
133
  return undefined;
111
134
  }
112
135
  runtime.transport = value.stateful.transport;
@@ -120,23 +143,17 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
120
143
  }
121
144
  runtime.completionDelivery = value.stateful.completionDelivery;
122
145
  }
123
- for (const key of [
124
- "maxAgents",
125
- "maxActiveTurns",
126
- "maxChildrenPerAgent",
127
- "maxMailboxMessages",
128
- "maxMailboxMessageBytes",
129
- "idleTtlMs",
130
- "maxStoredAgents",
131
- ] as const) {
146
+ for (const key of STATEFUL_LIMIT_FIELDS) {
132
147
  if (hasOwn(value.stateful, key)) {
133
- if (!isPositiveInteger(value.stateful[key])) return undefined;
148
+ if (!isValidStatefulLimit(key, value.stateful[key])) return undefined;
134
149
  runtime[key] = value.stateful[key];
135
150
  }
136
151
  }
137
- if (hasOwn(value.stateful, "maxDepth")) {
138
- if (!isNonNegativeInteger(value.stateful.maxDepth)) return undefined;
139
- runtime.maxDepth = value.stateful.maxDepth;
152
+ for (const key of ["maxMailboxMessages", "maxMailboxMessageBytes", "idleTtlMs"] as const) {
153
+ if (hasOwn(value.stateful, key)) {
154
+ if (!isPositiveInteger(value.stateful[key])) return undefined;
155
+ runtime[key] = value.stateful[key];
156
+ }
140
157
  }
141
158
  if (hasOwn(value.stateful, "retentionDays")) {
142
159
  if (!isPositiveNumber(value.stateful.retentionDays)) return undefined;
@@ -283,6 +300,32 @@ export interface CompletionDeliverySettingsSnapshot {
283
300
  error?: string;
284
301
  }
285
302
 
303
+ export interface StatefulTransportSettingsSnapshot {
304
+ path: string;
305
+ value: SubagentTransportKind;
306
+ source: "default" | "user settings";
307
+ error?: string;
308
+ }
309
+
310
+ export interface BlockingParallelLimitSettingsSnapshot {
311
+ path: string;
312
+ value: number;
313
+ source: "default" | "user settings";
314
+ error?: string;
315
+ }
316
+
317
+ export interface StatefulLimitFieldSnapshot {
318
+ value: number;
319
+ source: "default" | "user settings";
320
+ }
321
+
322
+ export interface StatefulLimitSettingsSnapshot {
323
+ path: string;
324
+ writePath: string;
325
+ values?: Record<StatefulLimitField, StatefulLimitFieldSnapshot>;
326
+ error?: string;
327
+ }
328
+
286
329
  export interface ConsultResourceSettingsSnapshot {
287
330
  path: string;
288
331
  value: ConsultResourcePolicy;
@@ -462,6 +505,71 @@ export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsS
462
505
  };
463
506
  }
464
507
 
508
+ export function inspectStatefulTransportSettings(): StatefulTransportSettingsSnapshot {
509
+ const inspected = inspectSubagentSettingsDocument();
510
+ if (!inspected.raw || !inspected.settings) {
511
+ return {
512
+ path: inspected.path,
513
+ value: "subprocess",
514
+ source: "default",
515
+ ...(inspected.error ? { error: inspected.error } : {}),
516
+ };
517
+ }
518
+ const explicit =
519
+ isPlainObject(inspected.raw.stateful) && hasOwn(inspected.raw.stateful, "transport");
520
+ return {
521
+ path: inspected.path,
522
+ value: inspected.settings.stateful?.transport ?? "subprocess",
523
+ source: explicit ? "user settings" : "default",
524
+ };
525
+ }
526
+
527
+ export function resolveBlockingMaxParallelTasks(settings?: SubagentSettings): number {
528
+ return settings?.blocking?.maxParallelTasks ?? DEFAULT_MAX_PARALLEL_TASKS;
529
+ }
530
+
531
+ export function inspectBlockingParallelLimitSettings(): BlockingParallelLimitSettingsSnapshot {
532
+ const inspected = inspectSubagentSettingsDocument();
533
+ if (!inspected.raw || !inspected.settings) {
534
+ return {
535
+ path: inspected.path,
536
+ value: DEFAULT_MAX_PARALLEL_TASKS,
537
+ source: "default",
538
+ ...(inspected.error ? { error: inspected.error } : {}),
539
+ };
540
+ }
541
+ const explicit =
542
+ isPlainObject(inspected.raw.blocking) && hasOwn(inspected.raw.blocking, "maxParallelTasks");
543
+ return {
544
+ path: inspected.path,
545
+ value: resolveBlockingMaxParallelTasks(inspected.settings),
546
+ source: explicit ? "user settings" : "default",
547
+ };
548
+ }
549
+
550
+ export function inspectStatefulLimitSettings(): StatefulLimitSettingsSnapshot {
551
+ const inspected = inspectSubagentSettingsDocument();
552
+ const writePath = subagentSettingsFilePath();
553
+ if (inspected.error) {
554
+ return { path: inspected.path, writePath, error: inspected.error };
555
+ }
556
+ const resolved = resolveStatefulLimits(inspected.settings?.stateful);
557
+ const rawStateful = isPlainObject(inspected.raw?.stateful) ? inspected.raw.stateful : undefined;
558
+ return {
559
+ path: inspected.path,
560
+ writePath,
561
+ values: Object.fromEntries(
562
+ STATEFUL_LIMIT_FIELDS.map((field) => [
563
+ field,
564
+ {
565
+ value: resolved[field],
566
+ source: rawStateful && hasOwn(rawStateful, field) ? "user settings" : "default",
567
+ },
568
+ ]),
569
+ ) as unknown as Record<StatefulLimitField, StatefulLimitFieldSnapshot>,
570
+ };
571
+ }
572
+
465
573
  export function updateDelegationWorkflowSetting(
466
574
  value: Exclude<DelegationWorkflow, "disabled">,
467
575
  ): void {
@@ -493,6 +601,27 @@ export function updateDelegationWorkflowSetting(
493
601
  });
494
602
  }
495
603
 
604
+ export function updateStatefulTransportSetting(value: SubagentTransportKind): void {
605
+ if (!["subprocess", "in-process", "rpc", "auto"].includes(value)) {
606
+ throw new Error(`Unsupported stateful transport: ${value}`);
607
+ }
608
+ withSettingsMutationLock(() => {
609
+ const update = readSettingsObjectForUpdate();
610
+ const raw = update.document;
611
+ const stateful = raw.stateful;
612
+ if (stateful !== undefined && !isPlainObject(stateful)) {
613
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
614
+ }
615
+ writeSettingsObjectUnlocked(
616
+ {
617
+ ...raw,
618
+ stateful: { ...(stateful ?? {}), transport: value },
619
+ },
620
+ update.replaceCanonical,
621
+ );
622
+ });
623
+ }
624
+
496
625
  export function updateCompletionDeliverySetting(value: CompletionDelivery): void {
497
626
  withSettingsMutationLock(() => {
498
627
  const update = readSettingsObjectForUpdate();
@@ -514,6 +643,64 @@ export function updateCompletionDeliverySetting(value: CompletionDelivery): void
514
643
  });
515
644
  }
516
645
 
646
+ export function updateBlockingMaxParallelTasksSetting(value: number): void {
647
+ if (!isPositiveInteger(value) || value > MAX_CONFIGURABLE_PARALLEL_TASKS) {
648
+ throw new Error(
649
+ `Maximum parallel tasks must be an integer between 1 and ${MAX_CONFIGURABLE_PARALLEL_TASKS}`,
650
+ );
651
+ }
652
+ withSettingsMutationLock(() => {
653
+ const update = readSettingsObjectForUpdate();
654
+ const raw = update.document;
655
+ const blocking = raw.blocking;
656
+ if (blocking !== undefined && !isPlainObject(blocking)) {
657
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} blocking settings`);
658
+ }
659
+ writeSettingsObjectUnlocked(
660
+ {
661
+ ...raw,
662
+ blocking: {
663
+ ...(blocking ?? {}),
664
+ maxParallelTasks: value,
665
+ },
666
+ },
667
+ update.replaceCanonical,
668
+ );
669
+ });
670
+ }
671
+
672
+ export function updateStatefulLimitSetting(
673
+ field: StatefulLimitField,
674
+ value: number,
675
+ expected?: StatefulLimits,
676
+ ): void {
677
+ if (!isValidStatefulLimit(field, value)) {
678
+ throw new Error(
679
+ `${field} must be a safe integer greater than or equal to ${statefulLimitDefinition(field).minimum}`,
680
+ );
681
+ }
682
+ withSettingsMutationLock(() => {
683
+ const update = readSettingsObjectForUpdate();
684
+ const raw = update.document;
685
+ const stateful = raw.stateful;
686
+ if (stateful !== undefined && !isPlainObject(stateful)) {
687
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
688
+ }
689
+ const normalized = normalizeSubagentSettings(raw);
690
+ if (!normalized) throw new Error(`Cannot update invalid ${SETTINGS_FILE}`);
691
+ if (expected && !sameStatefulLimits(resolveStatefulLimits(normalized.stateful), expected)) {
692
+ throw new Error("Detached limit settings changed; reopen settings and retry");
693
+ }
694
+ writeSettingsObjectUnlocked(
695
+ {
696
+ ...raw,
697
+ stateful: { ...(stateful ?? {}), [field]: value },
698
+ },
699
+ update.replaceCanonical,
700
+ );
701
+ });
702
+ }
703
+
517
704
  export function updateConsultResourceSetting(value: ConsultResourcePolicy): void {
518
705
  withSettingsMutationLock(() => {
519
706
  const update = readSettingsObjectForUpdate();
@@ -558,7 +745,18 @@ export function updateCwdPolicySetting(
558
745
  });
559
746
  }
560
747
 
748
+ export type AgentSettingsPatch = {
749
+ tools?: string[] | undefined;
750
+ model?: string | null | undefined;
751
+ thinkingLevel?: SubagentThinkingLevel | null | undefined;
752
+ timeoutMs?: number | null | undefined;
753
+ };
754
+
561
755
  export function updateAgentToolsSetting(name: string, tools: string[] | undefined): void {
756
+ updateAgentSettingsPatch({ [name]: { tools } });
757
+ }
758
+
759
+ export function updateAgentSettingsPatch(patches: Record<string, AgentSettingsPatch>): void {
562
760
  withSettingsMutationLock(() => {
563
761
  const update = readSettingsObjectForUpdate();
564
762
  const raw = update.document;
@@ -567,24 +765,41 @@ export function updateAgentToolsSetting(name: string, tools: string[] | undefine
567
765
  throw new Error(`Cannot update invalid ${SETTINGS_FILE} agent settings`);
568
766
  }
569
767
  const agents = { ...(rawAgents ?? {}) };
570
- const rawAgent = hasOwn(agents, name) ? agents[name] : undefined;
571
- if (rawAgent !== undefined && !isPlainObject(rawAgent)) {
572
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} settings for ${name}`);
573
- }
574
- const agent = { ...(rawAgent ?? {}) };
575
- if (tools === undefined) delete agent.tools;
576
- else agent.tools = tools;
577
- if (Object.keys(agent).length > 0) {
578
- Object.defineProperty(agents, name, {
579
- value: agent,
580
- enumerable: true,
581
- configurable: true,
582
- writable: true,
583
- });
584
- } else {
585
- delete agents[name];
768
+ for (const [name, patch] of Object.entries(patches)) {
769
+ const rawAgent = hasOwn(agents, name) ? agents[name] : undefined;
770
+ if (rawAgent !== undefined && !isPlainObject(rawAgent)) {
771
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} settings for ${name}`);
772
+ }
773
+ const agent = { ...(rawAgent ?? {}) };
774
+ for (const field of ["tools", "model", "thinkingLevel", "timeoutMs"] as const) {
775
+ if (!hasOwn(patch, field)) continue;
776
+ const value = patch[field];
777
+ if (value === undefined) delete agent[field];
778
+ else
779
+ Object.defineProperty(agent, field, {
780
+ value,
781
+ enumerable: true,
782
+ configurable: true,
783
+ writable: true,
784
+ });
785
+ }
786
+ if (Object.keys(agent).length > 0) {
787
+ Object.defineProperty(agents, name, {
788
+ value: agent,
789
+ enumerable: true,
790
+ configurable: true,
791
+ writable: true,
792
+ });
793
+ } else {
794
+ delete agents[name];
795
+ }
586
796
  }
587
797
 
798
+ const normalized = normalizeSubagentSettings({
799
+ ...raw,
800
+ ...(Object.keys(agents).length > 0 ? { agents } : {}),
801
+ });
802
+ if (!normalized) throw new Error(`Cannot update invalid ${SETTINGS_FILE} agent settings`);
588
803
  const updated = { ...raw };
589
804
  if (Object.keys(agents).length > 0) updated.agents = agents;
590
805
  else delete updated.agents;
@@ -659,6 +874,10 @@ function withSettingsMutationLock<T>(mutate: () => T): T {
659
874
  }
660
875
  }
661
876
 
877
+ function sameStatefulLimits(left: StatefulLimits, right: StatefulLimits): boolean {
878
+ return STATEFUL_LIMIT_FIELDS.every((field) => left[field] === right[field]);
879
+ }
880
+
662
881
  function pathEntryExists(filePath: string): boolean {
663
882
  try {
664
883
  fs.lstatSync(filePath);
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentScope, SubagentThinkingLevel } from "./agents.js";
3
+ import type { DelegationContract } from "./delegation-contract.js";
4
+ import type { SubagentResultFormat } from "./result-contract.js";
5
+
6
+ export const MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH = 256;
7
+
8
+ export interface CanonicalSpawnRequest {
9
+ agent: string;
10
+ task: string;
11
+ cwd: string;
12
+ agentScope: AgentScope;
13
+ thinkingLevel?: SubagentThinkingLevel;
14
+ timeoutMs?: number;
15
+ idleTimeoutMs?: number;
16
+ maxTurns?: number;
17
+ maxToolCalls?: number;
18
+ parentId?: string;
19
+ context?: string;
20
+ contextSourceIds: readonly string[];
21
+ workspaceMode: "shared" | "worktree";
22
+ allowConcurrentWrites: boolean;
23
+ contract?: DelegationContract;
24
+ resultFormat: SubagentResultFormat;
25
+ }
26
+
27
+ export function hashSpawnRequest(request: CanonicalSpawnRequest): string {
28
+ return createHash("sha256")
29
+ .update(
30
+ JSON.stringify({
31
+ agent: request.agent,
32
+ task: request.task,
33
+ cwd: request.cwd,
34
+ agentScope: request.agentScope,
35
+ thinkingLevel: request.thinkingLevel ?? null,
36
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
37
+ ...(request.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: request.idleTimeoutMs }),
38
+ ...(request.maxTurns === undefined ? {} : { maxTurns: request.maxTurns }),
39
+ ...(request.maxToolCalls === undefined ? {} : { maxToolCalls: request.maxToolCalls }),
40
+ parentId: request.parentId ?? null,
41
+ contextHash: request.context
42
+ ? createHash("sha256").update(request.context).digest("hex")
43
+ : null,
44
+ contextSourceIds: [...request.contextSourceIds],
45
+ workspaceMode: request.workspaceMode,
46
+ allowConcurrentWrites: request.allowConcurrentWrites,
47
+ ...(request.contract === undefined ? {} : { contract: request.contract }),
48
+ resultFormat: request.resultFormat,
49
+ }),
50
+ )
51
+ .digest("hex");
52
+ }
53
+
54
+ export function assertSpawnIdempotencyKey(value: string | undefined): void {
55
+ if (value === undefined) return;
56
+ if (!value || value.length > MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH) {
57
+ throw new Error(
58
+ `subagent_spawn idempotencyKey must contain 1-${MAX_SPAWN_IDEMPOTENCY_KEY_LENGTH} characters`,
59
+ );
60
+ }
61
+ }
@@ -0,0 +1,13 @@
1
+ import type { CompletionDelivery, SubagentTransportKind } from "./agents.js";
2
+
3
+ export function resolveStatefulTransportKind(
4
+ value: SubagentTransportKind | undefined,
5
+ ): SubagentTransportKind {
6
+ return value ?? "subprocess";
7
+ }
8
+
9
+ export function resolveCompletionDelivery(
10
+ value: CompletionDelivery | undefined,
11
+ ): CompletionDelivery {
12
+ return value ?? "next-turn";
13
+ }
@@ -19,6 +19,7 @@ export function createSpawnPromptGuidelines(
19
19
  return [
20
20
  "Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
21
21
  "Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
22
+ "Set subagent_spawn timeoutMs to the shortest realistic work deadline for the task difficulty; use idleTimeoutMs for stalled work and maxTurns or maxToolCalls to stop repeated work without progress. Split oversized tasks instead of extending budgets merely to compensate for broad scope. Omit these fields only to preserve the retained agent or configured defaults.",
22
23
  deliveryGuidance,
23
24
  "Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
24
25
  ...(blockingEnabled