@estebanforge/pi-antigravity-bridge 1.7.7 → 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,16 @@
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
+
5
15
  ## [1.7.7] - 2026-09-30
6
16
 
7
17
  ### 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.7",
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,6 +1331,49 @@ 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
1378
  // A different id than the stored one means the driver recreated the
1287
1379
  // conversation under us (ACP session-load fallback, stream-json CLI
@@ -1293,12 +1385,14 @@ async function runTurnDriver(
1293
1385
  // documented residual; recovery starts on the next turn.
1294
1386
  if (existing?.conversationId && outcome.conversationId !== existing.conversationId) {
1295
1387
  store.delete(key);
1388
+ stagedAgentByConversation.delete(existing.conversationId);
1296
1389
  } else if (outcome.status === "OK" && !outcome.aborted) {
1297
1390
  store.set(key, {
1298
1391
  conversationId: outcome.conversationId,
1299
1392
  lastStepIdx: -1,
1300
1393
  lastMessageCount: messageCount,
1301
1394
  });
1395
+ if (stagedAgentName) stagedAgentByConversation.set(outcome.conversationId, stagedAgentName);
1302
1396
  }
1303
1397
  // A failed or aborted turn on the SAME conversation keeps the pre-turn
1304
1398
  // record: whether the prompt actually landed in agy's DB is not
@@ -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
+ }