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.
- package/CHANGELOG.md +13 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/card-render.d.ts +18 -1
- package/dist/card-render.js +40 -3
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/lead-answer-eval.d.ts +86 -0
- package/dist/lead-answer-eval.js +255 -0
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +79 -26
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/operate.js +39 -1
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +18 -6
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +4 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +31 -2
- package/dist/store.js +59 -6
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/teardown-git.js +33 -3
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +63 -9
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +50 -2
- package/dist/toolroll-update.js +4 -1
- 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
|
} | {
|
package/dist/reply-shape.d.ts
CHANGED
|
@@ -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
|
package/dist/reply-shape.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/server/chat.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/chat.js
CHANGED
|
@@ -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
|
-
|
|
1007
|
-
|
|
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();
|