@aeondave/pi-persona 1.17.3 → 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 (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -2,6 +2,10 @@
2
2
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { AgentConfig } from "../agents/agent.ts";
4
4
  import { Type } from "typebox";
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import { THINKING_LEVELS, isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
7
+ import { validateParallelWriteSets } from "../core/ownership.ts";
8
+ import { effortFullName } from "../core/leg-thinking.ts";
5
9
  import { END_TURN_NOTE } from "../core/brief.ts";
6
10
  import { fenceUntrusted } from "../core/fence.ts";
7
11
  import { Text, visibleWidth } from "@earendil-works/pi-tui";
@@ -16,7 +20,7 @@ import { expandDetailHint, failureDetails, isInline } from "../extension/shared.
16
20
  import {
17
21
  DelegationLedger, type DelegateParams as DelegateCall, keyInputOf, nameFor, normalizeDelegateConcurrency,
18
22
  shortModel, shouldRecordDelegationOutcome, specOf, unknownAgentError, unknownContractError,
19
- validateDelegationBrief, validateParallelWriteSets, coerceDelegateParams,
23
+ validateDelegationBrief, coerceDelegateParams,
20
24
  } from "./delegate.ts";
21
25
  import { Semaphore } from "../orchestration/parallel.ts";
22
26
  import type { AgentRunSpec, SteerFn, StrategyEngine } from "../orchestration/sdk.ts";
@@ -24,7 +28,7 @@ import type { AgentResult } from "../orchestration/types.ts";
24
28
  import { sanitizeLabel } from "../extension/shared.ts";
25
29
  import type { PersonaController } from "../persona/controller.ts";
26
30
  import type { AgentTree } from "../ui/agent-tree.ts";
27
- import { buildCompletionReport, runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
31
+ import { buildCompletionReport, observedRunModel, runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
28
32
  import type { BackgroundRuns } from "./background-run.ts";
29
33
  import { emptyUsage, type ProgressSnapshot, type ToolEvent } from "../engine/stream.ts";
30
34
  import { progressPatch } from "../ui/agent-tree.ts";
@@ -35,6 +39,8 @@ interface DelegateLaunchSnapshot {
35
39
  label: string;
36
40
  agent: string;
37
41
  model?: string;
42
+ /** Effort THIS call declared in its args (spec-level); absent when the call declared none. */
43
+ thinking?: ThinkingLevel;
38
44
  }
39
45
 
40
46
  interface DelegateLaunchRow {
@@ -43,6 +49,7 @@ interface DelegateLaunchRow {
43
49
  detailLabel: string;
44
50
  agent: string;
45
51
  model?: string;
52
+ thinking?: ThinkingLevel;
46
53
  displayLabel: string;
47
54
  clipped: boolean;
48
55
  hasAlias: boolean;
@@ -57,6 +64,12 @@ interface CollapsedLaunchLine {
57
64
 
58
65
  const COLLAPSED_LAUNCH_ROWS = 3;
59
66
 
67
+ /** One formatter for every launch-card metadata line: agent · model · FULL effort name —
68
+ * only the effort this call declared; `off` renders nothing (absence means off). */
69
+ function launchRowMetadata(row: { agent?: string; model?: string; thinking?: ThinkingLevel }): string {
70
+ return [row.agent, row.model, row.thinking ? effortFullName(row.thinking) : ""].filter((value): value is string => !!value).join(" · ");
71
+ }
72
+
60
73
  /** Prefer session-persisted identity, then recover old launch cards from the live tracker. */
61
74
  function launchRows(
62
75
  details: { runId?: unknown; runIds?: unknown; runs?: unknown },
@@ -74,6 +87,7 @@ function launchRows(
74
87
  label: run.label,
75
88
  agent: run.agent,
76
89
  ...(typeof run.model === "string" ? { model: run.model } : {}),
90
+ ...(isThinkingLevel(run.thinking) ? { thinking: run.thinking } : {}),
77
91
  });
78
92
  }
79
93
  }
@@ -111,6 +125,7 @@ function launchRows(
111
125
  label: sanitizeTerminalText(run.label),
112
126
  agent: sanitizeTerminalText(run.agent),
113
127
  ...(run.model ? { model: sanitizeTerminalText(run.model) } : {}),
128
+ ...(run.thinking ? { thinking: run.thinking } : {}),
114
129
  fullName: runDisplayName(run),
115
130
  hasAlias,
116
131
  }));
@@ -120,7 +135,7 @@ function launchRows(
120
135
  const duplicateSuffix = counts.get(row.fullName)! > 1 ? ` [${compactInlineText(row.id, { maxChars: 16 })}]` : "";
121
136
  const detailLabel = `${row.label}${duplicateSuffix}`;
122
137
  const displayLabel = compactInlineText(detailLabel, { maxChars: 80 });
123
- const displayMetadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
138
+ const displayMetadata = launchRowMetadata(row);
124
139
  return {
125
140
  ...row,
126
141
  detailLabel,
@@ -141,7 +156,7 @@ function collapsedLaunchRows(rows: readonly DelegateLaunchRow[], dropped: number
141
156
  ];
142
157
  const hintText = compactInlineText(expandDetailHint(), { maxChars: 32 });
143
158
  const rowsNeedHint = rows.slice(0, visibleCount).some((row, index) => {
144
- const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
159
+ const metadata = launchRowMetadata(row);
145
160
  const prefixWidth = index === 0 ? visibleWidth("launched ") : visibleWidth(" ");
146
161
  return row.clipped || prefixWidth + visibleWidth(row.displayLabel) + (metadata ? 3 + visibleWidth(metadata) : 0) > MAX_COLLAPSED_CARD_COLUMNS;
147
162
  });
@@ -151,7 +166,7 @@ function collapsedLaunchRows(rows: readonly DelegateLaunchRow[], dropped: number
151
166
  const prefix = index === 0 ? "launched " : " ";
152
167
  const fixedWidth = visibleWidth(prefix) + (index === visibleCount - 1 ? visibleWidth(suffix) : 0);
153
168
  const budget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - fixedWidth);
154
- const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
169
+ const metadata = launchRowMetadata(row);
155
170
  const metadataBudget = Math.min(36, visibleWidth(metadata));
156
171
  const labelBudget = Math.max(16, budget - (metadata ? metadataBudget + 3 : 0));
157
172
  const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
@@ -167,7 +182,7 @@ function collapsedLaunchRows(rows: readonly DelegateLaunchRow[], dropped: number
167
182
 
168
183
  function expandedLaunchDetails(text: string, rows: readonly DelegateLaunchRow[]): string {
169
184
  if (rows.length === 0 || !rows.some((row) => row.hasAlias)) return text;
170
- const diagnostics = rows.map((row) => `- ${[row.detailLabel, row.agent, row.model].filter((value): value is string => !!value).join(" · ")} [${row.id}]`).join("\n");
185
+ const diagnostics = rows.map((row) => `- ${[row.detailLabel, row.agent, row.model, row.thinking ? effortFullName(row.thinking) : ""].filter((value): value is string => !!value).join(" · ")} [${row.id}]`).join("\n");
171
186
  return `${text}\n\nLaunched workers:\n${diagnostics}`;
172
187
  }
173
188
 
@@ -190,7 +205,7 @@ function singleLaunchReceipt(row: DelegateLaunchRow): string[] {
190
205
  const labelBudget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth(prefix) - visibleWidth(suffix));
191
206
  const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
192
207
  const rows = [compactVisibleLine(`${prefix}${label}${suffix}`, MAX_COLLAPSED_CARD_COLUMNS)];
193
- const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
208
+ const metadata = launchRowMetadata(row);
194
209
  if (metadata) rows.push(compactVisibleLine(` ${compactInlineText(metadata, { maxChars: MAX_COLLAPSED_CARD_COLUMNS - 2 })}`, MAX_COLLAPSED_CARD_COLUMNS));
195
210
  return rows;
196
211
  }
@@ -222,6 +237,9 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
222
237
  const JsonString = Type.String({
223
238
  description: "JSON text of the same value — accepted when a client stringifies nested args",
224
239
  });
240
+ const ThinkingSchema = StringEnum(THINKING_LEVELS, {
241
+ description: "Requested effort for this leg (off, minimal, low, medium, high, xhigh, max). Overrides the agent and shared child defaults; Pi clamps to the selected model's capabilities. Results distinguish requested from observed Pi effort, not provider cognition.",
242
+ });
225
243
  const SkillsSchema = Type.Array(Type.String(), {
226
244
  description: "Skills the sub-agent loads first — spawns a dynamic specialist (skills are inherited from the host)",
227
245
  });
@@ -253,6 +271,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
253
271
  ),
254
272
  skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
255
273
  role: Type.Optional(RoleSchema),
274
+ thinking: Type.Optional(ThinkingSchema),
256
275
  model: Type.Optional(
257
276
  Type.String({ description: "Optional exact provider/id. Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." }),
258
277
  ),
@@ -278,6 +297,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
278
297
  name: Type.Optional(Type.String({ description: LeaderNameDescription })),
279
298
  skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
280
299
  role: Type.Optional(RoleSchema),
300
+ thinking: Type.Optional(ThinkingSchema),
281
301
  model: Type.Optional(Type.String({ description: "Optional exact provider/id (single mode). Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." })),
282
302
  tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override (single mode); [] explicitly grants no tools" }), JsonString])),
283
303
  isolation: Type.Optional(
@@ -476,6 +496,10 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
476
496
  kind: "subagent",
477
497
  agent,
478
498
  ...(runSpec.model ? { model: runSpec.model } : {}),
499
+ // The requested effort rides the SAME launch seam as the model above — spec-level, what
500
+ // the launch row carried. Rendered as the compact sigla after the model; the drill-in
501
+ // uses full names. Absent ⇒ nothing. Nothing observed is claimed here.
502
+ ...(runSpec.thinking ? { thinking: runSpec.thinking } : {}),
479
503
  detail: "queued",
480
504
  });
481
505
  // Arm the async-run status/stall monitor (no-op if both cadences are disabled). A headless leg runs
@@ -494,9 +518,14 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
494
518
  const dropped = droppedNote && `
495
519
 
496
520
  [pi-persona]${droppedNote}`;
521
+ const settledRuns = runs.map((run) => {
522
+ const modelUsed = observedRunModel(run);
523
+ return { id: run.id, agent: run.agent, ...(run.label ? { label: run.label } : {}),
524
+ ...(run.model ? { requestedModel: run.model } : {}), ...(modelUsed ? { modelUsed } : {}) };
525
+ });
497
526
  return {
498
527
  content: [{ type: "text" as const, text: `${buildCompletionReport(runs, fenceUntrusted, { bounded: false })}${dropped}${d.drainBusBlock()}` }],
499
- details: ok ? {} : failureDetails({}),
528
+ details: ok ? { settledRuns } : failureDetails({ settledRuns }),
500
529
  isError: !ok,
501
530
  ...toolUsageField(usage),
502
531
  };
@@ -513,7 +542,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
513
542
  "No fitting agent? Shape one on the fly: `operator` + `role` (extra system prompt) + `skills`.",
514
543
  "Assign each worker's name before launch so it sees the same identity from its first turn; omitted names keep the generic fallback.",
515
544
  "A `model` may be a loose name ('sonnet', 'opus-4.6', 'claude-pro-max-native/opus-4-6') — it resolves to YOUR provider's id; ambiguous names return",
516
- "candidates. Other options: name, tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
545
+ "candidates. Other options: name, thinking (per-leg effort; Pi clamps), tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
517
546
  ].join(" "),
518
547
  parameters: DelegateParams,
519
548
  async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
@@ -665,7 +694,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
665
694
  const label = nameFor(t, nameOffset + i);
666
695
  const id = launchAsyncRun(t.agent, t.task, spec, label, inline, fanOut);
667
696
  const model = shortModel(spec.model);
668
- return { id, label, agent: t.agent, ...(model ? { model } : {}) };
697
+ return { id, label, agent: t.agent, ...(model ? { model } : {}), ...(spec.thinking ? { thinking: spec.thinking } : {}) };
669
698
  });
670
699
  const ids = runs.map((run) => run.id);
671
700
  const droppedNote = dropped > 0 ? ` ${dropped} task(s) beyond the max-children limit (${d.RUN_LIMITS.maxChildren}) were dropped.` : "";
@@ -700,7 +729,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
700
729
  text: `Launched async run ${id} (${agent}) — runs in the background and wakes you on completion (${END_TURN_NOTE}).`,
701
730
  },
702
731
  ],
703
- details: { runId: id, runs: [{ id, label, agent, ...(model ? { model } : {}) }] },
732
+ details: { runId: id, runs: [{ id, label, agent, ...(model ? { model } : {}), ...(runSpec.thinking ? { thinking: runSpec.thinking } : {}) }] },
704
733
  isError: false,
705
734
  };
706
735
  }
@@ -716,8 +745,13 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
716
745
  const coerced = coerceDelegateParams(args);
717
746
  const view = coerced.ok ? coerced.params : undefined;
718
747
  if (view?.tasks && view.tasks.length > 0) {
719
- // Names live in the tree / final card — keep the call line itself minimal.
720
- return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}`, 0, 0);
748
+ // Names live in the tree / final card — keep the call line itself minimal. A UNIFORM
749
+ // declared effort rides along (every leg declares the same level; mixed or undeclared
750
+ // stays silent — no re-parsing of stringified tasks); per-leg rows carry their own.
751
+ const first = view.tasks[0]!;
752
+ const uniform = view.tasks.every((task) => task.thinking === first.thinking) ? first.thinking : undefined;
753
+ const effort = uniform ? effortFullName(uniform) : "";
754
+ return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})${effort ? ` · ${effort}` : ""}`)}`, 0, 0);
721
755
  }
722
756
  if (typeof args.tasks === "string" && args.tasks.trim()) {
723
757
  return new Text(`${title}${theme.fg("accent", "parallel")}`, 0, 0);
@@ -728,7 +762,9 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
728
762
  const agent = compactInlineText(view?.agent ?? (typeof args.agent === "string" ? args.agent : ""), { maxChars: 40 });
729
763
  const model = typeof modelValue === "string" && modelValue.trim() ? compactInlineText(modelValue, { maxChars: 80 }) : "";
730
764
  const titleLine = `${title}${theme.fg("accent", theme.bold(compactVisibleLine(alias, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth("delegate "))))}`;
731
- const metadata = [agent, model].filter(Boolean).join(" · ");
765
+ // Only the effort THIS call's args declare — never a value inherited from agent config.
766
+ const thinking = view?.thinking ?? (isThinkingLevel(args.thinking) ? args.thinking : undefined);
767
+ const metadata = [agent, model, thinking ? effortFullName(thinking) : ""].filter(Boolean).join(" · ");
732
768
  const metadataLine = metadata ? theme.fg("dim", compactVisibleLine(` ${metadata}`, MAX_COLLAPSED_CARD_COLUMNS)) : "";
733
769
  return new Text([titleLine, metadataLine].filter(Boolean).join("\n"), 0, 0);
734
770
  },
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { cappedList } from "../core/format.ts";
9
+ import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "../core/types.ts";
9
10
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
10
11
  import { type AgentRunSpec, type InfrastructureFailure, isInfrastructureFailure, isPositiveFiniteMs } from "../orchestration/sdk.ts";
11
12
 
@@ -20,6 +21,8 @@ export interface DelegateTask {
20
21
  skills?: string[];
21
22
  /** Model override for this sub-agent. */
22
23
  model?: string;
24
+ /** Requested effort for this leg; overrides agent and shared child defaults. Pi clamps it. */
25
+ thinking?: ThinkingLevel;
23
26
  /** Tool allowlist override for this sub-agent. */
24
27
  tools?: string[];
25
28
  /** Extra system-prompt text: an ON-THE-FLY specialist persona, appended to the
@@ -47,6 +50,7 @@ export interface DelegateParams {
47
50
  name?: string;
48
51
  skills?: string[];
49
52
  model?: string;
53
+ thinking?: ThinkingLevel;
50
54
  tools?: string[];
51
55
  role?: string;
52
56
  isolation?: "none" | "worktree";
@@ -133,10 +137,6 @@ export function validateDelegationBrief(params: DelegateParams): string | undefi
133
137
  return undefined;
134
138
  }
135
139
 
136
- // Moved to core/ownership.ts (so a strategy can call it without importing a tool module);
137
- // re-exported here unchanged for this file's own call sites and existing tests.
138
- export { findWriteSetOverlaps, validateParallelWriteSets, type WriteSetOverlap } from "../core/ownership.ts";
139
-
140
140
  function unwrapJsonish(value: unknown, label: string): { ok: true; value: unknown } | { ok: false; error: string } {
141
141
  let current = value;
142
142
  for (let depth = 0; depth < 2; depth++) {
@@ -205,6 +205,9 @@ function decodedTaskFieldError(bag: Record<string, unknown>, index: number): str
205
205
  return `delegate: ${label}.${field} must be a string.`;
206
206
  }
207
207
  }
208
+ if (bag.thinking !== undefined && !isThinkingLevel(bag.thinking)) {
209
+ return `delegate: ${label}.thinking must be one of: ${THINKING_LEVELS.join(", ")}.`;
210
+ }
208
211
  if (bag.isolation !== undefined && bag.isolation !== "none" && bag.isolation !== "worktree") {
209
212
  return `delegate: ${label}.isolation must be "none" or "worktree".`;
210
213
  }
@@ -264,6 +267,9 @@ export function coerceDelegateParams(raw: unknown): { ok: true; params: Delegate
264
267
  return { ok: false, error: "delegate: arguments must be an object." };
265
268
  }
266
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
+ }
267
273
  const tasks = coerceTaskList(bag.tasks);
268
274
  if (!tasks.ok) return tasks;
269
275
  if (tasks.value !== undefined) bag.tasks = tasks.value;
@@ -282,10 +288,10 @@ export function coerceDelegateParams(raw: unknown): { ok: true; params: Delegate
282
288
  return { ok: true, params: bag as DelegateParams };
283
289
  }
284
290
 
285
- /** Short model name for display: drop provider + `claude-` + any `:thinking`. */
291
+ /** Short model name for display: drop provider + `claude-`; model ids remain opaque. */
286
292
  export function shortModel(ref: string | undefined): string {
287
293
  if (!ref) return "";
288
- const base = ref.split(":")[0]?.split("/").pop() ?? ref;
294
+ const base = ref.split("/").pop() ?? ref;
289
295
  return base.replace(/^claude-/, "");
290
296
  }
291
297
 
@@ -321,6 +327,7 @@ export function specOf(t: DelegateTask, index = 0): AgentRunSpec {
321
327
  };
322
328
  if (t.skills && t.skills.length > 0) spec.skills = t.skills;
323
329
  if (t.model) spec.model = t.model;
330
+ if (t.thinking !== undefined) spec.thinking = t.thinking;
324
331
  if (t.tools !== undefined) spec.tools = t.tools;
325
332
  if (t.role?.trim()) spec.role = t.role.trim();
326
333
  if (t.outputContract?.trim()) spec.outputContract = t.outputContract.trim();
@@ -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>;