@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/AGENTS.md +13 -0
- package/CHANGELOG.md +30 -0
- package/README.md +11 -5
- package/docs/ARCHITECTURE.md +109 -18
- package/docs/STRATEGIES.md +6 -0
- package/package.json +2 -2
- package/src/bridge.ts +8 -15
- package/src/bus/broker/client.ts +32 -9
- package/src/bus/broker/host.ts +7 -3
- package/src/bus/broker/messages.ts +4 -1
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +10 -7
- package/src/bus/peers.ts +8 -6
- package/src/core/companion.ts +64 -0
- package/src/engine/adapter.ts +18 -2
- package/src/engine/async.ts +4 -1
- package/src/engine/child.ts +1 -0
- package/src/engine/inproc.ts +11 -1
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +20 -8
- package/src/extension/hooks.ts +18 -9
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +14 -12
- package/src/extension.ts +117 -134
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-run.ts +4 -1
- package/src/orchestration/sdk.ts +19 -15
- package/src/persona/orchestrate.ts +4 -7
- package/src/telemetry/contract.ts +1 -1
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +13 -11
- package/src/ui/agent-tree.ts +30 -11
- package/src/ui/strategy-leg.ts +40 -0
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 {
|
|
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:
|
|
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>,
|
|
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
|
|
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
|
|
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
|
-
: `
|
|
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
|
+
}
|
package/src/engine/adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/engine/async.ts
CHANGED
|
@@ -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.
|
package/src/engine/child.ts
CHANGED
|
@@ -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(),
|
package/src/engine/inproc.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/extension/engine.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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
|
package/src/extension/hooks.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
+
}
|