toolroll 0.9.59 → 0.9.60

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 (64) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +8 -6
  3. package/dist/assignment-adapters.d.ts +1 -1
  4. package/dist/assignment-adapters.js +1 -1
  5. package/dist/assignment-delivery.d.ts +16 -0
  6. package/dist/assignment-delivery.js +54 -28
  7. package/dist/assignment-status.d.ts +13 -4
  8. package/dist/assignment-status.js +70 -39
  9. package/dist/assignment.d.ts +6 -2
  10. package/dist/assignment.js +13 -8
  11. package/dist/bin.js +11 -2
  12. package/dist/brief-fence.d.ts +9 -0
  13. package/dist/brief-fence.js +22 -0
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +19 -19
  16. package/dist/builder.js +74 -25
  17. package/dist/chat-channel.js +1 -1
  18. package/dist/cli-fast.d.ts +8 -0
  19. package/dist/cli-fast.js +18 -0
  20. package/dist/cli-help.d.ts +5 -0
  21. package/dist/cli-help.js +91 -0
  22. package/dist/cli.d.ts +6 -5
  23. package/dist/cli.js +17 -96
  24. package/dist/design/tokens.css +234 -0
  25. package/dist/discord.js +2 -1
  26. package/dist/evidence.d.ts +22 -0
  27. package/dist/evidence.js +76 -17
  28. package/dist/flow-actions.js +2 -1
  29. package/dist/guides.js +14 -11
  30. package/dist/lead-cli.js +1 -1
  31. package/dist/lead-contract.js +1 -1
  32. package/dist/lead-doors.js +5 -2
  33. package/dist/lead-follow.d.ts +2 -0
  34. package/dist/lead-follow.js +138 -88
  35. package/dist/lead-tools.js +9 -2
  36. package/dist/mailbox.js +3 -2
  37. package/dist/mcp-core.js +1 -1
  38. package/dist/operate.js +17 -7
  39. package/dist/provider.d.ts +8 -0
  40. package/dist/provider.js +12 -1
  41. package/dist/serve.js +3 -2
  42. package/dist/server/chrome.d.ts +6 -3
  43. package/dist/server/chrome.js +8 -4
  44. package/dist/server/console.css +9 -64
  45. package/dist/server/render-chat.js +2 -2
  46. package/dist/server/render-tasks.d.ts +2 -0
  47. package/dist/server/render-tasks.js +11 -13
  48. package/dist/server/task-views.js +1 -0
  49. package/dist/slack-socket.d.ts +12 -0
  50. package/dist/slack-socket.js +29 -0
  51. package/dist/slack.d.ts +0 -7
  52. package/dist/slack.js +2 -24
  53. package/dist/steer-mailbox.d.ts +39 -0
  54. package/dist/steer-mailbox.js +207 -0
  55. package/dist/steer-status.d.ts +19 -0
  56. package/dist/steer-status.js +30 -0
  57. package/dist/store.d.ts +41 -2
  58. package/dist/store.js +95 -3
  59. package/dist/surface.js +2 -2
  60. package/dist/sweep-gate.d.ts +10 -0
  61. package/dist/sweep-gate.js +18 -0
  62. package/dist/team-updates.js +3 -3
  63. package/dist/toolroll-update.js +14 -2
  64. package/package.json +21 -21
@@ -30,83 +30,28 @@
30
30
  font-family: "Geist Mono"; font-style: normal; font-weight: 600;
31
31
  font-display: swap; src: url("/fonts/geist-mono-600.woff2") format("woff2");
32
32
  }
33
- /* One palette for every page (2026-09-27): neutral grey and paper with
34
- * one chart magenta, light by day and dark after hours. The device decides unless
35
- * the person pins a theme (html[data-theme], set from their cookie). The
36
- * console's older token names map onto it, so the React workspace, the
37
- * server-rendered pages and the pre-script fallback all paint the same. */
33
+ /* Signal's colour, shadow, radius, type and spacing tokens (--so-*) are
34
+ * generated by youi into src/design/tokens.css, light and dark, and the
35
+ * server prepends that sheet to this one (chrome.ts STYLE). Edit them in the
36
+ * design-language repo, never here. The device decides the scheme unless the
37
+ * person pins one (html[data-theme], set from their cookie). What follows are
38
+ * the console's older names: views onto those tokens, never a second ramp. */
38
39
  :root {
39
- color-scheme: light;
40
-
41
- --so-ground: #efefef; --so-paper: #ffffff; --so-sidebar: #efefef; --so-raised: #f5f5f5;
42
- --so-ink: #171717; --so-muted: #666666; --so-line: #e6e6e6; --so-input-line: #d4d4d4;
43
- --so-accent: #171717; --so-accent-hover: #383838; --so-accent-text: #171717; --so-on-accent: #ffffff; --so-soft: #f2f2f2;
44
- --so-nav-ink: #525252; --so-nav-hover: #e4e4e4; --so-nav-current: #ffffff; --so-nav-current-ink: #171717;
45
- --so-signal: #171717; --so-signal-hover: #383838; --so-on-signal: #ffffff; --so-signal-soft: #e8e8e8;
46
- --so-danger: #c4320a; --so-danger-soft: #feebe7; --so-success: #218358; --so-success-soft: #e6f6eb;
47
- --so-warning: #ab6400; --so-warning-soft: #fff4d5; --so-info: #0d74ce; --so-info-soft: #e6f4fe;
48
- --so-neutral-ink: #525252; --so-neutral-soft: #f0f0f0; --so-live: #0d74ce;
49
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
50
- --so-sheet-shadow: 0 0 0 1px rgb(0 0 0 / .06), 0 1px 2px rgb(0 0 0 / .04), 0 4px 12px -6px rgb(0 0 0 / .06);
51
- --so-pill-shadow: 0 0 0 1px rgb(0 0 0 / .06), 0 1px 2px rgb(0 0 0 / .06);
52
- --so-overlay: rgb(0 0 0 / .32); --so-shadow-overlay: 0 0 0 1px rgb(0 0 0 / .08), 0 24px 48px -12px rgb(0 0 0 / .22);
53
- --so-selection: #d4d4d4; --so-scroll: #cfcfcf; --so-code-bg: #fafafa; --so-user-bubble: #f2f2f2;
54
-
55
40
  --background: var(--so-ground); --foreground: var(--so-ink); --card: var(--so-paper);
56
41
  --muted: var(--so-raised); --muted-foreground: var(--so-muted); --border: var(--so-line); --input: var(--so-input-line);
57
42
  --primary: var(--so-accent); --primary-foreground: var(--so-on-accent); --secondary: var(--so-raised); --secondary-foreground: var(--so-ink);
58
43
  --accent: var(--so-soft); --destructive: var(--so-danger); --destructive-strong: var(--so-danger); --destructive-soft: var(--so-danger-soft);
59
44
  --success: var(--so-success); --success-soft: var(--so-success-soft); --warning: var(--so-warning); --warning-soft: var(--so-warning-soft);
60
45
  --running: var(--so-info); --running-soft: var(--so-info-soft); --ring: var(--so-signal);
61
- /* Magenta means one thing: a person is needed (approve, answer, decide). */
46
+ /* The accent means one thing: a person is needed (approve, answer, decide). */
62
47
  --brand: var(--so-attention); --brand-foreground: var(--so-on-attention); --brand-soft: var(--so-attention-soft);
63
48
  --glass: var(--so-paper); --glass-strong: var(--so-paper); --glass-border: var(--so-line); --glass-highlight: transparent;
64
49
  --ambient-one: transparent; --ambient-two: transparent; --user-message: var(--so-user-bubble);
65
50
  --surface: var(--so-paper); --ok: var(--so-success); --danger: var(--so-danger); --fg-muted: var(--so-muted);
66
51
  --shadow: none; --shadow-overlay: var(--so-shadow-overlay);
67
52
  --radius: 0.625rem;
68
- --so-ease-out: cubic-bezier(.23, 1, .32, 1);
69
- --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
70
- --font-mono: "Geist Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
71
- /* Phone spacing steps (760px and narrower): side gutter, gap under the
72
- header, gap between page blocks, between rows, and inside a card. */
73
- --so-phone-gutter: 16px; --so-phone-gap: 12px; --so-phone-block: 14px; --so-phone-row: 8px; --so-phone-card: 12px;
74
- }
75
- @media (prefers-color-scheme: dark) {
76
- :root:not([data-theme="light"]) {
77
- color-scheme: dark;
78
-
79
- --so-ground: #0b0b0b; --so-paper: #161616; --so-sidebar: #0b0b0b; --so-raised: #1c1c1c;
80
- --so-ink: #ededed; --so-muted: #a1a1a1; --so-line: #262626; --so-input-line: #363636;
81
- --so-accent: #ededed; --so-accent-hover: #ffffff; --so-accent-text: #ededed; --so-on-accent: #0a0a0a; --so-soft: #1f1f1f;
82
- --so-nav-ink: #a1a1a1; --so-nav-hover: #171717; --so-nav-current: #1f1f1f; --so-nav-current-ink: #ededed;
83
- --so-signal: #ededed; --so-signal-hover: #ffffff; --so-on-signal: #0a0a0a; --so-signal-soft: #2e2e2e;
84
- --so-danger: #ff977d; --so-danger-soft: rgb(255 151 125 / .12); --so-success: #3dd68c; --so-success-soft: rgb(61 214 140 / .12);
85
- --so-warning: #ffca16; --so-warning-soft: rgb(255 202 22 / .12); --so-info: #70b8ff; --so-info-soft: rgb(112 184 255 / .12);
86
- --so-neutral-ink: #b4b4b4; --so-neutral-soft: #1f1f1f; --so-live: #70b8ff;
87
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
88
- --so-sheet-shadow: 0 0 0 1px #262626, 0 1px 2px rgb(0 0 0 / .4);
89
- --so-pill-shadow: 0 0 0 1px #2a2a2a, 0 1px 2px rgb(0 0 0 / .5);
90
- --so-overlay: rgb(0 0 0 / .6); --so-shadow-overlay: 0 0 0 1px #2e2e2e, 0 24px 48px -12px rgb(0 0 0 / .7);
91
- --so-selection: #3a3a3a; --so-scroll: #333333; --so-code-bg: #111111; --so-user-bubble: #202020;
92
- }
93
- }
94
- :root[data-theme="dark"] {
95
- color-scheme: dark;
96
-
97
- --so-ground: #0b0b0b; --so-paper: #161616; --so-sidebar: #0b0b0b; --so-raised: #1c1c1c;
98
- --so-ink: #ededed; --so-muted: #a1a1a1; --so-line: #262626; --so-input-line: #363636;
99
- --so-accent: #ededed; --so-accent-hover: #ffffff; --so-accent-text: #ededed; --so-on-accent: #0a0a0a; --so-soft: #1f1f1f;
100
- --so-nav-ink: #a1a1a1; --so-nav-hover: #171717; --so-nav-current: #1f1f1f; --so-nav-current-ink: #ededed;
101
- --so-signal: #ededed; --so-signal-hover: #ffffff; --so-on-signal: #0a0a0a; --so-signal-soft: #2e2e2e;
102
- --so-danger: #ff977d; --so-danger-soft: rgb(255 151 125 / .12); --so-success: #3dd68c; --so-success-soft: rgb(61 214 140 / .12);
103
- --so-warning: #ffca16; --so-warning-soft: rgb(255 202 22 / .12); --so-info: #70b8ff; --so-info-soft: rgb(112 184 255 / .12);
104
- --so-neutral-ink: #b4b4b4; --so-neutral-soft: #1f1f1f; --so-live: #70b8ff;
105
- --so-attention: var(--so-signal); --so-on-attention: var(--so-on-signal); --so-attention-soft: var(--so-signal-soft);
106
- --so-sheet-shadow: 0 0 0 1px #262626, 0 1px 2px rgb(0 0 0 / .4);
107
- --so-pill-shadow: 0 0 0 1px #2a2a2a, 0 1px 2px rgb(0 0 0 / .5);
108
- --so-overlay: rgb(0 0 0 / .6); --so-shadow-overlay: 0 0 0 1px #2e2e2e, 0 24px 48px -12px rgb(0 0 0 / .7);
109
- --so-selection: #3a3a3a; --so-scroll: #333333; --so-code-bg: #111111; --so-user-bubble: #202020;
53
+ --font-sans: var(--so-font-sans);
54
+ --font-mono: var(--so-font-mono);
110
55
  }
111
56
  * { box-sizing: border-box; }
112
57
  /* Touch: no grey flash on a tap, no double-tap zoom wait on a control,
@@ -494,7 +494,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
494
494
  reserve: { label: "Worker assignment", action: payload["worker"] === null ? "release" : "reserve", icon: html `<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>` },
495
495
  hold: { label: "Pause work", action: "hold", icon: html `<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>` },
496
496
  unhold: { label: "Resume work", action: "release hold", icon: html `<path d="m7 4 13 8-13 8z"/>` },
497
- steer: { label: "Guidance for next attempt", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
497
+ steer: { label: "Guidance for the agent", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
498
498
  scope: { label: "Scope revision", action: "save scope", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
499
499
  answer: { label: "Decision answer", action: "confirm answer", icon: html `<path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"/><path d="M12 18h.01"/><circle cx="12" cy="12" r="9"/>` },
500
500
  cancel: { label: "Cancel task", action: "open task", icon: html `<path d="m15 9-6 6"/><path d="m9 9 6 6"/><circle cx="12" cy="12" r="9"/>` },
@@ -572,7 +572,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
572
572
  }
573
573
  else if (view.kind === "steer") {
574
574
  const taskTitle = text("taskTitle") || task;
575
- what = html `<h3>Guide <a href="${taskHref(task)}">${taskTitle}</a>'s next attempt</h3><p class="proposal-summary">${text("note")}</p>${facts(["when", "next attempt"], ["project", mono(repoId)])}<p class="meta proposal-disclosure">This guides the next attempt without changing the task’s scope. It does not interrupt work already running.</p>`;
575
+ what = html `<h3>Guidance for <a href="${taskHref(task)}">${taskTitle}</a></h3><p class="proposal-summary">${text("note")}</p>${facts(["project", mono(repoId)])}<p class="meta proposal-disclosure">A running agent reads it when it next checks for notes; otherwise the next attempt does. The task’s scope doesn’t change.</p>`;
576
576
  }
577
577
  else if (view.kind === "agents") {
578
578
  // The agents card (v48): exactly what changes, in the same words the
@@ -570,6 +570,8 @@ export declare function taskBodyParts(data: {
570
570
  } | null;
571
571
  /** Operator steering notes (arc 1), delivery state included. */
572
572
  steering?: SteerNote[];
573
+ /** A build's agent is running now and reads new notes when it next checks for them (v120). */
574
+ steeringLive?: boolean;
573
575
  /** The publication grant the publisher would act under — from
574
576
  * publicationGrantFor(repo) only; null when none, or no project. */
575
577
  grant?: PublicationGrant | null;
@@ -36,6 +36,7 @@ import { assignmentStageOf, demoChecksOf, stageOfCode, stageOfDispatch, STATUS_M
36
36
  import { buildProgressOf, dispatchActionLabel, earlierAttemptsWords, receiptPublicationWords, resultStatusOf, REVIEW_TOKENS, workStatusOf } from "../workspace-ui.js";
37
37
  import { createHash } from "node:crypto";
38
38
  import { isOverdue, oneLineOf, projectChip, regionScript, safePrUrl, screen, SENSITIVE_INPUT, sentenceCase, shortDigest, transcriptScript, whenTime } from "./chrome.js";
39
+ import { steerDestinationWords, steerStandingParts } from "../steer-status.js";
39
40
  import { reviewHref, taskChatHref, taskHref } from "./http.js";
40
41
  import { coordinatorProposalsSection } from "./render-chat.js";
41
42
  import { completionReceiptCard, criterionMatrixHtml, evidenceWords, homePhaseWords, incidentWords, needsCheck, pullRequestCardHtml, reasonWords, receiptStatusOf, repairChainHtml, reportItemsHtml, reviewRetryPanel, runNoun, runOutcomeBadge, semanticCoverageHtml, terminalDiffCard, verificationExplanation, verificationRecovered } from "./render-results.js";
@@ -1370,24 +1371,21 @@ export function taskBodyParts(data) {
1370
1371
  }
1371
1372
  return box("problem", "Dispatch unknown", "Refresh this task before relying on its scheduler state.", "unknown");
1372
1373
  })();
1373
- // Operator steering (arc 1): notes for the agent, each wearing exactly
1374
- // where it stands — waiting, attached, proven delivered, or superseded.
1374
+ // Operator steering (arc 1, v120): notes for the agent, each wearing the
1375
+ // same words the CLI and the lead use — read (and when), waiting for the
1376
+ // running agent, bound for the next attempt, or ended unread.
1375
1377
  const steering = data.steering ?? [];
1376
- const steerState = (one) => one.authorshipState !== "verified"
1377
- ? "recorded before steering required a credential — never delivered"
1378
- : one.supersededAt !== null
1379
- ? "the task ended before this landed"
1380
- : one.deliveredAt !== null
1381
- ? `reached build #${one.attachedRun}`
1382
- : one.attachedRun !== null
1383
- ? `attached to build #${one.attachedRun} — delivery not yet proven`
1384
- : "waiting for the next attempt";
1378
+ const steeringLive = data.steeringLive === true;
1379
+ const steerState = (one) => {
1380
+ const { words, readAt } = steerStandingParts(one, steeringLive);
1381
+ return readAt === null ? html `${words}` : html `${words} ${whenTime(readAt)}`;
1382
+ };
1385
1383
  const steerRows = steering.length === 0 ? "" : joinHtml(steering.map(one => html `<p class="row"><span class="meta">${one.author} · ${whenTime(one.createdAt)} · ${steerState(one)}</span> ${one.note}</p>`), "\n");
1386
1384
  const steerForm = data.csrf === "" || task.state === "done" || task.state === "cancelled"
1387
1385
  ? ""
1388
1386
  : joinHtml([
1389
- postForm(`${taskHref(task.id)}/steer`, html `<input type="text" name="note" placeholder="guidance for the next attempt" aria-label="steering note" style="width:100%;max-width:28rem"><button type="submit">Steer</button>`, { attrs: { class: "row" } }),
1390
- html `<p class="meta">Lands when the next attempt starts — a running agent is not interrupted, and a note cannot widen the approved scope</p>`,
1387
+ postForm(`${taskHref(task.id)}/steer`, html `<input type="text" name="note" placeholder="guidance for the agent" aria-label="steering note" style="width:100%;max-width:28rem"><button type="submit">Steer</button>`, { attrs: { class: "row" } }),
1388
+ html `<p class="meta">${steerDestinationWords(steeringLive)} A note can't widen the approved scope.</p>`,
1391
1389
  ]);
1392
1390
  const steeringCard = steerRows === "" && steerForm === "" ? "" : html `<h2>Steering</h2>${steerRows}${steerForm}`;
1393
1391
  const holds = data.holds.length === 0
@@ -473,6 +473,7 @@ export function createTaskViews(runtime) {
473
473
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
474
474
  })(),
475
475
  steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
476
+ steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
476
477
  // "publishes as" reads ONLY publicationGrantFor(repo) — the grant
477
478
  // the publisher would act under — never listGrants(), which is
478
479
  // dispatch authority (slice 1c).
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Socket Mode client, apart from the follower so the Slack SDK loads only
3
+ * once Slack is set up (slack.ts imports this when it finds credentials).
4
+ */
5
+ import { SocketModeClient } from "@slack/socket-mode";
6
+ export { LogLevel } from "@slack/socket-mode";
7
+ /** The bot and socket tokens must belong to the same app, including after reconnect. */
8
+ export declare class ToolrollSlackSocket extends SocketModeClient {
9
+ expectedApp: string;
10
+ wrongApp: () => void;
11
+ protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
12
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The Socket Mode client, apart from the follower so the Slack SDK loads only
3
+ * once Slack is set up (slack.ts imports this when it finds credentials).
4
+ */
5
+ import { SocketModeClient } from "@slack/socket-mode";
6
+ export { LogLevel } from "@slack/socket-mode";
7
+ /** The bot and socket tokens must belong to the same app, including after reconnect. */
8
+ export class ToolrollSlackSocket extends SocketModeClient {
9
+ expectedApp = "";
10
+ wrongApp = () => { };
11
+ async onWebSocketMessage(data, isBinary) {
12
+ let packet;
13
+ try {
14
+ packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ if (packet.type === "hello") {
20
+ const info = packet.connection_info;
21
+ if (info?.app_id !== this.expectedApp) {
22
+ this.wrongApp();
23
+ await this.disconnect();
24
+ return;
25
+ }
26
+ }
27
+ return super.onWebSocketMessage(data, isBinary);
28
+ }
29
+ }
package/dist/slack.d.ts CHANGED
@@ -1,11 +1,4 @@
1
- import { SocketModeClient } from "@slack/socket-mode";
2
1
  import { type SlackChatOptions } from "./slack-chat.js";
3
- /** The bot and socket tokens must belong to the same app, including after reconnect. */
4
- export declare class ToolrollSlackSocket extends SocketModeClient {
5
- expectedApp: string;
6
- wrongApp: () => void;
7
- protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
8
- }
9
2
  export declare function followSlack(options: Omit<SlackChatOptions, "identity" | "api" | "owner" | "current"> & {
10
3
  dir: string;
11
4
  signal: AbortSignal;
package/dist/slack.js CHANGED
@@ -1,33 +1,9 @@
1
1
  /** One leased Socket Mode connection, hosted by the ordinary worker. */
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
- import { SocketModeClient, LogLevel } from "@slack/socket-mode";
5
4
  import { loadSlackCredentials, slackApi } from "./slack-api.js";
6
5
  import { ChatState } from "./chat-delivery-state.js";
7
6
  import { receiveSlack, processSlackEvent, deliverSlackPart, planSlackNotifications, planSlackRooms, } from "./slack-chat.js";
8
- /** The bot and socket tokens must belong to the same app, including after reconnect. */
9
- export class ToolrollSlackSocket extends SocketModeClient {
10
- expectedApp = "";
11
- wrongApp = () => { };
12
- async onWebSocketMessage(data, isBinary) {
13
- let packet;
14
- try {
15
- packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
16
- }
17
- catch {
18
- return;
19
- }
20
- if (packet.type === "hello") {
21
- const info = packet.connection_info;
22
- if (info?.app_id !== this.expectedApp) {
23
- this.wrongApp();
24
- await this.disconnect();
25
- return;
26
- }
27
- }
28
- return super.onWebSocketMessage(data, isBinary);
29
- }
30
- }
31
7
  export async function followSlack(options) {
32
8
  const owner = randomBytes(16).toString("hex"), state = new ChatState(options.store, "slack");
33
9
  const pause = async (ms) => {
@@ -56,6 +32,8 @@ export async function followSlack(options) {
56
32
  state.owns(credentials.installation, owner));
57
33
  };
58
34
  const problem = (text) => state.setProblem(credentials.installation, owner, text);
35
+ // The SDK loads once Slack is set up, not with every command that could host the follower.
36
+ const { ToolrollSlackSocket, LogLevel } = await import("./slack-socket.js");
59
37
  // SDK log output can include envelopes/tokens. Only fixed status phrases are saved.
60
38
  const client = new ToolrollSlackSocket({
61
39
  appToken: credentials.appToken,
@@ -0,0 +1,39 @@
1
+ import type { Store } from "./store.js";
2
+ export declare const STEER_HANDLED = "handled";
3
+ export type SteerMailbox = {
4
+ runId: number;
5
+ taskRef: number;
6
+ /** Worktree-relative, as the brief names it. */
7
+ folder: string;
8
+ dir: string;
9
+ /** The folder's real path, resolved once when it was made inside the worktree. */
10
+ realDir: string;
11
+ /** This attempt's note key: in the brief and in every genuine note, nowhere else. */
12
+ key: string;
13
+ /** The highest number this pulse has written. */
14
+ last: number;
15
+ /** Mailed and not yet moved to handled/: number → note id. */
16
+ waiting: Map<number, number>;
17
+ };
18
+ export declare const steerNoteName: (number: number) => string;
19
+ /** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
20
+ export declare function openSteerMailbox(worktree: string, folder: string, runId: number, taskRef: number, key: string): SteerMailbox | null;
21
+ /** One beat: record what the agent read, then mail what is new while the agent runs. */
22
+ export declare function pulseSteerMailbox(store: Store, box: SteerMailbox, now: Date, agentRunning: boolean): void;
23
+ /** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
24
+ export declare function closeSteerMailbox(store: Store, box: SteerMailbox, now: Date): void;
25
+ /**
26
+ * Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
27
+ * while a note is unread, hands the agent one line of context between tool
28
+ * calls. It only says notes are waiting — it never quotes one, and its firing
29
+ * is never an acknowledgement. Rendered into the run's own --settings.
30
+ */
31
+ export declare function claudeSteerHooks(dir: string, folder: string): {
32
+ PostToolUse: {
33
+ matcher: string;
34
+ hooks: {
35
+ type: "command";
36
+ command: string;
37
+ }[];
38
+ }[];
39
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The running build's steering mailbox (v120). A note an operator files
3
+ * while a build runs used to wait for the next attempt; now the builder's
4
+ * pulse writes it into one nonce-named folder in the worktree, as a numbered
5
+ * file the agent reads and acknowledges by moving it into `handled/` — the
6
+ * same small contract a person's own inbox keeps:
7
+ *
8
+ * - The durable record is the delivery. A note is mailed (written into the
9
+ * folder) and read (moved by the agent); nothing else counts. A hook
10
+ * reminding the agent, or a file the pulse wrote, proves nothing.
11
+ * - Numbers are never reused: the next one is past every name in the folder
12
+ * and in `handled/`, and past every one this pulse has used.
13
+ * - Whatever is still unread when the attempt ends keeps today's road: the
14
+ * note stays pending, the next attempt's brief quotes it, and a task that
15
+ * ends first supersedes it.
16
+ *
17
+ * Every step is bookkeeping, and bookkeeping never fails a build: a folder
18
+ * the agent removed is made again, one it replaced with anything but a real
19
+ * directory is never written through, and an error waits for the next beat.
20
+ *
21
+ * Two boundaries, because the folder lives where the agent and anything it
22
+ * runs can write:
23
+ * - Every genuine note carries this attempt's key, which only the brief and
24
+ * the notes themselves name. A file in the folder without it was not
25
+ * written by the operator, and the brief tells the agent to act on none.
26
+ * (The brief rides the agent's argv, so a process that reads argv could
27
+ * learn it: this keeps ordinary repository code from posing as the
28
+ * operator, not a determined local process.)
29
+ * - The pulse writes only while the folder still resolves to this worktree's
30
+ * own folder, before and after each write, so swapping it for a link can
31
+ * never make the plane write a note somewhere else.
32
+ */
33
+ import { lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { fence } from "./brief-fence.js";
36
+ export const STEER_HANDLED = "handled";
37
+ const NOTE_NAME = /^(\d+)\.json$/;
38
+ export const steerNoteName = (number) => `${String(number).padStart(3, "0")}.json`;
39
+ const isRealDirectory = (path) => {
40
+ try {
41
+ return lstatSync(path).isDirectory();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ };
47
+ /** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
48
+ export function openSteerMailbox(worktree, folder, runId, taskRef, key) {
49
+ let realDir;
50
+ try {
51
+ realDir = join(realpathSync(worktree), folder);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ const box = { runId, taskRef, folder, dir: join(worktree, folder), realDir, key, last: 0, waiting: new Map() };
57
+ return ensureFolder(box) ? box : null;
58
+ }
59
+ /** The folder is still this worktree's own, not a link to somewhere else. */
60
+ const stillInside = (box) => {
61
+ try {
62
+ return realpathSync(box.dir) === box.realDir;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ };
68
+ function ensureFolder(box) {
69
+ for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
70
+ try {
71
+ mkdirSync(path, { mode: 0o700 });
72
+ }
73
+ catch (error) {
74
+ if (error.code !== "EEXIST")
75
+ return false;
76
+ }
77
+ // Something the agent put in the folder's place is never written through.
78
+ if (!isRealDirectory(path))
79
+ return false;
80
+ }
81
+ return stillInside(box);
82
+ }
83
+ function highestNumber(box) {
84
+ let highest = box.last;
85
+ for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
86
+ let names;
87
+ try {
88
+ names = readdirSync(path);
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ for (const name of names) {
94
+ const number = NOTE_NAME.exec(name)?.[1];
95
+ if (number !== undefined)
96
+ highest = Math.max(highest, Number(number));
97
+ }
98
+ }
99
+ return highest;
100
+ }
101
+ /** Every waiting note the agent moved into handled/ is read, by this run. */
102
+ function collectAcknowledgements(store, box, now) {
103
+ if (box.waiting.size === 0)
104
+ return;
105
+ const handled = join(box.dir, STEER_HANDLED);
106
+ if (!isRealDirectory(box.dir) || !isRealDirectory(handled))
107
+ return;
108
+ for (const [number, noteId] of [...box.waiting]) {
109
+ let moved;
110
+ try {
111
+ moved = lstatSync(join(handled, steerNoteName(number))).isFile();
112
+ }
113
+ catch {
114
+ moved = false;
115
+ }
116
+ if (!moved)
117
+ continue;
118
+ box.waiting.delete(number);
119
+ store.ackSteerNote(noteId, box.runId, now);
120
+ }
121
+ }
122
+ /** Remove a name this pulse wrote, only while the folder is still this worktree's own; a link goes, never its target. */
123
+ function removeOwn(box, name) {
124
+ if (!stillInside(box))
125
+ return;
126
+ try {
127
+ unlinkSync(join(box.dir, name));
128
+ }
129
+ catch { /* never written, or the agent already took it */ }
130
+ }
131
+ /** Write each note this run has not seen yet: a temp name, then the number. */
132
+ function mailNotes(store, box, now) {
133
+ const notes = store.mailableSteerNotes(box.taskRef, box.runId);
134
+ if (notes.length === 0 || !ensureFolder(box))
135
+ return;
136
+ for (const note of notes) {
137
+ if (!stillInside(box))
138
+ return;
139
+ const number = highestNumber(box) + 1;
140
+ const name = steerNoteName(number);
141
+ const temp = `.${name}.tmp`;
142
+ const body = { version: 1, number, key: box.key, filedAt: note.createdAt, note: fence(note.note) };
143
+ try {
144
+ writeFileSync(join(box.dir, temp), `${JSON.stringify(body, null, 2)}\n`, { flag: "wx", mode: 0o600 });
145
+ renameSync(join(box.dir, temp), join(box.dir, name));
146
+ }
147
+ catch {
148
+ removeOwn(box, temp);
149
+ return;
150
+ }
151
+ // Checked again after the write: a folder swapped for a link mid-write
152
+ // leaves the note unrecorded, deletes nothing, and mails nothing more this beat.
153
+ if (!stillInside(box))
154
+ return;
155
+ box.last = number;
156
+ let recorded;
157
+ try {
158
+ recorded = store.markSteerMailed(note.id, box.runId, now);
159
+ }
160
+ catch {
161
+ recorded = false;
162
+ }
163
+ if (recorded) {
164
+ box.waiting.set(number, note.id);
165
+ }
166
+ else {
167
+ // The task ended or the note settled between the read and the write, or
168
+ // the mailing could not be recorded: an unrecorded file would only be
169
+ // mailed twice, so it goes, and the next beat tries again.
170
+ removeOwn(box, name);
171
+ }
172
+ }
173
+ }
174
+ /** One beat: record what the agent read, then mail what is new while the agent runs. */
175
+ export function pulseSteerMailbox(store, box, now, agentRunning) {
176
+ collectAcknowledgements(store, box, now);
177
+ if (agentRunning)
178
+ mailNotes(store, box, now);
179
+ }
180
+ /** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
181
+ export function closeSteerMailbox(store, box, now) {
182
+ try {
183
+ collectAcknowledgements(store, box, now);
184
+ }
185
+ catch { /* the notes stay pending */ }
186
+ try {
187
+ rmSync(box.dir, { recursive: true, force: true });
188
+ }
189
+ catch { /* the next attempt's sweep removes it */ }
190
+ }
191
+ const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
192
+ /**
193
+ * Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
194
+ * while a note is unread, hands the agent one line of context between tool
195
+ * calls. It only says notes are waiting — it never quotes one, and its firing
196
+ * is never an acknowledgement. Rendered into the run's own --settings.
197
+ */
198
+ export function claudeSteerHooks(dir, folder) {
199
+ const context = {
200
+ hookSpecificOutput: {
201
+ hookEventName: "PostToolUse",
202
+ additionalContext: `Operator steering waiting: %s unread note(s) in ${folder}/. Read them in number order now, act on each within the agreed scope, then move each into ${folder}/${STEER_HANDLED}/.`,
203
+ },
204
+ };
205
+ const command = `set -- ${shellQuote(dir)}/[0-9]*.json; [ -e "$1" ] || exit 0; printf ${shellQuote(`${JSON.stringify(context)}\n`)} "$#"`;
206
+ return { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command }] }] };
207
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where a steering note stands, in the one set of words the task page, the
3
+ * CLI and the lead all use (v120). A note is read only when the agent proves
4
+ * it: the stream's receipt of a brief that quoted it, or the agent moving it
5
+ * to handled/ in a running build's steering folder. Everything else is
6
+ * either waiting for the agent that is running now, or for the next attempt.
7
+ */
8
+ import type { SteerNote } from "./store.js";
9
+ export type SteerStanding = "read" | "waiting" | "next-attempt" | "ended" | "unverified";
10
+ /** `liveBuild`: the task has a build whose agent is running and reads its steering folder (Store.steeringLiveBuild). */
11
+ export declare function steerStandingOf(note: SteerNote, liveBuild: boolean): SteerStanding;
12
+ /** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
13
+ export declare function steerStandingParts(note: SteerNote, liveBuild: boolean): {
14
+ words: string;
15
+ readAt: string | null;
16
+ };
17
+ export declare function steerStandingWords(note: SteerNote, liveBuild: boolean, time: (iso: string) => string): string;
18
+ /** Where a note filed now goes, said before or right after filing it. */
19
+ export declare function steerDestinationWords(liveBuild: boolean): string;
@@ -0,0 +1,30 @@
1
+ /** `liveBuild`: the task has a build whose agent is running and reads its steering folder (Store.steeringLiveBuild). */
2
+ export function steerStandingOf(note, liveBuild) {
3
+ if (note.authorshipState !== "verified")
4
+ return "unverified";
5
+ if (note.supersededAt !== null)
6
+ return "ended";
7
+ if (note.deliveredAt !== null)
8
+ return "read";
9
+ return liveBuild ? "waiting" : "next-attempt";
10
+ }
11
+ /** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
12
+ export function steerStandingParts(note, liveBuild) {
13
+ switch (steerStandingOf(note, liveBuild)) {
14
+ case "read": return { words: "Read by the agent at", readAt: note.ackedAt ?? note.deliveredAt };
15
+ case "waiting": return { words: "Waiting for the agent to read it", readAt: null };
16
+ case "next-attempt": return { words: "Will reach the next attempt", readAt: null };
17
+ case "ended": return { words: "The task ended before the agent read it", readAt: null };
18
+ case "unverified": return { words: "Saved before notes required a sign-in; never sent", readAt: null };
19
+ }
20
+ }
21
+ export function steerStandingWords(note, liveBuild, time) {
22
+ const { words, readAt } = steerStandingParts(note, liveBuild);
23
+ return readAt === null ? words : `${words} ${time(readAt)}`;
24
+ }
25
+ /** Where a note filed now goes, said before or right after filing it. */
26
+ export function steerDestinationWords(liveBuild) {
27
+ return liveBuild
28
+ ? "The running agent reads it when it next checks for notes."
29
+ : "The next attempt reads it before starting.";
30
+ }