@gajae-code/coding-agent 0.5.4 → 0.6.1

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.
Files changed (155) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/web-search-cli.d.ts +12 -0
  3. package/dist/types/commands/rlm.d.ts +10 -0
  4. package/dist/types/commands/web-search.d.ts +54 -0
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-profiles.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +3 -0
  8. package/dist/types/config/models-config-schema.d.ts +3 -0
  9. package/dist/types/config/settings-schema.d.ts +61 -3
  10. package/dist/types/edit/notebook.d.ts +3 -0
  11. package/dist/types/eval/py/executor.d.ts +3 -0
  12. package/dist/types/eval/py/kernel.d.ts +3 -1
  13. package/dist/types/eval/py/runtime.d.ts +9 -1
  14. package/dist/types/exec/bash-executor.d.ts +4 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  16. package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  18. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
  19. package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
  20. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
  21. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  22. package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
  23. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
  24. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
  25. package/dist/types/goals/state.d.ts +1 -1
  26. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  27. package/dist/types/main.d.ts +11 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +4 -2
  29. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
  30. package/dist/types/modes/components/model-selector.d.ts +5 -2
  31. package/dist/types/modes/components/status-line.d.ts +4 -1
  32. package/dist/types/modes/controllers/input-controller.d.ts +3 -0
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  34. package/dist/types/modes/print-mode.d.ts +6 -0
  35. package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
  36. package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
  37. package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
  38. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
  39. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
  40. package/dist/types/rlm/artifacts.d.ts +9 -0
  41. package/dist/types/rlm/complete-research-tool.d.ts +35 -0
  42. package/dist/types/rlm/data-context.d.ts +6 -0
  43. package/dist/types/rlm/index.d.ts +35 -0
  44. package/dist/types/rlm/notebook.d.ts +12 -0
  45. package/dist/types/rlm/preset.d.ts +23 -0
  46. package/dist/types/rlm/python-tool.d.ts +16 -0
  47. package/dist/types/rlm/report.d.ts +14 -0
  48. package/dist/types/rlm/types.d.ts +37 -0
  49. package/dist/types/sdk.d.ts +7 -0
  50. package/dist/types/session/agent-session.d.ts +21 -0
  51. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
  52. package/dist/types/tools/browser/attach.d.ts +19 -3
  53. package/dist/types/tools/browser/registry.d.ts +15 -0
  54. package/dist/types/tools/browser/render.d.ts +3 -0
  55. package/dist/types/tools/browser.d.ts +18 -1
  56. package/dist/types/tools/computer/render.d.ts +17 -0
  57. package/dist/types/tools/computer.d.ts +465 -0
  58. package/dist/types/tools/index.d.ts +24 -1
  59. package/dist/types/tools/job.d.ts +13 -0
  60. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  61. package/dist/types/web/search/index.d.ts +32 -2
  62. package/dist/types/web/search/providers/base.d.ts +22 -0
  63. package/dist/types/web/search/providers/xai.d.ts +64 -0
  64. package/dist/types/web/search/types.d.ts +11 -3
  65. package/package.json +7 -7
  66. package/src/cli/web-search-cli.ts +123 -8
  67. package/src/cli.ts +2 -0
  68. package/src/commands/rlm.ts +19 -0
  69. package/src/commands/web-search.ts +66 -0
  70. package/src/config/keybindings.ts +11 -0
  71. package/src/config/model-profiles.ts +11 -3
  72. package/src/config/model-registry.ts +55 -1
  73. package/src/config/models-config-schema.ts +1 -0
  74. package/src/config/settings-schema.ts +67 -1
  75. package/src/edit/notebook.ts +6 -2
  76. package/src/eval/py/executor.ts +8 -1
  77. package/src/eval/py/kernel.ts +9 -4
  78. package/src/eval/py/runtime.ts +153 -32
  79. package/src/exec/bash-executor.ts +10 -4
  80. package/src/extensibility/custom-tools/types.ts +2 -0
  81. package/src/extensibility/custom-tools/wrapper.ts +2 -0
  82. package/src/extensibility/extensions/types.ts +2 -0
  83. package/src/extensibility/extensions/wrapper.ts +1 -0
  84. package/src/gjc-runtime/launch-tmux.ts +129 -1
  85. package/src/gjc-runtime/session-state-sidecar.ts +61 -1
  86. package/src/gjc-runtime/tmux-common.ts +26 -2
  87. package/src/gjc-runtime/tmux-gc.ts +40 -27
  88. package/src/gjc-runtime/tmux-sessions.ts +13 -1
  89. package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
  90. package/src/goals/runtime.ts +4 -3
  91. package/src/goals/state.ts +1 -1
  92. package/src/goals/tools/goal-tool.ts +16 -3
  93. package/src/internal-urls/docs-index.generated.ts +13 -9
  94. package/src/main.ts +28 -3
  95. package/src/modes/components/custom-editor.ts +13 -4
  96. package/src/modes/components/custom-model-preset-wizard.ts +293 -0
  97. package/src/modes/components/hook-selector.ts +1 -1
  98. package/src/modes/components/model-selector.ts +72 -29
  99. package/src/modes/components/skill-message.ts +62 -8
  100. package/src/modes/components/status-line.ts +13 -1
  101. package/src/modes/controllers/input-controller.ts +60 -11
  102. package/src/modes/controllers/selector-controller.ts +39 -0
  103. package/src/modes/interactive-mode.ts +1 -1
  104. package/src/modes/print-mode.ts +14 -4
  105. package/src/modes/rpc/rpc-client.ts +250 -80
  106. package/src/modes/rpc/rpc-mode.ts +6 -12
  107. package/src/modes/rpc/rpc-socket-security.ts +103 -0
  108. package/src/modes/rpc/rpc-types.ts +10 -0
  109. package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
  110. package/src/modes/shared/agent-wire/command-validation.ts +1 -0
  111. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  112. package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
  113. package/src/modes/utils/hotkeys-markdown.ts +4 -2
  114. package/src/modes/utils/ui-helpers.ts +2 -2
  115. package/src/prompts/goals/goal-continuation.md +1 -0
  116. package/src/prompts/goals/goal-mode-active.md +1 -0
  117. package/src/prompts/system/rlm-report-command.md +1 -0
  118. package/src/prompts/system/rlm-research.md +23 -0
  119. package/src/prompts/tools/bash.md +23 -2
  120. package/src/prompts/tools/browser.md +7 -3
  121. package/src/prompts/tools/computer.md +74 -0
  122. package/src/prompts/tools/goal.md +3 -0
  123. package/src/prompts/tools/job.md +9 -1
  124. package/src/prompts/tools/web-search.md +7 -0
  125. package/src/rlm/artifacts.ts +60 -0
  126. package/src/rlm/complete-research-tool.ts +163 -0
  127. package/src/rlm/data-context.ts +26 -0
  128. package/src/rlm/index.ts +339 -0
  129. package/src/rlm/notebook.ts +108 -0
  130. package/src/rlm/preset.ts +76 -0
  131. package/src/rlm/python-tool.ts +68 -0
  132. package/src/rlm/report.ts +70 -0
  133. package/src/rlm/types.ts +40 -0
  134. package/src/sdk.ts +12 -0
  135. package/src/session/agent-session.ts +48 -3
  136. package/src/slash-commands/builtin-registry.ts +17 -0
  137. package/src/tools/bash-allowed-prefixes.ts +84 -1
  138. package/src/tools/bash.ts +80 -13
  139. package/src/tools/browser/attach.ts +103 -3
  140. package/src/tools/browser/registry.ts +176 -2
  141. package/src/tools/browser/render.ts +9 -1
  142. package/src/tools/browser.ts +33 -0
  143. package/src/tools/computer/render.ts +78 -0
  144. package/src/tools/computer.ts +640 -0
  145. package/src/tools/index.ts +41 -1
  146. package/src/tools/job.ts +88 -5
  147. package/src/tools/json-tree.ts +42 -29
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/tool-timeouts.ts +1 -0
  150. package/src/web/search/index.ts +27 -2
  151. package/src/web/search/provider.ts +16 -1
  152. package/src/web/search/providers/base.ts +22 -0
  153. package/src/web/search/providers/xai.ts +511 -0
  154. package/src/web/search/render.ts +7 -0
  155. package/src/web/search/types.ts +11 -1
@@ -35,6 +35,14 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
35
35
  type RpcCommandBody = DistributiveOmit<RpcCommand, "id">;
36
36
 
37
37
  export interface RpcClientOptions {
38
+ /** Dial an existing Unix-domain socket instead of spawning a stdio child. */
39
+ socketPath?: string;
40
+ /** Explicit transport selector; defaults to uds when socketPath is set, otherwise stdio. */
41
+ transport?: "stdio" | "uds";
42
+ /** Observe transport close/error state. */
43
+ onTransportClose?: (error?: Error) => void;
44
+ /** Alias for transport close/error observation. */
45
+ onTransportError?: (error: Error) => void;
38
46
  /** Path to the CLI entry point (default: searches for dist/cli.js) */
39
47
  cliPath?: string;
40
48
  /** Working directory for the agent */
@@ -176,62 +184,43 @@ function normalizeToolResult<TDetails>(result: RpcClientToolResult<TDetails>): A
176
184
  // RPC Client
177
185
  // ============================================================================
178
186
 
179
- export class RpcClient {
187
+ export interface RpcTransport {
188
+ readonly kind: "stdio" | "uds";
189
+ start(onFrame: (frame: unknown) => void, onClose: (error?: Error) => void): Promise<void>;
190
+ write(frame: unknown): void;
191
+ stop(): void;
192
+ getStderr(): string;
193
+ }
194
+
195
+ class StdioRpcTransport implements RpcTransport {
196
+ readonly kind = "stdio" as const;
180
197
  #process: ptree.ChildProcess | null = null;
181
- #eventListeners: RpcEventListener[] = [];
182
- #pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
183
- new Map();
184
- #customTools: RpcClientCustomTool[] = [];
185
- #pendingHostToolCalls = new Map<string, { controller: AbortController }>();
186
- #requestId = 0;
187
- #extensionUiListeners: Set<(req: RpcExtensionUIRequest) => void> = new Set();
188
- #workflowGateListeners: Set<(gate: RpcWorkflowGate) => void> = new Set();
189
198
  #abortController = new AbortController();
199
+ #startupStderrPromise: Promise<string> = Promise.resolve("");
190
200
 
191
- constructor(private options: RpcClientOptions = {}) {
192
- this.#customTools = [...(options.customTools ?? [])];
193
- }
194
-
195
- /**
196
- * Start the RPC agent process.
197
- */
198
- async start(): Promise<void> {
199
- if (this.#process) {
200
- throw new Error("Client already started");
201
- }
201
+ constructor(private readonly options: RpcClientOptions) {}
202
202
 
203
+ async start(onFrame: (frame: unknown) => void, onClose: (error?: Error) => void): Promise<void> {
204
+ if (this.#process) throw new Error("Transport already started");
205
+ this.#abortController = new AbortController();
203
206
  const cliPath = this.options.cliPath ?? "dist/cli.js";
204
207
  const args = ["--mode", "rpc"];
205
-
206
- if (this.options.provider) {
207
- args.push("--provider", this.options.provider);
208
- }
209
- if (this.options.model) {
210
- args.push("--model", this.options.model);
211
- }
212
- if (this.options.sessionDir) {
213
- args.push("--session-dir", this.options.sessionDir);
214
- }
215
- if (this.options.args) {
216
- args.push(...this.options.args);
217
- }
218
-
208
+ if (this.options.provider) args.push("--provider", this.options.provider);
209
+ if (this.options.model) args.push("--model", this.options.model);
210
+ if (this.options.sessionDir) args.push("--session-dir", this.options.sessionDir);
211
+ if (this.options.args) args.push(...this.options.args);
219
212
  this.#process = ptree.spawn(["bun", cliPath, ...args], {
220
213
  cwd: this.options.cwd,
221
214
  env: { ...Bun.env, ...this.options.env },
222
215
  stdin: "pipe",
223
216
  stderr: "full",
224
217
  });
225
- const startupStderrPromise = this.#process.stderr
218
+ this.#startupStderrPromise = this.#process.stderr
226
219
  ? new Response(this.#process.stderr).text().catch(() => "")
227
220
  : Promise.resolve("");
228
- const getStartupStderr = async () => this.#process?.peekStderr() || (await startupStderrPromise);
229
-
230
- // Wait for the "ready" signal or process exit
221
+ const getStartupStderr = async () => this.#process?.peekStderr() || (await this.#startupStderrPromise);
231
222
  const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
232
223
  let readySettled = false;
233
-
234
- // Process lines in background, intercepting the ready signal
235
224
  const lines = readJsonl(this.#process.stdout, this.#abortController.signal);
236
225
  void (async () => {
237
226
  for await (const line of lines) {
@@ -240,10 +229,8 @@ export class RpcClient {
240
229
  readyResolve();
241
230
  continue;
242
231
  }
243
- this.#handleLine(line);
232
+ onFrame(line);
244
233
  }
245
- // Stream ended without ready signal — process exited. Wait for the
246
- // managed process wrapper so stderr is fully drained before reporting.
247
234
  if (!readySettled) {
248
235
  const proc = this.#process;
249
236
  const exitCode = proc ? await proc.exited.catch(() => proc.exitCode ?? -1) : undefined;
@@ -256,40 +243,179 @@ export class RpcClient {
256
243
  ),
257
244
  );
258
245
  }
246
+ } else {
247
+ onClose(new Error("RPC stdio transport closed"));
259
248
  }
260
249
  })().catch((err: Error) => {
261
250
  if (!readySettled) {
262
251
  readySettled = true;
263
252
  readyReject(err);
264
- }
253
+ } else onClose(err);
265
254
  });
266
-
267
- // Also race against process exit (in case stdout closes before we read it)
268
255
  void this.#process.exited.then(async (exitCode: number) => {
269
256
  if (!readySettled) {
270
257
  const stderr = await getStartupStderr();
271
258
  if (readySettled) return;
272
259
  readySettled = true;
273
260
  readyReject(new Error(`Agent process exited with code ${exitCode}. Stderr: ${stderr}`));
274
- }
261
+ } else onClose(new Error(`RPC stdio transport exited with code ${exitCode}`));
275
262
  });
276
-
277
- // Timeout to prevent hanging forever
278
- const readyTimeout = this.#startTimeout(30000, () => {
263
+ const readyTimeout = setTimeout(() => {
279
264
  if (readySettled) return;
280
265
  readySettled = true;
281
- void getStartupStderr().then(stderr => {
282
- readyReject(new Error(`Timeout waiting for agent to become ready. Stderr: ${stderr}`));
283
- });
284
- });
266
+ void getStartupStderr().then(stderr =>
267
+ readyReject(new Error(`Timeout waiting for agent to become ready. Stderr: ${stderr}`)),
268
+ );
269
+ }, 30_000);
270
+ readyTimeout.unref();
271
+ try {
272
+ await readyPromise;
273
+ } finally {
274
+ clearTimeout(readyTimeout);
275
+ }
276
+ }
277
+
278
+ write(frame: unknown): void {
279
+ if (!this.#process?.stdin) throw new Error("Client not started");
280
+ const stdin = this.#process.stdin as import("bun").FileSink;
281
+ stdin.write(`${JSON.stringify(frame)}\n`);
282
+ const flushResult = stdin.flush();
283
+ if (flushResult instanceof Promise) void flushResult;
284
+ }
285
+
286
+ stop(): void {
287
+ if (!this.#process) return;
288
+ this.#process.kill();
289
+ this.#abortController.abort();
290
+ this.#process = null;
291
+ }
285
292
 
293
+ getStderr(): string {
294
+ return this.#process?.peekStderr() ?? "";
295
+ }
296
+ }
297
+
298
+ class UdsRpcTransport implements RpcTransport {
299
+ readonly kind = "uds" as const;
300
+ #socket: import("bun").Socket | null = null;
301
+ #buf = "";
302
+ #decoder = new TextDecoder("utf-8", { fatal: false });
303
+ constructor(private readonly socketPath: string) {}
304
+
305
+ async start(onFrame: (frame: unknown) => void, onClose: (error?: Error) => void): Promise<void> {
306
+ const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
307
+ let readySettled = false;
308
+ this.#socket = await Bun.connect({
309
+ unix: this.socketPath,
310
+ socket: {
311
+ data: (_socket, data) => {
312
+ this.#buf += this.#decoder.decode(data);
313
+ while (true) {
314
+ const nl = this.#buf.indexOf("\n");
315
+ if (nl < 0) break;
316
+ const text = this.#buf.slice(0, nl).trim();
317
+ this.#buf = this.#buf.slice(nl + 1);
318
+ if (!text) continue;
319
+ let frame: unknown;
320
+ try {
321
+ frame = JSON.parse(text);
322
+ } catch (err) {
323
+ onClose(err instanceof Error ? err : new Error(String(err)));
324
+ continue;
325
+ }
326
+ if (!readySettled && isRecord(frame) && frame.type === "ready") {
327
+ readySettled = true;
328
+ readyResolve();
329
+ continue;
330
+ }
331
+ onFrame(frame);
332
+ }
333
+ },
334
+ close: () => {
335
+ if (!readySettled) {
336
+ readySettled = true;
337
+ readyReject(new Error(`RPC UDS transport closed before ready: ${this.socketPath}`));
338
+ } else onClose(new Error(`RPC UDS transport closed: ${this.socketPath}`));
339
+ },
340
+ error: (_socket, error) => {
341
+ const err = error instanceof Error ? error : new Error(String(error));
342
+ if (!readySettled) {
343
+ readySettled = true;
344
+ readyReject(err);
345
+ } else onClose(err);
346
+ },
347
+ },
348
+ });
349
+ const readyTimeout = setTimeout(() => {
350
+ if (readySettled) return;
351
+ readySettled = true;
352
+ readyReject(new Error(`Timeout waiting for RPC UDS ready frame: ${this.socketPath}`));
353
+ }, 30_000);
354
+ readyTimeout.unref();
286
355
  try {
287
356
  await readyPromise;
357
+ } finally {
358
+ clearTimeout(readyTimeout);
359
+ }
360
+ }
361
+
362
+ write(frame: unknown): void {
363
+ if (!this.#socket) throw new Error("Client not started");
364
+ this.#socket.write(`${JSON.stringify(frame)}\n`);
365
+ }
366
+
367
+ stop(): void {
368
+ this.#socket?.end();
369
+ this.#socket = null;
370
+ }
371
+
372
+ getStderr(): string {
373
+ return "";
374
+ }
375
+ }
376
+
377
+ export class RpcClient {
378
+ #transport: RpcTransport | null = null;
379
+ #transportClosed = false;
380
+ #eventListeners: RpcEventListener[] = [];
381
+ #pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
382
+ new Map();
383
+ #customTools: RpcClientCustomTool[] = [];
384
+ #pendingHostToolCalls = new Map<string, { controller: AbortController }>();
385
+ #requestId = 0;
386
+ #extensionUiListeners: Set<(req: RpcExtensionUIRequest) => void> = new Set();
387
+ #workflowGateListeners: Set<(gate: RpcWorkflowGate) => void> = new Set();
388
+
389
+ constructor(private options: RpcClientOptions = {}) {
390
+ this.#customTools = [...(options.customTools ?? [])];
391
+ }
392
+
393
+ /**
394
+ * Start the RPC agent process.
395
+ */
396
+ async start(): Promise<void> {
397
+ if (this.#transport) {
398
+ throw new Error("Client already started");
399
+ }
400
+ this.#transportClosed = false;
401
+ const transportKind = this.options.transport ?? (this.options.socketPath ? "uds" : "stdio");
402
+ if (transportKind === "uds") {
403
+ if (!this.options.socketPath) throw new Error("socketPath is required for uds transport");
404
+ this.#transport = new UdsRpcTransport(this.options.socketPath);
405
+ } else {
406
+ this.#transport = new StdioRpcTransport(this.options);
407
+ }
408
+ try {
409
+ await this.#transport.start(
410
+ line => this.#handleLine(line),
411
+ error => this.#handleTransportClose(error),
412
+ );
288
413
  if (this.#customTools.length > 0) {
289
414
  await this.setCustomTools(this.#customTools);
290
415
  }
291
- } finally {
292
- clearTimeout(readyTimeout);
416
+ } catch (error) {
417
+ this.#transport = null;
418
+ throw error;
293
419
  }
294
420
  }
295
421
 
@@ -297,16 +423,11 @@ export class RpcClient {
297
423
  * Stop the RPC agent process.
298
424
  */
299
425
  stop() {
300
- if (!this.#process) return;
426
+ if (!this.#transport) return;
301
427
 
302
- this.#process.kill();
303
- this.#abortController.abort();
304
- this.#process = null;
305
- this.#pendingRequests.clear();
306
- for (const pendingCall of this.#pendingHostToolCalls.values()) {
307
- pendingCall.controller.abort();
308
- }
309
- this.#pendingHostToolCalls.clear();
428
+ this.#transport.stop();
429
+ this.#transport = null;
430
+ this.#rejectAllPending(new Error("RPC client stopped"));
310
431
  }
311
432
 
312
433
  /**
@@ -367,6 +488,29 @@ export class RpcClient {
367
488
  };
368
489
  }
369
490
 
491
+ /** Respond to an extension UI request over the live transport. */
492
+ respondExtensionUi(response: import("./rpc-types").RpcExtensionUIResponse): void {
493
+ this.#writeFrame(response);
494
+ }
495
+
496
+ /** Observe transport close/error notifications. */
497
+ onTransportError(listener: (error: Error) => void): () => void {
498
+ const previousClose = this.options.onTransportClose;
499
+ const previousError = this.options.onTransportError;
500
+ this.options.onTransportClose = error => {
501
+ previousClose?.(error);
502
+ listener(error ?? new Error("RPC transport closed"));
503
+ };
504
+ this.options.onTransportError = error => {
505
+ previousError?.(error);
506
+ listener(error);
507
+ };
508
+ return () => {
509
+ this.options.onTransportClose = previousClose;
510
+ this.options.onTransportError = previousError;
511
+ };
512
+ }
513
+
370
514
  /**
371
515
  * Enter unattended mode by declaring budget + scopes + action allowlist.
372
516
  * Returns the accepted declaration, or rejects (fail-closed) on refusal.
@@ -380,7 +524,7 @@ export class RpcClient {
380
524
  * Get collected stderr output (useful for debugging).
381
525
  */
382
526
  getStderr(): string {
383
- return this.#process?.peekStderr() ?? "";
527
+ return this.#transport?.getStderr() ?? "";
384
528
  }
385
529
 
386
530
  #startTimeout(timeoutMs: number, onTimeout: () => void): NodeJS.Timeout {
@@ -448,6 +592,12 @@ export class RpcClient {
448
592
  return this.#getData(response);
449
593
  }
450
594
 
595
+ /** Return unresolved workflow gates persisted by the RPC session. */
596
+ async getPendingWorkflowGates(): Promise<RpcWorkflowGate[]> {
597
+ const response = await this.#send({ type: "get_pending_workflow_gates" });
598
+ return this.#getData<{ gates: RpcWorkflowGate[] }>(response).gates;
599
+ }
600
+
451
601
  /**
452
602
  * Set model by provider and ID.
453
603
  */
@@ -658,7 +808,7 @@ export class RpcClient {
658
808
  */
659
809
  async setCustomTools(tools: RpcClientCustomTool[]): Promise<string[]> {
660
810
  this.#customTools = [...tools];
661
- if (!this.#process) {
811
+ if (!this.#transport) {
662
812
  return this.#customTools.map(tool => tool.name);
663
813
  }
664
814
  const definitions: RpcHostToolDefinition[] = this.#customTools.map(tool => ({
@@ -696,7 +846,7 @@ export class RpcClient {
696
846
  if (settled) return;
697
847
  settled = true;
698
848
  unsubscribe();
699
- reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.#process?.peekStderr() ?? ""}`));
849
+ reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.#transport?.getStderr() ?? ""}`));
700
850
  });
701
851
  return promise;
702
852
  }
@@ -722,7 +872,7 @@ export class RpcClient {
722
872
  if (settled) return;
723
873
  settled = true;
724
874
  unsubscribe();
725
- reject(new Error(`Timeout collecting events. Stderr: ${this.#process?.peekStderr() ?? ""}`));
875
+ reject(new Error(`Timeout collecting events. Stderr: ${this.#transport?.getStderr() ?? ""}`));
726
876
  });
727
877
  return promise;
728
878
  }
@@ -786,7 +936,7 @@ export class RpcClient {
786
936
  }
787
937
 
788
938
  #send(command: RpcCommandBody, timeoutMs = 30_000): Promise<RpcResponse> {
789
- if (!this.#process?.stdin) {
939
+ if (!this.#transport || this.#transportClosed) {
790
940
  throw new Error("Client not started");
791
941
  }
792
942
 
@@ -799,7 +949,7 @@ export class RpcClient {
799
949
  this.#pendingRequests.delete(id);
800
950
  settled = true;
801
951
  reject(
802
- new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.#process?.peekStderr() ?? ""}`),
952
+ new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.#transport?.getStderr() ?? ""}`),
803
953
  );
804
954
  });
805
955
 
@@ -884,22 +1034,42 @@ export class RpcClient {
884
1034
  }
885
1035
 
886
1036
  #writeFrame(
887
- frame: RpcCommand | RpcWorkflowGateResponse | RpcHostToolResult | RpcHostToolUpdate,
1037
+ frame:
1038
+ | RpcCommand
1039
+ | RpcWorkflowGateResponse
1040
+ | RpcHostToolResult
1041
+ | RpcHostToolUpdate
1042
+ | import("./rpc-types").RpcExtensionUIResponse,
888
1043
  onError?: (error: Error) => void,
889
1044
  ): void {
890
- if (!this.#process?.stdin) {
1045
+ if (!this.#transport || this.#transportClosed) {
891
1046
  throw new Error("Client not started");
892
1047
  }
893
- const stdin = this.#process.stdin as import("bun").FileSink;
894
- stdin.write(`${JSON.stringify(frame)}\n`);
895
- const flushResult = stdin.flush();
896
- if (flushResult instanceof Promise) {
897
- flushResult.catch((err: Error) => {
898
- onError?.(err);
899
- });
1048
+ try {
1049
+ this.#transport.write(frame);
1050
+ } catch (err) {
1051
+ const error = err instanceof Error ? err : new Error(String(err));
1052
+ onError?.(error);
1053
+ this.#handleTransportClose(error);
900
1054
  }
901
1055
  }
902
1056
 
1057
+ #handleTransportClose(error?: Error): void {
1058
+ if (this.#transportClosed) return;
1059
+ this.#transportClosed = true;
1060
+ const closeError = error ?? new Error("RPC transport closed");
1061
+ this.options.onTransportClose?.(closeError);
1062
+ this.options.onTransportError?.(closeError);
1063
+ this.#rejectAllPending(closeError);
1064
+ }
1065
+
1066
+ #rejectAllPending(error: Error): void {
1067
+ for (const pending of this.#pendingRequests.values()) pending.reject(error);
1068
+ this.#pendingRequests.clear();
1069
+ for (const pendingCall of this.#pendingHostToolCalls.values()) pendingCall.controller.abort();
1070
+ this.#pendingHostToolCalls.clear();
1071
+ }
1072
+
903
1073
  #getData<T>(response: RpcResponse): T {
904
1074
  if (!response.success) {
905
1075
  const errorResponse = response as Extract<RpcResponse, { success: false }>;
@@ -11,7 +11,6 @@
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
13
 
14
- import * as fs from "node:fs/promises";
15
14
  import * as path from "node:path";
16
15
  import { $pickenv, logger, readLines, Snowflake } from "@gajae-code/utils";
17
16
  import type {
@@ -31,6 +30,7 @@ import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "..
31
30
  import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
32
31
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
33
32
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
33
+ import { prepareRpcSocketPath, verifyRpcSocketAfterListen } from "./rpc-socket-security";
34
34
  import type {
35
35
  RpcCommand,
36
36
  RpcExtensionUIRequest,
@@ -128,6 +128,7 @@ export const RPC_SAFE_READ_CONTROL_COMMANDS: ReadonlySet<RpcCommand["type"]> = n
128
128
  "get_last_assistant_text",
129
129
  "get_messages",
130
130
  "get_login_providers",
131
+ "get_pending_workflow_gates",
131
132
  ]);
132
133
 
133
134
  /** True when a command may bypass the ordered serial chain and run immediately. */
@@ -725,16 +726,7 @@ export async function runRpcMode(
725
726
  // get_state/get_messages on reconnect).
726
727
  if (options?.listen) {
727
728
  const socketPath = options.listen;
728
- await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
729
- // Refuse to clobber a live previous owner: probe the path first and only
730
- // unlink a stale endpoint. A second `--listen` on the same path must not
731
- // remove the socket another running server is still serving (#606).
732
- // Unexpected probe failures are treated as alive, so this also refuses
733
- // rather than unlinking a socket path we could not safely classify.
734
- if (await isUnixSocketAlive(socketPath)) {
735
- throw new RpcListenRefusedError(socketPath);
736
- }
737
- await fs.rm(socketPath, { force: true }).catch(() => {});
729
+ await prepareRpcSocketPath(socketPath);
738
730
  await registerRpcSession({
739
731
  sessionId: session.sessionId,
740
732
  pid: process.pid,
@@ -748,7 +740,7 @@ export async function runRpcMode(
748
740
  const noopSink = (_line: string): void => {};
749
741
  let currentSocket: object | undefined;
750
742
  let buf = "";
751
- Bun.listen({
743
+ const server = Bun.listen({
752
744
  unix: socketPath,
753
745
  socket: {
754
746
  open(socket) {
@@ -779,6 +771,8 @@ export async function runRpcMode(
779
771
  error() {},
780
772
  },
781
773
  });
774
+ await verifyRpcSocketAfterListen(socketPath);
775
+ void server;
782
776
 
783
777
  const onSignal = (): void => {
784
778
  void shutdown(0, "RPC socket server signal");
@@ -0,0 +1,103 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as net from "node:net";
3
+ import * as path from "node:path";
4
+
5
+ export class RpcSocketSecurityError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "RpcSocketSecurityError";
9
+ }
10
+ }
11
+
12
+ const unsafeBits = 0o077;
13
+
14
+ function currentUid(): number | undefined {
15
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
16
+ }
17
+
18
+ function assertOwned(stat: { uid: number }, target: string): void {
19
+ const uid = currentUid();
20
+ if (uid !== undefined && stat.uid !== uid) {
21
+ throw new RpcSocketSecurityError(`${target} is owned by uid ${stat.uid}, expected ${uid}`);
22
+ }
23
+ }
24
+
25
+ function assertPrivateMode(mode: number, target: string): void {
26
+ if ((mode & unsafeBits) !== 0) throw new RpcSocketSecurityError(`${target} has group/other permissions`);
27
+ }
28
+
29
+ export async function prepareRpcSocketPath(socketPath: string): Promise<void> {
30
+ const parent = path.dirname(socketPath);
31
+ let parentStat: import("node:fs").Stats;
32
+ try {
33
+ parentStat = await fs.lstat(parent);
34
+ } catch (err) {
35
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
36
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
37
+ parentStat = await fs.lstat(parent);
38
+ }
39
+ if (parentStat.isSymbolicLink()) throw new RpcSocketSecurityError(`RPC socket parent is a symlink: ${parent}`);
40
+ if (!parentStat.isDirectory()) throw new RpcSocketSecurityError(`RPC socket parent is not a directory: ${parent}`);
41
+ assertOwned(parentStat, parent);
42
+ assertPrivateMode(parentStat.mode, parent);
43
+
44
+ let existing: import("node:fs").Stats;
45
+ try {
46
+ existing = await fs.lstat(socketPath);
47
+ } catch (err) {
48
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
49
+ throw err;
50
+ }
51
+ if (existing.isSymbolicLink()) throw new RpcSocketSecurityError(`RPC socket path is a symlink: ${socketPath}`);
52
+ assertOwned(existing, socketPath);
53
+ if (!existing.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
54
+ assertPrivateMode(existing.mode, socketPath);
55
+ if (await probeUnixSocketAlive(socketPath)) {
56
+ throw new RpcSocketSecurityError(`RPC socket path is live: ${socketPath}`);
57
+ }
58
+ await fs.unlink(socketPath);
59
+ }
60
+
61
+ export async function assertSafeClientSocket(socketPath: string): Promise<void> {
62
+ const parent = path.dirname(socketPath);
63
+ const parentStat = await fs.lstat(parent);
64
+ if (parentStat.isSymbolicLink()) throw new RpcSocketSecurityError(`RPC socket parent is a symlink: ${parent}`);
65
+ if (!parentStat.isDirectory()) throw new RpcSocketSecurityError(`RPC socket parent is not a directory: ${parent}`);
66
+ assertOwned(parentStat, parent);
67
+ assertPrivateMode(parentStat.mode, parent);
68
+
69
+ const socketStat = await fs.lstat(socketPath);
70
+ if (socketStat.isSymbolicLink()) throw new RpcSocketSecurityError(`RPC socket path is a symlink: ${socketPath}`);
71
+ assertOwned(socketStat, socketPath);
72
+ if (!socketStat.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
73
+ assertPrivateMode(socketStat.mode, socketPath);
74
+ }
75
+
76
+ export async function verifyRpcSocketAfterListen(socketPath: string): Promise<void> {
77
+ await fs.chmod(socketPath, 0o600);
78
+ const st = await fs.lstat(socketPath);
79
+ if (st.isSymbolicLink()) throw new RpcSocketSecurityError(`RPC socket path became a symlink: ${socketPath}`);
80
+ if (!st.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket after listen: ${socketPath}`);
81
+ assertOwned(st, socketPath);
82
+ assertPrivateMode(st.mode, socketPath);
83
+ }
84
+
85
+ export function probeUnixSocketAlive(socketPath: string): Promise<boolean> {
86
+ return new Promise((resolve, reject) => {
87
+ const socket = net.createConnection({ path: socketPath });
88
+ let settled = false;
89
+ const settle = (value: boolean) => {
90
+ if (settled) return;
91
+ settled = true;
92
+ socket.destroy();
93
+ resolve(value);
94
+ };
95
+ socket.once("connect", () => settle(true));
96
+ socket.once("error", err => {
97
+ const code = (err as NodeJS.ErrnoException).code;
98
+ if (code === "ENOENT" || code === "ECONNREFUSED") settle(false);
99
+ else reject(err);
100
+ });
101
+ socket.setTimeout(1000, () => reject(new RpcSocketSecurityError(`timed out probing ${socketPath}`)));
102
+ });
103
+ }
@@ -32,6 +32,7 @@ export type RpcCommand =
32
32
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
33
33
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
34
34
  | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
35
+ | { id?: string; type: "get_pending_workflow_gates" }
35
36
 
36
37
  // Model
37
38
  | { id?: string; type: "set_model"; provider: string; modelId: string }
@@ -132,6 +133,13 @@ export type RpcResponse =
132
133
  | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
133
134
  | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
134
135
  | { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }
136
+ | {
137
+ id?: string;
138
+ type: "response";
139
+ command: "get_pending_workflow_gates";
140
+ success: true;
141
+ data: { gates: RpcWorkflowGate[] };
142
+ }
135
143
 
136
144
  // Model
137
145
  | {
@@ -446,6 +454,8 @@ export interface RpcWorkflowGateOption {
446
454
 
447
455
  export interface RpcWorkflowGateContext {
448
456
  title?: string;
457
+ plan?: string;
458
+ source?: string;
449
459
  prompt?: string;
450
460
  summary?: string;
451
461
  stage_state?: Record<string, unknown>;
@@ -45,6 +45,8 @@ export interface RpcUnattendedControlPlane {
45
45
  negotiate(declaration: RpcUnattendedDeclaration): RpcUnattendedAccepted;
46
46
  /** Resolve a pending workflow gate with the agent's answer. */
47
47
  resolveGate(response: RpcWorkflowGateResponse): Promise<RpcWorkflowGateResolution>;
48
+ /** List unresolved durable workflow gates for reconnect replay. */
49
+ listPendingGates?(): import("../../rpc/rpc-types").RpcWorkflowGate[];
48
50
  isUnattended?(): boolean;
49
51
  preflightCommand?(command: RpcCommand): void;
50
52
  reconcileUsage?(phase?: string): void;
@@ -213,6 +215,11 @@ export async function dispatchRpcCommand(
213
215
  return rpcSuccess(id, "set_host_tools", { toolNames: tools.map(tool => tool.name) });
214
216
  }
215
217
 
218
+ case "get_pending_workflow_gates": {
219
+ const gates = unattendedControlPlane?.listPendingGates?.() ?? [];
220
+ return rpcSuccess(id, "get_pending_workflow_gates", { gates });
221
+ }
222
+
216
223
  case "set_host_uri_schemes": {
217
224
  try {
218
225
  const schemes = hostUriRegistry.setSchemes(command.schemes);
@@ -118,6 +118,7 @@ export function isRpcCommand(value: unknown): value is RpcCommand {
118
118
  case "get_last_assistant_text":
119
119
  case "get_messages":
120
120
  case "get_login_providers":
121
+ case "get_pending_workflow_gates":
121
122
  return true;
122
123
  case "abort_and_prompt":
123
124
  return stringField(value, "message") && optionalArray(value.images);