@estebanforge/pi-antigravity-bridge 1.7.6 → 1.7.8

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,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.8] - 2026-09-30
6
+
7
+ ### Fixed
8
+
9
+ - **The system prompt no longer rides the prompt line on stream-json.** Probed on agy 1.2.14: the CLI silently drops any single prompt past a ~25KB input cap. The drop reports SUCCESS with an empty response, zero usage, and no model step, and a conversation whose turn was dropped never answers again - resuming it hangs with no result frame. The G10 system-prompt block shipped inline on the first prompt of every fresh conversation by default and routinely crossed the cap, so provider sessions answered boilerplate while the user's message never ran (issue #2). The block now stages as an agy agent file under the agents discovery root and rides `--agent`: the CLI reads agent files from disk, so the cap never applies, and the prompt line carries only the user message. Staging is content-addressed - one dir per distinct body, write-once, pid-marked, orphan-swept - so two conversations in one process can never overwrite each other's carrier (the round-2 review blocked the original per-process slot for exactly that cross-contamination). A user-configured `config.agent` always wins, with the inline block as fallback and a one-time warning. ACP keeps the inline block: the JSON-RPC transport has no cap and no `--agent` slot.
10
+ - **A dropped or dead turn now fails loudly instead of poisoning the session.** The stream-json driver settles every turn with model-output and result-frame evidence. An OK outcome with no model output - the exact signature of the silent over-cap drop - fails the turn with the cause and the remedy, and never stores the conversation binding; a deadline on a bound conversation with no result frame clears the binding so the next turn starts a fresh conversation instead of hanging forever on a dead one. Real terse turns (tool steps, thinking, any usage) are untouched; a deadline that recovered a withheld answer keeps settling OK.
11
+ - **Regression coverage pins the whole failure chain at both layers.** The fake agy binary grew a SILENT-DROP branch reproducing the exact wire signature; driver tests pin the evidence fields; provider tests cover the drop, the poisoned resume, a deadline-with-result keeping its binding, terse-turn false positives, carrier isolation across interleaved conversations, and deadline-flavored error copy. 1343 tests, tsc clean; round-2 adversarial review signed off after the carrier-isolation blocker fix.
12
+
13
+ Thanks @Jamsa for the report (#2).
14
+
15
+ ## [1.7.7] - 2026-09-30
16
+
17
+ ### Fixed
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+
23
+ Thanks @fishfuuu for the report (#1).
24
+
5
25
  ## [1.7.6] - 2026-09-28
6
26
 
7
27
  ### Fixed
package/README.md CHANGED
@@ -119,7 +119,7 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
119
119
  | `webTools` | `off` (no web tools), `on` (register `agy_web_search` + `agy_read_url` as Pi tools, usable by ANY provider's model). Each call spawns a one-shot search-only `agy` agent (plan mode, bridge MCP inheritance off), gates the answer on an observed native `search_web`/`read_url_content` step, and spends Antigravity quota. Off by default: Antigravity sessions already have native web tools on both engines; these serve other providers | `off` |
120
120
  | `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
121
121
  | `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
122
- | `systemPrompt` | `on` (prepend pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user) or `off` (agy-native behavior) | `on` |
122
+ | `systemPrompt` | `on` (send pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user. Delivery: stream-json stages it as an agy agent file via `--agent`, off the prompt line - the CLI silently drops single prompts past a ~25KB cap and the dropped turn kills the conversation; ACP prepends it to the first prompt) or `off` (agy-native behavior) | `on` |
123
123
  | `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
124
124
  | `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard | `5` |
125
125
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.6",
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.",
3
+ "version": "1.7.8",
4
+ "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -90,6 +90,20 @@ export interface TurnOutcome {
90
90
  usage?: AgyUsage;
91
91
  finished: boolean;
92
92
  aborted: boolean;
93
+ /** The model demonstrably produced output this turn: an agent_response
94
+ * or tool step was observed, or the settled response/usage is non-empty.
95
+ * Stream-json sets it on every outcome; ACP leaves it undefined.
96
+ * false + status OK is agy's silent over-cap drop signature (issue #2:
97
+ * prompts past ~25KB are discarded with SUCCESS, no error). */
98
+ modelOutputSeen?: boolean;
99
+ /** Stream-json only: a result frame arrived for this turn. Undefined on
100
+ * ACP. A deadline kill or process exit before the result frame means the
101
+ * turn never committed. */
102
+ sawResult?: boolean;
103
+ /** Stream-json only: the turn ended via the overall/inactivity deadline
104
+ * guard (not a user abort, not a process crash). With sawResult false on
105
+ * a resumed conversation this marks the conversation as poisoned. */
106
+ deadline?: "timeout" | "stall";
93
107
  }
94
108
 
95
109
  export interface TurnHandle {
package/src/driver.ts CHANGED
@@ -67,6 +67,10 @@ interface ActiveTurn {
67
67
  * transcript steps to those written after this. */
68
68
  startedAt: number;
69
69
  sawResult: boolean;
70
+ /** An agent_response or tool step arrived this turn. Distinguishes a
71
+ * real (if terse) turn from agy's silent over-cap drop, which settles
72
+ * SUCCESS with no model step at all (issue #2). */
73
+ sawModelStep: boolean;
70
74
  /** Text-dedupe guard state (delta vs cumulative response_text). */
71
75
  cumulativeText: boolean | undefined;
72
76
  /** Open bridge parks. Each one suspends the stdout idle timer: agy is
@@ -120,6 +124,20 @@ function nowIso(): string {
120
124
  return new Date().toISOString().slice(11, 19);
121
125
  }
122
126
 
127
+ /** True when any usage counter carries a real value. The silent over-cap
128
+ * drop reports all-zero usage (no model call happened); a turn that burned
129
+ * any tokens demonstrably reached the model. */
130
+ function hasTokenEvidence(usage: AgyUsage | undefined): boolean {
131
+ if (!usage) return false;
132
+ return (
133
+ (usage.input_tokens ?? 0) > 0 ||
134
+ (usage.output_tokens ?? 0) > 0 ||
135
+ (usage.thinking_tokens ?? 0) > 0 ||
136
+ (usage.cache_read_tokens ?? 0) > 0 ||
137
+ (usage.total_tokens ?? 0) > 0
138
+ );
139
+ }
140
+
123
141
  /** True when `next` is a cumulative resend of `accumulated` (it repeats every
124
142
  * byte already streamed) rather than a fresh delta. Exported for tests. */
125
143
  export function isCumulativeResend(accumulated: string, next: string): boolean {
@@ -287,6 +305,7 @@ export class StreamDriver implements TurnDriver {
287
305
  outcome,
288
306
  response: "",
289
307
  sawResult: false,
308
+ sawModelStep: false,
290
309
  cumulativeText: undefined,
291
310
  parks: 0,
292
311
  startedAt: Date.now(),
@@ -500,6 +519,7 @@ export class StreamDriver implements TurnDriver {
500
519
  emit(turn, { type: "usage", usage: s.usage });
501
520
  }
502
521
  if (s.step_type === "agent_response") {
522
+ turn.sawModelStep = true;
503
523
  const text =
504
524
  typeof s.text_delta === "string"
505
525
  ? s.text_delta
@@ -513,6 +533,7 @@ export class StreamDriver implements TurnDriver {
513
533
  break;
514
534
  }
515
535
  if (s.step_type === "tool") {
536
+ turn.sawModelStep = true;
516
537
  const name = s.tool_name ?? s.tool_info?.name ?? "tool";
517
538
  const args =
518
539
  s.tool_info?.parameters && typeof s.tool_info.parameters === "object"
@@ -614,9 +635,22 @@ export class StreamDriver implements TurnDriver {
614
635
  this.#state = this.#child ? "ready" : "dead";
615
636
  for (const wake of turn.wake) wake();
616
637
  turn.wake = [];
617
- turn.resolve(outcome);
638
+ // Evidence fields every consumer can rely on. modelOutputSeen separates a
639
+ // real (if terse) turn from agy's silent over-cap drop, which settles
640
+ // SUCCESS with NO model step, empty response, and zero usage (issue #2).
641
+ const usage = outcome.usage ?? turn.usage;
642
+ const enriched: TurnOutcome = {
643
+ ...outcome,
644
+ usage,
645
+ sawResult: turn.sawResult,
646
+ modelOutputSeen:
647
+ turn.sawModelStep ||
648
+ outcome.response.length > 0 ||
649
+ hasTokenEvidence(usage),
650
+ };
651
+ turn.resolve(enriched);
618
652
  try {
619
- this.#onTurnEnd?.(outcome);
653
+ this.#onTurnEnd?.(enriched);
620
654
  } catch {
621
655
  /* listener errors must not break settling */
622
656
  }
@@ -630,7 +664,7 @@ export class StreamDriver implements TurnDriver {
630
664
  * One found, the turn settles OK with the withheld answer instead of
631
665
  * discarding finished work; either way the next turn respawns into a
632
666
  * known state. */
633
- async #turnDeadlineGuard(turn: ActiveTurn, label: string, message: string): Promise<void> {
667
+ async #turnDeadlineGuard(turn: ActiveTurn, label: "timeout" | "stall", message: string): Promise<void> {
634
668
  if (turn.closed) return;
635
669
  this.#log(`${label}:${turn.id}`);
636
670
  this.#killChild();
@@ -650,13 +684,14 @@ export class StreamDriver implements TurnDriver {
650
684
  usage: turn.usage,
651
685
  finished: true,
652
686
  aborted: false,
687
+ deadline: label,
653
688
  });
654
689
  return;
655
690
  }
656
- this.#failTurn(turn, message);
691
+ this.#failTurn(turn, message, label);
657
692
  }
658
693
 
659
- #failTurn(turn: ActiveTurn, message: string): void {
694
+ #failTurn(turn: ActiveTurn, message: string, deadline?: "timeout" | "stall"): void {
660
695
  this.#settle(turn, {
661
696
  conversationId: turn.conversationId,
662
697
  status: "ERROR",
@@ -665,6 +700,7 @@ export class StreamDriver implements TurnDriver {
665
700
  usage: turn.usage,
666
701
  finished: true,
667
702
  aborted: false,
703
+ deadline,
668
704
  });
669
705
  }
670
706
 
package/src/provider.ts CHANGED
@@ -43,9 +43,22 @@ import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-ga
43
43
  import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-server.js";
44
44
  import path from "node:path";
45
45
  import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
46
+ import { stageSyspromptAgent } from "./sysprompt-agent.js";
46
47
 
47
48
  const DEFAULT_TIMEOUT_MIN = 10;
48
49
 
50
+ /** Warned once per process: config.agent set while the system prompt needs
51
+ * the staged carrier agent (issue #2 inline-block fallback). */
52
+ let warnedUserAgentConflict = false;
53
+
54
+ /** conversationId -> staged carrier agent name. Gate-closed turns look the
55
+ * name up here so a conversation's agent field never flips: a change would
56
+ * recycle the agy process mid-conversation (profile drift), and a respawn
57
+ * must reload THAT conversation's own staged instructions. Content-addressed
58
+ * staging (one dir per body) keeps sibling conversations from overwriting
59
+ * each other's files. */
60
+ const stagedAgentByConversation = new Map<string, string>();
61
+
49
62
  /** Zero-usage helper. agy doesn't expose token counts; pi's cost math gets
50
63
  * zeros (we're not billing through this provider). */
51
64
  function zeroUsage(): Usage {
@@ -1182,6 +1195,10 @@ async function runTurnDriver(
1182
1195
  }
1183
1196
 
1184
1197
  let handle: TurnHandle;
1198
+ // Name of the carrier agent staged for THIS turn's fresh conversation, if
1199
+ // any. Recorded into stagedAgentByConversation once the outcome binds the
1200
+ // conversation id, so later gate-closed turns pass the same agent.
1201
+ let stagedAgentName: string | undefined;
1185
1202
  if (isContinuation) {
1186
1203
  const active = deps.driver.reentry();
1187
1204
  if (!active) {
@@ -1225,12 +1242,44 @@ async function runTurnDriver(
1225
1242
  // The uri is suffixed per turn so a deduping server cannot serve stale
1226
1243
  // content on turn 2+.
1227
1244
  const embeddedDigest = deps.engine === "acp" && digest ? digest : undefined;
1228
- // Fresh conversation only: agy stores the block in its own history, so
1229
- // re-sending it every turn would bloat each prompt and bust the cache.
1230
- const sysPrompt =
1231
- config.systemPrompt && !existing?.conversationId
1232
- ? getCurrentSystemPrompt(context.messages) || undefined
1233
- : undefined;
1245
+ // G10, fresh conversations only (bound conversations ride agy's own
1246
+ // history; re-sending would bloat every prompt and bust the cache).
1247
+ // stream-json delivery is a staged agent file, NOT prompt text: agy's
1248
+ // stream-json path silently drops any turn past a ~25KB input cap and
1249
+ // the dropped turn leaves the conversation permanently unresponsive
1250
+ // (issue #2, probed 2026-09-30), and the block routinely crossed it.
1251
+ // ACP keeps the inline block (JSON-RPC transport; --agent is a CLI
1252
+ // feature). A user-configured agent always wins: never clobber it.
1253
+ const gateOpen = config.systemPrompt && !existing?.conversationId;
1254
+ const rawSysPrompt = gateOpen ? getCurrentSystemPrompt(context.messages) || undefined : undefined;
1255
+ let sysPrompt = rawSysPrompt;
1256
+ let agent = config.agent;
1257
+ if (deps.engine !== "acp") {
1258
+ if (rawSysPrompt && !config.agent) {
1259
+ const stagedName = stageSyspromptAgent(
1260
+ [SYSTEM_PROMPT_PREAMBLE, rawSysPrompt, TOOL_PRIORITY_NOTE].join("\n\n"),
1261
+ );
1262
+ if (stagedName) {
1263
+ agent = stagedName;
1264
+ sysPrompt = undefined;
1265
+ stagedAgentName = stagedName;
1266
+ }
1267
+ // Staging failed: inline fallback. The silent-drop guard below
1268
+ // keeps the resulting cap failure visible instead of quiet.
1269
+ } else if (rawSysPrompt && config.agent && !warnedUserAgentConflict) {
1270
+ warnedUserAgentConflict = true;
1271
+ deps.log?.(
1272
+ "sysprompt-agent-conflict",
1273
+ { agent: config.agent, effect: "system prompt ships inline in the prompt (over-cap drop risk)" },
1274
+ "warn",
1275
+ );
1276
+ } else if (!rawSysPrompt && !config.agent && existing?.conversationId) {
1277
+ // Gate closed (bound conversation): pass the carrier staged for
1278
+ // THIS conversation, if any, so the driver profile never drifts on
1279
+ // "agent" and a respawned process reloads its own instructions.
1280
+ agent = stagedAgentByConversation.get(existing.conversationId);
1281
+ }
1282
+ }
1234
1283
  const fullPrompt =
1235
1284
  late.length > 0
1236
1285
  ? buildLateResultPrompt(late, prompt || undefined)
@@ -1242,12 +1291,12 @@ async function runTurnDriver(
1242
1291
  effort,
1243
1292
  mode: config.mode,
1244
1293
  skipPermissions: config.skipPermissions,
1245
- agent: config.agent,
1246
1294
  timeoutMin: config.turnTimeoutMin,
1247
1295
  inactivityMin: config.inactivityTimeoutMin,
1248
1296
  conversationId: existing?.conversationId ?? null,
1249
1297
  prompt: fullPrompt,
1250
1298
  images: images.length > 0 ? images : undefined,
1299
+ agent,
1251
1300
  contextBlock: embeddedDigest
1252
1301
  ? {
1253
1302
  uri: `urn:pi-bridge:context-digest/${messageCount}`,
@@ -1282,12 +1331,74 @@ async function runTurnDriver(
1282
1331
  }
1283
1332
 
1284
1333
  const outcome = await handle.outcome;
1334
+ // Issue #2 recovery paths. agy's stream-json prompt path silently drops an
1335
+ // over-cap turn (SUCCESS, empty response, zero usage, NO model step) and a
1336
+ // dropped turn leaves the conversation permanently unresponsive, so both
1337
+ // failures clear the binding instead of settling a quiet empty reply —
1338
+ // settling OK here would persist a dead conversation for every later turn
1339
+ // (probe 2026-09-30: resume of a dropped-turn conversation never returns
1340
+ // a result frame). stream-json only: the ACP driver leaves the evidence
1341
+ // fields undefined.
1342
+ const silentDrop = outcome.status === "OK" && outcome.modelOutputSeen === false;
1343
+ const poisonedResume =
1344
+ outcome.status === "ERROR" &&
1345
+ outcome.deadline !== undefined &&
1346
+ outcome.sawResult === false &&
1347
+ Boolean(existing?.conversationId);
1348
+ if (silentDrop || poisonedResume) {
1349
+ if (existing?.conversationId || outcome.conversationId) {
1350
+ store.delete(key);
1351
+ if (outcome.conversationId) stagedAgentByConversation.delete(outcome.conversationId);
1352
+ }
1353
+ deps.log?.(
1354
+ "turn-error",
1355
+ {
1356
+ reason: silentDrop ? "silent-overcap-drop" : "poisoned-conversation",
1357
+ conversation: outcome.conversationId ?? null,
1358
+ },
1359
+ "error",
1360
+ );
1361
+ // A deadline with no model output is a stall/timeout, not the over-cap
1362
+ // signature: label it by its real cause (the deadline guard's recovered-
1363
+ // answer probe can settle OK with an empty response after the kill).
1364
+ const dropCause = outcome.deadline
1365
+ ? "the turn hit its time limit and produced no model output"
1366
+ : "the prompt was silently dropped (agy discards prompts past ~25KB with no error)";
1367
+ finalize(
1368
+ stream,
1369
+ blocks,
1370
+ "error",
1371
+ silentDrop
1372
+ ? `agy returned success but produced no model output: ${dropCause}. Common cause: a very large pasted prompt. The conversation binding was cleared; retry with a smaller prompt.`
1373
+ : "agy stopped responding on the resumed conversation (no response frame before the deadline; the conversation is dead). The binding was cleared, so the next antigravity turn starts a fresh conversation.",
1374
+ );
1375
+ return;
1376
+ }
1285
1377
  if (outcome.conversationId) {
1286
- store.set(key, {
1287
- conversationId: outcome.conversationId,
1288
- lastStepIdx: -1,
1289
- lastMessageCount: messageCount,
1290
- });
1378
+ // A different id than the stored one means the driver recreated the
1379
+ // conversation under us (ACP session-load fallback, stream-json CLI
1380
+ // dropping a stale --conversation). The recreated conversation has seen
1381
+ // NOTHING, so the record's watermark would starve the digest forever and
1382
+ // its bound id would keep the sysPrompt gate closed. Drop the record:
1383
+ // the next turn resumes from watermark 0 with the gate re-armed (issue
1384
+ // #1). The recreation turn itself is past the gate, so it is the
1385
+ // documented residual; recovery starts on the next turn.
1386
+ if (existing?.conversationId && outcome.conversationId !== existing.conversationId) {
1387
+ store.delete(key);
1388
+ stagedAgentByConversation.delete(existing.conversationId);
1389
+ } else if (outcome.status === "OK" && !outcome.aborted) {
1390
+ store.set(key, {
1391
+ conversationId: outcome.conversationId,
1392
+ lastStepIdx: -1,
1393
+ lastMessageCount: messageCount,
1394
+ });
1395
+ if (stagedAgentName) stagedAgentByConversation.set(outcome.conversationId, stagedAgentName);
1396
+ }
1397
+ // A failed or aborted turn on the SAME conversation keeps the pre-turn
1398
+ // record: whether the prompt actually landed in agy's DB is not
1399
+ // observable from here. Keeping the watermark re-delivers the missed
1400
+ // turn in the next digest (bounded by the digest cap); advancing it
1401
+ // would starve the conversation of context it may never have received.
1291
1402
  }
1292
1403
  if (outcome.aborted) {
1293
1404
  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`;
@@ -0,0 +1,109 @@
1
+ // G10 delivery for the stream-json engine: staged system-prompt agent.
2
+ //
3
+ // agy's stream-json prompt path silently drops any turn past a ~25KB input
4
+ // cap (probed 2026-09-30, issue #2: status SUCCESS, empty response, zero
5
+ // usage, no error — and the dropped turn leaves the conversation permanently
6
+ // unresponsive to later input). The pi system prompt block used to ride the
7
+ // first prompt of every fresh conversation and routinely crossed that cap,
8
+ // killing the whole session. It now stages as an agy agent file discovered
9
+ // under ~/.gemini/config/agents and passed via --agent: the CLI reads agent
10
+ // files from disk, off the prompt line, so the cap never applies.
11
+ //
12
+ // Frontmatter keeps agy's DEFAULTS on purpose (no excludeDefaultComponents,
13
+ // no tools list, no inheritMcp override): provider sessions need the normal
14
+ // toolset and the bridge MCP config for G9 round-trips. Contrast the ask-tool
15
+ // reviewer agent, which strips everything for sandboxing.
16
+ //
17
+ // Lifecycle: one agent dir per DISTINCT body (content-addressed by sha256);
18
+ // two conversations with different system prompts in one process stage two
19
+ // dirs and never overwrite each other, so a driver respawn mid-conversation
20
+ // always reloads that conversation's own instructions. Same body -> same dir
21
+ // (write-once; identical bytes are never rewritten). The name stays stable
22
+ // across the turns of a conversation so the driver's profile comparison
23
+ // never recycles on "agent" drift. The .pid marker lets sweepStaleWebAgents
24
+ // prune orphans (same doctrine as the web delegates and the ask-tool
25
+ // reviewer).
26
+
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { sweepStaleWebAgents } from "./web-tools.js";
32
+
33
+ export const SYS_AGENT_PREFIX = "pi-bridge-sys-";
34
+
35
+ /** AGY_AGENTS_ROOT override for tests and sandboxes (same contract as the
36
+ * ask-tool reviewer root). */
37
+ export function syspromptAgentsRoot(): string {
38
+ return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
39
+ }
40
+
41
+ /** agent.md for the carrier. The body is the pi system prompt composed by the
42
+ * provider (preamble + transcript system prompt + tool-priority note). */
43
+ export function syspromptAgentMd(name: string, body: string): string {
44
+ return [
45
+ "---",
46
+ `name: ${name}`,
47
+ "description: Pi bridge system-prompt carrier: pi operating instructions plus project AGENTS.md context",
48
+ "mainAgent: true",
49
+ "model: inherit",
50
+ "---",
51
+ "",
52
+ body,
53
+ "",
54
+ ].join("\n");
55
+ }
56
+
57
+ interface Staged {
58
+ root: string;
59
+ name: string;
60
+ dir: string;
61
+ }
62
+
63
+ /** Content-addressed staging cache: body sha256 -> staged dir. Two
64
+ * conversations with different system prompts in one process must never
65
+ * share a carrier file (a rewrite would leak one conversation's instructions
66
+ * into the other's respawned agy process). */
67
+ const stagedByHash = new Map<string, Staged>();
68
+
69
+ function bodyHash(body: string): string {
70
+ return createHash("sha256").update(body).digest("hex").slice(0, 12);
71
+ }
72
+
73
+ /** Stage the carrier agent with `body` as its instructions. Content-addressed:
74
+ * the same body reuses its dir (no rewrite), a new body gets a fresh dir.
75
+ * Returns the agent name for --agent, or null when staging failed — the
76
+ * caller then falls back to the legacy inline prompt block, where the
77
+ * silent-drop guard keeps the cap risk visible. */
78
+ export function stageSyspromptAgent(
79
+ body: string,
80
+ opts: { agentsRoot?: string } = {},
81
+ ): string | null {
82
+ try {
83
+ const root = opts.agentsRoot ?? syspromptAgentsRoot();
84
+ const hash = bodyHash(body);
85
+ const cached = stagedByHash.get(hash);
86
+ if (cached && cached.root === root) return cached.name;
87
+ sweepStaleWebAgents(root, Date.now(), SYS_AGENT_PREFIX);
88
+ fs.mkdirSync(root, { recursive: true });
89
+ // Nonce: two pi processes staging the same body must not fight over one
90
+ // dir; the pid plus nonce keeps every staging attempt self-contained.
91
+ const name = `${SYS_AGENT_PREFIX}${hash}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
92
+ const dir = path.join(root, name);
93
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
95
+ fs.writeFileSync(path.join(dir, "agent.md"), syspromptAgentMd(name, body), {
96
+ mode: 0o600,
97
+ });
98
+ const entry: Staged = { root, name, dir };
99
+ stagedByHash.set(hash, entry);
100
+ return entry.name;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Test hook: forget the staging cache without touching the filesystem. */
107
+ export function resetSyspromptAgentForTests(): void {
108
+ stagedByHash.clear();
109
+ }