toolroll 0.9.95 → 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 (75) hide show
  1. package/CHANGELOG.md +13 -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/card-render.d.ts +18 -1
  6. package/dist/card-render.js +40 -3
  7. package/dist/chat-delivery.d.ts +5 -1
  8. package/dist/chat-delivery.js +74 -25
  9. package/dist/chat-follow.d.ts +6 -3
  10. package/dist/chat-follow.js +23 -7
  11. package/dist/chat-live-schema.d.ts +17 -0
  12. package/dist/chat-live-schema.js +32 -0
  13. package/dist/chat-live.d.ts +161 -0
  14. package/dist/chat-live.js +491 -0
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/discord-api.d.ts +3 -1
  19. package/dist/discord-api.js +9 -3
  20. package/dist/discord-chat.d.ts +13 -3
  21. package/dist/discord-chat.js +77 -21
  22. package/dist/discord.js +8 -1
  23. package/dist/lead-answer-eval.d.ts +86 -0
  24. package/dist/lead-answer-eval.js +255 -0
  25. package/dist/lead-cli-live.d.ts +56 -0
  26. package/dist/lead-cli-live.js +200 -0
  27. package/dist/lead-cli.d.ts +3 -0
  28. package/dist/lead-cli.js +34 -4
  29. package/dist/lead-latency-schema.d.ts +24 -0
  30. package/dist/lead-latency-schema.js +36 -0
  31. package/dist/lead-progress.d.ts +10 -1
  32. package/dist/lead-progress.js +1 -1
  33. package/dist/lead-queue-runtime.d.ts +1 -1
  34. package/dist/lead-queue-runtime.js +1 -1
  35. package/dist/lead-route.d.ts +56 -0
  36. package/dist/lead-route.js +104 -0
  37. package/dist/lead.d.ts +4 -2
  38. package/dist/lead.js +79 -26
  39. package/dist/live-reply.d.ts +83 -0
  40. package/dist/live-reply.js +302 -0
  41. package/dist/operate.js +39 -1
  42. package/dist/project-knowledge.d.ts +3 -3
  43. package/dist/reply-shape.d.ts +9 -0
  44. package/dist/reply-shape.js +13 -0
  45. package/dist/serve.js +11 -4
  46. package/dist/server/chat.d.ts +2 -1
  47. package/dist/server/chat.js +18 -6
  48. package/dist/server/live.js +18 -21
  49. package/dist/server/render-chat.d.ts +5 -0
  50. package/dist/server/render-chat.js +4 -2
  51. package/dist/server/render-settings.d.ts +8 -0
  52. package/dist/server/render-settings.js +13 -2
  53. package/dist/server/route-table.js +2 -0
  54. package/dist/server/settings.js +14 -1
  55. package/dist/slack-api.js +11 -0
  56. package/dist/slack-chat.d.ts +11 -1
  57. package/dist/slack-chat.js +50 -5
  58. package/dist/slack-settings.js +8 -1
  59. package/dist/slack.js +10 -3
  60. package/dist/store.d.ts +31 -2
  61. package/dist/store.js +59 -6
  62. package/dist/subscription-chat.d.ts +2 -0
  63. package/dist/subscription-chat.js +3 -1
  64. package/dist/teams-chat.d.ts +9 -2
  65. package/dist/teams-chat.js +50 -2
  66. package/dist/teams.js +5 -2
  67. package/dist/teardown-git.js +33 -3
  68. package/dist/telegram-lead.d.ts +3 -0
  69. package/dist/telegram-lead.js +63 -9
  70. package/dist/telegram-live.d.ts +116 -0
  71. package/dist/telegram-live.js +224 -0
  72. package/dist/telegram.d.ts +2 -0
  73. package/dist/telegram.js +50 -2
  74. package/dist/toolroll-update.js +4 -1
  75. package/package.json +1 -1
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The shared live-reply core (docs/plans/responsive-chat.md §2): one lead turn's `LeadProgress` turned into text-only
3
+ * views that every surface renders. Surfaces never read `LeadProgress` themselves.
4
+ *
5
+ * queued → working (one status line) → writing (status + display-safe partial text) → closed (answered | failed | blocked)
6
+ *
7
+ * Nothing here is durable or authoritative: the saved thread message and its cards remain the record, and no view ever
8
+ * carries a card, a button, an ask or an approval. A partial is the reply as written so far, cut back to what is
9
+ * settled (never an unfinished word, code span, link or bold run), withheld while its tail could be the start of a
10
+ * password or key, and shaped with the final reply's own presentation policy (presentation, not redaction).
11
+ *
12
+ * `createLiveSender` is the one sender per live surface message (§2.4): at most one request in flight, one pending
13
+ * slot holding only the newest view, a generation that only grows, and `close()` — after which nothing more is sent,
14
+ * and which resolves only once the request in flight has settled, so durable delivery can start strictly after it.
15
+ */
16
+ import { scanForSecrets } from "./evidence.js";
17
+ import { shapeReply, shapeReplyPrefix } from "./reply-shape.js";
18
+ /** The status line's fixed words (§2.3). A running tool reads as its own label plus "…". */
19
+ export const LIVE_STATUS = {
20
+ queued: "Waiting for your earlier messages…",
21
+ thinking: "Thinking…",
22
+ writing: "Writing…",
23
+ repair: "Fixing the reply…",
24
+ shorten: "Shortening the reply…",
25
+ /** A preview closed because text looked like a password or key; the turn itself may still answer. */
26
+ held: "Working on it…",
27
+ };
28
+ /** One token that could still grow into a match of a secret family: a non-empty prefix of one of its openings, or the
29
+ * whole opening followed by its body characters. Preceded by a non-word character or the start, as the detectors'
30
+ * own word boundaries are. */
31
+ function opening(leads, body) {
32
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
33
+ const alternatives = new Set();
34
+ for (const lead of leads) {
35
+ for (let length = 1; length < lead.length; length += 1)
36
+ alternatives.add(escape(lead.slice(0, length)));
37
+ alternatives.add(`${escape(lead)}${body}*`);
38
+ }
39
+ return `(?:${[...alternatives].join("|")})`;
40
+ }
41
+ /**
42
+ * The start of every `SECRET_PATTERNS` family (evidence.ts), checked against the end of the text written so far:
43
+ * while any matches, the partial is withheld. Single-token families look at the unfinished last token; the families
44
+ * whose match spans spaces (a private-key header, an npm `_authToken = value`) look at the whole last line.
45
+ */
46
+ const SECRET_STARTS = [
47
+ // private-key: "-----BEGIN RSA PRIVATE KEY-----", partway. No start boundary: the complete detector has none either.
48
+ /-{1,5}(?:B(?:E(?:G(?:I(?:N(?:[ \t][A-Z \t]*-{0,5})?)?)?)?)?)?$/,
49
+ // npm-auth-token: "_authToken = value", partway.
50
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9])${opening(["_authToken"], String.raw `[\s=]`)}\S*$`),
51
+ // aws-access-key, github-token, slack-token, openai-key, stripe-key, google-api-key, gitlab-token.
52
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["AKIA", "ASIA"], "[0-9A-Z]")}$`),
53
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"], "[A-Za-z0-9_]")}$`),
54
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"], "[A-Za-z0-9-]")}$`),
55
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["sk-"], "[A-Za-z0-9_-]")}$`),
56
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["sk_live_", "sk_test_", "rk_live_", "rk_test_", "whsec_"], "[A-Za-z0-9]")}$`),
57
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["AIza"], "[0-9A-Za-z_-]")}$`),
58
+ new RegExp(String.raw `(?:^|[^A-Za-z0-9_])${opening(["glpat-"], "[A-Za-z0-9_-]")}$`),
59
+ // telegram-bot-token: "123456789:AA…".
60
+ /(?:^|[^A-Za-z0-9_])\d+(?::(?:A(?:A[A-Za-z0-9_-]*)?)?)?$/,
61
+ // discord-bot-token: "M…24.…6.…27".
62
+ /(?:^|[^A-Za-z0-9_])[MN][A-Za-z\d]*(?:\.[\w-]*(?:\.[\w-]*)?)?$/,
63
+ // password-in-url: "scheme://user:password@host", partway (a scheme and its colon are enough to wait).
64
+ /(?:^|[^A-Za-z0-9+.-])[a-z][a-z0-9+.-]*:\S*$/i,
65
+ ];
66
+ /** Could the end of this text be the start of a password or key? Then nothing of it shows yet. */
67
+ export function secretMayStart(text) {
68
+ const lastLine = text.replace(/\r\n?/g, "\n").split("\n").at(-1) ?? "";
69
+ return SECRET_STARTS.some(pattern => pattern.test(lastLine));
70
+ }
71
+ /**
72
+ * What of a reply still being written is settled: everything before its last whitespace (the unfinished last word
73
+ * stays out), then cut back before any code fence, code span, bold run or link that hasn't closed yet. Trailing
74
+ * whitespace is trimmed.
75
+ */
76
+ export function settledText(raw) {
77
+ const text = raw.replace(/\r\n?/g, "\n");
78
+ let end = text.length;
79
+ while (end > 0 && !/\s/.test(text[end - 1]))
80
+ end -= 1;
81
+ const shown = text.slice(0, end);
82
+ let fence = -1, code = -1, bold = -1, link = -1;
83
+ let linkState = null;
84
+ for (let index = 0; index < shown.length; index += 1) {
85
+ if (shown.startsWith("```", index)) {
86
+ fence = fence === -1 ? index : -1;
87
+ index += 2;
88
+ continue;
89
+ }
90
+ if (fence !== -1)
91
+ continue;
92
+ const char = shown[index];
93
+ if (char === "\n") {
94
+ code = -1;
95
+ bold = -1;
96
+ link = -1;
97
+ linkState = null;
98
+ continue;
99
+ }
100
+ if (char === "`") {
101
+ code = code === -1 ? index : -1;
102
+ continue;
103
+ }
104
+ if (code !== -1)
105
+ continue;
106
+ if (shown.startsWith("**", index)) {
107
+ bold = bold === -1 ? index : -1;
108
+ index += 1;
109
+ continue;
110
+ }
111
+ if (char === "[" && linkState === null) {
112
+ link = index;
113
+ linkState = "label";
114
+ continue;
115
+ }
116
+ if (linkState === "label" && char === "]") {
117
+ if (shown[index + 1] === "(") {
118
+ linkState = "url";
119
+ index += 1;
120
+ }
121
+ else {
122
+ link = -1;
123
+ linkState = null;
124
+ }
125
+ continue;
126
+ }
127
+ if (linkState === "url" && (char === ")" || /\s/.test(char))) {
128
+ link = -1;
129
+ linkState = null;
130
+ }
131
+ }
132
+ const cut = Math.min(...[fence, code, bold, link].filter(at => at !== -1), shown.length);
133
+ return shown.slice(0, cut).trimEnd();
134
+ }
135
+ /**
136
+ * A partial as it may be shown: `blocked` when it holds a password or key (the preview closes), `withheld` while its
137
+ * tail could still become one (show nothing new), else the settled text shaped for display. A step's finished text
138
+ * (`final`, secret-checked whole by the engine) shows whole.
139
+ */
140
+ export function partialDisplay(raw, final, shape = {}) {
141
+ if (scanForSecrets(raw).length > 0)
142
+ return { kind: "blocked" };
143
+ if (!final && secretMayStart(raw))
144
+ return { kind: "withheld" };
145
+ const text = final ? shapeReply(raw, shape) : shapeReplyPrefix(settledText(raw), shape);
146
+ // Shaping only relabels and removes; a secret in what it returns would mean it came from somewhere else. Never shown.
147
+ if (scanForSecrets(text).length > 0)
148
+ return { kind: "blocked" };
149
+ return { kind: "shown", text };
150
+ }
151
+ /** One turn's live reply. `shape`: the owner's message (`asked`) and this app's origins, as the final reply is shaped. */
152
+ export function createLiveReply(shape = {}, options = {}) {
153
+ const steps = [];
154
+ let queued = options.queued === true;
155
+ let closed = null;
156
+ let turn = null;
157
+ let last = "";
158
+ const current = () => steps.at(-1) ?? (steps.push({ calls: [], raw: "", shown: "" }), steps.at(-1));
159
+ const status = () => {
160
+ const step = steps.at(-1);
161
+ const running = step?.calls.filter(call => call.state === "running").at(-1);
162
+ if (running !== undefined)
163
+ return `${running.label}…`;
164
+ if (step?.reason !== undefined)
165
+ return LIVE_STATUS[step.reason];
166
+ if (closed === "blocked")
167
+ return LIVE_STATUS.held;
168
+ if (step !== undefined && step.raw.trim() !== "")
169
+ return LIVE_STATUS.writing;
170
+ return LIVE_STATUS.thinking;
171
+ };
172
+ const view = () => {
173
+ if (closed !== null)
174
+ return { phase: "closed", why: closed };
175
+ if (queued && steps.length === 0)
176
+ return { phase: "queued" };
177
+ const text = [...steps].reverse().find(step => step.shown !== "")?.shown ?? "";
178
+ return text === "" ? { phase: "working", status: status() } : { phase: "writing", status: status(), text };
179
+ };
180
+ const changed = () => {
181
+ const now = JSON.stringify(view());
182
+ if (now === last)
183
+ return false;
184
+ last = now;
185
+ return true;
186
+ };
187
+ last = JSON.stringify(view());
188
+ return {
189
+ get turn() { return turn; },
190
+ push(event) {
191
+ if (closed !== null)
192
+ return false;
193
+ turn ??= event.turn;
194
+ queued = false;
195
+ switch (event.kind) {
196
+ case "started": break;
197
+ case "step":
198
+ steps.push({ calls: [], raw: "", shown: "", ...(event.reason === undefined ? {} : { reason: event.reason }) });
199
+ break;
200
+ case "tool":
201
+ current().calls.push({ id: event.id, label: event.label, state: "running" });
202
+ break;
203
+ case "tool-result":
204
+ for (const step of steps) {
205
+ const index = step.calls.findIndex(call => call.id === event.id);
206
+ if (index !== -1) {
207
+ step.calls[index] = { id: event.id, label: step.calls[index].label, ...event.outcome };
208
+ break;
209
+ }
210
+ }
211
+ break;
212
+ case "text": {
213
+ const step = current();
214
+ step.raw = event.text;
215
+ const shown = partialDisplay(event.text, event.final === true, shape);
216
+ if (shown.kind === "blocked") {
217
+ closed = "blocked";
218
+ for (const one of steps)
219
+ one.shown = "";
220
+ }
221
+ else if (shown.kind === "shown")
222
+ step.shown = shown.text;
223
+ break;
224
+ }
225
+ case "blocked":
226
+ closed = "blocked";
227
+ for (const one of steps)
228
+ one.shown = "";
229
+ break;
230
+ }
231
+ return changed();
232
+ },
233
+ close(why) {
234
+ if (closed === null)
235
+ closed = why;
236
+ for (const one of steps)
237
+ one.shown = "";
238
+ changed();
239
+ },
240
+ view,
241
+ status,
242
+ tools: () => steps.map(step => step.calls.map(call => ({ ...call }))),
243
+ };
244
+ }
245
+ /**
246
+ * The serialized sender for one live surface message (§2.4). `standing` re-proves the viewer's right to see this turn
247
+ * before every send (it may await; the close is re-checked after it). `spacingMs` is the least time between two sends,
248
+ * read before each one (a surface widens it after the platform asks for a pause). A failed send drops that view:
249
+ * previews are best effort.
250
+ */
251
+ export function createLiveSender(options) {
252
+ const spacing = () => typeof options.spacingMs === "function" ? options.spacingMs() : options.spacingMs ?? 0;
253
+ const now = options.now ?? Date.now;
254
+ let generation = 0;
255
+ let closed = false;
256
+ let pending = null;
257
+ let inflight = null;
258
+ let timer = null;
259
+ let lastSent = Number.NEGATIVE_INFINITY;
260
+ const pump = () => {
261
+ if (closed || inflight !== null || timer !== null || pending === null)
262
+ return;
263
+ const wait = lastSent + spacing() - now();
264
+ if (wait > 0) {
265
+ timer = setTimeout(() => { timer = null; pump(); }, wait);
266
+ timer.unref?.();
267
+ return;
268
+ }
269
+ const next = pending;
270
+ pending = null;
271
+ inflight = (async () => {
272
+ try {
273
+ if (options.standing !== undefined && !(await options.standing()))
274
+ return;
275
+ if (closed)
276
+ return;
277
+ lastSent = now();
278
+ await options.send(next.view, next.generation);
279
+ }
280
+ catch { /* best effort: this view is dropped */ }
281
+ })().finally(() => { inflight = null; pump(); });
282
+ };
283
+ return {
284
+ get generation() { return generation; },
285
+ get closed() { return closed; },
286
+ offer(view) {
287
+ if (closed)
288
+ return;
289
+ generation += 1;
290
+ pending = { view, generation };
291
+ pump();
292
+ },
293
+ async close() {
294
+ closed = true;
295
+ pending = null;
296
+ if (timer !== null)
297
+ clearTimeout(timer);
298
+ timer = null;
299
+ await inflight;
300
+ },
301
+ };
302
+ }
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();