@aeondave/pi-persona 1.15.2
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 +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AbortSignal plumbing for the engine backends.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** One signal that fires when any of the given signals fires (ignores undefined). */
|
|
6
|
+
export function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
|
|
7
|
+
const live = signals.filter((s): s is AbortSignal => s !== undefined);
|
|
8
|
+
if (live.length === 0) return undefined;
|
|
9
|
+
if (live.length === 1) return live[0];
|
|
10
|
+
return AbortSignal.any(live);
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-spec preflight failures shared by BOTH engine backends (child adapter + in-process).
|
|
3
|
+
* A spec that names an agent or an output contract that is not installed must fail before
|
|
4
|
+
* anything spawns, and the message must let the caller self-correct: it names what IS
|
|
5
|
+
* installed. One wording, one cap, so the two engines can never drift apart again.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cappedList } from "../core/format.ts";
|
|
9
|
+
import type { AgentResult, FailureKind } from "../orchestration/types.ts";
|
|
10
|
+
import { emptyUsage } from "./stream.ts";
|
|
11
|
+
|
|
12
|
+
/** Names shown in a self-correcting hint before the list is elided. */
|
|
13
|
+
const HINT_CAP = 12;
|
|
14
|
+
|
|
15
|
+
/** `" — installed agents: a, b, …"`; empty when nothing is installed (no misleading hint). */
|
|
16
|
+
export function installedHint(kind: "agents" | "contracts", names: string[]): string {
|
|
17
|
+
if (names.length === 0) return "";
|
|
18
|
+
return ` — installed ${kind}: ${cappedList(names, HINT_CAP)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The result skeleton both preflight rejections share; only the error text and failureKind
|
|
22
|
+
* differ between "unknown agent" and "unknown contract". */
|
|
23
|
+
function failure(agent: string, error: string, failureKind: FailureKind): AgentResult {
|
|
24
|
+
return { agent, output: "", usage: emptyUsage(), ok: false, error, failureKind };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function unknownAgentFailure(agent: string, installed: string[]): AgentResult {
|
|
28
|
+
return failure(agent, `[${agent}] unknown agent (not found in registry)${installedHint("agents", installed)}`, "unknown-agent");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unknownContractFailure(agent: string, requested: string, installed: string[]): AgentResult {
|
|
32
|
+
return failure(agent, `[${agent}] output contract "${requested}" not found${installedHint("contracts", installed)}`, "contract");
|
|
33
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsing of the `pi --mode json -p` event stream.
|
|
3
|
+
*
|
|
4
|
+
* The CLI emits one JSON object per line (a session event). This module turns
|
|
5
|
+
* that stream into an accumulated result — final assistant text, summed usage,
|
|
6
|
+
* model, and stop reason — without any I/O, so it is fully unit-testable. The
|
|
7
|
+
* spawn wrapper (child.ts) only does process plumbing on top of these helpers.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the canonical recipe in Pi's reference subagent example.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ChildUsage {
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
cost: number;
|
|
18
|
+
contextTokens: number;
|
|
19
|
+
turns: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Structured lifecycle transition for one runtime tool call. Deliberately excludes arguments and output. */
|
|
23
|
+
export interface ToolEvent {
|
|
24
|
+
phase: "start" | "end";
|
|
25
|
+
callId: string;
|
|
26
|
+
name: string;
|
|
27
|
+
failed?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StreamState {
|
|
31
|
+
/** The last assistant text content seen (the agent's answer — the returned result). */
|
|
32
|
+
output: string;
|
|
33
|
+
/** The chronological live log shown in the f9 detail view: every completed message's
|
|
34
|
+
* reasoning AND text, plus one `⚙ tool` line per tool call — the whole progression,
|
|
35
|
+
* never just the latest message. Bounded (old head trimmed) so a long run can't
|
|
36
|
+
* grow supervisor memory without limit. */
|
|
37
|
+
transcript: string;
|
|
38
|
+
/** The current in-progress message's visible content (streamed text, or reasoning
|
|
39
|
+
* while there's no answer text yet). Cleared on message_end (folded into transcript).
|
|
40
|
+
* Bounded (old head trimmed) like the transcript: `snapshot()` copies it on every
|
|
41
|
+
* event, per leg, so an unbounded one is the same leak. */
|
|
42
|
+
partial: string;
|
|
43
|
+
/** The in-progress message's content parts assembled from streaming deltas, indexed by
|
|
44
|
+
* `contentIndex`. pi ≥0.83 makes `message_update` DELTA-ONLY on the JSON wire (no
|
|
45
|
+
* cumulative `message`), so live text can only be reconstructed here. */
|
|
46
|
+
deltaParts: string[];
|
|
47
|
+
/** {@link deltaParts} joined — the value `partial` takes. Kept incrementally (a delta for
|
|
48
|
+
* the last non-blank part appends to it) because re-joining every part on every delta is
|
|
49
|
+
* quadratic in message length, on the hot path of every leg. */
|
|
50
|
+
deltaLive: string;
|
|
51
|
+
/** Index of the last non-blank part in {@link deltaParts}: the only slot whose growth
|
|
52
|
+
* appends to {@link deltaLive} verbatim, which is what makes the append exact. -1 = none. */
|
|
53
|
+
deltaTail: number;
|
|
54
|
+
usage: ChildUsage;
|
|
55
|
+
model?: string;
|
|
56
|
+
stopReason?: string;
|
|
57
|
+
errorMessage?: string;
|
|
58
|
+
sawAssistant: boolean;
|
|
59
|
+
/** The tool the child is currently running (e.g. "grep src/…"), or undefined. */
|
|
60
|
+
activity?: string;
|
|
61
|
+
/** Calls whose start was surfaced but whose end has not arrived. This is not a retention
|
|
62
|
+
* cache: active ids are never evicted merely because completed calls churn. */
|
|
63
|
+
activeToolCalls?: Set<string>;
|
|
64
|
+
/** Recently completed call ids whose start/end phase was surfaced. Both caches are bounded;
|
|
65
|
+
* they suppress duplicate wire events without growing for the lifetime of a long run. */
|
|
66
|
+
seenToolStarts?: Set<string>;
|
|
67
|
+
seenToolEnds?: Set<string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emptyUsage(): ChildUsage {
|
|
71
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createStreamState(): StreamState {
|
|
75
|
+
return {
|
|
76
|
+
output: "",
|
|
77
|
+
transcript: "",
|
|
78
|
+
partial: "",
|
|
79
|
+
deltaParts: [],
|
|
80
|
+
deltaLive: "",
|
|
81
|
+
deltaTail: -1,
|
|
82
|
+
usage: emptyUsage(),
|
|
83
|
+
sawAssistant: false,
|
|
84
|
+
activeToolCalls: new Set(),
|
|
85
|
+
seenToolStarts: new Set(),
|
|
86
|
+
seenToolEnds: new Set(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TOOL_EVENT_DEDUPE_CACHE_MAX = 1_024;
|
|
91
|
+
|
|
92
|
+
function rememberRecent(cache: Set<string>, callId: string): void {
|
|
93
|
+
cache.add(callId);
|
|
94
|
+
while (cache.size > TOOL_EVENT_DEDUPE_CACHE_MAX) {
|
|
95
|
+
const oldest = cache.values().next().value as string | undefined;
|
|
96
|
+
if (oldest === undefined) break;
|
|
97
|
+
cache.delete(oldest);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Start the next message's delta assembly from an empty slate (parts AND their cache). */
|
|
102
|
+
function resetDeltaParts(state: StreamState): void {
|
|
103
|
+
state.deltaParts = [];
|
|
104
|
+
state.deltaLive = "";
|
|
105
|
+
state.deltaTail = -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isObject(x: unknown): x is Record<string, unknown> {
|
|
109
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function num(x: unknown): number {
|
|
113
|
+
return typeof x === "number" && !Number.isNaN(x) ? x : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function firstText(content: unknown): string | undefined {
|
|
117
|
+
if (!Array.isArray(content)) return undefined;
|
|
118
|
+
for (const part of content) {
|
|
119
|
+
if (isObject(part) && part.type === "text" && typeof part.text === "string") return part.text;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The visible content of an in-progress message: its reasoning AND its answer text,
|
|
125
|
+
* in content order, so a thinking agent isn't a mute "waiting" and the answer appearing
|
|
126
|
+
* doesn't erase the reasoning that led to it. */
|
|
127
|
+
function liveContent(content: unknown): string | undefined {
|
|
128
|
+
if (!Array.isArray(content)) return undefined;
|
|
129
|
+
const parts: string[] = [];
|
|
130
|
+
for (const part of content) {
|
|
131
|
+
if (!isObject(part)) continue;
|
|
132
|
+
if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) parts.push(part.thinking);
|
|
133
|
+
else if (part.type === "text" && typeof part.text === "string" && part.text.trim()) parts.push(part.text);
|
|
134
|
+
}
|
|
135
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A malformed line must not be able to allocate an arbitrarily sparse deltaParts array
|
|
139
|
+
// (the re-join below walks its whole length) — no real message has this many content parts.
|
|
140
|
+
const MAX_CONTENT_PARTS = 4096;
|
|
141
|
+
|
|
142
|
+
// Bound the in-progress message exactly as the transcript is bounded: MAX_CONTENT_PARTS caps
|
|
143
|
+
// how MANY parts a malformed line can allocate, but nothing on the wire caps the bytes in one
|
|
144
|
+
// (a single `text_end` can carry megabytes), and `snapshot()` copies the assembly on every
|
|
145
|
+
// event, per leg. Over the cap the OLD head goes — the user is watching the tail.
|
|
146
|
+
const PARTIAL_MAX = 200_000;
|
|
147
|
+
const PARTIAL_KEEP = 150_000;
|
|
148
|
+
|
|
149
|
+
function boundLive(text: string): string {
|
|
150
|
+
if (text.length <= PARTIAL_MAX) return text;
|
|
151
|
+
return `…[earlier output trimmed]\n${text.slice(text.length - PARTIAL_KEEP)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Re-join every non-blank part and record which one is last. The general case — used when a
|
|
155
|
+
* delta can't simply extend the tail of what's already assembled. */
|
|
156
|
+
function rejoinDeltaParts(state: StreamState): void {
|
|
157
|
+
let live = "";
|
|
158
|
+
let tail = -1;
|
|
159
|
+
for (let i = 0; i < state.deltaParts.length; i++) {
|
|
160
|
+
const part = state.deltaParts[i];
|
|
161
|
+
if (!part?.trim()) continue;
|
|
162
|
+
live = live ? `${live}\n\n${part}` : part;
|
|
163
|
+
tail = i;
|
|
164
|
+
}
|
|
165
|
+
state.deltaLive = boundLive(live);
|
|
166
|
+
state.deltaTail = tail;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Fold one streaming `assistantMessageEvent` delta into the live partial. This is the
|
|
170
|
+
* pi ≥0.83 wire shape: `message_update` carries only `{contentIndex, delta}`, never the
|
|
171
|
+
* cumulative message, so the visible content has to be reassembled part by part.
|
|
172
|
+
* Tool-call deltas are raw argument JSON — not something to show as agent output. */
|
|
173
|
+
function applyMessageDelta(state: StreamState, ev: Record<string, unknown>): void {
|
|
174
|
+
if (ev.type === "start") {
|
|
175
|
+
resetDeltaParts(state);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const idx = ev.contentIndex;
|
|
179
|
+
if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx >= MAX_CONTENT_PARTS) return;
|
|
180
|
+
const prev = state.deltaParts[idx] ?? "";
|
|
181
|
+
if (ev.type === "text_delta" || ev.type === "thinking_delta") {
|
|
182
|
+
if (typeof ev.delta !== "string") return;
|
|
183
|
+
const grown = boundLive(prev + ev.delta);
|
|
184
|
+
state.deltaParts[idx] = grown;
|
|
185
|
+
if (idx === state.deltaTail) {
|
|
186
|
+
// The hot path. deltaTail is by construction the LAST non-blank part, so appending to
|
|
187
|
+
// it appends to the assembly verbatim: no re-join, and no trim() of the whole part
|
|
188
|
+
// either (that flattens the rope V8 builds from the concatenation, which is itself
|
|
189
|
+
// quadratic in message length). A part just trimmed at the cap moved its own seam.
|
|
190
|
+
if (grown === prev + ev.delta) state.deltaLive = boundLive(state.deltaLive + ev.delta);
|
|
191
|
+
else rejoinDeltaParts(state);
|
|
192
|
+
} else if (idx > state.deltaTail && ev.delta.trim()) {
|
|
193
|
+
// Every part past the tail is blank, so this one becomes the new tail and lands at the
|
|
194
|
+
// END of the assembly — an append too.
|
|
195
|
+
state.deltaLive = boundLive(state.deltaLive ? `${state.deltaLive}\n\n${grown}` : grown);
|
|
196
|
+
state.deltaTail = idx;
|
|
197
|
+
} else if (idx < state.deltaTail || grown.trim()) {
|
|
198
|
+
// A late delta for an earlier part, or a blank part promoted by whitespace-then-text:
|
|
199
|
+
// the seams moved, so re-join once.
|
|
200
|
+
rejoinDeltaParts(state);
|
|
201
|
+
}
|
|
202
|
+
// else a blank part stayed blank — excluded from the assembly either way, nothing moved.
|
|
203
|
+
} else if (ev.type === "text_end" || ev.type === "thinking_end") {
|
|
204
|
+
if (typeof ev.content !== "string") return;
|
|
205
|
+
state.deltaParts[idx] = boundLive(ev.content);
|
|
206
|
+
rejoinDeltaParts(state);
|
|
207
|
+
} else return;
|
|
208
|
+
if (state.deltaLive) {
|
|
209
|
+
state.partial = state.deltaLive;
|
|
210
|
+
delete state.activity; // generating, not running a tool
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A short "toolName arg" activity label from a tool_execution_start event. */
|
|
215
|
+
function toolActivity(toolName: string, args: unknown): string {
|
|
216
|
+
if (isObject(args)) {
|
|
217
|
+
const hint = args.path ?? args.pattern ?? args.command ?? args.file ?? args.query ?? args.url;
|
|
218
|
+
if (typeof hint === "string" && hint.trim()) {
|
|
219
|
+
return `${toolName} ${hint.length > 40 ? `${hint.slice(0, 40)}…` : hint}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return toolName;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Bound the live transcript: a long tool-heavy run must not grow memory without limit.
|
|
226
|
+
// When the cap is hit, the OLD head is trimmed (the user watches the tail live).
|
|
227
|
+
const TRANSCRIPT_MAX = 200_000;
|
|
228
|
+
const TRANSCRIPT_KEEP = 150_000;
|
|
229
|
+
|
|
230
|
+
function appendTranscript(state: StreamState, chunk: string, sep: string): void {
|
|
231
|
+
state.transcript += (state.transcript ? sep : "") + chunk;
|
|
232
|
+
if (state.transcript.length > TRANSCRIPT_MAX) {
|
|
233
|
+
state.transcript = `…[earlier output trimmed]\n${state.transcript.slice(state.transcript.length - TRANSCRIPT_KEEP)}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Fold one parsed stream event into the accumulating state (in place), returning a
|
|
238
|
+
* deduplicated structured lifecycle transition when the runtime supplied a stable call id. */
|
|
239
|
+
export function applyEvent(state: StreamState, event: unknown): ToolEvent | undefined {
|
|
240
|
+
if (!isObject(event)) return undefined;
|
|
241
|
+
|
|
242
|
+
// Track the current tool so the UI can show "running: grep src/…" while a
|
|
243
|
+
// tool-heavy agent reads before it has written any text. The tool line also goes
|
|
244
|
+
// into the transcript, so the live log reads chronologically (think → tools → text)
|
|
245
|
+
// instead of tool-heavy stretches leaving the view apparently frozen.
|
|
246
|
+
if (event.type === "tool_execution_start" && typeof event.toolName === "string") {
|
|
247
|
+
state.activity = toolActivity(event.toolName, event.args);
|
|
248
|
+
appendTranscript(state, `⚙ ${state.activity}`, "\n");
|
|
249
|
+
const callId = event.toolCallId;
|
|
250
|
+
if (typeof callId !== "string" || callId.length === 0) return undefined;
|
|
251
|
+
const active = state.activeToolCalls ??= new Set();
|
|
252
|
+
const seenStarts = state.seenToolStarts ??= new Set();
|
|
253
|
+
const seenEnds = state.seenToolEnds ??= new Set();
|
|
254
|
+
if (active.has(callId) || seenStarts.has(callId) || seenEnds.has(callId)) return undefined;
|
|
255
|
+
active.add(callId);
|
|
256
|
+
return { phase: "start", callId, name: event.toolName };
|
|
257
|
+
}
|
|
258
|
+
if (event.type === "tool_execution_end") {
|
|
259
|
+
delete state.activity;
|
|
260
|
+
const callId = event.toolCallId;
|
|
261
|
+
const name = event.toolName;
|
|
262
|
+
if (typeof callId !== "string" || callId.length === 0 || typeof name !== "string") return undefined;
|
|
263
|
+
const seenEnds = state.seenToolEnds ??= new Set();
|
|
264
|
+
if (seenEnds.has(callId)) return undefined;
|
|
265
|
+
const active = state.activeToolCalls ??= new Set();
|
|
266
|
+
const wasActive = active.delete(callId);
|
|
267
|
+
rememberRecent(seenEnds, callId);
|
|
268
|
+
if (wasActive) rememberRecent(state.seenToolStarts ??= new Set(), callId);
|
|
269
|
+
const toolEvent: ToolEvent = { phase: "end", callId, name };
|
|
270
|
+
if (typeof event.isError === "boolean") toolEvent.failed = event.isError;
|
|
271
|
+
return toolEvent;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Live partial: pi streams the growing message as `message_update` events. Surface
|
|
275
|
+
// its text (or reasoning) so a long-thinking agent shows progress, not just tokens.
|
|
276
|
+
// ONLY the assistant's own message counts: pi also emits message_start/message_end for
|
|
277
|
+
// the user prompt it just delivered and for every tool result, and folding those in
|
|
278
|
+
// would make the child's own task echo look like streamed output — cancelling the
|
|
279
|
+
// startup deadline before the first provider call and salvaging the prompt as
|
|
280
|
+
// "partial output" when the leg dies without ever answering.
|
|
281
|
+
if (event.type === "message_update" || event.type === "message_start") {
|
|
282
|
+
if (isObject(event.message)) {
|
|
283
|
+
if (event.message.role !== "assistant") return;
|
|
284
|
+
if (event.type === "message_start") resetDeltaParts(state);
|
|
285
|
+
const live = liveContent(event.message.content);
|
|
286
|
+
if (live !== undefined) {
|
|
287
|
+
state.partial = boundLive(live);
|
|
288
|
+
delete state.activity; // generating, not running a tool
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// pi ≥0.83 strips the cumulative `message` from message_update on the JSON wire —
|
|
293
|
+
// only the deltas remain, so assemble the live text from them instead.
|
|
294
|
+
if (event.type === "message_update" && isObject(event.assistantMessageEvent)) {
|
|
295
|
+
applyMessageDelta(state, event.assistantMessageEvent);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (event.type !== "message_end" || !isObject(event.message)) return;
|
|
301
|
+
const msg = event.message;
|
|
302
|
+
if (msg.role !== "assistant") return;
|
|
303
|
+
|
|
304
|
+
delete state.activity; // a message means it's reasoning, not mid-tool
|
|
305
|
+
state.partial = ""; // the completed message is folded into the transcript below
|
|
306
|
+
resetDeltaParts(state); // the next message's deltas start from an empty slate
|
|
307
|
+
state.sawAssistant = true;
|
|
308
|
+
state.usage.turns++;
|
|
309
|
+
|
|
310
|
+
const text = firstText(msg.content);
|
|
311
|
+
if (text !== undefined) state.output = text;
|
|
312
|
+
// Fold the WHOLE completed message (reasoning + text) into the transcript — not just
|
|
313
|
+
// the text. Otherwise each message_end wipes the thinking the user was reading
|
|
314
|
+
// (`partial` clears) and a tool-heavy agent's view looks like it "overwrites itself".
|
|
315
|
+
const live = liveContent(msg.content);
|
|
316
|
+
if (live) appendTranscript(state, live, "\n\n");
|
|
317
|
+
|
|
318
|
+
const usage = msg.usage;
|
|
319
|
+
if (isObject(usage)) {
|
|
320
|
+
state.usage.input += num(usage.input);
|
|
321
|
+
state.usage.output += num(usage.output);
|
|
322
|
+
state.usage.cacheRead += num(usage.cacheRead);
|
|
323
|
+
state.usage.cacheWrite += num(usage.cacheWrite);
|
|
324
|
+
state.usage.cost += isObject(usage.cost) ? num(usage.cost.total) : 0;
|
|
325
|
+
// contextTokens is the *current* window size, not a sum — replace it, but
|
|
326
|
+
// only when this turn actually reported one (don't zero it out otherwise).
|
|
327
|
+
if (typeof usage.totalTokens === "number" && !Number.isNaN(usage.totalTokens)) {
|
|
328
|
+
state.usage.contextTokens = usage.totalTokens;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (state.model === undefined && typeof msg.model === "string") state.model = msg.model;
|
|
333
|
+
if (typeof msg.stopReason === "string") state.stopReason = msg.stopReason;
|
|
334
|
+
if (typeof msg.errorMessage === "string") state.errorMessage = msg.errorMessage;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A compact live progress view of a running agent (for peek / ProgressView). */
|
|
338
|
+
export interface ProgressSnapshot {
|
|
339
|
+
output: string;
|
|
340
|
+
turns: number;
|
|
341
|
+
tokens: number;
|
|
342
|
+
/** The tool currently running (e.g. "grep src/…"), if any. */
|
|
343
|
+
activity?: string;
|
|
344
|
+
/** One authoritative runtime tool lifecycle transition, when this snapshot is event-driven. */
|
|
345
|
+
toolEvent?: ToolEvent;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function snapshot(state: StreamState, toolEvent?: ToolEvent): ProgressSnapshot {
|
|
349
|
+
const snap: ProgressSnapshot = {
|
|
350
|
+
// The live view shows completed messages (transcript) plus the in-progress one
|
|
351
|
+
// (partial), falling back to the last text before anything has accumulated.
|
|
352
|
+
output: [state.transcript, state.partial].filter((s) => s).join("\n\n") || state.output,
|
|
353
|
+
turns: state.usage.turns,
|
|
354
|
+
tokens: state.usage.input + state.usage.output,
|
|
355
|
+
};
|
|
356
|
+
if (state.activity !== undefined) snap.activity = state.activity;
|
|
357
|
+
if (toolEvent !== undefined) snap.toolEvent = toolEvent;
|
|
358
|
+
return snap;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Buffer a stdout chunk into complete lines, returning the partial remainder. */
|
|
362
|
+
export function feedLines(buffer: string, chunk: string): { lines: string[]; rest: string } {
|
|
363
|
+
const parts = (buffer + chunk).split("\n");
|
|
364
|
+
const rest = parts.pop() ?? "";
|
|
365
|
+
// Strip a trailing CR so CRLF (Windows) output yields clean lines; the partial
|
|
366
|
+
// remainder is left untouched (it may be mid-line).
|
|
367
|
+
const lines = parts.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
368
|
+
return { lines, rest };
|
|
369
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree isolation — run a sub-agent in a throwaway `git worktree` so its file
|
|
3
|
+
* edits / test runs never touch the main working tree (spec §6 `isolation: worktree`).
|
|
4
|
+
* The worktree is detached at HEAD, used, then force-removed (the agent's experiments are
|
|
5
|
+
* discarded — isolation, not persistence; the agent returns its findings as its result).
|
|
6
|
+
*
|
|
7
|
+
* Pure over an injected async `GitExec` (so the lifecycle is unit-tested without a real repo);
|
|
8
|
+
* the default exec shells out to `git` via `child_process.execFile` — asynchronous, so the
|
|
9
|
+
* extension host keeps rendering, ticking timers and serving broker sockets while a large
|
|
10
|
+
* checkout is created or removed. Only the child-process engine uses this (a worktree needs
|
|
11
|
+
* its own cwd, i.e. a separate process).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface GitResult {
|
|
20
|
+
code: number;
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type GitExec = (args: string[]) => Promise<GitResult>;
|
|
26
|
+
|
|
27
|
+
export type WorktreeArtifact =
|
|
28
|
+
| { ok: true; diff: string }
|
|
29
|
+
| { ok: false; error: string };
|
|
30
|
+
|
|
31
|
+
/** Bound the data copied from an isolated checkout into the supervisor context. */
|
|
32
|
+
export const MAX_WORKTREE_ARTIFACT_CHARS = 1_000_000;
|
|
33
|
+
|
|
34
|
+
/** Output buffer for one git invocation — comfortably above MAX_WORKTREE_ARTIFACT_CHARS so the
|
|
35
|
+
* size check below, not the OS pipe, is what rejects an oversized diff. */
|
|
36
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Default git runner — `git <args>`, never throwing (returns the exit code). */
|
|
39
|
+
export const defaultGitExec: GitExec = (args) =>
|
|
40
|
+
new Promise((resolveResult) => {
|
|
41
|
+
execFile("git", args, { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER, windowsHide: true, timeout: 120_000 }, (err, stdout, stderr) => {
|
|
42
|
+
if (!err) {
|
|
43
|
+
resolveResult({ code: 0, stdout, stderr });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const e = err as NodeJS.ErrnoException & { code?: number | string };
|
|
47
|
+
const code = typeof e.code === "number" ? e.code : 1;
|
|
48
|
+
resolveResult({ code, stdout: stdout ?? "", stderr: stderr || e.message });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** The preflight gate for an isolated coding leg. Isolation is a safety boundary: a missing
|
|
53
|
+
* repository or a dirty checkout is not permission to run against the real cwd. */
|
|
54
|
+
export async function worktreePreflight(root: string, exec: GitExec = defaultGitExec): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
55
|
+
const repo = await exec(["-C", root, "rev-parse", "--is-inside-work-tree"]);
|
|
56
|
+
if (repo.code !== 0 || repo.stdout.trim().toLowerCase() === "false") {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: `worktree isolation requires a Git repository at ${root}; use a Git checkout or remove isolation: "worktree"`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const status = await exec(["-C", root, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
63
|
+
if (status.code !== 0) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: `worktree isolation could not inspect Git status at ${root}; commit/stash the checkout or remove isolation: "worktree" (${status.stderr.trim() || `git exited ${status.code}`})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (status.stdout.trim().length > 0) {
|
|
70
|
+
const summary = status.stdout.trim().replace(/\s+/g, " ").slice(0, 240);
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `worktree isolation requires a clean Git checkout; commit/stash current changes (including untracked files) or remove isolation: "worktree" (status: ${summary})`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Capture the complete post-run worktree state as a portable unified diff. Tracked edits use
|
|
80
|
+
* `git diff HEAD`; files newly created by a coding leg are added with `git diff --no-index`.
|
|
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
|
+
const tracked = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", "HEAD", "--"]);
|
|
84
|
+
if (tracked.code !== 0) {
|
|
85
|
+
return { ok: false, error: `could not export the isolated worktree diff: ${tracked.stderr.trim() || `git exited ${tracked.code}`}` };
|
|
86
|
+
}
|
|
87
|
+
if (tracked.stdout.length > MAX_WORKTREE_ARTIFACT_CHARS) {
|
|
88
|
+
return { ok: false, error: `isolated worktree artifact is too large to return safely (limit ${MAX_WORKTREE_ARTIFACT_CHARS} characters)` };
|
|
89
|
+
}
|
|
90
|
+
const listed = await exec(["-C", root, "ls-files", "--others", "--exclude-standard", "-z"]);
|
|
91
|
+
if (listed.code !== 0) {
|
|
92
|
+
return { ok: false, error: `could not enumerate new files in the isolated worktree: ${listed.stderr.trim() || `git exited ${listed.code}`}` };
|
|
93
|
+
}
|
|
94
|
+
const parts = tracked.stdout.trim().length > 0 ? [tracked.stdout] : [];
|
|
95
|
+
let totalChars = tracked.stdout.length;
|
|
96
|
+
for (const name of listed.stdout.split("\0").filter((entry) => entry.length > 0)) {
|
|
97
|
+
const result = await exec(["-C", root, "diff", "--no-ext-diff", "--binary", "--no-index", "--", "/dev/null", name]);
|
|
98
|
+
if (result.code !== 0 && result.code !== 1) {
|
|
99
|
+
return { ok: false, error: `could not export new file ${name}: ${result.stderr.trim() || `git exited ${result.code}`}` };
|
|
100
|
+
}
|
|
101
|
+
totalChars += result.stdout.length;
|
|
102
|
+
if (totalChars > MAX_WORKTREE_ARTIFACT_CHARS) {
|
|
103
|
+
return { ok: false, error: `isolated worktree artifact is too large to return safely (limit ${MAX_WORKTREE_ARTIFACT_CHARS} characters)` };
|
|
104
|
+
}
|
|
105
|
+
if (result.stdout.trim().length > 0) parts.push(result.stdout);
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, diff: parts.join("\n") };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Create a detached worktree off `root`'s HEAD, run `body(worktreeDir)`, then force-remove it. */
|
|
111
|
+
export async function withWorktree<T>(root: string, exec: GitExec, body: (dir: string) => Promise<T>): Promise<T> {
|
|
112
|
+
const preflight = await worktreePreflight(root, exec);
|
|
113
|
+
if (!preflight.ok) throw new Error(preflight.error);
|
|
114
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-persona-wt-"));
|
|
115
|
+
const add = await exec(["-C", root, "worktree", "add", "--detach", dir, "HEAD"]);
|
|
116
|
+
if (add.code !== 0) {
|
|
117
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {
|
|
118
|
+
/* best effort */
|
|
119
|
+
});
|
|
120
|
+
throw new Error(`could not create git worktree: ${add.stderr.trim() || `git exited ${add.code}`}`);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return await body(dir);
|
|
124
|
+
} finally {
|
|
125
|
+
// Remove the worktree registration, then the dir (force: it may carry the agent's edits).
|
|
126
|
+
// Never throw: cleanup must not replace the body's result (or its error) — `defaultGitExec`
|
|
127
|
+
// never rejects, but an injected `GitExec` could, and a rejection here must not either.
|
|
128
|
+
const rejected = (): GitResult => ({ code: 1, stdout: "", stderr: "git exec rejected" });
|
|
129
|
+
const remove = await exec(["-C", root, "worktree", "remove", "--force", dir]).catch(rejected);
|
|
130
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {
|
|
131
|
+
/* the worktree remove usually handles it; ignore races */
|
|
132
|
+
});
|
|
133
|
+
// A failed remove (on Windows a grandchild tool process can still hold a file inside the
|
|
134
|
+
// worktree) leaves the .git/worktrees entry registered while the dir is gone — prune it,
|
|
135
|
+
// or stale ghosts accumulate across delegations until a manual `git worktree prune`.
|
|
136
|
+
// Never throw: cleanup must not replace the body's result (or its error).
|
|
137
|
+
if (remove.code !== 0) {
|
|
138
|
+
await exec(["-C", root, "worktree", "prune"]).catch(rejected);
|
|
139
|
+
if (process.env.PI_PERSONA_DEBUG) {
|
|
140
|
+
process.stderr.write(`[pi-persona] git worktree remove failed for ${dir} (pruned): ${remove.stderr.trim() || `git exited ${remove.code}`}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recover the optional value form Pi's extension-flag API cannot currently expose.
|
|
3
|
+
*
|
|
4
|
+
* Pi registers `exocom` as a boolean so the established bare `--exocom` keeps working.
|
|
5
|
+
* Its CLI parser accepts `--exocom=<value>`, but boolean flag validation intentionally
|
|
6
|
+
* collapses that value to `true`. Read only the exact equals form, before the ordinary
|
|
7
|
+
* `--` option terminator; all enablement still flows through Pi's registered flag/config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ExocomArgSelection {
|
|
11
|
+
joinCode?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CODE = /^[0-9A-Za-z]{4}$/;
|
|
16
|
+
|
|
17
|
+
export function parseExocomArgv(argv: readonly string[]): ExocomArgSelection {
|
|
18
|
+
const explicit: string[] = [];
|
|
19
|
+
for (const arg of argv) {
|
|
20
|
+
if (arg === "--") break;
|
|
21
|
+
if (!arg.startsWith("--exocom=")) continue;
|
|
22
|
+
const value = arg.slice("--exocom=".length);
|
|
23
|
+
if (!CODE.test(value)) {
|
|
24
|
+
return { error: "--exocom join code must be exactly 4 Base62 characters (0-9, A-Z, a-z)" };
|
|
25
|
+
}
|
|
26
|
+
explicit.push(value);
|
|
27
|
+
}
|
|
28
|
+
const unique = [...new Set(explicit)];
|
|
29
|
+
if (unique.length > 1) return { error: "conflicting --exocom join codes" };
|
|
30
|
+
return unique[0] === undefined ? {} : { joinCode: unique[0] };
|
|
31
|
+
}
|