@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.
- package/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- package/src/ui/strategy-leg.ts +40 -0
package/src/exocom/plane.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { createFrameReader, encodeFrame } from "../bus/broker/framing.ts";
|
|
|
33
33
|
import { frameSigningPayload, isExocomFrame, isSemanticFrame, nextHops, parseExocomArtifactDescriptor, truncateForInject, type AgentCard, type ExocomAck, type ExocomArtifactDescriptor, type ExocomBye, type ExocomFrame, type ExocomMessage, type ExocomNack, type ExocomSemanticFrame } from "./envelope.ts";
|
|
34
34
|
import { EXOCOM } from "./limits.ts";
|
|
35
35
|
import { exocomRoot } from "./paths.ts";
|
|
36
|
+
import { normalizeExocomProfile, type ExocomProfile } from "./profile.ts";
|
|
36
37
|
import { isAlive, normalizePeerName, prune, readAll, removeEntryIfMatches, writeEntry, type RegistryEntry, type RegistryOwnership } from "./registry.ts";
|
|
37
38
|
|
|
38
39
|
export interface ExocomIdentity {
|
|
@@ -398,6 +399,9 @@ export class ExocomPlane {
|
|
|
398
399
|
private server: net.Server | undefined;
|
|
399
400
|
private readonly unlinkEndpoint: () => void;
|
|
400
401
|
private endpointOwned = false;
|
|
402
|
+
private profile: ExocomProfile | undefined;
|
|
403
|
+
/** Last successfully published metadata, so editing a profile cannot restore startup labels. */
|
|
404
|
+
private presence: RegistryEntry | undefined;
|
|
401
405
|
/** Ownership of the registry entry successfully written by this plane. A plane
|
|
402
406
|
* may share a session_id with a replacement, so session_id alone is not enough
|
|
403
407
|
* to clean up safely. */
|
|
@@ -443,6 +447,17 @@ export class ExocomPlane {
|
|
|
443
447
|
return normalizePeerName(this.deps.identity.name);
|
|
444
448
|
}
|
|
445
449
|
|
|
450
|
+
/** The plane owns this transient, optional self-report for its live session. */
|
|
451
|
+
getProfile(): ExocomProfile | undefined {
|
|
452
|
+
return this.profile ? { ...this.profile, capabilities: [...this.profile.capabilities] } : undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Update the live registry immediately; `undefined` withdraws the entire self-report. */
|
|
456
|
+
setProfile(profile: ExocomProfile | undefined): void {
|
|
457
|
+
this.profile = profile === undefined ? undefined : normalizeExocomProfile(profile);
|
|
458
|
+
if (this.registeredOwnership) this.heartbeat({ ...(this.presence ?? this.buildEntry()), heartbeat_at: new Date(this.now()).toISOString() });
|
|
459
|
+
}
|
|
460
|
+
|
|
446
461
|
/** Messages this instance has successfully `send()`'d TO the given peer (ack settled), keyed by
|
|
447
462
|
* the peer's session_id — 0 for a peer never sent to. The plane is the SINGLE source of these
|
|
448
463
|
* counters — the widget reads them, it never keeps its own. */
|
|
@@ -480,6 +495,7 @@ export class ExocomPlane {
|
|
|
480
495
|
prune(agentDir, hash, { now: this.now(), staleMs: EXOCOM.STALE_AFTER_MS });
|
|
481
496
|
const entry = this.buildEntry();
|
|
482
497
|
writeEntry(agentDir, hash, entry);
|
|
498
|
+
this.presence = entry;
|
|
483
499
|
this.registeredOwnership = {
|
|
484
500
|
session_id: entry.session_id,
|
|
485
501
|
endpoint: entry.endpoint,
|
|
@@ -524,6 +540,7 @@ export class ExocomPlane {
|
|
|
524
540
|
workspace_code: identity.workspace.code,
|
|
525
541
|
workspace_label: identity.workspace.label,
|
|
526
542
|
} : {}),
|
|
543
|
+
...(this.profile ? { profile: this.getProfile()! } : {}),
|
|
527
544
|
};
|
|
528
545
|
}
|
|
529
546
|
|
|
@@ -535,8 +552,15 @@ export class ExocomPlane {
|
|
|
535
552
|
* key exists only in this process. `session_id` is ours by construction, never the caller's. */
|
|
536
553
|
heartbeat(entry: Omit<RegistryEntry, "public_key">): void {
|
|
537
554
|
const { agentDir, hash, identity } = this.deps;
|
|
538
|
-
const
|
|
555
|
+
const { profile: _callerProfile, ...entryWithoutProfile } = entry;
|
|
556
|
+
const owned = {
|
|
557
|
+
...entryWithoutProfile,
|
|
558
|
+
session_id: identity.session_id,
|
|
559
|
+
public_key: this.publicKey,
|
|
560
|
+
...(this.profile ? { profile: this.getProfile()! } : {}),
|
|
561
|
+
};
|
|
539
562
|
writeEntry(agentDir, hash, owned);
|
|
563
|
+
this.presence = owned;
|
|
540
564
|
this.registeredOwnership = { session_id: owned.session_id, endpoint: owned.endpoint, public_key: owned.public_key };
|
|
541
565
|
}
|
|
542
566
|
|
|
@@ -864,6 +888,11 @@ export class ExocomPlane {
|
|
|
864
888
|
return;
|
|
865
889
|
}
|
|
866
890
|
case "claim":
|
|
891
|
+
case "task_create":
|
|
892
|
+
case "task_take":
|
|
893
|
+
case "task_finish":
|
|
894
|
+
case "offer":
|
|
895
|
+
case "publish":
|
|
867
896
|
case "ask":
|
|
868
897
|
case "answer":
|
|
869
898
|
case "progress":
|
|
@@ -1112,5 +1141,7 @@ export class ExocomPlane {
|
|
|
1112
1141
|
// spill. In a scope where Exocom is never enabled again, that is never: sweeping on the
|
|
1113
1142
|
// way out bounds the retention without touching anything still inside its TTL.
|
|
1114
1143
|
this.cleanupArtifacts();
|
|
1144
|
+
this.profile = undefined;
|
|
1145
|
+
this.presence = undefined;
|
|
1115
1146
|
}
|
|
1116
1147
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Self-reported presence only; these fields carry no identity or authorization. */
|
|
2
|
+
export interface ExocomProfile {
|
|
3
|
+
capabilities: readonly string[];
|
|
4
|
+
availability?: "available" | "busy" | "away";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const UNSAFE_PROFILE_TEXT = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u206f<>]/g;
|
|
8
|
+
const AVAILABILITIES = new Set(["available", "busy", "away"]);
|
|
9
|
+
|
|
10
|
+
function normalizeCapability(value: unknown): string | undefined {
|
|
11
|
+
if (typeof value !== "string") return undefined;
|
|
12
|
+
const clean = Array.from(value.normalize("NFKC").replace(UNSAFE_PROFILE_TEXT, " ").replace(/\s+/g, " ").trim())
|
|
13
|
+
.slice(0, 48).join("");
|
|
14
|
+
return clean || undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalize untrusted registry or tool data into the bounded, display-safe profile contract. */
|
|
18
|
+
export function normalizeExocomProfile(value: unknown): ExocomProfile | undefined {
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
20
|
+
const input = value as Record<string, unknown>;
|
|
21
|
+
const capabilities: string[] = [];
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
if (Array.isArray(input.capabilities)) {
|
|
24
|
+
for (const value of input.capabilities) {
|
|
25
|
+
const capability = normalizeCapability(value);
|
|
26
|
+
if (!capability || seen.has(capability.toLowerCase())) continue;
|
|
27
|
+
seen.add(capability.toLowerCase());
|
|
28
|
+
capabilities.push(capability);
|
|
29
|
+
if (capabilities.length === 8) break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const availability = typeof input.availability === "string" && AVAILABILITIES.has(input.availability)
|
|
33
|
+
? input.availability as ExocomProfile["availability"]
|
|
34
|
+
: undefined;
|
|
35
|
+
if (capabilities.length === 0 && availability === undefined) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
capabilities,
|
|
38
|
+
...(availability ? { availability } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/exocom/registry.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync, lstatSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { agentsDir, registryPath } from "./paths.ts";
|
|
5
|
+
import { normalizeExocomProfile, type ExocomProfile } from "./profile.ts";
|
|
5
6
|
|
|
6
7
|
export interface RegistryEntry {
|
|
7
8
|
/** `name` is display-only — NOT unique, NOT a lookup key. The registry FILE is keyed by
|
|
@@ -16,6 +17,8 @@ export interface RegistryEntry {
|
|
|
16
17
|
workspace_id?: string;
|
|
17
18
|
workspace_code?: string;
|
|
18
19
|
workspace_label?: string;
|
|
20
|
+
/** Optional self-reported presence; it is descriptive and never grants capability. */
|
|
21
|
+
profile?: ExocomProfile;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** The fields that prove which process owns a registry slot. `session_id` is the
|
|
@@ -57,6 +60,7 @@ export function normalizeRegistryEntry(value: unknown): RegistryEntry | undefine
|
|
|
57
60
|
const contextPct = typeof e.context_pct === "number" && Number.isFinite(e.context_pct) ? Math.max(0, Math.min(100, e.context_pct)) : 0;
|
|
58
61
|
const inbox = typeof e.inbox === "number" && Number.isFinite(e.inbox) ? Math.max(0, Math.min(1_000_000, Math.floor(e.inbox))) : 0;
|
|
59
62
|
const publicKey = opaqueString(e.public_key, 256);
|
|
63
|
+
const profile = normalizeExocomProfile(e.profile);
|
|
60
64
|
const hasWorkspaceMetadata = e.workspace_id !== undefined || e.workspace_code !== undefined || e.workspace_label !== undefined;
|
|
61
65
|
let workspace: Pick<RegistryEntry, "workspace_id" | "workspace_code" | "workspace_label"> = {};
|
|
62
66
|
if (hasWorkspaceMetadata) {
|
|
@@ -83,6 +87,7 @@ export function normalizeRegistryEntry(value: unknown): RegistryEntry | undefine
|
|
|
83
87
|
inbox,
|
|
84
88
|
heartbeat_at: new Date(Date.parse(heartbeatAt)).toISOString(),
|
|
85
89
|
...(publicKey && /^[A-Za-z0-9+/]+={0,2}$/.test(publicKey) ? { public_key: publicKey } : {}),
|
|
90
|
+
...(profile ? { profile } : {}),
|
|
86
91
|
...workspace,
|
|
87
92
|
};
|
|
88
93
|
}
|
package/src/exocom/status.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { LedgerAsk, LedgerClaim, LedgerState } from "./ledger.ts";
|
|
|
20
20
|
import { formatDuration } from "../core/time.ts";
|
|
21
21
|
import { fencePeer } from "../core/fence.ts";
|
|
22
22
|
import { renderWriteSet, untrusted, UNTRUSTED_MAX } from "./untrusted.ts";
|
|
23
|
+
import { collaborationOfferStatus, collaborationStatus } from "./collaboration.ts";
|
|
23
24
|
|
|
24
25
|
/** No section may hand the model an unbounded ledger — a long-lived scope with many claims stays
|
|
25
26
|
* one bounded read, same discipline as the pending-ask block and the peer roster. */
|
|
@@ -72,8 +73,12 @@ export function formatLedgerStatus(
|
|
|
72
73
|
const peerClaims = state.claims.filter((claim) => claim.from_session !== self).sort(byTsAscending);
|
|
73
74
|
const asksForYou = state.asks.filter((ask) => ask.to_session === self).sort(byTsAscending);
|
|
74
75
|
const yourAsks = state.asks.filter((ask) => ask.from_session === self).sort(byTsAscending);
|
|
76
|
+
const collaboration = state.collaboration;
|
|
77
|
+
const tasks = collaboration?.tasks ?? [];
|
|
78
|
+
const offers = collaboration?.offers ?? [];
|
|
79
|
+
const evidence = collaboration?.evidence ?? [];
|
|
75
80
|
|
|
76
|
-
if (ownClaims.length === 0 && peerClaims.length === 0 && asksForYou.length === 0 && yourAsks.length === 0) {
|
|
81
|
+
if (ownClaims.length === 0 && peerClaims.length === 0 && asksForYou.length === 0 && yourAsks.length === 0 && tasks.length === 0 && offers.length === 0 && evidence.length === 0) {
|
|
77
82
|
return "nothing claimed or pending";
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -82,5 +87,10 @@ export function formatLedgerStatus(
|
|
|
82
87
|
section("peer claims", peerClaims.map((claim) => claimRow(claim, self, labelFor, now)), { fence: true }),
|
|
83
88
|
section("asks waiting for you", asksForYou.map((ask) => askRow(ask, ask.from_session, labelFor, now)), { fence: true }),
|
|
84
89
|
section("your open asks", yourAsks.map((ask) => askRow(ask, ask.to_session, labelFor, now))),
|
|
90
|
+
...(tasks.length ? [section("shared tasks (exocom_task list for detail)", tasks.map((task) =>
|
|
91
|
+
`${task.created.work_key} · ${collaborationStatus(task)} · ${resolveLabel(task.taken?.from_session ?? task.created.from_session, labelFor)}`), { fence: true })] : []),
|
|
92
|
+
...(offers.length ? [section("optional invitations", offers.map((offer) =>
|
|
93
|
+
`${offer.offer_id} · task ${offer.work_key} · ${collaborationOfferStatus(offer, tasks, now)} · ${offer.to_session ? resolveLabel(offer.to_session, labelFor) : "open invitation"}`), { fence: true })] : []),
|
|
94
|
+
...(evidence.length ? [`${evidence.length} retained evidence excerpt(s); discover with exocom_publish { action: "read", work_key: "<task>" }.`] : []),
|
|
85
95
|
].join("\n\n");
|
|
86
96
|
}
|
|
@@ -64,6 +64,13 @@ export function writeWarningReason(path: string, claim: LedgerClaim, label: stri
|
|
|
64
64
|
return `exocom: ${path} is inside ${who}'s open claim — coordinate with exocom_ask or claim it; call the tool again to proceed (this warning shows once per path)\n${fencePeer(`${slice}: ${writeSet}`)}`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The operator-TOAST variant of the same warning: its single authoritative line, sanitized and
|
|
68
|
+
* bounded. The fenced peer-authored detail below the newline stays model-facing (the tool
|
|
69
|
+
* result); a toast is one line and never carries the claim block. */
|
|
70
|
+
export function writeWarningToastReason(path: string, claim: LedgerClaim, label: string): string {
|
|
71
|
+
return untrusted(writeWarningReason(path, claim, label).split("\n")[0] ?? "", 300);
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
/** Warn-once memory for the write guard. A bare unbounded set would grow for the life of the
|
|
68
75
|
* install on a long session with many distinct claim+path pairs; caps at `maxEntries` (like the
|
|
69
76
|
* sibling `SeenMessages` in guards.ts) and drops the oldest insertion first once exceeded. */
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Supervisor-side broker lifecycle (spec B1-B7): the ONE cross-process host every child-engine
|
|
3
3
|
* leg connects to. Started lazily on the first child-engine build; a failed bind is reported to
|
|
4
4
|
* the user ONCE, children built while it is failed spawn without a bus endpoint (so they never
|
|
5
|
-
* burn the connect backoff against a dead socket), and the next build retries.
|
|
5
|
+
* burn the connect backoff against a dead socket), and the next build retries. Result readers
|
|
6
|
+
* use the separate strict awaited preflight on this SAME host, never bus-less degradation.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { mkdirSync } from "node:fs";
|
|
@@ -11,6 +12,7 @@ import { dirname } from "node:path";
|
|
|
11
12
|
import type { EngineAdapterBroker } from "../engine/adapter.ts";
|
|
12
13
|
import { type BrokerHost, startBrokerHost } from "../bus/broker/host.ts";
|
|
13
14
|
import type { InProcessBus } from "../bus/inproc.ts";
|
|
15
|
+
import { RESULT_READ_TIMEOUT_MS, type PreparedResultReader, type ScopedReader } from "../bus/result-reader.ts";
|
|
14
16
|
|
|
15
17
|
export interface SupervisorBrokerDeps {
|
|
16
18
|
bus: InProcessBus;
|
|
@@ -49,6 +51,8 @@ export class SupervisorBroker {
|
|
|
49
51
|
private readonly expected = new Set<string>();
|
|
50
52
|
private readonly preHostSteers = new Map<string, string[]>();
|
|
51
53
|
private readonly deps: SupervisorBrokerDeps;
|
|
54
|
+
private generation = 0;
|
|
55
|
+
private readonly preparingReaders = new Set<() => void>();
|
|
52
56
|
|
|
53
57
|
constructor(deps: SupervisorBrokerDeps) {
|
|
54
58
|
this.deps = deps;
|
|
@@ -82,6 +86,7 @@ export class SupervisorBroker {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
const start = this.deps.startHost ?? startBrokerHost;
|
|
89
|
+
const generation = this.generation;
|
|
85
90
|
this.promise = start({
|
|
86
91
|
bus: this.deps.bus,
|
|
87
92
|
supervisorHandle: this.deps.supervisorHandle,
|
|
@@ -90,6 +95,7 @@ export class SupervisorBroker {
|
|
|
90
95
|
});
|
|
91
96
|
this.promise.then(
|
|
92
97
|
(h) => {
|
|
98
|
+
if (generation !== this.generation) return; // close() owns the late host's teardown
|
|
93
99
|
this.hostRef = h;
|
|
94
100
|
this.lastError = undefined;
|
|
95
101
|
this.warned = false;
|
|
@@ -98,6 +104,7 @@ export class SupervisorBroker {
|
|
|
98
104
|
this.preHostSteers.clear();
|
|
99
105
|
},
|
|
100
106
|
(err) => {
|
|
107
|
+
if (generation !== this.generation) return;
|
|
101
108
|
this.promise = undefined; // a later build retries
|
|
102
109
|
this.lastError = err instanceof Error ? err.message : String(err);
|
|
103
110
|
const dropped = [...this.preHostSteers.values()].reduce((n, texts) => n + texts.length, 0);
|
|
@@ -112,6 +119,46 @@ export class SupervisorBroker {
|
|
|
112
119
|
);
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
/** Strict reader preflight, unlike ordinary bus-less degradation. Call ensure(endpoint)
|
|
123
|
+
* first, then await this BEFORE adapter registration/spawn. The owner must revoke on
|
|
124
|
+
* settlement/spawn failure and close this broker when the owning session changes. */
|
|
125
|
+
async prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader> {
|
|
126
|
+
const generation = this.generation;
|
|
127
|
+
let revoked = false;
|
|
128
|
+
const revoke = (): void => {
|
|
129
|
+
if (revoked) return;
|
|
130
|
+
revoked = true;
|
|
131
|
+
try { reader.revoke(); } catch { /* fixed diagnostic, even if cleanup throws */ }
|
|
132
|
+
};
|
|
133
|
+
const fail = (): Error => new Error("Result reader preparation failed.");
|
|
134
|
+
const hostPromise = this.promise;
|
|
135
|
+
if (!hostPromise || signal?.aborted) { revoke(); throw fail(); }
|
|
136
|
+
return new Promise<PreparedResultReader>((resolve, reject) => {
|
|
137
|
+
let settled = false;
|
|
138
|
+
const cleanup = (): void => {
|
|
139
|
+
settled = true;
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
this.preparingReaders.delete(cancel);
|
|
142
|
+
signal?.removeEventListener("abort", cancel);
|
|
143
|
+
};
|
|
144
|
+
const cancel = (): void => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
revoke(); cleanup(); reject(fail());
|
|
147
|
+
};
|
|
148
|
+
const timer = setTimeout(cancel, RESULT_READ_TIMEOUT_MS);
|
|
149
|
+
timer.unref?.();
|
|
150
|
+
this.preparingReaders.add(cancel);
|
|
151
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
152
|
+
void hostPromise.then(async (host) => {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
const grant = await host.prepareResultReader(handle, { read: (request, s) => reader.read(request, s), revoke }, signal);
|
|
155
|
+
if (settled || generation !== this.generation || signal?.aborted) { grant.revoke(); cancel(); return; }
|
|
156
|
+
cleanup();
|
|
157
|
+
resolve(grant);
|
|
158
|
+
}).catch(cancel);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
/** The adapter-facing deps, or undefined while the last start failed (children spawn bus-less). */
|
|
116
163
|
adapterDeps(endpoint: string): EngineAdapterBroker | undefined {
|
|
117
164
|
const failedBefore = this.state() === "failed";
|
|
@@ -141,6 +188,7 @@ export class SupervisorBroker {
|
|
|
141
188
|
return true;
|
|
142
189
|
},
|
|
143
190
|
hasPendingAskFrom: (handle) => this.deps.bus.hasPendingAskFrom(handle),
|
|
191
|
+
prepareResultReader: (handle, reader, signal) => this.prepareResultReader(handle, reader, signal),
|
|
144
192
|
};
|
|
145
193
|
}
|
|
146
194
|
|
|
@@ -164,18 +212,22 @@ export class SupervisorBroker {
|
|
|
164
212
|
}
|
|
165
213
|
|
|
166
214
|
async close(): Promise<void> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
} catch {
|
|
172
|
-
/* best-effort — never block shutdown on a broker teardown error */
|
|
173
|
-
}
|
|
174
|
-
}
|
|
215
|
+
this.generation++;
|
|
216
|
+
for (const cancel of this.preparingReaders) cancel();
|
|
217
|
+
const host = this.hostRef;
|
|
218
|
+
const pending = this.promise;
|
|
175
219
|
this.hostRef = undefined;
|
|
176
220
|
this.promise = undefined;
|
|
221
|
+
this.lastError = undefined;
|
|
177
222
|
this.peers.clear();
|
|
178
223
|
this.expected.clear();
|
|
179
224
|
this.preHostSteers.clear();
|
|
225
|
+
try {
|
|
226
|
+
// Invoke immediately: bound readers revoke before the first await.
|
|
227
|
+
if (host) await host.close();
|
|
228
|
+
else if (pending) await (await pending).close();
|
|
229
|
+
} catch {
|
|
230
|
+
/* best-effort — never block shutdown on a broker teardown error */
|
|
231
|
+
}
|
|
180
232
|
}
|
|
181
233
|
}
|
|
@@ -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
|
@@ -21,6 +21,7 @@ import { emptyUsage, type ProgressSnapshot } from "../engine/stream.ts";
|
|
|
21
21
|
import type { AgentRunSpec, StrategyEngine } from "../orchestration/sdk.ts";
|
|
22
22
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
23
23
|
import type { InProcessBus } from "../bus/inproc.ts";
|
|
24
|
+
import type { ResultArchiveView } from "../results/retrieval.ts";
|
|
24
25
|
|
|
25
26
|
export interface EngineFactories {
|
|
26
27
|
makeEngine: typeof makeEngine;
|
|
@@ -45,6 +46,10 @@ export interface BuildEngineDeps {
|
|
|
45
46
|
runLimits: RunLimits;
|
|
46
47
|
bus: InProcessBus;
|
|
47
48
|
supervisorHandle: string;
|
|
49
|
+
/** The live terminal-result archive (a GETTER: the owning session can change across runs).
|
|
50
|
+
* Feeds both engines' reference-handoff preflight + scoped `read_result` readers.
|
|
51
|
+
* Absent ⇒ reference-handoff legs fail closed on both engines (no archive to page). */
|
|
52
|
+
resultArchive?: () => ResultArchiveView | undefined;
|
|
48
53
|
/** Breadcrumb for a provider reroute (toast + log); absent ⇒ silent, as before. */
|
|
49
54
|
onFallback?: (info: { from: string; to: string; agent: string }) => void;
|
|
50
55
|
/** Injected git runner for the non-worktree files-changed report (tests only); defaults to
|
|
@@ -61,6 +66,10 @@ export type BuildEngine = (
|
|
|
61
66
|
* `false`: there the SDK's own main-model recovery is the ONE reroute, so an arbitrary
|
|
62
67
|
* provider must not be substituted underneath a member the user pinned. */
|
|
63
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;
|
|
64
73
|
},
|
|
65
74
|
) => StrategyEngine;
|
|
66
75
|
|
|
@@ -69,9 +78,9 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
69
78
|
const {
|
|
70
79
|
agents, contractDefs, controller, host, config, personaConfigs, lastCtx, workerSpineText,
|
|
71
80
|
engineFactories, makeBrokerDeps, userAgentDir, childPiSettingsEnv, runLimits: RUN_LIMITS,
|
|
72
|
-
bus, supervisorHandle: SUPERVISOR, onFallback,
|
|
81
|
+
bus, supervisorHandle: SUPERVISOR, onFallback, resultArchive,
|
|
73
82
|
} = d();
|
|
74
|
-
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);
|
|
75
84
|
// A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
|
|
76
85
|
const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
|
|
77
86
|
const contractNames = (): string[] => installedContractNames(contractDefs);
|
|
@@ -111,7 +120,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
111
120
|
const getBrokerDeps = (): EngineAdapterBroker | undefined => {
|
|
112
121
|
if (!brokerDepsBuilt) {
|
|
113
122
|
brokerDepsBuilt = true;
|
|
114
|
-
const sessionId =
|
|
123
|
+
const sessionId = lastCtx?.sessionManager.getSessionId();
|
|
115
124
|
if (config.broker && lastCtx && sessionId) brokerDepsMemo = makeBrokerDeps(lastCtx);
|
|
116
125
|
}
|
|
117
126
|
return brokerDepsMemo;
|
|
@@ -120,7 +129,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
120
129
|
// A child-process engine pinned to a specific cwd — the seam worktree isolation runs
|
|
121
130
|
// through (a worktree needs its own working dir, i.e. a separate process).
|
|
122
131
|
const childEngineAt = (cwd: string): StrategyEngine => {
|
|
123
|
-
const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, cwd };
|
|
132
|
+
const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, cwd, memoryPersona: persona ?? null };
|
|
124
133
|
deps.listAgents = () => agents.map((a) => a.name);
|
|
125
134
|
deps.listContracts = contractNames;
|
|
126
135
|
if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
|
|
@@ -137,10 +146,14 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
137
146
|
if (onProgress) deps.childOptions.onProgress = onProgress;
|
|
138
147
|
const brokerDeps = getBrokerDeps();
|
|
139
148
|
if (brokerDeps) deps.broker = brokerDeps;
|
|
149
|
+
// Reference-handoff legs page the owning session's terminal archive (strict preflight
|
|
150
|
+
// + scoped reader); the getter is read at LEG time so a mid-run session switch is seen.
|
|
151
|
+
if (resultArchive !== undefined) deps.resultArchive = resultArchive;
|
|
140
152
|
// Peer messaging obeys the persona's bus capability, and blocking asks are honoured
|
|
141
153
|
// only for async runs — same guards as the inproc engine below (spec B7 / §4.9).
|
|
142
154
|
const caps = controller.capabilities;
|
|
143
|
-
if (
|
|
155
|
+
if (engOpts?.canUseBus === false) deps.canUseBus = false;
|
|
156
|
+
else if (caps) deps.canUseBus = caps.canUseBus;
|
|
144
157
|
if (engOpts?.async) deps.allowBlocking = true;
|
|
145
158
|
return engineFactories.makeEngine(deps);
|
|
146
159
|
};
|
|
@@ -149,7 +162,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
149
162
|
let base: StrategyEngine;
|
|
150
163
|
if (config.engine === "inproc" && lastCtx) {
|
|
151
164
|
if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=inproc\n");
|
|
152
|
-
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 };
|
|
153
166
|
ideps.listAgents = () => agents.map((a) => a.name);
|
|
154
167
|
ideps.listContracts = contractNames;
|
|
155
168
|
if (legSpine) ideps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
|
|
@@ -164,15 +177,17 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
164
177
|
// can't answer, so blocking there would deadlock; the tool downgrades to one-way).
|
|
165
178
|
ideps.bus = bus;
|
|
166
179
|
ideps.supervisorHandle = SUPERVISOR;
|
|
180
|
+
if (resultArchive !== undefined) ideps.resultArchive = resultArchive;
|
|
167
181
|
if (controller.activePersona?.coaching) ideps.coaching = true;
|
|
168
182
|
// Peer messaging obeys the persona's bus capability (canUseBus; Task: sibling peer comm).
|
|
169
183
|
const caps = controller.capabilities;
|
|
170
|
-
if (
|
|
184
|
+
if (engOpts?.canUseBus === false) ideps.canUseBus = false;
|
|
185
|
+
else if (caps) ideps.canUseBus = caps.canUseBus;
|
|
171
186
|
if (engOpts?.async) ideps.allowBlocking = true;
|
|
172
187
|
base = engineFactories.makeInProcessEngine(ideps);
|
|
173
188
|
} else {
|
|
174
189
|
if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=child\n");
|
|
175
|
-
const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking };
|
|
190
|
+
const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, memoryPersona: persona ?? null };
|
|
176
191
|
deps.listAgents = () => agents.map((a) => a.name);
|
|
177
192
|
deps.listContracts = contractNames;
|
|
178
193
|
if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
|
|
@@ -187,10 +202,12 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
187
202
|
if (onProgress) deps.childOptions.onProgress = onProgress;
|
|
188
203
|
const brokerDeps = getBrokerDeps();
|
|
189
204
|
if (brokerDeps) deps.broker = brokerDeps;
|
|
205
|
+
if (resultArchive !== undefined) deps.resultArchive = resultArchive;
|
|
190
206
|
// Peer messaging obeys the persona's bus capability, and blocking asks are honoured
|
|
191
207
|
// only for async runs — same guards as the inproc engine above (spec B7 / §4.9).
|
|
192
208
|
const caps = controller.capabilities;
|
|
193
|
-
if (
|
|
209
|
+
if (engOpts?.canUseBus === false) deps.canUseBus = false;
|
|
210
|
+
else if (caps) deps.canUseBus = caps.canUseBus;
|
|
194
211
|
if (engOpts?.async) deps.allowBlocking = true;
|
|
195
212
|
base = engineFactories.makeEngine(deps);
|
|
196
213
|
}
|
|
@@ -248,9 +265,9 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
248
265
|
const preflight = await worktreePreflight(root, defaultGitExec);
|
|
249
266
|
if (!preflight.ok) return isolatedWorktreeFailure(spec, preflight.error);
|
|
250
267
|
try {
|
|
251
|
-
return await withWorktree(root, defaultGitExec, async (dir) => {
|
|
268
|
+
return await withWorktree(root, defaultGitExec, async (dir, baseCommit) => {
|
|
252
269
|
const result = await childEngineAt(dir).run({ ...spec, isolation: "none" }, perProgress, perSignal, perSteer);
|
|
253
|
-
const artifact = await captureWorktreeArtifact(dir, defaultGitExec);
|
|
270
|
+
const artifact = await captureWorktreeArtifact(dir, baseCommit, defaultGitExec);
|
|
254
271
|
const hasOutputArtifact = hasUnifiedDiff(result.output);
|
|
255
272
|
if (!result.ok) {
|
|
256
273
|
return artifact.ok && artifact.diff.trim().length > 0
|
|
@@ -258,7 +275,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
258
275
|
: result;
|
|
259
276
|
}
|
|
260
277
|
if (!artifact.ok) {
|
|
261
|
-
if (hasOutputArtifact) return result;
|
|
278
|
+
if (hasOutputArtifact) return prependWorktreeCaptureWarning(result);
|
|
262
279
|
return isolatedWorktreeFailure(spec, `${artifact.error}; the isolated leg returned success without an exportable diff/artifact`);
|
|
263
280
|
}
|
|
264
281
|
if (artifact.diff.trim().length > 0) return appendWorktreeArtifact(result, artifact.diff);
|
|
@@ -301,6 +318,11 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
301
318
|
return appendBlock(result, block);
|
|
302
319
|
}
|
|
303
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
|
+
|
|
304
326
|
/** Append a block to a leg's output, never leading with blank lines when there was nothing to
|
|
305
327
|
* trim — the shape `withChangeReport` and `appendWorktreeArtifact` both need: a leg that
|
|
306
328
|
* produced no text of its own (an early abort/timeout before any streamed output) still gets a
|
|
@@ -314,38 +336,27 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
314
336
|
// The host's model-level availability snapshot, plus the exact active model. A provider's
|
|
315
337
|
// credentials do not grant every model in its catalog. Extension-native bridges can be
|
|
316
338
|
// absent from an auth snapshot: restore only their explicit registered model lists in that
|
|
317
|
-
// case, never every inherited model under their provider id.
|
|
318
|
-
// availability API retain their historical getAll() fallback.
|
|
339
|
+
// case, never every inherited model under their provider id.
|
|
319
340
|
export function configuredModels(ctx: ExtensionContext): ModelCostMetadata[] {
|
|
320
341
|
const reg = ctx.modelRegistry;
|
|
321
342
|
const all = reg.getAll();
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
available = reg.getAvailable();
|
|
325
|
-
} catch {
|
|
326
|
-
return all;
|
|
327
|
-
}
|
|
343
|
+
const available = reg.getAvailable();
|
|
328
344
|
const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
|
|
329
345
|
if (ctx.model) byRef.set(`${ctx.model.provider}\0${ctx.model.id}`, ctx.model);
|
|
330
346
|
const availableProviders = new Set(available.map((m) => m.provider));
|
|
331
347
|
const declaredRefs = new Set<string>();
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
registered = reg.getRegisteredProviderIds?.() ?? [];
|
|
335
|
-
} catch {
|
|
336
|
-
/* a third-party/older registry facade may not expose extension provider ids */
|
|
337
|
-
}
|
|
348
|
+
const registered = reg.getRegisteredProviderIds();
|
|
338
349
|
for (const provider of registered) {
|
|
339
350
|
// A populated snapshot can intentionally exclude individual models for this credential.
|
|
340
351
|
if (availableProviders.has(provider)) continue;
|
|
341
352
|
let native: ReturnType<typeof reg.getRegisteredNativeProvider>;
|
|
342
353
|
try {
|
|
343
|
-
native = reg.getRegisteredNativeProvider
|
|
354
|
+
native = reg.getRegisteredNativeProvider(provider);
|
|
344
355
|
// An empty credential-filtered list is authoritative too; it need not be a stale snapshot.
|
|
345
|
-
if (reg.getProvider
|
|
356
|
+
if (reg.getProvider(provider)?.filterModels || native?.filterModels) continue;
|
|
346
357
|
} catch { continue; }
|
|
347
358
|
try {
|
|
348
|
-
const models = reg.getRegisteredProviderConfig
|
|
359
|
+
const models = reg.getRegisteredProviderConfig(provider)?.models;
|
|
349
360
|
if (Array.isArray(models)) {
|
|
350
361
|
for (const model of models) {
|
|
351
362
|
if ((model.type === undefined || model.type === "chat") && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
|