toolroll 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -24
- package/dist/accent-colors.d.ts +6 -3
- package/dist/accent-colors.js +9 -4
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-workspace.d.ts +46 -0
- package/dist/builder.d.ts +18 -1
- package/dist/builder.js +177 -5
- package/dist/chat-delivery.js +6 -4
- package/dist/claim.d.ts +15 -1
- package/dist/claim.js +30 -2
- package/dist/control-ui.js +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +21 -0
- package/dist/decision.d.ts +6 -0
- package/dist/decision.js +12 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +3 -1
- package/dist/dispatch.js +13 -0
- package/dist/dispose.js +11 -7
- package/dist/exhaustion.d.ts +14 -0
- package/dist/exhaustion.js +35 -0
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +61 -0
- package/dist/invoke.js +26 -0
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +15 -2
- package/dist/mate.js +5 -2
- package/dist/operate.d.ts +5 -0
- package/dist/operate.js +109 -15
- package/dist/provider-auth.d.ts +138 -0
- package/dist/provider-auth.js +291 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +8 -1
- package/dist/releases.d.ts +76 -0
- package/dist/releases.js +225 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +207 -76
- package/dist/setup-guide.d.ts +2 -2
- package/dist/setup-guide.js +2 -0
- package/dist/skills.d.ts +6 -0
- package/dist/skills.js +8 -0
- package/dist/store.d.ts +2 -2
- package/dist/store.js +13 -4
- package/dist/subscription-chat.js +3 -0
- package/dist/update-notice.d.ts +5 -0
- package/dist/update-notice.js +4 -0
- package/dist/work-index.js +22 -4
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +55 -29
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { PROVIDER_IDS } from "./provider.js";
|
|
2
|
+
/** Every sign-in message's dedupe key starts here: `signin:<kind>:<provider>:<ordinal>:<person>`.
|
|
3
|
+
* Not a lifecycle (`life:`) key: those are progress chatter, and an undelivered
|
|
4
|
+
* one stays out of the attention list — this one needs a person. */
|
|
5
|
+
export const SIGN_IN_KEY_PREFIX = "signin:";
|
|
6
|
+
export const PROVIDER_AUTH_SCHEMA = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS provider_auth_pause (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
provider TEXT NOT NULL,
|
|
10
|
+
ordinal INTEGER NOT NULL,
|
|
11
|
+
auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription', 'api-key')),
|
|
12
|
+
opened_at TEXT NOT NULL,
|
|
13
|
+
first_run INTEGER,
|
|
14
|
+
task_ref INTEGER,
|
|
15
|
+
runs INTEGER NOT NULL DEFAULT 1,
|
|
16
|
+
-- 1 once a sign-in probe during this pause said the provider is signed out:
|
|
17
|
+
-- only a probe that then sees it signed in again may lift the pause.
|
|
18
|
+
probe_saw_out INTEGER NOT NULL DEFAULT 0,
|
|
19
|
+
lifted_at TEXT,
|
|
20
|
+
lifted_by TEXT,
|
|
21
|
+
lifted_how TEXT CHECK (lifted_how IS NULL OR lifted_how IN ('run', 'probe', 'person')),
|
|
22
|
+
resumed INTEGER,
|
|
23
|
+
-- The last task let through as a trial while paused: a sign-in check can
|
|
24
|
+
-- say "logged in" for a session that no longer works, so a real run decides.
|
|
25
|
+
last_trial_at TEXT,
|
|
26
|
+
UNIQUE (provider, ordinal)
|
|
27
|
+
);
|
|
28
|
+
CREATE UNIQUE INDEX IF NOT EXISTS provider_auth_pause_open ON provider_auth_pause (provider) WHERE lifted_at IS NULL;
|
|
29
|
+
`;
|
|
30
|
+
const NAMES = { claude: "Claude", codex: "Codex", openrouter: "OpenRouter", gemini: "Gemini" };
|
|
31
|
+
const SIGN_IN = { claude: "claude /login", codex: "codex login", gemini: "gemini", openrouter: "toolroll keys set openrouter" };
|
|
32
|
+
export function providerName(provider) {
|
|
33
|
+
return PROVIDER_IDS.includes(provider) ? NAMES[provider] : provider;
|
|
34
|
+
}
|
|
35
|
+
/** The plain reason: "Claude needs you to sign in again". */
|
|
36
|
+
export function signInReason(pause) {
|
|
37
|
+
const name = providerName(pause.provider);
|
|
38
|
+
return pause.authMode === "api-key" ? `${name} needs a working API key` : `${name} needs you to sign in again`;
|
|
39
|
+
}
|
|
40
|
+
/** What to run, as a command. */
|
|
41
|
+
export function signInCommand(pause) {
|
|
42
|
+
return pause.authMode === "api-key" && pause.provider !== "openrouter" ? `toolroll keys set ${pause.provider}` : SIGN_IN[pause.provider];
|
|
43
|
+
}
|
|
44
|
+
/** The same command for a chat message: the phone scrub hides anything shaped
|
|
45
|
+
* like a path (`/login` included), so a message names Claude's equivalent
|
|
46
|
+
* `claude auth login`. */
|
|
47
|
+
function messageCommand(pause) {
|
|
48
|
+
const command = signInCommand(pause);
|
|
49
|
+
return command === "claude /login" ? "claude auth login" : command;
|
|
50
|
+
}
|
|
51
|
+
/** One line for status, ready and the console. */
|
|
52
|
+
export function signInWords(pause) {
|
|
53
|
+
return `${signInReason(pause)}. Run \`${signInCommand(pause)}\` on this computer; its tasks start again on their own once it works (or \`toolroll providers resume ${pause.provider}\`).`;
|
|
54
|
+
}
|
|
55
|
+
function readPause(row) {
|
|
56
|
+
return {
|
|
57
|
+
id: Number(row["id"]),
|
|
58
|
+
provider: String(row["provider"]),
|
|
59
|
+
ordinal: Number(row["ordinal"]),
|
|
60
|
+
authMode: row["auth_mode"] === "api-key" ? "api-key" : "subscription",
|
|
61
|
+
openedAt: String(row["opened_at"]),
|
|
62
|
+
firstRun: row["first_run"] == null ? null : Number(row["first_run"]),
|
|
63
|
+
taskRef: row["task_ref"] == null ? null : Number(row["task_ref"]),
|
|
64
|
+
runs: Number(row["runs"]),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The open pause for a provider, or null. */
|
|
68
|
+
export function authPauseOf(store, provider) {
|
|
69
|
+
const row = store.handle.prepare("SELECT * FROM provider_auth_pause WHERE provider = ? AND lifted_at IS NULL").get(provider);
|
|
70
|
+
return row === undefined ? null : readPause(row);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* SQL: whether the pause `pause` (a provider_auth_pause alias) is on one of
|
|
74
|
+
* the task_ref `taskRef`'s current providers — its pins, its working profile,
|
|
75
|
+
* route legs and fallback entries — so a task re-routed to another provider
|
|
76
|
+
* stops saying it waits on the old one. A task that names none yet (an
|
|
77
|
+
* unpinned planner, whose agent comes from configuration) keeps what the gate
|
|
78
|
+
* found.
|
|
79
|
+
*/
|
|
80
|
+
export function pauseOnTaskProviders(pause, taskRef) {
|
|
81
|
+
const providers = `SELECT r.agent_provider provider FROM task_ref r WHERE r.id = ${taskRef}
|
|
82
|
+
UNION ALL SELECT r.plan_provider FROM task_ref r WHERE r.id = ${taskRef}
|
|
83
|
+
UNION ALL SELECT json_extract(ts.profile_json, '$.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id
|
|
84
|
+
WHERE r.id = ${taskRef} AND json_valid(ts.profile_json)
|
|
85
|
+
UNION ALL SELECT json_extract(leg.value, '$.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id,
|
|
86
|
+
json_each(CASE WHEN json_valid(ts.proposed_route_json) THEN ts.proposed_route_json ELSE '{}' END, '$.legs') leg WHERE r.id = ${taskRef}
|
|
87
|
+
UNION ALL SELECT json_extract(entry.value, '$.profile.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id,
|
|
88
|
+
json_each(CASE WHEN json_valid(ts.proposed_chain_json) THEN ts.proposed_chain_json ELSE '{}' END, '$.chain') entry WHERE r.id = ${taskRef}`;
|
|
89
|
+
return `(${pause}.provider IN (SELECT provider FROM (${providers}) WHERE provider IS NOT NULL)
|
|
90
|
+
OR NOT EXISTS (SELECT 1 FROM (${providers}) WHERE provider IS NOT NULL))`;
|
|
91
|
+
}
|
|
92
|
+
/** The open pause the dispatch gate last left this task waiting on, while it
|
|
93
|
+
* is still on one of the task's providers; or null. */
|
|
94
|
+
export function authWaitOf(store, taskRef) {
|
|
95
|
+
const row = store.handle.prepare(`SELECT p.* FROM task_ref r JOIN provider_auth_pause p ON p.id = r.auth_wait_pause
|
|
96
|
+
WHERE r.id = ? AND p.lifted_at IS NULL AND ${pauseOnTaskProviders("p", "r.id")}`).get(taskRef);
|
|
97
|
+
return row === undefined ? null : readPause(row);
|
|
98
|
+
}
|
|
99
|
+
/** Every open pause, oldest first. */
|
|
100
|
+
export function openAuthPauses(store) {
|
|
101
|
+
return store.handle.prepare("SELECT * FROM provider_auth_pause WHERE lifted_at IS NULL ORDER BY id").all().map(readPause);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Record an auth-expired run against its provider's pause, opening the
|
|
105
|
+
* incident (and its one notification) when none is open. Called inside the
|
|
106
|
+
* run's own failure transaction. Returns whether this run opened it.
|
|
107
|
+
*/
|
|
108
|
+
export function pauseForAuth(store, args) {
|
|
109
|
+
return store.transact(() => {
|
|
110
|
+
const db = store.handle;
|
|
111
|
+
const open = authPauseOf(store, args.provider);
|
|
112
|
+
if (open !== null) {
|
|
113
|
+
db.prepare("UPDATE provider_auth_pause SET runs = runs + 1 WHERE id = ?").run(open.id);
|
|
114
|
+
return { opened: false, pause: { ...open, runs: open.runs + 1 } };
|
|
115
|
+
}
|
|
116
|
+
const ordinal = Number(db.prepare("SELECT COALESCE(MAX(ordinal), 0) + 1 AS n FROM provider_auth_pause WHERE provider = ?").get(args.provider)?.["n"] ?? 1);
|
|
117
|
+
const inserted = db.prepare(`INSERT INTO provider_auth_pause (provider, ordinal, auth_mode, opened_at, first_run, task_ref)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(args.provider, ordinal, args.authMode ?? "subscription", args.now.toISOString(), args.runId, args.taskRef);
|
|
119
|
+
const pause = readPause(db.prepare("SELECT * FROM provider_auth_pause WHERE id = ?").get(Number(inserted.lastInsertRowid)));
|
|
120
|
+
notifyOperators(store, `auth-expired:${pause.provider}:${pause.ordinal}`, {
|
|
121
|
+
kind: "auth-expired",
|
|
122
|
+
subject: signInReason(pause),
|
|
123
|
+
body: `Run \`${messageCommand(pause)}\` on this computer. Its tasks wait in the queue; one is tried every 10 minutes and the rest start again on their own once ${providerName(pause.provider)} works (or run \`toolroll providers resume ${pause.provider}\`).`,
|
|
124
|
+
}, args.now);
|
|
125
|
+
return { opened: true, pause };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* One message per instance operator, addressed to them: only someone at this
|
|
130
|
+
* computer can sign an agent back in, and a personal notification reaches that
|
|
131
|
+
* person on every channel they paired (Telegram, Slack, Discord, Teams) and in
|
|
132
|
+
* the console, whichever projects those channels follow.
|
|
133
|
+
*/
|
|
134
|
+
function notifyOperators(store, key, message, now) {
|
|
135
|
+
const operators = store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name));
|
|
136
|
+
for (const recipient of operators) {
|
|
137
|
+
store.enqueueNotification({
|
|
138
|
+
dedupeKey: `${SIGN_IN_KEY_PREFIX}${key}:${recipient}`,
|
|
139
|
+
kind: message.kind,
|
|
140
|
+
pushClass: "attention",
|
|
141
|
+
link: "/work",
|
|
142
|
+
subject: message.subject,
|
|
143
|
+
body: message.body,
|
|
144
|
+
recipient,
|
|
145
|
+
source: { installation: true },
|
|
146
|
+
}, now);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Lift a provider's open pause — a run or probe on it succeeded, or a person
|
|
151
|
+
* resumed it — and send the one short "signed in again" message. Returns the
|
|
152
|
+
* number of tasks its incident sent back to the queue, or null when nothing
|
|
153
|
+
* was paused.
|
|
154
|
+
*/
|
|
155
|
+
export function liftAuthPause(store, provider, how, by, now,
|
|
156
|
+
/** For a run: when it started. A run that began before the pause opened proves nothing about the sign-in now. */
|
|
157
|
+
runStartedAt) {
|
|
158
|
+
return store.transact(() => {
|
|
159
|
+
const pause = authPauseOf(store, provider);
|
|
160
|
+
if (pause === null)
|
|
161
|
+
return null;
|
|
162
|
+
if (runStartedAt !== undefined && runStartedAt.getTime() < Date.parse(pause.openedAt))
|
|
163
|
+
return null;
|
|
164
|
+
const resumed = Number(store.handle.prepare(`SELECT COUNT(DISTINCT task_ref) AS n FROM run
|
|
165
|
+
WHERE provider = ? AND reason = 'auth-expired' AND finished_at >= ?`).get(provider, pause.openedAt)?.["n"] ?? 0);
|
|
166
|
+
store.handle.prepare("UPDATE provider_auth_pause SET lifted_at = ?, lifted_by = ?, lifted_how = ?, resumed = ? WHERE id = ? AND lifted_at IS NULL")
|
|
167
|
+
.run(now.toISOString(), by.slice(0, 120), how, resumed, pause.id);
|
|
168
|
+
const name = providerName(provider);
|
|
169
|
+
notifyOperators(store, `auth-restored:${pause.provider}:${pause.ordinal}`, {
|
|
170
|
+
kind: "auth-restored",
|
|
171
|
+
subject: `${name} is signed in again, ${resumed} ${resumed === 1 ? "task" : "tasks"} resumed`,
|
|
172
|
+
body: how === "person" ? `Resumed by ${by.slice(0, 80)}.` : `A ${how === "run" ? "run" : "sign-in check"} on ${name} worked.`,
|
|
173
|
+
}, now);
|
|
174
|
+
if (how === "person") {
|
|
175
|
+
store.recordAction({ at: now.toISOString(), actor: by.slice(0, 120), repo: null, taskId: null, runId: null,
|
|
176
|
+
action: `resumed ${name} after a sign-in pause`, outcome: "resumed", source: "request", detail: `${resumed} ${resumed === 1 ? "task" : "tasks"}` });
|
|
177
|
+
}
|
|
178
|
+
store.bumpWake();
|
|
179
|
+
return { resumed, pause };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* What a non-spending sign-in probe said. A probe lifts the pause only after
|
|
184
|
+
* one during the same pause saw the provider signed out: a CLI that reports
|
|
185
|
+
* "logged in" while its session cannot refresh must not lift and re-open the
|
|
186
|
+
* pause over and over.
|
|
187
|
+
*/
|
|
188
|
+
export function noteSignInProbe(store, provider, state, now) {
|
|
189
|
+
const pause = authPauseOf(store, provider);
|
|
190
|
+
if (pause === null)
|
|
191
|
+
return null;
|
|
192
|
+
if (state === "signed-out" || state === "key-refused" || state === "missing-key") {
|
|
193
|
+
store.handle.prepare("UPDATE provider_auth_pause SET probe_saw_out = 1 WHERE id = ?").run(pause.id);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (state !== "connected" && state !== "key-works")
|
|
197
|
+
return null;
|
|
198
|
+
const sawOut = Number(store.handle.prepare("SELECT probe_saw_out FROM provider_auth_pause WHERE id = ?").get(pause.id)?.["probe_saw_out"] ?? 0) === 1;
|
|
199
|
+
if (!sawOut)
|
|
200
|
+
return null;
|
|
201
|
+
const lifted = liftAuthPause(store, provider, "probe", "sign-in check", now);
|
|
202
|
+
return lifted === null ? null : { resumed: lifted.resumed };
|
|
203
|
+
}
|
|
204
|
+
/** How often a paused provider lets one task through as a trial. */
|
|
205
|
+
export const AUTH_TRIAL_MS = 10 * 60_000;
|
|
206
|
+
/**
|
|
207
|
+
* Whether this task may go ahead as the paused provider's trial: at most one
|
|
208
|
+
* every AUTH_TRIAL_MS. A trial that works lifts the pause (its run proves the
|
|
209
|
+
* sign-in); one that fails the same way only counts against the open
|
|
210
|
+
* incident, so nobody is told twice. Null when no trial is due.
|
|
211
|
+
*/
|
|
212
|
+
export function claimAuthTrial(store, pause, now) {
|
|
213
|
+
return store.transact(() => {
|
|
214
|
+
const due = new Date(now.getTime() - AUTH_TRIAL_MS).toISOString();
|
|
215
|
+
const row = store.handle.prepare("SELECT last_trial_at FROM provider_auth_pause WHERE id = ?").get(pause.id);
|
|
216
|
+
const previous = row?.["last_trial_at"] == null ? null : String(row["last_trial_at"]);
|
|
217
|
+
const takenAt = now.toISOString();
|
|
218
|
+
const claimed = store.handle.prepare(`UPDATE provider_auth_pause SET last_trial_at = ?
|
|
219
|
+
WHERE id = ? AND lifted_at IS NULL AND COALESCE(last_trial_at, opened_at) <= ?`).run(takenAt, pause.id, due);
|
|
220
|
+
return Number(claimed.changes) === 1 ? { pauseId: pause.id, takenAt, previous } : null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Give a trial back when its task never started (its claim failed), so the next pass may take it. */
|
|
224
|
+
export function giveBackAuthTrial(store, trial) {
|
|
225
|
+
store.handle.prepare("UPDATE provider_auth_pause SET last_trial_at = ? WHERE id = ? AND last_trial_at = ? AND lifted_at IS NULL")
|
|
226
|
+
.run(trial.previous, trial.pauseId, trial.takenAt);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The sign-in gate every road that starts an agent asks before it claims
|
|
230
|
+
* anything: the tick's queue, and the roads beside it (fallback entries,
|
|
231
|
+
* attended continuations, contest resumes). Waits when a provider is paused,
|
|
232
|
+
* unless this is the pause's one trial. A waiting task is noted on its
|
|
233
|
+
* task_ref, so the work index says exactly what the gate decided; a task let
|
|
234
|
+
* through is un-noted. A road whose claim fails after a trial calls giveBack.
|
|
235
|
+
*/
|
|
236
|
+
export function signInGate(store, providers, now, taskRef) {
|
|
237
|
+
const pause = [...new Set(providers)].map(one => authPauseOf(store, one)).find(one => one !== null) ?? null;
|
|
238
|
+
const trial = pause === null ? null : claimAuthTrial(store, pause, now);
|
|
239
|
+
const waiting = pause !== null && trial === null ? pause : null;
|
|
240
|
+
if (taskRef !== undefined) {
|
|
241
|
+
store.handle.prepare("UPDATE task_ref SET auth_wait_pause = ? WHERE id = ? AND auth_wait_pause IS NOT ?").run(waiting?.id ?? null, taskRef, waiting?.id ?? null);
|
|
242
|
+
}
|
|
243
|
+
let given = false;
|
|
244
|
+
return {
|
|
245
|
+
waiting,
|
|
246
|
+
giveBack: () => {
|
|
247
|
+
if (trial === null || given)
|
|
248
|
+
return;
|
|
249
|
+
given = true;
|
|
250
|
+
giveBackAuthTrial(store, trial);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** The loop beside the console: every two minutes, probe each paused
|
|
255
|
+
* provider's sign-in (no model, nothing spent) and lift the pause when it
|
|
256
|
+
* works again. Nothing is probed while nothing is paused. */
|
|
257
|
+
export function startSignInProbes(store, check, everyMs = 120_000) {
|
|
258
|
+
let stopped = false;
|
|
259
|
+
let running = false;
|
|
260
|
+
const tick = async () => {
|
|
261
|
+
if (stopped || running)
|
|
262
|
+
return;
|
|
263
|
+
running = true;
|
|
264
|
+
try {
|
|
265
|
+
for (const pause of openAuthPauses(store)) {
|
|
266
|
+
try {
|
|
267
|
+
noteSignInProbe(store, pause.provider, (await check(pause.provider, true)).state, new Date());
|
|
268
|
+
}
|
|
269
|
+
catch { /* the next pass asks again */ }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { /* the next pass asks again */ }
|
|
273
|
+
finally {
|
|
274
|
+
running = false;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const timer = setInterval(() => { void tick(); }, everyMs);
|
|
278
|
+
timer.unref?.();
|
|
279
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
280
|
+
}
|
|
281
|
+
/** The console's view of every open pause: title, command, one action. */
|
|
282
|
+
export function signInNotices(store) {
|
|
283
|
+
return openAuthPauses(store).map(pause => ({
|
|
284
|
+
provider: pause.provider,
|
|
285
|
+
title: signInReason(pause),
|
|
286
|
+
command: signInCommand(pause),
|
|
287
|
+
detail: "",
|
|
288
|
+
resumeLabel: `Resume ${providerName(pause.provider)}`,
|
|
289
|
+
resumeHref: `/providers/${pause.provider}/resume`,
|
|
290
|
+
}));
|
|
291
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -152,6 +152,8 @@ export declare const OPENROUTER_ENV_KEY = "OPENROUTER_API_KEY";
|
|
|
152
152
|
* the argv and later turns cannot spend past it.
|
|
153
153
|
*/
|
|
154
154
|
export declare const claudeHeldArgv: (invocation: Omit<Invocation, "brief">) => string[];
|
|
155
|
+
/** Claude's own tools that only make sense with a person to wake them. */
|
|
156
|
+
export declare const HEADLESS_DISALLOWED_TOOLS: readonly string[];
|
|
155
157
|
/**
|
|
156
158
|
* The diagnostic discipline (Phase 3 B6/C6): the harness's own words are
|
|
157
159
|
* untrusted bytes headed for refusal screens and notifications — controls
|
package/dist/provider.js
CHANGED
|
@@ -184,6 +184,8 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
|
|
|
184
184
|
required: ["version"],
|
|
185
185
|
additionalProperties: false,
|
|
186
186
|
};
|
|
187
|
+
/** Claude's own tools that only make sense with a person to wake them. */
|
|
188
|
+
export const HEADLESS_DISALLOWED_TOOLS = ["ScheduleWakeup", "CronCreate", "Monitor"];
|
|
187
189
|
const claudeArgv = (invocation) => [
|
|
188
190
|
"-p",
|
|
189
191
|
invocation.brief,
|
|
@@ -207,7 +209,12 @@ const claudeArgv = (invocation) => [
|
|
|
207
209
|
...(invocation.maxBudgetUsd === undefined ? [] : ["--max-budget-usd", String(invocation.maxBudgetUsd)]),
|
|
208
210
|
...(invocation.phase === "review"
|
|
209
211
|
? [...CLAUDE_REVIEW_ISOLATION_ARGV, "--json-schema", JSON.stringify(CLAUDE_REVIEW_JSON_SCHEMA)]
|
|
210
|
-
: [
|
|
212
|
+
: [
|
|
213
|
+
// Headless (run 2085): a wakeup, cron job or monitor needs a later
|
|
214
|
+
// turn that a -p process never gets. Denied at the harness too.
|
|
215
|
+
"--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
|
|
216
|
+
...(invocation.toolArgv ?? []), ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : [])
|
|
217
|
+
]),
|
|
211
218
|
];
|
|
212
219
|
/**
|
|
213
220
|
* The turn's own words, preferring the schema-validated structured field
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing, calmly, when a newer Toolroll exists.
|
|
3
|
+
*
|
|
4
|
+
* At most once a day one anonymous GET asks the npm registry for the latest
|
|
5
|
+
* version, and one more reads that version's GitHub release notes (the body
|
|
6
|
+
* only). Nothing identifies this installation: no telemetry, no id, no
|
|
7
|
+
* query string. The answer is cached in a small JSON file beside the
|
|
8
|
+
* database; offline or any other failure is silent and changes nothing.
|
|
9
|
+
* TOOLROLL_NO_UPDATE_CHECK=1 or the switch on Settings → Updates turns the
|
|
10
|
+
* check off entirely.
|
|
11
|
+
*/
|
|
12
|
+
import type { Store } from "./store.js";
|
|
13
|
+
export declare const RELEASE_CACHE_FILE = "latest-release.json";
|
|
14
|
+
/** Holds "off" while a person has switched the daily check off on Settings → Updates. */
|
|
15
|
+
export declare const UPDATE_SWITCH_FILE = "update-check";
|
|
16
|
+
export declare const RUNNER_VERSIONS_FILE = "runner-versions.json";
|
|
17
|
+
export declare const CHECK_EVERY_MS: number;
|
|
18
|
+
export declare const CHECK_TIMEOUT_MS = 5000;
|
|
19
|
+
export declare const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
|
|
20
|
+
export declare const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
|
|
21
|
+
export type Release = {
|
|
22
|
+
version: string;
|
|
23
|
+
notes: string;
|
|
24
|
+
url: string;
|
|
25
|
+
security: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type ReleaseIo = {
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
env?: Record<string, string | undefined>;
|
|
31
|
+
};
|
|
32
|
+
/** Off by the environment (it wins) or by the Settings switch. */
|
|
33
|
+
export declare function updateChecksOff(env: Record<string, string | undefined>, dir: string): {
|
|
34
|
+
off: boolean;
|
|
35
|
+
byEnv: boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare function setUpdateChecks(dir: string, on: boolean): void;
|
|
38
|
+
/** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
|
|
39
|
+
export declare function cachedRelease(dir: string): Release | null;
|
|
40
|
+
/**
|
|
41
|
+
* The latest published release, checked at most once a day. Returns what the
|
|
42
|
+
* cache knows while it is fresh, asks again once it is a day old, and on any
|
|
43
|
+
* failure keeps what it knew before (null when it knew nothing). Never throws.
|
|
44
|
+
*/
|
|
45
|
+
export declare function latestRelease(dir: string, io?: ReleaseIo): Promise<Release | null>;
|
|
46
|
+
/** Notes that mention "Security" mark a security release. */
|
|
47
|
+
/** A release marks itself as a security release with a line or heading that starts with "Security" (for example
|
|
48
|
+
* "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
|
|
49
|
+
export declare function isSecurityRelease(notes: string): boolean;
|
|
50
|
+
/** a > b for dotted versions; a pre-release sorts before its release. */
|
|
51
|
+
export declare function isNewer(a: string, b: string): boolean;
|
|
52
|
+
/** The release worth mentioning: newer than `current`, else null. */
|
|
53
|
+
export declare function newerRelease(release: Release | null, current: string): Release | null;
|
|
54
|
+
/** The one status line, only when a newer release exists. */
|
|
55
|
+
export declare function updateLine(release: Release | null, current: string, updateCommand: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* A security release tells each instance operator once per version, through
|
|
58
|
+
* the outbox and addressed to them (like a sign-in pause): only someone at
|
|
59
|
+
* this computer can update it. Returns how many messages were queued.
|
|
60
|
+
*/
|
|
61
|
+
export declare function notifySecurityRelease(store: Store, release: Release | null, current: string, updateCommand: string, now: Date): number;
|
|
62
|
+
/** Each runner's Toolroll version, as it last said at start. */
|
|
63
|
+
export type RunnerVersion = {
|
|
64
|
+
runner: string;
|
|
65
|
+
version: string;
|
|
66
|
+
at: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function recordRunnerVersion(dir: string, runner: string, version: string, now: Date): void;
|
|
69
|
+
export declare function runnerVersions(dir: string): RunnerVersion[];
|
|
70
|
+
/**
|
|
71
|
+
* The console's background check: at start and then hourly (the daily limit
|
|
72
|
+
* is latestRelease's own), and a security release notifies operators.
|
|
73
|
+
*/
|
|
74
|
+
export declare function startUpdateChecks(store: Store, dir: string, current: string, updateCommand: string, io?: ReleaseIo & {
|
|
75
|
+
everyMs?: number;
|
|
76
|
+
}): () => void;
|
package/dist/releases.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing, calmly, when a newer Toolroll exists.
|
|
3
|
+
*
|
|
4
|
+
* At most once a day one anonymous GET asks the npm registry for the latest
|
|
5
|
+
* version, and one more reads that version's GitHub release notes (the body
|
|
6
|
+
* only). Nothing identifies this installation: no telemetry, no id, no
|
|
7
|
+
* query string. The answer is cached in a small JSON file beside the
|
|
8
|
+
* database; offline or any other failure is silent and changes nothing.
|
|
9
|
+
* TOOLROLL_NO_UPDATE_CHECK=1 or the switch on Settings → Updates turns the
|
|
10
|
+
* check off entirely.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { envValue } from "./names.js";
|
|
15
|
+
export const RELEASE_CACHE_FILE = "latest-release.json";
|
|
16
|
+
/** Holds "off" while a person has switched the daily check off on Settings → Updates. */
|
|
17
|
+
export const UPDATE_SWITCH_FILE = "update-check";
|
|
18
|
+
export const RUNNER_VERSIONS_FILE = "runner-versions.json";
|
|
19
|
+
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
export const CHECK_TIMEOUT_MS = 5_000;
|
|
21
|
+
export const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
|
|
22
|
+
export const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
|
|
23
|
+
const NOTES_API = "https://api.github.com/repos/ap9000/toolroll/releases/tags/";
|
|
24
|
+
const NOTES_MAX = 20_000;
|
|
25
|
+
const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
|
|
26
|
+
/** Off by the environment (it wins) or by the Settings switch. */
|
|
27
|
+
export function updateChecksOff(env, dir) {
|
|
28
|
+
const flag = envValue(env, "NO_UPDATE_CHECK");
|
|
29
|
+
if (flag !== undefined && flag !== "" && flag !== "0")
|
|
30
|
+
return { off: true, byEnv: true };
|
|
31
|
+
try {
|
|
32
|
+
return { off: readFileSync(join(dir, UPDATE_SWITCH_FILE), "utf8").trim() === "off", byEnv: false };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { off: false, byEnv: false };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function setUpdateChecks(dir, on) {
|
|
39
|
+
writeAtomic(join(dir, UPDATE_SWITCH_FILE), on ? "on\n" : "off\n");
|
|
40
|
+
}
|
|
41
|
+
/** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
|
|
42
|
+
export function cachedRelease(dir) {
|
|
43
|
+
return readCache(dir)?.release ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The latest published release, checked at most once a day. Returns what the
|
|
47
|
+
* cache knows while it is fresh, asks again once it is a day old, and on any
|
|
48
|
+
* failure keeps what it knew before (null when it knew nothing). Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export async function latestRelease(dir, io = {}) {
|
|
51
|
+
const env = io.env ?? process.env;
|
|
52
|
+
if (updateChecksOff(env, dir).off)
|
|
53
|
+
return null;
|
|
54
|
+
const now = (io.now ?? (() => new Date()))();
|
|
55
|
+
const cache = readCache(dir);
|
|
56
|
+
if (cache !== null && now.getTime() - Date.parse(cache.checkedAt) < CHECK_EVERY_MS && Date.parse(cache.checkedAt) <= now.getTime())
|
|
57
|
+
return cache.release;
|
|
58
|
+
const request = io.fetch ?? fetch;
|
|
59
|
+
let release = cache?.release ?? null;
|
|
60
|
+
try {
|
|
61
|
+
const version = await askVersion(request);
|
|
62
|
+
if (version !== null) {
|
|
63
|
+
const notes = version === cache?.release?.version && cache.release.notes !== "" ? cache.release.notes : await askNotes(request, version);
|
|
64
|
+
release = { version, notes, url: `${RELEASES_PAGE}v${version}`, security: isSecurityRelease(notes) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Offline, slow or refused: nothing to say.
|
|
69
|
+
}
|
|
70
|
+
// The attempt is stamped either way, so a failure does not ask again until tomorrow.
|
|
71
|
+
try {
|
|
72
|
+
writeAtomic(join(dir, RELEASE_CACHE_FILE), `${JSON.stringify({ checkedAt: now.toISOString(), release })}\n`);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A read-only folder only means asking again next time.
|
|
76
|
+
}
|
|
77
|
+
return release;
|
|
78
|
+
}
|
|
79
|
+
/** Notes that mention "Security" mark a security release. */
|
|
80
|
+
/** A release marks itself as a security release with a line or heading that starts with "Security" (for example
|
|
81
|
+
* "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
|
|
82
|
+
export function isSecurityRelease(notes) {
|
|
83
|
+
return /^\s*(?:#{1,6}\s*|[-*]\s*(?:\*\*)?)?security\b/im.test(notes);
|
|
84
|
+
}
|
|
85
|
+
/** a > b for dotted versions; a pre-release sorts before its release. */
|
|
86
|
+
export function isNewer(a, b) {
|
|
87
|
+
const parse = (value) => {
|
|
88
|
+
const [core = "", pre] = value.replace(/^v/, "").split("-", 2);
|
|
89
|
+
return { parts: core.split(".").map(part => Number.parseInt(part, 10) || 0), pre: pre ?? null };
|
|
90
|
+
};
|
|
91
|
+
const left = parse(a), right = parse(b);
|
|
92
|
+
for (let index = 0; index < 3; index++) {
|
|
93
|
+
const diff = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
|
|
94
|
+
if (diff !== 0)
|
|
95
|
+
return diff > 0;
|
|
96
|
+
}
|
|
97
|
+
if (left.pre === right.pre)
|
|
98
|
+
return false;
|
|
99
|
+
if (left.pre === null)
|
|
100
|
+
return true;
|
|
101
|
+
if (right.pre === null)
|
|
102
|
+
return false;
|
|
103
|
+
return left.pre.localeCompare(right.pre, "en", { numeric: true }) > 0;
|
|
104
|
+
}
|
|
105
|
+
/** The release worth mentioning: newer than `current`, else null. */
|
|
106
|
+
export function newerRelease(release, current) {
|
|
107
|
+
return release !== null && isNewer(release.version, current) ? release : null;
|
|
108
|
+
}
|
|
109
|
+
/** The one status line, only when a newer release exists. */
|
|
110
|
+
export function updateLine(release, current, updateCommand) {
|
|
111
|
+
const newer = newerRelease(release, current);
|
|
112
|
+
return newer === null ? null : `${newer.version} is available — ${updateCommand} · ${newer.url}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A security release tells each instance operator once per version, through
|
|
116
|
+
* the outbox and addressed to them (like a sign-in pause): only someone at
|
|
117
|
+
* this computer can update it. Returns how many messages were queued.
|
|
118
|
+
*/
|
|
119
|
+
export function notifySecurityRelease(store, release, current, updateCommand, now) {
|
|
120
|
+
const newer = newerRelease(release, current);
|
|
121
|
+
if (newer === null || !newer.security)
|
|
122
|
+
return 0;
|
|
123
|
+
const operators = store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name));
|
|
124
|
+
let queued = 0;
|
|
125
|
+
for (const recipient of operators) {
|
|
126
|
+
const added = store.enqueueNotification({
|
|
127
|
+
dedupeKey: `release:security:${newer.version}:${recipient}`,
|
|
128
|
+
kind: "security-release",
|
|
129
|
+
pushClass: "attention",
|
|
130
|
+
link: "/settings#updates",
|
|
131
|
+
subject: `Toolroll ${newer.version} is a security release`,
|
|
132
|
+
body: `This computer runs ${current}. Update with \`${updateCommand}\`. Notes: ${newer.url}`,
|
|
133
|
+
recipient,
|
|
134
|
+
source: { installation: true },
|
|
135
|
+
}, now);
|
|
136
|
+
if (added)
|
|
137
|
+
queued++;
|
|
138
|
+
}
|
|
139
|
+
return queued;
|
|
140
|
+
}
|
|
141
|
+
export function recordRunnerVersion(dir, runner, version, now) {
|
|
142
|
+
const known = runnerVersions(dir).filter(one => one.runner !== runner);
|
|
143
|
+
known.push({ runner, version, at: now.toISOString() });
|
|
144
|
+
try {
|
|
145
|
+
writeAtomic(join(dir, RUNNER_VERSIONS_FILE), `${JSON.stringify(known.sort((a, b) => a.runner.localeCompare(b.runner)))}\n`);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// The version is a display fact; a failed write only leaves it unknown.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function runnerVersions(dir) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(readFileSync(join(dir, RUNNER_VERSIONS_FILE), "utf8"));
|
|
154
|
+
if (!Array.isArray(parsed))
|
|
155
|
+
return [];
|
|
156
|
+
return parsed.filter((one) => typeof one === "object" && one !== null
|
|
157
|
+
&& typeof one.runner === "string" && typeof one.version === "string" && VERSION.test(one.version) && typeof one.at === "string");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The console's background check: at start and then hourly (the daily limit
|
|
165
|
+
* is latestRelease's own), and a security release notifies operators.
|
|
166
|
+
*/
|
|
167
|
+
export function startUpdateChecks(store, dir, current, updateCommand, io = {}) {
|
|
168
|
+
let stopped = false;
|
|
169
|
+
const pass = async () => {
|
|
170
|
+
const release = await latestRelease(dir, io);
|
|
171
|
+
if (!stopped) {
|
|
172
|
+
try {
|
|
173
|
+
notifySecurityRelease(store, release, current, updateCommand, (io.now ?? (() => new Date()))());
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// A closed store at shutdown: nothing to tell.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
void pass();
|
|
181
|
+
const timer = setInterval(() => void pass(), io.everyMs ?? 60 * 60_000);
|
|
182
|
+
timer.unref?.();
|
|
183
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
184
|
+
}
|
|
185
|
+
async function askVersion(request) {
|
|
186
|
+
const answer = await request(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
|
|
187
|
+
if (!answer.ok)
|
|
188
|
+
return null;
|
|
189
|
+
const version = (await answer.json()).version;
|
|
190
|
+
return typeof version === "string" && VERSION.test(version) ? version : null;
|
|
191
|
+
}
|
|
192
|
+
async function askNotes(request, version) {
|
|
193
|
+
try {
|
|
194
|
+
const answer = await request(`${NOTES_API}v${version}`, { headers: { accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
|
|
195
|
+
if (!answer.ok)
|
|
196
|
+
return "";
|
|
197
|
+
const body = (await answer.json()).body;
|
|
198
|
+
return typeof body === "string" ? body.replace(/\r\n/g, "\n").slice(0, NOTES_MAX) : "";
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return "";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function readCache(dir) {
|
|
205
|
+
const file = join(dir, RELEASE_CACHE_FILE);
|
|
206
|
+
if (!existsSync(file))
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
210
|
+
if (typeof parsed.checkedAt !== "string" || Number.isNaN(Date.parse(parsed.checkedAt)))
|
|
211
|
+
return null;
|
|
212
|
+
const release = parsed.release;
|
|
213
|
+
const valid = release !== null && typeof release === "object" && release !== undefined
|
|
214
|
+
&& typeof release.version === "string" && VERSION.test(release.version) && typeof release.notes === "string";
|
|
215
|
+
return { checkedAt: parsed.checkedAt, release: valid ? { version: release.version, notes: release.notes, url: `${RELEASES_PAGE}v${release.version}`, security: isSecurityRelease(release.notes) } : null };
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function writeAtomic(file, content) {
|
|
222
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
223
|
+
writeFileSync(temporary, content, { mode: 0o600 });
|
|
224
|
+
renameSync(temporary, file);
|
|
225
|
+
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { type Phase } from "./provider.js";
|
|
|
14
14
|
import type { RevisionLineage } from "./store.js";
|
|
15
15
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
16
16
|
export type ServeOptions = {
|
|
17
|
+
/** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
|
|
18
|
+
installBin?: string;
|
|
17
19
|
/** Native coding workspace injection for isolated integration tests. */
|
|
18
20
|
codingWorkspace?: CodingWorkspace;
|
|
19
21
|
store: Store;
|
|
@@ -183,6 +185,8 @@ export type RouteView = {
|
|
|
183
185
|
* The form offers these and nothing else. */
|
|
184
186
|
choices: Record<Phase, AgentChoice[]>;
|
|
185
187
|
};
|
|
188
|
+
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
189
|
+
export declare const PAGE_CSS: string;
|
|
186
190
|
/**
|
|
187
191
|
* The sensitivity classifier (arc 4, findings 3/17): a body showing a
|
|
188
192
|
* password input renders WITHOUT the chrome additions (palette, overlay,
|