@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
@@ -26,21 +26,29 @@ import { makeContactSupervisorTool } from "../bus/contact.ts";
26
26
  import { makeContactPeerTool } from "../bus/peers.ts";
27
27
  import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
28
28
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
29
+ import { resolveLegThinking } from "../core/leg-thinking.ts";
29
30
  import { assignedIdentityPrompt } from "../core/naming.ts";
31
+ import { installFrozenMemoryOwner } from "../extension/memory-owner.ts";
30
32
  import { attributeInbound } from "../core/fence.ts";
31
33
  import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
32
34
  import { roleHint } from "../orchestration/roster.ts";
33
35
  import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
34
36
  import type { AgentResult } from "../orchestration/types.ts";
37
+ import { makeReadResultTool } from "../tools/read-result.ts";
38
+ import { admitResultReadTool, referenceHandoffFailure } from "./result-grant.ts";
39
+ import { makeScopedResultReader, type ResultArchiveView } from "../results/retrieval.ts";
35
40
  import { looksLikeProviderError } from "./errors.ts";
36
41
  import { piHostModuleHint } from "./host-module.ts";
37
42
  import { nextChildHandle } from "./handles.ts";
38
43
  import { combineSignals } from "./signals.ts";
39
44
  import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
45
+ import { mergeSessionToolAllowlist } from "./session-tools.ts";
40
46
  import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
41
47
 
42
48
  /** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
43
49
  export interface InProcSession {
50
+ /** Host readback after Pi applies its capability clamp; injected test sessions may omit it. */
51
+ readonly thinkingLevel?: unknown;
44
52
  subscribe(listener: (event: unknown) => void): () => void;
45
53
  prompt(input: string): Promise<void>;
46
54
  agent: { abort(): void; waitForIdle(): Promise<void>; steer(message: unknown): void };
@@ -48,13 +56,14 @@ export interface InProcSession {
48
56
  }
49
57
 
50
58
  export interface CreateSessionOptions {
59
+ /** Immutable owning supervisor persona; null explicitly means no persona. */
60
+ memoryPersona?: string | null;
51
61
  model: unknown;
52
- modelRegistry: ModelRegistry;
53
62
  /** Cooperative cancellation for resource/session initialization. Pi's loader does not
54
63
  * currently accept a signal itself, but injected factories can stop promptly and the
55
64
  * default factory checks it before/after each uncancellable Pi operation. */
56
65
  signal?: AbortSignal;
57
- /** The host's canonical runtime (Pi >=0.81); shared so auth/provider registrations survive. */
66
+ /** The host's canonical runtime; shared so auth/provider registrations survive. */
58
67
  modelRuntime?: unknown;
59
68
  cwd: string;
60
69
  /** Pi's global agent dir (~/.pi/agent) for the sub-agent's resource loader. */
@@ -71,12 +80,9 @@ export interface CreateSessionOptions {
71
80
  export type CreateInProcSession = (opts: CreateSessionOptions) => Promise<InProcSession>;
72
81
 
73
82
  /**
74
- * Pi 0.81 moved the canonical auth/provider state from the extension-facing
75
- * ModelRegistry to a ModelRuntime passed directly to createAgentSession. The
76
- * compatibility facade still owns that runtime, but intentionally keeps it
77
- * private in its public TypeScript API. Read the runtime only when the expected
78
- * methods are present; old Pi versions and test facades then retain the legacy
79
- * modelRegistry path.
83
+ * The current Pi ModelRegistry facade owns the canonical ModelRuntime privately.
84
+ * Recover that runtime only when its expected methods are present, so native provider
85
+ * registrations and in-memory credentials remain shared with delegated sessions.
80
86
  */
81
87
  function sharedModelRuntime(registry: ModelRegistry): unknown {
82
88
  const candidate = (registry as unknown as { runtime?: unknown }).runtime;
@@ -88,6 +94,8 @@ function sharedModelRuntime(registry: ModelRegistry): unknown {
88
94
  }
89
95
 
90
96
  export interface InProcessDeps {
97
+ /** Frozen at buildEngine construction, independent of later persona changes or saved settings. */
98
+ memoryPersona?: string | null;
91
99
  resolveAgent: (name: string) => AgentConfig | undefined;
92
100
  contracts?: (name: string) => ContractDef | undefined;
93
101
  /** The host session's registry — shared so the sub-agent reuses the loader's auth. */
@@ -157,6 +165,11 @@ export interface InProcessDeps {
157
165
  * there, and this deadline aborts such a leg outright (failureKind "timeout"); the idle
158
166
  * watchdog would not, because any event at all re-arms it. */
159
167
  startupTimeoutMs?: number;
168
+ /** The owning session's terminal archive (a GETTER — the owner can change across sessions).
169
+ * Required by any leg carrying `resultRefs` (flow reference handoff): the scoped
170
+ * `read_result` tool pages THIS archive, after a zero-usage preflight verified every
171
+ * granted ref is durably stored. Absent ⇒ such legs fail closed before the session factory. */
172
+ resultArchive?: () => ResultArchiveView | undefined;
160
173
  }
161
174
 
162
175
  // The sub-agent must never re-enter the supervisor's orchestration surface. This is the SECOND
@@ -172,15 +185,6 @@ export function withOrchestrationToolsExcluded(extra: readonly string[] | undefi
172
185
  return [...new Set([...ORCHESTRATION_TOOLS, ...(extra ?? [])])];
173
186
  }
174
187
 
175
- /** Preserve the semantic difference between an absent allowlist (Pi defaults) and an explicit
176
- * empty one (no tools). Custom communication tools join only a non-empty allowlist; an empty
177
- * grant must not quietly grow capabilities during session construction. */
178
- export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
179
- if (tools === undefined) return undefined;
180
- if (tools.length === 0) return [];
181
- return [...new Set([...tools, ...customNames])];
182
- }
183
-
184
188
  /** Default factory: a real in-process Pi session. The resource loader appends the
185
189
  * agent's persona to the base system prompt (the in-process `--append-system-prompt`).
186
190
  * Extensions DO load (so auth providers like pi-claude work) — the fork-bomb guard is
@@ -188,9 +192,12 @@ export function mergeSessionToolAllowlist(tools: readonly string[] | undefined,
188
192
  * the orchestration tools excluded as a second line of defense. */
189
193
  const createPiSession: CreateInProcSession = async (opts) => {
190
194
  if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
195
+ const manager = SessionManager.inMemory(opts.cwd);
196
+ const memoryPersona = opts.memoryPersona;
191
197
  const loader = new DefaultResourceLoader({
192
198
  cwd: opts.cwd,
193
199
  agentDir: opts.agentDir,
200
+ ...(memoryPersona !== undefined ? { extensionFactories: [(pi) => installFrozenMemoryOwner(pi, manager, memoryPersona)] } : {}),
194
201
  ...(opts.systemPrompt ? { appendSystemPrompt: [opts.systemPrompt] } : {}),
195
202
  });
196
203
  await loader.reload();
@@ -199,17 +206,11 @@ const createPiSession: CreateInProcSession = async (opts) => {
199
206
  // otherwise the allowlist would filter them out of the child's active set.
200
207
  const customNames = (opts.customTools ?? []).map((t) => t.name);
201
208
  const tools = mergeSessionToolAllowlist(opts.tools, customNames);
202
- // Forward-compat with the model-runtime migration (pi > 0.80.7). pi <= 0.80.7 reads
203
- // `CreateAgentSessionOptions.modelRegistry` to share the host's auth/model registry with the
204
- // sub-session; newer pi takes the canonical `modelRuntime` instead. The extension-facing
205
- // ModelRegistry keeps that runtime private in its type surface, so `sharedModelRuntime()` above
206
- // recovers it only when the runtime shape is present. Passing it is important even when auth is
207
- // on disk: native providers and runtime credentials can exist only in the supervisor's memory.
208
- // `agentDir` remains a valid fallback for older/standalone SDKs, and the legacy key stays outside
209
- // the object literal so current SDK types do not reject it.
209
+ // Current Pi consumes the canonical modelRuntime. Sharing it also preserves native providers
210
+ // and credentials that exist only in the supervisor's memory.
210
211
  const sessionOptions: NonNullable<Parameters<typeof createAgentSession>[0]> = {
211
212
  model: opts.model as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>,
212
- sessionManager: SessionManager.inMemory(opts.cwd),
213
+ sessionManager: manager,
213
214
  resourceLoader: loader,
214
215
  cwd: opts.cwd,
215
216
  agentDir: opts.agentDir,
@@ -219,14 +220,9 @@ const createPiSession: CreateInProcSession = async (opts) => {
219
220
  excludeTools: withOrchestrationToolsExcluded(opts.excludeTools),
220
221
  ...(tools !== undefined ? { tools } : {}),
221
222
  ...(opts.customTools && opts.customTools.length > 0 ? { customTools: opts.customTools } : {}),
222
- // `opts.thinkingLevel` is our local ThinkingLevel superset — cast to pi's field type at the
223
- // boundary (same rationale as PersonaHost.setThinkingLevel: a `max`-style level only reaches
224
- // here on a pi that supports it, and pi clamps an unknown level regardless).
225
- ...(opts.thinkingLevel
226
- ? { thinkingLevel: opts.thinkingLevel as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]> }
227
- : {}),
223
+ // Pi owns capability clamping (which can move effort up as well as down).
224
+ ...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
228
225
  };
229
- (sessionOptions as { modelRegistry?: unknown }).modelRegistry = opts.modelRegistry;
230
226
  const { session } = await createAgentSession(sessionOptions);
231
227
  if (opts.signal?.aborted) {
232
228
  try {
@@ -237,6 +233,10 @@ const createPiSession: CreateInProcSession = async (opts) => {
237
233
  throw new DOMException("Session initialization aborted", "AbortError");
238
234
  }
239
235
  return {
236
+ get thinkingLevel() {
237
+ const level: unknown = session.thinkingLevel;
238
+ return isThinkingLevel(level) ? level : undefined;
239
+ },
240
240
  subscribe: (l) => session.subscribe(l as Parameters<typeof session.subscribe>[0]),
241
241
  prompt: async (input) => {
242
242
  await session.prompt(input);
@@ -293,6 +293,7 @@ function popDisableGuard(): void {
293
293
  }
294
294
 
295
295
  export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
296
+ const memoryPersona = deps.memoryPersona;
296
297
  const createSession = deps.createSession ?? createPiSession;
297
298
  const supervisorHandle = deps.supervisorHandle ?? "supervisor";
298
299
  // Per-run contract pinning (I3), mirroring the child adapter.
@@ -316,6 +317,13 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
316
317
  async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
317
318
  const cfg = deps.resolveAgent(spec.agent);
318
319
  if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
320
+ let thinkingRequested: ThinkingLevel | undefined;
321
+ try {
322
+ thinkingRequested = resolveLegThinking(spec, cfg, deps.childThinking);
323
+ } catch (error) {
324
+ return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, failureKind: "contract", error: `[${spec.agent}] ${error instanceof Error ? error.message : String(error)}` };
325
+ }
326
+ const thinkingMetadata = thinkingRequested !== undefined ? { thinkingRequested } : {};
319
327
  const tools = spec.tools ?? cfg.tools;
320
328
  const invalidTools = toolModifierFailure(spec.agent, tools);
321
329
  if (invalidTools) return invalidTools;
@@ -333,7 +341,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
333
341
  // Make the failure self-correcting: name a few real registry refs (nearest
334
342
  // matches first) so the supervisor's retry can pick a valid model at once.
335
343
  const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
336
- const frag = (ref ?? "").split("/").pop()?.split(":")[0]?.toLowerCase() ?? "";
344
+ const frag = (ref ?? "").split("/").pop()?.toLowerCase() ?? "";
337
345
  const near = frag ? all.filter((r) => r.toLowerCase().includes(frag)) : [];
338
346
  const hint = (near.length > 0 ? near : all).slice(0, 5);
339
347
  return {
@@ -342,16 +350,40 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
342
350
  usage: emptyUsage(),
343
351
  ok: false,
344
352
  error: `[${spec.agent} · ${ref ?? "(no model)"}] model not found in registry (from ${src})${hint.length > 0 ? ` — try e.g.: ${hint.join(", ")}` : ""}`,
345
- failureKind: "unknown-model",
353
+ failureKind: "unknown-model",
354
+ ...thinkingMetadata,
346
355
  };
347
356
  }
348
357
  // The canonical provider/id this run resolved to — surfaced on every result (for the
349
358
  // UI/cost) and, on a provider failure, used as the seed of the fallback chain.
350
359
  const rm = model as { provider?: string; id?: string };
351
360
  const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
352
- const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
353
361
  const excludeTools = cfg.excludeTools;
354
362
 
363
+ // ── reference handoff preflight (contract 3/4) — BEFORE the session factory, after every
364
+ // cheap zero-usage rejection above it. Admission uses the SAME shared rule as the child
365
+ // engine; storage is verified durable (session-entry); a failure is a zero-usage
366
+ // contract failure and the factory is never called. The revoke rides `releaseHandle()`
367
+ // (every exit path from here on drains it), so the scoped reader dies with the leg.
368
+ const legCleanups: Array<() => void> = [];
369
+ let resultReadTool: ToolDefinition | undefined;
370
+ let admittedTools = tools;
371
+ if (spec.resultRefs !== undefined && spec.resultRefs.length > 0) {
372
+ const grant = admitResultReadTool({
373
+ specTools: spec.tools, cfgTools: cfg.tools, excludeTools: cfg.excludeTools,
374
+ canUseBus: deps.canUseBus ?? true,
375
+ });
376
+ if (!grant.ok) return referenceHandoffFailure(spec.agent, grant.error);
377
+ const archive = deps.resultArchive?.();
378
+ if (archive === undefined) return referenceHandoffFailure(spec.agent, "terminal result archive unavailable for reference handoff");
379
+ const scoped = makeScopedResultReader(archive, spec.resultRefs);
380
+ const { missing } = scoped.preflight();
381
+ if (missing.length > 0) return referenceHandoffFailure(spec.agent, `archived results unavailable: ${missing.join(", ")}`);
382
+ resultReadTool = makeReadResultTool(scoped);
383
+ admittedTools = grant.tools;
384
+ legCleanups.push(() => scoped.revoke());
385
+ }
386
+
355
387
  // Diagnostic tag: which agent + which model ref + whether it was dynamically
356
388
  // specialised (skills / model / tools override on the spec). Users need this
357
389
  // to tell "the delegate typo'd the model" from "the provider rejected it".
@@ -368,20 +400,20 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
368
400
  // Already cancelled (e.g. a queued async run stopped before its slot came up):
369
401
  // don't build a session or burn a model call — settle as aborted right away.
370
402
  if (signal?.aborted) {
371
- return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), failureKind: "abort" };
403
+ return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), ...thinkingMetadata, failureKind: "abort" };
372
404
  }
373
405
 
374
406
  const modelRuntime = sharedModelRuntime(deps.modelRegistry);
375
407
  const sessionOpts: CreateSessionOptions = {
376
408
  model,
377
- modelRegistry: deps.modelRegistry,
378
409
  ...(modelRuntime !== undefined ? { modelRuntime } : {}),
379
410
  cwd: deps.cwd,
380
411
  agentDir: deps.agentDir ?? deps.cwd,
381
412
  };
382
- if (tools !== undefined) sessionOpts.tools = tools;
413
+ if (admittedTools !== undefined) sessionOpts.tools = admittedTools;
414
+ if (memoryPersona !== undefined) sessionOpts.memoryPersona = memoryPersona;
383
415
  if (excludeTools && excludeTools.length > 0) sessionOpts.excludeTools = excludeTools;
384
- if (thinkingLevel) sessionOpts.thinkingLevel = thinkingLevel;
416
+ if (thinkingRequested !== undefined) sessionOpts.thinkingLevel = thinkingRequested;
385
417
  // The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
386
418
  // on-the-fly `role` specialisation from the spec. All three reach the session as the
387
419
  // loader's `appendSystemPrompt`, so a leg keeps Pi's full base prompt either way — the
@@ -398,20 +430,21 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
398
430
  if (spec.peers === true && !wantsPeers && process.env.PI_PERSONA_DEBUG) {
399
431
  process.stderr.write(`[pi-persona] peers requested for ${spec.agent} but bus/capability unavailable — running without contact_peer\n`);
400
432
  }
401
- if (deps.bus && (deps.coaching || wantsPeers)) {
433
+ const customTools: ToolDefinition[] = [];
434
+ const wantsCoaching = deps.coaching === true && (deps.canUseBus ?? true);
435
+ if (deps.bus && (wantsCoaching || wantsPeers)) {
402
436
  childHandle = nextChildHandle(spec.agent);
403
437
  deps.bus.register(supervisorHandle);
404
438
  deps.bus.register(childHandle);
405
- const customTools: ToolDefinition[] = [];
406
- if (deps.coaching) {
439
+ if (wantsCoaching) {
407
440
  customTools.push(
408
441
  makeContactSupervisorTool(deps.bus, childHandle, supervisorHandle, { allowBlocking: deps.allowBlocking ?? false }),
409
442
  );
410
443
  }
411
444
  if (wantsPeers) {
412
445
  const label = spec.name?.trim()
413
- ? sanitizeDisplayLabel(spec.name, childHandle)
414
- : spec.role ? `${childHandle} (${roleHint(spec.role)})` : childHandle;
446
+ ? sanitizeDisplayLabel(spec.name, childHandle)
447
+ : spec.role ? `${childHandle} (${roleHint(spec.role)})` : childHandle;
415
448
  peerLabels.set(childHandle, label);
416
449
  const self = childHandle;
417
450
  customTools.push(
@@ -420,8 +453,12 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
420
453
  }),
421
454
  );
422
455
  }
423
- sessionOpts.customTools = customTools;
424
456
  }
457
+ // A granted reference-handoff reader joins the SAME custom-tools surface (contract 3):
458
+ // literal `read_result` already admitted into a nonempty allowlist above; an absent
459
+ // allowlist stays absent and the injected tool rides Pi's session defaults.
460
+ if (resultReadTool !== undefined) customTools.push(resultReadTool);
461
+ if (customTools.length > 0) sessionOpts.customTools = customTools;
425
462
 
426
463
  // The handle is live on the SESSION-LONG bus from here on, while the only unregister sits
427
464
  // in the run's own finally below. Anything that throws before we reach it must release
@@ -429,6 +466,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
429
466
  // for the rest of the supervisor's session.
430
467
  let handleReleased = false;
431
468
  const releaseHandle = (): void => {
469
+ // Leg-scoped cleanups FIRST — the reference reader revoke must run even on a leg that
470
+ // never owned a bus handle (non-coaching, peer-less).
471
+ for (const cleanup of legCleanups.splice(0)) {
472
+ try { cleanup(); } catch { /* a throwing cleanup must never strand its siblings */ }
473
+ }
432
474
  if (!childHandle || handleReleased) return;
433
475
  handleReleased = true;
434
476
  peerLabels.delete(childHandle);
@@ -578,6 +620,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
578
620
  error: `${tag} agent aborted`,
579
621
  ...(resolvedRef ? { modelUsed: resolvedRef } : {}),
580
622
  failureKind: "abort",
623
+ ...thinkingMetadata,
581
624
  };
582
625
  }
583
626
 
@@ -649,6 +692,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
649
692
  error: `${initError}${cleanupHint}`,
650
693
  ...(resolvedRef ? { modelUsed: resolvedRef } : {}),
651
694
  failureKind: hardTimedOut || timedOut || startupTimedOut ? "timeout" : "abort",
695
+ ...thinkingMetadata,
652
696
  };
653
697
  }
654
698
  session = initializationOutcome.session;
@@ -774,6 +818,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
774
818
  // folded into the AgentResult below. Otherwise the stream state stays empty
775
819
  // and the user sees an opaque "agent failed (unknown)".
776
820
  let thrownError: string | undefined;
821
+ let thinkingEffective: ThinkingLevel | undefined;
777
822
  try {
778
823
  armIdle(); // start the idle clock (reset on every session event)
779
824
  armHard(); // start the lifetime ceiling (never reset)
@@ -796,6 +841,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
796
841
  releaseHandle();
797
842
  unsub?.();
798
843
  steerable = false;
844
+ const observed = session.thinkingLevel;
845
+ if (isThinkingLevel(observed)) thinkingEffective = observed;
799
846
  session.dispose();
800
847
  }
801
848
 
@@ -824,7 +871,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
824
871
  }
825
872
  }
826
873
 
827
- const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
874
+ const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), ...thinkingMetadata };
875
+ // The final message describes the effort of that request; session readback is the
876
+ // observed setting when a host emits no effort field. Neither proves provider cognition.
877
+ const observed = state.thinkingEffective ?? thinkingEffective;
878
+ if (observed !== undefined) result.thinkingEffective = observed;
828
879
  // A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
829
880
  if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
830
881
  // The deadline cannot tell the two causes apart, so the message names both: claiming
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The frozen `read_result` admission rule (flow reference handoff, contract 4) — ONE helper
3
+ * shared by BOTH engine backends, so the child-process and in-process paths can never drift.
4
+ *
5
+ * effective tools = spec.tools ?? cfg.tools. An explicit `[]` denies. A nonempty allowlist
6
+ * must already include `read` OR `read_result` (the grant builds on a leg that can read —
7
+ * it never widens a write-only specialist into a reader). Any deny (agent `excludeTools`,
8
+ * wildcards included) matching either name denies. The parent's `canUseBus` capability must
9
+ * be true for reference mode at all; explicit parent denies always win. Only after a grant:
10
+ * the literal `read_result` joins a nonempty allowlist; an absent allowlist stays absent
11
+ * (session defaults + the injected custom tool).
12
+ */
13
+
14
+ import { isAllowed } from "../core/permissions.ts";
15
+ import type { AgentResult } from "../orchestration/types.ts";
16
+ import { emptyUsage } from "./stream.ts";
17
+
18
+ /** The literal, never-renamed tool a reference-handoff leg reads archived results through. */
19
+ export const RESULT_READ_TOOL = "read_result";
20
+
21
+ export interface ResultToolAdmission {
22
+ specTools: readonly string[] | undefined;
23
+ cfgTools: readonly string[] | undefined;
24
+ /** The agent's own deny list (glob patterns allowed — the same matcher Pi's permission
25
+ * layer uses, so a `read*` deny here means what it means everywhere else). */
26
+ excludeTools: readonly string[] | undefined;
27
+ /** The persona-level bus capability (`EffectiveCapabilities.canUseBus`). */
28
+ canUseBus: boolean;
29
+ }
30
+
31
+ export type ResultToolGrant =
32
+ | { ok: false; error: string }
33
+ /** `tools` is the effective allowlist to run the leg with: undefined = an absent
34
+ * allowlist stays absent (session default + custom-tool injection). */
35
+ | { ok: true; tools: string[] | undefined };
36
+
37
+ export function admitResultReadTool(input: ResultToolAdmission): ResultToolGrant {
38
+ if (!input.canUseBus) {
39
+ return { ok: false, error: "reference handoff requires the bus capability (this persona denies it)" };
40
+ }
41
+ const tools = input.specTools ?? input.cfgTools;
42
+ if (tools !== undefined && tools.length === 0) {
43
+ return { ok: false, error: "reference handoff requires the read tool; this leg's tool allowlist is empty" };
44
+ }
45
+ // Denies are evaluated by NAME against both spellings, wildcards included, before any
46
+ // allowlist reasoning — an explicit deny always wins over the grant below.
47
+ const deny = { deny: [...(input.excludeTools ?? [])] };
48
+ if (!isAllowed("read", deny) || !isAllowed(RESULT_READ_TOOL, deny)) {
49
+ return { ok: false, error: "reference handoff requires the read tool; read/read_result is denied for this leg" };
50
+ }
51
+ if (tools !== undefined && !tools.includes("read") && !tools.includes(RESULT_READ_TOOL)) {
52
+ return { ok: false, error: `reference handoff requires the read or ${RESULT_READ_TOOL} tool; the allowlist names neither` };
53
+ }
54
+ if (tools === undefined) return { ok: true, tools: undefined };
55
+ return { ok: true, tools: [...new Set([...tools, RESULT_READ_TOOL])] };
56
+ }
57
+
58
+ /** A reference-handoff preflight rejection: ZERO usage (nothing ran), terminal for the leg,
59
+ * listing what is missing so the caller can report it honestly. */
60
+ export function referenceHandoffFailure(agent: string, error: string): AgentResult {
61
+ return { agent, output: "", usage: emptyUsage(), ok: false, error: `[${agent}] ${error}`, failureKind: "contract" };
62
+ }
@@ -0,0 +1,6 @@
1
+ /** Preserve absent/default and explicit empty grants when adding runtime-owned custom tools. */
2
+ export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
3
+ if (tools === undefined) return undefined;
4
+ if (tools.length === 0) return [];
5
+ return [...new Set([...tools, ...customNames])];
6
+ }
@@ -9,6 +9,8 @@
9
9
  * Mirrors the canonical recipe in Pi's reference subagent example.
10
10
  */
11
11
 
12
+ import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
13
+
12
14
  export interface ChildUsage {
13
15
  input: number;
14
16
  output: number;
@@ -53,6 +55,8 @@ export interface StreamState {
53
55
  deltaTail: number;
54
56
  usage: ChildUsage;
55
57
  model?: string;
58
+ /** Final assistant-message Pi effort, not provider cognition. Unknown stays absent. */
59
+ thinkingEffective?: ThinkingLevel;
56
60
  stopReason?: string;
57
61
  errorMessage?: string;
58
62
  sawAssistant: boolean;
@@ -306,6 +310,8 @@ export function applyEvent(state: StreamState, event: unknown): ToolEvent | unde
306
310
  resetDeltaParts(state); // the next message's deltas start from an empty slate
307
311
  state.sawAssistant = true;
308
312
  state.usage.turns++;
313
+ if (isThinkingLevel(msg.thinkingLevel)) state.thinkingEffective = msg.thinkingLevel;
314
+ else delete state.thinkingEffective;
309
315
 
310
316
  const text = firstText(msg.content);
311
317
  if (text !== undefined) state.output = text;
@@ -76,11 +76,14 @@ export async function worktreePreflight(root: string, exec: GitExec = defaultGit
76
76
  return { ok: true };
77
77
  }
78
78
 
79
- /** Capture the complete post-run worktree state as a portable unified diff. Tracked edits use
80
- * `git diff HEAD`; files newly created by a coding leg are added with `git diff --no-index`.
79
+ /** Capture the complete post-run worktree state as a portable unified diff from its pinned
80
+ * starting commit. Files newly created by a coding leg are added with `git diff --no-index`.
81
81
  * Exit code 1 is the normal "files differ" result for --no-index. */
82
- export async function captureWorktreeArtifact(root: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
83
- const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", "HEAD", "--"]);
82
+ export async function captureWorktreeArtifact(root: string, baseCommit: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
83
+ if (!baseCommit.trim()) {
84
+ return { ok: false, error: "could not export the isolated worktree diff: starting commit is missing" };
85
+ }
86
+ const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", baseCommit, "--"]);
84
87
  if (tracked.code !== 0) {
85
88
  return { ok: false, error: `could not export the isolated worktree diff: ${tracked.stderr.trim() || `git exited ${tracked.code}`}` };
86
89
  }
@@ -107,8 +110,9 @@ export async function captureWorktreeArtifact(root: string, exec: GitExec = defa
107
110
  return { ok: true, diff: parts.join("\n") };
108
111
  }
109
112
 
110
- /** Create a detached worktree off `root`'s HEAD, run `body(worktreeDir)`, then force-remove it. */
111
- export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string) => Promise<T>): Promise<T> {
113
+ /** Create a detached worktree off `root`'s HEAD, pin its starting commit, run
114
+ * `body(worktreeDir, baseCommit)`, then force-remove it. */
115
+ export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string, baseCommit: string) => Promise<T>): Promise<T> {
112
116
  const preflight = await worktreePreflight(root, exec);
113
117
  if (!preflight.ok) throw new Error(preflight.error);
114
118
  const dir = await mkdtemp(join(tmpdir(), "pi-persona-wt-"));
@@ -120,7 +124,13 @@ export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: s
120
124
  throw new Error(`could not create git worktree: ${add.stderr.trim() || `git exited ${add.code}`}`);
121
125
  }
122
126
  try {
123
- return await body(dir);
127
+ const initial = await exec(["-C", dir, "rev-parse", "--verify", "HEAD^{commit}"]);
128
+ const baseCommit = initial.stdout.trim();
129
+ if (initial.code !== 0 || !baseCommit) {
130
+ const reason = initial.stderr.trim() || (initial.code !== 0 ? `git exited ${initial.code}` : "Git returned an empty commit id");
131
+ throw new Error(`could not resolve the initial worktree commit: ${reason}`);
132
+ }
133
+ return await body(dir, baseCommit);
124
134
  } finally {
125
135
  // Remove the worktree registration, then the dir (force: it may carry the agent's edits).
126
136
  // Never throw: cleanup must not replace the body's result (or its error) — `defaultGitExec`
@@ -0,0 +1,29 @@
1
+ /** Signed offer wakes project retained ledger data; the envelope is never its authority. */
2
+ import { attributePeer } from "../core/fence.ts";
3
+ import { collaborationOfferStatus } from "./collaboration.ts";
4
+ import { frameSigningPayload, type ExocomOffer, type ExocomSemanticFrame } from "./envelope.ts";
5
+ import type { LedgerState } from "./ledger.ts";
6
+
7
+ export function collaborationWriteIntent(frame: ExocomSemanticFrame, state: LedgerState): boolean {
8
+ if (frame.kind === "claim" || frame.kind === "task_create") return frame.write_set.length > 0;
9
+ return frame.kind === "task_take" && (state.collaboration?.tasks.find((task) => task.created.work_key === frame.work_key)?.created.write_set.length ?? 0) > 0;
10
+ }
11
+
12
+ export function deliverCollaborationOffer(
13
+ frame: ExocomOffer, state: LedgerState, sessionId: string, label: string, now: number,
14
+ ): { accepted: true; notice?: string } | { accepted: false; reason: string } {
15
+ if (frame.to_session !== sessionId) return { accepted: false, reason: "offer addressed to another session" };
16
+ const stored = state.collaboration?.offers.find((offer) => offer.offer_id === frame.offer_id);
17
+ if (!stored || frameSigningPayload(stored) !== frameSigningPayload(frame)) {
18
+ return { accepted: false, reason: "offer is not durable with this sender and payload" };
19
+ }
20
+ const tasks = state.collaboration?.tasks ?? [];
21
+ if (collaborationOfferStatus(stored, tasks, now) !== "open") return { accepted: true };
22
+ const task = tasks.find((candidate) => candidate.created.work_key === stored.work_key && candidate.created.msg_id === stored.task_id);
23
+ if (!task) return { accepted: true };
24
+ return { accepted: true, notice: [
25
+ `[pi-persona] Optional peer invitation · task=${stored.work_key} · offer=${stored.offer_id} · expires ${stored.expires_at}`,
26
+ "This creates no response obligation or permission. Inspect the task and take it voluntarily only within your authorized scope; otherwise continue your work.",
27
+ attributePeer(frame.from_session === task.created.from_session ? label : task.created.from_session, task.created.description),
28
+ ].join("\n") };
29
+ }