@aeondave/pi-persona 1.17.4 → 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.
package/src/bus/peers.ts CHANGED
@@ -24,7 +24,7 @@ import { Text } from "@earendil-works/pi-tui";
24
24
 
25
25
  import { compactInlineText } from "../ui/presentation.ts";
26
26
  import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
27
- import type { InProcessBus } from "./inproc.ts";
27
+ import type { ContactTransport } from "./contact-transport.ts";
28
28
 
29
29
  /** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
30
30
  export const MAX_PEER_SENDS = 20;
@@ -59,7 +59,7 @@ function result(t: string, details: PeerDetails): { content: [{ type: "text"; te
59
59
 
60
60
  /** Build the child's `contact_peer` tool. Same `defineTool` variance shim as
61
61
  * `makeContactSupervisorTool`, so it slots into `customTools: ToolDefinition[]`. */
62
- export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps: ContactPeerDeps) {
62
+ export function makeContactPeerTool(bus: Pick<ContactTransport, "send">, selfHandle: string, deps: ContactPeerDeps) {
63
63
  const maxSends = deps.maxSends ?? MAX_PEER_SENDS;
64
64
  let sent = 0;
65
65
  return defineTool({
@@ -74,7 +74,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
74
74
  ].join(" "),
75
75
  promptSnippet: "contact_peer — list your run's sibling agents or send one a one-way note",
76
76
  parameters: PeerParams,
77
- async execute(_toolCallId, params: Static<typeof PeerParams>, _signal, _onUpdate, _ctx) {
77
+ async execute(_toolCallId, params: Static<typeof PeerParams>, signal, _onUpdate, _ctx) {
78
78
  if (params.action === "list") {
79
79
  const peers = await deps.listPeers();
80
80
  return result(
@@ -87,6 +87,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
87
87
  if (!params.to || params.message === undefined) {
88
88
  return result("contact_peer send needs { to, message } — use `list` for peer handles.", { action: "send", delivered: false });
89
89
  }
90
+ if (signal?.aborted) return result("Peer message cancelled before delivery.", { action: "send", delivered: false });
90
91
  if (params.message.length > MAX_PEER_MESSAGE_CHARS) {
91
92
  return result(
92
93
  `contact_peer message exceeds the ${MAX_PEER_MESSAGE_CHARS}-character limit; send a smaller note.`,
@@ -102,14 +103,15 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
102
103
  sent += 1;
103
104
  // Enforce the run-isolation boundary (previously only convention): a handle that is
104
105
  // not one of THIS run's peers (e.g. "supervisor", or another run's child) never gets
105
- // bus.send called at all — same "gone" wording as an actually-finished peer, so the
106
+ // bus.send called at all — same unavailable wording as an actually-finished peer, so the
106
107
  // tool result gives no signal either way about what exists outside this run's scope.
107
108
  const inScope = (await deps.listPeers()).some((p) => p.handle === params.to);
108
- const delivered = inScope && bus.send(selfHandle, params.to, params.message, "progress");
109
+ const accepted = inScope && !signal?.aborted && await bus.send(selfHandle, params.to, params.message, "progress", signal);
110
+ const delivered = accepted && !signal?.aborted;
109
111
  return result(
110
112
  delivered
111
113
  ? `Sent to ${params.to}.`
112
- : `Peer "${params.to}" is gone — it may have already finalized. Proceed with your own judgement.`,
114
+ : `Delivery to peer "${params.to}" could not be confirmed — it may be unavailable or its inbox full. Proceed with your own judgement.`,
113
115
  { action: "send", delivered },
114
116
  );
115
117
  },
@@ -0,0 +1,64 @@
1
+ /** Public local companion contracts. These describe loaded capabilities and UI activity, not authority. */
2
+ export const COMPANION_EVENT = "pi-persona:companion:v1";
3
+ export const ACTIVITY_EVENT = "pi-persona:activity:v1";
4
+ export const MEMORY_OWNER_EVENT = "pi-persona:memory-owner:v1";
5
+ export const MEMORY_OWNER_CHANGED_EVENT = "pi-persona:memory-owner-changed:v1";
6
+ export const MEMORY_PERSONA_ENV = "PI_PERSONA_MEMORY_PERSONA";
7
+
8
+ export interface CompanionMemoryOwner {
9
+ version: 1;
10
+ provider: "pi-persona";
11
+ sessionId: string;
12
+ ready: boolean;
13
+ persona: string | null;
14
+ }
15
+
16
+ /** Spawn-local inheritance only. Absent/invalid is unavailable, never a global-settings fallback. */
17
+ export function inheritedMemoryPersona(raw: string | undefined): { persona: string | null } | undefined {
18
+ if (raw === undefined || raw.length > 8_192) return undefined;
19
+ try {
20
+ const value = JSON.parse(raw) as Record<string, unknown> | null;
21
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1 || Object.keys(value).some((key) => key !== "version" && key !== "persona")) return undefined;
22
+ if (value.persona !== null && (typeof value.persona !== "string" || !value.persona.trim() || value.persona.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(value.persona))) return undefined;
23
+ return { persona: value.persona as string | null };
24
+ } catch { return undefined; }
25
+ }
26
+
27
+ export type CompanionRole = "supervisor" | "leg" | "disabled";
28
+ export interface CompanionPresence {
29
+ version: 1;
30
+ provider: "pi-persona";
31
+ role: CompanionRole;
32
+ capabilities: { memory: 1 | false; activity: 1 | false };
33
+ }
34
+ export interface CompanionActivity {
35
+ version: 1;
36
+ producerId: "pi-persona-mind";
37
+ activityId: string;
38
+ sessionId: string;
39
+ phase: "inspect" | "stage" | "apply";
40
+ state: "running" | "staged" | "done" | "failed";
41
+ target: "project" | "project-long" | "persona" | "shared";
42
+ }
43
+
44
+ export function companionPresence(role: CompanionRole): CompanionPresence {
45
+ return { version: 1, provider: "pi-persona", role, capabilities: { memory: role === "disabled" ? false : 1, activity: role === "supervisor" ? 1 : false } };
46
+ }
47
+
48
+ /** Closed records cannot accidentally export raw memory, model claims, or prompts into the tree. */
49
+ export function parseCompanionActivity(value: unknown): CompanionActivity | undefined {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
51
+ const prototype = Object.getPrototypeOf(value);
52
+ if (prototype !== null && prototype !== Object.prototype) return undefined;
53
+ const allowed = new Set(["version", "producerId", "activityId", "sessionId", "phase", "state", "target"]);
54
+ if (Reflect.ownKeys(value).some((key) => typeof key !== "string" || !allowed.has(key))) return undefined;
55
+ const activity = value as Record<string, unknown>;
56
+ if (activity.version !== 1 || activity.producerId !== "pi-persona-mind") return undefined;
57
+ if (typeof activity.activityId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/.test(activity.activityId)) return undefined;
58
+ if (typeof activity.sessionId !== "string" || activity.sessionId.length < 1 || activity.sessionId.length > 256 || /[\s\u0000-\u001f\u007f]/u.test(activity.sessionId)) return undefined;
59
+ if (activity.phase !== "inspect" && activity.phase !== "stage" && activity.phase !== "apply") return undefined;
60
+ if (activity.state !== "running" && activity.state !== "staged" && activity.state !== "done" && activity.state !== "failed") return undefined;
61
+ if (activity.state === "staged" && activity.phase !== "stage") return undefined;
62
+ if (activity.target !== "project" && activity.target !== "project-long" && activity.target !== "persona" && activity.target !== "shared") return undefined;
63
+ return { version: 1, producerId: "pi-persona-mind", activityId: activity.activityId, sessionId: activity.sessionId, phase: activity.phase, state: activity.state, target: activity.target };
64
+ }
@@ -13,6 +13,7 @@ import { sanitizeDisplayLabel } from "../core/display-label.ts";
13
13
  import { resolveLegThinking } from "../core/leg-thinking.ts";
14
14
  import type { ThinkingLevel } from "../core/types.ts";
15
15
  import { assignedIdentityPrompt } from "../core/naming.ts";
16
+ import { MEMORY_PERSONA_ENV } from "../core/companion.ts";
16
17
  import { roleHint } from "../orchestration/roster.ts";
17
18
  import { type AgentProgress, type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
18
19
  import type { AgentResult } from "../orchestration/types.ts";
@@ -54,6 +55,8 @@ export interface EngineAdapterBroker {
54
55
  }
55
56
 
56
57
  export interface EngineAdapterDeps {
58
+ /** Immutable owning supervisor persona; null explicitly means no persona. */
59
+ memoryPersona?: string | null;
57
60
  resolveAgent: (name: string) => AgentConfig | undefined;
58
61
  contracts?: (name: string) => ContractDef | undefined;
59
62
  signal?: AbortSignal;
@@ -98,6 +101,7 @@ export interface EngineAdapterDeps {
98
101
  }
99
102
 
100
103
  export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
104
+ const memoryPersona = deps.memoryPersona;
101
105
  // Per-run contract pinning (I3): an engine instance is created per run, so the first
102
106
  // time a contract is used we pin `name@hash` and reuse that frozen snapshot for the
103
107
  // rest of the run — hot-reloading a contract file can't change an in-flight run.
@@ -188,6 +192,12 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
188
192
  if (deps.cwd) childSpec.cwd = deps.cwd;
189
193
 
190
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
+ }
191
201
  // NP2: a per-leg timeoutMs override raises (or shortens) just THIS leg's idle-timeout
192
202
  // ceiling without touching the shared default other legs of the same run still see.
193
203
  // Junk (non-finite/≤0) is ignored — the engine's existing childOptions.timeoutMs stands.
@@ -265,8 +275,14 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
265
275
  // The opaque provider/id the child ran on — for the UI and
266
276
  // as the seed of the provider-fallback chain on a provider failure. The child's
267
277
  // STREAM-REPORTED model wins over the requested spec: a resolved alias or a
268
- // provider-side redirect must seed the fallback chain with what actually ran.
269
- const modelUsed = child.model ?? childSpec.model ?? spec.model ?? cfg.model;
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;
270
286
  if (modelUsed) result.modelUsed = modelUsed;
271
287
  // Diagnostic tag: agent · model ref · dynamic overrides. Same shape as inproc engine,
272
288
  // so failed follow-ups always say WHICH agent+model died and WHY.
@@ -71,6 +71,8 @@ export interface AsyncRun {
71
71
  /** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
72
72
  * last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
73
73
  batch?: string;
74
+ /** Companion-owned maintenance settles to its owner without waking the supervisor model. */
75
+ delivery?: "companion";
74
76
  /** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
75
77
  * digest instead of a stall verdict, because silence while a human decides is not a wedge. */
76
78
  awaiting?: string;
@@ -140,7 +142,7 @@ export class AsyncRunTracker {
140
142
  if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
141
143
  }
142
144
 
143
- 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 {
144
146
  this.seq += 1;
145
147
  const id = `run-${this.seq.toString(36)}`;
146
148
  const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
@@ -155,6 +157,7 @@ export class AsyncRunTracker {
155
157
  };
156
158
  if (meta.mutates) entry.mutates = true;
157
159
  if (meta.batch) entry.batch = meta.batch;
160
+ if (meta.delivery) entry.delivery = meta.delivery;
158
161
  // Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
159
162
  // once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
160
163
  // those views; renderers still sanitize defensively when they compose a label.
@@ -214,6 +214,7 @@ export async function runChildAgent(
214
214
  delete inherited.PI_PERSONA_PEERS;
215
215
  delete inherited.PI_PERSONA_ALLOW_BLOCKING;
216
216
  delete inherited.PI_PERSONA_BUS_TOOLS;
217
+ delete inherited.PI_PERSONA_MEMORY_PERSONA;
217
218
  delete inherited.PI_PERSONA_RESULT_READER_TOKEN;
218
219
  const proc = spawn(inv.command, inv.args, {
219
220
  cwd: spec.cwd ?? process.cwd(),
@@ -28,6 +28,7 @@ import { type ContractDef, contractInstructions, parseAndValidate, pinContract,
28
28
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
29
29
  import { resolveLegThinking } from "../core/leg-thinking.ts";
30
30
  import { assignedIdentityPrompt } from "../core/naming.ts";
31
+ import { installFrozenMemoryOwner } from "../extension/memory-owner.ts";
31
32
  import { attributeInbound } from "../core/fence.ts";
32
33
  import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
33
34
  import { roleHint } from "../orchestration/roster.ts";
@@ -55,6 +56,8 @@ export interface InProcSession {
55
56
  }
56
57
 
57
58
  export interface CreateSessionOptions {
59
+ /** Immutable owning supervisor persona; null explicitly means no persona. */
60
+ memoryPersona?: string | null;
58
61
  model: unknown;
59
62
  /** Cooperative cancellation for resource/session initialization. Pi's loader does not
60
63
  * currently accept a signal itself, but injected factories can stop promptly and the
@@ -91,6 +94,8 @@ function sharedModelRuntime(registry: ModelRegistry): unknown {
91
94
  }
92
95
 
93
96
  export interface InProcessDeps {
97
+ /** Frozen at buildEngine construction, independent of later persona changes or saved settings. */
98
+ memoryPersona?: string | null;
94
99
  resolveAgent: (name: string) => AgentConfig | undefined;
95
100
  contracts?: (name: string) => ContractDef | undefined;
96
101
  /** The host session's registry — shared so the sub-agent reuses the loader's auth. */
@@ -187,9 +192,12 @@ export function withOrchestrationToolsExcluded(extra: readonly string[] | undefi
187
192
  * the orchestration tools excluded as a second line of defense. */
188
193
  const createPiSession: CreateInProcSession = async (opts) => {
189
194
  if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
195
+ const manager = SessionManager.inMemory(opts.cwd);
196
+ const memoryPersona = opts.memoryPersona;
190
197
  const loader = new DefaultResourceLoader({
191
198
  cwd: opts.cwd,
192
199
  agentDir: opts.agentDir,
200
+ ...(memoryPersona !== undefined ? { extensionFactories: [(pi) => installFrozenMemoryOwner(pi, manager, memoryPersona)] } : {}),
193
201
  ...(opts.systemPrompt ? { appendSystemPrompt: [opts.systemPrompt] } : {}),
194
202
  });
195
203
  await loader.reload();
@@ -202,7 +210,7 @@ const createPiSession: CreateInProcSession = async (opts) => {
202
210
  // and credentials that exist only in the supervisor's memory.
203
211
  const sessionOptions: NonNullable<Parameters<typeof createAgentSession>[0]> = {
204
212
  model: opts.model as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>,
205
- sessionManager: SessionManager.inMemory(opts.cwd),
213
+ sessionManager: manager,
206
214
  resourceLoader: loader,
207
215
  cwd: opts.cwd,
208
216
  agentDir: opts.agentDir,
@@ -285,6 +293,7 @@ function popDisableGuard(): void {
285
293
  }
286
294
 
287
295
  export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
296
+ const memoryPersona = deps.memoryPersona;
288
297
  const createSession = deps.createSession ?? createPiSession;
289
298
  const supervisorHandle = deps.supervisorHandle ?? "supervisor";
290
299
  // Per-run contract pinning (I3), mirroring the child adapter.
@@ -402,6 +411,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
402
411
  agentDir: deps.agentDir ?? deps.cwd,
403
412
  };
404
413
  if (admittedTools !== undefined) sessionOpts.tools = admittedTools;
414
+ if (memoryPersona !== undefined) sessionOpts.memoryPersona = memoryPersona;
405
415
  if (excludeTools && excludeTools.length > 0) sessionOpts.excludeTools = excludeTools;
406
416
  if (thinkingRequested !== undefined) sessionOpts.thinkingLevel = thinkingRequested;
407
417
  // The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
@@ -0,0 +1,38 @@
1
+ /** Framework adapter for loaded companion presence and current-owner activity contributions. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ACTIVITY_EVENT, COMPANION_EVENT, companionPresence, parseCompanionActivity, type CompanionRole } from "../core/companion.ts";
4
+ import { ActivityRows } from "../ui/activity-rows.ts";
5
+ import type { AgentTree } from "../ui/agent-tree.ts";
6
+
7
+ /** Registered before worker/disable guards; the host revokes subscriptions when the extension unloads. */
8
+ export function installCompanionPresence(pi: Pick<ExtensionAPI, "events">, role: CompanionRole): void {
9
+ pi.events.on(COMPANION_EVENT, (request: unknown) => {
10
+ if (!request || typeof request !== "object" || Array.isArray(request)) return;
11
+ const query = request as Record<string, unknown>;
12
+ if (query.version !== 1 || typeof query.respond !== "function" || Reflect.ownKeys(query).some((key) => key !== "version" && key !== "respond")) return;
13
+ try { query.respond(companionPresence(role)); } catch { /* a companion's callback cannot disrupt Persona */ }
14
+ });
15
+ }
16
+
17
+ export interface ActivityIngress { start(ctx: ExtensionContext): void; stop(removeRows?: boolean): void }
18
+ export function installActivityIngress(pi: Pick<ExtensionAPI, "events">, tree: AgentTree, now: () => number = Date.now): ActivityIngress {
19
+ const rows = new ActivityRows();
20
+ let owner: { manager: ExtensionContext["sessionManager"]; sessionId: string; file: string | undefined } | undefined;
21
+ const stop = (removeRows = true): void => {
22
+ owner = undefined;
23
+ const ids = rows.clear();
24
+ if (removeRows) for (const id of ids) tree.remove(id);
25
+ };
26
+ pi.events.on(ACTIVITY_EVENT, (value: unknown) => {
27
+ const event = parseCompanionActivity(value);
28
+ if (!event || !owner || event.sessionId !== owner.sessionId) return;
29
+ try {
30
+ if (owner.manager.getSessionId() !== owner.sessionId || owner.manager.getSessionFile() !== owner.file) return;
31
+ const change = rows.accept(event, now());
32
+ if (!change) return;
33
+ for (const id of change.removed) tree.remove(id);
34
+ tree.add(change.node);
35
+ } catch { /* malformed/local UI contributions never affect memory or orchestration */ }
36
+ });
37
+ return { start(ctx) { stop(); owner = { manager: ctx.sessionManager, sessionId: ctx.sessionManager.getSessionId(), file: ctx.sessionManager.getSessionFile() }; }, stop };
38
+ }
@@ -66,6 +66,10 @@ export type BuildEngine = (
66
66
  * `false`: there the SDK's own main-model recovery is the ONE reroute, so an arbitrary
67
67
  * provider must not be substituted underneath a member the user pinned. */
68
68
  providerFallback?: boolean;
69
+ /** Trusted companion worker definition, scoped to this engine build; never installed/discovered. */
70
+ agentOverride?: AgentConfig;
71
+ /** Internal tool-less maintenance may revoke communication even for a coaching persona. */
72
+ canUseBus?: false;
69
73
  },
70
74
  ) => StrategyEngine;
71
75
 
@@ -76,7 +80,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
76
80
  engineFactories, makeBrokerDeps, userAgentDir, childPiSettingsEnv, runLimits: RUN_LIMITS,
77
81
  bus, supervisorHandle: SUPERVISOR, onFallback, resultArchive,
78
82
  } = d();
79
- const resolveAgent = (n: string): AgentConfig | undefined => agents.find((a) => a.name === n);
83
+ const resolveAgent = (n: string): AgentConfig | undefined => engOpts?.agentOverride?.name === n ? engOpts.agentOverride : agents.find((a) => a.name === n);
80
84
  // A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
81
85
  const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
82
86
  const contractNames = (): string[] => installedContractNames(contractDefs);
@@ -125,7 +129,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
125
129
  // A child-process engine pinned to a specific cwd — the seam worktree isolation runs
126
130
  // through (a worktree needs its own working dir, i.e. a separate process).
127
131
  const childEngineAt = (cwd: string): StrategyEngine => {
128
- const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, cwd };
132
+ const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, cwd, memoryPersona: persona ?? null };
129
133
  deps.listAgents = () => agents.map((a) => a.name);
130
134
  deps.listContracts = contractNames;
131
135
  if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
@@ -148,7 +152,8 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
148
152
  // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
149
153
  // only for async runs — same guards as the inproc engine below (spec B7 / §4.9).
150
154
  const caps = controller.capabilities;
151
- if (caps) deps.canUseBus = caps.canUseBus;
155
+ if (engOpts?.canUseBus === false) deps.canUseBus = false;
156
+ else if (caps) deps.canUseBus = caps.canUseBus;
152
157
  if (engOpts?.async) deps.allowBlocking = true;
153
158
  return engineFactories.makeEngine(deps);
154
159
  };
@@ -157,7 +162,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
157
162
  let base: StrategyEngine;
158
163
  if (config.engine === "inproc" && lastCtx) {
159
164
  if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=inproc\n");
160
- const ideps: InProcessDeps = { resolveAgent, contracts, modelFor, childThinking, modelRegistry: lastCtx.modelRegistry, cwd: lastCtx.cwd, agentDir: userAgentDir() };
165
+ const ideps: InProcessDeps = { resolveAgent, contracts, modelFor, childThinking, modelRegistry: lastCtx.modelRegistry, cwd: lastCtx.cwd, agentDir: userAgentDir(), memoryPersona: persona ?? null };
161
166
  ideps.listAgents = () => agents.map((a) => a.name);
162
167
  ideps.listContracts = contractNames;
163
168
  if (legSpine) ideps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
@@ -176,12 +181,13 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
176
181
  if (controller.activePersona?.coaching) ideps.coaching = true;
177
182
  // Peer messaging obeys the persona's bus capability (canUseBus; Task: sibling peer comm).
178
183
  const caps = controller.capabilities;
179
- if (caps) ideps.canUseBus = caps.canUseBus;
184
+ if (engOpts?.canUseBus === false) ideps.canUseBus = false;
185
+ else if (caps) ideps.canUseBus = caps.canUseBus;
180
186
  if (engOpts?.async) ideps.allowBlocking = true;
181
187
  base = engineFactories.makeInProcessEngine(ideps);
182
188
  } else {
183
189
  if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=child\n");
184
- const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking };
190
+ const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, memoryPersona: persona ?? null };
185
191
  deps.listAgents = () => agents.map((a) => a.name);
186
192
  deps.listContracts = contractNames;
187
193
  if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
@@ -200,7 +206,8 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
200
206
  // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
201
207
  // only for async runs — same guards as the inproc engine above (spec B7 / §4.9).
202
208
  const caps = controller.capabilities;
203
- if (caps) deps.canUseBus = caps.canUseBus;
209
+ if (engOpts?.canUseBus === false) deps.canUseBus = false;
210
+ else if (caps) deps.canUseBus = caps.canUseBus;
204
211
  if (engOpts?.async) deps.allowBlocking = true;
205
212
  base = engineFactories.makeEngine(deps);
206
213
  }
@@ -268,7 +275,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
268
275
  : result;
269
276
  }
270
277
  if (!artifact.ok) {
271
- if (hasOutputArtifact) return result;
278
+ if (hasOutputArtifact) return prependWorktreeCaptureWarning(result);
272
279
  return isolatedWorktreeFailure(spec, `${artifact.error}; the isolated leg returned success without an exportable diff/artifact`);
273
280
  }
274
281
  if (artifact.diff.trim().length > 0) return appendWorktreeArtifact(result, artifact.diff);
@@ -311,6 +318,11 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
311
318
  return appendBlock(result, block);
312
319
  }
313
320
 
321
+ function prependWorktreeCaptureWarning(result: AgentResult): AgentResult {
322
+ const warning = "⚠ Worktree artifact capture failed; the following patch is worker-provided and was not verified against the worktree state.";
323
+ return { ...result, output: result.output.trim() ? `${warning}\n\n${result.output}` : warning };
324
+ }
325
+
314
326
  /** Append a block to a leg's output, never leading with blank lines when there was nothing to
315
327
  * trim — the shape `withChangeReport` and `appendWorktreeArtifact` both need: a leg that
316
328
  * produced no text of its own (an early abort/timeout before any streamed output) still gets a
@@ -36,7 +36,9 @@ import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
36
36
  import type { PersonaConfigStore } from "../persona/config-store.ts";
37
37
  import { TELEMETRY_EVENT_NAME } from "../telemetry/contract.ts";
38
38
  import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
39
- import { isWakeSource, type AgentTree, type AgentNode } from "../ui/agent-tree.ts";
39
+ import { isNonAgentNode, type AgentTree, type AgentNode } from "../ui/agent-tree.ts";
40
+ import type { ActivityIngress } from "./companion.ts";
41
+ import type { MemoryOwnerControl } from "./memory-owner.ts";
40
42
  import { compactInlineText } from "../ui/presentation.ts";
41
43
  import type { FailureKind } from "../orchestration/types.ts";
42
44
  import type { ExocomInstall } from "../exocom/install.ts";
@@ -46,7 +48,9 @@ import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
46
48
 
47
49
  type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind; resultReceipt?: ResultReceipt };
48
50
 
49
- export interface HookHost {
51
+ export interface HookHost {
52
+ readonly companionActivities: ActivityIngress;
53
+ readonly memoryOwner: MemoryOwnerControl;
50
54
  readonly identity: SessionIdentity;
51
55
  readonly results: SessionResults;
52
56
  config: PiPersonaConfig;
@@ -180,7 +184,9 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
180
184
  }
181
185
 
182
186
  // ── lifecycle ─────────────────────────────────────────────────────────────
183
- pi.on("session_start", async (_event, ctx) => {
187
+ pi.on("session_start", async (_event, ctx) => {
188
+ const memoryOwnerReady = h.memoryOwner.start(ctx.sessionManager);
189
+ h.companionActivities.start(ctx);
184
190
  clearInFlight();
185
191
  h.lastCtx = ctx;
186
192
  h.results.activate(ctx);
@@ -273,16 +279,19 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
273
279
  // published — a session start that follows no teardown carries no stale projection.
274
280
  h.lastAgentProjection.clear();
275
281
  for (const node of h.agentTree.snapshot()) {
276
- if (isWakeSource(node)) continue; // timer/monitor rows are not agents: no replay, no projection
282
+ if (isNonAgentNode(node)) continue; // local activity/wake rows are not agent telemetry
277
283
  h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
278
284
  h.telemetry.publishAgentAdded(h.telemetryAgent(node));
279
285
  }
280
- exocom.publishTelemetryPeers();
281
- }
282
- }
283
- });
286
+ exocom.publishTelemetryPeers();
287
+ }
288
+ }
289
+ memoryOwnerReady();
290
+ });
284
291
 
285
- pi.on("session_shutdown", async (event, ctx) => {
292
+ pi.on("session_shutdown", async (event, ctx) => {
293
+ h.memoryOwner.stop();
294
+ h.companionActivities.stop(false); // revoke now; the single tree.clear below owns the final repaint
286
295
  clearInFlight();
287
296
  h.lastCtx = ctx;
288
297
  recordLostWork(ctx); // before the notifiers/timers/runs below are torn down
@@ -0,0 +1,54 @@
1
+ /** Live supervisor selection and immutable per-leg inheritance over the current local host bus. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { MEMORY_OWNER_EVENT, MEMORY_OWNER_CHANGED_EVENT, type CompanionMemoryOwner } from "../core/companion.ts";
4
+
5
+ type Manager = ExtensionContext["sessionManager"];
6
+ export interface MemoryOwnerControl {
7
+ /** Returns a readiness callback bound to this exact activation, revoked by start/stop. */
8
+ start(manager: Manager): () => void;
9
+ changed(): void;
10
+ stop(): void;
11
+ }
12
+
13
+ export function installMemoryOwner(pi: Pick<ExtensionAPI, "events">, persona: () => string | null): MemoryOwnerControl {
14
+ let binding: { manager: Manager; sessionId: string; file: string | undefined; ready: boolean } | undefined;
15
+ const read = (sessionId: string): CompanionMemoryOwner => {
16
+ const unavailable: CompanionMemoryOwner = { version: 1, provider: "pi-persona", sessionId, ready: false, persona: null };
17
+ try {
18
+ if (!binding?.ready || sessionId !== binding.sessionId || binding.manager.getSessionId() !== sessionId || binding.manager.getSessionFile() !== binding.file) return unavailable;
19
+ return { ...unavailable, ready: true, persona: persona() };
20
+ } catch { return unavailable; }
21
+ };
22
+ const notify = (): void => {
23
+ if (!binding) return;
24
+ try { pi.events.emit(MEMORY_OWNER_CHANGED_EVENT, read(binding.sessionId)); } catch { /* observer-only */ }
25
+ };
26
+ pi.events.on(MEMORY_OWNER_EVENT, (request: unknown) => {
27
+ if (!request || typeof request !== "object" || Array.isArray(request)) return;
28
+ const query = request as Record<string, unknown>;
29
+ if (query.version !== 1 || typeof query.sessionId !== "string" || !query.sessionId || query.sessionId.length > 256 || /[\s\u0000-\u001f\u007f]/u.test(query.sessionId) || typeof query.respond !== "function" || Reflect.ownKeys(query).some((key) => key !== "version" && key !== "sessionId" && key !== "respond")) return;
30
+ try { query.respond(read(query.sessionId)); } catch { /* a caller cannot break Persona */ }
31
+ });
32
+ const api: MemoryOwnerControl = {
33
+ start(manager) {
34
+ api.stop();
35
+ const captured = { manager, sessionId: manager.getSessionId(), file: manager.getSessionFile(), ready: false };
36
+ binding = captured;
37
+ return () => { if (binding === captured) { captured.ready = true; notify(); } };
38
+ },
39
+ changed() { if (binding?.ready) notify(); },
40
+ stop() {
41
+ const previous = binding;
42
+ binding = undefined;
43
+ if (previous) try { pi.events.emit(MEMORY_OWNER_CHANGED_EVENT, { version: 1, provider: "pi-persona", sessionId: previous.sessionId, ready: false, persona: null } satisfies CompanionMemoryOwner); } catch { /* observer-only */ }
44
+ },
45
+ };
46
+ return api;
47
+ }
48
+
49
+ /** A factory closed over this child's actual manager: no mutable process-global persona state. */
50
+ export function installFrozenMemoryOwner(pi: Pick<ExtensionAPI, "events" | "on">, manager: Manager, persona: string | null): void {
51
+ const owner = installMemoryOwner(pi, () => persona);
52
+ owner.start(manager)();
53
+ pi.on("session_shutdown", () => owner.stop());
54
+ }