@xingular/harness 1.3.1-unstable.2 → 1.3.1-unstable.4

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 (125) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +3680 -1947
  4. package/dist/sdk/cli.js +2 -0
  5. package/dist/sdk/commands/registry.d.ts +15 -0
  6. package/dist/sdk/commands/registry.js +2 -0
  7. package/dist/sdk/computer/screenshot.d.ts +32 -0
  8. package/dist/sdk/computer/screenshot.js +35 -0
  9. package/dist/sdk/context/attribution.d.ts +18 -0
  10. package/dist/sdk/context/attribution.js +295 -0
  11. package/dist/sdk/context/types.d.ts +94 -0
  12. package/dist/sdk/context/types.js +10 -0
  13. package/dist/sdk/index.d.ts +2 -1
  14. package/dist/sdk/index.js +2 -1
  15. package/dist/sdk/plugins/builtin.js +1 -0
  16. package/dist/sdk/prompt/compile.d.ts +42 -2
  17. package/dist/sdk/prompt/compile.js +87 -15
  18. package/dist/sdk/remote/addresses.d.ts +19 -0
  19. package/dist/sdk/remote/addresses.js +52 -0
  20. package/dist/sdk/remote/pairing.d.ts +59 -0
  21. package/dist/sdk/remote/pairing.js +160 -0
  22. package/dist/sdk/remote/seal.d.ts +28 -0
  23. package/dist/sdk/remote/seal.js +84 -0
  24. package/dist/sdk/remote/server.d.ts +28 -0
  25. package/dist/sdk/remote/server.js +158 -0
  26. package/dist/sdk/remote/service.d.ts +125 -0
  27. package/dist/sdk/remote/service.js +330 -0
  28. package/dist/sdk/remote/webClient.d.ts +4 -0
  29. package/dist/sdk/remote/webClient.js +444 -0
  30. package/dist/sdk/server/methods.js +3 -0
  31. package/dist/sdk/sessions/contracts.d.ts +4 -0
  32. package/dist/sdk/sessions/contracts.js +2 -0
  33. package/dist/sdk/sessions/planMode.d.ts +26 -1
  34. package/dist/sdk/sessions/planMode.js +48 -1
  35. package/dist/sdk/sessions/runner.d.ts +12 -0
  36. package/dist/sdk/sessions/runner.js +34 -2
  37. package/dist/sdk/sessions/sessions.js +1 -0
  38. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  39. package/dist/sdk/sessions/subagentService.js +120 -38
  40. package/dist/sdk/sessions/subagents.d.ts +31 -0
  41. package/dist/sdk/sessions/subagents.js +45 -0
  42. package/dist/sdk/sessions/workSlice.d.ts +5 -9
  43. package/dist/sdk/sessions/workSlice.js +8 -149
  44. package/dist/sdk/terminal/client.d.ts +3 -1
  45. package/dist/sdk/terminal/client.js +15 -6
  46. package/dist/sdk/terminal/control.d.ts +1 -1
  47. package/dist/sdk/terminal/control.js +2 -2
  48. package/dist/sdk/terminal/controller.d.ts +22 -0
  49. package/dist/sdk/terminal/controller.js +104 -13
  50. package/dist/sdk/terminal/piExtension.js +106 -4
  51. package/dist/sdk/terminal/stallWatch.d.ts +13 -0
  52. package/dist/sdk/terminal/stallWatch.js +16 -0
  53. package/dist/sdk/terminal/standalone.d.ts +2 -0
  54. package/dist/sdk/terminal/standalone.js +9 -2
  55. package/dist/sdk/terminal/terminals.d.ts +14 -0
  56. package/dist/sdk/terminal/terminals.js +98 -2
  57. package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
  58. package/dist/sdk/terminal/tui/commandPages.js +12 -0
  59. package/dist/sdk/terminal/tui/commands.d.ts +3 -2
  60. package/dist/sdk/terminal/tui/commands.js +19 -5
  61. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  62. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  63. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  64. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  65. package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
  66. package/dist/sdk/terminal/tui/markdown.js +55 -0
  67. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  68. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  69. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  70. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  71. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  72. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  73. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +62 -0
  74. package/dist/sdk/terminal/tui/pages/contextPage.js +484 -0
  75. package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
  76. package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
  77. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  78. package/dist/sdk/terminal/tui/qr.d.ts +4 -0
  79. package/dist/sdk/terminal/tui/qr.js +35 -0
  80. package/dist/sdk/terminal/tui/runtime.js +8 -2
  81. package/dist/sdk/terminal/tui/search.js +3 -2
  82. package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
  83. package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
  84. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  85. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  86. package/dist/sdk/terminal/tui/status.d.ts +6 -0
  87. package/dist/sdk/terminal/tui/status.js +15 -1
  88. package/dist/sdk/terminal/tui/theme.d.ts +9 -0
  89. package/dist/sdk/terminal/tui/theme.js +9 -0
  90. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  91. package/dist/sdk/tools/defaultSkills.js +27 -1
  92. package/dist/sdk/tools/dispatch.js +8 -9
  93. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  94. package/dist/sdk/tools/spawnSubagent.js +4 -2
  95. package/dist/terminal-opentui.js +1607 -492
  96. package/dist/terminal-pi.js +256 -144
  97. package/docs/README.md +1 -0
  98. package/docs/architecture/02-sessions-and-runs.md +55 -22
  99. package/docs/architecture/04-prompt.md +23 -4
  100. package/docs/architecture/07-tools-and-gate.md +4 -2
  101. package/docs/architecture/10-server-protocol.md +24 -0
  102. package/docs/architecture/11-terminal.md +52 -10
  103. package/docs/architecture/12-storage.md +5 -2
  104. package/docs/architecture/13-world-tools.md +17 -3
  105. package/docs/architecture/14-surfaces-and-parity.md +7 -3
  106. package/docs/architecture/15-remote-control.md +118 -0
  107. package/docs/architecture/README.md +3 -0
  108. package/docs/architecture/proposals/PROP-0049-context-attribution.md +409 -0
  109. package/docs/architecture/proposals/README.md +2 -1
  110. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
  111. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  112. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  113. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  114. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  115. package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
  116. package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
  117. package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
  118. package/docs/decisions/ADR-0056-remote-control.md +53 -0
  119. package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
  120. package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
  121. package/docs/parity.md +14 -10
  122. package/docs/releases/1.3.1-unstable.3.md +54 -0
  123. package/docs/releases/1.3.1-unstable.4.md +56 -0
  124. package/docs/terminal-tui.md +43 -18
  125. package/package.json +3 -1
@@ -7,6 +7,7 @@ import { createWorkspaceBashOperations } from "../core/workspaceSandbox.js";
7
7
  import { withWorkspaceWriteAuthorization, workspaceEditOperations, workspaceWriteOperations } from "../core/workspaceWriteOperations.js";
8
8
  import { loadPi } from "../pi/runtime.js";
9
9
  import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from "../prompt/compile.js";
10
+ import { PLAN_REPAIR_CUSTOM_TYPE, PLAN_REPAIR_INSTRUCTIONS, finalizePlanOutcome } from "./planMode.js";
10
11
  import { MAX_CHECKPOINT_CHECKS, MAX_CHECKPOINT_FILES, MAX_CHECKPOINT_SUMMARY } from "./contracts.js";
11
12
  import { derivedReason } from "./workSlice.js";
12
13
  import { toolAttachmentRefsSchema } from "./contracts.js";
@@ -129,6 +130,21 @@ function contextToolSpecs(session) {
129
130
  });
130
131
  return { tools: tools.map((entry) => entry.spec), truncated: source.length > MAX_TOOL_SPECS || tools.some((entry) => entry.truncated) };
131
132
  }
133
+ /** Pi's compaction settings for this session's model, when the session exposes them. */
134
+ function compactionSettings(session) {
135
+ const manager = session.settingsManager;
136
+ if (!manager?.getCompactionSettings)
137
+ return null;
138
+ try {
139
+ const model = session.model?.id && session.model.provider ? { provider: session.model.provider, id: session.model.id } : undefined;
140
+ const settings = manager.getCompactionSettings(model);
141
+ return { enabled: settings.enabled === true, reserveTokens: Math.max(0, Math.round(settings.reserveTokens || 0)) };
142
+ }
143
+ catch {
144
+ // A model Pi cannot resolve keeps the reserve unknown rather than guessed.
145
+ return null;
146
+ }
147
+ }
132
148
  const numberField = (event, key, fallback = 0) => (typeof event[key] === 'number' && Number.isFinite(event[key]) ? event[key] : fallback);
133
149
  const stringField = (event, key, fallback = '') => (typeof event[key] === 'string' ? event[key] : fallback);
134
150
  const lastLine = (text) => {
@@ -141,7 +157,6 @@ function derivedCheckpoint(state, ledger) {
141
157
  summary: ledger.lastText.trim().slice(0, MAX_CHECKPOINT_SUMMARY) || `The reply stopped after ${state.steps} steps at its work-slice boundary.`,
142
158
  done: [],
143
159
  next: '',
144
- ...(state.endedBy === 'stuck' ? { blocked: (state.endedByLimit ?? 'Repeated work').slice(0, 500) } : {}),
145
160
  files: [...ledger.files].slice(-MAX_CHECKPOINT_FILES).map(([path, why]) => ({ path: path.slice(0, 300), why: why.slice(0, 200) })),
146
161
  checks: ledger.checks.slice(-MAX_CHECKPOINT_CHECKS),
147
162
  decisions: [],
@@ -183,10 +198,12 @@ export async function runTurn(run, session, params) {
183
198
  const systemPrompt = limitedText(session.systemPrompt ?? params.system, MAX_SYSTEM_PROMPT_CHARS);
184
199
  const contextUpdate = limitedText(params.update?.content ?? '', MAX_SYSTEM_PROMPT_CHARS);
185
200
  const toolSpecs = contextToolSpecs(session);
201
+ const compaction = compactionSettings(session);
186
202
  run.emit('context.snapshot', {
187
203
  systemPrompt: systemPrompt.text,
188
204
  ...(contextUpdate.text ? { contextUpdate: contextUpdate.text } : {}),
189
205
  tools: toolSpecs.tools,
206
+ ...(compaction ? { compaction } : {}),
190
207
  ...(systemPrompt.truncated ? { systemPromptTruncated: true } : {}),
191
208
  ...(contextUpdate.truncated ? { contextUpdateTruncated: true } : {}),
192
209
  ...(toolSpecs.truncated ? { toolsTruncated: true } : {})
@@ -522,11 +539,26 @@ export async function runTurn(run, session, params) {
522
539
  }
523
540
  if (!aborted && !lastError)
524
541
  await params.settle?.();
542
+ if (params.plan && params.repairPlan && !aborted && !lastError && !slice?.endedBy) {
543
+ const errorBeforeRepair = lastError;
544
+ const result = await finalizePlanOutcome(ledger.lastText, async () => {
545
+ if (!session.sendCustomMessage)
546
+ throw new Error('Plan repair unavailable');
547
+ ledger.lastText = '';
548
+ await session.sendCustomMessage({ customType: PLAN_REPAIR_CUSTOM_TYPE, content: PLAN_REPAIR_INSTRUCTIONS, display: false, details: { reason: 'missing_plan_block' } }, { triggerTurn: true });
549
+ return ledger.lastText;
550
+ });
551
+ if (result.repaired && !aborted)
552
+ lastError = errorBeforeRepair;
553
+ if (result.outcome.kind === 'invalid' && !aborted) {
554
+ run.emit('extension.notice', { kind: 'notify', level: 'warning', text: 'The plan reply did not include a valid plan or blocked block.' });
555
+ }
556
+ }
525
557
  if (slice?.endedBy && !aborted && !lastError) {
526
558
  // The model stopped because its slice was over. Keep the state it did not write itself.
527
559
  if (!slice.checkpointWritten)
528
560
  run.emit('checkpoint', derivedCheckpoint(slice, ledger));
529
- run.finish('interrupted', undefined, slice.endedBy === 'stuck' ? 'stalled' : 'slice_complete');
561
+ run.finish('interrupted', undefined, 'slice_complete');
530
562
  return;
531
563
  }
532
564
  if (aborted)
@@ -612,6 +612,7 @@ export class Sessions {
612
612
  gate,
613
613
  restrictTools: input.run.relation === 'subagent',
614
614
  ...(input.plan ? { plan: true } : {}),
615
+ ...(stateEligible && input.plan ? { repairPlan: true } : {}),
615
616
  cwd: input.cwd,
616
617
  workspaceRoot,
617
618
  sessionDir: input.sessionDir,
@@ -5,7 +5,7 @@ import type { RunRegistry } from "./registry.js";
5
5
  import type { Effort } from "./runner.js";
6
6
  import type { SessionRunInput } from "./sessions.js";
7
7
  import type { PluginDescriptor } from "../plugins/types.js";
8
- import { type SubagentInput, type SubagentReceipt, type SubagentResult, type SubagentSelection } from "./subagents.js";
8
+ import { type SubagentInput, type SubagentQuota, type SubagentReceipt, type SubagentResult, type SubagentSelection } from "./subagents.js";
9
9
  type Started = {
10
10
  runId: string;
11
11
  nativeSessionId: string | null;
@@ -35,11 +35,15 @@ export declare class Subagents {
35
35
  private readonly launch;
36
36
  private readonly options;
37
37
  private readonly parents;
38
+ /** Child runs reserved synchronously at spawn, before their record exists, so parallel calls cannot overspend. */
39
+ private readonly reserved;
38
40
  private readonly runtimeSettingsByRun;
39
41
  constructor(env: HarnessEnv, runs: RunRegistry, launch: (input: SessionRunInput) => Promise<Started>, options?: SubagentServiceOptions);
40
42
  /** Internal adapter contract: context comes from a live run, never from model arguments. */
41
43
  register(input: SessionRunInput, accountId: string, capabilities: CapabilitySet, deferUntilPrompt?: boolean): void;
42
44
  runtimeSettings(runId: string): SubagentRuntimeSettings | null;
45
+ /** The budget for a run, or the foundation defaults when it has not registered one. */
46
+ quota(runId: string): SubagentQuota;
43
47
  private rememberRuntimeSettings;
44
48
  resolvedModel(runId: string, model: string, effort?: Effort): void;
45
49
  private parent;
@@ -51,6 +55,12 @@ export declare class Subagents {
51
55
  private launchChild;
52
56
  /** The children this parent may read or continue: its own, and those recovered from its history. */
53
57
  private owned;
58
+ /** The run and the runs it was continued from, so a work-slice continuation keeps the budget. */
59
+ private ancestry;
60
+ /** Distinct child conversations this run has started, plus slots reserved before their record exists. */
61
+ private usage;
62
+ private reserve;
63
+ private release;
54
64
  /** The newest run in a continued child's chain, among the runs this parent owns. */
55
65
  private latestLink;
56
66
  /** The first run of a chain: its folder holds the child's history for every later link. */
@@ -10,7 +10,7 @@ import { planResearchEligibility } from "../agents/planResearch.js";
10
10
  import { resolveProfile } from "../agents/profiles.js";
11
11
  import { continuationOf } from "./continuation.js";
12
12
  import { findRun, loadRun, readEvents, recordedSubagents, recordSubagent, saveRun } from "./runStore.js";
13
- import { SPAWN_SUBAGENT, SUBAGENT_RELATION, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, briefTitle, continueHint, subagentHistory, subagentTitle } from "./subagents.js";
13
+ import { SPAWN_SUBAGENT, SUBAGENT_RELATION, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, UNPROFILED_SUBAGENT, briefTitle, continueHint, subagentHistory, subagentProfileKey, subagentProfileLimit, subagentQuota, subagentTitle } from "./subagents.js";
14
14
  function until(promise, signal) {
15
15
  if (signal.aborted) {
16
16
  void promise.catch(() => { });
@@ -29,6 +29,8 @@ export class Subagents {
29
29
  launch;
30
30
  options;
31
31
  parents = new Map();
32
+ /** Child runs reserved synchronously at spawn, before their record exists, so parallel calls cannot overspend. */
33
+ reserved = new Map();
32
34
  runtimeSettingsByRun = new Map();
33
35
  constructor(env, runs, launch, options = {}) {
34
36
  this.env = env;
@@ -72,6 +74,10 @@ export class Subagents {
72
74
  runtimeSettings(runId) {
73
75
  return this.runtimeSettingsByRun.get(runId) ?? null;
74
76
  }
77
+ /** The budget for a run, or the foundation defaults when it has not registered one. */
78
+ quota(runId) {
79
+ return subagentQuota(this.parents.get(runId)?.input.subagents ?? undefined);
80
+ }
75
81
  rememberRuntimeSettings(runId, settings) {
76
82
  this.runtimeSettingsByRun.set(runId, settings);
77
83
  if (this.runtimeSettingsByRun.size > 500) {
@@ -174,44 +180,63 @@ export class Subagents {
174
180
  throw invalid(`There is no agent profile named ${request.agent.trim()}.`);
175
181
  }
176
182
  }
177
- await until(parent.ready, parent.stopped.signal);
178
- let variant = input.variant ?? null;
179
- let launch = input.launch;
180
- let model = input.model;
181
- let effort = input.effort;
182
- if (profile) {
183
- // A variant's own subagent works from its definition alone; a profile keeps the parent's
184
- // variant unless it names another one, or none.
185
- if (profile.source === 'variant' || profile.variant === 'none')
186
- variant = null;
187
- else if (profile.variant)
188
- variant = { scope: profile.variant.scope, version: profile.variant.version };
189
- if (profile.model)
190
- model = profile.model;
191
- if (profile.effort)
192
- effort = profile.effort;
193
- launch = { ...(input.launch ?? {}), sections: [...(input.launch?.sections ?? []).filter((section) => section.id !== 'subagent'), { id: 'subagent', title: `Your role: ${profile.name}`, body: profile.instructions || profile.description, budget: 16_000, order: 22 }] };
194
- }
195
- const narrowed = input.plan
196
- ? { capabilities: planEligibility.capabilities, unavailable: planEligibility.unavailableTools }
197
- : narrowForProfile(capabilities, profile, request.tools, this.options.descriptors?.() ?? []);
198
- const childCapabilities = narrowed.capabilities;
199
- const tools = childCapabilities.tools;
183
+ const quota = subagentQuota(input.subagents ?? undefined);
200
184
  const runId = randomUUID();
201
- const sessionId = randomUUID();
202
- const child = {
203
- ...input,
204
- run: { ...input.run, ...(input.plan ? { worktreeId: null } : {}), id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, goal: takeChars(brief, 4000), title, attachmentCount: 0, resumedFromRunId: null, handoffId: null, profile: profile?.name ?? null },
205
- accountId, poolId: null, sessionDir: join(input.sessionDir, 'subagents', runId), resume: null,
206
- text: brief, images: undefined, requiresImageInput: false, preamble: null,
207
- caller: { ...input.caller, runId: undefined, sessionId },
208
- tools: { allow: tools, gated: (input.tools.gated ?? []).filter((name) => tools.includes(name)) },
209
- capabilities: childCapabilities, depth: (input.depth ?? 0) + 1,
210
- variant, launch, model, effort,
211
- ...(input.plan ? { connections: false, subagents: null } : {})
212
- };
213
- const result = await this.launchChild(parent, request, child);
214
- return { ...result, ...(profile ? { agent: profile.name } : {}), ...(narrowed.unavailable.length ? { unavailableTools: narrowed.unavailable } : {}) };
185
+ const usage = this.usage(request.parentRunId);
186
+ const slot = (count, of = '') => `${count} ${of}subagent slot${count === 1 ? '' : 's'}`;
187
+ if (usage.total >= quota.children)
188
+ throw invalid(`This run has used its ${slot(quota.children)}. Do the remaining work yourself.`);
189
+ const profileKey = subagentProfileKey(profile?.name);
190
+ const profileLimit = subagentProfileLimit(quota, profileKey);
191
+ const profileUsed = usage.byProfile.get(profileKey) ?? 0;
192
+ if (profileUsed >= profileLimit) {
193
+ const label = profileKey === UNPROFILED_SUBAGENT ? 'unprofiled ' : `${profileKey} `;
194
+ throw invalid(`This run has used its ${slot(profileLimit, label)}. Do the remaining work yourself.`);
195
+ }
196
+ // Reserve before the first await so two tool calls in one message cannot both pass the check.
197
+ this.reserve(request.parentRunId, runId, profileKey);
198
+ try {
199
+ await until(parent.ready, parent.stopped.signal);
200
+ let variant = input.variant ?? null;
201
+ let launch = input.launch;
202
+ let model = input.model;
203
+ let effort = input.effort;
204
+ if (profile) {
205
+ // A variant's own subagent works from its definition alone; a profile keeps the parent's
206
+ // variant unless it names another one, or none.
207
+ if (profile.source === 'variant' || profile.variant === 'none')
208
+ variant = null;
209
+ else if (profile.variant)
210
+ variant = { scope: profile.variant.scope, version: profile.variant.version };
211
+ if (profile.model)
212
+ model = profile.model;
213
+ if (profile.effort)
214
+ effort = profile.effort;
215
+ launch = { ...(input.launch ?? {}), sections: [...(input.launch?.sections ?? []).filter((section) => section.id !== 'subagent'), { id: 'subagent', title: `Your role: ${profile.name}`, body: profile.instructions || profile.description, budget: 16_000, order: 22 }] };
216
+ }
217
+ const narrowed = input.plan
218
+ ? { capabilities: planEligibility.capabilities, unavailable: planEligibility.unavailableTools }
219
+ : narrowForProfile(capabilities, profile, request.tools, this.options.descriptors?.() ?? []);
220
+ const childCapabilities = narrowed.capabilities;
221
+ const tools = childCapabilities.tools;
222
+ const sessionId = randomUUID();
223
+ const child = {
224
+ ...input,
225
+ run: { ...input.run, ...(input.plan ? { worktreeId: null } : {}), id: runId, sessionId, parentRunId: request.parentRunId, relation: SUBAGENT_RELATION, goal: takeChars(brief, 4000), title, attachmentCount: 0, resumedFromRunId: null, handoffId: null, profile: profile?.name ?? null },
226
+ accountId, poolId: null, sessionDir: join(input.sessionDir, 'subagents', runId), resume: null,
227
+ text: brief, images: undefined, requiresImageInput: false, preamble: null,
228
+ caller: { ...input.caller, runId: undefined, sessionId },
229
+ tools: { allow: tools, gated: (input.tools.gated ?? []).filter((name) => tools.includes(name)) },
230
+ capabilities: childCapabilities, depth: (input.depth ?? 0) + 1,
231
+ variant, launch, model, effort,
232
+ ...(input.plan ? { connections: false, subagents: null } : {})
233
+ };
234
+ const result = await this.launchChild(parent, request, child);
235
+ return { ...result, ...(profile ? { agent: profile.name } : {}), ...(narrowed.unavailable.length ? { unavailableTools: narrowed.unavailable } : {}) };
236
+ }
237
+ finally {
238
+ this.release(request.parentRunId, runId);
239
+ }
215
240
  }
216
241
  /** Records ownership, starts the child, and writes a failed record when it never opened. */
217
242
  async launchChild(parent, request, child) {
@@ -236,6 +261,63 @@ export class Subagents {
236
261
  owned(parentRunId, parent) {
237
262
  return new Set([...recordedSubagents(this.env.root, parentRunId), ...(parent?.recovered ?? [])]);
238
263
  }
264
+ /** The run and the runs it was continued from, so a work-slice continuation keeps the budget. */
265
+ ancestry(runId) {
266
+ const chain = [];
267
+ const seen = new Set();
268
+ let current = runId;
269
+ while (current && !seen.has(current) && chain.length < 20) {
270
+ seen.add(current);
271
+ chain.push(current);
272
+ current = findRun(this.env.root, current)?.resumedFromRunId ?? null;
273
+ }
274
+ return chain;
275
+ }
276
+ /** Distinct child conversations this run has started, plus slots reserved before their record exists. */
277
+ usage(parentRunId) {
278
+ const byProfile = new Map();
279
+ const seen = new Set();
280
+ let total = 0;
281
+ const add = (key) => {
282
+ total += 1;
283
+ byProfile.set(key, (byProfile.get(key) ?? 0) + 1);
284
+ };
285
+ for (const owner of this.ancestry(parentRunId)) {
286
+ for (const id of recordedSubagents(this.env.root, owner)) {
287
+ const record = findRun(this.env.root, id);
288
+ if (!record || record.relation !== SUBAGENT_RELATION)
289
+ continue;
290
+ // Continuing a stopped child opens a new run of the same conversation: one slot.
291
+ const first = this.firstLink(record).id;
292
+ if (seen.has(first))
293
+ continue;
294
+ seen.add(first);
295
+ add(subagentProfileKey(record.profile));
296
+ }
297
+ }
298
+ const reserved = this.reserved.get(parentRunId);
299
+ if (reserved)
300
+ for (const [id, key] of reserved) {
301
+ if (seen.has(id) || findRun(this.env.root, id))
302
+ continue;
303
+ seen.add(id);
304
+ add(key);
305
+ }
306
+ return { total, byProfile };
307
+ }
308
+ reserve(parentRunId, runId, profile) {
309
+ const reserved = this.reserved.get(parentRunId) ?? new Map();
310
+ reserved.set(runId, profile);
311
+ this.reserved.set(parentRunId, reserved);
312
+ }
313
+ release(parentRunId, runId) {
314
+ const reserved = this.reserved.get(parentRunId);
315
+ if (!reserved)
316
+ return;
317
+ reserved.delete(runId);
318
+ if (!reserved.size)
319
+ this.reserved.delete(parentRunId);
320
+ }
239
321
  /** The newest run in a continued child's chain, among the runs this parent owns. */
240
322
  latestLink(start, owned) {
241
323
  const records = [...owned].flatMap((id) => findRun(this.env.root, id) ?? []);
@@ -7,7 +7,38 @@ export declare const MAX_SUBAGENT_DEPTH = 3;
7
7
  export interface SubagentOptions {
8
8
  /** Maximum consecutive native subagent levels below a caller (1 to 3). */
9
9
  maxDepth?: number;
10
+ /** Distinct child conversations a run may start. */
11
+ maxChildren?: number;
12
+ /** Children a run may start from one named profile; a name it omits uses `maxOther`. */
13
+ maxByProfile?: Record<string, number>;
14
+ /** Children allowed from a named profile `maxByProfile` does not list. */
15
+ maxOther?: number;
16
+ /** Children allowed when the caller starts one with no profile. */
17
+ maxUnprofiled?: number;
10
18
  }
19
+ /** How many distinct children a run may start when the caller does not say. */
20
+ export declare const DEFAULT_SUBAGENT_CHILDREN = 4;
21
+ /** The most a host may allow, so a mistyped setting cannot lift the budget entirely. */
22
+ export declare const MAX_SUBAGENT_CHILDREN = 50;
23
+ /** Foundation limits for the profiles the harness ships; `maxByProfile` overrides them. */
24
+ export declare const DEFAULT_SUBAGENT_PROFILE_CHILDREN: Readonly<Record<string, number>>;
25
+ export declare const DEFAULT_SUBAGENT_OTHER_PROFILE = 1;
26
+ export declare const DEFAULT_SUBAGENT_UNPROFILED = 1;
27
+ /** The bucket a child with no profile counts against. No profile name can be `*`. */
28
+ export declare const UNPROFILED_SUBAGENT = "*";
29
+ /** The children one run may start, by profile and in total. */
30
+ export interface SubagentQuota {
31
+ children: number;
32
+ profiles: Readonly<Record<string, number>>;
33
+ other: number;
34
+ unprofiled: number;
35
+ }
36
+ /** The effective budget: the caller's values merged over the foundation defaults. */
37
+ export declare function subagentQuota(options: SubagentOptions | undefined): SubagentQuota;
38
+ /** The bucket a child counts against: its profile name, or the unprofiled bucket. */
39
+ export declare function subagentProfileKey(profile: string | null | undefined): string;
40
+ /** How many children this profile may still start under the quota. */
41
+ export declare function subagentProfileLimit(quota: SubagentQuota, key: string): number;
11
42
  export declare function subagentDepth(options: SubagentOptions | undefined): number;
12
43
  export interface SubagentInput {
13
44
  parentRunId: string;
@@ -6,6 +6,51 @@ export const SUBAGENT_RELATION = 'subagent';
6
6
  /** How deep subagents may nest when the caller does not say. */
7
7
  export const DEFAULT_SUBAGENT_DEPTH = 1;
8
8
  export const MAX_SUBAGENT_DEPTH = 3;
9
+ /** How many distinct children a run may start when the caller does not say. */
10
+ export const DEFAULT_SUBAGENT_CHILDREN = 4;
11
+ /** The most a host may allow, so a mistyped setting cannot lift the budget entirely. */
12
+ export const MAX_SUBAGENT_CHILDREN = 50;
13
+ /** Foundation limits for the profiles the harness ships; `maxByProfile` overrides them. */
14
+ export const DEFAULT_SUBAGENT_PROFILE_CHILDREN = { worker: 2, explorer: 3 };
15
+ export const DEFAULT_SUBAGENT_OTHER_PROFILE = 1;
16
+ export const DEFAULT_SUBAGENT_UNPROFILED = 1;
17
+ /** The bucket a child with no profile counts against. No profile name can be `*`. */
18
+ export const UNPROFILED_SUBAGENT = '*';
19
+ function limit(value, fallback) {
20
+ if (typeof value !== 'number' || !Number.isFinite(value))
21
+ return fallback;
22
+ return Math.min(MAX_SUBAGENT_CHILDREN, Math.max(0, Math.trunc(value)));
23
+ }
24
+ /** The effective budget: the caller's values merged over the foundation defaults. */
25
+ export function subagentQuota(options) {
26
+ const profiles = { ...DEFAULT_SUBAGENT_PROFILE_CHILDREN };
27
+ const given = options?.maxByProfile;
28
+ if (given && typeof given === 'object' && !Array.isArray(given)) {
29
+ for (const [raw, value] of Object.entries(given)) {
30
+ const name = raw.trim();
31
+ if (!name || name.length > 64 || /[\x00-\x1f\x7f]/u.test(name))
32
+ continue;
33
+ if (typeof value !== 'number' || !Number.isFinite(value))
34
+ continue;
35
+ profiles[name] = limit(value, profiles[name] ?? DEFAULT_SUBAGENT_OTHER_PROFILE);
36
+ }
37
+ }
38
+ return {
39
+ children: limit(options?.maxChildren, DEFAULT_SUBAGENT_CHILDREN),
40
+ profiles,
41
+ other: limit(options?.maxOther, DEFAULT_SUBAGENT_OTHER_PROFILE),
42
+ unprofiled: limit(options?.maxUnprofiled, DEFAULT_SUBAGENT_UNPROFILED)
43
+ };
44
+ }
45
+ /** The bucket a child counts against: its profile name, or the unprofiled bucket. */
46
+ export function subagentProfileKey(profile) {
47
+ const name = typeof profile === 'string' ? profile.trim() : '';
48
+ return name || UNPROFILED_SUBAGENT;
49
+ }
50
+ /** How many children this profile may still start under the quota. */
51
+ export function subagentProfileLimit(quota, key) {
52
+ return key === UNPROFILED_SUBAGENT ? quota.unprofiled : quota.profiles[key] ?? quota.other;
53
+ }
9
54
  export function subagentDepth(options) {
10
55
  const value = Math.trunc(options?.maxDepth ?? DEFAULT_SUBAGENT_DEPTH);
11
56
  return Math.min(MAX_SUBAGENT_DEPTH, Math.max(1, Number.isFinite(value) ? value : DEFAULT_SUBAGENT_DEPTH));
@@ -1,7 +1,7 @@
1
1
  import type { Checkpoint } from "./contracts.js";
2
2
  export declare const WORK_SLICE_EXTENSION = "work-slice";
3
3
  export interface WorkSliceInput {
4
- /** False turns the guard off for this run: no budget, no stall notes, no continuation. */
4
+ /** False turns the guard off for this run: no budget, no handoff request, no continuation. */
5
5
  enabled?: boolean;
6
6
  /** Tool calls in one reply; 0 disables this limit. */
7
7
  maxToolCalls?: number;
@@ -32,7 +32,7 @@ export declare class WorkSliceError extends Error {
32
32
  * defaults.
33
33
  */
34
34
  export declare function parseWorkSlice(input: unknown): SlicePolicy;
35
- export type SliceEnd = 'budget' | 'stuck' | 'compaction-thrash';
35
+ export type SliceEnd = 'budget' | 'compaction-thrash';
36
36
  /** What a live reply tracks for its slice; the runner reads it after the reply. */
37
37
  export interface SliceState {
38
38
  slice: number;
@@ -54,16 +54,12 @@ export declare function createSliceState(_policy: SlicePolicy, slice?: number, n
54
54
  export declare function resetSliceState(state: SliceState, now?: number): void;
55
55
  /** The first limit this reply has passed, named as a person would say it. */
56
56
  export declare function sliceLimitReached(policy: SlicePolicy, state: SliceState, now?: number): string | null;
57
- /** Commands that read as a check for the repeated-failure rule. */
58
- export declare function looksLikeCheck(command: string): boolean;
59
57
  interface ToolCall {
60
58
  toolName: string;
61
- input?: unknown;
62
59
  }
63
60
  interface ToolResult {
64
61
  toolName: string;
65
62
  isError?: boolean;
66
- input?: unknown;
67
63
  }
68
64
  /** The part of Pi's extension API this guard uses. Structural, so the module needs no Pi import. */
69
65
  interface GuardApi {
@@ -91,9 +87,9 @@ export interface WorkSliceGuardOptions {
91
87
  onEnd?: (end: SliceEnd, limit: string) => void;
92
88
  }
93
89
  /**
94
- * Bounds one reply and names repeated work. Refusals request a checkpoint when available; without
95
- * one, they ask for a partial report and prohibit further tools. A repeated call or failing command
96
- * is named once, then ends the reply.
90
+ * Bounds one reply. A call past the budget is refused, with one wrap-up request when a checkpoint
91
+ * is available, and an ended reply lets only a handoff call through. Three compactions in one reply
92
+ * end it too. Every other call is allowed, however often it is made.
97
93
  */
98
94
  export declare function workSliceGuard(options: WorkSliceGuardOptions): (pi: GuardApi) => void;
99
95
  /**