toolroll 0.9.63 → 0.9.65

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 (292) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +50 -2
  16. package/dist/agent-fence.js +157 -10
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +235 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +197 -15
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/checkout-proof.d.ts +28 -0
  55. package/dist/checkout-proof.js +62 -0
  56. package/dist/claim.d.ts +12 -0
  57. package/dist/claim.js +19 -0
  58. package/dist/cli-help.d.ts +1 -1
  59. package/dist/cli-help.js +4 -1
  60. package/dist/cli.d.ts +3 -0
  61. package/dist/cli.js +21 -2
  62. package/dist/coding-ui.js +6 -4
  63. package/dist/console-instance.d.ts +124 -0
  64. package/dist/console-instance.js +337 -0
  65. package/dist/contracts/chat-actions.d.ts +16 -0
  66. package/dist/contracts/chat-actions.js +4 -0
  67. package/dist/contracts/chat-content.d.ts +19 -0
  68. package/dist/contracts/chat-content.js +6 -0
  69. package/dist/contracts/console-api.d.ts +12 -4
  70. package/dist/contracts/console-api.js +12 -4
  71. package/dist/contracts/flow-card.d.ts +4 -0
  72. package/dist/contracts/flow-card.js +3 -0
  73. package/dist/contracts/flow.d.ts +46 -0
  74. package/dist/contracts/flow.js +11 -2
  75. package/dist/contracts/lead-tools.d.ts +92 -0
  76. package/dist/contracts/lead-tools.js +13 -0
  77. package/dist/contracts/provider.d.ts +11 -1
  78. package/dist/contracts/provider.js +1 -1
  79. package/dist/contracts/route.d.ts +76 -1
  80. package/dist/contracts/route.js +1 -1
  81. package/dist/contracts/store-json.d.ts +2 -0
  82. package/dist/control-setup.d.ts +1 -1
  83. package/dist/control-ui.js +2 -2
  84. package/dist/converse.d.ts +16 -0
  85. package/dist/converse.js +32 -4
  86. package/dist/demo-chat.js +4 -4
  87. package/dist/demo.js +2 -2
  88. package/dist/desktop-update-gate.js +6 -0
  89. package/dist/desktop-update.d.ts +1 -1
  90. package/dist/discord-chat.js +4 -1
  91. package/dist/doctor.js +1 -1
  92. package/dist/egress-bridge.d.ts +17 -0
  93. package/dist/egress-bridge.js +111 -0
  94. package/dist/egress.d.ts +119 -0
  95. package/dist/egress.js +423 -0
  96. package/dist/exec.d.ts +78 -0
  97. package/dist/exec.js +367 -9
  98. package/dist/export-ui.js +2 -2
  99. package/dist/flow-engine.js +24 -5
  100. package/dist/flow-gallery-ui.js +2 -2
  101. package/dist/flow-gallery.js +27 -8
  102. package/dist/flow-share.js +16 -5
  103. package/dist/flow-triggers.d.ts +77 -8
  104. package/dist/flow-triggers.js +517 -70
  105. package/dist/flows-cli.js +8 -2
  106. package/dist/flows-ui.d.ts +0 -2
  107. package/dist/flows-ui.js +52 -4
  108. package/dist/flows.d.ts +4 -0
  109. package/dist/flows.js +9 -1
  110. package/dist/html.d.ts +9 -0
  111. package/dist/html.js +11 -0
  112. package/dist/invoke.d.ts +17 -0
  113. package/dist/invoke.js +217 -21
  114. package/dist/keys.d.ts +18 -14
  115. package/dist/keys.js +5 -0
  116. package/dist/lead-capabilities.js +3 -2
  117. package/dist/lead-contract.js +2 -0
  118. package/dist/lead-doors.d.ts +2 -0
  119. package/dist/lead-doors.js +7 -0
  120. package/dist/lead-follow.d.ts +2 -0
  121. package/dist/lead-follow.js +58 -2
  122. package/dist/lead-progress.js +1 -0
  123. package/dist/lead-queue-runtime.d.ts +46 -0
  124. package/dist/lead-queue-runtime.js +191 -0
  125. package/dist/lead-queue.d.ts +104 -0
  126. package/dist/lead-queue.js +187 -0
  127. package/dist/lead-sweep.d.ts +67 -0
  128. package/dist/lead-sweep.js +99 -0
  129. package/dist/lead-tools.d.ts +22 -0
  130. package/dist/lead-tools.js +152 -1
  131. package/dist/lead.d.ts +13 -5
  132. package/dist/lead.js +89 -29
  133. package/dist/limits-ui.d.ts +8 -1
  134. package/dist/limits-ui.js +26 -0
  135. package/dist/live-bus.d.ts +8 -0
  136. package/dist/live-bus.js +40 -4
  137. package/dist/live-detail.d.ts +27 -0
  138. package/dist/live-detail.js +57 -0
  139. package/dist/live-notes.d.ts +20 -0
  140. package/dist/live-notes.js +47 -0
  141. package/dist/live-records.d.ts +140 -0
  142. package/dist/live-records.js +935 -0
  143. package/dist/live-terminal.d.ts +258 -0
  144. package/dist/live-terminal.js +649 -0
  145. package/dist/live.d.ts +38 -27
  146. package/dist/live.js +142 -124
  147. package/dist/mcp-oauth.d.ts +6 -3
  148. package/dist/mcp-oauth.js +8 -4
  149. package/dist/memory-pass.js +2 -2
  150. package/dist/mobile-viewport.d.ts +5 -1
  151. package/dist/mobile-viewport.js +18 -2
  152. package/dist/model-catalog.js +4 -0
  153. package/dist/monitoring-ui.js +2 -2
  154. package/dist/needs-you.d.ts +1 -1
  155. package/dist/needs-you.js +11 -4
  156. package/dist/operate.d.ts +2 -0
  157. package/dist/operate.js +275 -17
  158. package/dist/passwordless.d.ts +23 -0
  159. package/dist/passwordless.js +26 -0
  160. package/dist/peek-cli.d.ts +12 -4
  161. package/dist/peek-cli.js +19 -11
  162. package/dist/phase-routing.js +10 -3
  163. package/dist/planner-source.d.ts +3 -0
  164. package/dist/planner-source.js +14 -0
  165. package/dist/planner.d.ts +2 -1
  166. package/dist/planner.js +7 -1
  167. package/dist/policy-ui.js +2 -2
  168. package/dist/policy.d.ts +1 -20
  169. package/dist/policy.js +29 -2
  170. package/dist/project-delete-ui.d.ts +12 -0
  171. package/dist/project-delete-ui.js +41 -9
  172. package/dist/project-delete.js +2 -2
  173. package/dist/project-memory.d.ts +3 -1
  174. package/dist/project-memory.js +57 -25
  175. package/dist/project-tools.d.ts +5 -3
  176. package/dist/project-tools.js +12 -9
  177. package/dist/provider-auth.d.ts +4 -3
  178. package/dist/provider-auth.js +5 -4
  179. package/dist/provider-connection.d.ts +2 -1
  180. package/dist/provider-connection.js +5 -0
  181. package/dist/provider.d.ts +25 -2
  182. package/dist/provider.js +63 -9
  183. package/dist/pull-request-ui.js +2 -2
  184. package/dist/push.js +1 -1
  185. package/dist/quick-start.d.ts +24 -0
  186. package/dist/quick-start.js +127 -0
  187. package/dist/release.d.ts +3 -0
  188. package/dist/release.js +14 -8
  189. package/dist/request-budget-ui.js +2 -2
  190. package/dist/result-review.d.ts +1 -1
  191. package/dist/result-review.js +9 -3
  192. package/dist/retention-ui.js +2 -2
  193. package/dist/retention.js +10 -1
  194. package/dist/run-efficiency.d.ts +73 -0
  195. package/dist/run-efficiency.js +228 -0
  196. package/dist/runner.d.ts +5 -1
  197. package/dist/runner.js +22 -1
  198. package/dist/scope.d.ts +170 -5
  199. package/dist/scope.js +251 -31
  200. package/dist/scout-net.d.ts +4 -1
  201. package/dist/scout-net.js +37 -12
  202. package/dist/scout.d.ts +20 -6
  203. package/dist/scout.js +27 -3
  204. package/dist/serve.js +247 -40
  205. package/dist/server/chat.d.ts +1 -1
  206. package/dist/server/chat.js +122 -31
  207. package/dist/server/chrome.d.ts +6 -1
  208. package/dist/server/chrome.js +18 -7
  209. package/dist/server/console.css +52 -38
  210. package/dist/server/flows.js +38 -8
  211. package/dist/server/guards.d.ts +1 -0
  212. package/dist/server/guards.js +23 -9
  213. package/dist/server/http.d.ts +7 -0
  214. package/dist/server/http.js +3 -2
  215. package/dist/server/live.d.ts +34 -1
  216. package/dist/server/live.js +39 -17
  217. package/dist/server/pages-assets.js +5 -0
  218. package/dist/server/pages.js +90 -13
  219. package/dist/server/people-tokens.d.ts +3 -0
  220. package/dist/server/people-tokens.js +55 -17
  221. package/dist/server/remote-hooks.js +12 -6
  222. package/dist/server/remote.d.ts +2 -1
  223. package/dist/server/remote.js +14 -5
  224. package/dist/server/render-chat.d.ts +12 -3
  225. package/dist/server/render-chat.js +47 -16
  226. package/dist/server/render-pages.d.ts +4 -1
  227. package/dist/server/render-pages.js +5 -4
  228. package/dist/server/render-people.d.ts +3 -2
  229. package/dist/server/render-people.js +3 -2
  230. package/dist/server/render-results.d.ts +18 -2
  231. package/dist/server/render-results.js +37 -28
  232. package/dist/server/render-settings.js +6 -5
  233. package/dist/server/render-tasks.d.ts +35 -10
  234. package/dist/server/render-tasks.js +110 -27
  235. package/dist/server/request-context.d.ts +2 -0
  236. package/dist/server/request-context.js +2 -1
  237. package/dist/server/route-table.d.ts +3 -0
  238. package/dist/server/route-table.js +46 -5
  239. package/dist/server/runtime.d.ts +33 -2
  240. package/dist/server/session.d.ts +25 -1
  241. package/dist/server/session.js +18 -4
  242. package/dist/server/settings.js +121 -10
  243. package/dist/server/sweeps.d.ts +4 -0
  244. package/dist/server/sweeps.js +58 -0
  245. package/dist/server/task-views.d.ts +2 -1
  246. package/dist/server/task-views.js +96 -6
  247. package/dist/server/tasks.js +320 -74
  248. package/dist/server/terminals.d.ts +31 -0
  249. package/dist/server/terminals.js +365 -0
  250. package/dist/setup-guide.js +3 -2
  251. package/dist/slack-chat.js +4 -1
  252. package/dist/spend-ui.js +3 -3
  253. package/dist/spend.js +2 -1
  254. package/dist/steer-capabilities.d.ts +32 -0
  255. package/dist/steer-capabilities.js +60 -0
  256. package/dist/steer-controls.d.ts +14 -0
  257. package/dist/steer-controls.js +36 -0
  258. package/dist/steer-status.js +2 -2
  259. package/dist/storage-ui.js +4 -4
  260. package/dist/store-baseline.d.ts +1 -1
  261. package/dist/store-baseline.js +1 -1
  262. package/dist/store.d.ts +430 -15
  263. package/dist/store.js +1041 -52
  264. package/dist/subagent-desk.js +1 -1
  265. package/dist/summary.js +3 -0
  266. package/dist/task-source.d.ts +58 -0
  267. package/dist/task-source.js +93 -0
  268. package/dist/team-leads.js +1 -1
  269. package/dist/teams-chat.js +4 -1
  270. package/dist/telegram-lead.d.ts +3 -0
  271. package/dist/telegram-lead.js +44 -7
  272. package/dist/telegram.d.ts +11 -2
  273. package/dist/telegram.js +51 -17
  274. package/dist/terminal-catalog.d.ts +11 -0
  275. package/dist/terminal-catalog.js +129 -0
  276. package/dist/text-limits.d.ts +2 -0
  277. package/dist/text-limits.js +2 -0
  278. package/dist/tool-broker.d.ts +2 -0
  279. package/dist/tool-broker.js +1 -0
  280. package/dist/toolroll-update-ui.js +2 -2
  281. package/dist/toolroll-update.js +13 -3
  282. package/dist/tools-ui.js +2 -2
  283. package/dist/webhook-limits.d.ts +23 -0
  284. package/dist/webhook-limits.js +30 -0
  285. package/dist/webhook-readers.d.ts +56 -0
  286. package/dist/webhook-readers.js +138 -0
  287. package/dist/webhook-signing.d.ts +27 -0
  288. package/dist/webhook-signing.js +119 -0
  289. package/dist/work-index.d.ts +29 -0
  290. package/dist/work-index.js +16 -2
  291. package/dist/workspace-revision.js +3 -1
  292. package/package.json +11 -2
package/dist/live.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * Fail-soft everywhere: a live view that cannot be written must never
14
14
  * break, slow, or reclassify a build.
15
15
  */
16
+ import { type KeyState, type LiveActivityKind, type LiveRecord, type LiveRecorderOptions } from "./live-records.js";
16
17
  export declare const LIVE_LINE_CAP: number;
17
18
  export declare const LIVE_FILE_CAP: number;
18
19
  export declare const LIVE_CAP_MARKER = "[the live view stopped at its size cap \u2014 the run record continues]";
@@ -23,29 +24,18 @@ export declare const LIVE_RETAIN_MS: number;
23
24
  export declare const LIVE_FILE_BOUND = 500;
24
25
  export declare function liveDir(root: string): string;
25
26
  /** The exact filename, from the numeric id and nothing else — no user
26
- * input is ever parsed into this path. */
27
+ * input is ever parsed into this path. Records (`.jsonl`) are what every
28
+ * run writes now; the name is the format. */
27
29
  export declare function liveLogPath(root: string, runId: number): string;
28
- /**
29
- * The fixed tool vocabulary. Known harness tools map to OUR kinds and
30
- * phrases; anything unrecognized gets the generic one. The tool's own
31
- * name, arguments, and metadata never reach the file: a tool named after
32
- * a secret stays unspoken.
33
- */
34
- export type LiveActivityKind = "session" | "message" | "edit" | "command" | "read" | "search" | "lookup" | "plan" | "delegate" | "tool";
35
- export declare const LIVE_ACTIVITY_KINDS: readonly LiveActivityKind[];
30
+ /** The plain-text file runs wrote before the live conversation; read until it ages out. */
31
+ export declare function legacyLiveLogPath(root: string, runId: number): string;
32
+ export { LIVE_ACTIVITY_KINDS, type LiveActivityKind } from "./live-records.js";
36
33
  /**
37
34
  * What one stream event says the agent just did, as one fixed kind: the
38
35
  * last-activity line's only input. Never text, names or arguments; an
39
36
  * event that says nothing (results, deltas, subagent chatter) is null.
40
37
  */
41
38
  export declare function liveActivityKind(event: Record<string, unknown>): LiveActivityKind | null;
42
- /**
43
- * One stream event → zero or more display lines, in the fixed vocabulary.
44
- * Assistant TEXT is the agent's own words (sanitized below); tool use is a
45
- * headline; everything else — tool results, deltas, results, subagent
46
- * chatter — renders nothing in v1.
47
- */
48
- export declare function renderTranscriptLines(event: Record<string, unknown>): string[];
49
39
  /** Sanitize one display line: strip controls and bidi overrides, redact whole on any secret hit, cap. */
50
40
  export declare function sanitizeTranscriptLine(line: string): string;
51
41
  export type LiveLog = {
@@ -53,31 +43,52 @@ export type LiveLog = {
53
43
  close(): void;
54
44
  };
55
45
  /**
56
- * Open the run's live file for appending. The directory is 0700, the file
57
- * 0600 and CREATED HERE (O_EXCL: a name that already exists — whatever put
58
- * it there — is refused, not appended to), opened O_NOFOLLOW with the
59
- * descriptor retained for the run's whole duration. Returns null on any
60
- * failure: no live view is ever worth a build.
46
+ * Open the run's live file for appending records. The directory is 0700,
47
+ * the file 0600 and CREATED HERE (O_EXCL: a name that already exists —
48
+ * whatever put it there — is refused, not appended to), opened O_NOFOLLOW
49
+ * with the descriptor retained for the run's whole duration. `options` is
50
+ * the run's trusted context: its checkout, and whether its project shows
51
+ * commands (read once, here). Returns null on any failure: no live view is
52
+ * ever worth a build.
61
53
  */
62
- export declare function openLiveLog(root: string, runId: number): LiveLog | null;
54
+ export declare function openLiveLog(root: string, runId: number, options?: Partial<LiveRecorderOptions>): LiveLog | null;
55
+ export type LiveFormat = "records" | "text";
63
56
  export type LiveWindowRead = {
64
57
  ok: true;
58
+ /** Complete lines read from `from`: records (JSON lines) or legacy text. */
65
59
  text: string;
66
60
  nextOffset: number;
67
61
  eof: boolean;
62
+ format: LiveFormat;
63
+ /** Which file this offset belongs to: a client holding another resets. */
64
+ gen: string;
68
65
  } | {
69
66
  ok: false;
70
67
  reason: "missing" | "replaced" | "unreadable";
71
68
  };
72
69
  /**
73
70
  * Read the live file from a byte offset, returning only COMPLETE lines
74
- * (through the last LF in the window). The descriptor is the authority:
75
- * opened O_NOFOLLOW, proved a regular file, measured and read through
76
- * itself. A file smaller than the offset was replaced — the caller resets,
77
- * visibly, never silently duplicating (arc 1 §4). `allowTail` (the final
78
- * drain) returns the unterminated tail after finalization.
71
+ * (through the last LF in the window). The records file is read when it
72
+ * exists, else the legacy text file. The descriptor is the authority:
73
+ * opened O_NOFOLLOW | O_NONBLOCK inside a real live directory, proved a
74
+ * regular file, measured and read through itself. A file smaller than the
75
+ * offset was replaced — the caller resets, visibly, and `gen` names the file
76
+ * so an offset is never reused against another one. A line longer than the
77
+ * whole window is skipped through its end, so a reader never stalls.
78
+ * `allowTail` (the final drain) returns the unterminated tail after
79
+ * finalization. Legacy text is cleaned again on the way out.
79
80
  */
80
81
  export declare function readLiveWindow(root: string, runId: number, from: number, allowTail?: boolean): LiveWindowRead;
82
+ /** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format. */
83
+ export declare function liveWindowLines(read: {
84
+ text: string;
85
+ format: LiveFormat;
86
+ }, keys?: KeyState): string[];
87
+ /** A window as records, whatever its format: an older plain file's lines become the agent's words. */
88
+ export declare function liveWindowRecords(read: {
89
+ text: string;
90
+ format: LiveFormat;
91
+ }, keys?: KeyState): LiveRecord[];
81
92
  export type LiveSweepVerdict = {
82
93
  /** run id, or null when the name is not a run's file. */
83
94
  runId: number | null;
package/dist/live.js CHANGED
@@ -13,12 +13,13 @@
13
13
  * Fail-soft everywhere: a live view that cannot be written must never
14
14
  * break, slow, or reclassify a build.
15
15
  */
16
- import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs";
16
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { scanForSecrets } from "./evidence.js";
19
+ import { createLiveRecorder, LIVE_CAP_WORDS, parseLiveRecords, redactLiveLines, projectLiveText, serializeLiveRecord } from "./live-records.js";
19
20
  export const LIVE_LINE_CAP = 2 * 1024;
20
- export const LIVE_FILE_CAP = 2 * 1024 * 1024;
21
- export const LIVE_CAP_MARKER = "[the live view stopped at its size cap — the run record continues]";
21
+ export const LIVE_FILE_CAP = 4 * 1024 * 1024;
22
+ export const LIVE_CAP_MARKER = LIVE_CAP_WORDS;
22
23
  export const LIVE_REDACTED_LINE = "[a credential-shaped line was redacted]";
23
24
  /** Finalized runs keep their live file this long for the final drain. */
24
25
  export const LIVE_RETAIN_MS = 24 * 60 * 60 * 1000;
@@ -28,11 +29,17 @@ export function liveDir(root) {
28
29
  return join(root, "live");
29
30
  }
30
31
  /** The exact filename, from the numeric id and nothing else — no user
31
- * input is ever parsed into this path. */
32
+ * input is ever parsed into this path. Records (`.jsonl`) are what every
33
+ * run writes now; the name is the format. */
32
34
  export function liveLogPath(root, runId) {
35
+ return join(liveDir(root), `${Math.trunc(runId)}.jsonl`);
36
+ }
37
+ /** The plain-text file runs wrote before the live conversation; read until it ages out. */
38
+ export function legacyLiveLogPath(root, runId) {
33
39
  return join(liveDir(root), `${Math.trunc(runId)}.log`);
34
40
  }
35
- export const LIVE_ACTIVITY_KINDS = ["session", "message", "edit", "command", "read", "search", "lookup", "plan", "delegate", "tool"];
41
+ export { LIVE_ACTIVITY_KINDS } from "./live-records.js";
42
+ /** The activity line's kinds for known Claude tools (task-activity.ts). */
36
43
  const TOOL_KINDS = new Map([
37
44
  ["Edit", "edit"],
38
45
  ["Write", "edit"],
@@ -48,18 +55,6 @@ const TOOL_KINDS = new Map([
48
55
  ["TodoWrite", "plan"],
49
56
  ["Task", "delegate"],
50
57
  ]);
51
- const KIND_HEADLINES = {
52
- session: "the agent session started",
53
- message: "",
54
- edit: "→ editing files",
55
- command: "→ running a command",
56
- read: "→ reading the code",
57
- search: "→ searching the code",
58
- lookup: "→ looking something up",
59
- plan: "→ organizing its plan",
60
- delegate: "→ delegating to a helper",
61
- tool: "→ using a tool",
62
- };
63
58
  const toolKind = (name) => TOOL_KINDS.get(typeof name === "string" ? name : "") ?? "tool";
64
59
  /**
65
60
  * What one stream event says the agent just did, as one fixed kind: the
@@ -119,77 +114,6 @@ function capBytes(text, cap) {
119
114
  // A cut mid-character decodes with a trailing replacement char — drop it.
120
115
  return decoded.endsWith("�") ? decoded.slice(0, -1) : decoded;
121
116
  }
122
- /**
123
- * One stream event → zero or more display lines, in the fixed vocabulary.
124
- * Assistant TEXT is the agent's own words (sanitized below); tool use is a
125
- * headline; everything else — tool results, deltas, results, subagent
126
- * chatter — renders nothing in v1.
127
- */
128
- export function renderTranscriptLines(event) {
129
- const type = String(event["type"] ?? "");
130
- if (type === "system" && String(event["subtype"] ?? "") === "init") {
131
- return ["the agent session started"];
132
- }
133
- // Codex-shaped streams (peek): a thread starts, items complete.
134
- if (type === "thread.started")
135
- return ["the agent session started"];
136
- if (type === "item.completed") {
137
- const item = event["item"];
138
- const kind = item === undefined ? "" : String(item["type"] ?? "");
139
- if (kind === "agent_message") {
140
- const text = item?.["text"];
141
- return typeof text === "string" ? text.split("\n").filter(one => one.trim() !== "") : [];
142
- }
143
- if (kind === "command_execution")
144
- return ["→ running a command"];
145
- if (kind === "file_change")
146
- return ["→ editing files"];
147
- if (kind === "web_search")
148
- return ["→ looking something up"];
149
- return [];
150
- }
151
- // Gemini-shaped streams (peek): whole assistant messages, never deltas.
152
- if (type === "init")
153
- return ["the agent session started"];
154
- if (type === "message" && String(event["role"] ?? "") === "assistant" && event["delta"] !== true) {
155
- const content = event["content"];
156
- return typeof content === "string" ? content.split("\n").filter(one => one.trim() !== "") : [];
157
- }
158
- if (type === "tool_call" || type === "tool_use") {
159
- const name = event["name"] ?? event["tool"]?.["name"];
160
- return [KIND_HEADLINES[toolKind(name)]];
161
- }
162
- if (type !== "assistant")
163
- return [];
164
- // Top-level only: a subagent's words are not this run's transcript.
165
- const parent = event["parent_tool_use_id"];
166
- if (parent !== undefined && parent !== null)
167
- return [];
168
- const message = event["message"];
169
- const content = message?.["content"];
170
- if (!Array.isArray(content))
171
- return [];
172
- const lines = [];
173
- for (const block of content) {
174
- if (block === null || typeof block !== "object")
175
- continue;
176
- const kind = String(block["type"] ?? "");
177
- if (kind === "text") {
178
- const text = block["text"];
179
- if (typeof text === "string" && text.trim() !== "") {
180
- for (const one of text.split("\n")) {
181
- if (one.trim() !== "")
182
- lines.push(one);
183
- }
184
- }
185
- }
186
- else if (kind === "tool_use") {
187
- const name = block["name"];
188
- lines.push(KIND_HEADLINES[toolKind(name)]);
189
- }
190
- }
191
- return lines;
192
- }
193
117
  /** Bidirectional overrides and isolates (U+202A–U+202E, U+2066–U+2069): they can make shown text read differently from what was written. */
194
118
  const BIDI_CONTROLS = /[\u202a-\u202e\u2066-\u2069]/g;
195
119
  /** Sanitize one display line: strip controls and bidi overrides, redact whole on any secret hit, cap. */
@@ -200,13 +124,15 @@ export function sanitizeTranscriptLine(line) {
200
124
  return capBytes(stripped, LIVE_LINE_CAP);
201
125
  }
202
126
  /**
203
- * Open the run's live file for appending. The directory is 0700, the file
204
- * 0600 and CREATED HERE (O_EXCL: a name that already exists — whatever put
205
- * it there — is refused, not appended to), opened O_NOFOLLOW with the
206
- * descriptor retained for the run's whole duration. Returns null on any
207
- * failure: no live view is ever worth a build.
127
+ * Open the run's live file for appending records. The directory is 0700,
128
+ * the file 0600 and CREATED HERE (O_EXCL: a name that already exists —
129
+ * whatever put it there — is refused, not appended to), opened O_NOFOLLOW
130
+ * with the descriptor retained for the run's whole duration. `options` is
131
+ * the run's trusted context: its checkout, and whether its project shows
132
+ * commands (read once, here). Returns null on any failure: no live view is
133
+ * ever worth a build.
208
134
  */
209
- export function openLiveLog(root, runId) {
135
+ export function openLiveLog(root, runId, options = {}) {
210
136
  let fd;
211
137
  try {
212
138
  mkdirSync(liveDir(root), { recursive: true, mode: 0o700 });
@@ -215,19 +141,20 @@ export function openLiveLog(root, runId) {
215
141
  catch {
216
142
  return null;
217
143
  }
144
+ const recorder = createLiveRecorder({ base: options.base ?? null, commands: options.commands === true, ...(options.clock === undefined ? {} : { clock: options.clock }) });
218
145
  let written = 0;
219
146
  let capped = false;
220
147
  let closed = false;
221
- const append = (line) => {
148
+ const append = (record) => {
222
149
  if (closed || capped)
223
150
  return;
224
- const payload = Buffer.from(`${line}\n`, "utf8");
225
- // The marker is RESERVED inside the cap: whatever remains must still fit it.
226
- const markerBytes = Buffer.byteLength(`${LIVE_CAP_MARKER}\n`, "utf8");
227
- if (written + payload.length + markerBytes > LIVE_FILE_CAP) {
151
+ const payload = Buffer.from(`${serializeLiveRecord(record)}\n`, "utf8");
152
+ // The cap record is RESERVED inside the cap: whatever remains must still fit it.
153
+ const capLine = `${serializeLiveRecord({ k: "cap", at: record.at })}\n`;
154
+ if (written + payload.length + Buffer.byteLength(capLine, "utf8") > LIVE_FILE_CAP) {
228
155
  capped = true;
229
156
  try {
230
- writeSync(fd, `${LIVE_CAP_MARKER}\n`);
157
+ writeSync(fd, capLine);
231
158
  }
232
159
  catch {
233
160
  // Nothing to do: the file simply ends early.
@@ -248,20 +175,27 @@ export function openLiveLog(root, runId) {
248
175
  }
249
176
  }
250
177
  };
178
+ append({ k: "open", at: (options.clock ?? (() => new Date()))().toISOString(), detail: options.commands === true ? "commands" : "base" });
251
179
  return {
252
180
  observe(event) {
253
181
  try {
254
- for (const line of renderTranscriptLines(event)) {
255
- append(sanitizeTranscriptLine(line));
256
- }
182
+ for (const record of recorder.observe(event))
183
+ append(record);
257
184
  }
258
185
  catch {
259
- // Rendering is observational; a malformed event renders nothing.
186
+ // Recording is observational; a malformed event records nothing.
260
187
  }
261
188
  },
262
189
  close() {
263
190
  if (closed)
264
191
  return;
192
+ try {
193
+ for (const record of recorder.end())
194
+ append(record);
195
+ }
196
+ catch {
197
+ // the steps simply stay open in the file
198
+ }
265
199
  closed = true;
266
200
  try {
267
201
  closeSync(fd);
@@ -274,28 +208,64 @@ export function openLiveLog(root, runId) {
274
208
  }
275
209
  const READ_WINDOW = 64 * 1024;
276
210
  const READ_LOOKAHEAD = 2 * 1024;
211
+ /** How far past an over-long line one read looks for its end; past it, the line is skipped up to there. */
212
+ const SKIP_SCAN = 64 * 1024 * 1024;
213
+ /** The live directory itself: a real directory, never a link to one. */
214
+ function liveDirIsReal(root) {
215
+ try {
216
+ const stat = lstatSync(liveDir(root));
217
+ return stat.isDirectory() && !stat.isSymbolicLink();
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ }
223
+ function openForRead(path) {
224
+ try {
225
+ return { fd: openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) };
226
+ }
227
+ catch (error) {
228
+ return error.code === "ENOENT" ? { missing: true } : { unreadable: true };
229
+ }
230
+ }
231
+ /** The line that stands in for one too long to read, in the file's own format. */
232
+ function cutLine(format) {
233
+ return format === "records" ? `${serializeLiveRecord({ k: "cut", at: "" })}\n` : "[a line was too long to show]\n";
234
+ }
277
235
  /**
278
236
  * Read the live file from a byte offset, returning only COMPLETE lines
279
- * (through the last LF in the window). The descriptor is the authority:
280
- * opened O_NOFOLLOW, proved a regular file, measured and read through
281
- * itself. A file smaller than the offset was replaced — the caller resets,
282
- * visibly, never silently duplicating (arc 1 §4). `allowTail` (the final
283
- * drain) returns the unterminated tail after finalization.
237
+ * (through the last LF in the window). The records file is read when it
238
+ * exists, else the legacy text file. The descriptor is the authority:
239
+ * opened O_NOFOLLOW | O_NONBLOCK inside a real live directory, proved a
240
+ * regular file, measured and read through itself. A file smaller than the
241
+ * offset was replaced — the caller resets, visibly, and `gen` names the file
242
+ * so an offset is never reused against another one. A line longer than the
243
+ * whole window is skipped through its end, so a reader never stalls.
244
+ * `allowTail` (the final drain) returns the unterminated tail after
245
+ * finalization. Legacy text is cleaned again on the way out.
284
246
  */
285
247
  export function readLiveWindow(root, runId, from, allowTail = false) {
286
248
  if (!Number.isSafeInteger(from) || from < 0)
287
249
  return { ok: false, reason: "unreadable" };
288
- let fd;
289
- try {
290
- fd = openSync(liveLogPath(root, runId), constants.O_RDONLY | constants.O_NOFOLLOW);
291
- }
292
- catch (error) {
293
- return { ok: false, reason: error.code === "ENOENT" ? "missing" : "unreadable" };
250
+ if (!liveDirIsReal(root))
251
+ return { ok: false, reason: "missing" };
252
+ let format = "records";
253
+ let opened = openForRead(liveLogPath(root, runId));
254
+ if ("missing" in opened) {
255
+ format = "text";
256
+ opened = openForRead(legacyLiveLogPath(root, runId));
294
257
  }
258
+ if ("missing" in opened)
259
+ return { ok: false, reason: "missing" };
260
+ if ("unreadable" in opened)
261
+ return { ok: false, reason: "unreadable" };
262
+ const fd = opened.fd;
295
263
  try {
296
- const stat = fstatSync(fd);
264
+ const stat = fstatSync(fd, { bigint: true });
297
265
  if (!stat.isFile())
298
266
  return { ok: false, reason: "unreadable" };
267
+ // Linux hands a removed file's inode straight to the next one made, so the birth time (ns) tells them apart too.
268
+ const gen = `${format}:${stat.dev}:${stat.ino}:${stat.birthtimeNs}`;
299
269
  const size = Number(stat.size);
300
270
  if (size < from)
301
271
  return { ok: false, reason: "replaced" };
@@ -311,17 +281,44 @@ export function readLiveWindow(root, runId, from, allowTail = false) {
311
281
  const chunk = buffer.subarray(0, offset);
312
282
  const lastLf = chunk.lastIndexOf(0x0a);
313
283
  const eofReached = from + offset >= size;
284
+ const out = (text, next, eof) => ({
285
+ ok: true,
286
+ // Raw: every reader cleans and redacts through liveWindowLines / liveWindowRecords, carrying the key state.
287
+ text,
288
+ nextOffset: next,
289
+ eof,
290
+ format,
291
+ gen,
292
+ });
314
293
  if (lastLf === -1) {
315
- if (allowTail && eofReached && offset > 0) {
316
- return { ok: true, text: chunk.toString("utf8"), nextOffset: from + offset, eof: true };
294
+ if (allowTail && eofReached && offset > 0)
295
+ return out(chunk.toString("utf8"), from + offset, true);
296
+ // A whole window without an end of line: skip through its end (bounded), never stall on it.
297
+ if (offset === READ_WINDOW + READ_LOOKAHEAD) {
298
+ const scan = Buffer.alloc(64 * 1024);
299
+ const limit = Math.min(size, from + SKIP_SCAN);
300
+ let at = from + offset;
301
+ while (at < limit) {
302
+ const read = readSync(fd, scan, 0, Math.min(scan.length, limit - at), at);
303
+ if (read <= 0)
304
+ break;
305
+ const lf = scan.subarray(0, read).indexOf(0x0a);
306
+ if (lf !== -1)
307
+ return { ok: true, text: cutLine(format), nextOffset: at + lf + 1, eof: at + lf + 1 >= size, format, gen };
308
+ at += read;
309
+ }
310
+ // No end within reach. A finished run's drain, or a line longer than any read looks, is cut where the
311
+ // look stopped, so reading always moves on; a live run's line may still be being written, so it waits.
312
+ if (allowTail || at >= from + SKIP_SCAN)
313
+ return { ok: true, text: cutLine(format), nextOffset: at, eof: at >= size, format, gen };
314
+ return { ok: true, text: "", nextOffset: from, eof: false, format, gen };
317
315
  }
318
- return { ok: true, text: "", nextOffset: from, eof: eofReached && offset === 0 };
319
- }
320
- if (allowTail && eofReached && lastLf + 1 < offset) {
321
- return { ok: true, text: chunk.toString("utf8"), nextOffset: from + offset, eof: true };
316
+ return out("", from, eofReached && offset === 0);
322
317
  }
318
+ if (allowTail && eofReached && lastLf + 1 < offset)
319
+ return out(chunk.toString("utf8"), from + offset, true);
323
320
  const complete = chunk.subarray(0, lastLf + 1);
324
- return { ok: true, text: complete.toString("utf8"), nextOffset: from + lastLf + 1, eof: from + lastLf + 1 >= size };
321
+ return out(complete.toString("utf8"), from + lastLf + 1, from + lastLf + 1 >= size);
325
322
  }
326
323
  catch {
327
324
  return { ok: false, reason: "unreadable" };
@@ -330,6 +327,27 @@ export function readLiveWindow(root, runId, from, allowTail = false) {
330
327
  closeSync(fd);
331
328
  }
332
329
  }
330
+ /** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format. */
331
+ export function liveWindowLines(read, keys = { inKey: false }) {
332
+ if (read.format === "records")
333
+ return projectLiveText(parseLiveRecords(read.text, keys));
334
+ return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "");
335
+ }
336
+ /** A window as records, whatever its format: an older plain file's lines become the agent's words. */
337
+ export function liveWindowRecords(read, keys = { inKey: false }) {
338
+ if (read.format === "records")
339
+ return parseLiveRecords(read.text, keys);
340
+ return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "").map(line => ({ k: "say", at: "", t: [line] }));
341
+ }
342
+ /** Legacy text lines, cleaned again on read: the file may have been altered after it was written. */
343
+ function cleanLegacy(text, keys) {
344
+ const lines = text.split("\n");
345
+ const ended = lines.length > 0 && lines[lines.length - 1] === "";
346
+ if (ended)
347
+ lines.pop();
348
+ const shown = redactLiveLines(lines, keys);
349
+ return ended ? `${shown.join("\n")}\n` : shown.join("\n");
350
+ }
333
351
  /**
334
352
  * Enumerate the live directory for the reconcile sweep. Pure listing — the
335
353
  * CALLER decides which runs are finalized and when; this module only
@@ -353,7 +371,7 @@ export function listLiveFiles(root) {
353
371
  catch {
354
372
  continue;
355
373
  }
356
- const match = /^(\d{1,15})\.log$/.exec(name);
374
+ const match = /^(\d{1,15})\.(?:jsonl|log)$/.exec(name);
357
375
  out.push({ runId: match === null ? null : Number(match[1]), fileName: name, mtimeMs });
358
376
  }
359
377
  return out;
@@ -51,7 +51,9 @@ export type OAuthSession = {
51
51
  name: string;
52
52
  role: "approver" | "viewer";
53
53
  csrf: string;
54
- /** Signed in with an identity provider: its name, and whether it checked the person in the last ten minutes. */
54
+ /** The account's credential generation this session signed in under: a code is never issued under another. */
55
+ generation: number;
56
+ /** Signed in with an identity provider: its name, and whether an explicit re-check made on request awaits one step-up. */
55
57
  sso: {
56
58
  label: string;
57
59
  fresh: boolean;
@@ -68,8 +70,9 @@ export type OAuthHttpOptions = {
68
70
  session: (request: IncomingMessage) => OAuthSession | null;
69
71
  /** A posted form came from a page of this console (its Origin, or Referer, names an allowed host). */
70
72
  sameSite: (request: IncomingMessage) => boolean;
71
- /** The step-up: the person's password, or (empty) a fresh identity-provider check. */
72
- confirm: (session: OAuthSession, typed: string) => boolean;
73
+ /** The step-up, proved at the moment of consent on this request's live session: the person's password, or (empty)
74
+ * an explicit identity-provider re-check, spent by this consent. */
75
+ confirm: (request: IncomingMessage, session: OAuthSession, typed: string) => boolean;
73
76
  /** The projects this person may choose: each a project the server knows that they can use now. */
74
77
  projectsFor: (account: string) => string[];
75
78
  /** The token exchange's budget by source (request-budget.ts): charged once per request, before its body is read or any code or refresh secret is spent. */
package/dist/mcp-oauth.js CHANGED
@@ -308,8 +308,8 @@ export function createOAuthHttp(options) {
308
308
  return backToClient(response, origin, one.redirectUri, one.state, { error: "access_denied", error_description: "the person declined" }, "Cancelled. Returning to your MCP client…");
309
309
  }
310
310
  const account = store.accountOf(session.name);
311
- if (account === null || account.revokedAt !== null)
312
- return problemPage(response, 403, "Sign in first", "Your account can't sign in. Nothing was allowed.");
311
+ if (account === null || account.revokedAt !== null || account.generation !== session.generation)
312
+ return problemPage(response, 403, "Sign in first", "Your access changed. Sign in again; nothing was allowed.");
313
313
  const access = form.get("access") === "act" ? "act" : "read";
314
314
  const chosen = [...new Set(form.getAll("project"))];
315
315
  const again = (status, problem) => page(response, status, consentHtml(session, id, one, problem, { access, projects: chosen }));
@@ -323,11 +323,15 @@ export function createOAuthHttp(options) {
323
323
  return again(403, "Choose from your own projects.");
324
324
  if (!adapterPolicy({ caller: "cookie", capability: account.role === "approver" ? "act" : "read", viewer: account.role === "viewer" }, "step-up", source => source === "none" && chosen.every(path => mine.includes(path))).ok)
325
325
  return again(403, "Choose from your own projects.");
326
- if (!options.confirm(session, form.get("password") ?? ""))
326
+ if (!options.confirm(request, session, form.get("password") ?? ""))
327
327
  return again(403, "Enter your Toolroll password to allow access.");
328
+ // Re-proved after the step-up: the code carries the generation the person consented under, never a newer one.
329
+ const current = store.accountOf(session.name);
330
+ if (current === null || current.revokedAt !== null || current.generation !== session.generation)
331
+ return problemPage(response, 403, "Sign in first", "Your access changed. Sign in again; nothing was allowed.");
328
332
  waiting.delete(id);
329
333
  const code = randomBytes(32).toString("base64url");
330
- store.saveOAuthCode(sha256(code), { client: client.id, account: session.name, generation: account.generation, access, projects: chosen, redirectUri: one.redirectUri, resource: one.resource,
334
+ store.saveOAuthCode(sha256(code), { client: client.id, account: session.name, generation: session.generation, access, projects: chosen, redirectUri: one.redirectUri, resource: one.resource,
331
335
  challenge: one.challenge, expiresAt: new Date(now.getTime() + OAUTH_TIMES.codeMs).toISOString() });
332
336
  return backToClient(response, origin, one.redirectUri, one.state, { code }, "Allowed. Returning to your MCP client…");
333
337
  }
@@ -138,11 +138,11 @@ function fileSources(repo, kind, home) {
138
138
  /** The plane's own sessions: lead threads that touched the project, and finished crew runs with their handoff and check output. */
139
139
  function planeSources(store, repo, evidenceRoot) {
140
140
  const out = [];
141
- const threads = store.handle.prepare(`SELECT c.thread AS thread, c.id AS conversation, MAX(m.id) AS last, MAX(m.created_at) AS at FROM team_conversation c JOIN lead_message m ON m.thread=c.thread
141
+ const threads = store.handle.prepare(`SELECT c.thread AS thread, c.id AS conversation, MAX(m.id) AS last, MAX(m.created_at) AS at FROM team_conversation c JOIN lead_message m ON m.thread=c.thread AND m.pending=0
142
142
  WHERE EXISTS (SELECT 1 FROM json_each(c.projects_json) WHERE value=?) GROUP BY c.thread`).all(repo);
143
143
  for (const row of threads) {
144
144
  const thread = Number(row['thread']);
145
- out.push({ id: `lead:${thread}:${Number(row['last'])}`, repo, kind: 'lead', source: `conversation:${String(row['conversation'])}`, at: String(row['at']), trace: () => redact(store.handle.prepare('SELECT role,text FROM lead_message WHERE thread=? ORDER BY id LIMIT 400').all(thread).map(m => `${m['role']}: ${clip(String(m['text']), 4000)}`).join('\n')) });
145
+ out.push({ id: `lead:${thread}:${Number(row['last'])}`, repo, kind: 'lead', source: `conversation:${String(row['conversation'])}`, at: String(row['at']), trace: () => redact(store.handle.prepare('SELECT role,text FROM lead_message WHERE thread=? AND pending=0 ORDER BY id LIMIT 400').all(thread).map(m => `${m['role']}: ${clip(String(m['text']), 4000)}`).join('\n')) });
146
146
  }
147
147
  const runs = store.handle.prepare(`SELECT r.id, r.role, r.finished_at, r.handoff, t.external_id FROM run r JOIN task_ref t ON t.id=r.task_ref WHERE t.repo=? AND r.role IN ('builder','repair') AND r.outcome IS NOT NULL ORDER BY r.id DESC LIMIT 100`).all(repo);
148
148
  for (const row of runs) {
@@ -1,3 +1,7 @@
1
1
  /** Presentation only: no fields are read, no requests are sent, and no
2
- * keyboard events are captured. A focused input alone is NOT a keyboard. */
2
+ * keyboard events are captured. A focused input alone is NOT a keyboard.
3
+ *
4
+ * It publishes the visible area (--viewport-height, --viewport-top): a phone's
5
+ * keyboard shrinks only the visual viewport on iOS (100dvh never moves), so a
6
+ * full-height app shell sized to it is what keeps the composer above the keys. */
3
7
  export declare const MOBILE_VIEWPORT_SCRIPT: string;
@@ -1,10 +1,14 @@
1
1
  /** Presentation only: no fields are read, no requests are sent, and no
2
- * keyboard events are captured. A focused input alone is NOT a keyboard. */
2
+ * keyboard events are captured. A focused input alone is NOT a keyboard.
3
+ *
4
+ * It publishes the visible area (--viewport-height, --viewport-top): a phone's
5
+ * keyboard shrinks only the visual viewport on iOS (100dvh never moves), so a
6
+ * full-height app shell sized to it is what keeps the composer above the keys. */
3
7
  export const MOBILE_VIEWPORT_SCRIPT = String.raw `
4
8
  (function(){
5
9
  var root=document.documentElement,viewport=window.visualViewport;
6
10
  if(!viewport)return;
7
- var phone=window.matchMedia('(max-width: 760px)'),baseline=window.innerHeight,frame=0;
11
+ var phone=window.matchMedia('(max-width: 900px)'),baseline=window.innerHeight,frame=0;
8
12
  function update(){
9
13
  frame=0;
10
14
  var active=document.activeElement;
@@ -18,8 +22,20 @@ export const MOBILE_VIEWPORT_SCRIPT = String.raw `
18
22
  var opening=open&&!root.hasAttribute('data-mobile-keyboard');
19
23
  var atEnd=opening&&window.innerHeight+window.scrollY>=root.scrollHeight-120;
20
24
  root.toggleAttribute('data-mobile-keyboard',Boolean(open));
25
+ // A pinch zoom shrinks the visual viewport too, and then a keyboard can't be told from the zoom: zoomed, the shell
26
+ // is the whole screen (the reader pans to the field), so a keyboard closed while zoomed never leaves it short.
27
+ if(Math.abs(viewport.scale-1)<0.01){
28
+ root.style.setProperty('--viewport-height',Math.round(viewport.height)+'px');
29
+ root.style.setProperty('--viewport-top',Math.max(0,Math.round(viewport.offsetTop))+'px');
30
+ }else{
31
+ root.style.removeProperty('--viewport-height');
32
+ root.style.removeProperty('--viewport-top');
33
+ }
21
34
  root.style.setProperty('--keyboard-inset',open?Math.round(gap)+'px':'0px');
22
35
  if(atEnd)window.scrollTo({top:root.scrollHeight,behavior:'instant'});
36
+ // In the app shell the field sits in its own scrolling panel, which the shell's new size just cut short:
37
+ // bring it back into that panel's view once the smaller layout is in.
38
+ if(opening&&active.closest('.so-workspace'))requestAnimationFrame(function(){active.scrollIntoView({block:'nearest',behavior:'instant'});});
23
39
  }
24
40
  function schedule(){if(!frame)frame=requestAnimationFrame(update);}
25
41
  viewport.addEventListener('resize',schedule);
@@ -6,6 +6,7 @@
6
6
  * installed CLIs are current. Everything lands in orders.db so pickers,
7
7
  * spend pins and "new model" notices read one saved snapshot.
8
8
  */
9
+ import { isAcpProvider } from "./acp-agents.js";
9
10
  import { execFile } from "node:child_process";
10
11
  import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "node:fs";
11
12
  import { homedir } from "node:os";
@@ -197,6 +198,9 @@ export function modelOptions(store, provider, now, home = homedir()) {
197
198
  }
198
199
  if (provider === "gemini")
199
200
  return seenModels(store, "gemini").slice(0, 20).map(option);
201
+ // An ACP agent offers only the models its check recorded, on the runner that checked it — read there, never guessed.
202
+ if (isAcpProvider(provider))
203
+ return [];
200
204
  return seenModels(store, "openrouter").filter(model => model.tools).sort((a, b) => a.name.localeCompare(b.name)).map(option);
201
205
  }
202
206
  /** Chat reserves worst-case spend: price per token in whole micro-dollars,