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.
Files changed (51) hide show
  1. package/README.md +38 -24
  2. package/dist/accent-colors.d.ts +6 -3
  3. package/dist/accent-colors.js +9 -4
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +23 -23
  6. package/dist/browser-workspace.d.ts +46 -0
  7. package/dist/builder.d.ts +18 -1
  8. package/dist/builder.js +177 -5
  9. package/dist/chat-delivery.js +6 -4
  10. package/dist/claim.d.ts +15 -1
  11. package/dist/claim.js +30 -2
  12. package/dist/control-ui.js +1 -1
  13. package/dist/daemon.d.ts +9 -0
  14. package/dist/daemon.js +21 -0
  15. package/dist/decision.d.ts +6 -0
  16. package/dist/decision.js +12 -0
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/dispatch.d.ts +3 -1
  19. package/dist/dispatch.js +13 -0
  20. package/dist/dispose.js +11 -7
  21. package/dist/exhaustion.d.ts +14 -0
  22. package/dist/exhaustion.js +35 -0
  23. package/dist/install-method.d.ts +17 -0
  24. package/dist/install-method.js +61 -0
  25. package/dist/invoke.js +26 -0
  26. package/dist/lead-status.d.ts +7 -0
  27. package/dist/lead-status.js +15 -2
  28. package/dist/mate.js +5 -2
  29. package/dist/operate.d.ts +5 -0
  30. package/dist/operate.js +109 -15
  31. package/dist/provider-auth.d.ts +138 -0
  32. package/dist/provider-auth.js +291 -0
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +8 -1
  35. package/dist/releases.d.ts +76 -0
  36. package/dist/releases.js +225 -0
  37. package/dist/serve.d.ts +4 -0
  38. package/dist/serve.js +207 -76
  39. package/dist/setup-guide.d.ts +2 -2
  40. package/dist/setup-guide.js +2 -0
  41. package/dist/skills.d.ts +6 -0
  42. package/dist/skills.js +8 -0
  43. package/dist/store.d.ts +2 -2
  44. package/dist/store.js +13 -4
  45. package/dist/subscription-chat.js +3 -0
  46. package/dist/update-notice.d.ts +5 -0
  47. package/dist/update-notice.js +4 -0
  48. package/dist/work-index.js +22 -4
  49. package/dist/worktree.d.ts +13 -0
  50. package/dist/worktree.js +55 -29
  51. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -10,6 +10,8 @@ import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, mont
10
10
  import { spendCsv } from "./spend-ui.js";
11
11
  import { buildExport, exportSummary, exportZip, writeExportFolder } from "./export.js";
12
12
  import { startBudgetAlerts } from "./budget-alerts.js";
13
+ import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
14
+ import { createConnectionChecker } from "./provider-connection.js";
13
15
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
14
16
  import { pushLimitSink } from "./provider-limits.js";
15
17
  import { limitsView } from "./limits-ui.js";
@@ -121,7 +123,7 @@ import { verdictWords as proofVerdictWords, matrixWords, semanticCoverage, cover
121
123
  import { probeRepo, isVerified } from "./probe.js";
122
124
  import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch, } from "./dispatch.js";
123
125
  import { createDecisionServer } from "./serve.js";
124
- import { daemonLaunchCommand, daemonStatus, installDaemon, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
126
+ import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
125
127
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
126
128
  import { scanRepo } from "./capscan.js";
127
129
  import { computeGaps, describeCapability } from "./gaps.js";
@@ -179,6 +181,9 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
179
181
  import { beads } from "./beads.js";
180
182
  import { githubIssues } from "./issues.js";
181
183
  import { installationStatus, renderInstallationStatus, renderTaskWait, taskWaitSnapshot } from "./lead-status.js";
184
+ import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
185
+ import { installMethod } from "./install-method.js";
186
+ import { PACKAGE_VERSION } from "./version.js";
182
187
  /**
183
188
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
184
189
  *
@@ -405,6 +410,8 @@ Agents — which provider and model each phase runs on
405
410
  toolroll providers --report --runner <name> --token <t>
406
411
  record this machine's readiness per
407
412
  provider under its runner name
413
+ toolroll providers resume <provider> after signing in again, start its
414
+ paused tasks now
408
415
  toolroll config set chat --provider claude-subscription|codex-subscription|anthropic-api|openrouter-api
409
416
  [--model <m>] [--weekly-usd <n>] [--daily-turns <n>] --as <you> --token <t>
410
417
  membership providers reuse a logged-in local harness with no dollar
@@ -716,6 +723,8 @@ export async function runOperate(command, argv, write, options = {}) {
716
723
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
717
724
  ...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
718
725
  ...(options.waitSleep === undefined ? {} : { waitSleep: options.waitSleep }),
726
+ ...(options.releaseIo === undefined ? {} : { releaseIo: options.releaseIo }),
727
+ ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
719
728
  });
720
729
  }
721
730
  catch (error) {
@@ -913,6 +922,8 @@ async function dispatch(command, positional, flags, context) {
913
922
  case "intake":
914
923
  return intakeCommand(positional, flags, context);
915
924
  case "providers":
925
+ if (positional[0] === "resume")
926
+ return resumeProviderCommand(positional.slice(1), flags, context);
916
927
  return providersCommand(flags, context);
917
928
  case "template":
918
929
  return templateCommand(positional, flags, context);
@@ -1007,6 +1018,7 @@ async function readyCommand(flags, context) {
1007
1018
  const ready = store.listReady(now);
1008
1019
  const described = ready.map(ref => describeRef(store, ref, now));
1009
1020
  const dispatchableCount = described.filter(one => diagnosisIsDispatchable(one.dispatch)).length;
1021
+ const pauses = openAuthPauses(store);
1010
1022
  if (json) {
1011
1023
  write(envelopeJson({
1012
1024
  ok: ready.length > 0,
@@ -1014,10 +1026,14 @@ async function readyCommand(flags, context) {
1014
1026
  ...(ready.length > 0 ? {} : { reason: "empty", message: "nothing is task-locally ready" }),
1015
1027
  count: ready.length,
1016
1028
  dispatchableCount,
1029
+ signIn: pauses.map(one => ({ provider: one.provider, message: signInWords(one), since: one.openedAt })),
1017
1030
  tasks: described,
1018
1031
  }));
1019
1032
  return ready.length > 0 ? EXIT.ok : EXIT.refused;
1020
1033
  }
1034
+ // A paused provider first: nothing on it runs until someone signs in.
1035
+ for (const one of pauses)
1036
+ write(signInWords(one));
1021
1037
  if (ready.length === 0) {
1022
1038
  write("Nothing is ready to dispatch.");
1023
1039
  return EXIT.refused;
@@ -1026,7 +1042,7 @@ async function readyCommand(flags, context) {
1026
1042
  for (const ref of ready) {
1027
1043
  const task = store.getTask(ref.externalId);
1028
1044
  const diagnosis = diagnoseTaskDispatch(store, ref.externalId, now);
1029
- write(` ${ref.externalId} ${task === null ? "" : task.title}${ref.assignedRunner === null ? "" : ` (reserved for ${ref.assignedRunner})`}${diagnosis === null ? "" : ` — ${diagnosis.summary.toLowerCase()}`}`);
1045
+ write(` ${ref.externalId} ${task === null ? "" : task.title}${ref.assignedRunner === null ? "" : ` (reserved for ${ref.assignedRunner})`}${diagnosis === null ? "" : ` — ${diagnosis.code === "signed-out" ? diagnosis.summary : diagnosis.summary.toLowerCase()}`}`);
1030
1046
  }
1031
1047
  return EXIT.ok;
1032
1048
  }
@@ -1826,6 +1842,7 @@ async function buildCommand(positional, flags, context) {
1826
1842
  const broke = result.reason === "agent" ||
1827
1843
  result.reason === "agent-reported" ||
1828
1844
  result.reason === "no-op" ||
1845
+ result.reason === "no-handoff" ||
1829
1846
  result.reason === "moved-head" ||
1830
1847
  result.reason === "timeout" ||
1831
1848
  result.reason === "git" ||
@@ -2114,12 +2131,20 @@ async function tickCommand(flags, context) {
2114
2131
  resumed.push({ id: taskId, outcome: "skipped", reason: "budget", detail: budgetWords(laneBudget) });
2115
2132
  continue;
2116
2133
  }
2134
+ // The sign-in pause holds a resumed lane too: it waits, parked, instead of failing.
2135
+ const laneGate = signInGate(store, [racer.provider], clock());
2136
+ if (laneGate.waiting !== null) {
2137
+ backToParked();
2138
+ resumed.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(laneGate.waiting) });
2139
+ continue;
2140
+ }
2117
2141
  // Legacy comparison lanes carried an absolute clock. New profiles use
2118
2142
  // a progress watchdog and may remain alive indefinitely while useful
2119
2143
  // work is observable; only legacy approvals retain their cumulative
2120
2144
  // wall-clock contract.
2121
2145
  const remaining = waiting.kind === "comparison" ? null : racer.budgetMicrousd - racer.accountedMicrousd;
2122
2146
  if (remaining !== null && remaining <= 0) {
2147
+ laneGate.giveBack();
2123
2148
  const current = store.getContestant(racer.id);
2124
2149
  if (current !== null)
2125
2150
  store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
@@ -2131,6 +2156,7 @@ async function tickCommand(flags, context) {
2131
2156
  const laneProfile = racer.profile ?? contestantProfileOf(racer.provider, racer.model, racer.repairModel);
2132
2157
  const clockCapMs = 3 * laneProfile.timeoutSeconds * 1000;
2133
2158
  if (laneProfile.timeoutKind !== "idle" && store.contestantCumulativeMs(racer.id) >= clockCapMs) {
2159
+ laneGate.giveBack();
2134
2160
  const current = store.getContestant(racer.id);
2135
2161
  if (current !== null)
2136
2162
  store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
@@ -2141,12 +2167,14 @@ async function tickCommand(flags, context) {
2141
2167
  }
2142
2168
  const reclaimed = acquire(store, waiting.taskRef, runner, { now: clock(), token, ttlMs: leaseTtlMs });
2143
2169
  if (!reclaimed.ok) {
2170
+ laneGate.giveBack();
2144
2171
  backToParked();
2145
2172
  continue;
2146
2173
  }
2147
2174
  const freshContest = store.getContest(waiting.id);
2148
2175
  if (freshContest === null || !store.casContestState(waiting.id, ["decision-wait", "racing"], "racing", freshContest.generation)) {
2149
2176
  release(store, reclaimed.claim.leaseId, clock());
2177
+ laneGate.giveBack();
2150
2178
  backToParked();
2151
2179
  continue;
2152
2180
  }
@@ -2365,7 +2393,10 @@ async function tickCommand(flags, context) {
2365
2393
  // gateway re-checks freshly at spawn; this cache only keeps a skipped
2366
2394
  // queue from probing once per task.
2367
2395
  const attestedThisPass = new Map();
2396
+ let untakenTrial = null;
2368
2397
  for (const ref of ready) {
2398
+ untakenTrial?.giveBack();
2399
+ untakenTrial = null;
2369
2400
  // The build budget governs UNATTENDED admissions (round-1 finding 4):
2370
2401
  // once it is spent, the pass keeps SCANNING for attended
2371
2402
  // authorizations — operator-invoked sessions launch regardless —
@@ -2575,6 +2606,21 @@ async function tickCommand(flags, context) {
2575
2606
  dispatched.push({ id, outcome: "skipped", reason: "auth-mode", detail: brokenMode.problem });
2576
2607
  continue;
2577
2608
  }
2609
+ // THE SIGN-IN PAUSE: a provider whose sign-in stopped working takes no
2610
+ // new work — no claim, no run, never a substitute (a lapsed login must not
2611
+ // reach a paid fallback) — until a run or check on it works again or a
2612
+ // person resumes it. Other providers keep working.
2613
+ // One task every AUTH_TRIAL_MS goes ahead as the trial: a sign-in check
2614
+ // can say "logged in" for a session that cannot refresh, so a real run
2615
+ // decides — it lifts the pause, or fails into the same incident.
2616
+ const gate = signInGate(store, modeProviders, clock(), ref.id);
2617
+ if (gate.waiting !== null) {
2618
+ dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(gate.waiting) });
2619
+ continue;
2620
+ }
2621
+ // A trial whose task is skipped before its claim succeeds is given back
2622
+ // at the next task (or after the pass), so the next pass may take it.
2623
+ untakenTrial = gate;
2578
2624
  // The leg is the authority: what resolved must BE the leg, exactly —
2579
2625
  // the sealed build leg, or the parked fallback entry's own pair.
2580
2626
  const governingLeg = wantsPlan ? planLeg : parkedEntry !== null ? { provider: parkedEntry.provider, model: parkedEntry.model, chosen: "fallback" } : sealedBuildLeg;
@@ -2741,6 +2787,8 @@ async function tickCommand(flags, context) {
2741
2787
  ? {}
2742
2788
  : { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
2743
2789
  });
2790
+ if (claimed.ok)
2791
+ untakenTrial = null;
2744
2792
  if (!claimed.ok) {
2745
2793
  // Losing a race, finding the task no longer ready, and a machine that
2746
2794
  // lacks what the task needs are all the system working. None fails the
@@ -3678,7 +3726,9 @@ async function tickCommand(flags, context) {
3678
3726
  id,
3679
3727
  outcome: "failed",
3680
3728
  reason: disposition.sealed
3681
- ? `${disposition.failureClass}${disposition.disposition === "backoff" ? ` — retry ${disposition.strikes}/3` : disposition.disposition === "stalled" ? " — stalled" : ""}`
3729
+ ? disposition.disposition === "auth-expired"
3730
+ ? "auth-expired — requeued, sign-in needed"
3731
+ : `${disposition.failureClass}${disposition.disposition === "backoff" ? ` — retry ${disposition.strikes}/3` : disposition.disposition === "stalled" ? " — stalled" : ""}`
3682
3732
  : "fenced",
3683
3733
  ...(result.ok ? {} : { detail: result.message.slice(0, 200) }),
3684
3734
  worktree: leased.worktree.path,
@@ -3699,6 +3749,7 @@ async function tickCommand(flags, context) {
3699
3749
  break;
3700
3750
  }
3701
3751
  }
3752
+ untakenTrial?.giveBack();
3702
3753
  // THE CHAIN ADMISSION PASS (E3d): cycles a recognized exhaustion advanced
3703
3754
  // to pending-admission dispatch their NEXT approved entry here — the ONLY
3704
3755
  // road that runs a fallback entry. Every authority is re-derived inside
@@ -3745,6 +3796,12 @@ async function tickCommand(flags, context) {
3745
3796
  dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "budget-unenforceable" });
3746
3797
  continue;
3747
3798
  }
3799
+ // The sign-in pause holds a fallback entry too: the admission stays pending until its provider works.
3800
+ const entryGate = signInGate(store, [peek.profile.provider], clock(), pending.taskRef);
3801
+ if (entryGate.waiting !== null) {
3802
+ dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(entryGate.waiting) });
3803
+ continue;
3804
+ }
3748
3805
  // The FALLBACK claim (finding 4): every acquireIfReady gate — task
3749
3806
  // state, non-backoff holds, blockers, approved scope + mode belt,
3750
3807
  // capability, capacity, and quota keyed by the PINNED credential —
@@ -3767,6 +3824,7 @@ async function tickCommand(flags, context) {
3767
3824
  : { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
3768
3825
  });
3769
3826
  if (!claimed.ok) {
3827
+ entryGate.giveBack();
3770
3828
  dispatched.push({ id: pending.taskId, outcome: "skipped", reason: claimed.reason });
3771
3829
  continue;
3772
3830
  }
@@ -3934,8 +3992,15 @@ async function tickCommand(flags, context) {
3934
3992
  dispatched.push({ id: taskId, outcome: "skipped", reason: "budget", detail: budgetWords(continuationBudget) });
3935
3993
  continue;
3936
3994
  }
3995
+ // The sign-in pause holds a continuation too: its authorization stays open and it starts once the sign-in works.
3996
+ const continuationGate = signInGate(store, [pinned.provider], clock(), parent.taskRef);
3997
+ if (continuationGate.waiting !== null) {
3998
+ dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(continuationGate.waiting) });
3999
+ continue;
4000
+ }
3937
4001
  const claimed = acquireContinuation(store, continuation, runner, { now: clock(), token, ttlMs: leaseTtlMs });
3938
4002
  if (!claimed.ok) {
4003
+ continuationGate.giveBack();
3939
4004
  dispatched.push({ id: taskId, outcome: "skipped", reason: claimed.reason, ...("message" in claimed ? { detail: claimed.message } : {}) });
3940
4005
  continue;
3941
4006
  }
@@ -4633,9 +4698,13 @@ async function startConsole(options) {
4633
4698
  server.on("close", stopMonitoring);
4634
4699
  // v105: budget alerts at 50/80/100 %, a pass a minute.
4635
4700
  server.on("close", startBudgetAlerts(context.store));
4701
+ // A paused provider's sign-in is checked every two minutes (no model, nothing spent); the pause lifts when it works.
4702
+ server.on("close", startSignInProbes(context.store, createConnectionChecker()));
4636
4703
  // Sprint 8: scheduled backups, a pass a minute; its lease also tells a restore that the console is running.
4637
4704
  server.on("close", startBackups(context.store, context.databaseFile));
4638
4705
  server.on("close", startCodexLimits(context.store));
4706
+ // At most one anonymous look a day for a newer Toolroll (Settings → Updates can switch it off).
4707
+ server.on("close", startUpdateChecks(context.store, dirname(context.databaseFile), PACKAGE_VERSION, installMethod(context.installBin).updateCommand, context.releaseIo ?? {}));
4639
4708
  const bound = server.address();
4640
4709
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
4641
4710
  // A bind-everywhere address is not a place a browser can go: the URL
@@ -5009,6 +5078,27 @@ async function webhookCommand(positional, flags, context) {
5009
5078
  `Send yourself a proof: toolroll webhook test`,
5010
5079
  ]);
5011
5080
  }
5081
+ /**
5082
+ * `toolroll providers resume <provider>` — a person says the sign-in works
5083
+ * again (after `claude /login`, say): the provider's sign-in pause lifts, its
5084
+ * tasks may start, and one short message says how many resumed. A pause that
5085
+ * lifted by itself answers so; nothing is paused twice.
5086
+ */
5087
+ function resumeProviderCommand(positional, flags, context) {
5088
+ const { store, write, json } = context;
5089
+ const command = "providers resume";
5090
+ const provider = positional[0];
5091
+ if (provider === undefined || !isProviderId(provider) || positional.length > 1) {
5092
+ return fail(write, json, command, "usage", `Use \`toolroll providers resume <${PROVIDER_IDS.join("|")}>\`.`, EXIT.usage);
5093
+ }
5094
+ const who = text(flags, "as") ?? process.env["USER"] ?? "operator";
5095
+ const lifted = liftAuthPause(store, provider, "person", who, context.clock());
5096
+ if (lifted === null)
5097
+ return succeed(write, json, command, { provider, resumed: 0, paused: false }, () => [`${provider} is not paused.`]);
5098
+ return succeed(write, json, command, { provider, resumed: lifted.resumed, paused: false }, () => [
5099
+ `${provider} resumed — ${lifted.resumed} ${lifted.resumed === 1 ? "task" : "tasks"} can start again.`,
5100
+ ]);
5101
+ }
5012
5102
  /**
5013
5103
  * `toolroll providers` — identification, never integration theater.
5014
5104
  *
@@ -6884,18 +6974,12 @@ async function daemonCommand(positional, flags, context) {
6884
6974
  write("Nothing was written. Re-run without --dry-run to install.");
6885
6975
  return EXIT.ok;
6886
6976
  }
6887
- if (legacy !== null) {
6888
- try {
6889
- await uninstallDaemon(legacy, supervise);
6890
- }
6891
- catch (error) {
6892
- return fail(write, json, "daemon install", "supervisor", `could not stop ${legacy.label}, the same watch under its older name: ${error.message}`, EXIT.failed);
6893
- }
6894
- }
6895
- const installed = await installDaemon(plan, token, supervise);
6977
+ const installed = await installReplacingLegacy(plan, token, legacy, supervise);
6896
6978
  if (!installed.ok) {
6897
6979
  return fail(write, json, "daemon install", "supervisor", installed.message, EXIT.failed);
6898
6980
  }
6981
+ // The legacy watch beat for this runner until it stopped: only a beat after that proves the new one.
6982
+ const readySince = legacy === null ? heartbeatBefore : store.getRunner(runnerName)?.runner.heartbeatAt ?? null;
6899
6983
  const started = await daemonStatus(plan, supervise);
6900
6984
  if (started.state !== "running") {
6901
6985
  return fail(write, json, "daemon install", "not-running", `the service was installed but did not stay running (${started.detail}) — read ${plan.logPath}; no work will be claimed until this is fixed`, EXIT.failed);
@@ -6906,7 +6990,7 @@ async function daemonCommand(positional, flags, context) {
6906
6990
  // readiness receipt that proves this service reached the work loop —
6907
6991
  // a FRESH one when the service was (re)started; the one already
6908
6992
  // standing when a healthy running service was left alone.
6909
- const fresh = await awaitFreshHeartbeat(store, runnerName, installed.action === "running" ? null : heartbeatBefore);
6993
+ const fresh = await awaitFreshHeartbeat(store, runnerName, installed.action === "running" && legacy === null ? null : readySince);
6910
6994
  const liveRunner = fresh.ok ? store.getRunner(runnerName)?.runner ?? null : null;
6911
6995
  if (liveRunner === null) {
6912
6996
  const macHint = process.platform === "darwin" && repo.startsWith(join(homedir(), "Documents"))
@@ -7013,6 +7097,7 @@ async function runWatchLoop(args) {
7013
7097
  // attributes to this episode by runner and window, and `brief
7014
7098
  // --latest-watch` bounds itself to exactly it.
7015
7099
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7100
+ recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7016
7101
  args.onReady?.();
7017
7102
  // A false renewal is FATAL (arc 2 finding 15): the lease or the
7018
7103
  // credential is gone, and admitting one more pass would be work done for
@@ -8002,6 +8087,7 @@ async function upCommand(flags, context) {
8002
8087
  });
8003
8088
  };
8004
8089
  observeReadiness();
8090
+ recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
8005
8091
  const runnerHeartbeat = setInterval(() => {
8006
8092
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
8007
8093
  if (!beat.ok && !stopping) {
@@ -8887,7 +8973,7 @@ function readPaths(flags, backend) {
8887
8973
  return backend === BUILT_IN ? [BUILT_IN] : [];
8888
8974
  }
8889
8975
  // ---- authoring ------------------------------------------------------------
8890
- function statusCommand(positional, flags, context) {
8976
+ async function statusCommand(positional, flags, context) {
8891
8977
  const command = "status";
8892
8978
  const allowed = new Set(["db", "json"]);
8893
8979
  for (const name of flags.keys()) {
@@ -8897,7 +8983,15 @@ function statusCommand(positional, flags, context) {
8897
8983
  if (positional.length > 0)
8898
8984
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
8899
8985
  const status = installationStatus(context.store, context.clock());
8900
- return succeed(context.write, context.json, command, { ...status }, () => renderInstallationStatus(status));
8986
+ // One line, only when a newer Toolroll exists; offline or switched off says nothing.
8987
+ const current = PACKAGE_VERSION;
8988
+ const method = installMethod(context.installBin);
8989
+ const release = newerRelease(await latestRelease(dirname(context.databaseFile), { now: context.clock, ...context.releaseIo }), current);
8990
+ if (release !== null)
8991
+ notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
8992
+ const line = updateLine(release, current, method.updateCommand);
8993
+ const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
8994
+ return succeed(context.write, context.json, command, { ...status, ...update }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line])]);
8901
8995
  }
8902
8996
  async function waitTask(positional, flags, context) {
8903
8997
  const command = "task wait";
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Sign-in pauses: when a provider's sign-in (or API key) stops working, its
3
+ * runs fail in seconds and every retry fails the same way. So an
4
+ * `auth-expired` run takes no strike and no retry: its task goes back to the
5
+ * queue, dispatch for THAT provider pauses (the others keep working), and a
6
+ * person is told once per incident, on every connected messaging channel, what
7
+ * to run. The pause lifts when a run or a sign-in probe on that provider
8
+ * succeeds, or when a person resumes it; one short message says so.
9
+ *
10
+ * One row per incident. At most one open incident per provider (a partial
11
+ * unique index), so however many runs hit it, one notification goes out:
12
+ * its key is `signin:auth-expired:<provider>:<ordinal>:<person>`, the ordinal
13
+ * being the incident's own number for that provider.
14
+ */
15
+ import type { Store } from "./store.js";
16
+ import { type ProviderId } from "./provider.js";
17
+ /** Every sign-in message's dedupe key starts here: `signin:<kind>:<provider>:<ordinal>:<person>`.
18
+ * Not a lifecycle (`life:`) key: those are progress chatter, and an undelivered
19
+ * one stays out of the attention list — this one needs a person. */
20
+ export declare const SIGN_IN_KEY_PREFIX = "signin:";
21
+ export declare const PROVIDER_AUTH_SCHEMA = "\nCREATE TABLE IF NOT EXISTS provider_auth_pause (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n provider TEXT NOT NULL,\n ordinal INTEGER NOT NULL,\n auth_mode TEXT NOT NULL DEFAULT 'subscription' CHECK (auth_mode IN ('subscription', 'api-key')),\n opened_at TEXT NOT NULL,\n first_run INTEGER,\n task_ref INTEGER,\n runs INTEGER NOT NULL DEFAULT 1,\n -- 1 once a sign-in probe during this pause said the provider is signed out:\n -- only a probe that then sees it signed in again may lift the pause.\n probe_saw_out INTEGER NOT NULL DEFAULT 0,\n lifted_at TEXT,\n lifted_by TEXT,\n lifted_how TEXT CHECK (lifted_how IS NULL OR lifted_how IN ('run', 'probe', 'person')),\n resumed INTEGER,\n -- The last task let through as a trial while paused: a sign-in check can\n -- say \"logged in\" for a session that no longer works, so a real run decides.\n last_trial_at TEXT,\n UNIQUE (provider, ordinal)\n);\nCREATE UNIQUE INDEX IF NOT EXISTS provider_auth_pause_open ON provider_auth_pause (provider) WHERE lifted_at IS NULL;\n";
22
+ export type AuthPause = {
23
+ id: number;
24
+ provider: ProviderId;
25
+ ordinal: number;
26
+ authMode: "subscription" | "api-key";
27
+ openedAt: string;
28
+ firstRun: number | null;
29
+ taskRef: number | null;
30
+ runs: number;
31
+ };
32
+ export declare function providerName(provider: string): string;
33
+ /** The plain reason: "Claude needs you to sign in again". */
34
+ export declare function signInReason(pause: Pick<AuthPause, "provider" | "authMode">): string;
35
+ /** What to run, as a command. */
36
+ export declare function signInCommand(pause: Pick<AuthPause, "provider" | "authMode">): string;
37
+ /** One line for status, ready and the console. */
38
+ export declare function signInWords(pause: AuthPause): string;
39
+ /** The open pause for a provider, or null. */
40
+ export declare function authPauseOf(store: Store, provider: string): AuthPause | null;
41
+ /**
42
+ * SQL: whether the pause `pause` (a provider_auth_pause alias) is on one of
43
+ * the task_ref `taskRef`'s current providers — its pins, its working profile,
44
+ * route legs and fallback entries — so a task re-routed to another provider
45
+ * stops saying it waits on the old one. A task that names none yet (an
46
+ * unpinned planner, whose agent comes from configuration) keeps what the gate
47
+ * found.
48
+ */
49
+ export declare function pauseOnTaskProviders(pause: string, taskRef: string): string;
50
+ /** The open pause the dispatch gate last left this task waiting on, while it
51
+ * is still on one of the task's providers; or null. */
52
+ export declare function authWaitOf(store: Store, taskRef: number): AuthPause | null;
53
+ /** Every open pause, oldest first. */
54
+ export declare function openAuthPauses(store: Store): AuthPause[];
55
+ /**
56
+ * Record an auth-expired run against its provider's pause, opening the
57
+ * incident (and its one notification) when none is open. Called inside the
58
+ * run's own failure transaction. Returns whether this run opened it.
59
+ */
60
+ export declare function pauseForAuth(store: Store, args: {
61
+ provider: string;
62
+ authMode: "subscription" | "api-key" | null;
63
+ runId: number;
64
+ taskRef: number;
65
+ now: Date;
66
+ }): {
67
+ opened: boolean;
68
+ pause: AuthPause;
69
+ };
70
+ /**
71
+ * Lift a provider's open pause — a run or probe on it succeeded, or a person
72
+ * resumed it — and send the one short "signed in again" message. Returns the
73
+ * number of tasks its incident sent back to the queue, or null when nothing
74
+ * was paused.
75
+ */
76
+ export declare function liftAuthPause(store: Store, provider: string, how: "run" | "probe" | "person", by: string, now: Date,
77
+ /** For a run: when it started. A run that began before the pause opened proves nothing about the sign-in now. */
78
+ runStartedAt?: Date): {
79
+ resumed: number;
80
+ pause: AuthPause;
81
+ } | null;
82
+ /**
83
+ * What a non-spending sign-in probe said. A probe lifts the pause only after
84
+ * one during the same pause saw the provider signed out: a CLI that reports
85
+ * "logged in" while its session cannot refresh must not lift and re-open the
86
+ * pause over and over.
87
+ */
88
+ export declare function noteSignInProbe(store: Store, provider: string, state: string, now: Date): {
89
+ resumed: number;
90
+ } | null;
91
+ /** How often a paused provider lets one task through as a trial. */
92
+ export declare const AUTH_TRIAL_MS: number;
93
+ /** A trial taken: the pause, when it was taken, and the trial time it replaced. */
94
+ export type AuthTrial = {
95
+ pauseId: number;
96
+ takenAt: string;
97
+ previous: string | null;
98
+ };
99
+ /**
100
+ * Whether this task may go ahead as the paused provider's trial: at most one
101
+ * every AUTH_TRIAL_MS. A trial that works lifts the pause (its run proves the
102
+ * sign-in); one that fails the same way only counts against the open
103
+ * incident, so nobody is told twice. Null when no trial is due.
104
+ */
105
+ export declare function claimAuthTrial(store: Store, pause: AuthPause, now: Date): AuthTrial | null;
106
+ /** Give a trial back when its task never started (its claim failed), so the next pass may take it. */
107
+ export declare function giveBackAuthTrial(store: Store, trial: AuthTrial): void;
108
+ /** What the sign-in gate decided: the open pause the work waits on, or null
109
+ * when it may go ahead; and, when it goes ahead as a pause's trial, how to
110
+ * give that trial back if its claim then fails. */
111
+ export type SignInGate = {
112
+ waiting: AuthPause | null;
113
+ giveBack: () => void;
114
+ };
115
+ /**
116
+ * The sign-in gate every road that starts an agent asks before it claims
117
+ * anything: the tick's queue, and the roads beside it (fallback entries,
118
+ * attended continuations, contest resumes). Waits when a provider is paused,
119
+ * unless this is the pause's one trial. A waiting task is noted on its
120
+ * task_ref, so the work index says exactly what the gate decided; a task let
121
+ * through is un-noted. A road whose claim fails after a trial calls giveBack.
122
+ */
123
+ export declare function signInGate(store: Store, providers: readonly string[], now: Date, taskRef?: number): SignInGate;
124
+ /** The loop beside the console: every two minutes, probe each paused
125
+ * provider's sign-in (no model, nothing spent) and lift the pause when it
126
+ * works again. Nothing is probed while nothing is paused. */
127
+ export declare function startSignInProbes(store: Store, check: (provider: ProviderId, fresh?: boolean) => Promise<{
128
+ state: string;
129
+ }>, everyMs?: number): () => void;
130
+ /** The console's view of every open pause: title, command, one action. */
131
+ export declare function signInNotices(store: Store): {
132
+ provider: string;
133
+ title: string;
134
+ command: string;
135
+ detail: string;
136
+ resumeLabel: string;
137
+ resumeHref: string;
138
+ }[];