@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/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 `agy models` and return its raw stdout text. Returns "" on any
108
- * failure (non-zero exit, spawn error, or watchdog timeout). Bounded by
109
- * DISCOVERY_TIMEOUT_MS so a hung agy (auth prompt, network stall) can't
110
- * block extension load. Shared by the provider and the tool catalog so the
111
- * extension spawns `agy models` ONCE per load. */
112
- export async function spawnAgyModelsRaw(binary: string): Promise<string> {
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, ["models"], {
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) => (out += d));
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
- }, DISCOVERY_TIMEOUT_MS);
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
@@ -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. Ported from tianzuo/pi-antigravity lib/native-tools.ts (MIT).
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 && blocks.thinkingIdx === null && outcome.response) {
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";
@@ -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 and cross-checked against
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
+ }
@@ -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
+ }