@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  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/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -0,0 +1,57 @@
1
+ /** Per-leg effort selection. Pi alone owns model-capability clamping. Pure, no host imports. */
2
+ import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "./types.ts";
3
+
4
+ /** An explicit invalid selection is a configuration error, never an instruction to inherit. */
5
+ export function parseLegThinking(value: unknown, label = "thinking"): ThinkingLevel | undefined {
6
+ if (value === undefined) return undefined;
7
+ if (!isThinkingLevel(value)) throw new Error(`${label} must be one of: ${THINKING_LEVELS.join(", ")}`);
8
+ return value;
9
+ }
10
+
11
+ /** One shared display formatter for effort on delegate launch cards: the FULL level name,
12
+ * with `off` rendering nothing so absence is unambiguous. Unknown input renders nothing too
13
+ * (render paths must never throw); the compact siglas live in the tree/overlay view, not here. */
14
+ const EFFORT_FULL_NAMES: Record<ThinkingLevel, string> = {
15
+ off: "",
16
+ minimal: "minimal",
17
+ low: "low",
18
+ medium: "medium",
19
+ high: "high",
20
+ xhigh: "xhigh",
21
+ max: "max",
22
+ };
23
+
24
+ export function effortFullName(level: ThinkingLevel): string {
25
+ return EFFORT_FULL_NAMES[level] ?? "";
26
+ }
27
+
28
+ /** The COMPACT siglas for the agent-tree/overlay rows (frozen convention): minimal→mn, low→l,
29
+ * medium→m, high→h, xhigh→xh, max→mx — with `off` rendering nothing so absence is unambiguous.
30
+ * Unknown input renders nothing too (render paths must never throw). The delegate CARDS keep the
31
+ * full names via {@link effortFullName}; the F9 drill-in uses those full names through the same
32
+ * formatter — only the tree/overlay ROWS are compact. */
33
+ const EFFORT_SIGLAS: Record<ThinkingLevel, string> = {
34
+ off: "",
35
+ minimal: "mn",
36
+ low: "l",
37
+ medium: "m",
38
+ high: "h",
39
+ xhigh: "xh",
40
+ max: "mx",
41
+ };
42
+
43
+ export function effortSigla(level: ThinkingLevel): string {
44
+ return EFFORT_SIGLAS[level] ?? "";
45
+ }
46
+
47
+ /** Explicit spec (including a roster member) > agent default > configured/inherited child effort.
48
+ * Validate explicit fields even when another field wins; never normalize a typo into inheritance. */
49
+ export function resolveLegThinking(
50
+ spec: { thinking?: ThinkingLevel },
51
+ agent: { thinking?: ThinkingLevel },
52
+ childThinking: unknown,
53
+ ): ThinkingLevel | undefined {
54
+ const explicit = parseLegThinking(spec.thinking);
55
+ const configured = parseLegThinking(agent.thinking, "agent.thinking");
56
+ return explicit ?? configured ?? (isThinkingLevel(childThinking) ? childThinking : undefined);
57
+ }
@@ -77,8 +77,7 @@ export function writeSetPathError(path: string): string | undefined {
77
77
  }
78
78
 
79
79
  /** Return an actionable error for a parallel write-set collision; undefined means safe to spawn.
80
- * Lives here (not in `tools/delegate.ts`) so a strategy can call it without importing a tool
81
- * module — `delegate.ts` re-exports it unchanged for its own call sites. */
80
+ * Lives here so strategies and tools can depend on ownership without importing each other. */
82
81
  export function validateParallelWriteSets(tasks: readonly WriteSetOwner[]): string | undefined {
83
82
  for (const [index, task] of tasks.entries()) {
84
83
  for (const path of task.writeSet ?? []) {
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
2
  import { dirname, join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
 
6
- /** Oldest supported Pi host. The release is qualified against Pi 1.0's SDK and tool lifecycle;
7
- * pre-1.0 hosts are unsupported. Keep in sync with README.md;
8
- * npm host peers use "*" because Pi supplies them through its extension loader. */
9
- export const MIN_PI_VERSION = "1.0.0";
5
+ /** API baseline of the current stable Pi target. Update with the four pinned development
6
+ * packages and qualify the installed artifact; historical host releases are not maintained.
7
+ * Keep README.md aligned. Host peers remain "*" because Pi supplies them through its loader. */
8
+ export const MIN_PI_VERSION = "1.4.0";
10
9
 
11
10
  function parse(v: string): [number, number, number] | undefined {
12
11
  const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
@@ -21,15 +20,9 @@ export function satisfiesFloor(version: string, floor: string): boolean {
21
20
  }
22
21
 
23
22
  function resolveHostEntry(): string | undefined {
24
- // The host package is ESM-only (no CJS `main`), so `createRequire(...).resolve` throws
25
- // ERR_PACKAGE_PATH_NOT_EXPORTED for it — `import.meta.resolve` is tried first because it
26
- // follows an `exports` map with no `main`/`require` condition. `createRequire` stays as a
27
- // fallback for any host build that does publish one.
23
+ // The current host exposes its entry through the ESM import condition.
28
24
  try {
29
25
  return fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
30
- } catch { /* fall through */ }
31
- try {
32
- return createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent");
33
26
  } catch { /* unresolved host */ }
34
27
  return undefined;
35
28
  }
@@ -83,5 +83,5 @@ export function restoreNotice(lost: LostWork): string | undefined {
83
83
  if (lost.monitors > 0) parts.push(plural(lost.monitors, "monitor"));
84
84
  if (parts.length === 0) return undefined;
85
85
  return `Session restored. Background work from before the restore did not survive (it lives only in memory): ${parts.join("; ")}. `
86
- + "Earlier run results can no longer be fetched. Do not wait for them; relaunch or re-arm only what you still need.";
86
+ + "Runtime workers and live run handles are not restored. Retained terminal reports may still be available via intercom { action:'results' } and result { ref:'result:<UUID>' }; read saved data without restarting work. Do not wait for vanished workers; relaunch unfinished work or re-arm wake sources only when still needed.";
87
87
  }
package/src/core/types.ts CHANGED
@@ -14,7 +14,7 @@ export type { Permission };
14
14
  * new level here too — `pi.getThinkingLevel()` returns the upstream union, so a missing member here
15
15
  * is a hard compile error where the host reads it (see `PersonaHost.getThinkingLevel`). */
16
16
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
17
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
17
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
18
18
 
19
19
  export function isThinkingLevel(x: unknown): x is ThinkingLevel {
20
20
  return typeof x === "string" && (THINKING_LEVELS as readonly string[]).includes(x);
@@ -10,7 +10,10 @@ import { randomUUID } from "node:crypto";
10
10
  import type { AgentConfig } from "../agents/agent.ts";
11
11
  import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
12
12
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
13
+ import { resolveLegThinking } from "../core/leg-thinking.ts";
14
+ import type { ThinkingLevel } from "../core/types.ts";
13
15
  import { assignedIdentityPrompt } from "../core/naming.ts";
16
+ import { MEMORY_PERSONA_ENV } from "../core/companion.ts";
14
17
  import { roleHint } from "../orchestration/roster.ts";
15
18
  import { type AgentProgress, type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
16
19
  import type { AgentResult } from "../orchestration/types.ts";
@@ -18,8 +21,13 @@ import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./chi
18
21
  import { looksLikeProviderError } from "./errors.ts";
19
22
  import { piHostModuleHint } from "./host-module.ts";
20
23
  import { nextChildHandle } from "./handles.ts";
24
+ import { admitResultReadTool, referenceHandoffFailure } from "./result-grant.ts";
25
+ import { makeScopedResultReader, type ResultArchiveView } from "../results/retrieval.ts";
26
+ import type { PreparedResultReader, ScopedReader } from "../bus/result-reader.ts";
21
27
  import { combineSignals } from "./signals.ts";
28
+ import { emptyUsage } from "./stream.ts";
22
29
  import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
30
+ import { mergeSessionToolAllowlist } from "./session-tools.ts";
23
31
 
24
32
  /** The engine-side face of the cross-process broker (spec B3/B4/B7) — a child-engine
25
33
  * spawn's connection to the supervisor-hosted relay. The concrete implementation
@@ -40,9 +48,15 @@ export interface EngineAdapterBroker {
40
48
  * parent-side idle/startup watchdogs consult this before killing a silent leg (R9): a leg
41
49
  * blocked on the supervisor is re-armed, not killed. */
42
50
  hasPendingAskFrom(handle: string): boolean;
51
+ /** Strict, AWAITED result-reader preflight for a reference-handoff leg (the broker host binds
52
+ * a single-use token to this handle). Must complete BEFORE `register` and the spawn — a
53
+ * missing broker/binding fails the leg closed with zero usage instead of degrading bus-less. */
54
+ prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader>;
43
55
  }
44
56
 
45
57
  export interface EngineAdapterDeps {
58
+ /** Immutable owning supervisor persona; null explicitly means no persona. */
59
+ memoryPersona?: string | null;
46
60
  resolveAgent: (name: string) => AgentConfig | undefined;
47
61
  contracts?: (name: string) => ContractDef | undefined;
48
62
  signal?: AbortSignal;
@@ -55,8 +69,7 @@ export interface EngineAdapterDeps {
55
69
  * the on-the-fly role this run carries, when it has one, so a member's own assignment key
56
70
  * (`agent#hash`) resolves ahead of the bare agent's. */
57
71
  modelFor?: (agent: string, role?: string) => string | undefined;
58
- /** Explicit thinking level appended to the child model (`model:level`) so it can't
59
- * fall into a model's default "adaptive" mode, which some models reject. */
72
+ /** Configured/inherited child effort, below explicit leg and agent selections. */
60
73
  childThinking?: string;
61
74
  /** The shared behavioral layer (docs/SPINE.md), prepended to every leg's prompt. This is the
62
75
  * WORKER variant (`prompts/spine.worker.md`), not the supervisor text — a leg runs headless
@@ -80,9 +93,15 @@ export interface EngineAdapterDeps {
80
93
  * blocking would deadlock: default false, mirroring `inproc.ts`'s `allowBlocking`
81
94
  * default. Forwarded to the child as `PI_PERSONA_ALLOW_BLOCKING` (bridge.ts). */
82
95
  allowBlocking?: boolean;
96
+ /** The owning session's terminal archive (a GETTER — the owner can change across sessions).
97
+ * Required by any leg carrying `resultRefs` (flow reference handoff): the scoped reader
98
+ * handed to the broker pages THIS archive, after a zero-usage preflight verified every
99
+ * granted ref is durably stored. Absent ⇒ such legs fail closed before any spawn. */
100
+ resultArchive?: () => ResultArchiveView | undefined;
83
101
  }
84
102
 
85
103
  export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
104
+ const memoryPersona = deps.memoryPersona;
86
105
  // Per-run contract pinning (I3): an engine instance is created per run, so the first
87
106
  // time a contract is used we pin `name@hash` and reuse that frozen snapshot for the
88
107
  // rest of the run — hot-reloading a contract file can't change an in-flight run.
@@ -113,10 +132,38 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
113
132
  ): Promise<AgentResult> {
114
133
  const cfg = deps.resolveAgent(spec.agent);
115
134
  if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
135
+ let thinkingRequested: ThinkingLevel | undefined;
136
+ try {
137
+ thinkingRequested = resolveLegThinking(spec, cfg, deps.childThinking);
138
+ } catch (error) {
139
+ return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, failureKind: "contract", error: `[${spec.agent}] ${error instanceof Error ? error.message : String(error)}` };
140
+ }
116
141
  const tools = spec.tools ?? cfg.tools;
117
142
  const invalidTools = toolModifierFailure(spec.agent, tools);
118
143
  if (invalidTools) return invalidTools;
119
144
 
145
+ // ── reference handoff preflight (contract 3/4) — BEFORE any broker work or spawn ──
146
+ // Admission uses the SAME shared rule as the in-process engine; storage is verified
147
+ // durable (session-entry); the broker binding is AWAITED before registration/spawn.
148
+ // Any miss is a zero-usage contract failure and the child is never invoked.
149
+ let admittedTools = tools;
150
+ let resultReader: ScopedReader | undefined;
151
+ if (spec.resultRefs !== undefined && spec.resultRefs.length > 0) {
152
+ const grant = admitResultReadTool({
153
+ specTools: spec.tools, cfgTools: cfg.tools, excludeTools: cfg.excludeTools,
154
+ canUseBus: deps.canUseBus ?? true,
155
+ });
156
+ if (!grant.ok) return referenceHandoffFailure(spec.agent, grant.error);
157
+ const archive = deps.resultArchive?.();
158
+ if (archive === undefined) return referenceHandoffFailure(spec.agent, "terminal result archive unavailable for reference handoff");
159
+ const scoped = makeScopedResultReader(archive, spec.resultRefs);
160
+ const { missing } = scoped.preflight();
161
+ if (missing.length > 0) return referenceHandoffFailure(spec.agent, `archived results unavailable: ${missing.join(", ")}`);
162
+ if (deps.broker === undefined) return referenceHandoffFailure(spec.agent, "cross-process broker reader unavailable for reference handoff");
163
+ resultReader = scoped;
164
+ admittedTools = grant.tools;
165
+ }
166
+
120
167
  // Resolved (and pinned) up front: the SAME def both instructs the member and
121
168
  // validates its output — an instruction/validation drift is impossible. A named
122
169
  // contract that cannot be resolved is a hard configuration error: running the leg
@@ -130,12 +177,10 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
130
177
  : spec.task;
131
178
  const task = contractDef ? `${withSkills}\n\n${contractInstructions(contractDef)}` : withSkills;
132
179
  const childSpec: ChildRunSpec = { task };
133
- let model = spec.model ?? deps.modelFor?.(spec.agent, spec.role) ?? cfg.model;
134
- // Append an explicit thinking level (model:level) unless one is already present —
135
- // a child without it defaults to "adaptive", which some models reject.
136
- if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
180
+ const model = spec.model ?? deps.modelFor?.(spec.agent, spec.role) ?? cfg.model;
137
181
  if (model) childSpec.model = model;
138
- if (tools !== undefined) childSpec.tools = tools;
182
+ if (thinkingRequested !== undefined) childSpec.thinking = thinkingRequested;
183
+ if (admittedTools !== undefined) childSpec.tools = admittedTools;
139
184
  if (cfg.excludeTools && cfg.excludeTools.length > 0) childSpec.excludeTools = cfg.excludeTools;
140
185
  // The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
141
186
  // on-the-fly `role` specialisation from the spec. All three go out as
@@ -147,6 +192,12 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
147
192
  if (deps.cwd) childSpec.cwd = deps.cwd;
148
193
 
149
194
  const childOptions: ChildEngineOptions = { ...deps.childOptions };
195
+ if (memoryPersona !== undefined || childOptions.env?.[MEMORY_PERSONA_ENV] !== undefined) {
196
+ const env = { ...childOptions.env };
197
+ delete env[MEMORY_PERSONA_ENV];
198
+ if (memoryPersona !== undefined) env[MEMORY_PERSONA_ENV] = JSON.stringify({ version: 1, persona: memoryPersona });
199
+ childOptions.env = env;
200
+ }
150
201
  // NP2: a per-leg timeoutMs override raises (or shortens) just THIS leg's idle-timeout
151
202
  // ceiling without touching the shared default other legs of the same run still see.
152
203
  // Junk (non-finite/≤0) is ignored — the engine's existing childOptions.timeoutMs stands.
@@ -160,27 +211,46 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
160
211
 
161
212
  // Cross-process comm plane (spec B1-B7): absent broker ⇒ none of this runs, so
162
213
  // the spawn env + behaviour stay byte-identical to pre-broker pi-persona.
163
- // Present ⇒ mint a handle, register it with the host BEFORE
164
- // spawn, wire the child's env, and give the caller a steer function.
214
+ // Present ⇒ mint a handle, (for a reference-handoff leg) AWAIT the strict reader
215
+ // binding BEFORE registration, register the handle with the host BEFORE spawn, wire
216
+ // the child's env, and give the caller a steer function.
165
217
  let handle: string | undefined;
218
+ let preparedReader: PreparedResultReader | undefined;
166
219
  if (deps.broker) {
167
- handle = nextChildHandle(spec.agent);
220
+ const broker = deps.broker;
221
+ handle = nextChildHandle(spec.agent);
168
222
  const wantsPeers = spec.peers === true && (deps.canUseBus ?? true);
169
- const label = spec.name?.trim()
223
+ const busTools = (deps.canUseBus ?? true) ? ["contact_supervisor", ...(wantsPeers ? ["contact_peer"] : [])] : [];
224
+ const mergedTools = mergeSessionToolAllowlist(admittedTools, busTools);
225
+ if (mergedTools !== undefined) childSpec.tools = mergedTools;
226
+ const label = spec.name?.trim()
170
227
  ? sanitizeDisplayLabel(spec.name, handle)
171
228
  : spec.role ? `${handle} (${roleHint(spec.role)})` : handle;
172
- deps.broker.register({ handle, label, group: peerGroup, ...(wantsPeers ? { peers: true } : {}) });
229
+ if (resultReader !== undefined) {
230
+ try {
231
+ preparedReader = await broker.prepareResultReader(handle, resultReader, signal);
232
+ } catch (error) {
233
+ return referenceHandoffFailure(spec.agent, `broker result reader preparation failed: ${error instanceof Error ? error.message : String(error)}`);
234
+ }
235
+ childOptions.env = {
236
+ ...childOptions.env,
237
+ // Private spawn credential for the child's bridge (bridge.ts installs read_result
238
+ // only when this is set); scrubbed from the inherited env inside child.ts.
239
+ PI_PERSONA_RESULT_READER_TOKEN: preparedReader.token,
240
+ };
241
+ }
242
+ broker.register({ handle, label, group: peerGroup, ...(wantsPeers ? { peers: true } : {}) });
173
243
  childOptions.env = {
174
244
  ...childOptions.env,
175
- PI_PERSONA_BUS: deps.broker.endpoint,
245
+ PI_PERSONA_BUS: broker.endpoint,
176
246
  PI_PERSONA_HANDLE: handle,
247
+ PI_PERSONA_BUS_TOOLS: (deps.canUseBus ?? true) ? "on" : "off",
177
248
  ...(wantsPeers ? { PI_PERSONA_PEERS: "1" } : {}),
178
249
  ...(deps.allowBlocking ? { PI_PERSONA_ALLOW_BLOCKING: "1" } : {}),
179
250
  };
180
251
  // The parent-side watchdogs must not kill a leg that is genuinely waiting on the
181
252
  // supervisor's answer (R9, inproc parity) — ask the bus, don't guess from a static flag.
182
- childOptions.isBlocked = () => deps.broker!.hasPendingAskFrom(handle!);
183
- const broker = deps.broker;
253
+ childOptions.isBlocked = () => broker.hasPendingAskFrom(handle!);
184
254
  const h = handle;
185
255
  const steer: SteerFn = Object.assign((text: string) => broker.steerFrame(h, text), {
186
256
  isWaitingForSupervisor: () => broker.hasPendingAskFrom(h),
@@ -192,17 +262,27 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
192
262
  try {
193
263
  child = await runChildAgent(childSpec, signal, childOptions);
194
264
  } finally {
195
- // Unregister on EVERY settle path (success, failure, or an unexpected throw) —
196
- // a leaked handle would otherwise linger in the host's registries forever.
265
+ // Revoke the reader grant and unregister on EVERY settle path (success, failure, or
266
+ // an unexpected throw) — a leaked grant would outlive its leg; a leaked handle would
267
+ // otherwise linger in the host's registries forever.
268
+ preparedReader?.revoke();
197
269
  if (deps.broker && handle) deps.broker.unregister(handle);
198
270
  }
199
271
 
200
272
  const result: AgentResult = { agent: spec.agent, output: child.output, usage: child.usage, ok: child.ok };
201
- // The provider/id the child ran on (drop any `:thinking` suffix) — for the UI and
273
+ if (thinkingRequested !== undefined) result.thinkingRequested = thinkingRequested;
274
+ if (child.thinkingEffective !== undefined) result.thinkingEffective = child.thinkingEffective;
275
+ // The opaque provider/id the child ran on — for the UI and
202
276
  // as the seed of the provider-fallback chain on a provider failure. The child's
203
277
  // STREAM-REPORTED model wins over the requested spec: a resolved alias or a
204
- // provider-side redirect must seed the fallback chain with what actually ran.
205
- const modelUsed = (child.model ?? childSpec.model ?? spec.model ?? cfg.model)?.split(":")[0];
278
+ // provider-side redirect must seed the fallback chain with what actually ran. Pi's native
279
+ // assistant message reports a bare model id: retain the pinned provider ONLY when that
280
+ // id exactly matches, never attach it to a different observed model or qualified route.
281
+ const requestedModel = childSpec.model ?? spec.model ?? cfg.model;
282
+ const observedModel = child.model?.trim() || undefined;
283
+ const modelUsed = observedModel && !observedModel.includes("/") && requestedModel?.includes("/")
284
+ && requestedModel.slice(requestedModel.indexOf("/") + 1) === observedModel
285
+ ? requestedModel : observedModel ?? requestedModel;
206
286
  if (modelUsed) result.modelUsed = modelUsed;
207
287
  // Diagnostic tag: agent · model ref · dynamic overrides. Same shape as inproc engine,
208
288
  // so failed follow-ups always say WHICH agent+model died and WHY.
@@ -11,13 +11,13 @@
11
11
 
12
12
  import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
13
13
  import type { AgentResult } from "../orchestration/types.ts";
14
+ import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
14
15
  import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
15
16
  import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
16
17
  import { compactTokens } from "../core/format.ts";
18
+ import { isThinkingLevel } from "../core/types.ts";
17
19
  import { formatDuration } from "../core/time.ts";
18
20
 
19
- export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
20
-
21
21
  /** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
22
22
  * badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
23
23
  * visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
@@ -33,6 +33,7 @@ export interface AsyncRun {
33
33
  status: "running" | "done" | "failed" | "stopped";
34
34
  progress: ProgressSnapshot;
35
35
  result?: AgentResult;
36
+ resultReceipt?: ResultReceipt;
36
37
  error?: string;
37
38
  /** The codename the launcher gave this run (e.g. "atlas-static") — the SAME name shown on the
38
39
  * agent-tree node for this run. When set, it's the canonical display name (over the bare
@@ -70,6 +71,8 @@ export interface AsyncRun {
70
71
  /** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
71
72
  * last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
72
73
  batch?: string;
74
+ /** Companion-owned maintenance settles to its owner without waking the supervisor model. */
75
+ delivery?: "companion";
73
76
  /** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
74
77
  * digest instead of a stall verdict, because silence while a human decides is not a wedge. */
75
78
  awaiting?: string;
@@ -115,6 +118,7 @@ export class AsyncRunTracker {
115
118
  /** Ids whose payload was evicted while STILL UNCOLLECTED, oldest first (insertion-ordered Set). */
116
119
  private readonly droppedIds = new Set<string>();
117
120
  private readonly completeListeners: Array<(run: AsyncRun) => void> = [];
121
+ private readonly createListeners: Array<(run: AsyncRun) => void> = [];
118
122
  private seq = 0;
119
123
  private readonly maxRetained: number;
120
124
  private readonly now: () => number;
@@ -138,7 +142,7 @@ export class AsyncRunTracker {
138
142
  if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
139
143
  }
140
144
 
141
- launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
145
+ launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"]; delivery?: AsyncRun["delivery"] }, run: RunThunk): string {
142
146
  this.seq += 1;
143
147
  const id = `run-${this.seq.toString(36)}`;
144
148
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -153,12 +157,16 @@ export class AsyncRunTracker {
153
157
  };
154
158
  if (meta.mutates) entry.mutates = true;
155
159
  if (meta.batch) entry.batch = meta.batch;
160
+ if (meta.delivery) entry.delivery = meta.delivery;
156
161
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
157
162
  // once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
158
163
  // those views; renderers still sanitize defensively when they compose a label.
159
164
  if (meta.label !== undefined) entry.label = sanitizeDisplayLabel(meta.label);
160
165
  if (meta.model !== undefined) entry.model = sanitizeDisplayLabel(meta.model, "model");
161
166
  this.runs.set(id, entry);
167
+ for (const cb of [...this.createListeners]) {
168
+ try { cb(entry); } catch { /* archival observation cannot prevent launch */ }
169
+ }
162
170
 
163
171
  const settle = (): void => this.settleOnce(entry);
164
172
  // `Promise.resolve(run(...))` routes BOTH a returned rejection AND a SYNCHRONOUS throw from
@@ -176,7 +184,9 @@ export class AsyncRunTracker {
176
184
  .then((result) => {
177
185
  if (entry.settled) return; // force-settled by the supervisor — drop the late natural result
178
186
  entry.status = result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed";
179
- entry.result = result;
187
+ // Retain scalar metadata by value: an engine reusing/mutating its result object
188
+ // after settlement must not rewrite this run's requested/observed effort.
189
+ entry.result = { ...result, usage: { ...result.usage }, ...(result.resultReceipt ? { resultReceipt: { ...result.resultReceipt } } : {}) };
180
190
  if (!result.ok && result.error) entry.error = result.error;
181
191
  })
182
192
  .catch((err: unknown) => {
@@ -347,6 +357,14 @@ export class AsyncRunTracker {
347
357
  return batch === undefined || !this.running().some((r) => r.batch === batch);
348
358
  }
349
359
 
360
+ onCreate(cb: (run: AsyncRun) => void): () => void {
361
+ this.createListeners.push(cb);
362
+ return () => {
363
+ const i = this.createListeners.indexOf(cb);
364
+ if (i >= 0) this.createListeners.splice(i, 1);
365
+ };
366
+ }
367
+
350
368
  onComplete(cb: (run: AsyncRun) => void): () => void {
351
369
  this.completeListeners.push(cb);
352
370
  return () => {
@@ -363,10 +381,18 @@ export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
363
381
  return [...byId.values()];
364
382
  }
365
383
 
384
+ /** A terminal engine observation supersedes the launch choice; running/launch rows retain their snapshot. */
385
+ export function observedRunModel(run: Partial<Pick<AsyncRun, "status" | "result">>): string | undefined {
386
+ const model = run.status !== undefined && run.status !== "running" ? run.result?.modelUsed : undefined;
387
+ return model?.trim() ? model.split("/").map((part) => sanitizeDisplayLabel(part, "model", 256)).join("/").slice(0, 256) : undefined;
388
+ }
389
+
366
390
  /** One identity everywhere a tracked run is rendered: the launcher's codename when present,
367
- * otherwise the agent type, with the already-shortened model appended once. */
368
- export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
369
- const model = run.model === undefined ? undefined : sanitizeDisplayLabel(run.model, "model", 24);
391
+ * otherwise the agent type, with the observed terminal model or launch model appended once. */
392
+ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model"> & Partial<Pick<AsyncRun, "status" | "result">>): string {
393
+ const observed = observedRunModel(run);
394
+ const selected = observed ? (observed.split("/").pop() ?? observed).replace(/^claude-/, "") : run.model;
395
+ const model = selected === undefined ? undefined : sanitizeDisplayLabel(selected, "model", 24);
370
396
  if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
371
397
  const suffix = ` · ${model}`;
372
398
  const nameLimit = Math.max(1, MAX_DISPLAY_LABEL_CHARS - suffix.length);
@@ -618,6 +644,25 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
618
644
  const stoppedCount = stopped.length > 0 ? `, ${stopped.length} stopped` : "";
619
645
  const head = `[pi-persona] ${runs.length} async run${runs.length === 1 ? "" : "s"} settled — ${done.length} done, ${failed.length} failed${stoppedCount}`;
620
646
  const blocks: string[] = [head];
647
+ const models = runs.flatMap((run) => {
648
+ const model = observedRunModel(run);
649
+ return model ? [`${run.id}: Observed model: ${model}`] : [];
650
+ });
651
+ if (models.length > 0) blocks.push(`\n${models.join("\n")}`);
652
+ const receipts = runs.slice(0, MAX_ASYNC_STATUS_ROWS).flatMap((run) => {
653
+ const hint = resultReceiptHint(run.resultReceipt);
654
+ return hint ? [`${run.id}: ${hint}`] : [];
655
+ });
656
+ if (receipts.length > 0) blocks.push(`\nTerminal reports (intercom results lists all retained references):\n${receipts.join("\n")}`);
657
+ // Common result metadata, outside untrusted worker prose, on inline and background paths.
658
+ // A host effort observation is a Pi request setting, not a measure of provider cognition.
659
+ const efforts = runs.flatMap((r) => {
660
+ const requested = r.result?.thinkingRequested;
661
+ const observed = r.result?.thinkingEffective;
662
+ if (!isThinkingLevel(requested) && !isThinkingLevel(observed)) return [];
663
+ return [`${r.id}: requested ${isThinkingLevel(requested) ? requested : "unknown"}; observed Pi effort ${isThinkingLevel(observed) ? observed : "unknown"}`];
664
+ });
665
+ if (efforts.length > 0) blocks.push(`\nThinking (not provider cognition):\n${efforts.join("\n")}`);
621
666
  for (const r of done) {
622
667
  // Only fence REAL output — an empty/whitespace result gets a plain "(no output)", never an
623
668
  // empty <fence></fence> shell (there's nothing untrusted to guard, and the empty block reads
@@ -660,7 +705,7 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
660
705
  // The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
661
706
  // cut keeps, so a cut leg is still fetchable by id instead of silently missing.
662
707
  const named = report.length > MAX_COMPLETION_REPORT_CHARS
663
- ? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}`
708
+ ? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}. Saved reports: intercom { action:"results" }; use result { ref, offset, limit } even if live handles were dropped.`
664
709
  : report;
665
710
  return boundCompletionSurface(named);
666
711
  }
@@ -697,9 +742,6 @@ export const MAX_EMPTY_RENDER_RETRIES = 3;
697
742
  export interface IdleNotifierDeps<T> {
698
743
  /** Whether the supervisor is idle (not streaming a turn). */
699
744
  isIdle: () => boolean;
700
- /** Legacy host-queue probe. Retained for source compatibility, but deliberately ignored: when
701
- * the supervisor is idle, starting a turn is what drains any orphaned host follow-ups. */
702
- hasPending?: () => boolean;
703
745
  /** Deliver the coalesced message. Async rejection is treated exactly like a synchronous throw:
704
746
  * the batch is restored at the front of the queue and retried. */
705
747
  deliver: (message: string) => void | Promise<void>;
@@ -10,11 +10,13 @@ import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
+ import type { ThinkingLevel } from "../core/types.ts";
13
14
  import { applyEvent, type ChildUsage, createStreamState, feedLines, type ProgressSnapshot, snapshot } from "./stream.ts";
14
15
 
15
16
  export interface ChildRunSpec {
16
17
  task: string;
17
18
  model?: string;
19
+ thinking?: ThinkingLevel;
18
20
  tools?: string[];
19
21
  excludeTools?: string[];
20
22
  systemPrompt?: string;
@@ -32,6 +34,7 @@ export interface ChildRunResult {
32
34
  /** True when the idle watchdog OR the hard wall-clock cap fired. Disjoint from `aborted`. */
33
35
  timedOut: boolean;
34
36
  model?: string;
37
+ thinkingEffective?: ThinkingLevel;
35
38
  stopReason?: string;
36
39
  errorMessage?: string;
37
40
  }
@@ -166,6 +169,7 @@ export async function runChildAgent(
166
169
 
167
170
  const args: string[] = ["--mode", "json", "-p", "--no-session"];
168
171
  if (spec.model) args.push("--model", spec.model);
172
+ if (spec.thinking !== undefined) args.push("--thinking", spec.thinking);
169
173
  if (spec.tools !== undefined) {
170
174
  if (spec.tools.length > 0) args.push("--tools", spec.tools.join(","));
171
175
  else args.push("--no-tools");
@@ -201,13 +205,17 @@ export async function runChildAgent(
201
205
  const inv = resolveInvocation(args);
202
206
  // A stale broker wiring in the SUPERVISOR's own environment must not leak into a
203
207
  // plain child: PI_PERSONA_BUS would boot the child's bridge against a dead (or
204
- // foreign) endpoint. The adapter re-adds these deliberately via opts.env when a
205
- // broker is actually live for THIS run.
208
+ // foreign) endpoint, and a stale reader token would arm a read_result tool nobody
209
+ // granted. The adapter re-adds these deliberately via opts.env when a broker (and a
210
+ // result-reader grant) is actually live for THIS run.
206
211
  const inherited = { ...process.env };
207
212
  delete inherited.PI_PERSONA_BUS;
208
213
  delete inherited.PI_PERSONA_HANDLE;
209
214
  delete inherited.PI_PERSONA_PEERS;
210
215
  delete inherited.PI_PERSONA_ALLOW_BLOCKING;
216
+ delete inherited.PI_PERSONA_BUS_TOOLS;
217
+ delete inherited.PI_PERSONA_MEMORY_PERSONA;
218
+ delete inherited.PI_PERSONA_RESULT_READER_TOKEN;
211
219
  const proc = spawn(inv.command, inv.args, {
212
220
  cwd: spec.cwd ?? process.cwd(),
213
221
  shell: false,
@@ -496,6 +504,7 @@ export async function runChildAgent(
496
504
  timedOut: timedOut || hardTimedOut || startupTimedOut,
497
505
  };
498
506
  if (state.model !== undefined) result.model = state.model;
507
+ if (state.thinkingEffective !== undefined) result.thinkingEffective = state.thinkingEffective;
499
508
  if (state.stopReason !== undefined) result.stopReason = state.stopReason;
500
509
  // errorMessage precedence: a timeout/abort is the *cause of death*, so label
501
510
  // it explicitly and fold in any stream-level error rather than letting the