toolroll 0.9.16 → 0.9.18

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 (70) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/browser/workspace.js +14 -14
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-actions.d.ts +7 -0
  6. package/dist/chat-actions.js +40 -4
  7. package/dist/chat-ask.js +29 -15
  8. package/dist/chat-channel.js +3 -1
  9. package/dist/chat-decide.d.ts +203 -0
  10. package/dist/chat-decide.js +554 -0
  11. package/dist/chat-delivery-state.d.ts +7 -0
  12. package/dist/chat-delivery.d.ts +3 -0
  13. package/dist/chat-delivery.js +63 -17
  14. package/dist/chat-quiet.d.ts +4 -1
  15. package/dist/chat-quiet.js +6 -2
  16. package/dist/chat-warmth.d.ts +21 -0
  17. package/dist/chat-warmth.js +36 -0
  18. package/dist/desktop-update.d.ts +1 -1
  19. package/dist/discord-api.d.ts +3 -1
  20. package/dist/discord-api.js +4 -2
  21. package/dist/discord-chat.d.ts +3 -2
  22. package/dist/discord-chat.js +35 -6
  23. package/dist/lead-about.d.ts +35 -0
  24. package/dist/lead-about.js +62 -0
  25. package/dist/lead-capabilities.d.ts +64 -0
  26. package/dist/lead-capabilities.js +199 -0
  27. package/dist/lead-commitments.js +13 -7
  28. package/dist/lead-context.d.ts +5 -3
  29. package/dist/lead-context.js +29 -7
  30. package/dist/lead-follow.d.ts +1 -1
  31. package/dist/lead-follow.js +3 -2
  32. package/dist/lead-identity.js +1 -1
  33. package/dist/lead-people.d.ts +55 -0
  34. package/dist/lead-people.js +128 -0
  35. package/dist/lead-voice.d.ts +3 -2
  36. package/dist/lead-voice.js +4 -3
  37. package/dist/mate-cli.d.ts +3 -0
  38. package/dist/mate-cli.js +15 -14
  39. package/dist/mate-contract.d.ts +1 -1
  40. package/dist/mate-contract.js +6 -4
  41. package/dist/mate-progress.js +2 -0
  42. package/dist/mate-tools.d.ts +8 -0
  43. package/dist/mate-tools.js +112 -6
  44. package/dist/mate.d.ts +26 -1
  45. package/dist/mate.js +89 -25
  46. package/dist/modes.d.ts +6 -0
  47. package/dist/modes.js +6 -0
  48. package/dist/operate.js +20 -8
  49. package/dist/phase-routing.d.ts +1 -1
  50. package/dist/project-delete.js +3 -0
  51. package/dist/provider-auth.d.ts +4 -0
  52. package/dist/provider-auth.js +1 -1
  53. package/dist/reply-shape.d.ts +96 -0
  54. package/dist/reply-shape.js +452 -0
  55. package/dist/result-shots.d.ts +51 -0
  56. package/dist/result-shots.js +119 -0
  57. package/dist/serve.js +76 -49
  58. package/dist/slack-chat.d.ts +2 -0
  59. package/dist/slack-chat.js +38 -7
  60. package/dist/store.d.ts +36 -3
  61. package/dist/store.js +163 -16
  62. package/dist/team-runtime.js +1 -1
  63. package/dist/teams-chat.js +17 -1
  64. package/dist/telegram-mate.d.ts +12 -0
  65. package/dist/telegram-mate.js +27 -11
  66. package/dist/telegram-team.js +6 -3
  67. package/dist/telegram.d.ts +5 -3
  68. package/dist/telegram.js +227 -21
  69. package/dist/test-temp.js +1 -1
  70. package/package.json +1 -1
package/dist/mate.js CHANGED
@@ -11,6 +11,7 @@ import { MATE_MAX_PROPOSALS_PER_TURN, MATE_TOOL_SCHEMAS, executeMateTool, isMate
11
11
  import { composeSubscriptionMatePrompt, performSubscriptionMateRequest } from "./subscription-chat.js";
12
12
  import { leadContext } from './lead-context.js';
13
13
  import { envValue } from "./names.js";
14
+ import { deliverableClaim, deliverableRepair, dropDeliverableClaims, replyCarriesDeliverable } from "./reply-shape.js";
14
15
  export const MATE_MESSAGE_MAX_CHARS = 2_000;
15
16
  /** Where a promise made this turn is reported: the chat app it arrived on, or this conversation for the console and terminal. */
16
17
  function promiseChannelOfTurn(channel) {
@@ -41,6 +42,47 @@ export const MATE_REFUSAL_COPY = {
41
42
  "request-changed": "That send was already received with different text or task context. Reload the conversation before sending a new message.",
42
43
  channel: "This conversation's connection changed, so the message wasn't sent. Reconnect it, then send again.",
43
44
  };
45
+ /** A turn stopped because its chat's standing changed: what happened, what it means, one next step. */
46
+ export const MATE_CHANNEL_COPY = {
47
+ unpaired: "This chat is no longer connected to your account, so the reply was stopped. Nothing it proposed was kept. Connect the chat again in Settings, then send your message again.",
48
+ "not-approver": "Your account can no longer approve work here, so the reply was stopped. Nothing it proposed was kept. Ask an owner to restore your access, then send your message again.",
49
+ "projects-changed": "The projects this chat can see changed while the lead was answering, so the reply was stopped. Nothing it proposed was kept. Send your message again.",
50
+ "projects-unreadable": "Your project list couldn't be read just now, so the reply was stopped. Nothing it proposed was kept. Send your message again in a minute.",
51
+ "member-changed": "Your account in this chat app no longer has access, so the reply was stopped. Nothing it proposed was kept. Ask the app's admin to add you back, then send your message again.",
52
+ "access-changed": "This chat's access changed while the lead was answering, so the reply was stopped. Nothing it proposed was kept. Reconnect the chat, then send your message again.",
53
+ };
54
+ /** Why a reply did not finish, in the same three parts: what happened, what it means, one next step. */
55
+ export const MATE_FAILURE_COPY = {
56
+ stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.",
57
+ ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.",
58
+ tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.",
59
+ secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.",
60
+ secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.",
61
+ paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.",
62
+ notStarted: "The reply couldn't be started. Nothing was kept or charged. Send your message again.",
63
+ notInstalled: "The chat app the lead uses isn't installed on this computer, so it couldn't answer. Nothing was kept. Install it, or choose another provider in Settings → Lead.",
64
+ unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.",
65
+ signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.",
66
+ refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.",
67
+ tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
68
+ lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
69
+ unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
70
+ empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
71
+ unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
72
+ };
73
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
74
+ export function mateFailureText(reason) {
75
+ switch (reason) {
76
+ case "timeout": return MATE_FAILURE_COPY.tooLong;
77
+ case "malformed-reply": return MATE_FAILURE_COPY.unreadable;
78
+ case "secret-refused": return MATE_FAILURE_COPY.secretReply;
79
+ case "latched": return MATE_FAILURE_COPY.paused;
80
+ case "revoked": return MATE_FAILURE_COPY.ended;
81
+ case "superseded": return MATE_FAILURE_COPY.stopped;
82
+ case "provider-error": return MATE_FAILURE_COPY.refused;
83
+ default: return "The lead's reply didn't finish. Nothing it proposed was kept. Send your message again.";
84
+ }
85
+ }
44
86
  const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
45
87
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
46
88
  export function historyFor(store, thread, queuedMessageId) {
@@ -222,6 +264,7 @@ export async function runMateTurn(input) {
222
264
  const readDecisions = new Map();
223
265
  const readResults = new Map();
224
266
  const searchedMemory = new Map();
267
+ const checkedCapabilities = new Map();
225
268
  /** The turn's one question to its owner: shown with the reply only once the turn answers; a failed turn drops it. */
226
269
  const ask = (question, options) => store.recordMateAsk({ turn: turnId, thread: thread.id, question, options }, clock());
227
270
  let tokensIn = 0;
@@ -256,20 +299,30 @@ export async function runMateTurn(input) {
256
299
  // AFTER it resolves, immediately before sending context or using a tool.
257
300
  const guard = (channel) => {
258
301
  if (!stillOurs())
259
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before its next action", unknownSpend: false };
302
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
260
303
  if (!channel.ok)
261
- return fail("revoked", `this conversation's connection changed (${channel.reason}) — nothing it proposed was kept`, false);
304
+ return fail("revoked", MATE_CHANNEL_COPY[channel.reason] ?? MATE_CHANNEL_COPY["access-changed"], false);
262
305
  const standing = reproveApprover(store, who);
263
306
  const liveSession = store.getMateSession(session.id);
264
307
  const liveThread = store.getMateThread(thread.id);
265
308
  if (!standing.ok || liveSession === null || liveSession.endedAt !== null || liveThread === null || liveThread.closedAt !== null) {
266
- return fail("revoked", "your standing or this conversation ended — nothing it proposed was kept", false);
309
+ return fail("revoked", MATE_FAILURE_COPY.ended, false);
267
310
  }
268
311
  return null;
269
312
  };
270
313
  let reply = null;
271
314
  let stoppedAtCap = false;
272
315
  let lastText = "";
316
+ /** A page shown this turn (show_control): a reply may say it links one. */
317
+ let shownControl = false;
318
+ /** The turn's one repair step for a reply that claims an attachment it does not carry. */
319
+ let repaired = false;
320
+ const unbacked = (text) => {
321
+ const claim = deliverableClaim(text);
322
+ if (claim === null || shownControl || replyCarriesDeliverable(text) || store.listMateTurnEvidence(turnId).length > 0)
323
+ return null;
324
+ return claim;
325
+ };
273
326
  while (steps < maxSteps) {
274
327
  const blocked = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
275
328
  if (blocked !== null)
@@ -277,22 +330,22 @@ export async function runMateTurn(input) {
277
330
  now = clock();
278
331
  const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
279
332
  if (remainingMs <= 0)
280
- return fail("timeout", "the turn ran out of time before the model finished", false);
333
+ return fail("timeout", MATE_FAILURE_COPY.tooLong, false);
281
334
  const request = direct ? composeDirect(input.key) : composeSubscription();
282
335
  // Tool results join the outbound body: scanned again before every dispatch.
283
336
  const outbound = typeof request === "string" ? request : request.body;
284
337
  if (scanForSecrets(outbound).length > 0) {
285
- return fail("secret-refused", "a tool result contained something credential-shaped — the turn stopped before sending it", false);
338
+ return fail("secret-refused", MATE_FAILURE_COPY.secretRead, false);
286
339
  }
287
340
  const step = store.openMateStep({ mateTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: config.model, deadlineMs: remainingMs + 10_000 }, now);
288
341
  if (!step.ok) {
289
342
  if (step.reason === "latched")
290
- return fail("latched", "an earlier reply stopped before its cost was known, so chat is paused — acknowledge that reply on the Chat page, then send again", false);
291
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before its next step", unknownSpend: false };
343
+ return fail("latched", MATE_FAILURE_COPY.paused, false);
344
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
292
345
  }
293
346
  const stepStarted = store.startChatTurn(step.id, now);
294
347
  if (!stepStarted.ok)
295
- return fail("provider-error", "the step could not be dispatched", false);
348
+ return fail("provider-error", MATE_FAILURE_COPY.notStarted, false);
296
349
  steps++;
297
350
  progress({ kind: "step", turn: turnId, step: steps });
298
351
  const requestBytes = Buffer.byteLength(outbound, "utf8");
@@ -337,36 +390,36 @@ export async function runMateTurn(input) {
337
390
  if (!direct) {
338
391
  finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0 });
339
392
  const message = result.problem === "not-found"
340
- ? "the selected subscription CLI is not installed on this machine"
393
+ ? MATE_FAILURE_COPY.notInstalled
341
394
  : result.problem === "timeout"
342
- ? "the subscription turn ran out of time"
395
+ ? MATE_FAILURE_COPY.tooLong
343
396
  : result.problem === "malformed-reply"
344
- ? "the subscription provider returned a malformed answer and it was discarded"
345
- : "the subscription provider refused or could not complete the turn — check its login in the terminal";
397
+ ? MATE_FAILURE_COPY.unreadable
398
+ : MATE_FAILURE_COPY.signIn;
346
399
  return fail(result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
347
400
  }
348
401
  if (result.problem.startsWith("status-")) {
349
402
  // The provider ANSWERED with an error: nothing billed for this step.
350
403
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
351
- return fail("provider-error", "the provider refused the request — nothing was billed for that step", false);
404
+ return fail("provider-error", MATE_FAILURE_COPY.refused, false);
352
405
  }
353
406
  if (result.problem === "timeout") {
354
407
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
355
- return fail("timeout", "the turn timed out; that step's cost is unknown — the whole reservation is charged and the credential is blocked until you acknowledge it", true);
408
+ return fail("timeout", MATE_FAILURE_COPY.tooLongUnknownCost, true);
356
409
  }
357
410
  if (result.problem === "network") {
358
411
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
359
- return fail("provider-error", "the provider could not be reached after dispatch; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
412
+ return fail("provider-error", MATE_FAILURE_COPY.lostUnknownCost, true);
360
413
  }
361
414
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
362
- return fail("malformed-reply", "the provider's response was malformed and was discarded; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
415
+ return fail("malformed-reply", MATE_FAILURE_COPY.unreadableUnknownCost, true);
363
416
  }
364
417
  const answer = result.answer;
365
418
  // Usage that cannot be true — more input tokens than bytes sent — is a
366
419
  // malformed reply with unknown cost, never a number to settle by.
367
420
  if (direct && answer.tokensIn > requestBytes) {
368
421
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
369
- return fail("malformed-reply", "the provider reported usage that cannot be true; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
422
+ return fail("malformed-reply", MATE_FAILURE_COPY.unreadableUnknownCost, true);
370
423
  }
371
424
  // The pinned math, or the provider's own reported charge when HIGHER.
372
425
  const stepSettled = direct
@@ -385,15 +438,23 @@ export async function runMateTurn(input) {
385
438
  return changed;
386
439
  if (answer.calls.length === 0) {
387
440
  if (answer.text.trim() === "")
388
- return fail("malformed-reply", "the model answered with nothing", false);
389
- reply = answer.text;
441
+ return fail("malformed-reply", MATE_FAILURE_COPY.empty, false);
442
+ // "Here's the screenshot" with nothing attached or linked: one repair step to attach it or drop the claim; never the claim alone.
443
+ const claim = unbacked(answer.text);
444
+ if (claim !== null && !repaired && steps < maxSteps) {
445
+ repaired = true;
446
+ history.push({ role: "assistant", text: answer.text, calls: [] });
447
+ history.push({ role: "operator", text: deliverableRepair(claim) });
448
+ continue;
449
+ }
450
+ reply = claim === null ? answer.text : dropDeliverableClaims(answer.text);
390
451
  break;
391
452
  }
392
453
  if (answer.calls.length > MATE_MAX_CALLS_PER_STEP)
393
- return fail("malformed-reply", "the model asked for more tool calls than one step allows", false);
454
+ return fail("malformed-reply", MATE_FAILURE_COPY.unusable, false);
394
455
  for (const call of answer.calls) {
395
456
  if (!isMateTool(call.name))
396
- return fail("malformed-reply", "the model called a tool that does not exist", false);
457
+ return fail("malformed-reply", MATE_FAILURE_COPY.unusable, false);
397
458
  }
398
459
  history.push({ role: "assistant", text: answer.text, calls: answer.calls });
399
460
  for (const [index, call] of answer.calls.entries()) {
@@ -403,9 +464,11 @@ export async function runMateTurn(input) {
403
464
  return changed;
404
465
  }
405
466
  progress({ kind: "tool", turn: turnId, step: steps, label: mateToolLabel(call.name) });
406
- const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
467
+ const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
407
468
  if (READ_TOOLS.has(call.name))
408
469
  reads++;
470
+ if (call.name === "show_control" && outcome.ok)
471
+ shownControl = true;
409
472
  // Opt-in, local diagnostics for end-to-end runs: what the lead asked of each tool and what came back (its start), keys
410
473
  // blanked. Each line stays whole JSON so a run can assert on what a tool returned rather than on the model's words.
411
474
  if (envValue(process.env, "MATE_TRACE") === "1") {
@@ -419,16 +482,17 @@ export async function runMateTurn(input) {
419
482
  }
420
483
  if (reply === null) {
421
484
  stoppedAtCap = true;
422
- reply = `${lastText.trim() === "" ? "" : `${lastText.trim()}\n\n`}(stopped after ${maxSteps} steps${maxSteps < MATE_MAX_STEPS ? " within this conversation’s remaining allowance" : ""})`;
485
+ const last = lastText.trim() === "" || unbacked(lastText) === null ? lastText.trim() : dropDeliverableClaims(lastText);
486
+ reply = `${last === "" ? "" : `${last}\n\n`}(I stopped here: this answer needed more steps than ${maxSteps < MATE_MAX_STEPS ? "this conversation's remaining spending allows" : "one reply allows"}. Ask me to carry on.)`;
423
487
  }
424
488
  // Ruling 11: model text is scanned before it becomes durable.
425
489
  if (scanForSecrets(reply).length > 0)
426
- return fail("secret-refused", "the model's reply contained something credential-shaped and was discarded", false);
490
+ return fail("secret-refused", MATE_FAILURE_COPY.secretReply, false);
427
491
  now = clock();
428
492
  const activity = activitySummary(reads, proposals, steps);
429
493
  // One write (finding 12): settle, debit, promote the drafts, append the assistant text.
430
494
  const finalized = store.finalizeMateTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
431
495
  if (!finalized)
432
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before it could be kept", unknownSpend: false };
496
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
433
497
  return { ok: true, turn: turnId, reply, activity, proposals, steps, stoppedAtCap, settledMicrousd: settled };
434
498
  }
package/dist/modes.d.ts CHANGED
@@ -65,6 +65,12 @@ export type ModeTerms = {
65
65
  * `repairAuto` is also true — a mode may sign a cap without signing the
66
66
  * authority, but never the reverse. */
67
67
  repairMaxAttempts: number;
68
+ /** Explicit opt-in: the signer's paired Telegram chat may approve this
69
+ * repository's plans and merge its ready pull requests with two taps,
70
+ * for the mode's lifetime. A plan that widens permissions, exceeds the
71
+ * per-attempt budget or touches protected paths still opens Toolroll.
72
+ * Legacy modes read FALSE — never inherited, only freshly signed. */
73
+ chatApprove: boolean;
68
74
  absoluteExpiry: string;
69
75
  };
70
76
  export declare const MODE_MAX_DAYS = 7;
package/dist/modes.js CHANGED
@@ -34,6 +34,7 @@ export function presetTerms(name, absoluteExpiry) {
34
34
  allowPaidFallback: false,
35
35
  repairAuto: false,
36
36
  repairMaxAttempts: 0,
37
+ chatApprove: false,
37
38
  absoluteExpiry,
38
39
  }
39
40
  : {
@@ -51,6 +52,7 @@ export function presetTerms(name, absoluteExpiry) {
51
52
  allowPaidFallback: false,
52
53
  repairAuto: false,
53
54
  repairMaxAttempts: 0,
55
+ chatApprove: false,
54
56
  absoluteExpiry,
55
57
  };
56
58
  }
@@ -119,6 +121,7 @@ export function modeTermsFromJson(json) {
119
121
  (t["repairAuto"] === undefined || typeof t["repairAuto"] === "boolean") &&
120
122
  (t["repairMaxAttempts"] === undefined ||
121
123
  (typeof t["repairMaxAttempts"] === "number" && Number.isInteger(t["repairMaxAttempts"]) && t["repairMaxAttempts"] >= 0 && t["repairMaxAttempts"] <= 3)) &&
124
+ (t["chatApprove"] === undefined || typeof t["chatApprove"] === "boolean") &&
122
125
  typeof t["absoluteExpiry"] === "string" &&
123
126
  !Number.isNaN(Date.parse(t["absoluteExpiry"]))) {
124
127
  return {
@@ -136,6 +139,7 @@ export function modeTermsFromJson(json) {
136
139
  allowPaidFallback: t["allowPaidFallback"] === true,
137
140
  repairAuto: t["repairAuto"] === true,
138
141
  repairMaxAttempts: typeof t["repairMaxAttempts"] === "number" ? t["repairMaxAttempts"] : 0,
142
+ chatApprove: t["chatApprove"] === true,
139
143
  absoluteExpiry: t["absoluteExpiry"],
140
144
  };
141
145
  }
@@ -179,6 +183,8 @@ export function modeWords(terms) {
179
183
  : "automatic fallback never switches to a paid API key on its own; a subscription that runs out stops and waits for you",
180
184
  ...(terms.repairAuto
181
185
  ? ["historical automatic repair grants are retained on record but no longer schedule work"] : []),
186
+ ...(terms.chatApprove
187
+ ? ["your paired Telegram chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll"] : []),
182
188
  `everything above ends at ${terms.absoluteExpiry.slice(0, 16).replace("T", " ")} — revoking it earlier is one click, and every act it covered falls back to its own ceremony`,
183
189
  ];
184
190
  }
package/dist/operate.js CHANGED
@@ -79,7 +79,7 @@ import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./
79
79
  import { homedir, hostname, tmpdir } from "node:os";
80
80
  import { basename, dirname, join, resolve } from "node:path";
81
81
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
82
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
82
+ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
83
83
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
84
84
  import { authorizePlanUnderMode } from "./plan-auto.js";
85
85
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
@@ -247,6 +247,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
247
247
  toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
248
248
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
249
249
  toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
250
+ toolroll notifications screenshots off|first|all a result's saved screenshots with its chat message: none, the first one, or up to 4
250
251
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
251
252
  toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
252
253
  toolroll backup now|list back the database up now; list backups and how the last ones went
@@ -647,7 +648,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
647
648
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
648
649
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
649
650
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
650
- "self-heal", "plan-auto", "repair-auto", "review-retry-auto", "no-local",
651
+ "self-heal", "plan-auto", "chat-approve", "repair-auto", "review-retry-auto", "no-local",
651
652
  "html", "csv", "alerts-only",
652
653
  // v105: the full export.
653
654
  "zip",
@@ -5716,6 +5717,9 @@ async function modeCommand(positional, flags, context) {
5716
5717
  // explicit flag lets an exhausted subscription switch to another account.
5717
5718
  if (flag(flags, "allow-paid-fallback"))
5718
5719
  terms.allowPaidFallback = true;
5720
+ // Approving plans and merges from the signer's paired chat: never a preset default, only this flag.
5721
+ if (flag(flags, "chat-approve"))
5722
+ terms.chatApprove = true;
5719
5723
  // Automerge requires a live merge-capable grant on the repo (D1).
5720
5724
  if (terms.publication === "automerge" && !store.hasMergeCapableGrant(repo, now)) {
5721
5725
  return fail(write, json, "mode set", "no-grant", "automerge needs a merge-capable publication grant on this repo — file one first", EXIT.refused);
@@ -11643,6 +11647,8 @@ async function chatCommand(flags, context) {
11643
11647
  ceilingUsd: ceilingGiven === undefined ? undefined : Number(ceilingGiven),
11644
11648
  ...(context.mateSeams === undefined ? {} : { seams: { ...context.mateSeams, clock: context.mateSeams.clock ?? context.clock } }),
11645
11649
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
11650
+ // The console's own address (and its public one): links there read as "the task", "the result", "Settings → Lead".
11651
+ appOrigin: [loadConsoleUrl(process.env, dirname(context.databaseFile)), phoneOrigin(process.env, dirname(context.databaseFile))],
11646
11652
  });
11647
11653
  return result.code;
11648
11654
  }
@@ -12236,8 +12242,9 @@ async function budgetCommand(positional, flags, context) {
12236
12242
  const saved = store.setBudget({ scope: target.scope, key: target.key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: !flags.has("alerts-only") }, acting.name, context.clock());
12237
12243
  return succeed(context.write, context.json, command, { budget: saved }, () => [`${budgetLabel(saved).replace(/'s$/, "")}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`]);
12238
12244
  }
12239
- /** `notifications [quiet | all | digest <HH:MM> | digest off]`: how chats reach this person — only when they are
12240
- * needed (the default), every step, and an optional evening digest. Each person sets their own. */
12245
+ /** `notifications [quiet | all | digest <HH:MM> | digest off | screenshots off|first|all]`: how chats reach this
12246
+ * person — only when they are needed (the default), every step, an optional evening digest, and whether a result's
12247
+ * saved screenshots follow its message. Each person sets their own. */
12241
12248
  async function notificationsCommand(positional, flags, context) {
12242
12249
  const [action, value, ...extra] = positional;
12243
12250
  const command = `notifications${action === undefined ? "" : ` ${action}`}`;
@@ -12246,10 +12253,13 @@ async function notificationsCommand(positional, flags, context) {
12246
12253
  for (const name of flags.keys())
12247
12254
  if (!["as", "token", "token-file", "token-env", "db", "json", ...(muting ? ["repo"] : [])].includes(name))
12248
12255
  return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
12249
- const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, or notifications mute|unmute --repo <project>.";
12250
- if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest", "mute", "unmute"].includes(action)) || (action === "digest") !== (value !== undefined) || (muting && (value !== undefined || text(flags, "repo") === undefined))) {
12256
+ const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, notifications screenshots off|first|all, or notifications mute|unmute --repo <project>.";
12257
+ const valued = action === "digest" || action === "screenshots";
12258
+ if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest", "screenshots", "mute", "unmute"].includes(action)) || valued !== (value !== undefined) || (muting && (value !== undefined || text(flags, "repo") === undefined))) {
12251
12259
  return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
12252
12260
  }
12261
+ if (action === "screenshots" && !RESULT_SCREENSHOTS.includes(value))
12262
+ return fail(context.write, context.json, command, "usage", "Screenshots with results is off, first (the first one) or all (up to 4).", EXIT.usage);
12253
12263
  if (action === "digest" && value !== "off" && !isDigestTime(value))
12254
12264
  return fail(context.write, context.json, command, "usage", "The digest time is HH:MM on a 24-hour clock, like 18:30, or off.", EXIT.usage);
12255
12265
  const acting = await askCredentials(flags, context);
@@ -12272,11 +12282,13 @@ async function notificationsCommand(positional, flags, context) {
12272
12282
  }
12273
12283
  const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
12274
12284
  : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
12275
- : store.notificationPreference(acting.name);
12285
+ : action === "screenshots" ? store.setNotificationPreference(acting.name, { screenshots: value }, acting.name, now)
12286
+ : store.notificationPreference(acting.name);
12276
12287
  const muted = store.mutedProjects(acting.name);
12277
- return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, mutedProjects: muted }, () => [
12288
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, screenshots: preference.screenshots, mutedProjects: muted }, () => [
12278
12289
  preference.mode === "quiet" ? "Only when you're needed: one message per task, updated as it moves, and a new one when something needs you." : "Every step: a message for each update.",
12279
12290
  preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
12291
+ preference.screenshots === "off" ? "Screenshots with results: off." : `Screenshots with results: ${preference.screenshots === "first" ? "the first one" : "up to 4"}.`,
12280
12292
  ...(muted.length === 0 ? [] : [`Muted: ${muted.map(one => basename(one)).join(", ")}.`]),
12281
12293
  ]);
12282
12294
  }
@@ -234,7 +234,7 @@ export declare function parseOverrides(raw: unknown): RouteOverride[] | null;
234
234
  * nobody had overridden anything. */
235
235
  export declare function overridesFromJson(json: string | null): RouteOverride[] | null;
236
236
  export declare function canonicalOverridesJson(overrides: readonly RouteOverride[]): string | null;
237
- export type ReadinessLookup = (provider: ProviderId) => Pick<ReadinessObservation, "state" | "reason" | "runner" | "observedAt"> | null;
237
+ export type ReadinessLookup = (provider: ProviderId) => (Pick<ReadinessObservation, "state" | "reason" | "runner" | "observedAt"> & Partial<Pick<ReadinessObservation, "probe">>) | null;
238
238
  export type RouteLegProjection = RouteLeg & {
239
239
  readiness: ReadinessState;
240
240
  readinessReason: string | null;
@@ -228,12 +228,15 @@ function deleteRows(store, repo, d, now) {
228
228
  del("telegram_flow_prompt", IN("card"), C);
229
229
  del("telegram_question_action", IN("question"), questions);
230
230
  del("telegram_question_prompt", IN("question"), questions);
231
+ del("chat_decide_action", IN("task_id"), K);
232
+ del("chat_decide_prompt", IN("task_id"), K);
231
233
  del("telegram_task_message", `${IN("task_id")} OR ${IN("source_run")}`, K, R);
232
234
  del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
233
235
  del("telegram_conversation", IN("id"), conversations);
234
236
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
235
237
  del("chat_card_task", IN("task_ref"), T);
236
238
  del("chat_batch_item", IN("task_ref"), T);
239
+ del("result_shot_sent", IN("run"), R);
237
240
  del("push_delivery", IN("notification"), notifications);
238
241
  del("notification_actor", IN("notification"), notifications);
239
242
  del("notification_delivery", IN("notification"), notifications);
@@ -34,6 +34,10 @@ export declare function providerName(provider: string): string;
34
34
  export declare function signInReason(pause: Pick<AuthPause, "provider" | "authMode">): string;
35
35
  /** What to run, as a command. */
36
36
  export declare function signInCommand(pause: Pick<AuthPause, "provider" | "authMode">): string;
37
+ /** The same command for a chat message: the phone scrub hides anything shaped
38
+ * like a path (`/login` included), so a message names Claude's equivalent
39
+ * `claude auth login`. */
40
+ export declare function messageCommand(pause: Pick<AuthPause, "provider" | "authMode">): string;
37
41
  /** One line for status, ready and the console. */
38
42
  export declare function signInWords(pause: AuthPause): string;
39
43
  /** The open pause for a provider, or null. */
@@ -44,7 +44,7 @@ export function signInCommand(pause) {
44
44
  /** The same command for a chat message: the phone scrub hides anything shaped
45
45
  * like a path (`/login` included), so a message names Claude's equivalent
46
46
  * `claude auth login`. */
47
- function messageCommand(pause) {
47
+ export function messageCommand(pause) {
48
48
  const command = signInCommand(pause);
49
49
  return command === "claude /login" ? "claude auth login" : command;
50
50
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The lead's voice, enforced: every chat surface (console, terminal, Telegram, Slack, Discord, Teams) runs a reply
3
+ * through here before it is sent. The contract asks for plain text; this makes it so, without changing what the reply
4
+ * says. Shaping is channel-neutral and idempotent — it yields a small canonical form (`**anchor**`, `[label](url)`,
5
+ * `code`) — and each channel renders that form in its own format.
6
+ *
7
+ * - Markdown headers become plain lines.
8
+ * - Bold stays on at most three short anchors; the rest is plain.
9
+ * - Bare URLs become labelled links, named from their target: "the task", "the result", "Settings → Lead", else the host.
10
+ * - Internal ids (run #12, digests, r1-style project ids) are removed unless the owner asked for them. When a reply names
11
+ * several of one kind (two runs, two projects), they stay: two items must never read the same.
12
+ * - Runs of blank lines collapse to one.
13
+ * Code (inline or fenced) is left exactly as written.
14
+ */
15
+ export type ReplyChannel = "console" | "terminal" | "telegram" | "slack" | "discord" | "teams";
16
+ export type ShapeOptions = {
17
+ /** The owner's message this reply answers: when it asks for ids, the ids stay. */
18
+ asked?: string;
19
+ /** This app's own origin (or origins: the console's address and its public one). Only a link there is named as the
20
+ * app's page ("the task"); every other link is named by its real host, whatever label the model wrote, so a reply can
21
+ * never dress a foreign link up as ours. */
22
+ appOrigin?: string | readonly (string | null)[] | null;
23
+ };
24
+ export declare const BOLD_ANCHORS = 3;
25
+ /** A bold anchor is a few words, not a sentence. */
26
+ export declare const BOLD_ANCHOR_CHARS = 40;
27
+ /** Did the owner ask for ids (an id, a digest, a hash, a run number or a project's r-number)? */
28
+ export declare function askedForIds(asked: string | undefined): boolean;
29
+ /** What a link is called, from where it goes: the app's own task, result and settings pages by name (only on `appOrigin`); anything else by host. */
30
+ export declare function linkLabel(url: string, appOrigin?: ShapeOptions["appOrigin"]): string;
31
+ /**
32
+ * The canonical shaped reply: plain lines, at most three bold anchors, labelled links, no internal ids unless asked
33
+ * (or unless several of a kind would otherwise read the same), single blank lines. Idempotent: shaping a shaped reply
34
+ * changes nothing.
35
+ */
36
+ export declare function shapeReply(text: string, options?: ShapeOptions): string;
37
+ /**
38
+ * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
39
+ * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
40
+ * half of one. The parts share one bold budget and one rule for ids, as if the reply were shaped whole. `measure` is
41
+ * how long a shaped part is once the channel renders it (its own link and escape format): a part that renders past
42
+ * `size` is cut again, smaller. Only a single link longer than a part can exceed it, and it is never cut.
43
+ */
44
+ export declare function shapeReplyParts(text: string, size: number, options?: ShapeOptions, measure?: (shaped: string) => number): string[];
45
+ export type ReplyPiece = {
46
+ kind: "text";
47
+ text: string;
48
+ } | {
49
+ kind: "bold";
50
+ text: string;
51
+ } | {
52
+ kind: "code";
53
+ text: string;
54
+ } | {
55
+ kind: "link";
56
+ label: string;
57
+ url: string;
58
+ };
59
+ /** A shaped reply as pieces: plain text, bold anchors, inline code and labelled links. Fenced code stays text. */
60
+ export declare function replyPieces(shaped: string): ReplyPiece[];
61
+ /** Discord's presentation syntax, escaped; mentions are broken so nothing pings. */
62
+ export declare const discordPlain: (text: string) => string;
63
+ /** One line of a shaped reply as console HTML (the console lays out paragraphs and lists itself). */
64
+ export declare function replyHtmlInline(shapedLine: string): string;
65
+ /** A shaped reply in a channel's own text format (Telegram's formatting rides entities: see `telegramReply`). */
66
+ export declare function renderReply(shaped: string, channel: ReplyChannel): string;
67
+ export type TelegramEntity = {
68
+ type: "bold" | "code";
69
+ offset: number;
70
+ length: number;
71
+ } | {
72
+ type: "text_link";
73
+ offset: number;
74
+ length: number;
75
+ url: string;
76
+ };
77
+ /** A shaped reply for Telegram: plain text plus entities (bold anchors, code, labelled links). Offsets are UTF-16, as JS strings are. */
78
+ export declare function telegramReply(shaped: string): {
79
+ text: string;
80
+ entities: TelegramEntity[];
81
+ };
82
+ /** Shape and render in one step, for a surface that sends the reply as text. */
83
+ export declare function voiceReply(text: string, channel: ReplyChannel, options?: ShapeOptions): string;
84
+ /**
85
+ * The words in a reply that say something is being sent or attached ("I've attached the log", "here's the
86
+ * screenshot"), or null. A claim needs an attachment noun and a send verb; one whose content the reply lists itself
87
+ * ("I've included the files I changed: a.ts") is not a claim.
88
+ */
89
+ export declare function deliverableClaim(text: string): string | null;
90
+ /** Does the reply carry the thing itself: a link, or content quoted in a code block? */
91
+ export declare function replyCarriesDeliverable(text: string): boolean;
92
+ export declare const NOTHING_ATTACHED = "I couldn't attach that to this reply.";
93
+ /** The reply without its claims of an attachment, said plainly once; whatever a claim listed stays; never the claim alone. */
94
+ export declare function dropDeliverableClaims(text: string): string;
95
+ /** The one repair step's words to the lead: attach it, or drop the claim. */
96
+ export declare function deliverableRepair(claim: string): string;