toolroll 0.5.0 → 0.6.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 +1 -1
- package/dist/browser/workspace.js +12 -12
- package/dist/browser-workspace.d.ts +12 -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/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/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.js +75 -13
- package/dist/provider-auth.d.ts +113 -0
- package/dist/provider-auth.js +250 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +8 -1
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +118 -74
- 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/work-index.js +20 -4
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +55 -29
- package/package.json +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
/** The open pause the dispatch gate last left this task waiting on, or null. */
|
|
73
|
+
export function authWaitOf(store, taskRef) {
|
|
74
|
+
const row = store.handle.prepare(`SELECT p.* FROM task_ref r JOIN provider_auth_pause p ON p.id = r.auth_wait_pause
|
|
75
|
+
WHERE r.id = ? AND p.lifted_at IS NULL`).get(taskRef);
|
|
76
|
+
return row === undefined ? null : readPause(row);
|
|
77
|
+
}
|
|
78
|
+
/** Every open pause, oldest first. */
|
|
79
|
+
export function openAuthPauses(store) {
|
|
80
|
+
return store.handle.prepare("SELECT * FROM provider_auth_pause WHERE lifted_at IS NULL ORDER BY id").all().map(readPause);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Record an auth-expired run against its provider's pause, opening the
|
|
84
|
+
* incident (and its one notification) when none is open. Called inside the
|
|
85
|
+
* run's own failure transaction. Returns whether this run opened it.
|
|
86
|
+
*/
|
|
87
|
+
export function pauseForAuth(store, args) {
|
|
88
|
+
return store.transact(() => {
|
|
89
|
+
const db = store.handle;
|
|
90
|
+
const open = authPauseOf(store, args.provider);
|
|
91
|
+
if (open !== null) {
|
|
92
|
+
db.prepare("UPDATE provider_auth_pause SET runs = runs + 1 WHERE id = ?").run(open.id);
|
|
93
|
+
return { opened: false, pause: { ...open, runs: open.runs + 1 } };
|
|
94
|
+
}
|
|
95
|
+
const ordinal = Number(db.prepare("SELECT COALESCE(MAX(ordinal), 0) + 1 AS n FROM provider_auth_pause WHERE provider = ?").get(args.provider)?.["n"] ?? 1);
|
|
96
|
+
const inserted = db.prepare(`INSERT INTO provider_auth_pause (provider, ordinal, auth_mode, opened_at, first_run, task_ref)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(args.provider, ordinal, args.authMode ?? "subscription", args.now.toISOString(), args.runId, args.taskRef);
|
|
98
|
+
const pause = readPause(db.prepare("SELECT * FROM provider_auth_pause WHERE id = ?").get(Number(inserted.lastInsertRowid)));
|
|
99
|
+
notifyOperators(store, `auth-expired:${pause.provider}:${pause.ordinal}`, {
|
|
100
|
+
kind: "auth-expired",
|
|
101
|
+
subject: signInReason(pause),
|
|
102
|
+
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}\`).`,
|
|
103
|
+
}, args.now);
|
|
104
|
+
return { opened: true, pause };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One message per instance operator, addressed to them: only someone at this
|
|
109
|
+
* computer can sign an agent back in, and a personal notification reaches that
|
|
110
|
+
* person on every channel they paired (Telegram, Slack, Discord, Teams) and in
|
|
111
|
+
* the console, whichever projects those channels follow.
|
|
112
|
+
*/
|
|
113
|
+
function notifyOperators(store, key, message, now) {
|
|
114
|
+
const operators = store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name));
|
|
115
|
+
for (const recipient of operators) {
|
|
116
|
+
store.enqueueNotification({
|
|
117
|
+
dedupeKey: `${SIGN_IN_KEY_PREFIX}${key}:${recipient}`,
|
|
118
|
+
kind: message.kind,
|
|
119
|
+
pushClass: "attention",
|
|
120
|
+
link: "/work",
|
|
121
|
+
subject: message.subject,
|
|
122
|
+
body: message.body,
|
|
123
|
+
recipient,
|
|
124
|
+
source: { installation: true },
|
|
125
|
+
}, now);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Lift a provider's open pause — a run or probe on it succeeded, or a person
|
|
130
|
+
* resumed it — and send the one short "signed in again" message. Returns the
|
|
131
|
+
* number of tasks its incident sent back to the queue, or null when nothing
|
|
132
|
+
* was paused.
|
|
133
|
+
*/
|
|
134
|
+
export function liftAuthPause(store, provider, how, by, now,
|
|
135
|
+
/** For a run: when it started. A run that began before the pause opened proves nothing about the sign-in now. */
|
|
136
|
+
runStartedAt) {
|
|
137
|
+
return store.transact(() => {
|
|
138
|
+
const pause = authPauseOf(store, provider);
|
|
139
|
+
if (pause === null)
|
|
140
|
+
return null;
|
|
141
|
+
if (runStartedAt !== undefined && runStartedAt.getTime() < Date.parse(pause.openedAt))
|
|
142
|
+
return null;
|
|
143
|
+
const resumed = Number(store.handle.prepare(`SELECT COUNT(DISTINCT task_ref) AS n FROM run
|
|
144
|
+
WHERE provider = ? AND reason = 'auth-expired' AND finished_at >= ?`).get(provider, pause.openedAt)?.["n"] ?? 0);
|
|
145
|
+
store.handle.prepare("UPDATE provider_auth_pause SET lifted_at = ?, lifted_by = ?, lifted_how = ?, resumed = ? WHERE id = ? AND lifted_at IS NULL")
|
|
146
|
+
.run(now.toISOString(), by.slice(0, 120), how, resumed, pause.id);
|
|
147
|
+
const name = providerName(provider);
|
|
148
|
+
notifyOperators(store, `auth-restored:${pause.provider}:${pause.ordinal}`, {
|
|
149
|
+
kind: "auth-restored",
|
|
150
|
+
subject: `${name} is signed in again, ${resumed} ${resumed === 1 ? "task" : "tasks"} resumed`,
|
|
151
|
+
body: how === "person" ? `Resumed by ${by.slice(0, 80)}.` : `A ${how === "run" ? "run" : "sign-in check"} on ${name} worked.`,
|
|
152
|
+
}, now);
|
|
153
|
+
if (how === "person") {
|
|
154
|
+
store.recordAction({ at: now.toISOString(), actor: by.slice(0, 120), repo: null, taskId: null, runId: null,
|
|
155
|
+
action: `resumed ${name} after a sign-in pause`, outcome: "resumed", source: "request", detail: `${resumed} ${resumed === 1 ? "task" : "tasks"}` });
|
|
156
|
+
}
|
|
157
|
+
store.bumpWake();
|
|
158
|
+
return { resumed, pause };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* What a non-spending sign-in probe said. A probe lifts the pause only after
|
|
163
|
+
* one during the same pause saw the provider signed out: a CLI that reports
|
|
164
|
+
* "logged in" while its session cannot refresh must not lift and re-open the
|
|
165
|
+
* pause over and over.
|
|
166
|
+
*/
|
|
167
|
+
export function noteSignInProbe(store, provider, state, now) {
|
|
168
|
+
const pause = authPauseOf(store, provider);
|
|
169
|
+
if (pause === null)
|
|
170
|
+
return null;
|
|
171
|
+
if (state === "signed-out" || state === "key-refused" || state === "missing-key") {
|
|
172
|
+
store.handle.prepare("UPDATE provider_auth_pause SET probe_saw_out = 1 WHERE id = ?").run(pause.id);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (state !== "connected" && state !== "key-works")
|
|
176
|
+
return null;
|
|
177
|
+
const sawOut = Number(store.handle.prepare("SELECT probe_saw_out FROM provider_auth_pause WHERE id = ?").get(pause.id)?.["probe_saw_out"] ?? 0) === 1;
|
|
178
|
+
if (!sawOut)
|
|
179
|
+
return null;
|
|
180
|
+
const lifted = liftAuthPause(store, provider, "probe", "sign-in check", now);
|
|
181
|
+
return lifted === null ? null : { resumed: lifted.resumed };
|
|
182
|
+
}
|
|
183
|
+
/** How often a paused provider lets one task through as a trial. */
|
|
184
|
+
export const AUTH_TRIAL_MS = 10 * 60_000;
|
|
185
|
+
/**
|
|
186
|
+
* Whether this task may go ahead as the paused provider's trial: at most one
|
|
187
|
+
* every AUTH_TRIAL_MS. A trial that works lifts the pause (its run proves the
|
|
188
|
+
* sign-in); one that fails the same way only counts against the open
|
|
189
|
+
* incident, so nobody is told twice.
|
|
190
|
+
*/
|
|
191
|
+
export function claimAuthTrial(store, pause, now) {
|
|
192
|
+
const due = new Date(now.getTime() - AUTH_TRIAL_MS).toISOString();
|
|
193
|
+
const claimed = store.handle.prepare(`UPDATE provider_auth_pause SET last_trial_at = ?
|
|
194
|
+
WHERE id = ? AND lifted_at IS NULL AND COALESCE(last_trial_at, opened_at) <= ?`).run(now.toISOString(), pause.id, due);
|
|
195
|
+
return Number(claimed.changes) === 1;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The sign-in gate every road that starts an agent asks before it claims
|
|
199
|
+
* anything: the tick's queue, and the roads beside it (fallback entries,
|
|
200
|
+
* attended continuations, contest resumes). The open pause the work waits
|
|
201
|
+
* on, or null when it may go ahead — nothing paused, or this is the pause's
|
|
202
|
+
* one trial. A waiting task is noted on its task_ref, so the work index says
|
|
203
|
+
* exactly what the gate decided; a task let through is un-noted.
|
|
204
|
+
*/
|
|
205
|
+
export function signInGate(store, providers, now, taskRef) {
|
|
206
|
+
const pause = [...new Set(providers)].map(one => authPauseOf(store, one)).find(one => one !== null) ?? null;
|
|
207
|
+
const waiting = pause !== null && !claimAuthTrial(store, pause, now) ? pause : null;
|
|
208
|
+
if (taskRef !== undefined) {
|
|
209
|
+
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);
|
|
210
|
+
}
|
|
211
|
+
return waiting;
|
|
212
|
+
}
|
|
213
|
+
/** The loop beside the console: every two minutes, probe each paused
|
|
214
|
+
* provider's sign-in (no model, nothing spent) and lift the pause when it
|
|
215
|
+
* works again. Nothing is probed while nothing is paused. */
|
|
216
|
+
export function startSignInProbes(store, check, everyMs = 120_000) {
|
|
217
|
+
let stopped = false;
|
|
218
|
+
let running = false;
|
|
219
|
+
const tick = async () => {
|
|
220
|
+
if (stopped || running)
|
|
221
|
+
return;
|
|
222
|
+
running = true;
|
|
223
|
+
try {
|
|
224
|
+
for (const pause of openAuthPauses(store)) {
|
|
225
|
+
try {
|
|
226
|
+
noteSignInProbe(store, pause.provider, (await check(pause.provider, true)).state, new Date());
|
|
227
|
+
}
|
|
228
|
+
catch { /* the next pass asks again */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch { /* the next pass asks again */ }
|
|
232
|
+
finally {
|
|
233
|
+
running = false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const timer = setInterval(() => { void tick(); }, everyMs);
|
|
237
|
+
timer.unref?.();
|
|
238
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
239
|
+
}
|
|
240
|
+
/** The console's view of every open pause: title, command, one action. */
|
|
241
|
+
export function signInNotices(store) {
|
|
242
|
+
return openAuthPauses(store).map(pause => ({
|
|
243
|
+
provider: pause.provider,
|
|
244
|
+
title: signInReason(pause),
|
|
245
|
+
command: signInCommand(pause),
|
|
246
|
+
detail: "",
|
|
247
|
+
resumeLabel: `Resume ${providerName(pause.provider)}`,
|
|
248
|
+
resumeHref: `/providers/${pause.provider}/resume`,
|
|
249
|
+
}));
|
|
250
|
+
}
|
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
|
package/dist/serve.d.ts
CHANGED
|
@@ -183,6 +183,8 @@ export type RouteView = {
|
|
|
183
183
|
* The form offers these and nothing else. */
|
|
184
184
|
choices: Record<Phase, AgentChoice[]>;
|
|
185
185
|
};
|
|
186
|
+
/** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
|
|
187
|
+
export declare const PAGE_CSS: string;
|
|
186
188
|
/**
|
|
187
189
|
* The sensitivity classifier (arc 4, findings 3/17): a body showing a
|
|
188
190
|
* password input renders WITHOUT the chrome additions (palette, overlay,
|