@estebanforge/pi-antigravity-bridge 1.7.5 → 1.7.7

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.7] - 2026-09-30
6
+
7
+ ### Fixed
8
+
9
+ - **A recreated agy conversation no longer starves the context digest.** When `session/load` failed, the ACP engine silently fell back to a fresh conversation (`session-load-failed-creating-fresh`), but the end-of-turn write-back kept advancing the stored watermark, so the new conversation never received the backlog: the digest delivered only the current prompt for the rest of the session, and the G10 system-prompt gate stayed closed too, because the surviving record always carried a conversation id. The write-back now compares the outcome's conversation id against the one stored before the turn and drops the record on a mismatch: the next turn re-delivers the full digest backlog and re-arms the system prompt, on both engines, with no protocol change. `SessionStore` gained a per-key `delete()` whose removal persists exactly once (the removal set prunes after a durable write, so a later persist can never destroy a record another process wrote in the meantime), preserving the multi-process merge invariant.
10
+ - **Failed and aborted turns no longer advance the digest watermark.** The write-back recorded `lastMessageCount` on any turn that reported a conversation id, ERROR and aborts included. Whether the prompt actually landed in the conversation's history is not observable from the provider, and advancing on that ambiguity starves the digest of the missed turn (the same failure class as the recreation bug above). The watermark now advances only on a clean, non-aborted turn; failed, aborted, and unknown outcomes leave the pre-turn record untouched, so the next digest re-delivers the missed turn bounded by the existing `maxChars` cap (default 8000) instead of losing it. With the digest off (the default) neither fix changes observable behavior.
11
+ - **Regression coverage pins both behaviors at the provider layer.** Eight new tests: record drop, backlog re-delivery, and system-prompt re-arm across the id swap; unchanged watermark across failed, aborted, and unknown outcomes on the same conversation; and the session-store delete and multi-process semantics, including a reclaimed key surviving an unrelated persist from another process. 1329 tests, tsc clean.
12
+
13
+ Thanks @fishfuuu for the report (#1).
14
+
15
+ ## [1.7.6] - 2026-09-28
16
+
17
+ ### Fixed
18
+
19
+ - **Plan-mode delegations enforce read-only instead of promising it.** The old design withheld the skip flag and relied on a prompt asking nicely; the field disagreed once — a plan delegation with the flag re-injected through the environment still edited files, because a prompt guard is a request, not a wall. Plan-mode runs now stage a temporary agy agent under `~/.gemini/config/agents/pi-bridge-ask-*` whose `tools:` list carries only `view_file` and `run_command`: with no file-editing tool in the toolset the model cannot emit an edit call at all (probed on agy 1.2.12 with and without `--dangerously-skip-permissions` — the restricted toolset reports "none" for edits both ways). The staged agent's `commandExecutionPolicy: auto` is what keeps reviews useful: analysis commands (git, rg, test runners) run headless for every user, with no `permissions.allow` edits — the per-user-config approach was rejected as unshippable for a product. With the agent staged, the skip flag returns under the same `skipPermissions` knob (default on): its only real danger was auto-approving edit calls, and none exist to approve. The prompt guard switches to a mutation-forbidding text (read-only commands allowed), and the tool description now states the true residual plainly: file-editing tools are hard-blocked, arbitrary shell execution under the flag stays prompt-guarded alone. Staging failure degrades fail-closed — no agent, no flag, the stricter all-commands-forbidden guard, with an env-injected skip flag stripped from `AGY_EXTRA_ARGS` on that path only. Agent dirs are nonce-named, pid-marked, removed in the run's finally, and orphans swept at registration (the web-delegate sweep gained a prefix parameter so both doctrines share one implementation).
20
+ - **Headless plan prompts say what they cannot do.** `agy -p` cannot answer permission prompts, and in plan mode a shell-command attempt is soft-denied: the run ends cleanly with nothing on stdout and the reason only on stderr. Plan prompts now append an explicit constraint — no shell commands, answer from the material in the prompt, state exactly what is missing — converting the silent death into a visible missing-content request (probed: the previously-fatal prompt shape now answers "Missing information: ..." in about ten seconds). With the staged reviewer agent above, this guard yields to the mutation-forbidding variant; it remains the rule on the staging-failure fallback path.
21
+
5
22
  ## [1.7.5] - 2026-09-25
6
23
 
7
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // nothing (see its defer guard). This module is the single source of truth.
13
13
 
14
14
  import { spawn } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
15
16
  import * as fs from "node:fs";
16
17
  import * as path from "node:path";
17
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -28,6 +29,7 @@ import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
29
  import { redactText } from "./redact.js";
29
30
  import { acquireBridgeSuppression } from "./mcp-registration.js";
30
31
  import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
32
+ import { sweepStaleWebAgents, webAgentsRoot } from "./web-tools.js";
31
33
 
32
34
  // --- Constants -------------------------------------------------------------
33
35
 
@@ -78,7 +80,7 @@ TWO MODES (you choose):
78
80
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
79
81
 
80
82
  EXECUTION MODES (param: mode):
81
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs never receive the skip-permissions flag, so a write or command attempt ends the run quickly with a "confirm plan" message instead of executing.
83
+ - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs execute in a temporary restricted agent whose toolset has NO file-editing tools (hard block); the reviewer may still execute arbitrary shell commands, kept read-only by the prompt guard alone. Inline the material to review - a plan run cannot fetch it.
82
84
  - **accept-edits** (default): agy applies edits directly inside the workspace.
83
85
 
84
86
  COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
@@ -270,6 +272,109 @@ function extraArgs(): string[] {
270
272
  return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
271
273
  }
272
274
 
275
+ // --- Prompt assembly -------------------------------------------------------
276
+
277
+ /** Guard for plan runs backed by the restricted reviewer agent. Difference
278
+ * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
279
+ * agent's commandExecutionPolicy auto-approves them headless, probed
280
+ * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
281
+ * and keeps the answer-from-material discipline. */
282
+ export const AGENT_REVIEW_GUARD = [
283
+ "",
284
+ "--- Review constraints ---",
285
+ "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
286
+ "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
287
+ "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
288
+ ].join("\n");
289
+
290
+ /** Appended to every headless plan-mode prompt. `agy -p` cannot answer
291
+ * permission prompts: in plan mode a run_command attempt is soft-denied and
292
+ * the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
293
+ * the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
294
+ * allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
295
+ * --sandbox does NOT relax the command gate, so the only input every user
296
+ * is guaranteed is the prompt itself. The guard steers the model to answer
297
+ * from that material. accept-edits runs keep their tools and never get it. */
298
+ export const PLAN_HEADLESS_GUARD = [
299
+ "",
300
+ "--- Headless session constraints ---",
301
+ "- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
302
+ "- Work only from the material provided in this prompt.",
303
+ "- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
304
+ ].join("\n");
305
+
306
+ /** Assemble the prompt sent to agy: digest marker first (existing behavior),
307
+ * then the caller's prompt, then the plan-mode guard last - the position
308
+ * the model reads with the most recency. No user-config mutation: this is
309
+ * the only plan-mode lever the tool itself owns. */
310
+ export function buildFinalPrompt(
311
+ prompt: string,
312
+ mode: AgyMode,
313
+ digest: boolean,
314
+ /** True when the restricted reviewer agent is staged: its empty edit
315
+ * toolset is the hard block, so the prompt may ALLOW read-only commands. */
316
+ agentEnforced = false,
317
+ ): string {
318
+ let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
319
+ if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
320
+ return out;
321
+ }
322
+
323
+ // --- Plan-mode reviewer agent ----------------------------------------------
324
+
325
+ /** Temp agent dirs share the ~/.gemini/config/agents discovery root with the
326
+ * web delegates; AGY_AGENTS_ROOT overrides for tests and sandboxes. */
327
+ export function askAgentsRoot(): string {
328
+ return process.env.AGY_AGENTS_ROOT ?? webAgentsRoot();
329
+ }
330
+
331
+ const ASK_AGENT_PREFIX = "pi-bridge-ask-";
332
+ const ASK_AGENT_TOOLS = ["view_file", "run_command"];
333
+
334
+ /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
335
+ * with no file-editing tool present the model cannot emit an edit call at
336
+ * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
337
+ * edits, with and without the skip flag). commandExecutionPolicy auto is
338
+ * what lets read commands run headless with no user allow rules. Never add
339
+ * a write-capable tool here. */
340
+ export function reviewerAgentMd(name: string): string {
341
+ return [
342
+ "---",
343
+ `name: ${name}`,
344
+ "description: Temporary Pi plan-mode reviewer",
345
+ "mainAgent: true",
346
+ "subagent: false",
347
+ "model: inherit",
348
+ "excludeDefaultComponents: true",
349
+ "inheritCustomizations: false",
350
+ "inheritMcp: false",
351
+ "commandExecutionPolicy: auto",
352
+ "tools:",
353
+ ...ASK_AGENT_TOOLS.map((t) => ` - ${t}`),
354
+ "skills: []",
355
+ "rules: []",
356
+ "agents: []",
357
+ "mcpServers: []",
358
+ "---",
359
+ "",
360
+ "You are a strict read-only code reviewer. Review, analyze, and plan; never modify anything.",
361
+ "",
362
+ ].join("\n");
363
+ }
364
+
365
+ /** Stage one unique reviewer agent (agent.md + pid marker) under root. Same
366
+ * hygiene doctrine as the web delegates: nonce-named, cleaned in finally,
367
+ * orphans swept by pid marker at registration. */
368
+ export function stageReviewerAgent(root: string): { name: string; dir: string } {
369
+ fs.mkdirSync(root, { recursive: true });
370
+ const name = `${ASK_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
371
+ const dir = path.join(root, name);
372
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
373
+ fs.writeFileSync(path.join(dir, "agent.md"), reviewerAgentMd(name), { mode: 0o600 });
374
+ fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
375
+ return { name, dir };
376
+ }
377
+
273
378
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
274
379
 
275
380
  // --- Registration ----------------------------------------------------------
@@ -284,6 +389,13 @@ export async function registerAskAntigravityTool(
284
389
  * verbose tier; info/warn/error always land. */
285
390
  log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void,
286
391
  ): Promise<void> {
392
+ // Orphan sweep for the plan-mode reviewer agents (SIGKILL can skip the
393
+ // run's finally): same pid-marker doctrine as the web delegates.
394
+ try {
395
+ sweepStaleWebAgents(askAgentsRoot(), Date.now(), ASK_AGENT_PREFIX);
396
+ } catch {
397
+ // a sweep failure must never block registration
398
+ }
287
399
  pi.registerTool({
288
400
  name: "AskAntigravity",
289
401
  label: "Ask Antigravity",
@@ -494,11 +606,27 @@ export async function registerAskAntigravityTool(
494
606
  const snapshot = isContinuation ? null : snapshotConversations();
495
607
 
496
608
  const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
609
+ // Plan runs stage a restricted reviewer agent: its tools list has NO
610
+ // file-editing tool, which is the hard edit block (the prompt guard
611
+ // alone was observed failing once - a sub-agent still edited files).
612
+ // Staging failure degrades to the legacy fallback: no agent, no skip
613
+ // flag, the stricter command-forbidding guard.
614
+ let reviewerAgent: { name: string; dir: string } | null = null;
615
+ if (mode === "plan") {
616
+ try {
617
+ reviewerAgent = stageReviewerAgent(askAgentsRoot());
618
+ } catch {
619
+ reviewerAgent = null;
620
+ }
621
+ }
497
622
  const useDigest: boolean =
498
623
  typeof params.digest === "boolean" ? params.digest : mode === "plan";
499
- const finalPrompt: string = useDigest
500
- ? `(Use compact digests, not full file contents.)\n${params.prompt}`
501
- : params.prompt;
624
+ const finalPrompt: string = buildFinalPrompt(
625
+ params.prompt,
626
+ mode,
627
+ useDigest,
628
+ reviewerAgent !== null,
629
+ );
502
630
 
503
631
  // Opt-in full-context export (isolated stays the default).
504
632
  let contextFile: string | null = null;
@@ -529,22 +657,31 @@ export async function registerAskAntigravityTool(
529
657
  { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
530
658
  "info",
531
659
  );
532
- const extra = extraArgs();
660
+ const extraRaw = extraArgs();
661
+ // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
662
+ // mode flags, so an env-injected skip flag would re-arm what the
663
+ // plan-mode rule strips. Only the enforced agent makes the flag safe.
664
+ const extra =
665
+ mode === "plan" && !reviewerAgent
666
+ ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
667
+ : extraRaw;
533
668
  if (extra.length) args.push(...extra);
534
669
  if (resolved.model) args.push("--model", resolved.model);
535
670
  if (resolved.effort) args.push("--effort", resolved.effort);
536
671
  args.push("--mode", mode);
537
- // Honor the shared permissions setting (same knob as the provider). Non-
538
- // interactive -p can't answer a permission prompt, so when this is off
539
- // any run_command will hang - but the setting must mean what it says.
540
- // Plan mode never gets the flag even when the knob is on: the flag
541
- // auto-approves ALL permission requests including plan mode's own
542
- // approval gate, which would silently turn "review-only" into full
543
- // write access inside --add-dir (probed 2026-09-25: with the flag a
544
- // plan run wrote files; without it, file and command attempts end
545
- // exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
546
- // never a hang). Omitting it is what makes "plan = review-only" true.
547
- if (mode !== "plan" && config.skipPermissions !== false) {
672
+ if (reviewerAgent) args.push("--agent", reviewerAgent.name);
673
+ // Honor the shared permissions setting (same knob as the provider).
674
+ // accept-edits: the flag keeps commands from hanging on an
675
+ // unanswerable prompt in -p mode. Plan runs WITH the reviewer agent:
676
+ // the flag is safe again because the agent's toolset has no
677
+ // file-editing tool - the 2026-09-25 write incident rode edit tools
678
+ // that existed then; probed 2026-09-28 the restricted toolset reports
679
+ // "none" for edits even with the flag, and plan discipline turned a
680
+ // create-a-file request into a proposed plan instead of a write. The
681
+ // flag is what lets analysis commands (builds, test runners) run for
682
+ // users without allow rules. Plan fallback (staging failed): strict
683
+ // legacy behavior, no flag - the prompt guard is all that is left.
684
+ if (config.skipPermissions !== false && (mode !== "plan" || reviewerAgent !== null)) {
548
685
  args.push("--dangerously-skip-permissions");
549
686
  }
550
687
  if (isContinuation) args.push("--conversation", rawConvId as string);
@@ -775,7 +912,7 @@ export async function registerAskAntigravityTool(
775
912
  const note = [
776
913
  "agy exited cleanly but produced no output.",
777
914
  stderr ? `stderr: ${stderr}` : null,
778
- "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied. Allow-list it under permissions.allow in ~/.gemini/antigravity-cli/settings.json, or rerun outside plan mode with skipPermissions.",
915
+ "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
779
916
  ]
780
917
  .filter(Boolean)
781
918
  .join(" ");
@@ -803,6 +940,11 @@ export async function registerAskAntigravityTool(
803
940
  fs.unlinkSync(contextFile);
804
941
  } catch {}
805
942
  }
943
+ if (reviewerAgent) {
944
+ try {
945
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
946
+ } catch {}
947
+ }
806
948
  }
807
949
  },
808
950
  });
package/src/provider.ts CHANGED
@@ -1283,11 +1283,28 @@ async function runTurnDriver(
1283
1283
 
1284
1284
  const outcome = await handle.outcome;
1285
1285
  if (outcome.conversationId) {
1286
- store.set(key, {
1287
- conversationId: outcome.conversationId,
1288
- lastStepIdx: -1,
1289
- lastMessageCount: messageCount,
1290
- });
1286
+ // A different id than the stored one means the driver recreated the
1287
+ // conversation under us (ACP session-load fallback, stream-json CLI
1288
+ // dropping a stale --conversation). The recreated conversation has seen
1289
+ // NOTHING, so the record's watermark would starve the digest forever and
1290
+ // its bound id would keep the sysPrompt gate closed. Drop the record:
1291
+ // the next turn resumes from watermark 0 with the gate re-armed (issue
1292
+ // #1). The recreation turn itself is past the gate, so it is the
1293
+ // documented residual; recovery starts on the next turn.
1294
+ if (existing?.conversationId && outcome.conversationId !== existing.conversationId) {
1295
+ store.delete(key);
1296
+ } else if (outcome.status === "OK" && !outcome.aborted) {
1297
+ store.set(key, {
1298
+ conversationId: outcome.conversationId,
1299
+ lastStepIdx: -1,
1300
+ lastMessageCount: messageCount,
1301
+ });
1302
+ }
1303
+ // A failed or aborted turn on the SAME conversation keeps the pre-turn
1304
+ // record: whether the prompt actually landed in agy's DB is not
1305
+ // observable from here. Keeping the watermark re-delivers the missed
1306
+ // turn in the next digest (bounded by the digest cap); advancing it
1307
+ // would starve the conversation of context it may never have received.
1291
1308
  }
1292
1309
  if (outcome.aborted) {
1293
1310
  finalize(stream, blocks, "aborted", "Operation aborted");
package/src/sessions.ts CHANGED
@@ -68,6 +68,7 @@ export class SessionStore {
68
68
  private readonly path: string;
69
69
  private cache: StoreMap = {};
70
70
  private readonly dirty = new Set<string>();
71
+ private readonly removed = new Set<string>();
71
72
  private writeChain: Promise<void> = Promise.resolve();
72
73
 
73
74
  constructor(storePath: string = STORE_PATH) {
@@ -100,6 +101,7 @@ export class SessionStore {
100
101
  set(key: string, session: AgySession): void {
101
102
  this.cache[key] = session;
102
103
  this.dirty.add(key);
104
+ this.removed.delete(key);
103
105
  // Serialize through a chain so concurrent sets don't interleave renames.
104
106
  this.writeChain = this.writeChain
105
107
  .then(() => this.persist())
@@ -108,6 +110,22 @@ export class SessionStore {
108
110
  });
109
111
  }
110
112
 
113
+ /** Drop one session binding and persist the deletion. Used when the driver
114
+ * recreated the conversation under us: the record's watermark claims
115
+ * history the new conversation never saw, and its bound id keeps the
116
+ * sysPrompt gate closed. Dropping resets the watermark to 0 AND re-arms
117
+ * the gate on the next turn (issue #1). The write is queued. */
118
+ delete(key: string): void {
119
+ delete this.cache[key];
120
+ this.dirty.delete(key);
121
+ this.removed.add(key);
122
+ this.writeChain = this.writeChain
123
+ .then(() => this.persist())
124
+ .catch(() => {
125
+ /* swallow; next write retries */
126
+ });
127
+ }
128
+
111
129
  private async persist(): Promise<void> {
112
130
  const dir = path.dirname(this.path);
113
131
  await fs.promises.mkdir(dir, { recursive: true });
@@ -126,6 +144,9 @@ export class SessionStore {
126
144
  for (const key of this.dirty) {
127
145
  merged[key] = this.cache[key];
128
146
  }
147
+ for (const key of this.removed) {
148
+ delete merged[key];
149
+ }
129
150
 
130
151
  const tmp = `${this.path}.${process.pid}.tmp`;
131
152
  await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n", {
@@ -133,6 +154,10 @@ export class SessionStore {
133
154
  });
134
155
  await fs.promises.rename(tmp, this.path);
135
156
  this.dirty.clear();
157
+ // Mirror dirty: once the deletion is durable, stop re-asserting it. A
158
+ // later unrelated persist must not destroy a record another process
159
+ // wrote for that key after our delete landed.
160
+ this.removed.clear();
136
161
  }
137
162
 
138
163
  /** Wipe all session bindings (forces fresh agy conversations on every
@@ -140,6 +165,7 @@ export class SessionStore {
140
165
  clear(): void {
141
166
  this.cache = {};
142
167
  this.dirty.clear();
168
+ this.removed.clear();
143
169
  this.writeChain = this.writeChain
144
170
  .then(async () => {
145
171
  const tmp = `${this.path}.${process.pid}.tmp`;
package/src/web-tools.ts CHANGED
@@ -37,10 +37,15 @@ export function webAgentsRoot(): string {
37
37
  return path.join(os.homedir(), ".gemini", "config", "agents");
38
38
  }
39
39
 
40
- /** Remove leftover pi-bridge-web-* agent dirs: dead-pid markers always, and
41
- * marker-less dirs only after a grace period (a live sibling process may be
42
- * between mkdir and its pid write). Never touches foreign agent dirs. */
43
- export function sweepStaleWebAgents(root: string = webAgentsRoot(), now = Date.now()): void {
40
+ /** Remove leftover pi-bridge agent dirs matching `prefix`: dead-pid markers
41
+ * always, and marker-less dirs only after a grace period (a live sibling
42
+ * process may be between mkdir and its pid write). Never touches foreign
43
+ * agent dirs. The ask-tool reuses this for its reviewer agents. */
44
+ export function sweepStaleWebAgents(
45
+ root: string = webAgentsRoot(),
46
+ now = Date.now(),
47
+ prefix: string = WEB_AGENT_PREFIX,
48
+ ): void {
44
49
  let entries: string[];
45
50
  try {
46
51
  entries = readdirSync(root);
@@ -48,7 +53,7 @@ export function sweepStaleWebAgents(root: string = webAgentsRoot(), now = Date.n
48
53
  return; // no agents dir yet: nothing to sweep
49
54
  }
50
55
  for (const entry of entries) {
51
- if (!entry.startsWith(WEB_AGENT_PREFIX)) continue;
56
+ if (!entry.startsWith(prefix)) continue;
52
57
  try {
53
58
  if (isStaleWebAgentDir(path.join(root, entry), now)) {
54
59
  rmSync(path.join(root, entry), { recursive: true, force: true });