toolroll 0.9.96 → 0.9.98

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 (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +32 -32
  6. package/dist/browser-workspace.d.ts +6 -3
  7. package/dist/chat-actions.d.ts +40 -5
  8. package/dist/chat-actions.js +124 -13
  9. package/dist/chat-card.js +4 -1
  10. package/dist/chat-channel.d.ts +3 -0
  11. package/dist/chat-channel.js +24 -7
  12. package/dist/chat-decide.js +4 -2
  13. package/dist/chat-delivery.d.ts +5 -1
  14. package/dist/chat-delivery.js +74 -25
  15. package/dist/chat-follow.d.ts +6 -3
  16. package/dist/chat-follow.js +23 -7
  17. package/dist/chat-live-schema.d.ts +17 -0
  18. package/dist/chat-live-schema.js +32 -0
  19. package/dist/chat-live.d.ts +161 -0
  20. package/dist/chat-live.js +491 -0
  21. package/dist/chat-task-actions.js +1 -1
  22. package/dist/chat-terms.d.ts +8 -0
  23. package/dist/chat-terms.js +14 -0
  24. package/dist/chat-voice.js +1 -1
  25. package/dist/contracts/chat-actions.d.ts +6 -0
  26. package/dist/contracts/chat-actions.js +2 -0
  27. package/dist/contracts/cli.d.ts +6 -0
  28. package/dist/contracts/cli.js +3 -0
  29. package/dist/contracts/console-api.d.ts +1 -0
  30. package/dist/contracts/console-api.js +1 -0
  31. package/dist/contracts/lead-tools.d.ts +17 -0
  32. package/dist/contracts/lead-tools.js +4 -1
  33. package/dist/contracts/store-json.d.ts +1 -0
  34. package/dist/discord-api.d.ts +3 -1
  35. package/dist/discord-api.js +9 -3
  36. package/dist/discord-chat.d.ts +13 -3
  37. package/dist/discord-chat.js +77 -21
  38. package/dist/discord.js +8 -1
  39. package/dist/fast-start.d.ts +83 -6
  40. package/dist/fast-start.js +112 -15
  41. package/dist/home-groups.d.ts +39 -0
  42. package/dist/home-groups.js +33 -0
  43. package/dist/lead-answer-eval.d.ts +93 -0
  44. package/dist/lead-answer-eval.js +259 -0
  45. package/dist/lead-capabilities.js +1 -1
  46. package/dist/lead-cli-live.d.ts +56 -0
  47. package/dist/lead-cli-live.js +200 -0
  48. package/dist/lead-cli.d.ts +3 -0
  49. package/dist/lead-cli.js +34 -4
  50. package/dist/lead-context.js +2 -2
  51. package/dist/lead-contract.d.ts +1 -1
  52. package/dist/lead-contract.js +10 -7
  53. package/dist/lead-doors.d.ts +32 -0
  54. package/dist/lead-doors.js +80 -14
  55. package/dist/lead-latency-schema.d.ts +24 -0
  56. package/dist/lead-latency-schema.js +36 -0
  57. package/dist/lead-progress.d.ts +10 -1
  58. package/dist/lead-progress.js +1 -1
  59. package/dist/lead-queue-runtime.d.ts +1 -1
  60. package/dist/lead-queue-runtime.js +1 -1
  61. package/dist/lead-route.d.ts +56 -0
  62. package/dist/lead-route.js +104 -0
  63. package/dist/lead-status.d.ts +3 -0
  64. package/dist/lead-status.js +8 -3
  65. package/dist/lead-tools.d.ts +20 -0
  66. package/dist/lead-tools.js +66 -3
  67. package/dist/lead-voice.js +1 -1
  68. package/dist/lead.d.ts +4 -2
  69. package/dist/lead.js +97 -28
  70. package/dist/live-reply.d.ts +83 -0
  71. package/dist/live-reply.js +302 -0
  72. package/dist/local-owner.js +2 -0
  73. package/dist/one-step-chat-schema.d.ts +22 -0
  74. package/dist/one-step-chat-schema.js +37 -0
  75. package/dist/operate.d.ts +27 -1
  76. package/dist/operate.js +245 -33
  77. package/dist/project-delete.js +9 -0
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +2 -0
  80. package/dist/publish.js +1 -1
  81. package/dist/pull-request-flow.d.ts +4 -0
  82. package/dist/pull-request-flow.js +16 -1
  83. package/dist/reply-shape.d.ts +9 -0
  84. package/dist/reply-shape.js +13 -0
  85. package/dist/reply-voice.d.ts +23 -0
  86. package/dist/reply-voice.js +37 -0
  87. package/dist/scope.d.ts +6 -1
  88. package/dist/scope.js +3 -2
  89. package/dist/serve.js +11 -4
  90. package/dist/server/chat.d.ts +2 -1
  91. package/dist/server/chat.js +22 -10
  92. package/dist/server/chrome.js +2 -2
  93. package/dist/server/home.d.ts +4 -3
  94. package/dist/server/home.js +33 -28
  95. package/dist/server/live.js +18 -21
  96. package/dist/server/render-chat.d.ts +5 -0
  97. package/dist/server/render-chat.js +15 -8
  98. package/dist/server/render-pages.js +2 -2
  99. package/dist/server/render-settings.d.ts +8 -0
  100. package/dist/server/render-settings.js +13 -2
  101. package/dist/server/route-table.js +2 -0
  102. package/dist/server/settings.js +14 -1
  103. package/dist/setup-guide.d.ts +2 -2
  104. package/dist/slack-api.js +11 -0
  105. package/dist/slack-chat.d.ts +11 -1
  106. package/dist/slack-chat.js +50 -5
  107. package/dist/slack-settings.js +8 -1
  108. package/dist/slack.js +10 -3
  109. package/dist/store.d.ts +33 -2
  110. package/dist/store.js +92 -16
  111. package/dist/subscription-chat.d.ts +2 -0
  112. package/dist/subscription-chat.js +3 -1
  113. package/dist/teams-chat.d.ts +9 -2
  114. package/dist/teams-chat.js +50 -2
  115. package/dist/teams.js +5 -2
  116. package/dist/telegram-lead.d.ts +3 -0
  117. package/dist/telegram-lead.js +132 -14
  118. package/dist/telegram-live.d.ts +116 -0
  119. package/dist/telegram-live.js +224 -0
  120. package/dist/telegram-status.js +9 -4
  121. package/dist/telegram.d.ts +12 -0
  122. package/dist/telegram.js +142 -10
  123. package/dist/toolroll-update.js +6 -1
  124. package/dist/tui-contract.d.ts +2 -0
  125. package/dist/tui-source.js +3 -0
  126. package/dist/tui.js +41 -41
  127. package/dist/typed-start.d.ts +51 -0
  128. package/dist/typed-start.js +98 -0
  129. package/dist/wake-hub.d.ts +72 -0
  130. package/dist/wake-hub.js +151 -0
  131. package/package.json +1 -1
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Live replies in Slack, Discord and Teams (docs/plans/responsive-chat.md §3.2, §3.5, slice 3): the 👀 on a person's
3
+ * message when it arrives, and one "Working on it…" message per turn that shows the lead's status and its reply as it
4
+ * is written, then becomes the answer.
5
+ *
6
+ * - The 👀 is put on beside the serial chat queue (a turn there can take a minute), from the saved event, for the
7
+ * owner's own direct message only, and taken off once the answer or the failure notice is delivered. It carries no
8
+ * project data: the lease and a live binding are its fence.
9
+ * - The live message is its own `chat_live` row (v157), never a `chat_part`: planning the reply still adds its own
10
+ * parts, and the first of them edits this message, so the event stays queued until then. Creation is
11
+ * creating → placed. On Discord a nonce makes a repeated create within its window return the same message, and
12
+ * a lookup that fails decides nothing; on Slack and Teams an answer that never came leaves the row uncertain, as
13
+ * does an older Discord create not found among the recent messages. An uncertain message is never posted again (the
14
+ * reply then posts fresh, and the event's problem says a "Working on it…" may be left above it). That is the
15
+ * promise: no duplicate, except after an uncertain create.
16
+ * - Edits go through one serialized sender per message (live-reply.ts): one request in flight, the newest view
17
+ * waiting, closed (and the request in flight settled) before the reply is planned. Every edit re-proves the chat's
18
+ * standing (`standing`, the same proof final delivery uses), then the lease after it. Once that fails, nothing more is
19
+ * edited, cleanup included: the last text already ends "(still writing — not final)".
20
+ * - Cosmetic sends share the app's cooldown with durable delivery: none goes while it holds, and a "slow down" on one
21
+ * sets it for everything. Final parts come first (they never wait on these), then the 👀, then live edits, which
22
+ * start each app's spacing conservatively and widen it on every "slow down".
23
+ *
24
+ * No live payload carries a button, a card or an ask: those exist only on the planned reply.
25
+ */
26
+ import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
27
+ import { renderReply, shapeReplyParts } from "./reply-shape.js";
28
+ /** The live message's fixed words. */
29
+ export const LIVE_WORDS = {
30
+ working: "Working on it…",
31
+ notFinal: "(still writing — not final)",
32
+ continues: "(continues…)",
33
+ maybeLeft: "A “Working on it…” message may be left above this reply.",
34
+ };
35
+ /** Each app's starting least time between two live edits (§3.5); a "slow down" doubles it, up to the most. */
36
+ export const LIVE_SPACING_MS = { slack: 1_500, discord: 1_250, teams: 2_000 };
37
+ const MOST_SPACING_MS = 10_000;
38
+ /** A message older than this when the 👀 would go gets none. */
39
+ const ACK_WINDOW_MS = 60_000;
40
+ /** One 👀 per person a second; a burst waits its turn. */
41
+ const ACK_SPACING_MS = 1_000;
42
+ /** After an app refused reactions (a missing permission), ask again at most this often (one quiet try a minute). */
43
+ const ACK_REFUSED_RETRY_MS = 60_000;
44
+ /** The chat_meta key that says reactions were refused, and when. */
45
+ export const ACK_REFUSED_META = "reactions-refused";
46
+ /** The transport's spacing for that switch (undefined: the app's own start). */
47
+ export const liveSpacingOf = (on) => typeof on === "object" ? on.spacingMs : undefined;
48
+ const gates = new Map();
49
+ function gateOf(state, installation, base) {
50
+ const key = `${state.provider}:${installation}`;
51
+ let gate = gates.get(key);
52
+ if (gate === undefined || gate.base !== base)
53
+ gates.set(key, gate = { spacing: base, base, liveNext: 0 });
54
+ return gate;
55
+ }
56
+ /** The app asked everyone to wait (shared with durable delivery). */
57
+ const coolingDown = (state, installation, now) => state.retryAt(installation) > now.toISOString();
58
+ /** A "slow down" on a cosmetic send: the whole connection waits, and live edits space out further. */
59
+ function slowDown(state, installation, gate, retryMs, now) {
60
+ state.deferUntil(installation, new Date(now.getTime() + Math.max(1_000, retryMs)).toISOString());
61
+ if (gate !== null)
62
+ gate.spacing = Math.min(MOST_SPACING_MS, Math.max(gate.base, gate.spacing * 2, 500));
63
+ }
64
+ const easeBack = (gate) => { gate.spacing = Math.max(gate.base, gate.spacing - (gate.spacing - gate.base) / 4); };
65
+ /** Forget every connection's live spacing (tests). */
66
+ export function resetLiveGates() { gates.clear(); }
67
+ // ---- rendering --------------------------------------------------------------------------------------------------
68
+ const italic = (platform, plain) => {
69
+ const words = renderReply(plain, platform);
70
+ return platform === "discord" ? `*${words}*` : `_${words}_`;
71
+ };
72
+ /**
73
+ * A view as one live message's text, rendered for the app and measured after rendering: the shaped reply's first
74
+ * window that fits (cut where the final split would cut, never inside a link, code or bold), "(continues…)" when
75
+ * more follows, then the status and the "not final" marker. Null: nothing to show (the turn ended).
76
+ */
77
+ export function liveText(view, platform, limit, shape = {}) {
78
+ switch (view.phase) {
79
+ case "closed": return null;
80
+ case "queued": return italic(platform, LIVE_STATUS.queued);
81
+ case "working": return italic(platform, view.status);
82
+ case "writing": {
83
+ const marker = italic(platform, view.status === LIVE_STATUS.writing ? LIVE_WORDS.notFinal : `${view.status} ${LIVE_WORDS.notFinal}`);
84
+ const room = Math.max(200, limit - marker.length - LIVE_WORDS.continues.length - 8);
85
+ const windows = shapeReplyParts(view.text, room, shape, shaped => renderReply(shaped, platform).length);
86
+ const first = renderReply(windows[0] ?? "", platform).slice(0, room);
87
+ const more = windows.length > 1 ? `\n\n${italic(platform, LIVE_WORDS.continues)}` : "";
88
+ return `${first}${more}\n\n${marker}`;
89
+ }
90
+ }
91
+ }
92
+ export function liveRow(state, event) {
93
+ const row = state.prepare("SELECT state, message, generation, problem, created FROM chat_live WHERE provider = :provider AND event = ?").get(event);
94
+ return row === undefined ? null : { state: String(row["state"]), message: row["message"] == null ? null : String(row["message"]),
95
+ generation: Number(row["generation"]), problem: row["problem"] == null ? null : String(row["problem"]), created: String(row["created"]) };
96
+ }
97
+ /** The message an unconfirmed create left: found, not among the recent messages (absent), or the lookup failed (a
98
+ * "slow down" is shared). */
99
+ async function recoverLive(state, installation, transport, now) {
100
+ try {
101
+ const found = await transport.recover();
102
+ return found === null ? { absent: true } : { found };
103
+ }
104
+ catch (error) {
105
+ const wait = transport.rateLimited(error);
106
+ if (wait !== null)
107
+ slowDown(state, installation, null, wait, now());
108
+ return { failed: true };
109
+ }
110
+ }
111
+ const setLive = (state, event, from, to, now, extra = {}) => Number(state.prepare(`UPDATE chat_live SET state = ?, message = COALESCE(?, message), problem = COALESCE(?, problem), updated = ?
112
+ WHERE provider = :provider AND event = ? AND state IN (SELECT value FROM json_each(?))`)
113
+ .run(to, extra.message ?? null, extra.problem ?? null, now.toISOString(), event, JSON.stringify(from)).changes) === 1;
114
+ /** Is this message id the live message of that event (the one its reply's first part edits)? */
115
+ export function isLiveMessage(state, event, message) {
116
+ if (message === undefined)
117
+ return false;
118
+ const row = liveRow(state, event);
119
+ return row !== null && row.message === message;
120
+ }
121
+ /** A first reply part that may replace the live message: words only, to the person's own chat. */
122
+ const editable = (part) => part !== undefined && part.edit === undefined && part.channel === undefined && part.proposal === undefined && part.image === undefined &&
123
+ part.rich === undefined && part.ask === undefined && part.decide === undefined && part.flow === undefined && part.choose === undefined &&
124
+ part.note === undefined && part.question === undefined && part.prompt === undefined;
125
+ /**
126
+ * Plan an event's reply (ChatState.plan) onto its live message: when one was placed, the first part edits it, and the
127
+ * row is finalized in the same transaction. An uncertain create (Slack, Teams) leaves the reply to post fresh, and
128
+ * the event's problem says a "Working on it…" may be left above it.
129
+ */
130
+ export function planWithLive(state, event, parts, now) {
131
+ state.store.transact(() => {
132
+ const row = liveRow(state, event);
133
+ const onto = row !== null && row.message !== null && (row.state === "placed" || row.state === "abandoned") && editable(parts[0]);
134
+ state.plan(event, onto ? [{ ...parts[0], edit: row.message }, ...parts.slice(1)] : parts, now);
135
+ if (row === null)
136
+ return;
137
+ if (onto)
138
+ setLive(state, event, ["placed", "abandoned"], "finalized", now);
139
+ else if (row.state === "creating" || row.state === "uncertain") {
140
+ setLive(state, event, ["creating", "uncertain"], "uncertain", now, { problem: LIVE_WORDS.maybeLeft });
141
+ state.prepare("UPDATE chat_event SET problem = ? WHERE provider = :provider AND id = ?").run(LIVE_WORDS.maybeLeft, event);
142
+ }
143
+ });
144
+ }
145
+ export function openChatLive(input) {
146
+ const { state, event, transport } = input;
147
+ const gate = gateOf(state, input.installation, transport.spacingMs ?? LIVE_SPACING_MS[transport.platform]);
148
+ const live = createLiveReply(input.shape);
149
+ let row = liveRow(state, event.id);
150
+ // Off for good: an uncertain or finished message, a refused create, or lost standing.
151
+ let off = row !== null && row.state !== "placed" && !(row.state === "creating" && transport.recover !== undefined);
152
+ // A create from before a restart on an app that can't tell whether it landed: uncertain, never posted again.
153
+ if (row?.state === "creating" && transport.recover === undefined)
154
+ setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
155
+ let message = row?.state === "placed" ? row.message : null;
156
+ let placing = null;
157
+ const stopped = new AbortController();
158
+ const abandon = (problem) => {
159
+ off = true;
160
+ setLive(state, event.id, ["creating", "placed"], "abandoned", input.now(), { problem });
161
+ };
162
+ const place = (text) => {
163
+ if (off || message !== null)
164
+ return Promise.resolve();
165
+ placing ??= (async () => {
166
+ try {
167
+ if (!input.fenced() || coolingDown(state, input.installation, input.now()))
168
+ return;
169
+ row = liveRow(state, event.id);
170
+ if (row === null) {
171
+ state.prepare("INSERT OR IGNORE INTO chat_live (provider, event, state, created, updated) VALUES (:provider, ?, 'creating', ?, ?)")
172
+ .run(event.id, input.now().toISOString(), input.now().toISOString());
173
+ }
174
+ else if (row.state === "creating") {
175
+ // A create from before a restart: Discord finds it by its nonce, or repeats it under the same nonce while
176
+ // that is known to return the same message; Slack and Teams can't know, so it is never posted again.
177
+ if (transport.recover === undefined) {
178
+ off = true;
179
+ setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
180
+ return;
181
+ }
182
+ const found = await recoverLive(state, input.installation, transport, input.now);
183
+ // The lookup failed: undecided, so nothing is posted; the row stays creating for a later look.
184
+ if ("failed" in found)
185
+ return;
186
+ if ("found" in found) {
187
+ message = found.found;
188
+ setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
189
+ return;
190
+ }
191
+ const age = input.now().getTime() - Date.parse(row.created);
192
+ if (!(age >= 0 && age < (transport.nonceMs ?? 0))) {
193
+ off = true;
194
+ setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
195
+ return;
196
+ }
197
+ }
198
+ else if (row.state === "placed") {
199
+ message = row.message;
200
+ return;
201
+ }
202
+ else {
203
+ off = true;
204
+ return;
205
+ }
206
+ if (!input.fenced())
207
+ return;
208
+ const id = await transport.create(text);
209
+ message = id;
210
+ setLive(state, event.id, ["creating"], "placed", input.now(), { message: id });
211
+ gate.liveNext = input.now().getTime() + gate.spacing;
212
+ }
213
+ catch (error) {
214
+ const wait = transport.rateLimited(error);
215
+ if (wait !== null)
216
+ slowDown(state, input.installation, gate, wait, input.now());
217
+ if (transport.uncertain(error) && transport.recover === undefined) {
218
+ off = true;
219
+ setLive(state, event.id, ["creating"], "uncertain", input.now(), { problem: LIVE_WORDS.maybeLeft });
220
+ }
221
+ else if (transport.uncertain(error)) {
222
+ // Discord: the row stays creating, so the reply's planning looks for it by nonce once more (settleLive).
223
+ const found = await recoverLive(state, input.installation, transport, input.now);
224
+ if ("found" in found) {
225
+ message = found.found;
226
+ setLive(state, event.id, ["creating"], "placed", input.now(), { message: found.found });
227
+ }
228
+ else
229
+ off = true;
230
+ }
231
+ else {
232
+ // Refused outright: nothing was posted. The reply goes out as a new message.
233
+ off = true;
234
+ state.prepare("DELETE FROM chat_live WHERE provider = :provider AND event = ? AND state = 'creating'").run(event.id);
235
+ }
236
+ }
237
+ finally {
238
+ placing = null;
239
+ }
240
+ })();
241
+ return placing;
242
+ };
243
+ const pause = (ms) => new Promise(resolve => {
244
+ if (ms <= 0 || stopped.signal.aborted)
245
+ return resolve();
246
+ const end = () => { clearTimeout(timer); stopped.signal.removeEventListener("abort", end); resolve(); };
247
+ const timer = setTimeout(end, ms);
248
+ timer.unref?.();
249
+ stopped.signal.addEventListener("abort", end, { once: true });
250
+ });
251
+ const sender = createLiveSender({
252
+ send: async (text, generation) => {
253
+ if (placing !== null)
254
+ await placing;
255
+ if (off || message === null || stopped.signal.aborted)
256
+ return;
257
+ // Spacing: the app's current pace, and a 👀 just sent goes first.
258
+ await pause(gate.liveNext - input.now().getTime());
259
+ if (off || stopped.signal.aborted)
260
+ return;
261
+ if (!input.fenced())
262
+ return abandon("Chat access changed");
263
+ let standing;
264
+ try {
265
+ standing = await input.standing();
266
+ }
267
+ catch (error) {
268
+ // Not a refusal, only no answer now (a "slow down", a lost answer): this view is dropped and the message stays.
269
+ const wait = transport.rateLimited(error);
270
+ if (wait !== null)
271
+ slowDown(state, input.installation, gate, wait, input.now());
272
+ return;
273
+ }
274
+ if (!standing)
275
+ return abandon("Chat access changed");
276
+ // Re-read after the awaits: the turn may have ended, or the lease moved, meanwhile.
277
+ if (off || stopped.signal.aborted || !input.fenced())
278
+ return;
279
+ // A cooldown holds: this view is dropped (a later one may go).
280
+ if (coolingDown(state, input.installation, input.now()))
281
+ return;
282
+ gate.liveNext = input.now().getTime() + gate.spacing;
283
+ try {
284
+ await transport.edit(message, text);
285
+ easeBack(gate);
286
+ state.prepare("UPDATE chat_live SET generation = ?, updated = ? WHERE provider = :provider AND event = ? AND state = 'placed'")
287
+ .run(generation, input.now().toISOString(), event.id);
288
+ }
289
+ catch (error) {
290
+ const wait = transport.rateLimited(error);
291
+ if (wait !== null)
292
+ slowDown(state, input.installation, gate, wait, input.now());
293
+ // Anything else: this view is dropped, previews are best effort.
294
+ }
295
+ },
296
+ });
297
+ const show = (view) => {
298
+ // A blocked preview clears at once back to the status line; the turn may still answer.
299
+ const shown = view.phase === "closed" && view.why === "blocked" ? { phase: "working", status: live.status() } : view;
300
+ const text = liveText(shown, transport.platform, transport.limit, input.shape);
301
+ if (text === null)
302
+ return;
303
+ void place(italic(transport.platform, LIVE_WORDS.working));
304
+ sender.offer(text);
305
+ };
306
+ return {
307
+ push(progress) {
308
+ try {
309
+ if (off || sender.closed)
310
+ return;
311
+ if (live.push(progress))
312
+ show(live.view());
313
+ else if (message === null && placing === null)
314
+ void place(italic(transport.platform, LIVE_WORDS.working));
315
+ }
316
+ catch { /* a live message never breaks the turn */ }
317
+ },
318
+ async queued() {
319
+ if (off || message !== null)
320
+ return;
321
+ await place(italic(transport.platform, LIVE_STATUS.queued));
322
+ },
323
+ async close() {
324
+ stopped.abort();
325
+ await sender.close();
326
+ if (placing !== null)
327
+ await placing;
328
+ },
329
+ };
330
+ }
331
+ const setAck = (state, event, ack, now) => {
332
+ state.prepare(`INSERT INTO chat_ack (provider, event, state, created, updated) VALUES (:provider, ?, ?, ?, ?)
333
+ ON CONFLICT (provider, event) DO UPDATE SET state = excluded.state, updated = excluded.updated`).run(event, ack, now.toISOString(), now.toISOString());
334
+ };
335
+ /** Did this install refuse reactions within the last minute? Then no 👀 is asked for (no error after error). */
336
+ export function reactionsRefused(state, installation, now) {
337
+ const at = state.meta(installation, ACK_REFUSED_META);
338
+ return at !== null && Date.parse(at) + ACK_REFUSED_RETRY_MS > now.getTime();
339
+ }
340
+ const lastAck = new Map();
341
+ /**
342
+ * One step of the 👀 work for an installation, beside the queue: take one 👀 off a message whose reply (or failure
343
+ * notice) is delivered, else put one on the next owner's message that arrived. True: it did something (call again).
344
+ */
345
+ export async function ackPass(input) {
346
+ const { state, installation, hooks } = input;
347
+ const fenced = () => input.current() && state.owns(installation, input.owner, input.now());
348
+ if (!fenced() || coolingDown(state, installation, input.now()))
349
+ return false;
350
+ const eventOf = (row) => (row === undefined ? null : state.event(String(row["event"])));
351
+ // Taking one off: its event is handled and nothing of its reply waits to send.
352
+ if (hooks.unreact !== undefined) {
353
+ const done = eventOf(state.prepare(`SELECT a.event FROM chat_ack a JOIN chat_event e ON e.provider = a.provider AND e.id = a.event
354
+ WHERE a.provider = :provider AND a.state = 'sent' AND e.installation = ? AND e.state IN ('done','dropped')
355
+ AND NOT EXISTS (SELECT 1 FROM chat_part p WHERE p.provider = a.provider AND p.event = a.event AND p.state = 'pending')
356
+ ORDER BY a.created LIMIT 1`).get(installation));
357
+ if (done !== null) {
358
+ try {
359
+ await hooks.unreact(done);
360
+ setAck(state, done.id, "done", input.now());
361
+ }
362
+ catch (error) {
363
+ const wait = hooks.rateLimited(error);
364
+ if (wait !== null)
365
+ slowDown(state, installation, null, wait, input.now());
366
+ // Gone already, or not ours to take off any more: settled. Anything else is tried again on a later pass.
367
+ else if (hooks.settled(error) || hooks.refused(error))
368
+ setAck(state, done.id, "done", input.now());
369
+ else
370
+ return false;
371
+ }
372
+ return true;
373
+ }
374
+ }
375
+ // Putting one on: the owner's own direct message, saved and not yet acknowledged, recent enough to matter.
376
+ const since = new Date(input.now().getTime() - ACK_WINDOW_MS).toISOString();
377
+ const event = eventOf(state.prepare(`SELECT e.id AS event FROM chat_event e JOIN chat_binding b ON b.provider = e.provider AND b.id = e.binding
378
+ WHERE e.provider = :provider AND e.installation = ? AND e.kind = 'message' AND e.state = 'queued' AND e.created >= ?
379
+ AND b.revoked IS NULL AND e.member = b.member AND e.channel = b.channel
380
+ AND NOT EXISTS (SELECT 1 FROM chat_ack a WHERE a.provider = e.provider AND a.event = e.id)
381
+ ORDER BY e.created, e.id LIMIT 1`).get(installation, since));
382
+ if (event === null || event.binding === null)
383
+ return false;
384
+ const binding = state.bindingById(event.binding);
385
+ if (binding === null || !state.live(binding)) {
386
+ setAck(state, event.id, "skipped", input.now());
387
+ return true;
388
+ }
389
+ const key = `${state.provider}:${binding.id}`;
390
+ if ((lastAck.get(key) ?? Number.NEGATIVE_INFINITY) + ACK_SPACING_MS > input.now().getTime())
391
+ return false;
392
+ if (hooks.react === undefined && hooks.typing === undefined) {
393
+ setAck(state, event.id, "skipped", input.now());
394
+ return true;
395
+ }
396
+ if (hooks.react !== undefined && reactionsRefused(state, installation, input.now())) {
397
+ setAck(state, event.id, "skipped", input.now());
398
+ return true;
399
+ }
400
+ if (!fenced())
401
+ return false;
402
+ lastAck.set(key, input.now().getTime());
403
+ // Saved as on before it is asked for: a crash in between can only leave a take-off with nothing to take off.
404
+ setAck(state, event.id, hooks.react !== undefined ? "sent" : "done", input.now());
405
+ // A 👀 goes before the next live edit.
406
+ for (const [name, gate] of gates)
407
+ if (name === `${state.provider}:${installation}`)
408
+ gate.liveNext = Math.max(gate.liveNext, input.now().getTime() + gate.spacing / 2);
409
+ try {
410
+ if (hooks.react !== undefined)
411
+ await hooks.react(event);
412
+ else
413
+ await hooks.typing(event);
414
+ if (hooks.react !== undefined)
415
+ state.deleteMeta(installation, ACK_REFUSED_META);
416
+ }
417
+ catch (error) {
418
+ const wait = hooks.rateLimited(error);
419
+ if (wait !== null)
420
+ slowDown(state, installation, null, wait, input.now());
421
+ if (hooks.react !== undefined && hooks.settled(error))
422
+ return true;
423
+ if (hooks.react !== undefined && hooks.refused(error))
424
+ state.setMeta(installation, ACK_REFUSED_META, input.now().toISOString(), input.now());
425
+ setAck(state, event.id, "skipped", input.now());
426
+ }
427
+ return true;
428
+ }
429
+ /**
430
+ * Before an event's reply is planned: a create that never confirmed (this pass, or before a restart) is found by its
431
+ * nonce on Discord, else marked uncertain, so planning edits the right message or none. False: the lookup failed, so
432
+ * it is still undecided and planning waits (a "slow down" is shared).
433
+ */
434
+ export async function settleLive(state, event, transport, now) {
435
+ const row = liveRow(state, event);
436
+ if (row?.state !== "creating")
437
+ return true;
438
+ const found = transport?.recover === undefined ? { absent: true } : await recoverLive(state, state.event(event)?.installation ?? "", transport, now);
439
+ if ("failed" in found)
440
+ return false;
441
+ if ("found" in found)
442
+ setLive(state, event, ["creating"], "placed", now(), { message: found.found });
443
+ else
444
+ setLive(state, event, ["creating"], "uncertain", now(), { problem: LIVE_WORDS.maybeLeft });
445
+ return true;
446
+ }
447
+ /** A cosmetic send (typing…) under the app's shared cooldown: skipped while it holds, and a "slow down" sets it. */
448
+ export function underCooldown(state, installation, rateLimited, now, call) {
449
+ return async () => {
450
+ if (coolingDown(state, installation, now()))
451
+ return;
452
+ try {
453
+ return await call();
454
+ }
455
+ catch (error) {
456
+ const wait = rateLimited(error);
457
+ if (wait !== null)
458
+ slowDown(state, installation, null, wait, now());
459
+ throw error;
460
+ }
461
+ };
462
+ }
463
+ /** Forget who was acknowledged when (tests). */
464
+ export function resetAckSpacing() { lastAck.clear(); }
465
+ /**
466
+ * The 👀 loop a worker runs beside its queue while its connection is the current one: a pass every quarter second,
467
+ * and at once when `wake` is called (a message just arrived). Never throws.
468
+ */
469
+ export function runChatAcks(input) {
470
+ let wakeUp = null;
471
+ const wake = () => { wakeUp?.(); };
472
+ input.signal.addEventListener("abort", wake, { once: true });
473
+ const done = (async () => {
474
+ while (input.same() && !input.signal.aborted) {
475
+ try {
476
+ for (let steps = 0; steps < 8 && await ackPass({ ...input, now: input.now ?? (() => new Date()) }); steps++)
477
+ ;
478
+ }
479
+ catch { /* quiet: a 👀 is a courtesy */ }
480
+ if (!input.same() || input.signal.aborted)
481
+ break;
482
+ await new Promise(resolve => {
483
+ const timer = setTimeout(() => wakeUp?.(), 250);
484
+ timer.unref?.();
485
+ wakeUp = () => { clearTimeout(timer); wakeUp = null; resolve(); };
486
+ });
487
+ }
488
+ input.signal.removeEventListener("abort", wake);
489
+ })();
490
+ return { wake, done };
491
+ }
@@ -67,7 +67,7 @@ export function applyChatTaskAction(store, who, payload, now, web, controls = {}
67
67
  if (!allowed.ok && controls.via === "web")
68
68
  return { ok: false, message: allowed.why };
69
69
  const resumed = resumeTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: controls.via ?? "web" }, now);
70
- return resumed.ok ? { ok: true, taskId: task, said: `Stop released. ${resumed.gate ? "Other requirements still prevent the next attempt." : "The next attempt may start."}` } : { ok: false, message: resumed.detail };
70
+ return resumed.ok ? { ok: true, taskId: task, said: `Stop lifted. ${resumed.gate ? "Something else still holds the next attempt." : "The next attempt may start."}` } : { ok: false, message: resumed.detail };
71
71
  }
72
72
  const { via, evidenceRoot: _root, ...rest } = controls;
73
73
  const result = requestTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: via ?? (web ? "web" : "cli"), ...rest }, now);
@@ -19,3 +19,11 @@ export declare function chatLimitProblem(scope: Pick<Scope, "profile" | "budgetM
19
19
  fullAccess: boolean;
20
20
  capMicrousd: number | null;
21
21
  }, yours: string): string | null;
22
+ /** The chat apps a card goes to. */
23
+ export type CardChannel = "telegram" | "slack" | "discord" | "teams";
24
+ /** Whether a chat app shows a card's words exactly as they are stored, so a tap there is a yes to those words. Teams
25
+ * draws card text as Markdown, where a link's label hides its address, and Slack turns `:name:` into an emoji: there
26
+ * the words go to the review page and no tap approves them. Telegram's card is plain text and Discord's escapes its
27
+ * presentation syntax. Both approval roads read this one rule (chat-decide.ts shownWhole, chat-actions.ts
28
+ * sharedActionAllowsChallenge), so neither can seal words the other would refuse. */
29
+ export declare function shownAsWritten(text: string, channel: CardChannel): boolean;
@@ -79,3 +79,17 @@ export function chatLimitProblem(scope, limits, yours) {
79
79
  return `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.`;
80
80
  return null;
81
81
  }
82
+ /** Whether a chat app shows a card's words exactly as they are stored, so a tap there is a yes to those words. Teams
83
+ * draws card text as Markdown, where a link's label hides its address, and Slack turns `:name:` into an emoji: there
84
+ * the words go to the review page and no tap approves them. Telegram's card is plain text and Discord's escapes its
85
+ * presentation syntax. Both approval roads read this one rule (chat-decide.ts shownWhole, chat-actions.ts
86
+ * sharedActionAllowsChallenge), so neither can seal words the other would refuse. */
87
+ export function shownAsWritten(text, channel) {
88
+ switch (channel) {
89
+ case "telegram":
90
+ case "discord": return true;
91
+ // Emoji names: letters and digits of any script (workspaces add their own), `_`, `+`, `'`, `-`.
92
+ case "slack": return !/:[\p{L}\p{N}_+'-]+:/u.test(text);
93
+ case "teams": return false;
94
+ }
95
+ }
@@ -4,7 +4,7 @@
4
4
  * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
5
  * The console keeps every exact name and fact. */
6
6
  import { leadOnIt } from "./task-status.js";
7
- import { phoneText } from "./telegram-status.js";
7
+ import { publicChatText as phoneText } from "./chat-display.js";
8
8
  /** The bot's display name in every chat app. */
9
9
  export const BOT_NAME = "Toolroll";
10
10
  /** Updates for one person landing within this window become one message, edited in place as it grows. */
@@ -353,6 +353,11 @@ export declare const chatActionRequestSchemas: {
353
353
  task: z.ZodString;
354
354
  run: z.ZodInt;
355
355
  }, z.core.$strict>;
356
+ /** One-step start from chat: the project and the preview the lead's turn loop prepared (never the model's id). */
357
+ readonly task_start: z.ZodObject<{
358
+ repo: z.ZodString;
359
+ preview: z.ZodString;
360
+ }, z.core.$strict>;
356
361
  };
357
362
  export type ChatActionOperation = keyof typeof chatActionRequestSchemas;
358
363
  export declare const CHAT_ACTION_OPERATIONS: [ChatActionOperation, ...ChatActionOperation[]];
@@ -435,6 +440,7 @@ export declare const sharedActionSchema: z.ZodObject<{
435
440
  task_reopen: "task_reopen";
436
441
  task_restore: "task_restore";
437
442
  task_resume: "task_resume";
443
+ task_start: "task_start";
438
444
  tool_add: "tool_add";
439
445
  tool_remove: "tool_remove";
440
446
  trial_stop: "trial_stop";
@@ -111,6 +111,8 @@ export const chatActionRequestSchemas = {
111
111
  result_accept: z.strictObject({ task: words, run: id }),
112
112
  task_cancel: z.strictObject({ task: words }),
113
113
  task_resume: z.strictObject({ task: words, run: id }),
114
+ /** One-step start from chat: the project and the preview the lead's turn loop prepared (never the model's id). */
115
+ task_start: z.strictObject({ repo: words, preview: words }),
114
116
  };
115
117
  export const CHAT_ACTION_OPERATIONS = Object.keys(chatActionRequestSchemas);
116
118
  /** The fields each action takes, from its schema. */
@@ -209,6 +209,12 @@ export declare const COMMAND_OUTPUTS: {
209
209
  task: z.ZodNullable<z.ZodString>;
210
210
  line: z.ZodString;
211
211
  }, z.core.$loose>>;
212
+ figures: z.ZodOptional<z.ZodObject<{
213
+ waitingOnYou: z.ZodNumber;
214
+ running: z.ZodNumber;
215
+ queued: z.ZodNumber;
216
+ done: z.ZodNumber;
217
+ }, z.core.$loose>>;
212
218
  projects: z.ZodArray<z.ZodObject<{
213
219
  repo: z.ZodString;
214
220
  name: z.ZodString;
@@ -85,6 +85,9 @@ const statusOutput = answers("status", {
85
85
  signIn: rows({ provider: str, reason: str, command: str, since: str }),
86
86
  tasks: rows({ task: str, title: str, headline: str, sentence: str }),
87
87
  lead: nullable(z.looseObject({ owner: str, doing: str, at: str, task: nullable(str), line: str })),
88
+ // Home's figures (home-groups.ts), the opening line's counts: each is the count of the Tasks list it names. An
89
+ // older Toolroll's answer has none.
90
+ figures: z.looseObject({ waitingOnYou: int, running: int, queued: int, done: int }).optional(),
88
91
  projects: rows({ repo: str, name: str, running: int, limit: int }),
89
92
  update: z.looseObject({ current: str, latest: str, security: z.boolean(), updateCommand: str, url: str }).optional(),
90
93
  integrations: z.looseObject({ broken: rows({ key: str, name: str, fix: nullable(str) }) }).optional(),
@@ -168,6 +168,7 @@ export declare const CONSOLE_FORMS: {
168
168
  readonly leadIdentity: FormContract<"name" | "persona">;
169
169
  readonly leadAbout: FormContract<"about">;
170
170
  readonly leadPromiseCancel: FormContract<"promise">;
171
+ readonly leadQuick: FormContract<"enabled">;
171
172
  readonly leadCheck: FormContract<"return">;
172
173
  readonly leadOn: FormContract<"agent" | "return">;
173
174
  readonly welcomeSample: FormContract<never>;
@@ -183,6 +183,7 @@ export const CONSOLE_FORMS = {
183
183
  leadIdentity: formContract("POST /settings/lead/identity", ["name", "persona"]),
184
184
  leadAbout: formContract("POST /settings/lead/about", ["about"]),
185
185
  leadPromiseCancel: formContract("POST /settings/lead/promise/cancel", ["promise"]),
186
+ leadQuick: formContract("POST /settings/lead/quick", ["enabled"]),
186
187
  leadCheck: formContract("POST /lead/check", ["return"]),
187
188
  leadOn: formContract("POST /settings/lead/on", ["agent", "return"]),
188
189
  welcomeSample: formContract("POST /welcome/sample", []),
@@ -756,6 +756,10 @@ export declare const LEAD_TOOL_INPUTS: {
756
756
  report: z.ZodOptional<z.ZodBoolean>;
757
757
  checks: z.ZodOptional<z.ZodString>;
758
758
  }, z.core.$strict>;
759
+ readonly propose_start: z.ZodObject<{
760
+ repo: z.ZodString;
761
+ title: z.ZodString;
762
+ }, z.core.$strict>;
759
763
  readonly propose_sweep: z.ZodObject<{
760
764
  repo: z.ZodString;
761
765
  title: z.ZodString;
@@ -1567,6 +1571,10 @@ export declare const LEAD_TOOL_READ_INPUTS: {
1567
1571
  report: z.ZodOptional<z.ZodBoolean>;
1568
1572
  checks: z.ZodOptional<z.ZodString>;
1569
1573
  }, z.core.$strict>;
1574
+ readonly propose_start: z.ZodObject<{
1575
+ repo: z.ZodString;
1576
+ title: z.ZodString;
1577
+ }, z.core.$strict>;
1570
1578
  readonly propose_sweep: z.ZodObject<{
1571
1579
  repo: z.ZodString;
1572
1580
  title: z.ZodString;
@@ -2212,6 +2220,15 @@ export declare const LEAD_TOOL_OUTPUTS: {
2212
2220
  kind: z.ZodLiteral<"task">;
2213
2221
  awaiting: z.ZodString;
2214
2222
  }, z.core.$loose>;
2223
+ readonly propose_start: z.ZodObject<{
2224
+ proposal: z.ZodNumber;
2225
+ kind: z.ZodEnum<{
2226
+ action: "action";
2227
+ task: "task";
2228
+ }>;
2229
+ startsNow: z.ZodBoolean;
2230
+ awaiting: z.ZodString;
2231
+ }, z.core.$loose>;
2215
2232
  readonly propose_sweep: z.ZodObject<{
2216
2233
  proposal: z.ZodNumber;
2217
2234
  label: z.ZodString;