@oh-my-pi/pi-coding-agent 17.0.8 → 17.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.
Files changed (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
@@ -1,8 +1,24 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { isRecord } from "@oh-my-pi/pi-utils";
3
+ import type { RpcChunkFrame } from "./rpc-types";
3
4
 
4
5
  /** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
5
6
  export const MAX_RPC_FRAME_BYTES = 1024 * 1024;
7
+ /** Maximum UTF-8 size of one logical frame reassembled by protocol v2. */
8
+ export const MAX_RPC_REASSEMBLED_BYTES = 64 * 1024 * 1024;
9
+
10
+ const RPC_CHUNK_PAYLOAD_BYTES = 256 * 1024;
11
+
12
+ export type RpcProtocolVersion = 1 | 2;
13
+
14
+ interface PendingRpcChunks {
15
+ chunkId: string;
16
+ count: number;
17
+ byteLength: number;
18
+ nextIndex: number;
19
+ chunks: Buffer[];
20
+ receivedBytes: number;
21
+ }
6
22
 
7
23
  interface ShrinkPass {
8
24
  stringCap: number;
@@ -68,6 +84,110 @@ function encodedMessageSnapshot(encoded: string): { message: unknown } | undefin
68
84
  : undefined;
69
85
  }
70
86
 
87
+ /**
88
+ * Emit protocol v2 chunk frames for one pre-serialized logical frame, one physical
89
+ * JSONL line at a time so callers can write with backpressure instead of holding the
90
+ * whole ~4/3-sized base64 transport in memory. The reassembly ceiling is enforced on
91
+ * `Buffer.byteLength` BEFORE any full-payload allocation.
92
+ */
93
+ function* encodeChunkedRpcFrames(frame: object, json: string, chunkId: string): Generator<string> {
94
+ const byteLength = Buffer.byteLength(json, "utf8");
95
+ if (byteLength > MAX_RPC_REASSEMBLED_BYTES) {
96
+ yield `${JSON.stringify(overflowFrame(frame))}\n`;
97
+ return;
98
+ }
99
+ const bytes = Buffer.from(json, "utf8");
100
+ const count = Math.ceil(byteLength / RPC_CHUNK_PAYLOAD_BYTES);
101
+ for (let index = 0; index < count; index++) {
102
+ const chunk: RpcChunkFrame = {
103
+ type: "rpc_chunk",
104
+ chunkId,
105
+ index,
106
+ count,
107
+ byteLength,
108
+ data: bytes
109
+ .subarray(index * RPC_CHUNK_PAYLOAD_BYTES, (index + 1) * RPC_CHUNK_PAYLOAD_BYTES)
110
+ .toString("base64"),
111
+ };
112
+ const line = `${JSON.stringify(chunk)}\n`;
113
+ if (serializedFrameBytes(line.slice(0, -1)) > MAX_RPC_FRAME_BYTES)
114
+ throw new Error("RPC chunk exceeded the transport limit");
115
+ yield line;
116
+ }
117
+ }
118
+
119
+ function isRpcChunkFrame(value: unknown): value is RpcChunkFrame {
120
+ return isRecord(value) && value.type === "rpc_chunk";
121
+ }
122
+
123
+ function decodeBase64(data: unknown): Buffer {
124
+ if (
125
+ typeof data !== "string" ||
126
+ data.length === 0 ||
127
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)
128
+ )
129
+ throw new Error("invalid rpc chunk data");
130
+ const bytes = Buffer.from(data, "base64");
131
+ if (bytes.toString("base64") !== data) throw new Error("invalid rpc chunk data");
132
+ return bytes;
133
+ }
134
+
135
+ /** Reassemble protocol v2 chunk frames after each JSONL line has been parsed. */
136
+ export class RpcFrameDecoder {
137
+ #pending?: PendingRpcChunks;
138
+
139
+ push(value: unknown): object | undefined {
140
+ if (!isRpcChunkFrame(value)) {
141
+ if (this.#pending) throw new Error("rpc chunk sequence interrupted");
142
+ if (!isRecord(value)) throw new Error("rpc frame must be an object");
143
+ return value;
144
+ }
145
+ const { chunkId, index, count, byteLength } = value;
146
+ if (
147
+ typeof chunkId !== "string" ||
148
+ chunkId.length === 0 ||
149
+ chunkId.length > 128 ||
150
+ !Number.isSafeInteger(index) ||
151
+ !Number.isSafeInteger(count) ||
152
+ !Number.isSafeInteger(byteLength) ||
153
+ index < 0 ||
154
+ count < 2 ||
155
+ count > Math.ceil(MAX_RPC_REASSEMBLED_BYTES / RPC_CHUNK_PAYLOAD_BYTES) ||
156
+ index >= count ||
157
+ byteLength < MAX_RPC_FRAME_BYTES ||
158
+ byteLength > MAX_RPC_REASSEMBLED_BYTES
159
+ )
160
+ throw new Error("invalid rpc chunk metadata");
161
+ const bytes = decodeBase64(value.data);
162
+ if (bytes.byteLength > RPC_CHUNK_PAYLOAD_BYTES) throw new Error("rpc chunk payload exceeds the transport limit");
163
+
164
+ if (!this.#pending) {
165
+ if (index !== 0) throw new Error("rpc chunk sequence must start at index 0");
166
+ this.#pending = { chunkId, count, byteLength, nextIndex: 0, chunks: [], receivedBytes: 0 };
167
+ }
168
+ const pending = this.#pending;
169
+ if (
170
+ pending.chunkId !== chunkId ||
171
+ pending.count !== count ||
172
+ pending.byteLength !== byteLength ||
173
+ pending.nextIndex !== index
174
+ )
175
+ throw new Error("rpc chunk sequence mismatch");
176
+ pending.chunks.push(bytes);
177
+ pending.receivedBytes += bytes.byteLength;
178
+ pending.nextIndex++;
179
+ if (pending.receivedBytes > pending.byteLength) throw new Error("rpc chunk sequence exceeds declared length");
180
+ if (pending.nextIndex < pending.count) return undefined;
181
+ if (pending.receivedBytes !== pending.byteLength) throw new Error("rpc chunk sequence length mismatch");
182
+
183
+ this.#pending = undefined;
184
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(pending.chunks));
185
+ const frame: unknown = JSON.parse(decoded);
186
+ if (!isRecord(frame)) throw new Error("rpc frame must be an object");
187
+ return frame;
188
+ }
189
+ }
190
+
71
191
  function compactTerminalFrame(
72
192
  frame: object,
73
193
  streamedMessageCount: number,
@@ -142,15 +262,55 @@ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamed
142
262
  /** Stateful encoder that tracks which messages a client has already received. */
143
263
  export class RpcFrameEncoder {
144
264
  #streamedMessages: unknown[] = [];
265
+ #protocolVersion: RpcProtocolVersion = 1;
266
+ #chunkCounter = 0;
145
267
 
146
- encode(frame: object): string {
268
+ setProtocolVersion(version: number): void {
269
+ if (version !== 1 && version !== 2) throw new Error(`Unsupported RPC protocol version: ${version}`);
270
+ this.#protocolVersion = version;
271
+ }
272
+
273
+ /**
274
+ * Encode one logical frame into physical JSONL lines. Encoder bookkeeping runs
275
+ * eagerly; only chunk emission is lazy, so a chunked result can be streamed to
276
+ * stdout with backpressure without holding the whole transport in memory. The
277
+ * returned iterable MUST be fully consumed exactly once.
278
+ */
279
+ encodeFrames(frame: object): Iterable<string> {
147
280
  if (isRecord(frame) && frame.type === "agent_start") this.#streamedMessages = [];
148
- const encoded = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
149
- if (!isRecord(frame)) return encoded;
281
+ const json = JSON.stringify(frame);
282
+ let frames: Iterable<string>;
283
+ let singleFrame: string | undefined;
284
+ if (this.#protocolVersion === 2 && serializedFrameBytes(json) > MAX_RPC_FRAME_BYTES) {
285
+ const compacted = compactTerminalFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
286
+ // Reuse the original serialization when compaction was a no-op.
287
+ const compactedJson = compacted === frame ? json : JSON.stringify(compacted);
288
+ if (serializedFrameBytes(compactedJson) > MAX_RPC_FRAME_BYTES) {
289
+ frames = encodeChunkedRpcFrames(compacted, compactedJson, `rpc-${++this.#chunkCounter}`);
290
+ } else {
291
+ singleFrame = `${compactedJson}\n`;
292
+ frames = [singleFrame];
293
+ }
294
+ } else {
295
+ singleFrame = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
296
+ frames = [singleFrame];
297
+ }
298
+ if (!isRecord(frame)) return frames;
150
299
  if (frame.type === "message_end") {
151
- const snapshot = encodedMessageSnapshot(encoded);
300
+ const snapshot =
301
+ this.#protocolVersion === 2 && Object.hasOwn(frame, "message")
302
+ ? { message: jsonSnapshot(frame.message) }
303
+ : singleFrame !== undefined
304
+ ? encodedMessageSnapshot(singleFrame)
305
+ : undefined;
152
306
  if (snapshot) this.#streamedMessages.push(snapshot.message);
153
307
  } else if (frame.type === "agent_end" && frame.willContinue !== true) this.#streamedMessages = [];
308
+ return frames;
309
+ }
310
+
311
+ encode(frame: object): string {
312
+ let encoded = "";
313
+ for (const line of this.encodeFrames(frame)) encoded += line;
154
314
  return encoded;
155
315
  }
156
316
  }
@@ -0,0 +1,127 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import { isRecord } from "@oh-my-pi/pi-utils";
3
+
4
+ const DEFAULT_RPC_MESSAGE_PAGE_LIMIT = 100;
5
+ const MAX_RPC_MESSAGE_PAGE_LIMIT = 256;
6
+ const MAX_RPC_MESSAGE_PAGE_BYTES = 768 * 1024;
7
+ const MAX_RPC_MESSAGE_CURSOR_CHARS = 2048;
8
+
9
+ export const RPC_MESSAGES_PAGE_BUSY_ERROR = "Cannot page messages while the session is changing";
10
+ export const RPC_MESSAGES_PAGE_STALE_ERROR = "RPC message cursor is stale";
11
+
12
+ /** Machine-readable reasons a `get_messages_page` request can fail; carried as `code` on the error response. */
13
+ export type RpcMessagesPageErrorCode = "session_busy" | "stale_cursor";
14
+
15
+ /** Paging failure that maps to a structured wire `code`, so clients can react without matching message text. */
16
+ export class RpcMessagesPageError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly code: RpcMessagesPageErrorCode,
20
+ ) {
21
+ super(message);
22
+ this.name = "RpcMessagesPageError";
23
+ }
24
+ }
25
+
26
+ export interface RpcMessageSnapshot {
27
+ sessionId: string;
28
+ leafId: string | null;
29
+ messageCount: number;
30
+ }
31
+
32
+ export interface RpcMessagesPage {
33
+ messages: AgentMessage[];
34
+ nextCursor?: string;
35
+ totalMessages: number;
36
+ }
37
+
38
+ interface RpcMessageCursorPayload extends RpcMessageSnapshot {
39
+ version: 1;
40
+ offset: number;
41
+ }
42
+
43
+ export interface RpcMessagesPageOptions {
44
+ cursor?: string;
45
+ limit?: number;
46
+ }
47
+
48
+ function encodeCursor(snapshot: RpcMessageSnapshot, offset: number): string {
49
+ const payload: RpcMessageCursorPayload = { version: 1, ...snapshot, offset };
50
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
51
+ }
52
+
53
+ function decodeCursor(cursor: string): RpcMessageCursorPayload {
54
+ if (cursor.length === 0 || cursor.length > MAX_RPC_MESSAGE_CURSOR_CHARS || !/^[A-Za-z0-9_-]+$/.test(cursor))
55
+ throw new Error("Invalid RPC message cursor");
56
+ const bytes = Buffer.from(cursor, "base64url");
57
+ if (bytes.toString("base64url") !== cursor) throw new Error("Invalid RPC message cursor");
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
61
+ } catch {
62
+ throw new Error("Invalid RPC message cursor");
63
+ }
64
+ if (!isRecord(value)) throw new Error("Invalid RPC message cursor");
65
+ const { version, sessionId, leafId, messageCount, offset } = value;
66
+ if (
67
+ version !== 1 ||
68
+ typeof sessionId !== "string" ||
69
+ sessionId.length === 0 ||
70
+ sessionId.length > 256 ||
71
+ !((typeof leafId === "string" && leafId.length > 0 && leafId.length <= 256) || leafId === null) ||
72
+ typeof messageCount !== "number" ||
73
+ !Number.isSafeInteger(messageCount) ||
74
+ messageCount < 0 ||
75
+ typeof offset !== "number" ||
76
+ !Number.isSafeInteger(offset) ||
77
+ offset < 0 ||
78
+ offset > messageCount
79
+ )
80
+ throw new Error("Invalid RPC message cursor");
81
+ return { version, sessionId, leafId, messageCount, offset };
82
+ }
83
+
84
+ function sameSnapshot(cursor: RpcMessageCursorPayload, snapshot: RpcMessageSnapshot): boolean {
85
+ return (
86
+ cursor.sessionId === snapshot.sessionId &&
87
+ cursor.leafId === snapshot.leafId &&
88
+ cursor.messageCount === snapshot.messageCount
89
+ );
90
+ }
91
+
92
+ /** Page one stable in-memory message snapshot without crossing the v1 frame budget. */
93
+ export function pageRpcMessages(
94
+ messages: readonly AgentMessage[],
95
+ snapshot: RpcMessageSnapshot,
96
+ options: RpcMessagesPageOptions = {},
97
+ ): RpcMessagesPage {
98
+ if (snapshot.messageCount !== messages.length)
99
+ throw new Error("RPC message snapshot does not match current messages");
100
+ const limit = options.limit ?? DEFAULT_RPC_MESSAGE_PAGE_LIMIT;
101
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RPC_MESSAGE_PAGE_LIMIT)
102
+ throw new Error(`RPC message page limit must be between 1 and ${MAX_RPC_MESSAGE_PAGE_LIMIT}`);
103
+ let offset = 0;
104
+ if (options.cursor !== undefined) {
105
+ const cursor = decodeCursor(options.cursor);
106
+ if (!sameSnapshot(cursor, snapshot))
107
+ throw new RpcMessagesPageError(RPC_MESSAGES_PAGE_STALE_ERROR, "stale_cursor");
108
+ offset = cursor.offset;
109
+ }
110
+
111
+ const page: AgentMessage[] = [];
112
+ let pageBytes = 2;
113
+ while (offset + page.length < messages.length && page.length < limit) {
114
+ const message = messages[offset + page.length];
115
+ const messageBytes = Buffer.byteLength(JSON.stringify(message), "utf8") + (page.length === 0 ? 0 : 1);
116
+ if (page.length > 0 && pageBytes + messageBytes > MAX_RPC_MESSAGE_PAGE_BYTES) break;
117
+ page.push(message);
118
+ pageBytes += messageBytes;
119
+ }
120
+
121
+ const nextOffset = offset + page.length;
122
+ return {
123
+ messages: page,
124
+ ...(nextOffset < messages.length ? { nextCursor: encodeCursor(snapshot, nextOffset) } : {}),
125
+ totalMessages: messages.length,
126
+ };
127
+ }
@@ -10,6 +10,7 @@
10
10
  * - Events: AgentSessionEvent objects streamed as they occur
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
+ import { once } from "node:events";
13
14
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
15
  import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
15
16
  import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
@@ -34,8 +35,9 @@ import type { EventBus } from "../../utils/event-bus";
34
35
  import { initializeExtensions } from "../runtime-init";
35
36
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
36
37
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
37
- import { RpcFrameEncoder } from "./rpc-frame";
38
+ import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
38
39
  import { claimRpcInput } from "./rpc-input";
40
+ import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
39
41
  import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
40
42
  import type {
41
43
  RpcCommand,
@@ -619,9 +621,33 @@ export async function runRpcMode(
619
621
  process.env.PI_NOTIFICATIONS = "off";
620
622
 
621
623
  const frameEncoder = new RpcFrameEncoder();
622
- process.stdout.write(frameEncoder.encode({ type: "ready" }));
624
+ // Ordered stdout writer honoring backpressure: chunked v2 frames are produced
625
+ // lazily by the encoder and written one physical line at a time, so a near-limit
626
+ // logical frame never materializes its full base64 transport in memory.
627
+ let stdoutQueue: Promise<void> = Promise.resolve();
628
+ const writeFrames = (frames: Iterable<string>) => {
629
+ stdoutQueue = stdoutQueue
630
+ .then(async () => {
631
+ for (const line of frames) {
632
+ if (!process.stdout.write(line)) await once(process.stdout, "drain");
633
+ }
634
+ })
635
+ // stdout gone (host exited) — nothing left to deliver; keep the queue alive.
636
+ .catch(() => {});
637
+ };
638
+ writeFrames(
639
+ frameEncoder.encodeFrames({
640
+ type: "ready",
641
+ protocolVersion: 1,
642
+ supportedProtocolVersions: [1, 2],
643
+ maxFrameBytes: MAX_RPC_FRAME_BYTES,
644
+ maxReassembledFrameBytes: MAX_RPC_REASSEMBLED_BYTES,
645
+ }),
646
+ );
623
647
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
624
- process.stdout.write(frameEncoder.encode(obj));
648
+ writeFrames(frameEncoder.encodeFrames(obj));
649
+ if (isRecord(obj) && obj.type === "response" && obj.command === "negotiate_protocol" && obj.success === true)
650
+ frameEncoder.setProtocolVersion(2);
625
651
  };
626
652
  const emitRpcTitles = shouldEmitRpcTitles();
627
653
 
@@ -636,8 +662,8 @@ export async function runRpcMode(
636
662
  return { id, type: "response", command, success: true, data } as RpcResponse;
637
663
  };
638
664
 
639
- const error = (id: string | undefined, command: string, message: string): RpcResponse => {
640
- return { id, type: "response", command, success: false, error: message };
665
+ const error = (id: string | undefined, command: string, message: string, code?: string): RpcResponse => {
666
+ return { id, type: "response", command, success: false, error: message, ...(code ? { code } : {}) };
641
667
  };
642
668
 
643
669
  const extensionUserMessageTracker = new RpcExtensionUserMessageTracker();
@@ -936,6 +962,12 @@ export async function runRpcMode(
936
962
  const id = command.id;
937
963
 
938
964
  switch (command.type) {
965
+ case "negotiate_protocol": {
966
+ if (command.protocolVersion !== 2)
967
+ return error(id, "negotiate_protocol", `Unsupported RPC protocol version: ${command.protocolVersion}`);
968
+ return success(id, "negotiate_protocol", { protocolVersion: 2 });
969
+ }
970
+
939
971
  // =================================================================
940
972
  // Prompting
941
973
  // =================================================================
@@ -1122,8 +1154,19 @@ export async function runRpcMode(
1122
1154
  // =================================================================
1123
1155
 
1124
1156
  case "set_model": {
1125
- const models = session.getAvailableModels();
1126
- const model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1157
+ let models = session.getAvailableModels();
1158
+ let model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1159
+ if (!model) {
1160
+ // Model not in the current catalog. Wait for in-flight
1161
+ // background discovery before declaring it missing: on cold
1162
+ // start, discovery-backed providers (proxy / ollama / etc.)
1163
+ // populate seconds after session ready. Models already in
1164
+ // the bundled catalog skip this await entirely so the RPC
1165
+ // queue is not stalled behind unrelated discovery.
1166
+ await session.modelRegistry.awaitBackgroundRefresh();
1167
+ models = session.getAvailableModels();
1168
+ model = models.find(m => m.provider === command.provider && m.id === command.modelId);
1169
+ }
1127
1170
  if (!model) {
1128
1171
  return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
1129
1172
  }
@@ -1140,6 +1183,7 @@ export async function runRpcMode(
1140
1183
  }
1141
1184
 
1142
1185
  case "get_available_models": {
1186
+ await session.modelRegistry.awaitBackgroundRefresh();
1143
1187
  const models = session.getAvailableModels();
1144
1188
  return success(id, "get_available_models", { models });
1145
1189
  }
@@ -1277,6 +1321,34 @@ export async function runRpcMode(
1277
1321
  return success(id, "get_messages", { messages: session.messages });
1278
1322
  }
1279
1323
 
1324
+ case "get_messages_page": {
1325
+ if (session.isStreaming || session.isCompacting)
1326
+ return error(id, "get_messages_page", RPC_MESSAGES_PAGE_BUSY_ERROR, "session_busy");
1327
+ const messages = session.messages;
1328
+ try {
1329
+ return success(
1330
+ id,
1331
+ "get_messages_page",
1332
+ pageRpcMessages(
1333
+ messages,
1334
+ {
1335
+ sessionId: session.sessionId,
1336
+ leafId: session.sessionManager.getLeafId(),
1337
+ messageCount: messages.length,
1338
+ },
1339
+ { cursor: command.cursor, limit: command.limit },
1340
+ ),
1341
+ );
1342
+ } catch (pageError) {
1343
+ return error(
1344
+ id,
1345
+ "get_messages_page",
1346
+ pageError instanceof Error ? pageError.message : String(pageError),
1347
+ pageError instanceof RpcMessagesPageError ? pageError.code : undefined,
1348
+ );
1349
+ }
1350
+ }
1351
+
1280
1352
  // =================================================================
1281
1353
  // Login
1282
1354
  // =================================================================
@@ -19,12 +19,16 @@ import type {
19
19
  SubagentProgressPayload,
20
20
  } from "../../task";
21
21
  import type { TodoPhase } from "../../tools/todo";
22
+ import type { RpcMessagesPage } from "./rpc-messages";
22
23
 
23
24
  // ============================================================================
24
25
  // RPC Commands (stdin)
25
26
  // ============================================================================
26
27
 
27
28
  export type RpcCommand =
29
+ // Protocol
30
+ | { id?: string; type: "negotiate_protocol"; protocolVersion: number }
31
+
28
32
  // Prompting
29
33
  | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
30
34
  | { id?: string; type: "steer"; message: string; images?: ImageContent[] }
@@ -81,6 +85,7 @@ export type RpcCommand =
81
85
 
82
86
  // Messages
83
87
  | { id?: string; type: "get_messages" }
88
+ | { id?: string; type: "get_messages_page"; cursor?: string; limit?: number }
84
89
 
85
90
  // Login
86
91
  | { id?: string; type: "get_login_providers" }
@@ -132,6 +137,23 @@ export interface RpcPromptResultFrame {
132
137
  agentInvoked: boolean;
133
138
  }
134
139
 
140
+ export interface RpcReadyFrame {
141
+ type: "ready";
142
+ protocolVersion: 1;
143
+ supportedProtocolVersions: [1, 2];
144
+ maxFrameBytes: number;
145
+ maxReassembledFrameBytes: number;
146
+ }
147
+
148
+ export interface RpcChunkFrame {
149
+ type: "rpc_chunk";
150
+ chunkId: string;
151
+ index: number;
152
+ count: number;
153
+ byteLength: number;
154
+ data: string;
155
+ }
156
+
135
157
  export interface RpcHandoffResult {
136
158
  savedPath?: string;
137
159
  }
@@ -168,6 +190,15 @@ export interface RpcSubagentMessagesResult {
168
190
 
169
191
  // Success responses with data
170
192
  export type RpcResponse =
193
+ // Protocol
194
+ | {
195
+ id?: string;
196
+ type: "response";
197
+ command: "negotiate_protocol";
198
+ success: true;
199
+ data: { protocolVersion: 2 };
200
+ }
201
+
171
202
  // Prompting (async - events follow)
172
203
  | { id?: string; type: "response"; command: "prompt"; success: true; data?: { agentInvoked: boolean } }
173
204
  | { id?: string; type: "response"; command: "steer"; success: true }
@@ -284,6 +315,7 @@ export type RpcResponse =
284
315
 
285
316
  // Messages
286
317
  | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
318
+ | { id?: string; type: "response"; command: "get_messages_page"; success: true; data: RpcMessagesPage }
287
319
 
288
320
  // Login
289
321
  | {
@@ -295,8 +327,8 @@ export type RpcResponse =
295
327
  }
296
328
  | { id?: string; type: "response"; command: "login"; success: true; data: { providerId: string } }
297
329
 
298
- // Error response (any command can fail)
299
- | { id?: string; type: "response"; command: string; success: false; error: string };
330
+ // Error response (any command can fail); `code` is an optional machine-readable reason.
331
+ | { id?: string; type: "response"; command: string; success: false; error: string; code?: string };
300
332
 
301
333
  // ============================================================================
302
334
  // Subagent Events (stdout)
@@ -90,6 +90,8 @@ export async function initializeExtensions(session: AgentSession, options: Initi
90
90
  setModel: model => runExtensionSetModel(session, model),
91
91
  getThinkingLevel: () => session.thinkingLevel,
92
92
  setThinkingLevel: level => session.setThinkingLevel(level),
93
+ getServiceTiers: () => session.serviceTierByFamily,
94
+ setServiceTier: (family, tier) => session.setServiceTierFamily(family, tier),
93
95
  getSessionName: () => session.sessionManager.getSessionName(),
94
96
  setSessionName: async name => {
95
97
  await session.sessionManager.setSessionName(name, "user");
@@ -10,7 +10,8 @@ import { theme } from "../../theme/theme";
10
10
  import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
11
11
 
12
12
  const MAX_VISIBLE_MODELS = 10;
13
- const WIZARD_SCREEN_RESERVE = 22;
13
+ /** ModelBrowser chrome: search row + blank above the list, blank + two detail rows below. */
14
+ const BROWSER_FRAME_ROWS = 5;
14
15
 
15
16
  class ModelSceneController implements SetupSceneController {
16
17
  title = "Choose your default model";
@@ -54,16 +55,13 @@ class ModelSceneController implements SetupSceneController {
54
55
  this.#browser.routeMouse(event, line - this.#browserRowStart);
55
56
  }
56
57
 
57
- render(width: number): readonly string[] {
58
- const visibleRows = Math.max(
59
- 1,
60
- Math.min(MAX_VISIBLE_MODELS, this.host.ctx.ui.terminal.rows - WIZARD_SCREEN_RESERVE),
61
- );
62
- this.#browser.setMaxVisible(visibleRows);
58
+ render(width: number, maxLines?: number): readonly string[] {
63
59
  const lines = [
64
60
  this.#status ?? theme.fg("muted", "Type to search. Enter saves the highlighted model as your default."),
65
61
  "",
66
62
  ];
63
+ const budget = maxLines === undefined ? MAX_VISIBLE_MODELS : maxLines - lines.length - BROWSER_FRAME_ROWS;
64
+ this.#browser.setMaxVisible(Math.max(1, Math.min(MAX_VISIBLE_MODELS, budget)));
67
65
  this.#browserRowStart = lines.length;
68
66
  lines.push(...this.#browser.render(width));
69
67
  return lines;
@@ -85,10 +85,11 @@ class ProvidersSceneController implements SetupSceneController {
85
85
  }
86
86
  }
87
87
 
88
- render(width: number): readonly string[] {
88
+ render(width: number, maxLines?: number): readonly string[] {
89
89
  const tabLines = this.#tabBar.render(width);
90
90
  this.#tabRowCount = tabLines.length;
91
- return [...tabLines, "", ...this.#activeTab().render(width)];
91
+ const tabBudget = maxLines === undefined ? undefined : Math.max(1, maxLines - tabLines.length - 1);
92
+ return [...tabLines, "", ...this.#activeTab().render(width, tabBudget)];
92
93
  }
93
94
 
94
95
  dispose(): void {
@@ -131,13 +131,19 @@ export class SignInTab implements SetupTab {
131
131
  this.#selector.routeMouse(event, line - this.#selectorRowStart, col);
132
132
  }
133
133
 
134
- render(width: number): readonly string[] {
134
+ render(width: number, maxLines?: number): readonly string[] {
135
135
  const lines: string[] = [];
136
136
  if (this.#loggingInProvider) {
137
137
  lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`));
138
138
  } else {
139
- lines.push(theme.fg("muted", "Pick a provider to sign in you can connect more than one."), "");
139
+ // Hint + blank cost two rows; the wizard subtitle already explains
140
+ // this panel, so on short screens the rows go to the provider list
141
+ // instead (17 = full selector: 4 chrome above, 10 rows, 3 below).
142
+ if (maxLines === undefined || maxLines >= 17 + 2) {
143
+ lines.push(theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), "");
144
+ }
140
145
  this.#selectorRowStart = lines.length;
146
+ if (maxLines !== undefined) this.#selector.setMaxHeight(maxLines - lines.length);
141
147
  lines.push(...this.#selector.render(width));
142
148
  }
143
149
 
@@ -136,21 +136,31 @@ class ThemeSceneController implements SetupSceneController {
136
136
  routeSelectListMouse(this.#selectList, event, listLine);
137
137
  }
138
138
 
139
- render(width: number): readonly string[] {
139
+ render(width: number, maxLines?: number): readonly string[] {
140
+ const budget = maxLines ?? Number.POSITIVE_INFINITY;
140
141
  const lines = [
141
142
  theme.fg("muted", "Theme changes preview live. Nothing is saved until you press Enter."),
142
143
  this.#mode === "all"
143
144
  ? theme.fg("dim", "Browsing all themes · Esc returns to curated choices")
144
145
  : theme.fg("dim", "Esc skips this step"),
145
146
  "",
146
- ...renderThemePreview(width),
147
- "",
148
147
  ];
148
+ // The mock status-line/editor block is decorative — the wizard itself
149
+ // re-renders in the highlighted theme — so it yields to the list when
150
+ // it would squeeze the window below the six curated rows (+1 for the
151
+ // list's own search-status row).
152
+ const preview = renderThemePreview(width);
153
+ if (budget - lines.length - (preview.length + 1) - 1 >= CURATED_ITEMS.length) {
154
+ lines.push(...preview, "");
155
+ }
149
156
  if (this.#loadingAllThemes) {
150
157
  this.#listRowStart = -1;
151
158
  lines.push(theme.fg("dim", "Loading themes…"));
152
159
  } else {
153
160
  this.#listRowStart = lines.length;
161
+ if (maxLines !== undefined) {
162
+ this.#selectList.setMaxVisible(Math.max(1, Math.min(10, budget - lines.length - 1)));
163
+ }
154
164
  lines.push(...this.#selectList.render(width));
155
165
  }
156
166
  if (this.#message) {