toolroll 0.9.59 → 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 (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. package/scripts/provider-canary.mjs +235 -147
package/dist/lead-cli.js CHANGED
@@ -71,25 +71,28 @@ export function proposalLines(proposals, repos, ordinal = (_p) => 0) {
71
71
  ? `${t("operation") === "revise" ? "request a revision" : "save feedback"} for ${t("taskTitle") || t("task")}: ${t("note")}${Array.isArray(payload["notes"]) && payload["notes"].length > 0 ? ` (saved notes: ${payload["notes"].join(", ")})` : ""}`
72
72
  : one.kind === "control"
73
73
  ? (isChatControl(payload["control"]) ? `${CHAT_CONTROLS[payload["control"]].label}: ${chatControlHref(payload["control"], t("task"), payload["run"], payload["project"])} (open in the console)` : "control unavailable")
74
- : one.kind === "steer" ? `guide ${t("task")}'s next attempt: ${t("note")}`
75
- : one.kind === "agents" ? `change agents for ${t("task")}: ${t("role")} ${t("provider")} ${t("model")} ${t("risk")}`
76
- : one.kind === "repair" ? `${t("operation")} dependency ${t("blocker")} for ${t("task")}`
77
- :
78
- one.kind === "task"
79
- ? `file "${t("title")}" in ${repoLabel}${payload["report"] === true ? " (a scout task — it delivers a report, never a branch)" : ""}`
80
- : one.kind === "next"
81
- ? `move ${t("task")} to the front (was ${String(payload["position"] ?? "?")} of ${String(payload["of"] ?? "?")})`
82
- : one.kind === "reserve"
83
- ? `${payload["worker"] === null ? `release ${t("task")} to the shared queue` : `reserve ${t("task")} for ${t("worker")}`}`
84
- : one.kind === "hold"
85
- ? `hold ${t("task")}: ${t("reason")}`
86
- : one.kind === "unhold"
87
- ? `release ${t("task")} from its hold`
88
- : one.kind === "answer"
89
- ? `answer decision #${String(payload["decision"])} on ${t("task")} with "${t("optionLabel")}"${payload["reversible"] === false ? " (irreversible — confirm N yes)" : ""}: ${t("rationale")}`
90
- : one.kind === "scope"
91
- ? `rewrite the scope of ${t("task")} (then approve it: toolroll task approve ${t("task")})`
92
- : `cancel ${t("task")}: ${t("reason")} (arm it yourself: toolroll task cancel ${t("task")})`;
74
+ : one.kind === "steer" && one.payload["mode"] === "pause" ? `pause ${t("task")}'s build`
75
+ : one.kind === "steer" && one.payload["mode"] === "resume" ? `resume ${t("task")}'s paused build`
76
+ : one.kind === "steer" && one.payload["mode"] === "pause-and-steer" ? `pause ${t("task")}'s build, then send: ${t("note")}`
77
+ : one.kind === "steer" ? `guidance for ${t("task")}'s agent: ${t("note")}`
78
+ : one.kind === "agents" ? `change agents for ${t("task")}: ${t("role")} ${t("provider")} ${t("model")} ${t("risk")}`
79
+ : one.kind === "repair" ? `${t("operation")} dependency ${t("blocker")} for ${t("task")}`
80
+ :
81
+ one.kind === "task"
82
+ ? `file "${t("title")}" in ${repoLabel}${payload["report"] === true ? " (a scout task — it delivers a report, never a branch)" : ""}`
83
+ : one.kind === "next"
84
+ ? `move ${t("task")} to the front (was ${String(payload["position"] ?? "?")} of ${String(payload["of"] ?? "?")})`
85
+ : one.kind === "reserve"
86
+ ? `${payload["worker"] === null ? `release ${t("task")} to the shared queue` : `reserve ${t("task")} for ${t("worker")}`}`
87
+ : one.kind === "hold"
88
+ ? `hold ${t("task")}: ${t("reason")}`
89
+ : one.kind === "unhold"
90
+ ? `release ${t("task")} from its hold`
91
+ : one.kind === "answer"
92
+ ? `answer decision #${String(payload["decision"])} on ${t("task")} with "${t("optionLabel")}"${payload["reversible"] === false ? " (irreversible — confirm N yes)" : ""}: ${t("rationale")}`
93
+ : one.kind === "scope"
94
+ ? `rewrite the scope of ${t("task")} (then approve it: toolroll task approve ${t("task")})`
95
+ : `cancel ${t("task")}: ${t("reason")} (arm it yourself: toolroll task cancel ${t("task")})`;
93
96
  const state = one.state === "pending" ? "" : ` [${one.state}${one.outcome !== null && typeof one.outcome.said === "string" ? `: ${one.outcome.said}` : ""}]`;
94
97
  return ` ${number}. ${what}${state}`;
95
98
  });
@@ -20,7 +20,7 @@ export const LEAD_CONTRACT = [
20
20
  "Talk like a colleague in everyday words: task titles, 'the changes', 'the checks', 'try again'. Never say execution, run, scope, digest, dispatch or lane to the operator; say what happens next and what they can do.",
21
21
  "Read get_result for the exact execution/run; page nextFeedbackOffset. propose_review revise requests changes; note saves feedback. Use read notes only; clarify ambiguous intent. Ready is a saved result, not a reviewer stage. Historical missing assessments never require rerunning work. Failed checks and missing files remain visible; completion never deploys or changes checks.",
22
22
  "For screenshots, call get_result_images for that exact execution and run. On Telegram say they follow, never that they were delivered; elsewhere name result and image count. Limit 8/reply. If nextImageOffset exists, name remaining count; call it again with that offset or with the image ids it listed when asked. Select a newer revision explicitly; never describe unselected images or expose paths.",
23
- "propose_steer guides the next attempt; propose_scope changes scope. propose_task_action uses control.run for stop/resume and supports retry, plan, dependencies. Requested stop is not stopped; ending chat differs. For protected actions read get_actions, use propose_action and full secure review. get_action_status proves saved outcomes; show_control only opens controls.",
23
+ "propose_steer reaches a running build (a Claude build in seconds), else the next attempt; get_task steering says if it was read. Its mode pause stops a running Claude build's turn and keeps the build; pause-and-steer then sends the note; resume continues a paused build; never use these for Stop. propose_scope changes scope. propose_task_action uses control.run for stop/resume and supports retry, plan, dependencies. Requested stop is not stopped; ending chat differs. For protected actions read get_actions, use propose_action and full secure review. get_action_status proves saved outcomes; show_control only opens controls.",
24
24
  "For a task's models/risk, read get_agents and answer in its words; for defaults, CLI versions and new models, read get_models; never an agent that is not listed. Changes require renewed approval. Before propose_answer read get_decision in an earlier step, including every option and consequence. Irreversible answers require explicit confirmation; cards retain consequences and recommendations.",
25
25
  "For intake, a plain-language outcome is enough to draft a task. Infer title, narrow goal, safe non-goals and testable criteria; leave touches empty for discovery. Do not ask the operator for a title, paths, implementation details, acceptance wording, model, budget or safely inferable fields. Ask at most three questions with defaults (one at a time with ask_owner) for material ambiguity, conflicting goals or unresolved irreversible/public/security/data-loss/migration choices. With 'use your judgment', use reversible defaults; real blockers may still arise.",
26
26
  "Set propose_task planning to 'required' for broad, risky or plan-first work, 'skip' for explicitly small direct builds, otherwise 'auto'. report:true investigates without a branch; follow-ups require confirmed proposals.",
@@ -15,8 +15,10 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
15
15
  * silent re-read.
16
16
  */
17
17
  import { closedQuestionWords } from "./decision.js";
18
+ import { steerDestinationWords } from "./steer-status.js";
18
19
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
19
20
  import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
21
+ import { agentQuestion, answerAgentQuestion } from "./agent-board.js";
20
22
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
21
23
  import { TURN_WALL_CLOCK_MS } from './converse.js';
22
24
  import { teamChatAuthorization, subscriptionTeamChatProvider } from './team-chat-authorization.js';
@@ -58,7 +60,7 @@ const VIA_WORDS = {
58
60
  };
59
61
  const CARD_WORDS = {
60
62
  task: "New task", next: "Queue priority", reserve: "Worker assignment", hold: "Pause work", unhold: "Resume work",
61
- steer: "Guidance for the next attempt", scope: "Scope revision", answer: "Decision answer", repair: "Waiting task",
63
+ steer: "Guidance for the agent", scope: "Scope revision", answer: "Decision answer", repair: "Waiting task",
62
64
  agents: "Agents change", task_action: "Task update", action: "Action",
63
65
  };
64
66
  /** A card about a task, confirmed anywhere but that task's own chat (the
@@ -407,10 +409,52 @@ function executeProposal(store, actor, kind, payload, now, options) {
407
409
  store.unhold(ref.id);
408
410
  return { ok: true, kind, said: `${taskId} released from its hold`, taskId };
409
411
  }
412
+ if (kind === "steer" && payload["mode"] === "resume") {
413
+ // Resume (live steering): the paused build the proposal saw, under the request id it was drafted with; no note.
414
+ const sawRun = payloadNumber(payload, "sawRun");
415
+ const request = payloadString(payload, "request");
416
+ if (sawRun === null || request === null)
417
+ return refuse("refused", "this proposal is missing what it saw");
418
+ const asked = store.requestResume({ taskId, runId: sawRun, by: verifiedAuthor(actor.name), via: "chat", requestId: request }, now);
419
+ if (!asked.ok) {
420
+ return refuse(asked.reason === "stale" || asked.reason === "not-paused" ? "stale" : "refused", asked.reason === "stale" ? "that build has stopped taking notes — look again" : asked.reason === "not-paused" ? "that build isn't paused any more — look again" : asked.detail);
421
+ }
422
+ const taskName = payloadString(payload, "taskTitle") ?? taskId;
423
+ return { ok: true, kind, said: `Resuming ${taskName} where it stopped.`, taskId };
424
+ }
425
+ if (kind === "steer" && (payload["mode"] === "pause" || payload["mode"] === "pause-and-steer")) {
426
+ // Pause, or Pause and steer (live steering): the build the proposal saw, under the request id it was drafted with.
427
+ const mode = payload["mode"];
428
+ const sawRun = payloadNumber(payload, "sawRun");
429
+ const request = payloadString(payload, "request");
430
+ const note = payloadString(payload, "note");
431
+ if (sawRun === null || request === null || (mode === "pause-and-steer" && note === null))
432
+ return refuse("refused", "this proposal is missing what it saw");
433
+ const asked = store.requestPause({ taskId, runId: sawRun, by: verifiedAuthor(actor.name), via: "chat", requestId: request, ...(mode === "pause-and-steer" ? { note: note } : {}) }, now);
434
+ if (!asked.ok)
435
+ return refuse(asked.reason === "stale" ? "stale" : "refused", asked.reason === "stale" ? "that build has stopped taking notes — look again" : asked.detail);
436
+ const taskName = payloadString(payload, "taskTitle") ?? taskId;
437
+ return { ok: true, kind, said: mode === "pause" ? `Pausing ${taskName}. Its next note continues it.` : `Pausing ${taskName}, then sending your note.`, taskId };
438
+ }
410
439
  if (kind === "steer") {
411
440
  const note = payloadString(payload, "note");
412
441
  if (note === null)
413
442
  return refuse("refused", "this proposal carries no guidance");
443
+ // A reply to the running agent's own question: the same door the Agents board's Reply uses, closing exactly that key.
444
+ const question = payloadNumber(payload, "question");
445
+ if (question !== null) {
446
+ const asked = agentQuestion(store, question);
447
+ if (asked === null || asked.taskRef !== ref.id)
448
+ return refuse("stale", "that question is not on this task");
449
+ const answered = answerAgentQuestion(store, { question, answer: note, author: actor.name, at: now,
450
+ file: (task, text) => store.fileSteerNote(task, verifiedAuthor(actor.name), text, now) });
451
+ if (!answered.ok) {
452
+ return answered.reason === "invalid-answer" || answered.reason === "not-saved"
453
+ ? refuse("refused", answered.problem ?? "that reply could not be saved")
454
+ : refuse("stale", "the agent's question already closed or its attempt ended — look again");
455
+ }
456
+ return { ok: true, kind, said: `Reply sent to the agent working on ${payloadString(payload, "taskTitle") ?? taskId}.`, taskId };
457
+ }
414
458
  const filed = store.fileSteerNote(taskId, verifiedAuthor(actor.name), note, now);
415
459
  if (!filed.ok) {
416
460
  const said = filed.reason === "task-finished"
@@ -421,7 +465,10 @@ function executeProposal(store, actor, kind, payload, now, options) {
421
465
  return refuse("refused", said);
422
466
  }
423
467
  const taskName = payloadString(payload, "taskTitle") ?? taskId;
424
- return { ok: true, kind, said: `Guidance saved for ${taskName}'s next attempt`, taskId };
468
+ const steered = store.lookupRef(taskId);
469
+ const live = steered !== null && store.steeringLiveBuild(steered.id, now) !== null;
470
+ const liveInput = steered !== null && store.liveInputRun(steered.id, now) !== null;
471
+ return { ok: true, kind, said: `Guidance saved for ${taskName}. ${steerDestinationWords(live, liveInput)}`, taskId };
425
472
  }
426
473
  if (kind === "repair") {
427
474
  const blocker = payloadString(payload, "blocker");
@@ -6,6 +6,8 @@ export declare function leadFollowStatus(store: Store, actor: string): {
6
6
  enabled: boolean;
7
7
  detail: string;
8
8
  };
9
+ /** A follow pass that failed outright says so where each enabled conversation reads its follow status. */
10
+ export declare function recordLeadFollowFailure(store: Store, error: unknown, now: Date): void;
9
11
  export type LeadFollowInput = {
10
12
  store: Store;
11
13
  repos: () => readonly string[];
@@ -1,13 +1,28 @@
1
1
  /** Durable crew updates for the built-in lead. Idle scans use no model.
2
- * Delivery replays reuse the saved lead request; they never dispatch a task. */
2
+ * The lead is an ordinary consumer of the assignment inbox: its scan records
3
+ * status events under its follow grant (assignment-status.ts), and it reads
4
+ * and acknowledges them through the same saved pages every coordinator uses
5
+ * (assignment-delivery.ts). Replays reuse the saved lead request; they never
6
+ * dispatch a task. */
3
7
  import { createHash } from 'node:crypto';
4
8
  import { LEAD_FOLLOW_MESSAGE } from './store.js';
5
9
  import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './principal.js';
6
10
  import { assignmentOf } from './assignment.js';
11
+ import { ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, followActor, LEAD_WAKE_STATES, noteFollowStatus } from './assignment-status.js';
12
+ import { acknowledgeLeadFollowDelivery, leadFollowInbox } from './assignment-delivery.js';
13
+ import { publicChatText } from './chat-display.js';
7
14
  import { runLeadTurn } from './lead.js';
8
15
  import { updateAdmissionPaused } from './desktop-update-gate.js';
9
16
  import { checkLeadCommitments } from './lead-commitments.js';
17
+ import { sweepIfMoved } from './sweep-gate.js';
10
18
  const CONFIG = 'lead follow configured';
19
+ /** What follow status reads while nothing is waiting on the lead. */
20
+ const FOLLOW_READY = 'The lead will respond when a result or decision is ready, or a task is cancelled.';
21
+ const WAITING = 'Crew updates are waiting:';
22
+ const FAILED = `${WAITING} the last update check failed`;
23
+ /** The built-in lead's one inbox per grant. */
24
+ const CONSUMER = 'lead';
25
+ const PAGE = 20;
11
26
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
12
27
  export function configureLeadFollow(store, who, session, thread, enabled, now) {
13
28
  if (!isVerifiedApprover(who) || !reproveApprover(store, who).ok || session.approver !== who.name || thread.approver !== who.name ||
@@ -46,7 +61,7 @@ export function leadFollowStatus(store, actor) {
46
61
  if (!grant?.enabled || !authorized(store, grant, grant.repos))
47
62
  return { enabled: false, detail: 'Automatic crew updates are off.' };
48
63
  const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? AND source='work' ORDER BY id DESC LIMIT 1").get(actor, `lead delivery status:${grant.id}`);
49
- return { enabled: true, detail: row ? String(row['outcome']) : 'The lead will respond when a result or decision is ready.' };
64
+ return { enabled: true, detail: row ? String(row['outcome']) : FOLLOW_READY };
50
65
  }
51
66
  function authorized(store, grant, admitted) {
52
67
  const latest = grantFor(store, grant.actor);
@@ -59,63 +74,81 @@ function authorized(store, grant, admitted) {
59
74
  const binding = liveBinding(store, proved.who, grant.session, grant.thread);
60
75
  return binding ? { who: proved.who, ...binding } : null;
61
76
  }
62
- /** Observe bounded pages; first sight of pre-existing work is only a baseline.
63
- * Subsequent meaningful transitions become immutable delivery events. */
77
+ /** Observe bounded pages; first sight of work that has not moved since follow
78
+ * was enabled is only a baseline, recorded so the next change is compared to it. Every later transition — cancellation too —
79
+ * becomes an immutable status event in the grant's own inbox. */
64
80
  function observe(store, grant, now, root) {
65
81
  store.transact(() => {
66
82
  const cursor = `lead:${grant.id}:scan`, after = store.serviceCursor(cursor);
67
83
  const roots = store.handle.prepare(`SELECT id,external_id FROM task_ref WHERE backend='built-in' AND revision_of IS NULL
68
84
  AND repo IN (SELECT value FROM json_each(?)) AND id>? ORDER BY id LIMIT 50`).all(JSON.stringify(grant.repos), after);
85
+ const since = baselineOf(store, grant);
69
86
  for (const row of roots) {
70
87
  const a = assignmentOf(store, String(row['external_id']), now, { principal: 'operator', repos: grant.repos }, root);
71
88
  if (!a || !a.repo)
72
89
  continue;
73
- const observation = { task: a.rootId, repo: a.repo, state: a.state, detail: a.detail.slice(0, 400), result: a.receipt?.runId ?? null,
74
- checks: a.receipt?.checks.status ?? null, decision: a.primaryAction?.target.decisionId ?? null, head: a.receipt?.head ?? null };
75
- const digest = hash(observation), action = `lead observed:${grant.id}`;
76
- const old = store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND task_id=? AND action=? AND source='work' ORDER BY id DESC LIMIT 1").get(grant.actor, a.rootId, action);
77
- if (old?.['outcome'] === digest)
78
- continue;
79
- store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: a.repo, taskId: a.rootId, runId: a.receipt?.runId ?? null, action, outcome: digest, source: 'work' });
80
- // Scope, question, result and completion writes do not necessarily
81
- // update task.updated_at. A transition between enabling follow and
82
- // the first scan must not be mistaken for a pre-existing baseline.
83
- const scope = store.getScope(a.activeTaskId);
84
- const question = observation.decision === null ? null : store.getDecision(observation.decision);
85
- const changedAfterGrant = [store.getTask(a.activeTaskId)?.updatedAt, scope?.proposedAt, scope?.approvedAt,
86
- question?.createdAt, a.completion?.at, ...a.attempts.map(attempt => attempt.runId === null ? null : store.getRun(attempt.runId)?.finishedAt)]
87
- .some(at => typeof at === 'string' && at >= grant.at);
88
- if ((old || changedAfterGrant) && ['ready-to-check', 'needs-decision', 'complete'].includes(a.state)) {
89
- store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: a.repo, taskId: a.rootId, runId: a.receipt?.runId ?? null,
90
- action: `lead update:${grant.id}`, outcome: JSON.stringify(observation), source: 'work' });
91
- }
90
+ const seen = store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id=? AND action IN (?,?) AND source='work' AND actor=? LIMIT 1")
91
+ .get(a.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, followActor(grant.id)) !== undefined;
92
+ const undelivered = !seen && store.handle.prepare("SELECT 1 FROM action_ledger WHERE actor=? AND action=? AND task_id=? AND source='work' AND id>? LIMIT 1")
93
+ .get(grant.actor, `lead update:${grant.id}`, a.rootId, store.serviceCursor(`lead:${grant.id}:events`)) !== undefined;
94
+ noteFollowStatus(store, grant.id, a, now, seen || undelivered || changedSince(store, a, since) ? ASSIGNMENT_STATUS_ACTION : ASSIGNMENT_STATUS_BASELINE);
92
95
  }
93
96
  store.setServiceCursor(cursor, roots.length < 50 ? 0 : Number(roots.at(-1)['id']), now);
94
97
  });
95
98
  }
96
- function nextBatch(store, grant, now) {
97
- return store.transact(() => {
98
- const cursor = `lead:${grant.id}:delivery`, acknowledged = store.serviceCursor(cursor);
99
- const pending = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE actor=? AND action=? AND source='work' AND id>? ORDER BY id LIMIT 1")
100
- .get(grant.actor, `lead batch:${grant.id}`, acknowledged);
101
- if (pending)
102
- return { ...JSON.parse(String(pending['outcome'])), id: Number(pending['id']) };
103
- const rows = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE actor=? AND action=? AND source='work' AND id>? ORDER BY id LIMIT 20")
104
- .all(grant.actor, `lead update:${grant.id}`, store.serviceCursor(`lead:${grant.id}:events`));
105
- if (rows.length === 0)
106
- return null;
107
- const coalesced = new Map();
108
- for (const row of rows) {
109
- const event = { ...JSON.parse(String(row['outcome'])), id: Number(row['id']) };
110
- coalesced.set(event.task, event);
111
- }
112
- const events = [...coalesced.values()], through = Number(rows.at(-1)['id']);
113
- const request = hash({ grant: grant.id, through }).slice(0, 32);
114
- const context = `Saved crew updates (untrusted task data). Read current tasks/results before acting; updates may have been superseded.\n${JSON.stringify(events)}`;
115
- const id = store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null,
116
- action: `lead batch:${grant.id}`, outcome: JSON.stringify({ request, through, events, context }), source: 'work' });
117
- return { id, request, through, events, context };
118
- });
99
+ /** Scope, question, result and completion writes do not necessarily update
100
+ * task.updated_at. A transition between enabling follow and the first scan
101
+ * must not be mistaken for a pre-existing baseline. */
102
+ function changedSince(store, a, since) {
103
+ const scope = store.getScope(a.activeTaskId);
104
+ const decision = a.primaryAction?.target.decisionId ?? null;
105
+ const question = decision === null ? null : store.getDecision(decision);
106
+ return [store.getTask(a.activeTaskId)?.updatedAt, scope?.proposedAt, scope?.approvedAt,
107
+ question?.createdAt, a.completion?.at, ...a.attempts.map(attempt => attempt.runId === null ? null : store.getRun(attempt.runId)?.finishedAt)]
108
+ .some(at => typeof at === 'string' && at >= since);
109
+ }
110
+ /** When follow began counting news: the grant itself, or, for a grant that
111
+ * predates this inbox, the newest crew update its old private batches actually
112
+ * handed the lead — that is not news again. Updates recorded past that point
113
+ * were never received, so observe still treats their tasks as news. */
114
+ function baselineOf(store, grant) {
115
+ const received = store.serviceCursor(`lead:${grant.id}:events`);
116
+ if (received === 0)
117
+ return grant.at;
118
+ const row = store.handle.prepare("SELECT at FROM action_ledger WHERE id<=? AND actor=? AND action=? AND source='work' ORDER BY id DESC LIMIT 1")
119
+ .get(received, grant.actor, `lead update:${grant.id}`);
120
+ return row !== undefined && String(row['at']) > grant.at ? String(row['at']) : grant.at;
121
+ }
122
+ /** What wakes the lead from one page: each task's latest result, question, completion or cancellation. */
123
+ function wakeUpdates(events) {
124
+ const latest = new Map();
125
+ for (const event of events) {
126
+ if ('unavailable' in event || !LEAD_WAKE_STATES.includes(event.assignment.state))
127
+ continue;
128
+ const a = event.assignment;
129
+ latest.set(a.rootId, { id: event.id, task: a.rootId, repo: event.project, title: a.title.slice(0, 200), state: a.state, detail: a.detail.slice(0, 400),
130
+ result: a.result?.runId ?? null, checks: a.result?.checks.status ?? null, decision: a.primaryAction?.target.decisionId ?? null });
131
+ }
132
+ return [...latest.values()];
133
+ }
134
+ /** The lead's follow status line, written once per distinct status. */
135
+ const lastDeliveryStatus = (store, grant) => store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? ORDER BY id DESC LIMIT 1").get(grant.actor, `lead delivery status:${grant.id}`)?.['outcome'];
136
+ function noteDeliveryStatus(store, grant, status, now) {
137
+ if (lastDeliveryStatus(store, grant) !== status)
138
+ store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
139
+ }
140
+ const enabledGrants = (store) => store.handle.prepare("SELECT a.* FROM action_ledger a WHERE a.action=? AND a.source='work' AND a.id=(SELECT MAX(b.id) FROM action_ledger b WHERE b.actor=a.actor AND b.action=a.action AND b.source=a.source) ORDER BY a.id")
141
+ .all(CONFIG).map(readGrant).filter((one) => one !== null && one.enabled);
142
+ const failureStatus = (error) => `${FAILED} (${publicChatText(error instanceof Error ? error.message : String(error), 200)}). It retries in a few seconds.`;
143
+ /** With no page left unacknowledged, an earlier fault or refusal is no longer waiting. */
144
+ function settle(store, grant, now) {
145
+ if (String(lastDeliveryStatus(store, grant) ?? '').startsWith(WAITING))
146
+ noteDeliveryStatus(store, grant, FOLLOW_READY, now);
147
+ }
148
+ /** A follow pass that failed outright says so where each enabled conversation reads its follow status. */
149
+ export function recordLeadFollowFailure(store, error, now) {
150
+ for (const grant of enabledGrants(store))
151
+ noteDeliveryStatus(store, grant, failureStatus(error), now);
119
152
  }
120
153
  export async function runLeadFollowPass(input) {
121
154
  const { store } = input, clock = input.clock ?? (() => new Date());
@@ -124,48 +157,65 @@ export async function runLeadFollowPass(input) {
124
157
  store.sweepStaleLeadTurns(clock());
125
158
  // The lead's own promises are kept whether or not automatic crew updates are on: no model, one line when met.
126
159
  checkLeadCommitments(store, clock(), input.evidenceRoot);
127
- const grants = store.handle.prepare("SELECT a.* FROM action_ledger a WHERE a.action=? AND a.source='work' AND a.id=(SELECT MAX(b.id) FROM action_ledger b WHERE b.actor=a.actor AND b.action=a.action AND b.source=a.source) ORDER BY a.id").all(CONFIG).map(readGrant).filter((one) => one !== null && one.enabled);
128
- for (const grant of grants) {
129
- const access = authorized(store, grant, input.repos());
130
- if (!access || updateAdmissionPaused(store.raw()))
131
- continue;
132
- observe(store, grant, clock(), input.evidenceRoot);
133
- const batch = nextBatch(store, grant, clock());
134
- if (!batch)
135
- continue;
136
- // A replay after process exit reads the saved turn; even a failed turn is
137
- // terminal for this delivery. Human chat remains available for follow-up.
138
- const saved = store.leadRequestReceipt(grant.session, batch.request);
139
- let turn = saved ? store.getLeadTurn(saved.turn) : null;
140
- if (!turn) {
141
- const provider = input.provider();
142
- if (!provider)
143
- continue;
144
- const result = await (input.runTurn ?? runLeadTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
145
- requestId: batch.request, message: LEAD_FOLLOW_MESSAGE,
146
- context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
147
- ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
148
- if (!result.ok && 'refused' in result) {
149
- const status = `Crew updates are waiting: ${result.message}`;
150
- const last = store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? ORDER BY id DESC LIMIT 1").get(grant.actor, `lead delivery status:${grant.id}`);
151
- if (last?.['outcome'] !== status)
152
- store.recordAction({ at: clock().toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
153
- continue;
154
- }
155
- turn = store.getLeadTurn(result.turn);
160
+ for (const grant of enabledGrants(store)) {
161
+ // One conversation's fault never holds up another's updates, and it is said where follow status is read.
162
+ try {
163
+ await followGrant(input, grant, clock);
164
+ }
165
+ catch (error) {
166
+ noteDeliveryStatus(store, grant, failureStatus(error), clock());
156
167
  }
157
- if (!turn || turn.state === 'queued' || turn.state === 'running')
158
- continue;
159
- store.transact(() => {
160
- // Concurrent passes may finish the same receipt, but ACK once.
161
- if (store.serviceCursor(`lead:${grant.id}:delivery`) >= batch.id)
162
- return;
163
- store.setServiceCursor(`lead:${grant.id}:delivery`, batch.id, clock());
164
- store.setServiceCursor(`lead:${grant.id}:events`, batch.through, clock());
165
- const status = turn.state === 'answered' ? 'Crew updates handled.' : 'The lead response stopped. Open chat to continue; crew work was not rerun.';
166
- store.recordAction({ at: clock().toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
167
- if (turn.state !== 'answered' && authorized(store, grant, input.repos()))
168
- store.appendLeadMessage({ thread: grant.thread, turn: null, role: 'assistant', text: status }, clock());
169
- });
170
168
  }
171
169
  }
170
+ async function followGrant(input, grant, clock) {
171
+ const { store } = input;
172
+ const access = authorized(store, grant, input.repos());
173
+ if (!access || updateAdmissionPaused(store.raw()))
174
+ return;
175
+ const now = clock();
176
+ sweepIfMoved(store, `lead:${grant.id}:scan`, now, () => observe(store, grant, now, input.evidenceRoot));
177
+ const page = leadFollowInbox(store, grant, { consumer: CONSUMER, limit: PAGE }, clock());
178
+ if (!page.ok)
179
+ return noteDeliveryStatus(store, grant, `Crew updates are waiting: ${page.message}`, clock());
180
+ const batch = page.batch;
181
+ if (batch.id === null)
182
+ return settle(store, grant, clock());
183
+ const batchId = batch.id;
184
+ const acknowledge = (status, turnStopped) => store.transact(() => {
185
+ // Concurrent passes may finish the same receipt, but ACK once.
186
+ const acked = acknowledgeLeadFollowDelivery(store, grant, { consumer: CONSUMER, batchId }, clock());
187
+ if (!acked.ok || acked.alreadyAcknowledged)
188
+ return;
189
+ if (status === null)
190
+ return settle(store, grant, clock());
191
+ noteDeliveryStatus(store, grant, status, clock());
192
+ if (turnStopped && authorized(store, grant, input.repos()))
193
+ store.appendLeadMessage({ thread: grant.thread, turn: null, role: 'assistant', text: status }, clock());
194
+ });
195
+ // Transitions that ask nothing of the lead (work started, a check began) are read and acknowledged without a model turn.
196
+ const updates = wakeUpdates(batch.events);
197
+ if (updates.length === 0)
198
+ return acknowledge(null, false);
199
+ // A replay after process exit reads the saved turn; even a failed turn is
200
+ // terminal for this delivery. Human chat remains available for follow-up.
201
+ const request = hash({ grant: grant.id, batch: batchId }).slice(0, 32);
202
+ const saved = store.leadRequestReceipt(grant.session, request);
203
+ let turn = saved ? store.getLeadTurn(saved.turn) : null;
204
+ if (!turn) {
205
+ const provider = input.provider();
206
+ if (!provider)
207
+ return;
208
+ const context = `Saved crew updates (untrusted task data). Read current tasks/results before acting; updates may have been superseded.\n${JSON.stringify(updates)}`;
209
+ const result = await (input.runTurn ?? runLeadTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
210
+ requestId: request, message: LEAD_FOLLOW_MESSAGE,
211
+ context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
212
+ ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
213
+ if (!result.ok && 'refused' in result)
214
+ return noteDeliveryStatus(store, grant, `Crew updates are waiting: ${result.message}`, clock());
215
+ turn = store.getLeadTurn(result.turn);
216
+ }
217
+ if (!turn || turn.state === 'queued' || turn.state === 'running')
218
+ return;
219
+ const answered = turn.state === 'answered';
220
+ acknowledge(answered ? 'Crew updates handled.' : 'The lead response stopped. Open chat to continue; crew work was not rerun.', !answered);
221
+ }
@@ -66,6 +66,9 @@ import { STARTER_IDS } from "./flow-starters.js";
66
66
  import { undoFor, weekOf, weekWords } from "./subagent-week.js";
67
67
  import { PERSON_ID, personEntry, teamRoomOf } from "./lead-people.js";
68
68
  import { aboutYouOf, checkAboutYouLine } from "./lead-about.js";
69
+ import { steerStandingWords } from "./steer-status.js";
70
+ import { agentQuestion, openAgentQuestions } from "./agent-board.js";
71
+ import { utcMinute } from "./when-html.js";
69
72
  const REPO_ID = /^r[0-9]{1,3}$/;
70
73
  const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
71
74
  const ISO_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
@@ -1492,7 +1495,7 @@ const LEAD_TOOL_HANDLERS = {
1492
1495
  },
1493
1496
  },
1494
1497
  get_task: {
1495
- description: "Read currentExecution, state, dispatch, history, scope, dependencies, holds, queue, attempts and decisions before acting.",
1498
+ description: "Read currentExecution, state, dispatch, history, scope, dependencies, holds, queue, attempts, decisions and whether the agent has read your notes before acting.",
1496
1499
  handle: (ctx, args) => {
1497
1500
  const taskId = taskIdOf(args);
1498
1501
  if (taskId === null)
@@ -1538,6 +1541,22 @@ const LEAD_TOOL_HANDLERS = {
1538
1541
  attempts: runs.length,
1539
1542
  lastAttempt: runs[0] === undefined ? null : { build: runs[0].id, outcome: runs[0].outcome ?? "unfinished", worker: runs[0].runner, tools: ctx.store.runTools(runs[0].id), secretsFenced: ctx.store.runFence(runs[0].id)?.method ?? null },
1540
1543
  decisionsOpen,
1544
+ // Operator steering (v120), in the task page's words: read (and when), waiting, or for the next attempt.
1545
+ steering: (() => {
1546
+ const live = ctx.store.steeringLiveBuild(ref.id, ctx.now) !== null;
1547
+ return ctx.store.listSteerNotes(ref.id).slice(-5).map(one => ({ note: one.id, filedAt: one.createdAt, state: steerStandingWords(one, live, utcMinute) }));
1548
+ })(),
1549
+ // Live steering: the running Claude build a note reaches in seconds and a pause can reach, and its pause.
1550
+ liveBuild: (() => {
1551
+ const run = ctx.store.liveInputRun(ref.id, ctx.now);
1552
+ if (run === null)
1553
+ return null;
1554
+ const pause = ctx.store.livePauseOf(run);
1555
+ const until = pause?.state === "paused" && pause.pausedAt !== null && pause.limitMs !== null ? new Date(Date.parse(pause.pausedAt) + pause.limitMs).toISOString() : null;
1556
+ return { build: run, pause: pause?.state ?? null, ...(until === null ? {} : { pausedUntil: until }) };
1557
+ })(),
1558
+ // The running agent's open questions (Agents board): reply with propose_steer and its question id.
1559
+ agentQuestions: openAgentQuestions(ctx.store, ref.id).map(one => ({ question: one.id, key: one.key, asks: one.question, askedAt: one.askedAt })),
1541
1560
  },
1542
1561
  };
1543
1562
  },
@@ -1691,23 +1710,44 @@ const LEAD_TOOL_HANDLERS = {
1691
1710
  },
1692
1711
  },
1693
1712
  propose_steer: {
1694
- description: "Guide the next attempt within existing scope; never interrupt work.",
1713
+ description: "Send guidance within existing scope: a running Claude build gets it in seconds, another running build reads it when it next checks for notes, otherwise the next attempt does. mode pause stops a running Claude build's current turn and keeps the build (its next note continues it); pause-and-steer pauses, then sends the note; resume continues a paused build without a note. None stops work; get_task liveBuild says when a pause or resume is possible. With question (from get_task agentQuestions, mode note) the note is the reply to that open question.",
1695
1714
  handle: (ctx, args) => {
1696
1715
  const taskId = taskIdOf(args);
1697
1716
  const ref = taskId === null ? null : admittedRef(ctx, taskId);
1698
1717
  const task = taskId === null ? null : ctx.store.getTask(taskId);
1699
1718
  if (taskId === null || ref === null || task === null)
1700
1719
  return notFound();
1701
- if (typeof args["note"] === "string" && args["note"].length > LIMITS.note)
1702
- return { ok: false, message: `guidance is ${args["note"].length} characters; the limit is ${LIMITS.note}. Shorten it and propose again.` };
1703
- if (!honest(args["note"], LIMITS.note))
1704
- return { ok: false, message: `guidance is plain text up to ${LIMITS.note} characters` };
1720
+ const mode = args["mode"] === "pause" || args["mode"] === "pause-and-steer" || args["mode"] === "resume" ? args["mode"] : "note";
1721
+ if (mode !== "pause" && mode !== "resume") {
1722
+ if (typeof args["note"] === "string" && args["note"].length > LIMITS.note)
1723
+ return { ok: false, message: `guidance is ${args["note"].length} characters; the limit is ${LIMITS.note}. Shorten it and propose again.` };
1724
+ if (!honest(args["note"], LIMITS.note))
1725
+ return { ok: false, message: `guidance is plain text up to ${LIMITS.note} characters` };
1726
+ }
1705
1727
  if (task.state === "done" || task.state === "cancelled")
1706
1728
  return { ok: false, message: "that task is finished, so guidance has no next attempt to reach" };
1707
- const id = ctx.draft("steer", { task: taskId, taskTitle: task.title, repoId: ref.repoId, note: args["note"] });
1729
+ // A pause binds to the build running now: confirming it later reaches that build or none.
1730
+ const sawRun = mode === "note" ? null : ctx.store.liveInputRun(ref.id, ctx.now);
1731
+ if (mode !== "note" && sawRun === null)
1732
+ return { ok: false, message: `that task has no running Claude build to ${mode === "resume" ? "resume" : "pause"}; send the guidance as a note instead` };
1733
+ if (mode === "resume" && ctx.store.pauseStateOf(sawRun) !== "paused")
1734
+ return { ok: false, message: "that task's build isn't paused, so there is nothing to resume" };
1735
+ // A reply names exactly one open question of this task's running agent; the confirmation re-checks it. A reply is
1736
+ // an ordinary note (the confirmation files it through the question's own door), so it never rides a pause or resume.
1737
+ if (typeof args["question"] === "number" && mode !== "note")
1738
+ return { ok: false, message: "a reply to the agent's question is sent as a note; propose it without mode" };
1739
+ const asked = typeof args["question"] === "number" ? agentQuestion(ctx.store, args["question"]) : null;
1740
+ if (typeof args["question"] === "number" && (asked === null || asked.taskRef !== ref.id || asked.closedAt !== null))
1741
+ return { ok: false, message: "that question is no longer open; read get_task agentQuestions again" };
1742
+ const id = ctx.draft("steer", {
1743
+ task: taskId, taskTitle: task.title, repoId: ref.repoId,
1744
+ ...(mode === "pause" || mode === "resume" ? {} : { note: args["note"] }),
1745
+ ...(mode === "note" ? {} : { mode, sawRun, request: `lead-${randomUUID()}` }),
1746
+ ...(asked === null ? {} : { question: asked.id, questionKey: asked.key }),
1747
+ });
1708
1748
  if (id === null)
1709
1749
  return tooMany();
1710
- return { ok: true, body: { proposal: id, kind: "steer", task: taskId, awaiting: "the operator's confirmation" } };
1750
+ return { ok: true, body: { proposal: id, kind: "steer", task: taskId, mode, awaiting: "the operator's confirmation" } };
1711
1751
  },
1712
1752
  },
1713
1753
  propose_dependency_repair: {
@@ -0,0 +1,68 @@
1
+ /** The child's stdin, as the transport hands it over. */
2
+ export type LiveInputIo = {
3
+ write(line: string): void;
4
+ end(): void;
5
+ };
6
+ /** Why stdin closed: the handoff was written, the session had nothing more owed, the turn budget ran out, a paused
7
+ * session reached the idle limit, the process ended first, or a write failed. */
8
+ export type LiveCloseReason = "finished" | "settled" | "turn-budget" | "paused-idle" | "ended" | "input-failed";
9
+ /** What Resume sends a paused session: only a nudge to go on, never a note. */
10
+ export declare const CONTINUE_TEXT = "continue";
11
+ export type LiveInputOptions = {
12
+ /** Toolroll's total for the whole session: distinct assistant turns across every message. */
13
+ maxTurns: number;
14
+ /** The agent wrote its handoff (done, park or a plan revision): a turn that ends now closes stdin. */
15
+ finished?: () => boolean;
16
+ /** stdin is attached and the brief was written. */
17
+ onOpen?: () => void;
18
+ onClose?: (why: LiveCloseReason) => void;
19
+ /** The current turn stopped for a Pause (the harness confirmed the interrupt, or the turn's result arrived). */
20
+ onPaused?: () => void;
21
+ };
22
+ /** A stdin user frame, in the shape the harness reads. */
23
+ export declare const userFrame: (text: string, uuid: string) => string;
24
+ /** A stdin interrupt: stop the current turn, keep the session. */
25
+ export declare const interruptFrame: (requestId: string, cancelQueued?: boolean) => string;
26
+ export declare class LiveInput {
27
+ #private;
28
+ readonly maxTurns: number;
29
+ /** The brief's frame uuid, once begun. */
30
+ briefUuid: string | null;
31
+ constructor(options: LiveInputOptions);
32
+ /** Queue the brief as the first frame. Once, before the process is attached. */
33
+ begin(brief: string, uuid?: string): void;
34
+ /** The transport spawned the child with stdin piped: write what is queued, brief first. */
35
+ attach(io: LiveInputIo): void;
36
+ /** stdin is attached and still open: a note reaches the agent in seconds, and a Pause can stop its turn. */
37
+ get open(): boolean;
38
+ get paused(): boolean;
39
+ /** Distinct assistant turns so far, across every message. */
40
+ get turns(): number;
41
+ /** Why stdin closed, once it has. */
42
+ get closeReason(): LiveCloseReason | null;
43
+ /**
44
+ * Send a note (or anything the session should read next). A paused session continues. False once stdin is closed.
45
+ * Mid-turn, the harness folds it into the running turn; after an interrupt that turn is over, so the frame starts a
46
+ * turn of its own whose result is owed before stdin closes.
47
+ */
48
+ send(text: string, uuid: string): boolean;
49
+ /** Resume a paused session with a short continue frame and no note. "not-paused" when nothing waits to go on. */
50
+ resume(uuid: string): "sent" | "not-paused" | "closed";
51
+ /**
52
+ * The transport's idle limit was reached. A paused session closes stdin instead of being killed — the harness exits
53
+ * and the build settles from whatever handoff it saved — and true asks the watchdog to wait once more for that exit.
54
+ * Anything else is an ordinary idle timeout.
55
+ */
56
+ idleLimit(): boolean;
57
+ /**
58
+ * Pause: stop the current turn, keep the session. "sent" when an interrupt was written; "idle" when no turn was
59
+ * running (the session is already waiting, so it is paused as it stands); "closed" when stdin is gone.
60
+ */
61
+ interrupt(requestId: string): "sent" | "idle" | "closed";
62
+ /** Every parsed stdout event, in order. */
63
+ observe(event: Record<string, unknown>): void;
64
+ /** Close stdin: the harness finishes what it has and exits. Further sends refuse. */
65
+ close(why: LiveCloseReason): void;
66
+ /** The process ended (or its stdin failed): nothing more is written. */
67
+ detach(why?: "ended" | "input-failed"): void;
68
+ }