@aeondave/pi-persona 1.17.1 → 1.17.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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -1,28 +1,14 @@
1
1
  /**
2
- * The `delegate` tool's core logic — pure, engine-injected, testable. Maps params
3
- * to engine runs (single + bounded parallel), formats the result, and emits live
4
- * per-task "views" (running → done/failed) so the UI can show each sub-agent as
5
- * it runs. A task may carry `skills`/`model`/`tools` to spawn a *dynamic* sub-agent
6
- * (e.g. the generic `operator` specialised by the skills it loads). Capability
7
- * gating happens upstream in the `tool_call` hook.
2
+ * The `delegate` tool's pure core: parameter coercion and validation, the task → run-spec mapping,
3
+ * and the anti-loop ledger. A task may carry `skills`/`model`/`tools` to spawn a *dynamic* sub-agent
4
+ * (e.g. the generic `operator` specialised by the skills it loads). Capability gating happens
5
+ * upstream in the `tool_call` hook; launching and settling live in `delegate-tool.ts`.
8
6
  */
9
7
 
10
- import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
11
- import { mapWithConcurrency } from "../orchestration/parallel.ts";
12
8
  import { cappedList } from "../core/format.ts";
9
+ import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "../core/types.ts";
13
10
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
14
- import { validateParallelWriteSets } from "../core/ownership.ts";
15
- import { aggregateResults } from "../orchestration/reducers.ts";
16
- import {
17
- type AgentRunSpec,
18
- type InfrastructureFailure,
19
- isInfrastructureFailure,
20
- isPositiveFiniteMs,
21
- legFailure,
22
- type SteerFn,
23
- type StrategyEngine,
24
- } from "../orchestration/sdk.ts";
25
- import type { AgentResult } from "../orchestration/types.ts";
11
+ import { type AgentRunSpec, type InfrastructureFailure, isInfrastructureFailure, isPositiveFiniteMs } from "../orchestration/sdk.ts";
26
12
 
27
13
  export interface DelegateTask {
28
14
  agent: string;
@@ -35,6 +21,8 @@ export interface DelegateTask {
35
21
  skills?: string[];
36
22
  /** Model override for this sub-agent. */
37
23
  model?: string;
24
+ /** Requested effort for this leg; overrides agent and shared child defaults. Pi clamps it. */
25
+ thinking?: ThinkingLevel;
38
26
  /** Tool allowlist override for this sub-agent. */
39
27
  tools?: string[];
40
28
  /** Extra system-prompt text: an ON-THE-FLY specialist persona, appended to the
@@ -62,6 +50,7 @@ export interface DelegateParams {
62
50
  name?: string;
63
51
  skills?: string[];
64
52
  model?: string;
53
+ thinking?: ThinkingLevel;
65
54
  tools?: string[];
66
55
  role?: string;
67
56
  isolation?: "none" | "worktree";
@@ -73,8 +62,6 @@ export interface DelegateParams {
73
62
  outputContract?: string;
74
63
  tasks?: DelegateTask[];
75
64
  concurrency?: number;
76
- async?: boolean;
77
- sync?: boolean;
78
65
  }
79
66
 
80
67
  /** Structured cold-start context that any persona policy may require. */
@@ -150,10 +137,6 @@ export function validateDelegationBrief(params: DelegateParams): string | undefi
150
137
  return undefined;
151
138
  }
152
139
 
153
- // Moved to core/ownership.ts (so a strategy can call it without importing a tool module);
154
- // re-exported here unchanged for this file's own call sites and existing tests.
155
- export { findWriteSetOverlaps, validateParallelWriteSets, type WriteSetOverlap } from "../core/ownership.ts";
156
-
157
140
  function unwrapJsonish(value: unknown, label: string): { ok: true; value: unknown } | { ok: false; error: string } {
158
141
  let current = value;
159
142
  for (let depth = 0; depth < 2; depth++) {
@@ -222,6 +205,9 @@ function decodedTaskFieldError(bag: Record<string, unknown>, index: number): str
222
205
  return `delegate: ${label}.${field} must be a string.`;
223
206
  }
224
207
  }
208
+ if (bag.thinking !== undefined && !isThinkingLevel(bag.thinking)) {
209
+ return `delegate: ${label}.thinking must be one of: ${THINKING_LEVELS.join(", ")}.`;
210
+ }
225
211
  if (bag.isolation !== undefined && bag.isolation !== "none" && bag.isolation !== "worktree") {
226
212
  return `delegate: ${label}.isolation must be "none" or "worktree".`;
227
213
  }
@@ -281,6 +267,9 @@ export function coerceDelegateParams(raw: unknown): { ok: true; params: Delegate
281
267
  return { ok: false, error: "delegate: arguments must be an object." };
282
268
  }
283
269
  const bag = { ...(raw as Record<string, unknown>) };
270
+ if (bag.thinking !== undefined && !isThinkingLevel(bag.thinking)) {
271
+ return { ok: false, error: `delegate: thinking must be one of: ${THINKING_LEVELS.join(", ")}.` };
272
+ }
284
273
  const tasks = coerceTaskList(bag.tasks);
285
274
  if (!tasks.ok) return tasks;
286
275
  if (tasks.value !== undefined) bag.tasks = tasks.value;
@@ -299,32 +288,16 @@ export function coerceDelegateParams(raw: unknown): { ok: true; params: Delegate
299
288
  return { ok: true, params: bag as DelegateParams };
300
289
  }
301
290
 
302
- /** A live per-sub-agent view for the UI (running → done/failed). */
303
- export interface DelegateView {
304
- agent: string;
305
- /** Display label: `name · model` (e.g. "kiln-port · sonnet-4-6"). */
306
- label: string;
307
- running: boolean;
308
- ok: boolean;
309
- /** Terminal failure classification; `abort` projects as stopped rather than failed in the UI. */
310
- failureKind?: AgentResult["failureKind"];
311
- output: string;
312
- /** The tool the leg is currently running (e.g. "grep src/…"), "" if none. */
313
- activity: string;
314
- usage: ChildUsage;
315
- }
316
-
317
- /** Short model name for display: drop provider + `claude-` + any `:thinking`. */
291
+ /** Short model name for display: drop provider + `claude-`; model ids remain opaque. */
318
292
  export function shortModel(ref: string | undefined): string {
319
293
  if (!ref) return "";
320
- const base = ref.split(":")[0]?.split("/").pop() ?? ref;
294
+ const base = ref.split("/").pop() ?? ref;
321
295
  return base.replace(/^claude-/, "");
322
296
  }
323
297
 
324
298
  /** The bare display NAME for a sub-agent (no model suffix): the loader's name, or a codename
325
299
  * for a generic operator, else the agent's own name. Exported so a caller that folds in the
326
- * model itself (e.g. the async tree node, `AsyncRun.label`) doesn't have to strip it back out
327
- * of {@link labelFor}'s composite string. */
300
+ * model itself (e.g. the async tree node, `AsyncRun.label`). */
328
301
  export function nameFor(t: { agent: string; name?: string }, index: number): string {
329
302
  // No catalog of call-signs: an unnamed generic operator stays the agent name, disambiguated
330
303
  // by index. The model is supposed to invent `<call-sign>-<purpose>` itself; this is only the
@@ -333,28 +306,8 @@ export function nameFor(t: { agent: string; name?: string }, index: number): str
333
306
  return sanitizeDisplayLabel(t.name?.trim() || fallback, fallback);
334
307
  }
335
308
 
336
- /** Display label for a sub-agent: its bare {@link nameFor} name plus its short model. */
337
- export function labelFor(t: { agent: string; name?: string; model?: string }, index: number): string {
338
- const name = nameFor(t, index);
339
- const m = shortModel(t.model);
340
- return m ? `${name} · ${m}` : name;
341
- }
342
-
343
- export interface DelegateOutcome {
344
- text: string;
345
- results: AgentResult[];
346
- views: DelegateView[];
347
- ok: boolean;
348
- }
349
-
350
- /** Hard ceilings the engine enforces regardless of what the LLM asks for. */
351
- export interface DelegateLimits {
352
- maxConcurrency: number;
353
- maxChildren: number;
354
- }
355
-
356
- /** Normalize an LLM-authored concurrency request once for sync scheduling, async scheduling, and
357
- * policy gates. Fractional/non-finite inputs fail toward serialization instead of rounding up. */
309
+ /** Normalize an LLM-authored concurrency request once for scheduling and policy gates.
310
+ * Fractional/non-finite inputs fail toward serialization instead of rounding up. */
358
311
  export function normalizeDelegateConcurrency(value: number | undefined, maximum: number): number {
359
312
  const ceiling = Math.max(1, Number.isFinite(maximum) ? Math.floor(maximum) : 1);
360
313
  if (value === undefined) return ceiling;
@@ -363,9 +316,8 @@ export function normalizeDelegateConcurrency(value: number | undefined, maximum:
363
316
  }
364
317
 
365
318
  /** Map a task to the run-spec fields the engine accepts — the single source of truth for the
366
- * delegate path's field mapping. Exported so the async launch path (extension.ts) routes
367
- * through the SAME mapping instead of duplicating it (a duplicated copy is how NP2's
368
- * `timeoutMs` could silently miss the async fan-out, which is the interactive default). */
319
+ * delegate path's field mapping (a second copy is how a per-leg knob like `timeoutMs` silently
320
+ * misses one launch route). */
369
321
  export function specOf(t: DelegateTask, index = 0): AgentRunSpec {
370
322
  const renderedBrief = renderDelegationBrief(t.brief);
371
323
  const spec: AgentRunSpec = {
@@ -375,6 +327,7 @@ export function specOf(t: DelegateTask, index = 0): AgentRunSpec {
375
327
  };
376
328
  if (t.skills && t.skills.length > 0) spec.skills = t.skills;
377
329
  if (t.model) spec.model = t.model;
330
+ if (t.thinking !== undefined) spec.thinking = t.thinking;
378
331
  if (t.tools !== undefined) spec.tools = t.tools;
379
332
  if (t.role?.trim()) spec.role = t.role.trim();
380
333
  if (t.outputContract?.trim()) spec.outputContract = t.outputContract.trim();
@@ -406,6 +359,18 @@ export interface DelegationKeyInput {
406
359
  isolation?: string;
407
360
  }
408
361
 
362
+ /** The ledger's view of a task or run spec: just the key fields, absent when unset. */
363
+ export function keyInputOf(t: Pick<DelegateTask, "agent" | "task" | "model" | "role" | "tools" | "isolation">): DelegationKeyInput {
364
+ return {
365
+ agent: t.agent,
366
+ task: t.task,
367
+ ...(t.model ? { model: t.model } : {}),
368
+ ...(t.role ? { role: t.role } : {}),
369
+ ...(t.tools !== undefined ? { tools: t.tools } : {}),
370
+ ...(t.isolation ? { isolation: t.isolation } : {}),
371
+ };
372
+ }
373
+
409
374
  export class DelegationLedger {
410
375
  private readonly failures = new Map<string, number>();
411
376
  private readonly maxKeys = 200;
@@ -469,9 +434,6 @@ export class DelegationLedger {
469
434
  * an operator abort is cancellation, and an engine REJECTION is the harness breaking (see
470
435
  * {@link isInfrastructureFailure}) — a full disk or a crashed adapter says nothing about whether the
471
436
  * delegation was a good idea, and two of them would blacklist a task that was never really attempted.
472
- * Lives beside the ledger so every path that records feeds it the same notion of evidence: the sync
473
- * fan-out settles a rejection as `ok:false` while the single and async paths let it propagate, and
474
- * without this the identical infrastructure crash would count in one path and vanish in the others.
475
437
  */
476
438
  export function shouldRecordDelegationOutcome(result: Partial<InfrastructureFailure>): boolean {
477
439
  return result.failureKind !== "abort" && !isInfrastructureFailure(result);
@@ -518,146 +480,3 @@ export function unknownContractError(requested: Array<string | undefined>, insta
518
480
  "`outputContract` takes a contract NAME; omit it for free-form output and describe the report shape in `requiredArtifacts`."
519
481
  );
520
482
  }
521
-
522
- /**
523
- * Should this `delegate` call run in the BACKGROUND (async)? An explicit `async` always wins;
524
- * otherwise an interactive session (`hasUI`) defaults to background — the supervisor stays free and
525
- * results return as follow-ups — unless the call opts out with `sync: true`. A headless run (no UI,
526
- * e.g. `pi -p`) defaults to SYNC: the single turn must carry the result, and nothing drains a
527
- * follow-up after the process exits. `renderCall` passes `hasUI: true` (it only ever fires in a UI),
528
- * so the async tag it shows tracks the exact same default the execute path takes.
529
- */
530
- export function wantsAsyncRun(params: { async?: boolean; sync?: boolean }, hasUI: boolean): boolean {
531
- return params.async ?? (hasUI && params.sync !== true);
532
- }
533
-
534
- function viewOf(agent: string, label: string, r: AgentResult): DelegateView {
535
- return {
536
- agent,
537
- label,
538
- running: false,
539
- ok: r.ok,
540
- ...(r.failureKind !== undefined ? { failureKind: r.failureKind } : {}),
541
- output: r.ok ? r.output || "(no output)" : r.error ?? "(failed)",
542
- activity: "",
543
- usage: r.usage,
544
- };
545
- }
546
-
547
- export async function runDelegate(
548
- params: DelegateParams,
549
- engine: StrategyEngine,
550
- limits: DelegateLimits = { maxConcurrency: 4, maxChildren: 8 },
551
- onProgress?: (views: DelegateView[]) => void,
552
- onLegStart?: (index: number, abort: () => void) => void,
553
- onLegSteerable?: (index: number, steer: SteerFn) => void,
554
- /** The RUN-level abort signal — the same one the caller baked into its engine. A whole-run
555
- * cancellation reaches a leg through THIS, never through the per-leg controller below, so
556
- * without it a cancelled leg whose engine rejects is filed as an agent failure and the user's
557
- * own stop renders as FAILED. Mirrors `SDKDeps.signal`, which classifies the same way. */
558
- signal?: AbortSignal,
559
- /** Authoritative per-leg tool lifecycle; arguments and output are intentionally absent. */
560
- onLegToolEvent?: (index: number, event: ToolEvent) => void,
561
- ): Promise<DelegateOutcome> {
562
- if (params.tasks && params.tasks.length > 0) {
563
- // Enforce the hard ceilings: cap the fan-out and clamp the concurrency the
564
- // LLM requested — a strategy/tool call cannot exceed the declared limits.
565
- const tasks = params.tasks.slice(0, limits.maxChildren);
566
- const dropped = params.tasks.length - tasks.length;
567
- const concurrency = normalizeDelegateConcurrency(params.concurrency, limits.maxConcurrency);
568
- if (concurrency > 1) {
569
- const writeSetError = validateParallelWriteSets(tasks);
570
- if (writeSetError) return { text: writeSetError, results: [], views: [], ok: false };
571
- }
572
- const labels = tasks.map((t, i) => labelFor(t, i));
573
- const views: DelegateView[] = tasks.map((t, i) => ({
574
- agent: t.agent,
575
- label: labels[i] as string,
576
- running: true,
577
- ok: false,
578
- output: "",
579
- activity: "",
580
- usage: emptyUsage(),
581
- }));
582
- onProgress?.(views.map((v) => ({ ...v })));
583
-
584
- const results = await mapWithConcurrency(tasks, concurrency, async (t, i) => {
585
- const ac = new AbortController();
586
- onLegStart?.(i, () => ac.abort());
587
- // An engine that REJECTS becomes this leg's own ok:false result: the fan-out's completed
588
- // siblings (already spawned and billed) must still reach the supervisor. Nothing in here
589
- // is run-fatal — the max-children ceiling was applied to `tasks` before anything spawned.
590
- let r: AgentResult;
591
- try {
592
- r = await engine.run(
593
- specOf(t, i),
594
- (p) => {
595
- // Stream the leg's rolling output + current tool activity.
596
- if (p.toolEvent) onLegToolEvent?.(i, p.toolEvent);
597
- const cur = views[i] as DelegateView;
598
- views[i] = { ...cur, output: p.output || cur.output, activity: p.activity ?? "" };
599
- onProgress?.(views.map((v) => ({ ...v })));
600
- },
601
- ac.signal,
602
- (steer) => onLegSteerable?.(i, steer),
603
- );
604
- } catch (err) {
605
- r = legFailure(t.agent, err, ac.signal.aborted || (signal?.aborted ?? false));
606
- }
607
- views[i] = viewOf(t.agent, labels[i] as string, r);
608
- onProgress?.(views.map((v) => ({ ...v })));
609
- return r;
610
- });
611
- const agg = aggregateResults(results);
612
- const text =
613
- dropped > 0
614
- ? `${agg.output}\n\n[pi-persona] ${dropped} task(s) beyond the max-children limit (${limits.maxChildren}) were dropped.`
615
- : agg.output;
616
- return { text, results, views, ok: agg.ok };
617
- }
618
-
619
- if (params.agent && params.task) {
620
- const single: DelegateTask = { agent: params.agent, task: params.task };
621
- if (params.name) single.name = params.name;
622
- if (params.skills && params.skills.length > 0) single.skills = params.skills;
623
- if (params.model) single.model = params.model;
624
- if (params.tools !== undefined) single.tools = params.tools;
625
- if (params.role?.trim()) single.role = params.role.trim();
626
- if (params.isolation !== undefined) single.isolation = params.isolation;
627
- if (params.mcp !== undefined) single.mcp = params.mcp;
628
- if (params.timeoutMs !== undefined) single.timeoutMs = params.timeoutMs;
629
- if (params.brief !== undefined) single.brief = params.brief;
630
- if (params.writeSet !== undefined) single.writeSet = params.writeSet;
631
- if (params.outputContract !== undefined) single.outputContract = params.outputContract;
632
- const label = labelFor(single, 0);
633
- const view: DelegateView = {
634
- agent: params.agent,
635
- label,
636
- running: true,
637
- ok: false,
638
- output: "",
639
- activity: "",
640
- usage: emptyUsage(),
641
- };
642
- onProgress?.([{ ...view }]);
643
- const ac = new AbortController();
644
- onLegStart?.(0, () => ac.abort());
645
- const r = await engine.run(
646
- specOf(single),
647
- (p) => {
648
- if (p.toolEvent) onLegToolEvent?.(0, p.toolEvent);
649
- if (p.output) view.output = p.output;
650
- view.activity = p.activity ?? "";
651
- onProgress?.([{ ...view }]);
652
- },
653
- ac.signal,
654
- (steer) => onLegSteerable?.(0, steer),
655
- );
656
- const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
657
- const terminalView = viewOf(params.agent, label, r);
658
- onProgress?.([{ ...terminalView }]);
659
- return { text, results: [r], views: [terminalView], ok: r.ok };
660
- }
661
-
662
- return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
663
- }
@@ -0,0 +1,168 @@
1
+ /** Runtime validation and TypeBox schemas for the Exocom collaboration tools. */
2
+ import { type Static, Type } from "typebox";
3
+
4
+ import { writeSetPathError } from "../core/ownership.ts";
5
+
6
+ const TOKEN = { minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" } as const;
7
+ export const TaskParams = Type.Object({
8
+ action: Type.Union([Type.Literal("create"), Type.Literal("take"), Type.Literal("progress"), Type.Literal("finish"), Type.Literal("list"), Type.Literal("get")], {
9
+ description: "Choose list to discover tasks (do not pass work_key or task_id); use get with both identifiers returned by create/list to inspect one exact task generation.",
10
+ }),
11
+ work_key: Type.Optional(Type.String({ ...TOKEN, description: "Required for create, take, progress, finish, and get. Omit for list. Pair with task_id for an existing task." })),
12
+ task_id: Type.Optional(Type.String({ ...TOKEN, description: "Generation id returned by create or list. Required with work_key for take, progress, finish, and get; omit for create and list." })),
13
+ description: Type.Optional(Type.String({ minLength: 1, maxLength: 2_048 })),
14
+ write_set: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 64 })),
15
+ note: Type.Optional(Type.String({ minLength: 1, maxLength: 4_096 })),
16
+ outcome: Type.Optional(Type.Union([Type.Literal("done"), Type.Literal("abandoned")])),
17
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 64 })),
18
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Rows per page: defaults to 10 for summaries or 5 when detail=true. Detailed pages allow at most 5." })),
19
+ detail: Type.Optional(Type.Boolean({ description: "Include peer-authored task/progress prose. Detailed lists default to 5 rows and reject an explicit limit above 5." })),
20
+ });
21
+ export const OfferParams = Type.Object({
22
+ work_key: Type.String({ ...TOKEN }),
23
+ task_id: Type.String({ ...TOKEN }),
24
+ target: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
25
+ expiresInMinutes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_440 })),
26
+ });
27
+ export const PublishParams = Type.Object({
28
+ action: Type.Union([Type.Literal("publish"), Type.Literal("read")]),
29
+ work_key: Type.Optional(Type.String({ ...TOKEN })),
30
+ task_id: Type.Optional(Type.String({ ...TOKEN })),
31
+ title: Type.Optional(Type.String({ minLength: 1, maxLength: 160 })),
32
+ excerpt: Type.Optional(Type.String({ minLength: 1, maxLength: 4_096 })),
33
+ msg_id: Type.Optional(Type.String({ ...TOKEN })),
34
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 64 })),
35
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
36
+ });
37
+
38
+ type RecordValue = Record<string, unknown>;
39
+ const isRecord = (value: unknown): value is RecordValue => !!value && typeof value === "object" && !Array.isArray(value);
40
+ const isToken = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
41
+ function invalid(tool: string, field: string, expected: string): never {
42
+ throw new Error(`${tool}: ${field} ${expected}`);
43
+ }
44
+ function exactKeys(tool: string, value: RecordValue, allowed: readonly string[]): void {
45
+ const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
46
+ if (unexpected) invalid(tool, unexpected, "is not a supported parameter");
47
+ }
48
+ function boundedString(tool: string, field: string, value: unknown, max: number): string {
49
+ if (typeof value !== "string" || value.length < 1 || value.length > max) invalid(tool, field, `must be 1–${max} characters`);
50
+ return value;
51
+ }
52
+ function tokenField(tool: string, field: string, value: unknown): string {
53
+ if (!isToken(value)) invalid(tool, field, "must be 1–128 token characters matching [A-Za-z0-9._:-]");
54
+ return value;
55
+ }
56
+ function objectInput(tool: string, input: unknown): RecordValue {
57
+ if (!isRecord(input)) invalid(tool, "parameters", "must be an object");
58
+ return input;
59
+ }
60
+
61
+ export type TaskInput =
62
+ | { action: "list"; offset: number; limit: number; detail: boolean }
63
+ | { action: "get"; work_key: string; task_id: string }
64
+ | { action: "create"; work_key: string; description: string; write_set: string[] }
65
+ | { action: "take"; work_key: string; task_id: string }
66
+ | { action: "progress"; work_key: string; task_id: string; note: string }
67
+ | { action: "finish"; work_key: string; task_id: string; note: string; outcome: "done" | "abandoned" };
68
+
69
+ export function parseTaskInput(input: unknown): TaskInput {
70
+ const tool = "exocom_task";
71
+ const value = objectInput(tool, input);
72
+ if (typeof value.action !== "string" || !["create", "take", "progress", "finish", "list", "get"].includes(value.action)) invalid(tool, "action", "must be create, take, progress, finish, list, or get");
73
+ const action = value.action as "create" | "take" | "progress" | "finish" | "list" | "get";
74
+ if (action === "list") {
75
+ exactKeys(tool, value, ["action", "offset", "limit", "detail"]);
76
+ const offset = value.offset === undefined ? 0 : value.offset;
77
+ const detail = value.detail === undefined ? false : value.detail;
78
+ const limit = value.limit === undefined ? (detail ? 5 : 10) : value.limit;
79
+ if (!Number.isInteger(offset) || (offset as number) < 0 || (offset as number) > 64) invalid(tool, "offset", "must be an integer from 0 to 64");
80
+ if (!Number.isInteger(limit) || (limit as number) < 1 || (limit as number) > 20) invalid(tool, "limit", "must be an integer from 1 to 20");
81
+ if (typeof detail !== "boolean") invalid(tool, "detail", "must be a boolean");
82
+ return { action, offset: offset as number, limit: limit as number, detail };
83
+ }
84
+ if (action === "get") {
85
+ exactKeys(tool, value, ["action", "work_key", "task_id"]);
86
+ return { action, work_key: tokenField(tool, "work_key", value.work_key), task_id: tokenField(tool, "task_id", value.task_id) };
87
+ }
88
+ if (action === "create") {
89
+ exactKeys(tool, value, ["action", "work_key", "description", "write_set"]);
90
+ const work_key = tokenField(tool, "work_key", value.work_key);
91
+ const description = boundedString(tool, "description", value.description, 2_048);
92
+ const write_set = value.write_set === undefined ? [] : value.write_set;
93
+ if (!Array.isArray(write_set) || write_set.length > 64 || write_set.some((path) => typeof path !== "string" || path.length < 1 || path.length > 256)) {
94
+ invalid(tool, "write_set", "must contain at most 64 paths of 1–256 characters");
95
+ }
96
+ for (const path of write_set as string[]) {
97
+ const issue = writeSetPathError(path);
98
+ if (issue) invalid(tool, "write_set path", `is invalid (${issue})`);
99
+ }
100
+ return { action, work_key, description, write_set: write_set as string[] };
101
+ }
102
+ const work_key = tokenField(tool, "work_key", value.work_key);
103
+ if (action === "take") {
104
+ exactKeys(tool, value, ["action", "work_key", "task_id"]);
105
+ return { action, work_key, task_id: tokenField(tool, "task_id", value.task_id) };
106
+ }
107
+ if (action === "progress") {
108
+ exactKeys(tool, value, ["action", "work_key", "task_id", "note"]);
109
+ return { action, work_key, task_id: tokenField(tool, "task_id", value.task_id), note: boundedString(tool, "note", value.note, 4_096) };
110
+ }
111
+ exactKeys(tool, value, ["action", "work_key", "task_id", "note", "outcome"]);
112
+ if (value.outcome !== "done" && value.outcome !== "abandoned") invalid(tool, "outcome", "must be done or abandoned");
113
+ return { action, work_key, task_id: tokenField(tool, "task_id", value.task_id), note: boundedString(tool, "note", value.note, 4_096), outcome: value.outcome };
114
+ }
115
+
116
+ export function parseOfferInput(input: unknown) {
117
+ const value = objectInput("exocom_offer", input);
118
+ exactKeys("exocom_offer", value, ["work_key", "task_id", "target", "expiresInMinutes"]);
119
+ const work_key = tokenField("exocom_offer", "work_key", value.work_key);
120
+ const task_id = tokenField("exocom_offer", "task_id", value.task_id);
121
+ let target: string | undefined;
122
+ if (value.target !== undefined) {
123
+ const raw = boundedString("exocom_offer", "target", value.target, 80);
124
+ if (raw === "*") invalid("exocom_offer", "target", "must name one peer, never *");
125
+ if (!/^[^\p{Cc}\p{Zl}\p{Zp}<>]+$/u.test(raw) || raw !== raw.trim()) invalid("exocom_offer", "target", "must exactly match a clean public target from exocom_list");
126
+ target = raw;
127
+ }
128
+ const expiresInMinutes = value.expiresInMinutes === undefined ? 60 : value.expiresInMinutes;
129
+ if (!Number.isInteger(expiresInMinutes) || (expiresInMinutes as number) < 1 || (expiresInMinutes as number) > 1_440) {
130
+ invalid("exocom_offer", "expiresInMinutes", "must be an integer from 1 to 1440");
131
+ }
132
+ return { work_key, task_id, target, expiresInMinutes: expiresInMinutes as number };
133
+ }
134
+
135
+ export type PublishInput =
136
+ | { action: "read"; msg_id: string }
137
+ | { action: "read"; work_key: string; task_id?: string; offset: number; limit: number }
138
+ | { action: "publish"; work_key: string; task_id: string; title: string; excerpt: string };
139
+
140
+ export function parsePublishInput(input: unknown): PublishInput {
141
+ const value = objectInput("exocom_publish", input);
142
+ if (value.action === "read") {
143
+ if (value.msg_id !== undefined) {
144
+ exactKeys("exocom_publish", value, ["action", "msg_id"]);
145
+ return { action: "read", msg_id: tokenField("exocom_publish", "msg_id", value.msg_id) };
146
+ }
147
+ exactKeys("exocom_publish", value, ["action", "work_key", "task_id", "offset", "limit"]);
148
+ const work_key = tokenField("exocom_publish", "work_key", value.work_key);
149
+ const task_id = value.task_id === undefined ? undefined : tokenField("exocom_publish", "task_id", value.task_id);
150
+ const offset = value.offset === undefined ? 0 : value.offset;
151
+ const limit = value.limit === undefined ? 10 : value.limit;
152
+ if (!Number.isInteger(offset) || (offset as number) < 0 || (offset as number) > 64) invalid("exocom_publish", "offset", "must be an integer from 0 to 64");
153
+ if (!Number.isInteger(limit) || (limit as number) < 1 || (limit as number) > 20) invalid("exocom_publish", "limit", "must be an integer from 1 to 20");
154
+ return { action: "read", work_key, ...(task_id === undefined ? {} : { task_id }), offset: offset as number, limit: limit as number };
155
+ }
156
+ if (value.action !== "publish") invalid("exocom_publish", "action", "must be publish or read");
157
+ exactKeys("exocom_publish", value, ["action", "work_key", "task_id", "title", "excerpt"]);
158
+ const work_key = tokenField("exocom_publish", "work_key", value.work_key);
159
+ const task_id = tokenField("exocom_publish", "task_id", value.task_id);
160
+ const title = boundedString("exocom_publish", "title", value.title, 160);
161
+ const excerpt = boundedString("exocom_publish", "excerpt", value.excerpt, 4_096);
162
+ if (Buffer.byteLength(excerpt, "utf8") > 8_192) invalid("exocom_publish", "excerpt", "must be at most 8192 UTF-8 bytes");
163
+ return { action: "publish", work_key, task_id, title, excerpt };
164
+ }
165
+
166
+ export type TaskParamsType = Static<typeof TaskParams>;
167
+ export type OfferParamsType = Static<typeof OfferParams>;
168
+ export type PublishParamsType = Static<typeof PublishParams>;