toolroll 0.9.10 → 0.9.12

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 (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +3 -1
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +20 -18
  6. package/dist/browser-workspace.d.ts +61 -0
  7. package/dist/chat-channel.d.ts +9 -7
  8. package/dist/chat-channel.js +23 -17
  9. package/dist/chat-delivery.js +21 -0
  10. package/dist/chat-quiet.js +1 -0
  11. package/dist/chat-voice.d.ts +3 -1
  12. package/dist/chat-voice.js +13 -5
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +1 -0
  15. package/dist/coding-update.d.ts +13 -0
  16. package/dist/coding-update.js +68 -0
  17. package/dist/desktop-update.d.ts +7 -0
  18. package/dist/desktop-update.js +42 -1
  19. package/dist/flow-triggers.js +17 -1
  20. package/dist/guides.js +9 -0
  21. package/dist/lead-status.d.ts +10 -1
  22. package/dist/lead-status.js +13 -3
  23. package/dist/lead-voice.d.ts +81 -0
  24. package/dist/lead-voice.js +275 -0
  25. package/dist/needs-you.d.ts +7 -0
  26. package/dist/needs-you.js +9 -0
  27. package/dist/operate.d.ts +2 -0
  28. package/dist/operate.js +186 -38
  29. package/dist/serve.d.ts +5 -1
  30. package/dist/serve.js +447 -226
  31. package/dist/setup-guide.d.ts +2 -2
  32. package/dist/store.d.ts +15 -1
  33. package/dist/store.js +38 -1
  34. package/dist/surface.js +8 -1
  35. package/dist/task-status.d.ts +6 -0
  36. package/dist/task-status.js +19 -2
  37. package/dist/team-cli.js +1 -1
  38. package/dist/telegram-mate.d.ts +7 -0
  39. package/dist/telegram-mate.js +50 -21
  40. package/dist/telegram-progress.js +7 -0
  41. package/dist/telegram-settings.d.ts +3 -0
  42. package/dist/telegram-settings.js +11 -0
  43. package/dist/telegram.d.ts +14 -0
  44. package/dist/telegram.js +105 -12
  45. package/dist/test-temp.js +1 -1
  46. package/dist/toolroll-update.d.ts +3 -0
  47. package/dist/toolroll-update.js +13 -2
  48. package/dist/work-index.d.ts +9 -0
  49. package/dist/work-index.js +57 -20
  50. package/dist/work-summary.d.ts +4 -1
  51. package/dist/workspace-ui.d.ts +25 -0
  52. package/dist/workspace-ui.js +20 -1
  53. package/package.json +1 -1
@@ -7,6 +7,8 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
7
7
  import type { Store } from './store.js';
8
8
  import type { WorkSummaryAccess } from './work-summary.js';
9
9
  import type { StatusTone } from './workspace-ui.js';
10
+ import type { Ask } from './needs-you.js';
11
+ import type { WorkIndexGroup } from './work-index.js';
10
12
  import type { AssignmentCard } from './assignment-ui.js';
11
13
  import type { TaskStatus } from './task-status.js';
12
14
  import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
@@ -103,6 +105,14 @@ export type BrowserTasksView = {
103
105
  count: number;
104
106
  active: boolean;
105
107
  })[];
108
+ /** Tasks waiting on a person in this view's scope ("3 need you" beside the title). */
109
+ needsYou: number;
110
+ /** All and Needs you list their rows in these groups, in this order (empty ones left out); null: one plain list. */
111
+ groups: {
112
+ key: BrowserTaskGroup;
113
+ label: string;
114
+ count: number;
115
+ }[] | null;
106
116
  rows: {
107
117
  id: string;
108
118
  title: string;
@@ -114,6 +124,9 @@ export type BrowserTasksView = {
114
124
  tone: StatusTone;
115
125
  token: string;
116
126
  };
127
+ /** What a Needs you row asks (its chip); null for every other row. */
128
+ ask: Ask | null;
129
+ group: BrowserTaskGroup;
117
130
  action: BrowserLink | null;
118
131
  detail: string | null;
119
132
  problem: string | null;
@@ -133,6 +146,7 @@ export type BrowserTasksView = {
133
146
  limits: BrowserLimits | null;
134
147
  };
135
148
  /** One limit: a plan's usage window ("Claude · 5-hour, 48%") or a monthly budget ("shop · Budget, $4.20 of $10"). */
149
+ export type BrowserTaskGroup = WorkIndexGroup;
136
150
  export type BrowserLimitTile = {
137
151
  key: string;
138
152
  name: string;
@@ -436,6 +450,7 @@ export type BrowserResultPanel = {
436
450
  * Accept that records the decision (null when it is not offered here). */
437
451
  youCheck: {
438
452
  lines: string[];
453
+ items: BrowserCheckItem[];
439
454
  accept: {
440
455
  action: string;
441
456
  run: number;
@@ -465,6 +480,39 @@ export type BrowserResultPanel = {
465
480
  /** The feedback section holds only the closed form (no notes, revisions or history). */
466
481
  requestQuiet: boolean;
467
482
  };
483
+ /** One "You check this one" item and the evidence to judge it by: the
484
+ * changed lines it cites (or, citing none, the change's own first lines),
485
+ * its screenshots, and the agent's note. Wrapped text, never sideways. */
486
+ export type BrowserCheckItem = {
487
+ id: string;
488
+ statement: string;
489
+ words: string;
490
+ note: string | null;
491
+ excerpts: {
492
+ path: string;
493
+ cited: boolean;
494
+ lines: {
495
+ kind: "addition" | "deletion" | "context";
496
+ line: number | null;
497
+ text: string;
498
+ }[];
499
+ more: number;
500
+ }[];
501
+ shots: {
502
+ src: string;
503
+ href: string;
504
+ caption: string;
505
+ }[];
506
+ };
507
+ /** The result's one decision: what Accept is called, why it isn't plain
508
+ * Accept, and what pressing it does. */
509
+ export type BrowserResultDecision = {
510
+ label: "Accept" | "Accept without checks";
511
+ ready: boolean;
512
+ why: string | null;
513
+ effect: string;
514
+ sentence: string;
515
+ };
468
516
  /** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
469
517
  * button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
470
518
  * when the evidence disagrees. */
@@ -531,6 +579,8 @@ export type BrowserResultView = {
531
579
  receipt: string;
532
580
  run: number;
533
581
  } | null;
582
+ /** Words for the result's Accept: Mark complete when offered, else the Needs you acceptance. */
583
+ decision: BrowserResultDecision | null;
534
584
  checks: {
535
585
  detail: string;
536
586
  problem: boolean;
@@ -889,6 +939,11 @@ export type BrowserWorkspace = {
889
939
  refreshSeconds?: number;
890
940
  /** Providers whose sign-in stopped working: their work waits (one per provider). */
891
941
  signIn?: BrowserSignIn[];
942
+ /** A demo database: the notice that says so, scrolling with the page (`short`: its one line on a phone). */
943
+ demo?: {
944
+ text: string;
945
+ short: string;
946
+ };
892
947
  /** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
893
948
  update?: BrowserUpdateNotice;
894
949
  /** Chat's first run, until the first Ready result: the three steps and first tasks to try. */
@@ -945,6 +1000,12 @@ export type BrowserHome = {
945
1000
  }[];
946
1001
  catchUp: BrowserCatchUpItem[];
947
1002
  allHref: string;
1003
+ /** What this person's lead is doing now and when it last acted (lead-voice.ts); its task when it named one. */
1004
+ lead?: {
1005
+ doing: string;
1006
+ at: string;
1007
+ href: string | null;
1008
+ } | null;
948
1009
  };
949
1010
  /** `sandbox`: the demo command, offered beside the sign-in command while no agent is signed in. `intro`: how it works,
950
1011
  * once, above the composer with the suggestions. `lead`: the one line saying what runs the lead, and where to change it.
@@ -12,18 +12,18 @@ export type ResolvedMate = {
12
12
  };
13
13
  } | {
14
14
  ok: false;
15
- reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "session-mismatch" | "thread-mismatch";
15
+ reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
16
16
  said: string | null;
17
17
  };
18
18
  /**
19
19
  * The session and thread a paired phone speaks in: the approver's live
20
20
  * session when it is compatible (same generation, same membership
21
- * credential, same ceiling), else one minted under the very terms the
22
- * console and CLI state for membership chat (no dollar ceiling, this
23
- * ceiling digest) — and only when NO session is live, because minting
24
- * ends the previous one and an unrelated console conversation is not
25
- * this phone's to end. The same rule for the thread: reused when its
26
- * ceiling matches, never closed from here.
21
+ * credential, same ceiling), else a fresh one minted under the very terms
22
+ * the console and CLI state for membership chat (no dollar ceiling, this
23
+ * ceiling digest). An incompatible session or thread left open on the
24
+ * computer is replaced rather than refused, so a message from the phone
25
+ * always gets an answer — except while a turn is still running there,
26
+ * which is "busy": the message waits its turn instead of ending it.
27
27
  */
28
28
  export declare function resolveChannelMate(store: Store, binding: {
29
29
  approver: string;
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
34
34
  * chat as well, so the task shows everything asked of it wherever it was
35
35
  * asked. Best effort: the phone conversation never depends on it. */
36
36
  export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
37
+ /** The one short message a person gets when their question could not be answered. */
38
+ export declare function couldNotAnswerText(reason: string): string;
37
39
  export declare function tooLongText(length: number): string;
38
40
  /** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
39
41
  export declare function whichTaskText(titles: readonly string[]): string;
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
25
25
  /**
26
26
  * The session and thread a paired phone speaks in: the approver's live
27
27
  * session when it is compatible (same generation, same membership
28
- * credential, same ceiling), else one minted under the very terms the
29
- * console and CLI state for membership chat (no dollar ceiling, this
30
- * ceiling digest) — and only when NO session is live, because minting
31
- * ends the previous one and an unrelated console conversation is not
32
- * this phone's to end. The same rule for the thread: reused when its
33
- * ceiling matches, never closed from here.
28
+ * credential, same ceiling), else a fresh one minted under the very terms
29
+ * the console and CLI state for membership chat (no dollar ceiling, this
30
+ * ceiling digest). An incompatible session or thread left open on the
31
+ * computer is replaced rather than refused, so a message from the phone
32
+ * always gets an answer — except while a turn is still running there,
33
+ * which is "busy": the message waits its turn instead of ending it.
34
34
  */
35
35
  export function resolveChannelMate(store, binding, repos, now) {
36
36
  const config = store.getChatConfig();
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
60
60
  return { ok: false, reason: "unpaired", said: null };
61
61
  const who = verified.who;
62
62
  const credentialKey = subscriptionCredentialKey(config.provider);
63
- const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
64
63
  let session = store.activeMateSession(who.name);
65
- if (session !== null &&
64
+ const liveThread = store.liveMateThreadFor(who.name);
65
+ const sessionMismatch = session !== null &&
66
66
  (session.approverGeneration !== who.generation ||
67
67
  session.credentialKey !== credentialKey ||
68
- session.ceilingDigest !== who.ceilingDigest)) {
69
- return { ok: false, reason: "session-mismatch", said: mismatch };
70
- }
71
- const liveThread = store.liveMateThreadFor(who.name);
72
- if (liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest)
73
- return { ok: false, reason: "thread-mismatch", said: mismatch };
74
- if (session === null) {
68
+ session.ceilingDigest !== who.ceilingDigest);
69
+ const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
70
+ // Replacing either would end a turn still running on the computer.
71
+ if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
72
+ return { ok: false, reason: "busy", said: null };
73
+ if (session === null || sessionMismatch) {
75
74
  const termsDigest = createHash("sha256")
76
75
  .update(`0\n${who.ceilingDigest}`)
77
76
  .digest("hex");
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
85
84
  }, now);
86
85
  session = store.getMateSession(id);
87
86
  if (session === null)
88
- return { ok: false, reason: "session-mismatch", said: mismatch };
87
+ return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
89
88
  }
90
- const thread = liveThread ?? store.openMateThread(who.name, who.ceilingDigest, now).thread;
89
+ // openMateThread closes a thread kept under another ceiling.
90
+ const thread = threadMismatch || liveThread === null
91
+ ? store.openMateThread(who.name, who.ceilingDigest, now).thread
92
+ : liveThread;
91
93
  return {
92
94
  ok: true,
93
95
  who,
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
113
115
  // The task chat is a copy; the phone's own conversation already has it.
114
116
  }
115
117
  }
118
+ /** The one short message a person gets when their question could not be answered. */
119
+ export function couldNotAnswerText(reason) {
120
+ return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
121
+ }
116
122
  export function tooLongText(length) {
117
123
  return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
118
124
  }
@@ -11,6 +11,7 @@ import { triggerConfigOf } from "./flow-triggers.js";
11
11
  import { telegramProgressCard } from "./telegram-progress.js";
12
12
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
13
13
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
14
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
14
15
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
15
16
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
16
17
  import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
@@ -202,6 +203,10 @@ export async function processChatEvent(options) {
202
203
  return true;
203
204
  }
204
205
  const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
206
+ if (!resolved.ok && resolved.reason === "busy") {
207
+ state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
208
+ return true;
209
+ }
205
210
  if (!resolved.ok) {
206
211
  state.plan(event.id, [
207
212
  {
@@ -647,6 +652,7 @@ export async function planChatNotifications(options) {
647
652
  enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
648
653
  }
649
654
  catch { /* Retried on the next pass. */ }
655
+ enqueueLeadLapses(store, nowOf(options));
650
656
  const cursor = Number(state
651
657
  .prepare("SELECT notification FROM chat_runtime WHERE installation=?")
652
658
  .get(identity.installation)?.notification ?? 0);
@@ -672,6 +678,21 @@ export async function planChatNotifications(options) {
672
678
  // Only when I'm needed (the default): the task's one card is edited in place, and a new message
673
679
  // follows only when this person is needed. Every step keeps the branches below unchanged.
674
680
  const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
681
+ if (notification.kind === LEAD_SAY_KIND) {
682
+ // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
683
+ const content = { text: leadSayText(notification),
684
+ ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
685
+ const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
686
+ .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
687
+ if (earlier !== undefined) {
688
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(earlier.id));
689
+ return;
690
+ }
691
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
692
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
693
+ state.plan(id, [content], now);
694
+ return;
695
+ }
675
696
  if (quiet) {
676
697
  planQuietCard(options, binding, notification, now);
677
698
  if (!needsPerson(notification))
@@ -149,6 +149,7 @@ function lineOf(store, item, now, root, viewer) {
149
149
  pullRequest,
150
150
  // The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
151
151
  lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
152
+ ...(card.facts.lead === "on-it" ? { leadOnIt: true } : {}),
152
153
  };
153
154
  // A result ready for a person offers its real next step, then a look first.
154
155
  if (asksToFinish(fact))
@@ -34,6 +34,8 @@ export type FinishedFact = {
34
34
  words: string;
35
35
  phrase: string;
36
36
  } | null;
37
+ /** The person's lead took it on (lead-voice.ts): "Your lead is on it", never "It waits for you". */
38
+ leadOnIt?: boolean;
37
39
  };
38
40
  /** The two buttons under a result ready for a person: the real next step, then a look first. */
39
41
  export declare const READY_ACTIONS: {
@@ -53,7 +55,7 @@ export declare function batchLine(facts: readonly FinishedFact[]): string;
53
55
  * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
54
56
  * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
55
57
  */
56
- export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade"): Promise<boolean>;
58
+ export declare function nameTelegramBot(transport: TelegramTransport, when: "pairing" | "upgrade", signal?: AbortSignal): Promise<boolean>;
57
59
  /** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
58
60
  * "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
59
61
  * task's short title when one is given, else "this task". */
@@ -53,6 +53,9 @@ export function finishedLine(fact) {
53
53
  return fact.update.words;
54
54
  const name = fact.summary, lower = inSentence(fact.summary);
55
55
  const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
56
+ // What waited on the person and the lead has taken on (Needs you reads Waiting then).
57
+ if (fact.leadOnIt === true && (fact.headline === "Needs you" || fact.headline === "Waiting" || fact.headline === "Stopped"))
58
+ return `${name}: your lead is on it.`;
56
59
  switch (fact.headline) {
57
60
  case "Ready for review":
58
61
  if (fact.report)
@@ -63,6 +66,8 @@ export function finishedLine(fact) {
63
66
  return `${ready}. Checks are off for this project, so look it over first.`;
64
67
  return `${ready}, but no tests ran. Look it over first.`;
65
68
  case "Failed":
69
+ if (fact.leadOnIt === true)
70
+ return fact.checks === "failed" ? `${name} is built, but its tests failed. Your lead is on it.` : `${name} stopped before it finished. Your lead is on it.`;
66
71
  if (fact.checks === "failed")
67
72
  return fact.lead === true
68
73
  ? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
@@ -103,8 +108,9 @@ export function batchLine(facts) {
103
108
  const shown = names.slice(0, 3).join(", ");
104
109
  const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
105
110
  // What happens next: anything that broke or needs a decision waits for the person.
106
- const failed = facts.filter(one => one.headline === "Failed");
107
- const needs = facts.filter(one => one.headline === "Needs you").length;
111
+ // What the lead is on waits for nobody.
112
+ const failed = facts.filter(one => one.headline === "Failed" && one.leadOnIt !== true);
113
+ const needs = facts.filter(one => one.headline === "Needs you" && one.leadOnIt !== true).length;
108
114
  const broke = failed.length !== 1 ? `${failed.length} failed`
109
115
  : failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
110
116
  const waits = [
@@ -123,16 +129,18 @@ const OLD_NAME = /standing\s*-?\s*orders/i;
123
129
  * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
124
130
  * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
125
131
  */
126
- export async function nameTelegramBot(transport, when) {
132
+ export async function nameTelegramBot(transport, when, signal) {
127
133
  try {
128
- const current = await transport("getMyName", {});
134
+ const current = await transport("getMyName", {}, signal);
129
135
  const name = current.ok ? current.result?.name : undefined;
130
136
  const shown = typeof name === "string" ? name : null;
131
137
  if (shown === BOT_NAME)
132
138
  return false;
133
139
  if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
134
140
  return false;
135
- const set = await transport("setMyName", { name: BOT_NAME });
141
+ if (signal?.aborted)
142
+ return false;
143
+ const set = await transport("setMyName", { name: BOT_NAME }, signal);
136
144
  return set.ok;
137
145
  }
138
146
  catch {
package/dist/cli.d.ts CHANGED
@@ -39,7 +39,7 @@ export type ParseResult = {
39
39
  error: string;
40
40
  };
41
41
  type Write = (line: string) => void;
42
- export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
42
+ export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
43
43
  export declare function parseArgs(argv: readonly string[]): ParseResult;
44
44
  /** The commands that operate the queue rather than report on the world. */
45
45
  /** Every verb the CLI routes OUTSIDE runOperate. Exported (with
package/dist/cli.js CHANGED
@@ -66,6 +66,7 @@ Usage
66
66
  toolroll connect save a private connection to your central service
67
67
  toolroll lead named leads on the connected service
68
68
  toolroll lead token mint your lead agent's credential: its own work pings nobody
69
+ toolroll lead say "<text>" (the lead) one short message in its person's chat
69
70
  toolroll conversation shared and private conversations on that service
70
71
  toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)
71
72
 
@@ -10,6 +10,19 @@ export declare function activeCodingUpdateWork(orders: Database): {
10
10
  /** An idle provider may remain loaded during drain. The normal server stop
11
11
  * must close it and establish native/tool exit before either app swap. */
12
12
  export declare function assertCodingUpdateStopped(orders: Database): void;
13
+ /** What a stale owner record named when an updater released it, for its ledger. */
14
+ export type ReleasedCodingOwner = {
15
+ pid: number;
16
+ nativePid: number | null;
17
+ };
18
+ /** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
19
+ * owner record naming a dead process, and every later swap refuses. Only an
20
+ * updater that has itself proved each process of the stopped service gone may
21
+ * release it here, and only while nothing the catalog records still lives:
22
+ * the agent, its tools, a session or an unconfirmed message. Returns what it
23
+ * released, or null when the record was already released by an ordinary stop;
24
+ * anything unproven keeps the ordinary refusal. */
25
+ export declare function releaseStaleCodingOwner(orders: Database, stoppedPids: readonly number[], alive?: (pid: number, group: boolean) => boolean): ReleasedCodingOwner | null;
13
26
  /** Back up through SQLite so committed WAL records are included. This is a
14
27
  * retained recovery artifact; updates never restore the live catalog. */
15
28
  export declare function backupCodingCatalog(sourceFile: string, target: string, updateId: string): Promise<void>;
@@ -1,6 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { chmodSync, closeSync, fsyncSync, lstatSync, openSync } from 'node:fs';
3
+ import { hostname } from 'node:os';
3
4
  import { sqliteRuntime } from './sqlite-runtime.js';
5
+ import { processMayBeAlive } from './process-liveness.js';
4
6
  const gatePrefix = 'so_coding_update_';
5
7
  const pausedMessage = 'Toolroll is updating. Current work can finish; new coding work will resume after the update.';
6
8
  export function codingCatalogPath(orders) {
@@ -100,6 +102,72 @@ export function assertCodingUpdateStopped(orders) {
100
102
  throw Error('The coding server has not verified agent and tool shutdown. No app was replaced.');
101
103
  });
102
104
  }
105
+ /** A runtime killed before its close (deploys over 0.9.11, Oct 2) leaves the
106
+ * owner record naming a dead process, and every later swap refuses. Only an
107
+ * updater that has itself proved each process of the stopped service gone may
108
+ * release it here, and only while nothing the catalog records still lives:
109
+ * the agent, its tools, a session or an unconfirmed message. Returns what it
110
+ * released, or null when the record was already released by an ordinary stop;
111
+ * anything unproven keeps the ordinary refusal. */
112
+ export function releaseStaleCodingOwner(orders, stoppedPids, alive = processMayBeAlive) {
113
+ return withCatalog(orders, null, false, db => {
114
+ db.exec('BEGIN IMMEDIATE');
115
+ try {
116
+ const owner = db.prepare('SELECT token,pid,native_pid,clean FROM coding_owner WHERE singleton=1').get();
117
+ const pid = Number(owner['pid']), nativePid = owner['native_pid'] === null ? null : Number(owner['native_pid']);
118
+ if (owner['token'] === '' && pid === 0 && nativePid === null && owner['clean'] === 1) {
119
+ db.exec('ROLLBACK');
120
+ return null;
121
+ }
122
+ const refuse = (why) => { throw Error(`The coding server has not verified agent and tool shutdown (${why}). No app was replaced.`); };
123
+ // A record its own server released while recovery was still required stays as it is: that needs a person's check.
124
+ if (owner['token'] === '' || !Number.isSafeInteger(pid) || pid <= 0)
125
+ refuse('its saved record needs a recovery check');
126
+ if (!stoppedPids.includes(pid))
127
+ refuse(`process ${pid} is not one this update stopped`);
128
+ if (alive(pid, false))
129
+ refuse(`process ${pid} is still running`);
130
+ // The agent runs in its own process group: a service killed by launchd can leave it behind.
131
+ if (nativePid !== null && alive(nativePid, true))
132
+ refuse(`the agent process ${nativePid} is still running`);
133
+ const custody = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='coding_custody'").get()
134
+ ? db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get() : undefined;
135
+ if (custody) {
136
+ let c;
137
+ try {
138
+ c = JSON.parse(String(custody['payload']));
139
+ }
140
+ catch {
141
+ c = {};
142
+ }
143
+ const processes = Array.isArray(c.descendants) ? c.descendants : null;
144
+ if (c.host !== hostname() || c.container || c.observationUnknown !== false || processes === null)
145
+ refuse("the agent's tool processes could not be accounted for");
146
+ const recorded = [...(c.pid === null ? [] : [{ pid: c.pid, group: c.group }]), ...processes];
147
+ for (const one of recorded) {
148
+ if (!Number.isSafeInteger(one.pid) || Number(one.pid) <= 0)
149
+ refuse("the agent's tool processes could not be accounted for");
150
+ if (alive(Number(one.pid), one.group === true))
151
+ refuse(`the agent's process ${String(one.pid)} is still running`);
152
+ }
153
+ }
154
+ const sessions = Number(db.prepare("SELECT count(*) n FROM coding_session WHERE json_extract(document,'$.status') IN ('starting','working','needs-input','stopping','uncertain') OR json_extract(document,'$.turnId') IS NOT NULL").get()?.['n']);
155
+ const deliveries = Number(db.prepare("SELECT count(*) n FROM coding_submission WHERE status IN ('preparing','pending','uncertain')").get()?.['n']);
156
+ if (sessions > 0 || deliveries > 0)
157
+ refuse(`${sessions} coding session(s) and ${deliveries} unconfirmed message(s) were still active`);
158
+ db.prepare("UPDATE coding_owner SET token='',pid=0,native_pid=NULL,clean=1 WHERE singleton=1 AND token=?").run(owner['token']);
159
+ db.exec('COMMIT');
160
+ return { pid, nativePid };
161
+ }
162
+ catch (error) {
163
+ try {
164
+ db.exec('ROLLBACK');
165
+ }
166
+ catch { /* already ended */ }
167
+ throw error;
168
+ }
169
+ });
170
+ }
103
171
  function snapshot(db) {
104
172
  const data = {};
105
173
  // Include every catalog table, including current/future custody records.
@@ -1,5 +1,6 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
2
  import { type DesktopBundle } from "./desktop-bundle.js";
3
+ import { type ReleasedCodingOwner } from "./coding-update.js";
3
4
  type Phase = "prepared" | "draining" | "backing-up" | "stopping" | "installing" | "verifying" | "rolling-back" | "releasing" | "complete" | "restored" | "cancelled" | "needs-attention";
4
5
  export type UpdateJournal = {
5
6
  version: 1;
@@ -31,6 +32,10 @@ export type UpdateJournal = {
31
32
  waiting?: LingeringRun & {
32
33
  since: string;
33
34
  };
35
+ /** The service's supervisor and controller, recorded before it is stopped: the processes proved gone afterwards. */
36
+ stoppedPids?: number[];
37
+ /** A stale coding owner record this update released after proving those processes gone. */
38
+ codingOwnerReleased?: ReleasedCodingOwner;
34
39
  };
35
40
  type ServiceState = {
36
41
  state: string;
@@ -49,6 +54,8 @@ export type UpdateHooks = {
49
54
  /** Fault injection for state-machine tests, never selectable by a CLI flag. */
50
55
  checkpoint?: (phase: Phase) => void;
51
56
  otherControllers?: (bundle: DesktopBundle, stateDir: string) => Promise<boolean>;
57
+ /** Whether a recorded service process still runs (tests). */
58
+ processAlive?: (pid: number) => boolean;
52
59
  };
53
60
  export declare const updateTerminal: (phase: string) => boolean;
54
61
  /** Durable receipts are private and independent of the app being replaced. */
@@ -10,7 +10,8 @@ import { run } from "./exec.js";
10
10
  import { readSchemaVersion, SCHEMA_VERSION, Store } from "./store.js";
11
11
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused } from "./desktop-update-gate.js";
12
12
  import { currentDesktopAccess } from "./desktop-access.js";
13
- import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists } from "./coding-update.js";
13
+ import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner } from "./coding-update.js";
14
+ import { processMayBeAlive } from "./process-liveness.js";
14
15
  /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
15
16
  * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
16
17
  function sqlite() {
@@ -76,6 +77,8 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
76
77
  throw Error("The recorded replacement state is invalid. Nothing was changed.");
77
78
  if (j.codingCatalogExpected !== undefined && typeof j.codingCatalogExpected !== "boolean")
78
79
  throw Error("The recorded coding catalog presence is invalid. Nothing was changed.");
80
+ if (j.stoppedPids !== undefined && (!Array.isArray(j.stoppedPids) || !j.stoppedPids.every(pid => Number.isSafeInteger(pid) && pid > 1)))
81
+ throw Error("The recorded service processes are invalid. Nothing was changed.");
79
82
  if ((j.codingBackupPath === undefined) !== (j.codingBackupHash === undefined) || (j.codingBackupPath && (dirname(j.codingBackupPath) !== j.workDir || !/^coding\.backup(?:\.[a-f0-9-]{36})?\.sqlite$/.test(basename(j.codingBackupPath)))) || (j.codingBackupHash && !/^[a-f0-9]{64}$/.test(j.codingBackupHash)))
80
83
  throw Error("The retained coding backup paths or identity are invalid. Nothing was changed.");
81
84
  for (const directory of [dirname(j.workDir), j.workDir]) {
@@ -537,6 +540,34 @@ function assertDesktopCodingStopped(j, db) {
537
540
  assertCodingCatalogPresent(j);
538
541
  assertCodingUpdateStopped(db);
539
542
  }
543
+ /** The service's own processes, from its supervisor's status file: read before the stop, never after. */
544
+ function servicePids(stateDir) {
545
+ try {
546
+ const status = JSON.parse(readFileSync(join(stateDir, "controller-supervisor.json"), "utf8"));
547
+ return [status.supervisorPid, status.controllerPid].filter((pid) => Number.isSafeInteger(pid) && Number(pid) > 1);
548
+ }
549
+ catch {
550
+ return [];
551
+ }
552
+ }
553
+ /** An older app killed before its close leaves the coding owner record behind. Once the stopped service's processes
554
+ * are proved gone, it is released here (and recorded in the ledger); anything unproven keeps the ordinary refusal. */
555
+ function releaseDesktopCodingOwner(j, db, alive) {
556
+ assertCodingCatalogPresent(j);
557
+ const released = releaseStaleCodingOwner(db, j.stoppedPids ?? [], (pid, group) => alive(pid) || processMayBeAlive(pid, group));
558
+ if (released === null)
559
+ return;
560
+ j.codingOwnerReleased = released;
561
+ save(j, j.phase, j.detail);
562
+ const ledger = connect(j.databaseFile);
563
+ try {
564
+ ledger.prepare("INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (?,'desktop update',NULL,NULL,NULL,'coding owner released','released','policy',?)")
565
+ .run(new Date().toISOString(), `${j.old.version} stopped without releasing the coding workspace; process ${released.pid}${released.nativePid === null ? "" : ` and agent ${released.nativePid}`} proved gone`);
566
+ }
567
+ finally {
568
+ ledger.close();
569
+ }
570
+ }
540
571
  function assertCodingBackup(j) {
541
572
  assertCodingCatalogPresent(j);
542
573
  if (j.codingBackupPath || j.codingBackupHash) {
@@ -815,15 +846,25 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
815
846
  throw Error("The installed app changed after preview. Nothing was replaced.");
816
847
  const current = actual === j.old.hash ? j.old : atInstalled(j.next, j);
817
848
  j.serviceInterrupted = true;
849
+ // Recorded once, before the first stop: a resumed update keeps the processes it first saw.
850
+ if (j.stoppedPids === undefined)
851
+ j.stoppedPids = servicePids(j.stateDir);
818
852
  checkpoint("stopping", "Current work is finished. Stopping the background service for the update.");
819
853
  await control("stop", current, j);
820
854
  const stopped = await status(current, j);
821
855
  if (["running", "loaded"].includes(stopped.state))
822
856
  throw Error("The background service did not stop. No app swap was attempted.");
857
+ const alive = hooks.processAlive ?? (pid => processMayBeAlive(pid, false));
858
+ for (let waited = 0; j.stoppedPids.some(alive); waited += 250) {
859
+ if (waited >= 60_000)
860
+ throw Error(`The background service is still running (process ${j.stoppedPids.filter(alive).join(", ")}) after it was stopped. No app swap was attempted.`);
861
+ await sleep(250);
862
+ }
823
863
  const afterStop = connect(j.databaseFile, true);
824
864
  try {
825
865
  if (Object.values(activeUpdateWork(afterStop)).some(n => n !== 0) || afterStop.prepare("SELECT 1 FROM watch_lease WHERE expires_at>? LIMIT 1").get(new Date().toISOString()))
826
866
  throw Error("A worker or active operation still uses this database. Stop the other controller before continuing the update.");
867
+ releaseDesktopCodingOwner(j, afterStop, alive);
827
868
  assertDesktopCodingStopped(j, afterStop);
828
869
  }
829
870
  finally {