toolroll 0.8.1 → 0.9.0

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 (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +7 -0
  3. package/dist/agent-onboard.js +23 -5
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +21 -20
  6. package/dist/browser-workspace.d.ts +10 -0
  7. package/dist/chat-delivery.js +51 -3
  8. package/dist/chat-quiet.d.ts +28 -0
  9. package/dist/chat-quiet.js +161 -0
  10. package/dist/checkout-cleanup.d.ts +84 -0
  11. package/dist/checkout-cleanup.js +245 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +3 -0
  14. package/dist/digest-times.d.ts +3 -0
  15. package/dist/digest-times.js +7 -0
  16. package/dist/dispose.js +2 -0
  17. package/dist/export.js +1 -1
  18. package/dist/first-run.d.ts +4 -1
  19. package/dist/first-run.js +2 -1
  20. package/dist/integrations-ui.d.ts +13 -0
  21. package/dist/integrations-ui.js +65 -0
  22. package/dist/integrations.d.ts +113 -0
  23. package/dist/integrations.js +526 -0
  24. package/dist/mcp.js +26 -0
  25. package/dist/operate.d.ts +5 -6
  26. package/dist/operate.js +254 -25
  27. package/dist/project-delete.js +1 -0
  28. package/dist/prompt.d.ts +7 -2
  29. package/dist/prompt.js +36 -7
  30. package/dist/publish.d.ts +11 -0
  31. package/dist/publish.js +9 -2
  32. package/dist/pull-request-flow.d.ts +140 -0
  33. package/dist/pull-request-flow.js +440 -0
  34. package/dist/pull-request-schema.d.ts +5 -0
  35. package/dist/pull-request-schema.js +21 -0
  36. package/dist/pull-request-ui.d.ts +23 -0
  37. package/dist/pull-request-ui.js +55 -0
  38. package/dist/pulls.d.ts +5 -0
  39. package/dist/pulls.js +11 -0
  40. package/dist/serve.d.ts +6 -0
  41. package/dist/serve.js +323 -26
  42. package/dist/setup-guide.d.ts +2 -2
  43. package/dist/storage-ui.d.ts +10 -0
  44. package/dist/storage-ui.js +67 -0
  45. package/dist/storage.d.ts +15 -1
  46. package/dist/storage.js +62 -7
  47. package/dist/store.d.ts +66 -5
  48. package/dist/store.js +160 -19
  49. package/dist/surface.js +2 -0
  50. package/dist/telegram-progress.d.ts +1 -0
  51. package/dist/telegram-progress.js +1 -1
  52. package/dist/telegram.js +95 -15
  53. package/dist/toolroll-update-ui.d.ts +2 -0
  54. package/dist/toolroll-update-ui.js +1 -1
  55. package/dist/toolroll-update.d.ts +26 -6
  56. package/dist/toolroll-update.js +188 -50
  57. package/dist/worktree.d.ts +24 -10
  58. package/dist/worktree.js +84 -44
  59. package/package.json +1 -1
package/dist/surface.js CHANGED
@@ -118,6 +118,8 @@ export const COMMAND_GUIDE = [
118
118
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
119
119
  // ---- the queue (agent surface) ----
120
120
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
121
+ { invocation: "integrations", synopsis: "every integration as Connected, Not set up or Broken, with its last success and error, what uses it and what to do; --saved skips new checks; never a secret", audience: "agent", agentMayInvoke: true, mutation: "none",
122
+ flags: [jsonFlag, dbFlag, { name: "saved", takesValue: false, meaning: "show the last checks without checking again" }] },
121
123
  { invocation: "ready", synopsis: "what could be dispatched right now (rows carry reservedFor)", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
122
124
  { invocation: "brief", synopsis: "with a saved central profile, --lead <id> --conversation <id> reads messages, proposals and saved work; --request-id inspects your saved receipt without resending; --local uses local DB catch-up, where --history selects the older operational report", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
123
125
  { invocation: "gaps", synopsis: "requirement gaps blocking dispatch", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag, repoFlag] },
@@ -11,4 +11,5 @@ export declare function telegramProgressCard(store: Store, run: Run, taskId: str
11
11
  text: string;
12
12
  entities: ProgressEntity[];
13
13
  link: PhoneTaskLink;
14
+ next: string;
14
15
  };
@@ -123,7 +123,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
123
123
  ...(next ? ["", next] : []),
124
124
  ...(acceptance === null ? [] : [acceptance]),
125
125
  ...(delivery === null ? [] : ["", delivery]), "", `${projectLabel(project)} · #${run.id}`].join("\n");
126
- return { text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
126
+ return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
127
127
  link: { label, path: operatorHold || accessBlocked || (!built && run.outcome !== null)
128
128
  ? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, checksFailed ? "checks" : "summary") : chatControlHref("task", taskId) } };
129
129
  }
package/dist/telegram.js CHANGED
@@ -29,6 +29,7 @@ import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
30
  import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
32
+ import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
32
33
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
33
34
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
34
35
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -519,6 +520,8 @@ async function processConversations(store, botId, transport, owner, clock, repor
519
520
  function factLinkLabel(path) {
520
521
  if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
521
522
  return "Inspect result";
523
+ if (/^\/t\/[^?#]+#merge$/.test(path))
524
+ return "Merge";
522
525
  if (/^\/chat\?task=[^&]+&result=/.test(path))
523
526
  return "Open result";
524
527
  if (/^\/(?:chat\?task=|t\/)/.test(path))
@@ -563,6 +566,13 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
563
566
  }
564
567
  const digest = store.telegramDigest();
565
568
  const now = clock();
569
+ // An evening digest someone asked for is due: record it as their own notification, delivered below.
570
+ try {
571
+ enqueueEveningDigests(store, now, evidenceRoot);
572
+ }
573
+ catch {
574
+ report.problems.push("evening digest could not be prepared");
575
+ }
566
576
  const digestDue = digest.everyMs === null || digest.lastSentAt === null || now.getTime() >= new Date(digest.lastSentAt).getTime() + digest.everyMs;
567
577
  // Every paired person is a destination of their own: each binding claims
568
578
  // and settles its own rows under its own ceiling, in turn.
@@ -571,13 +581,16 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
571
581
  }
572
582
  async function deliverOutboxTo(store, botId, binding, transport, owner, clock, report, digest, digestDue, readProjects, canDeliver, phoneOrigin, evidenceRoot) {
573
583
  const now = clock();
574
- const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, digestDue ? "all" : "urgent");
584
+ // Only when I'm needed (the default): a task's facts edit its one card and only what needs this person makes a
585
+ // new message, so the installation's routine-fact cadence does not apply. Every step keeps today's behaviour.
586
+ const quiet = store.notificationPreference(binding.approver).mode === "quiet";
587
+ const claimed = store.claimTelegramDeliveries(binding, owner, DELIVERY_CLAIM_MS, now, quiet || digestDue ? "all" : "urgent");
575
588
  // Preserve ID order, flushing earlier routine facts before an urgent task
576
589
  // update. An earlier failed/retrying row also fences later rows for that task.
577
590
  const groups = [];
578
591
  for (const row of claimed) {
579
592
  const last = groups[groups.length - 1];
580
- if (digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
593
+ if (!quiet && digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
581
594
  last.push(row);
582
595
  else
583
596
  groups.push([row]);
@@ -638,12 +651,12 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
638
651
  }
639
652
  return null;
640
653
  };
641
- const sender = async (text, keyboard, messageRows = rows, entities) => {
654
+ const sender = async (text, keyboard, messageRows = rows, entities, silent) => {
642
655
  // Finish every await before the synchronous fence and transport call.
643
656
  const problem = (await readAccess()) ?? fence();
644
657
  if (problem !== null)
645
658
  return { ok: false, error: problem };
646
- const sent = await send(transport, binding.chatId, text, keyboard, entities);
659
+ const sent = await send(transport, binding.chatId, text, keyboard, entities, silent);
647
660
  if (!sent.ok) {
648
661
  if (sent.retryAfter !== undefined)
649
662
  store.deferTelegram(botId, new Date(clock().getTime() + sent.retryAfter * 1_000).toISOString());
@@ -694,7 +707,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
694
707
  const after = (await readAccess()) ?? fence();
695
708
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
696
709
  };
697
- const batched = digest.everyMs !== null && !isUrgent(rows[0]);
710
+ const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
698
711
  const row = rows[0];
699
712
  const progressRun = batched ? null : store.telegramProgressRun(row);
700
713
  const progress = progressRun !== null && isTelegramProgressNotification(row);
@@ -731,15 +744,80 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
731
744
  const after = (await readAccess()) ?? fence();
732
745
  return after === null ? edited : { ok: false, error: after };
733
746
  };
734
- // A failure or decision still gets its own alert. Refresh an existing
735
- // card first so it does not keep saying the build is running.
736
- if (!progress && progressRun !== null)
737
- await updateProgress(true);
738
- const updated = progress ? await updateProgress(false) : null;
739
- const outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
740
- : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
741
- ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
742
- : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
747
+ /** Quiet mode: the task's one card (or its group's), created silently and then only ever edited. */
748
+ const updateCard = async (fact) => {
749
+ const problem = (await readAccess()) ?? fence();
750
+ if (problem !== null)
751
+ return { ok: false, error: problem };
752
+ const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
753
+ const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
754
+ if (view === null)
755
+ return { ok: true, messageId: card.message };
756
+ let button = null;
757
+ try {
758
+ button = phoneLinkButton(phoneOrigin?.() ?? null, view.link);
759
+ }
760
+ catch { /* No trusted origin. */ }
761
+ const keyboard = button === null ? [] : [button];
762
+ const shown = createHash("sha256").update(JSON.stringify([view.text, view.entities, keyboard])).digest("hex");
763
+ if (card.message !== null && card.digest === shown) {
764
+ store.recordTelegramMessage(fact, binding, card.message, clock());
765
+ return { ok: true, messageId: card.message };
766
+ }
767
+ if (card.message !== null) {
768
+ const edited = await editProgress(transport, binding.chatId, card.message, view.text, keyboard, view.entities);
769
+ if (edited.ok) {
770
+ store.setChatCardMessage(card.id, card.message, shown);
771
+ store.recordTelegramMessage(fact, binding, card.message, clock());
772
+ const after = (await readAccess()) ?? fence();
773
+ return after === null ? edited : { ok: false, error: after };
774
+ }
775
+ if (edited.retryAfter !== undefined)
776
+ store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
777
+ // Only Telegram's definitive missing/uneditable answer permits a replacement card.
778
+ if (!edited.replace)
779
+ return edited;
780
+ }
781
+ const sent = await sender(view.text, keyboard, [fact], view.entities, true);
782
+ // A card that reached the chat is kept even if a later fence refused the acknowledgement: never a second card.
783
+ const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
784
+ if (placed !== null)
785
+ store.setChatCardMessage(card.id, placed, shown);
786
+ return sent;
787
+ };
788
+ const quietOutcome = async (fact) => {
789
+ const card = await updateCard(fact);
790
+ if (!card.ok)
791
+ return card;
792
+ if (!needsPerson(fact))
793
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
794
+ const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
795
+ if (readyRun !== null) {
796
+ const ping = readyPingView(store, store.getRun(readyRun.id), fact.taskId, fact.project, clock(), evidenceRoot);
797
+ let button = null;
798
+ try {
799
+ button = phoneLinkButton(phoneOrigin?.() ?? null, ping.link);
800
+ }
801
+ catch { /* No trusted origin. */ }
802
+ const sent = await sender(ping.text, button === null ? undefined : [button], [fact], ping.entities);
803
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
804
+ }
805
+ return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
806
+ };
807
+ let outcome;
808
+ if (quiet && rows.length === 1 && isTaskFact(row))
809
+ outcome = await quietOutcome(row);
810
+ else {
811
+ // A failure or decision still gets its own alert. Refresh an existing
812
+ // card first so it does not keep saying the build is running.
813
+ if (!progress && progressRun !== null)
814
+ await updateProgress(true);
815
+ const updated = progress ? await updateProgress(false) : null;
816
+ outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
817
+ : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
818
+ ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
819
+ : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
820
+ }
743
821
  const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
744
822
  const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
745
823
  // A skipped screenshot settles its row but nothing reached the phone.
@@ -934,7 +1012,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
934
1012
  }
935
1013
  return { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) };
936
1014
  }
937
- async function send(transport, chatId, text, keyboard, entities) {
1015
+ async function send(transport, chatId, text, keyboard, entities, silent) {
938
1016
  // No markup parsing. Only machine-selected heading ranges may be bold;
939
1017
  // agent text remains literal and URLs never trigger link previews.
940
1018
  let answer;
@@ -944,6 +1022,8 @@ async function send(transport, chatId, text, keyboard, entities) {
944
1022
  text,
945
1023
  ...(entities === undefined ? {} : { entities }),
946
1024
  link_preview_options: { is_disabled: true },
1025
+ // A new quiet card arrives without a sound; only a ping should buzz.
1026
+ ...(silent === true ? { disable_notification: true } : {}),
947
1027
  ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
948
1028
  });
949
1029
  }
@@ -24,6 +24,8 @@ export type UpdatesView = {
24
24
  version: string;
25
25
  notes: string[];
26
26
  } | null;
27
+ /** The version `toolroll update --rollback` returns to: the last completed update's, while it is what runs. */
28
+ rollbackTo: string | null;
27
29
  csrf: string;
28
30
  };
29
31
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
@@ -70,7 +70,7 @@ export function updatesHtml(view, notice) {
70
70
  `<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
71
71
  `<button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div></form>`
72
72
  : "";
73
- const rollback = j?.kind === "update" && j.phase === "complete" ? `<p class="meta">To go back to ${e(j.from.version)}: <code>toolroll update --rollback</code></p>` : "";
73
+ const rollback = view.rollbackTo !== null ? `<p class="meta">To go back to ${e(view.rollbackTo)}: <code>toolroll update --rollback</code></p>` : "";
74
74
  return `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
75
75
  }
76
76
  export function newerThan(a, b) {
@@ -67,10 +67,12 @@ export type RuntimeUpdateJournal = {
67
67
  unit: string;
68
68
  pids: number[];
69
69
  };
70
- /** Each repo's watch daemon that runs this version, recorded the same way: stopped, switched and restarted with the service. */
70
+ /** Each repo's watch daemon that runs this version, recorded the same way: switched with the service, and stopped and
71
+ * restarted with it only when launchd had it loaded before the update (`loaded` absent: 0.8.1 stopped every one). */
71
72
  watches?: {
72
73
  unit: string;
73
74
  pids: number[];
75
+ loaded?: boolean;
74
76
  }[];
75
77
  /** Recorded before each change so a resumed or failed run knows what to put back. */
76
78
  switched?: {
@@ -88,10 +90,15 @@ export type RuntimeUpdateJournal = {
88
90
  }[];
89
91
  databaseRestored?: boolean;
90
92
  };
91
- /** What the live database held when a failed run restored its backup: nothing written is lost. */
93
+ /** What the live database held when a failed run restored its backup: nothing written is lost. The newest of `kept`. */
92
94
  keptAside?: string;
93
95
  /** The live database could not be read, so it was moved aside whole rather than copied. */
94
96
  keptAsideUnreadable?: boolean;
97
+ /** Every copy kept aside, oldest first: a retried restore adds one and never drops the pointer to an earlier one. */
98
+ kept?: {
99
+ path: string;
100
+ unreadable: boolean;
101
+ }[];
95
102
  /** The restore put the backup back: a retried restore never puts it back again (what was written since belongs to
96
103
  * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
97
104
  restoredDatabase?: boolean;
@@ -129,6 +136,8 @@ export type UpdateSystem = {
129
136
  }) => Promise<void>;
130
137
  /** The processes the service runs now. */
131
138
  servicePids: (unit: string) => Promise<number[]>;
139
+ /** Whether launchd has the service loaded now. */
140
+ serviceLoaded: (unit: string) => Promise<boolean>;
132
141
  /** Unload the service; resolves once launchd no longer has it. */
133
142
  stopService: (unit: string) => Promise<void>;
134
143
  /** Load and start the service from its definition on disk. */
@@ -138,8 +147,11 @@ export type UpdateSystem = {
138
147
  healthTimeoutMs?: number;
139
148
  /** How long stopped service processes may take to exit. */
140
149
  exitTimeoutMs?: number;
141
- /** Fault injection for state-machine tests, never selectable by a flag. */
142
- checkpoint?: (phase: RuntimePhase) => void;
150
+ /** Bytes free for this user in the folder `dir` is on. */
151
+ freeBytes?: (dir: string) => number;
152
+ /** Fault injection for state-machine tests, never selectable by a flag. `kept-aside`: the live database was just
153
+ * kept aside, before the backup is put back. */
154
+ checkpoint?: (phase: RuntimePhase | "kept-aside") => void;
143
155
  };
144
156
  export type UpdateOutcome = {
145
157
  ok: boolean;
@@ -211,7 +223,10 @@ export declare function startRuntimeRollback(o: {
211
223
  export declare function resumeRuntimeUpdate(stateDir: string, system: UpdateSystem, id?: string): Promise<UpdateOutcome>;
212
224
  /** A prepared update whose job could not start. */
213
225
  export declare function abandonRuntimeUpdate(stateDir: string, id: string, why: string, now: Date): void;
214
- export declare function requestRuntimeUpdateCancel(stateDir: string, now?: Date): string;
226
+ /** `locked`: a test's seam, called once the updater's lock is held and before the journal is read again. */
227
+ export declare function requestRuntimeUpdateCancel(stateDir: string, now?: Date, seams?: {
228
+ locked?: () => void;
229
+ }): string;
215
230
  /** Keep the newest release-* runtimes (and any in `keep`), at most KEEP_RUNTIMES; deploy-browser's browser-* and
216
231
  * the rollback-* records are not this updater's to remove. */
217
232
  export declare function pruneRuntimes(stateDir: string, keep: readonly string[]): string[];
@@ -223,6 +238,11 @@ export type RuntimeUpdateStatus = {
223
238
  version: string;
224
239
  notes: string[];
225
240
  } | null;
241
+ /** The last completed update `--rollback` returns from, whatever was attempted since. */
242
+ lastUpdate: {
243
+ from: string;
244
+ to: string;
245
+ } | null;
226
246
  };
227
247
  export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
228
248
  export declare function markWhatsNewSeen(stateDir: string): void;
@@ -261,7 +281,7 @@ export declare function launchRuntimeUpdate(args: {
261
281
  home?: string;
262
282
  run?: SupervisorRunner;
263
283
  platform?: NodeJS.Platform;
264
- npmBin?: () => string | null;
284
+ npmBin?: () => Promise<string | null>;
265
285
  }): Promise<void>;
266
286
  /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
267
287
  export declare function retireUpdateJob(id: string, home?: string): void;