@simonepri/refined-antigravity-acp 0.1.0

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.
@@ -0,0 +1,1386 @@
1
+ import fs from "node:fs";
2
+ import { type ChildProcess, spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import readline from "node:readline";
5
+ import type { AcpConnector, AcpStream, AcpStreamMessage } from "@getpaseo/plugin/server/acp";
6
+ import { agyCommand, ensureAntigravityBinary } from "./command.js";
7
+ import { ensurePatchedBinary } from "./agy-patch.js";
8
+ import { formatSystemContext, sanitizeAssistantText, StreamSanitizer } from "./sanitize.js";
9
+ import { ensureAuthenticated } from "./auth.js";
10
+ import { type McpProxyPool, sharedMcpProxyPool } from "./mcp-proxy.js";
11
+
12
+ export const DEFAULT_HANG_GRACE_MS = 5000;
13
+ export const DEFAULT_HANG_INACTIVITY_MS = 30_000;
14
+ export const DEFAULT_RECYCLE_TIMEOUT_MS = 30_000;
15
+
16
+ export const RECYCLE_ID_PREFIX = "paseo-antigravity:recycle:";
17
+ export const RECYCLE_INIT_ID = `${RECYCLE_ID_PREFIX}init`;
18
+ export const RECYCLE_LOAD_ID = `${RECYCLE_ID_PREFIX}load`;
19
+ export const RECYCLE_MODE_ID = `${RECYCLE_ID_PREFIX}mode`;
20
+ export const RECYCLE_CONFIG_PREFIX = `${RECYCLE_ID_PREFIX}config:`;
21
+
22
+ function parseEnvMs(val: string | undefined, defaultMs: number): number {
23
+ if (!val) return defaultMs;
24
+ const num = Number(val);
25
+ return Number.isFinite(num) && num > 0 ? num : defaultMs;
26
+ }
27
+
28
+ function extractJsonCandidate(line: string): string | null {
29
+ const marker = "RAW WS MSG:";
30
+ const idx = line.indexOf(marker);
31
+ if (idx !== -1) {
32
+ return line.slice(idx + marker.length).trim();
33
+ }
34
+ const keyIdx = line.indexOf('"trajectoryStateUpdate"');
35
+ if (keyIdx === -1) return null;
36
+ const braceIdx = line.lastIndexOf("{", keyIdx);
37
+ if (braceIdx === -1) return null;
38
+ return line.slice(braceIdx).trim();
39
+ }
40
+
41
+ function parseTsuObject(parsed: unknown): { trajectoryId: string; state: string } | null {
42
+ if (!parsed || typeof parsed !== "object") return null;
43
+ const tsu = (parsed as { trajectoryStateUpdate?: unknown }).trajectoryStateUpdate;
44
+ if (!tsu || typeof tsu !== "object") return null;
45
+ const o = tsu as { trajectoryId?: unknown; state?: unknown };
46
+ if (typeof o.trajectoryId !== "string" || typeof o.state !== "string") return null;
47
+ return { trajectoryId: o.trajectoryId, state: o.state };
48
+ }
49
+
50
+ export function parseTrajectoryStateUpdate(
51
+ line: string,
52
+ ): { trajectoryId: string; state: string } | null {
53
+ const candidate = extractJsonCandidate(line);
54
+ if (!candidate) return null;
55
+
56
+ try {
57
+ return parseTsuObject(JSON.parse(candidate));
58
+ } catch {
59
+ const lastBrace = candidate.lastIndexOf("}");
60
+ if (lastBrace === -1) return null;
61
+ try {
62
+ return parseTsuObject(JSON.parse(candidate.slice(0, lastBrace + 1)));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ }
68
+
69
+ function parseStepUpdateObject(
70
+ parsed: unknown,
71
+ ): { trajectoryId: string; state: string; target?: string; source?: string } | null {
72
+ if (!parsed || typeof parsed !== "object") return null;
73
+ const su = (parsed as { stepUpdate?: unknown }).stepUpdate;
74
+ if (!su || typeof su !== "object") return null;
75
+ const o = su as {
76
+ trajectoryId?: unknown;
77
+ state?: unknown;
78
+ target?: unknown;
79
+ source?: unknown;
80
+ };
81
+ if (typeof o.trajectoryId !== "string" || typeof o.state !== "string") return null;
82
+ return {
83
+ trajectoryId: o.trajectoryId,
84
+ state: o.state,
85
+ target: typeof o.target === "string" ? o.target : undefined,
86
+ source: typeof o.source === "string" ? o.source : undefined,
87
+ };
88
+ }
89
+
90
+ export function parseStepUpdate(
91
+ line: string,
92
+ ): { trajectoryId: string; state: string; target?: string; source?: string } | null {
93
+ const candidate = extractJsonCandidate(line);
94
+ if (!candidate) return null;
95
+
96
+ try {
97
+ return parseStepUpdateObject(JSON.parse(candidate));
98
+ } catch {
99
+ const lastBrace = candidate.lastIndexOf("}");
100
+ if (lastBrace === -1) return null;
101
+ try {
102
+ return parseStepUpdateObject(JSON.parse(candidate.slice(0, lastBrace + 1)));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ }
108
+
109
+ export interface PendingPrompt {
110
+ nativeSessionId: string;
111
+ sentAt: number;
112
+ lastActivityAt: number;
113
+ timer?: NodeJS.Timeout;
114
+ inactivityTimer?: NodeJS.Timeout;
115
+ }
116
+
117
+ export interface HangDetectorOptions {
118
+ graceMs?: number;
119
+ inactivityMs?: number;
120
+ onHangDeclared?: (nativeSessionId: string, promptId: string | number) => void;
121
+ now?: () => number;
122
+ }
123
+
124
+ export class HangDetector {
125
+ public readonly graceMs: number;
126
+ public readonly inactivityMs: number;
127
+ public onHangDeclared?: (nativeSessionId: string, promptId: string | number) => void;
128
+ public readonly pendingPrompts: Map<string | number, PendingPrompt> = new Map();
129
+ public readonly lastStates: Map<string, string> = new Map();
130
+ public readonly hungSessions: Set<string> = new Set();
131
+ private readonly now: () => number;
132
+
133
+ constructor(options?: HangDetectorOptions) {
134
+ this.graceMs =
135
+ options?.graceMs ?? parseEnvMs(process.env.PASEO_AGY_HANG_GRACE_MS, DEFAULT_HANG_GRACE_MS);
136
+ this.inactivityMs =
137
+ options?.inactivityMs ??
138
+ parseEnvMs(process.env.PASEO_AGY_HANG_INACTIVITY_MS, DEFAULT_HANG_INACTIVITY_MS);
139
+ this.onHangDeclared = options?.onHangDeclared;
140
+ this.now = options?.now ?? Date.now;
141
+ }
142
+
143
+ isHung(nativeSessionId: string): boolean {
144
+ return this.hungSessions.has(nativeSessionId);
145
+ }
146
+
147
+ clearHung(nativeSessionId: string): void {
148
+ this.hungSessions.delete(nativeSessionId);
149
+ this.lastStates.delete(nativeSessionId);
150
+ }
151
+
152
+ private scheduleInactivityTimer(
153
+ nativeSessionId: string,
154
+ promptId: string | number,
155
+ prompt: PendingPrompt,
156
+ ): void {
157
+ if (prompt.inactivityTimer) {
158
+ clearTimeout(prompt.inactivityTimer);
159
+ }
160
+ prompt.inactivityTimer = setTimeout(() => {
161
+ this.checkInactivity(nativeSessionId, promptId);
162
+ }, this.inactivityMs);
163
+ }
164
+
165
+ private startGraceTimer(
166
+ nativeSessionId: string,
167
+ id: string | number,
168
+ prompt: PendingPrompt,
169
+ ): void {
170
+ if (prompt.timer) return;
171
+ prompt.timer = setTimeout(() => {
172
+ this.onGraceTimerFired(nativeSessionId, id);
173
+ }, this.graceMs);
174
+ }
175
+
176
+ recordPrompt(id: string | number, nativeSessionId: string): void {
177
+ const now = this.now();
178
+ const prompt: PendingPrompt = {
179
+ nativeSessionId,
180
+ sentAt: now,
181
+ lastActivityAt: now,
182
+ };
183
+ this.pendingPrompts.set(id, prompt);
184
+ this.scheduleInactivityTimer(nativeSessionId, id, prompt);
185
+
186
+ if (this.lastStates.get(nativeSessionId) === "STATE_WAITING_FOR_TASKS") {
187
+ this.startGraceTimer(nativeSessionId, id, prompt);
188
+ }
189
+ }
190
+
191
+ recordActivity(nativeSessionId: string): void {
192
+ const now = this.now();
193
+ for (const [id, prompt] of this.pendingPrompts.entries()) {
194
+ if (prompt.nativeSessionId === nativeSessionId) {
195
+ prompt.lastActivityAt = now;
196
+ this.scheduleInactivityTimer(nativeSessionId, id, prompt);
197
+ }
198
+ }
199
+ }
200
+
201
+ resolvePrompt(id: string | number): void {
202
+ const prompt = this.pendingPrompts.get(id);
203
+ if (!prompt) return;
204
+
205
+ if (prompt.timer) {
206
+ clearTimeout(prompt.timer);
207
+ }
208
+ if (prompt.inactivityTimer) {
209
+ clearTimeout(prompt.inactivityTimer);
210
+ }
211
+ this.pendingPrompts.delete(id);
212
+ }
213
+
214
+ private clearPromptTimers(prompt: PendingPrompt): void {
215
+ if (prompt.timer) {
216
+ clearTimeout(prompt.timer);
217
+ prompt.timer = undefined;
218
+ }
219
+ if (prompt.inactivityTimer) {
220
+ clearTimeout(prompt.inactivityTimer);
221
+ prompt.inactivityTimer = undefined;
222
+ }
223
+ }
224
+
225
+ private logHangReason(
226
+ nativeSessionId: string,
227
+ promptId: string | number,
228
+ reason: "grace" | "inactivity" | "turn_complete",
229
+ ): void {
230
+ if (reason === "inactivity") {
231
+ console.error(
232
+ `[paseo-antigravity] Inactivity backstop fired for session ${nativeSessionId}, prompt ${promptId}`,
233
+ );
234
+ } else if (reason === "turn_complete") {
235
+ console.error(
236
+ `[paseo-antigravity] Turn completion grace timer fired for session ${nativeSessionId}, prompt ${promptId}`,
237
+ );
238
+ } else {
239
+ console.error(
240
+ `[paseo-antigravity] Hang declared for session ${nativeSessionId}, prompt ${promptId} (STATE_WAITING_FOR_TASKS)`,
241
+ );
242
+ }
243
+ }
244
+
245
+ private declareHang(
246
+ nativeSessionId: string,
247
+ promptId: string | number,
248
+ reason: "grace" | "inactivity" | "turn_complete",
249
+ ): void {
250
+ this.hungSessions.add(nativeSessionId);
251
+ const prompt = this.pendingPrompts.get(promptId);
252
+ if (prompt) {
253
+ this.clearPromptTimers(prompt);
254
+ this.pendingPrompts.delete(promptId);
255
+ }
256
+ this.logHangReason(nativeSessionId, promptId, reason);
257
+ this.onHangDeclared?.(nativeSessionId, promptId);
258
+ }
259
+
260
+ private onGraceTimerFired(
261
+ nativeSessionId: string,
262
+ promptId: string | number,
263
+ reason: "grace" | "turn_complete" = "grace",
264
+ ): void {
265
+ const prompt = this.pendingPrompts.get(promptId);
266
+ if (!prompt) return;
267
+ prompt.timer = undefined;
268
+ this.declareHang(nativeSessionId, promptId, reason);
269
+ }
270
+
271
+ private checkInactivity(nativeSessionId: string, promptId: string | number): void {
272
+ const prompt = this.pendingPrompts.get(promptId);
273
+ if (!prompt) return;
274
+
275
+ const elapsed = this.now() - prompt.lastActivityAt;
276
+ if (elapsed >= this.inactivityMs) {
277
+ this.declareHang(nativeSessionId, promptId, "inactivity");
278
+ return;
279
+ }
280
+ const remaining = this.inactivityMs - elapsed;
281
+ prompt.inactivityTimer = setTimeout(() => {
282
+ this.checkInactivity(nativeSessionId, promptId);
283
+ }, remaining);
284
+ }
285
+
286
+ private handleWaitingForTasks(nativeSessionId: string): void {
287
+ for (const [id, prompt] of this.pendingPrompts.entries()) {
288
+ if (prompt.nativeSessionId === nativeSessionId) {
289
+ this.startGraceTimer(nativeSessionId, id, prompt);
290
+ }
291
+ }
292
+ }
293
+
294
+ private handleTurnCompleteCandidate(nativeSessionId: string): void {
295
+ for (const [id, prompt] of this.pendingPrompts.entries()) {
296
+ if (prompt.nativeSessionId === nativeSessionId && !prompt.timer) {
297
+ prompt.timer = setTimeout(() => {
298
+ this.onGraceTimerFired(nativeSessionId, id, "turn_complete");
299
+ }, this.graceMs);
300
+ }
301
+ }
302
+ }
303
+
304
+ private clearGraceTimersForSession(nativeSessionId: string): void {
305
+ for (const prompt of this.pendingPrompts.values()) {
306
+ if (prompt.nativeSessionId === nativeSessionId && prompt.timer) {
307
+ clearTimeout(prompt.timer);
308
+ prompt.timer = undefined;
309
+ }
310
+ }
311
+ }
312
+
313
+ processStderrLine(line: string): void {
314
+ const tsu = parseTrajectoryStateUpdate(line);
315
+ if (tsu) {
316
+ const { trajectoryId, state } = tsu;
317
+ this.lastStates.set(trajectoryId, state);
318
+
319
+ if (state === "STATE_WAITING_FOR_TASKS") {
320
+ this.handleWaitingForTasks(trajectoryId);
321
+ } else {
322
+ this.clearGraceTimersForSession(trajectoryId);
323
+ }
324
+ return;
325
+ }
326
+
327
+ const su = parseStepUpdate(line);
328
+ if (su) {
329
+ const { trajectoryId, state, target } = su;
330
+ if (state === "STATE_DONE" && target === "TARGET_USER") {
331
+ this.handleTurnCompleteCandidate(trajectoryId);
332
+ } else if (state === "STATE_ACTIVE" && target === "TARGET_ENVIRONMENT") {
333
+ // Active environment step (e.g. running a tool) clears grace timer
334
+ this.clearGraceTimersForSession(trajectoryId);
335
+ }
336
+ }
337
+ }
338
+
339
+ processOutbound(msg: AcpStreamMessage): void {
340
+ if (!("method" in msg) || msg.method !== "session/prompt") return;
341
+ const m = msg as { id?: unknown; params?: { sessionId?: unknown } };
342
+ if (typeof m.id !== "string" && typeof m.id !== "number") return;
343
+ if (typeof m.params?.sessionId !== "string") return;
344
+
345
+ this.recordPrompt(m.id, m.params.sessionId);
346
+ }
347
+
348
+ processInbound(msg: AcpStreamMessage): void {
349
+ if ("method" in msg && msg.method === "session/update") {
350
+ const sessionId = (msg as { params?: { sessionId?: unknown } }).params?.sessionId;
351
+ if (typeof sessionId === "string") {
352
+ this.recordActivity(sessionId);
353
+ }
354
+ return;
355
+ }
356
+
357
+ if (("result" in msg || "error" in msg) && "id" in msg) {
358
+ const id = (msg as { id?: unknown }).id;
359
+ if (typeof id === "string" || typeof id === "number") {
360
+ this.resolvePrompt(id);
361
+ }
362
+ }
363
+ }
364
+
365
+ dispose(): void {
366
+ for (const prompt of this.pendingPrompts.values()) {
367
+ this.clearPromptTimers(prompt);
368
+ }
369
+ this.pendingPrompts.clear();
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Strips duplicate mode configuration options from ACP session/new and session/load responses.
375
+ *
376
+ * agy_acp_server returns modes both in `result.modes` and as a setting option with `id: "mode"`
377
+ * in `result.configOptions`. Filtering out `id: "mode"` prevents duplicate controls in the Paseo UI.
378
+ */
379
+ export function filterConfigOptions(result: unknown): void {
380
+ if (!result || typeof result !== "object" || !("configOptions" in result)) return;
381
+ const res = result as { configOptions?: unknown[] };
382
+ if (!Array.isArray(res.configOptions)) return;
383
+
384
+ res.configOptions = res.configOptions.filter((opt) => {
385
+ if (!opt || typeof opt !== "object") return true;
386
+ const o = opt as { id?: string; category?: string };
387
+ return o.id !== "mode" && o.category !== "mode";
388
+ });
389
+ }
390
+
391
+ function fallbackSanitizeBlock(block: unknown): void {
392
+ if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
393
+ block.text = sanitizeAssistantText(block.text);
394
+ }
395
+ }
396
+
397
+ function fallbackSanitizeInboundText(msg: AcpStreamMessage): void {
398
+ if (!("method" in msg) || msg.method !== "session/update") return;
399
+ const content = (msg as { params?: { update?: { content?: unknown } } }).params?.update?.content;
400
+ if (!content) return;
401
+
402
+ if (Array.isArray(content)) {
403
+ for (const block of content) fallbackSanitizeBlock(block);
404
+ } else {
405
+ fallbackSanitizeBlock(content);
406
+ }
407
+ }
408
+
409
+ function sanitizeBlock(block: unknown, sanitizer: StreamSanitizer): boolean {
410
+ if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
411
+ block.text = sanitizer.process(block.text);
412
+ return block.text !== "";
413
+ }
414
+ return true;
415
+ }
416
+
417
+ function extractUpdateContent(
418
+ msg: AcpStreamMessage,
419
+ ): { content: unknown; isMessageChunk: boolean } | null {
420
+ if (!("method" in msg) || msg.method !== "session/update") return null;
421
+ const update = (msg as { params?: { update?: unknown } }).params?.update;
422
+ if (!update || typeof update !== "object") return null;
423
+ const content = (update as { content?: unknown }).content;
424
+ if (!content) return null;
425
+ const isMessageChunk =
426
+ (update as { sessionUpdate?: string }).sessionUpdate === "agent_message_chunk";
427
+ return { content, isMessageChunk };
428
+ }
429
+
430
+ function sanitizeInboundText(msg: AcpStreamMessage, sanitizer: StreamSanitizer): boolean {
431
+ const target = extractUpdateContent(msg);
432
+ if (!target) return true;
433
+
434
+ let hasNonEmpty = false;
435
+ if (Array.isArray(target.content)) {
436
+ for (const block of target.content) {
437
+ if (sanitizeBlock(block, sanitizer)) hasNonEmpty = true;
438
+ }
439
+ } else {
440
+ hasNonEmpty = sanitizeBlock(target.content, sanitizer);
441
+ }
442
+
443
+ return !target.isMessageChunk || hasNonEmpty;
444
+ }
445
+
446
+ export interface SystemPromptState {
447
+ pendingSystemPrompts: Map<string | number, string | undefined>;
448
+ sessionSystemPrompts: Map<string, string | undefined>;
449
+ injectedSessions: Set<string>;
450
+ }
451
+
452
+ export function createSystemPromptState(): SystemPromptState {
453
+ return {
454
+ pendingSystemPrompts: new Map(),
455
+ sessionSystemPrompts: new Map(),
456
+ injectedSessions: new Set(),
457
+ };
458
+ }
459
+
460
+ const defaultSystemPromptState = createSystemPromptState();
461
+
462
+ export function resetSystemPromptState(): void {
463
+ defaultSystemPromptState.pendingSystemPrompts.clear();
464
+ defaultSystemPromptState.sessionSystemPrompts.clear();
465
+ defaultSystemPromptState.injectedSessions.clear();
466
+ }
467
+
468
+ /** Tracks the session a turn belongs to so flushed text can be attributed to it. */
469
+ export interface InboundState {
470
+ lastSessionId: string | null;
471
+ }
472
+
473
+ function sessionIdOf(msg: AcpStreamMessage): string | null {
474
+ if (!("method" in msg) || msg.method !== "session/update") return null;
475
+ const sessionId = (msg as { params?: { sessionId?: unknown } }).params?.sessionId;
476
+ return typeof sessionId === "string" ? sessionId : null;
477
+ }
478
+
479
+ function agentMessageChunk(sessionId: string, text: string): AcpStreamMessage {
480
+ return {
481
+ jsonrpc: "2.0",
482
+ method: "session/update",
483
+ params: {
484
+ sessionId,
485
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
486
+ },
487
+ } as unknown as AcpStreamMessage;
488
+ }
489
+
490
+ /**
491
+ * Processes an inbound ACP message received from `agy_acp_server`:
492
+ * 1. Filters duplicate mode controls from `configOptions`.
493
+ * 2. Sanitizes assistant streaming text notifications across chunk boundaries.
494
+ * 3. Flushes text still held in the sanitizer's look-ahead buffer at a turn boundary.
495
+ *
496
+ * Returns the messages to forward. A streaming chunk that sanitizes to nothing is
497
+ * dropped, and a turn boundary may yield an extra chunk carrying the flushed tail.
498
+ */
499
+ function closeTurn(
500
+ msg: AcpStreamMessage,
501
+ sanitizer?: StreamSanitizer,
502
+ state?: InboundState,
503
+ ): AcpStreamMessage[] {
504
+ filterConfigOptions((msg as { result: unknown }).result);
505
+ // A response ends the turn. Whatever is still buffered was a suspected harness tag
506
+ // that never materialized, so it is real text and must be emitted, not discarded.
507
+ const tail = sanitizer?.flush() ?? "";
508
+ sanitizer?.reset();
509
+ const sessionId = state?.lastSessionId;
510
+ return tail && sessionId ? [agentMessageChunk(sessionId, tail), msg] : [msg];
511
+ }
512
+
513
+ export interface CachedConfigOption {
514
+ value: unknown;
515
+ type?: string;
516
+ }
517
+
518
+ export interface CachedSessionMetadata {
519
+ sessionId: string;
520
+ cwd?: string;
521
+ mcpServers?: unknown[];
522
+ _meta?: unknown;
523
+ lastModeId?: string;
524
+ lastConfigOptions: Map<string, CachedConfigOption>;
525
+ needsRecycle?: boolean;
526
+ }
527
+
528
+ export interface SessionCache {
529
+ sessions: Map<string, CachedSessionMetadata>;
530
+ pendingSessionMetadata: Map<string | number, Partial<CachedSessionMetadata>>;
531
+ cachedInitializeParams?: unknown;
532
+ }
533
+
534
+ export function createSessionCache(): SessionCache {
535
+ return {
536
+ sessions: new Map(),
537
+ pendingSessionMetadata: new Map(),
538
+ cachedInitializeParams: undefined,
539
+ };
540
+ }
541
+
542
+ const defaultSessionCache = createSessionCache();
543
+
544
+ export function resetSessionCache(): void {
545
+ defaultSessionCache.sessions.clear();
546
+ defaultSessionCache.pendingSessionMetadata.clear();
547
+ defaultSessionCache.cachedInitializeParams = undefined;
548
+ }
549
+
550
+ export function getOrCreateSession(cache: SessionCache, sessionId: string): CachedSessionMetadata {
551
+ let session = cache.sessions.get(sessionId);
552
+ if (!session) {
553
+ session = {
554
+ sessionId,
555
+ lastConfigOptions: new Map(),
556
+ };
557
+ cache.sessions.set(sessionId, session);
558
+ }
559
+ return session;
560
+ }
561
+
562
+ function recordAllocatedSessionPrompt(
563
+ id: string | number,
564
+ sessionId: string,
565
+ promptState: SystemPromptState,
566
+ ): void {
567
+ if (promptState.pendingSystemPrompts.has(id)) {
568
+ const systemPrompt = promptState.pendingSystemPrompts.get(id);
569
+ promptState.pendingSystemPrompts.delete(id);
570
+ promptState.sessionSystemPrompts.set(sessionId, systemPrompt);
571
+ }
572
+ }
573
+
574
+ function recordAllocatedSessionMeta(
575
+ id: string | number,
576
+ sessionId: string,
577
+ cache: SessionCache,
578
+ ): void {
579
+ if (cache.pendingSessionMetadata.has(id)) {
580
+ const pending = cache.pendingSessionMetadata.get(id);
581
+ cache.pendingSessionMetadata.delete(id);
582
+ const session = getOrCreateSession(cache, sessionId);
583
+ if (pending?.cwd !== undefined) session.cwd = pending.cwd;
584
+ if (pending?.mcpServers !== undefined) session.mcpServers = pending.mcpServers;
585
+ if (pending?._meta !== undefined) session._meta = pending._meta;
586
+ }
587
+ }
588
+
589
+ function recordAllocatedSession(
590
+ msg: AcpStreamMessage,
591
+ promptState: SystemPromptState,
592
+ cache: SessionCache,
593
+ ): void {
594
+ if (!("result" in msg) || !msg.result || typeof msg.result !== "object") return;
595
+ if (!("sessionId" in msg.result)) return;
596
+ const sessionId = (msg.result as { sessionId?: unknown }).sessionId;
597
+ if (typeof sessionId !== "string" || !("id" in msg) || msg.id === null || msg.id === undefined) {
598
+ return;
599
+ }
600
+
601
+ recordAllocatedSessionPrompt(msg.id, sessionId, promptState);
602
+ recordAllocatedSessionMeta(msg.id, sessionId, cache);
603
+ }
604
+
605
+ export function processInboundMessage(
606
+ msg: AcpStreamMessage,
607
+ sanitizer?: StreamSanitizer,
608
+ state?: InboundState,
609
+ promptState: SystemPromptState = defaultSystemPromptState,
610
+ sessionCache: SessionCache = defaultSessionCache,
611
+ ): AcpStreamMessage[] {
612
+ if ("result" in msg && msg.result) {
613
+ recordAllocatedSession(msg, promptState, sessionCache);
614
+ return closeTurn(msg, sanitizer, state);
615
+ }
616
+
617
+ if (state) {
618
+ const sessionId = sessionIdOf(msg);
619
+ if (sessionId) state.lastSessionId = sessionId;
620
+ }
621
+
622
+ if (sanitizer) {
623
+ return sanitizeInboundText(msg, sanitizer) ? [msg] : [];
624
+ }
625
+ fallbackSanitizeInboundText(msg);
626
+ return [msg];
627
+ }
628
+
629
+ interface McpHeader {
630
+ name: string;
631
+ value: string;
632
+ }
633
+
634
+ function normalizeMcpHeaders(headers: unknown): McpHeader[] | undefined {
635
+ if (!headers) return undefined;
636
+ if (Array.isArray(headers)) {
637
+ return headers.flatMap((h) => {
638
+ if (!h || typeof h !== "object") return [];
639
+ const name = typeof h.name === "string" ? h.name : typeof h.key === "string" ? h.key : "";
640
+ const value = typeof h.value === "string" ? h.value : "";
641
+ return name ? [{ name, value }] : [];
642
+ });
643
+ }
644
+ if (typeof headers === "object") {
645
+ return Object.entries(headers as Record<string, unknown>).flatMap(([name, val]) =>
646
+ typeof val === "string" ? [{ name, value: val }] : [],
647
+ );
648
+ }
649
+ return undefined;
650
+ }
651
+
652
+ /**
653
+ * Rewrites Paseo MCP server declarations for agy_acp_server:
654
+ * 1. Routes the URL through a local proxy that normalizes the MCP protocol version.
655
+ * 2. Normalizes headers to array shape [{ name, value }].
656
+ *
657
+ * The transport type is passed through untouched. agy advertises
658
+ * `mcpCapabilities: {http: true, sse: true}` and issues streamable-HTTP POSTs either
659
+ * way, so declaring Paseo's streamable-HTTP endpoint as "sse" only misdescribed it.
660
+ */
661
+ export async function rewriteMcpServers(servers: unknown[], proxies?: McpProxyPool): Promise<void> {
662
+ for (const s of servers) {
663
+ if (!s || typeof s !== "object") continue;
664
+ const server = s as { type?: string; url?: string; headers?: unknown };
665
+ if (server.url && proxies) {
666
+ server.url = await proxies.rewriteUrl(server.url);
667
+ }
668
+ const normalized = normalizeMcpHeaders(server.headers);
669
+ if (normalized) {
670
+ server.headers = normalized;
671
+ }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Processes an outbound ACP message sent from Paseo to `agy_acp_server`:
677
+ * 1. Rewrites MCP server declarations (URL proxying, headers normalization).
678
+ * 2. Maps legacy / generic mode IDs ("accept-edits" -> "auto_edit", "dangerously-skip-permissions" -> "yolo").
679
+ */
680
+ function remapModeId(modeId: string): string {
681
+ if (modeId === "accept-edits") return "auto_edit";
682
+ if (modeId === "dangerously-skip-permissions") return "yolo";
683
+ if (modeId === "plan") return "default";
684
+ return modeId;
685
+ }
686
+
687
+ interface OutboundParams {
688
+ mcpServers?: unknown[];
689
+ modeId?: string;
690
+ sessionId?: unknown;
691
+ prompt?: unknown;
692
+ cwd?: string;
693
+ configId?: string;
694
+ value?: unknown;
695
+ type?: string;
696
+ _meta?: {
697
+ _paseo?: {
698
+ systemPrompt?: string;
699
+ };
700
+ };
701
+ }
702
+
703
+ function captureSessionNewPrompt(msg: AcpStreamMessage, promptState: SystemPromptState): void {
704
+ if (!("id" in msg) || msg.id === null || msg.id === undefined) return;
705
+ const params = (msg as { params?: OutboundParams }).params;
706
+ const systemPrompt = params?._meta?._paseo?.systemPrompt;
707
+ promptState.pendingSystemPrompts.set(msg.id, systemPrompt);
708
+ }
709
+
710
+ function injectSystemContext(prompt: unknown, systemPrompt?: string): void {
711
+ if (!Array.isArray(prompt)) return;
712
+ const textPart = prompt.find((p): p is { type: "text"; text: string } =>
713
+ Boolean(
714
+ p &&
715
+ typeof p === "object" &&
716
+ (p as { type?: unknown }).type === "text" &&
717
+ typeof (p as { text?: unknown }).text === "string",
718
+ ),
719
+ );
720
+ const formatted = `${formatSystemContext(systemPrompt)}
721
+
722
+ `;
723
+ if (textPart) {
724
+ textPart.text = `${formatted}${textPart.text}`;
725
+ } else {
726
+ prompt.unshift({ type: "text", text: formatted.trimEnd() });
727
+ }
728
+ }
729
+
730
+ function handleSessionPrompt(
731
+ params: OutboundParams | undefined,
732
+ promptState: SystemPromptState,
733
+ ): void {
734
+ const sessionId = typeof params?.sessionId === "string" ? params.sessionId : null;
735
+ if (!sessionId) return;
736
+ if (!promptState.sessionSystemPrompts.has(sessionId)) return;
737
+ if (promptState.injectedSessions.has(sessionId)) return;
738
+
739
+ promptState.injectedSessions.add(sessionId);
740
+ const systemPrompt = promptState.sessionSystemPrompts.get(sessionId);
741
+ injectSystemContext(params?.prompt, systemPrompt);
742
+ }
743
+
744
+ function captureSessionNewMetadata(msg: AcpStreamMessage, cache: SessionCache): void {
745
+ if (!("id" in msg) || msg.id === null || msg.id === undefined) return;
746
+ const params = (msg as { params?: OutboundParams }).params;
747
+ cache.pendingSessionMetadata.set(msg.id, {
748
+ cwd: typeof params?.cwd === "string" ? params.cwd : undefined,
749
+ mcpServers: Array.isArray(params?.mcpServers) ? params?.mcpServers : undefined,
750
+ _meta: params?._meta,
751
+ });
752
+ }
753
+
754
+ function captureSessionLoadMetadata(msg: AcpStreamMessage, cache: SessionCache): void {
755
+ const params = (msg as { params?: OutboundParams }).params;
756
+ if (typeof params?.sessionId !== "string") return;
757
+ const session = getOrCreateSession(cache, params.sessionId);
758
+ if (typeof params.cwd === "string") session.cwd = params.cwd;
759
+ if (Array.isArray(params.mcpServers)) session.mcpServers = params.mcpServers;
760
+ if (params._meta !== undefined) session._meta = params._meta;
761
+ }
762
+
763
+ function captureSetMode(msg: AcpStreamMessage, cache: SessionCache): void {
764
+ const params = (msg as { params?: OutboundParams }).params;
765
+ if (typeof params?.sessionId !== "string" || typeof params?.modeId !== "string") return;
766
+ const session = getOrCreateSession(cache, params.sessionId);
767
+ session.lastModeId = params.modeId;
768
+ }
769
+
770
+ function captureSetConfigOption(msg: AcpStreamMessage, cache: SessionCache): void {
771
+ const params = (msg as { params?: OutboundParams }).params;
772
+ if (typeof params?.sessionId !== "string" || typeof params?.configId !== "string") return;
773
+ const session = getOrCreateSession(cache, params.sessionId);
774
+ session.lastConfigOptions.set(params.configId, {
775
+ value: params.value,
776
+ type: typeof params.type === "string" ? params.type : undefined,
777
+ });
778
+ }
779
+
780
+ function cacheInitializeParams(msg: AcpStreamMessage, cache: SessionCache): void {
781
+ if ("params" in msg) {
782
+ cache.cachedInitializeParams = (msg as { params?: unknown }).params;
783
+ }
784
+ }
785
+
786
+ export function extractSessionId(msg: AcpStreamMessage): string | null {
787
+ if (!("params" in msg) || !msg.params || typeof msg.params !== "object") {
788
+ return null;
789
+ }
790
+ const sid = (msg.params as { sessionId?: unknown }).sessionId;
791
+ return typeof sid === "string" ? sid : null;
792
+ }
793
+
794
+ async function adaptOutboundParams(params?: OutboundParams, proxies?: McpProxyPool): Promise<void> {
795
+ if (params?.mcpServers && Array.isArray(params.mcpServers)) {
796
+ await rewriteMcpServers(params.mcpServers, proxies);
797
+ }
798
+ if (params?.modeId) {
799
+ params.modeId = remapModeId(params.modeId);
800
+ }
801
+ }
802
+
803
+ function dispatchOutboundMethod(
804
+ msg: AcpStreamMessage & { method: string },
805
+ params: OutboundParams | undefined,
806
+ promptState: SystemPromptState,
807
+ sessionCache: SessionCache,
808
+ ): void {
809
+ switch (msg.method) {
810
+ case "initialize":
811
+ cacheInitializeParams(msg, sessionCache);
812
+ break;
813
+ case "session/new":
814
+ captureSessionNewPrompt(msg, promptState);
815
+ captureSessionNewMetadata(msg, sessionCache);
816
+ break;
817
+ case "session/load":
818
+ captureSessionLoadMetadata(msg, sessionCache);
819
+ break;
820
+ case "session/set_mode":
821
+ captureSetMode(msg, sessionCache);
822
+ break;
823
+ case "session/set_config_option":
824
+ captureSetConfigOption(msg, sessionCache);
825
+ break;
826
+ case "session/prompt":
827
+ handleSessionPrompt(params, promptState);
828
+ break;
829
+ }
830
+ }
831
+
832
+ export async function processOutboundMessage(
833
+ msg: AcpStreamMessage,
834
+ proxies?: McpProxyPool,
835
+ promptState: SystemPromptState = defaultSystemPromptState,
836
+ sessionCache: SessionCache = defaultSessionCache,
837
+ ): Promise<AcpStreamMessage> {
838
+ if (!("method" in msg)) return msg;
839
+ const params = (msg as { params?: OutboundParams }).params;
840
+ await adaptOutboundParams(params, proxies);
841
+ dispatchOutboundMethod(msg, params, promptState, sessionCache);
842
+ return msg;
843
+ }
844
+
845
+ export interface StreamState {
846
+ isClosed: boolean;
847
+ }
848
+
849
+ const ACP_TRACE_ENABLED = process.env.PASEO_AGY_TRACE === "1";
850
+
851
+ /**
852
+ * Logs ACP request/response ids when PASEO_AGY_TRACE=1.
853
+ *
854
+ * An ACP request that never receives a response leaves Paseo waiting out its 60s
855
+ * session RPC timeout with no indication of which call stalled; this names it.
856
+ */
857
+ function traceAcp(direction: "paseo->agy" | "agy->paseo", msg: AcpStreamMessage): void {
858
+ if (!ACP_TRACE_ENABLED) return;
859
+ const m = msg as { id?: unknown; method?: unknown; error?: unknown };
860
+ const id = m.id === undefined ? "-" : String(m.id);
861
+ const kind = m.method ? String(m.method) : m.error ? "error" : "result";
862
+ console.error(`[paseo-antigravity][acp] ${direction} id=${id} ${kind}`);
863
+ }
864
+
865
+ export interface AntigravityConnectorOptions extends HangDetectorOptions {
866
+ spawnProcess?: (cmd: string, args: string[]) => ChildProcess | Promise<ChildProcess>;
867
+ recycleTimeoutMs?: number;
868
+ }
869
+
870
+ export interface ChildSlotParams {
871
+ cmd: string;
872
+ args: string[];
873
+ initialChild: ChildProcess;
874
+ proxies?: McpProxyPool;
875
+ options?: AntigravityConnectorOptions;
876
+ }
877
+
878
+ interface PendingInternalRequest {
879
+ resolve: (msg: AcpStreamMessage) => void;
880
+ reject: (err: Error) => void;
881
+ timer: NodeJS.Timeout;
882
+ }
883
+
884
+ export function resolveHarnessPath(cmd: string): string | undefined {
885
+ const real = fs.realpathSync(cmd);
886
+ const sibling = require("node:path").join(
887
+ require("node:path").dirname(real),
888
+ "localharness_external",
889
+ );
890
+ if (fs.existsSync(sibling)) return sibling;
891
+ const originalSibling = "/Users/simonepri/.local/share/antigravity-acp/localharness_external";
892
+ return fs.existsSync(originalSibling) ? originalSibling : undefined;
893
+ }
894
+
895
+ function defaultSpawnProcess(cmd: string, args: string[]): ChildProcess {
896
+ const env: NodeJS.ProcessEnv = {
897
+ ...process.env,
898
+ AGY_ACP_FORCE_FILE_STORAGE: "1",
899
+ BROWSER: "false",
900
+ };
901
+ const harness = resolveHarnessPath(cmd);
902
+ if (harness) {
903
+ env.ANTIGRAVITY_HARNESS_PATH = harness;
904
+ }
905
+ return spawn(cmd, args, {
906
+ stdio: ["pipe", "pipe", "pipe"],
907
+ env,
908
+ });
909
+ }
910
+
911
+ export class ChildSlot {
912
+ public currentChild: ChildProcess;
913
+ public rlOut: readline.Interface | null = null;
914
+ public rlErr: readline.Interface | null = null;
915
+ public isRecycling = false;
916
+ public isSwallowingUpdates = false;
917
+ public readonly suppressedResponseIds: Set<string | number> = new Set();
918
+ public readonly sessionCache: SessionCache = createSessionCache();
919
+ public readonly promptState: SystemPromptState = createSystemPromptState();
920
+ public readonly hangDetector: HangDetector;
921
+ public readonly state: StreamState = { isClosed: false };
922
+ public readableController: ReadableStreamDefaultController<AcpStreamMessage> | null = null;
923
+
924
+ private readonly cmd: string;
925
+ private readonly args: string[];
926
+ private readonly spawnFn: (cmd: string, args: string[]) => ChildProcess | Promise<ChildProcess>;
927
+ private readonly recycleTimeoutMs: number;
928
+ private readonly sanitizer = new StreamSanitizer();
929
+ private readonly inboundState: InboundState = { lastSessionId: null };
930
+ private readonly pendingRequests: Map<string | number, PendingInternalRequest> = new Map();
931
+ private activeRecycle: Promise<void> | null = null;
932
+
933
+ constructor(params: ChildSlotParams) {
934
+ this.cmd = params.cmd;
935
+ this.args = params.args;
936
+ this.currentChild = params.initialChild;
937
+ this.spawnFn = params.options?.spawnProcess ?? defaultSpawnProcess;
938
+ this.recycleTimeoutMs =
939
+ params.options?.recycleTimeoutMs ??
940
+ parseEnvMs(process.env.PASEO_AGY_RECYCLE_TIMEOUT_MS, DEFAULT_RECYCLE_TIMEOUT_MS);
941
+
942
+ const userOnHang = params.options?.onHangDeclared;
943
+ this.hangDetector = new HangDetector({
944
+ ...params.options,
945
+ onHangDeclared: (nativeSessionId, promptId) => {
946
+ this.handleHangDeclared(nativeSessionId, promptId);
947
+ userOnHang?.(nativeSessionId, promptId);
948
+ },
949
+ });
950
+
951
+ this.bindChild(this.currentChild);
952
+ }
953
+
954
+ setReadableController(controller: ReadableStreamDefaultController<AcpStreamMessage>): void {
955
+ this.readableController = controller;
956
+ }
957
+
958
+ private parseJsonLine(line: string): AcpStreamMessage | null {
959
+ try {
960
+ return JSON.parse(line) as AcpStreamMessage;
961
+ } catch {
962
+ return null;
963
+ }
964
+ }
965
+
966
+ private tryHandleInternalRecycle(msg: AcpStreamMessage): boolean {
967
+ if (!("id" in msg) || msg.id === null || msg.id === undefined) return false;
968
+ const idStr = String(msg.id);
969
+ if (!idStr.startsWith(RECYCLE_ID_PREFIX)) return false;
970
+
971
+ const pending = this.pendingRequests.get(msg.id);
972
+ if (pending) {
973
+ clearTimeout(pending.timer);
974
+ this.pendingRequests.delete(msg.id);
975
+ if ("error" in msg && msg.error) {
976
+ pending.reject(new Error(msg.error.message || "Recycle request failed"));
977
+ } else {
978
+ pending.resolve(msg);
979
+ }
980
+ }
981
+ return true;
982
+ }
983
+
984
+ private tryHandleSuppressedResponse(msg: AcpStreamMessage): boolean {
985
+ if (!("id" in msg) || msg.id === null || msg.id === undefined) return false;
986
+ if (this.suppressedResponseIds.has(msg.id)) {
987
+ this.suppressedResponseIds.delete(msg.id);
988
+ console.error(`[paseo-antigravity] Dropped late response for hung prompt ${msg.id}`);
989
+ return true;
990
+ }
991
+ return false;
992
+ }
993
+
994
+ private forwardInbound(msg: AcpStreamMessage): void {
995
+ const list = processInboundMessage(
996
+ msg,
997
+ this.sanitizer,
998
+ this.inboundState,
999
+ this.promptState,
1000
+ this.sessionCache,
1001
+ );
1002
+ for (const processed of list) {
1003
+ try {
1004
+ this.readableController?.enqueue(processed);
1005
+ } catch {
1006
+ // Controller may already be closed
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ handleStdoutLine(line: string): void {
1012
+ if (this.state.isClosed) return;
1013
+ const msg = this.parseJsonLine(line);
1014
+ if (!msg) return;
1015
+
1016
+ traceAcp("agy->paseo", msg);
1017
+
1018
+ if (this.tryHandleInternalRecycle(msg)) return;
1019
+ if (this.tryHandleSuppressedResponse(msg)) return;
1020
+ if (this.isSwallowingUpdates && "method" in msg && msg.method === "session/update") return;
1021
+
1022
+ this.hangDetector.processInbound(msg);
1023
+ this.forwardInbound(msg);
1024
+ }
1025
+
1026
+ handleChildClose(err?: Error): void {
1027
+ if (this.isRecycling) return;
1028
+ if (this.state.isClosed) return;
1029
+
1030
+ this.state.isClosed = true;
1031
+ this.hangDetector.dispose();
1032
+ this.rlErr?.close();
1033
+ this.rlOut?.close();
1034
+ try {
1035
+ if (err) this.readableController?.error(err);
1036
+ else this.readableController?.close();
1037
+ } catch {
1038
+ // Stream already closed
1039
+ }
1040
+ }
1041
+
1042
+ handleHangDeclared(nativeSessionId: string, promptId: string | number): void {
1043
+ console.error(
1044
+ `[paseo-antigravity] Session ${nativeSessionId} hung on prompt ${promptId}; turn synthetic end_turn emitted, scheduled process recycle`,
1045
+ );
1046
+ this.suppressedResponseIds.add(promptId);
1047
+ const session = getOrCreateSession(this.sessionCache, nativeSessionId);
1048
+ session.needsRecycle = true;
1049
+
1050
+ const syntheticMsg: AcpStreamMessage = {
1051
+ jsonrpc: "2.0",
1052
+ id: promptId,
1053
+ result: { stopReason: "end_turn" },
1054
+ } as unknown as AcpStreamMessage;
1055
+
1056
+ try {
1057
+ this.readableController?.enqueue(syntheticMsg);
1058
+ } catch {
1059
+ // Controller may be closed
1060
+ }
1061
+ }
1062
+
1063
+ sendInternalRequest(
1064
+ child: ChildProcess,
1065
+ id: string,
1066
+ method: string,
1067
+ params?: unknown,
1068
+ ): Promise<AcpStreamMessage> {
1069
+ return new Promise((resolve, reject) => {
1070
+ const timer = setTimeout(() => {
1071
+ this.pendingRequests.delete(id);
1072
+ reject(
1073
+ new Error(
1074
+ `Recycle internal request ${method} (${id}) timed out after ${this.recycleTimeoutMs}ms`,
1075
+ ),
1076
+ );
1077
+ }, this.recycleTimeoutMs);
1078
+
1079
+ this.pendingRequests.set(id, { resolve, reject, timer });
1080
+ const req = { jsonrpc: "2.0", id, method, ...(params !== undefined && { params }) };
1081
+
1082
+ if (!child.stdin?.writable) {
1083
+ clearTimeout(timer);
1084
+ this.pendingRequests.delete(id);
1085
+ reject(new Error(`Child stdin not writable for ${method}`));
1086
+ return;
1087
+ }
1088
+
1089
+ child.stdin.write(JSON.stringify(req) + "\n", (err) => {
1090
+ if (err) {
1091
+ clearTimeout(timer);
1092
+ this.pendingRequests.delete(id);
1093
+ reject(err);
1094
+ }
1095
+ });
1096
+ });
1097
+ }
1098
+
1099
+ private async resyncSessionLoad(
1100
+ session: CachedSessionMetadata,
1101
+ newChild: ChildProcess,
1102
+ ): Promise<void> {
1103
+ this.isSwallowingUpdates = true;
1104
+ try {
1105
+ const loadParams: Record<string, unknown> = { sessionId: session.sessionId };
1106
+ if (session.cwd !== undefined) loadParams.cwd = session.cwd;
1107
+ if (session.mcpServers !== undefined) loadParams.mcpServers = session.mcpServers;
1108
+ if (session._meta !== undefined) loadParams._meta = session._meta;
1109
+ await this.sendInternalRequest(newChild, RECYCLE_LOAD_ID, "session/load", loadParams);
1110
+ } finally {
1111
+ this.isSwallowingUpdates = false;
1112
+ }
1113
+ }
1114
+
1115
+ private async resyncSessionMode(
1116
+ session: CachedSessionMetadata,
1117
+ newChild: ChildProcess,
1118
+ ): Promise<void> {
1119
+ if (!session.lastModeId) return;
1120
+ await this.sendInternalRequest(newChild, RECYCLE_MODE_ID, "session/set_mode", {
1121
+ sessionId: session.sessionId,
1122
+ modeId: session.lastModeId,
1123
+ });
1124
+ }
1125
+
1126
+ private async resyncSessionConfig(
1127
+ session: CachedSessionMetadata,
1128
+ newChild: ChildProcess,
1129
+ ): Promise<void> {
1130
+ if (!session.lastConfigOptions || session.lastConfigOptions.size === 0) return;
1131
+ for (const [configId, opt] of session.lastConfigOptions.entries()) {
1132
+ const configParams: Record<string, unknown> = {
1133
+ sessionId: session.sessionId,
1134
+ configId,
1135
+ value: opt.value,
1136
+ };
1137
+ if (opt.type !== undefined) configParams.type = opt.type;
1138
+ await this.sendInternalRequest(
1139
+ newChild,
1140
+ `${RECYCLE_CONFIG_PREFIX}${configId}`,
1141
+ "session/set_config_option",
1142
+ configParams,
1143
+ );
1144
+ }
1145
+ }
1146
+
1147
+ private async performResync(
1148
+ session: CachedSessionMetadata,
1149
+ newChild: ChildProcess,
1150
+ ): Promise<void> {
1151
+ const initParams = this.sessionCache.cachedInitializeParams ?? {
1152
+ protocolVersion: 1,
1153
+ clientCapabilities: {},
1154
+ clientInfo: { name: "paseo", version: "1.0.0" },
1155
+ };
1156
+ await this.sendInternalRequest(newChild, RECYCLE_INIT_ID, "initialize", initParams);
1157
+ await this.resyncSessionLoad(session, newChild);
1158
+ await this.resyncSessionMode(session, newChild);
1159
+ await this.resyncSessionConfig(session, newChild);
1160
+ }
1161
+
1162
+ private cleanupOldChild(): void {
1163
+ const oldChild = this.currentChild;
1164
+ this.rlOut?.close();
1165
+ this.rlErr?.close();
1166
+ this.rlOut = null;
1167
+ this.rlErr = null;
1168
+ if (oldChild && !oldChild.killed) {
1169
+ oldChild.kill("SIGKILL");
1170
+ }
1171
+ }
1172
+
1173
+ private handleRecycleFailure(err: unknown): void {
1174
+ console.error("[paseo-antigravity] Process recycle failed:", err);
1175
+ this.state.isClosed = true;
1176
+ this.hangDetector.dispose();
1177
+ this.rlErr?.close();
1178
+ this.rlOut?.close();
1179
+ if (this.currentChild && !this.currentChild.killed) {
1180
+ this.currentChild.kill("SIGKILL");
1181
+ }
1182
+ try {
1183
+ const errorObj = err instanceof Error ? err : new Error(String(err));
1184
+ this.readableController?.error(errorObj);
1185
+ } catch {
1186
+ // Controller may already be closed
1187
+ }
1188
+ }
1189
+
1190
+ async recycleProcess(session: CachedSessionMetadata): Promise<void> {
1191
+ try {
1192
+ this.isRecycling = true;
1193
+ this.cleanupOldChild();
1194
+
1195
+ const newChild = await this.spawnFn(this.cmd, this.args);
1196
+ this.currentChild = newChild;
1197
+ this.bindChild(newChild);
1198
+
1199
+ await this.performResync(session, newChild);
1200
+
1201
+ session.needsRecycle = false;
1202
+ this.hangDetector.clearHung(session.sessionId);
1203
+ this.sanitizer.reset();
1204
+ this.isRecycling = false;
1205
+ } catch (err) {
1206
+ this.handleRecycleFailure(err);
1207
+ throw err;
1208
+ }
1209
+ }
1210
+
1211
+ private async writeToChild(msg: AcpStreamMessage): Promise<void> {
1212
+ if (this.state.isClosed || !this.currentChild.stdin?.writable) {
1213
+ throw new Error("Antigravity ACP stream is closed");
1214
+ }
1215
+ if (!this.currentChild.stdin.write(JSON.stringify(msg) + "\n")) {
1216
+ await once(this.currentChild.stdin, "drain");
1217
+ }
1218
+ }
1219
+
1220
+ async write(msg: AcpStreamMessage, proxies?: McpProxyPool): Promise<void> {
1221
+ if (this.state.isClosed) {
1222
+ throw new Error("Antigravity ACP stream is closed");
1223
+ }
1224
+
1225
+ const targetSessionId = extractSessionId(msg);
1226
+ const session = targetSessionId ? this.sessionCache.sessions.get(targetSessionId) : undefined;
1227
+
1228
+ if (session?.needsRecycle) {
1229
+ if (!this.activeRecycle) {
1230
+ this.activeRecycle = this.recycleProcess(session);
1231
+ }
1232
+ try {
1233
+ await this.activeRecycle;
1234
+ } finally {
1235
+ this.activeRecycle = null;
1236
+ }
1237
+ }
1238
+
1239
+ const processed = await processOutboundMessage(
1240
+ msg,
1241
+ proxies,
1242
+ this.promptState,
1243
+ this.sessionCache,
1244
+ );
1245
+ this.hangDetector.processOutbound(processed);
1246
+ traceAcp("paseo->agy", processed);
1247
+ await this.writeToChild(processed);
1248
+ }
1249
+
1250
+ close(): void {
1251
+ if (this.state.isClosed) return;
1252
+ this.state.isClosed = true;
1253
+ this.hangDetector.dispose();
1254
+ this.rlErr?.close();
1255
+ this.rlOut?.close();
1256
+ this.currentChild.stdin?.end();
1257
+ if (!this.currentChild.killed) {
1258
+ this.currentChild.kill("SIGTERM");
1259
+ }
1260
+ try {
1261
+ this.readableController?.close();
1262
+ } catch {
1263
+ // Controller already closed
1264
+ }
1265
+ }
1266
+
1267
+ abort(): void {
1268
+ if (this.state.isClosed) return;
1269
+ this.state.isClosed = true;
1270
+ this.hangDetector.dispose();
1271
+ this.rlErr?.close();
1272
+ this.rlOut?.close();
1273
+ this.currentChild.stdin?.end();
1274
+ if (!this.currentChild.killed) {
1275
+ this.currentChild.kill("SIGKILL");
1276
+ }
1277
+ try {
1278
+ this.readableController?.close();
1279
+ } catch {
1280
+ // Controller already closed
1281
+ }
1282
+ }
1283
+
1284
+ bindChild(child: ChildProcess): void {
1285
+ if (child.stdout) {
1286
+ this.rlOut = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
1287
+ this.rlOut.on("line", (line) => this.handleStdoutLine(line));
1288
+ }
1289
+
1290
+ if (child.stderr) {
1291
+ child.stderr.on("data", (chunk: Buffer | string) => {
1292
+ process.stderr.write(chunk);
1293
+ });
1294
+ this.rlErr = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
1295
+ this.rlErr.on("line", (line) => {
1296
+ this.hangDetector.processStderrLine(line);
1297
+ });
1298
+ }
1299
+
1300
+ child.on("close", () => this.handleChildClose());
1301
+ child.on("error", (err) => this.handleChildClose(err));
1302
+ }
1303
+ }
1304
+
1305
+ export function createAcpReadableStream(slot: ChildSlot): ReadableStream<AcpStreamMessage> {
1306
+ return new ReadableStream<AcpStreamMessage>({
1307
+ start(controller) {
1308
+ slot.setReadableController(controller);
1309
+ },
1310
+ cancel() {
1311
+ slot.close();
1312
+ },
1313
+ });
1314
+ }
1315
+
1316
+ export function createAcpWritableStream(
1317
+ slot: ChildSlot,
1318
+ proxies?: McpProxyPool,
1319
+ ): WritableStream<AcpStreamMessage> {
1320
+ return new WritableStream<AcpStreamMessage>({
1321
+ async write(msg) {
1322
+ await slot.write(msg, proxies);
1323
+ },
1324
+ close() {
1325
+ slot.close();
1326
+ },
1327
+ abort() {
1328
+ slot.abort();
1329
+ },
1330
+ });
1331
+ }
1332
+
1333
+ async function resolveBinaryCommand(): Promise<[string, ...string[]]> {
1334
+ const [initialCmd, ...args] = agyCommand();
1335
+ let cmd = initialCmd;
1336
+ if (!fs.existsSync(cmd)) {
1337
+ try {
1338
+ cmd = await ensureAntigravityBinary();
1339
+ } catch (err) {
1340
+ console.error("[paseo-antigravity] Failed to auto-download binary:", err);
1341
+ }
1342
+ }
1343
+ if (fs.existsSync(cmd)) {
1344
+ try {
1345
+ cmd = ensurePatchedBinary(cmd);
1346
+ } catch (err) {
1347
+ console.error("[paseo-antigravity] Failed to patch binary:", err);
1348
+ }
1349
+ }
1350
+ return [cmd, ...args];
1351
+ }
1352
+
1353
+ /**
1354
+ * Creates an in-process ACP connector stream that launches the official `agy_acp_server` binary,
1355
+ * automatically downloading it if not present, and applies mode option filtering, outbound MCP adaptation,
1356
+ * text sanitization, hang detection, and transparent process recycling.
1357
+ *
1358
+ * MCP proxies come from a process-wide pool rather than being owned by this stream.
1359
+ * Antigravity persists the rewritten MCP URL into its per-session database, so a proxy
1360
+ * torn down with the stream would leave resumed sessions pointing at a dead port.
1361
+ */
1362
+ export function createAntigravityConnector(
1363
+ proxies: McpProxyPool = sharedMcpProxyPool,
1364
+ options?: AntigravityConnectorOptions,
1365
+ ): AcpConnector {
1366
+ return async (): Promise<AcpStream> => {
1367
+ const [cmd, ...args] = await resolveBinaryCommand();
1368
+ ensureAuthenticated();
1369
+
1370
+ const spawnFn = options?.spawnProcess ?? defaultSpawnProcess;
1371
+ const initialChild = await spawnFn(cmd, args);
1372
+
1373
+ const slot = new ChildSlot({
1374
+ cmd,
1375
+ args,
1376
+ initialChild,
1377
+ proxies,
1378
+ options,
1379
+ });
1380
+
1381
+ return {
1382
+ readable: createAcpReadableStream(slot),
1383
+ writable: createAcpWritableStream(slot, proxies),
1384
+ };
1385
+ };
1386
+ }