toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -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 +117 -2
  16. package/dist/agent-fence.js +439 -12
  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 +39 -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/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
package/dist/telegram.js CHANGED
@@ -46,6 +46,10 @@ import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
46
46
  import { envValue } from "./names.js";
47
47
  import { applyProposalTap, processTelegramConversations, phoneLinkButton, replyContextFor, telegramConversationRepos, telegramRequestId, tooLongText, whichTaskText, } from "./telegram-lead.js";
48
48
  import { askOf, parseTelegramUpdate, pickOf, readTelegramButtonData, readTelegramUpdate, telegramButton } from "./contracts/telegram-callback.js";
49
+ /** Read the enrolled project list on demand. No callback means no task data,
50
+ * never an implicit all-database ceiling. Shared by pass and embedded follower. */
51
+ /** A delivery its person began watching after it was claimed: held or settled at the final fence (cockpit §3). */
52
+ const WATCHED_SINCE_CLAIM = "Held: its person is watching this agent now";
49
53
  /** The environment name — and therefore the name the builder strips from agents (both names; see names.ts). */
50
54
  export { TELEGRAM_TOKEN_ENV as TOKEN_ENV, TELEGRAM_TOKEN_ENVS as TOKEN_ENVS } from "./names.js";
51
55
  /** BotFather's shape: numeric bot id, colon, secret. */
@@ -732,6 +736,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
732
736
  const blocked = [];
733
737
  const preflight = (await readAccess()) ?? channelProblem();
734
738
  for (const row of group) {
739
+ // Watched since the claim (cockpit §3): held for later or settled quietly, never sent now, never a failure.
740
+ if (preflight === null && store.telegramWatchFence(row, binding, owner, clock()) !== "send")
741
+ continue;
735
742
  const problem = preflight ?? store.telegramDeliveryProblem(row, binding, owner, projects, clock(), [...rows.map(one => one.id), row.id]);
736
743
  if (problem === null)
737
744
  rows.push(row);
@@ -747,7 +754,18 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
747
754
  if (rows.length === 0)
748
755
  continue;
749
756
  const ids = rows.map(row => row.id);
750
- const fence = () => {
757
+ // Once any part of this delivery has reached Telegram it is committed: a watch opening between its parts never
758
+ // releases it (that would send the parts already accepted again). Every transport call below goes through `wire`.
759
+ let sentAny = false;
760
+ const wire = async (...args) => {
761
+ const answer = await transport(...args);
762
+ if (answer.ok === true)
763
+ sentAny = true;
764
+ return answer;
765
+ };
766
+ // `beforeSend`: the fence before a transport call also asks the watch — until something of this delivery went out;
767
+ // the one after a confirmed send never does, so a message Telegram accepted is recorded as delivered.
768
+ const fence = (beforeSend = !sentAny) => {
751
769
  const channel = channelProblem();
752
770
  if (channel !== null)
753
771
  return channel;
@@ -755,6 +773,10 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
755
773
  const problem = store.telegramDeliveryProblem(row, binding, owner, projects, clock(), ids);
756
774
  if (problem !== null)
757
775
  return problem;
776
+ // The watch, at the last moment before the network: a held row's claim is released (its finalize is then a
777
+ // no-op), so nothing is spent on it.
778
+ if (beforeSend && store.telegramWatchFence(row, binding, owner, clock()) !== "send")
779
+ return WATCHED_SINCE_CLAIM;
758
780
  }
759
781
  return null;
760
782
  };
@@ -763,7 +785,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
763
785
  const problem = (await readAccess()) ?? fence();
764
786
  if (problem !== null)
765
787
  return { ok: false, error: problem };
766
- const sent = await send(transport, binding.chatId, text, keyboard, entities, silent);
788
+ const sent = await send(wire, binding.chatId, text, keyboard, entities, silent);
767
789
  if (!sent.ok) {
768
790
  if (sent.retryAfter !== undefined)
769
791
  store.deferTelegram(botId, new Date(clock().getTime() + sent.retryAfter * 1_000).toISOString());
@@ -774,7 +796,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
774
796
  for (const row of messageRows)
775
797
  store.recordTelegramMessage(row, binding, sent.messageId, clock());
776
798
  // Keep the old message's history but never acknowledge a replaced pairing.
777
- const after = (await readAccess()) ?? fence();
799
+ const after = (await readAccess()) ?? fence(false);
778
800
  return after === null ? sent : { ok: false, error: after };
779
801
  };
780
802
  const imageSender = async (row) => {
@@ -803,7 +825,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
803
825
  // No await between the access fence, file validation and upload.
804
826
  let answer;
805
827
  try {
806
- answer = await transport("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
828
+ answer = await wire("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
807
829
  field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, artifact, verified.format),
808
830
  });
809
831
  }
@@ -819,7 +841,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
819
841
  return { ok: false, error: answer.uncertain || (answer.ok && messageId == null) ? "Screenshot delivery is unconfirmed; retry may duplicate it" : "Telegram did not accept the screenshot" };
820
842
  }
821
843
  store.recordTelegramMessage(row, binding, String(messageId), clock());
822
- const after = (await readAccess()) ?? fence();
844
+ const after = (await readAccess()) ?? fence(false);
823
845
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
824
846
  };
825
847
  /** Screenshots with a result (result-shots.ts): photos, several as one album, re-proved before every call. Each is
@@ -846,10 +868,10 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
846
868
  let answer;
847
869
  try {
848
870
  answer = batch.length > 1
849
- ? await transport("sendMediaGroup", { chat_id: binding.chatId, media: batch.map((one, index) => ({ type: "photo", media: `attach://shot${index}`, ...(index === 0 || one.numbered === true ? { caption: one.caption } : {}) })) }, undefined, upload(batch[0], "shot0"), batch.slice(1).map((one, index) => upload(one, `shot${index + 1}`)))
871
+ ? await wire("sendMediaGroup", { chat_id: binding.chatId, media: batch.map((one, index) => ({ type: "photo", media: `attach://shot${index}`, ...(index === 0 || one.numbered === true ? { caption: one.caption } : {}) })) }, undefined, upload(batch[0], "shot0"), batch.slice(1).map((one, index) => upload(one, `shot${index + 1}`)))
850
872
  : batch[0].photo
851
- ? await transport("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
852
- : await transport("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
873
+ ? await wire("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
874
+ : await wire("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
853
875
  }
854
876
  catch {
855
877
  answer = { ok: false, uncertain: true };
@@ -893,7 +915,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
893
915
  const keyboard = button === null ? [] : [button];
894
916
  if (messageId === null)
895
917
  return sender(card.text, keyboard, rows, card.entities);
896
- const edited = await editProgress(transport, binding.chatId, messageId, card.text, keyboard, card.entities);
918
+ const edited = await editProgress(wire, binding.chatId, messageId, card.text, keyboard, card.entities);
897
919
  if (!edited.ok) {
898
920
  if (edited.retryAfter !== undefined)
899
921
  store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
@@ -905,7 +927,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
905
927
  }
906
928
  if (!onlyExisting)
907
929
  store.recordTelegramMessage(row, binding, messageId, clock());
908
- const after = (await readAccess()) ?? fence();
930
+ const after = (await readAccess()) ?? fence(false);
909
931
  return after === null ? edited : { ok: false, error: after };
910
932
  };
911
933
  /** Quiet mode: the task's one card (or its group's), created silently and then only ever edited. */
@@ -929,11 +951,11 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
929
951
  return { ok: true, messageId: card.message };
930
952
  }
931
953
  if (card.message !== null) {
932
- const edited = await editProgress(transport, binding.chatId, card.message, view.text, keyboard, view.entities);
954
+ const edited = await editProgress(wire, binding.chatId, card.message, view.text, keyboard, view.entities);
933
955
  if (edited.ok) {
934
956
  store.setChatCardMessage(card.id, card.message, shown);
935
957
  store.recordTelegramMessage(fact, binding, card.message, clock());
936
- const after = (await readAccess()) ?? fence();
958
+ const after = (await readAccess()) ?? fence(false);
937
959
  return after === null ? edited : { ok: false, error: after };
938
960
  }
939
961
  if (edited.retryAfter !== undefined)
@@ -1003,14 +1025,14 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1003
1025
  if (problem !== null)
1004
1026
  return { ok: false, error: problem };
1005
1027
  mintFor();
1006
- const edited = await editProgress(transport, binding.chatId, batch.message, text, keyboard);
1028
+ const edited = await editProgress(wire, binding.chatId, batch.message, text, keyboard);
1007
1029
  if (edited.ok) {
1008
1030
  // The repaint landed: the old words' buttons retire and the new ones ride the message.
1009
1031
  retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
1010
1032
  placeDecideTokens(store, tokens, batch.message);
1011
1033
  store.setChatBatchMessage(batch.id, batch.message, shown);
1012
1034
  store.recordTelegramMessage(fact, binding, batch.message, clock());
1013
- const after = (await readAccess()) ?? fence();
1035
+ const after = (await readAccess()) ?? fence(false);
1014
1036
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
1015
1037
  }
1016
1038
  // A failed edit: the buttons it carried never act, and the card's old ones keep working.
@@ -1049,10 +1071,10 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1049
1071
  const problem = (await readAccess()) ?? fence();
1050
1072
  if (problem !== null)
1051
1073
  return { ok: false, error: problem };
1052
- const edited = await editProgress(transport, binding.chatId, earlier, words, keyboard);
1074
+ const edited = await editProgress(wire, binding.chatId, earlier, words, keyboard);
1053
1075
  if (edited.ok) {
1054
1076
  store.recordTelegramMessage(fact, binding, earlier, clock());
1055
- const after = (await readAccess()) ?? fence();
1077
+ const after = (await readAccess()) ?? fence(false);
1056
1078
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, earlier) } : { ok: false, error: after };
1057
1079
  }
1058
1080
  if (edited.retryAfter !== undefined)
@@ -1082,7 +1104,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1082
1104
  ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock, taskId => chatTitle(store, taskId))
1083
1105
  : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
1084
1106
  }
1085
- const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
1107
+ const finalProblem = outcome.ok ? (await readAccess()) ?? fence(false) : null;
1086
1108
  const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
1087
1109
  // A skipped screenshot settles its row but nothing reached the phone.
1088
1110
  const skipped = settled.ok && settled.receipt !== null && settled.receipt.startsWith("skipped:");
@@ -1748,6 +1770,8 @@ function applyMessage(context, update, effects) {
1748
1770
  * message that carried several tasks (never guessed). Everything hostile is
1749
1771
  * silence, as for every other inbound shape.
1750
1772
  */
1773
+ /** At most this many of a chat's messages wait for the lead at once (the personal lead queue's bound). */
1774
+ export const TELEGRAM_MAX_WAITING = 10;
1751
1775
  function applyConversation(context, update, effects) {
1752
1776
  const { store, botId, transport, clock, report } = context;
1753
1777
  const message = update.message;
@@ -1812,6 +1836,12 @@ function applyConversation(context, update, effects) {
1812
1836
  context_ = focusContextFor(focused);
1813
1837
  }
1814
1838
  }
1839
+ // The lead queue's bound, per chat: ten messages wait at most; the next is answered at once instead of queued.
1840
+ if (store.telegramMessagesWaiting(binding.id) >= TELEGRAM_MAX_WAITING) {
1841
+ say(`${TELEGRAM_MAX_WAITING} messages are already waiting for the lead. Wait for a reply, then send this again.`);
1842
+ report.ignored++;
1843
+ return;
1844
+ }
1815
1845
  store.enqueueTelegramConversation({
1816
1846
  binding, updateId: update.update_id, messageId: String(message.message_id), replyTo,
1817
1847
  request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
@@ -2248,6 +2278,10 @@ function applyCallback(context, update, effects) {
2248
2278
  return;
2249
2279
  }
2250
2280
  const focused = store.chatFocus("telegram", binding.id);
2281
+ if (store.telegramMessagesWaiting(binding.id) >= TELEGRAM_MAX_WAITING) {
2282
+ ack(`${TELEGRAM_MAX_WAITING} messages are already waiting. Wait for a reply.`);
2283
+ return;
2284
+ }
2251
2285
  store.enqueueTelegramConversation({
2252
2286
  binding, updateId: update.update_id, messageId: String(message.message_id), replyTo: null,
2253
2287
  request: telegramRequestId(botId, binding.id, update.update_id), text: option,
@@ -0,0 +1,11 @@
1
+ import type { Database } from "./store.js";
2
+ import type { TerminalLedger } from "./live-terminal.js";
3
+ export declare function terminalCatalogPath(orders: Database): string | null;
4
+ /** The console's ledger: creates the catalog, drops rows of consoles that are gone, and writes its own. */
5
+ export declare function openTerminalLedger(orders: Database, alive?: (pid: number) => boolean): TerminalLedger | null;
6
+ export declare function installTerminalUpdateGate(orders: Database, id: string): void;
7
+ export declare function removeTerminalUpdateGate(orders: Database, id: string): void;
8
+ /** Terminals starting or running in a console that is still alive: active work an update waits for. */
9
+ export declare function activeTerminalUpdateWork(orders: Database, alive?: (pid: number) => boolean): {
10
+ terminals: number;
11
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Live terminals, written down for the updater (docs/plans/live-terminal.md, review round 2 finding 7). Terminals live
3
+ * in the console's memory, but an update is decided by another process, so each one is a row in a small catalog
4
+ * beside the database (`<db>.terminals.sqlite`), as the coding workspace keeps its own:
5
+ *
6
+ * - a start inserts its row before any work, and an update's gate (a trigger) refuses that insert while the update
7
+ * owns admission: no terminal starts during a drain;
8
+ * - a row is `starting` or `running` until the terminal's sweep ends, then deleted — or kept as `uncertain` when the
9
+ * sweep couldn't prove its processes gone, until the terminal is removed; the updater counts rows whose console is
10
+ * still alive as active work, so "update when idle" waits for running and unproved terminals;
11
+ * - rows of a console that is gone are stale (its programs went with it, or are swept at its next start).
12
+ */
13
+ import { chmodSync, lstatSync } from "node:fs";
14
+ import { processMayBeAlive } from "./process-liveness.js";
15
+ import { sqliteRuntime } from "./sqlite-runtime.js";
16
+ const gatePrefix = "so_terminal_update_";
17
+ const PAUSED = "Toolroll is updating. Running terminals keep going; new ones can start after the update.";
18
+ const SCHEMA = "CREATE TABLE IF NOT EXISTS live_terminal (id TEXT PRIMARY KEY, console_pid INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('starting','running','uncertain')), at TEXT NOT NULL)";
19
+ export function terminalCatalogPath(orders) {
20
+ const main = orders.prepare("PRAGMA database_list").all().find(row => row["name"] === "main")?.["file"];
21
+ return typeof main === "string" && main !== "" ? `${main}.terminals.sqlite` : null;
22
+ }
23
+ function exists(file) {
24
+ try {
25
+ return lstatSync(file).isFile();
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function open(file, readOnly) {
32
+ if (exists(file) && lstatSync(file).isSymbolicLink())
33
+ throw Error("The terminal catalog is linked, not a regular database.");
34
+ const db = new (sqliteRuntime().DatabaseSync)(file, { readOnly });
35
+ db.exec("PRAGMA busy_timeout=1000");
36
+ return db;
37
+ }
38
+ /** The updater's read: never creates the catalog. */
39
+ function withCatalog(orders, absent, readOnly, action) {
40
+ const file = terminalCatalogPath(orders);
41
+ if (file === null || !exists(file))
42
+ return absent;
43
+ const db = open(file, readOnly);
44
+ try {
45
+ return action(db);
46
+ }
47
+ finally {
48
+ db.close();
49
+ }
50
+ }
51
+ /** The console's ledger: creates the catalog, drops rows of consoles that are gone, and writes its own. */
52
+ export function openTerminalLedger(orders, alive = pid => processMayBeAlive(pid, false)) {
53
+ const file = terminalCatalogPath(orders);
54
+ if (file === null)
55
+ return null;
56
+ const db = open(file, false);
57
+ chmodSync(file, 0o600);
58
+ db.exec(SCHEMA);
59
+ for (const row of db.prepare("SELECT DISTINCT console_pid FROM live_terminal").all()) {
60
+ const pid = Number(row["console_pid"]);
61
+ if (pid !== process.pid && !alive(pid))
62
+ db.prepare("DELETE FROM live_terminal WHERE console_pid=?").run(pid);
63
+ }
64
+ return {
65
+ reserve(id) {
66
+ // Fail closed even where this catalog had no gate to install (it didn't exist yet when the update began): the main
67
+ // database's own admission pause refuses too.
68
+ if (orders.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name GLOB 'so_desktop_update_*' LIMIT 1").get() !== undefined)
69
+ throw new Error(PAUSED);
70
+ try {
71
+ db.prepare("INSERT INTO live_terminal (id, console_pid, state, at) VALUES (?, ?, 'starting', ?)").run(id, process.pid, new Date().toISOString());
72
+ }
73
+ catch (error) {
74
+ throw new Error(error instanceof Error && error.message.includes(PAUSED) ? PAUSED : "No terminal can start right now (its record couldn't be written).");
75
+ }
76
+ },
77
+ running(id) { db.prepare("UPDATE live_terminal SET state='running', at=? WHERE id=?").run(new Date().toISOString(), id); },
78
+ // A sweep that couldn't prove its processes gone stays written down: the updater counts it while this console lives.
79
+ release(id, cleanup) {
80
+ try {
81
+ if (cleanup === "uncertain")
82
+ db.prepare("UPDATE live_terminal SET state='uncertain', at=? WHERE id=?").run(new Date().toISOString(), id);
83
+ else
84
+ db.prepare("DELETE FROM live_terminal WHERE id=?").run(id);
85
+ }
86
+ catch { /* the next start drops it as stale */ }
87
+ },
88
+ forget(id) { try {
89
+ db.prepare("DELETE FROM live_terminal WHERE id=?").run(id);
90
+ }
91
+ catch { /* stale later */ } },
92
+ };
93
+ }
94
+ function gateStatement(id) {
95
+ if (!/^[a-f0-9-]{36}$/.test(id))
96
+ throw Error("Invalid update identity.");
97
+ return `CREATE TRIGGER ${gatePrefix}start BEFORE INSERT ON live_terminal BEGIN SELECT RAISE(ABORT, '${PAUSED} [${id}]'); END`;
98
+ }
99
+ function changeGate(db, id, install) {
100
+ const wanted = gateStatement(id);
101
+ db.exec("BEGIN IMMEDIATE");
102
+ try {
103
+ db.exec(SCHEMA);
104
+ const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name GLOB 'so_terminal_update_*'").all();
105
+ if (rows.some(row => String(row["sql"]) !== wanted))
106
+ throw Error("Another or unrecognized update owns terminal admission. Its gate was preserved.");
107
+ if (install && rows.length === 0)
108
+ db.exec(wanted);
109
+ if (!install)
110
+ for (const row of rows)
111
+ db.exec(`DROP TRIGGER "${String(row["name"])}"`);
112
+ db.exec("COMMIT");
113
+ }
114
+ catch (error) {
115
+ db.exec("ROLLBACK");
116
+ throw error;
117
+ }
118
+ }
119
+ export function installTerminalUpdateGate(orders, id) { withCatalog(orders, undefined, false, db => changeGate(db, id, true)); }
120
+ export function removeTerminalUpdateGate(orders, id) { withCatalog(orders, undefined, false, db => changeGate(db, id, false)); }
121
+ /** Terminals starting or running in a console that is still alive: active work an update waits for. */
122
+ export function activeTerminalUpdateWork(orders, alive = pid => processMayBeAlive(pid, false)) {
123
+ return withCatalog(orders, { terminals: 0 }, true, db => {
124
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='live_terminal'").get())
125
+ return { terminals: 0 };
126
+ const rows = db.prepare("SELECT console_pid FROM live_terminal").all();
127
+ return { terminals: rows.filter(row => alive(Number(row["console_pid"]))).length };
128
+ });
129
+ }
@@ -68,6 +68,8 @@ export declare const TEXT_LIMITS: {
68
68
  readonly triggerState: 40;
69
69
  readonly triggerWebhookTitle: 120;
70
70
  readonly triggerWebhookField: 80;
71
+ /** v126: a Sentry or Vercel project (or Vercel team) a signed webhook is scoped to. */
72
+ readonly triggerWebhookScope: 120;
71
73
  readonly triggerFolder: 100;
72
74
  readonly triggerSender: 300;
73
75
  readonly triggerSubject: 100;
@@ -68,6 +68,8 @@ export const TEXT_LIMITS = {
68
68
  triggerState: 40,
69
69
  triggerWebhookTitle: 120,
70
70
  triggerWebhookField: 80,
71
+ /** v126: a Sentry or Vercel project (or Vercel team) a signed webhook is scoped to. */
72
+ triggerWebhookScope: 120,
71
73
  triggerFolder: 100,
72
74
  triggerSender: 300,
73
75
  triggerSubject: 100,
@@ -11,7 +11,13 @@ export type BrokerOptions = {
11
11
  omitEnv?: readonly string[];
12
12
  /** Toolroll's own settings for a local server (the run's isolated database), passed as they are. */
13
13
  pass?: Readonly<Record<string, string>>;
14
+ /** Where a local server starts: a folder the build can't write (never its checkout). */
14
15
  cwd?: string;
16
+ /** Folders the build writes (its checkout, its caches): a server whose command, or a path it's given, resolves inside
17
+ * one is never started, since it would run the build's own bytes outside the build's limits. */
18
+ untrusted?: readonly string[];
19
+ /** Why a local server may not start unwrapped (no fence for it here), read when it would; null when it may. */
20
+ unwrapped?: () => string | null;
15
21
  /** How a local server is started inside its run's fence, read when it starts (the fence is fixed after the tools). */
16
22
  wrap?: () => ((file: string, args: readonly string[]) => {
17
23
  file: string;
@@ -22,6 +28,8 @@ export type ToolBroker = {
22
28
  /** The launch's token: the agent's only key to its tools. */
23
29
  token: string;
24
30
  url: (name: string) => string;
31
+ /** Its loopback port: the one a confined network leaves reachable for its agent (agent-fence.ts NetworkFence). */
32
+ port: number;
25
33
  /** The local servers running now (the fence tests read them). */
26
34
  pids: () => number[];
27
35
  /** Revoke the token, stop every server, close the port. */
@@ -33,5 +41,11 @@ export declare const SERVER_ENV: readonly string[];
33
41
  /** A local server's environment: the allowlisted names from `base` (and its LC_* locale), less `omit`, then `pass`,
34
42
  * then the tool's own values. */
35
43
  export declare function serverEnv(base: NodeJS.ProcessEnv, omit: readonly string[], pass: Readonly<Record<string, string>>, values: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
44
+ /**
45
+ * The first of a server's command and path-like arguments that resolves inside an untrusted folder (a build's checkout
46
+ * or caches), or null. The command is resolved as spawn would: a path against `cwd`, a bare name through PATH; an
47
+ * argument counts when it names an existing path (relative to `cwd`) or is written as one (`./x`, `../x`, `/x`).
48
+ */
49
+ export declare function workspaceBytes(command: string, args: readonly string[], cwd: string | undefined, path: string | undefined, untrusted: readonly string[]): string | null;
36
50
  /** Start one launch's broker on a loopback port. */
37
51
  export declare function startToolBroker(tools: readonly BrokeredTool[], options?: BrokerOptions): Promise<ToolBroker>;
@@ -20,6 +20,8 @@
20
20
  import { spawn } from "node:child_process";
21
21
  import { randomBytes, timingSafeEqual } from "node:crypto";
22
22
  import { createServer } from "node:http";
23
+ import { accessSync, constants, existsSync, realpathSync } from "node:fs";
24
+ import { delimiter, isAbsolute, resolve, sep } from "node:path";
23
25
  const BODY_LIMIT = 4 * 1024 * 1024;
24
26
  const ANSWER_LIMIT = 32 * 1024 * 1024;
25
27
  const STOP_GRACE_MS = 2_000;
@@ -59,6 +61,54 @@ export function serverEnv(base, omit, pass, values) {
59
61
  delete env[name];
60
62
  return Object.assign(env, pass, values);
61
63
  }
64
+ /**
65
+ * The first of a server's command and path-like arguments that resolves inside an untrusted folder (a build's checkout
66
+ * or caches), or null. The command is resolved as spawn would: a path against `cwd`, a bare name through PATH; an
67
+ * argument counts when it names an existing path (relative to `cwd`) or is written as one (`./x`, `../x`, `/x`).
68
+ */
69
+ export function workspaceBytes(command, args, cwd, path, untrusted) {
70
+ if (untrusted.length === 0)
71
+ return null;
72
+ const base = cwd ?? process.cwd();
73
+ // Canonical on-disk names, folded where the filesystem ignores case (macOS, Windows), so `/Users/a/CHECKOUT` is the checkout.
74
+ const fold = process.platform === "darwin" || process.platform === "win32" ? (one) => one.toLowerCase() : (one) => one;
75
+ const real = (one) => { try {
76
+ return fold(realpathSync.native(one));
77
+ }
78
+ catch {
79
+ return fold(resolve(one));
80
+ } };
81
+ const roots = untrusted.map(real);
82
+ const within = (one) => { const at = real(one); return roots.some(root => at === root || at.startsWith(`${root}${sep}`)); };
83
+ // A root's name anywhere in an argument (inline code, a list of paths) counts, however it's spelled.
84
+ const mentions = (arg) => { const text = fold(arg); return roots.some(root => text.includes(root)) || untrusted.some(root => text.includes(fold(root))); };
85
+ const located = command.includes("/") || command.includes(sep) ? resolve(base, command)
86
+ : (path ?? "").split(delimiter).filter(Boolean).map(dir => resolve(base, dir, command)).find(one => { try {
87
+ accessSync(one, constants.X_OK);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ } }) ?? null;
93
+ if (located !== null && within(located))
94
+ return located;
95
+ // A relative path once meant "in the checkout" (servers started there): it is refused rather than read elsewhere.
96
+ if (!isAbsolute(command) && (command.includes("/") || command.includes(sep)))
97
+ return `${command} (a relative path)`;
98
+ for (const arg of args) {
99
+ if (mentions(arg))
100
+ return `${arg} (names what this build writes)`;
101
+ // A flag's value (`--config=x`, `-c=x`) is checked as its own argument.
102
+ for (const piece of [arg, ...(arg.startsWith("-") && arg.includes("=") ? [arg.slice(arg.indexOf("=") + 1)] : [])]) {
103
+ if (piece.startsWith("./") || piece.startsWith("../"))
104
+ return `${piece} (a relative path)`;
105
+ const candidate = resolve(base, piece);
106
+ if ((isAbsolute(piece) || existsSync(candidate)) && within(candidate))
107
+ return candidate;
108
+ }
109
+ }
110
+ return null;
111
+ }
62
112
  /** A local server, started on first use and spoken to over its stdin and stdout. */
63
113
  class LocalServer {
64
114
  tool;
@@ -68,6 +118,7 @@ class LocalServer {
68
118
  buffer = "";
69
119
  next = 1;
70
120
  pending = new Map();
121
+ refusal = null;
71
122
  constructor(tool, options) {
72
123
  this.tool = tool;
73
124
  this.options = options;
@@ -75,7 +126,19 @@ class LocalServer {
75
126
  pid() { return this.child?.pid ?? null; }
76
127
  start() {
77
128
  const env = serverEnv(this.options.env ?? process.env, this.options.omitEnv ?? [], this.options.pass ?? {}, this.tool.values);
129
+ const planted = workspaceBytes(this.tool.spec.command, this.tool.spec.args, this.options.cwd, env["PATH"], this.options.untrusted ?? []);
130
+ if (planted !== null) {
131
+ this.refusal = `The tool's server would run ${planted}, inside what this build can write, so it was not started.`;
132
+ return null;
133
+ }
78
134
  const wrap = this.options.wrap?.() ?? null;
135
+ if (wrap === null) {
136
+ const why = this.options.unwrapped?.() ?? null;
137
+ if (why !== null) {
138
+ this.refusal = why;
139
+ return null;
140
+ }
141
+ }
79
142
  const { file, args } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args] } : wrap(this.tool.spec.command, this.tool.spec.args);
80
143
  const child = spawn(file, args, { env, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
81
144
  this.child = child;
@@ -131,8 +194,9 @@ class LocalServer {
131
194
  // A new session (a reconnect) starts a fresh server: a server answers initialize once.
132
195
  if (message.method === "initialize" && this.initialized)
133
196
  this.stop();
134
- if (this.child === null)
135
- this.start();
197
+ if (this.child === null && this.start() === null) {
198
+ return Promise.resolve(isRequest(message) ? rpcError(message.id, this.refusal ?? "The tool could not start.") : null);
199
+ }
136
200
  if (message.method === "initialize")
137
201
  this.initialized = true;
138
202
  if (!isRequest(message)) {
@@ -357,6 +421,7 @@ export async function startToolBroker(tools, options = {}) {
357
421
  return {
358
422
  token,
359
423
  url: name => `http://127.0.0.1:${port}/mcp/${name}`,
424
+ port,
360
425
  pids: () => [...local.values()].map(one => one.pid()).filter((pid) => pid !== null),
361
426
  close: () => {
362
427
  revoked = true;
@@ -4,7 +4,7 @@
4
4
  * and a one-time "What's new" card afterwards. The work itself is the
5
5
  * `toolroll update` command (toolroll-update.ts), started as its own job.
6
6
  */
7
- import { html, postForm, replaceMarkup } from "./html.js";
7
+ import { html, postForm, replaceMarkup, reauth } from "./html.js";
8
8
  import { STEP_WORDS, UPDATE_STEPS, runtimeUpdateTerminal } from "./toolroll-update.js";
9
9
  const clock = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
10
10
  export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:anywhere}.updates .card{margin:0 0 16px}.updates h2{margin:0;font-size:1.0625rem}.updates .card>p{margin:4px 0 0}` +
@@ -69,7 +69,7 @@ export function updatesHtml(view, notice) {
69
69
  : latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
70
70
  : html `<h2>Toolroll ${view.current} is up to date</h2>`;
71
71
  const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
72
- ? postForm("/settings/updates", html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
72
+ ? postForm("/settings/updates", html `${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
73
73
  : "";
74
74
  const rollback = view.rollbackTo !== null ? html `<p class="meta">To go back to ${view.rollbackTo}: <code>toolroll update --rollback</code></p>` : "";
75
75
  return html `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
@@ -32,10 +32,11 @@ import { createRequire } from "node:module";
32
32
  import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
33
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
34
  import { processMayBeAlive } from "./process-liveness.js";
35
+ import { removeTerminalUpdateGate } from "./terminal-catalog.js";
35
36
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
37
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
38
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS } from "./store.js";
39
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS } from "./store.js";
39
40
  import { isNewer, REGISTRY } from "./releases.js";
40
41
  import { markNeverIndex } from "./never-index.js";
41
42
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -373,6 +374,8 @@ function addedColumns(...versions) {
373
374
  }
374
375
  return rules;
375
376
  }
377
+ /** Rules on tables that later gained declared columns (addedColumns merges into these instead of replacing them). */
378
+ const EARLIER_RULES = { flow_trigger: { fromRoutines: "trigger" } };
376
379
  const HISTORY_RULES = {
377
380
  ...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
378
381
  action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
@@ -401,7 +404,11 @@ const HISTORY_RULES = {
401
404
  // v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
402
405
  // v122: a project's recorded remote reads none the same way.
403
406
  // v123: a plan authorization's basis reads the operating mode, and a trigger firing reads not on time and no script ran.
404
- ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS),
407
+ // v126: a receipt's state, acceptance time and held item, a trigger's credential generation and turned-away count,
408
+ // and who made a card read none, 0, or none. A table with an earlier rule (flow_trigger's v115 move) keeps it too.
409
+ // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
410
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS))
411
+ .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
405
412
  };
406
413
  /** The tables a migration may remove whole (v115's removed features). */
407
414
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -908,8 +915,10 @@ function ungate(j) {
908
915
  db.exec("PRAGMA busy_timeout=5000");
909
916
  if (updateGateOwned(db, j.id))
910
917
  removeUpdateGate(db, j.id);
911
- else
918
+ else {
912
919
  removeCodingUpdateGate(db, j.id);
920
+ removeTerminalUpdateGate(db, j.id);
921
+ }
913
922
  }
914
923
  finally {
915
924
  db.close();
@@ -920,6 +929,7 @@ function ungateCoding(j) {
920
929
  try {
921
930
  db.exec("PRAGMA busy_timeout=5000");
922
931
  removeCodingUpdateGate(db, j.id);
932
+ removeTerminalUpdateGate(db, j.id);
923
933
  }
924
934
  finally {
925
935
  db.close();
package/dist/tools-ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Settings → Tools: one project's MCP servers, in plain words. Secrets are
2
2
  * entered here and nowhere else, and never shown again. */
3
- import { html, postForm } from "./html.js";
3
+ import { html, postForm, reauth } from "./html.js";
4
4
  import { brandMarkHtml, TILE_PHONE } from "./brand-mark.js";
5
5
  import { signedIn } from "./oauth-run-token.js";
6
6
  import { credentialModeOf, toolCommandLine, toolStanding } from "./project-tools.js";
@@ -15,7 +15,7 @@ export function toolsProjectPicker(projects, chosen, keep = {}) {
15
15
  }
16
16
  /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
17
17
  export const TOOLS_PROJECT_SCRIPT = `(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();`;
18
- const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
18
+ const password = html `${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}`;
19
19
  function toolCard(one, base, manage, project, research = null) {
20
20
  const { tool, secretsSet } = one;
21
21
  const standing = toolStanding(tool, secretsSet);
@@ -0,0 +1,2 @@
1
+ import type { Store } from "./store.js";
2
+ export declare function allowWhereUnfenced<T extends Store>(store: T): T;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tests that run real commands on a machine with no OS fence (CI's Ubuntu runners, where unprivileged namespaces are
3
+ * off) allow unconfined agents on their own store, as an instance operator there would; anywhere a fence exists,
4
+ * nothing changes and the fence is what the test runs under.
5
+ */
6
+ import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
7
+ export function allowWhereUnfenced(store) {
8
+ if (!macosFenceAvailable() && !linuxFenceAvailable())
9
+ store.setUnfencedAgentsAllowed(true, "test", new Date(0));
10
+ return store;
11
+ }