@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +6 -8
- package/extensions/index.ts +279 -14
- package/package.json +1 -1
- package/src/acp/driver.ts +3 -3
- package/src/agents.ts +64 -0
- package/src/agy-paths.ts +33 -0
- package/src/agy-version.ts +137 -0
- package/src/artifacts-ui.ts +86 -0
- package/src/artifacts.ts +144 -0
- package/src/ask-tool.ts +38 -7
- package/src/config.ts +10 -0
- package/src/driver-types.ts +8 -2
- package/src/driver.ts +68 -16
- package/src/models.ts +44 -9
- package/src/native-tools.ts +1 -1
- package/src/parked-turn.ts +125 -0
- package/src/provider.ts +14 -1
- package/src/skills.ts +0 -1
- package/src/stream-events.ts +1 -2
- package/src/subagent-roster.ts +245 -0
- package/src/tasks-ui.ts +88 -0
- package/src/tasks.ts +174 -0
- package/src/usage.ts +174 -0
package/src/driver.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Recycle semantics: the child is killed and respawned when the next turn's
|
|
9
9
|
// process profile (model / effort / mode / cwd / conversation) drifts from the
|
|
10
|
-
// running one
|
|
10
|
+
// running one. Stats and a bounded
|
|
11
11
|
// lifecycle log feed /agy doctor.
|
|
12
12
|
//
|
|
13
13
|
// The driver never talks to the MCP bridge directly: the provider owns the
|
|
@@ -20,6 +20,7 @@ import { parseAgyLine } from "./stream-events.js";
|
|
|
20
20
|
import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guard.js";
|
|
21
21
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
22
22
|
import { gateHooksStaged } from "./approval-hook.js";
|
|
23
|
+
import { parkedTurnAnswer } from "./parked-turn.js";
|
|
23
24
|
import { redactText } from "./redact.js";
|
|
24
25
|
import type {
|
|
25
26
|
AgyUsage,
|
|
@@ -62,6 +63,9 @@ interface ActiveTurn {
|
|
|
62
63
|
response: string;
|
|
63
64
|
usage?: AgyUsage;
|
|
64
65
|
conversationId?: string;
|
|
66
|
+
/** Wall clock at turn creation; the parked-turn probe filters
|
|
67
|
+
* transcript steps to those written after this. */
|
|
68
|
+
startedAt: number;
|
|
65
69
|
sawResult: boolean;
|
|
66
70
|
/** Text-dedupe guard state (delta vs cumulative response_text). */
|
|
67
71
|
cumulativeText: boolean | undefined;
|
|
@@ -71,6 +75,15 @@ interface ActiveTurn {
|
|
|
71
75
|
parks: number;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** The skip-permissions flag actually passed to agy. Plan mode never
|
|
79
|
+
* carries it: the flag auto-approves every permission request including
|
|
80
|
+
* plan mode's own approval gate, silently turning review-only into full
|
|
81
|
+
* write access. #start (argv + profile) and #recycleCause (comparison)
|
|
82
|
+
* must both go through this, or plan turns recycle on every turn. */
|
|
83
|
+
function effectiveSkipPermissions(mode: DriverTurnRequest["mode"], skipPermissions: boolean): boolean {
|
|
84
|
+
return mode !== "plan" && skipPermissions;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
function emit(turn: ActiveTurn, activity: DriverActivity): void {
|
|
75
88
|
if (turn.closed) return;
|
|
76
89
|
if (turn.wake.length > 0) turn.wake.shift()!();
|
|
@@ -171,10 +184,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
171
184
|
// 0 disables the stall guard (config inactivityTimeoutMin: 0).
|
|
172
185
|
if (idleMin > 0) {
|
|
173
186
|
turn.idleTimer = setTimeout(() => {
|
|
174
|
-
|
|
175
|
-
this.#log(`stall:${turn.id}`);
|
|
176
|
-
this.#killChild();
|
|
177
|
-
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
187
|
+
void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
|
|
178
188
|
}, idleMin * 60_000);
|
|
179
189
|
}
|
|
180
190
|
}
|
|
@@ -279,6 +289,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
279
289
|
sawResult: false,
|
|
280
290
|
cumulativeText: undefined,
|
|
281
291
|
parks: 0,
|
|
292
|
+
startedAt: Date.now(),
|
|
282
293
|
};
|
|
283
294
|
if (request.signal) {
|
|
284
295
|
turn.onAbort = () => {
|
|
@@ -306,19 +317,13 @@ export class StreamDriver implements TurnDriver {
|
|
|
306
317
|
const totalMin = turn.request.timeoutMin ?? 10;
|
|
307
318
|
if (totalMin > 0) {
|
|
308
319
|
turn.overallTimer = setTimeout(() => {
|
|
309
|
-
|
|
310
|
-
this.#log(`timeout:${turn.id}`);
|
|
311
|
-
this.#killChild();
|
|
312
|
-
this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
|
|
320
|
+
void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
|
|
313
321
|
}, totalMin * 60_000);
|
|
314
322
|
}
|
|
315
323
|
const idleMin = turn.request.inactivityMin ?? 5;
|
|
316
324
|
if (idleMin > 0) {
|
|
317
325
|
turn.idleTimer = setTimeout(() => {
|
|
318
|
-
|
|
319
|
-
this.#log(`stall:${turn.id}`);
|
|
320
|
-
this.#killChild();
|
|
321
|
-
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
326
|
+
void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
|
|
322
327
|
}, idleMin * 60_000);
|
|
323
328
|
}
|
|
324
329
|
}
|
|
@@ -327,12 +332,21 @@ export class StreamDriver implements TurnDriver {
|
|
|
327
332
|
this.#state = "starting";
|
|
328
333
|
this.#generation += 1;
|
|
329
334
|
const generation = this.#generation;
|
|
335
|
+
// Plan mode never carries the skip flag: the flag auto-approves ALL
|
|
336
|
+
// permission requests including plan mode's own approval gate, which
|
|
337
|
+
// would silently turn "review-only" into full write access (probed
|
|
338
|
+
// 2026-09-25: with the flag a plan session wrote files; without it,
|
|
339
|
+
// file and command attempts end exit 0 in ~20-30s with a "confirm
|
|
340
|
+
// plan" message). The profile stores the EFFECTIVE value so the
|
|
341
|
+
// recycle comparison stays consistent with the argv.
|
|
342
|
+
const skipPermissions = effectiveSkipPermissions(request.mode, request.skipPermissions);
|
|
330
343
|
this.#profile = {
|
|
331
344
|
cwd: request.cwd,
|
|
332
345
|
model: request.model,
|
|
333
346
|
effort: request.effort,
|
|
334
347
|
mode: request.mode,
|
|
335
|
-
skipPermissions
|
|
348
|
+
skipPermissions,
|
|
349
|
+
agent: request.agent,
|
|
336
350
|
};
|
|
337
351
|
this.#boundConversation = request.conversationId ?? undefined;
|
|
338
352
|
this.#stderrTail = "";
|
|
@@ -346,7 +360,8 @@ export class StreamDriver implements TurnDriver {
|
|
|
346
360
|
args.push("--model", request.model);
|
|
347
361
|
if (request.effort) args.push("--effort", request.effort);
|
|
348
362
|
args.push("--mode", request.mode);
|
|
349
|
-
if (request.
|
|
363
|
+
if (request.agent) args.push("--agent", request.agent);
|
|
364
|
+
if (skipPermissions) args.push("--dangerously-skip-permissions");
|
|
350
365
|
if (request.conversationId) args.push("--conversation", request.conversationId);
|
|
351
366
|
args.push(
|
|
352
367
|
"--input-format",
|
|
@@ -607,6 +622,40 @@ export class StreamDriver implements TurnDriver {
|
|
|
607
622
|
}
|
|
608
623
|
}
|
|
609
624
|
|
|
625
|
+
/** Stall / total-timeout guard shared by both timer sites. Kills the
|
|
626
|
+
* child FIRST (bumping the generation so a racing exit handler cannot
|
|
627
|
+
* settle the turn ERROR mid-probe), then probes the conversation's
|
|
628
|
+
* brain transcript for a clean final-response step written during the
|
|
629
|
+
* turn (parked turn: agy finished its answer but never streamed it).
|
|
630
|
+
* One found, the turn settles OK with the withheld answer instead of
|
|
631
|
+
* discarding finished work; either way the next turn respawns into a
|
|
632
|
+
* known state. */
|
|
633
|
+
async #turnDeadlineGuard(turn: ActiveTurn, label: string, message: string): Promise<void> {
|
|
634
|
+
if (turn.closed) return;
|
|
635
|
+
this.#log(`${label}:${turn.id}`);
|
|
636
|
+
this.#killChild();
|
|
637
|
+
let parked: string | undefined;
|
|
638
|
+
try {
|
|
639
|
+
parked = turn.conversationId
|
|
640
|
+
? await parkedTurnAnswer(turn.conversationId, turn.startedAt)
|
|
641
|
+
: undefined;
|
|
642
|
+
} catch {
|
|
643
|
+
parked = undefined; // the guard must never throw
|
|
644
|
+
}
|
|
645
|
+
if (parked !== undefined) {
|
|
646
|
+
this.#settle(turn, {
|
|
647
|
+
conversationId: turn.conversationId,
|
|
648
|
+
status: "OK",
|
|
649
|
+
response: parked,
|
|
650
|
+
usage: turn.usage,
|
|
651
|
+
finished: true,
|
|
652
|
+
aborted: false,
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.#failTurn(turn, message);
|
|
657
|
+
}
|
|
658
|
+
|
|
610
659
|
#failTurn(turn: ActiveTurn, message: string): void {
|
|
611
660
|
this.#settle(turn, {
|
|
612
661
|
conversationId: turn.conversationId,
|
|
@@ -626,7 +675,10 @@ export class StreamDriver implements TurnDriver {
|
|
|
626
675
|
if (cur.model !== next.model) return "model";
|
|
627
676
|
if (cur.effort !== next.effort) return "effort";
|
|
628
677
|
if (cur.mode !== next.mode) return "mode";
|
|
629
|
-
if (cur.skipPermissions !== next.skipPermissions)
|
|
678
|
+
if (cur.skipPermissions !== effectiveSkipPermissions(next.mode, next.skipPermissions)) {
|
|
679
|
+
return "permissions";
|
|
680
|
+
}
|
|
681
|
+
if (cur.agent !== next.agent) return "agent";
|
|
630
682
|
if (!next.conversationId) return this.#boundConversation ? "conversation-reset" : undefined;
|
|
631
683
|
return next.conversationId === this.#boundConversation ? undefined : "conversation";
|
|
632
684
|
}
|
package/src/models.ts
CHANGED
|
@@ -25,6 +25,14 @@ import type { Api, Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-work
|
|
|
25
25
|
|
|
26
26
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
27
27
|
|
|
28
|
+
/** Stdout ceiling for `agy models`. Real output is a few KB of ASCII slugs;
|
|
29
|
+
* anything past 1 MiB is a runaway process. Fail closed to "" so the
|
|
30
|
+
* fallback catalog engages instead of letting the buffer grow without
|
|
31
|
+
* limit. The check counts decoded string length (UTF-16 code units), not
|
|
32
|
+
* raw bytes — a deliberate proxy, since the margin between a few KB of
|
|
33
|
+
* real output and 1 MiB dwarfs any encoding difference. */
|
|
34
|
+
export const MODELS_OUTPUT_CAP_BYTES = 1024 * 1024;
|
|
35
|
+
|
|
28
36
|
/** Reasoning-effort tiers agy accepts via --effort. */
|
|
29
37
|
export type AgyEffort = "low" | "medium" | "high";
|
|
30
38
|
|
|
@@ -104,28 +112,50 @@ export interface AgyModelEntry {
|
|
|
104
112
|
efforts?: AgyEffort[];
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
/** Spawn
|
|
108
|
-
* failure (non-zero exit, spawn error,
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
115
|
+
/** Spawn a read-only agy listing subcommand and return its raw stdout text.
|
|
116
|
+
* Returns "" on any failure (non-zero exit, spawn error, watchdog timeout,
|
|
117
|
+
* or output cap). Bounded by timeoutMs (default DISCOVERY_TIMEOUT_MS) so a
|
|
118
|
+
* hung agy (auth prompt, network stall) can't block the caller, and by
|
|
119
|
+
* capBytes (default MODELS_OUTPUT_CAP_BYTES) so a runaway agy can't grow
|
|
120
|
+
* the buffer without limit. Shared by the model catalog (spawned ONCE per
|
|
121
|
+
* load, provider + tool catalog), the agent roster, and the quota fetch
|
|
122
|
+
* (which passes its own 30s budget: /usage answers in ~10s live). */
|
|
123
|
+
export async function spawnAgyRaw(
|
|
124
|
+
binary: string,
|
|
125
|
+
args: string[],
|
|
126
|
+
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
|
127
|
+
capBytes: number = MODELS_OUTPUT_CAP_BYTES,
|
|
128
|
+
): Promise<string> {
|
|
113
129
|
try {
|
|
114
130
|
return await new Promise<string>((resolve, reject) => {
|
|
115
|
-
const proc = spawn(binary,
|
|
131
|
+
const proc = spawn(binary, args, {
|
|
116
132
|
stdio: ["ignore", "pipe", "ignore"],
|
|
117
133
|
shell: false,
|
|
118
134
|
});
|
|
119
135
|
proc.stdout?.setEncoding("utf8");
|
|
120
136
|
let out = "";
|
|
121
137
|
let done = false;
|
|
138
|
+
let capped = false;
|
|
122
139
|
const finish = (v: string) => {
|
|
123
140
|
if (done) return;
|
|
124
141
|
done = true;
|
|
125
142
|
clearTimeout(watchdog);
|
|
126
143
|
resolve(v);
|
|
127
144
|
};
|
|
128
|
-
proc.stdout?.on("data", (d: string) =>
|
|
145
|
+
proc.stdout?.on("data", (d: string) => {
|
|
146
|
+
if (capped) return;
|
|
147
|
+
if (out.length + d.length > capBytes) {
|
|
148
|
+
capped = true;
|
|
149
|
+
try {
|
|
150
|
+
proc.kill("SIGKILL");
|
|
151
|
+
} catch {
|
|
152
|
+
/* already gone */
|
|
153
|
+
}
|
|
154
|
+
finish("");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
out += d;
|
|
158
|
+
});
|
|
129
159
|
proc.on("error", (err) => {
|
|
130
160
|
clearTimeout(watchdog);
|
|
131
161
|
reject(err);
|
|
@@ -138,13 +168,18 @@ export async function spawnAgyModelsRaw(binary: string): Promise<string> {
|
|
|
138
168
|
/* already gone */
|
|
139
169
|
}
|
|
140
170
|
finish("");
|
|
141
|
-
},
|
|
171
|
+
}, timeoutMs);
|
|
142
172
|
});
|
|
143
173
|
} catch {
|
|
144
174
|
return "";
|
|
145
175
|
}
|
|
146
176
|
}
|
|
147
177
|
|
|
178
|
+
/** Spawn `agy models`: the model catalog listing. */
|
|
179
|
+
export async function spawnAgyModelsRaw(binary: string): Promise<string> {
|
|
180
|
+
return spawnAgyRaw(binary, ["models"]);
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
// --- catalog cache ----------------------------------------------------------
|
|
149
184
|
//
|
|
150
185
|
// `agy models` can take seconds (OAuth refresh, cold start) and its output
|
package/src/native-tools.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Mutating tools (commands, edits, writes) and agy-specialty tools are never
|
|
7
7
|
// re-executed - they replay through the display-only `antigravity` wrapper
|
|
8
|
-
// tool.
|
|
8
|
+
// tool.
|
|
9
9
|
|
|
10
10
|
export interface NativeToolCall {
|
|
11
11
|
/** pi builtin tool name (`read`, `ls`, `grep`, `find`). */
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Parked-turn stall probe (stream-json engine only).
|
|
2
|
+
//
|
|
3
|
+
// The stall guard fails a turn after N minutes of stdout silence. But agy
|
|
4
|
+
// sometimes finishes its answer without streaming it (parked turn): the
|
|
5
|
+
// response only ever lands in the conversation's brain transcript. Before
|
|
6
|
+
// failing, this module reads the transcript tail and looks for a DONE,
|
|
7
|
+
// MODEL-sourced final-response step written DURING the turn. One found, the
|
|
8
|
+
// driver can settle the turn OK with the withheld answer instead of
|
|
9
|
+
// throwing away finished work.
|
|
10
|
+
//
|
|
11
|
+
// Live-verified format (agy 1.2.10, transcript_full.jsonl last line):
|
|
12
|
+
// {"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE",
|
|
13
|
+
// "status":"DONE","created_at":"2026-09-08T16:11:43Z","content":"..."}
|
|
14
|
+
// A corpus sweep (2026-09-24, 9205 response steps across all local
|
|
15
|
+
// transcripts) shows PLANNER_RESPONSE is the only *RESPONSE type agy
|
|
16
|
+
// writes, so the gate matches it exactly. A deeper sweep during the agy
|
|
17
|
+
// peer review (2026-09-25, 9547 steps) found 96.6% of PLANNER_RESPONSE
|
|
18
|
+
// steps carry tool_calls — they are intermediate plan-and-act steps whose
|
|
19
|
+
// content is status text ("Running test..."), NOT answers. The probe
|
|
20
|
+
// therefore requires the NEWEST step written during the turn to be a
|
|
21
|
+
// clean, tool-free response; any other newer step means the turn was
|
|
22
|
+
// still mid-flight. If a future agy renames the type, the probe degrades
|
|
23
|
+
// to the old stall-fail — the safe direction.
|
|
24
|
+
// The ACP engine writes no transcript under its brain tree (probed
|
|
25
|
+
// 2026-09-24) and reports turn completion itself, so this probe is
|
|
26
|
+
// deliberately stream-json-only.
|
|
27
|
+
|
|
28
|
+
import { open } from "node:fs/promises";
|
|
29
|
+
import { agyConversationDir } from "./agy-paths.js";
|
|
30
|
+
|
|
31
|
+
/** How much of the transcript tail to read. One final step is a few KiB;
|
|
32
|
+
* 512KiB covers it plus slack for big tool-step lines above it. */
|
|
33
|
+
export const PARKED_PROBE_MAX_BYTES = 512 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Clock tolerance for "written during the turn": transcript timestamps are
|
|
36
|
+
* second-precision and may come from a slightly skewed source. */
|
|
37
|
+
const CREATED_AT_TOLERANCE_MS = 2_000;
|
|
38
|
+
|
|
39
|
+
/** Pull the parked answer out of a transcript tail. Scans from the END;
|
|
40
|
+
* the FIRST step written during the turn decides: a clean, tool-free DONE
|
|
41
|
+
* MODEL response yields its content; any other step from the turn (tool
|
|
42
|
+
* output, error, plan step with tool_calls) means no answer landed and
|
|
43
|
+
* the probe returns undefined. Steps older than the turn (clock tolerance
|
|
44
|
+
* 2s) end the scan. Malformed or foreign lines are skipped without
|
|
45
|
+
* deciding. Pure; exported for tests. */
|
|
46
|
+
export function extractParkedAnswer(
|
|
47
|
+
tail: string,
|
|
48
|
+
turnStartedMs: number,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
const lines = tail.split("\n");
|
|
51
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
52
|
+
const line = lines[i]?.trim();
|
|
53
|
+
if (!line) continue;
|
|
54
|
+
let step: {
|
|
55
|
+
source?: unknown;
|
|
56
|
+
type?: unknown;
|
|
57
|
+
status?: unknown;
|
|
58
|
+
created_at?: unknown;
|
|
59
|
+
content?: unknown;
|
|
60
|
+
tool_calls?: unknown;
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
step = JSON.parse(line) as typeof step;
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (typeof step.created_at !== "string") continue;
|
|
68
|
+
const createdAtMs = Date.parse(step.created_at);
|
|
69
|
+
if (Number.isNaN(createdAtMs)) continue;
|
|
70
|
+
if (createdAtMs < turnStartedMs - CREATED_AT_TOLERANCE_MS) break;
|
|
71
|
+
|
|
72
|
+
// The newest step written during this turn must be a completed MODEL
|
|
73
|
+
// response without tool calls. Intermediate tool steps, errors, or
|
|
74
|
+
// non-terminal responses mean the turn did not finish with a parked answer.
|
|
75
|
+
if (step.source !== "MODEL" || step.status !== "DONE") return undefined;
|
|
76
|
+
if (step.type !== "PLANNER_RESPONSE") return undefined;
|
|
77
|
+
if (Array.isArray(step.tool_calls) && step.tool_calls.length > 0) return undefined;
|
|
78
|
+
if (typeof step.content !== "string" || step.content.length === 0) return undefined;
|
|
79
|
+
return step.content;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read at most `maxBytes` from the end of a file. Empty string on any
|
|
85
|
+
* error (missing file, permission, ...) — the caller degrades to the old
|
|
86
|
+
* stall-fail behavior. */
|
|
87
|
+
export async function readTranscriptTail(filePath: string, maxBytes: number): Promise<string> {
|
|
88
|
+
let handle;
|
|
89
|
+
try {
|
|
90
|
+
handle = await open(filePath, "r");
|
|
91
|
+
} catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const { size } = await handle.stat();
|
|
96
|
+
const start = Math.max(0, size - maxBytes);
|
|
97
|
+
const length = size - start;
|
|
98
|
+
const buffer = Buffer.alloc(length);
|
|
99
|
+
let read = 0;
|
|
100
|
+
while (read < length) {
|
|
101
|
+
const { bytesRead } = await handle.read(buffer, read, length - read, start + read);
|
|
102
|
+
if (bytesRead === 0) break;
|
|
103
|
+
read += bytesRead;
|
|
104
|
+
}
|
|
105
|
+
return buffer.subarray(0, read).toString("utf8");
|
|
106
|
+
} catch {
|
|
107
|
+
return "";
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Probe one stream-json conversation's transcript for a parked answer.
|
|
114
|
+
* Undefined whenever nothing usable is found (missing transcript, no DONE
|
|
115
|
+
* step from this turn) — never throws; the stall guard must not fail. */
|
|
116
|
+
export async function parkedTurnAnswer(
|
|
117
|
+
conversationId: string,
|
|
118
|
+
turnStartedMs: number,
|
|
119
|
+
home: string | undefined = undefined,
|
|
120
|
+
): Promise<string | undefined> {
|
|
121
|
+
const transcript = `${agyConversationDir("stream-json", conversationId, home)}/.system_generated/logs/transcript_full.jsonl`;
|
|
122
|
+
const tail = await readTranscriptTail(transcript, PARKED_PROBE_MAX_BYTES);
|
|
123
|
+
if (tail === "") return undefined;
|
|
124
|
+
return extractParkedAnswer(tail, turnStartedMs);
|
|
125
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import fs from "node:fs";
|
|
34
34
|
import type { Api } from "@earendil-works/pi-ai";
|
|
35
35
|
import type { DriverActivity, TurnDriver, TurnHandle } from "./driver-types.js";
|
|
36
|
+
import { SubagentRoster } from "./subagent-roster.js";
|
|
36
37
|
import { toPiUsage } from "./stream-events.js";
|
|
37
38
|
import { mapAgyToolToNative } from "./native-tools.js";
|
|
38
39
|
import { toAgyEffort, type AgyModelEntry } from "./models.js";
|
|
@@ -349,6 +350,8 @@ export interface StreamSimpleDeps {
|
|
|
349
350
|
/** Output-only ACP tool events; persisted as TUI entries, never sent to
|
|
350
351
|
* Antigravity or dispatched through Pi's executable tools. */
|
|
351
352
|
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
353
|
+
/** Subagent roster (both engines); folded from every activity. */
|
|
354
|
+
roster?: SubagentRoster;
|
|
352
355
|
/** Daily file log sink (src/daily-log.ts). Records pre-dispatch turn
|
|
353
356
|
* errors that never create a driver turn (and so never reach onTurnEnd). */
|
|
354
357
|
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
@@ -916,6 +919,8 @@ export interface DriverDeps {
|
|
|
916
919
|
replay?: WrapperReplay;
|
|
917
920
|
nativeActive?: (name: string) => boolean;
|
|
918
921
|
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
922
|
+
/** Subagent roster fold target (both engines; best-effort telemetry). */
|
|
923
|
+
roster?: SubagentRoster;
|
|
919
924
|
/** Active engine (config), for engine-scoped session keys. */
|
|
920
925
|
engine: "stream-json" | "acp";
|
|
921
926
|
/** Daily file log sink for pre-dispatch errors (see StreamSimpleDeps). */
|
|
@@ -930,6 +935,7 @@ export interface ActivityFeatures {
|
|
|
930
935
|
roundTrips?: ToolRoundTrips;
|
|
931
936
|
engine?: "stream-json" | "acp";
|
|
932
937
|
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
938
|
+
roster?: SubagentRoster;
|
|
933
939
|
}
|
|
934
940
|
|
|
935
941
|
/** Process-wide counter: round-trip ids must never repeat across turns in
|
|
@@ -976,6 +982,10 @@ export function consumeActivity(
|
|
|
976
982
|
feats: ActivityFeatures,
|
|
977
983
|
): "parked" | "continue" {
|
|
978
984
|
const partial = blocks.partial;
|
|
985
|
+
// Subagent roster folds BEFORE any rendering branch: pure in-memory
|
|
986
|
+
// telemetry over the same activities both engines emit, and it must see
|
|
987
|
+
// spawn/message/manage steps regardless of how they render.
|
|
988
|
+
feats.roster?.fold(activity);
|
|
979
989
|
switch (activity.type) {
|
|
980
990
|
case "text":
|
|
981
991
|
appendText(stream, blocks, activity.delta);
|
|
@@ -1232,6 +1242,7 @@ async function runTurnDriver(
|
|
|
1232
1242
|
effort,
|
|
1233
1243
|
mode: config.mode,
|
|
1234
1244
|
skipPermissions: config.skipPermissions,
|
|
1245
|
+
agent: config.agent,
|
|
1235
1246
|
timeoutMin: config.turnTimeoutMin,
|
|
1236
1247
|
inactivityMin: config.inactivityTimeoutMin,
|
|
1237
1248
|
conversationId: existing?.conversationId ?? null,
|
|
@@ -1261,6 +1272,7 @@ async function runTurnDriver(
|
|
|
1261
1272
|
roundTrips: deps.roundTrips,
|
|
1262
1273
|
engine: deps.engine,
|
|
1263
1274
|
onNativeEvent: deps.onNativeEvent,
|
|
1275
|
+
roster: deps.roster,
|
|
1264
1276
|
};
|
|
1265
1277
|
|
|
1266
1278
|
for (;;) {
|
|
@@ -1285,7 +1297,7 @@ async function runTurnDriver(
|
|
|
1285
1297
|
finalize(stream, blocks, "error", outcome.error ?? "agy turn failed");
|
|
1286
1298
|
return;
|
|
1287
1299
|
}
|
|
1288
|
-
if (blocks.textIdx === null &&
|
|
1300
|
+
if (blocks.textIdx === null && outcome.response) {
|
|
1289
1301
|
appendText(stream, blocks, outcome.response);
|
|
1290
1302
|
}
|
|
1291
1303
|
if (blocks.textIdx === null && blocks.thinkingIdx === null) {
|
|
@@ -1328,6 +1340,7 @@ export function createStreamSimple(
|
|
|
1328
1340
|
replay: deps.replay,
|
|
1329
1341
|
nativeActive: deps.nativeActive,
|
|
1330
1342
|
onNativeEvent: deps.onNativeEvent,
|
|
1343
|
+
roster: deps.roster,
|
|
1331
1344
|
// Record the engine of the driver that will ACTUALLY run: if the
|
|
1332
1345
|
// ACP driver is absent, the config switch falls back to stream,
|
|
1333
1346
|
// and keying the session as @acp would store a stream
|
package/src/skills.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// can tell when a skill applies. Calling it returns the full SKILL.md plus the
|
|
6
6
|
// bundled resource dir. Nothing is appended to the prompt: agy sees the
|
|
7
7
|
// catalog in tools/list on every spawn, including after pi compaction.
|
|
8
|
-
// Shape borrowed from tianzuo/pi-antigravity lib/skills.ts (MIT).
|
|
9
8
|
|
|
10
9
|
import fs from "node:fs";
|
|
11
10
|
import os from "node:os";
|
package/src/stream-events.ts
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// `agy --input-format stream-json --output-format stream-json` emits one JSON
|
|
4
4
|
// object per line on stdout: `init` (conversation binding), `step_update`
|
|
5
5
|
// (user_input / agent_response / checkpoint / tool steps), `result` (terminal).
|
|
6
|
-
// Shapes captured from live output
|
|
7
|
-
// tianzuo/pi-antigravity lib/events.ts (MIT). Unknown event kinds parse as
|
|
6
|
+
// Shapes captured from live output. Unknown event kinds parse as
|
|
8
7
|
// {kind:"unknown"} so a future agy release degrades instead of crashing the
|
|
9
8
|
// reader loop.
|
|
10
9
|
|