toolroll 0.9.11 → 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 (44) hide show
  1. package/CHANGELOG.md +18 -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-delivery.js +17 -0
  8. package/dist/chat-quiet.js +1 -0
  9. package/dist/chat-voice.d.ts +3 -1
  10. package/dist/chat-voice.js +13 -5
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +1 -0
  13. package/dist/coding-update.d.ts +13 -0
  14. package/dist/coding-update.js +68 -0
  15. package/dist/desktop-update.d.ts +7 -0
  16. package/dist/desktop-update.js +42 -1
  17. package/dist/flow-triggers.js +17 -1
  18. package/dist/guides.js +9 -0
  19. package/dist/lead-status.d.ts +10 -1
  20. package/dist/lead-status.js +13 -3
  21. package/dist/lead-voice.d.ts +81 -0
  22. package/dist/lead-voice.js +275 -0
  23. package/dist/needs-you.d.ts +7 -0
  24. package/dist/needs-you.js +9 -0
  25. package/dist/operate.js +80 -4
  26. package/dist/serve.d.ts +5 -1
  27. package/dist/serve.js +438 -224
  28. package/dist/setup-guide.d.ts +2 -2
  29. package/dist/store.js +8 -0
  30. package/dist/surface.js +8 -1
  31. package/dist/task-status.d.ts +6 -0
  32. package/dist/task-status.js +19 -2
  33. package/dist/team-cli.js +1 -1
  34. package/dist/telegram-progress.js +7 -0
  35. package/dist/telegram.js +34 -3
  36. package/dist/test-temp.js +1 -1
  37. package/dist/toolroll-update.d.ts +3 -0
  38. package/dist/toolroll-update.js +13 -2
  39. package/dist/work-index.d.ts +9 -0
  40. package/dist/work-index.js +57 -20
  41. package/dist/work-summary.d.ts +4 -1
  42. package/dist/workspace-ui.d.ts +25 -0
  43. package/dist/workspace-ui.js +20 -1
  44. 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.
@@ -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";
@@ -651,6 +652,7 @@ export async function planChatNotifications(options) {
651
652
  enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
652
653
  }
653
654
  catch { /* Retried on the next pass. */ }
655
+ enqueueLeadLapses(store, nowOf(options));
654
656
  const cursor = Number(state
655
657
  .prepare("SELECT notification FROM chat_runtime WHERE installation=?")
656
658
  .get(identity.installation)?.notification ?? 0);
@@ -676,6 +678,21 @@ export async function planChatNotifications(options) {
676
678
  // Only when I'm needed (the default): the task's one card is edited in place, and a new message
677
679
  // follows only when this person is needed. Every step keeps the branches below unchanged.
678
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
+ }
679
696
  if (quiet) {
680
697
  planQuietCard(options, binding, notification, now);
681
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 {
@@ -446,7 +446,13 @@ function polled(config) {
446
446
  function startZone(config, definition) {
447
447
  return config.zone !== null && definition.stages.some(one => one.id === config.zone) ? config.zone : definition.start;
448
448
  }
449
- /** One outside thing into one card, at most once per trigger. Keys and control characters never reach a card. */
449
+ /** The active flow that already made a card for this failing commit in a project, if any. */
450
+ function checksCardOf(store, repo, key) {
451
+ const row = store.handle.prepare(`SELECT f.name FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger JOIN flow f ON f.id = t.flow
452
+ WHERE f.repo = ? AND f.state = 'active' AND e.key = ? AND e.card IS NOT NULL LIMIT 1`).get(repo, key);
453
+ return row === undefined ? null : `“${String(row["name"])}”`;
454
+ }
455
+ /** One outside thing into one card, at most once per trigger (a failing run, once per project). Keys and control characters never reach a card. */
450
456
  function makeCard(store, trigger, config, item, by, now) {
451
457
  const flow = store.getFlow(trigger.flow);
452
458
  const definition = flow === null ? null : flowDefinitionOf(flow);
@@ -460,7 +466,17 @@ function makeCard(store, trigger, config, item, by, now) {
460
466
  store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
461
467
  return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
462
468
  }
469
+ // Claimed inside the write lock: a worker and a console (or an old and a new worker) checking at once make one card.
463
470
  return store.transact(() => {
471
+ if (store.flowTriggerSaw(trigger.id, item.key))
472
+ return { made: "seen", note: null, card: null };
473
+ // One failing run is one fix: a second trigger watching the same checks in this project leaves it to the first.
474
+ const filed = item.key.startsWith("checks:") ? checksCardOf(store, flow.repo, item.key) : null;
475
+ if (filed !== null) {
476
+ const note = `already a card in ${filed}`;
477
+ store.recordFlowTriggerEvent(trigger.id, item.key, null, note, now);
478
+ return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
479
+ }
464
480
  const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, now);
465
481
  store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now);
466
482
  return { made: "added", note: null, card };
package/dist/guides.js CHANGED
@@ -95,6 +95,15 @@ login, so your housekeeping never pings them.
95
95
  or for a security alert.
96
96
  - Hand a task to a person: \`task ask <id> --person <name> --why "<what
97
97
  they need to do>"\` — one message, and the task is theirs from then on.
98
+ - Tell your person what you are doing, at milestones: \`lead say "<one
99
+ short line>" [--task <id>]\` when you start work, when you are handling a
100
+ failure, and when you are done. Plain words ("Fixing the release check's
101
+ failing test"); several within two minutes become one message.
102
+ - Take on a task you are fixing: \`assignment claim <id>\`. It reads "Your
103
+ lead is on it" instead of waiting for them and leaves Needs you until you
104
+ complete it, hand it over with \`task ask\`, or go two hours without
105
+ acting on it (then it is back with them, and says so). Claim a failure
106
+ before you start on it, and \`lead say\` what you are doing.
98
107
  - Re-filing (a release check re-gated as \`release-<name>b\`): \`task add
99
108
  "<title>" --id <new> --replaces <old>\`, or \`task state <old> cancelled
100
109
  --replaced-by <new>\`. The old task reads "Replaced by <new>", never
@@ -76,6 +76,14 @@ export type InstallationStatus = {
76
76
  headline: string;
77
77
  sentence: string;
78
78
  }[];
79
+ /** What the person's lead is doing now and when it last acted (lead-voice.ts), as one line. */
80
+ lead: {
81
+ owner: string;
82
+ doing: string;
83
+ at: string;
84
+ task: string | null;
85
+ line: string;
86
+ } | null;
79
87
  };
80
88
  /** One indexed observation for `task wait`. `watchedRun` is the attempt the
81
89
  * caller first saw. Each poll follows the task's newest attempt from there, so
@@ -93,6 +101,7 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
93
101
  exitsRecorded: number;
94
102
  readyForReview: number;
95
103
  };
96
- export declare function installationStatus(store: Store, now: Date): InstallationStatus;
104
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
105
+ export declare function installationStatus(store: Store, now: Date, viewer?: string | null): InstallationStatus;
97
106
  export declare function renderInstallationStatus(status: InstallationStatus): string[];
98
107
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;
@@ -5,6 +5,8 @@ import { windowLabel } from "./provider-limits.js";
5
5
  import { openAuthPauses, signInCommand, signInReason } from "./provider-auth.js";
6
6
  import { BUILT_IN, checkSuitesOf } from "./store.js";
7
7
  import { workIndexPage, workReadyForReview } from "./work-index.js";
8
+ import { currentActor } from "./actor.js";
9
+ import { leadActivity, leadActivityLine } from "./lead-voice.js";
8
10
  const maybeNumber = (value) => value === null || value === undefined || !Number.isInteger(Number(value)) ? null : Number(value);
9
11
  const CHECK_COLUMNS = "rc.status AS check_status, rc.exit_code AS check_exit_code, rc.suites_json AS check_suites_json";
10
12
  function checkOf(row) {
@@ -177,7 +179,8 @@ export function repairStaleStatuses(store, now) {
177
179
  const exitsRecorded = store.recordFinishedRunExits(now);
178
180
  return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
179
181
  }
180
- export function installationStatus(store, now) {
182
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
183
+ export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
181
184
  const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.external_id AS task
182
185
  FROM run INDEXED BY work_unfinished
183
186
  JOIN task_ref AS ref ON ref.id = run.task_ref
@@ -251,8 +254,13 @@ export function installationStatus(store, now) {
251
254
  observedAt: String(row["observed_at"]),
252
255
  })),
253
256
  signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
254
- tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true }, { limit: 8 }).items
257
+ tasks: workIndexPage(store, now, { principal: "operator", repos: null, includeUnplaced: true, viewer }, { limit: 8 }).items
255
258
  .map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
259
+ lead: (() => {
260
+ // Only the asker's own lead: another person's lead is never "Your lead".
261
+ const activity = leadActivity(store, viewer);
262
+ return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
263
+ })(),
256
264
  };
257
265
  }
258
266
  export function renderInstallationStatus(status) {
@@ -260,6 +268,8 @@ export function renderInstallationStatus(status) {
260
268
  // A paused provider comes first: it is the one thing a person must do.
261
269
  for (const one of status.signIn)
262
270
  lines.push(`${one.reason} — run \`${one.command}\`. Its tasks wait until then.`);
271
+ if (status.lead != null)
272
+ lines.push(status.lead.line);
263
273
  // Each task's one headline, the same words as the console and chat cards.
264
274
  if (status.tasks.length > 0) {
265
275
  lines.push("Tasks:");
@@ -295,7 +305,7 @@ export function renderInstallationStatus(status) {
295
305
  lines.push(`Plan windows: ${provider} — ${detail}`);
296
306
  }
297
307
  }
298
- return lines.slice(0, 12 + status.signIn.length + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
308
+ return lines.slice(0, 12 + status.signIn.length + (status.lead == null ? 0 : 1) + (status.tasks.length === 0 ? 0 : status.tasks.length + 1));
299
309
  }
300
310
  export function renderTaskWait(snapshot, outcome = snapshot.outcome) {
301
311
  const check = snapshot.check.status === "unknown" ? "checks unknown" : snapshot.check.status === "not-run" ? "checks not run" : `checks ${snapshot.check.status}${snapshot.check.exitCode === null ? "" : ` (exit ${snapshot.check.exitCode})`}`;