@aeondave/pi-persona 1.17.3 → 1.17.4
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 +44 -36
- package/CHANGELOG.md +76 -1
- package/README.md +23 -13
- package/docs/ARCHITECTURE.md +97 -37
- 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/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +25 -8
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- 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/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 +83 -19
- package/src/engine/async.ts +49 -10
- package/src/engine/child.ts +10 -2
- package/src/engine/inproc.ts +87 -46
- 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/engine.ts +20 -21
- package/src/extension/hooks.ts +952 -938
- package/src/extension/identity.ts +41 -75
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2963 -2873
- package/src/loader.ts +11 -3
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +122 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +44 -6
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- 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/agent-overlay.ts +11 -3
- package/src/ui/agent-tree.ts +43 -1
- 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/engine/inproc.ts
CHANGED
|
@@ -26,21 +26,28 @@ import { makeContactSupervisorTool } from "../bus/contact.ts";
|
|
|
26
26
|
import { makeContactPeerTool } from "../bus/peers.ts";
|
|
27
27
|
import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
|
|
28
28
|
import { sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
29
|
+
import { resolveLegThinking } from "../core/leg-thinking.ts";
|
|
29
30
|
import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
30
31
|
import { attributeInbound } from "../core/fence.ts";
|
|
31
32
|
import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
|
|
32
33
|
import { roleHint } from "../orchestration/roster.ts";
|
|
33
34
|
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
35
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
36
|
+
import { makeReadResultTool } from "../tools/read-result.ts";
|
|
37
|
+
import { admitResultReadTool, referenceHandoffFailure } from "./result-grant.ts";
|
|
38
|
+
import { makeScopedResultReader, type ResultArchiveView } from "../results/retrieval.ts";
|
|
35
39
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
40
|
import { piHostModuleHint } from "./host-module.ts";
|
|
37
41
|
import { nextChildHandle } from "./handles.ts";
|
|
38
42
|
import { combineSignals } from "./signals.ts";
|
|
39
43
|
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
44
|
+
import { mergeSessionToolAllowlist } from "./session-tools.ts";
|
|
40
45
|
import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
|
|
41
46
|
|
|
42
47
|
/** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
|
|
43
48
|
export interface InProcSession {
|
|
49
|
+
/** Host readback after Pi applies its capability clamp; injected test sessions may omit it. */
|
|
50
|
+
readonly thinkingLevel?: unknown;
|
|
44
51
|
subscribe(listener: (event: unknown) => void): () => void;
|
|
45
52
|
prompt(input: string): Promise<void>;
|
|
46
53
|
agent: { abort(): void; waitForIdle(): Promise<void>; steer(message: unknown): void };
|
|
@@ -49,12 +56,11 @@ export interface InProcSession {
|
|
|
49
56
|
|
|
50
57
|
export interface CreateSessionOptions {
|
|
51
58
|
model: unknown;
|
|
52
|
-
modelRegistry: ModelRegistry;
|
|
53
59
|
/** Cooperative cancellation for resource/session initialization. Pi's loader does not
|
|
54
60
|
* currently accept a signal itself, but injected factories can stop promptly and the
|
|
55
61
|
* default factory checks it before/after each uncancellable Pi operation. */
|
|
56
62
|
signal?: AbortSignal;
|
|
57
|
-
/** The host's canonical runtime
|
|
63
|
+
/** The host's canonical runtime; shared so auth/provider registrations survive. */
|
|
58
64
|
modelRuntime?: unknown;
|
|
59
65
|
cwd: string;
|
|
60
66
|
/** Pi's global agent dir (~/.pi/agent) for the sub-agent's resource loader. */
|
|
@@ -71,12 +77,9 @@ export interface CreateSessionOptions {
|
|
|
71
77
|
export type CreateInProcSession = (opts: CreateSessionOptions) => Promise<InProcSession>;
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
|
-
* Pi
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* private in its public TypeScript API. Read the runtime only when the expected
|
|
78
|
-
* methods are present; old Pi versions and test facades then retain the legacy
|
|
79
|
-
* modelRegistry path.
|
|
80
|
+
* The current Pi ModelRegistry facade owns the canonical ModelRuntime privately.
|
|
81
|
+
* Recover that runtime only when its expected methods are present, so native provider
|
|
82
|
+
* registrations and in-memory credentials remain shared with delegated sessions.
|
|
80
83
|
*/
|
|
81
84
|
function sharedModelRuntime(registry: ModelRegistry): unknown {
|
|
82
85
|
const candidate = (registry as unknown as { runtime?: unknown }).runtime;
|
|
@@ -157,6 +160,11 @@ export interface InProcessDeps {
|
|
|
157
160
|
* there, and this deadline aborts such a leg outright (failureKind "timeout"); the idle
|
|
158
161
|
* watchdog would not, because any event at all re-arms it. */
|
|
159
162
|
startupTimeoutMs?: number;
|
|
163
|
+
/** The owning session's terminal archive (a GETTER — the owner can change across sessions).
|
|
164
|
+
* Required by any leg carrying `resultRefs` (flow reference handoff): the scoped
|
|
165
|
+
* `read_result` tool pages THIS archive, after a zero-usage preflight verified every
|
|
166
|
+
* granted ref is durably stored. Absent ⇒ such legs fail closed before the session factory. */
|
|
167
|
+
resultArchive?: () => ResultArchiveView | undefined;
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
// The sub-agent must never re-enter the supervisor's orchestration surface. This is the SECOND
|
|
@@ -172,15 +180,6 @@ export function withOrchestrationToolsExcluded(extra: readonly string[] | undefi
|
|
|
172
180
|
return [...new Set([...ORCHESTRATION_TOOLS, ...(extra ?? [])])];
|
|
173
181
|
}
|
|
174
182
|
|
|
175
|
-
/** Preserve the semantic difference between an absent allowlist (Pi defaults) and an explicit
|
|
176
|
-
* empty one (no tools). Custom communication tools join only a non-empty allowlist; an empty
|
|
177
|
-
* grant must not quietly grow capabilities during session construction. */
|
|
178
|
-
export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
|
|
179
|
-
if (tools === undefined) return undefined;
|
|
180
|
-
if (tools.length === 0) return [];
|
|
181
|
-
return [...new Set([...tools, ...customNames])];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
183
|
/** Default factory: a real in-process Pi session. The resource loader appends the
|
|
185
184
|
* agent's persona to the base system prompt (the in-process `--append-system-prompt`).
|
|
186
185
|
* Extensions DO load (so auth providers like pi-claude work) — the fork-bomb guard is
|
|
@@ -199,14 +198,8 @@ const createPiSession: CreateInProcSession = async (opts) => {
|
|
|
199
198
|
// otherwise the allowlist would filter them out of the child's active set.
|
|
200
199
|
const customNames = (opts.customTools ?? []).map((t) => t.name);
|
|
201
200
|
const tools = mergeSessionToolAllowlist(opts.tools, customNames);
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// sub-session; newer pi takes the canonical `modelRuntime` instead. The extension-facing
|
|
205
|
-
// ModelRegistry keeps that runtime private in its type surface, so `sharedModelRuntime()` above
|
|
206
|
-
// recovers it only when the runtime shape is present. Passing it is important even when auth is
|
|
207
|
-
// on disk: native providers and runtime credentials can exist only in the supervisor's memory.
|
|
208
|
-
// `agentDir` remains a valid fallback for older/standalone SDKs, and the legacy key stays outside
|
|
209
|
-
// the object literal so current SDK types do not reject it.
|
|
201
|
+
// Current Pi consumes the canonical modelRuntime. Sharing it also preserves native providers
|
|
202
|
+
// and credentials that exist only in the supervisor's memory.
|
|
210
203
|
const sessionOptions: NonNullable<Parameters<typeof createAgentSession>[0]> = {
|
|
211
204
|
model: opts.model as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>,
|
|
212
205
|
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
@@ -219,14 +212,9 @@ const createPiSession: CreateInProcSession = async (opts) => {
|
|
|
219
212
|
excludeTools: withOrchestrationToolsExcluded(opts.excludeTools),
|
|
220
213
|
...(tools !== undefined ? { tools } : {}),
|
|
221
214
|
...(opts.customTools && opts.customTools.length > 0 ? { customTools: opts.customTools } : {}),
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
// here on a pi that supports it, and pi clamps an unknown level regardless).
|
|
225
|
-
...(opts.thinkingLevel
|
|
226
|
-
? { thinkingLevel: opts.thinkingLevel as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]> }
|
|
227
|
-
: {}),
|
|
215
|
+
// Pi owns capability clamping (which can move effort up as well as down).
|
|
216
|
+
...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
|
|
228
217
|
};
|
|
229
|
-
(sessionOptions as { modelRegistry?: unknown }).modelRegistry = opts.modelRegistry;
|
|
230
218
|
const { session } = await createAgentSession(sessionOptions);
|
|
231
219
|
if (opts.signal?.aborted) {
|
|
232
220
|
try {
|
|
@@ -237,6 +225,10 @@ const createPiSession: CreateInProcSession = async (opts) => {
|
|
|
237
225
|
throw new DOMException("Session initialization aborted", "AbortError");
|
|
238
226
|
}
|
|
239
227
|
return {
|
|
228
|
+
get thinkingLevel() {
|
|
229
|
+
const level: unknown = session.thinkingLevel;
|
|
230
|
+
return isThinkingLevel(level) ? level : undefined;
|
|
231
|
+
},
|
|
240
232
|
subscribe: (l) => session.subscribe(l as Parameters<typeof session.subscribe>[0]),
|
|
241
233
|
prompt: async (input) => {
|
|
242
234
|
await session.prompt(input);
|
|
@@ -316,6 +308,13 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
316
308
|
async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
|
|
317
309
|
const cfg = deps.resolveAgent(spec.agent);
|
|
318
310
|
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
311
|
+
let thinkingRequested: ThinkingLevel | undefined;
|
|
312
|
+
try {
|
|
313
|
+
thinkingRequested = resolveLegThinking(spec, cfg, deps.childThinking);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, failureKind: "contract", error: `[${spec.agent}] ${error instanceof Error ? error.message : String(error)}` };
|
|
316
|
+
}
|
|
317
|
+
const thinkingMetadata = thinkingRequested !== undefined ? { thinkingRequested } : {};
|
|
319
318
|
const tools = spec.tools ?? cfg.tools;
|
|
320
319
|
const invalidTools = toolModifierFailure(spec.agent, tools);
|
|
321
320
|
if (invalidTools) return invalidTools;
|
|
@@ -333,7 +332,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
333
332
|
// Make the failure self-correcting: name a few real registry refs (nearest
|
|
334
333
|
// matches first) so the supervisor's retry can pick a valid model at once.
|
|
335
334
|
const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
|
|
336
|
-
const frag = (ref ?? "").split("/").pop()?.
|
|
335
|
+
const frag = (ref ?? "").split("/").pop()?.toLowerCase() ?? "";
|
|
337
336
|
const near = frag ? all.filter((r) => r.toLowerCase().includes(frag)) : [];
|
|
338
337
|
const hint = (near.length > 0 ? near : all).slice(0, 5);
|
|
339
338
|
return {
|
|
@@ -342,16 +341,40 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
342
341
|
usage: emptyUsage(),
|
|
343
342
|
ok: false,
|
|
344
343
|
error: `[${spec.agent} · ${ref ?? "(no model)"}] model not found in registry (from ${src})${hint.length > 0 ? ` — try e.g.: ${hint.join(", ")}` : ""}`,
|
|
345
|
-
|
|
344
|
+
failureKind: "unknown-model",
|
|
345
|
+
...thinkingMetadata,
|
|
346
346
|
};
|
|
347
347
|
}
|
|
348
348
|
// The canonical provider/id this run resolved to — surfaced on every result (for the
|
|
349
349
|
// UI/cost) and, on a provider failure, used as the seed of the fallback chain.
|
|
350
350
|
const rm = model as { provider?: string; id?: string };
|
|
351
351
|
const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
|
|
352
|
-
const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
|
|
353
352
|
const excludeTools = cfg.excludeTools;
|
|
354
353
|
|
|
354
|
+
// ── reference handoff preflight (contract 3/4) — BEFORE the session factory, after every
|
|
355
|
+
// cheap zero-usage rejection above it. Admission uses the SAME shared rule as the child
|
|
356
|
+
// engine; storage is verified durable (session-entry); a failure is a zero-usage
|
|
357
|
+
// contract failure and the factory is never called. The revoke rides `releaseHandle()`
|
|
358
|
+
// (every exit path from here on drains it), so the scoped reader dies with the leg.
|
|
359
|
+
const legCleanups: Array<() => void> = [];
|
|
360
|
+
let resultReadTool: ToolDefinition | undefined;
|
|
361
|
+
let admittedTools = tools;
|
|
362
|
+
if (spec.resultRefs !== undefined && spec.resultRefs.length > 0) {
|
|
363
|
+
const grant = admitResultReadTool({
|
|
364
|
+
specTools: spec.tools, cfgTools: cfg.tools, excludeTools: cfg.excludeTools,
|
|
365
|
+
canUseBus: deps.canUseBus ?? true,
|
|
366
|
+
});
|
|
367
|
+
if (!grant.ok) return referenceHandoffFailure(spec.agent, grant.error);
|
|
368
|
+
const archive = deps.resultArchive?.();
|
|
369
|
+
if (archive === undefined) return referenceHandoffFailure(spec.agent, "terminal result archive unavailable for reference handoff");
|
|
370
|
+
const scoped = makeScopedResultReader(archive, spec.resultRefs);
|
|
371
|
+
const { missing } = scoped.preflight();
|
|
372
|
+
if (missing.length > 0) return referenceHandoffFailure(spec.agent, `archived results unavailable: ${missing.join(", ")}`);
|
|
373
|
+
resultReadTool = makeReadResultTool(scoped);
|
|
374
|
+
admittedTools = grant.tools;
|
|
375
|
+
legCleanups.push(() => scoped.revoke());
|
|
376
|
+
}
|
|
377
|
+
|
|
355
378
|
// Diagnostic tag: which agent + which model ref + whether it was dynamically
|
|
356
379
|
// specialised (skills / model / tools override on the spec). Users need this
|
|
357
380
|
// to tell "the delegate typo'd the model" from "the provider rejected it".
|
|
@@ -368,20 +391,19 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
368
391
|
// Already cancelled (e.g. a queued async run stopped before its slot came up):
|
|
369
392
|
// don't build a session or burn a model call — settle as aborted right away.
|
|
370
393
|
if (signal?.aborted) {
|
|
371
|
-
return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), failureKind: "abort" };
|
|
394
|
+
return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), ...thinkingMetadata, failureKind: "abort" };
|
|
372
395
|
}
|
|
373
396
|
|
|
374
397
|
const modelRuntime = sharedModelRuntime(deps.modelRegistry);
|
|
375
398
|
const sessionOpts: CreateSessionOptions = {
|
|
376
399
|
model,
|
|
377
|
-
modelRegistry: deps.modelRegistry,
|
|
378
400
|
...(modelRuntime !== undefined ? { modelRuntime } : {}),
|
|
379
401
|
cwd: deps.cwd,
|
|
380
402
|
agentDir: deps.agentDir ?? deps.cwd,
|
|
381
403
|
};
|
|
382
|
-
if (
|
|
404
|
+
if (admittedTools !== undefined) sessionOpts.tools = admittedTools;
|
|
383
405
|
if (excludeTools && excludeTools.length > 0) sessionOpts.excludeTools = excludeTools;
|
|
384
|
-
if (
|
|
406
|
+
if (thinkingRequested !== undefined) sessionOpts.thinkingLevel = thinkingRequested;
|
|
385
407
|
// The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
|
|
386
408
|
// on-the-fly `role` specialisation from the spec. All three reach the session as the
|
|
387
409
|
// loader's `appendSystemPrompt`, so a leg keeps Pi's full base prompt either way — the
|
|
@@ -398,20 +420,21 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
398
420
|
if (spec.peers === true && !wantsPeers && process.env.PI_PERSONA_DEBUG) {
|
|
399
421
|
process.stderr.write(`[pi-persona] peers requested for ${spec.agent} but bus/capability unavailable — running without contact_peer\n`);
|
|
400
422
|
}
|
|
401
|
-
|
|
423
|
+
const customTools: ToolDefinition[] = [];
|
|
424
|
+
const wantsCoaching = deps.coaching === true && (deps.canUseBus ?? true);
|
|
425
|
+
if (deps.bus && (wantsCoaching || wantsPeers)) {
|
|
402
426
|
childHandle = nextChildHandle(spec.agent);
|
|
403
427
|
deps.bus.register(supervisorHandle);
|
|
404
428
|
deps.bus.register(childHandle);
|
|
405
|
-
|
|
406
|
-
if (deps.coaching) {
|
|
429
|
+
if (wantsCoaching) {
|
|
407
430
|
customTools.push(
|
|
408
431
|
makeContactSupervisorTool(deps.bus, childHandle, supervisorHandle, { allowBlocking: deps.allowBlocking ?? false }),
|
|
409
432
|
);
|
|
410
433
|
}
|
|
411
434
|
if (wantsPeers) {
|
|
412
435
|
const label = spec.name?.trim()
|
|
413
|
-
|
|
414
|
-
|
|
436
|
+
? sanitizeDisplayLabel(spec.name, childHandle)
|
|
437
|
+
: spec.role ? `${childHandle} (${roleHint(spec.role)})` : childHandle;
|
|
415
438
|
peerLabels.set(childHandle, label);
|
|
416
439
|
const self = childHandle;
|
|
417
440
|
customTools.push(
|
|
@@ -420,8 +443,12 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
420
443
|
}),
|
|
421
444
|
);
|
|
422
445
|
}
|
|
423
|
-
sessionOpts.customTools = customTools;
|
|
424
446
|
}
|
|
447
|
+
// A granted reference-handoff reader joins the SAME custom-tools surface (contract 3):
|
|
448
|
+
// literal `read_result` already admitted into a nonempty allowlist above; an absent
|
|
449
|
+
// allowlist stays absent and the injected tool rides Pi's session defaults.
|
|
450
|
+
if (resultReadTool !== undefined) customTools.push(resultReadTool);
|
|
451
|
+
if (customTools.length > 0) sessionOpts.customTools = customTools;
|
|
425
452
|
|
|
426
453
|
// The handle is live on the SESSION-LONG bus from here on, while the only unregister sits
|
|
427
454
|
// in the run's own finally below. Anything that throws before we reach it must release
|
|
@@ -429,6 +456,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
429
456
|
// for the rest of the supervisor's session.
|
|
430
457
|
let handleReleased = false;
|
|
431
458
|
const releaseHandle = (): void => {
|
|
459
|
+
// Leg-scoped cleanups FIRST — the reference reader revoke must run even on a leg that
|
|
460
|
+
// never owned a bus handle (non-coaching, peer-less).
|
|
461
|
+
for (const cleanup of legCleanups.splice(0)) {
|
|
462
|
+
try { cleanup(); } catch { /* a throwing cleanup must never strand its siblings */ }
|
|
463
|
+
}
|
|
432
464
|
if (!childHandle || handleReleased) return;
|
|
433
465
|
handleReleased = true;
|
|
434
466
|
peerLabels.delete(childHandle);
|
|
@@ -578,6 +610,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
578
610
|
error: `${tag} agent aborted`,
|
|
579
611
|
...(resolvedRef ? { modelUsed: resolvedRef } : {}),
|
|
580
612
|
failureKind: "abort",
|
|
613
|
+
...thinkingMetadata,
|
|
581
614
|
};
|
|
582
615
|
}
|
|
583
616
|
|
|
@@ -649,6 +682,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
649
682
|
error: `${initError}${cleanupHint}`,
|
|
650
683
|
...(resolvedRef ? { modelUsed: resolvedRef } : {}),
|
|
651
684
|
failureKind: hardTimedOut || timedOut || startupTimedOut ? "timeout" : "abort",
|
|
685
|
+
...thinkingMetadata,
|
|
652
686
|
};
|
|
653
687
|
}
|
|
654
688
|
session = initializationOutcome.session;
|
|
@@ -774,6 +808,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
774
808
|
// folded into the AgentResult below. Otherwise the stream state stays empty
|
|
775
809
|
// and the user sees an opaque "agent failed (unknown)".
|
|
776
810
|
let thrownError: string | undefined;
|
|
811
|
+
let thinkingEffective: ThinkingLevel | undefined;
|
|
777
812
|
try {
|
|
778
813
|
armIdle(); // start the idle clock (reset on every session event)
|
|
779
814
|
armHard(); // start the lifetime ceiling (never reset)
|
|
@@ -796,6 +831,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
796
831
|
releaseHandle();
|
|
797
832
|
unsub?.();
|
|
798
833
|
steerable = false;
|
|
834
|
+
const observed = session.thinkingLevel;
|
|
835
|
+
if (isThinkingLevel(observed)) thinkingEffective = observed;
|
|
799
836
|
session.dispose();
|
|
800
837
|
}
|
|
801
838
|
|
|
@@ -824,7 +861,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
824
861
|
}
|
|
825
862
|
}
|
|
826
863
|
|
|
827
|
-
const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
|
|
864
|
+
const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), ...thinkingMetadata };
|
|
865
|
+
// The final message describes the effort of that request; session readback is the
|
|
866
|
+
// observed setting when a host emits no effort field. Neither proves provider cognition.
|
|
867
|
+
const observed = state.thinkingEffective ?? thinkingEffective;
|
|
868
|
+
if (observed !== undefined) result.thinkingEffective = observed;
|
|
828
869
|
// A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
|
|
829
870
|
if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
830
871
|
// The deadline cannot tell the two causes apart, so the message names both: claiming
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen `read_result` admission rule (flow reference handoff, contract 4) — ONE helper
|
|
3
|
+
* shared by BOTH engine backends, so the child-process and in-process paths can never drift.
|
|
4
|
+
*
|
|
5
|
+
* effective tools = spec.tools ?? cfg.tools. An explicit `[]` denies. A nonempty allowlist
|
|
6
|
+
* must already include `read` OR `read_result` (the grant builds on a leg that can read —
|
|
7
|
+
* it never widens a write-only specialist into a reader). Any deny (agent `excludeTools`,
|
|
8
|
+
* wildcards included) matching either name denies. The parent's `canUseBus` capability must
|
|
9
|
+
* be true for reference mode at all; explicit parent denies always win. Only after a grant:
|
|
10
|
+
* the literal `read_result` joins a nonempty allowlist; an absent allowlist stays absent
|
|
11
|
+
* (session defaults + the injected custom tool).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isAllowed } from "../core/permissions.ts";
|
|
15
|
+
import type { AgentResult } from "../orchestration/types.ts";
|
|
16
|
+
import { emptyUsage } from "./stream.ts";
|
|
17
|
+
|
|
18
|
+
/** The literal, never-renamed tool a reference-handoff leg reads archived results through. */
|
|
19
|
+
export const RESULT_READ_TOOL = "read_result";
|
|
20
|
+
|
|
21
|
+
export interface ResultToolAdmission {
|
|
22
|
+
specTools: readonly string[] | undefined;
|
|
23
|
+
cfgTools: readonly string[] | undefined;
|
|
24
|
+
/** The agent's own deny list (glob patterns allowed — the same matcher Pi's permission
|
|
25
|
+
* layer uses, so a `read*` deny here means what it means everywhere else). */
|
|
26
|
+
excludeTools: readonly string[] | undefined;
|
|
27
|
+
/** The persona-level bus capability (`EffectiveCapabilities.canUseBus`). */
|
|
28
|
+
canUseBus: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ResultToolGrant =
|
|
32
|
+
| { ok: false; error: string }
|
|
33
|
+
/** `tools` is the effective allowlist to run the leg with: undefined = an absent
|
|
34
|
+
* allowlist stays absent (session default + custom-tool injection). */
|
|
35
|
+
| { ok: true; tools: string[] | undefined };
|
|
36
|
+
|
|
37
|
+
export function admitResultReadTool(input: ResultToolAdmission): ResultToolGrant {
|
|
38
|
+
if (!input.canUseBus) {
|
|
39
|
+
return { ok: false, error: "reference handoff requires the bus capability (this persona denies it)" };
|
|
40
|
+
}
|
|
41
|
+
const tools = input.specTools ?? input.cfgTools;
|
|
42
|
+
if (tools !== undefined && tools.length === 0) {
|
|
43
|
+
return { ok: false, error: "reference handoff requires the read tool; this leg's tool allowlist is empty" };
|
|
44
|
+
}
|
|
45
|
+
// Denies are evaluated by NAME against both spellings, wildcards included, before any
|
|
46
|
+
// allowlist reasoning — an explicit deny always wins over the grant below.
|
|
47
|
+
const deny = { deny: [...(input.excludeTools ?? [])] };
|
|
48
|
+
if (!isAllowed("read", deny) || !isAllowed(RESULT_READ_TOOL, deny)) {
|
|
49
|
+
return { ok: false, error: "reference handoff requires the read tool; read/read_result is denied for this leg" };
|
|
50
|
+
}
|
|
51
|
+
if (tools !== undefined && !tools.includes("read") && !tools.includes(RESULT_READ_TOOL)) {
|
|
52
|
+
return { ok: false, error: `reference handoff requires the read or ${RESULT_READ_TOOL} tool; the allowlist names neither` };
|
|
53
|
+
}
|
|
54
|
+
if (tools === undefined) return { ok: true, tools: undefined };
|
|
55
|
+
return { ok: true, tools: [...new Set([...tools, RESULT_READ_TOOL])] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A reference-handoff preflight rejection: ZERO usage (nothing ran), terminal for the leg,
|
|
59
|
+
* listing what is missing so the caller can report it honestly. */
|
|
60
|
+
export function referenceHandoffFailure(agent: string, error: string): AgentResult {
|
|
61
|
+
return { agent, output: "", usage: emptyUsage(), ok: false, error: `[${agent}] ${error}`, failureKind: "contract" };
|
|
62
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Preserve absent/default and explicit empty grants when adding runtime-owned custom tools. */
|
|
2
|
+
export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
|
|
3
|
+
if (tools === undefined) return undefined;
|
|
4
|
+
if (tools.length === 0) return [];
|
|
5
|
+
return [...new Set([...tools, ...customNames])];
|
|
6
|
+
}
|
package/src/engine/stream.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Mirrors the canonical recipe in Pi's reference subagent example.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
|
|
13
|
+
|
|
12
14
|
export interface ChildUsage {
|
|
13
15
|
input: number;
|
|
14
16
|
output: number;
|
|
@@ -53,6 +55,8 @@ export interface StreamState {
|
|
|
53
55
|
deltaTail: number;
|
|
54
56
|
usage: ChildUsage;
|
|
55
57
|
model?: string;
|
|
58
|
+
/** Final assistant-message Pi effort, not provider cognition. Unknown stays absent. */
|
|
59
|
+
thinkingEffective?: ThinkingLevel;
|
|
56
60
|
stopReason?: string;
|
|
57
61
|
errorMessage?: string;
|
|
58
62
|
sawAssistant: boolean;
|
|
@@ -306,6 +310,8 @@ export function applyEvent(state: StreamState, event: unknown): ToolEvent | unde
|
|
|
306
310
|
resetDeltaParts(state); // the next message's deltas start from an empty slate
|
|
307
311
|
state.sawAssistant = true;
|
|
308
312
|
state.usage.turns++;
|
|
313
|
+
if (isThinkingLevel(msg.thinkingLevel)) state.thinkingEffective = msg.thinkingLevel;
|
|
314
|
+
else delete state.thinkingEffective;
|
|
309
315
|
|
|
310
316
|
const text = firstText(msg.content);
|
|
311
317
|
if (text !== undefined) state.output = text;
|
package/src/engine/worktree.ts
CHANGED
|
@@ -76,11 +76,14 @@ export async function worktreePreflight(root: string, exec: GitExec = defaultGit
|
|
|
76
76
|
return { ok: true };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/** Capture the complete post-run worktree state as a portable unified diff
|
|
80
|
-
*
|
|
79
|
+
/** Capture the complete post-run worktree state as a portable unified diff from its pinned
|
|
80
|
+
* starting commit. Files newly created by a coding leg are added with `git diff --no-index`.
|
|
81
81
|
* Exit code 1 is the normal "files differ" result for --no-index. */
|
|
82
|
-
export async function captureWorktreeArtifact(root: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
|
|
83
|
-
|
|
82
|
+
export async function captureWorktreeArtifact(root: string, baseCommit: string, exec: GitExec = defaultGitExec): Promise<WorktreeArtifact> {
|
|
83
|
+
if (!baseCommit.trim()) {
|
|
84
|
+
return { ok: false, error: "could not export the isolated worktree diff: starting commit is missing" };
|
|
85
|
+
}
|
|
86
|
+
const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", baseCommit, "--"]);
|
|
84
87
|
if (tracked.code !== 0) {
|
|
85
88
|
return { ok: false, error: `could not export the isolated worktree diff: ${tracked.stderr.trim() || `git exited ${tracked.code}`}` };
|
|
86
89
|
}
|
|
@@ -107,8 +110,9 @@ export async function captureWorktreeArtifact(root: string, exec: GitExec = defa
|
|
|
107
110
|
return { ok: true, diff: parts.join("\n") };
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
/** Create a detached worktree off `root`'s HEAD,
|
|
111
|
-
|
|
113
|
+
/** Create a detached worktree off `root`'s HEAD, pin its starting commit, run
|
|
114
|
+
* `body(worktreeDir, baseCommit)`, then force-remove it. */
|
|
115
|
+
export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string, baseCommit: string) => Promise<T>): Promise<T> {
|
|
112
116
|
const preflight = await worktreePreflight(root, exec);
|
|
113
117
|
if (!preflight.ok) throw new Error(preflight.error);
|
|
114
118
|
const dir = await mkdtemp(join(tmpdir(), "pi-persona-wt-"));
|
|
@@ -120,7 +124,13 @@ export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: s
|
|
|
120
124
|
throw new Error(`could not create git worktree: ${add.stderr.trim() || `git exited ${add.code}`}`);
|
|
121
125
|
}
|
|
122
126
|
try {
|
|
123
|
-
|
|
127
|
+
const initial = await exec(["-C", dir, "rev-parse", "--verify", "HEAD^{commit}"]);
|
|
128
|
+
const baseCommit = initial.stdout.trim();
|
|
129
|
+
if (initial.code !== 0 || !baseCommit) {
|
|
130
|
+
const reason = initial.stderr.trim() || (initial.code !== 0 ? `git exited ${initial.code}` : "Git returned an empty commit id");
|
|
131
|
+
throw new Error(`could not resolve the initial worktree commit: ${reason}`);
|
|
132
|
+
}
|
|
133
|
+
return await body(dir, baseCommit);
|
|
124
134
|
} finally {
|
|
125
135
|
// Remove the worktree registration, then the dir (force: it may carry the agent's edits).
|
|
126
136
|
// Never throw: cleanup must not replace the body's result (or its error) — `defaultGitExec`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Signed offer wakes project retained ledger data; the envelope is never its authority. */
|
|
2
|
+
import { attributePeer } from "../core/fence.ts";
|
|
3
|
+
import { collaborationOfferStatus } from "./collaboration.ts";
|
|
4
|
+
import { frameSigningPayload, type ExocomOffer, type ExocomSemanticFrame } from "./envelope.ts";
|
|
5
|
+
import type { LedgerState } from "./ledger.ts";
|
|
6
|
+
|
|
7
|
+
export function collaborationWriteIntent(frame: ExocomSemanticFrame, state: LedgerState): boolean {
|
|
8
|
+
if (frame.kind === "claim" || frame.kind === "task_create") return frame.write_set.length > 0;
|
|
9
|
+
return frame.kind === "task_take" && (state.collaboration?.tasks.find((task) => task.created.work_key === frame.work_key)?.created.write_set.length ?? 0) > 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function deliverCollaborationOffer(
|
|
13
|
+
frame: ExocomOffer, state: LedgerState, sessionId: string, label: string, now: number,
|
|
14
|
+
): { accepted: true; notice?: string } | { accepted: false; reason: string } {
|
|
15
|
+
if (frame.to_session !== sessionId) return { accepted: false, reason: "offer addressed to another session" };
|
|
16
|
+
const stored = state.collaboration?.offers.find((offer) => offer.offer_id === frame.offer_id);
|
|
17
|
+
if (!stored || frameSigningPayload(stored) !== frameSigningPayload(frame)) {
|
|
18
|
+
return { accepted: false, reason: "offer is not durable with this sender and payload" };
|
|
19
|
+
}
|
|
20
|
+
const tasks = state.collaboration?.tasks ?? [];
|
|
21
|
+
if (collaborationOfferStatus(stored, tasks, now) !== "open") return { accepted: true };
|
|
22
|
+
const task = tasks.find((candidate) => candidate.created.work_key === stored.work_key && candidate.created.msg_id === stored.task_id);
|
|
23
|
+
if (!task) return { accepted: true };
|
|
24
|
+
return { accepted: true, notice: [
|
|
25
|
+
`[pi-persona] Optional peer invitation · task=${stored.work_key} · offer=${stored.offer_id} · expires ${stored.expires_at}`,
|
|
26
|
+
"This creates no response obligation or permission. Inspect the task and take it voluntarily only within your authorized scope; otherwise continue your work.",
|
|
27
|
+
attributePeer(frame.from_session === task.created.from_session ? label : task.created.from_session, task.created.description),
|
|
28
|
+
].join("\n") };
|
|
29
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Voluntary peer work. Pure domain rules; claims and persistence stay in the ledger transaction. */
|
|
2
|
+
import { writeSetPathError } from "../core/ownership.ts";
|
|
3
|
+
import { isSemanticFrame, type ExocomSemanticFrame, type ExocomCollaborationFrame, type ExocomOffer, type ExocomProgress, type ExocomPublish, type ExocomTaskCreate, type ExocomTaskFinish, type ExocomTaskTake } from "./envelope.ts";
|
|
4
|
+
|
|
5
|
+
export interface CollaborationTask {
|
|
6
|
+
created: ExocomTaskCreate;
|
|
7
|
+
taken?: ExocomTaskTake;
|
|
8
|
+
progress?: ExocomProgress;
|
|
9
|
+
finished?: ExocomTaskFinish;
|
|
10
|
+
/** Loss of the owning registry lease is visible and never permits automatic pickup. */
|
|
11
|
+
stale?: true;
|
|
12
|
+
/** Receiver clock at the first lease-loss reconciliation, or release timestamp. */
|
|
13
|
+
staleAt?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CollaborationState { tasks: CollaborationTask[]; offers: ExocomOffer[]; evidence: ExocomPublish[]; }
|
|
16
|
+
export const COLLABORATION_LIMITS = { tasks: 64, offers: 64, evidence: 64, maxBytes: 131_072 } as const;
|
|
17
|
+
export function emptyCollaboration(): CollaborationState { return { tasks: [], offers: [], evidence: [] }; }
|
|
18
|
+
export function collaborationStatus(task: CollaborationTask): "open" | "taken" | "done" | "abandoned" | "stale" {
|
|
19
|
+
return task.finished?.outcome ?? (task.stale ? "stale" : task.taken ? "taken" : "open");
|
|
20
|
+
}
|
|
21
|
+
export function offerStatus(offer: ExocomOffer, now: number): "open" | "expired" {
|
|
22
|
+
return Date.parse(offer.expires_at) <= now ? "expired" : "open";
|
|
23
|
+
}
|
|
24
|
+
/** Time expiry is authoritative even after the exact task generation becomes unavailable. */
|
|
25
|
+
export function collaborationOfferStatus(offer: ExocomOffer, tasks: readonly CollaborationTask[], now: number): "open" | "expired" | "stale" {
|
|
26
|
+
if (offerStatus(offer, now) === "expired") return "expired";
|
|
27
|
+
const task = tasks.find((candidate) => candidate.created.work_key === offer.work_key && candidate.created.msg_id === offer.task_id);
|
|
28
|
+
return task && collaborationStatus(task) === "open" ? "open" : "stale";
|
|
29
|
+
}
|
|
30
|
+
function bounded(state: CollaborationState, reserveLifecycle = true): boolean {
|
|
31
|
+
// Local ledger lifecycle events are unsigned. Reserve their largest identity metadata and a
|
|
32
|
+
// minimal terminal note, as well as lease loss, before admitting optional prose. A full note
|
|
33
|
+
// can hit quota; taking work, finishing with a short note and release must remain available.
|
|
34
|
+
const reserved = { ...state, tasks: state.tasks.map((task) => {
|
|
35
|
+
if (task.finished) return task;
|
|
36
|
+
const future = { work_key: task.created.work_key, task_id: task.created.msg_id, from_session: "x".repeat(128),
|
|
37
|
+
from_name: "界".repeat(256), msg_id: "x".repeat(128), ts: "9999-12-31T23:59:59.999Z" };
|
|
38
|
+
return { ...task, stale: true, staleAt: -Number.MAX_VALUE,
|
|
39
|
+
taken: task.taken ?? { ...future, kind: "task_take" }, finished: { ...future, kind: "task_finish", outcome: "abandoned", note: "x" } };
|
|
40
|
+
}) };
|
|
41
|
+
return state.tasks.length <= COLLABORATION_LIMITS.tasks && state.offers.length <= COLLABORATION_LIMITS.offers
|
|
42
|
+
&& state.evidence.length <= COLLABORATION_LIMITS.evidence && Buffer.byteLength(JSON.stringify(reserveLifecycle ? reserved : state), "utf8") <= COLLABORATION_LIMITS.maxBytes;
|
|
43
|
+
}
|
|
44
|
+
/** Retained facts outlive the rolling transport-id window; they must never acquire duplicate identities. */
|
|
45
|
+
export function retainedCollaborationEvent(state: CollaborationState, msgId: string): ExocomSemanticFrame | undefined {
|
|
46
|
+
for (const task of state.tasks) {
|
|
47
|
+
for (const event of [task.created, task.taken, task.progress, task.finished]) if (event?.msg_id === msgId) return event;
|
|
48
|
+
}
|
|
49
|
+
return state.offers.find((event) => event.msg_id === msgId) ?? state.evidence.find((event) => event.msg_id === msgId);
|
|
50
|
+
}
|
|
51
|
+
type Transition = { ok: true; state: CollaborationState } | { ok: false; error: string };
|
|
52
|
+
function transition(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress, reserveLifecycle: boolean): Transition {
|
|
53
|
+
if (event.kind === "progress" && event.task_id === undefined) return { ok: true, state };
|
|
54
|
+
const task = state.tasks.find((value) => value.created.work_key === event.work_key);
|
|
55
|
+
let next = state;
|
|
56
|
+
if (event.kind === "task_create") {
|
|
57
|
+
if (task) return { ok: false, error: "task work_key already exists" };
|
|
58
|
+
for (const path of event.write_set) {
|
|
59
|
+
const error = writeSetPathError(path);
|
|
60
|
+
if (error) return { ok: false, error: `task write_set path is ${error}` };
|
|
61
|
+
}
|
|
62
|
+
next = { ...state, tasks: [...state.tasks, { created: event }] };
|
|
63
|
+
} else {
|
|
64
|
+
if (!task) return { ok: false, error: "unknown collaboration task" };
|
|
65
|
+
if (event.task_id !== task.created.msg_id) return { ok: false, error: "task_id does not match this work key's retained generation" };
|
|
66
|
+
if (event.kind === "publish") next = { ...state, evidence: [...state.evidence, event] };
|
|
67
|
+
else if (event.kind === "offer") {
|
|
68
|
+
if (state.offers.some((offer) => offer.offer_id === event.offer_id)) return { ok: false, error: "offer_id already exists" };
|
|
69
|
+
if (event.to_session === event.from_session) return { ok: false, error: "offer cannot target self" };
|
|
70
|
+
if (collaborationStatus(task) !== "open") return { ok: false, error: "offers require an open task" };
|
|
71
|
+
next = { ...state, offers: [...state.offers, event] };
|
|
72
|
+
} else {
|
|
73
|
+
const status = collaborationStatus(task);
|
|
74
|
+
if (event.kind === "task_take") {
|
|
75
|
+
if (status !== "open") return { ok: false, error: `task is ${status}; pickup refused` };
|
|
76
|
+
next = { ...state, tasks: state.tasks.map((value) => value === task ? { ...task, taken: event } : value) };
|
|
77
|
+
} else {
|
|
78
|
+
if (status !== "taken" || task.taken?.from_session !== event.from_session) return { ok: false, error: "only the active task taker may report progress or finish" };
|
|
79
|
+
if (event.kind === "progress" && Date.parse(event.ts) < Date.parse(task.progress?.ts ?? task.taken.ts)) return { ok: false, error: "task progress predates its latest observation" };
|
|
80
|
+
const updated = event.kind === "progress" ? { ...task, progress: event } : { ...task, finished: event };
|
|
81
|
+
next = { ...state, tasks: state.tasks.map((value) => value === task ? updated : value) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return bounded(next, reserveLifecycle) ? { ok: true, state: next } : { ok: false, error: "collaboration retention budget reached; shorten optional report text" };
|
|
86
|
+
}
|
|
87
|
+
export function applyCollaborationEvent(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress): Transition {
|
|
88
|
+
return transition(state, event, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function staleCollaboration(state: CollaborationState, isLive: (session: string) => boolean, now: number): CollaborationState {
|
|
92
|
+
return { ...state, tasks: state.tasks.map((task) => task.finished || task.stale || isLive(task.taken?.from_session ?? task.created.from_session)
|
|
93
|
+
? task : { ...task, stale: true, staleAt: now }) };
|
|
94
|
+
}
|
|
95
|
+
export function releaseCollaboration(state: CollaborationState, session: string, workKey: string, now: number): CollaborationState {
|
|
96
|
+
return { ...state, tasks: state.tasks.map((task) => task.created.work_key === workKey && !task.finished && !task.stale
|
|
97
|
+
&& (task.taken?.from_session ?? task.created.from_session) === session ? { ...task, stale: true, staleAt: now } : task) };
|
|
98
|
+
}
|
|
99
|
+
export function pruneCollaboration(state: CollaborationState, now: number, ttlMs: number, isLive: (session: string) => boolean): CollaborationState {
|
|
100
|
+
const reconciled = staleCollaboration(state, isLive, now);
|
|
101
|
+
const tasks = reconciled.tasks.filter((task) => {
|
|
102
|
+
const terminalAt = task.finished ? Date.parse(task.finished.ts) : task.staleAt;
|
|
103
|
+
return terminalAt === undefined || now - terminalAt <= ttlMs;
|
|
104
|
+
});
|
|
105
|
+
const retained = new Set(tasks.map((task) => task.created.work_key));
|
|
106
|
+
return {
|
|
107
|
+
tasks,
|
|
108
|
+
offers: state.offers.filter((offer) => retained.has(offer.work_key) && now - Date.parse(offer.expires_at) <= ttlMs),
|
|
109
|
+
evidence: state.evidence.filter((item) => retained.has(item.work_key) && now - Date.parse(item.ts) <= ttlMs),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reconstruct snapshots through the same rules. No malformed ownership record is silently skipped. */
|
|
114
|
+
export function parseCollaboration(raw: unknown): CollaborationState | undefined {
|
|
115
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
116
|
+
const value = raw as CollaborationState;
|
|
117
|
+
if (!Array.isArray(value.tasks) || !Array.isArray(value.offers) || !Array.isArray(value.evidence) || !bounded(value)) return undefined;
|
|
118
|
+
let state = emptyCollaboration();
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
function replay(event: unknown, kind: string): boolean {
|
|
121
|
+
if (!isSemanticFrame(event) || event.kind !== kind || seen.has(event.msg_id)) return false;
|
|
122
|
+
if (!(event.kind === "task_create" || event.kind === "task_take" || event.kind === "task_finish" || event.kind === "progress" || event.kind === "offer" || event.kind === "publish")) return false;
|
|
123
|
+
// Definitions must precede historical offers. Replay checks physical bounds; reserve
|
|
124
|
+
// future lifecycle only on the final state, once completed tasks are terminal again.
|
|
125
|
+
const applied = transition(state, event, false);
|
|
126
|
+
if (!applied.ok) return false;
|
|
127
|
+
seen.add(event.msg_id);
|
|
128
|
+
state = applied.state;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
// All definitions precede offers/evidence; a finished task's historical offer is valid.
|
|
132
|
+
for (const task of value.tasks) {
|
|
133
|
+
if (!task || typeof task !== "object" || !replay(task.created, "task_create")) return undefined;
|
|
134
|
+
}
|
|
135
|
+
for (const offer of value.offers) if (!replay(offer, "offer")) return undefined;
|
|
136
|
+
for (const task of value.tasks) {
|
|
137
|
+
for (const [event, kind] of [[task.taken, "task_take"], [task.progress, "progress"], [task.finished, "task_finish"]] as const) {
|
|
138
|
+
if (event !== undefined && (event.work_key !== task.created.work_key || !replay(event, kind))) return undefined;
|
|
139
|
+
}
|
|
140
|
+
if (task.stale !== undefined && task.stale !== true) return undefined;
|
|
141
|
+
if (task.stale) {
|
|
142
|
+
if (task.finished || typeof task.staleAt !== "number" || !Number.isFinite(task.staleAt)) return undefined;
|
|
143
|
+
state = releaseCollaboration(state, task.taken?.from_session ?? task.created.from_session, task.created.work_key, task.staleAt);
|
|
144
|
+
} else if (task.staleAt !== undefined) return undefined;
|
|
145
|
+
}
|
|
146
|
+
for (const evidence of value.evidence) if (!replay(evidence, "publish")) return undefined;
|
|
147
|
+
return bounded(state) ? state : undefined;
|
|
148
|
+
}
|