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.
- package/CHANGELOG.md +11 -0
- package/README.md +8 -6
- package/dist/assignment-adapters.d.ts +1 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +13 -4
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +6 -2
- package/dist/assignment.js +13 -8
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +19 -19
- package/dist/builder.js +74 -25
- package/dist/chat-channel.js +1 -1
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/design/tokens.css +234 -0
- package/dist/discord.js +2 -1
- package/dist/evidence.d.ts +22 -0
- package/dist/evidence.js +76 -17
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/lead-cli.js +1 -1
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +5 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +9 -2
- package/dist/mailbox.js +3 -2
- package/dist/mcp-core.js +1 -1
- package/dist/operate.js +17 -7
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +12 -1
- package/dist/serve.js +3 -2
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +11 -13
- package/dist/server/task-views.js +1 -0
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +19 -0
- package/dist/steer-status.js +30 -0
- package/dist/store.d.ts +41 -2
- package/dist/store.js +95 -3
- package/dist/surface.js +2 -2
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/team-updates.js +3 -3
- package/dist/toolroll-update.js +14 -2
- package/package.json +21 -21
package/dist/server/console.css
CHANGED
|
@@ -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
|
-
/*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
/*
|
|
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
|
-
--
|
|
69
|
-
--font-
|
|
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
|
|
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>
|
|
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
|
|
1374
|
-
//
|
|
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
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
|
1390
|
-
html `<p class="meta"
|
|
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
|
+
}
|