toolroll 0.9.65 → 0.9.68

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 (264) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +20 -2
  3. package/dist/acp-client.js +1 -3
  4. package/dist/agent-board.d.ts +0 -1
  5. package/dist/agent-board.js +0 -1
  6. package/dist/agent-fence.d.ts +69 -2
  7. package/dist/agent-fence.js +294 -11
  8. package/dist/agentconfig.js +4 -1
  9. package/dist/batch-checks.js +3 -2
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  11. package/dist/browser/terminal.js +134 -23
  12. package/dist/browser/workspace.css +2 -2
  13. package/dist/browser/workspace.js +31 -29
  14. package/dist/builder.d.ts +4 -0
  15. package/dist/builder.js +56 -37
  16. package/dist/canonical-json.d.ts +14 -0
  17. package/dist/canonical-json.js +36 -0
  18. package/dist/chat-actions.js +1 -2
  19. package/dist/chat-admission.d.ts +19 -0
  20. package/dist/chat-admission.js +22 -0
  21. package/dist/chat-channel.js +1 -1
  22. package/dist/chat-delivery.js +0 -1
  23. package/dist/chat-follow.d.ts +17 -0
  24. package/dist/chat-follow.js +32 -0
  25. package/dist/chat-messages.js +0 -1
  26. package/dist/chat-settings-page.d.ts +23 -0
  27. package/dist/chat-settings-page.js +41 -0
  28. package/dist/claim.js +3 -6
  29. package/dist/claude-print.d.ts +12 -0
  30. package/dist/claude-print.js +34 -0
  31. package/dist/cli-http.d.ts +0 -7
  32. package/dist/cli-http.js +0 -21
  33. package/dist/cli.d.ts +3 -10
  34. package/dist/cli.js +15 -24
  35. package/dist/coding-provider.js +2 -1
  36. package/dist/coding-workspace.js +1 -1
  37. package/dist/{team-cli.d.ts → connect-cli.d.ts} +5 -9
  38. package/dist/connect-cli.js +331 -0
  39. package/dist/containment.d.ts +0 -2
  40. package/dist/containment.js +2 -6
  41. package/dist/contracts/chat-actions.d.ts +0 -1
  42. package/dist/contracts/chat-content.d.ts +0 -4
  43. package/dist/contracts/chat-content.js +0 -2
  44. package/dist/contracts/chat-tables.d.ts +0 -1
  45. package/dist/contracts/cli.d.ts +0 -62
  46. package/dist/contracts/cli.js +0 -4
  47. package/dist/contracts/coding-workspace.js +1 -1
  48. package/dist/contracts/console-api.d.ts +1 -0
  49. package/dist/contracts/console-api.js +1 -0
  50. package/dist/contracts/contract.js +1 -1
  51. package/dist/contracts/flow-card.d.ts +0 -1
  52. package/dist/contracts/flow-propose.d.ts +0 -1
  53. package/dist/contracts/flow-send.js +1 -1
  54. package/dist/contracts/gateway-tools.d.ts +0 -1
  55. package/dist/contracts/integration-metadata.js +1 -1
  56. package/dist/contracts/lead-tools.d.ts +0 -1
  57. package/dist/contracts/memory-pass.d.ts +0 -1
  58. package/dist/contracts/memory-pass.js +1 -1
  59. package/dist/contracts/plan.js +1 -1
  60. package/dist/contracts/project-knowledge.js +1 -1
  61. package/dist/contracts/project-skills.d.ts +0 -1
  62. package/dist/contracts/proof.js +1 -1
  63. package/dist/contracts/recipes.js +1 -1
  64. package/dist/contracts/scope.js +1 -1
  65. package/dist/contracts/scout-report.js +1 -1
  66. package/dist/contracts/teams-callback.d.ts +0 -1
  67. package/dist/converse.d.ts +0 -6
  68. package/dist/converse.js +0 -19
  69. package/dist/daemon.js +3 -2
  70. package/dist/decision.d.ts +1 -3
  71. package/dist/deprecations.d.ts +12 -4
  72. package/dist/deprecations.js +27 -4
  73. package/dist/desktop-update-recovery.js +1 -1
  74. package/dist/desktop-update.js +3 -2
  75. package/dist/discord-chat.js +4 -15
  76. package/dist/discord-settings.d.ts +2 -6
  77. package/dist/discord-settings.js +10 -38
  78. package/dist/discord.js +12 -23
  79. package/dist/evidence.d.ts +0 -1
  80. package/dist/evidence.js +0 -3
  81. package/dist/exec.d.ts +0 -9
  82. package/dist/exec.js +3 -81
  83. package/dist/flow-actions.js +0 -3
  84. package/dist/flow-draft.d.ts +1 -3
  85. package/dist/flow-draft.js +15 -28
  86. package/dist/flow-scripts.d.ts +1 -8
  87. package/dist/flow-scripts.js +0 -13
  88. package/dist/flow-send.js +1 -1
  89. package/dist/flow-share.d.ts +0 -2
  90. package/dist/flow-share.js +1 -6
  91. package/dist/flow-steps.js +1 -1
  92. package/dist/flow-triggers.d.ts +0 -1
  93. package/dist/flow-triggers.js +1 -2
  94. package/dist/flow-unattended.js +1 -12
  95. package/dist/flows.js +1 -1
  96. package/dist/health-cli.d.ts +2 -2
  97. package/dist/health-cli.js +1 -1
  98. package/dist/invoke.d.ts +18 -3
  99. package/dist/invoke.js +142 -17
  100. package/dist/is-record.d.ts +2 -0
  101. package/dist/is-record.js +2 -0
  102. package/dist/lead-doors.d.ts +1 -5
  103. package/dist/lead-doors.js +0 -4
  104. package/dist/lead-tools.js +3 -6
  105. package/dist/live-detail.d.ts +0 -1
  106. package/dist/live-detail.js +0 -6
  107. package/dist/live-terminal.d.ts +24 -13
  108. package/dist/live-terminal.js +81 -43
  109. package/dist/live.d.ts +0 -1
  110. package/dist/live.js +1 -2
  111. package/dist/mcp-core.js +0 -2
  112. package/dist/memory-pass.js +1 -1
  113. package/dist/model-catalog.js +1 -1
  114. package/dist/modes.js +1 -12
  115. package/dist/operate.d.ts +2 -0
  116. package/dist/operate.js +436 -745
  117. package/dist/password-bearer.d.ts +0 -2
  118. package/dist/password-bearer.js +0 -4
  119. package/dist/peek-cli.js +2 -1
  120. package/dist/peek.d.ts +2 -26
  121. package/dist/peek.js +2 -168
  122. package/dist/phase-routing.d.ts +0 -4
  123. package/dist/phase-routing.js +2 -21
  124. package/dist/planner-source.d.ts +2 -1
  125. package/dist/planner-source.js +2 -12
  126. package/dist/planner.js +2 -14
  127. package/dist/process-tree.d.ts +0 -2
  128. package/dist/process-tree.js +0 -10
  129. package/dist/project-delete-ui.d.ts +6 -0
  130. package/dist/project-delete-ui.js +20 -1
  131. package/dist/project-delete.d.ts +0 -2
  132. package/dist/project-delete.js +1 -3
  133. package/dist/project-learning.d.ts +0 -14
  134. package/dist/project-skills.d.ts +0 -1
  135. package/dist/project-skills.js +0 -14
  136. package/dist/project-tools.js +2 -10
  137. package/dist/provider.d.ts +0 -17
  138. package/dist/provider.js +0 -26
  139. package/dist/pulls.d.ts +0 -2
  140. package/dist/pulls.js +0 -9
  141. package/dist/release-adapters.d.ts +9 -0
  142. package/dist/release-adapters.js +81 -4
  143. package/dist/release.d.ts +7 -1
  144. package/dist/release.js +20 -7
  145. package/dist/remote-exec.d.ts +3 -2
  146. package/dist/remote-exec.js +3 -2
  147. package/dist/repos.d.ts +0 -5
  148. package/dist/repos.js +2 -10
  149. package/dist/repository-context.d.ts +0 -2
  150. package/dist/repository-context.js +0 -4
  151. package/dist/restart-certification.d.ts +0 -2
  152. package/dist/restart-certification.js +1 -5
  153. package/dist/result-follow-ups.d.ts +1 -4
  154. package/dist/result-follow-ups.js +4 -8
  155. package/dist/result-review.d.ts +0 -2
  156. package/dist/result-review.js +0 -2
  157. package/dist/retention.d.ts +0 -1
  158. package/dist/retention.js +0 -1
  159. package/dist/review-context.d.ts +0 -1
  160. package/dist/review-context.js +1 -12
  161. package/dist/reviewer.d.ts +0 -16
  162. package/dist/reviewer.js +0 -18
  163. package/dist/run-closeout.js +2 -1
  164. package/dist/scope.d.ts +13 -0
  165. package/dist/scope.js +11 -18
  166. package/dist/scout-browser.js +2 -1
  167. package/dist/serve.js +3 -2
  168. package/dist/server/chrome.d.ts +10 -1
  169. package/dist/server/chrome.js +45 -4
  170. package/dist/server/console.css +60 -137
  171. package/dist/server/flows.js +2 -2
  172. package/dist/server/http.d.ts +2 -0
  173. package/dist/server/http.js +5 -0
  174. package/dist/server/pages.js +2 -2
  175. package/dist/server/people-tokens.js +3 -4
  176. package/dist/server/remote.js +1 -6
  177. package/dist/server/render-chat.d.ts +9 -2
  178. package/dist/server/render-chat.js +53 -7
  179. package/dist/server/render-pages.d.ts +28 -3
  180. package/dist/server/render-pages.js +78 -9
  181. package/dist/server/render-results.d.ts +0 -6
  182. package/dist/server/render-results.js +3 -9
  183. package/dist/server/render-tasks.js +6 -6
  184. package/dist/server/route-table.d.ts +0 -4
  185. package/dist/server/route-table.js +1 -6
  186. package/dist/server/settings.js +43 -22
  187. package/dist/server/task-views.js +1 -1
  188. package/dist/server/tasks.js +10 -7
  189. package/dist/setup-guide.d.ts +2 -2
  190. package/dist/skills.d.ts +0 -6
  191. package/dist/skills.js +0 -8
  192. package/dist/slack-chat.d.ts +1 -7
  193. package/dist/slack-chat.js +5 -18
  194. package/dist/slack-settings.d.ts +2 -6
  195. package/dist/slack-settings.js +10 -38
  196. package/dist/slack.js +12 -23
  197. package/dist/sleep.d.ts +2 -0
  198. package/dist/sleep.js +2 -0
  199. package/dist/store.d.ts +11 -74
  200. package/dist/store.js +36 -179
  201. package/dist/structured-output.d.ts +0 -1
  202. package/dist/structured-output.js +0 -1
  203. package/dist/subagent-work.d.ts +0 -2
  204. package/dist/subagent-work.js +0 -2
  205. package/dist/subagents.d.ts +1 -3
  206. package/dist/subagents.js +20 -33
  207. package/dist/subscription-chat.d.ts +1 -3
  208. package/dist/subscription-chat.js +2 -3
  209. package/dist/task-sizing.d.ts +1 -4
  210. package/dist/task-sizing.js +3 -10
  211. package/dist/teams-chat.js +4 -11
  212. package/dist/teams.js +12 -19
  213. package/dist/teardown-git.d.ts +22 -0
  214. package/dist/teardown-git.js +136 -2
  215. package/dist/telegram-lead.js +2 -2
  216. package/dist/telegram.js +1 -1
  217. package/dist/token-notices.d.ts +0 -11
  218. package/dist/token-notices.js +1 -5
  219. package/dist/tool-broker.d.ts +12 -0
  220. package/dist/tool-broker.js +66 -2
  221. package/dist/tool-secret-store.d.ts +0 -3
  222. package/dist/tool-secret-store.js +2 -11
  223. package/dist/toolroll-update.d.ts +0 -2
  224. package/dist/toolroll-update.js +2 -2
  225. package/dist/unfenced-test-support.d.ts +2 -0
  226. package/dist/unfenced-test-support.js +11 -0
  227. package/dist/verification-evidence.d.ts +0 -1
  228. package/dist/verification-evidence.js +0 -1
  229. package/dist/webhooks.d.ts +0 -1
  230. package/dist/webhooks.js +0 -1
  231. package/dist/workspace-ui.d.ts +0 -8
  232. package/dist/workspace-ui.js +1 -6
  233. package/dist/writes-mode.d.ts +9 -0
  234. package/dist/writes-mode.js +8 -0
  235. package/package.json +5 -1
  236. package/dist/contracts/native-census.d.ts +0 -42
  237. package/dist/contracts/native-census.js +0 -36
  238. package/dist/process-recovery-anchor.d.ts +0 -75
  239. package/dist/process-recovery-anchor.js +0 -188
  240. package/dist/process-recovery-coalition.d.ts +0 -25
  241. package/dist/process-recovery-coalition.js +0 -105
  242. package/dist/process-recovery-eligibility.d.ts +0 -179
  243. package/dist/process-recovery-eligibility.js +0 -174
  244. package/dist/process-recovery-native.d.ts +0 -161
  245. package/dist/process-recovery-native.js +0 -595
  246. package/dist/process-recovery-provenance.d.ts +0 -163
  247. package/dist/process-recovery-provenance.js +0 -292
  248. package/dist/process-recovery-services.d.ts +0 -56
  249. package/dist/process-recovery-services.js +0 -191
  250. package/dist/process-recovery-settlement.d.ts +0 -32
  251. package/dist/process-recovery-settlement.js +0 -133
  252. package/dist/process-recovery.d.ts +0 -41
  253. package/dist/process-recovery.js +0 -91
  254. package/dist/session-brief.d.ts +0 -8
  255. package/dist/session-brief.js +0 -24
  256. package/dist/session-cli.d.ts +0 -42
  257. package/dist/session-cli.js +0 -271
  258. package/dist/session-contract.d.ts +0 -183
  259. package/dist/session-contract.js +0 -122
  260. package/dist/session-remote.d.ts +0 -19
  261. package/dist/session-remote.js +0 -86
  262. package/dist/session-service.d.ts +0 -22
  263. package/dist/session-service.js +0 -157
  264. package/dist/team-cli.js +0 -692
@@ -32,7 +32,7 @@ import { budgetLabel, monthOf } from "../spend.js";
32
32
  import { PACKAGE_VERSION } from "../version.js";
33
33
  import { handlersOf } from './handler-registry.js';
34
34
  import { refuse, screen } from "./chrome.js";
35
- import { HANDOFF_STYLE, redirect, respond, safeReturn, signInSpent, startedHere } from "./http.js";
35
+ import { HANDOFF_STYLE, redirect, respond, safeReturn, sayTo, signInSpent, startedHere } from "./http.js";
36
36
  import { html, htmlString, joinHtml, postForm, reauth } from "../html.js";
37
37
  import { browserCaller } from "./session.js";
38
38
  /** settings handlers, moved without changing their route bodies. */
@@ -80,6 +80,7 @@ import { policyHtml } from "../policy-ui.js";
80
80
  import { checkPolicy, parseList, policyParts } from "../policy.js";
81
81
  import { parseProjectConcurrency, saveProjectConcurrency } from "../project-concurrency.js";
82
82
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
83
+ import { isWritesMode } from "../writes-mode.js";
83
84
  import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
84
85
  import { deleteProject, holdingsWords } from "../project-delete.js";
85
86
  import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
@@ -126,7 +127,7 @@ import { personChip, ROLE_TITLES } from "./chrome.js";
126
127
  import { chatReturnWithSaid, goOutside } from "./http.js";
127
128
  import { latestReleaseFor, leadSettingsHtml, notificationProjects, SETTINGS_AUTOSAVE_SCRIPT, settingsPage, settingsWorkers, telegramDeliveryWords, telegramTrouble } from "./render-settings.js";
128
129
  export function createSettingsHandlers(runtime) {
129
- const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, recordSignIn, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
130
+ const { store, providerHome, managedRepos, consoleProjects, sendScreen, chromeFor, connectionCheck, clock, modelCatalog, modelSeams, toolsViewOf, codexServers, projectViewOf, sessions, restricted, options, integrations, consoleOrigin, evidenceRoot, storagePool, visible, ssoSettings, checkLocalAgents, chatKeyFor, chatCatalog, leadWords, agentSignInCommand, settingsUpdates, phoneSetup, authenticateApprover, authenticateCredential, deletedRepos, projectOf, providerFor, admissionList, toolHome, connectVisits, mintApprovalNonce, consumeApprovalNonce, defaultProject, googleVisits } = runtime;
130
131
  // v105: what agent work cost, by month, and the monthly budgets. An instance operator's page.
131
132
  async function spendPage(ctx) {
132
133
  const { url, who, response, now } = ctx;
@@ -450,7 +451,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
450
451
  // v105: Settings → Data, where an instance operator downloads everything. The download is a POST with the password.
451
452
  async function dataPage(ctx) {
452
453
  const { url, response, project } = ctx;
453
- const who = browserCaller(ctx.who);
454
+ browserCaller(ctx.who);
454
455
  return sendScreen(response, 200, screen("Data", html `<p><a href="/settings">Settings</a></p><h1>Data</h1>${(dataExportHtml({ problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
455
456
  }
456
457
  // v100: Settings → Sign-in, the identity provider people sign in with. An instance operator's page.
@@ -485,7 +486,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
485
486
  const { response, now, posted } = ctx;
486
487
  const who = browserCaller(ctx.who);
487
488
  const body = readForm(posted, CONSOLE_FORMS.passwordless);
488
- const back = (key, words) => redirect(response, `/settings/passwordless?${key}=${encodeURIComponent(words)}`);
489
+ const back = sayTo(response, "/settings/passwordless");
489
490
  const action = body.get("action");
490
491
  if (action === "off") {
491
492
  setPasswordless(store, false, who.name, now);
@@ -811,7 +812,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
811
812
  async function spendBudget(ctx) {
812
813
  const { who, response, now, posted } = ctx;
813
814
  const body = readForm(posted, CONSOLE_FORMS.spendBudget);
814
- const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
815
+ const back = sayTo(response, "/spend");
815
816
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
816
817
  return back("problem", "Enter your Toolroll password to change a budget.");
817
818
  const target = /^(installation|project|person|subagent):(.+)$/.exec(body.get("target") ?? "");
@@ -847,7 +848,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
847
848
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
848
849
  if (who.via !== "cookie" || who.role !== "approver" || !store.accountCanAccess(who.name, repo))
849
850
  return refuse(response, who, 403, "An approver for this project sets how many tasks build at once.", "/settings/project");
850
- const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
851
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`);
851
852
  const n = parseProjectConcurrency(body.get("concurrency") ?? "");
852
853
  if (n === null)
853
854
  return back("problem", "Builds at once is a whole number from 1 to 64. Nothing changed.");
@@ -869,7 +870,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
869
870
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
870
871
  if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
871
872
  return refuse(response, who, 403, "An approver for this project changes the live view.", "/settings/project");
872
- const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
873
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`);
873
874
  const act = body.get("act");
874
875
  if (act === "off")
875
876
  return back("said", setLiveCommands(store, { repo, commands: false, by: who.name, via: "the console" }, now).said);
@@ -892,7 +893,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
892
893
  return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
893
894
  if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
894
895
  return refuse(response, who, 403, "An approver for this project changes what its builds may reach.", "/settings/project");
895
- const back = (key, words) => redirect(response, `/settings/project?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}#network`);
896
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`, "#network");
896
897
  const hosts = (body.get("hosts") ?? "").split(/[\s,]+/).filter(Boolean).map(normalizeHost);
897
898
  if (hosts.some(one => one === null))
898
899
  return back("problem", "A host is a name like api.stripe.com or *.stripe.com: no address, port or path. Nothing changed.");
@@ -941,7 +942,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
941
942
  async function policySave(ctx) {
942
943
  const { who, response, now, posted } = ctx;
943
944
  const body = readForm(posted, CONSOLE_FORMS.policy);
944
- const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
945
+ const back = sayTo(response, "/settings/policy");
945
946
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
946
947
  return back("problem", "That password didn't match. Nothing changed.");
947
948
  const chosen = body.getAll("provider");
@@ -961,7 +962,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
961
962
  const known = consoleProjects();
962
963
  if (!known.includes(repo))
963
964
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
964
- const back = (key, words) => redirect(response, `/settings/approval?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
965
+ const back = sayTo(response, `/settings/approval?repo=${encodeURIComponent(repo)}`);
965
966
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
966
967
  return back("problem", "That password didn't match. Nothing changed.");
967
968
  const protect = body.get("protect") ?? "none";
@@ -979,7 +980,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
979
980
  async function requestLimitsSave(ctx) {
980
981
  const { who, response, now, posted } = ctx;
981
982
  const body = readForm(posted, CONSOLE_FORMS.requestLimits);
982
- const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
983
+ const back = sayTo(response, "/settings/sessions");
983
984
  const target = body.get("target") ?? "";
984
985
  const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
985
986
  if (target !== "*" && token === null)
@@ -1003,7 +1004,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1003
1004
  const body = readForm(posted, CONSOLE_FORMS.signIn);
1004
1005
  if (!options.configDir)
1005
1006
  return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
1006
- const back = (key, words) => redirect(response, `/settings/sign-in?${key}=${encodeURIComponent(words)}`);
1007
+ const back = sayTo(response, "/settings/sign-in");
1007
1008
  const action = body.get("action") ?? "";
1008
1009
  const before = ssoSettings();
1009
1010
  if (action === "test") {
@@ -1045,7 +1046,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1045
1046
  if (databaseFile === null)
1046
1047
  return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
1047
1048
  const stateDir = dirname(databaseFile);
1048
- const back = (key, words) => redirect(response, `/settings/updates?${key}=${encodeURIComponent(words)}`);
1049
+ const back = sayTo(response, "/settings/updates");
1049
1050
  if (op === "seen") {
1050
1051
  markWhatsNewSeen(stateDir);
1051
1052
  return redirect(response, "/settings/updates");
@@ -1089,7 +1090,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1089
1090
  async function retentionSave(ctx) {
1090
1091
  const { who, response, now, posted } = ctx;
1091
1092
  const body = readForm(posted, CONSOLE_FORMS.retention);
1092
- const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
1093
+ const back = sayTo(response, "/settings/retention");
1093
1094
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1094
1095
  return back("problem", "Enter your Toolroll password to change retention.");
1095
1096
  const before = store.retentionPeriods();
@@ -1111,7 +1112,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1111
1112
  const databaseFile = store.databaseFile();
1112
1113
  if (databaseFile === null)
1113
1114
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
1114
- const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
1115
+ const back = sayTo(response, "/settings/storage");
1115
1116
  const what = op === "clean" ? "clean up" : op === "discard" ? "discard a checkout" : "change checkout cleanup";
1116
1117
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1117
1118
  return back("problem", `Enter your Toolroll password to ${what}.`);
@@ -1151,7 +1152,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1151
1152
  return refuse(response, who, 404, "No such project.", "/projects");
1152
1153
  if (who.role !== "approver" || store.isDemo())
1153
1154
  return refuse(response, who, 403, "An approver sets up pull requests.", "/projects");
1154
- const back = (key, words) => redirect(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
1155
+ const back = sayTo(response, `/settings/pull-requests?repo=${encodeURIComponent(repo)}`);
1155
1156
  const act = body.get("act");
1156
1157
  if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
1157
1158
  return back("problem", "Enter your Toolroll password to change pull requests.");
@@ -1192,7 +1193,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1192
1193
  return refuse(response, who, 404, "No such project.", "/projects");
1193
1194
  if (who.role !== "approver" || store.isDemo())
1194
1195
  return refuse(response, who, 403, "An approver sets a project's checks.", "/projects");
1195
- const back = (key, words) => redirect(response, `/settings/checks?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
1196
+ const back = sayTo(response, `/settings/checks?repo=${encodeURIComponent(repo)}`);
1196
1197
  if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
1197
1198
  return back("problem", "Enter your Toolroll password to change checks.");
1198
1199
  const act = body.get("act");
@@ -1255,7 +1256,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1255
1256
  const databaseFile = store.databaseFile();
1256
1257
  if (databaseFile === null)
1257
1258
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
1258
- const back = (key, words) => redirect(response, `/settings/backups?${key}=${encodeURIComponent(words)}`);
1259
+ const back = sayTo(response, "/settings/backups");
1259
1260
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1260
1261
  return back("problem", "Enter your Toolroll password to change backups.");
1261
1262
  const every = body.get("every") ?? "";
@@ -1295,7 +1296,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1295
1296
  const { who, response, posted } = ctx;
1296
1297
  const body = readForm(posted, CONSOLE_FORMS.flowsOn);
1297
1298
  const repo = body.get("repo") ?? "";
1298
- const back = (key, words) => redirect(response, `/settings/flows?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
1299
+ const back = sayTo(response, `/settings/flows?repo=${encodeURIComponent(repo)}`);
1299
1300
  const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
1300
1301
  if (store.isDemo())
1301
1302
  return refuse(response, who, 403, "An approver switches starter flows on.", "/settings/flows");
@@ -1314,7 +1315,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1314
1315
  if (!options.configDir)
1315
1316
  return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
1316
1317
  const key = body.get("key") ?? "";
1317
- const back = (name, words) => redirect(response, `/settings/integrations?${name}=${encodeURIComponent(words)}`);
1318
+ const back = sayTo(response, "/settings/integrations");
1318
1319
  const checked = (await integrations.check([key])).find(one => one.key === key);
1319
1320
  if (checked === undefined)
1320
1321
  return back("problem", "That integration isn't here any more.");
@@ -1327,7 +1328,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1327
1328
  const body = readForm(posted, CONSOLE_FORMS.monitoring);
1328
1329
  if (!options.configDir)
1329
1330
  return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
1330
- const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
1331
+ const back = sayTo(response, "/settings/monitoring");
1331
1332
  if (!authenticateApprover(who, body.get("password") ?? "").ok)
1332
1333
  return back("problem", "Enter your Toolroll password to change monitoring.");
1333
1334
  const before = readMonitoring(options.configDir);
@@ -1428,7 +1429,7 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1428
1429
  const repo = body.get("repo") ?? "";
1429
1430
  if (!visible(repo) || !consoleProjects().includes(repo))
1430
1431
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
1431
- const back = (key, words, anchor = "") => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}${anchor}`);
1432
+ const back = sayTo(response, `/settings/tools?repo=${encodeURIComponent(repo)}`);
1432
1433
  // As for Connect: a post from a page that showed another project is refused, never applied here.
1433
1434
  const shown = body.get("shown") ?? "";
1434
1435
  if (shown !== repo) {
@@ -2483,6 +2484,7 @@ ${modeWords(terms)
2483
2484
  "settings.project-concurrency": projectConcurrency,
2484
2485
  "settings.project-live-view": projectLiveView,
2485
2486
  "settings.project-network": projectNetwork,
2487
+ "settings.project-writes": projectWrites,
2486
2488
  "settings.project-delete": projectDelete,
2487
2489
  "settings.policy-send": policySave,
2488
2490
  "settings.approval-send": approvalSave,
@@ -2566,5 +2568,24 @@ ${modeWords(terms)
2566
2568
  "edge.google-callback": googleCallback,
2567
2569
  "edge.connect-callback": connectCallback,
2568
2570
  });
2571
+ // Settings → Project → Where builds write (writes-mode.ts). An approver for the project changes it with their password
2572
+ // typed now; the ledger keeps before → after. New scopes use it; approved ones keep theirs.
2573
+ async function projectWrites(ctx) {
2574
+ const { who, response, now, posted } = ctx;
2575
+ const body = readForm(posted, CONSOLE_FORMS.projectWrites);
2576
+ const repo = body.get("repo") ?? "";
2577
+ if (!consoleProjects().includes(repo) || !visible(repo))
2578
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
2579
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2580
+ return refuse(response, who, 403, "An approver for this project changes where its builds may write.", "/settings/project");
2581
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`, "#writes");
2582
+ const wanted = body.get("writes");
2583
+ if (!isWritesMode(wanted))
2584
+ return back("problem", "Choose where builds may write. Nothing changed.");
2585
+ if (!authenticateCredential(who, body.get("password") ?? "").ok)
2586
+ return back("problem", "Enter your Toolroll password to change where builds may write.");
2587
+ const changed = store.setProjectWrites(repo, wanted, who.name, now);
2588
+ return back("said", changed.before === changed.after ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2589
+ }
2569
2590
  return { registrations, modelsView };
2570
2591
  }
@@ -3,7 +3,7 @@ import { liveAttemptOf, steerControlsFor } from "../steer-controls.js";
3
3
  import { originOf } from "../task-source.js";
4
4
  import { taskBaseOf } from "../task-base.js";
5
5
  import { assignmentPresentationOf, historicalAssessmentReason } from '../assignment-presentation.js';
6
- import { html, htmlString, joinHtml } from "../html.js";
6
+ import { html, joinHtml } from "../html.js";
7
7
  import { assignmentStatusOf, assignmentWithEvidence } from '../assignment-ui.js';
8
8
  import { assignmentOf } from '../assignment.js';
9
9
  import { knowledgeContextHtml } from "../knowledge-ui.js";
@@ -11,7 +11,7 @@ import { handlersOf } from './handler-registry.js';
11
11
  import { closedQuestionWords } from "../decision.js";
12
12
  import { DEPRECATED_PAGE } from "../deprecations.js";
13
13
  /** tasks handlers, moved without changing their route bodies. */
14
- import { html, htmlString, joinHtml, postForm, textHtml } from "../html.js";
14
+ import { html, htmlString, joinHtml, textHtml } from "../html.js";
15
15
  import { createHash, randomBytes, randomUUID } from "node:crypto";
16
16
  import { lstatSync, realpathSync } from "node:fs";
17
17
  import {} from "node:http";
@@ -40,7 +40,7 @@ import { isTaskSize, PHASES as ROUTE_PHASES } from "../phase-routing.js";
40
40
  import { applyModeToNewFiling, authorizePlanUnderMode } from "../plan-auto.js";
41
41
  import { milestonesOf, parseExecutionPlanDocument } from "../plan.js";
42
42
  import { levelOfProfile } from "../policy.js";
43
- import { authorizedProject, canonicalProject, projectName, rowVisible } from "../project.js";
43
+ import { authorizedProject, canonicalProject, projectName, } from "../project.js";
44
44
  import { fileTaskProposal } from "../proposal.js";
45
45
  import { isProviderId, PROVIDER_IDS, validateSpec } from "../provider.js";
46
46
  import { completeAndOpenPullRequest, mergeAsPerson } from '../pull-request-flow.js';
@@ -61,15 +61,15 @@ import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-boar
61
61
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
62
62
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
63
63
  import { requestContext } from "./request-context.js";
64
- import { oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
64
+ import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
65
65
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
66
- import { boardBody, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
66
+ import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
67
67
  import { runEfficiencyOf } from "../run-efficiency.js";
68
68
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
69
69
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
70
70
  import {} from "./session.js";
71
71
  export function createTasksHandlers(runtime) {
72
- const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, taskRooms, identify, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
72
+ const { store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
73
73
  /** A row admitted the address but its path parameter does not parse: the console's unknown page. */
74
74
  function noPage(who, response) {
75
75
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
@@ -399,7 +399,10 @@ export function createTasksHandlers(runtime) {
399
399
  });
400
400
  });
401
401
  const laneCards = [...cards, ...unverifiedCards];
402
- const body = boardBody({ cards: laneCards, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
402
+ // The worker strip: the workers this board's work can go to — bound to
403
+ // this project, or (rolled up) to any project the ceiling admits.
404
+ const workers = boardWorkers(store.listRunners(), { all, project, visible });
405
+ const body = boardBody({ cards: laneCards, workers, done: verifiedDone, saturated: snapshot.saturated, now, all, project, delta }, pr => store.ciFailureObserved(pr));
403
406
  if (url.searchParams.get("fragment") === "1") {
404
407
  // The live region alone — the in-page swapper's diet. Same auth,
405
408
  // same ceiling, no shell, no scripts (finding 2).
@@ -423,7 +426,7 @@ export function createTasksHandlers(runtime) {
423
426
  const regionBody = html `<div id="board-region">${body}</div><p class="meta" id="board-region-stamp"></p>`;
424
427
  return sendScreen(response, 200, screen("board", regionBody, {
425
428
  chrome: chromeFor(project, "board", undefined, all ? "board-all" : undefined),
426
- functional: { script: regionScript("board-region", "1"), fetches: true },
429
+ functional: { script: regionScript("board-region", "1", undefined, { animate: true }) + boardTickScript(), fetches: true },
427
430
  }));
428
431
  }
429
432
  /** A completed (or attempted) row as the review list ranks it: its proof reasons, CI standing, and the assignment
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/skills.d.ts CHANGED
@@ -33,12 +33,6 @@ export declare const CONTEXT_END = "<!-- standing-orders:end -->";
33
33
  export declare function skillContent(): string;
34
34
  /** The AGENTS.md block, marker-fenced so reinstalls replace only themselves. */
35
35
  export declare function contextBlock(): string;
36
- /**
37
- * Whether a repository has the Toolroll skill: a SKILL.md in the current
38
- * folder, or in the one installs before the rename used. The setup guide
39
- * and the console's first-run checklist both ask this, so they agree.
40
- */
41
- export declare function projectSkillInstalled(repo: string): boolean;
42
36
  export type InstallPlan = {
43
37
  skillPath: string;
44
38
  skillAction: "create" | "replace" | "refuse-foreign";
package/dist/skills.js CHANGED
@@ -72,14 +72,6 @@ in the plane: read \`skills get knowledge\`, search it with
72
72
  merge anything yourself.
73
73
  ${CONTEXT_END}`;
74
74
  }
75
- /**
76
- * Whether a repository has the Toolroll skill: a SKILL.md in the current
77
- * folder, or in the one installs before the rename used. The setup guide
78
- * and the console's first-run checklist both ask this, so they agree.
79
- */
80
- export function projectSkillInstalled(repo) {
81
- return [SKILL_DIR, LEGACY_SKILL_DIR].some(dir => existsSync(join(repo, dir, SKILL_FILE)));
82
- }
83
75
  /** What install would do, computed without writing anything. */
84
76
  export function planInstall(repo, writeContext) {
85
77
  const skillPath = join(repo, SKILL_DIR, SKILL_FILE);
@@ -1,4 +1,4 @@
1
- import { ChatState, type ChatBinding, type ChatContent, type ChatEvent } from "./chat-delivery-state.js";
1
+ import { ChatState } from "./chat-delivery-state.js";
2
2
  import { uploadSlackBytes, type SlackApi, type SlackIdentity } from "./slack-api.js";
3
3
  import { type Store } from "./store.js";
4
4
  import type { SubscriptionLeadRunner } from "./subscription-chat.js";
@@ -19,12 +19,6 @@ export type SlackChatOptions = {
19
19
  /** Synchronous receipt before ACK. Never stores response_url, raw tokens, or a pairing code. */
20
20
  export declare function receiveSlack(state: ChatState, identity: SlackIdentity, type: string, raw: unknown, now: Date): boolean;
21
21
  export declare const processSlackEvent: (options: SlackChatOptions) => Promise<boolean>;
22
- export declare const applySlackAction: (options: SlackChatOptions, event: ChatEvent, binding: ChatBinding, repos: readonly string[]) => {
23
- merge: import("./chat-decide.js").ChatMerge;
24
- part: number;
25
- shown: string;
26
- content: ChatContent;
27
- } | null;
28
22
  export declare const planSlackNotifications: (options: SlackChatOptions) => Promise<void>;
29
23
  export declare const planSlackRooms: (options: SlackChatOptions) => Promise<void>;
30
24
  export declare function slackBlocks(text: string, buttons?: Record<string, unknown>[]): Record<string, unknown>[];
@@ -2,9 +2,8 @@ import { chatFlowButtons } from "./chat-flow.js";
2
2
  import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
3
3
  import { chatQuestionButtons } from "./subagent-question.js";
4
4
  import { chatAskButtons } from "./chat-ask.js";
5
- import { channelInbox } from "./chat-inbox.js";
6
- import { roomCommand } from "./chat-rooms.js";
7
- import { processChatEvent, applyChatAction, planChatNotifications, channelAccess, planRoomMessages, heldForWatch, } from "./chat-delivery.js";
5
+ import { admitChatEvent } from "./chat-admission.js";
6
+ import { processChatEvent, planChatNotifications, channelAccess, planRoomMessages, heldForWatch, } from "./chat-delivery.js";
8
7
  /** Slack is a transport for the same saved assistant and confirmation doors. */
9
8
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
10
9
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
@@ -100,21 +99,10 @@ export function receiveSlack(state, identity, type, raw, now) {
100
99
  // A channel or private group is a room: accepted only while it follows a
101
100
  // conversation, or for the `/team` words that make it follow one.
102
101
  const isRoom = !slackId(channel, "D");
103
- const words = "text" in payload ? payload.text ?? "" : "";
104
- // v89: a channel that feeds a flow takes anyone's message (as a card, with no say over anything); "flow 12" connects one.
105
- const inbox = isRoom && kind === "message" ? channelInbox(state.store, "slack", identity.installation, String(channel), words) : { watched: false, command: false };
106
- const roomish = isRoom && (state.room(identity.installation, String(channel)) !== null || (kind === "message" && roomCommand(words) !== null) || inbox.watched || inbox.command);
107
- if (isRoom && !roomish)
108
- return false;
109
- const binding = state.bindingFor(identity.installation, member);
110
- const open = inbox.watched && !inbox.command;
111
- if (kind !== "pair" && !open &&
112
- (!binding ||
113
- !state.live(binding) ||
114
- (binding.channel !== channel && !roomish)))
115
- return false;
116
- if (kind === "pair" && (binding || isRoom))
102
+ const admitted = admitChatEvent(state, "slack", identity.installation, { kind, member, channel, isRoom, payload });
103
+ if (admitted === null)
117
104
  return false;
105
+ const { binding } = admitted;
118
106
  return state.enqueue({
119
107
  id,
120
108
  installation: identity.installation,
@@ -139,7 +127,6 @@ const delivery = (options) => ({
139
127
  }),
140
128
  });
141
129
  export const processSlackEvent = (options) => processChatEvent(delivery(options));
142
- export const applySlackAction = (options, event, binding, repos) => applyChatAction(delivery(options), event, binding, repos);
143
130
  export const planSlackNotifications = (options) => planChatNotifications(delivery(options));
144
131
  export const planSlackRooms = (options) => planRoomMessages(delivery(options));
145
132
  const access = (options, binding, ceiling) => channelAccess(delivery(options), binding, ceiling);
@@ -1,8 +1,4 @@
1
1
  import type { Store } from "./store.js";
2
2
  import { type Html } from "./html.js";
3
- export declare function slackSettingsHtml(store: Store, dir: string, options?: {
4
- code?: string;
5
- problem?: string;
6
- now?: Date;
7
- who?: string;
8
- }): Html;
3
+ import { type ChatSettingsOptions } from "./chat-settings-page.js";
4
+ export declare function slackSettingsHtml(store: Store, dir: string, options?: ChatSettingsOptions): Html;
@@ -1,41 +1,13 @@
1
- import { loadPrimary } from "./webhooks.js";
2
- import { html, postForm } from "./html.js";
1
+ import { html } from "./html.js";
3
2
  import { loadSlackCredentials } from "./slack-api.js";
4
- import { ChatState } from "./chat-delivery-state.js";
3
+ import { chatSettingsHtml } from "./chat-settings-page.js";
5
4
  export function slackSettingsHtml(store, dir, options = {}) {
6
- const credentials = loadSlackCredentials(dir), state = new ChatState(store, "slack"), bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [], binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null, others = bindings.length - (binding === null ? 0 : 1);
7
- const runtime = credentials ? state.runtime(credentials.installation) : null;
8
- const now = options.now ?? new Date(), live = runtime &&
9
- runtime.leaseUntil !== null &&
10
- runtime.leaseUntil > now.toISOString() &&
11
- runtime.connected;
12
- const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
13
- const post = (action, content) => postForm(`/settings/slack/${action}`, content, { attrs: { class: "card" } });
14
- const parts = [];
15
- if (!credentials) {
16
- parts.push(html `<p>Manage projects and review results in a private Slack conversation.</p>`, html `<details open><summary>Create your Slack app</summary><ol><li>Download the <a href="/settings/slack/manifest">app manifest</a>, then <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">create an app</a> in your workspace using that manifest.</li><li>Under Basic Information, create an app-level token with <code>connections:write</code>.</li><li>Under OAuth &amp; Permissions, install the app and copy its bot token.</li></ol></details>`, post("connect", html `<label>App token<input style="min-height:44px" type="password" name="app-token" autocomplete="off" placeholder="xapp-…" required></label><label>Bot token<input style="min-height:44px" type="password" name="bot-token" autocomplete="off" placeholder="xoxb-…" required></label>${password}<p class="meta">Tokens stay on this installation. Next, pair your own Slack account.</p><button type="submit">Connect Slack</button>`));
17
- }
18
- else {
19
- parts.push(html `<p><strong>${credentials.workspace}</strong> · ${live ? "Connected" : "Waiting for connection"}</p>`);
20
- if (runtime?.problem)
21
- parts.push(html `<p role="status">${String(runtime.problem)}</p>`);
22
- if (!live && !runtime?.problem)
23
- parts.push(html `<p>Keep the Toolroll worker running to receive Slack messages.</p>`);
24
- if (options.code) {
25
- parts.push(html `<h2>Pair your account</h2><p>Send this in a direct message to your Toolroll Slack app. It expires in 10 minutes.</p>`, html `<label>Pairing message<input type="text" readonly value="pair ${options.code}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>`, html `<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/slack">Check connection</a>`);
26
- }
27
- else if (binding && state.live(binding)) {
28
- const { pending, dropped: failed } = state.partCounts(binding.id);
29
- parts.push(html `<p>Your Slack account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}${pending ? ` ${pending} replies waiting to send.` : ""}${failed ? ` ${failed} replies could not be delivered. Open the saved chat to recover them.` : ""}</p>`, html `<p>Try “What needs my attention?” or “Show the evidence for the latest result.”</p>`, html `<p><a href="/chat">Open saved chat</a></p>`, loadPrimary(process.env, dir) === "slack"
30
- ? html `<p>Task updates are sent here.</p>`
31
- : post("alerts", html `<button type="submit">Send task updates here</button>`));
32
- }
33
- else {
34
- parts.push(post("pair", html `<h2>Pair your account</h2><p>Your paired Slack account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : ""}</p>${password}<button type="submit">Create pairing code</button>`));
35
- }
36
- if (binding && state.live(binding) && !options.code)
37
- parts.push(post("unpair", html `${password}<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>`));
38
- parts.push(html `<details><summary>Connection settings</summary>${post("disconnect", html `${password}<button type="submit">Disconnect Slack</button>`)}</details>`);
39
- }
40
- return html `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Slack</h1>${options.problem ? html `<p class="problem" role="alert">${options.problem}</p>` : ""}${parts}<details><summary>Access and setup</summary><p>Private messages and threads use your Toolroll permissions. Shared channels are not supported. Secure review links use this installation’s configured HTTPS console address.</p><p>Socket Mode connects out to Slack. It does not need a public webhook or a Tailscale account.</p></details></section>`;
5
+ return chatSettingsHtml(store, dir, options, {
6
+ provider: "slack",
7
+ label: "Slack",
8
+ credentials: loadSlackCredentials(dir),
9
+ setup: html `<details open><summary>Create your Slack app</summary><ol><li>Download the <a href="/settings/slack/manifest">app manifest</a>, then <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">create an app</a> in your workspace using that manifest.</li><li>Under Basic Information, create an app-level token with <code>connections:write</code>.</li><li>Under OAuth &amp; Permissions, install the app and copy its bot token.</li></ol></details>`,
10
+ connect: password => html `<label>App token<input style="min-height:44px" type="password" name="app-token" autocomplete="off" placeholder="xapp-…" required></label><label>Bot token<input style="min-height:44px" type="password" name="bot-token" autocomplete="off" placeholder="xoxb-…" required></label>${password}<p class="meta">Tokens stay on this installation. Next, pair your own Slack account.</p><button type="submit">Connect Slack</button>`,
11
+ access: html `<p>Private messages and threads use your Toolroll permissions. Shared channels are not supported. Secure review links use this installation’s configured HTTPS console address.</p><p>Socket Mode connects out to Slack. It does not need a public webhook or a Tailscale account.</p>`,
12
+ });
41
13
  }
package/dist/slack.js CHANGED
@@ -1,19 +1,12 @@
1
1
  /** One leased Socket Mode connection, hosted by the ordinary worker. */
2
2
  import { randomBytes } from "node:crypto";
3
- import { setTimeout as sleep } from "node:timers/promises";
3
+ import { abortablePause, workChatQueue } from "./chat-follow.js";
4
4
  import { loadSlackCredentials, slackApi } from "./slack-api.js";
5
5
  import { ChatState } from "./chat-delivery-state.js";
6
6
  import { receiveSlack, processSlackEvent, deliverSlackPart, planSlackNotifications, planSlackRooms, } from "./slack-chat.js";
7
7
  export async function followSlack(options) {
8
8
  const owner = randomBytes(16).toString("hex"), state = new ChatState(options.store, "slack");
9
- const pause = async (ms) => {
10
- try {
11
- await sleep(ms, undefined, { signal: options.signal });
12
- }
13
- catch {
14
- /* shutdown */
15
- }
16
- };
9
+ const pause = abortablePause(options.signal);
17
10
  while (!options.signal.aborted) {
18
11
  const credentials = loadSlackCredentials(options.dir);
19
12
  if (!credentials ||
@@ -104,20 +97,16 @@ export async function followSlack(options) {
104
97
  };
105
98
  try {
106
99
  await client.start();
107
- while (same()) {
108
- if (!connected || state.retryAt(credentials.installation) > new Date().toISOString()) {
109
- await pause(1000);
110
- continue;
111
- }
112
- await processSlackEvent(chat);
113
- if (same())
114
- await planSlackRooms(chat);
115
- if (same())
116
- await deliverSlackPart(chat);
117
- if (same() && options.notifications())
118
- await planSlackNotifications(chat);
119
- await pause(1000);
120
- }
100
+ await workChatQueue({
101
+ same,
102
+ ready: () => connected && !(state.retryAt(credentials.installation) > new Date().toISOString()),
103
+ pause,
104
+ notifications: () => options.notifications(),
105
+ process: () => processSlackEvent(chat),
106
+ rooms: () => planSlackRooms(chat),
107
+ deliver: () => deliverSlackPart(chat),
108
+ notify: () => planSlackNotifications(chat),
109
+ });
121
110
  }
122
111
  catch {
123
112
  if (alive)
@@ -0,0 +1,2 @@
1
+ /** Wait `ms` milliseconds on the global timer (so fake timers in tests still drive it). */
2
+ export declare const sleep: (ms: number) => Promise<void>;
package/dist/sleep.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Wait `ms` milliseconds on the global timer (so fake timers in tests still drive it). */
2
+ export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));