@aeondave/pi-persona 1.17.0 → 1.17.3
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 +79 -29
- package/CHANGELOG.md +264 -0
- package/README.md +27 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +223 -57
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +189 -35
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +16 -11
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +251 -0
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +195 -136
- package/src/engine/child.ts +1 -1
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +11 -6
- package/src/engine/stream.ts +3 -0
- package/src/exocom/install.ts +2 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +336 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +642 -267
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -3
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/persona.ts +7 -3
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +315 -285
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +328 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
- package/src/persona/state.ts +0 -28
package/src/engine/inproc.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { roleHint } from "../orchestration/roster.ts";
|
|
|
33
33
|
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
34
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
35
35
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
|
+
import { piHostModuleHint } from "./host-module.ts";
|
|
36
37
|
import { nextChildHandle } from "./handles.ts";
|
|
37
38
|
import { combineSignals } from "./signals.ts";
|
|
38
39
|
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
@@ -666,7 +667,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
666
667
|
// One progress tick, routed exactly as the subscription routes them: the per-call sink
|
|
667
668
|
// takes the AgentProgress shape (no `turns`), else the construction-time sink.
|
|
668
669
|
const emitProgress = (snap: ProgressSnapshot): void => {
|
|
669
|
-
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
670
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
670
671
|
else if (deps.onProgress) deps.onProgress(snap);
|
|
671
672
|
};
|
|
672
673
|
let contractDef: ContractDef | undefined = requestedDef;
|
|
@@ -735,7 +736,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
735
736
|
// launch path (extension.ts) only has the latter.
|
|
736
737
|
const snap = snapshot(state);
|
|
737
738
|
const activity = `✉ from ${env.from}`;
|
|
738
|
-
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, activity });
|
|
739
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), activity });
|
|
739
740
|
else if (deps.onProgress) deps.onProgress({ ...snap, activity });
|
|
740
741
|
}
|
|
741
742
|
};
|
|
@@ -825,7 +826,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
825
826
|
|
|
826
827
|
const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
|
|
827
828
|
// A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
|
|
828
|
-
if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
829
|
+
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})` : ""}`;
|
|
829
830
|
// The deadline cannot tell the two causes apart, so the message names both: claiming
|
|
830
831
|
// "it never started" would misdirect the operator of a leg that did start and was
|
|
831
832
|
// simply waiting on a slow first response (mirrors the child engine's wording).
|
|
@@ -836,6 +837,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
836
837
|
else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
|
|
837
838
|
else if (thrownError) result.error = `${tag} ${thrownError}`;
|
|
838
839
|
else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
|
|
840
|
+
const hostModuleHint = !ok ? piHostModuleHint(state.errorMessage ?? thrownError ?? "") : undefined;
|
|
841
|
+
if (hostModuleHint && result.error) result.error += ` — ${hostModuleHint}`;
|
|
839
842
|
// Classify the failure so the fallback decorator can reroute ONLY provider errors,
|
|
840
843
|
// never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
|
|
841
844
|
// own report of an API failure → provider. A THROWN error is not automatically a
|
|
@@ -846,9 +849,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
846
849
|
? "timeout"
|
|
847
850
|
: aborted
|
|
848
851
|
? "abort"
|
|
849
|
-
:
|
|
850
|
-
? "
|
|
851
|
-
: "
|
|
852
|
+
: hostModuleHint
|
|
853
|
+
? "agent"
|
|
854
|
+
: state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
|
|
855
|
+
? "provider"
|
|
856
|
+
: "agent";
|
|
852
857
|
|
|
853
858
|
if (contractDef) {
|
|
854
859
|
const v = parseAndValidate(state.output, contractDef);
|
package/src/engine/stream.ts
CHANGED
|
@@ -339,6 +339,8 @@ export interface ProgressSnapshot {
|
|
|
339
339
|
output: string;
|
|
340
340
|
turns: number;
|
|
341
341
|
tokens: number;
|
|
342
|
+
/** Copied cumulative counters for display; optional for legacy/custom progress sources. */
|
|
343
|
+
usage?: ChildUsage;
|
|
342
344
|
/** The tool currently running (e.g. "grep src/…"), if any. */
|
|
343
345
|
activity?: string;
|
|
344
346
|
/** One authoritative runtime tool lifecycle transition, when this snapshot is event-driven. */
|
|
@@ -352,6 +354,7 @@ export function snapshot(state: StreamState, toolEvent?: ToolEvent): ProgressSna
|
|
|
352
354
|
output: [state.transcript, state.partial].filter((s) => s).join("\n\n") || state.output,
|
|
353
355
|
turns: state.usage.turns,
|
|
354
356
|
tokens: state.usage.input + state.usage.output,
|
|
357
|
+
usage: { ...state.usage },
|
|
355
358
|
};
|
|
356
359
|
if (state.activity !== undefined) snap.activity = state.activity;
|
|
357
360
|
if (toolEvent !== undefined) snap.toolEvent = toolEvent;
|
package/src/exocom/install.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { canCallTool, canParticipateInExocom, EXOCOM_TOOL_NAMES } from "../core/
|
|
|
13
13
|
import type { DelegationNudge } from "../core/nudge.ts";
|
|
14
14
|
import { shortModel } from "../tools/delegate.ts";
|
|
15
15
|
import { boundDisplayRows } from "../ui/presentation.ts";
|
|
16
|
+
import { compactWidget } from "../ui/compact-widget.ts";
|
|
16
17
|
import type { PersonaController } from "../persona/controller.ts";
|
|
17
18
|
import { type InstanceDescriptor, type PeerDescriptor } from "../telemetry/contract.ts";
|
|
18
19
|
import type { TelemetryProducer } from "../telemetry/producer.ts";
|
|
@@ -327,7 +328,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
327
328
|
return `${quiet ? "💤" : "📡"} ${name}${persona ? ` (${persona})` : ""} · ${shortModel(model) || "?"} · ctx ${contextPct}% · recv ${exocomPlane?.receivedFromPeer(p.session_id) ?? 0} · sent ${exocomPlane?.sentToPeer(p.session_id) ?? 0}${workspaceBit}`;
|
|
328
329
|
});
|
|
329
330
|
const lines = boundDisplayRows(local, peerRows, 7, "exocom_list for the full pool");
|
|
330
|
-
host.lastCtx.ui.setWidget("persona-exocom", lines, { placement: "aboveEditor" });
|
|
331
|
+
host.lastCtx.ui.setWidget("persona-exocom", compactWidget(lines), { placement: "aboveEditor" });
|
|
331
332
|
} catch {
|
|
332
333
|
/* cosmetic — the widget is best-effort */
|
|
333
334
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PERSONA_SETTINGS, validatePreferences, type CompanionPreferenceValues } from "../core/settings.ts";
|
|
3
|
+
|
|
4
|
+
export const COMPANION_SETTINGS_CHANNEL = "pi-persona:settings:collect:v1";
|
|
5
|
+
export interface CompanionSettingsSnapshot {
|
|
6
|
+
mind?: Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
|
|
7
|
+
flow?: Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Synchronous, optional discovery on Pi's public bus. No plugin import, model request or writer. */
|
|
11
|
+
export function collectCompanionSettings(bus: Pick<EventBus, "emit"> | undefined): CompanionSettingsSnapshot {
|
|
12
|
+
const snapshot: CompanionSettingsSnapshot = {};
|
|
13
|
+
if (!bus) return snapshot;
|
|
14
|
+
try {
|
|
15
|
+
bus.emit(COMPANION_SETTINGS_CHANNEL, { add: (id: unknown, values: unknown): void => {
|
|
16
|
+
if (id !== "mind" && id !== "flow") return;
|
|
17
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) return;
|
|
18
|
+
const keys = PERSONA_SETTINGS.filter((setting) => setting.owner === id).map((setting) => setting.id);
|
|
19
|
+
const reportedKeys = Object.keys(values);
|
|
20
|
+
if (reportedKeys.length === 0 || !reportedKeys.every((key) => keys.includes(key as keyof CompanionPreferenceValues))) return;
|
|
21
|
+
try {
|
|
22
|
+
const validated = validatePreferences(values);
|
|
23
|
+
if (id === "mind") snapshot.mind = validated as Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
|
|
24
|
+
else snapshot.flow = validated as Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
|
|
25
|
+
} catch { /* A malformed contribution is not an editable settings owner. */ }
|
|
26
|
+
} });
|
|
27
|
+
} catch { /* Keep valid responses if another extension listener throws. */ }
|
|
28
|
+
return snapshot;
|
|
29
|
+
}
|
package/src/extension/engine.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type ContractDef, DEFAULT_CONTRACT, installedContractNames } from "../c
|
|
|
8
8
|
import type { RunLimits } from "../core/capabilities.ts";
|
|
9
9
|
import type { PiPersonaConfig } from "../core/config.ts";
|
|
10
10
|
import { isThinkingLevel } from "../core/types.ts";
|
|
11
|
+
import type { ModelCostMetadata } from "../core/model-tier.ts";
|
|
11
12
|
import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
|
|
12
13
|
import { participantKey } from "../persona/model-participants.ts";
|
|
13
14
|
import type { PersonaController, PersonaHost } from "../persona/controller.ts";
|
|
@@ -310,33 +311,59 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
310
311
|
}
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
// The
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
export function configuredModels(ctx: ExtensionContext): Array<{ provider: string; id: string }> {
|
|
314
|
+
// The host's model-level availability snapshot, plus the exact active model. A provider's
|
|
315
|
+
// credentials do not grant every model in its catalog. Extension-native bridges can be
|
|
316
|
+
// absent from an auth snapshot: restore only their explicit registered model lists in that
|
|
317
|
+
// case, never every inherited model under their provider id. Older facades without an
|
|
318
|
+
// availability API retain their historical getAll() fallback.
|
|
319
|
+
export function configuredModels(ctx: ExtensionContext): ModelCostMetadata[] {
|
|
320
320
|
const reg = ctx.modelRegistry;
|
|
321
321
|
const all = reg.getAll();
|
|
322
322
|
let available: typeof all;
|
|
323
323
|
try {
|
|
324
324
|
available = reg.getAvailable();
|
|
325
325
|
} catch {
|
|
326
|
-
return all
|
|
326
|
+
return all;
|
|
327
327
|
}
|
|
328
|
-
const
|
|
329
|
-
if (ctx.model
|
|
328
|
+
const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
|
|
329
|
+
if (ctx.model) byRef.set(`${ctx.model.provider}\0${ctx.model.id}`, ctx.model);
|
|
330
|
+
const availableProviders = new Set(available.map((m) => m.provider));
|
|
331
|
+
const declaredRefs = new Set<string>();
|
|
332
|
+
let registered: readonly string[] = [];
|
|
330
333
|
try {
|
|
331
|
-
|
|
332
|
-
for (const provider of registered) trustedProviders.add(provider);
|
|
334
|
+
registered = reg.getRegisteredProviderIds?.() ?? [];
|
|
333
335
|
} catch {
|
|
334
336
|
/* a third-party/older registry facade may not expose extension provider ids */
|
|
335
337
|
}
|
|
336
|
-
const
|
|
338
|
+
for (const provider of registered) {
|
|
339
|
+
// A populated snapshot can intentionally exclude individual models for this credential.
|
|
340
|
+
if (availableProviders.has(provider)) continue;
|
|
341
|
+
let native: ReturnType<typeof reg.getRegisteredNativeProvider>;
|
|
342
|
+
try {
|
|
343
|
+
native = reg.getRegisteredNativeProvider?.(provider);
|
|
344
|
+
// An empty credential-filtered list is authoritative too; it need not be a stale snapshot.
|
|
345
|
+
if (reg.getProvider?.(provider)?.filterModels || native?.filterModels) continue;
|
|
346
|
+
} catch { continue; }
|
|
347
|
+
try {
|
|
348
|
+
const models = reg.getRegisteredProviderConfig?.(provider)?.models;
|
|
349
|
+
if (Array.isArray(models)) {
|
|
350
|
+
for (const model of models) {
|
|
351
|
+
if ((model.type === undefined || model.type === "chat") && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch { /* one broken extension's metadata must not hide other providers */ }
|
|
355
|
+
try {
|
|
356
|
+
const models = native?.getModels();
|
|
357
|
+
if (Array.isArray(models)) {
|
|
358
|
+
for (const model of models) {
|
|
359
|
+
if (model.provider === provider && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch { /* a native catalog may be temporarily unavailable */ }
|
|
363
|
+
}
|
|
337
364
|
for (const model of all) {
|
|
338
|
-
|
|
365
|
+
const ref = `${model.provider}\0${model.id}`;
|
|
366
|
+
if (declaredRefs.has(ref)) byRef.set(ref, model);
|
|
339
367
|
}
|
|
340
|
-
|
|
341
|
-
return list.map((m) => ({ provider: m.provider, id: m.id }));
|
|
368
|
+
return [...byRef.values()];
|
|
342
369
|
}
|
package/src/extension/hooks.ts
CHANGED
|
@@ -10,10 +10,11 @@ import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
|
|
|
10
10
|
import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
|
|
11
11
|
import type { PiPersonaConfig } from "../core/config.ts";
|
|
12
12
|
import { fenceUntrusted } from "../core/fence.ts";
|
|
13
|
-
import { HANDOFF_TOOL_NAMES, type DelegationNudge, type PersistenceNudge } from "../core/nudge.ts";
|
|
13
|
+
import { HANDOFF_TOOL_NAMES, longWaitSeconds, type DelegationNudge, type PersistenceNudge, type PollGuard } from "../core/nudge.ts";
|
|
14
|
+
import { findStoredRuns, makeRunsEntry, restoreNotice, RUNS_ENTRY_CUSTOM_TYPE } from "../core/session-runs.ts";
|
|
14
15
|
import { buildSessionAnchor } from "../core/time.ts";
|
|
15
16
|
import { readClockSnapshot } from "./clock.ts";
|
|
16
|
-
import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
17
|
+
import { type AsyncRun, type AsyncRunTracker, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
17
18
|
import { constrainedTurnAllows } from "../exocom/gate.ts";
|
|
18
19
|
import type { LedgerClaim } from "../exocom/ledger.ts";
|
|
19
20
|
import type { DisplayPeer } from "../exocom/plane.ts";
|
|
@@ -33,12 +34,15 @@ import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
|
|
|
33
34
|
import type { PersonaConfigStore } from "../persona/config-store.ts";
|
|
34
35
|
import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
|
|
35
36
|
import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
|
|
36
|
-
import type
|
|
37
|
+
import { isWakeSource, type AgentTree, type AgentNode } from "../ui/agent-tree.ts";
|
|
37
38
|
import { compactInlineText } from "../ui/presentation.ts";
|
|
38
39
|
import type { FailureKind } from "../orchestration/types.ts";
|
|
39
40
|
import type { ExocomInstall } from "../exocom/install.ts";
|
|
40
41
|
import type { SessionIdentity } from "./identity.ts";
|
|
41
42
|
|
|
43
|
+
type SessionManagerLike = { getSessionId?: () => string; getEntries?: () => readonly unknown[] };
|
|
44
|
+
const sessionManagerOf = (ctx: ExtensionContext): SessionManagerLike => (ctx as ExtensionContext & { sessionManager?: SessionManagerLike }).sessionManager ?? {};
|
|
45
|
+
|
|
42
46
|
type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind };
|
|
43
47
|
|
|
44
48
|
export interface HookHost {
|
|
@@ -69,11 +73,15 @@ export interface HookHost {
|
|
|
69
73
|
telemetryAgent(node: AgentNode): TelemetryAgentInput;
|
|
70
74
|
delegationNudge: DelegationNudge;
|
|
71
75
|
persistenceNudge: PersistenceNudge;
|
|
76
|
+
/** Refuses an unchanged repeat of an intercom peek/result; absent when the nudge kill switch is off. */
|
|
77
|
+
pollGuard: PollGuard | undefined;
|
|
78
|
+
/** The async-run tracker: what is still running, and the run-id counter a restore continues. */
|
|
79
|
+
tracker: Pick<AsyncRunTracker, "running" | "issued" | "continueFrom">;
|
|
72
80
|
appendNudgeEntry(content: string): void;
|
|
73
81
|
completionNotifier: IdleCoalescingNotifier<AsyncRun>;
|
|
74
82
|
intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
|
|
75
83
|
timerNotifier: IdleCoalescingNotifier<TimerEntry>;
|
|
76
|
-
timerScheduler: { cancelAll(): void };
|
|
84
|
+
timerScheduler: { cancelAll(): void; readonly size: number };
|
|
77
85
|
monitors: MonitorSession;
|
|
78
86
|
peekWatcher: PeekWatcher;
|
|
79
87
|
stopPeek(): void;
|
|
@@ -124,13 +132,59 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
124
132
|
}
|
|
125
133
|
pendingSurrenderRelays.add(id);
|
|
126
134
|
};
|
|
135
|
+
// `delegate`/`council`/`flow` calls (all background launches) whose tool_call passed but whose result has
|
|
136
|
+
// not landed. Pi prepares every call of a batch before any executes, so a same-batch launch + `sleep`
|
|
137
|
+
// would otherwise see no running child yet. A launch that later fails validation counts until its result lands: accepted.
|
|
138
|
+
const launching = new Set<string>();
|
|
139
|
+
const clearInFlight = (): void => { pendingSurrenderRelays.clear(); launching.clear(); };
|
|
140
|
+
/** A tool the model can call right now: active in the host and allowed by the persona. */
|
|
141
|
+
const toolAllowed = (name: string): boolean => {
|
|
142
|
+
const caps = h.controller.capabilities;
|
|
143
|
+
return pi.getActiveTools().includes(name) && (!caps || canCallTool(caps, name));
|
|
144
|
+
};
|
|
145
|
+
// ── restore honesty ───────────────────────────────────────────────────────
|
|
146
|
+
// A reload/resume/`pi -c` ends this instance and with it every background run, armed timer and
|
|
147
|
+
// monitor (memory only). Shutdown records what dies and the run-id counter in a session entry
|
|
148
|
+
// (core/session-runs.ts); the next start tells the restored supervisor once, passively, and
|
|
149
|
+
// continues the ids so they never repeat within one history. Best effort: a failure on either
|
|
150
|
+
// side degrades to the old behaviour, never to a broken shutdown or start.
|
|
151
|
+
let restoredIssued = 0; // the counter this instance started from: unchanged ⇒ nothing new to record
|
|
152
|
+
function recordLostWork(ctx: ExtensionContext): void {
|
|
153
|
+
try {
|
|
154
|
+
const sessionId = sessionManagerOf(ctx).getSessionId?.();
|
|
155
|
+
if (!sessionId) return;
|
|
156
|
+
const lost = {
|
|
157
|
+
runs: [...new Set([...h.tracker.running(), ...h.completionNotifier.peekPending()].map((run) => run.id))],
|
|
158
|
+
timers: h.timerScheduler.size,
|
|
159
|
+
monitors: h.monitors.list().length,
|
|
160
|
+
};
|
|
161
|
+
if (lost.runs.length === 0 && lost.timers === 0 && lost.monitors === 0 && h.tracker.issued <= restoredIssued) return;
|
|
162
|
+
pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(sessionId, h.tracker.issued, lost));
|
|
163
|
+
} catch { /* shutdown must complete */ }
|
|
164
|
+
}
|
|
165
|
+
function announceLostWork(ctx: ExtensionContext): void {
|
|
166
|
+
try {
|
|
167
|
+
const manager = sessionManagerOf(ctx);
|
|
168
|
+
const stored = findStoredRuns(manager.getEntries?.() ?? [], manager.getSessionId?.() ?? "");
|
|
169
|
+
if (!stored) return;
|
|
170
|
+
h.tracker.continueFrom(stored.issued);
|
|
171
|
+
restoredIssued = h.tracker.issued;
|
|
172
|
+
const notice = restoreNotice(stored.lost);
|
|
173
|
+
if (!notice) return;
|
|
174
|
+
pi.sendMessage({ customType: "pi-persona", content: notice, display: true }, { deliverAs: "nextTurn" }); // passive: never starts a turn
|
|
175
|
+
// Acknowledge so the newest entry is an empty loss: the same loss can never be announced twice.
|
|
176
|
+
pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(stored.sessionId, stored.issued, { runs: [], timers: 0, monitors: 0 }));
|
|
177
|
+
} catch { /* the session start must complete */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
127
180
|
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
128
181
|
pi.on("session_start", async (_event, ctx) => {
|
|
129
|
-
|
|
182
|
+
clearInFlight();
|
|
130
183
|
h.lastCtx = ctx;
|
|
131
184
|
h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
|
|
132
185
|
h.childUsage.reset();
|
|
133
186
|
h.publishPersonaCost();
|
|
187
|
+
announceLostWork(ctx);
|
|
134
188
|
// Opt-in only (PI_PERSONA_SEED=on): auto-install the bundled defaults once. Default is off —
|
|
135
189
|
// a fresh install shows no h.personas until `/persona seed` or `/persona restore`.
|
|
136
190
|
if (h.config.seed && !existsSync(h.seedMarker())) {
|
|
@@ -182,8 +236,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
182
236
|
await exocom.reconcile(ctx); // after persona activation, so plane + active tools share one gate
|
|
183
237
|
await h.telemetry?.stop("session-replaced");
|
|
184
238
|
h.telemetry = undefined;
|
|
185
|
-
const
|
|
186
|
-
const sessionId = sessionManager?.getSessionId?.();
|
|
239
|
+
const sessionId = sessionManagerOf(ctx).getSessionId?.();
|
|
187
240
|
if (sessionId) {
|
|
188
241
|
const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
|
|
189
242
|
try {
|
|
@@ -218,6 +271,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
218
271
|
// published — a session start that follows no teardown carries no stale projection.
|
|
219
272
|
h.lastAgentProjection.clear();
|
|
220
273
|
for (const node of h.agentTree.snapshot()) {
|
|
274
|
+
if (isWakeSource(node)) continue; // timer/monitor rows are not agents: no replay, no projection
|
|
221
275
|
h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
|
|
222
276
|
h.telemetry.publishAgentAdded(h.telemetryAgent(node));
|
|
223
277
|
}
|
|
@@ -227,8 +281,9 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
227
281
|
});
|
|
228
282
|
|
|
229
283
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
230
|
-
|
|
284
|
+
clearInFlight();
|
|
231
285
|
h.lastCtx = ctx;
|
|
286
|
+
recordLostWork(ctx); // before the notifiers/timers/runs below are torn down
|
|
232
287
|
h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
|
|
233
288
|
h.deferredOrchestrations.length = 0;
|
|
234
289
|
h.pendingOrchestration = undefined;
|
|
@@ -277,7 +332,8 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
277
332
|
// idle window first; its replay starts the next run and the remaining FIFO entries follow one per
|
|
278
333
|
// settlement. With no deferred input, flush ordinary wakes immediately.
|
|
279
334
|
pi.on("agent_settled", async () => {
|
|
280
|
-
|
|
335
|
+
clearInFlight();
|
|
336
|
+
h.pollGuard?.reset(); // a settled turn ends any poll streak: the next peek is a fresh look
|
|
281
337
|
if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
|
|
282
338
|
// Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
|
|
283
339
|
// after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
|
|
@@ -356,13 +412,16 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
356
412
|
const now = Date.now();
|
|
357
413
|
const anchor = buildSessionAnchor(ctx.sessionManager?.getHeader?.() ?? null, now);
|
|
358
414
|
if (anchor) prompt = `${prompt}\n\n${anchor}`;
|
|
359
|
-
const wakeCaps = h.controller.capabilities;
|
|
360
|
-
const activeWakeTools = new Set(pi.getActiveTools());
|
|
361
|
-
const wakeAllows = (name: string) => activeWakeTools.has(name) && (!wakeCaps || canCallTool(wakeCaps, name));
|
|
362
415
|
const wakeGuidance: string[] = [];
|
|
363
|
-
if (
|
|
364
|
-
if (
|
|
365
|
-
if (wakeGuidance.length)
|
|
416
|
+
if (toolAllowed("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
|
|
417
|
+
if (toolAllowed("monitor") && toolAllowed("bash")) wakeGuidance.push("monitor watches a job or event-producing program: mode=exit for job completion, mode=output for file/log/system changes reported as stdout lines. Filter changes in the producer; set a useful deadline and event count, and cancel the watch when its purpose ends.");
|
|
418
|
+
if (wakeGuidance.length) {
|
|
419
|
+
// Headless `pi -p` exits when the turn settles, so "end your turn" would drop the wake.
|
|
420
|
+
const wakeTail = ctx.hasUI
|
|
421
|
+
? "Continue independent work while waiting; end your turn when only the event remains. These wakes require this Pi session to stay open."
|
|
422
|
+
: "Headless: a wake is delivered only if it fires before your turn settles; do not end the turn to wait for one.";
|
|
423
|
+
prompt = `${prompt}\n\n[Session wakes] ${wakeGuidance.join(" ")} ${wakeTail} Avoid duplicate watches.`;
|
|
424
|
+
}
|
|
366
425
|
const brief = h.delegationBrief(ctx);
|
|
367
426
|
if (brief) prompt = `${prompt}\n\n${brief}`;
|
|
368
427
|
// Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
|
|
@@ -482,32 +541,28 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
482
541
|
status: "running",
|
|
483
542
|
});
|
|
484
543
|
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
485
|
-
|
|
486
|
-
|
|
544
|
+
// A blocked call never reaches tool_result, so close its telemetry here.
|
|
545
|
+
const reject = <R>(result: R): R => {
|
|
487
546
|
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
488
547
|
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
489
|
-
return
|
|
490
|
-
}
|
|
548
|
+
return result;
|
|
549
|
+
};
|
|
550
|
+
const gate = h.controller.gate(event.toolName, event.input);
|
|
551
|
+
if (gate) return reject(gate);
|
|
491
552
|
if (exocom.ledgerFile) {
|
|
492
553
|
let pending: ReturnType<ExocomInstall["pendingAsks"]>;
|
|
493
554
|
try {
|
|
494
555
|
pending = exocom.pendingAsks(ctx);
|
|
495
556
|
} catch (error) {
|
|
496
557
|
if (constrainedTurnAllows(event.toolName)) pending = [];
|
|
497
|
-
else {
|
|
498
|
-
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
499
|
-
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
500
|
-
return { block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` };
|
|
501
|
-
}
|
|
558
|
+
else return reject({ block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` });
|
|
502
559
|
}
|
|
503
560
|
if (pending.length > 0 && !constrainedTurnAllows(event.toolName)) {
|
|
504
|
-
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
505
|
-
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
506
561
|
const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
|
|
507
|
-
return {
|
|
562
|
+
return reject({
|
|
508
563
|
block: true,
|
|
509
564
|
reason: `exocom: pending ask(s) to this session — only exocom_name (identity metadata), exocom_answer, exocom_decline, read, grep, find, ls until you answer (ask_id=${pending[0]!.ask_id}${omitted})`,
|
|
510
|
-
};
|
|
565
|
+
});
|
|
511
566
|
}
|
|
512
567
|
if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
|
|
513
568
|
// Advisory: a ledger read failure here must not block an otherwise-allowed write.
|
|
@@ -524,13 +579,25 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
524
579
|
const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
|
|
525
580
|
const reason = writeWarningReason(input.path, claim, label);
|
|
526
581
|
try { if (ctx.hasUI) ctx.ui.notify(reason, "warning"); } catch { /* cosmetic */ }
|
|
527
|
-
|
|
528
|
-
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
529
|
-
return { block: true, reason };
|
|
582
|
+
return reject({ block: true, reason });
|
|
530
583
|
}
|
|
531
584
|
}
|
|
532
585
|
}
|
|
533
586
|
}
|
|
587
|
+
// A long leading sleep holds the turn that the idle-gated completion wake needs idle (and queues
|
|
588
|
+
// the operator's steering). Interactive only: a headless leg runs inline, so the model is parked
|
|
589
|
+
// in the delegate call and no wake is waiting on the turn.
|
|
590
|
+
if (h.config.nudge && ctx.hasUI === true && !event.parentToolCallId && (event.toolName === "bash" || event.toolName === "powershell") && typeof input.command === "string") {
|
|
591
|
+
const waitSeconds = longWaitSeconds(input.command);
|
|
592
|
+
const running = h.tracker.running().map((run) => run.id);
|
|
593
|
+
if (waitSeconds !== undefined && running.length + launching.size > 0) {
|
|
594
|
+
const instead = [toolAllowed("timer") && "a fixed clock time use timer arm", toolAllowed("monitor") && toolAllowed("bash") && "an external event use monitor"].filter(Boolean).join("; for ");
|
|
595
|
+
return reject({
|
|
596
|
+
block: true,
|
|
597
|
+
reason: `A ${Math.round(waitSeconds)}s wait blocks the completion wake and operator steering of ${running.length > 0 ? `the running sub-agents (${running.join(", ")})` : "the sub-agents you are launching"}. A finished run or batch wakes you by itself - end your turn.${instead ? ` For ${instead} - never to wait for these children.` : ""}`,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
534
601
|
if (event.toolName === "intercom") {
|
|
535
602
|
const action = typeof input.action === "string" ? input.action : "message";
|
|
536
603
|
if (action === "send" || action === "reply") {
|
|
@@ -583,6 +650,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
583
650
|
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
584
651
|
});
|
|
585
652
|
}
|
|
653
|
+
if (event.toolName === "delegate" || event.toolName === "council" || event.toolName === "flow") launching.add(event.toolCallId);
|
|
586
654
|
return undefined;
|
|
587
655
|
});
|
|
588
656
|
|
|
@@ -590,12 +658,13 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
590
658
|
// hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
|
|
591
659
|
// offending tool's result. It lands in RECENT context, on the very command that tripped it, where
|
|
592
660
|
// a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
|
|
593
|
-
// sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council`
|
|
594
|
-
// resets the run.
|
|
661
|
+
// sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council`/`flow`
|
|
662
|
+
// result resets the run (a failed `flow` is self-describing and neither resets nor nudges).
|
|
595
663
|
pi.on("tool_result", (event, ctx) => {
|
|
596
664
|
h.lastCtx = ctx;
|
|
597
665
|
const open = h.telemetryToolStartedAt.get(event.toolCallId);
|
|
598
666
|
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
667
|
+
launching.delete(event.toolCallId);
|
|
599
668
|
if (open !== undefined) {
|
|
600
669
|
h.telemetry?.publish("tool.finished", {
|
|
601
670
|
callId: event.toolCallId,
|
|
@@ -711,12 +780,12 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
711
780
|
}
|
|
712
781
|
const notes: string[] = [];
|
|
713
782
|
// Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
|
|
714
|
-
// tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
|
|
783
|
+
// tools (delegate/council/flow reset the run). `size` classifies substantive vs glue + fat dump.
|
|
715
784
|
const size = text.length;
|
|
716
785
|
const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
|
|
717
786
|
if (sweepNote) notes.push(sweepNote);
|
|
718
|
-
// Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
|
|
719
|
-
// results only; because
|
|
787
|
+
// Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council/flow
|
|
788
|
+
// results only; because those reset the run the two never fire on one event).
|
|
720
789
|
if (surrender) notes.push(surrender);
|
|
721
790
|
if (notes.length === 0) return errorPatch;
|
|
722
791
|
const joined = notes.join("\n\n");
|
|
@@ -843,6 +912,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
843
912
|
pi.on("input", async (event, ctx) => {
|
|
844
913
|
h.lastCtx = ctx;
|
|
845
914
|
if (event.source === "extension") return undefined;
|
|
915
|
+
h.pollGuard?.reset(); // a real operator message earns a fresh look
|
|
846
916
|
const orch = h.controller.activePersona?.orchestration;
|
|
847
917
|
const task = event.text?.trim();
|
|
848
918
|
if (!orch || !task) return undefined;
|