toolroll 0.9.96 → 0.9.97

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 (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/browser/workspace.css +2 -2
  3. package/dist/browser/workspace.js +32 -32
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-delivery.d.ts +5 -1
  6. package/dist/chat-delivery.js +74 -25
  7. package/dist/chat-follow.d.ts +6 -3
  8. package/dist/chat-follow.js +23 -7
  9. package/dist/chat-live-schema.d.ts +17 -0
  10. package/dist/chat-live-schema.js +32 -0
  11. package/dist/chat-live.d.ts +161 -0
  12. package/dist/chat-live.js +491 -0
  13. package/dist/contracts/console-api.d.ts +1 -0
  14. package/dist/contracts/console-api.js +1 -0
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/discord-api.d.ts +3 -1
  17. package/dist/discord-api.js +9 -3
  18. package/dist/discord-chat.d.ts +13 -3
  19. package/dist/discord-chat.js +77 -21
  20. package/dist/discord.js +8 -1
  21. package/dist/lead-answer-eval.d.ts +86 -0
  22. package/dist/lead-answer-eval.js +255 -0
  23. package/dist/lead-cli-live.d.ts +56 -0
  24. package/dist/lead-cli-live.js +200 -0
  25. package/dist/lead-cli.d.ts +3 -0
  26. package/dist/lead-cli.js +34 -4
  27. package/dist/lead-latency-schema.d.ts +24 -0
  28. package/dist/lead-latency-schema.js +36 -0
  29. package/dist/lead-progress.d.ts +10 -1
  30. package/dist/lead-progress.js +1 -1
  31. package/dist/lead-queue-runtime.d.ts +1 -1
  32. package/dist/lead-queue-runtime.js +1 -1
  33. package/dist/lead-route.d.ts +56 -0
  34. package/dist/lead-route.js +104 -0
  35. package/dist/lead.d.ts +4 -2
  36. package/dist/lead.js +79 -26
  37. package/dist/live-reply.d.ts +83 -0
  38. package/dist/live-reply.js +302 -0
  39. package/dist/operate.js +39 -1
  40. package/dist/project-knowledge.d.ts +3 -3
  41. package/dist/reply-shape.d.ts +9 -0
  42. package/dist/reply-shape.js +13 -0
  43. package/dist/serve.js +11 -4
  44. package/dist/server/chat.d.ts +2 -1
  45. package/dist/server/chat.js +18 -6
  46. package/dist/server/live.js +18 -21
  47. package/dist/server/render-chat.d.ts +5 -0
  48. package/dist/server/render-chat.js +4 -2
  49. package/dist/server/render-settings.d.ts +8 -0
  50. package/dist/server/render-settings.js +13 -2
  51. package/dist/server/route-table.js +2 -0
  52. package/dist/server/settings.js +14 -1
  53. package/dist/slack-api.js +11 -0
  54. package/dist/slack-chat.d.ts +11 -1
  55. package/dist/slack-chat.js +50 -5
  56. package/dist/slack-settings.js +8 -1
  57. package/dist/slack.js +10 -3
  58. package/dist/store.d.ts +31 -2
  59. package/dist/store.js +59 -6
  60. package/dist/subscription-chat.d.ts +2 -0
  61. package/dist/subscription-chat.js +3 -1
  62. package/dist/teams-chat.d.ts +9 -2
  63. package/dist/teams-chat.js +50 -2
  64. package/dist/teams.js +5 -2
  65. package/dist/telegram-lead.d.ts +3 -0
  66. package/dist/telegram-lead.js +63 -9
  67. package/dist/telegram-live.d.ts +116 -0
  68. package/dist/telegram-live.js +224 -0
  69. package/dist/telegram.d.ts +2 -0
  70. package/dist/telegram.js +50 -2
  71. package/dist/toolroll-update.js +4 -1
  72. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -9,6 +9,7 @@ import { adapterPolicy } from "./server/route-policy.js";
9
9
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
10
10
  import { checkPublicCommand } from "./public-check.js";
11
11
  import { leadNameOf } from "./lead-identity.js";
12
+ import { modelName, quickLeadModel } from "./lead-route.js";
12
13
  import { maybeTriggerRepair } from "./dispose.js";
13
14
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
14
15
  import { fileAddTestsTask, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
@@ -122,7 +123,7 @@ import { homedir, hostname } from "node:os";
122
123
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
123
124
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
124
125
  import { dur } from "./design/kit.js";
125
- import { checkoutIdle, setCheckoutIdle, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
126
+ import { checkoutIdle, setCheckoutIdle, leadQuickAnswersOn, setLeadQuickAnswers, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
126
127
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
127
128
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
128
129
  import { backgroundOff, backgroundOn, backgroundShow, laptopStatus, laptopStop, restartOwnBuilder } from "./laptop-background.js";
@@ -208,6 +209,7 @@ import { scanRepo } from "./capscan.js";
208
209
  import { computeGaps, describeCapability } from "./gaps.js";
209
210
  import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
210
211
  import { runLeadCli, answerContextLines } from "./lead-cli.js";
212
+ import { liveTerminalWanted, processTerminal } from "./lead-cli-live.js";
211
213
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./lead-doors.js";
212
214
  import { verifyApproverByPassword as approverByPassword } from "./principal.js";
213
215
  import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
@@ -598,6 +600,9 @@ Agents — which provider and model each phase runs on
598
600
  [--model <m>] [--weekly-usd <n>] [--daily-turns <n>] --as <you> --token <t>
599
601
  membership providers reuse a logged-in local harness with no dollar
600
602
  maximum; direct API providers require a key and weekly dollar ceiling
603
+ toolroll config set|clear|show quick-answers --as <you> --token <t>
604
+ quick answers on (set) or off (clear): plain status questions go to a
605
+ faster model on the Claude membership; changes stay on the main model
601
606
  toolroll chat --as <you> [--repo <path>…] [--say "…"] [--end]
602
607
  [--ceiling-usd <n>] [--json] (password at the prompt;
603
608
  --token <t> only for scripts — it lands in shell history)
@@ -5101,6 +5106,9 @@ async function configCommand(positional, flags, context) {
5101
5106
  return fail(write, json, "config", "usage", `unknown \`config ${action}\` — try ${CONFIG_ACTIONS.join(", ")}`, EXIT.usage);
5102
5107
  const repoGiven = text(flags, "repo");
5103
5108
  const scope = repoGiven === undefined ? INSTALLATION_SCOPE : canonicalProject(repoGiven) ?? resolve(repoGiven);
5109
+ // Settings → Lead's "Quick answers use a faster model", the same switch (responsive-chat.md §8).
5110
+ if (phase === "quick-answers")
5111
+ return quickAnswersConfig(action ?? "show", repoGiven !== undefined, flags, context);
5104
5112
  if (action === undefined || action === "show") {
5105
5113
  const installation = store.listPhaseConfig(INSTALLATION_SCOPE);
5106
5114
  const project = scope === INSTALLATION_SCOPE ? [] : store.listPhaseConfig(scope);
@@ -10671,6 +10679,8 @@ async function chatCommand(flags, context) {
10671
10679
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
10672
10680
  // The console's own address (and its public one): links there read as "the task", "the result", "Settings → Lead".
10673
10681
  appOrigin: [loadConsoleUrl(process.env, dirname(context.databaseFile)), phoneOrigin(process.env, dirname(context.databaseFile))],
10682
+ // A live status line and the answer as it is written, on a real terminal only (tests drive runLeadCli directly).
10683
+ ...(context.leadSeams === undefined && liveTerminalWanted(json, process.env, process.stdout.isTTY === true, () => underAgent(process.env)) ? { liveTerminal: processTerminal() } : {}),
10674
10684
  });
10675
10685
  return result.code;
10676
10686
  }
@@ -14087,3 +14097,31 @@ async function projectFallbackCommand(positional, flags, context) {
14087
14097
  return fail(context.write, context.json, command, "refused", done.problem, EXIT.refused);
14088
14098
  return succeed(context.write, context.json, command, shown(), () => [...words(), "Tasks scoped from now on use this; tasks already approved keep the chain they were approved with."]);
14089
14099
  }
14100
+ /** `toolroll config set|clear|show quick-answers`: Settings → Lead's quick-answer switch (responsive-chat.md §8). On
14101
+ * or off is installation-wide, so changing it takes an instance operator's password, as the web page's route does. */
14102
+ async function quickAnswersConfig(action, repoGiven, flags, context) {
14103
+ const { store, write, json, clock } = context;
14104
+ const command = `config ${action}`;
14105
+ if (repoGiven)
14106
+ return fail(write, json, command, "usage", "quick answers are installation-wide: no --repo", EXIT.usage);
14107
+ const said = () => {
14108
+ const on = leadQuickAnswersOn(store);
14109
+ const model = quickLeadModel(store);
14110
+ return { payload: { quickAnswers: { on, model } }, lines: () => [on
14111
+ ? `Quick answers are on: plain status questions are answered by ${modelName(model)}; anything that asks for a change stays on the lead's main model.`
14112
+ : "Quick answers are off: the lead's main model answers everything."] };
14113
+ };
14114
+ if (action === "show") {
14115
+ const shown = said();
14116
+ return succeed(write, json, command, shown.payload, shown.lines);
14117
+ }
14118
+ const acting = await askCredentials(flags, context);
14119
+ if (acting === null)
14120
+ return fail(write, json, command, "usage", "changing quick answers takes `--as <you> --token <t>`", EXIT.usage);
14121
+ const verified = authenticateApprover(store, acting.name, acting.token);
14122
+ if (!verified.ok || !store.isInstanceOperator(acting.name))
14123
+ return fail(write, json, command, "unauthenticated", "an instance operator changes quick answers, with their password", EXIT.refused);
14124
+ setLeadQuickAnswers(store, action === "set", acting.name, clock());
14125
+ const shown = said();
14126
+ return succeed(write, json, command, shown.payload, shown.lines);
14127
+ }
@@ -22,7 +22,6 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- references?: never;
26
25
  revision: number;
27
26
  stale: boolean;
28
27
  notice: string;
@@ -30,10 +29,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
30
29
  content?: never;
31
30
  history?: never;
32
31
  instructions?: never;
32
+ references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
- references?: never;
37
36
  stale?: never;
38
37
  decision: {
39
38
  repo: string;
@@ -56,10 +55,10 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
56
55
  revision?: never;
57
56
  history?: never;
58
57
  instructions?: never;
58
+ references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
- references?: never;
63
62
  stale?: never;
64
63
  revision: number;
65
64
  title: string;
@@ -67,6 +66,7 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
67
66
  notice: string;
68
67
  history?: never;
69
68
  instructions?: never;
69
+ references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
@@ -35,6 +35,15 @@ export declare function linkLabel(url: string, appOrigin?: ShapeOptions["appOrig
35
35
  * changes nothing.
36
36
  */
37
37
  export declare function shapeReply(text: string, options?: ShapeOptions): string;
38
+ /**
39
+ * A reply still being written, shaped with the final reply's presentation policy (docs/plans/responsive-chat.md §2.2),
40
+ * except that ids are dropped unless the owner asked for them: whether a reply names several of a kind isn't known
41
+ * until it ends, so a prefix never keeps them on that ground. Presentation, not redaction.
42
+ */
43
+ export declare function shapeReplyPrefix(text: string, options?: ShapeOptions): string;
44
+ /** The first window of a shaped text of at most `size` characters, cut where a part would be (never inside code, a link,
45
+ * a bold anchor or an emoji's two halves). A single link longer than the window stays whole, so the caller measures. */
46
+ export declare function shapedWindow(shaped: string, size: number): string;
38
47
  /**
39
48
  * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
40
49
  * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
@@ -199,6 +199,14 @@ function shapeWith(text, options, state) {
199
199
  export function shapeReply(text, options = {}) {
200
200
  return shapeWith(text, options, stateFor(text, options));
201
201
  }
202
+ /**
203
+ * A reply still being written, shaped with the final reply's presentation policy (docs/plans/responsive-chat.md §2.2),
204
+ * except that ids are dropped unless the owner asked for them: whether a reply names several of a kind isn't known
205
+ * until it ends, so a prefix never keeps them on that ground. Presentation, not redaction.
206
+ */
207
+ export function shapeReplyPrefix(text, options = {}) {
208
+ return shapeWith(text, options, { boldLeft: BOLD_ANCHORS, keepIds: askedForIds(options.asked) ? "all" : new Set() });
209
+ }
202
210
  /** Where a long reply may be cut: never inside code, a link, a URL or a bold anchor; best at a paragraph, then a line,
203
211
  * then a space. Always past `from`, and never between the two halves of an emoji. */
204
212
  function cutAt(text, from, size) {
@@ -231,6 +239,11 @@ function cutAt(text, from, size) {
231
239
  end = end - 1 > from ? end - 1 : end + 1;
232
240
  return end;
233
241
  }
242
+ /** The first window of a shaped text of at most `size` characters, cut where a part would be (never inside code, a link,
243
+ * a bold anchor or an emoji's two halves). A single link longer than the window stays whole, so the caller measures. */
244
+ export function shapedWindow(shaped, size) {
245
+ return shaped.slice(0, cutAt(shaped, 0, size));
246
+ }
234
247
  /**
235
248
  * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
236
249
  * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
package/dist/serve.js CHANGED
@@ -121,7 +121,7 @@ import { findFirstTasks, firstRunSteps, signInCommandFor } from "./first-run.js"
121
121
  import { installMethod } from "./install-method.js";
122
122
  import { createIntegrationMonitor } from "./integrations.js";
123
123
  import { logEvent } from "./log.js";
124
- import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
124
+ import { LEAD_MESSAGE_MAX_CHARS, leadFailureText } from "./lead.js";
125
125
  import {} from "./model-catalog.js";
126
126
  import { modeTermsFromJson } from "./modes.js";
127
127
  import { envValue } from "./names.js";
@@ -171,7 +171,7 @@ import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutatio
171
171
  import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
172
172
  import { sourceKey } from "./source-key.js";
173
173
  import { readSsoSettings } from "./sso-settings.js";
174
- import { LEAD_THREAD, localOwnerBinding } from "./store.js";
174
+ import { LEAD_THREAD, latestLeadTurnOfThread, localOwnerBinding } from "./store.js";
175
175
  import { waitingUpdate } from "./toolroll-update.js";
176
176
  import { PACKAGE_VERSION } from "./version.js";
177
177
  import { loadConsoleUrl } from "./webhooks.js";
@@ -629,7 +629,8 @@ export function createDecisionServer(options) {
629
629
  return run !== null && family.versions.some(one => one.refId === run.taskRef);
630
630
  }),
631
631
  names: { project: projectName, modes: TASK_COMPOSER_MODES },
632
- live: { begin: thread => chatHandlers.beginLiveTurn(thread), end: (thread, ok, progress) => chatHandlers.endLiveTurn(thread, ok, progress) },
632
+ // A queued message's partials are shaped as its final reply will be: for its words, with this console's own addresses.
633
+ live: { begin: (thread, asked) => chatHandlers.beginLiveTurn(thread, { asked, appOrigin: [publicOrigin?.origin ?? null, options.configDir === undefined ? null : loadConsoleUrl(process.env, options.configDir)] }), end: (thread, ok, progress) => chatHandlers.endLiveTurn(thread, ok, progress) },
633
634
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
634
635
  ...(options.subscriptionChatRunner ? { subscriptionRunner: options.subscriptionChatRunner } : {}),
635
636
  });
@@ -1326,7 +1327,13 @@ export function createDecisionServer(options) {
1326
1327
  const replaced = thread !== null ? store.replacedLeadThread(thread)
1327
1328
  : found !== null && found.scope.kind === "lead" && store.listLeadMessages(found.id, 1).length > 0 ? found : null;
1328
1329
  const previousProposals = replaced === null ? [] : store.listLeadProposals(replaced.id);
1330
+ // How this thread's last reply ended, read from its saved turn (never process memory, which a restart loses): a
1331
+ // failed turn that saved no message of its own says why in the thread, until the next reply starts.
1332
+ const lastTurn = thread === null ? null : latestLeadTurnOfThread(store, thread.id);
1333
+ const failure = lastTurn !== null && lastTurn.state === "failed" && !messages.some(one => one.role === "assistant" && one.turn === lastTurn.id)
1334
+ ? leadFailureText(lastTurn.failureReason, store.leadTurnCostUnconfirmed(lastTurn.id), lastTurn.failureDetail ?? null) : null;
1329
1335
  return {
1336
+ failure,
1330
1337
  previous: replaced === null ? null : { messages: store.listLeadMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
1331
1338
  messages,
1332
1339
  ask,
@@ -1369,7 +1376,7 @@ export function createDecisionServer(options) {
1369
1376
  sessionId: session?.id ?? NOT_STARTED_SESSION, user: session?.approver ?? who.name, version: leadChatVersion({ ...rows, focusTask }),
1370
1377
  messages: leadBrowserMessages(rows, back, { task: focusTask?.id ?? null, project: chatProject }),
1371
1378
  pendingTurnId: rows.pending?.id ?? null, requestId: randomBytes(16).toString("hex"), maxChars: LEAD_MESSAGE_MAX_CHARS,
1372
- taskId: focusTask?.id ?? null, resultRunId, project: focusTask === null ? chatProject : null,
1379
+ taskId: focusTask?.id ?? null, resultRunId, project: focusTask === null ? chatProject : null, failure: rows.pending === null ? rows.failure : null,
1373
1380
  };
1374
1381
  }
1375
1382
  /** The session-layer principal (ruling 3): cookie, csrf, and role were
@@ -3,6 +3,7 @@ import { type DemoResultView } from "../demo-chat.js";
3
3
  import type { DemoExchange } from "../demo.js";
4
4
  import { type FirstTaskSuggestion } from "../first-run.js";
5
5
  import type { LeadProgress } from "../lead-progress.js";
6
+ import type { ShapeOptions } from "../reply-shape.js";
6
7
  import type { DirectChatProviderId } from "../store.js";
7
8
  import type { ServerRuntime } from './runtime.js';
8
9
  import { type ChatEnablement } from "./render-chat.js";
@@ -14,7 +15,7 @@ export declare function createChatHandlers(runtime: ServerRuntime): {
14
15
  via: "cookie";
15
16
  }, now: Date) => BrowserPhoneCard | undefined;
16
17
  chatProjectOf: (values: string[]) => string | null | undefined;
17
- beginLiveTurn: (thread: number) => (event: LeadProgress) => void;
18
+ beginLiveTurn: (thread: number, shape?: ShapeOptions) => (event: LeadProgress) => void;
18
19
  endLiveTurn: (thread: number, ok: boolean, progress?: (event: LeadProgress) => void) => void;
19
20
  noteLead: (csrf: string, turn: number | null, message: string) => void;
20
21
  takeLeadNote: (csrf: string, liveSession: number | null) => string | null;
@@ -23,6 +23,7 @@ import { configureLeadFollow, leadFollowStatus } from '../lead-follow.js';
23
23
  import { leadActivity } from "../lead-voice.js";
24
24
  import { limitsView } from "../limits-ui.js";
25
25
  import { confirmCoordinatorProposal, confirmLeadProposal, dismissCoordinatorProposal, dismissLeadProposal } from "../lead-doors.js";
26
+ import { createLiveReply } from "../live-reply.js";
26
27
  import { LEAD_MESSAGE_MAX_CHARS, runLeadTurn } from "../lead.js";
27
28
  import { livePin, modelOptions } from "../model-catalog.js";
28
29
  import { verifyApproverByPassword, verifyApproverStanding } from "../principal.js";
@@ -991,20 +992,24 @@ export function createChatHandlers(runtime) {
991
992
  // The live display starts once this message holds a turn: a refusal (a secret, a turn still running) never blanks
992
993
  // the display of a reply that is still running in the same conversation.
993
994
  let onProgress;
995
+ // Partials are shaped as the final reply is: for this message, with this console's own addresses.
996
+ const liveShape = { asked: message, appOrigin: requestContext.getStore()?.appOrigins ?? null };
994
997
  const ended = (ok) => { if (onProgress !== undefined)
995
998
  endLiveTurn(opened.thread.id, ok, onProgress); };
996
999
  // Detached work never carries this request's browser session (Passwordless reads it): it runs with no session facts.
997
1000
  void freshFacts.run(null, () => runLeadTurn({ store, who: principal, session: leadSession, thread: opened.thread, config: enabled.config, key: enabled.key, message,
998
- onAdmitted: () => { onProgress = beginLiveTurn(opened.thread.id); }, onProgress: event => onProgress?.(event), channel: "console", requestId, ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot }))
1001
+ onAdmitted: () => { onProgress = beginLiveTurn(opened.thread.id, liveShape); }, onProgress: event => onProgress?.(event), channel: "console", requestId, ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot }))
999
1002
  .then(outcome => {
1000
1003
  // Lost a race to another turn of this person's between deciding and admission: it waits instead of being refused.
1001
1004
  if (!outcome.ok && "refused" in outcome && (outcome.refused === "concurrent" || outcome.refused === "backlog") && queueIt().kind === "queued") {
1002
1005
  ended(false);
1003
1006
  return;
1004
1007
  }
1005
- // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
1006
- if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
1007
- noteLead(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
1008
+ // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once. Any other turn
1009
+ // that ended failed is said from its saved row (leadConversationRows `failure`), which a restart keeps; only a
1010
+ // refusal before any turn existed is said from memory.
1011
+ if (!outcome.ok && !("saved" in outcome && outcome.saved === true) && !("turn" in outcome))
1012
+ noteLead(who.session.csrf, null, outcome.message);
1008
1013
  ended(outcome.ok);
1009
1014
  })
1010
1015
  .catch(() => { noteLead(who.session.csrf, null, "the turn failed unexpectedly"); ended(false); })
@@ -1208,11 +1213,11 @@ export function createChatHandlers(runtime) {
1208
1213
  /** Which live display each progress handle started: ending a turn ends only its own display (a turn stopped from
1209
1214
  * another window that settles late can never end the next turn's). */
1210
1215
  const progressOf = new WeakMap();
1211
- function beginLiveTurn(thread) {
1216
+ function beginLiveTurn(thread, shape = {}) {
1212
1217
  const previous = liveTurns.get(thread);
1213
1218
  if (previous?.expiry)
1214
1219
  clearTimeout(previous.expiry);
1215
- const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [] };
1220
+ const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [], reply: createLiveReply(shape) };
1216
1221
  liveTurns.set(thread, live);
1217
1222
  // A page that has this conversation open follows the reply (GET /live, its chat room).
1218
1223
  runtime.liveTurnStarted(thread);
@@ -1222,6 +1227,7 @@ export function createChatHandlers(runtime) {
1222
1227
  if (live.done)
1223
1228
  return;
1224
1229
  live.turn ??= event.turn;
1230
+ live.reply?.push(event);
1225
1231
  if (event.kind === "step")
1226
1232
  newStep();
1227
1233
  else if (event.kind === "tool") {
@@ -1241,6 +1247,11 @@ export function createChatHandlers(runtime) {
1241
1247
  current().text = event.text;
1242
1248
  (live.confirmed ??= [])[live.steps.length - 1] = event.final === true;
1243
1249
  }
1250
+ else if (event.kind === "blocked") {
1251
+ // Held back as a possible password or key: no step keeps its words (the Live grid's tile reads these too).
1252
+ for (const step of live.steps)
1253
+ step.text = "";
1254
+ }
1244
1255
  for (const listener of live.listeners)
1245
1256
  listener();
1246
1257
  };
@@ -1255,6 +1266,7 @@ export function createChatHandlers(runtime) {
1255
1266
  return;
1256
1267
  live.done = true;
1257
1268
  live.ok = ok;
1269
+ live.reply?.close(ok ? "answered" : "failed");
1258
1270
  live.endedAt = clock().getTime();
1259
1271
  for (const listener of live.listeners)
1260
1272
  listener();
@@ -4,6 +4,7 @@ import { agentRunFingerprint } from "../agents-board.js";
4
4
  import { createLiveRooms, LiveConnection } from "../live-bus.js";
5
5
  import { reproveRemote } from "../operate-remote.js";
6
6
  import { rowVisible } from "../project.js";
7
+ import { createLiveSender, LIVE_STATUS } from "../live-reply.js";
7
8
  import { TEAM_PASSWORD_REVERIFY_MS } from "../team-http.js";
8
9
  import { handlersOf } from "./handler-registry.js";
9
10
  import { respond } from "./http.js";
@@ -18,6 +19,11 @@ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary|agents-live)(?:
18
19
  const WORKSPACE_SETTLE_MS = 1_000;
19
20
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
20
21
  const TURN_SETTLE_MS = 80;
22
+ function chatSnapshot(turn) {
23
+ const view = turn.reply?.view() ?? { phase: "working", status: LIVE_STATUS.thinking };
24
+ const steps = (turn.reply?.tools() ?? []).map(calls => ({ tools: calls.map(call => call.label), toolCalls: calls }));
25
+ return { steps, phase: view.phase, status: turn.done ? "" : turn.reply?.status() ?? LIVE_STATUS.thinking, text: view.phase === "writing" ? view.text : "", done: turn.done, ok: turn.ok };
26
+ }
21
27
  /** A room's name, read strictly: anything else is not a room. */
22
28
  export function parseLiveRoom(name) {
23
29
  const match = ROOM_NAME.exec(name);
@@ -114,39 +120,30 @@ export function createLiveHandlers(runtime, live) {
114
120
  }
115
121
  return valid;
116
122
  }
117
- /** A running reply for one viewer's thread: snapshots while it runs, then the last one. */
123
+ /** A running reply for one viewer's thread: snapshots while it runs, then the last one. Every snapshot goes through
124
+ * one serialized sender (live-reply.ts): one in flight, the newest waiting, standing re-proved before each, and the
125
+ * last one only after the sender has closed, so no preview lands after it. */
118
126
  function follow(viewer, thread) {
119
127
  if (!admitted(viewer, thread))
120
128
  return;
121
129
  const turn = liveTurns.get(thread);
122
130
  if (turn === undefined || turn.done || viewer.detach !== null)
123
131
  return;
124
- let queued = null;
125
- const snapshot = () => {
126
- if (admitted(viewer, thread))
127
- viewer.connection.send(viewer.room, "turn", { steps: turn.steps, done: turn.done, ok: turn.ok });
128
- };
129
- const flush = () => {
130
- queued = null;
131
- snapshot();
132
- if (turn.done)
133
- stop();
134
- };
132
+ const send = (payload) => { viewer.connection.send(viewer.room, "turn", payload); };
133
+ const sender = createLiveSender({ send, standing: () => admitted(viewer, thread), spacingMs: TURN_SETTLE_MS });
135
134
  const listener = () => {
136
- if (turn.done) {
137
- if (queued !== null)
138
- clearTimeout(queued);
139
- flush();
135
+ if (!turn.done) {
136
+ sender.offer(chatSnapshot(turn));
140
137
  return;
141
138
  }
142
- if (queued === null)
143
- queued = setTimeout(flush, TURN_SETTLE_MS);
139
+ stop();
140
+ void sender.close().then(() => { if (admitted(viewer, thread))
141
+ send(chatSnapshot(turn)); });
144
142
  };
145
- const stop = () => { if (queued !== null)
146
- clearTimeout(queued); turn.listeners.delete(listener); viewer.detach = null; };
143
+ const stop = () => { turn.listeners.delete(listener); viewer.detach = null; void sender.close(); };
147
144
  turn.listeners.add(listener);
148
145
  viewer.detach = stop;
149
- snapshot();
146
+ sender.offer(chatSnapshot(turn));
150
147
  }
151
148
  /** A reply starts on a thread: whoever has that conversation open follows it. */
152
149
  function turnStarted(thread) {
@@ -300,6 +300,8 @@ export declare function replacedBrowserMessages(previous: ReplacedThread): impor
300
300
  /** The replaced thread for the server-rendered page: read only, then the divider. */
301
301
  export declare function replacedThreadHtml(previous: ReplacedThread | null | undefined): Html;
302
302
  export type LeadThreadRows = {
303
+ /** How the thread's last reply failed, from its saved turn (no message of its own); null otherwise. */
304
+ failure?: string | null;
303
305
  /** The lead thread a change in reachable projects replaced; shown only. */
304
306
  previous?: ReplacedThread | null;
305
307
  messages: LeadMessage[];
@@ -404,6 +406,9 @@ export type LiveTurn = {
404
406
  stepAt?: number[];
405
407
  /** Per step: its text is the provider's finished answer, secret-checked whole (lead.ts `final`). */
406
408
  confirmed?: boolean[];
409
+ /** The shared live-reply core (live-reply.ts): the only source of what the chat room shows — one status line, the
410
+ * tools behind a disclosure, and shaped, settled partial text. `steps[].text` above is raw and never sent. */
411
+ reply?: import("../live-reply.js").LiveReply;
407
412
  };
408
413
  export type ChatEnablement = {
409
414
  ok: true;
@@ -825,7 +825,7 @@ export function replacedThreadHtml(previous) {
825
825
  * A div, not a p: the Remove form can't sit inside a paragraph (the parser would close it first). */
826
826
  function queuedStateHtml(row, back) {
827
827
  if (row.status === "queued") {
828
- const waiting = row.error?.startsWith("Waiting: ") === true ? row.error.slice("Waiting: ".length) : "It goes to the lead when it finishes.";
828
+ const waiting = row.error?.startsWith("Waiting: ") === true ? row.error.slice("Waiting: ".length) : "Waiting for your earlier messages…";
829
829
  return html `<div class="so-queued" data-queued="${row.message}" data-queue-state="queued"><span class="so-queued-state">Queued</span> · ${waiting}${postForm(`/chat/queued/${row.message}/remove`, html `<button type="submit" class="so-queued-remove quiet">Remove</button>`, { attrs: { class: "inline" }, ...(back === null ? {} : { returnTo: back }) })}</div>`;
830
830
  }
831
831
  if (row.status === "running" && row.turnId === null)
@@ -949,6 +949,8 @@ export function leadThreadHtml(data) {
949
949
  const ask = message.turn === null ? undefined : data.asks?.get(message.turn);
950
950
  thread.push(html `<div class="msg mate" data-message-role="assistant" data-key="m${message.id}"${message.id === latestReply ? html ` id="latest"` : ""}>${renderChatText(message.text, asked)}${ask === undefined ? "" : leadAskHtml(ask, { task: data.focusTask?.id ?? null, project: data.chatProject ?? null }, "chat-message", data.pending === null && data.ask?.turn === ask.turn)}${cards.map(one => leadProposalCard(one, inert, data.decisions.get(typeof one.payload["decision"] === "number" ? one.payload["decision"] : -1) ?? null, data.focusTask === null ? null : returnTo))}<div class="chat-message-foot">${chatActivity(message.activity)}<time datetime="${message.createdAt}">${relativeAge(message.createdAt, data.now)}</time></div></div>`);
951
951
  }
952
+ if (data.pending === null && data.failure)
953
+ thread.push(html `<div class="problem" data-key="failed" data-lead-failed role="alert">${data.failure}</div>`);
952
954
  parts.push(html `<div class="thread" data-key="thread" data-chat-list>\n${joinHtml(thread, "\n")}\n</div>`);
953
955
  if (data.pending !== null) {
954
956
  const subscription = data.pending.reservedMicrousd === 0;
@@ -1033,7 +1035,7 @@ export function leadPage(chrome, data) {
1033
1035
  sessionId, user: approver, version: leadChatVersion(data),
1034
1036
  messages: leadBrowserMessages(data, data.focusTask === null && chatProject === null ? null : returnTo, { task: data.focusTask?.id ?? null, project: chatProject }),
1035
1037
  pendingTurnId: data.pending?.id ?? null, requestId: randomBytes(16).toString('hex'), maxChars: LEAD_MESSAGE_MAX_CHARS,
1036
- taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject,
1038
+ taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject, failure: data.pending === null ? data.failure ?? null : null,
1037
1039
  ...(data.previous == null ? {} : { previous: { messages: replacedBrowserMessages(data.previous) } }),
1038
1040
  },
1039
1041
  focus: data.focusTask === null ? null : { id: data.focusTask.id, title: data.focusTask.title,
@@ -41,6 +41,14 @@ export declare function leadSettingsHtml(data: {
41
41
  when: string;
42
42
  until: string;
43
43
  }[];
44
+ /** Quick answers (responsive-chat.md §8): whether they're on, the faster model's name, whether this person may change
45
+ * them (an instance operator), and whether the last save was this one. Absent: not shown. */
46
+ quick?: {
47
+ on: boolean;
48
+ model: string;
49
+ canChange: boolean;
50
+ saved: boolean;
51
+ };
44
52
  /** D5: the lead's subagents in the projects this person can use: name and role, project, and whether it's paused. */
45
53
  subagents?: {
46
54
  id: number;
@@ -11,7 +11,7 @@ import {} from "../telegram.js";
11
11
  import { chatMoney, screen, when } from "./chrome.js";
12
12
  import { taskHref } from "./http.js";
13
13
  import { html, joinHtml, postForm } from "../html.js";
14
- import { acts, btn, chip, codeLine, disclose, facts, formRow, input, note, notice, select, settingsLink, settingsRecord, settingsSection, textarea } from "./kit.js";
14
+ import { acts, btn, chip, codeLine, disclose, facts, formRow, input, note, notice, select, settingsLink, settingsRecord, settingsSection, switchInput, textarea } from "./kit.js";
15
15
  import { kitSectionForm, SECRET_OPT_OUTS } from "../settings-kit.js";
16
16
  import { settingsSubPage } from "../settings-groups-ui.js";
17
17
  /** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
@@ -43,6 +43,17 @@ export function leadSettingsHtml(data) {
43
43
  attrs: { maxlength: ABOUT_YOU_MAX_LINES * (ABOUT_YOU_LINE_MAX + 1), placeholder: "Keep copy terse.\nI test changes myself.", "aria-label": "What your lead knows about you" } }), "top"),
44
44
  html `<div class="k-ssave">${savedLine(data.aboutSaved === true)}${btn("Save", { sm: true, type: "submit" })}</div>`,
45
45
  ]), { attrs: { "data-lead-about": true } }) });
46
+ // Quick answers: plain status questions go to a faster model; anything that asks for a change stays on the main one.
47
+ const quick = data.quick === undefined || config === null ? "" : settingsSection({ id: "lead-quick", title: "Quick answers",
48
+ lead: config.provider === "claude-subscription"
49
+ ? `Short questions about what's going on, like "what's running?", are answered by ${data.quick.model}, a faster model. Anything that asks for a change still goes to the lead's main model.`
50
+ : "Short questions about what's going on can go to a faster model. This works only when the lead uses the Anthropic membership.",
51
+ body: data.quick.canChange
52
+ ? postForm("/settings/lead/quick", joinHtml([
53
+ switchInput({ id: "lead-quick", name: "enabled", label: "Quick answers use a faster model", on: data.quick.on }),
54
+ html `<div class="k-ssave">${savedLine(data.quick.saved)}${btn("Save", { sm: true, type: "submit" })}</div>`,
55
+ ]), { attrs: { "data-lead-quick": true } })
56
+ : note(data.quick.on ? "On." : "Off.") });
46
57
  // What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
47
58
  const promises = (data.promises ?? []).length === 0 ? "" : settingsSection({ id: "lead-promises", title: "Promises", lead: "What the lead said it would follow up on. Each is reported once, then dropped after 7 days.",
48
59
  body: joinHtml(data.promises.map(one => settingsRecord({
@@ -69,7 +80,7 @@ export function leadSettingsHtml(data) {
69
80
  save: { label: "Turn the lead off", variant: "danger" }, confirm: "Turn the lead off", body: note("The lead stops answering until you turn it on again.") }) });
70
81
  return settingsSubPage("agents", "Lead", joinHtml([
71
82
  data.problem ? notice(data.problem.charAt(0).toUpperCase() + data.problem.slice(1)) : data.said === null ? "" : html `<p class="k-notice info" role="status">${data.said}</p>`,
72
- runs, identity, about, promises, subagents,
83
+ runs, identity, about, quick, promises, subagents,
73
84
  disclose("Advanced", joinHtml([leadConfigForm(config, facts), keys, off])),
74
85
  ], "\n"));
75
86
  }
@@ -619,6 +619,8 @@ post("chat", "onboarding.phone-dismiss", "/onboarding/phone/dismiss", approver("
619
619
  const LEAD_WORDS = { identity: "An approver names their lead.", about: "An approver edits what their lead knows about them.", "promise/cancel": "An approver manages the lead's promises.", on: "An approver turns the lead on." };
620
620
  for (const one of ["identity", "about", "promise/cancel", "on"])
621
621
  post("settings", `settings.lead-${one.replaceAll("/", "-")}`, `/settings/lead/${one}`, approver(LEAD_WORDS[one], "/settings/lead"));
622
+ // Quick answers is installation-wide: which model answers everyone's plain questions (responsive-chat.md §8).
623
+ post("settings", "settings.lead-quick", "/settings/lead/quick", { callers: ["cookie"], ...operator("An instance operator chooses how the lead answers.", "/settings/lead") });
622
624
  post("chat", "chat.config", "/chat/config", { scope: "step-up" });
623
625
  for (const one of ["mint", "follow", "end", "stop"])
624
626
  post("chat", `chat.mate-${one}`, `/chat/mate/${one}`, { callers: ["cookie"], ...(one === "mint" ? { scope: "step-up" } : {}) });
@@ -76,6 +76,7 @@ import { cleanUpHtml, decisionsHtml, framedSection, knowledgeHtml, MEMORY_INTRO,
76
76
  import { aboutYouOf, checkAboutYou, saveAboutYou } from "../lead-about.js";
77
77
  import { cancelCommitment, conditionWords, openCommitments } from '../lead-commitments.js';
78
78
  import { checkLeadIdentity, leadIdentityOf } from "../lead-identity.js";
79
+ import { modelName as leadModelName, quickLeadModel } from "../lead-route.js";
79
80
  import { mailboxAccess, readThroughImap } from "../mailbox.js";
80
81
  import { startConnect } from "../mcp-connect.js";
81
82
  import { decideProposal, defaultTidyAnalyzer, listProposals, memoryStatus } from "../memory-pass.js";
@@ -122,6 +123,7 @@ import { addProjectInstructions, ASSISTANTS, detectPreparation, modelChoices, pr
122
123
  import { skillsHtml, skillsScript } from "../skills-ui.js";
123
124
  import { checkSlackCredentials, clearSlackCredentials, loadSlackCredentials, saveSlackCredentials, SLACK_MANIFEST, SlackError } from "../slack-api.js";
124
125
  import { slackSettingsHtml } from "../slack-settings.js";
126
+ import { ACK_REFUSED_META } from "../chat-live.js";
125
127
  import { spendHtml, spendScript } from "../spend-ui.js";
126
128
  import { approvalFactsNow } from "./kit.js";
127
129
  import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
@@ -135,7 +137,7 @@ import { sweepSchedule } from "../storage-sweep-schedule.js";
135
137
  import { storageHtml } from "../storage-ui.js";
136
138
  import { landedSummary } from "../landed-cleanup.js";
137
139
  import { bytesWords, parseCleanup, parseIdle } from "../storage.js";
138
- import { checkoutIdle, isDigestTime, liveBackupState, setCheckoutIdle, localModeOf, RESULT_SCREENSHOTS, setLiveBackupSettings, setLocalModeOff } from "../store.js";
140
+ import { checkoutIdle, isDigestTime, leadQuickAnswersOn, liveBackupState, setCheckoutIdle, localModeOf, RESULT_SCREENSHOTS, setLeadQuickAnswers, setLiveBackupSettings, setLocalModeOff } from "../store.js";
139
141
  import { defaultLiveFolder, liveFolderOf, liveFolderProblem } from "../live-backup.js";
140
142
  import { LIVE_EVERY } from "../backup-ui.js";
141
143
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
@@ -673,6 +675,7 @@ ${restoreHtml(chosen, retired, undoableMerges(store, chosen), disabledLessons(st
673
675
  return sendScreen(response, 200, screen("Lead", leadSettingsHtml({ config, facts, words: leadWords(), signedIn, command: agentSignInCommand(),
674
676
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), saved: url.searchParams.get("saved") === "1", identity: leadIdentityOf(store, who.name), promises,
675
677
  about: aboutYouOf(store, who.name), aboutSaved: url.searchParams.get("saved") === "about",
678
+ quick: { on: leadQuickAnswersOn(store), model: leadModelName(quickLeadModel(store)), canChange: who.role === "approver" && store.isInstanceOperator(who.name), saved: url.searchParams.get("saved") === "quick" },
676
679
  subagents: store.subagents(consoleProjects().filter(repo => store.accountCanAccess(who.name, repo))).map(one => ({ id: one.id, label: labelOf(one), project: projectName(one.repo), paused: one.state === "paused" })) }), { chrome: chromeFor(project, "settings") }));
677
680
  }
678
681
  async function telegramPage(ctx) {
@@ -2005,6 +2008,8 @@ ${restoreHtml(chosen, retired, undoableMerges(store, chosen), disabledLessons(st
2005
2008
  if (credentials || loadSlackCredentials(dir))
2006
2009
  return show("Disconnect the current Slack app before connecting another.", 409);
2007
2010
  saveSlackCredentials(dir, checked);
2011
+ // A fresh connection asks for the 👀 again at once (an app reinstalled with reactions:write).
2012
+ state.deleteMeta(checked.installation, ACK_REFUSED_META);
2008
2013
  }
2009
2014
  catch (error) {
2010
2015
  return show(error instanceof SlackError ? error.message : "Slack could not be connected. Check the tokens and try again.");
@@ -2906,6 +2911,7 @@ ${postForm("/settings/chat-approval/save", html `\
2906
2911
  "mode.revoke": modeRevoke,
2907
2912
  "settings.lead-identity": leadIdentity,
2908
2913
  "settings.lead-about": leadAbout,
2914
+ "settings.lead-quick": leadQuick,
2909
2915
  "settings.lead-promise-cancel": leadPromiseCancel,
2910
2916
  "settings.lead-on": leadOn,
2911
2917
  "settings.code-map": codeMapAct("switch"),
@@ -3223,5 +3229,12 @@ ${postForm("/settings/chat-approval/save", html `\
3223
3229
  });
3224
3230
  return done.ok ? back("said", "Saved. Tasks scoped from now on use it; tasks already approved keep the chain they were approved with.") : back("problem", `Nothing changed: ${done.problem}`);
3225
3231
  }
3232
+ // Settings → Lead: quick answers on or off (installation-wide; the route admits an instance operator only).
3233
+ async function leadQuick(ctx) {
3234
+ const { who, response, now, posted } = ctx;
3235
+ const body = readForm(posted, CONSOLE_FORMS.leadQuick);
3236
+ setLeadQuickAnswers(store, body.get("enabled") === "on", who.name, now);
3237
+ return redirect(response, "/settings/lead?saved=quick");
3238
+ }
3226
3239
  return { registrations, modelsView };
3227
3240
  }
package/dist/slack-api.js CHANGED
@@ -19,6 +19,10 @@ export class SlackError extends ChatDeliveryError {
19
19
  channel_not_found: "The Slack conversation is unavailable. Pair your account again.",
20
20
  message_not_found: "The original Slack message was removed. Open the saved chat to recover it.",
21
21
  already_complete: "Waiting to confirm the uploaded file’s receipt.",
22
+ cant_update_message: "Slack wouldn't let that message be changed.",
23
+ edit_window_closed: "Slack wouldn't let that message be changed any more.",
24
+ already_reacted: "The reaction was already there.",
25
+ no_reaction: "The reaction was already gone.",
22
26
  }[code] ?? code;
23
27
  }
24
28
  }
@@ -36,6 +40,11 @@ const errors = new Set([
36
40
  "file_not_found",
37
41
  "already_complete",
38
42
  "not_in_channel",
43
+ // The 👀 and edits of the live message: named, so they are told apart from a real failure.
44
+ "already_reacted",
45
+ "no_reaction",
46
+ "cant_update_message",
47
+ "edit_window_closed",
39
48
  ]);
40
49
  export function slackApi(token, fetcher = fetch) {
41
50
  return async (method, args = {}) => {
@@ -208,6 +217,8 @@ export const SLACK_MANIFEST = {
208
217
  "users:read",
209
218
  "files:read",
210
219
  "files:write",
220
+ // The 👀 on a message when it arrives, and taking it off once the answer is delivered.
221
+ "reactions:write",
211
222
  ],
212
223
  },
213
224
  },