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
@@ -241,6 +241,31 @@ export type BrowserSettingsView = {
241
241
  redirect: string | null;
242
242
  };
243
243
  } | null;
244
+ /** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
245
+ updates?: BrowserUpdates | null;
246
+ };
247
+ export type BrowserUpdates = {
248
+ current: string;
249
+ /** The latest release the last check found; null before any check has worked. */
250
+ latest: {
251
+ version: string;
252
+ newer: boolean;
253
+ security: boolean;
254
+ notes: string;
255
+ url: string;
256
+ } | null;
257
+ updateCommand: string;
258
+ /** The daily check: off by TOOLROLL_NO_UPDATE_CHECK (byEnv) or by this switch; only an operator may flip it. */
259
+ check: {
260
+ on: boolean;
261
+ byEnv: boolean;
262
+ canManage: boolean;
263
+ };
264
+ workers: {
265
+ name: string;
266
+ version: string | null;
267
+ older: boolean;
268
+ }[];
244
269
  };
245
270
  /** A fold on the task page. Its HTML is the server's own section body, so
246
271
  * forms, ids and page scripts are unchanged. */
@@ -754,6 +779,27 @@ export type BrowserWorkspace = {
754
779
  view?: BrowserView | null;
755
780
  /** A live page (Inbox, System…) reads itself again this often; forms being edited are kept. */
756
781
  refreshSeconds?: number;
782
+ /** Providers whose sign-in stopped working: their work waits (one per provider). */
783
+ signIn?: BrowserSignIn[];
784
+ /** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
785
+ update?: BrowserUpdateNotice;
786
+ };
787
+ /** The console's update notice: neutral, never the accent — an update does not need a person. */
788
+ export type BrowserUpdateNotice = {
789
+ version: string;
790
+ security: boolean;
791
+ href: string;
792
+ dismissHref: string;
793
+ };
794
+ /** A provider's sign-in pause, as the console shows it: the plain reason,
795
+ * what to run, and the one action that resumes its work. */
796
+ export type BrowserSignIn = {
797
+ provider: string;
798
+ title: string;
799
+ command: string;
800
+ detail: string;
801
+ resumeLabel: string;
802
+ resumeHref: string;
757
803
  };
758
804
  /** Safe inside a script[type=application/json] element. JSON escaping alone
759
805
  * does not stop the HTML parser from closing that element at </script>. */
package/dist/builder.d.ts CHANGED
@@ -165,7 +165,9 @@ export type BuildResult = {
165
165
  };
166
166
  export type BuildRefusal = "skills-unavailable"
167
167
  /** Sprint 8: the organisation policy stops this provider, model or permission level. */
168
- | "policy" | "unapproved" | "scope-changed" | "stale-approval" | "mode-ended" | "stale-authorization" | "attended-only" | "session-cap" | "attended-held" | "attended-unsupported" | "run-held" | "spawn-failed" | "capability" | "no-claim" | "not-yours" | "not-leased" | "protected-branch" | "wrong-branch" | "moved-branch" | "fenced" | "agent" | "agent-reported" | "no-op" | "moved-head" | "timeout" | "git" | "commit-failure" | "malformed-decision" | "provider-init" | "setup" | "revision-brief" | "external" | "stopped" | "plan-revised" | "plan-revision-blocked" | "provider-unattested" | "provider-protocol" | "chain-credential" | "chain-custody" | "runner-custody" | "auth-mode" | "route-authority" | "containment";
168
+ | "policy" | "unapproved" | "scope-changed" | "stale-approval" | "mode-ended" | "stale-authorization" | "attended-only" | "session-cap" | "attended-held" | "attended-unsupported" | "run-held" | "spawn-failed" | "capability" | "no-claim" | "not-yours" | "not-leased" | "protected-branch" | "wrong-branch" | "moved-branch" | "fenced" | "agent" | "agent-reported" | "no-op"
169
+ /** Run 2085: the agent stopped before its handoff; its work was kept for the retry. */
170
+ | "no-handoff" | "moved-head" | "timeout" | "git" | "commit-failure" | "malformed-decision" | "provider-init" | "setup" | "revision-brief" | "external" | "stopped" | "plan-revised" | "plan-revision-blocked" | "provider-unattested" | "provider-protocol" | "chain-credential" | "chain-custody" | "runner-custody" | "auth-mode" | "route-authority" | "containment";
169
171
  /** Long enough for real work; short enough that a stuck build ends the same night. */
170
172
  export declare const DEFAULT_BUILD_TIMEOUT_MS: number;
171
173
  export declare const DEFAULT_MAX_TURNS = 40;
@@ -351,6 +353,21 @@ export declare function settleProviderOutcome(captured: CapturedBuild, result: A
351
353
  * old name of every rename git paired (provenance, never a path). Anything
352
354
  * missing, failed, tampered or unparseable reads as not captured. */
353
355
  export declare function sealedDiffStatFacts(store: Store, root: string, statArtifactId: number): DiffStatFacts | null;
356
+ /**
357
+ * Read the mailbox, if the agent wrote one, and turn it into a package the
358
+ * caller can seal — or a problem list repair can work from.
359
+ *
360
+ * The payload is preserved as evidence *before* it is judged: a malformed
361
+ * park is still a person's best clue to what the agent meant, and the raw
362
+ * bytes leave the worktree either way — ingested once, then removed, so no
363
+ * later attempt can mistake them for its own agent's voice.
364
+ */
365
+ /** What a person reads when an attempt stopped before its handoff (run 2085). */
366
+ export declare const NO_HANDOFF_WORDS = "The agent stopped before handing off; its work was kept and it is being resumed.";
367
+ /** The body of the work-in-progress commit an unresumable no-handoff attempt leaves on its branch. */
368
+ export declare const WIP_COMMIT_WORDS = "Work in progress: the agent stopped before handing off. Kept so the next attempt continues from it.";
369
+ /** The short turn a stopped agent's own session is resumed with. */
370
+ export declare function handoffResumePrompt(done: string): string;
354
371
  /**
355
372
  * What a non-zero agent exit means, in words a person can act on: the
356
373
  * harness's own result line names the ending (a turn ceiling, an error
package/dist/builder.js CHANGED
@@ -35,14 +35,14 @@ import { PREPARED_EVIDENCE_FILE, PREPARED_EVIDENCE_GIT, preparedScreenshotMatche
35
35
  * watchdog and a high runaway-turn breaker still stop a pathological loop;
36
36
  * repair remains narrowly time-bounded because its job is narrowly scoped.
37
37
  */
38
- import { unlinkSync, writeFileSync } from "node:fs";
38
+ import { existsSync, unlinkSync, writeFileSync } from "node:fs";
39
39
  import { homedir } from "node:os";
40
40
  import { join } from "node:path";
41
41
  import { run, runOwnerTag } from "./exec.js";
42
42
  import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
43
43
  import { witnessedRunner } from "./process-custody.js";
44
44
  import { runWithIsolatedDatabase } from "./child-database.js";
45
- import { recordWorktreeProcess } from "./worktree.js";
45
+ import { recordWorktreeProcess, saveWorkPatch } from "./worktree.js";
46
46
  import { approvalOf, digestOf, profileDigestOf, chainDigestOf, entryDigestOf, routeParityProblem, profileFromJson } from "./scope.js";
47
47
  import { legOf, routeDigestOf, routeFromJson } from "./phase-routing.js";
48
48
  import { execFileSync } from "node:child_process";
@@ -50,7 +50,7 @@ import { currentClaim, finalizeRevisionFenced, heartbeat, SYNC_MAX_AGE_MS } from
50
50
  import { missingCapability } from "./dispatch.js";
51
51
  import { heartbeat as runnerHeartbeat } from "./runner.js";
52
52
  import { MARKER as LEASE_MARKER } from "./worktree.js";
53
- import { parseDecision, parseHandoff, repairPrompt, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
53
+ import { parseDecision, parseHandoff, repairPrompt, HEADLESS_RULE, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
54
54
  import { createHash, randomUUID } from "node:crypto";
55
55
  import { invokeAgent } from "./invoke.js";
56
56
  import { TELEGRAM_TOKEN_ENVS } from "./names.js";
@@ -1540,6 +1540,13 @@ export async function settleProviderOutcome(captured, result) {
1540
1540
  if (result.sessionId !== null) {
1541
1541
  store.stampRun(request.runId, { sessionId: result.sessionId });
1542
1542
  }
1543
+ // The unfinished handoff (run 2085): the agent ended its turn with work in
1544
+ // the tree and no handoff, park or proposal. Its own session is resumed in
1545
+ // this same worktree for a short turn to finish and hand off, before the
1546
+ // ordinary settlement below reads whatever that turn produced.
1547
+ const resumed = await resumeUnhandedWork(captured, result.sessionId ?? undefined);
1548
+ if (resumed !== null)
1549
+ return resumed;
1543
1550
  // The adaptive-execution-plan settlement, checked at exactly the point
1544
1551
  // park is: both are "stop without committing" endings, and both must be
1545
1552
  // decided before the handoff is required of the agent at all.
@@ -1631,6 +1638,11 @@ export async function settleProviderOutcome(captured, result) {
1631
1638
  // Missing or unremovable — either way the sweep and the commit-path
1632
1639
  // exclusions keep it out of anybody's repository.
1633
1640
  }
1641
+ if (!spoken.ok && spoken.missing) {
1642
+ const kept = await keepUnhandedWork(captured, result.sessionId ?? undefined);
1643
+ if (kept !== null)
1644
+ return kept;
1645
+ }
1634
1646
  if (!spoken.ok) {
1635
1647
  return {
1636
1648
  ok: false,
@@ -1757,7 +1769,8 @@ export async function settleProviderOutcome(captured, result) {
1757
1769
  return { ok: true, committed: false, noChange: true, branch, summary: handoff.conclusion };
1758
1770
  }
1759
1771
  // completed
1760
- if (dirty.length === 0) {
1772
+ const finishesKeptWork = dirty.length === 0 && await startsFromKeptWork(captured, baseRevision, pinnedBase);
1773
+ if (dirty.length === 0 && !finishesKeptWork) {
1761
1774
  return {
1762
1775
  ok: false,
1763
1776
  reason: "no-op",
@@ -1775,7 +1788,11 @@ export async function settleProviderOutcome(captured, result) {
1775
1788
  };
1776
1789
  }
1777
1790
  store.setRunPhase(request.runId, "committing");
1778
- const made = await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null);
1791
+ // Kept work that is already complete is already committed: the attempt's
1792
+ // result is that commit, sealed below against the task's original base.
1793
+ const made = finishesKeptWork
1794
+ ? await keptWorkResult(captured, baseRevision, handoff.conclusion)
1795
+ : await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null);
1779
1796
  if (made.ok && made.parked === undefined && made.committed) {
1780
1797
  const newHead = await git(GIT, ["--no-optional-locks", "rev-parse", "HEAD"], { cwd: worktree });
1781
1798
  if (newHead.code === 0) {
@@ -2442,6 +2459,160 @@ function screenshotFileTag(path) {
2442
2459
  * bytes leave the worktree either way — ingested once, then removed, so no
2443
2460
  * later attempt can mistake them for its own agent's voice.
2444
2461
  */
2462
+ /** What a person reads when an attempt stopped before its handoff (run 2085). */
2463
+ export const NO_HANDOFF_WORDS = "The agent stopped before handing off; its work was kept and it is being resumed.";
2464
+ /** The body of the work-in-progress commit an unresumable no-handoff attempt leaves on its branch. */
2465
+ export const WIP_COMMIT_WORDS = "Work in progress: the agent stopped before handing off. Kept so the next attempt continues from it.";
2466
+ /**
2467
+ * Whether this attempt starts from the work-in-progress commit an earlier
2468
+ * no-handoff attempt of this task left (keepUnhandedWork), with changes
2469
+ * against the task's original base. That commit's changes are the unfinished
2470
+ * work this attempt was sent to finish, so they count as its own: an agent
2471
+ * that finds them complete and hands off `completed` with a clean tree has
2472
+ * done the work, not claimed work it never did.
2473
+ */
2474
+ async function startsFromKeptWork(captured, baseRevision, pinnedBase) {
2475
+ const { store, request, git, worktree, branch } = captured;
2476
+ if (pinnedBase === baseRevision)
2477
+ return false;
2478
+ const kept = store.runsFor(request.taskRef).some(one => one.id < request.runId && one.role === "builder" && one.branch === branch && one.outcome === "failed" && one.reason === "no-handoff");
2479
+ if (!kept)
2480
+ return false;
2481
+ const message = await git(GIT, ["--no-optional-locks", "log", "-1", "--format=%B", baseRevision], { cwd: worktree });
2482
+ if (message.code !== 0 || !message.stdout.includes(WIP_COMMIT_WORDS))
2483
+ return false;
2484
+ // --quiet exits 1 exactly when the two sides differ.
2485
+ const changed = await git(GIT, ["--no-optional-locks", "diff", "--quiet", pinnedBase, baseRevision, "--"], { cwd: worktree });
2486
+ return changed.code === 1;
2487
+ }
2488
+ /** The accepted result of an attempt that finished kept work with nothing left to change. An exact prepared candidate must still match it. */
2489
+ async function keptWorkResult(captured, head, summary) {
2490
+ const { git, worktree, branch, request, scope } = captured;
2491
+ const candidate = request.attended === undefined ? scope?.candidate ?? null : null;
2492
+ if (candidate !== null) {
2493
+ const exact = await git(GIT, ["--no-optional-locks", "diff", "--quiet", candidate, head, "--"], { cwd: worktree });
2494
+ if (exact.code !== 0)
2495
+ return { ok: false, reason: "commit-failure", message: exact.code === 1
2496
+ ? "The kept work-in-progress commit does not match the approved prepared candidate. The checkout is preserved."
2497
+ : "The kept work-in-progress commit could not be checked against the approved prepared candidate. The checkout is preserved." };
2498
+ }
2499
+ return { ok: true, committed: true, branch, summary };
2500
+ }
2501
+ /** The short turn a stopped agent's own session is resumed with. */
2502
+ export function handoffResumePrompt(done) {
2503
+ return [
2504
+ `Your last turn ended before the handoff. Finish the task and write ${done}.`,
2505
+ "Every rule from the original brief still applies.",
2506
+ ...HEADLESS_RULE,
2507
+ ].join("\n");
2508
+ }
2509
+ /** The agent's work in the tree: protocol files and the lease note aside. Null when git cannot say. */
2510
+ async function unhandedChanges(git, worktree) {
2511
+ const status = await git(GIT, ["--no-optional-locks", "status", "--porcelain"], { cwd: worktree });
2512
+ if (status.code !== 0)
2513
+ return null;
2514
+ return status.stdout
2515
+ .split("\n")
2516
+ .filter(line => line.trim() !== "" &&
2517
+ !line.trimEnd().endsWith(LEASE_MARKER) &&
2518
+ !(line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3))));
2519
+ }
2520
+ /** Whether this attempt's own session can be resumed in place. */
2521
+ function canResumeSession(captured, sessionId) {
2522
+ return sessionId !== undefined && sessionId.trim() !== "" && auditOf(captured.request.provider ?? "claude").resume === "native";
2523
+ }
2524
+ /**
2525
+ * Resume the agent's own session when it ended its turn with changes but no
2526
+ * handoff, park or plan proposal (run 2085: tests left running in the
2527
+ * background, a wakeup scheduled, the headless process gone). Each turn is a
2528
+ * repair-role child run under the same admission and budget as a park
2529
+ * repair, in the same worktree, with the build's own limits: finishing the
2530
+ * task can need a full check run. Nothing here reads or accepts a handoff —
2531
+ * the ordinary settlement does that afterwards, unweakened.
2532
+ */
2533
+ async function resumeUnhandedWork(captured, sessionId) {
2534
+ const { store, request, agent, git, worktree, mailbox, done, effective, clock, taskId } = captured;
2535
+ const waiting = () => !existsSync(join(worktree, done)) &&
2536
+ !existsSync(join(worktree, mailbox)) &&
2537
+ (captured.plan === undefined || !existsSync(join(worktree, captured.plan.proposal)));
2538
+ // A watched (attended) session has an operator; nothing resumes it headless.
2539
+ if (request.attended !== undefined || !waiting() || !canResumeSession(captured, sessionId))
2540
+ return null;
2541
+ const changes = await unhandedChanges(git, worktree);
2542
+ if (changes === null || changes.length === 0)
2543
+ return null;
2544
+ const provider = request.provider ?? "claude";
2545
+ const model = repairModelOf(effective.profile, request);
2546
+ for (let turn = 0; turn < REPAIR_TURNS && waiting(); turn++) {
2547
+ if (stopRequestedFor(store, request.runId, request.shouldStop))
2548
+ return null;
2549
+ if (request.leaseId !== undefined && !heartbeat(store, request.leaseId, clock()).ok) {
2550
+ return { ok: false, reason: "fenced", message: `${taskId}'s lease did not survive the resumed turn — the work is still in ${worktree}` };
2551
+ }
2552
+ if (store.budgetGate(clock())({ ...store.budgetSubject(request.taskRef), agents: [{ provider, billing: store.runBilling(request.runId) ?? store.agentsFor([provider])[0].billing }] }).over !== null)
2553
+ break;
2554
+ if (store.agentPolicyRefusal(provider, model ?? null) !== null)
2555
+ break;
2556
+ const admitted = admitProtocolRepair(store, request, effective.profile, sessionId, clock);
2557
+ if (!admitted.ok)
2558
+ break;
2559
+ const spoken = await invokeAgent(store, admitted.runId, { provider, model }, {
2560
+ phase: "build",
2561
+ brief: handoffResumePrompt(done),
2562
+ maxTurns: effective.maxTurns ?? request.maxTurns ?? DEFAULT_MAX_TURNS,
2563
+ permissionMode: effective.profile.provider === "claude" && effective.profile.permissionArgv !== "bypassPermissions"
2564
+ ? effective.profile.permissionArgv
2565
+ : (request.permissionMode ?? "auto"),
2566
+ skipPermissions: effective.skipPermissions,
2567
+ resumeSession: sessionId,
2568
+ // The build's own spend cap holds for the turn that finishes it.
2569
+ ...(request.maxBudgetUsd === undefined ? {} : { maxBudgetUsd: request.maxBudgetUsd }),
2570
+ }, {
2571
+ cwd: worktree,
2572
+ ...(effective.profile.timeoutKind === "idle" ? { idleTimeoutMs: effective.timeoutMs } : { timeoutMs: effective.timeoutMs }),
2573
+ omitEnv: AGENT_ENV_DENYLIST,
2574
+ ...(agent === undefined ? {} : { runner: agent }),
2575
+ ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2576
+ clock,
2577
+ });
2578
+ const handedOff = !waiting();
2579
+ const ran = spoken.kind === "ran" && !spoken.outcome.timedOut && spoken.outcome.code === 0 && !spoken.outcome.initFailed;
2580
+ store.finishRun(admitted.runId, {
2581
+ outcome: handedOff && ran ? "built" : "failed",
2582
+ reason: spoken.kind === "refused" ? spoken.reason : handedOff ? "resumed-handoff" : ran ? "no-handoff" : spoken.outcome.timedOut ? "timeout" : "agent",
2583
+ now: clock(),
2584
+ });
2585
+ if (request.leaseId !== undefined && !heartbeat(store, request.leaseId, clock()).ok) {
2586
+ return { ok: false, reason: "fenced", message: `${taskId}'s lease did not survive the resumed turn — the work is still in ${worktree}` };
2587
+ }
2588
+ }
2589
+ return null;
2590
+ }
2591
+ /**
2592
+ * The attempt ended without a handoff, even after any resumed turn. When it
2593
+ * left changes, they are never discarded: first saved as a patch in this
2594
+ * run's evidence folder, then — when its session cannot be resumed — kept as
2595
+ * a work-in-progress commit on the branch the next attempt continues from.
2596
+ * Otherwise they stay in place, and the next lease keeps them for the retry.
2597
+ * Null when there was nothing to keep: the ordinary no-op words apply.
2598
+ */
2599
+ async function keepUnhandedWork(captured, sessionId) {
2600
+ const { store, request, git, worktree, branch, taskId, scope, root, clock } = captured;
2601
+ const changes = await unhandedChanges(git, worktree);
2602
+ if (changes === null || changes.length === 0)
2603
+ return null;
2604
+ const saved = await saveWorkPatch(git, worktree, join(root, String(request.runId), "unhanded-work.patch"), `# work left without a handoff in ${worktree}\n# kept ${clock().toISOString()}\n`);
2605
+ if (!saved.ok) {
2606
+ return { ok: false, reason: "no-handoff", message: `${NO_HANDOFF_WORDS} Its changes stay uncommitted in ${worktree}; ${saved.message}.` };
2607
+ }
2608
+ if (request.attended === undefined && !canResumeSession(captured, sessionId) && scope !== null && !stopRequestedFor(store, request.runId, request.shouldStop)) {
2609
+ const made = await commit(git, worktree, branch, taskId, scope, WIP_COMMIT_WORDS);
2610
+ if (made.ok && "committed" in made && made.committed) {
2611
+ return { ok: false, reason: "no-handoff", message: `${NO_HANDOFF_WORDS} It was saved as a work-in-progress commit on ${branch}.` };
2612
+ }
2613
+ }
2614
+ return { ok: false, reason: "no-handoff", message: NO_HANDOFF_WORDS };
2615
+ }
2445
2616
  async function ingestPark(args) {
2446
2617
  const { store, request, agent, git, worktree, mailbox, baseRevision, root } = args;
2447
2618
  const path = join(worktree, mailbox);
@@ -2958,6 +3129,7 @@ planRevision = null) {
2958
3129
  `- You are on branch ${branch}. Do not switch branches, and never commit to main.`,
2959
3130
  "- Do not push, open a pull request, or run any network write.",
2960
3131
  "- Stay inside this worktree.",
3132
+ ...HEADLESS_RULE,
2961
3133
  ...(revisionBrief === null ? [] : [
2962
3134
  "- If the sealed revision kind is evidence-observation, collect observations only.",
2963
3135
  " Keep every repository file and HEAD unchanged; do not run the full suite.",
@@ -595,12 +595,14 @@ export async function planChatNotifications(options) {
595
595
  if (notification.createdAt >= binding.created &&
596
596
  notification.resolvedAt === null &&
597
597
  // A flow decision for "anyone who approves" reaches every approver who can see the project.
598
- (personal ? notification.recipient === binding.approver : notification.taskId || notification.kind === "flow-decision") &&
599
- notification.project &&
600
- repos.includes(notification.project)) {
598
+ // A notification addressed to this person reaches them whatever project
599
+ // their channel follows (a sign-in pause is the installation's, v108).
600
+ (personal
601
+ ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
602
+ : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
601
603
  const run = store.telegramProgressRun(notification);
602
604
  const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
603
- if (run && notification.taskId && isTelegramProgressNotification(notification)) {
605
+ if (run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
604
606
  const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
605
607
  const content = {
606
608
  text: card.text,
package/dist/claim.d.ts CHANGED
@@ -350,7 +350,9 @@ export declare function finalizeMalformedFenced(store: Store, args: {
350
350
  reason: "fenced" | "unknown" | "stopped";
351
351
  };
352
352
  /** How a failed attempt is classified, per gnhf. Decides retry, backoff, or stall. */
353
- export type FailureClass = "agent-reported" | "retryable-infra" | "no-op" | "commit-failure" | "unknown";
353
+ export type FailureClass = "agent-reported" | "retryable-infra" | "no-op"
354
+ /** Run 2085: stopped before its handoff; the work was kept and the retry resumes it. Strikes like no-op. */
355
+ | "no-handoff" | "commit-failure" | "unknown";
354
356
  export declare const MAX_STRIKES = 3;
355
357
  export type FailureDisposition = {
356
358
  ok: true;
@@ -366,6 +368,10 @@ export type FailureDisposition = {
366
368
  ok: true;
367
369
  disposition: "commit-incident";
368
370
  incidentId: number;
371
+ } | {
372
+ ok: true;
373
+ disposition: "auth-expired";
374
+ provider: string;
369
375
  } | {
370
376
  ok: false;
371
377
  reason: "fenced" | "unknown" | "stopped";
@@ -580,6 +586,10 @@ export type PlanFailureDisposition = {
580
586
  disposition: "exhausted";
581
587
  incidentId: number;
582
588
  strikes: number;
589
+ } | {
590
+ ok: true;
591
+ disposition: "auth-expired";
592
+ provider: string;
583
593
  } | {
584
594
  ok: false;
585
595
  reason: "fenced" | "unknown" | "stopped";
@@ -652,6 +662,10 @@ export type ScoutFailureDisposition = {
652
662
  disposition: "stalled";
653
663
  incidentId: number;
654
664
  strikes: number;
665
+ } | {
666
+ ok: true;
667
+ disposition: "auth-expired";
668
+ provider: string;
655
669
  } | {
656
670
  ok: false;
657
671
  reason: "fenced" | "unknown" | "stopped";
package/dist/claim.js CHANGED
@@ -30,6 +30,7 @@
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { attendedLivenessState } from "./liveness.js";
32
32
  import { authenticate } from "./runner.js";
33
+ import { pauseForAuth } from "./provider-auth.js";
33
34
  import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
34
35
  /** attendedLivenessState over the store's ISO columns. */
35
36
  function attendedWatchState(lastBeatAt, now, absoluteExpiry) {
@@ -1059,6 +1060,11 @@ export function finalizeFailureFenced(store, args) {
1059
1060
  store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1060
1061
  return refusal(db, leaseId);
1061
1062
  }
1063
+ // A sign-in that no longer works is not the attempt's fault: no strike,
1064
+ // no retry, the task back in the queue, and its provider paused.
1065
+ if (failureClass !== "commit-failure" && run.terminalClass === "auth-expired") {
1066
+ return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
1067
+ }
1062
1068
  store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
1063
1069
  if (failureClass === "commit-failure") {
1064
1070
  // The work exists and is uncommitted; nothing times its way out of
@@ -1113,19 +1119,37 @@ export function finalizeFailureFenced(store, args) {
1113
1119
  taskRef: run.taskRef,
1114
1120
  ownerKind: "backoff",
1115
1121
  ownerId: String(run.taskRef),
1116
- reason: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
1122
+ // Run 2085: this failure is named in plain words wherever the hold shows.
1123
+ reason: failureClass === "no-handoff"
1124
+ ? `The agent stopped before handing off; its work was kept and it is being resumed (retry ${strikes}/${MAX_STRIKES} in ${Math.round(wait / 60_000)}m)`
1125
+ : `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
1117
1126
  until,
1118
1127
  }, now);
1119
1128
  store.enqueueNotification({
1120
1129
  source: { run: runId },
1121
1130
  dedupeKey: `run:${runId}:failed`,
1122
1131
  kind: "build-failed",
1123
- subject: `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
1132
+ subject: failureClass === "no-handoff"
1133
+ ? `${taskId}: the agent stopped before handing off; its work was kept, retry ${strikes}/${MAX_STRIKES}`
1134
+ : `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
1124
1135
  body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
1125
1136
  }, now);
1126
1137
  return { ok: true, disposition: "backoff", strikes, until: until.toISOString() };
1127
1138
  });
1128
1139
  }
1140
+ /**
1141
+ * The auth-expired ending, inside the caller's fenced transaction (claim
1142
+ * already released): the run fails as `auth-expired`, the task goes back to
1143
+ * the queue with no strike and no backoff, and dispatch for the run's
1144
+ * provider pauses — the first such run of an incident tells a person once.
1145
+ */
1146
+ function sealAuthExpired(store, run, taskId, now) {
1147
+ store.finishRun(run.id, { outcome: "failed", reason: "auth-expired", now });
1148
+ store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId);
1149
+ pauseForAuth(store, { provider: run.provider, authMode: run.authMode ?? null, runId: run.id, taskRef: run.taskRef, now });
1150
+ store.bumpWake();
1151
+ return { disposition: "auth-expired", provider: run.provider };
1152
+ }
1129
1153
  /** Untrusted text on its way into a subject line: one line, bounded. */
1130
1154
  function oneLine(text, cap) {
1131
1155
  const flat = text.replace(/\s+/g, " ").trim();
@@ -1479,6 +1503,8 @@ export function finalizePlanFailureFenced(store, args) {
1479
1503
  store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1480
1504
  return refusal(db, leaseId);
1481
1505
  }
1506
+ if (kind === "failure" && run.terminalClass === "auth-expired")
1507
+ return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
1482
1508
  const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
1483
1509
  store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
1484
1510
  if (kind === "malformed") {
@@ -1620,6 +1646,8 @@ export function finalizeScoutFailureFenced(store, args) {
1620
1646
  store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1621
1647
  return refusal(db, leaseId);
1622
1648
  }
1649
+ if (kind === "failure" && run.terminalClass === "auth-expired")
1650
+ return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
1623
1651
  const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
1624
1652
  store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
1625
1653
  if (kind === "malformed") {
@@ -37,7 +37,7 @@ export function controlSetupHtml(data) {
37
37
  `<p class="meta">Preparation applies to subsequent runs, including already approved tasks. Saving setup does not execute it.</p></details>` +
38
38
  `<button type="submit">Review project setup</button></form>` +
39
39
  `<section class="card"><h2>Agent instructions</h2>` +
40
- (data.instructions.message ? `<p>${escape(data.instructions.message)}</p>` : data.instructions.installed ? `<p>Current Toolroll instructions are installed.</p>` :
40
+ (data.instructions.message ? `<p>${escape(data.instructions.message)}</p>` : data.instructions.installed ? `<p>Toolroll instructions are installed.</p>` :
41
41
  `<p>Give the agent the project's Toolroll handoff instructions.</p><form method="post" action="/control/instructions-preview">${hiddenFields({ csrf: data.csrf, repo: data.repo })}<button>Review instructions</button></form>`) +
42
42
  `</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
43
43
  }
package/dist/daemon.d.ts CHANGED
@@ -158,6 +158,15 @@ export declare function stopLaunchdService(definition: ServiceDefinition, run: S
158
158
  * Installing the new one stops and removes it, so two watches never run.
159
159
  */
160
160
  export declare function installedLegacyDaemon(args: Parameters<typeof planDaemon>[0]): DaemonPlan | null;
161
+ /**
162
+ * Install the watch, then retire its copy from before the rename. The new
163
+ * job goes in first: when its install fails the legacy watch is left
164
+ * running, so the repo is never left without one. Only once the new job is
165
+ * installed is the legacy one stopped and removed; the new one is then
166
+ * started again, since it may have stepped aside while the legacy watch
167
+ * still held the repo's lease.
168
+ */
169
+ export declare function installReplacingLegacy(plan: DaemonPlan, token: string, legacy: DaemonPlan | null, run: SupervisorRunner): Promise<ServiceStart>;
161
170
  /** Write the token 0600 and the unit, then hand the unit to the supervisor. */
162
171
  export declare function installDaemon(plan: DaemonPlan, token: string, run: SupervisorRunner): Promise<ServiceStart>;
163
172
  export declare function uninstallDaemon(plan: DaemonPlan, run: SupervisorRunner): Promise<{
package/dist/daemon.js CHANGED
@@ -404,6 +404,27 @@ export function installedLegacyDaemon(args) {
404
404
  const legacy = planDaemon({ ...args, label: legacyLabelFor(args.repo) });
405
405
  return "error" in legacy || !existsSync(legacy.unitPath) ? null : legacy;
406
406
  }
407
+ /**
408
+ * Install the watch, then retire its copy from before the rename. The new
409
+ * job goes in first: when its install fails the legacy watch is left
410
+ * running, so the repo is never left without one. Only once the new job is
411
+ * installed is the legacy one stopped and removed; the new one is then
412
+ * started again, since it may have stepped aside while the legacy watch
413
+ * still held the repo's lease.
414
+ */
415
+ export async function installReplacingLegacy(plan, token, legacy, run) {
416
+ const installed = await installDaemon(plan, token, run);
417
+ if (!installed.ok || legacy === null)
418
+ return installed;
419
+ try {
420
+ await uninstallDaemon(legacy, run);
421
+ }
422
+ catch (error) {
423
+ return { ok: false, message: `installed ${plan.label}, but could not stop ${legacy.label}, the same watch under its older name: ${error.message}` };
424
+ }
425
+ const restarted = await installDaemon(plan, token, run);
426
+ return restarted.ok ? installed : restarted;
427
+ }
407
428
  /** Write the token 0600 and the unit, then hand the unit to the supervisor. */
408
429
  export async function installDaemon(plan, token, run) {
409
430
  writeFileSync(plan.tokenFile, `${token}\n`, { mode: 0o600 });
@@ -117,4 +117,10 @@ export declare function parseHandoff(raw: string): {
117
117
  * then the one instruction. Written for an agent that already holds the
118
118
  * context — it needs the list of what failed, not the theory of why.
119
119
  */
120
+ /**
121
+ * Every unattended agent runs headless (run 2085): its turn ending IS the
122
+ * process exiting, so a command left in the background, a wakeup or a loop
123
+ * never gets a second turn — the attempt just ends without its handoff.
124
+ */
125
+ export declare const HEADLESS_RULE: readonly string[];
120
126
  export declare function repairPrompt(problems: readonly Problem[], mailbox: string): string;
package/dist/decision.js CHANGED
@@ -374,6 +374,17 @@ export function parseHandoff(raw) {
374
374
  * then the one instruction. Written for an agent that already holds the
375
375
  * context — it needs the list of what failed, not the theory of why.
376
376
  */
377
+ /**
378
+ * Every unattended agent runs headless (run 2085): its turn ending IS the
379
+ * process exiting, so a command left in the background, a wakeup or a loop
380
+ * never gets a second turn — the attempt just ends without its handoff.
381
+ */
382
+ export const HEADLESS_RULE = [
383
+ "- You run headless: run every command in the foreground and wait for it,",
384
+ " however long it takes. Never background a command to wait on it, and",
385
+ " never schedule a wakeup, cron job, monitor or loop. Your turn ending",
386
+ " ends the run, so write the handoff before you stop.",
387
+ ];
377
388
  export function repairPrompt(problems, mailbox) {
378
389
  return [
379
390
  `Your parked decision in ${mailbox} failed validation:`,
@@ -384,5 +395,6 @@ export function repairPrompt(problems, mailbox) {
384
395
  "JSON: { urgency: \"blocking\", recap, question, options: [{ id, label,",
385
396
  "consequence, reversible }], recommendation } — at least two options, every",
386
397
  "option's reversible stated explicitly, recommendation naming an option id.",
398
+ ...HEADLESS_RULE,
387
399
  ].join("\n");
388
400
  }
@@ -65,6 +65,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
65
65
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
66
66
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
67
67
  export declare function desktopUpdateStatus(stateDir: string): {
68
+ version?: never;
68
69
  active: boolean;
69
70
  phase: string;
70
71
  detail: string;
@@ -78,7 +79,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
78
79
  wasRunning?: never;
79
80
  backupPath?: never;
80
81
  updateId?: never;
81
- version?: never;
82
82
  buildId?: never;
83
83
  workDir?: never;
84
84
  } | {
@@ -37,7 +37,9 @@ export type DispatchDiagnosisCode = "updating" | "complete" | "needs-verificatio
37
37
  /** v50: every root review attempt ended without a review; nothing retries. */
38
38
  | "review-exhausted" | "cancelled" | "failed" | "running" | "vanished-run" | "retry-scheduled" | "waiting-decision" | "waiting-incident" | "held"
39
39
  /** v52: an operator stopped one exact attempt; only resuming it lifts the pause. */
40
- | "stopped" | "waiting-dependency" | "terminal-dependency" | "needs-project" | "needs-scope" | "needs-agent-profile" | "needs-approval" | "missing-requirement" | "no-worker-registered" | "no-worker-online" | "worker-at-capacity" | "provider-quota" | "planning-ready" | "planner-source" | "scouting-ready" | "ready";
40
+ | "stopped" | "waiting-dependency" | "terminal-dependency" | "needs-project" | "needs-scope" | "needs-agent-profile" | "needs-approval" | "missing-requirement" | "no-worker-registered" | "no-worker-online" | "worker-at-capacity" | "provider-quota"
41
+ /** The provider's sign-in stopped working; its dispatch is paused. */
42
+ | "signed-out" | "planning-ready" | "planner-source" | "scouting-ready" | "ready";
41
43
  export type DispatchDiagnosis = {
42
44
  /** The four-state Never Stuck contract. */
43
45
  condition: "running" | "retrying" | "waiting" | "terminal";
package/dist/dispatch.js CHANGED
@@ -10,6 +10,7 @@ import { isAlive } from "./runner.js";
10
10
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
11
11
  import { approvalOf } from "./scope.js";
12
12
  import { plannerSourceProblemOf } from "./planner-source.js";
13
+ import { authPauseOf, authWaitOf, providerName, signInReason } from "./provider-auth.js";
13
14
  import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js";
14
15
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
15
16
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
@@ -222,6 +223,11 @@ export function diagnoseTaskDispatch(store, taskId, now) {
222
223
  const local = taskReadinessBlocker(store, ref.id, now);
223
224
  if (local?.code === "hold") {
224
225
  if (local.ownerKind === "backoff") {
226
+ // Run 2085: an attempt that stopped before its handoff says so plainly.
227
+ const last = store.runsFor(ref.id).find(one => one.role === "builder" && one.finishedAt !== null);
228
+ const when = local.until === null ? "" : ` The next attempt is eligible at ${local.until}.`;
229
+ if (last?.reason === "no-handoff")
230
+ return answer("retry-scheduled", "retrying", "Retry scheduled", `The agent stopped before handing off; its work was kept and it is being resumed.${when}`, { nextAt: local.until });
225
231
  return answer("retry-scheduled", "retrying", "Retry scheduled", local.until === null ? "The failed attempt is backing off before retrying." : `The next attempt is eligible at ${local.until}.`, { nextAt: local.until });
226
232
  }
227
233
  if (local.ownerKind === "decision")
@@ -281,6 +287,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
281
287
  return answer("provider-quota", "retrying", "Provider quota exhausted", nextAt === null ? "The provider has not supplied a reset time; this needs operator attention." : `The next recorded reset is ${nextAt}.`, { action: nextAt === null ? "select-agent" : null, nextAt, role });
282
288
  }
283
289
  }
290
+ // The sign-in pause: the task's provider stopped working; nothing it would
291
+ // run starts until someone signs in (or a run or check on it works again).
292
+ const provider = profile?.provider ?? (role === "planner" ? ref.planProvider : null) ?? null;
293
+ const signedOut = authWaitOf(store, ref.id) ?? (provider === null ? null : authPauseOf(store, provider));
294
+ if (signedOut !== null) {
295
+ return answer("signed-out", "waiting", signInReason(signedOut), `This task starts again on its own once ${providerName(signedOut.provider)} works.`, { role });
296
+ }
284
297
  const openDecisions = store.countUnanswered();
285
298
  if (openDecisions >= DEFAULT_MAX_OPEN_DECISIONS && (role === "planner" || ref.parkRate > 0)) {
286
299
  return answer("waiting-decision", "waiting", "Decision queue is full", `${openDecisions} decisions already wait; answer some before this task may add another.`, { action: "answer-decision", role });