toolroll 0.9.60 → 0.9.61

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 (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -12,6 +12,8 @@ export function browserWorkActionHref(item) {
12
12
  return '/';
13
13
  if (action.code === 'sign-in')
14
14
  return '/settings#providers';
15
+ if (action.code === 'answer-agent' && runId !== null)
16
+ return `/agents?run=${runId}`;
15
17
  if (action.code === 'confirm-stopped')
16
18
  return `/t/${encodeURIComponent(item.rootId)}#confirm-stopped`;
17
19
  if (action.code === 'open-result' && runId !== null)
@@ -440,6 +440,16 @@ export type BrowserTaskView = {
440
440
  action: string;
441
441
  run: number;
442
442
  } | null;
443
+ /** Pause and Pause and steer, beside Stop: the same exact live Claude build, one request id per form (Pause, the note,
444
+ * Resume), where its latest pause stands (null: running), and until when a paused build can wait before it ends. */
445
+ pause?: {
446
+ action: string;
447
+ run: number;
448
+ state: "pausing" | "paused" | null;
449
+ requests: [string, string, string];
450
+ until?: string | null;
451
+ limitMs?: number | null;
452
+ } | null;
443
453
  /** The Building card's link to the live build's own record ("Build #N record", /r/<id>). */
444
454
  record?: BrowserLink | null;
445
455
  earlier?: {
@@ -1118,7 +1128,75 @@ export type BrowserFlowView = {
1118
1128
  }[];
1119
1129
  colors: string[];
1120
1130
  };
1121
- export type BrowserView = BrowserTasksView | BrowserSettingsView | BrowserTaskView | BrowserProjectsView | BrowserResultView | BrowserFlowView;
1131
+ /** The Agents board's groups, in order (agents-board.ts). */
1132
+ export type AgentGroupKey = 'needs-you' | 'working' | 'stuck' | 'done' | 'idle';
1133
+ /** One agent: its task, project, last activity line, elapsed time and diff size. */
1134
+ export type BrowserAgentRow = {
1135
+ runId: number;
1136
+ taskId: string;
1137
+ title: string;
1138
+ project: string | null;
1139
+ href: string;
1140
+ /** Its one line: what it is doing, its question, or why it ended; null when there is nothing the state chip doesn't say. */
1141
+ group: AgentGroupKey;
1142
+ label: string;
1143
+ line: string | null;
1144
+ quiet: boolean;
1145
+ /** When it started, and when it ended (null: still running): the row counts the time between. */
1146
+ startedAt: string;
1147
+ endedAt: string | null;
1148
+ /** "3 files", "+12 −4 · 3 files"; null when nothing could be measured. */
1149
+ diff: string | null;
1150
+ agent: string;
1151
+ };
1152
+ export type BrowserAgentQuestion = {
1153
+ id: number;
1154
+ key: string;
1155
+ text: string;
1156
+ action: string;
1157
+ };
1158
+ export type BrowserAgentDetail = BrowserAgentRow & {
1159
+ live: boolean;
1160
+ question: BrowserAgentQuestion | null;
1161
+ /** The live transcript's JSON window (/r/<run>?fragment=transcript); null when this console can't read it. */
1162
+ transcript: string | null;
1163
+ /** The live changes fragment (/r/<run>?fragment=peek) while it runs; the saved changes once it has finished. */
1164
+ peek: string | null;
1165
+ files: {
1166
+ path: string;
1167
+ added: number | null;
1168
+ removed: number | null;
1169
+ }[];
1170
+ filesMore: number;
1171
+ /** Steer the running agent (POST /t/<task>/steer): null once it has finished. */
1172
+ steer: {
1173
+ action: string;
1174
+ run: number;
1175
+ } | null;
1176
+ /** Whether this person may reply or steer (an approver, outside the demo); a viewer sees the question but no composer. */
1177
+ canAct: boolean;
1178
+ /** The task, and once finished the run's own page (its result page when it built one). */
1179
+ taskHref: string;
1180
+ resultHref: string | null;
1181
+ /** The exact-run live room: updates, and a watch that keeps this person's pings quiet. */
1182
+ room: string;
1183
+ said: {
1184
+ tone: 'info' | 'error';
1185
+ text: string;
1186
+ } | null;
1187
+ };
1188
+ export type BrowserAgentsView = {
1189
+ kind: 'agents';
1190
+ groups: {
1191
+ key: AgentGroupKey;
1192
+ label: string;
1193
+ rows: BrowserAgentRow[];
1194
+ }[];
1195
+ selected: BrowserAgentDetail | null;
1196
+ /** Shown when nothing is on the board. */
1197
+ empty: string | null;
1198
+ };
1199
+ export type BrowserView = BrowserTasksView | BrowserSettingsView | BrowserTaskView | BrowserProjectsView | BrowserResultView | BrowserFlowView | BrowserAgentsView;
1122
1200
  export type BrowserWorkspace = {
1123
1201
  version: 1;
1124
1202
  path: string;
@@ -42,6 +42,7 @@ export function browserNavigationOf(path, project = null, needsYou = 0, tasksPro
42
42
  { label: 'Tasks', href: `/work${tasksProject === null ? '' : `?project=${encodeURIComponent(tasksProject)}`}`,
43
43
  active: pathname === '/work' || pathname === '/tasks' || pathname.startsWith('/t/') || pathname.startsWith('/r/'),
44
44
  ...(needsYou > 0 ? { count: needsYou, ...(needsYouLabel === undefined ? {} : { countLabel: needsYouLabel }) } : {}) },
45
+ { label: 'Agents', href: '/agents', active: pathname === '/agents' || pathname === '/peek' },
45
46
  { label: 'Flows', href: '/flows', active: pathname === '/flows' || pathname.startsWith('/flows/') },
46
47
  { label: 'Projects', href: '/projects', active: pathname === '/projects' },
47
48
  { label: 'Knowledge', href: `/settings/knowledge${project === null ? '' : `?repo=${encodeURIComponent(project)}`}`, active: knowledge },
package/dist/builder.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { Decision, Store } from "./store.js";
4
4
  import { type ExecutionProfile, type Scope } from "./scope.js";
5
5
  import { type ParsedDecision, type Problem } from "./decision.js";
6
6
  import { type AgentOutcome } from "./invoke.js";
7
+ import { type LiveCloseReason } from "./live-input.js";
7
8
  import { type DiffStatFacts } from "./proof.js";
8
9
  import { type AuthoritySnapshot, type MilestoneState } from "./plan.js";
9
10
  export type Runner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
@@ -55,6 +56,8 @@ export type BuildRequest = {
55
56
  clock?: () => Date;
56
57
  /** How often the pulse beats while the agent runs. 0 disables it. */
57
58
  pulseMs?: number;
59
+ /** Live steering's dispatcher interval (steer-live.ts); tests shorten it. */
60
+ steerTickMs?: number;
58
61
  worktree: string;
59
62
  branch: string;
60
63
  now: Date;
@@ -268,6 +271,11 @@ export type CapturedBuild = {
268
271
  * one) settles exactly as it always has, and a caller assembling a
269
272
  * capture by hand need not know this protocol exists. */
270
273
  plan?: PlanBinding;
274
+ /** A Claude build's live input (live-input.ts): why Toolroll closed its stdin, and its total turn budget. */
275
+ live?: {
276
+ close: LiveCloseReason | null;
277
+ maxTurns: number;
278
+ };
271
279
  };
272
280
  /** What settlement needs to know about the plan this attempt built against. */
273
281
  export type PlanBinding = {
package/dist/builder.js CHANGED
@@ -42,7 +42,7 @@ import { PREPARED_EVIDENCE_FILE, PREPARED_EVIDENCE_GIT, preparedScreenshotMatche
42
42
  import { existsSync, unlinkSync, writeFileSync } from "node:fs";
43
43
  import { homedir } from "node:os";
44
44
  import { join } from "node:path";
45
- import { run, runOwnerTag } from "./exec.js";
45
+ import { run, runClaudeStreamJsonl, runOwnerTag, streamsInput } from "./exec.js";
46
46
  import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
47
47
  import { witnessedRunner } from "./process-custody.js";
48
48
  import { runWithIsolatedDatabase } from "./child-database.js";
@@ -61,10 +61,15 @@ import { TELEGRAM_TOKEN_ENVS } from "./names.js";
61
61
  import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
62
62
  import { openLiveLog } from "./live.js";
63
63
  import { fence } from "./brief-fence.js";
64
- import { closeSteerMailbox, openSteerMailbox, pulseSteerMailbox, STEER_HANDLED } from "./steer-mailbox.js";
64
+ import { closeSteerMailbox, openSteerMailbox, STEER_HANDLED } from "./steer-mailbox.js";
65
+ import { LiveInput } from "./live-input.js";
66
+ import { LIVE_TICK_MS, tickLiveSteer } from "./steer-live.js";
65
67
  import { withRunActivity } from "./task-activity.js";
66
68
  import { CheckProgressTracker } from "./check-progress.js";
67
- import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, steerFolderName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
69
+ import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, steerFolderName, statusFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
70
+ import { attemptKeyOf, closeRunQuestions, noteOpenTool, recordAgentTurn } from "./agent-board.js";
71
+ import { openStatusFile, pulseAgentBoard, removeStatusFile, statusBrief } from "./agent-pulse.js";
72
+ import { withAgentTurns } from "./agent-turns.js";
68
73
  import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
69
74
  import { parseHandoffArtifact } from "./contracts/handoff.js";
70
75
  import { captureReviewContext } from "./review-context.js";
@@ -931,7 +936,36 @@ export async function build(store, request) {
931
936
  // Notes are mailed only while the agent process runs: one written after
932
937
  // it exits could never be read, and would only wait for the next attempt.
933
938
  let agentLive = false;
939
+ // A Claude build's live input (live-input.ts): the brief goes in as the
940
+ // first stdin message, and notes and pauses follow it in seconds. The
941
+ // gateway attaches it only through a transport that keeps stdin open.
942
+ let liveSession = null;
943
+ let liveTimer;
944
+ // The Agents board rides the same pulse (agent-pulse.ts): the agent's status file, its transcript's size, the
945
+ // changed-file count and the Stuck mark. Null until the agent is about to start, and after it has exited.
946
+ let agentPulse = null;
947
+ const closeAgentPulse = () => {
948
+ if (agentPulse === null)
949
+ return;
950
+ const last = agentPulse;
951
+ agentPulse = null;
952
+ // One last read of what the agent wrote, then its status file goes, like the steering folder.
953
+ try {
954
+ pulseAgentBoard(store, { ...last, looking: true }, clock());
955
+ }
956
+ catch { /* display state */ }
957
+ removeStatusFile(last.status);
958
+ // Its questions close unanswered: a reply now could only reach a later attempt, as ordinary steering does.
959
+ try {
960
+ closeRunQuestions(store, last.run, clock());
961
+ }
962
+ catch { /* the run's ending closes them too */ }
963
+ };
934
964
  const closeSteering = () => {
965
+ if (liveTimer !== undefined)
966
+ clearInterval(liveTimer);
967
+ liveTimer = undefined;
968
+ liveSession?.detach();
935
969
  if (steerBox === null)
936
970
  return;
937
971
  const box = steerBox;
@@ -940,6 +974,10 @@ export async function build(store, request) {
940
974
  store.markSteerFolderClosed(request.runId, clock());
941
975
  }
942
976
  catch { /* display state; the run's outcome ends it */ }
977
+ try {
978
+ store.markLiveInputClosed(request.runId, clock());
979
+ }
980
+ catch { /* display state; the run's outcome ends it */ }
943
981
  closeSteerMailbox(store, box, clock());
944
982
  };
945
983
  if (request.leaseId !== undefined && pulseMs > 0) {
@@ -978,14 +1016,14 @@ export async function build(store, request) {
978
1016
  catch {
979
1017
  // A checkpoint is never worth an attempt. The next beat retries.
980
1018
  }
981
- // Steering, under the same rule: what the agent moved to handled/
982
- // is recorded read; a note filed since the last beat is mailed.
983
- if (steerBox !== null) {
1019
+ // Steering has its own faster beat (steer-live.ts tickLiveSteer, below), which mails and delivers notes —
1020
+ // an Agents-board Reply among them, as an ordinary steering note. The board's display state rides this one.
1021
+ if (agentPulse !== null) {
984
1022
  try {
985
- pulseSteerMailbox(store, steerBox, clock(), agentLive);
1023
+ pulseAgentBoard(store, agentPulse, clock());
986
1024
  }
987
1025
  catch {
988
- // Unread notes stay pending; the next beat, or the next attempt, carries them.
1026
+ // The board is display state: the next beat reads again.
989
1027
  }
990
1028
  }
991
1029
  }
@@ -1076,13 +1114,48 @@ export async function build(store, request) {
1076
1114
  // The steering folder exists before the brief names it, and only on the
1077
1115
  // agent road: a prepared candidate spawns no agent to read it.
1078
1116
  steerBox = steerable && (scope?.candidate ?? null) === null ? openSteerMailbox(worktree, steerFolder, request.runId, taskRef, randomBytes(8).toString("hex")) : null;
1079
- if (steerBox !== null)
1117
+ if (steerBox !== null) {
1080
1118
  store.markSteerFolderOpened(request.runId, clock());
1119
+ // Only through a transport that keeps stdin open: an injected runner opts in (exec.ts STREAM_INPUT).
1120
+ if (provider === "claude" && streamsInput(agent ?? runClaudeStreamJsonl)) {
1121
+ const handoffFiles = [done, mailbox, proposal];
1122
+ liveSession = new LiveInput({
1123
+ maxTurns: effective.maxTurns ?? maxTurns,
1124
+ finished: () => handoffFiles.some(name => existsSync(join(worktree, name))),
1125
+ // A paused build can wait as long as the idle limit allows (live-input.ts idleLimit); a wall-clock profile has none.
1126
+ onOpen: () => store.markLiveInputOpened(request.runId, clock(), effective.profile.timeoutKind === "idle" ? effective.timeoutMs : null),
1127
+ onClose: () => store.markLiveInputClosed(request.runId, clock()),
1128
+ onPaused: () => store.markRunPaused(request.runId, clock()),
1129
+ });
1130
+ }
1131
+ // Steering's own beat, independent of the lease pulse: a note is in the
1132
+ // folder (and on a Claude build's stdin) within a tick of being filed.
1133
+ // It rides the pulse's rule: a fenced build writes nothing more.
1134
+ const steer = { box: steerBox, session: liveSession };
1135
+ liveTimer = setInterval(() => {
1136
+ if (fencedMidBuild || steerBox === null)
1137
+ return;
1138
+ try {
1139
+ tickLiveSteer(store, steer, clock(), agentLive);
1140
+ }
1141
+ catch {
1142
+ // Unread notes stay pending; the next tick, or the next attempt, carries them.
1143
+ }
1144
+ }, request.steerTickMs ?? LIVE_TICK_MS);
1145
+ liveTimer.unref?.();
1146
+ }
1147
+ // The Agents board (agent-board.ts): turn states recorded under this run's attempt key, and a status file the
1148
+ // agent appends to, read on the pulse. A legacy run with no key records nothing; a prepared candidate runs no agent.
1149
+ const attemptKey = attemptKeyOf(store, request.runId);
1150
+ const statusName = attemptKey !== null && steerable && (scope?.candidate ?? null) === null ? statusFileName() : null;
1151
+ const statusPath = statusName === null ? null : openStatusFile(worktree, statusName);
1081
1152
  // The live window (arc 1): display state beside the run, never evidence.
1082
1153
  // Every streaming transport emits events now (peek); a file that cannot
1083
1154
  // open is a null, and a null never costs a build.
1084
1155
  // What the agent did last rides the same stream into one row (live tasks).
1085
- const liveLog = withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock);
1156
+ const liveLog = withAgentTurns(withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock), (state, at) => { if (attemptKey !== null)
1157
+ recordAgentTurn(store, { run: request.runId, attemptKey, state, at }); }, clock, (open, at) => { if (attemptKey !== null)
1158
+ noteOpenTool(store, request.runId, attemptKey, open, at); });
1086
1159
  // The retry base (steering fix for run 1465's proof): the SAME pinned
1087
1160
  // base the machine will use to capture the sealed diff (settleProof
1088
1161
  // below, mirroring store.firstBuilderBase's own doc). Null on a first
@@ -1101,7 +1174,8 @@ export async function build(store, request) {
1101
1174
  // replacement for.
1102
1175
  milestones.length === 0 || planRevisionHash === null
1103
1176
  ? null
1104
- : { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal), steerBox === null ? null : { folder: steerFolder, key: steerBox.key }) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n` + sharedDepsBrief(store, worktree);
1177
+ : { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal), steerBox === null ? null : { folder: steerFolder, key: steerBox.key, live: liveSession !== null }) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n` + sharedDepsBrief(store, worktree)
1178
+ + (statusPath === null || statusName === null ? "" : statusBrief(statusName, steerBox === null ? null : steerFolder));
1105
1179
  // THE PREPARED-CANDIDATE ROAD (v69): the scope names a commit, proved
1106
1180
  // above before anything moved. The machine brings the worktree to its exact
1107
1181
  // tree — uncommitted, as an agent would leave it — writes the handoff
@@ -1187,6 +1261,9 @@ export async function build(store, request) {
1187
1261
  }
1188
1262
  }
1189
1263
  let invoked;
1264
+ let spawned = false;
1265
+ if (attemptKey !== null && statusPath !== null)
1266
+ agentPulse = { run: request.runId, attemptKey, root, worktree, status: statusPath, base: undefined, looking: false };
1190
1267
  try {
1191
1268
  agentLive = true;
1192
1269
  invoked = await invokeAgent(store, request.runId,
@@ -1211,6 +1288,7 @@ export async function build(store, request) {
1211
1288
  ...(steerBox === null ? {} : { steerFolder: { dir: steerBox.dir, name: steerFolder } }),
1212
1289
  }, {
1213
1290
  cwd: worktree,
1291
+ ...(liveSession === null ? {} : { liveInput: liveSession }),
1214
1292
  // New approvals bind a no-progress watchdog, not a deadline. Legacy
1215
1293
  // snapshots carry no timeoutKind and retain their wall-clock terms.
1216
1294
  ...(effective.profile.timeoutKind === "idle"
@@ -1218,7 +1296,7 @@ export async function build(store, request) {
1218
1296
  : { timeoutMs: effective.timeoutMs }),
1219
1297
  omitEnv: AGENT_ENV_DENYLIST,
1220
1298
  ...(agent === undefined ? {} : { runner: agent }),
1221
- ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
1299
+ onSpawn: (pid) => { spawned = true; request.onProviderSpawn?.(pid); },
1222
1300
  onStreamEvent: (event) => liveLog.observe(event),
1223
1301
  // The receipt (finding 8): the stream proved the prompt reached the
1224
1302
  // agent — settle delivery NOW, durably, whatever happens to the run
@@ -1230,9 +1308,12 @@ export async function build(store, request) {
1230
1308
  }
1231
1309
  catch (error) {
1232
1310
  agentLive = false;
1311
+ if (spawned)
1312
+ liveLog.ended();
1233
1313
  if (pulseTimer !== undefined)
1234
1314
  clearInterval(pulseTimer);
1235
1315
  closeSteering();
1316
+ closeAgentPulse();
1236
1317
  liveLog?.close();
1237
1318
  try {
1238
1319
  unlinkSync(join(worktree, rubric));
@@ -1244,7 +1325,11 @@ export async function build(store, request) {
1244
1325
  // the folder goes — before settlement, whose stop and timeout roads sweep
1245
1326
  // protocol files. Anything unread stays pending for the next attempt.
1246
1327
  agentLive = false;
1328
+ // A process that ended mid-turn, without its result, crashed (the board says so; the outcome is settlement's).
1329
+ if (spawned)
1330
+ liveLog.ended();
1247
1331
  closeSteering();
1332
+ closeAgentPulse();
1248
1333
  try {
1249
1334
  // The gateway's value-shaped refusals (Phase 3 B5): a race past the
1250
1335
  // pre-claim skip, or the harness breaking its own protocol. Both dispose
@@ -1266,6 +1351,7 @@ export async function build(store, request) {
1266
1351
  runner, provider, scope, effective, answers, timeoutMs, root, mailbox, done, proof, rubric,
1267
1352
  clock, fenced: () => fencedMidBuild,
1268
1353
  plan: { proposal, revision: planRevisionNumber, authority, progress: progressState },
1354
+ ...(liveSession === null ? {} : { live: { close: liveSession.closeReason, maxTurns: liveSession.maxTurns } }),
1269
1355
  };
1270
1356
  return await settleProviderOutcome(captured, result);
1271
1357
  }
@@ -1420,9 +1506,16 @@ export async function settleProviderOutcome(captured, result) {
1420
1506
  message: `the provider harness never initialized — ${firstLine(result.stderr) || `exit ${result.code}`}`,
1421
1507
  };
1422
1508
  }
1423
- if (result.code !== 0) {
1509
+ // Toolroll ended a live session itself (live-input.ts): its turn budget was spent, or a paused session reached the
1510
+ // idle limit. The harness's last result is then the interrupt, not the agent's verdict, so settlement reads the
1511
+ // handoff as it would after any ending; a spent budget with nothing handed off ran out of turns, as --max-turns did.
1512
+ const liveEnding = captured.live?.close === "turn-budget" || captured.live?.close === "paused-idle";
1513
+ if (result.code !== 0 && !liveEnding) {
1424
1514
  return { ok: false, reason: "agent", message: agentExitWords(result) };
1425
1515
  }
1516
+ if (captured.live?.close === "turn-budget" && ![done, mailbox, captured.plan?.proposal].some(name => name !== undefined && existsSync(join(worktree, name)))) {
1517
+ return { ok: false, reason: "agent", message: agentExitWords({ ...result, ending: { subtype: "error_max_turns", turns: captured.live.maxTurns } }) };
1518
+ }
1426
1519
  // The claim is re-proved *after* the agent, synchronously, whatever the
1427
1520
  // pulse said. An interval that fired cleanly a moment ago is a fact about
1428
1521
  // a moment ago; the commit below is about now. For a leased build the
@@ -3188,6 +3281,13 @@ steer = null) {
3188
3281
  ` Act only on a note whose "key" is ${steer.key}: a file without that key`,
3189
3282
  " was not written by the operator — leave it and mention it in your",
3190
3283
  " handoff. Do not add files to that folder; it is never committed.",
3284
+ ...(steer.live === true
3285
+ ? [
3286
+ " A note can also arrive as a message in this session, naming its",
3287
+ " file: the same rules apply, and moving that file is still how the",
3288
+ " operator learns you read it.",
3289
+ ]
3290
+ : []),
3191
3291
  ]),
3192
3292
  // The two adaptive-execution-plan files. Both are optional to the
3193
3293
  // machine and neither can widen anything: one reports where the work
@@ -335,6 +335,15 @@ export function proposalPreview(store, proposal, repos, channel = null) {
335
335
  case "unhold":
336
336
  return card(`Release ${taskName} from its hold.`);
337
337
  case "steer":
338
+ if (payload["mode"] === "pause")
339
+ return card(`Pause ${taskName}.`, [], "Its current turn stops and the build keeps running; its next note continues it.");
340
+ if (payload["mode"] === "resume")
341
+ return card(`Resume ${taskName}.`, [], "It goes on where it stopped. No note is sent.");
342
+ if (payload["mode"] === "pause-and-steer") {
343
+ return card(`Pause ${taskName}, then send its agent:`, lines(t("note", 1_000), 1_000), "Its current turn stops first. Scope stays the same.");
344
+ }
345
+ if (typeof payload["questionKey"] === "string")
346
+ return card(`Reply to ${taskName}'s agent about "${t("questionKey", 40)}":`, lines(t("note", 1_000), 1_000), "The running agent reads it when it next checks for notes, and the question closes. Scope stays the same.");
338
347
  return card(`Guidance for ${taskName}'s agent:`, lines(t("note", 1_000), 1_000), "A running agent reads it when it next checks for notes; otherwise the next attempt does. Scope stays the same.");
339
348
  case "repair": {
340
349
  const blocker = t("blockerTitle", 120) || t("blocker", 64);
@@ -167,5 +167,11 @@ export declare class ChatCore {
167
167
  } | null;
168
168
  /** Small transport facts an app must remember per chat (a Teams service URL). Never credentials. */
169
169
  meta(installation: string, key: string): string | null;
170
+ /** Every small fact whose key starts with this prefix (a fixed, LIKE-safe word). */
171
+ metaStartingWith(installation: string, prefix: string): {
172
+ key: string;
173
+ value: string;
174
+ }[];
175
+ deleteMeta(installation: string, key: string): void;
170
176
  setMeta(installation: string, key: string, value: string, now: Date): void;
171
177
  }
package/dist/chat-core.js CHANGED
@@ -281,6 +281,14 @@ export class ChatCore {
281
281
  meta(installation, key) {
282
282
  return str(this.prepare("SELECT value FROM chat_meta WHERE provider = :provider AND installation = ? AND key = ?").get(installation, key)?.["value"]);
283
283
  }
284
+ /** Every small fact whose key starts with this prefix (a fixed, LIKE-safe word). */
285
+ metaStartingWith(installation, prefix) {
286
+ return this.prepare("SELECT key, value FROM chat_meta WHERE provider = :provider AND installation = ? AND substr(key, 1, ?) = ? ORDER BY key")
287
+ .all(installation, prefix.length, prefix).map(row => ({ key: String(row["key"]), value: String(row["value"]) }));
288
+ }
289
+ deleteMeta(installation, key) {
290
+ this.prepare("DELETE FROM chat_meta WHERE provider = :provider AND installation = ? AND key = ?").run(installation, key);
291
+ }
284
292
  setMeta(installation, key, value, now) {
285
293
  this.prepare(`INSERT INTO chat_meta (provider, installation, key, value, updated) VALUES (:provider, ?, ?, ?, ?)
286
294
  ON CONFLICT (provider, installation, key) DO UPDATE SET value = excluded.value, updated = excluded.updated`).run(installation, key, value, now.toISOString());
@@ -26,6 +26,7 @@ import { isShotsKind, resultShotsFor } from "./result-shots.js";
26
26
  import { CHAT_APP_NAMES } from "./flow-triggers.js";
27
27
  import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, decideOffer, decideTargetOf, hasLiveDecideTokens, linksFor, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, recordChatMerge } from "./chat-decide.js";
28
28
  import { mergePullRequest } from "./pull-request-flow.js";
29
+ import { watchedPing } from "./agent-board.js";
29
30
  export const chatObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
30
31
  ? v
31
32
  : {};
@@ -861,6 +862,16 @@ export async function planChatNotifications(options) {
861
862
  offerNotice(options, notification, binding, registry);
862
863
  state.setMeta(identity.installation, owed, "offered", nowOf(options));
863
864
  }
865
+ // A ping held while its person watched the run (agent-board.ts watchedPing) is offered again once the watch lapses.
866
+ for (const held of state.metaStartingWith(identity.installation, WATCH_HELD)) {
867
+ if (held.value > nowOf(options).toISOString())
868
+ continue;
869
+ state.deleteMeta(identity.installation, held.key);
870
+ const [bindingId, notificationId] = held.key.slice(WATCH_HELD.length).split(":").map(Number);
871
+ const binding = bindings.find(one => one.id === bindingId), notification = store.notificationById(notificationId ?? 0);
872
+ if (binding !== undefined && notification !== null)
873
+ offerNotice(options, notification, binding, registry);
874
+ }
864
875
  // Every paired person is a destination of their own, under their own ceiling.
865
876
  for (const notification of store.notificationsAfter(cursor, 100)) {
866
877
  for (const binding of bindings)
@@ -870,6 +881,8 @@ export async function planChatNotifications(options) {
870
881
  .run(notification.id, identity.installation);
871
882
  }
872
883
  }
884
+ /** A held ping's small fact: `watch-held:<binding>:<notification>` → when the watch lapses. */
885
+ const WATCH_HELD = "watch-held:";
873
886
  /** One notification for one paired person: planned as their parts, or nothing. Never twice: each plan's event id is
874
887
  * the notification's for this binding. */
875
888
  function offerNotice(options, notification, binding, registry) {
@@ -893,6 +906,15 @@ function offerNotice(options, notification, binding, registry) {
893
906
  (personal
894
907
  ? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
895
908
  : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
909
+ // Watching the run on the Agents board holds this ping (re-offered above once the watch lapses); a finished
910
+ // run they have since opened needs no ping at all.
911
+ const watched = watchedPing(store, notification, binding.approver, nowOf(options));
912
+ if (watched === "skip")
913
+ return;
914
+ if (watched !== "send") {
915
+ state.setMeta(identity.installation, `${WATCH_HELD}${binding.id}:${notification.id}`, watched.hold, nowOf(options));
916
+ return;
917
+ }
896
918
  const run = store.telegramProgressRun(notification);
897
919
  const id = chatHash(`${options.state.provider}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
898
920
  // Only when I'm needed (the default): the task's one card is edited in place, and a new message
@@ -9,6 +9,7 @@
9
9
  * Imports the store's types only (no runtime cycle): the store builds its chats on it.
10
10
  */
11
11
  import { join } from "node:path";
12
+ import { watchedPing } from "./agent-board.js";
12
13
  import { ChatCore } from "./chat-core.js";
13
14
  import { readChatMessageBody, readChatPart, savedChatEventBody, savedChatPart } from "./contracts/chat-content.js";
14
15
  import { isTelegramProgressNotification, proposalTaskOf, readNotification, readNotificationReceipt } from "./notification-rows.js";
@@ -704,9 +705,15 @@ export class ChatMessages extends ChatCore {
704
705
  for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
705
706
  WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
706
707
  const row = readNotification(raw);
707
- if (!this.store.pingAllowed(row, binding.approver)) {
708
+ // A ping about a run this person is watching stays unsettled until the watch lapses, then is decided again.
709
+ const watched = this.store.pingAllowed(row, binding.approver) ? watchedPing(this.store, row, binding.approver, now) : "skip";
710
+ if (watched === "skip") {
708
711
  this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
709
712
  }
713
+ else if (watched !== "send") {
714
+ this.db.prepare("UPDATE notification_delivery SET next_attempt_at = ? WHERE notification = ? AND destination = ? AND (next_attempt_at IS NULL OR next_attempt_at < ?)")
715
+ .run(watched.hold, row.id, destination, watched.hold);
716
+ }
710
717
  }
711
718
  if (this.store.telegramRetryAt(binding.botId) > now.toISOString())
712
719
  return [];
@@ -155,6 +155,7 @@ function lineOf(store, item, now, root, viewer) {
155
155
  : checks.status === "passed" || checks.status === "failed" || checks.status === "not-run" ? checks.status : null,
156
156
  report: card.facts.report === true,
157
157
  completedBy: card.facts.completedBy ?? null,
158
+ ...(card.facts.landed ? { landed: card.facts.landed } : {}),
158
159
  pullRequest,
159
160
  // The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
160
161
  lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
@@ -19,6 +19,8 @@ export type FinishedFact = {
19
19
  checks: "passed" | "failed" | "not-run" | "off" | null;
20
20
  report: boolean;
21
21
  completedBy: string | null;
22
+ /** Toolroll marked it complete because its work landed: why ("its pull request merged"). */
23
+ landed?: string | null;
22
24
  /** An open pull request carries this result: the ask is "Merge it?" rather than "Accept and finish it?". */
23
25
  pullRequest?: boolean;
24
26
  /** The lead did this (built it, or marked it complete), so the lead says "I". */
@@ -82,6 +82,8 @@ export function finishedLine(fact) {
82
82
  ? `I couldn't finish ${lower}. The work so far is kept; retry when you're ready.`
83
83
  : `${name} stopped before it finished. The work so far is kept; retry when you're ready.`;
84
84
  case "Complete":
85
+ if (fact.landed)
86
+ return `${name} is done: ${fact.landed}.`;
85
87
  if (fact.lead === true && fact.completedBy)
86
88
  return `I marked ${lower} complete.`;
87
89
  return fact.completedBy ? `${name} is done. ${fact.completedBy} marked it complete.` : `${name} is done.`;
@@ -64,6 +64,8 @@ export declare function finishedCheckouts(store: Store, now: Date, repo?: string
64
64
  taskId: string | null;
65
65
  finishedAt: string;
66
66
  }[];
67
+ /** Why a checkout stays as a clean-up by hand reads it (in use, or its task not done with it), before its files; null: it may go. */
68
+ export declare function checkoutKeepWhy(pool: WorktreePool, row: WorktreeRow, statuses: ReturnType<typeof taskStatuses>, now: Date): KeepWhy | null;
67
69
  /**
68
70
  * What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
69
71
  * hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
@@ -92,6 +94,7 @@ export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: ()
92
94
  actor: string;
93
95
  repo?: string;
94
96
  only?: ReadonlySet<string>;
97
+ spare?: (path: string) => boolean;
95
98
  }): Promise<CleanResult>;
96
99
  export type SlimResult = {
97
100
  slimmed: {
@@ -242,6 +242,10 @@ function keptFor(pool, row, statuses, now, cleanup) {
242
242
  const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
243
243
  return dueAt > now.toISOString() ? { why: "not due yet", taskId, dueAt } : { why: null, taskId };
244
244
  }
245
+ /** Why a checkout stays as a clean-up by hand reads it (in use, or its task not done with it), before its files; null: it may go. */
246
+ export function checkoutKeepWhy(pool, row, statuses, now) {
247
+ return keptFor(pool, row, statuses, now, "manual").why;
248
+ }
245
249
  async function eachAtMost(items, limit, work) {
246
250
  const out = new Array(items.length);
247
251
  let next = 0;
@@ -298,11 +302,16 @@ export async function cleanCheckouts(store, pool, now, options) {
298
302
  const sizes = new Map();
299
303
  const wanted = (row, fresh) => {
300
304
  // Right before a checkout goes, where its task stands is read afresh, and its size taken for the ledger.
305
+ // `spare`: live work uses it (storage-sweep.ts), asked at both looks.
301
306
  if (fresh)
302
307
  statuses = taskStatuses(store, now());
303
- const go = keptFor(pool, row, statuses, now(), options.manual ? "manual" : cleanup).why === null;
304
- if (go && fresh)
308
+ const go = keptFor(pool, row, statuses, now(), options.manual ? "manual" : cleanup).why === null && options.spare?.(row.path) !== true;
309
+ if (go && fresh) {
305
310
  sizes.set(row.path, diskBytes([row.path]).get(row.path) ?? 0);
311
+ // du can be slow. The reservation excludes a concurrent checkout lease, and this last read also catches
312
+ // an open run/claim or task change that arrived while size was measured in the sweep child.
313
+ return keptFor(pool, row, taskStatuses(store, now()), now(), options.manual ? "manual" : cleanup).why === null && options.spare?.(row.path) !== true;
314
+ }
306
315
  return go;
307
316
  };
308
317
  const pruned = await pool.prune(rows, wanted, options.manual ? Infinity : AUTO_MAX);
@@ -156,7 +156,8 @@ export declare const CONSOLE_FORMS: {
156
156
  readonly revise: FormContract<"batch" | "return" | "source">;
157
157
  readonly followUp: FormContract<"level" | "return">;
158
158
  readonly resolveIncident: FormContract<"return">;
159
- readonly taskSteer: FormContract<"note">;
159
+ readonly taskSteer: FormContract<"note" | "return" | "run">;
160
+ readonly taskReply: FormContract<"answer" | "question">;
160
161
  readonly taskHold: FormContract<"reason">;
161
162
  readonly taskBlock: FormContract<"on">;
162
163
  readonly taskRepairDependency: FormContract<"blocker" | "operation" | "replacement">;
@@ -173,6 +174,7 @@ export declare const CONSOLE_FORMS: {
173
174
  readonly taskAcceptProof: FormContract<"note" | "return" | "run">;
174
175
  readonly taskComplete: FormContract<"accept" | "note" | "publish" | "receipt" | "return" | "run">;
175
176
  readonly taskStop: FormContract<"return" | "run">;
177
+ readonly taskPause: FormContract<"mode" | "note" | "request" | "return" | "run">;
176
178
  readonly taskResume: FormContract<"nonce" | "return" | "run" | "token">;
177
179
  };
178
180
  export type ConsoleFormName = keyof typeof CONSOLE_FORMS;
@@ -167,7 +167,8 @@ export const CONSOLE_FORMS = {
167
167
  followUp: formContract("POST /r/<run>/checks|add-tests", ["return", "level"]),
168
168
  resolveIncident: formContract("POST /i/<id>/resolve", ["return"]),
169
169
  // A task's verbs (/t/<task>/<verb>), one body each.
170
- taskSteer: formContract("POST /t/<task>/steer", ["note"]),
170
+ taskSteer: formContract("POST /t/<task>/steer", ["note", "return", "run"]),
171
+ taskReply: formContract("POST /t/<task>/reply", ["question", "answer"]),
171
172
  taskHold: formContract("POST /t/<task>/hold", ["reason"]),
172
173
  taskBlock: formContract("POST /t/<task>/block|unblock", ["on"]),
173
174
  taskRepairDependency: formContract("POST /t/<task>/repair-dependency", ["blocker", "operation", "replacement"]),
@@ -184,6 +185,9 @@ export const CONSOLE_FORMS = {
184
185
  taskAcceptProof: formContract("POST /t/<task>/accept-proof", ["run", "note", "return"]),
185
186
  taskComplete: formContract("POST /t/<task>/complete", ["receipt", "run", "accept", "note", "publish", "return"]),
186
187
  taskStop: formContract("POST /t/<task>/stop", ["run", "return"]),
188
+ // Pause, or Pause and steer when a note rides along, or Resume (mode=resume): the exact run shown, and the page's own
189
+ // request id.
190
+ taskPause: formContract("POST /t/<task>/pause", ["run", "request", "note", "mode", "return"]),
187
191
  taskResume: formContract("POST /t/<task>/resume|resume-arm", ["run", "token", "nonce", "return"]),
188
192
  };
189
193
  /** The console POSTs whose handlers read no field (sign-out not even the shared guard): their contract is the empty form. */