@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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 (248) hide show
  1. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  2. package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
  3. package/dist/types/cli/classify-install-target.d.ts +0 -10
  4. package/dist/types/cli/daemon-cli.d.ts +2 -0
  5. package/dist/types/cli/initial-message.d.ts +1 -1
  6. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  7. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  8. package/dist/types/cli/notify-cli.d.ts +3 -1
  9. package/dist/types/cli/session-picker.d.ts +4 -3
  10. package/dist/types/cli/update-cli.d.ts +22 -3
  11. package/dist/types/cli.d.ts +5 -0
  12. package/dist/types/commands/daemon.d.ts +6 -2
  13. package/dist/types/commands/launch.d.ts +1 -0
  14. package/dist/types/commands/notify.d.ts +6 -0
  15. package/dist/types/commands/web-search.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
  18. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
  19. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
  20. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  21. package/dist/types/commit/analysis/summary.d.ts +1 -1
  22. package/dist/types/commit/changelog/generate.d.ts +3 -3
  23. package/dist/types/commit/changelog/index.d.ts +2 -2
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  26. package/dist/types/commit/shared-llm.d.ts +9 -9
  27. package/dist/types/config/keybindings.d.ts +134 -129
  28. package/dist/types/config/model-profile-activation.d.ts +1 -0
  29. package/dist/types/config/model-registry.d.ts +35 -35
  30. package/dist/types/config/models-config-schema.d.ts +111 -111
  31. package/dist/types/config/settings-schema.d.ts +56 -16
  32. package/dist/types/config/settings.d.ts +2 -0
  33. package/dist/types/coordinator/contract.d.ts +1 -1
  34. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  35. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  36. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  37. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  38. package/dist/types/daemon/control-types.d.ts +10 -0
  39. package/dist/types/daemon/operator-contract.d.ts +47 -0
  40. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  41. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
  42. package/dist/types/discovery/helpers.d.ts +1 -1
  43. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  44. package/dist/types/edit/modes/patch.d.ts +2 -2
  45. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  46. package/dist/types/goals/tools/goal-tool.d.ts +6 -6
  47. package/dist/types/i18n/messages/en.d.ts +8 -8
  48. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  49. package/dist/types/lsp/index.d.ts +9 -9
  50. package/dist/types/lsp/types.d.ts +9 -9
  51. package/dist/types/main.d.ts +66 -2
  52. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  53. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  54. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  56. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  57. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  58. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  59. package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
  60. package/dist/types/modes/components/session-selector.d.ts +22 -9
  61. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  62. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  63. package/dist/types/modes/components/status-line.d.ts +0 -10
  64. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  65. package/dist/types/modes/components/user-message.d.ts +2 -1
  66. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  67. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  68. package/dist/types/modes/interactive-mode.d.ts +24 -2
  69. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  70. package/dist/types/modes/print-mode.d.ts +3 -4
  71. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  72. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  73. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  74. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  75. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  76. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  77. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  78. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  79. package/dist/types/modes/types.d.ts +28 -2
  80. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  81. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  82. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  83. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  84. package/dist/types/notifications/config.d.ts +34 -0
  85. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  86. package/dist/types/notifications/index.d.ts +27 -0
  87. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  88. package/dist/types/notifications/notification-service.d.ts +144 -0
  89. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  90. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  91. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  92. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  93. package/dist/types/notifications/topic-registry.d.ts +24 -4
  94. package/dist/types/registry/agent-registry.d.ts +2 -0
  95. package/dist/types/rlm/index.d.ts +1 -1
  96. package/dist/types/sdk.d.ts +2 -0
  97. package/dist/types/secrets/obfuscator.d.ts +1 -1
  98. package/dist/types/session/agent-session.d.ts +40 -3
  99. package/dist/types/session/cache-economics.d.ts +79 -0
  100. package/dist/types/session/context-estimation.d.ts +31 -0
  101. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  102. package/dist/types/session/session-manager.d.ts +124 -4
  103. package/dist/types/session/session-storage.d.ts +155 -13
  104. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  105. package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
  106. package/dist/types/skc-runtime/state-schema.d.ts +19 -19
  107. package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
  108. package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  109. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
  110. package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
  111. package/dist/types/task/agents.d.ts +2 -2
  112. package/dist/types/task/executor.d.ts +3 -0
  113. package/dist/types/task/receipt.d.ts +3 -1
  114. package/dist/types/task/types.d.ts +35 -27
  115. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  116. package/dist/types/tools/bash.d.ts +1 -1
  117. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  118. package/dist/types/tools/browser.d.ts +28 -28
  119. package/dist/types/tools/computer.d.ts +20 -20
  120. package/dist/types/tools/cron.d.ts +2 -2
  121. package/dist/types/tools/debug.d.ts +40 -40
  122. package/dist/types/tools/gh.d.ts +10 -10
  123. package/dist/types/tools/image-gen.d.ts +2 -2
  124. package/dist/types/tools/index.d.ts +58 -6
  125. package/dist/types/tools/monitor.d.ts +6 -6
  126. package/dist/types/tools/skill-discovery.d.ts +4 -4
  127. package/dist/types/tools/subagent.d.ts +12 -12
  128. package/dist/types/tools/telegram-send.d.ts +7 -0
  129. package/dist/types/tools/todo-write.d.ts +8 -8
  130. package/dist/types/utils/external-editor.d.ts +2 -0
  131. package/dist/types/web/insane/bridge.d.ts +9 -9
  132. package/dist/types/web/search/index.d.ts +4 -4
  133. package/package.json +8 -8
  134. package/scripts/g004-tmux-smoke.ts +196 -36
  135. package/src/cli/daemon-cli.ts +19 -29
  136. package/src/cli/malloc-env-guard.test.ts +104 -0
  137. package/src/cli/malloc-env-guard.ts +149 -0
  138. package/src/cli/notify-cli.ts +78 -14
  139. package/src/cli/session-picker.ts +23 -40
  140. package/src/cli/update-cli.ts +56 -37
  141. package/src/cli.ts +51 -10
  142. package/src/commands/daemon.ts +19 -5
  143. package/src/commands/harness.ts +515 -47
  144. package/src/commands/launch.ts +11 -3
  145. package/src/commands/notify.ts +5 -1
  146. package/src/commands/session.ts +6 -1
  147. package/src/config/keybindings.ts +5 -0
  148. package/src/config/model-profile-activation.ts +1 -1
  149. package/src/config/model-profiles.ts +14 -14
  150. package/src/config/settings-schema.ts +41 -1
  151. package/src/config/settings.ts +96 -45
  152. package/src/coordinator/contract.ts +1 -0
  153. package/src/coordinator-mcp/model-preset.ts +100 -0
  154. package/src/coordinator-mcp/policy.ts +30 -0
  155. package/src/coordinator-mcp/server.ts +2958 -430
  156. package/src/coordinator-mcp/session-reaper.ts +129 -0
  157. package/src/daemon/control-types.ts +11 -0
  158. package/src/daemon/operator-contract.ts +121 -0
  159. package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  160. package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  161. package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  162. package/src/extensibility/extensions/runner.ts +1 -1
  163. package/src/extensibility/extensions/types.ts +3 -1
  164. package/src/internal-urls/docs-index.generated.ts +10 -9
  165. package/src/internal-urls/local-protocol.ts +29 -10
  166. package/src/main.ts +301 -80
  167. package/src/modes/acp/acp-agent.ts +730 -100
  168. package/src/modes/acp/acp-mode.ts +29 -2
  169. package/src/modes/components/assistant-message.ts +32 -3
  170. package/src/modes/components/bash-execution.ts +43 -13
  171. package/src/modes/components/custom-editor.ts +5 -0
  172. package/src/modes/components/footer.ts +3 -3
  173. package/src/modes/components/hook-selector.ts +12 -0
  174. package/src/modes/components/irc-sidebar.ts +206 -0
  175. package/src/modes/components/pet-selector.ts +55 -0
  176. package/src/modes/components/sayknow-pet-widget.ts +360 -0
  177. package/src/modes/components/session-selector.ts +222 -221
  178. package/src/modes/components/settings-selector.ts +10 -0
  179. package/src/modes/components/status-line/segments.ts +14 -7
  180. package/src/modes/components/status-line/types.ts +1 -1
  181. package/src/modes/components/status-line.ts +4 -171
  182. package/src/modes/components/tool-execution.ts +47 -6
  183. package/src/modes/components/user-message.ts +26 -9
  184. package/src/modes/controllers/command-controller.ts +28 -2
  185. package/src/modes/controllers/event-controller.ts +159 -36
  186. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  187. package/src/modes/controllers/input-controller.ts +8 -2
  188. package/src/modes/controllers/selector-controller.ts +62 -15
  189. package/src/modes/interactive-mode.ts +199 -7
  190. package/src/modes/irc-observation-ledger.ts +127 -0
  191. package/src/modes/print-mode.ts +228 -92
  192. package/src/modes/rpc/rpc-client.ts +58 -1
  193. package/src/modes/rpc/rpc-mode.ts +286 -85
  194. package/src/modes/rpc/rpc-types.ts +15 -1
  195. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  196. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  197. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  198. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  199. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  200. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  201. package/src/modes/types.ts +34 -2
  202. package/src/modes/utils/context-usage.ts +56 -92
  203. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  204. package/src/modes/utils/irc-message.ts +138 -0
  205. package/src/modes/utils/ui-helpers.ts +216 -50
  206. package/src/notifications/config.ts +44 -0
  207. package/src/notifications/daemon-paths.ts +8 -0
  208. package/src/notifications/index.ts +535 -75
  209. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  210. package/src/notifications/notification-service.ts +710 -0
  211. package/src/notifications/rate-limit-pool.ts +46 -12
  212. package/src/notifications/telegram-daemon-control.ts +27 -2
  213. package/src/notifications/telegram-daemon.ts +635 -94
  214. package/src/notifications/telegram-reference.ts +73 -13
  215. package/src/notifications/topic-registry.ts +86 -9
  216. package/src/prompts/system/irc-peer-roster.md +1 -0
  217. package/src/prompts/system/subagent-system-prompt.md +1 -0
  218. package/src/prompts/system/system-prompt.md +1 -0
  219. package/src/prompts/tools/irc.md +1 -1
  220. package/src/registry/agent-registry.ts +3 -0
  221. package/src/sdk.ts +52 -1
  222. package/src/secrets/obfuscator.ts +4 -2
  223. package/src/session/agent-session.ts +670 -217
  224. package/src/session/cache-economics.ts +275 -0
  225. package/src/session/context-estimation.ts +91 -0
  226. package/src/session/provider-safety-stop.ts +9 -0
  227. package/src/session/session-dump-format.ts +6 -0
  228. package/src/session/session-manager.ts +809 -39
  229. package/src/session/session-storage.ts +582 -38
  230. package/src/skc-runtime/session-state-sidecar.ts +756 -135
  231. package/src/skc-runtime/tmux-common.ts +26 -7
  232. package/src/skc-runtime/tmux-gc.ts +52 -9
  233. package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
  234. package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
  235. package/src/skc-runtime/tmux-sessions.ts +871 -68
  236. package/src/slash-commands/builtin-registry.ts +153 -9
  237. package/src/slash-commands/helpers/context-report.ts +26 -4
  238. package/src/task/executor.ts +12 -3
  239. package/src/task/index.ts +8 -0
  240. package/src/task/receipt.ts +7 -2
  241. package/src/task/types.ts +26 -0
  242. package/src/tools/ask-answer-registry.ts +25 -0
  243. package/src/tools/browser/tab-supervisor.ts +63 -6
  244. package/src/tools/browser.ts +8 -3
  245. package/src/tools/index.ts +81 -6
  246. package/src/tools/sqlite-reader.ts +8 -4
  247. package/src/tui/output-block.ts +18 -6
  248. package/src/utils/external-editor.ts +6 -1
@@ -11,7 +11,7 @@
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
13
 
14
- import { $pickenv, logger, readLines, Snowflake } from "@sayknow-cli/utils";
14
+ import { $pickenv, isKnownSinkPeerClosedError, logger, readLines, Snowflake } from "@sayknow-cli/utils";
15
15
  import type {
16
16
  ExtensionUIContext,
17
17
  ExtensionUIDialogOptions,
@@ -64,7 +64,18 @@ type RpcOutput = (
64
64
  | RpcHostUriRequest
65
65
  | RpcHostUriCancelRequest
66
66
  | object,
67
- ) => void;
67
+ ) => Promise<void>;
68
+ type StdioTransportState = "open" | "terminalizing" | "closed";
69
+
70
+ function sinkErrorCode(error: unknown): string | undefined {
71
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
72
+ try {
73
+ const code = Reflect.get(error, "code");
74
+ return typeof code === "string" ? code : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
68
79
 
69
80
  function parseValueDialogResponse(
70
81
  response: RpcExtensionUIResponse,
@@ -150,17 +161,24 @@ export function isFastLaneRpcCommand(type: RpcCommand["type"]): boolean {
150
161
  export function createRpcCommandScheduler(
151
162
  run: (command: RpcCommand) => Promise<void>,
152
163
  track: (task: Promise<void>) => void,
153
- ): { dispatch: (command: RpcCommand) => void } {
164
+ ): { dispatch: (command: RpcCommand) => void; close: () => void } {
154
165
  let orderedChain: Promise<void> = Promise.resolve();
166
+ let accepting = true;
155
167
  return {
156
168
  dispatch(command: RpcCommand): void {
169
+ if (!accepting) return;
157
170
  if (isFastLaneRpcCommand(command.type)) {
158
171
  track(run(command));
159
172
  return;
160
173
  }
161
- orderedChain = orderedChain.then(() => run(command));
174
+ orderedChain = orderedChain.then(async () => {
175
+ if (accepting) await run(command);
176
+ });
162
177
  track(orderedChain);
163
178
  },
179
+ close(): void {
180
+ accepting = false;
181
+ },
164
182
  };
165
183
  }
166
184
 
@@ -283,18 +301,94 @@ export async function runRpcMode(
283
301
  // may write there.
284
302
  process.env.PI_NOTIFICATIONS = "off";
285
303
 
286
- // Frames go to a swappable sink: stdout for stdio, the active client socket for a
287
- // persistent --listen (UDS) server. Defaults to stdout, so the stdio path is unchanged.
288
- let frameSink = (line: string): void => {
289
- process.stdout.write(line);
304
+ // Frames route to stdout for stdio and to the active client for --listen. A
305
+ // socket server begins with no peer, so it must never leak frames to stdout.
306
+ const noopSink = (_line: string): void => {};
307
+ let stdioTransportState: StdioTransportState = options?.listen ? "closed" : "open";
308
+ let beginStdioTerminalization: (() => void) | undefined;
309
+ const stdioFatalFailure = Promise.withResolvers<never>();
310
+ void stdioFatalFailure.promise.catch(() => {});
311
+ let hasStdioWriteFailure = false;
312
+ let stdioWriteFailure: unknown;
313
+ let stdioPeerClosed = false;
314
+ const pendingStdioWrites = new Set<{
315
+ completion: Promise<void>;
316
+ settle: (failed: boolean, failure?: unknown) => void;
317
+ }>();
318
+ const settlePendingStdioWrites = (failed: boolean, failure?: unknown): void => {
319
+ for (const pending of [...pendingStdioWrites]) pending.settle(failed, failure);
290
320
  };
291
- const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
292
- frameSink(`${JSON.stringify(obj)}\n`);
321
+ const waitForStdioWrites = async (): Promise<void> => {
322
+ while (pendingStdioWrites.size > 0) {
323
+ await Promise.allSettled([...pendingStdioWrites].map(pending => pending.completion));
324
+ }
325
+ };
326
+ const handleStdioWriteFailure = (failure: unknown): boolean => {
327
+ const code = sinkErrorCode(failure);
328
+ if (
329
+ isKnownSinkPeerClosedError(failure) &&
330
+ (code === "EPIPE" || (stdioPeerClosed && code === "ERR_STREAM_DESTROYED"))
331
+ ) {
332
+ stdioPeerClosed = true;
333
+ if (stdioTransportState === "open") stdioTransportState = "terminalizing";
334
+ settlePendingStdioWrites(false);
335
+ beginStdioTerminalization?.();
336
+ return false;
337
+ }
338
+ if (!hasStdioWriteFailure) {
339
+ hasStdioWriteFailure = true;
340
+ stdioWriteFailure = failure;
341
+ stdioTransportState = "terminalizing";
342
+ stdioFatalFailure.reject(failure);
343
+ settlePendingStdioWrites(true, failure);
344
+ beginStdioTerminalization?.();
345
+ }
346
+ return true;
347
+ };
348
+ const onStdioError = (failure: Error): void => {
349
+ handleStdioWriteFailure(failure);
350
+ };
351
+ if (!options?.listen) process.stdout.on("error", onStdioError);
352
+ const writeStdioFrame = (line: string): Promise<void> => {
353
+ if (stdioTransportState !== "open") return Promise.resolve();
354
+ const completion = Promise.withResolvers<void>();
355
+ let settled = false;
356
+ let pending: { completion: Promise<void>; settle: (failed: boolean, failure?: unknown) => void };
357
+ pending = {
358
+ completion: completion.promise,
359
+ settle: (failed, failure) => {
360
+ if (settled) return;
361
+ settled = true;
362
+ pendingStdioWrites.delete(pending);
363
+ if (failed) completion.reject(failure);
364
+ else completion.resolve();
365
+ },
366
+ };
367
+ pendingStdioWrites.add(pending);
368
+ try {
369
+ process.stdout.write(line, writeError => {
370
+ if (settled) return;
371
+ if (writeError) pending.settle(handleStdioWriteFailure(writeError), writeError);
372
+ else pending.settle(false);
373
+ });
374
+ } catch (failure) {
375
+ pending.settle(handleStdioWriteFailure(failure), failure);
376
+ }
377
+ return completion.promise;
378
+ };
379
+ let frameSink: (line: string) => void | Promise<void> = options?.listen ? noopSink : writeStdioFrame;
380
+ const output: RpcOutput = obj => {
381
+ if (!options?.listen && stdioTransportState !== "open") return Promise.resolve();
382
+ let emission: Promise<void>;
383
+ try {
384
+ emission = Promise.resolve(frameSink(`${JSON.stringify(obj)}\n`));
385
+ } catch (failure) {
386
+ emission = Promise.reject(failure);
387
+ if (!options?.listen) handleStdioWriteFailure(failure);
388
+ }
389
+ void emission.catch(() => {});
390
+ return emission;
293
391
  };
294
- // stdio announces readiness immediately; the UDS server announces it per client connection.
295
- if (!options?.listen) {
296
- output({ type: "ready" });
297
- }
298
392
  const emitRpcTitles = shouldEmitRpcTitles();
299
393
  const rpcCapabilities = { compactMessageUpdate: false };
300
394
  const decodeError = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -341,7 +435,9 @@ export async function runRpcMode(
341
435
  const unattendedControlPlane = new UnattendedSessionControlPlane({
342
436
  runId: session.sessionId,
343
437
  sessionId: session.sessionId,
344
- emitFrame: gate => output(gate),
438
+ emitFrame: gate => {
439
+ void output(gate);
440
+ },
345
441
  store: gateStore,
346
442
  audit: recordAudit,
347
443
  providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
@@ -350,45 +446,71 @@ export async function runRpcMode(
350
446
  return { tokens: stats.tokens.total, costUsd: stats.cost };
351
447
  },
352
448
  });
353
- unattendedControlPlane
354
- .recover()
355
- .catch(err =>
356
- output(error(undefined, "workflow_gate_recover", err instanceof Error ? err.message : String(err))),
357
- );
449
+ unattendedControlPlane.recover().catch(err => {
450
+ void output(error(undefined, "workflow_gate_recover", err instanceof Error ? err.message : String(err)));
451
+ });
358
452
  session.setWorkflowGateEmitter(unattendedControlPlane);
359
453
 
360
454
  // Shutdown request flag (wrapped in object to allow mutation with const)
361
455
  const shutdownState = { requested: false };
362
- let shutdownStarted = false;
456
+ let detachStdioInput: (() => void) | undefined;
457
+ let closeCommandScheduler: (() => void) | undefined;
458
+ let shutdownPromise: Promise<never> | undefined;
459
+ let shutdownExitCode = 0;
460
+ let suppressShutdownProtocolOutput = false;
461
+ let shutdownDispatchStopped = false;
363
462
  // Tracks in-flight non-blocking command handlers so shutdown can drain them.
364
463
  const inFlightCommands = new Set<Promise<void>>();
365
- async function shutdown(exitCode: number, reason: string): Promise<never> {
366
- if (shutdownStarted) {
367
- process.exit(exitCode);
368
- }
369
- shutdownStarted = true;
370
- // Let in-flight non-blocking commands (bash/compact/handoff) finish and emit
371
- // their responses before teardown, bounded so a never-resolving login cannot
372
- // wedge shutdown (issue 13).
373
- if (inFlightCommands.size > 0) {
374
- await Promise.race([Promise.allSettled([...inFlightCommands]), Bun.sleep(5000)]);
375
- }
376
- await unregisterRpcSession(session.sessionId).catch(() => {});
377
- hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
378
- hostUriBridge.clear(`${reason} before host URI request completed`);
379
- try {
380
- await session.sessionManager.ensureOnDisk();
381
- } catch (err) {
382
- output(error(undefined, "shutdown", decodeError(err)));
383
- process.exit(1);
464
+ function shutdown(
465
+ exitCode: number,
466
+ reason: string,
467
+ suppressProtocolOutput = false,
468
+ stopDispatch = true,
469
+ ): Promise<never> {
470
+ shutdownExitCode = Math.max(shutdownExitCode, exitCode);
471
+ suppressShutdownProtocolOutput ||= suppressProtocolOutput;
472
+ if (stopDispatch && !shutdownDispatchStopped) {
473
+ shutdownDispatchStopped = true;
474
+ closeCommandScheduler?.();
475
+ detachStdioInput?.();
384
476
  }
385
- try {
386
- await session.dispose();
387
- } catch (err) {
388
- output(error(undefined, "shutdown", decodeError(err)));
389
- process.exit(1);
390
- }
391
- process.exit(exitCode);
477
+ if (shutdownPromise) return shutdownPromise;
478
+ shutdownPromise = (async () => {
479
+ // Let in-flight non-blocking commands (bash/compact/handoff) finish and emit
480
+ // their responses before teardown, bounded so a never-resolving login cannot
481
+ // wedge shutdown (issue 13).
482
+ if (inFlightCommands.size > 0) {
483
+ await Promise.race([Promise.allSettled([...inFlightCommands]), Bun.sleep(5000)]);
484
+ }
485
+ if (!options?.listen) await waitForStdioWrites();
486
+ await unregisterRpcSession(session.sessionId).catch(() => {});
487
+ hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
488
+ hostUriBridge.clear(`${reason} before host URI request completed`);
489
+ let cleanupFailure: unknown;
490
+ try {
491
+ await session.sessionManager.ensureOnDisk();
492
+ } catch (failure) {
493
+ cleanupFailure = failure;
494
+ }
495
+ try {
496
+ await session.dispose();
497
+ } catch (failure) {
498
+ if (cleanupFailure === undefined) cleanupFailure = failure;
499
+ }
500
+ if (cleanupFailure !== undefined && !suppressShutdownProtocolOutput) {
501
+ await output(error(undefined, "shutdown", decodeError(cleanupFailure))).catch(() => {});
502
+ }
503
+ if (!options?.listen) {
504
+ await waitForStdioWrites();
505
+ process.stdout.removeListener("error", onStdioError);
506
+ stdioTransportState = "closed";
507
+ }
508
+ const terminalFailure = hasStdioWriteFailure ? stdioWriteFailure : cleanupFailure;
509
+ const finalExitCode = terminalFailure !== undefined || hasStdioWriteFailure ? 1 : shutdownExitCode;
510
+ process.exit(finalExitCode);
511
+ return await new Promise<never>(() => {});
512
+ })();
513
+ return shutdownPromise;
392
514
  }
393
515
 
394
516
  /**
@@ -397,7 +519,7 @@ export async function runRpcMode(
397
519
  class RpcExtensionUIContext implements ExtensionUIContext {
398
520
  constructor(
399
521
  private pendingRequests: Map<string, PendingExtensionRequest>,
400
- private output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void,
522
+ private output: RpcOutput,
401
523
  ) {}
402
524
 
403
525
  /** Helper for dialog methods with signal/timeout support */
@@ -665,17 +787,38 @@ export async function runRpcMode(
665
787
  // fast-lane command reach a long-running `bash`/`compact`/`handoff`/`login`
666
788
  // instead of being head-of-line-blocked behind it (issue 13).
667
789
  const runCommand = async (command: RpcCommand): Promise<void> => {
790
+ let response: RpcResponse;
668
791
  try {
669
- output(await handleCommand(command));
792
+ response = await handleCommand(command);
670
793
  } catch (err) {
671
- output(error(command.id, command.type, decodeError(err)));
794
+ response = error(command.id, command.type, decodeError(err));
672
795
  }
796
+ await output(response);
673
797
  };
674
798
  const trackCommand = (task: Promise<void>): void => {
675
799
  inFlightCommands.add(task);
676
- void task.finally(() => inFlightCommands.delete(task));
800
+ void task.then(
801
+ () => inFlightCommands.delete(task),
802
+ () => inFlightCommands.delete(task),
803
+ );
677
804
  };
678
- const { dispatch: dispatchCommand } = createRpcCommandScheduler(runCommand, trackCommand);
805
+ const scheduler = createRpcCommandScheduler(runCommand, trackCommand);
806
+ const dispatchCommand = scheduler.dispatch;
807
+ closeCommandScheduler = scheduler.close;
808
+ let stdioTerminalizationStarted = false;
809
+ beginStdioTerminalization = () => {
810
+ if (options?.listen || stdioTerminalizationStarted) return;
811
+ stdioTerminalizationStarted = true;
812
+ closeCommandScheduler?.();
813
+ detachStdioInput?.();
814
+ void session.abort({ timeoutMs: 5000, cause: "internal", silent: true }).catch(() => {});
815
+ void shutdown(
816
+ hasStdioWriteFailure ? 1 : 0,
817
+ hasStdioWriteFailure ? "RPC stdout write failed" : "RPC stdout peer disconnected",
818
+ true,
819
+ ).catch(() => {});
820
+ };
821
+ if (stdioTransportState !== "open") beginStdioTerminalization();
679
822
 
680
823
  /**
681
824
  * Check if shutdown was requested and perform shutdown if so.
@@ -690,17 +833,24 @@ export async function runRpcMode(
690
833
  // persistent UDS server so both transports use the same command surface.
691
834
  const inputDecoder = new TextDecoder("utf-8", { fatal: false });
692
835
  async function handleInboundLine(text: string): Promise<void> {
836
+ if (!options?.listen && stdioTransportState !== "open") return;
693
837
  let parsed: unknown;
838
+ let parseFailure: RpcResponse | undefined;
694
839
  try {
695
840
  parsed = JSON.parse(text);
696
841
  } catch (err) {
697
- output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
842
+ parseFailure = error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`);
843
+ }
844
+ if (parseFailure) {
845
+ output(parseFailure);
698
846
  return;
699
847
  }
848
+
849
+ let response: RpcResponse | undefined;
700
850
  try {
701
851
  if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
702
- const response = parsed as RpcExtensionUIResponse;
703
- pendingExtensionRequests.get(response.id)?.resolve(response);
852
+ const extensionResponse = parsed as RpcExtensionUIResponse;
853
+ pendingExtensionRequests.get(extensionResponse.id)?.resolve(extensionResponse);
704
854
  return;
705
855
  }
706
856
  if (isRpcHostToolResult(parsed)) {
@@ -722,22 +872,21 @@ export async function runRpcMode(
722
872
  const requested = Array.isArray(command.capabilities) ? command.capabilities : [];
723
873
  const acceptedCapabilities = requested.filter(capability => capability === "compact_message_update");
724
874
  rpcCapabilities.compactMessageUpdate = acceptedCapabilities.includes("compact_message_update");
725
- output(
726
- rpcSuccess(command.id, "set_capabilities", {
727
- acceptedCapabilities,
728
- unsupported: requested.filter(capability => capability !== "compact_message_update"),
729
- }),
730
- );
731
- return;
875
+ response = rpcSuccess(command.id, "set_capabilities", {
876
+ acceptedCapabilities,
877
+ unsupported: requested.filter(capability => capability !== "compact_message_update"),
878
+ });
879
+ } else {
880
+ // Ordered commands run through a serial chain to preserve causal order; the
881
+ // reader never blocks, so cancellation commands stay responsive even while a
882
+ // long command is in flight (issue 13).
883
+ dispatchCommand(parsed as RpcCommand);
884
+ await checkShutdownRequested();
732
885
  }
733
- // Ordered commands run through a serial chain to preserve causal order; the
734
- // reader never blocks, so cancellation commands stay responsive even while a
735
- // long command is in flight (issue 13).
736
- dispatchCommand(parsed as RpcCommand);
737
- await checkShutdownRequested();
738
886
  } catch (err) {
739
- output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
887
+ response = error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`);
740
888
  }
889
+ if (response) output(response);
741
890
  }
742
891
 
743
892
  // Persistent UDS server (issue 09): keep the AgentSession alive across client
@@ -757,9 +906,24 @@ export async function runRpcMode(
757
906
  endpoint: socketPath,
758
907
  }).catch(() => {});
759
908
 
760
- const noopSink = (_line: string): void => {};
761
- let currentSocket: object | undefined;
909
+ let currentSocket: Bun.Socket | undefined;
762
910
  let buf = "";
911
+ const detachCurrentSocket = (socket: Bun.Socket): void => {
912
+ if (socket !== currentSocket) return;
913
+ currentSocket = undefined;
914
+ frameSink = noopSink;
915
+ };
916
+ const socketIsLocallyTerminal = (socket: Bun.Socket): boolean => socket.readyState <= 0;
917
+ const handleSocketFailure = (socket: Bun.Socket, failure: unknown): void => {
918
+ if (socket !== currentSocket) return;
919
+ const code = sinkErrorCode(failure);
920
+ const peerClosed =
921
+ isKnownSinkPeerClosedError(failure) &&
922
+ (code === "EPIPE" || (code === "ERR_STREAM_DESTROYED" && socketIsLocallyTerminal(socket)));
923
+ detachCurrentSocket(socket);
924
+ if (peerClosed) return;
925
+ void shutdown(1, "RPC socket output failed", true).catch(() => {});
926
+ };
763
927
  const server = Bun.listen({
764
928
  unix: socketPath,
765
929
  socket: {
@@ -767,9 +931,23 @@ export async function runRpcMode(
767
931
  currentSocket = socket;
768
932
  buf = "";
769
933
  frameSink = (line: string) => {
770
- socket.write(line);
934
+ if (socket !== currentSocket) return;
935
+ try {
936
+ const written = socket.write(line);
937
+ if (written >= 0) return;
938
+ if (socketIsLocallyTerminal(socket)) {
939
+ detachCurrentSocket(socket);
940
+ return;
941
+ }
942
+ handleSocketFailure(
943
+ socket,
944
+ Object.assign(new Error("RPC socket write failed"), { code: "ERR_STREAM_DESTROYED" }),
945
+ );
946
+ } catch (failure) {
947
+ handleSocketFailure(socket, failure);
948
+ }
771
949
  };
772
- output({ type: "ready" });
950
+ void output({ type: "ready" });
773
951
  },
774
952
  data(socket, data) {
775
953
  if (socket !== currentSocket) return;
@@ -783,12 +961,11 @@ export async function runRpcMode(
783
961
  }
784
962
  },
785
963
  close(socket) {
786
- if (socket === currentSocket) {
787
- currentSocket = undefined;
788
- frameSink = noopSink;
789
- }
964
+ detachCurrentSocket(socket);
965
+ },
966
+ error(socket, failure) {
967
+ handleSocketFailure(socket, failure);
790
968
  },
791
- error() {},
792
969
  },
793
970
  });
794
971
  await verifyRpcSocketAfterListen(socketPath);
@@ -816,13 +993,37 @@ export async function runRpcMode(
816
993
 
817
994
  // Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
818
995
  // command reports a parse error without poisoning the whole long-lived RPC session.
819
- for await (const line of readLines(Bun.stdin.stream())) {
820
- const text = inputDecoder.decode(line).trim();
821
- if (!text) continue;
822
- await handleInboundLine(text);
996
+ const inputLines = readLines(Bun.stdin.stream());
997
+ const inputIterator = inputLines[Symbol.asyncIterator]();
998
+ detachStdioInput = () => {
999
+ const detached = inputIterator.return?.(undefined);
1000
+ if (detached) void detached.catch(() => {});
1001
+ };
1002
+ let inputFailure: unknown;
1003
+ try {
1004
+ await output({ type: "ready" });
1005
+ while (stdioTransportState === "open") {
1006
+ const next = await Promise.race([inputIterator.next(), stdioFatalFailure.promise]);
1007
+ if (next.done) break;
1008
+ const text = inputDecoder.decode(next.value).trim();
1009
+ if (text) await handleInboundLine(text);
1010
+ }
1011
+ } catch (failure) {
1012
+ inputFailure = failure;
1013
+ } finally {
1014
+ detachStdioInput = undefined;
823
1015
  }
824
-
825
- // stdin closed — RPC client is gone, flush durable state and exit cleanly
826
- await shutdown(0, "RPC client disconnected");
827
- throw new Error("RPC shutdown returned unexpectedly");
1016
+ if (inputFailure !== undefined) {
1017
+ return await shutdown(
1018
+ 1,
1019
+ hasStdioWriteFailure ? "RPC stdout write failed" : "RPC stdin failed",
1020
+ hasStdioWriteFailure,
1021
+ );
1022
+ }
1023
+ if (stdioTransportState === "open") return await shutdown(0, "RPC client disconnected", false, false);
1024
+ return await shutdown(
1025
+ hasStdioWriteFailure ? 1 : 0,
1026
+ hasStdioWriteFailure ? "RPC stdout write failed" : "RPC stdout peer disconnected",
1027
+ true,
1028
+ );
828
1029
  }
@@ -4,7 +4,7 @@
4
4
  * Commands are sent as JSON lines on stdin.
5
5
  * Responses and events are emitted as JSON lines on stdout.
6
6
  */
7
- import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@sayknow-cli/agent-core";
7
+ import type { AgentMessage, AgentToolResult, ResolvedThinkingLevel, ThinkingLevel } from "@sayknow-cli/agent-core";
8
8
  import type { CompactionResult } from "@sayknow-cli/agent-core/compaction";
9
9
  import type { Effort, ImageContent, Model } from "@sayknow-cli/ai";
10
10
  import type { BashResult } from "../../exec/bash-executor";
@@ -38,6 +38,13 @@ export type RpcCommand =
38
38
 
39
39
  // Model
40
40
  | { id?: string; type: "set_model"; provider: string; modelId: string }
41
+ | {
42
+ id?: string;
43
+ type: "set_default_model_selection";
44
+ provider: string;
45
+ modelId: string;
46
+ thinkingLevel?: ResolvedThinkingLevel;
47
+ }
41
48
  | { id?: string; type: "cycle_model" }
42
49
  | { id?: string; type: "get_available_models" }
43
50
 
@@ -158,6 +165,13 @@ export type RpcResponse =
158
165
  success: true;
159
166
  data: Model;
160
167
  }
168
+ | {
169
+ id?: string;
170
+ type: "response";
171
+ command: "set_default_model_selection";
172
+ success: true;
173
+ data: { provider: string; modelId: string; thinkingLevel: ResolvedThinkingLevel };
174
+ }
161
175
  | {
162
176
  id?: string;
163
177
  type: "response";
@@ -1,4 +1,4 @@
1
- import type { AgentTool } from "@sayknow-cli/agent-core";
1
+ import type { AgentTool, ResolvedThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import { ThinkingLevel } from "@sayknow-cli/agent-core";
3
3
  import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
4
4
  import { Snowflake } from "@sayknow-cli/utils";
@@ -102,6 +102,14 @@ function serializeRpcDispatchError(err: unknown): string | object {
102
102
  return err instanceof Error ? err.message : String(err);
103
103
  }
104
104
 
105
+ function isConcreteThinkingLevel(value: unknown): value is ResolvedThinkingLevel {
106
+ return (
107
+ typeof value === "string" &&
108
+ value !== ThinkingLevel.Inherit &&
109
+ (Object.values(ThinkingLevel) as string[]).includes(value)
110
+ );
111
+ }
112
+
105
113
  export async function dispatchRpcCommand(
106
114
  command: RpcCommand,
107
115
  context: RpcCommandDispatchContext,
@@ -239,6 +247,30 @@ export async function dispatchRpcCommand(
239
247
  return rpcSuccess(id, "set_model", model);
240
248
  }
241
249
 
250
+ case "set_default_model_selection": {
251
+ if (typeof command.provider !== "string" || !command.provider.trim()) {
252
+ return typedError("set_default_model_selection", "provider must be a non-empty string");
253
+ }
254
+ if (typeof command.modelId !== "string" || !command.modelId.trim()) {
255
+ return typedError("set_default_model_selection", "modelId must be a non-empty string");
256
+ }
257
+ const requestedLevel: unknown = command.thinkingLevel;
258
+ if (requestedLevel !== undefined && !isConcreteThinkingLevel(requestedLevel)) {
259
+ return typedError("set_default_model_selection", "thinkingLevel must be a concrete level");
260
+ }
261
+ const model = session
262
+ .getAvailableModels()
263
+ .find(candidate => candidate.provider === command.provider && candidate.id === command.modelId);
264
+ if (!model) {
265
+ return typedError(
266
+ "set_default_model_selection",
267
+ `Model not found: ${command.provider}/${command.modelId}`,
268
+ );
269
+ }
270
+ const selection = await session.setDefaultModelSelection(model, requestedLevel);
271
+ return rpcSuccess(id, "set_default_model_selection", selection);
272
+ }
273
+
242
274
  case "cycle_model": {
243
275
  const result = await session.cycleModel();
244
276
  if (!result) {
@@ -18,6 +18,7 @@ function stringField(value: Record<string, unknown>, key: string): boolean {
18
18
  }
19
19
 
20
20
  const THINKING_LEVELS = new Set(["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"]);
21
+ const RESOLVED_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
21
22
  const TODO_STATUSES = new Set(["pending", "in_progress", "completed", "abandoned"]);
22
23
 
23
24
  function optionalBoolean(value: unknown): boolean {
@@ -136,6 +137,15 @@ export function isRpcCommand(value: unknown): value is RpcCommand {
136
137
  );
137
138
  case "set_model":
138
139
  return stringField(value, "provider") && stringField(value, "modelId");
140
+ case "set_default_model_selection":
141
+ return (
142
+ typeof value.provider === "string" &&
143
+ value.provider.trim().length > 0 &&
144
+ typeof value.modelId === "string" &&
145
+ value.modelId.trim().length > 0 &&
146
+ (value.thinkingLevel === undefined ||
147
+ (typeof value.thinkingLevel === "string" && RESOLVED_THINKING_LEVELS.has(value.thinkingLevel)))
148
+ );
139
149
  case "set_thinking_level":
140
150
  return typeof value.level === "string" && THINKING_LEVELS.has(value.level);
141
151
  case "set_steering_mode":