@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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 +11 -2
- 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/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
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// In-memory subagent roster, folded from driver activities.
|
|
2
|
+
//
|
|
3
|
+
// agy has no read-only subcommand for live subagent state, so the only
|
|
4
|
+
// zero-token source is the turn stream: subagent spawns arrive as ordinary
|
|
5
|
+
// tool steps (invoke_subagent, run_subagent, define_subagent,
|
|
6
|
+
// browser_subagent), send_message addresses a spawned agent, and
|
|
7
|
+
// manage_subagents carries lifecycle actions. The roster folds
|
|
8
|
+
// DriverActivity — the engine-agnostic contract both turn engines emit — so
|
|
9
|
+
// stream-json (stepId-keyed) and ACP (toolCallId-keyed) feed one roster.
|
|
10
|
+
//
|
|
11
|
+
// Accepted heuristic limits (telemetry only, never persisted — agy's own
|
|
12
|
+
// conversation database remains the durable record):
|
|
13
|
+
// - send_message routes to the FIRST entry with the matching agent name
|
|
14
|
+
// regardless of status, so a message after a name is reused may credit a
|
|
15
|
+
// stale entry.
|
|
16
|
+
// - manage_subagents kill marks ALL running entries killed; the target arg
|
|
17
|
+
// is ignored because its shape is not stable across agy builds.
|
|
18
|
+
// - Parallel spawns with the same agent name are separate entries, but
|
|
19
|
+
// name-based routing cannot tell them apart.
|
|
20
|
+
|
|
21
|
+
import type { DriverActivity } from "./driver-types.js";
|
|
22
|
+
|
|
23
|
+
/** Tools whose start opens a roster entry and whose done/error closes it. */
|
|
24
|
+
const SPAWN_TOOLS = new Set(["invoke_subagent", "run_subagent", "browser_subagent"]);
|
|
25
|
+
/** Tools addressed at an already-spawned agent. */
|
|
26
|
+
const MESSAGE_TOOLS = new Set(["send_message"]);
|
|
27
|
+
/** Tools carrying lifecycle actions (kill/stop/...). */
|
|
28
|
+
const MANAGE_TOOLS = new Set(["manage_subagents"]);
|
|
29
|
+
|
|
30
|
+
/** Arg keys that plausibly name the target agent (their casing varies). */
|
|
31
|
+
const NAME_ARG_KEYS = /^(name|subagent|subagent_name|agent|agent_name|type|recipient|role|to)$/i;
|
|
32
|
+
/** Arg keys plausibly holding a manage_subagents lifecycle action. */
|
|
33
|
+
const ACTION_ARG_KEYS = /^(action|command|operation|op)$/i;
|
|
34
|
+
const DETAIL_MAX = 120;
|
|
35
|
+
|
|
36
|
+
export interface SubagentEntry {
|
|
37
|
+
/** Stable fold key: `s:<stepId>` on stream-json, `n:<seq>` on ACP. */
|
|
38
|
+
key: string;
|
|
39
|
+
name: string;
|
|
40
|
+
status: "running" | "done" | "error" | "killed";
|
|
41
|
+
spawnedAtMs: number;
|
|
42
|
+
lastActivityMs: number;
|
|
43
|
+
/** send_message count since spawn. */
|
|
44
|
+
messages: number;
|
|
45
|
+
/** First non-name string arg, truncated — usually the task text. */
|
|
46
|
+
detail: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function truncate(value: string): string {
|
|
51
|
+
return value.length > DETAIL_MAX ? `${value.slice(0, DETAIL_MAX)}…` : value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** First string arg whose key plausibly names the agent. */
|
|
55
|
+
function subagentNameFromArgs(args: Record<string, unknown>): string {
|
|
56
|
+
for (const [k, v] of Object.entries(args)) {
|
|
57
|
+
if (NAME_ARG_KEYS.test(k) && typeof v === "string" && v.trim()) return truncate(v.trim());
|
|
58
|
+
}
|
|
59
|
+
return "subagent";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** First string arg that is not the name — the task text, when agy passes one. */
|
|
63
|
+
function detailFromArgs(args: Record<string, unknown>): string {
|
|
64
|
+
for (const [k, v] of Object.entries(args)) {
|
|
65
|
+
if (!NAME_ARG_KEYS.test(k) && typeof v === "string" && v.trim()) return truncate(v.trim());
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ExtractedSpawn {
|
|
71
|
+
name: string;
|
|
72
|
+
detail: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractSpawnsFromArgs(args: Record<string, unknown>): ExtractedSpawn[] {
|
|
76
|
+
const raw = args.Subagents ?? args.subagents;
|
|
77
|
+
if (Array.isArray(raw) && raw.length > 0) {
|
|
78
|
+
const out: ExtractedSpawn[] = [];
|
|
79
|
+
for (const item of raw) {
|
|
80
|
+
if (item && typeof item === "object") {
|
|
81
|
+
const rec = item as Record<string, unknown>;
|
|
82
|
+
const name =
|
|
83
|
+
(typeof rec.Role === "string" && rec.Role.trim()) ||
|
|
84
|
+
(typeof rec.role === "string" && rec.role.trim()) ||
|
|
85
|
+
(typeof rec.TypeName === "string" && rec.TypeName.trim()) ||
|
|
86
|
+
(typeof rec.type_name === "string" && rec.type_name.trim()) ||
|
|
87
|
+
(typeof rec.name === "string" && rec.name.trim()) ||
|
|
88
|
+
"subagent";
|
|
89
|
+
const detail =
|
|
90
|
+
(typeof rec.Prompt === "string" && rec.Prompt.trim()) ||
|
|
91
|
+
(typeof rec.prompt === "string" && rec.prompt.trim()) ||
|
|
92
|
+
(typeof rec.task === "string" && rec.task.trim()) ||
|
|
93
|
+
"";
|
|
94
|
+
out.push({ name: truncate(name), detail: truncate(detail) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (out.length > 0) return out;
|
|
98
|
+
}
|
|
99
|
+
return [{ name: subagentNameFromArgs(args), detail: detailFromArgs(args) }];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function manageActionIsKill(args: Record<string, unknown>): boolean {
|
|
103
|
+
for (const [k, v] of Object.entries(args)) {
|
|
104
|
+
if (ACTION_ARG_KEYS.test(k) && typeof v === "string" && /kill|stop|terminate|cancel/i.test(v))
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class SubagentRoster {
|
|
111
|
+
#entries = new Map<string, SubagentEntry>();
|
|
112
|
+
#acpSeq = 0;
|
|
113
|
+
|
|
114
|
+
/** Fold key precedence: ACP toolCallId (stable protocol id), stream-json
|
|
115
|
+
* numeric stepId, then a process-local sequence for hypothetical engines
|
|
116
|
+
* with neither. */
|
|
117
|
+
#keyFor(activity: Extract<DriverActivity, { type: "tool_start" }>): string {
|
|
118
|
+
if (activity.toolCallId !== undefined) return `t:${activity.toolCallId}`;
|
|
119
|
+
if (activity.stepId !== undefined) return `s:${activity.stepId}`;
|
|
120
|
+
return `n:${this.#acpSeq++}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#entriesFor(activity: Extract<DriverActivity, { type: "tool_done" | "tool_error" }>): SubagentEntry[] {
|
|
124
|
+
const baseKey =
|
|
125
|
+
activity.toolCallId !== undefined
|
|
126
|
+
? `t:${activity.toolCallId}`
|
|
127
|
+
: activity.stepId !== undefined
|
|
128
|
+
? `s:${activity.stepId}`
|
|
129
|
+
: undefined;
|
|
130
|
+
if (baseKey !== undefined) {
|
|
131
|
+
const matched: SubagentEntry[] = [];
|
|
132
|
+
for (const [k, v] of this.#entries) {
|
|
133
|
+
if (k === baseKey || k.startsWith(`${baseKey}:`)) matched.push(v);
|
|
134
|
+
}
|
|
135
|
+
return matched;
|
|
136
|
+
}
|
|
137
|
+
for (const entry of this.#entries.values()) {
|
|
138
|
+
if (entry.status === "running") return [entry];
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Fold one driver activity. Non-tool activities are ignored; unknown
|
|
144
|
+
* tool names are other features' business. Never throws: roster
|
|
145
|
+
* bookkeeping must not be able to fail a turn. */
|
|
146
|
+
fold(activity: DriverActivity): void {
|
|
147
|
+
try {
|
|
148
|
+
this.#fold(activity);
|
|
149
|
+
} catch {
|
|
150
|
+
/* roster is best-effort telemetry */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#fold(activity: DriverActivity): void {
|
|
155
|
+
if (activity.type === "tool_start") {
|
|
156
|
+
if (SPAWN_TOOLS.has(activity.name)) {
|
|
157
|
+
const baseKey = this.#keyFor(activity);
|
|
158
|
+
const spawns = extractSpawnsFromArgs(activity.args);
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
for (let i = 0; i < spawns.length; i++) {
|
|
161
|
+
const key = spawns.length === 1 ? baseKey : `${baseKey}:${i}`;
|
|
162
|
+
this.#entries.set(key, {
|
|
163
|
+
key,
|
|
164
|
+
name: spawns[i]!.name,
|
|
165
|
+
status: "running",
|
|
166
|
+
spawnedAtMs: now,
|
|
167
|
+
lastActivityMs: now,
|
|
168
|
+
messages: 0,
|
|
169
|
+
detail: spawns[i]!.detail,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (MESSAGE_TOOLS.has(activity.name)) {
|
|
175
|
+
const entry = this.#entryByName(subagentNameFromArgs(activity.args));
|
|
176
|
+
if (entry) {
|
|
177
|
+
entry.messages += 1;
|
|
178
|
+
entry.lastActivityMs = Date.now();
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (MANAGE_TOOLS.has(activity.name) && manageActionIsKill(activity.args)) {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
for (const entry of this.#entries.values()) {
|
|
185
|
+
if (entry.status === "running") {
|
|
186
|
+
entry.status = "killed";
|
|
187
|
+
entry.lastActivityMs = now;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (activity.type === "tool_done" || activity.type === "tool_error") {
|
|
194
|
+
const entries = this.#entriesFor(activity);
|
|
195
|
+
if (entries.length === 0) return;
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
entry.lastActivityMs = now;
|
|
199
|
+
if (activity.type === "tool_error") {
|
|
200
|
+
entry.status = "error";
|
|
201
|
+
entry.error = truncate(activity.message);
|
|
202
|
+
} else if (entry.status === "running") {
|
|
203
|
+
entry.status = "done";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#entryByName(name: string, ...statuses: SubagentEntry["status"][]): SubagentEntry | undefined {
|
|
210
|
+
for (const entry of this.#entries.values()) {
|
|
211
|
+
if (entry.name === name && (statuses.length === 0 || statuses.includes(entry.status)))
|
|
212
|
+
return entry;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Entries in spawn order (Map preserves insertion). */
|
|
218
|
+
snapshot(): SubagentEntry[] {
|
|
219
|
+
return [...this.#entries.values()];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Live wire for status surfaces. */
|
|
223
|
+
runningCount(): number {
|
|
224
|
+
let n = 0;
|
|
225
|
+
for (const entry of this.#entries.values()) if (entry.status === "running") n += 1;
|
|
226
|
+
return n;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function formatSubagentRoster(entries: SubagentEntry[]): string {
|
|
231
|
+
const running = entries.filter((e) => e.status === "running").length;
|
|
232
|
+
const lines = [`antigravity subagents: ${entries.length} tracked, ${running} running`];
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
const secs = Math.max(0, Math.round((Date.now() - entry.spawnedAtMs) / 1000));
|
|
235
|
+
const dur = secs >= 60 ? `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, "0")}s` : `${secs}s`;
|
|
236
|
+
const parts = [
|
|
237
|
+
`${entry.name} · ${entry.status} · ${dur}`,
|
|
238
|
+
entry.messages > 0 ? `${entry.messages} msg` : undefined,
|
|
239
|
+
entry.detail || undefined,
|
|
240
|
+
entry.error ? `error: ${entry.error}` : undefined,
|
|
241
|
+
].filter((p): p is string => p !== undefined);
|
|
242
|
+
lines.push(`- ${parts.join(" · ")}`);
|
|
243
|
+
}
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
package/src/tasks-ui.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// /agy tasks overlay: SelectList over one conversation's background tasks.
|
|
2
|
+
//
|
|
3
|
+
// UI mirrors the artifacts browser (engine-picker pattern). Watch-only:
|
|
4
|
+
// enter closes (the list IS the dashboard), r rescans, esc closes. Tails
|
|
5
|
+
// live in the command form: /agy tasks tail <id> — a log tail is chat
|
|
6
|
+
// content, not overlay content.
|
|
7
|
+
|
|
8
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { AgyTask } from "./tasks.js";
|
|
12
|
+
|
|
13
|
+
export type TasksBrowserAction = { type: "rescan" } | { type: "close" };
|
|
14
|
+
|
|
15
|
+
export function taskItems(tasks: AgyTask[]): SelectItem[] {
|
|
16
|
+
return tasks.map((t) => {
|
|
17
|
+
const when = new Date(t.modifiedMs);
|
|
18
|
+
const stamp = `${String(when.getHours()).padStart(2, "0")}:${String(when.getMinutes()).padStart(2, "0")}`;
|
|
19
|
+
const state = t.livenessKnown ? (t.active ? "ACTIVE" : "idle") : "liveness?";
|
|
20
|
+
return {
|
|
21
|
+
value: String(t.id),
|
|
22
|
+
label: `#${t.id} · ${state}`,
|
|
23
|
+
description: `${humanBytes(t.bytes)} · last write ${stamp}`,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function humanBytes(n: number): string {
|
|
29
|
+
if (n < 1024) return `${n}B`;
|
|
30
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
31
|
+
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render the overlay. Resolves rescan (caller re-lists and reopens) or
|
|
35
|
+
* close. */
|
|
36
|
+
export async function showTasksBrowser(
|
|
37
|
+
ctx: ExtensionUIContext,
|
|
38
|
+
tasks: AgyTask[],
|
|
39
|
+
livenessKnown: boolean,
|
|
40
|
+
): Promise<TasksBrowserAction> {
|
|
41
|
+
const items = taskItems(tasks);
|
|
42
|
+
return ctx.custom<TasksBrowserAction>((tui, theme, _keybindings, done) => {
|
|
43
|
+
const container = new Container();
|
|
44
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
45
|
+
container.addChild(
|
|
46
|
+
new Text(theme.fg("accent", theme.bold(`Antigravity tasks (${tasks.length})`)), 1, 0),
|
|
47
|
+
);
|
|
48
|
+
if (!livenessKnown) {
|
|
49
|
+
container.addChild(
|
|
50
|
+
new Text(theme.fg("warning", "lsof not found; liveness unknown (install lsof for ACTIVE/idle)"), 1, 0),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
container.addChild(new Spacer(1));
|
|
54
|
+
|
|
55
|
+
const list = new SelectList(items, Math.min(items.length, 12), {
|
|
56
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
57
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
58
|
+
description: (t: string) => theme.fg("muted", t),
|
|
59
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
60
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
61
|
+
});
|
|
62
|
+
list.onSelect = () => done({ type: "close" });
|
|
63
|
+
list.onCancel = () => done({ type: "close" });
|
|
64
|
+
container.addChild(list);
|
|
65
|
+
|
|
66
|
+
container.addChild(new Spacer(1));
|
|
67
|
+
container.addChild(
|
|
68
|
+
new Text(theme.fg("dim", "↑↓ navigate · r rescan · esc close · /agy tasks tail <id> for a log"), 1, 0),
|
|
69
|
+
);
|
|
70
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
render: (width: number) => container.render(width),
|
|
74
|
+
invalidate: () => container.invalidate(),
|
|
75
|
+
handleInput: (data: string) => {
|
|
76
|
+
if (data.trim().toLowerCase() === "r") {
|
|
77
|
+
done({ type: "rescan" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
list.handleInput(data);
|
|
81
|
+
tui.requestRender();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}, {
|
|
85
|
+
overlay: true,
|
|
86
|
+
overlayOptions: { anchor: "center" as const, width: 72, maxHeight: "85%" as const, margin: 1 },
|
|
87
|
+
});
|
|
88
|
+
}
|