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

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 (76) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +1217 -701
  4. package/dist/sdk/computer/screenshot.d.ts +32 -0
  5. package/dist/sdk/computer/screenshot.js +35 -0
  6. package/dist/sdk/context/attribution.d.ts +18 -0
  7. package/dist/sdk/context/attribution.js +295 -0
  8. package/dist/sdk/context/types.d.ts +94 -0
  9. package/dist/sdk/context/types.js +10 -0
  10. package/dist/sdk/index.d.ts +2 -1
  11. package/dist/sdk/index.js +2 -1
  12. package/dist/sdk/plugins/builtin.js +1 -0
  13. package/dist/sdk/prompt/compile.d.ts +42 -2
  14. package/dist/sdk/prompt/compile.js +87 -15
  15. package/dist/sdk/server/methods.js +3 -0
  16. package/dist/sdk/sessions/contracts.d.ts +4 -0
  17. package/dist/sdk/sessions/contracts.js +2 -0
  18. package/dist/sdk/sessions/runner.d.ts +10 -0
  19. package/dist/sdk/sessions/runner.js +17 -0
  20. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  21. package/dist/sdk/sessions/subagentService.js +120 -38
  22. package/dist/sdk/sessions/subagents.d.ts +31 -0
  23. package/dist/sdk/sessions/subagents.js +45 -0
  24. package/dist/sdk/terminal/client.d.ts +1 -1
  25. package/dist/sdk/terminal/client.js +13 -6
  26. package/dist/sdk/terminal/control.d.ts +1 -1
  27. package/dist/sdk/terminal/control.js +2 -2
  28. package/dist/sdk/terminal/controller.d.ts +9 -0
  29. package/dist/sdk/terminal/controller.js +48 -11
  30. package/dist/sdk/terminal/piExtension.js +35 -1
  31. package/dist/sdk/terminal/terminals.d.ts +2 -0
  32. package/dist/sdk/terminal/terminals.js +11 -0
  33. package/dist/sdk/terminal/tui/commands.d.ts +1 -2
  34. package/dist/sdk/terminal/tui/commands.js +1 -4
  35. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  36. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  37. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  38. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  39. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  40. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  41. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  42. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  43. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  44. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  45. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +54 -0
  46. package/dist/sdk/terminal/tui/pages/contextPage.js +393 -0
  47. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  48. package/dist/sdk/terminal/tui/runtime.js +8 -2
  49. package/dist/sdk/terminal/tui/search.js +3 -2
  50. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  51. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  52. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  53. package/dist/sdk/tools/defaultSkills.js +27 -1
  54. package/dist/sdk/tools/dispatch.js +8 -9
  55. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  56. package/dist/sdk/tools/spawnSubagent.js +4 -2
  57. package/dist/terminal-opentui.js +570 -240
  58. package/dist/terminal-pi.js +142 -28
  59. package/docs/README.md +1 -0
  60. package/docs/architecture/02-sessions-and-runs.md +9 -0
  61. package/docs/architecture/04-prompt.md +13 -4
  62. package/docs/architecture/07-tools-and-gate.md +1 -1
  63. package/docs/architecture/11-terminal.md +12 -2
  64. package/docs/architecture/13-world-tools.md +17 -3
  65. package/docs/architecture/14-surfaces-and-parity.md +3 -3
  66. package/docs/architecture/README.md +1 -0
  67. package/docs/architecture/proposals/PROP-0049-context-attribution.md +382 -0
  68. package/docs/architecture/proposals/README.md +1 -0
  69. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  70. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  71. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  72. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  73. package/docs/parity.md +6 -6
  74. package/docs/releases/1.3.1-unstable.3.md +54 -0
  75. package/docs/terminal-tui.md +28 -15
  76. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { isSession, projectOf } from "../core/caller.js";
12
12
  import { isDir, now, readJson, safeId, writeJson } from "../core/fs.js";
13
13
  import { charCount, compareText, lines, takeChars } from "../core/text.js";
14
+ import { readSkillFile } from "../tools/defaultSkills.js";
14
15
  import * as notes from "../wiki/notes.js";
15
16
  import { isWorldTool, WORLD_GUIDANCE } from "../world/untrusted.js";
16
17
  import { TODO_GUIDANCE, TODO_WRITE, describeTodo } from "../tools/todo.js";
@@ -22,12 +23,14 @@ const WIKI_BUDGET = 2500;
22
23
  const TOOLS_BUDGET = 8000;
23
24
  const REPORTING_BUDGET = 2000;
24
25
  const SESSION_STATE_BUDGET = 1_000;
26
+ export const REQUIRED_SKILLS_BUDGET = 12_000;
27
+ const REQUIRED_SKILLS_POINTER = 'Some required skill text was omitted to fit. Load the rest with skill_load.';
25
28
  export const REFERENCES_BUDGET = 12000;
26
29
  /** Always-on variant text is capped when the variant is saved, so its sections are never cut here. */
27
30
  const VARIANT_BUDGET = 30_000;
28
31
  const NOTES_LISTED = 25;
29
32
  /** Where the harness's own sections sit; an application's sections are placed among them by `order`. */
30
- export const SECTION_ORDER = { harnessIdentity: 5, system: 25, sessionState: 30, guardrails: 40, memory: 45, references: 50, subagents: 64, scripts: 66, wiki: 80, tools: 90, reporting: 95 };
33
+ export const SECTION_ORDER = { harnessIdentity: 5, system: 25, sessionState: 30, guardrails: 40, memory: 45, references: 50, requiredSkills: 52, subagents: 64, scripts: 66, wiki: 80, tools: 90, reporting: 95 };
31
34
  /** How a run's final reply is shaped. Fixed text: it sits in the stable prefix of every pack. */
32
35
  export const REPORTING_GUIDANCE = `When you finish work, write the final reply for a person who did not watch you work.
33
36
 
@@ -235,6 +238,46 @@ function selectedReferences(root, launch) {
235
238
  built.cut ||= cut;
236
239
  return built;
237
240
  }
241
+ /** The request-scoped text for skills the person named; the compiler and the terminal word it alike. */
242
+ export function requiredSkillsText(skills) {
243
+ const seen = new Set();
244
+ const blocks = [];
245
+ for (const skill of skills) {
246
+ if (seen.has(skill.name))
247
+ continue;
248
+ seen.add(skill.name);
249
+ const label = skill.label ?? skill.name;
250
+ if (!skill.body.trim()) {
251
+ blocks.push(`- ${label}: this skill is unavailable in this session. Tell the person rather than guessing its steps.`);
252
+ continue;
253
+ }
254
+ const location = [
255
+ skill.baseDir ? `References in this skill resolve against ${skill.baseDir}.` : '',
256
+ skill.path ? `Skill file: ${skill.path}.` : ''
257
+ ].filter(Boolean).join(' ');
258
+ blocks.push(`### ${label}\n\n${location ? `${location}\n\n` : ''}${skill.body.trim()}`);
259
+ }
260
+ if (!blocks.length)
261
+ return null;
262
+ const text = `The person required these skills for this request. Follow each skill's instructions where they apply. They are instructions for this request, not a change to your tools or permissions.\n\n${blocks.join('\n\n')}`;
263
+ return fit(text, REQUIRED_SKILLS_BUDGET, REQUIRED_SKILLS_POINTER).body;
264
+ }
265
+ function requiredSkillsSection(launch, variant) {
266
+ const wanted = launch.requiredSkills ?? [];
267
+ if (!wanted.length)
268
+ return null;
269
+ const skills = wanted.map((entry) => {
270
+ const skill = readSkillFile(variant?.dir ?? null, entry.name);
271
+ return skill ? { ...skill, label: entry.label ?? skill.name } : { name: entry.name, label: entry.label, body: '' };
272
+ });
273
+ const text = requiredSkillsText(skills);
274
+ const seen = new Set();
275
+ const sources = skills.filter((skill) => skill.body && !seen.has(skill.name) && Boolean(seen.add(skill.name)))
276
+ .map((skill) => ({ id: skill.name, kind: 'skill', revision: variant?.fingerprint ?? null }));
277
+ return text
278
+ ? section('required-skills', 'Required skills', text, REQUIRED_SKILLS_BUDGET, REQUIRED_SKILLS_POINTER, sources)
279
+ : null;
280
+ }
238
281
  /** An application's section, after any pinned text. */
239
282
  function hostSection(host) {
240
283
  const own = host.body !== null ? section(host.id, host.title, host.body, host.budget, host.pointer ?? '', []) : null;
@@ -281,6 +324,12 @@ export function compile(env, context, launch = {}, variant = null) {
281
324
  const { root } = env;
282
325
  const groups = toolGroups(context, launch, root);
283
326
  const variantSource = variant ? [{ id: variant.scope ?? variant.dir, kind: 'variant', revision: variant.fingerprint }] : [];
327
+ const toolSection = launch.hideToolGuidance ? null : tools(groups, variant?.toolGuidance ?? '', launch.toolsPointer ?? '');
328
+ // The tools section ends with the variant's tool guidance: record how much of it is the variant's.
329
+ if (toolSection && variant?.toolGuidance.trim() && !toolSection.cut) {
330
+ const block = `### How to use your tools\n\n${variant.toolGuidance.trim()}\n`;
331
+ toolSection.sources = [{ ...variantSource[0], chars: Math.min(block.length, toolSection.size) }];
332
+ }
284
333
  const built = [
285
334
  {
286
335
  order: SECTION_ORDER.harnessIdentity,
@@ -289,8 +338,9 @@ export function compile(env, context, launch = {}, variant = null) {
289
338
  ...(variant?.sections ?? []).map((entry) => ({ order: entry.order, section: section(entry.id, entry.title, entry.body, VARIANT_BUDGET, '', variantSource) })),
290
339
  { order: SECTION_ORDER.sessionState, section: sessionStateSection(launch.sessionState, launch.tools ?? []) },
291
340
  { order: SECTION_ORDER.references, section: selectedReferences(root, launch) },
341
+ { order: SECTION_ORDER.requiredSkills, section: requiredSkillsSection(launch, variant) },
292
342
  { order: SECTION_ORDER.wiki, section: wiki(root, context) },
293
- { order: SECTION_ORDER.tools, section: launch.hideToolGuidance ? null : tools(groups, variant?.toolGuidance ?? '', launch.toolsPointer ?? '') },
343
+ { order: SECTION_ORDER.tools, section: toolSection },
294
344
  { order: SECTION_ORDER.reporting, section: section('reporting', 'Reporting your work', REPORTING_GUIDANCE, REPORTING_BUDGET, '', []) },
295
345
  ...(launch.standard ? standardSections({ ...launch.standard, project: launch.standard.project ?? projectName(root, context) }, launch.tools ?? []).filter((own) => !(launch.sections ?? []).some((host) => host.id === own.id)) : []).map((own) => ({ order: own.order, section: hostSection(own) })),
296
346
  ...(launch.sections ?? []).filter((host) => host.id !== 'session-state').map((host) => ({ order: host.order, section: hostSection(host) }))
@@ -306,17 +356,22 @@ export function compile(env, context, launch = {}, variant = null) {
306
356
  compiledAt: now()
307
357
  };
308
358
  }
309
- /** The pack as the one string a session receives. */
310
- export function render(env, pack) {
359
+ /** The pack as one string, with the characters each section contributed to it. */
360
+ export function renderSized(env, pack) {
311
361
  let text = `# ${env.productName} context\n`;
362
+ const sizes = {};
312
363
  for (const entry of pack.sections) {
313
364
  // Identity carries its own framing.
314
- if (entry.id === 'identity')
315
- text += `\n${entry.body}\n`;
316
- else
317
- text += `\n## ${entry.title}\n\n${entry.body}\n`;
365
+ const piece = entry.id === 'identity' ? `\n${entry.body}\n` : `\n## ${entry.title}\n\n${entry.body}\n`;
366
+ sizes[entry.id] = (sizes[entry.id] ?? 0) + piece.length;
367
+ text += piece;
318
368
  }
319
- return text;
369
+ const total = Object.values(sizes).reduce((sum, size) => sum + size, 0);
370
+ return { text, sizes, framing: text.length - total };
371
+ }
372
+ /** The pack as the one string a session receives. */
373
+ export function render(env, pack) {
374
+ return renderSized(env, pack).text;
320
375
  }
321
376
  export function packPath(root, runId) {
322
377
  return join(root, 'runs', safeId(runId), 'context.json');
@@ -341,9 +396,10 @@ export function record(root, pack) {
341
396
  export const BUILT_IN_DYNAMIC = [
342
397
  { id: 'session-state', order: SECTION_ORDER.sessionState },
343
398
  { id: 'references', order: SECTION_ORDER.references },
399
+ { id: 'required-skills', order: SECTION_ORDER.requiredSkills },
344
400
  { id: 'wiki', order: SECTION_ORDER.wiki }
345
401
  ];
346
- export const CONTEXT_GUIDANCE = 'App-origin context updates in the conversation supply current Wiki notes, active work, session state, and selected references. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.';
402
+ export const CONTEXT_GUIDANCE = 'App-origin context updates in the conversation supply current Wiki notes, active work, session state, selected references, and the skills the person required for this request. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references and required skills apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.';
347
403
  /** The hidden message a context update is saved as in native history, and where its snapshot sits. */
348
404
  export const CONTEXT_MESSAGE_TYPE = 'xingular-context';
349
405
  export const CONTEXT_SNAPSHOT_KEY = 'xingularContext';
@@ -439,19 +495,35 @@ export function sessionLaunch(env, context, launch, historyFile, variant = null)
439
495
  const previous = historyFile ? deliveredSnapshot(historyFile) : null;
440
496
  const snapshot = pack.sections.filter((entry) => dynamic.includes(entry.id));
441
497
  const updated = [];
442
- let content = '[App context update — not a user message]\nListed sections replace their previous app snapshots.\n';
498
+ const updateSizes = {};
499
+ const preamble = '[App context update — not a user message]\nListed sections replace their previous app snapshots.\n';
500
+ let content = preamble;
443
501
  for (const id of dynamic) {
444
502
  const current = snapshot.find((entry) => entry.id === id);
445
503
  // Explicit references belong to this request even when the same reference was selected before.
446
- if (previous && sameSection(current, previous.find((entry) => entry.id === id)) && !(id === 'references' && current))
504
+ if (previous && sameSection(current, previous.find((entry) => entry.id === id)) && !(id === 'references' && current) && !(id === 'required-skills' && current))
447
505
  continue;
448
506
  updated.push(id);
449
- content += current ? `\n## ${current.title}\n\n${current.body}\n` : `\n## ${id}\n\nThis section is not supplied for this request. Its earlier app snapshots are no longer current.\n`;
507
+ const piece = current ? `\n## ${current.title}\n\n${current.body}\n` : `\n## ${id}\n\nThis section is not supplied for this request. Its earlier app snapshots are no longer current.\n`;
508
+ content += piece;
509
+ updateSizes[id] = piece.length;
450
510
  }
451
511
  const stable = pack.sections.filter((entry) => !dynamic.includes(entry.id));
452
- pack.delivery = { systemSectionIds: stable.map((entry) => entry.id), updatedSectionIds: updated };
512
+ const rendered = renderSized(env, { sections: stable });
513
+ const system = `${rendered.text}\n## Context updates\n\n${CONTEXT_GUIDANCE}\n`;
514
+ const total = (sizes) => Object.values(sizes).reduce((sum, size) => sum + size, 0);
515
+ pack.delivery = {
516
+ systemSectionIds: stable.map((entry) => entry.id),
517
+ updatedSectionIds: updated,
518
+ sizes: {
519
+ system: rendered.sizes,
520
+ update: updated.length ? updateSizes : {},
521
+ systemFraming: system.length - total(rendered.sizes),
522
+ updateFraming: updated.length ? content.length - total(updateSizes) : 0
523
+ }
524
+ };
453
525
  record(env.root, pack);
454
- return { system: `${render(env, { sections: stable })}\n## Context updates\n\n${CONTEXT_GUIDANCE}\n`, update: updated.length ? { content, snapshot } : null, pack };
526
+ return { system, update: updated.length ? { content, snapshot } : null, pack };
455
527
  }
456
528
  // ---- Sessions that keep the instructions they started with ---------------------------------
457
529
  /** A terminal session keeps the pack it started with; each of its runs records a copy. */
@@ -12,6 +12,7 @@ import * as plugins from "../plugins/api.js";
12
12
  import { BUILTIN_PLUGIN_REGISTRY } from "../plugins/builtin.js";
13
13
  import * as install from "../plugins/install.js";
14
14
  import * as proposals from "../plugins/proposals.js";
15
+ import { attribution } from "../context/attribution.js";
15
16
  import { compile, load, render } from "../prompt/compile.js";
16
17
  import { continuationOf } from "../sessions/continuation.js";
17
18
  import * as control from "../sessions/control.js";
@@ -200,6 +201,8 @@ export function methods(harness) {
200
201
  return params.render ? { pack, text: render(env, pack) } : pack;
201
202
  },
202
203
  'context.runPack': (params) => load(root(), text(params, 'runId')),
204
+ // What the request was made of: one row per contributor, reconciled to the provider's figure.
205
+ 'context.attribution': (params) => attribution(env, text(params, 'runId')),
203
206
  // ---- Plugins: everything that shapes a session, and the trust a working folder's code needs ---
204
207
  // A host that is not a Node program describes its own parts here instead of in the options.
205
208
  'plugins.describe': (params) => {
@@ -271,6 +271,10 @@ export declare const runEventPayloadSchemas: {
271
271
  description: z.ZodString;
272
272
  parameters: z.ZodUnknown;
273
273
  }, z.core.$strip>>;
274
+ compaction: z.ZodOptional<z.ZodObject<{
275
+ enabled: z.ZodBoolean;
276
+ reserveTokens: z.ZodNumber;
277
+ }, z.core.$strip>>;
274
278
  systemPromptTruncated: z.ZodOptional<z.ZodBoolean>;
275
279
  contextUpdateTruncated: z.ZodOptional<z.ZodBoolean>;
276
280
  toolsTruncated: z.ZodOptional<z.ZodBoolean>;
@@ -42,6 +42,8 @@ const contextSnapshotPayload = z.object({
42
42
  systemPrompt: z.string().max(100_000),
43
43
  contextUpdate: z.string().max(100_000).optional(),
44
44
  tools: z.array(z.object({ name: z.string(), description: z.string().max(2_000), parameters: z.unknown() })).max(120),
45
+ /** The headroom Pi held the run to when it measured compaction, as the session's own settings give it. */
46
+ compaction: z.object({ enabled: z.boolean(), reserveTokens: z.number().nonnegative() }).optional(),
45
47
  systemPromptTruncated: z.boolean().optional(),
46
48
  contextUpdateTruncated: z.boolean().optional(),
47
49
  toolsTruncated: z.boolean().optional()
@@ -170,6 +170,16 @@ export interface PiSessionLike {
170
170
  description?: string;
171
171
  parameters?: unknown;
172
172
  }>;
173
+ /** Pi's compaction settings, so the run records the headroom it was held to. */
174
+ settingsManager?: {
175
+ getCompactionSettings(model?: {
176
+ provider: string;
177
+ id: string;
178
+ }): {
179
+ enabled: boolean;
180
+ reserveTokens: number;
181
+ };
182
+ };
173
183
  subscribe(listener: (event: PiEvent) => void): () => void;
174
184
  prompt(text: string, options?: {
175
185
  images?: unknown[];
@@ -129,6 +129,21 @@ function contextToolSpecs(session) {
129
129
  });
130
130
  return { tools: tools.map((entry) => entry.spec), truncated: source.length > MAX_TOOL_SPECS || tools.some((entry) => entry.truncated) };
131
131
  }
132
+ /** Pi's compaction settings for this session's model, when the session exposes them. */
133
+ function compactionSettings(session) {
134
+ const manager = session.settingsManager;
135
+ if (!manager?.getCompactionSettings)
136
+ return null;
137
+ try {
138
+ const model = session.model?.id && session.model.provider ? { provider: session.model.provider, id: session.model.id } : undefined;
139
+ const settings = manager.getCompactionSettings(model);
140
+ return { enabled: settings.enabled === true, reserveTokens: Math.max(0, Math.round(settings.reserveTokens || 0)) };
141
+ }
142
+ catch {
143
+ // A model Pi cannot resolve keeps the reserve unknown rather than guessed.
144
+ return null;
145
+ }
146
+ }
132
147
  const numberField = (event, key, fallback = 0) => (typeof event[key] === 'number' && Number.isFinite(event[key]) ? event[key] : fallback);
133
148
  const stringField = (event, key, fallback = '') => (typeof event[key] === 'string' ? event[key] : fallback);
134
149
  const lastLine = (text) => {
@@ -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 } : {})
@@ -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));
@@ -10,6 +10,6 @@ export interface SocketConnection {
10
10
  }
11
11
  export declare function connectionFromEnv(env?: NodeJS.ProcessEnv): SocketConnection;
12
12
  /** A memory, Wiki, recall, or connected-app call made on behalf of the session that owns the token. */
13
- export declare function socketCall<T = unknown>(connection: SocketConnection, call: string, params?: Record<string, unknown>, timeoutMs?: number, control?: string): Promise<T>;
13
+ export declare function socketCall<T = unknown>(connection: SocketConnection, call: string, params?: Record<string, unknown>, timeoutMs?: number, control?: string, signal?: AbortSignal): Promise<T>;
14
14
  /** Telemetry and decisions from the native `pi` process. Approvals may wait on a person, so the default wait is long. */
15
15
  export declare function terminalCall<T = Record<string, unknown>>(event: string, data?: Record<string, unknown>, timeoutMs?: number): Promise<T>;