frizz 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +9 -4
  2. package/dist/claude-agent-broker.js +9 -4
  3. package/dist/dev-child.js +1783 -527
  4. package/dist/frizz.js +709 -233
  5. package/package.json +5 -3
  6. package/runtime/cc-worker/DECISIONS.md +17 -0
  7. package/runtime/cc-worker/bin/frizz-mcp.mjs +151 -174
  8. package/runtime/cc-worker/hooks/session-seed.mjs +1 -1
  9. package/web-dist/assets/{TerminalPane-CTdetDJJ.js → TerminalPane-sd3XXjx5.js} +1 -1
  10. package/web-dist/assets/{abnfDiagram-VRR7QNED-BDKrjCMs.js → abnfDiagram-VRR7QNED-Bry_FIqg.js} +1 -1
  11. package/web-dist/assets/architecture-TIHT7OUA-CoMJQ5Ya.js +1 -0
  12. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-BJebpUUM.js → architectureDiagram-ZJ3FMSHR-Baok4OeI.js} +1 -1
  13. package/web-dist/assets/{blockDiagram-677ZJIJ3-BNbuk25k.js → blockDiagram-677ZJIJ3-BgHIO9lI.js} +1 -1
  14. package/web-dist/assets/{c4Diagram-LMCZKHZV-lCfyotdU.js → c4Diagram-LMCZKHZV-rfj9Z17Z.js} +1 -1
  15. package/web-dist/assets/channel-D6uS1y7I.js +1 -0
  16. package/web-dist/assets/{chunk-32BRIVSS-DP72SEkr.js → chunk-32BRIVSS-BwaHfK1Y.js} +1 -1
  17. package/web-dist/assets/{chunk-52WLFC77-DxO3gN-p.js → chunk-52WLFC77-BmdQDZOA.js} +1 -1
  18. package/web-dist/assets/{chunk-C7G6YPKG-BXFx9Vlr.js → chunk-C7G6YPKG-ByQFjoZe.js} +1 -1
  19. package/web-dist/assets/{chunk-EX3LRPZG-CR3sHpPf.js → chunk-EX3LRPZG-DQFudBKr.js} +1 -1
  20. package/web-dist/assets/{chunk-FWX5IMBZ-CJ8L__oG.js → chunk-FWX5IMBZ-OW9qEPiq.js} +2 -2
  21. package/web-dist/assets/{chunk-HOUHSVGY-BCl3JSWr.js → chunk-HOUHSVGY-BdKLF8nO.js} +1 -1
  22. package/web-dist/assets/{chunk-ICXQ74PX-CZxFMci1.js → chunk-ICXQ74PX-C1roqKR1.js} +1 -1
  23. package/web-dist/assets/{chunk-MOJQB5TN-Ck3_47dB.js → chunk-MOJQB5TN-Cx0u1ZDK.js} +1 -1
  24. package/web-dist/assets/{chunk-OGEWGWER-CVXjES-F.js → chunk-OGEWGWER-rxv9xtNR.js} +1 -1
  25. package/web-dist/assets/{chunk-PUDLZKDR-JjrVgaR7.js → chunk-PUDLZKDR-BXWPPPPi.js} +1 -1
  26. package/web-dist/assets/{chunk-Q4XR5HBZ-C5YMRAQ0.js → chunk-Q4XR5HBZ-CpqbD101.js} +1 -1
  27. package/web-dist/assets/{chunk-V7JOEXUC-DK0mfUHU.js → chunk-V7JOEXUC-CP8_3qVt.js} +1 -1
  28. package/web-dist/assets/{chunk-VAUOI2AC-BKBntpid.js → chunk-VAUOI2AC-Dl7xahpk.js} +1 -1
  29. package/web-dist/assets/{chunk-VR4S4FIN-zgE1dG6U.js → chunk-VR4S4FIN-BKtMX64l.js} +1 -1
  30. package/web-dist/assets/{chunk-WYO6CB5R-CZhh1IBq.js → chunk-WYO6CB5R-DmLOdJcu.js} +1 -1
  31. package/web-dist/assets/{chunk-ZGVPDNZ5-CrNjIEem.js → chunk-ZGVPDNZ5-5wN7eXvS.js} +1 -1
  32. package/web-dist/assets/classDiagram-OUVF2IWQ-BW5YiBf4.js +1 -0
  33. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BW5YiBf4.js +1 -0
  34. package/web-dist/assets/{cynefin-VYW2F7L2-Ca_BPfTG.js → cynefin-VYW2F7L2-DUYahBXb.js} +1 -1
  35. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-CsINQf6g.js → cynefinDiagram-TSTJHNR4-Rm0i30nE.js} +1 -1
  36. package/web-dist/assets/{dagre-VKFMJZFB-CE0DYMFy.js → dagre-VKFMJZFB-3uhsB-W3.js} +1 -1
  37. package/web-dist/assets/{diagram-FQU43EPY-BqTduEYE.js → diagram-FQU43EPY-BUvflNRQ.js} +1 -1
  38. package/web-dist/assets/{diagram-G47NLZAW-Cho718s6.js → diagram-G47NLZAW-DUluIZel.js} +1 -1
  39. package/web-dist/assets/{diagram-NH7WQ7WH-D0Z8t9UC.js → diagram-NH7WQ7WH-BBrwsbed.js} +1 -1
  40. package/web-dist/assets/{diagram-OA4YK3LP-CBacyeDx.js → diagram-OA4YK3LP-DWnTQBw4.js} +1 -1
  41. package/web-dist/assets/{diagram-WEI45ONY-CozpXa4j.js → diagram-WEI45ONY-BjEavoC0.js} +1 -1
  42. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-ba4NbQQo.js → ebnfDiagram-CCIWWBDH-CW4xZAZH.js} +1 -1
  43. package/web-dist/assets/{erDiagram-Q63AITRT-B3BciOYa.js → erDiagram-Q63AITRT-BemQ1R4B.js} +1 -1
  44. package/web-dist/assets/eventmodeling-45OFAUF4-BF5KB7ja.js +1 -0
  45. package/web-dist/assets/flowDiagram-23GEKE2U-BYo4Eytx.js +1 -0
  46. package/web-dist/assets/{ganttDiagram-NO4QXBWP-lPmrRit4.js → ganttDiagram-NO4QXBWP-lDdG5I_z.js} +1 -1
  47. package/web-dist/assets/{gitGraph-TEB2WS4Q-BjyclOq0.js → gitGraph-TEB2WS4Q-Bw87a_Se.js} +1 -1
  48. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-pzeb4Yrw.js → gitGraphDiagram-IHSO6WYX-B762r0yi.js} +1 -1
  49. package/web-dist/assets/index-B3QArXu2.css +1 -0
  50. package/web-dist/assets/index-CAapfhMv.js +365 -0
  51. package/web-dist/assets/{info-DKCQHKI2-Bwycegvf.js → info-DKCQHKI2-DGOv6Gx0.js} +1 -1
  52. package/web-dist/assets/{infoDiagram-FWYZ7A6U-BCvRGj_5.js → infoDiagram-FWYZ7A6U-DL92kAzf.js} +1 -1
  53. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-Ofw1RMj3.js → ishikawaDiagram-FXEZZL3T-CFYd4OtU.js} +1 -1
  54. package/web-dist/assets/{journeyDiagram-5HDEW3XC-C5ROwFio.js → journeyDiagram-5HDEW3XC-QHuYL5zq.js} +1 -1
  55. package/web-dist/assets/{kanban-definition-HUTT4EX6-YLPLkpeT.js → kanban-definition-HUTT4EX6-DwmMluQ8.js} +1 -1
  56. package/web-dist/assets/{line-KtkNqRgI.js → line-Je_PFFHm.js} +1 -1
  57. package/web-dist/assets/{mermaid-parser.core-D_FfqBe7.js → mermaid-parser.core-BeiGWsn3.js} +3 -3
  58. package/web-dist/assets/{mermaid.core-Ffv8anVf.js → mermaid.core-BP8JrW48.js} +3 -3
  59. package/web-dist/assets/{mindmap-definition-LN4V7U3C-B2jj4vfm.js → mindmap-definition-LN4V7U3C-BsmjCIrM.js} +1 -1
  60. package/web-dist/assets/{packet-7NZHBO7P-BjmWHwra.js → packet-7NZHBO7P-D6ekYYGJ.js} +1 -1
  61. package/web-dist/assets/{pegDiagram-2B236MQR-Dq3iJDyq.js → pegDiagram-2B236MQR-CQc2Kt0u.js} +1 -1
  62. package/web-dist/assets/{pie-RZYD4A2V-jBbH1lv9.js → pie-RZYD4A2V-C_45fO8J.js} +1 -1
  63. package/web-dist/assets/{pieDiagram-ENE6RG2P-C3ETW9lq.js → pieDiagram-ENE6RG2P-Dg5z6iWt.js} +1 -1
  64. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DItSmme7.js → quadrantDiagram-ABIIQ3AL-c7gZxuCX.js} +1 -1
  65. package/web-dist/assets/{radar-I7S5WNFK-6ey6crgP.js → radar-I7S5WNFK-D80XNJQq.js} +1 -1
  66. package/web-dist/assets/{railroad-3IZDKUUU-Cii-Mn0E.js → railroad-3IZDKUUU-Brz7eZQ0.js} +1 -1
  67. package/web-dist/assets/railroad-abnf-AHOZXSZD-D44pcGhJ.js +1 -0
  68. package/web-dist/assets/railroad-ebnf-EBAXGLYW-CiWVH5SP.js +1 -0
  69. package/web-dist/assets/railroad-peg-LSFZ7HO6-Cy1B6-pN.js +1 -0
  70. package/web-dist/assets/{railroadDiagram-RFXS5EU6-M363ils_.js → railroadDiagram-RFXS5EU6-Db9K7bty.js} +1 -1
  71. package/web-dist/assets/{requirementDiagram-TGXJPOKE-CTs2_V6T.js → requirementDiagram-TGXJPOKE-m1ipMrDe.js} +1 -1
  72. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-QTLLcDPD.js → sankeyDiagram-HTMAVEWB-4Zz9ry0k.js} +1 -1
  73. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-Bxw9Tr6e.js → sequenceDiagram-DBY2YBRQ-CZeGHw4X.js} +1 -1
  74. package/web-dist/assets/{stateDiagram-2N3HPSRC-BecB6roG.js → stateDiagram-2N3HPSRC-rLUVs1Pg.js} +1 -1
  75. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-w7QDFEvx.js +1 -0
  76. package/web-dist/assets/{swimlanes-5IMT3BWC-COiYgS0w.js → swimlanes-5IMT3BWC-BAeBdscz.js} +1 -1
  77. package/web-dist/assets/swimlanesDiagram-G3AALYLV-UhdgWpNZ.js +8 -0
  78. package/web-dist/assets/{timeline-definition-FHXFAJF6-Ci22coeH.js → timeline-definition-FHXFAJF6-DL0eWMF-.js} +1 -1
  79. package/web-dist/assets/{treeView-QDETBFTQ-4DW35czh.js → treeView-QDETBFTQ-gEtzLMA8.js} +1 -1
  80. package/web-dist/assets/{treemap-6X3UGDF4-Dq_-Z6-Y.js → treemap-6X3UGDF4-CCym1Gz6.js} +1 -1
  81. package/web-dist/assets/{vennDiagram-L72KCM5P-CHQSIEYq.js → vennDiagram-L72KCM5P-cF97-nb5.js} +1 -1
  82. package/web-dist/assets/{wardley-OPB4EBWU-Bwlh7HCY.js → wardley-OPB4EBWU-DQJHCGbM.js} +1 -1
  83. package/web-dist/assets/{wardleyDiagram-EHGQE667-DctjuPYn.js → wardleyDiagram-EHGQE667-0CnHzXLX.js} +1 -1
  84. package/web-dist/assets/{xychartDiagram-FW5EYKEG-ZoIGosv8.js → xychartDiagram-FW5EYKEG-9YDOp4m5.js} +1 -1
  85. package/web-dist/index.html +2 -2
  86. package/web-dist/assets/architecture-TIHT7OUA-ChUMo004.js +0 -1
  87. package/web-dist/assets/channel-pr7r6raB.js +0 -1
  88. package/web-dist/assets/classDiagram-OUVF2IWQ-CvGbPMn_.js +0 -1
  89. package/web-dist/assets/classDiagram-v2-EOCWNBFH-CvGbPMn_.js +0 -1
  90. package/web-dist/assets/eventmodeling-45OFAUF4-DKmyo-jd.js +0 -1
  91. package/web-dist/assets/flowDiagram-23GEKE2U-DluCBvT4.js +0 -1
  92. package/web-dist/assets/index-BQtjYMpV.css +0 -1
  93. package/web-dist/assets/index-CLW1Q49U.js +0 -360
  94. package/web-dist/assets/railroad-abnf-AHOZXSZD-U_vb4BrX.js +0 -1
  95. package/web-dist/assets/railroad-ebnf-EBAXGLYW-BIHG7gNU.js +0 -1
  96. package/web-dist/assets/railroad-peg-LSFZ7HO6-Cpd9r-tB.js +0 -1
  97. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-JXw9T96l.js +0 -1
  98. package/web-dist/assets/swimlanesDiagram-G3AALYLV--1Wv7FQQ.js +0 -8
package/dist/dev-child.js CHANGED
@@ -5541,13 +5541,27 @@ function isAllowedAttachmentName(name) {
5541
5541
  function isDirectSubAgent(agent) {
5542
5542
  return (agent.depth ?? 1) === 1;
5543
5543
  }
5544
- function isValidAwaitingTimer(value) {
5545
- const s = value.trim();
5546
- return AWAITING_TIMER_RE.test(s) && Number.isFinite(Date.parse(s));
5544
+ function retiredAwaitingKindsIn(body) {
5545
+ if (!body || !RETIRED_LINE_RE.test(body)) return [];
5546
+ const found = [];
5547
+ for (const line of body.split("\n")) {
5548
+ const m = /^\s*([a-z-]+):\s*\S/i.exec(line);
5549
+ const kind = m?.[1].toLowerCase();
5550
+ if (!kind) continue;
5551
+ const hit = RETIRED_AWAITING_KINDS.find((k) => k === kind);
5552
+ if (hit && !found.includes(hit)) found.push(hit);
5553
+ }
5554
+ return found;
5547
5555
  }
5548
- function canonicalSnoozeInstant(value) {
5549
- if (!isValidAwaitingTimer(value)) return null;
5550
- return new Date(Date.parse(value.trim())).toISOString();
5556
+ function isAwaitingItemKind(kind) {
5557
+ return AWAITING_ITEM_KINDS.includes(kind);
5558
+ }
5559
+ function parseAwaitingDuration(value) {
5560
+ const m = AWAITING_DURATION_RE.exec(value.trim());
5561
+ if (!m) return null;
5562
+ const ms = Number(m[1]) * DURATION_UNIT_MS[m[2]];
5563
+ if (!Number.isFinite(ms) || ms <= 0) return null;
5564
+ return Math.min(ms, AWAITING_FOR_MAX_MS);
5551
5565
  }
5552
5566
  function saysAllDone(text) {
5553
5567
  if (typeof text !== "string") return false;
@@ -5557,10 +5571,11 @@ function saysAllDone(text) {
5557
5571
  }
5558
5572
  return false;
5559
5573
  }
5560
- function restPromptMessage(prompt) {
5574
+ function restPromptMessage(prompt, opts = {}) {
5575
+ const note = opts.overQuestion ? `${OVER_QUESTION_NOTE} ${OPT_OUT_NOTE}` : OPT_OUT_NOTE;
5561
5576
  return `${prompt.trim()}
5562
5577
 
5563
- (Goal \u2014 sent each time you come to rest. ${OPT_OUT_NOTE})`;
5578
+ (Goal \u2014 sent each time you come to rest. ${note})`;
5564
5579
  }
5565
5580
  function compactionPromptMessage(prompt) {
5566
5581
  return `${prompt.trim()}
@@ -5580,11 +5595,55 @@ function formatIntervalLabel(seconds) {
5580
5595
  const hours = minutes / 60;
5581
5596
  return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} hr`;
5582
5597
  }
5583
- function watchWakeMessage(kind, target, detail) {
5584
- const what = kind === "shell" ? `your background shell \`${target}\`` : `${target}`;
5585
- return `\u23F0 ${detail} (${what}).
5598
+ function prWatchWakeMessage(input) {
5599
+ const lines = [];
5600
+ if (input.merged || input.closed) {
5601
+ lines.push(`\u23F0 ${input.target} was ${input.merged ? "MERGED" : "CLOSED"}.`, "");
5602
+ lines.push("(This watcher is spent \u2014 there is nothing further to report on a finished PR.)");
5603
+ return lines.join("\n");
5604
+ }
5605
+ if (input.checks) {
5606
+ const c = input.checks;
5607
+ lines.push(
5608
+ c.verdict === "passing" ? `\u2705 CI PASSED on ${input.target} \u2014 ${c.passed} check${c.passed === 1 ? "" : "s"} green.` : `\u274C CI FAILED on ${input.target}${c.failing.length ? `: ${c.failing.join(", ")}` : ""}.`
5609
+ );
5610
+ }
5611
+ if (input.review) {
5612
+ if (lines.length) lines.push("");
5613
+ lines.push(input.review);
5614
+ }
5615
+ lines.push("", "(Registered PR watcher \u2014 STILL ARMED. It reports again on the next CI change, review or comment. Drop it with `mcp__frizz__watch_pr` when it stops mattering.)");
5616
+ return lines.join("\n");
5617
+ }
5618
+ function shellDoneMessage(shell) {
5619
+ const what = shell.taskId ? `\`${shell.taskId}\` \u2014 ${shell.label}` : shell.label;
5620
+ const verb = shell.status === "failed" ? "FAILED" : shell.status === "killed" ? "was STOPPED" : "finished";
5621
+ return `\u23F0 Your background shell ${verb}: ${what}.
5586
5622
 
5587
- (Registered watcher \u2014 it has fired and is no longer armed. Register another with \`mcp__frizz__watch\` if you need to keep waiting.)`;
5623
+ (Frizz sends this because it finished after you came to rest, where your runtime's own completion notification does not reach you. Read its output if you still need it.)`;
5624
+ }
5625
+ function liveOpsLines(ops) {
5626
+ const lines = [];
5627
+ const section = (heading, kind, items) => {
5628
+ if (!items.length) return;
5629
+ lines.push("", heading);
5630
+ for (const i of items) lines.push(`- \`${kind}: ${i.id ?? "?"}\` \u2014 ${i.label}`);
5631
+ };
5632
+ section("Background shells still running:", "shell", ops?.shells ?? []);
5633
+ section("Sub-agents still running (they re-invoke you on their own, so parking on one is optional):", "agent", ops?.subAgents ?? []);
5634
+ section("Timers you have armed:", "timer", ops?.timers ?? []);
5635
+ section("Pull requests you registered:", "pr", ops?.prs ?? []);
5636
+ return lines;
5637
+ }
5638
+ function signoffNudgeMessage(ops) {
5639
+ const lines = liveOpsLines(ops);
5640
+ if (lines.length) {
5641
+ lines.push("", "An ```awaiting fence takes one such line per thing you are ACTUALLY waiting on, plus a");
5642
+ lines.push("required `for:` duration (`30s`/`15m`/`2h`/`3d`) and a one-line `reason:`. Frizz checks every");
5643
+ lines.push("id: name something that is not running and you are bumped rather than parked.");
5644
+ }
5645
+ return lines.length === 0 ? SIGNOFF_NUDGE_MESSAGE : `${SIGNOFF_NUDGE_MESSAGE}
5646
+ ${lines.join("\n")}`;
5588
5647
  }
5589
5648
  function timerPromptMessage(prompt, fireAt) {
5590
5649
  return `${prompt.trim()}
@@ -5692,7 +5751,7 @@ function parseGithubWakeSteer(text) {
5692
5751
  function frizzRoute(path) {
5693
5752
  return `${FRIZZ_ROUTE_PREFIX}${path.startsWith("/") ? path : `/${path}`}`;
5694
5753
  }
5695
- var ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, NativeInputRequired, AwaitingHint, AWAITING_TIMER_RE, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, DEFAULT_GOAL_TRIGGERS, DEFAULT_RECURRING_PROMPT, RecurringIntervalSeconds, ThreadRecurringPrompt, ALLDONE_SENTINEL, OPT_OUT_NOTE, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TIMER_MAX_ARMED, WATCH_MAX_ARMED, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, ThreadWatchKind, RegisterableThreadWatchKind, ThreadWatchTarget, ThreadWatchView, ThreadTimerView, ThreadFence, PlanView, LimitWindow, LimitPause, ThreadView, BoardErrorItem, BoardSnapshot, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountLogoutInput, AccountLogoutResult, AccountLoginStartInput, AccountLoginStartResult, AccountLoginStatusInput, AccountLoginStatusResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, FollowUpInput, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, SetThreadSnoozeInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, AddOwnThreadWatchInput, PromoteOwnThreadWatchInput, PromoteOwnThreadWatchResult, DropOwnThreadWatchInput, ListOwnThreadWatchesInput, OwnThreadWatchesResult, AddOwnThreadWatchResult, DropOwnThreadWatchResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, ThreadPluginReloadResult, ConfirmAwaitingInput, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, SetThreadProfileInput, SetThreadProfileResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, GITHUB_DISPATCH_UI_BOUNDARY, WAKE_DELIVERY_TOKEN_TAIL, WAKE_DELIVERY_TOKEN_LINE, AGENT_MESSAGE_WRAPPER, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REVIEW_LEAD, WAKE_BACKLOG_TAIL, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, WAKE_ITEM, WAKE_MORE, GithubStatus, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, ServerEvent, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketClientMsg, ProjectCard, PROJECT_ICON_EXTENSIONS, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT, threadIdentityName;
5754
+ var ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, NativeInputRequired, AwaitingHint, RETIRED_AWAITING_KINDS, RETIRED_LINE_RE, RETIRED_AWAITING_REPLACEMENT, AWAITING_ITEM_KINDS, AWAITING_DURATION_RE, DURATION_UNIT_MS, AWAITING_FOR_MAX_MS, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, RecurringIntervalSeconds, ThreadRecurringPrompt, ALLDONE_SENTINEL, OPT_OUT_NOTE, OVER_QUESTION_NOTE, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TIMER_MAX_ARMED, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, ThreadWatchKind, GithubChecksState, GithubMergeState, GithubWatchStatus, ThreadWatchView, PR_WATCH_DEFAULT_FOR_MS, PR_WATCH_MAX_ARMED, PrWatchView, AddOwnPrWatchInput, AddOwnPrWatchResult, DropOwnPrWatchInput, DropOwnPrWatchResult, ListOwnPrWatchesInput, OwnPrWatchesResult, ThreadTimerView, ThreadFence, PlanView, LimitWindow, LimitPause, ThreadView, BoardErrorItem, BoardSnapshot, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountLogoutInput, AccountLogoutResult, AccountLoginStartInput, AccountLoginStartResult, AccountLoginStatusInput, AccountLoginStatusResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, FollowUpInput, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, SetThreadSnoozeInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, ThreadActivityItem, ListOwnThreadActivityInput, OwnThreadActivityResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, ThreadPluginReloadResult, ConfirmAwaitingInput, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, SetThreadProfileInput, SetThreadProfileResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, GITHUB_DISPATCH_UI_BOUNDARY, WAKE_DELIVERY_TOKEN_TAIL, WAKE_DELIVERY_TOKEN_LINE, AGENT_MESSAGE_WRAPPER, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REVIEW_LEAD, WAKE_BACKLOG_TAIL, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, WAKE_ITEM, WAKE_MORE, GithubStatus, GithubRefCard, GithubRefPreviewInput, GithubRefPreviewResult, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, ServerEvent, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketClientMsg, ProjectCard, PROJECT_ICON_EXTENSIONS, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT, threadIdentityName;
5696
5755
  var init_src = __esm({
5697
5756
  "packages/shared/src/index.ts"() {
5698
5757
  "use strict";
@@ -5904,7 +5963,15 @@ var init_src = __esm({
5904
5963
  command: external_exports.string().optional(),
5905
5964
  // ISO8601 of the shell output file's last write — "last active 6 min ago" for a quiet-but-live
5906
5965
  // watcher. Optional (see SubAgentView.lastActivityAt).
5907
- lastActivityAt: external_exports.string().optional()
5966
+ lastActivityAt: external_exports.string().optional(),
5967
+ // The PROVIDER's session-wide background-task handle (`bzvtnt3ig`), as distinct from `id`, which is
5968
+ // the launch tool_use id. Both name the same shell and neither is a substitute for the other:
5969
+ // `id` is what the two copies of a row reconcile on, and this is the handle the runtime hands the
5970
+ // MODEL — "Command running in background with ID: bzvtnt3ig" is the only id a worker ever sees, so
5971
+ // it is the one it registers a `shell` watcher against. Matching on `id`/`label` alone meant every
5972
+ // such watcher was unfireable (scheduler.evalWatchers, 2026-08-14). Absent for a CODEX row, whose
5973
+ // single `processId` IS its `id`, and for a Claude row between its tool_use and its launch ack.
5974
+ taskId: external_exports.string().optional()
5908
5975
  });
5909
5976
  CompletionHoldOp = external_exports.object({
5910
5977
  label: external_exports.string(),
@@ -5941,10 +6008,22 @@ var init_src = __esm({
5941
6008
  title: external_exports.string().max(120)
5942
6009
  });
5943
6010
  AwaitingHint = external_exports.object({
5944
- kind: external_exports.enum(["pr-watch", "human", "timer", "pr", "ci", "session"]),
6011
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "for", "reason"]),
5945
6012
  value: external_exports.string()
5946
6013
  });
5947
- AWAITING_TIMER_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
6014
+ RETIRED_AWAITING_KINDS = ["watch", "pr-watch", "human", "ci", "session"];
6015
+ RETIRED_LINE_RE = new RegExp(`^\\s*(${RETIRED_AWAITING_KINDS.join("|")}):\\s*\\S`, "im");
6016
+ RETIRED_AWAITING_REPLACEMENT = {
6017
+ "watch": "`shell: <the id your runtime gave you>` (or `agent: <id>`) \u2014 the same id, on the current line kind",
6018
+ "pr-watch": "register the PR with `mcp__frizz__watch_pr`, then name it `pr: owner/repo#123`",
6019
+ "human": "there is no human gate any more \u2014 if you need a person, ask a ```question instead of parking",
6020
+ "ci": "CI is not a wait of its own: register the PR with `mcp__frizz__watch_pr` and you are woken when its checks settle",
6021
+ "session": "there is no cross-session wait \u2014 name the sub-agent you dispatched with `agent: <id>`"
6022
+ };
6023
+ AWAITING_ITEM_KINDS = ["shell", "agent", "timer", "pr"];
6024
+ AWAITING_DURATION_RE = /^(\d{1,5})(s|m|h|d)$/;
6025
+ DURATION_UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
6026
+ AWAITING_FOR_MAX_MS = 24 * 60 * 60 * 1e3;
5948
6027
  SnoozeUntil = external_exports.string().regex(
5949
6028
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
5950
6029
  "Snooze time must be an ISO-8601 UTC instant"
@@ -5958,13 +6037,6 @@ var init_src = __esm({
5958
6037
  RECURRING_MIN_INTERVAL_SECONDS = 60;
5959
6038
  RECURRING_MAX_INTERVAL_SECONDS = 24 * 60 * 60;
5960
6039
  RecurringPromptText = external_exports.string().trim().min(1).max(RECURRING_PROMPT_MAX);
5961
- DEFAULT_GOAL_TRIGGERS = {
5962
- stopHook: true,
5963
- heartbeat: false,
5964
- postCompaction: false,
5965
- pauseOnQuestions: true
5966
- };
5967
- DEFAULT_RECURRING_PROMPT = "If further work towards the original task/goal remains, keep going. If there are open questions that require human input, ask them with question fences.";
5968
6040
  RecurringIntervalSeconds = external_exports.number().int().min(RECURRING_MIN_INTERVAL_SECONDS).max(RECURRING_MAX_INTERVAL_SECONDS);
5969
6041
  ThreadRecurringPrompt = external_exports.object({
5970
6042
  prompt: external_exports.string(),
@@ -5974,12 +6046,6 @@ var init_src = __esm({
5974
6046
  stopHook: external_exports.boolean(),
5975
6047
  heartbeat: external_exports.boolean(),
5976
6048
  postCompaction: external_exports.boolean(),
5977
- /** NOT a fourth trigger — a HOLD over all three. While it is on, nothing is sent for as long as the
5978
- * thread is blocked on the human: an unanswered ```question fence, a native ask, or a permission
5979
- * prompt. The footer panel seeds it ON for a fresh arming, with the stop hook, because they are one
5980
- * intent — see scheduler.ts, "WHAT A PENDING QUESTION DOES TO ALL THREE TRIGGERS". The stored column
5981
- * still defaults OFF, so an existing row and an older caller both keep the behaviour they had. */
5982
- pauseOnQuestions: external_exports.boolean(),
5983
6049
  intervalSeconds: external_exports.number().int().positive().optional(),
5984
6050
  armedAt: external_exports.string(),
5985
6051
  /** Last delivery per trigger; stamped separately so each reads its own clock. */
@@ -5989,40 +6055,162 @@ var init_src = __esm({
5989
6055
  }).strict();
5990
6056
  ALLDONE_SENTINEL = "ALLDONE";
5991
6057
  OPT_OUT_NOTE = "To stop these, sign off with a ```done fence \u2014 but ONLY when the work is genuinely finished: it files this thread away, and nothing but new work from the human reopens it.";
6058
+ OVER_QUESTION_NOTE = "Your ```question is still unanswered, and a Goal armed at rest means the operator is not waiting to answer it: decide it yourself, say in one line which way you went and what would reverse it, and carry on. Do NOT re-ask it.";
5992
6059
  TIMER_PROMPT_MAX = SNOOZE_PROMPT_MAX;
5993
6060
  TIMER_MAX_DELAY_SECONDS = 30 * 24 * 60 * 60;
5994
6061
  TIMER_MAX_ARMED = 64;
5995
- WATCH_MAX_ARMED = 32;
5996
6062
  TimerPromptText = external_exports.string().trim().min(1).max(TIMER_PROMPT_MAX);
5997
6063
  SIGNOFF_NUDGE_MARKER = "**This message is from frizz, not from the human.**";
5998
6064
  SIGNOFF_NUDGE_MESSAGE = [
5999
- `${SIGNOFF_NUDGE_MARKER} Nothing about your task has changed, and this is not a request to do more work.`,
6065
+ `${SIGNOFF_NUDGE_MARKER} Nothing about your task has changed, and no new work is being asked of you.`,
6066
+ "",
6067
+ "You rested without a fence, so this thread cannot be triaged.",
6000
6068
  "",
6001
- "You rested without a fence, so this thread cannot be triaged. Add one at the END of your next message:",
6069
+ "**IF THE TASK STILL HAS PARTS LEFT, THE FENCE IS NOT WHAT YOU OWE \u2014 THE WORK IS.** If ANY part of the",
6070
+ "original task is unfinished, unverified, or deferred, resume it NOW, in THIS turn, and sign off once it",
6071
+ "is genuinely finished. A milestone, a green test run and a long turn are none of them endings, and",
6072
+ "neither is naming the next step or writing it into a scratch file.",
6073
+ "",
6074
+ "**AND THAT TASK IS ALSO THE CEILING \u2014 finish it, and nothing else.** Work you notice on the way is a",
6075
+ "FINDING TO REPORT in your sign-off, never work to take on: the bug beside the one you were sent for,",
6076
+ "the refactor the code obviously wants, the second issue the first one touches. Widening the job is not",
6077
+ "thoroughness \u2014 it is a different job nobody asked for, and it buries the answer they did ask for under",
6078
+ "changes they now have to review. If it should be done, name it in one line and let the human dispatch",
6079
+ "it.",
6080
+ "",
6081
+ "**IF WHAT YOU WERE ASKED FOR IS A DOCUMENT, THE DOCUMENT IS THE ENDING.** A triage, a review, an",
6082
+ "investigation, a recommendation, a plan \u2014 when that is the deliverable, the finished write-up IS the",
6083
+ "work. Implementing what it proposes is the NEXT job, and not yours unless you were asked. Sign off with",
6084
+ "the write-up.",
6085
+ "",
6086
+ "**DECIDE RATHER THAN ASK.** Stop only for a decision that is genuinely the human's AND that blocks you",
6087
+ "right now: ask that one in a question fence. Every other open choice INSIDE the task \u2014 a name, a",
6088
+ "default, a reversible design call \u2014 is yours to make: decide it, say in one line which way you went and",
6089
+ "what would reverse it, and carry on. A choice that would ENLARGE the task is not one of those: an",
6090
+ "unanswered question is not permission to go build the answer.",
6091
+ "",
6092
+ "Otherwise, add a fence at the END of your next message:",
6002
6093
  "",
6003
6094
  "- `` ```question `` \u2014 you need the human. One question per fence, lettered options, one recommended.",
6004
6095
  "- `` ```done `` \u2014 genuinely FINISHED. A DISMISSAL: the card is filed away and nobody looks again, so",
6005
6096
  " if anything is still owed, it is not done. Body: 1-3 sentences, then bullets, each opening with a",
6006
6097
  " **bolded verb phrase**.",
6007
- "- Blocked on a PR, CI, or your own background shell? Register it with `mcp__frizz__watch` and rest.",
6098
+ "- `` ```awaiting `` \u2014 you are WAITING on work that is actually running. It is PURE STRUCTURE: one line",
6099
+ " per thing you are waiting on, a REQUIRED `for:` duration, and one line of `reason:` prose.",
6100
+ "",
6101
+ " ```awaiting",
6102
+ " shell: <the id your runtime gave you>",
6103
+ " pr: owner/repo#123",
6104
+ " for: 2h",
6105
+ " reason: what you are waiting for, in one line",
6106
+ " ```",
6107
+ "",
6108
+ " Frizz CHECKS every line: name something that is not running and you are bumped rather than parked.",
6109
+ " A fence that names NOTHING is not a park at all \u2014 if you are not waiting on anything, you are not",
6110
+ " awaiting, you are done. Register a PR with `mcp__frizz__watch_pr` and a timer with",
6111
+ " `mcp__frizz__timer`; `mcp__frizz__activity` reads back everything you have running, with its id.",
6112
+ "",
6113
+ "**STILL OWED counts things you are not going to do yourself.** A decision you are RECOMMENDING, a",
6114
+ "draft you wrote but did not send, follow-up work you discovered \u2014 all of it dies with the card, even",
6115
+ "the part that is someone else's to do. Each ends on a question with your recommendation as option A,",
6116
+ "or gets its own card first via `mcp__frizz__spawn_thread`; none of them is a `done`. And what is not",
6117
+ 'worth a card is not worth a SENTENCE: delete the dangling "one thing to carry forward", never park it',
6118
+ "in the handoff.",
6008
6119
  "",
6009
6120
  "**DO NOT REPEAT YOURSELF.** If the message you just wrote already stands on its own, reply with the",
6010
6121
  "fence ALONE \u2014 the human reads both together, so restating it costs them the second read for nothing.",
6122
+ "The same holds inside one message: the card is the ledger of what shipped, the prose is only what a",
6123
+ "ledger cannot hold, and a sentence that reads the same in either belongs in exactly one of them.",
6011
6124
  "",
6012
6125
  "Only if it does NOT stand alone, fix that first, briefly. It has to be readable cold: the human has",
6013
6126
  "seen nothing since their own last message \u2014 the Goal, this reminder, a watcher wake all came from",
6014
6127
  "frizz \u2014 so anything you assumed they had followed, they have not."
6015
6128
  ].join("\n");
6016
6129
  ThreadWatchKind = external_exports.enum(["shell", "github"]);
6017
- RegisterableThreadWatchKind = external_exports.enum(["shell"]);
6018
- ThreadWatchTarget = external_exports.string().trim().min(1).max(200);
6130
+ GithubChecksState = external_exports.enum(["none", "running", "passing", "failing"]);
6131
+ GithubMergeState = external_exports.enum(["mergeable", "blocked", "conflicting", "unknown"]);
6132
+ GithubWatchStatus = external_exports.object({
6133
+ checks: GithubChecksState,
6134
+ /** The counts behind the verdict, so the card can say "3 running, 12 passed" the way GitHub does
6135
+ * rather than only "checks are running". */
6136
+ running: external_exports.number().int().nonnegative(),
6137
+ passed: external_exports.number().int().nonnegative(),
6138
+ failed: external_exports.number().int().nonnegative(),
6139
+ /** The failing job NAMES, capped — what a human actually needs to decide whether to look. */
6140
+ failing: external_exports.array(external_exports.string()).max(8).default([]),
6141
+ merge: GithubMergeState,
6142
+ /** OPEN | CLOSED | MERGED, lowercased. A merged or closed PR ends the wait outright. */
6143
+ state: external_exports.enum(["open", "closed", "merged"]),
6144
+ /** When frizz last heard from GitHub. A poll can fail or be rate-limited, and a stale reading stated
6145
+ * as current is worse than no reading. */
6146
+ polledAt: external_exports.string(),
6147
+ /** The head commit this verdict was reached on. A PR watcher reports a CI verdict when it is NEWS, and
6148
+ * "red again on a new commit" is news that the bare word `failing` cannot express. Optional because a
6149
+ * reading taken before 2026-08-17 carries none. */
6150
+ head: external_exports.string().optional(),
6151
+ /** A digest of WHICH jobs are failing right now. The head commit alone cannot express a re-run of the
6152
+ * same job, or a slower job going red after the first — both of which are a second failure the worker
6153
+ * must hear about. Empty when nothing is failing. */
6154
+ failureSig: external_exports.string().optional()
6155
+ }).strict();
6019
6156
  ThreadWatchView = external_exports.object({
6020
6157
  id: external_exports.string(),
6021
6158
  kind: ThreadWatchKind,
6022
6159
  target: external_exports.string(),
6023
6160
  state: external_exports.enum(["armed", "fired", "dropped"]),
6024
- createdAt: external_exports.string()
6161
+ createdAt: external_exports.string(),
6162
+ /** `github` rows only, and absent until the first successful poll. */
6163
+ github: GithubWatchStatus.optional()
6164
+ }).strict();
6165
+ PR_WATCH_DEFAULT_FOR_MS = 6 * 60 * 60 * 1e3;
6166
+ PR_WATCH_MAX_ARMED = 32;
6167
+ PrWatchView = external_exports.object({
6168
+ id: external_exports.string(),
6169
+ /** `owner/repo#N`, normalized — the same string the board's row and the status book are keyed by. */
6170
+ target: external_exports.string(),
6171
+ state: external_exports.enum(["armed", "dropped", "settled"]),
6172
+ createdAt: external_exports.string(),
6173
+ /** The PR's checks/mergeability as the poller last saw it. Absent until the first successful poll. */
6174
+ github: GithubWatchStatus.optional()
6175
+ }).strict();
6176
+ AddOwnPrWatchInput = external_exports.object({
6177
+ slug: ThreadSlug,
6178
+ /** `owner/repo#123` or a PR URL. Parsed server-side; an unparseable ref is refused rather than stored,
6179
+ * because a watcher that can never fire is worse than none — the worker rests believing it is covered. */
6180
+ target: external_exports.string().trim().min(1).max(200),
6181
+ /** How long to watch, as a DURATION (`30m`, `2h`, `3d` — parseAwaitingDuration).
6182
+ *
6183
+ * A PR nobody ever reviews would otherwise be polled forever, and a thread parked on it would wait
6184
+ * forever with it — the same unbounded wait the awaiting fence's `for:` closes, one level down. A
6185
+ * duration rather than an instant for the same reason it is one there: it cannot be written in the
6186
+ * past (maintainer 2026-08-15, asking for it explicitly).
6187
+ *
6188
+ * REQUIRED BY THE TOOL, OPTIONAL ON THE WIRE, and the asymmetry is deliberate. A worker's MCP server
6189
+ * outlives every frizz restart, so a session dispatched before this existed still holds a binary that
6190
+ * cannot send it — and making the RPC reject that would break `watch_pr` outright for every thread
6191
+ * already running, with no recourse from inside those threads. Absent ⇒ PR_WATCH_DEFAULT_FOR, which
6192
+ * still BOUNDS the poll; the point of the field is that a worker chooses, and one that cannot choose
6193
+ * is better bounded than broken. */
6194
+ for: external_exports.string().trim().min(1).max(16).optional()
6195
+ }).strict();
6196
+ AddOwnPrWatchResult = external_exports.object({
6197
+ id: external_exports.string(),
6198
+ target: external_exports.string(),
6199
+ /** True when this exact PR was ALREADY watched by this thread, so the call registered nothing new.
6200
+ * Re-registering after a compaction is the common case, and a duplicate would double every wake. */
6201
+ alreadyArmed: external_exports.boolean(),
6202
+ watches: external_exports.array(PrWatchView)
6203
+ }).strict();
6204
+ DropOwnPrWatchInput = external_exports.object({
6205
+ slug: ThreadSlug,
6206
+ id: external_exports.string().min(1).max(64)
6207
+ }).strict();
6208
+ DropOwnPrWatchResult = external_exports.object({
6209
+ dropped: external_exports.boolean(),
6210
+ watches: external_exports.array(PrWatchView)
6025
6211
  }).strict();
6212
+ ListOwnPrWatchesInput = external_exports.object({ slug: ThreadSlug }).strict();
6213
+ OwnPrWatchesResult = external_exports.object({ watches: external_exports.array(PrWatchView) }).strict();
6026
6214
  ThreadTimerView = external_exports.object({
6027
6215
  id: external_exports.string(),
6028
6216
  prompt: external_exports.string(),
@@ -6180,6 +6368,16 @@ var init_src = __esm({
6180
6368
  // AUTO-bump (the scheduler resumes the agent with exactly this text) rather than a reminder, which is
6181
6369
  // the distinction the held row's tooltip renders. Absent ⇒ the card merely re-surfaces.
6182
6370
  snoozePrompt: external_exports.string().optional(),
6371
+ /** The EVENT snooze on the resting card is armed for this exact rest — the human has said "hide this
6372
+ * until something reports". Distinct from `snoozedUntil`, which is a wall-clock park on the whole
6373
+ * thread: this one has no deadline and clears itself when the thread comes to a NEW rest.
6374
+ *
6375
+ * It travels because the chat has to honour it too (2026-08-14). Until then only the QUEUE did, on
6376
+ * the reasoning that the card states a FACT the drawer must keep showing or it blanks at rest and
6377
+ * reads as "the agent died". That reasoning holds for a thread nobody has parked; once the human has
6378
+ * explicitly parked THIS rest, showing them the same card with the same button one surface over is
6379
+ * not information, and they said so. */
6380
+ bgSnoozed: external_exports.boolean().optional(),
6183
6381
  // Which Claude transport serves this thread: "broker" = a session-broker-owned Agent SDK session
6184
6382
  // (typed control channel), "tmux" = the interactive TUI in a pane. Only the broker can be asked to
6185
6383
  // reload its plugin closure in place, so the board needs it to decide whether to offer that verb at
@@ -6297,6 +6495,13 @@ var init_src = __esm({
6297
6495
  // Optional so a pre-restart server keeps working; absent means "fall back to `/`", i.e. the old
6298
6496
  // behaviour. Registry-derived, so it is the same slug every other surface links to.
6299
6497
  projectSlug: external_exports.string().optional(),
6498
+ // The server's home directory — the expansion of a `~` a worker wrote in prose. Agents reference
6499
+ // files that way constantly (`~/.claude/CLAUDE.md`), and the browser has no way to derive it, so a
6500
+ // `~`-anchored Markdown link had no absolute path to become and stayed a same-origin anchor that
6501
+ // navigated out of Frizz. The client only ever uses it to build a path it then hands BACK to the
6502
+ // server, which realpath-gates it exactly as it gates one the author typed in full. Optional so a
6503
+ // pre-restart server keeps working; absent means `~` links stay unresolved, i.e. the old behaviour.
6504
+ homeDir: external_exports.string().optional(),
6300
6505
  // (No `.frizz/ exists` bit here on purpose. Threads are session-first — the ui.db registry IS the
6301
6506
  // board — so `.frizz/` presence says nothing about whether this project has one. Its only consumer
6302
6507
  // was a shell gate that dead-ended `.frizz`-less repos; the server still probes the directory
@@ -6403,13 +6608,23 @@ var init_src = __esm({
6403
6608
  // Default action for a vetted non-image local path in agent markdown. Image clicks always use the
6404
6609
  // OS default viewer so screenshots retain their expected behavior.
6405
6610
  localFileOpener: LocalFileOpener.optional(),
6406
- // GitHub batch-dispatch prompt templates (the picker's per-item worker prompt). Optional: when
6407
- // unset OR blank the server falls back to its exported DEFAULT_ISSUE_PROMPT / DEFAULT_PR_PROMPT.
6408
- // Substitution tokens the server fills: {repo} {n} {title} {url} {labels} {body}. The leading
6409
- // `THREAD: <slug>` tag is prepended by the server (not part of the editable template) so a custom
6410
- // prompt can never break the thread↔.frizz-file binding. Optional so old settings blobs parse.
6411
- githubIssuePrompt: external_exports.string().optional(),
6412
- githubPrPrompt: external_exports.string().optional()
6611
+ // The GitHub batch-dispatch prompt template (the picker's per-item worker prompt). Optional: when
6612
+ // unset OR blank the server falls back to its exported DEFAULT_GITHUB_PROMPT. Substitution tokens
6613
+ // the server fills: {repo} {n} {title} {url} {labels} {body}. The leading `THREAD: <slug>` tag is
6614
+ // prepended by the server (not part of the editable template) so a custom prompt can never break the
6615
+ // thread↔.frizz-file binding. Optional so old settings blobs parse.
6616
+ //
6617
+ // ONE field, not one per kind. Issue and PR each had their own template until 2026-08-15, and the two
6618
+ // said the same thing twice: read the whole thread, be skeptical, cite what you checked, post nothing.
6619
+ // A person tuning "be more dubious" had to make the same edit in two boxes and keep them in step. The
6620
+ // ONE thing that genuinely differs per kind — which `gh` command reads the item — is a line in the
6621
+ // metadata block, so the merged template just carries both, and the worker picks the one that applies.
6622
+ //
6623
+ // There is no migration off the two old keys, and that is deliberate (the maintainer's call): Settings
6624
+ // is a non-strict z.object, so `githubIssuePrompt`/`githubPrPrompt` are STRIPPED the moment an old
6625
+ // blob is parsed. Any existing override is dropped and the reader gets the new shipped default —
6626
+ // exactly the intended backfill. Merging two customized templates into one has no correct answer.
6627
+ githubPrompt: external_exports.string().optional()
6413
6628
  });
6414
6629
  DispatchProviderPreferences = external_exports.object({
6415
6630
  model: external_exports.string().trim().min(1).max(200).optional(),
@@ -6538,9 +6753,6 @@ var init_src = __esm({
6538
6753
  // so a client that predates it — an older tab, an older MCP server — keeps writing the row correctly
6539
6754
  // with the trigger off, which is the honest reading of a caller that has never heard of it.
6540
6755
  postCompaction: external_exports.boolean().default(false),
6541
- // The QUESTION HOLD (2026-08-11). Defaulted for the same reason as the trigger above: a caller that
6542
- // has never heard of it means "don't hold", which is also this option's own default.
6543
- pauseOnQuestions: external_exports.boolean().default(false),
6544
6756
  intervalSeconds: RecurringIntervalSeconds.optional()
6545
6757
  }).strict();
6546
6758
  SetOwnThreadRecurringPromptInput = external_exports.object({
@@ -6552,8 +6764,14 @@ var init_src = __esm({
6552
6764
  // so a client that predates it — an older tab, an older MCP server — keeps writing the row correctly
6553
6765
  // with the trigger off, which is the honest reading of a caller that has never heard of it.
6554
6766
  postCompaction: external_exports.boolean().default(false),
6555
- // The QUESTION HOLD (2026-08-11), defaulted exactly as the trigger above and for the same reason.
6556
- pauseOnQuestions: external_exports.boolean().default(false),
6767
+ /** ACCEPTED AND IGNORED. The question hold was deleted 2026-08-16 (see scheduler.ts) and no caller in
6768
+ * this repo sends it any more — but this object is `.strict()`, and the caller on the other end is a
6769
+ * DETACHED worker daemon holding the `frizz-mcp.mjs` it was spawned with. Those outlive a server
6770
+ * restart by design, so for as long as any pre-2026-08-16 worker is alive a `start` would otherwise
6771
+ * come back as a validation error on a field the model cannot see it is sending. Tolerated here rather
6772
+ * than in the router so the shape stays one declaration; delete it once no such worker can be running.
6773
+ * The BROWSER input above needs no such clause — a stale tab is one reload away. */
6774
+ pauseOnQuestions: external_exports.boolean().optional(),
6557
6775
  intervalSeconds: RecurringIntervalSeconds.optional()
6558
6776
  }).strict();
6559
6777
  SetOwnThreadRecurringPromptResult = external_exports.object({
@@ -6580,6 +6798,23 @@ var init_src = __esm({
6580
6798
  OwnThreadTimersResult = external_exports.object({
6581
6799
  timers: external_exports.array(ThreadTimerView)
6582
6800
  }).strict();
6801
+ ThreadActivityItem = external_exports.object({
6802
+ kind: external_exports.enum(["shell", "agent", "timer", "pr"]),
6803
+ /** The string a `<kind>:` fence line must carry. For a shell that is the runtime task id the worker
6804
+ * was shown; for a PR, `owner/repo#N`; for a timer, its `tmr_…` row id. */
6805
+ id: external_exports.string(),
6806
+ label: external_exports.string(),
6807
+ /** ISO8601 of when it started or was armed — absent when frizz has no instant for it. */
6808
+ since: external_exports.string().optional(),
6809
+ /** A timer's fire instant, or a PR's expiry. Absent for shells and sub-agents. */
6810
+ until: external_exports.string().optional()
6811
+ }).strict();
6812
+ ListOwnThreadActivityInput = external_exports.object({
6813
+ slug: ThreadSlug
6814
+ }).strict();
6815
+ OwnThreadActivityResult = external_exports.object({
6816
+ activity: external_exports.array(ThreadActivityItem)
6817
+ }).strict();
6583
6818
  SetOwnThreadTimerResult = external_exports.object({
6584
6819
  id: external_exports.string(),
6585
6820
  fireAt: external_exports.string(),
@@ -6589,49 +6824,6 @@ var init_src = __esm({
6589
6824
  cancelled: external_exports.boolean(),
6590
6825
  timers: external_exports.array(ThreadTimerView)
6591
6826
  }).strict();
6592
- AddOwnThreadWatchInput = external_exports.object({
6593
- slug: ThreadSlug,
6594
- // REGISTERABLE kinds only, which is `shell` and nothing else — deliberately narrower than
6595
- // `ThreadWatchKind`, whose `github` member is a board-derived VIEW row with no registration behind it.
6596
- // The MCP tool already refuses a PR here in words ("a pr wait does not belong here — use an ```awaiting
6597
- // fence"); this is the same refusal at the wire, where it cannot be talked past.
6598
- kind: RegisterableThreadWatchKind,
6599
- target: ThreadWatchTarget,
6600
- /** The worker is BLOCKING on this one inside its tool call, so the scheduler settles it silently
6601
- * instead of delivering a wake — see the column comment in storage.ts. Defaulted, so a caller that
6602
- * has never heard of it registers an ordinary background watch. */
6603
- foreground: external_exports.boolean().default(false)
6604
- }).strict();
6605
- PromoteOwnThreadWatchInput = external_exports.object({
6606
- slug: ThreadSlug,
6607
- id: external_exports.string().min(1).max(64)
6608
- }).strict();
6609
- PromoteOwnThreadWatchResult = external_exports.object({
6610
- promoted: external_exports.boolean(),
6611
- watches: external_exports.array(ThreadWatchView)
6612
- }).strict();
6613
- DropOwnThreadWatchInput = external_exports.object({
6614
- slug: ThreadSlug,
6615
- id: external_exports.string().min(1).max(64)
6616
- }).strict();
6617
- ListOwnThreadWatchesInput = external_exports.object({
6618
- slug: ThreadSlug
6619
- }).strict();
6620
- OwnThreadWatchesResult = external_exports.object({
6621
- watches: external_exports.array(ThreadWatchView)
6622
- }).strict();
6623
- AddOwnThreadWatchResult = external_exports.object({
6624
- id: external_exports.string(),
6625
- /** True when this exact (kind, target) was ALREADY armed on the thread, so the call registered
6626
- * nothing new. A worker re-registering after a compaction is the common case, and silently minting a
6627
- * duplicate would mean two wakes for one event. */
6628
- alreadyArmed: external_exports.boolean(),
6629
- watches: external_exports.array(ThreadWatchView)
6630
- }).strict();
6631
- DropOwnThreadWatchResult = external_exports.object({
6632
- dropped: external_exports.boolean(),
6633
- watches: external_exports.array(ThreadWatchView)
6634
- }).strict();
6635
6827
  SetOwnThreadStopHookInput = external_exports.object({
6636
6828
  slug: ThreadSlug,
6637
6829
  prompt: RecurringPromptText.nullable(),
@@ -6742,6 +6934,43 @@ ${DISPATCH_TASK_BANNER}
6742
6934
  nameWithOwner: external_exports.string().nullable(),
6743
6935
  authed: external_exports.boolean()
6744
6936
  });
6937
+ GithubRefCard = external_exports.object({
6938
+ ref: external_exports.string(),
6939
+ kind: external_exports.enum(["issue", "pr", "commit"]),
6940
+ repo: external_exports.string(),
6941
+ // owner/repo, for the card's header line
6942
+ url: external_exports.string(),
6943
+ title: external_exports.string(),
6944
+ body: external_exports.string(),
6945
+ // already truncated server-side to the card's excerpt budget
6946
+ state: external_exports.string(),
6947
+ // OPEN | CLOSED | MERGED | DRAFT — empty for a commit, which has no state
6948
+ stateReason: external_exports.string().optional(),
6949
+ // COMPLETED | NOT_PLANNED | REOPENED — GitHub's closed-issue nuance
6950
+ at: external_exports.string().optional(),
6951
+ // ISO: opened-at for an issue/PR, committed-at for a commit
6952
+ authorLogin: external_exports.string().optional(),
6953
+ authorName: external_exports.string().optional(),
6954
+ // commits carry a git author name with no GitHub account behind it
6955
+ authorAvatar: external_exports.string().optional(),
6956
+ labels: external_exports.array(external_exports.object({ name: external_exports.string(), color: external_exports.string() })).default([]),
6957
+ additions: external_exports.number().int().nonnegative().optional(),
6958
+ deletions: external_exports.number().int().nonnegative().optional(),
6959
+ changedFiles: external_exports.number().int().nonnegative().optional(),
6960
+ comments: external_exports.number().int().nonnegative().optional(),
6961
+ // Epoch ms of the fetch this card came from. The CLIENT owns the freshness decision (render the
6962
+ // cached card instantly, then revalidate if it is old), so it has to be able to see the age.
6963
+ fetchedAt: external_exports.number().int().nonnegative()
6964
+ });
6965
+ GithubRefPreviewInput = external_exports.object({
6966
+ refs: external_exports.array(external_exports.string().min(3).max(120)).min(1).max(100),
6967
+ refresh: external_exports.boolean().default(false)
6968
+ });
6969
+ GithubRefPreviewResult = external_exports.object({
6970
+ cards: external_exports.array(GithubRefCard),
6971
+ missing: external_exports.array(external_exports.string()),
6972
+ error: external_exports.string().optional()
6973
+ });
6745
6974
  GithubItem = external_exports.object({
6746
6975
  kind: external_exports.enum(["issue", "pr"]),
6747
6976
  number: external_exports.number().int().positive(),
@@ -7798,11 +8027,11 @@ ${GITHUB_DISPATCH_UI_BOUNDARY}
7798
8027
 
7799
8028
  ${filled}`;
7800
8029
  }
7801
- function effectiveTemplate(kind, custom2) {
8030
+ function effectiveTemplate(custom2) {
7802
8031
  if (custom2 && custom2.trim().length > 0) return custom2;
7803
- return kind === "issue" ? DEFAULT_ISSUE_PROMPT : DEFAULT_PR_PROMPT;
8032
+ return DEFAULT_GITHUB_PROMPT;
7804
8033
  }
7805
- var pexec, GH_TIMEOUT, GH_MAXBUF, SEARCH_SORT, SEARCH_RESULT_WINDOW, LINKED_PR_CHUNK, GITHUB_PAGE_SIZE, BODY_CAP, DEFAULT_ISSUE_PROMPT, DEFAULT_PR_PROMPT;
8034
+ var pexec, GH_TIMEOUT, GH_MAXBUF, SEARCH_SORT, SEARCH_RESULT_WINDOW, LINKED_PR_CHUNK, GITHUB_PAGE_SIZE, BODY_CAP, DEFAULT_GITHUB_PROMPT;
7806
8035
  var init_github = __esm({
7807
8036
  "packages/server/src/github.ts"() {
7808
8037
  "use strict";
@@ -7818,26 +8047,30 @@ var init_github = __esm({
7818
8047
  LINKED_PR_CHUNK = 25;
7819
8048
  GITHUB_PAGE_SIZE = 30;
7820
8049
  BODY_CAP = 8 * 1024;
7821
- DEFAULT_ISSUE_PROMPT = `Triage this GitHub issue and recommend what to do about it. This is a RESEARCH thread: the deliverable is FINDINGS and a recommendation, NOT a landed fix. Read the full thread first \u2014 body, labels, and the whole discussion \u2014 then classify the report as a BUG or a FEATURE request and say which it is and why. If it is a BUG: establish whether the reported behavior actually happens on the current tree (capture the exact steps, command and output if it reproduces; say what you saw instead if it does not), trace the cause to concrete code, and state the smallest correct fix \u2014 or the top 2 options with the tradeoff of each \u2014 plus the files it touches and the risk. If it is a FEATURE: restate the request precisely (the use-case behind it and any ambiguity a maintainer must resolve), map where it lands in the code and what public API / UX surface it changes, and sketch a concrete plan for the smallest viable version with its risks, open design questions and a rough size estimate. Cite exact file:line for every load-bearing claim \u2014 an uncited claim is a LEAD, so flag it. Do NOT implement anything unasked, and post NOTHING to GitHub (no comments, no labels, no close) unless the human explicitly asks \u2014 read-only. Put your findings and recommendation in your FINAL MESSAGE: this one is headed for a fix, so it is NOT \`\`\`done\`\`\` \u2014 close with a \`\`\`question\`\`\` when there's a fix to choose (recommendation marked, so one reply rolls into implementation), or bare-rest.
8050
+ DEFAULT_GITHUB_PROMPT = `Triage this GitHub issue/PR and make recommendations. Be thoughtful, thorough, and dubious. Think through whether this is truly a good idea or not. Many ideas are bad.
7822
8051
 
7823
- ---
8052
+ Read the full thread first \u2014 body, labels, and the whole discussion \u2014 then classify the report as a BUG or a FEATURE request and say which it is and why.
7824
8053
 
7825
- Issue #{n}: {title}
7826
- Repository: {repo}
7827
- URL: {url}
7828
- Labels: {labels}
7829
- Body + full thread: \`gh issue view {n} -R {repo} --comments\``;
7830
- DEFAULT_PR_PROMPT = `Review this open pull request. This is an AUDIT thread: adversarially verify the change is correct, safe and complete before recommending merge. Read the PR description and discussion first, then read the full diff \u2014 the changed files in context, not just the hunks (pipe large output through toon). For each substantive change ask: is it correct? does it handle edges? does it break existing behavior or the public API? are there tests, and do they actually cover the change? Check CI too: pending automation is evidence to report, not a reason to park this audit; if later scope explicitly includes shepherding the PR, keep CI/bot/merge progression active with the backend wait primitive from the worker contract. Then recommend approve / request-changes / needs-discussion with a concise findings list \u2014 each concern citing exact file:line in the diff, blocking issues distinguished from nits. Post NOTHING to GitHub (no review, no comment, no approve/merge) unless the human explicitly asks \u2014 read-only. Put your review in your FINAL MESSAGE and close with a \`\`\`done\`\`\` fence listing the completed audit/evidence, or a two-option \`\`\`question\`\`\` if you want a go/no-go on posting the review to GitHub.
8054
+ If it is a BUG REPORT: establish whether the reported behavior actually happens on the current tree (capture the exact steps, command and output if it reproduces; say what you saw instead if it does not), trace the cause to concrete code, and state the smallest correct fix \u2014 or the top 2 options with the tradeoff of each \u2014 plus the files it touches and the risk. Be sure you have high confidence that the bug is real. There should be a clean and elegant solution. If there isn't an elegant way to do something, then it often means that it's not a good idea or that we are in need of a larger codebase refactor. The issue: you should stay in the codebase for similar categories of bugs. We are constantly seeking to proactively self-improve the codebase.
8055
+
8056
+ If it is a FEATURE REQUEST: restate the request precisely (the use-case behind it and any ambiguity a maintainer must resolve), map where it lands in the code and what public API / UX surface it changes, and sketch a concrete plan for the smallest viable version with its risks, open design questions and a rough size estimate.
8057
+
8058
+ If it is a PR: review it. Read the PR description, comments, and changes. You should be dubious. Do not assume that the PR is high quality, many are not. For each substantive change ask: is there a better approach? Is it correct? Does it handle edges? Is it elegant? Does it break existing behavior or the public API? Are there tests, and do they actually cover the change? Trace the impact of the changes thoughtfully. Check CI too. Recommend either closing, making changes (explain them), or merging. Test the changes yourself if necessary/possible.
8059
+
8060
+ If it is a DOCS CHANGE: make sure that it is well written, minimal, no walls of text, well structured, consistent tonally with others docs/prose, and not AI slop.
8061
+
8062
+ Bias always towards proactivity and taking immediate action. If you encounter issues, feel free to add fixes for those in the same PR. Do not worry about perfect git hygiene.
7831
8063
 
7832
8064
  ---
7833
8065
 
7834
- PR #{n}: {title}
8066
+ Issue/PR #{n}: {title}
7835
8067
  Repository: {repo}
7836
8068
  URL: {url}
7837
8069
  Labels: {labels}
7838
- Description + discussion: \`gh pr view {n} -R {repo} --comments\`
7839
- Diff: \`gh pr diff {n} -R {repo}\`
7840
- CI: \`gh pr checks {n} -R {repo}\``;
8070
+ Body + thread (issues): \`gh issue view {n} -R {repo} --comments\`
8071
+ Body + thread (PRs): \`gh pr view {n} -R {repo} --comments\`
8072
+ Diff (PRs): \`gh pr diff {n} -R {repo}\`
8073
+ CI (PRs): \`gh pr checks {n} -R {repo}\``;
7841
8074
  }
7842
8075
  });
7843
8076
 
@@ -10440,41 +10673,39 @@ function createStorage(dbPath) {
10440
10673
  ON thread_timer(state, fire_at);
10441
10674
  CREATE INDEX IF NOT EXISTS thread_timer_slug
10442
10675
  ON thread_timer(thread_slug, state, fire_at);
10443
- -- A worker's registered WATCHERS (2026-08-12). One row per thing a thread is waiting on, so a wait
10444
- -- has an IDENTITY: it can be listed after a compaction and DROPPED when it stops mattering.
10445
- --
10446
- -- This is the table the awaiting FENCE could never be. That fence is derived state \u2014 recomputed
10447
- -- from the final assistant message on every fold, and cleared by any newer record \u2014 so a park was
10448
- -- lost the moment the worker said one more sentence, there was no id to cancel, and nothing for a
10449
- -- Snooze button to be about. Maintainer 2026-08-11: "seems a little weird to register these watchers
10450
- -- with no identifer and no elegant mechanism for dismissing the watcher. they just live forever in
10451
- -- markdown\u2026i think that's why we dropped awaiting before."
10676
+ -- A worker's registered PR WATCHERS (2026-08-14). Registered by tool call, never by a fence: the
10677
+ -- fence states what a thread is waiting on, and watching is a separate, orthogonal thing that
10678
+ -- happens whether or not anything is written down (maintainer: "We should have a tool for this. The
10679
+ -- agent should have a tool to register a PR watcher").
10452
10680
  --
10453
- -- Shaped on thread_timer deliberately, which is the same lesson already learned once: a thread may
10454
- -- hold MANY, so the record of intent is a table rather than a column on the session row.
10455
- CREATE TABLE IF NOT EXISTS thread_watch (
10681
+ -- REPEATING, not one-shot. It reports CI turning green or red and every later review or comment, so
10682
+ -- it stays armed and carries a cursor; only a merge or a close settles it.
10683
+ CREATE TABLE IF NOT EXISTS pr_watch (
10456
10684
  id TEXT PRIMARY KEY,
10457
10685
  thread_slug TEXT NOT NULL,
10458
- kind TEXT NOT NULL CHECK (kind IN ('pr', 'ci', 'shell')),
10459
- target TEXT NOT NULL,
10460
- state TEXT NOT NULL CHECK (state IN ('armed', 'fired', 'dropped')),
10686
+ owner TEXT NOT NULL,
10687
+ repo TEXT NOT NULL,
10688
+ number INTEGER NOT NULL,
10689
+ state TEXT NOT NULL CHECK (state IN ('armed', 'dropped', 'settled')),
10461
10690
  created_at INTEGER NOT NULL,
10462
10691
  settled_at INTEGER,
10463
- -- Opaque per-kind progress marker: for a pr watcher, the activity baseline that decides what
10464
- -- counts as NEW. Written by the scheduler, never read by anything else, so its grammar can change
10465
- -- without a migration.
10466
10692
  cursor TEXT,
10467
- -- FOREGROUND (2026-08-12): the worker is BLOCKING on this one inside its own tool call, so the
10468
- -- scheduler must SETTLE it without delivering a wake. Waking a thread that is mid-turn waiting for
10469
- -- the very thing that woke it is noise at best; at worst the message lands while the tool is still
10470
- -- blocked and the worker reads its own answer twice. The tool polls the row and returns; if its
10471
- -- timeout expires first it PROMOTES the row (clears this), and the wake comes back.
10472
- foreground INTEGER NOT NULL DEFAULT 0
10693
+ -- When this watcher stops polling by itself. REQUIRED at registration (2026-08-15): a PR nobody
10694
+ -- ever touches would otherwise be polled forever, and the thread parked on it would wait forever
10695
+ -- with it. Nullable in the column only so an older row reads; the tool refuses to arm without one.
10696
+ expires_at INTEGER
10473
10697
  );
10474
- CREATE INDEX IF NOT EXISTS thread_watch_armed
10475
- ON thread_watch(state, kind);
10476
- CREATE INDEX IF NOT EXISTS thread_watch_slug
10477
- ON thread_watch(thread_slug, state, created_at);
10698
+ CREATE INDEX IF NOT EXISTS pr_watch_armed
10699
+ ON pr_watch(state);
10700
+ CREATE INDEX IF NOT EXISTS pr_watch_slug
10701
+ ON pr_watch(thread_slug, state, created_at);
10702
+ -- thread_watch WAS HERE and is retired (2026-08-14). A worker's wait is a 'watch:' line in its own
10703
+ -- awaiting fence again, which is BOTH the park and the wake -- the scheduler matches the name against
10704
+ -- the thread's live shells and its retired-shell ring, so nothing needs registering and nothing can
10705
+ -- outlive the fence that declares it. The registry existed because a fence had no identity to drop;
10706
+ -- the answer turned out to be that a park nobody has to drop needs none. An old DB keeps the orphan
10707
+ -- table, which costs nothing and is safer than a migration to remove it.
10708
+ -- (No backticks in this comment: the whole schema is a template literal.)
10478
10709
  `);
10479
10710
  for (const col of [
10480
10711
  "archived INTEGER NOT NULL DEFAULT 0",
@@ -10549,16 +10780,21 @@ function createStorage(dbPath) {
10549
10780
  // which is the correct default: an existing prompt described the triggers its operator chose.
10550
10781
  "recurring_on_compact INTEGER NOT NULL DEFAULT 0",
10551
10782
  "recurring_compact_fired_at TEXT",
10552
- // The question hold (2026-08-11). Its own ALTER for the same reason as the trigger above: an existing
10553
- // armed row picks it up OFF on the next boot, which is the honest reading of a row whose operator has
10554
- // never been shown the option.
10555
- "recurring_pause_on_questions INTEGER NOT NULL DEFAULT 0",
10783
+ // NO `recurring_pause_on_questions` HERE ANY MORE. It held every trigger while the thread was waiting
10784
+ // on the human and the footer showed it inverted as "Autonomous mode"; both were deleted 2026-08-16
10785
+ // (see scheduler.ts, "WHAT A PENDING QUESTION DOES TO THE THREE TRIGGERS"). A database created before
10786
+ // that release still carries the column — it is `NOT NULL DEFAULT 0`, so nothing needs to write it,
10787
+ // and dropping it would cost a table rebuild to reclaim one inert integer per row.
10556
10788
  // THE BUILT-IN SIGN-OFF NUDGE (scheduler SOURCE 9, 2026-08-12). How many times in a row frizz has
10557
10789
  // told this thread how to sign off without a fence appearing. Cleared ONLY when the thread signs
10558
10790
  // off — never by a user record, because frizz's own delivery is one. The second column holds the
10559
10791
  // last-nudged delivery id, for diagnosis.
10560
10792
  "signoff_nudges INTEGER NOT NULL DEFAULT 0",
10561
10793
  "signoff_nudge_anchor TEXT",
10794
+ // Cleared by `resetParkBumps` when a park is actually HONOURED — the one event that proves the
10795
+ // correction landed, and the one frizz cannot cause by correcting.
10796
+ "park_bumps INTEGER NOT NULL DEFAULT 0",
10797
+ "park_bump_anchor TEXT",
10562
10798
  // Title provenance for the CURRENT text (2026-08-07): 1 = the worker's own title signal wrote it,
10563
10799
  // 0 = the dispatch seeded it. DEFAULT 0 is the conservative direction — an existing row is assumed
10564
10800
  // to hold its dispatch chop until the repair below (or the next title signal) says otherwise.
@@ -10569,6 +10805,10 @@ function createStorage(dbPath) {
10569
10805
  } catch {
10570
10806
  }
10571
10807
  }
10808
+ try {
10809
+ db.exec("ALTER TABLE pr_watch ADD COLUMN expires_at INTEGER");
10810
+ } catch {
10811
+ }
10572
10812
  try {
10573
10813
  db.exec("UPDATE session SET tmux_name = 'frizz-' || substr(tmux_name, 6) WHERE tmux_name LIKE 'fray-%'");
10574
10814
  } catch {
@@ -10925,10 +11165,6 @@ function createStorage(dbPath) {
10925
11165
  recurring_on_rest = ?,
10926
11166
  recurring_on_schedule = ?,
10927
11167
  recurring_on_compact = ?,
10928
- -- The hold is deliberately absent from every CASE below it: it changes neither the WORDS nor the
10929
- -- cadence, so a delivery already queued still describes this row exactly. Flipping it must not
10930
- -- mint a generation or drop a "last sent" stamp.
10931
- recurring_pause_on_questions = ?,
10932
11168
  recurring_armed_at = CASE
10933
11169
  WHEN ? IS NULL THEN NULL
10934
11170
  WHEN recurring_armed_at IS NOT NULL AND recurring_prompt IS ? AND recurring_interval_ms IS ? THEN recurring_armed_at
@@ -10945,7 +11181,7 @@ function createStorage(dbPath) {
10945
11181
  WHEN ? IS NULL THEN NULL
10946
11182
  WHEN recurring_armed_at IS NOT NULL AND recurring_prompt IS ? THEN recurring_compact_fired_at
10947
11183
  ELSE NULL END`;
10948
- const recurringArgs = ({ prompt, stopHook, heartbeat, postCompaction, pauseOnQuestions, intervalMs, armedAt }) => {
11184
+ const recurringArgs = ({ prompt, stopHook, heartbeat, postCompaction, intervalMs, armedAt }) => {
10949
11185
  const ms = prompt === null ? null : intervalMs;
10950
11186
  const flag = (on) => prompt === null || !on ? 0 : 1;
10951
11187
  return [
@@ -10955,7 +11191,6 @@ function createStorage(dbPath) {
10955
11191
  flag(stopHook),
10956
11192
  flag(heartbeat),
10957
11193
  flag(postCompaction),
10958
- flag(pauseOnQuestions),
10959
11194
  prompt,
10960
11195
  prompt,
10961
11196
  ms,
@@ -11015,35 +11250,41 @@ function createStorage(dbPath) {
11015
11250
  UPDATE session SET signoff_nudges = 0, signoff_nudge_anchor = NULL
11016
11251
  WHERE slug = ? AND signoff_nudges > 0
11017
11252
  `);
11018
- const armWatchStmt = db.prepare(`
11019
- INSERT INTO thread_watch (id, thread_slug, kind, target, state, created_at, settled_at, cursor, foreground)
11020
- VALUES (@id, @slug, @kind, @target, 'armed', @createdAtMs, NULL, NULL, @foreground)
11253
+ const countParkBumpStmt = db.prepare(`
11254
+ UPDATE session SET park_bumps = park_bumps + 1, park_bump_anchor = ?
11255
+ WHERE slug = ?
11021
11256
  `);
11022
- const watchesBySlugStmt = db.prepare(
11023
- "SELECT * FROM thread_watch WHERE thread_slug = ? ORDER BY created_at, id"
11257
+ const resetParkBumpsStmt = db.prepare(`
11258
+ UPDATE session SET park_bumps = 0, park_bump_anchor = NULL
11259
+ WHERE slug = ? AND park_bumps > 0
11260
+ `);
11261
+ const armPrWatchStmt = db.prepare(`
11262
+ INSERT INTO pr_watch (id, thread_slug, owner, repo, number, state, created_at, settled_at, cursor, expires_at)
11263
+ VALUES (@id, @slug, @owner, @repo, @number, 'armed', @createdAtMs, NULL, NULL, @expiresAtMs)
11264
+ `);
11265
+ const expiredPrWatchesStmt = db.prepare(
11266
+ "SELECT * FROM pr_watch WHERE state = 'armed' AND expires_at IS NOT NULL AND expires_at <= ? ORDER BY expires_at, id"
11024
11267
  );
11025
- const armedWatchesBySlugStmt = db.prepare(
11026
- "SELECT * FROM thread_watch WHERE thread_slug = ? AND state = 'armed' ORDER BY created_at, id"
11268
+ const prWatchesBySlugStmt = db.prepare(
11269
+ "SELECT * FROM pr_watch WHERE thread_slug = ? ORDER BY created_at, id"
11027
11270
  );
11028
- const watchByIdStmt = db.prepare("SELECT * FROM thread_watch WHERE id = ?");
11029
- const armedWatchesStmt = db.prepare(
11030
- "SELECT * FROM thread_watch WHERE state = 'armed' ORDER BY created_at, id"
11271
+ const armedPrWatchesBySlugStmt = db.prepare(
11272
+ "SELECT * FROM pr_watch WHERE thread_slug = ? AND state = 'armed' ORDER BY created_at, id"
11031
11273
  );
11032
- const promoteWatchStmt = db.prepare(`
11033
- UPDATE thread_watch SET foreground = 0 WHERE id = ? AND thread_slug = ? AND state = 'armed'
11034
- `);
11035
- const dropWatchStmt = db.prepare(`
11036
- UPDATE thread_watch SET state = 'dropped', settled_at = ?
11274
+ const prWatchByIdStmt = db.prepare("SELECT * FROM pr_watch WHERE id = ?");
11275
+ const armedPrWatchesStmt = db.prepare(
11276
+ "SELECT * FROM pr_watch WHERE state = 'armed' ORDER BY created_at, id"
11277
+ );
11278
+ const dropPrWatchStmt = db.prepare(`
11279
+ UPDATE pr_watch SET state = 'dropped', settled_at = ?
11037
11280
  WHERE id = ? AND thread_slug = ? AND state = 'armed'
11038
11281
  `);
11039
- const fireWatchStmt = db.prepare(`
11040
- UPDATE thread_watch SET state = 'fired', settled_at = ?
11282
+ const settlePrWatchStmt = db.prepare(`
11283
+ UPDATE pr_watch SET state = 'settled', settled_at = ?
11041
11284
  WHERE id = ? AND state = 'armed'
11042
11285
  `);
11043
- const watchCursorStmt = db.prepare(`
11044
- UPDATE thread_watch SET cursor = ? WHERE id = ? AND state = 'armed'
11045
- `);
11046
- const delThreadWatches = db.prepare("DELETE FROM thread_watch WHERE thread_slug = ?");
11286
+ const prWatchCursorStmt = db.prepare("UPDATE pr_watch SET cursor = ? WHERE id = ? AND state = 'armed'");
11287
+ const delPrWatches = db.prepare("DELETE FROM pr_watch WHERE thread_slug = ?");
11047
11288
  const confirmAwaitingWaitStmt = db.prepare(`
11048
11289
  UPDATE session
11049
11290
  SET awaiting_fence_id = ?, awaiting_confirmed_at = ?, snoozed_until = ?
@@ -11106,7 +11347,7 @@ function createStorage(dbPath) {
11106
11347
  }
11107
11348
  delRetiredOps.run(existing.slug);
11108
11349
  delThreadTimers.run(existing.slug);
11109
- delThreadWatches.run(existing.slug);
11350
+ delPrWatches.run(existing.slug);
11110
11351
  delSession.run(existing.slug);
11111
11352
  return existing;
11112
11353
  };
@@ -11544,14 +11785,16 @@ function createStorage(dbPath) {
11544
11785
  setRecurringPromptBySlug: (slug, write) => recurringBySlugStmt.run(...recurringArgs(write), slug).changes === 1,
11545
11786
  countSignoffNudge: (slug, anchor) => void countNudgeStmt.run(anchor, slug),
11546
11787
  resetSignoffNudges: (slug) => void resetNudgesStmt.run(slug),
11547
- armThreadWatch: (watch) => void armWatchStmt.run({ ...watch, foreground: watch.foreground ? 1 : 0 }),
11548
- promoteThreadWatch: (slug, id) => promoteWatchStmt.run(id, slug).changes === 1,
11549
- listThreadWatches: (slug, opts) => (opts?.armedOnly ? armedWatchesBySlugStmt : watchesBySlugStmt).all(slug),
11550
- getThreadWatch: (id) => watchByIdStmt.get(id),
11551
- armedThreadWatches: () => armedWatchesStmt.all(),
11552
- dropThreadWatch: (slug, id, settledAtMs) => dropWatchStmt.run(settledAtMs, id, slug).changes === 1,
11553
- markThreadWatchFired: (id, settledAtMs) => fireWatchStmt.run(settledAtMs, id).changes === 1,
11554
- setThreadWatchCursor: (id, cursor) => watchCursorStmt.run(cursor, id).changes === 1,
11788
+ countParkBump: (slug, anchor) => void countParkBumpStmt.run(anchor, slug),
11789
+ resetParkBumps: (slug) => void resetParkBumpsStmt.run(slug),
11790
+ armPrWatch: (watch) => void armPrWatchStmt.run(watch),
11791
+ listPrWatches: (slug, opts) => (opts?.armedOnly ? armedPrWatchesBySlugStmt : prWatchesBySlugStmt).all(slug),
11792
+ getPrWatch: (id) => prWatchByIdStmt.get(id),
11793
+ armedPrWatches: () => armedPrWatchesStmt.all(),
11794
+ expiredPrWatches: (nowMs) => expiredPrWatchesStmt.all(nowMs),
11795
+ dropPrWatch: (slug, id, settledAtMs) => dropPrWatchStmt.run(settledAtMs, id, slug).changes === 1,
11796
+ settlePrWatch: (id, settledAtMs) => settlePrWatchStmt.run(settledAtMs, id).changes === 1,
11797
+ setPrWatchCursor: (id, cursor) => prWatchCursorStmt.run(cursor, id).changes === 1,
11555
11798
  armThreadTimer: (timer) => void armTimerStmt.run(timer),
11556
11799
  listThreadTimers: (slug, opts) => (opts?.armedOnly ? armedTimersBySlugStmt : timersBySlugStmt).all(slug),
11557
11800
  getThreadTimer: (id) => timerByIdStmt.get(id),
@@ -13334,15 +13577,25 @@ function matchComposedText(items, recordText, contemporaneous) {
13334
13577
  }
13335
13578
  return matched;
13336
13579
  }
13337
- function ageDeliveries(items, nowMs) {
13580
+ function ageDeliveries(items, nowMs, observedUserAt) {
13338
13581
  if (!items.length) return items;
13339
13582
  let changed = false;
13583
+ const userAtMs = Date.parse(observedUserAt ?? "");
13584
+ const supersededByUserTurn = (item) => {
13585
+ if (!Number.isFinite(userAtMs)) return false;
13586
+ const born = Date.parse(item.at);
13587
+ return Number.isFinite(born) && userAtMs >= born;
13588
+ };
13340
13589
  const next = [];
13341
13590
  for (const item of items) {
13342
13591
  if (item.state === "cancelled") {
13343
13592
  next.push(item);
13344
13593
  continue;
13345
13594
  }
13595
+ if (supersededByUserTurn(item)) {
13596
+ changed = true;
13597
+ continue;
13598
+ }
13346
13599
  const born = Date.parse(item.at);
13347
13600
  if (item.state === "pending" && Number.isFinite(born) && nowMs - born > PENDING_TIMEOUT_MS) {
13348
13601
  next.push({ ...item, state: "unconfirmed", updatedAt: new Date(nowMs).toISOString() });
@@ -14027,6 +14280,7 @@ function newTailState(slug, sessionId, path, foreign = false, nativeSessionId =
14027
14280
  paneDead: false,
14028
14281
  noTranscript: false,
14029
14282
  nextDiscoverMs: 0,
14283
+ discoverMisses: 0,
14030
14284
  stallLogged: false,
14031
14285
  customTitleRevision: 0
14032
14286
  };
@@ -14086,18 +14340,23 @@ function parseSignalFence(text) {
14086
14340
  if (!kind) return void 0;
14087
14341
  if (norm.slice(end).trim() !== "") return void 0;
14088
14342
  if (kind === "done") return { kind, body: capFenceBody(raw2.trim()), hints: [] };
14343
+ const lines = raw2.split("\n");
14344
+ const delimiter2 = lines.findIndex((l) => /^\s*---+\s*$/.test(l));
14345
+ const frontmatter = delimiter2 === -1 ? lines : lines.slice(0, delimiter2);
14346
+ const prose = delimiter2 === -1 ? [] : lines.slice(delimiter2 + 1);
14089
14347
  const hints = [];
14090
14348
  const rest = [];
14091
- for (const line of raw2.split("\n")) {
14349
+ for (const line of frontmatter) {
14092
14350
  const hm = line.match(AWAITING_HINT_RE);
14093
14351
  const k = hm?.[1].toLowerCase();
14094
- if (hm && (k === "pr-watch" || k === "human" || k === "timer" || k === "pr" || k === "ci" || k === "session")) {
14352
+ if (hm && (k === "shell" || k === "agent" || k === "timer" || k === "pr" || k === "for" || k === "reason")) {
14095
14353
  const value = hm[2].trim();
14096
14354
  hints.push({ kind: k, value: value.length > HINT_VALUE_MAX ? value.slice(0, HINT_VALUE_MAX) : value });
14097
14355
  } else {
14098
14356
  rest.push(line);
14099
14357
  }
14100
14358
  }
14359
+ rest.push(...prose);
14101
14360
  return { kind, body: capFenceBody(rest.join("\n").trim()), hints: hints.slice(0, HINT_MAX) };
14102
14361
  }
14103
14362
  function isRealUserMessage(content) {
@@ -14202,7 +14461,7 @@ function retireLive(state, entry, finishedAt, status) {
14202
14461
  state.subAgents.delete(entry.toolUseId);
14203
14462
  if (entry.kind === "shell") {
14204
14463
  state.retiredShells.delete(entry.toolUseId);
14205
- state.retiredShells.set(entry.toolUseId, { toolUseId: entry.toolUseId, command: entry.command, outputFile: entry.outputFile, status });
14464
+ state.retiredShells.set(entry.toolUseId, { toolUseId: entry.toolUseId, command: entry.command, outputFile: entry.outputFile, status, taskId: entry.taskId, label: entry.label, finishedAt });
14206
14465
  while (state.retiredShells.size > RETAINED_SHELLS_MAX) {
14207
14466
  const oldest = state.retiredShells.keys().next().value;
14208
14467
  if (oldest === void 0) break;
@@ -14762,6 +15021,10 @@ function defaultBrokerDaemonAlive(project, now) {
14762
15021
  return alive;
14763
15022
  };
14764
15023
  }
15024
+ function rowIsArchived(row) {
15025
+ if (row.state === "open" || row.state === "archived") return row.state === "archived";
15026
+ return row.archived === 1;
15027
+ }
14765
15028
  function createTailer(deps) {
14766
15029
  const now = deps.now ?? Date.now;
14767
15030
  const paneDead = deps.paneDead ?? (() => true);
@@ -14977,10 +15240,15 @@ function createTailer(deps) {
14977
15240
  for (const e of state.subAgents.values()) {
14978
15241
  if (e.kind !== "shell") continue;
14979
15242
  const lastActivityAt = entryLastActivity(e);
14980
- out.push({ label: e.label, startedAt: e.startedAt, state: "running", id: e.toolUseId, ...e.taskId ? { stoppable: true } : {}, ...lastActivityAt ? { lastActivityAt } : {} });
15243
+ out.push({ label: e.label, startedAt: e.startedAt, state: "running", id: e.toolUseId, ...e.taskId ? { stoppable: true, taskId: e.taskId } : {}, ...lastActivityAt ? { lastActivityAt } : {} });
14981
15244
  }
14982
15245
  return out;
14983
15246
  }
15247
+ function retiredShellViews(state) {
15248
+ const out = [];
15249
+ for (const r of state.retiredShells.values()) out.push({ id: r.toolUseId, taskId: r.taskId, label: r.label, status: r.status, finishedAt: r.finishedAt });
15250
+ return out;
15251
+ }
14984
15252
  function codexBgShellViews(state) {
14985
15253
  if (!deps.codexBackgroundExecs || state.foreign || state.paneDead) return [];
14986
15254
  const execs = deps.codexBackgroundExecs(state.slug, state.sessionId);
@@ -15373,7 +15641,7 @@ ${ask}`;
15373
15641
  const next = items.filter((item) => item.state !== "pending" && item.state !== "enqueued");
15374
15642
  return next.length === items.length ? items : next;
15375
15643
  }
15376
- function ledgerFold(row, nowMs) {
15644
+ function ledgerFold(row, nowMs, state) {
15377
15645
  if (!row.delivery_ledger) {
15378
15646
  return { finish: () => ({ changed: false, value: null }) };
15379
15647
  }
@@ -15393,7 +15661,7 @@ ${ask}`;
15393
15661
  },
15394
15662
  finish: () => {
15395
15663
  items = retireDeliveriesLostWithTheDaemon(row, items);
15396
- items = ageDeliveries(items, nowMs);
15664
+ items = ageDeliveries(items, nowMs, state.lastUserAt);
15397
15665
  if (items === before) return { changed: false, value: null };
15398
15666
  const value = serializeDeliveryLedger(items);
15399
15667
  deps.storage.setDeliveryLedger(row.slug, value);
@@ -15562,6 +15830,7 @@ ${ask}`;
15562
15830
  }
15563
15831
  function captureStall(state, row) {
15564
15832
  if (state.stallLogged) return;
15833
+ if (row.exited && rowIsArchived(row)) return;
15565
15834
  state.stallLogged = true;
15566
15835
  let pane = "";
15567
15836
  try {
@@ -15571,11 +15840,11 @@ ${ask}`;
15571
15840
  }
15572
15841
  const authFailure = row.backend !== "codex" && isClaudeAuthErrorText(pane);
15573
15842
  if (authFailure) state.authFault = "authentication_rejected";
15574
- const evidence = isBrokerClaudeRow(row) && deps.project.stateDir ? `no tmux pane (broker runtime). Daemon diagnostics: ${claudeBrokerDiagnosticLogPath(deps.project.stateDir, row.session_id)}` : isHeadlessRow(row) ? `no tmux pane (headless ${row.backend === "codex" ? "codex app-server" : "claude broker"} runtime)` : "";
15575
- const detail = authFailure ? "(claude authentication failure \u2014 pane content redacted; sign in and retry)" : pane.trim() || evidence || "(pane empty / unavailable)";
15843
+ const evidence = isBrokerClaudeRow(row) && deps.project.stateDir ? `no tmux pane (broker runtime). Daemon diagnostics: ${claudeBrokerDiagnosticLogPath(deps.project.stateDir, row.session_id)}` : isHeadlessRow(row) ? `no tmux pane (headless ${row.backend === "codex" ? "codex app-server" : "claude broker"} runtime)` : "no pane captured (this build runs no tmux)";
15844
+ const detail = authFailure ? "(claude authentication failure \u2014 pane content redacted; sign in and retry)" : pane.trim() || evidence;
15576
15845
  log.error(
15577
15846
  "tailer",
15578
- `thread ${row.slug} (session ${row.session_id}): no transcript ${DISCOVERY_GRACE_MS / 1e3}s after dispatch \u2014 likely a boot failure. ${isHeadlessRow(row) && !pane.trim() && !authFailure ? "" : "Pane:\n"}${detail.slice(0, 4e3)}`
15847
+ `thread ${row.slug} (session ${row.session_id}): no transcript ${DISCOVERY_GRACE_MS / 1e3}s after dispatch \u2014 likely a boot failure. ${pane.trim() && !authFailure ? "Pane:\n" : ""}${detail.slice(0, 4e3)}`
15579
15848
  );
15580
15849
  try {
15581
15850
  mkdirSync10(stallLogDir, { recursive: true });
@@ -15598,6 +15867,7 @@ ${detail}
15598
15867
  if (size > 0) {
15599
15868
  state.noTranscript = false;
15600
15869
  state.stallLogged = false;
15870
+ state.discoverMisses = 0;
15601
15871
  return true;
15602
15872
  }
15603
15873
  const spawnedMs = Date.parse(row.spawned_at);
@@ -15612,6 +15882,7 @@ ${detail}
15612
15882
  state.primed = false;
15613
15883
  state.noTranscript = false;
15614
15884
  state.stallLogged = false;
15885
+ state.discoverMisses = 0;
15615
15886
  return true;
15616
15887
  }
15617
15888
  const found = discoverTranscriptId(logDir, row.session_id, { nowMs, exclude: claimedIds(row.slug) });
@@ -15634,8 +15905,14 @@ ${detail}
15634
15905
  state.primed = false;
15635
15906
  state.noTranscript = false;
15636
15907
  state.stallLogged = false;
15908
+ state.discoverMisses = 0;
15637
15909
  return true;
15638
15910
  }
15911
+ state.discoverMisses++;
15912
+ state.nextDiscoverMs = nowMs + Math.min(
15913
+ DISCOVER_RETRY_MS * 2 ** (state.discoverMisses - 1),
15914
+ DISCOVER_RETRY_MAX_MS
15915
+ );
15639
15916
  state.noTranscript = true;
15640
15917
  captureStall(state, row);
15641
15918
  return true;
@@ -15647,6 +15924,15 @@ ${detail}
15647
15924
  const nowMs = now();
15648
15925
  adoptionBindings = /* @__PURE__ */ new Map();
15649
15926
  const rows = deps.storage.allSessions();
15927
+ let coldVisibleRow = false;
15928
+ for (const row of rows) {
15929
+ if (rowIsArchived(row)) continue;
15930
+ const known = states.get(row.slug);
15931
+ if (!known || !known.primed) {
15932
+ coldVisibleRow = true;
15933
+ break;
15934
+ }
15935
+ }
15650
15936
  let primed = 0;
15651
15937
  let primedRows = 0;
15652
15938
  const tickStartedMs = performance.now();
@@ -15672,13 +15958,19 @@ ${detail}
15672
15958
  const prevNoTranscript = state.noTranscript;
15673
15959
  if (row.backend !== "codex" && !resolveTranscript(state, row, nowMs)) continue;
15674
15960
  if (!state.primed) {
15675
- if (primedRows >= MAX_PRIME_ROWS_PER_TICK || primedRows > 0 && performance.now() - tickStartedMs > PRIME_BUDGET_MS) {
15961
+ if (
15962
+ // Yield the slot to a still-cold visible row — but never at the cost of the forward-progress
15963
+ // guarantee below. `primedRows > 0` keeps the archive advancing by at least one row per tick
15964
+ // even if a visible row were somehow to stay cold indefinitely, so deferral can never become
15965
+ // starvation. The visible row still primes on this same tick; it just is not necessarily first.
15966
+ coldVisibleRow && primedRows > 0 && rowIsArchived(row) || primedRows >= MAX_PRIME_ROWS_PER_TICK || primedRows > 0 && performance.now() - tickStartedMs > PRIME_BUDGET_MS
15967
+ ) {
15676
15968
  primeIncomplete = true;
15677
15969
  continue;
15678
15970
  }
15679
15971
  primedRows++;
15680
15972
  const primeOffset = state.offset;
15681
- const primeLedger = ledgerFold(row, nowMs);
15973
+ const primeLedger = ledgerFold(row, nowMs, state);
15682
15974
  consume(state, backend, chainOnLine(primeLedger.onLine, codexSubAgentOnLine(row, state)));
15683
15975
  state.codexSubAgents?.poll(nowMs);
15684
15976
  const primedLedger = primeLedger.finish();
@@ -15734,7 +16026,7 @@ ${detail}
15734
16026
  const prevTurn = state.turn;
15735
16027
  const rowLedger = row.delivery_ledger ?? null;
15736
16028
  const ledgerDrifted = rowLedger !== (state.deliveryLedgerSeen ?? null);
15737
- const ledger = ledgerFold(row, nowMs);
16029
+ const ledger = ledgerFold(row, nowMs, state);
15738
16030
  consume(state, backend, chainOnLine(ledger.onLine, codexSubAgentOnLine(row, state)));
15739
16031
  state.codexSubAgents?.poll(nowMs);
15740
16032
  const ledgerResult = ledger.finish();
@@ -15949,7 +16241,7 @@ ${detail}
15949
16241
  if (!s) return void 0;
15950
16242
  const pendingQuestion = s.turn === "idle" && s.lastAssistantHasQuestion;
15951
16243
  const nowMs = now();
15952
- return { turn: s.turn, permPrompt: s.permPrompt, permPolicy: s.permPolicy, permDenies: s.permDenies, nativeInputRequired: s.nativeInputRequired, model: s.model, effort: s.effort, profileAt: s.profileAt, profileRevision: s.profileRevision, permissionMode: s.permissionMode, permissionModeAt: s.permissionModeAt, permissionModeRevision: s.permissionModeRevision, lastActivityAt: s.lastActivityAt, lastAssistantAt: s.lastAssistantAt, lastAssistant: s.lastAssistant, aiTitle: s.aiTitle, customTitle: s.customTitle, customTitleRevision: s.customTitleRevision, subAgents: subAgentViews(s, nowMs), droppedReports: [...s.queuedReports.values()], bgShells: [...bgShellViews(s), ...codexBgShellViews(s)], pendingAsk: s.pendingAsk, pendingQuestion, lastAssistantAllDone: s.lastAssistantAllDone, lastUserAt: s.lastUserAt, lastUserText: s.lastUserText, lastFence: s.lastFence, noTranscript: s.noTranscript, authFault: s.authFault, limitFault: s.limitFault, contextTokens: s.contextTokens, contextWindow: s.contextWindow, lastCompactionAt: s.lastCompactionAt };
16244
+ return { turn: s.turn, permPrompt: s.permPrompt, permPolicy: s.permPolicy, permDenies: s.permDenies, nativeInputRequired: s.nativeInputRequired, model: s.model, effort: s.effort, profileAt: s.profileAt, profileRevision: s.profileRevision, permissionMode: s.permissionMode, permissionModeAt: s.permissionModeAt, permissionModeRevision: s.permissionModeRevision, lastActivityAt: s.lastActivityAt, lastAssistantAt: s.lastAssistantAt, lastAssistant: s.lastAssistant, aiTitle: s.aiTitle, customTitle: s.customTitle, customTitleRevision: s.customTitleRevision, subAgents: subAgentViews(s, nowMs), droppedReports: [...s.queuedReports.values()], bgShells: [...bgShellViews(s), ...codexBgShellViews(s)], retiredShells: retiredShellViews(s), pendingAsk: s.pendingAsk, pendingQuestion, lastAssistantAllDone: s.lastAssistantAllDone, lastUserAt: s.lastUserAt, lastUserText: s.lastUserText, lastFence: s.lastFence, noTranscript: s.noTranscript, authFault: s.authFault, limitFault: s.limitFault, contextTokens: s.contextTokens, contextWindow: s.contextWindow, lastCompactionAt: s.lastCompactionAt };
15953
16245
  },
15954
16246
  // The CURRENT fresh foreign session ids (mtime within FOREIGN_FRESH_MS, capped), mtime-desc. Kept
15955
16247
  // as the last scan's result — recomputed at most every FOREIGN_SCAN_EVERY ticks.
@@ -16002,7 +16294,7 @@ function landsAfterRead(eventAt, lastReadAt) {
16002
16294
  if (!Number.isFinite(e) || !Number.isFinite(r)) return true;
16003
16295
  return e > r;
16004
16296
  }
16005
- var IDLE_BACKSTOP_MS, RUNTIME_CHASE_MAX, POLL_MS2, MAX_PRIME_ROWS_PER_TICK, PRIME_BUDGET_MS, TRANSCRIPT_READ_WINDOW, EMPTY_BUFFER, MAX_POLL_MS, SUBAGENT_STALE_MS, FOREIGN_FRESH_MS, FOREIGN_MAX, FOREIGN_SCAN_EVERY, CACHE_FLUSH_MS, PRIME_PROGRESS_EVERY, DISCOVER_RETRY_MS, PERM_YES_OPTION, PERM_QUESTION, PERM_FOOTER, PERM_COMPOSER_FOOTER, PERM_MODAL_TAIL_ROWS, PERM_COMPOSER_TAIL_ROWS, BOOT_APIKEY_HEADLINE, BOOT_APIKEY_QUESTION, BOOT_TRUST_QUESTION, BOOT_TRUST_OPTION, BOOT_MODAL_TAIL_ROWS, RETAINED_SUBAGENTS_MAX, RETAINED_SHELLS_MAX, DESCENDANT_TERMINALS_MAX, PENDING_RESUMES_MAX, PENDING_SHELLS_MAX, RESUME_REPLAY_SLACK_MS, QUESTION_BLOCK_RE, SIGNAL_FENCE_RE, AWAITING_HINT_RE, FENCE_BODY_MAX, HINT_MAX, HINT_VALUE_MAX, LAUNCH_ACK_RE, AUTO_BACKGROUND_ACK_RE, DESCENDANT_SIDECAR_MAX, DESCENDANT_NOTIFY_GRACE_MS, MAX_OWNED_TOOL_USE_IDS, BROKER_LIVENESS_TTL_MS, UNRESTORED_TAIL_FIELDS;
16297
+ var IDLE_BACKSTOP_MS, RUNTIME_CHASE_MAX, POLL_MS2, MAX_PRIME_ROWS_PER_TICK, PRIME_BUDGET_MS, TRANSCRIPT_READ_WINDOW, EMPTY_BUFFER, MAX_POLL_MS, SUBAGENT_STALE_MS, FOREIGN_FRESH_MS, FOREIGN_MAX, FOREIGN_SCAN_EVERY, CACHE_FLUSH_MS, PRIME_PROGRESS_EVERY, DISCOVER_RETRY_MS, DISCOVER_RETRY_MAX_MS, PERM_YES_OPTION, PERM_QUESTION, PERM_FOOTER, PERM_COMPOSER_FOOTER, PERM_MODAL_TAIL_ROWS, PERM_COMPOSER_TAIL_ROWS, BOOT_APIKEY_HEADLINE, BOOT_APIKEY_QUESTION, BOOT_TRUST_QUESTION, BOOT_TRUST_OPTION, BOOT_MODAL_TAIL_ROWS, RETAINED_SUBAGENTS_MAX, RETAINED_SHELLS_MAX, DESCENDANT_TERMINALS_MAX, PENDING_RESUMES_MAX, PENDING_SHELLS_MAX, RESUME_REPLAY_SLACK_MS, QUESTION_BLOCK_RE, SIGNAL_FENCE_RE, AWAITING_HINT_RE, FENCE_BODY_MAX, HINT_MAX, HINT_VALUE_MAX, LAUNCH_ACK_RE, AUTO_BACKGROUND_ACK_RE, DESCENDANT_SIDECAR_MAX, DESCENDANT_NOTIFY_GRACE_MS, MAX_OWNED_TOOL_USE_IDS, BROKER_LIVENESS_TTL_MS, UNRESTORED_TAIL_FIELDS;
16006
16298
  var init_tailer = __esm({
16007
16299
  "packages/server/src/tailer.ts"() {
16008
16300
  "use strict";
@@ -16037,6 +16329,7 @@ var init_tailer = __esm({
16037
16329
  CACHE_FLUSH_MS = 3e4;
16038
16330
  PRIME_PROGRESS_EVERY = 20;
16039
16331
  DISCOVER_RETRY_MS = 15e3;
16332
+ DISCOVER_RETRY_MAX_MS = 15 * 6e4;
16040
16333
  PERM_YES_OPTION = /(^|\n)\s*(❯\s*)?1\.\s+Yes\b/;
16041
16334
  PERM_QUESTION = /\b(?:Do you want|Would you like)\b/;
16042
16335
  PERM_FOOTER = /\bEsc to (cancel|reject)\b/;
@@ -16056,7 +16349,7 @@ var init_tailer = __esm({
16056
16349
  RESUME_REPLAY_SLACK_MS = 6e4;
16057
16350
  QUESTION_BLOCK_RE = /^```question(?:[ \t]+[A-Za-z][^\r\n]*?)?[ \t]*\r?\n[\s\S]*?\r?\n```[ \t]*$/gm;
16058
16351
  SIGNAL_FENCE_RE = /^```(done|awaiting)[ \t]*\n([\s\S]*?)\n```[ \t]*$/gm;
16059
- AWAITING_HINT_RE = /^(pr-watch|human|timer|pr|ci|session):\s*(\S.*)$/i;
16352
+ AWAITING_HINT_RE = /^(shell|agent|timer|pr|for|reason):\s*(\S.*)$/i;
16060
16353
  FENCE_BODY_MAX = 500;
16061
16354
  HINT_MAX = 8;
16062
16355
  HINT_VALUE_MAX = 200;
@@ -16089,6 +16382,7 @@ var init_tailer = __esm({
16089
16382
  "permDenies",
16090
16383
  "noTranscript",
16091
16384
  "nextDiscoverMs",
16385
+ "discoverMisses",
16092
16386
  "stallLogged",
16093
16387
  "deliveryLedgerSeen",
16094
16388
  // The chase bookkeeping is compared against an IN-MEMORY, per-process counter — the ingest's `live`
@@ -16451,8 +16745,8 @@ function parseCodexSubAgentLine(line, toolNameFor) {
16451
16745
  if (pt === "function_call" && p.name === "spawn_agent") {
16452
16746
  const args = parseToolArguments(p.arguments);
16453
16747
  const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
16454
- const str = (v) => typeof v === "string" && v.trim() ? v.trim() : void 0;
16455
- return { kind: "spawn", at, callId, taskName: str(a.task_name), model: str(a.model), effort: str(a.reasoning_effort), agentType: str(a.agent_type) };
16748
+ const str2 = (v) => typeof v === "string" && v.trim() ? v.trim() : void 0;
16749
+ return { kind: "spawn", at, callId, taskName: str2(a.task_name), model: str2(a.model), effort: str2(a.reasoning_effort), agentType: str2(a.agent_type) };
16456
16750
  }
16457
16751
  if (pt !== "function_call_output") return void 0;
16458
16752
  const name = toolNameFor(callId);
@@ -18602,7 +18896,7 @@ var require_sender = __commonJS({
18602
18896
  * @param {Function} [cb] Callback
18603
18897
  * @private
18604
18898
  */
18605
- getBlobData(blob, compress, options, cb) {
18899
+ getBlobData(blob, compress2, options, cb) {
18606
18900
  this._bufferedBytes += options[kByteLength];
18607
18901
  this._state = GET_BLOB_DATA;
18608
18902
  blob.arrayBuffer().then((arrayBuffer) => {
@@ -18615,12 +18909,12 @@ var require_sender = __commonJS({
18615
18909
  }
18616
18910
  this._bufferedBytes -= options[kByteLength];
18617
18911
  const data = toBuffer(arrayBuffer);
18618
- if (!compress) {
18912
+ if (!compress2) {
18619
18913
  this._state = DEFAULT;
18620
18914
  this.sendFrame(_Sender.frame(data, options), cb);
18621
18915
  this.dequeue();
18622
18916
  } else {
18623
- this.dispatch(data, compress, options, cb);
18917
+ this.dispatch(data, compress2, options, cb);
18624
18918
  }
18625
18919
  }).catch((err) => {
18626
18920
  process.nextTick(onError, this, err, cb);
@@ -18649,8 +18943,8 @@ var require_sender = __commonJS({
18649
18943
  * @param {Function} [cb] Callback
18650
18944
  * @private
18651
18945
  */
18652
- dispatch(data, compress, options, cb) {
18653
- if (!compress) {
18946
+ dispatch(data, compress2, options, cb) {
18947
+ if (!compress2) {
18654
18948
  this.sendFrame(_Sender.frame(data, options), cb);
18655
18949
  return;
18656
18950
  }
@@ -24857,20 +25151,64 @@ function parsePrRef(value) {
24857
25151
  return { owner: m[1], repo: m[2].replace(/\.git$/, ""), number };
24858
25152
  }
24859
25153
  function isActionableAwaitingHint(hint) {
24860
- if (!hint) return false;
24861
- if (hint.kind === "timer") return isValidAwaitingTimer(hint.value);
24862
- if (hint.kind === "pr-watch") return parsePrRef(hint.value) !== void 0;
24863
- return false;
25154
+ return hint !== void 0 && false;
25155
+ }
25156
+ function readAwaitingPark(hints) {
25157
+ const items = [];
25158
+ let forMs = null;
25159
+ let reason = "";
25160
+ for (const h of hints) {
25161
+ const value = h.value.trim();
25162
+ if (isAwaitingItemKind(h.kind)) {
25163
+ if (value) items.push({ kind: h.kind, value });
25164
+ } else if (h.kind === "for") {
25165
+ forMs = parseAwaitingDuration(value);
25166
+ } else if (h.kind === "reason" && !reason) {
25167
+ reason = value;
25168
+ }
25169
+ }
25170
+ return { items, forMs, reason };
25171
+ }
25172
+ function unaccountedItems(items, live) {
25173
+ return items.filter((i) => !live[LIVE_SET[i.kind]].has(i.value));
25174
+ }
25175
+ function parkIsHonoured(park, live) {
25176
+ if (park.items.length === 0) return false;
25177
+ if (park.forMs === null) return false;
25178
+ return unaccountedItems(park.items, live).length === 0;
25179
+ }
25180
+ function parkExpiresAt(park, fenceAtMs) {
25181
+ if (park.forMs === null || !Number.isFinite(fenceAtMs)) return null;
25182
+ return fenceAtMs + Math.min(park.forMs, AWAITING_FOR_MAX_MS);
24864
25183
  }
24865
25184
  function awaitingFenceIdentity(hint, fenceAt) {
24866
25185
  return createHash11("sha256").update(fenceAt).update("\0").update(hint.kind).update("\0").update(hint.value).digest("hex");
24867
25186
  }
24868
- var PR_REF_RE;
25187
+ function readGithubStatusBook(raw2) {
25188
+ if (!raw2 || typeof raw2 !== "object" || Array.isArray(raw2)) return {};
25189
+ const out = {};
25190
+ for (const [key, value] of Object.entries(raw2)) {
25191
+ const parsed = GithubWatchStatus.safeParse(value);
25192
+ if (parsed.success) out[key] = parsed.data;
25193
+ }
25194
+ return out;
25195
+ }
25196
+ function githubStatusKey(ref) {
25197
+ return `${ref.owner}/${ref.repo}#${ref.number}`;
25198
+ }
25199
+ var PR_REF_RE, LIVE_SET, GITHUB_STATUS_SETTING;
24869
25200
  var init_awaiting = __esm({
24870
25201
  "packages/server/src/awaiting.ts"() {
24871
25202
  "use strict";
24872
25203
  init_src();
24873
25204
  PR_REF_RE = /(?:https?:\/\/github\.com\/)?([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\/pull\/|\/pulls\/|#)(\d+)/;
25205
+ LIVE_SET = {
25206
+ shell: "shells",
25207
+ agent: "agents",
25208
+ timer: "timers",
25209
+ pr: "prs"
25210
+ };
25211
+ GITHUB_STATUS_SETTING = "waker.github.status.v1";
24874
25212
  }
24875
25213
  });
24876
25214
 
@@ -24971,11 +25309,12 @@ triage: it says neither "answer me" nor "this is finished", so it sits in the qu
24971
25309
  Frizz corrects it, twice, and then stops: rest without a fence and you will be told to pick one, which
24972
25310
  costs you a turn each time.
24973
25311
 
24974
- Pick \`question\` when you need the human, \`done\` when the work is genuinely finished, and REGISTER A
24975
- WAIT (\`mcp__frizz__watch\`) when you are blocked on a PR, CI, or your own background shell \u2014 a
24976
- registered wait is durable, survives compaction and restarts, and can be dropped when it stops
24977
- mattering. You stay OUT of the queue only while you own a live sub-agent or a valid \`awaiting\` fence;
24978
- a detached background process does NOT hold you out.
25312
+ Pick \`question\` when you need the human, \`done\` when the work is genuinely finished, and \`awaiting\`
25313
+ when you are WAITING \u2014 on a PR, or on your own background work \u2014 naming what you wait on, one line each.
25314
+
25315
+ \`awaiting\` REGISTERS NOTHING. It is how you come to rest without being bumped for a handoff, and how
25316
+ the human sees what you are waiting on. Your background shells and sub-agents are watched AUTOMATICALLY,
25317
+ fence or no fence: frizz wakes you when one finishes, every time.
24979
25318
 
24980
25319
  **WRITE IT TO BE READ COLD.** The human reads your final message in a queue, hours later, with none of
24981
25320
  your context \u2014 and they have NOT seen anything since their own last message. Every prompt you received
@@ -24986,6 +25325,16 @@ you would do next \u2014 rather than only the last thing you touched. Do not ass
24986
25325
  Be concise, but do not omit. Use whatever markup makes it readable: headings, bullets, tables, code.
24987
25326
  The one place brevity is a RULE is a \`done\` body \u2014 see its entry below.
24988
25327
 
25328
+ **NAME THE PULL REQUEST, EVERY TIME YOU REST.** If your effort has a PR \u2014 one you opened, one you are
25329
+ reviewing, one you are fixing \u2014 write its number in EVERY resting message, not just the one where it
25330
+ first appeared. The human reads these as a queue of cards from a dozen threads at once, and a card that
25331
+ says "pushed the fix, CI is green" without a number is a card they cannot place: they have to open the
25332
+ thread to learn which PR it was about (maintainer 2026-08-16: "I keep being unclear what PR is being
25333
+ implemented in a given chat"). One \`#123\` in the first line or two costs you nothing and answers it.
25334
+
25335
+ Several PRs in play \u21D2 name them all. No PR yet \u21D2 say what the work is against instead (a branch, an
25336
+ issue, a file) \u2014 the point is that the card identifies its own subject without being opened.
25337
+
24989
25338
  Frizz turns \`#123\`, \`owner/repo#123\` and bare commit hashes into github.com links itself, in every
24990
25339
  surface it draws your prose, for any project whose origin remote is github.com. Write the bare form;
24991
25340
  wrapping one in a \`[link](url)\` yourself is redundant. Everything else you want clickable \u2014 a file, a
@@ -24995,8 +25344,14 @@ Use at most ONE fenced signal block, at the very END of the final message. The f
24995
25344
  state; the body is the card the human reads. A \` \`\`\`question \` block and real permission prompts are
24996
25345
  higher-priority asks than either fence below.
24997
25346
 
25347
+ **THE FENCE AND THE PROSE ABOVE IT ARE TWO SURFACES, NOT ONE MESSAGE WRITTEN TWICE.** The card is the
25348
+ LEDGER: one bullet per deliverable, what shipped and where. The prose is only what a ledger cannot hold
25349
+ \u2014 the reasoning, the caveat, the thing the human has to do. If a sentence would read the same in either,
25350
+ it belongs in exactly ONE of them. Writing the summary and then restating it as bullets costs the human
25351
+ a second read for nothing, and it is the commonest thing wrong with an otherwise good handoff.
25352
+
24998
25353
  - \` \`\`\`done \` \u2014 you COMPLETED the effort's real work: code LANDED on the project's mainline, a plan or
24999
- doc written, or a commissioned research/audit report finished. Body: ONE TO THREE SENTENCES, then a
25354
+ doc written, or a commissioned research/audit report finished INTO A FILE. Body: ONE TO THREE SENTENCES, then a
25000
25355
  BULLET LIST, one \`- \` item per task, each opening with a **bolded verb phrase** and naming what
25001
25356
  shipped and where. This is the ONE place the short shape is a rule \u2014 a dismissal card is scanned, not
25002
25357
  read, so it is a ledger. Everywhere else, say as much as the reader needs. The card renders inline markdown, so backtick every path,
@@ -25011,62 +25366,140 @@ higher-priority asks than either fence below.
25011
25366
  \`\`\`
25012
25367
 
25013
25368
  **\`done\` is a DISMISSAL, not a summary** \u2014 its card files the thread away where nobody looks again,
25014
- and anything living only in the conversation goes with it. If the thread points at future work AT ALL,
25015
- bare-rest instead; uncertain is not done. What blocks \`done\` is work you still OWE, not a process that
25016
- happens to still be RUNNING: a watcher, dev server, or poller you have already moved on from does not
25017
- hold it back \u2014 name it in the body and fence anyway. A \`done\` fence REPLACES the awaiting-background
25018
- card with your own completion card, which is what turns a finished thread with a live watcher into a
25019
- one-click dismissal instead of a card the human has to keep deciding about. Code written but not LANDED
25020
- is not done (a commit, a pushed branch, an open PR is work still ahead of the merge \u2014 where the project
25021
- uses PRs, \`done\` waits for the MERGE, so park the PR on \` \`\`\`awaiting \` with \`pr-watch:\`). An
25022
- investigation headed for a fix is NOT \`done\`; the fix is still owed. Two cases DO earn \`done\` on
25023
- something other than landed code: a commissioned research or audit EFFORT, whose finished report is the
25024
- deliverable, earns \`done\`; and so does a PLANNING session whose plan file is FULLY written and
25025
- PERSISTED (\`.frizz/plans/<topic>.md\`), because that artifact already lives outside the thread, so
25026
- dismissing the thread loses nothing.
25027
-
25028
- - \` \`\`\`awaiting \` \u2014 a durable wait frizz's scheduler manages. Lead the body with one or more
25029
- \`kind: value\` hint lines, then concise prose naming the exact wake condition. New waits use only:
25030
-
25031
- - \`pr-watch: owner/repo#NUMBER\` \u2014 frizz polls the PR and resumes you on ANY new review, approval, or
25032
- comment, from a HUMAN OR A BOT alike. Your thread STAYS IN THE QUEUE as a visible "watching it"
25033
- handoff. This is the default for a PR you opened. ONE line watches ONE PR, and a fence may carry
25034
- SEVERAL \u2014 repeat the line per PR, across any mix of repos, and activity on ANY of them wakes you.
25035
- Never downgrade to a periodic \`timer:\` sweep just because you are tracking more than one PR. The
25036
- body keeps its first 8 hint lines and silently DROPS the rest, so past 8 watch the 8 that matter
25037
- and cover the tail with a \`timer:\`.
25038
-
25039
- **READ THE PR BEFORE YOU PARK ON IT.** After your first park on a given PR the baseline is the
25040
- INSTANT YOU REST \u2014 only what arrives AFTER can wake you again. Frizz covers the first park by
25041
- replaying whatever was already sitting there, once, so review you never read cannot be lost; but
25042
- that replay costs you a whole turn to answer something you could have read before resting. Run
25043
- \`gh pr view N --comments\` first, deal with what is there, and only then write the fence.
25044
- "Waiting on review" is a claim that review has not arrived; go and check that it is true.
25045
- - \`human: <actor + exact review/approval>\` \u2014 a third party whose action cannot be supplied in this
25046
- frizz conversation. PARKS you in the dimmed Held band. A bot, CI gate, or merge queue is NOT a human
25047
- wait. Pair with \`pr-watch:\` when a GitHub PR exists, or \`timer:\` when none does.
25048
- - \`timer: <ISO-8601 instant>\` \u2014 the durable scheduler resumes you then, across restarts
25049
- (\`{{FRIZZ_RESUME_CMD}}\`).
25369
+ and anything living only in the conversation goes with it. So the test is never "have I stopped
25370
+ working": it is **WHAT IS LOST IF NOBODY EVER OPENS THIS THREAD AGAIN?** Name one thing and it is not
25371
+ done. If the thread points at future work AT ALL, you are not done \u2014 see **When the work is finished
25372
+ but the thread found more** for the ways out of that, none of which is stretching \`done\`. Uncertain
25373
+ is not done.
25374
+
25375
+ **A RECOMMENDATION IS NOT A CONCLUSION, AND AN UNSENT DRAFT IS NOT A DELIVERABLE.** When the verdict
25376
+ is that SOMEONE SHOULD NOW DO SOMETHING \u2014 merge it, decline it, post this comment, pick one of these
25377
+ two designs, press the button you are not allowed to press \u2014 that someone is the human, so the effort
25378
+ ends on a \` \`\`\`question \` carrying your recommendation as option A. Same for anything you WROTE but
25379
+ did not SEND: a drafted comment, reply, issue body or release note you were right not to publish
25380
+ unilaterally is one click short of delivered, and \`done\` files that draft away with the thread.
25381
+
25382
+ **A \`done\` MESSAGE SAYS WHAT HAPPENED. DELETE EVERY DANGLING "WORTH DOING LATER".** "One thing to
25383
+ carry forward\u2026", "a follow-up could\u2026", "someone should add a changelog line before this ships" \u2014 a
25384
+ forward-reference parked in a dismissal card is clutter: too weak for anyone to act on, too present to
25385
+ ignore, and archived unread. Each one resolves four ways and there is no fifth \u2014 DO it, SPAWN it onto
25386
+ its own card, ASK about it, or DROP it. Something the human must do NOW ("re-pull before you restart")
25387
+ is not a dangling idea; that is the handoff, and it belongs in the prose.
25388
+
25389
+ What blocks \`done\` is work still OWED \u2014 by you, or by the human because of what you found \u2014 not a
25390
+ BACKGROUND PROCESS that happens to still be running: a watcher, dev server, or poller you have already
25391
+ moved on from does not hold it back \u2014 name it in the body and fence anyway. **Read that carve-out
25392
+ narrowly: its subject is a running process, and nothing else.** Follow-up work you DISCOVERED blocks
25393
+ \`done\` just as hard as work you were assigned, even when it is someone else's to do \u2014 "not mine" is
25394
+ not "not owed", because the card takes the finding with it either way. A \`done\` fence REPLACES the
25395
+ awaiting-background card with your own completion card, which is what turns a finished thread with a
25396
+ live watcher into a one-click dismissal instead of a card the human has to keep deciding about. Code
25397
+ written but not LANDED is not done (a commit, a pushed branch, an open PR is work still ahead of the
25398
+ merge \u2014 where the project uses PRs, \`done\` waits for the MERGE, so park the PR on \` \`\`\`awaiting \`
25399
+ with \`pr:\`). An investigation headed for a fix is NOT \`done\`; the fix is still owed. Two cases
25400
+ DO earn \`done\` on something other than landed code, and BOTH earn it the same way \u2014 by passing the
25401
+ test above, because the deliverable is a FILE the human opens without this thread: a commissioned
25402
+ research or audit EFFORT, whose finished report is written to that file, earns \`done\`; and so does a
25403
+ PLANNING session whose plan file is FULLY written and PERSISTED (\`.frizz/plans/<topic>.md\`), because
25404
+ that artifact already lives outside the thread, so dismissing the thread loses nothing. Neither
25405
+ exception stretches to a report that ENDS IN A DECISION the human has yet to make: write the file AND
25406
+ ask the question.
25407
+
25408
+ - \` \`\`\`awaiting \` \u2014 you have STOPPED, and you are waiting on work that is actually running. The
25409
+ fence is PURE STRUCTURE: a list of things frizz can look up, how long the park may stand, and one line
25410
+ of prose. Nothing else belongs in it.
25050
25411
 
25051
25412
  \`\`\`awaiting
25052
- pr-watch: acme/app#391
25053
- PR is open and CI is green. Watching for review \u2014 I'll address comments or merge on approval.
25413
+ shell: bzvtnt3ig
25414
+ agent: <the runtime agent id>
25415
+ timer: tmr_a1b2c3d4e5f6
25416
+ pr: acme/app#391
25417
+ for: 2h
25418
+ ---
25419
+ Waiting on the three-platform run before porting the v2 drivers.
25420
+
25421
+ - the macOS leg is the one that has been flaky
25422
+ - if it goes red I will bisect rather than re-run
25054
25423
  \`\`\`
25055
25424
 
25056
- \`pr:\` / \`ci:\` / \`session:\` remain parser compatibility for existing transcripts only; never emit
25057
- them. The operator's own answer or approval is a \` \`\`\`question \`, not \`awaiting\`.
25425
+ **FRONTMATTER, THEN MARKDOWN.** The structural lines come first; a \`---\` line ends them and everything
25426
+ after it is ORDINARY MARKDOWN \u2014 as much as you want, with paragraphs, lists and links. That prose is what
25427
+ the human reads on your resting card, so write it as a handoff, not as a label.
25428
+
25429
+ Omit the \`---\` and the whole fence is structure. A single \`reason: <one line>\` line also still works, but
25430
+ prefer the delimiter: a handoff is prose, and one line is not enough room for one.
25431
+
25432
+
25433
+ - \`shell:\` / \`agent:\` \u2014 the runtime id you were handed when you launched it ("Command running in
25434
+ background with ID: bzvtnt3ig").
25435
+
25436
+ **LOST AN ID? \`mcp__frizz__activity\` PRINTS THEM ALL** \u2014 every shell, sub-agent, timer and PR you have
25437
+ out, each with the exact line your fence needs. Call it rather than guessing: a guessed id names
25438
+ nothing, so frizz refuses the park and bumps you.
25439
+ - \`timer:\` \u2014 a timer id from \`mcp__frizz__timer\`. NOT an instant; the tool creates the timer.
25440
+ - \`pr:\` \u2014 a pull request you REGISTERED with \`mcp__frizz__watch_pr\`, written \`owner/repo#N\`. Register
25441
+ FIRST: this line states the wait, it does not create one. That tool wakes you on CI turning green or red,
25442
+ and on every later review, approval or comment, human or bot, for as long as the PR lives.
25443
+ - \`for:\` \u2014 **REQUIRED**, and a DURATION: \`30s\`, \`15m\`, \`2h\`, \`3d\`. Never an instant. When it runs
25444
+ out frizz brings you back to re-check everything; re-parking is fine and uncapped.
25445
+ - \`reason:\` \u2014 a one-line alternative to the \`---\` block above, kept for fences already written
25446
+ that way. Prefer the delimiter.
25447
+
25448
+ **REGISTERING IS NOT PARKING, AND PARKING IS NOT REGISTERING.** Launching a shell, dispatching a
25449
+ sub-agent, setting a timer, registering a PR watcher \u2014 those just happen, and frizz watches them
25450
+ whether or not you ever write a fence. This fence is only how you declare that you have STOPPED, and
25451
+ name which of them you stopped for.
25058
25452
 
25059
- **CI, releases, deploys and merge progression are automatable \u2014 never \`awaiting\` them.** They stay
25060
- ACTIVE: dispatch a sub-agent to own the wait (its liveness keeps you Active and its return re-invokes
25061
- you), or use a timer when the next check genuinely belongs at a later wall-clock time.
25453
+ **A FENCE THAT NAMES NOTHING IS NOT A PARK.** \`for:\` and \`reason:\` describe a wait; they are not one.
25454
+ If you are waiting on CI, REGISTER the PR and name it \u2014 do not write "waiting on the checks" and a
25455
+ duration, because nothing will wake you and your thread just sits in the queue. And if you are not
25456
+ actually waiting on anything, you are not awaiting: end with \`\`\`done, or ask a \`\`\`question.
25457
+
25458
+ **FRIZZ CHECKS EVERY NAME THE MOMENT YOU REST.** All of them live \u21D2 your thread parks in the dimmed
25459
+ Held band. Any one of them dead, unknown, or not yours \u21D2 you are BUMPED immediately and told which.
25460
+ A fence naming nothing at all is not a park either \u2014 it is a claim to be waiting with no way to be
25461
+ woken, which is the exact failure this grammar exists to make impossible.
25462
+
25463
+ **WAITING ON A PERSON IS A \` \`\`\`question \`.** There is no human gate and there is no prose park.
25464
+ If you need a human, ask them something answerable.
25465
+
25466
+ **CI, RELEASES, DEPLOYS AND MERGE PROGRESSION ARE AUTOMATABLE \u2014 never \` \`\`\`awaiting \` them
25467
+ BLINDLY.** For a PULL REQUEST that is what \`mcp__frizz__watch_pr\` is for: register it and you are told
25468
+ when CI settles, so the wait is real rather than a hope. For anything else, stay ACTIVE \u2014 dispatch a
25469
+ sub-agent to own the wait (its return re-invokes you), or set a timer and name it here.
25470
+
25471
+ **THE PARK SURVIVES A RESTART.** The registration and the fence are both durable, so frizz brings you
25472
+ back (\`{{FRIZZ_RESUME_CMD}}\`) even if its own server or your daemon is replaced while you wait.
25062
25473
 
25063
25474
  **A follow-up clears the previous fence.** If the human says "back to awaiting", never answer that it
25064
- is already parked and never rely on the old fence: re-check the blocker, then re-emit a fresh
25065
- \` \`\`\`awaiting \` with a current \`human:\` / \`timer:\` / \`pr-watch:\` hint. If it turns out to be
25066
- automatable, arm the active wait instead and do not fence.
25475
+ is already parked and never rely on the old fence: re-check what you are waiting on, confirm it is
25476
+ still running, and emit a FRESH fence naming it. If it turns out to be automatable, arm the active
25477
+ wait instead and do not fence.
25067
25478
 
25068
25479
  - \` \`\`\`question \` \u2014 you need the human's input; see **Questions for the human**.
25069
25480
 
25481
+ ### When the work is finished but the thread found more
25482
+
25483
+ Your mandate is complete and you turned up something else: a bug you were not sent for, a fix nobody
25484
+ landed, a follow-up the change implies. \`done\` is barred \u2014 the finding dies with the card. \`awaiting\`
25485
+ is barred \u2014 nothing is running. \`question\` looks barred \u2014 the human asked nothing. **That deadlock is
25486
+ not real, and stretching \`done\` is not how you break it.** Take the first exit that fits:
25487
+
25488
+ 1. **DO IT** \u2014 small, in scope, yours to make. The default from **The stop criterion**. Then \`done\` is
25489
+ honest with nothing left over.
25490
+ 2. **HAND IT OFF TO ITS OWN CARD** \u2014 \`mcp__frizz__spawn_thread\` puts a separable effort on the board as
25491
+ its own thread. Once the follow-up has a card, THIS thread owes nothing and \`done\` is accurate; put
25492
+ the returned \`[title](/thread/<slug>)\` link in the body. Spawn what deserves an effort, not every
25493
+ stray observation \u2014 but an observation that does not deserve a card does not deserve a trailing
25494
+ sentence either. DROP it.
25495
+ 3. **ASK** \u2014 the human should choose whether or how it happens. An ordinary \` \`\`\`question \`; "nothing
25496
+ was pending" is not an objection. Make "spawn it as its own thread" an option when it is a candidate.
25497
+ 4. **DROP IT** \u2014 it is not worth an effort, a card, or a question. Then it is not worth a SENTENCE
25498
+ either: delete it. Do not park it in the handoff as "one thing to carry forward".
25499
+
25500
+ A bare rest is the residual, not a plan \u2014 legitimate only when nothing above fits, and frizz will ask
25501
+ you twice for a fence before it gives up.
25502
+
25070
25503
  A mid-conversation turn carries NO fence. Nor is a turn on a thread that still points at future work \u2014
25071
25504
  a live code-change discussion above all \u2014 ever \`done\`.`;
25072
25505
  AGENT_COMPLETION = `## Agent completion invariant
@@ -25165,6 +25598,23 @@ Three specific traps, each of which has ended a turn that should have continued:
25165
25598
  - **Pre-emptively worrying about context budget.** You do not get to stop early because the turn feels
25166
25599
  long. If context genuinely runs out, the harness summarizes and you continue.
25167
25600
 
25601
+ **AND THE INSTRUCTION IS ALSO THE CEILING.** Everything above pushes you not to stop early; none of it
25602
+ is a licence to grow the job. "Keep going" has no upper bound of its own \u2014 there is always more to do
25603
+ in any repository \u2014 so a worker that may not stop until nothing is left can only ever stop by widening
25604
+ what it was asked. Finish the mandate you were given, and nothing else.
25605
+
25606
+ - **Work you notice on the way is a FINDING, not a task.** The bug beside the one you were sent for,
25607
+ the refactor the code obviously wants, the second issue the first one touches: name each in one line
25608
+ in your handoff and let the human dispatch it. Widening is not thoroughness \u2014 it buries the answer
25609
+ they asked for under changes they now have to review, and it spends hours they did not agree to.
25610
+ - **If the deliverable is a DOCUMENT, the document is the ending.** A triage, a review, an
25611
+ investigation, a recommendation, a plan \u2014 when that is what you were asked for, the finished
25612
+ write-up IS the work. Implementing what it proposes is the NEXT job, and it is not yours unless you
25613
+ were asked.
25614
+ - **An unanswered question is not permission to build the answer.** If you asked and nobody has
25615
+ replied, decide what the task itself needs and carry on INSIDE it. Do not treat the silence as
25616
+ authorization for the work the answer would have unlocked.
25617
+
25168
25618
  **Your default is to DECIDE.** A reversible call costs minutes to redo; a round-trip to the human costs
25169
25619
  hours \u2014 they may not read your question for hours, and the whole effort sits idle until they do. If it
25170
25620
  is derivable from the code, the conventions, or ordinary engineering judgment, it is yours: make it,
@@ -25173,12 +25623,20 @@ say which way you went, and keep moving.
25173
25623
  **The test that catches almost every bad question: if you are about to mark one option
25174
25624
  \`(recommended)\`, you already know the answer \u2014 so implement it instead of asking.** Writing a
25175
25625
  confident rationale for your own recommendation is proof the decision was yours to make. The same goes
25176
- for "want me to fix it?" on a bug you were dispatched to fix or noticed on the way, "should I also
25626
+ for "want me to fix it?" on a bug you were dispatched to fix, "should I also
25177
25627
  handle X?" where X is obviously in scope, and any question about a name, a default value, a file
25178
25628
  location, an error message, a flag spelling, or which of two equivalent designs to use. Those are
25179
- granular implementation calls. Make them, note the notable ones in your handoff, and move on \u2014 a
25629
+ granular implementation calls. (A bug you merely NOTICED is not a question either \u2014 by the ceiling
25630
+ above it is a line in your handoff, not work to adopt and not permission to seek.) Make them, note the
25631
+ notable ones in your handoff, and move on \u2014 a
25180
25632
  decision the human can see and reverse in a line of code is not worth an hours-long stall.
25181
25633
 
25634
+ **That test inverts when knowing the answer and being ABLE TO ACT ON IT come apart.** Where the act is
25635
+ one you are not permitted or not able to perform \u2014 a read-only boundary, a comment that goes out under
25636
+ the human's name, a merge, a close, a publish, a spend \u2014 your recommendation is not a decision you take
25637
+ silently. It becomes the QUESTION, with the recommendation as option A and the act spelled out concretely
25638
+ enough to approve in one word. Never resolve that fork by fencing \` \`\`\`done \` on the investigation.
25639
+
25182
25640
  Stop only when a wrong guess would be BOTH costly AND hard to undo:
25183
25641
 
25184
25642
  - destructive or irreversible actions (history rewrite, data loss, force-merge, a published release);
@@ -25471,8 +25929,9 @@ When delegation is explicitly authorized:
25471
25929
  Keep automatable waits inside the active turn through the selected persistent \`exec_command\` /
25472
25930
  \`write_stdin\` monitor session until it reaches a terminal condition. Then diagnose/fix/retry/merge as
25473
25931
  authorized. Do not emit \`awaiting\` for CI,
25474
- automated review, release, or merge progression. Those tool sessions are process-bound; use a durable
25475
- \`timer:\` awaiting fence only when the next check belongs at a named wall-clock instant. A partial
25932
+ automated review, release, or merge progression. Those tool sessions are process-bound; set a durable
25933
+ timer with \`mcp__frizz__timer\` and name it in your fence only when the next check belongs at a later
25934
+ wall-clock time. A partial
25476
25935
  \`gh pr checks\` rollup is not a CI-green verdict: inspect workflow runs for the exact PR head too, and
25477
25936
  treat \`ACTION_REQUIRED\` fork gates as pending. When no valid project monitor is declared, run the
25478
25937
  bundled \`ci-watch.mjs\` / \`review-watch.mjs\` named above instead of inventing a loop of your own.
@@ -25497,7 +25956,7 @@ So when a wait is longer than a single yield budget:
25497
25956
  3. Drain each yield fully before the next one, and stop as soon as the result is terminal.
25498
25957
 
25499
25958
  If you find yourself on the third identical poll with no new output, you are in this anti-pattern:
25500
- switch to a blocking monitor, or emit a \`timer:\` awaiting fence for a genuinely distant check.
25959
+ switch to a blocking monitor, or set a timer with \`mcp__frizz__timer\` for a genuinely distant check.
25501
25960
 
25502
25961
  When you genuinely need to work alongside a disposable local process (a dev server or long gate), use
25503
25962
  the managed unified-exec handoff: create the \`tools.exec_command(...)\` promise, call
@@ -25512,8 +25971,9 @@ the cell can wake a rested turn.
25512
25971
 
25513
25972
  Never implement a recurring wake as a shell \`sleep\` loop. Shell output cannot create a new Codex turn,
25514
25973
  so an unattended loop is not a wake mechanism. When a later wall-clock check genuinely belongs after a
25515
- rest, emit an \`awaiting\` fence with \`timer: <ISO-8601 instant>\`; Frizz's durable scheduler starts the
25516
- new turn at that instant. The timer is one-shot; do not try to make it recur.
25974
+ rest, set one with \`mcp__frizz__timer\` and name the id it returns on a \`timer:\` line in your
25975
+ \`\`\`awaiting fence; Frizz's durable scheduler starts the new turn then. Each timer is one-shot \u2014 set
25976
+ several if you need several, and do not try to make one recur.
25517
25977
 
25518
25978
  ## Your model and reasoning effort
25519
25979
 
@@ -25538,7 +25998,13 @@ effort that deserves its own card and whose output you do not need.
25538
25998
 
25539
25999
  Give it a self-contained \`prompt\` and choose \`model\` + \`effort\` by the new task's complexity (both
25540
26000
  required). It returns a \`[title](/thread/<slug>)\` link \u2014 put that in your handoff so the human can open
25541
- it.`;
26001
+ it.
26002
+
26003
+ **Its most valuable use is the one that unblocks \` \`\`\`done \`.** When your own mandate is complete but
26004
+ you turned up separable follow-up work, that finding is exactly what a \`done\` card would bury. Spawn it,
26005
+ and it lives on its own card instead of inside a thread nobody will reopen \u2014 which is what makes \`done\`
26006
+ on THIS thread honest rather than a stretch. See **When the work is finished but the thread found
26007
+ more**.`;
25542
26008
  THREAD_EXECUTION = {
25543
26009
  claude: `## Thread types
25544
26010
 
@@ -26967,17 +27433,6 @@ function buildClaudeResumeCommand(opts) {
26967
27433
  if (opts.message) argv.push(opts.message);
26968
27434
  return argv;
26969
27435
  }
26970
- function armDefaultGoal(storage, slug) {
26971
- try {
26972
- storage.setRecurringPromptBySlug(slug, {
26973
- prompt: DEFAULT_RECURRING_PROMPT,
26974
- ...DEFAULT_GOAL_TRIGGERS,
26975
- intervalMs: null,
26976
- armedAt: (/* @__PURE__ */ new Date()).toISOString()
26977
- });
26978
- } catch {
26979
- }
26980
- }
26981
27436
  function createDispatcher(deps) {
26982
27437
  const readBoardSource = deps.readBoard ?? readBoard;
26983
27438
  const frizzDir = join23(deps.project.dir, ".frizz");
@@ -27063,7 +27518,6 @@ function createDispatcher(deps) {
27063
27518
  effort: effort ?? null,
27064
27519
  permission_mode: permissionMode
27065
27520
  });
27066
- armDefaultGoal(deps.storage, slug);
27067
27521
  deps.storage.setBackend(slug, "codex");
27068
27522
  deps.storage.setAgentSession(slug, spawned.binding.codexSessionId);
27069
27523
  deps.storage.setCodexRuntime(slug, "app-server");
@@ -27124,7 +27578,6 @@ function createDispatcher(deps) {
27124
27578
  effort: effort ?? null,
27125
27579
  permission_mode: permissionMode
27126
27580
  });
27127
- armDefaultGoal(deps.storage, slug);
27128
27581
  deps.storage.setBackend(slug, "claude");
27129
27582
  deps.storage.setClaudeRuntime(slug, "broker");
27130
27583
  void deps.board.rebuild().catch(() => {
@@ -27501,6 +27954,7 @@ var init_plan_files = __esm({
27501
27954
  import {
27502
27955
  watch as fsWatch
27503
27956
  } from "node:fs";
27957
+ import { homedir as homedir12 } from "node:os";
27504
27958
  import { join as join25 } from "node:path";
27505
27959
  import watcher from "@parcel/watcher";
27506
27960
  function appServerTurnStalled(liveness, lastActivityAt, nowMs) {
@@ -27564,6 +28018,53 @@ function stampStoppableShells(shells, row) {
27564
28018
  if (isHeadlessRow(row)) return shells;
27565
28019
  return shells.map((shell) => shell.stoppable ? { ...shell, stoppable: false } : shell);
27566
28020
  }
28021
+ function declaredWaitIds(tele) {
28022
+ if (tele?.lastFence?.kind !== "awaiting") return [];
28023
+ return readAwaitingPark(tele.lastFence.hints).items.filter((i) => i.kind === "shell" || i.kind === "agent").map((i) => i.value);
28024
+ }
28025
+ function liveWaitHandles(tele) {
28026
+ const handles = /* @__PURE__ */ new Set();
28027
+ for (const shell of tele?.bgShells ?? []) {
28028
+ if (shell.state !== "running") continue;
28029
+ if (shell.id) handles.add(shell.id);
28030
+ if (shell.taskId) handles.add(shell.taskId);
28031
+ if (shell.label) handles.add(shell.label);
28032
+ }
28033
+ for (const agent of tele?.subAgents ?? []) {
28034
+ if (!isDirectSubAgent(agent) || agent.state !== "running") continue;
28035
+ handles.add(agent.id);
28036
+ if (agent.label) handles.add(agent.label);
28037
+ }
28038
+ return handles;
28039
+ }
28040
+ function hasDeclaredBackgroundPark(tele, nowMs) {
28041
+ const named = declaredWaitIds(tele);
28042
+ if (named.length === 0) return false;
28043
+ const live = liveWaitHandles(tele);
28044
+ if (!named.every((id) => live.has(id))) return false;
28045
+ const restedMs = Date.parse(tele?.lastAssistantAt ?? "");
28046
+ if (Number.isFinite(restedMs) && nowMs - restedMs > DECLARED_PARK_MAX_MS) return false;
28047
+ return true;
28048
+ }
28049
+ function hasDeclaredWait(tele, nowMs) {
28050
+ if (hasDeclaredBackgroundPark(tele, nowMs)) return true;
28051
+ if (tele?.lastFence?.kind === "awaiting" && hasParkedPrWatch(tele)) return true;
28052
+ return (tele?.subAgents ?? []).some((a) => isDirectSubAgent(a) && a.state === "running");
28053
+ }
28054
+ function heldByRunningChecks(tele, github, registered) {
28055
+ if (tele?.lastFence?.kind !== "awaiting") return false;
28056
+ const refs = [];
28057
+ for (const hint of tele.lastFence.hints) {
28058
+ if (hint.kind !== "pr") continue;
28059
+ const ref = parsePrRef(hint.value);
28060
+ if (ref && registered.has(githubStatusKey(ref))) refs.push(githubStatusKey(ref));
28061
+ }
28062
+ if (refs.length === 0) return false;
28063
+ return refs.every((key) => {
28064
+ const status = github[key];
28065
+ return status?.checks === "running" && status.state === "open";
28066
+ });
28067
+ }
27567
28068
  function hasLiveOwnWork(tele) {
27568
28069
  return Boolean(
27569
28070
  tele?.subAgents?.some((agent) => isDirectSubAgent(agent) && agent.state === "running") || tele?.bgShells?.some((shell) => shell.state === "running") || // A PARKED PR WATCHER COUNTS (2026-08-13). It is the same shape of thing as a detached shell: the
@@ -27577,46 +28078,47 @@ function hasLiveOwnWork(tele) {
27577
28078
  }
27578
28079
  function hasParkedPrWatch(tele) {
27579
28080
  if (tele?.lastFence?.kind !== "awaiting") return false;
27580
- return tele.lastFence.hints.some((hint) => hint.kind === "pr-watch" && parsePrRef(hint.value) !== void 0);
28081
+ return tele.lastFence.hints.some((hint) => hint.kind === "pr" && parsePrRef(hint.value) !== void 0);
27581
28082
  }
27582
28083
  function bgSnoozeArmed(row) {
27583
28084
  return row.bg_snooze_rested_at != null && row.rested_at != null && row.bg_snooze_rested_at === row.rested_at;
27584
28085
  }
27585
- function hasParkedExternalWait(tele, nowMs) {
27586
- if (tele?.lastFence?.kind !== "awaiting") return false;
27587
- return tele.lastFence.hints.some(
27588
- (hint) => hint.kind === "human" || hint.kind === "timer" && isValidAwaitingTimer(hint.value) && Date.parse(hint.value) > nowMs
27589
- );
28086
+ function hasParkedExternalWait(tele, _nowMs) {
28087
+ return false;
27590
28088
  }
27591
- function githubWatchViews(slug, tele, fenceAt) {
27592
- if (tele?.lastFence?.kind !== "awaiting") return [];
28089
+ function fenceWatchViews(slug, tele, fenceAt, github = {}, registered = []) {
27593
28090
  const seen = /* @__PURE__ */ new Set();
27594
28091
  const out = [];
27595
- for (const hint of tele.lastFence.hints) {
27596
- if (hint.kind !== "pr-watch") continue;
27597
- const ref = parsePrRef(hint.value);
27598
- if (!ref) continue;
27599
- const target = `${ref.owner}/${ref.repo}#${ref.number}`;
27600
- if (seen.has(target)) continue;
27601
- seen.add(target);
28092
+ for (const w of registered) {
28093
+ if (seen.has(`github:${w.target}`)) continue;
28094
+ seen.add(`github:${w.target}`);
27602
28095
  out.push({
27603
- id: `github:${slug}:${target}`,
28096
+ id: `github:${slug}:${w.target}`,
27604
28097
  kind: "github",
27605
- target,
28098
+ target: w.target,
27606
28099
  state: "armed",
27607
- // When the worker PARKED — which is what the strip's duration should count from, and what the
27608
- // watcher's own activity baseline is keyed on. Falls back to the thread's last activity when the
27609
- // fold has no instant for the fence.
27610
- createdAt: fenceAt ?? tele.lastAssistantAt ?? (/* @__PURE__ */ new Date()).toISOString()
28100
+ createdAt: w.createdAt,
28101
+ // Absent until the poller's first successful read. The card says "checking…" then, rather than
28102
+ // inventing a verdict — an unpolled PR and a PR with no CI are different facts.
28103
+ ...github[w.target] ? { github: github[w.target] } : {}
27611
28104
  });
27612
28105
  }
28106
+ if (tele?.lastFence?.kind !== "awaiting") return out;
28107
+ const createdAt = fenceAt ?? tele.lastAssistantAt ?? (/* @__PURE__ */ new Date()).toISOString();
28108
+ for (const hint of tele.lastFence.hints) {
28109
+ if (hint.kind !== "shell" && hint.kind !== "agent") continue;
28110
+ const target = hint.value.trim();
28111
+ if (!target || seen.has(`shell:${target}`) || !liveWaitHandles(tele).has(target)) continue;
28112
+ seen.add(`shell:${target}`);
28113
+ out.push({ id: `shell:${slug}:${target}`, kind: "shell", target, state: "armed", createdAt });
28114
+ }
27613
28115
  return out;
27614
28116
  }
27615
28117
  function hasFreshDelivery(row, processGone) {
27616
28118
  if (processGone) return false;
27617
28119
  return parseDeliveryLedger(row.delivery_ledger).some((d) => d.state === "pending" || d.state === "enqueued");
27618
28120
  }
27619
- function deriveNeedsYou(row, tele, runtime, hasActionableInteraction = false, nowMs = Date.now(), limitPause = void 0, excuseLiveOwnWork = true, deliveryProcessGone = false) {
28121
+ function deriveNeedsYou(row, tele, runtime, hasActionableInteraction = false, nowMs = Date.now(), limitPause = void 0, excuseLiveOwnWork = true, deliveryProcessGone = false, github = {}, registeredPrWatches = /* @__PURE__ */ new Set()) {
27620
28122
  if (futureSnooze(row, nowMs)) return false;
27621
28123
  if (hasActionableInteraction) return true;
27622
28124
  if (runtime === "perm-prompt") return true;
@@ -27630,15 +28132,17 @@ function deriveNeedsYou(row, tele, runtime, hasActionableInteraction = false, no
27630
28132
  if (hasParkedExternalWait(tele, nowMs)) return false;
27631
28133
  if (limitPause?.autoResume) return false;
27632
28134
  if (excuseLiveOwnWork && runtime !== "exited" && hasLiveBackgroundWork(tele) && !tele?.lastFence) return false;
28135
+ if (excuseLiveOwnWork && runtime !== "exited" && hasDeclaredBackgroundPark(tele, nowMs)) return false;
28136
+ if (excuseLiveOwnWork && runtime !== "exited" && heldByRunningChecks(tele, github, registeredPrWatches)) return false;
27633
28137
  if (runtime !== "exited" && hasLiveOwnWork(tele) && tele?.lastFence?.kind !== "done") return !bgSnoozeArmed(row);
27634
28138
  if (tele?.lastFence?.kind === "done") return true;
27635
28139
  return true;
27636
28140
  }
27637
- function deriveAwaitingBackground(row, tele, runtime, hasActionableInteraction = false, nowMs = Date.now(), limitPause = void 0, deliveryProcessGone = false) {
27638
- if (runtime !== "turn-idle" || !hasLiveOwnWork(tele)) return false;
28141
+ function deriveAwaitingBackground(row, tele, runtime, hasActionableInteraction = false, nowMs = Date.now(), limitPause = void 0, deliveryProcessGone = false, github = {}, registeredPrWatches = /* @__PURE__ */ new Set()) {
28142
+ if (runtime !== "turn-idle" || !hasDeclaredWait(tele, nowMs)) return false;
27639
28143
  if (hasActionableInteraction || tele?.pendingAsk || tele?.nativeInputRequired || tele?.pendingQuestion) return false;
27640
28144
  if (tele?.lastFence?.kind === "done") return false;
27641
- if (tele?.lastFence?.kind === "awaiting" && !hasParkedPrWatch(tele)) return false;
28145
+ if (tele?.lastFence?.kind === "awaiting" && !hasParkedPrWatch(tele) && !hasDeclaredBackgroundPark(tele, nowMs)) return false;
27642
28146
  return deriveNeedsYou({ ...row, bg_snooze_rested_at: null }, tele, runtime, hasActionableInteraction, nowMs, limitPause, false, deliveryProcessGone);
27643
28147
  }
27644
28148
  function resolveSessionProfile(row, tele) {
@@ -27683,7 +28187,6 @@ function resolveRecurringPrompt(row) {
27683
28187
  stopHook: row.recurring_on_rest === 1,
27684
28188
  heartbeat: row.recurring_on_schedule === 1,
27685
28189
  postCompaction: row.recurring_on_compact === 1,
27686
- pauseOnQuestions: row.recurring_pause_on_questions === 1,
27687
28190
  // Carried whenever a cadence has ever been chosen, INCLUDING while the heartbeat is off — the
27688
28191
  // minutes field has to read back what switching it on again would use.
27689
28192
  intervalSeconds: row.recurring_interval_ms ? Math.round(row.recurring_interval_ms / 1e3) : void 0,
@@ -27717,7 +28220,12 @@ function resolveSessionTitle(row, tele) {
27717
28220
  aiTitle: locked ? void 0 : tele?.aiTitle ?? persisted
27718
28221
  };
27719
28222
  }
27720
- function sessionThreadView(projectDir, storage, row, tele, registeredLegacyTerminal, interactionPresence, nowMs, codexTurnLiveness, claudeBrokerDaemonAlive) {
28223
+ function sessionThreadView(projectDir, storage, row, tele, registeredLegacyTerminal, interactionPresence, nowMs, codexTurnLiveness, claudeBrokerDaemonAlive, github = {}) {
28224
+ const armedPrWatches = storage.listPrWatches(row.slug, { armedOnly: true }).map((w) => ({
28225
+ target: `${w.owner}/${w.repo}#${w.number}`,
28226
+ createdAt: new Date(w.created_at).toISOString()
28227
+ }));
28228
+ const registeredPrWatches = new Set(armedPrWatches.map((w) => w.target));
27721
28229
  const headlessStalled = row.codex_runtime === "app-server" ? appServerTurnStalled(codexTurnLiveness(row.slug, row.session_id), tele?.lastActivityAt, nowMs) : isBrokerClaudeRow(row) ? !claudeBrokerDaemonAlive(row.session_id) : false;
27722
28230
  const headlessLostWork = isBrokerClaudeRow(row) && headlessStalled && hasUnretiredOwnAgents(tele);
27723
28231
  const deliveryProcessGone = isBrokerClaudeRow(row) && headlessStalled;
@@ -27728,8 +28236,8 @@ function sessionThreadView(projectDir, storage, row, tele, registeredLegacyTermi
27728
28236
  const state = effectiveSessionState(row, registeredLegacyTerminal);
27729
28237
  const archived = state === "archived";
27730
28238
  const limitPause = resolveLimitPause(row, tele, nowMs);
27731
- const needsYou = archived ? false : deriveNeedsYou(row, tele, runtime, interactionPresence.needsUser, nowMs, limitPause, true, deliveryProcessGone);
27732
- const awaitingBackground = archived ? false : deriveAwaitingBackground(row, tele, runtime, interactionPresence.needsUser, nowMs, limitPause, deliveryProcessGone);
28239
+ const needsYou = archived ? false : deriveNeedsYou(row, tele, runtime, interactionPresence.needsUser, nowMs, limitPause, true, deliveryProcessGone, github, registeredPrWatches);
28240
+ const awaitingBackground = archived ? false : deriveAwaitingBackground(row, tele, runtime, interactionPresence.needsUser, nowMs, limitPause, deliveryProcessGone, github, registeredPrWatches);
27733
28241
  const crashed = runtime === "exited" && (tele?.turn === "in-flight" || hasLiveBackgroundWork(tele) || headlessLostWork);
27734
28242
  const snoozedUntil = futureSnooze(row, nowMs);
27735
28243
  const profile = resolveSessionProfile(row, tele);
@@ -27761,20 +28269,11 @@ function sessionThreadView(projectDir, storage, row, tele, registeredLegacyTermi
27761
28269
  lastAssistantAt: tele?.lastAssistantAt,
27762
28270
  subAgents: stampStoppable(tele?.subAgents ?? [], row),
27763
28271
  bgShells: stampStoppableShells(tele?.bgShells ?? [], row),
27764
- // TWO SOURCES, one list. A `shell` watch is a REGISTRATION the worker made by tool call, read from
27765
- // the registry — the whole point of moving that kind of wait out of the fence. A `github` watch has
27766
- // no row and never will (`f366e2d`); it is derived from the standing fence, so that the strip lists
27767
- // every wait the thread actually has out rather than only the ones with a table behind them.
27768
- watches: [
27769
- ...storage.listThreadWatches(row.slug, { armedOnly: true }).map((w) => ({
27770
- id: w.id,
27771
- kind: w.kind,
27772
- target: w.target,
27773
- state: w.state,
27774
- createdAt: new Date(w.created_at).toISOString()
27775
- })),
27776
- ...githubWatchViews(row.slug, tele, tele?.lastAssistantAt)
27777
- ],
28272
+ // ONE SOURCE: the FENCE. Both kinds are derived from what the worker wrote `pr-watch:` lines
28273
+ // become the github rows, `watch:` lines the shell rows so this strip lists exactly what will
28274
+ // actually wake the thread, and the two cannot drift into claiming different things. There is no
28275
+ // registry behind either any more (`thread_watch`, retired 2026-08-14).
28276
+ watches: fenceWatchViews(row.slug, tele, tele?.lastAssistantAt, github, armedPrWatches),
27778
28277
  pendingAsk: tele?.pendingAsk ? { questions: tele.pendingAsk.questions } : void 0,
27779
28278
  nativeInputRequired: tele?.nativeInputRequired,
27780
28279
  pendingQuestion: tele?.pendingQuestion ?? false,
@@ -27795,6 +28294,10 @@ function sessionThreadView(projectDir, storage, row, tele, registeredLegacyTermi
27795
28294
  // Only meaningful while the snooze is still pending — a prompt without a live deadline is an
27796
28295
  // already-delivered (or superseded) bump the row has not been swept clean of yet.
27797
28296
  snoozePrompt: snoozedUntil ? row.snooze_prompt ?? void 0 : void 0,
28297
+ // The event-snooze, as a fact the CHAT can read. `awaitingBackground` still ignores it — that flag
28298
+ // states whether the thread is waiting, which the snooze does not change — so the suppression is a
28299
+ // presentation rule the client applies, not a second opinion about the thread's state.
28300
+ bgSnoozed: bgSnoozeArmed(row) || void 0,
27798
28301
  claudeRuntime: row.claude_runtime === "broker" ? "broker" : row.claude_runtime === "tmux" ? "tmux" : void 0,
27799
28302
  // The recurring prompt — the same projection the worker's own `action: "get"` reads back.
27800
28303
  recurringPrompt: resolveRecurringPrompt(row),
@@ -27881,6 +28384,7 @@ function createBoard(project, storage, bus, tailer, bootId, deps = {}) {
27881
28384
  let plansCache = [];
27882
28385
  function buildSessionThreads(nowMs) {
27883
28386
  const rows = storage.allSessions().filter((row) => ThreadSlug.safeParse(row.slug).success);
28387
+ const github = readGithubStatusBook(storage.getSetting(GITHUB_STATUS_SETTING));
27884
28388
  const currentInteractionKeys = /* @__PURE__ */ new Set();
27885
28389
  const out = [];
27886
28390
  for (const row of rows) {
@@ -27916,7 +28420,8 @@ function createBoard(project, storage, bus, tailer, bootId, deps = {}) {
27916
28420
  interactionPresence,
27917
28421
  nowMs,
27918
28422
  codexTurnLiveness,
27919
- claudeBrokerDaemonAlive
28423
+ claudeBrokerDaemonAlive,
28424
+ github
27920
28425
  ));
27921
28426
  }
27922
28427
  for (const key of pendingInteractionCache.keys()) {
@@ -27932,7 +28437,9 @@ function createBoard(project, storage, bus, tailer, bootId, deps = {}) {
27932
28437
  projectName: project.name,
27933
28438
  projectLabel: project.label,
27934
28439
  ...project.githubRepo ? { githubRepo: project.githubRepo } : {},
27935
- projectSlug: findByPath(project.dir)?.slug
28440
+ projectSlug: findByPath(project.dir)?.slug,
28441
+ // So the client can expand a `~` a worker wrote in prose (see BoardSnapshot.homeDir).
28442
+ homeDir: homedir12()
27936
28443
  };
27937
28444
  const sessionThreads = buildSessionThreads(assembledAtMs);
27938
28445
  armSnoozeWake(sessionThreads, assembledAtMs);
@@ -28113,7 +28620,7 @@ function createBoard(project, storage, bus, tailer, bootId, deps = {}) {
28113
28620
  }
28114
28621
  };
28115
28622
  }
28116
- var DEBOUNCE_MS, RECONCILE_MS, STALL_GRACE_MS, LINE_CAP;
28623
+ var DEBOUNCE_MS, RECONCILE_MS, STALL_GRACE_MS, LINE_CAP, DECLARED_PARK_MAX_MS;
28117
28624
  var init_board = __esm({
28118
28625
  "packages/server/src/board.ts"() {
28119
28626
  "use strict";
@@ -28134,6 +28641,7 @@ var init_board = __esm({
28134
28641
  RECONCILE_MS = 15e3;
28135
28642
  STALL_GRACE_MS = 3e4;
28136
28643
  LINE_CAP = 240;
28644
+ DECLARED_PARK_MAX_MS = 24 * 60 * 60 * 1e3;
28137
28645
  }
28138
28646
  });
28139
28647
 
@@ -28641,6 +29149,17 @@ var init_github_review = __esm({
28641
29149
  import { execFile as execFile6 } from "node:child_process";
28642
29150
  import { promisify as promisify6 } from "node:util";
28643
29151
  import { createHash as createHash14, randomUUID as randomUUID14 } from "node:crypto";
29152
+ function failureSignature(rollup) {
29153
+ const ids = [];
29154
+ for (const c of Array.isArray(rollup) ? rollup : []) {
29155
+ if (!c || typeof c !== "object") continue;
29156
+ if (!rollupEntryFailed(typeof c.conclusion === "string" ? c.conclusion : void 0, typeof c.state === "string" ? c.state : void 0)) continue;
29157
+ const label = c.name ?? c.context ?? c.workflowName ?? "?";
29158
+ ids.push(`${label}@${c.detailsUrl ?? c.targetUrl ?? c.completedAt ?? ""}`);
29159
+ }
29160
+ if (ids.length === 0) return "";
29161
+ return createHash14("sha1").update(ids.sort().join("\n")).digest("hex").slice(0, 12);
29162
+ }
28644
29163
  function parsePrRef2(value) {
28645
29164
  const m = value.trim().match(PR_REF_RE2);
28646
29165
  if (!m) return void 0;
@@ -28652,7 +29171,7 @@ function refKey2(r) {
28652
29171
  return `${r.owner}/${r.repo}#${r.number}`;
28653
29172
  }
28654
29173
  function ghPrViewArgs(ref) {
28655
- return ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "state,mergedAt,statusCheckRollup,headRefOid"];
29174
+ return ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "state,mergedAt,statusCheckRollup,headRefOid,mergeable,reviewDecision"];
28656
29175
  }
28657
29176
  function latestWorkflowRuns(runs) {
28658
29177
  const latest = /* @__PURE__ */ new Map();
@@ -28708,12 +29227,40 @@ function failedCheckNames(rollup, runs = [], cap = 8) {
28708
29227
  }
28709
29228
  return { names: names.slice(0, cap), omitted: Math.max(0, names.length - cap) };
28710
29229
  }
28711
- function isPrWatchHint(kind) {
28712
- return kind === "pr-watch";
29230
+ function githubWatchStatus(pr, polledAt) {
29231
+ const entries = Array.isArray(pr.rollup) ? pr.rollup.filter((c) => c && typeof c === "object") : [];
29232
+ let running = 0;
29233
+ let passed = 0;
29234
+ let failed = 0;
29235
+ for (const c of entries) {
29236
+ const status = typeof c.status === "string" ? c.status : void 0;
29237
+ const state2 = typeof c.state === "string" ? c.state : void 0;
29238
+ const conclusion = typeof c.conclusion === "string" ? c.conclusion : void 0;
29239
+ const terminal = status ? status === "COMPLETED" : state2 ? state2 !== "PENDING" && state2 !== "EXPECTED" : false;
29240
+ if (!terminal) running++;
29241
+ else if (rollupEntryFailed(conclusion, state2)) failed++;
29242
+ else passed++;
29243
+ }
29244
+ const state = pr.state === "MERGED" ? "merged" : pr.state === "CLOSED" ? "closed" : "open";
29245
+ const checks = entries.length === 0 ? "none" : failed > 0 ? "failing" : running > 0 ? "running" : "passing";
29246
+ const merge = pr.mergeable === "CONFLICTING" ? "conflicting" : pr.mergeable !== "MERGEABLE" ? "unknown" : checks === "failing" || pr.reviewDecision === "CHANGES_REQUESTED" || pr.reviewDecision === "REVIEW_REQUIRED" ? "blocked" : "mergeable";
29247
+ return {
29248
+ checks,
29249
+ running,
29250
+ passed,
29251
+ failed,
29252
+ failing: failedCheckNames(entries, pr.workflowRuns ?? []).names,
29253
+ merge,
29254
+ state,
29255
+ polledAt,
29256
+ head: pr.head,
29257
+ failureSig: failureSignature(entries)
29258
+ };
29259
+ }
29260
+ function isPrWatchHint(_kind) {
29261
+ return false;
28713
29262
  }
28714
- function isActionable(hint) {
28715
- if (hint.kind === "timer") return isValidAwaitingTimer(hint.value);
28716
- if (isPrWatchHint(hint.kind) || hint.kind === "pr" || hint.kind === "ci") return parsePrRef2(hint.value) !== void 0;
29263
+ function isActionable(_hint) {
28717
29264
  return false;
28718
29265
  }
28719
29266
  function fenceIdentity(hints, fenceAt) {
@@ -28763,20 +29310,28 @@ function isHeartbeatFenceId(fenceId) {
28763
29310
  function saidDone(tele) {
28764
29311
  return tele.lastFence?.kind === "done" || tele.lastAssistantAllDone === true;
28765
29312
  }
28766
- function restMessageIsSignedOff(tele) {
28767
- return tele.pendingQuestion === true || saidDone(tele) || parkedOnAWaitItCannotAdvance(tele);
29313
+ function armedTimerIdsOf(storage, slug) {
29314
+ return new Set(storage.listThreadTimers(slug, { armedOnly: true }).map((t) => t.id));
28768
29315
  }
28769
- function parkedOnAWaitItCannotAdvance(tele) {
28770
- const fence = tele.lastFence;
28771
- if (fence?.kind !== "awaiting") return false;
28772
- return fence.hints.some((h) => isActionable(h) || h.kind === "human");
29316
+ function restMessageIsSignedOff(tele, _registeredPrWatches = /* @__PURE__ */ new Set(), _armedTimerIds = /* @__PURE__ */ new Set()) {
29317
+ if (tele.lastFence?.kind === "awaiting") return true;
29318
+ return saidDone(tele);
28773
29319
  }
28774
- function blockedOnHuman(tele) {
28775
- return tele.pendingQuestion === true || tele.pendingAsk !== void 0 || tele.permPrompt === true || tele.nativeInputRequired !== void 0;
29320
+ function liveActivityOf(tele, registeredPrWatches, armedTimerIds) {
29321
+ const shells = /* @__PURE__ */ new Set();
29322
+ for (const sh of tele.bgShells ?? []) {
29323
+ if (sh.state !== "running") continue;
29324
+ for (const h of [sh.taskId, sh.id, sh.label]) if (h) shells.add(h);
29325
+ }
29326
+ const agents = /* @__PURE__ */ new Set();
29327
+ for (const a of tele.subAgents ?? []) {
29328
+ if (a.state !== "running") continue;
29329
+ for (const h of [a.id, a.label]) if (h) agents.add(h);
29330
+ }
29331
+ return { shells, agents, timers: armedTimerIds, prs: registeredPrWatches };
28776
29332
  }
28777
- function heldByQuestion(row, tele) {
28778
- if (row.recurring_pause_on_questions !== 1) return false;
28779
- return tele !== void 0 && blockedOnHuman(tele);
29333
+ function registeredPrWatchesOf(storage, slug) {
29334
+ return new Set(storage.listPrWatches(slug, { armedOnly: true }).map((w) => `${w.owner}/${w.repo}#${w.number}`));
28780
29335
  }
28781
29336
  function armedSchedule(row) {
28782
29337
  const prompt = row.recurring_prompt?.trim();
@@ -28814,21 +29369,24 @@ function armedCompact(row) {
28814
29369
  if (row.recurring_on_compact !== 1) return void 0;
28815
29370
  return { prompt, armedAt };
28816
29371
  }
29372
+ function shellFenceId(toolUseId) {
29373
+ return `${SHELL_FENCE_PREFIX}:${toolUseId}`;
29374
+ }
29375
+ function isShellFenceId(fenceId) {
29376
+ return fenceId.startsWith(`${SHELL_FENCE_PREFIX}:`);
29377
+ }
29378
+ function prWatchFenceId(watchId, report) {
29379
+ return `${PR_WATCH_FENCE_PREFIX}:${watchId}:${report}`;
29380
+ }
29381
+ function isPrWatchFenceId(fenceId) {
29382
+ return fenceId.startsWith(`${PR_WATCH_FENCE_PREFIX}:`);
29383
+ }
28817
29384
  function signoffFenceId(restedAt) {
28818
29385
  return `${SIGNOFF_FENCE_PREFIX}:${restedAt}`;
28819
29386
  }
28820
29387
  function isSignoffFenceId(fenceId) {
28821
29388
  return fenceId.startsWith(`${SIGNOFF_FENCE_PREFIX}:`);
28822
29389
  }
28823
- function watchFenceId(watchId) {
28824
- return `${WATCH_FENCE_PREFIX}:${watchId}`;
28825
- }
28826
- function isWatchFenceId(fenceId) {
28827
- return fenceId.startsWith(`${WATCH_FENCE_PREFIX}:`);
28828
- }
28829
- function watchIdOf(fenceId) {
28830
- return fenceId.slice(WATCH_FENCE_PREFIX.length + 1);
28831
- }
28832
29390
  function timerFenceId(timerId) {
28833
29391
  return `${TIMER_FENCE_PREFIX}:${timerId}`;
28834
29392
  }
@@ -28839,12 +29397,6 @@ function timerIdOf(fenceId) {
28839
29397
  return fenceId.slice(TIMER_FENCE_PREFIX.length + 1);
28840
29398
  }
28841
29399
  function evalHint(hint, nowMs, prCache, fenceBody) {
28842
- if (hint.kind === "timer") {
28843
- const target = Date.parse(hint.value);
28844
- if (!isValidAwaitingTimer(hint.value) || !Number.isFinite(target)) return void 0;
28845
- const desc = fenceBody.trim().replace(/\s+/g, " ").slice(0, 200);
28846
- return { met: nowMs >= target, steer: `\u23F0 Your timer fired${desc ? `: ${desc}` : ""}. Continue.`, reason: `timer ${hint.value}` };
28847
- }
28848
29400
  if (isPrWatchHint(hint.kind)) return void 0;
28849
29401
  const ref = parsePrRef2(hint.value);
28850
29402
  if (!ref) return void 0;
@@ -28895,6 +29447,9 @@ async function defaultFetchPr(ref) {
28895
29447
  state: j.state,
28896
29448
  mergedAt: typeof j.mergedAt === "string" ? j.mergedAt : null,
28897
29449
  rollup: Array.isArray(j.statusCheckRollup) ? j.statusCheckRollup : [],
29450
+ head: j.headRefOid,
29451
+ mergeable: typeof j.mergeable === "string" ? j.mergeable : void 0,
29452
+ reviewDecision: typeof j.reviewDecision === "string" ? j.reviewDecision : void 0,
28898
29453
  workflowRuns
28899
29454
  };
28900
29455
  } catch {
@@ -28937,6 +29492,14 @@ function createScheduler(deps) {
28937
29492
  let timer = null;
28938
29493
  let activeTick = null;
28939
29494
  let stopped = false;
29495
+ function publishGithubStatus(key, pr, nowMs) {
29496
+ const raw2 = deps.storage.getSetting(GITHUB_STATUS_SETTING);
29497
+ const book = raw2 && typeof raw2 === "object" && !Array.isArray(raw2) ? { ...raw2 } : {};
29498
+ book[key] = githubWatchStatus(pr, new Date(nowMs).toISOString());
29499
+ const keys = Object.keys(book);
29500
+ for (const stale of keys.slice(0, Math.max(0, keys.length - GITHUB_STATUS_CAP))) delete book[stale];
29501
+ deps.storage.setSetting(GITHUB_STATUS_SETTING, book);
29502
+ }
28940
29503
  function loadFired() {
28941
29504
  const raw2 = deps.storage.getSetting("waker.fired");
28942
29505
  return Array.isArray(raw2) ? raw2.filter((x) => typeof x === "string") : [];
@@ -29067,6 +29630,12 @@ function createScheduler(deps) {
29067
29630
  if (!fault || limitFenceId(fault) !== item.fenceId) return "superseded";
29068
29631
  return tele.turn === "idle" ? "current-idle" : "current-busy";
29069
29632
  }
29633
+ if (isShellFenceId(item.fenceId)) {
29634
+ return tele.turn === "idle" ? "current-idle" : "current-busy";
29635
+ }
29636
+ if (isPrWatchFenceId(item.fenceId)) {
29637
+ return tele.turn === "idle" ? "current-idle" : "current-busy";
29638
+ }
29070
29639
  if (isSnoozeFenceId(item.fenceId)) {
29071
29640
  if (armedSnooze(row)?.fenceId !== item.fenceId) return "superseded";
29072
29641
  return tele.turn === "idle" ? "current-idle" : "current-busy";
@@ -29079,7 +29648,7 @@ function createScheduler(deps) {
29079
29648
  if (isStopHookFenceId(item.fenceId)) {
29080
29649
  const armed = armedRest(row);
29081
29650
  if (!armed || item.fenceId !== stopHookFenceId(armed.armedAt, tele.lastAssistantAt ?? "")) return "superseded";
29082
- if (restMessageIsSignedOff(tele)) return "superseded";
29651
+ if (restMessageIsSignedOff(tele, registeredPrWatchesOf(deps.storage, item.slug), armedTimerIdsOf(deps.storage, item.slug))) return "superseded";
29083
29652
  return tele.turn === "idle" ? "current-idle" : "current-busy";
29084
29653
  }
29085
29654
  if (isCompactFenceId(item.fenceId)) {
@@ -29096,10 +29665,6 @@ function createScheduler(deps) {
29096
29665
  if (tele.lastFence || tele.pendingQuestion) return "superseded";
29097
29666
  return tele.turn === "idle" ? "current-idle" : "current-busy";
29098
29667
  }
29099
- if (isWatchFenceId(item.fenceId)) {
29100
- if (deps.storage.getThreadWatch(watchIdOf(item.fenceId))?.state !== "armed") return "superseded";
29101
- return tele.turn === "idle" ? "current-idle" : "current-busy";
29102
- }
29103
29668
  if (isReportFenceId(item.fenceId)) {
29104
29669
  const stillMissing = tele.droppedReports?.some((r) => reportFenceId(r.taskId) === item.fenceId);
29105
29670
  if (!stillMissing) return "superseded";
@@ -29246,26 +29811,30 @@ function createScheduler(deps) {
29246
29811
  const deliveryId = wakeDeliveryId(slug, sessionId, fenceId);
29247
29812
  if (outbox.get(deliveryId)) st.fired = true;
29248
29813
  if (st.fired) return;
29249
- const needsPr = actionable.some((h) => h.kind === "pr" || h.kind === "ci");
29814
+ const needsPr = false;
29250
29815
  const needsReview = actionable.some((h) => isPrWatchHint(h.kind));
29251
29816
  if ((needsPr || needsReview) && (st.lastPollAt === 0 || nowMs - st.lastPollAt >= pollMs)) {
29252
29817
  st.lastPollAt = nowMs;
29253
29818
  const refs = /* @__PURE__ */ new Map();
29254
29819
  const reviewRefs = /* @__PURE__ */ new Map();
29820
+ const verdictRefs = /* @__PURE__ */ new Set();
29255
29821
  for (const h of actionable) {
29256
29822
  const ref = parsePrRef2(h.value);
29257
29823
  if (!ref) continue;
29258
- if (h.kind === "pr" || h.kind === "ci") refs.set(refKey2(ref), ref);
29259
29824
  if (isPrWatchHint(h.kind)) reviewRefs.set(refKey2(ref), ref);
29260
29825
  }
29261
29826
  await Promise.all([
29262
29827
  ...[...refs].map(async ([k, ref]) => {
29263
29828
  try {
29264
29829
  const s = await fetchPr(ref);
29265
- if (s) st.prCache.set(k, s);
29266
- else log3(`waker: gh check skipped for ${k} (${slug}) \u2014 gh unavailable / not authed / rate-limited`);
29830
+ if (s) {
29831
+ st.prCache.set(k, s);
29832
+ publishGithubStatus(k, s, nowMs);
29833
+ } else if (verdictRefs.has(k)) {
29834
+ log3(`waker: gh check skipped for ${k} (${slug}) \u2014 gh unavailable / not authed / rate-limited`);
29835
+ }
29267
29836
  } catch (err) {
29268
- log3(`waker: gh check errored for ${k} (${slug}): ${err instanceof Error ? err.message : String(err)}`);
29837
+ if (verdictRefs.has(k)) log3(`waker: gh check errored for ${k} (${slug}): ${err instanceof Error ? err.message : String(err)}`);
29269
29838
  }
29270
29839
  }),
29271
29840
  ...[...reviewRefs].map(async ([k, ref]) => {
@@ -29451,7 +30020,19 @@ function createScheduler(deps) {
29451
30020
  sessionId: row.session_id,
29452
30021
  fenceId,
29453
30022
  hintKey: SIGNOFF_HINT_KEY,
29454
- message: SIGNOFF_NUDGE_MESSAGE,
30023
+ // The reminder plus THIS THREAD's live ops and their ids, so the agent can write a correct
30024
+ // `watch:` line without going looking for an id it cannot see. Shells are named by `taskId` —
30025
+ // the handle the runtime actually showed the worker — because that is the string it will
30026
+ // naturally reach for, and the one the fence's own integrity check matches on.
30027
+ message: signoffNudgeMessage({
30028
+ shells: (tele.bgShells ?? []).filter((sh) => sh.state === "running").map((sh) => ({ id: sh.taskId ?? sh.id, label: sh.label })),
30029
+ subAgents: (tele.subAgents ?? []).filter((a) => a.state === "running").map((a) => ({ id: a.id, label: a.label })),
30030
+ // The other two registries, so the nudge lists EVERY kind an awaiting fence can name rather
30031
+ // than the two the fold happens to know about — a worker told about half its work writes half
30032
+ // a fence, and the half it left out is not what gets it bumped.
30033
+ timers: deps.storage.listThreadTimers(row.slug, { armedOnly: true }).map((t) => ({ id: t.id, label: t.prompt.trim().replace(/\s+/g, " ").slice(0, 80) })),
30034
+ prs: deps.storage.listPrWatches(row.slug, { armedOnly: true }).map((w) => ({ id: `${w.owner}/${w.repo}#${w.number}`, label: `${w.owner}/${w.repo}#${w.number}` }))
30035
+ }),
29455
30036
  reason: "rested without signing off"
29456
30037
  }, nowMs).delivery;
29457
30038
  log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
@@ -29462,27 +30043,99 @@ function createScheduler(deps) {
29462
30043
  if (!isSignoffFenceId(item.fenceId)) return;
29463
30044
  deps.storage.countSignoffNudge(item.slug, item.fenceId);
29464
30045
  }
29465
- function evalWatchers(nowMs) {
29466
- for (const watch of deps.storage.armedThreadWatches()) {
29467
- if (watch.kind !== "shell") continue;
29468
- const row = deps.storage.getSession(watch.thread_slug);
29469
- if (!row || row.state === "archived" || row.archived === 1) continue;
30046
+ function evalParkIntegrity(nowMs) {
30047
+ for (const row of deps.storage.allSessions()) {
30048
+ if (row.state === "archived" || row.archived === 1) continue;
29470
30049
  const tele = deps.tailer.get(row.slug);
29471
- if (!tele) continue;
29472
- const live = tele.bgShells.some(
29473
- (sh) => sh.state === "running" && (sh.id === watch.target || sh.label === watch.target)
30050
+ if (!tele || tele.turn !== "idle") continue;
30051
+ if (tele.lastFence?.kind !== "awaiting") continue;
30052
+ const spokeAt = tele.lastAssistantAt;
30053
+ if (!spokeAt) continue;
30054
+ if (tele.lastUserAt && Date.parse(tele.lastUserAt) >= Date.parse(spokeAt)) continue;
30055
+ if (tele.authFault) continue;
30056
+ const park = readAwaitingPark(tele.lastFence.hints);
30057
+ const live = liveActivityOf(
30058
+ tele,
30059
+ registeredPrWatchesOf(deps.storage, row.slug),
30060
+ armedTimerIdsOf(deps.storage, row.slug)
29474
30061
  );
29475
- if (live) {
29476
- if (watch.cursor !== SHELL_SEEN) deps.storage.setThreadWatchCursor(watch.id, SHELL_SEEN);
29477
- continue;
29478
- }
29479
- if (watch.cursor !== SHELL_SEEN) continue;
29480
- if (watch.foreground === 1) {
29481
- deps.storage.markThreadWatchFired(watch.id, nowMs);
29482
- log3(`waker: settled ${row.slug} \u2014 foreground watcher ${watch.id} resolved (shell ${watch.target})`);
29483
- continue;
29484
- }
29485
- const fenceId = watchFenceId(watch.id);
30062
+ const dead = unaccountedItems(park.items, live);
30063
+ const expiresAt = parkExpiresAt(park, Date.parse(spokeAt));
30064
+ const expired = expiresAt !== null && nowMs >= expiresAt;
30065
+ const nameless = park.items.length === 0;
30066
+ if (parkIsHonoured(park, live) && (row.park_bumps ?? 0) > 0) deps.storage.resetParkBumps(row.slug);
30067
+ if (dead.length === 0 && !expired && !nameless) continue;
30068
+ if (park.forMs === null && !nameless) continue;
30069
+ const status = park.items.map((i) => {
30070
+ const gone = dead.some((d) => d.kind === i.kind && d.value === i.value);
30071
+ return `- \`${i.kind}: ${i.value}\` \u2014 ${gone ? "NOT RUNNING" : "still running"}`;
30072
+ });
30073
+ const retired = retiredAwaitingKindsIn(tele.lastFence.body ?? "");
30074
+ const head = retired.length > 0 ? [
30075
+ `\u26D4 Your \`\`\`awaiting fence uses ${retired.length === 1 ? "a line kind" : "line kinds"} that no longer exist, so frizz ignored ${retired.length === 1 ? "it" : "them"} \u2014 the`,
30076
+ "fence named nothing and your thread stayed in the queue.",
30077
+ "",
30078
+ ...retired.map((k) => `- \`${k}:\` is GONE \u2192 ${RETIRED_AWAITING_REPLACEMENT[k]}`),
30079
+ "",
30080
+ "THE ONLY LINE KINDS NOW SUPPORTED, and every one is checked against something frizz can see:",
30081
+ "",
30082
+ " shell: <runtime task id> a background shell you launched",
30083
+ " agent: <runtime agent id> a sub-agent you dispatched",
30084
+ " timer: tmr_\u2026 a timer from `mcp__frizz__timer`",
30085
+ " pr: owner/repo#123 a PR registered with `mcp__frizz__watch_pr`",
30086
+ " for: 2h REQUIRED \u2014 a DURATION, never an instant",
30087
+ " reason: one line the only prose",
30088
+ "",
30089
+ "`mcp__frizz__activity` lists everything you have running with the exact id each line needs.",
30090
+ "If you are not waiting on anything, you are not awaiting \u2014 end with ```done, or ask a ```question."
30091
+ ].join("\n") : nameless ? [
30092
+ "\u26A0\uFE0F Your ```awaiting fence names nothing to wait on, so it is not a park \u2014 your thread is still",
30093
+ "in the queue, and nothing will wake you.",
30094
+ "",
30095
+ "A wait has to be something frizz can SEE. Register one, then name it:",
30096
+ "",
30097
+ "- a pull request \u2192 `mcp__frizz__watch_pr` (it wakes you on CI going green or red, and on every",
30098
+ " later review and comment) \u2192 `pr: owner/repo#123`",
30099
+ "- a wall-clock check \u2192 `mcp__frizz__timer` \u2192 `timer: tmr_\u2026`",
30100
+ "- work you already launched \u2192 `shell: <the id your runtime gave you>` or `agent: <id>`",
30101
+ "",
30102
+ "Use `mcp__frizz__activity` to read back everything you have running, with the exact id each",
30103
+ "line needs.",
30104
+ "",
30105
+ "AND IF YOU ARE NOT WAITING ON ANYTHING, you are not awaiting \u2014 you are done. End with ```done,",
30106
+ "or ask a ```question if you need the human."
30107
+ ].join("\n") : expired ? [
30108
+ "\u23F0 Your wait expired, nothing resolved. Check back in on everything.",
30109
+ "",
30110
+ ...status,
30111
+ "",
30112
+ "Re-park if they are genuinely still going \u2014 there is no limit on that, and a long job is not a",
30113
+ "failure. If something is finished, read its result. If nothing is left, end in ```done or ask a",
30114
+ "```question."
30115
+ ].join("\n") : [
30116
+ `\u26A0\uFE0F Your \`\`\`awaiting fence names ${dead.length === 1 ? "something that is" : "things that are"} not running, so it is not a park and your thread stayed in the queue.`,
30117
+ "",
30118
+ ...status,
30119
+ "",
30120
+ "Use `mcp__frizz__activity` to read back what you actually have out, with the exact id each line",
30121
+ "needs, then re-fence naming only those \u2014 or end in ```done or a ```question if there is nothing",
30122
+ "left to wait for."
30123
+ ].join("\n");
30124
+ const cause = retired.length > 0 ? "retired" : nameless ? "nameless" : expired ? "expired" : "dead";
30125
+ const ops = cause === "expired" ? [] : liveOpsLines({
30126
+ shells: (tele.bgShells ?? []).filter((sh) => sh.state === "running").map((sh) => ({ id: sh.taskId ?? sh.id, label: sh.label })),
30127
+ subAgents: (tele.subAgents ?? []).filter((a) => a.state === "running").map((a) => ({ id: a.id, label: a.label })),
30128
+ timers: deps.storage.listThreadTimers(row.slug, { armedOnly: true }).map((t) => ({ id: t.id, label: t.prompt.trim().replace(/\s+/g, " ").slice(0, 80) })),
30129
+ prs: deps.storage.listPrWatches(row.slug, { armedOnly: true }).map((w) => ({ id: `${w.owner}/${w.repo}#${w.number}`, label: `${w.owner}/${w.repo}#${w.number}` }))
30130
+ });
30131
+ const message = ops.length > 0 ? `${head}
30132
+ ${ops.join("\n")}` : cause === "expired" ? head : `${head}
30133
+
30134
+ You have NOTHING running right now \u2014 no shell, no sub-agent, no timer, no registered
30135
+ pull request. There is nothing that could wake you, so this thread is not awaiting: finish in
30136
+ \`\`\`done, or ask a \`\`\`question.`;
30137
+ if (cause !== "expired" && (row.park_bumps ?? 0) >= PARK_BUMP_MAX) continue;
30138
+ const fenceId = `park:${cause}:${spokeAt}`;
29486
30139
  const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
29487
30140
  if (outbox.get(deliveryId)) continue;
29488
30141
  const item = outbox.enqueue({
@@ -29490,17 +30143,199 @@ function createScheduler(deps) {
29490
30143
  slug: row.slug,
29491
30144
  sessionId: row.session_id,
29492
30145
  fenceId,
29493
- hintKey: `${WATCH_HINT_PREFIX}${watch.id}`,
29494
- message: watchWakeMessage("shell", watch.target, "The background shell you were waiting on has finished"),
29495
- reason: `watcher ${watch.id} resolved (shell ${watch.target})`
30146
+ hintKey: fenceId,
30147
+ message,
30148
+ reason: retired.length > 0 ? `awaiting fence uses retired kind(s): ${retired.join(", ")}` : nameless ? "awaiting park names nothing" : expired ? "awaiting park expired" : `awaiting park named ${dead.length} dead item(s)`
29496
30149
  }, nowMs).delivery;
30150
+ if (cause !== "expired") deps.storage.countParkBump(row.slug, fenceId);
29497
30151
  log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
29498
30152
  checkpoint("after-enqueue", item);
29499
30153
  }
29500
30154
  }
29501
- function settleWatcher(item) {
29502
- if (!isWatchFenceId(item.fenceId)) return;
29503
- deps.storage.markThreadWatchFired(watchIdOf(item.fenceId), now());
30155
+ const prWatchPolledAt = /* @__PURE__ */ new Map();
30156
+ function readPrWatchCursor(raw2) {
30157
+ try {
30158
+ const parsed = raw2 ? JSON.parse(raw2) : null;
30159
+ if (!parsed || typeof parsed !== "object") return { seen: [] };
30160
+ const seen = Array.isArray(parsed.seen) ? parsed.seen.filter((x) => typeof x === "string") : [];
30161
+ return { seen, checks: typeof parsed.checks === "string" ? parsed.checks : void 0, report: Number(parsed.report) || 0 };
30162
+ } catch {
30163
+ return { seen: [] };
30164
+ }
30165
+ }
30166
+ async function evalPrWatches(nowMs) {
30167
+ for (const w of deps.storage.expiredPrWatches(nowMs)) {
30168
+ const row = deps.storage.getSession(w.thread_slug);
30169
+ deps.storage.settlePrWatch(w.id, nowMs);
30170
+ const ref = `${w.owner}/${w.repo}#${w.number}`;
30171
+ log3(`waker: settled ${w.thread_slug} \u2014 pr watcher ${w.id} expired (${ref})`);
30172
+ if (!row || row.state === "archived" || row.archived === 1) continue;
30173
+ const fenceId = `prwatch-expired:${w.id}`;
30174
+ const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
30175
+ if (outbox.get(deliveryId)) continue;
30176
+ const item = outbox.enqueue({
30177
+ id: deliveryId,
30178
+ slug: row.slug,
30179
+ sessionId: row.session_id,
30180
+ fenceId,
30181
+ hintKey: `prwatch-expired:${w.id}`,
30182
+ message: `\u23F0 Your watcher on ${ref} has expired and is no longer armed \u2014 nothing on that PR will wake you now.
30183
+
30184
+ If you still care about it, register it again with \`mcp__frizz__watch_pr\` and a fresh \`for:\`. If you do not, and it was the only thing you were waiting on, end in a proper terminal state instead of parking on it again.`,
30185
+ reason: `pr watcher ${w.id} expired (${ref})`
30186
+ }, nowMs).delivery;
30187
+ log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
30188
+ checkpoint("after-enqueue", item);
30189
+ }
30190
+ const armed = deps.storage.armedPrWatches();
30191
+ if (armed.length === 0) return;
30192
+ const refs = /* @__PURE__ */ new Map();
30193
+ for (const w of armed) {
30194
+ const key = `${w.owner}/${w.repo}#${w.number}`;
30195
+ const last = prWatchPolledAt.get(key) ?? 0;
30196
+ if (nowMs - last < PR_WATCH_POLL_MS) continue;
30197
+ refs.set(key, { owner: w.owner, repo: w.repo, number: w.number });
30198
+ }
30199
+ const status = /* @__PURE__ */ new Map();
30200
+ const activity = /* @__PURE__ */ new Map();
30201
+ await Promise.all([...refs].map(async ([key, ref]) => {
30202
+ prWatchPolledAt.set(key, nowMs);
30203
+ try {
30204
+ const pr = await fetchPr(ref);
30205
+ if (pr) {
30206
+ status.set(key, githubWatchStatus(pr, new Date(nowMs).toISOString()));
30207
+ publishGithubStatus(key, pr, nowMs);
30208
+ }
30209
+ } catch (err) {
30210
+ log3(`waker: pr-watch status fetch failed for ${key}: ${err instanceof Error ? err.message : String(err)}`);
30211
+ }
30212
+ try {
30213
+ const result = normalizeReviewResult(await fetchGithubReview(ref));
30214
+ if (result.status === "ok") {
30215
+ activity.set(key, result.activity);
30216
+ recordReviewSuccess(key, "pr-watch registry");
30217
+ } else if (result.status === "error") recordReviewFailure(key, "pr-watch registry", result, nowMs);
30218
+ } catch (err) {
30219
+ recordReviewFailure(key, "pr-watch registry", {
30220
+ status: "error",
30221
+ failure: { kind: "network", message: err instanceof Error ? err.message : String(err) }
30222
+ }, nowMs);
30223
+ }
30224
+ }));
30225
+ for (const w of armed) {
30226
+ const row = deps.storage.getSession(w.thread_slug);
30227
+ if (!row || row.state === "archived" || row.archived === 1) continue;
30228
+ const key = `${w.owner}/${w.repo}#${w.number}`;
30229
+ const st = status.get(key);
30230
+ const acts = activity.get(key);
30231
+ if (!st && !acts) continue;
30232
+ const cursor = readPrWatchCursor(w.cursor);
30233
+ if (st && st.state !== "open") {
30234
+ deps.storage.settlePrWatch(w.id, nowMs);
30235
+ enqueuePrWatchWake(row, w.id, nextReport(cursor), prWatchWakeMessage({
30236
+ target: key,
30237
+ merged: st.state === "merged",
30238
+ closed: st.state === "closed"
30239
+ }), `pr-watch ${key} ${st.state}`, nowMs);
30240
+ continue;
30241
+ }
30242
+ const terminal = st && (st.checks === "passing" || st.checks === "failing") ? st.checks : void 0;
30243
+ const stamp = terminal !== void 0 ? `${st?.head ?? "?"}:${terminal}:${st?.failureSig ?? ""}` : void 0;
30244
+ const checksChanged = stamp !== void 0 && cursor.checks !== stamp;
30245
+ const seen = new Set(cursor.seen);
30246
+ const firstPoll = w.cursor === null;
30247
+ const newestFirst = [...acts ?? []].sort((a, b) => {
30248
+ const at = Date.parse(b.at ?? "") - Date.parse(a.at ?? "");
30249
+ return Number.isFinite(at) && at !== 0 ? at : b.id.localeCompare(a.id);
30250
+ });
30251
+ const fresh = newestFirst.filter((a) => {
30252
+ if (seen.has(a.id)) return false;
30253
+ if (!firstPoll) return true;
30254
+ const landed = Date.parse(a.at ?? "");
30255
+ return Number.isFinite(landed) && landed > w.created_at;
30256
+ });
30257
+ const named = fresh.slice(0, REVIEW_STEER_CAP);
30258
+ const reviewSteer = fresh.length > 0 ? formatGithubWakeSteer({
30259
+ ref: key,
30260
+ omitted: fresh.length - named.length,
30261
+ items: [...named].reverse().map((a) => ({
30262
+ label: activityLabel(a),
30263
+ actor: a.actor,
30264
+ bot: isBotGithubActor(a),
30265
+ ...a.at ? { at: a.at } : {},
30266
+ ...a.url ? { url: a.url } : {}
30267
+ }))
30268
+ }) : void 0;
30269
+ const nextCursor = {
30270
+ seen: acts ? [.../* @__PURE__ */ new Set([...cursor.seen, ...acts.map((a) => a.id)])].slice(-REVIEW_SEEN_CAP) : cursor.seen,
30271
+ // The STAMP, so the next poll compares against the commit as well as the colour.
30272
+ checks: stamp ?? cursor.checks,
30273
+ report: cursor.report ?? 0
30274
+ };
30275
+ if (!checksChanged && !reviewSteer) {
30276
+ if (JSON.stringify(nextCursor) !== JSON.stringify(cursor)) {
30277
+ deps.storage.setPrWatchCursor(w.id, JSON.stringify(nextCursor));
30278
+ }
30279
+ continue;
30280
+ }
30281
+ deps.storage.setSnoozedUntil(row.slug, null);
30282
+ const report = nextReport(cursor);
30283
+ enqueuePrWatchWake(row, w.id, report, prWatchWakeMessage({
30284
+ target: key,
30285
+ ...checksChanged && terminal ? { checks: { verdict: terminal, passed: st.passed, failed: st.failed, failing: st.failing } } : {},
30286
+ ...reviewSteer ? { review: reviewSteer } : {}
30287
+ }), `pr-watch ${key}${checksChanged ? ` CI ${terminal}` : ""}${reviewSteer ? " review" : ""}`, nowMs);
30288
+ deps.storage.setPrWatchCursor(w.id, JSON.stringify({ ...nextCursor, report }));
30289
+ }
30290
+ }
30291
+ function nextReport(cursor) {
30292
+ return (cursor.report ?? 0) + 1;
30293
+ }
30294
+ function enqueuePrWatchWake(row, watchId, report, message, reason, nowMs) {
30295
+ const fenceId = prWatchFenceId(watchId, report);
30296
+ const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
30297
+ if (outbox.get(deliveryId)) return;
30298
+ const item = outbox.enqueue({
30299
+ id: deliveryId,
30300
+ slug: row.slug,
30301
+ sessionId: row.session_id,
30302
+ fenceId,
30303
+ hintKey: `${PR_WATCH_FENCE_PREFIX}:${watchId}`,
30304
+ message,
30305
+ reason
30306
+ }, nowMs).delivery;
30307
+ log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
30308
+ checkpoint("after-enqueue", item);
30309
+ }
30310
+ function evalShellCompletions(nowMs) {
30311
+ for (const row of deps.storage.allSessions()) {
30312
+ if (row.state === "archived" || row.archived === 1) continue;
30313
+ const tele = deps.tailer.get(row.slug);
30314
+ if (!tele) continue;
30315
+ if (tele.turn !== "idle") continue;
30316
+ if (tele.authFault) continue;
30317
+ const restedAt = Date.parse(tele.lastAssistantAt ?? "");
30318
+ if (!Number.isFinite(restedAt)) continue;
30319
+ for (const shell of tele.retiredShells ?? []) {
30320
+ const finishedAt = Date.parse(shell.finishedAt ?? "");
30321
+ if (!Number.isFinite(finishedAt) || finishedAt <= restedAt) continue;
30322
+ const fenceId = shellFenceId(shell.id);
30323
+ const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
30324
+ if (outbox.get(deliveryId)) continue;
30325
+ const item = outbox.enqueue({
30326
+ id: deliveryId,
30327
+ slug: row.slug,
30328
+ sessionId: row.session_id,
30329
+ fenceId,
30330
+ hintKey: `${SHELL_FENCE_PREFIX}:${shell.id}`,
30331
+ message: shellDoneMessage(shell),
30332
+ reason: `background shell finished (${shell.taskId ?? shell.label})`
30333
+ }, nowMs).delivery;
30334
+ log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
30335
+ checkpoint("after-enqueue", item);
30336
+ return;
30337
+ }
30338
+ }
29504
30339
  }
29505
30340
  function evalTimers(nowMs) {
29506
30341
  for (const timer2 of deps.storage.dueThreadTimers(nowMs)) {
@@ -29534,7 +30369,6 @@ function createScheduler(deps) {
29534
30369
  if (!armed || armed.dueAtMs > nowMs) continue;
29535
30370
  const beatTele = deps.tailer.get(row.slug);
29536
30371
  if (beatTele && saidDone(beatTele)) continue;
29537
- if (heldByQuestion(row, beatTele)) continue;
29538
30372
  if (openSchedulePrompt(row.slug, row.session_id)) continue;
29539
30373
  const beat = beatIndex(row.recurring_schedule_fired_at ?? null, armed);
29540
30374
  const fenceId = heartbeatFenceId(armed.armedAt, beat);
@@ -29583,8 +30417,7 @@ function createScheduler(deps) {
29583
30417
  if (!restedAt) continue;
29584
30418
  if (tele.lastUserAt && Date.parse(tele.lastUserAt) >= Date.parse(restedAt)) continue;
29585
30419
  if (tele.authFault) continue;
29586
- if (restMessageIsSignedOff(tele)) continue;
29587
- if (heldByQuestion(row, tele)) continue;
30420
+ if (restMessageIsSignedOff(tele, registeredPrWatchesOf(deps.storage, row.slug), armedTimerIdsOf(deps.storage, row.slug))) continue;
29588
30421
  const fenceId = stopHookFenceId(armed.armedAt, restedAt);
29589
30422
  const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
29590
30423
  if (outbox.get(deliveryId)) continue;
@@ -29594,7 +30427,7 @@ function createScheduler(deps) {
29594
30427
  sessionId: row.session_id,
29595
30428
  fenceId,
29596
30429
  hintKey: STOP_HOOK_HINT_KEY,
29597
- message: restPromptMessage(armed.prompt),
30430
+ message: restPromptMessage(armed.prompt, { overQuestion: tele.pendingQuestion === true }),
29598
30431
  reason: "recurring prompt at rest"
29599
30432
  }, nowMs).delivery;
29600
30433
  log3(`waker: queued ${row.slug} \u2014 ${item.reason}`);
@@ -29609,7 +30442,6 @@ function createScheduler(deps) {
29609
30442
  const tele = deps.tailer.get(row.slug);
29610
30443
  if (!tele?.lastCompactionAt) continue;
29611
30444
  if (tele.lastCompactionAt <= armed.armedAt) continue;
29612
- if (heldByQuestion(row, tele)) continue;
29613
30445
  const fenceId = compactFenceId(armed.armedAt, tele.lastCompactionAt);
29614
30446
  const deliveryId = wakeDeliveryId(row.slug, row.session_id, fenceId);
29615
30447
  if (outbox.get(deliveryId)) continue;
@@ -29659,7 +30491,6 @@ function createScheduler(deps) {
29659
30491
  settleRestPrompt(item);
29660
30492
  settleCompactPrompt(item);
29661
30493
  settleTimer(item);
29662
- settleWatcher(item);
29663
30494
  continue;
29664
30495
  }
29665
30496
  if (context === "superseded") {
@@ -29679,7 +30510,6 @@ function createScheduler(deps) {
29679
30510
  if (recovered?.state === "exhausted") {
29680
30511
  settleSnooze(item);
29681
30512
  settleTimer(item);
29682
- settleWatcher(item);
29683
30513
  log3(`waker: delivery EXHAUSTED for ${item.slug} after ${recovered.attempts} attempts \u2014 ${recovered.lastError ?? "unknown error"}`);
29684
30514
  }
29685
30515
  }
@@ -29698,7 +30528,6 @@ function createScheduler(deps) {
29698
30528
  settleRestPrompt(item);
29699
30529
  settleCompactPrompt(item);
29700
30530
  settleTimer(item);
29701
- settleWatcher(item);
29702
30531
  continue;
29703
30532
  }
29704
30533
  if (context === "superseded") {
@@ -29726,7 +30555,6 @@ function createScheduler(deps) {
29726
30555
  outbox.supersede(item.id, failedAt, message);
29727
30556
  settleSnooze(item);
29728
30557
  settleTimer(item);
29729
- settleWatcher(item);
29730
30558
  log3(`waker: delivery ABANDONED for ${item.slug} (terminal, no retry): ${message}`);
29731
30559
  continue;
29732
30560
  }
@@ -29752,7 +30580,6 @@ function createScheduler(deps) {
29752
30580
  settleRestPrompt(item);
29753
30581
  settleCompactPrompt(item);
29754
30582
  settleTimer(item);
29755
- settleWatcher(item);
29756
30583
  settleSignoffNudge(item);
29757
30584
  const acknowledged = outbox.get(item.id);
29758
30585
  if (acknowledged) checkpoint("after-ack", acknowledged);
@@ -29821,15 +30648,22 @@ function createScheduler(deps) {
29821
30648
  }
29822
30649
  try {
29823
30650
  evalSignoffNudges(now());
30651
+ evalParkIntegrity(now());
29824
30652
  } catch (err) {
29825
30653
  if (err instanceof InjectedSchedulerCrash) throw err;
29826
30654
  log3(`waker: sign-off nudge pass failed: ${err instanceof Error ? err.message : String(err)}`);
29827
30655
  }
29828
30656
  try {
29829
- evalWatchers(now());
30657
+ await evalPrWatches(now());
30658
+ } catch (err) {
30659
+ if (err instanceof InjectedSchedulerCrash) throw err;
30660
+ log3(`waker: pr-watch registry pass failed: ${err instanceof Error ? err.message : String(err)}`);
30661
+ }
30662
+ try {
30663
+ evalShellCompletions(now());
29830
30664
  } catch (err) {
29831
30665
  if (err instanceof InjectedSchedulerCrash) throw err;
29832
- log3(`waker: watcher pass failed: ${err instanceof Error ? err.message : String(err)}`);
30666
+ log3(`waker: shell-completion pass failed: ${err instanceof Error ? err.message : String(err)}`);
29833
30667
  }
29834
30668
  try {
29835
30669
  evalTimers(now());
@@ -29881,11 +30715,12 @@ function createScheduler(deps) {
29881
30715
  tick
29882
30716
  };
29883
30717
  }
29884
- var execFileAsync4, PR_REF_RE2, LIMIT_FENCE_PREFIX, LIMIT_HINT_PREFIX, LIMIT_RESUME_GRACE_MS, LIMIT_RESUME_HEADROOM_PERCENT, LIMIT_HEADROOM_MIN_FAULT_AGE_MS, REPORT_FENCE_PREFIX, REPORT_HINT_PREFIX, SNOOZE_FENCE_PREFIX, SNOOZE_HINT_PREFIX, HEARTBEAT_FENCE_PREFIX, HEARTBEAT_HINT_PREFIX, STOP_HOOK_FENCE_PREFIX, STOP_HOOK_HINT_KEY, COMPACT_FENCE_PREFIX, COMPACT_HINT_KEY, TIMER_FENCE_PREFIX, TIMER_HINT_PREFIX, SIGNOFF_FENCE_PREFIX, SIGNOFF_HINT_KEY, SIGNOFF_NUDGE_MAX, SIGNOFF_NUDGE_SETTING, WATCH_FENCE_PREFIX, WATCH_HINT_PREFIX, SHELL_SEEN, FIRED_CAP, REGISTRATION_CAP, REVIEW_SEEN_CAP, INTRODUCED_CAP, REVIEW_STEER_CAP;
30718
+ var execFileAsync4, PR_REF_RE2, LIMIT_FENCE_PREFIX, LIMIT_HINT_PREFIX, LIMIT_RESUME_GRACE_MS, LIMIT_RESUME_HEADROOM_PERCENT, LIMIT_HEADROOM_MIN_FAULT_AGE_MS, REPORT_FENCE_PREFIX, REPORT_HINT_PREFIX, SNOOZE_FENCE_PREFIX, SNOOZE_HINT_PREFIX, HEARTBEAT_FENCE_PREFIX, HEARTBEAT_HINT_PREFIX, STOP_HOOK_FENCE_PREFIX, STOP_HOOK_HINT_KEY, COMPACT_FENCE_PREFIX, COMPACT_HINT_KEY, TIMER_FENCE_PREFIX, TIMER_HINT_PREFIX, SHELL_FENCE_PREFIX, PR_WATCH_FENCE_PREFIX, PR_WATCH_POLL_MS, SIGNOFF_FENCE_PREFIX, SIGNOFF_HINT_KEY, SIGNOFF_NUDGE_MAX, PARK_BUMP_MAX, SIGNOFF_NUDGE_SETTING, FIRED_CAP, GITHUB_STATUS_CAP, REGISTRATION_CAP, REVIEW_SEEN_CAP, INTRODUCED_CAP, REVIEW_STEER_CAP;
29885
30719
  var init_scheduler = __esm({
29886
30720
  "packages/server/src/scheduler.ts"() {
29887
30721
  "use strict";
29888
30722
  init_src();
30723
+ init_awaiting();
29889
30724
  init_usage_limit();
29890
30725
  init_wake_store();
29891
30726
  init_shutdown();
@@ -29912,14 +30747,16 @@ var init_scheduler = __esm({
29912
30747
  COMPACT_HINT_KEY = "recurring:compaction";
29913
30748
  TIMER_FENCE_PREFIX = "timer";
29914
30749
  TIMER_HINT_PREFIX = "timer:";
30750
+ SHELL_FENCE_PREFIX = "shell";
30751
+ PR_WATCH_FENCE_PREFIX = "prwatch";
30752
+ PR_WATCH_POLL_MS = 6e4;
29915
30753
  SIGNOFF_FENCE_PREFIX = "signoff";
29916
30754
  SIGNOFF_HINT_KEY = "signoff:rest";
29917
30755
  SIGNOFF_NUDGE_MAX = 2;
30756
+ PARK_BUMP_MAX = 3;
29918
30757
  SIGNOFF_NUDGE_SETTING = "signoffNudge";
29919
- WATCH_FENCE_PREFIX = "watch";
29920
- WATCH_HINT_PREFIX = "watch:";
29921
- SHELL_SEEN = "seen";
29922
30758
  FIRED_CAP = 500;
30759
+ GITHUB_STATUS_CAP = 200;
29923
30760
  REGISTRATION_CAP = 500;
29924
30761
  REVIEW_SEEN_CAP = 300;
29925
30762
  INTRODUCED_CAP = 500;
@@ -30628,7 +31465,7 @@ var init_orphan_reaper = __esm({
30628
31465
  // packages/server/src/context.ts
30629
31466
  import { join as join29 } from "node:path";
30630
31467
  import { randomUUID as randomUUID15 } from "node:crypto";
30631
- import { homedir as homedir12 } from "node:os";
31468
+ import { homedir as homedir13 } from "node:os";
30632
31469
  function reconcileSessions(storage) {
30633
31470
  for (const row of storage.allSessions()) {
30634
31471
  if (isHeadlessRow(row)) continue;
@@ -30782,7 +31619,7 @@ async function createContext(opts = {}) {
30782
31619
  }
30783
31620
  }
30784
31621
  function createContextUnchecked(opts, resources) {
30785
- const home = opts.home ?? homedir12();
31622
+ const home = opts.home ?? homedir13();
30786
31623
  const project = opts.project ?? resolveProject();
30787
31624
  const dbPath = join29(project.stateDir, "ui.db");
30788
31625
  const storage = createStorage(dbPath);
@@ -31356,11 +32193,11 @@ var init_url = __esm({
31356
32193
  }
31357
32194
  return null;
31358
32195
  };
31359
- tryDecode = (str, decoder) => {
32196
+ tryDecode = (str2, decoder) => {
31360
32197
  try {
31361
- return decoder(str);
32198
+ return decoder(str2);
31362
32199
  } catch {
31363
- return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match2) => {
32200
+ return str2.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match2) => {
31364
32201
  try {
31365
32202
  return decoder(match2);
31366
32203
  } catch {
@@ -31369,7 +32206,7 @@ var init_url = __esm({
31369
32206
  });
31370
32207
  }
31371
32208
  };
31372
- tryDecodeURI = (str) => tryDecode(str, decodeURI);
32209
+ tryDecodeURI = (str2) => tryDecode(str2, decodeURI);
31373
32210
  getPath = (request) => {
31374
32211
  const url = request.url;
31375
32212
  const start = url.indexOf("/", url.indexOf(":") + 4);
@@ -31517,7 +32354,7 @@ var init_request = __esm({
31517
32354
  init_constants();
31518
32355
  init_body();
31519
32356
  init_url();
31520
- tryDecodeURIComponent = (str) => tryDecode(str, decodeURIComponent_);
32357
+ tryDecodeURIComponent = (str2) => tryDecode(str2, decodeURIComponent_);
31521
32358
  HonoRequest = class {
31522
32359
  /**
31523
32360
  * `.raw` can get the raw Request object.
@@ -31816,27 +32653,27 @@ var init_html = __esm({
31816
32653
  escapedString.callbacks = callbacks;
31817
32654
  return escapedString;
31818
32655
  };
31819
- resolveCallback = async (str, phase, preserveCallbacks, context, buffer) => {
31820
- if (typeof str === "object" && !(str instanceof String)) {
31821
- if (!(str instanceof Promise)) {
31822
- str = str.toString();
32656
+ resolveCallback = async (str2, phase, preserveCallbacks, context, buffer) => {
32657
+ if (typeof str2 === "object" && !(str2 instanceof String)) {
32658
+ if (!(str2 instanceof Promise)) {
32659
+ str2 = str2.toString();
31823
32660
  }
31824
- if (str instanceof Promise) {
31825
- str = await str;
32661
+ if (str2 instanceof Promise) {
32662
+ str2 = await str2;
31826
32663
  }
31827
32664
  }
31828
- const callbacks = str.callbacks;
32665
+ const callbacks = str2.callbacks;
31829
32666
  if (!callbacks?.length) {
31830
- return Promise.resolve(str);
32667
+ return Promise.resolve(str2);
31831
32668
  }
31832
32669
  if (buffer) {
31833
- buffer[0] += str;
32670
+ buffer[0] += str2;
31834
32671
  } else {
31835
- buffer = [str];
32672
+ buffer = [str2];
31836
32673
  }
31837
32674
  const resStr = Promise.all(callbacks.map((c) => c({ phase, buffer, context }))).then(
31838
32675
  (res) => Promise.all(
31839
- res.filter(Boolean).map((str2) => resolveCallback(str2, phase, false, context, buffer))
32676
+ res.filter(Boolean).map((str22) => resolveCallback(str22, phase, false, context, buffer))
31840
32677
  ).then(() => buffer[0])
31841
32678
  );
31842
32679
  if (preserveCallbacks) {
@@ -34006,11 +34843,14 @@ import { closeSync as closeSync13, existsSync as existsSync11, fstatSync as fsta
34006
34843
  import { createHash as createHash15 } from "node:crypto";
34007
34844
  import { StringDecoder as StringDecoder4 } from "node:string_decoder";
34008
34845
  import { join as join30 } from "node:path";
34009
- import { homedir as homedir13 } from "node:os";
34846
+ import { homedir as homedir14 } from "node:os";
34010
34847
  function isInjectedNoise(text) {
34011
34848
  const t = text.trimStart();
34012
34849
  return NOISE_PREFIXES.some((p) => t.startsWith(p));
34013
34850
  }
34851
+ function isInterruptMarker(text) {
34852
+ return INTERRUPT_MARKERS.includes(text.trim());
34853
+ }
34014
34854
  function formatTokens(n) {
34015
34855
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
34016
34856
  if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
@@ -34246,8 +35086,8 @@ function createTranscriptFold(identityPrefix = "claude") {
34246
35086
  }
34247
35087
  const peerOrigin = att.origin?.kind === "peer" ? att.origin : void 0;
34248
35088
  if (peerOrigin && att.commandMode === "prompt") {
34249
- const str = (v) => typeof v === "string" ? v.trim() : "";
34250
- const senderTaskId = str(peerOrigin.senderTaskId);
35089
+ const str2 = (v) => typeof v === "string" ? v.trim() : "";
35090
+ const senderTaskId = str2(peerOrigin.senderTaskId);
34251
35091
  const dispatchId = senderTaskId ? childDispatchIds.get(senderTaskId) : void 0;
34252
35092
  const entry = prompt.trim() ? findQueued(prompt) : void 0;
34253
35093
  if (entry) {
@@ -34263,7 +35103,7 @@ function createTranscriptFold(identityPrefix = "claude") {
34263
35103
  } else {
34264
35104
  const parsed = prompt.trim() ? parseAgentMessage(prompt) : void 0;
34265
35105
  const describedHere = dispatchId ? dispatchLabels.get(dispatchId) : void 0;
34266
- const from = describedHere || str(peerOrigin.from) || str(peerOrigin.name) || parsed?.from || "";
35106
+ const from = describedHere || str2(peerOrigin.from) || str2(peerOrigin.name) || parsed?.from || "";
34267
35107
  const body = parsed?.body ?? (typeof peerOrigin.body === "string" ? peerOrigin.body : "");
34268
35108
  if (from && body.trim()) {
34269
35109
  out.push({
@@ -34307,6 +35147,7 @@ function createTranscriptFold(identityPrefix = "claude") {
34307
35147
  }
34308
35148
  let text = userText(rec);
34309
35149
  if (text && isAllInjectedNoise(text)) return;
35150
+ if (text && isInterruptMarker(text)) return;
34310
35151
  if (text) {
34311
35152
  const queued = findQueued(text)?.message;
34312
35153
  if (queued) {
@@ -35015,7 +35856,7 @@ function verifyIncrementalParse(path, identityPrefix, incremental) {
35015
35856
  }
35016
35857
  }
35017
35858
  function logDirOf(project) {
35018
- return join30(homedir13(), ".claude", "projects", project.cwdSlug);
35859
+ return join30(homedir14(), ".claude", "projects", project.cwdSlug);
35019
35860
  }
35020
35861
  function resolveTranscriptPath(project, sessionId) {
35021
35862
  const path = join30(logDirOf(project), `${sessionId}.jsonl`);
@@ -36302,7 +37143,7 @@ function readTranscriptFile(absPath) {
36302
37143
  return [];
36303
37144
  }
36304
37145
  }
36305
- var MAX_MESSAGES, NOISE_PREFIXES, REST_LABEL, LEGACY_TASK_MARKER, LEGACY_BANNER_PREAMBLE, COMMAND_NAME_RE, COMMAND_ARGS_RE, PEER_SESSION_PREAMBLE, QUEUED_STALE_MS, EDIT_CAP, TRUNC_MARKER, ENCRYPTED_PAYLOAD, AGENT_PROMPT_CAP, SEND_BODY_CAP, COMMAND_CAP, TOOL_INPUT_CAP, READ_LINE_CAP, READ_BYTE_CAP, IMAGE_MEDIA_EXT, SCREENSHOT_CACHE_DIR, SCREENSHOT_CACHE_MAX, SCREENSHOT_MAX_BASE64, screenshotTmpSeq, SENT_IMAGE_MAX_BYTES, SENT_FILES_MAX, TODO_ROWS_MAX, TODO_TEXT_CAP, TRANSCRIPT_CACHE_CAP, transcriptCache, PARSE_VERIFY_SAMPLE, TRANSCRIPT_DECODE_WINDOW, FOREIGN_SESSION_ID_RE, MAX_PINNED_BACKGROUND_OPERATIONS, CAPTION_MAX, _defaultCodexBackend, TRANSCRIPT_EARLIER_MAX_ITEMS, TRANSCRIPT_EARLIER_MAX_BYTES, fullDigestCache, FULL_DIGEST_CACHE_CAP, EMPTY_RETIRED_OPS;
37146
+ var MAX_MESSAGES, NOISE_PREFIXES, INTERRUPT_MARKERS, REST_LABEL, LEGACY_TASK_MARKER, LEGACY_BANNER_PREAMBLE, COMMAND_NAME_RE, COMMAND_ARGS_RE, PEER_SESSION_PREAMBLE, QUEUED_STALE_MS, EDIT_CAP, TRUNC_MARKER, ENCRYPTED_PAYLOAD, AGENT_PROMPT_CAP, SEND_BODY_CAP, COMMAND_CAP, TOOL_INPUT_CAP, READ_LINE_CAP, READ_BYTE_CAP, IMAGE_MEDIA_EXT, SCREENSHOT_CACHE_DIR, SCREENSHOT_CACHE_MAX, SCREENSHOT_MAX_BASE64, screenshotTmpSeq, SENT_IMAGE_MAX_BYTES, SENT_FILES_MAX, TODO_ROWS_MAX, TODO_TEXT_CAP, TRANSCRIPT_CACHE_CAP, transcriptCache, PARSE_VERIFY_SAMPLE, TRANSCRIPT_DECODE_WINDOW, FOREIGN_SESSION_ID_RE, MAX_PINNED_BACKGROUND_OPERATIONS, CAPTION_MAX, _defaultCodexBackend, TRANSCRIPT_EARLIER_MAX_ITEMS, TRANSCRIPT_EARLIER_MAX_BYTES, fullDigestCache, FULL_DIGEST_CACHE_CAP, EMPTY_RETIRED_OPS;
36306
37147
  var init_transcript = __esm({
36307
37148
  "packages/server/src/transcript.ts"() {
36308
37149
  "use strict";
@@ -36318,6 +37159,7 @@ var init_transcript = __esm({
36318
37159
  init_frizz_paths();
36319
37160
  MAX_MESSAGES = 300;
36320
37161
  NOISE_PREFIXES = ["<task-notification>", "[SYSTEM NOTIFICATION", "<system-reminder>", "<frizz-", "[frizz]"];
37162
+ INTERRUPT_MARKERS = ["[Request interrupted by user]", "[Request interrupted by user for tool use]"];
36321
37163
  REST_LABEL = "Agent rested";
36322
37164
  LEGACY_TASK_MARKER = "\nTASK:\n";
36323
37165
  LEGACY_BANNER_PREAMBLE = "Everything ABOVE this line is frizz system orientation. Everything BELOW the `TASK:` marker is the human operator's own prompt, verbatim.";
@@ -36394,7 +37236,7 @@ var init_open_external = __esm({
36394
37236
  // packages/server/src/local-file.ts
36395
37237
  import { spawn as spawn6 } from "node:child_process";
36396
37238
  import { readFileSync as readFileSync20, realpathSync as realpathSync8, statSync as statSync15 } from "node:fs";
36397
- import { homedir as homedir14 } from "node:os";
37239
+ import { homedir as homedir15 } from "node:os";
36398
37240
  import { isAbsolute as isAbsolute3, join as join31, resolve as resolve7, sep } from "node:path";
36399
37241
  function isUnder(real, root) {
36400
37242
  let rootReal;
@@ -36422,7 +37264,7 @@ function resolveLocalFile(rawPath, roots) {
36422
37264
  }
36423
37265
  return real;
36424
37266
  }
36425
- function resolveOpenableFile(raw2, projectDir, roots, home = homedir14()) {
37267
+ function resolveOpenableFile(raw2, projectDir, roots, home = homedir15()) {
36426
37268
  const trimmed = raw2.trim().replace(/:\d+(?::\d+)?$/, "");
36427
37269
  if (!trimmed) return null;
36428
37270
  const abs = trimmed === "~" ? home : trimmed.startsWith("~/") ? join31(home, trimmed.slice(2)) : isAbsolute3(trimmed) ? trimmed : resolve7(projectDir, trimmed);
@@ -36462,6 +37304,286 @@ var init_local_file = __esm({
36462
37304
  }
36463
37305
  });
36464
37306
 
37307
+ // packages/server/src/github-hovercard.ts
37308
+ function parseRef(ref) {
37309
+ const match2 = REF_PATTERN.exec(ref);
37310
+ if (!match2) return null;
37311
+ const [, owner, name, number, sha] = match2;
37312
+ if (number) {
37313
+ const n = Number(number);
37314
+ if (!Number.isSafeInteger(n) || n <= 0 || n > 2147483647) return null;
37315
+ return { key: ref, owner, name, number: n };
37316
+ }
37317
+ return { key: ref, owner, name, sha };
37318
+ }
37319
+ function isImmutableRef(ref) {
37320
+ return ref.sha !== void 0;
37321
+ }
37322
+ function buildRefQuery(refs) {
37323
+ const variables = {};
37324
+ const declarations = [];
37325
+ const selections = [];
37326
+ refs.forEach((ref, index) => {
37327
+ declarations.push(`$owner${index}: String!`, `$name${index}: String!`);
37328
+ variables[`owner${index}`] = ref.owner;
37329
+ variables[`name${index}`] = ref.name;
37330
+ if (ref.number !== void 0) {
37331
+ declarations.push(`$number${index}: Int!`);
37332
+ variables[`number${index}`] = ref.number;
37333
+ selections.push(` a${index}: repository(owner: $owner${index}, name: $name${index}) {
37334
+ target: issueOrPullRequest(number: $number${index}) {${ISSUE_FIELDS}
37335
+ }
37336
+ }`);
37337
+ } else {
37338
+ declarations.push(`$rev${index}: String!`);
37339
+ variables[`rev${index}`] = ref.sha;
37340
+ selections.push(` a${index}: repository(owner: $owner${index}, name: $name${index}) {
37341
+ target: object(expression: $rev${index}) {${COMMIT_FIELDS}
37342
+ }
37343
+ }`);
37344
+ }
37345
+ });
37346
+ return { query: `query(${declarations.join(", ")}) {
37347
+ ${selections.join("\n")}
37348
+ }`, variables };
37349
+ }
37350
+ function str(v) {
37351
+ return typeof v === "string" && v ? v : void 0;
37352
+ }
37353
+ function int(v) {
37354
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : void 0;
37355
+ }
37356
+ function excerptBody(body) {
37357
+ if (!body) return "";
37358
+ const cleaned = body.replace(/<!--[\s\S]*?-->/g, "").trim();
37359
+ if (cleaned.length <= BODY_BUDGET) return cleaned;
37360
+ return `${cleaned.slice(0, BODY_BUDGET).trimEnd()}\u2026`;
37361
+ }
37362
+ function labelsOf(v) {
37363
+ const nodes = v?.nodes;
37364
+ if (!Array.isArray(nodes)) return [];
37365
+ const out = [];
37366
+ for (const node of nodes) {
37367
+ const name = str(node?.name);
37368
+ if (name) out.push({ name, color: str(node?.color) ?? "" });
37369
+ }
37370
+ return out;
37371
+ }
37372
+ function cardFromNode(ref, node, fetchedAt) {
37373
+ if (!node || typeof node !== "object") return null;
37374
+ const n = node;
37375
+ const repo = `${ref.owner}/${ref.name}`;
37376
+ const typename = str(n.__typename);
37377
+ if (typename === "Commit") {
37378
+ const headline = str(n.messageHeadline) ?? "";
37379
+ if (!headline) return null;
37380
+ const author2 = n.author ?? {};
37381
+ const user = author2.user ?? {};
37382
+ return {
37383
+ ref: ref.key,
37384
+ kind: "commit",
37385
+ repo,
37386
+ url: str(n.url) ?? `https://github.com/${repo}/commit/${str(n.oid) ?? ref.sha}`,
37387
+ title: headline,
37388
+ body: excerptBody(str(n.messageBody)),
37389
+ state: "",
37390
+ at: str(n.committedDate),
37391
+ ...str(user.login) ? { authorLogin: str(user.login) } : {},
37392
+ ...str(author2.name) ? { authorName: str(author2.name) } : {},
37393
+ // The LINKED ACCOUNT's avatar first. A commit's `GitActor.avatarUrl` is derived from the
37394
+ // committer email, so for an author with no matching account it is a Gravatar behind GitHub's
37395
+ // camo image proxy — a different host, and a generic silhouette rather than the person.
37396
+ ...str(user.avatarUrl) || str(author2.avatarUrl) ? { authorAvatar: str(user.avatarUrl) ?? str(author2.avatarUrl) } : {},
37397
+ labels: [],
37398
+ ...int(n.additions) !== void 0 ? { additions: int(n.additions) } : {},
37399
+ ...int(n.deletions) !== void 0 ? { deletions: int(n.deletions) } : {},
37400
+ ...int(n.changedFilesIfAvailable) !== void 0 ? { changedFiles: int(n.changedFilesIfAvailable) } : {},
37401
+ fetchedAt
37402
+ };
37403
+ }
37404
+ if (typename !== "Issue" && typename !== "PullRequest") return null;
37405
+ const title = str(n.title);
37406
+ if (!title) return null;
37407
+ const author = n.author ?? {};
37408
+ const state = n.isDraft === true && str(n.state)?.toUpperCase() === "OPEN" ? "DRAFT" : str(n.state)?.toUpperCase() ?? "";
37409
+ return {
37410
+ ref: ref.key,
37411
+ kind: typename === "Issue" ? "issue" : "pr",
37412
+ repo,
37413
+ url: str(n.url) ?? `https://github.com/${repo}/issues/${ref.number}`,
37414
+ title,
37415
+ body: excerptBody(str(n.body)),
37416
+ state,
37417
+ ...str(n.stateReason) ? { stateReason: str(n.stateReason).toUpperCase() } : {},
37418
+ at: str(n.createdAt),
37419
+ ...str(author.login) ? { authorLogin: str(author.login) } : {},
37420
+ ...str(author.avatarUrl) ? { authorAvatar: str(author.avatarUrl) } : {},
37421
+ labels: labelsOf(n.labels),
37422
+ ...int(n.additions) !== void 0 ? { additions: int(n.additions) } : {},
37423
+ ...int(n.deletions) !== void 0 ? { deletions: int(n.deletions) } : {},
37424
+ ...int(n.changedFiles) !== void 0 ? { changedFiles: int(n.changedFiles) } : {},
37425
+ ...int(n.comments?.totalCount) !== void 0 ? { comments: int(n.comments.totalCount) } : {},
37426
+ fetchedAt
37427
+ };
37428
+ }
37429
+ function parseRefResponse(raw2, refs, fetchedAt) {
37430
+ const data = raw2?.data;
37431
+ const cards = [];
37432
+ const missing = [];
37433
+ refs.forEach((ref, index) => {
37434
+ const alias = data?.[`a${index}`] ?? null;
37435
+ const card = cardFromNode(ref, alias?.target, fetchedAt);
37436
+ if (card) cards.push(card);
37437
+ else missing.push(ref.key);
37438
+ });
37439
+ return { cards, missing };
37440
+ }
37441
+ function conciseError2(error) {
37442
+ const raw2 = error instanceof Error ? error.message : String(error);
37443
+ return raw2.trim().split(/\r?\n/, 1)[0]?.slice(0, 300) || "unknown error";
37444
+ }
37445
+ function createGithubHovercardService(deps = {}) {
37446
+ const getToken = deps.getToken ?? defaultGetToken;
37447
+ const request = deps.request ?? globalThis.fetch;
37448
+ const now = deps.now ?? Date.now;
37449
+ const cache = /* @__PURE__ */ new Map();
37450
+ const inflight = /* @__PURE__ */ new Map();
37451
+ let tokenPromise;
37452
+ const token = async () => {
37453
+ if (!tokenPromise) {
37454
+ tokenPromise = getToken();
37455
+ tokenPromise.catch(() => {
37456
+ tokenPromise = void 0;
37457
+ });
37458
+ }
37459
+ return tokenPromise;
37460
+ };
37461
+ const isFresh = (ref, entry, refresh) => {
37462
+ if (isImmutableRef(ref) && entry.card) return true;
37463
+ if (refresh) return false;
37464
+ return now() - entry.at < MUTABLE_TTL_MS;
37465
+ };
37466
+ const fetchChunk = async (refs) => {
37467
+ const authToken = await token();
37468
+ const { query: query2, variables } = buildRefQuery(refs);
37469
+ const controller = new AbortController();
37470
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS2);
37471
+ timeout.unref?.();
37472
+ try {
37473
+ const response = await request("https://api.github.com/graphql", {
37474
+ method: "POST",
37475
+ headers: {
37476
+ accept: "application/vnd.github+json",
37477
+ authorization: `Bearer ${authToken}`,
37478
+ "content-type": "application/json",
37479
+ "user-agent": "frizz-hovercard",
37480
+ "x-github-api-version": "2022-11-28"
37481
+ },
37482
+ body: JSON.stringify({ query: query2, variables }),
37483
+ signal: controller.signal
37484
+ });
37485
+ const text = await response.text();
37486
+ if (!response.ok) {
37487
+ if (response.status === 401 || response.status === 403) tokenPromise = void 0;
37488
+ throw new Error(`GitHub GraphQL returned HTTP ${response.status}`);
37489
+ }
37490
+ let body;
37491
+ try {
37492
+ body = JSON.parse(text);
37493
+ } catch {
37494
+ throw new Error("GitHub GraphQL returned malformed JSON");
37495
+ }
37496
+ const at = now();
37497
+ const { cards, missing } = parseRefResponse(body, refs, at);
37498
+ for (const card of cards) cache.set(card.ref, { card, at });
37499
+ for (const key of missing) cache.set(key, { missing: true, at });
37500
+ } finally {
37501
+ clearTimeout(timeout);
37502
+ }
37503
+ };
37504
+ return {
37505
+ size: () => cache.size,
37506
+ async preview(refs, opts = {}) {
37507
+ const refresh = opts.refresh === true;
37508
+ const parsed = /* @__PURE__ */ new Map();
37509
+ const missing = [];
37510
+ for (const raw2 of refs) {
37511
+ if (parsed.has(raw2)) continue;
37512
+ const ref = parseRef(raw2);
37513
+ if (ref) parsed.set(raw2, ref);
37514
+ else missing.push(raw2);
37515
+ }
37516
+ const wanted = [];
37517
+ const joins = [];
37518
+ for (const ref of parsed.values()) {
37519
+ const entry = cache.get(ref.key);
37520
+ if (entry && isFresh(ref, entry, refresh)) continue;
37521
+ const pending = inflight.get(ref.key);
37522
+ if (pending && !refresh) joins.push(pending);
37523
+ else wanted.push(ref);
37524
+ }
37525
+ let error;
37526
+ if (wanted.length > 0) {
37527
+ const chunks = [];
37528
+ for (let i = 0; i < wanted.length; i += MAX_REFS_PER_QUERY) chunks.push(wanted.slice(i, i + MAX_REFS_PER_QUERY));
37529
+ const runs = chunks.map((chunk) => {
37530
+ const run3 = fetchChunk(chunk);
37531
+ for (const ref of chunk) inflight.set(ref.key, run3);
37532
+ return run3.finally(() => {
37533
+ for (const ref of chunk) if (inflight.get(ref.key) === run3) inflight.delete(ref.key);
37534
+ });
37535
+ });
37536
+ const settled = await Promise.allSettled([...runs, ...joins]);
37537
+ const failure = settled.find((r) => r.status === "rejected");
37538
+ if (failure && failure.status === "rejected") error = conciseError2(failure.reason);
37539
+ } else if (joins.length > 0) {
37540
+ const settled = await Promise.allSettled(joins);
37541
+ const failure = settled.find((r) => r.status === "rejected");
37542
+ if (failure && failure.status === "rejected") error = conciseError2(failure.reason);
37543
+ }
37544
+ const cards = [];
37545
+ for (const ref of parsed.values()) {
37546
+ const entry = cache.get(ref.key);
37547
+ if (entry?.card) cards.push(entry.card);
37548
+ else if (entry?.missing) missing.push(ref.key);
37549
+ }
37550
+ return { cards, missing, ...error ? { error } : {} };
37551
+ }
37552
+ };
37553
+ }
37554
+ var REQUEST_TIMEOUT_MS2, MAX_REFS_PER_QUERY, BODY_BUDGET, MUTABLE_TTL_MS, REF_SEG, REF_PATTERN, ISSUE_FIELDS, COMMIT_FIELDS;
37555
+ var init_github_hovercard = __esm({
37556
+ "packages/server/src/github-hovercard.ts"() {
37557
+ "use strict";
37558
+ init_github_review();
37559
+ REQUEST_TIMEOUT_MS2 = 15e3;
37560
+ MAX_REFS_PER_QUERY = 25;
37561
+ BODY_BUDGET = 400;
37562
+ MUTABLE_TTL_MS = 5 * 6e4;
37563
+ REF_SEG = "[A-Za-z0-9][A-Za-z0-9._-]*";
37564
+ REF_PATTERN = new RegExp(`^(${REF_SEG})/(${REF_SEG})(?:#([1-9]\\d{0,9})|@([0-9a-f]{7,40}))$`);
37565
+ ISSUE_FIELDS = `
37566
+ ... on Issue {
37567
+ __typename number title body state stateReason url createdAt
37568
+ author { login avatarUrl }
37569
+ labels(first: 6) { nodes { name color } }
37570
+ comments { totalCount }
37571
+ }
37572
+ ... on PullRequest {
37573
+ __typename number title body state isDraft url createdAt additions deletions changedFiles
37574
+ author { login avatarUrl }
37575
+ labels(first: 6) { nodes { name color } }
37576
+ comments { totalCount }
37577
+ }`;
37578
+ COMMIT_FIELDS = `
37579
+ ... on Commit {
37580
+ __typename oid abbreviatedOid url messageHeadline messageBody committedDate
37581
+ additions deletions changedFilesIfAvailable
37582
+ author { name avatarUrl user { login avatarUrl } }
37583
+ }`;
37584
+ }
37585
+ });
37586
+
36465
37587
  // packages/server/src/backend/account-actions.ts
36466
37588
  import { execFile as execFile8 } from "node:child_process";
36467
37589
  import { promisify as promisify7 } from "node:util";
@@ -36897,7 +38019,7 @@ import { join as join32, resolve as resolve8 } from "node:path";
36897
38019
  import { randomUUID as randomUUID16 } from "node:crypto";
36898
38020
  import { basename as basename10, dirname as dirname14 } from "node:path";
36899
38021
  import { existsSync as existsSync12, mkdirSync as mkdirSync16, rmSync as rmSync9, writeFileSync as writeFileSync12 } from "node:fs";
36900
- import { homedir as homedir15 } from "node:os";
38022
+ import { homedir as homedir16 } from "node:os";
36901
38023
  function validateGithubDispatchProfile(input) {
36902
38024
  validateThreadProfile(input.backend, input.model, input.effort);
36903
38025
  }
@@ -37056,7 +38178,7 @@ function projectCard(entry, stale) {
37056
38178
  function addProjectAtPath(input) {
37057
38179
  const typed = input.trim();
37058
38180
  if (!typed) throw new Error("Enter a folder path.");
37059
- const expanded = typed === "~" || typed.startsWith("~/") ? join32(homedir15(), typed.slice(1)) : typed;
38181
+ const expanded = typed === "~" || typed.startsWith("~/") ? join32(homedir16(), typed.slice(1)) : typed;
37060
38182
  const absolute = resolve8(expanded);
37061
38183
  let stats;
37062
38184
  try {
@@ -37066,7 +38188,7 @@ function addProjectAtPath(input) {
37066
38188
  }
37067
38189
  if (!stats.isDirectory()) throw new Error(`That is a file, not a folder: ${absolute}`);
37068
38190
  const root = discoverProjectRoot(absolute);
37069
- const id = ensureProjectIdFile(root, homedir15(), existingProjectId(root));
38191
+ const id = ensureProjectIdFile(root, homedir16(), existingProjectId(root));
37070
38192
  const remoteOwner = resolveProjectLabel(root)?.split("/")[0];
37071
38193
  let registered = registerProject({ dir: root, id, remoteOwner });
37072
38194
  if (registered.action === "duplicate") {
@@ -37139,14 +38261,18 @@ function createRouter(ctx) {
37139
38261
  if (input.prompt === null || input.intervalSeconds === void 0) return null;
37140
38262
  return input.intervalSeconds * 1e3;
37141
38263
  }
37142
- function armedWatchViews(slug) {
37143
- return ctx.storage.listThreadWatches(slug, { armedOnly: true }).map((w) => ({
37144
- id: w.id,
37145
- kind: w.kind,
37146
- target: w.target,
37147
- state: w.state,
37148
- createdAt: new Date(w.created_at).toISOString()
37149
- }));
38264
+ function armedPrWatchViews(slug) {
38265
+ const github = readGithubStatusBook(ctx.storage.getSetting(GITHUB_STATUS_SETTING));
38266
+ return ctx.storage.listPrWatches(slug, { armedOnly: true }).map((w) => {
38267
+ const target = `${w.owner}/${w.repo}#${w.number}`;
38268
+ return {
38269
+ id: w.id,
38270
+ target,
38271
+ state: w.state,
38272
+ createdAt: new Date(w.created_at).toISOString(),
38273
+ ...github[target] ? { github: github[target] } : {}
38274
+ };
38275
+ });
37150
38276
  }
37151
38277
  function armedTimerViews(slug) {
37152
38278
  return ctx.storage.listThreadTimers(slug, { armedOnly: true }).map((t) => ({
@@ -37162,16 +38288,14 @@ function createRouter(ctx) {
37162
38288
  if (!row) throw new Error(`thread ${slug} is not registered`);
37163
38289
  const otherOn = trigger === "rest" ? row.recurring_on_schedule === 1 : row.recurring_on_rest === 1;
37164
38290
  const compactOn = row.recurring_on_compact === 1;
37165
- const holdOn = row.recurring_pause_on_questions === 1;
37166
38291
  const storedSeconds = row.recurring_interval_ms ? Math.round(row.recurring_interval_ms / 1e3) : void 0;
37167
38292
  const on = write.enabled && write.prompt !== null;
37168
38293
  const prompt = on ? write.prompt : otherOn || compactOn ? row.recurring_prompt ?? null : null;
37169
- const next = prompt === null ? { prompt: null, stopHook: false, heartbeat: false, postCompaction: false, pauseOnQuestions: false } : {
38294
+ const next = prompt === null ? { prompt: null, stopHook: false, heartbeat: false, postCompaction: false } : {
37170
38295
  prompt,
37171
38296
  stopHook: trigger === "rest" ? on : otherOn,
37172
38297
  heartbeat: trigger === "schedule" ? on : otherOn,
37173
38298
  postCompaction: compactOn,
37174
- pauseOnQuestions: holdOn,
37175
38299
  intervalSeconds: trigger === "schedule" && on ? write.intervalSeconds : storedSeconds
37176
38300
  };
37177
38301
  assertRecurringPromptArmable(next, row);
@@ -37180,7 +38304,6 @@ function createRouter(ctx) {
37180
38304
  stopHook: next.stopHook,
37181
38305
  heartbeat: next.heartbeat,
37182
38306
  postCompaction: next.postCompaction,
37183
- pauseOnQuestions: next.pauseOnQuestions,
37184
38307
  intervalMs: recurringIntervalMs(next),
37185
38308
  armedAt: (/* @__PURE__ */ new Date()).toISOString()
37186
38309
  })) {
@@ -38004,7 +39127,6 @@ function createRouter(ctx) {
38004
39127
  stopHook: input.stopHook,
38005
39128
  heartbeat: input.heartbeat,
38006
39129
  postCompaction: input.postCompaction,
38007
- pauseOnQuestions: input.pauseOnQuestions,
38008
39130
  intervalMs: recurringIntervalMs(input),
38009
39131
  armedAt: (/* @__PURE__ */ new Date()).toISOString()
38010
39132
  })) {
@@ -38034,7 +39156,6 @@ function createRouter(ctx) {
38034
39156
  stopHook: input.stopHook,
38035
39157
  heartbeat: input.heartbeat,
38036
39158
  postCompaction: input.postCompaction,
38037
- pauseOnQuestions: input.pauseOnQuestions,
38038
39159
  intervalMs: recurringIntervalMs(input),
38039
39160
  armedAt: (/* @__PURE__ */ new Date()).toISOString()
38040
39161
  })) {
@@ -38111,56 +39232,106 @@ function createRouter(ctx) {
38111
39232
  output: OwnThreadTimersResult,
38112
39233
  handler: async ({ input }) => ({ timers: armedTimerViews(input.slug) })
38113
39234
  }),
38114
- // ---- THE WATCHER REGISTRY (add / drop / list) ------------------------------------------------
38115
- // The worker's own waits, from `mcp__frizz__watch`. Same caller and therefore the same rules as the
38116
- // timers above: slug-only (the MCP server outlives the session ids underneath it), and no thread
38117
- // parameter a model could aim elsewhere.
38118
- addOwnThreadWatch: mutation({
38119
- input: AddOwnThreadWatchInput,
38120
- output: AddOwnThreadWatchResult,
39235
+ // EVERY kind of background work this thread has out, with the id its awaiting fence names it by.
39236
+ //
39237
+ // The fence is structural it references live things by id so a worker that no longer has those
39238
+ // ids to hand cannot write a correct fence at all, and the failure is silent: it names something
39239
+ // wrong, frizz refuses the park, and the thread queues. That is the exact stall the grammar exists
39240
+ // to prevent, so the ids have to be RETRIEVABLE rather than remembered. Same list the sign-off nudge
39241
+ // prints, from the same source, so the two can never tell a worker different things.
39242
+ //
39243
+ // A shell is keyed by its RUNTIME task id ("Command running in background with ID: bzvtnt3ig"),
39244
+ // never its launch tool_use id: that is the string the worker was actually shown and the one it will
39245
+ // reach for. Falls back to the launch id only when the ack has not landed yet.
39246
+ listOwnThreadActivity: mutation({
39247
+ input: ListOwnThreadActivityInput,
39248
+ output: OwnThreadActivityResult,
39249
+ handler: async ({ input }) => {
39250
+ const tele = ctx.tailer.get(input.slug);
39251
+ const activity = [];
39252
+ for (const sh of tele?.bgShells ?? []) {
39253
+ if (sh.state !== "running") continue;
39254
+ const id = sh.taskId ?? sh.id;
39255
+ if (id) activity.push({ kind: "shell", id, label: sh.label, since: sh.startedAt });
39256
+ }
39257
+ for (const a of tele?.subAgents ?? []) {
39258
+ if (a.state !== "running") continue;
39259
+ if (a.id) activity.push({ kind: "agent", id: a.id, label: a.label, since: a.startedAt });
39260
+ }
39261
+ for (const t of ctx.storage.listThreadTimers(input.slug, { armedOnly: true })) {
39262
+ activity.push({
39263
+ kind: "timer",
39264
+ id: t.id,
39265
+ label: t.prompt.trim().replace(/\s+/g, " ").slice(0, 120),
39266
+ since: new Date(t.created_at).toISOString(),
39267
+ until: new Date(t.fire_at).toISOString()
39268
+ });
39269
+ }
39270
+ for (const w of ctx.storage.listPrWatches(input.slug, { armedOnly: true })) {
39271
+ activity.push({
39272
+ kind: "pr",
39273
+ id: `${w.owner}/${w.repo}#${w.number}`,
39274
+ label: `${w.owner}/${w.repo}#${w.number}`,
39275
+ since: new Date(w.created_at).toISOString()
39276
+ });
39277
+ }
39278
+ return { activity };
39279
+ }
39280
+ }),
39281
+ // THE WATCHER REGISTRY IS GONE (2026-08-14). `mcp__frizz__watch` and its four procedures are
39282
+ // deleted rather than shimmed: a wait is now a `watch:` line in the worker's own ```awaiting fence,
39283
+ // which is BOTH the park and the wake (scheduler `watchVerdict`), so there is nothing to register and
39284
+ // nothing an alias could usefully do. A session dispatched before the change still holds the old MCP
39285
+ // binary and will get a 404 from it — which is the honest answer, and the tool it should not be
39286
+ // calling is the only casualty.
39287
+ // ---- REGISTERED PR WATCHERS (add / drop / list) ---------------------------------------------
39288
+ // The worker's own PR watchers, from `mcp__frizz__watch_pr`. Same caller and therefore the same rules
39289
+ // as the timers above: slug-only (the MCP server outlives the session ids underneath it), and no
39290
+ // thread parameter a model could aim elsewhere.
39291
+ addOwnPrWatch: mutation({
39292
+ input: AddOwnPrWatchInput,
39293
+ output: AddOwnPrWatchResult,
38121
39294
  handler: async ({ input }) => {
38122
39295
  const row = ctx.storage.getSession(input.slug);
38123
39296
  if (!row) throw new Error(`thread ${input.slug} is not registered`);
38124
39297
  if (row.state === "archived" || row.archived === 1) {
38125
39298
  throw new Error("Reopen this thread before registering a watcher on it");
38126
39299
  }
38127
- const armed = ctx.storage.listThreadWatches(input.slug, { armedOnly: true });
38128
- const existing = armed.find((w) => w.kind === input.kind && w.target === input.target);
38129
- if (existing) return { id: existing.id, alreadyArmed: true, watches: armedWatchViews(input.slug) };
38130
- if (armed.length >= WATCH_MAX_ARMED) {
38131
- throw new Error(`this thread already has ${armed.length} armed watchers (the limit is ${WATCH_MAX_ARMED}) \u2014 drop one first`);
39300
+ const ref = parsePrRef(input.target);
39301
+ if (!ref) {
39302
+ throw new Error(`\`${input.target}\` is not a pull request I can watch \u2014 give owner/repo#123 or a PR URL`);
38132
39303
  }
38133
- const id = `wch_${randomUUID16().replace(/-/g, "").slice(0, 12)}`;
38134
- ctx.storage.armThreadWatch({ id, slug: input.slug, kind: input.kind, target: input.target, createdAtMs: Date.now(), foreground: input.foreground });
39304
+ const armed = ctx.storage.listPrWatches(input.slug, { armedOnly: true });
39305
+ const existing = armed.find((w) => w.owner === ref.owner && w.repo === ref.repo && w.number === ref.number);
39306
+ const target = `${ref.owner}/${ref.repo}#${ref.number}`;
39307
+ if (existing) return { id: existing.id, target, alreadyArmed: true, watches: armedPrWatchViews(input.slug) };
39308
+ if (armed.length >= PR_WATCH_MAX_ARMED) {
39309
+ throw new Error(`this thread already watches ${armed.length} pull requests (the limit is ${PR_WATCH_MAX_ARMED}) \u2014 drop one first`);
39310
+ }
39311
+ const forMs = input.for === void 0 ? PR_WATCH_DEFAULT_FOR_MS : parseAwaitingDuration(input.for);
39312
+ if (forMs === null) {
39313
+ throw new Error(`\`for: ${input.for}\` is not a duration \u2014 give one like \`30m\`, \`2h\` or \`3d\` (max 24h)`);
39314
+ }
39315
+ const now = Date.now();
39316
+ const id = `prw_${randomUUID16().replace(/-/g, "").slice(0, 12)}`;
39317
+ ctx.storage.armPrWatch({ id, slug: input.slug, owner: ref.owner, repo: ref.repo, number: ref.number, createdAtMs: now, expiresAtMs: now + forMs });
38135
39318
  ctx.board.refresh();
38136
- return { id, alreadyArmed: false, watches: armedWatchViews(input.slug) };
39319
+ return { id, target, alreadyArmed: false, watches: armedPrWatchViews(input.slug) };
38137
39320
  }
38138
39321
  }),
38139
- dropOwnThreadWatch: mutation({
38140
- input: DropOwnThreadWatchInput,
38141
- output: DropOwnThreadWatchResult,
39322
+ dropOwnPrWatch: mutation({
39323
+ input: DropOwnPrWatchInput,
39324
+ output: DropOwnPrWatchResult,
38142
39325
  handler: async ({ input }) => {
38143
- const dropped = ctx.storage.dropThreadWatch(input.slug, input.id, Date.now());
39326
+ const dropped = ctx.storage.dropPrWatch(input.slug, input.id, Date.now());
38144
39327
  if (dropped) ctx.board.refresh();
38145
- return { dropped, watches: armedWatchViews(input.slug) };
38146
- }
38147
- }),
38148
- // Hand a foreground watch back to the scheduler. Called by the blocking tool call when its timeout
38149
- // expires, so a wait the worker started degrades into a durable one rather than being lost — which
38150
- // is the property that makes choosing the blocking mode safe.
38151
- promoteOwnThreadWatch: mutation({
38152
- input: PromoteOwnThreadWatchInput,
38153
- output: PromoteOwnThreadWatchResult,
38154
- handler: async ({ input }) => {
38155
- const promoted = ctx.storage.promoteThreadWatch(input.slug, input.id);
38156
- if (promoted) ctx.board.refresh();
38157
- return { promoted, watches: armedWatchViews(input.slug) };
39328
+ return { dropped, watches: armedPrWatchViews(input.slug) };
38158
39329
  }
38159
39330
  }),
38160
- listOwnThreadWatches: mutation({
38161
- input: ListOwnThreadWatchesInput,
38162
- output: OwnThreadWatchesResult,
38163
- handler: async ({ input }) => ({ watches: armedWatchViews(input.slug) })
39331
+ listOwnPrWatches: mutation({
39332
+ input: ListOwnPrWatchesInput,
39333
+ output: OwnPrWatchesResult,
39334
+ handler: async ({ input }) => ({ watches: armedPrWatchViews(input.slug) })
38164
39335
  }),
38165
39336
  // The SUPERSEDED worker procedures, aliased onto the row above — see SetOwnThreadStopHookInput for
38166
39337
  // why they cannot simply be deleted. A worker's MCP server outlives every server restart, so these
@@ -38238,10 +39409,7 @@ function createRouter(ctx) {
38238
39409
  if (!fenceAt || !Number.isFinite(Date.parse(fenceAt)) || fenceAt !== input.fenceAt) {
38239
39410
  throw new Error("This awaiting proposal changed before it could be confirmed");
38240
39411
  }
38241
- const snoozedUntil = hint.kind === "timer" ? canonicalSnoozeInstant(hint.value) : null;
38242
- if (hint.kind === "timer" && !snoozedUntil) {
38243
- throw new Error("This awaiting proposal changed before it could be confirmed");
38244
- }
39412
+ const snoozedUntil = null;
38245
39413
  if (snoozedUntil && Date.parse(snoozedUntil) <= Date.now()) {
38246
39414
  throw new Error("This scheduled time has already passed");
38247
39415
  }
@@ -38738,12 +39906,12 @@ function createRouter(ctx) {
38738
39906
  output: DispatchPreferences,
38739
39907
  handler: async ({ input }) => setDispatchPreference(ctx.storage, ctx.getSettings(), input, readCodexModels())
38740
39908
  }),
38741
- // The shipped GitHub batch-dispatch prompt templates (single source of truth: server/github.ts).
38742
- // The Settings UI reads these to prefill the editors for editing and to power "reset to default";
38743
- // an empty/unset githubIssuePrompt/githubPrPrompt setting means the server uses exactly these.
39909
+ // The shipped GitHub batch-dispatch prompt template (single source of truth: server/github.ts).
39910
+ // The Settings UI reads it to prefill the editor and to power "reset to default"; an empty/unset
39911
+ // githubPrompt setting means the server uses exactly this. One template serves issues AND PRs.
38744
39912
  githubPromptDefaults: query({
38745
- output: external_exports.object({ issue: external_exports.string(), pr: external_exports.string() }),
38746
- handler: async () => ({ issue: DEFAULT_ISSUE_PROMPT, pr: DEFAULT_PR_PROMPT })
39913
+ output: external_exports.object({ prompt: external_exports.string() }),
39914
+ handler: async () => ({ prompt: DEFAULT_GITHUB_PROMPT })
38747
39915
  }),
38748
39916
  // ---- GitHub-first batch dispatch ----
38749
39917
  // gh availability: installed (cache-warmed resolveInstalled) + inRepo/nameWithOwner (cache-warmed
@@ -38775,6 +39943,18 @@ function createRouter(ctx) {
38775
39943
  return await listItems(repo, input.kind, input.sort, input.page, input.perPage);
38776
39944
  }
38777
39945
  }),
39946
+ // Hovercard data for every GitHub reference the client autolinked into the prose on screen —
39947
+ // ONE request for a whole page of refs, answered from a process-lifetime cache (see
39948
+ // github-hovercard.ts). The client asks as the prose renders, so the hover itself never waits on
39949
+ // the network; `refresh` is its revalidation of the handful it is actually pointing at.
39950
+ //
39951
+ // Never throws: a missing gh, an unauthenticated one, a rate limit and a network stall all come
39952
+ // back as `error` with whatever cards the cache already holds, and the anchor stays a plain link.
39953
+ githubRefPreview: query({
39954
+ input: GithubRefPreviewInput,
39955
+ output: GithubRefPreviewResult,
39956
+ handler: async ({ input }) => await githubHovercards.preview(input.refs, { refresh: input.refresh })
39957
+ }),
38778
39958
  // Spin up one frizz thread per checked item: hydrate each fresh from gh, template a server-side
38779
39959
  // prompt (single source of truth, unit-tested), then REUSE ctx.dispatcher.dispatch (no new spawn
38780
39960
  // logic). SEQUENTIAL — a burst of 20 concurrent tmux spawns would hammer the box (risk 5). A
@@ -38786,14 +39966,13 @@ function createRouter(ctx) {
38786
39966
  validateGithubDispatchProfile(input);
38787
39967
  const repo = await resolveRepo();
38788
39968
  if (!repo) throw new Error("not a GitHub repo");
38789
- const settings = ctx.getSettings();
39969
+ const template = effectiveTemplate(ctx.getSettings().githubPrompt);
38790
39970
  const dispatched = [];
38791
39971
  const failed = [];
38792
39972
  for (const it of input.items) {
38793
39973
  try {
38794
39974
  const title = `${it.kind === "issue" ? "Investigate" : "Review"} ${repo}#${it.number}`;
38795
39975
  const slug = resolveSlug(frizzDir, slugify(title), (s) => ctx.storage.getSession(s) !== void 0);
38796
- const template = effectiveTemplate(it.kind, it.kind === "issue" ? settings.githubIssuePrompt : settings.githubPrPrompt);
38797
39976
  const hydrated = it.kind === "issue" ? await hydrateIssue(repo, it.number) : await hydratePr(repo, it.number);
38798
39977
  const prompt = renderGithubPrompt(template, repo, hydrated, slug, it.kind);
38799
39978
  const request = githubDispatcherRequest(input, { prompt, title, slug });
@@ -38808,7 +39987,7 @@ function createRouter(ctx) {
38808
39987
  })
38809
39988
  };
38810
39989
  }
38811
- var SlugInput, cachedLivenessTerminator, HOLD_OPS_MAX, HOLD_LABEL_MAX;
39990
+ var SlugInput, cachedLivenessTerminator, HOLD_OPS_MAX, HOLD_LABEL_MAX, githubHovercards;
38812
39991
  var init_router5 = __esm({
38813
39992
  "packages/server/src/router.ts"() {
38814
39993
  "use strict";
@@ -38826,6 +40005,7 @@ var init_router5 = __esm({
38826
40005
  init_local_file();
38827
40006
  init_project();
38828
40007
  init_github();
40008
+ init_github_hovercard();
38829
40009
  init_dispatch();
38830
40010
  init_codex_models();
38831
40011
  init_codex();
@@ -38860,6 +40040,7 @@ var init_router5 = __esm({
38860
40040
  };
38861
40041
  HOLD_OPS_MAX = 8;
38862
40042
  HOLD_LABEL_MAX = 100;
40043
+ githubHovercards = createGithubHovercardService();
38863
40044
  }
38864
40045
  });
38865
40046
 
@@ -38999,6 +40180,52 @@ var init_local_origin = __esm({
38999
40180
  }
39000
40181
  });
39001
40182
 
40183
+ // packages/server/src/compression.ts
40184
+ import { brotliCompressSync, gzipSync, constants as constants3 } from "node:zlib";
40185
+ function negotiateEncoding(acceptEncoding) {
40186
+ if (!acceptEncoding) return null;
40187
+ const offers = /* @__PURE__ */ new Map();
40188
+ for (const part of acceptEncoding.split(",")) {
40189
+ const [name, ...params] = part.trim().split(";");
40190
+ if (!name) continue;
40191
+ const q = params.map((p) => /^\s*q=([0-9.]+)\s*$/i.exec(p)).find(Boolean);
40192
+ offers.set(name.trim().toLowerCase(), q ? Number(q[1]) : 1);
40193
+ }
40194
+ const wildcard = offers.get("*");
40195
+ const accepted = (name) => {
40196
+ const explicit = offers.get(name);
40197
+ if (explicit !== void 0) return explicit > 0;
40198
+ return wildcard !== void 0 && wildcard > 0;
40199
+ };
40200
+ if (accepted("br")) return "br";
40201
+ if (accepted("gzip")) return "gzip";
40202
+ return null;
40203
+ }
40204
+ function compress(body, encoding) {
40205
+ const out = encoding === "br" ? brotliCompressSync(body, {
40206
+ params: {
40207
+ [constants3.BROTLI_PARAM_QUALITY]: 4,
40208
+ // Telling brotli the input size lets it size its window instead of guessing.
40209
+ [constants3.BROTLI_PARAM_SIZE_HINT]: body.byteLength
40210
+ }
40211
+ }) : gzipSync(body, { level: 6 });
40212
+ return Uint8Array.from(out);
40213
+ }
40214
+ function shouldCompress(headers, byteLength2) {
40215
+ if (byteLength2 < MIN_COMPRESS_BYTES) return false;
40216
+ if (headers.has("content-encoding")) return false;
40217
+ const type = headers.get("content-type") ?? "";
40218
+ if (type.includes("text/event-stream")) return false;
40219
+ return /json|text\/|javascript|xml|svg/i.test(type);
40220
+ }
40221
+ var MIN_COMPRESS_BYTES;
40222
+ var init_compression = __esm({
40223
+ "packages/server/src/compression.ts"() {
40224
+ "use strict";
40225
+ MIN_COMPRESS_BYTES = 1024;
40226
+ }
40227
+ });
40228
+
39002
40229
  // packages/server/src/local-image.ts
39003
40230
  import { readFileSync as readFileSync22, realpathSync as realpathSync9, statSync as statSync18 } from "node:fs";
39004
40231
  import { extname as extname2, isAbsolute as isAbsolute4 } from "node:path";
@@ -39035,7 +40262,7 @@ var init_local_image = __esm({
39035
40262
 
39036
40263
  // packages/server/src/project-icon.ts
39037
40264
  import { readFileSync as readFileSync23, readdirSync as readdirSync11, realpathSync as realpathSync10, statSync as statSync19 } from "node:fs";
39038
- import { homedir as homedir16 } from "node:os";
40265
+ import { homedir as homedir17 } from "node:os";
39039
40266
  import { basename as basename11, dirname as dirname15, extname as extname3, join as join33, relative, sep as sep2 } from "node:path";
39040
40267
  function normalizeStem(stem) {
39041
40268
  const sized = stem.toLowerCase().replace(/[@-]\d+(\.\d+)?x(\d+)?$/u, "").replace(/[-_]\d{2,4}$/u, "");
@@ -39174,7 +40401,7 @@ function iconMediaType(path) {
39174
40401
  }
39175
40402
  function resolveProjectIconResponse(id, options = {}) {
39176
40403
  if (!id) return { status: 400 };
39177
- const home = options.home ?? homedir16();
40404
+ const home = options.home ?? homedir17();
39178
40405
  const entry = readRegistry(home).projects.find((project) => project.id === id);
39179
40406
  if (!entry) return { status: 404 };
39180
40407
  const path = resolveProjectIcon(id, (root) => detectProjectIcon(root)?.path, { home });
@@ -39502,6 +40729,21 @@ function createApp(ctx, options = {}) {
39502
40729
  c.header("x-frizz-boot", ctx.bootId);
39503
40730
  await next();
39504
40731
  });
40732
+ app.use(`${frizzRoute("/rpc")}/*`, async (c, next) => {
40733
+ await next();
40734
+ const encoding = negotiateEncoding(c.req.header("accept-encoding"));
40735
+ if (!encoding || !c.res.body) return;
40736
+ const body = new Uint8Array(await c.res.arrayBuffer());
40737
+ if (!shouldCompress(c.res.headers, body.byteLength)) {
40738
+ c.res = new Response(body, { status: c.res.status, headers: c.res.headers });
40739
+ return;
40740
+ }
40741
+ const headers = new Headers(c.res.headers);
40742
+ headers.set("content-encoding", encoding);
40743
+ headers.set("vary", "Accept-Encoding");
40744
+ headers.delete("content-length");
40745
+ c.res = new Response(compress(body, encoding), { status: c.res.status, headers });
40746
+ });
39505
40747
  app.get(frizzRoute("/health"), (c) => c.json({
39506
40748
  ok: true,
39507
40749
  projectId: ctx.project.id,
@@ -39608,6 +40850,7 @@ var init_app = __esm({
39608
40850
  init_src();
39609
40851
  init_router5();
39610
40852
  init_local_origin();
40853
+ init_compression();
39611
40854
  init_local_image();
39612
40855
  init_project_icon();
39613
40856
  init_local_visualization();
@@ -40071,6 +41314,11 @@ function createAppSocketServer(deps) {
40071
41314
  const bufferedAmount = deps.bufferedAmount ?? ((ws) => ws.bufferedAmount);
40072
41315
  const now = deps.now ?? Date.now;
40073
41316
  const serializeMessage = deps.serializeMessage ?? JSON.stringify;
41317
+ const scheduleSubscriptionFlush = deps.scheduleSubscriptionFlush ?? ((flush) => {
41318
+ const immediate = setImmediate(flush);
41319
+ immediate.unref?.();
41320
+ return () => clearImmediate(immediate);
41321
+ });
40074
41322
  const terminationTimers = /* @__PURE__ */ new WeakMap();
40075
41323
  const keyframes = /* @__PURE__ */ new Set();
40076
41324
  let closing = false;
@@ -40307,7 +41555,33 @@ function createAppSocketServer(deps) {
40307
41555
  scheduleTranscriptFlush(BACKPRESSURE_RETRY_MS);
40308
41556
  }
40309
41557
  const pendingSubscriptionPushes = /* @__PURE__ */ new Map();
40310
- let subscriptionFlushImmediate = null;
41558
+ let subscriptionFlushArmed = false;
41559
+ let cancelSubscriptionFlush = null;
41560
+ function flushSubscriptionPushes() {
41561
+ subscriptionFlushArmed = false;
41562
+ cancelSubscriptionFlush = null;
41563
+ if (closing) {
41564
+ pendingSubscriptionPushes.clear();
41565
+ return;
41566
+ }
41567
+ const batch = [...pendingSubscriptionPushes];
41568
+ pendingSubscriptionPushes.clear();
41569
+ const batchResults = /* @__PURE__ */ new Map();
41570
+ for (const [candidate, candidateSlugs] of batch) {
41571
+ if (candidate.readyState !== candidate.OPEN) continue;
41572
+ for (const candidateSlug of candidateSlugs) {
41573
+ if (!registry.isSubscribed(candidate, candidateSlug)) continue;
41574
+ let result = batchResults.get(candidateSlug);
41575
+ if (!result || result.kind === "limited" && result.scope === "origin") {
41576
+ result = snapshotFor(candidateSlug, socketOrigins.get(candidate));
41577
+ if (!(result.kind === "limited" && result.scope === "origin")) {
41578
+ batchResults.set(candidateSlug, result);
41579
+ }
41580
+ }
41581
+ pushSnapshotResult(candidate, candidateSlug, result);
41582
+ }
41583
+ }
41584
+ }
40311
41585
  function enqueueSubscriptionPush(ws, slug) {
40312
41586
  deleteTranscriptSnapshot(slug);
40313
41587
  let slugs = pendingSubscriptionPushes.get(ws);
@@ -40316,32 +41590,10 @@ function createAppSocketServer(deps) {
40316
41590
  pendingSubscriptionPushes.set(ws, slugs);
40317
41591
  }
40318
41592
  slugs.add(slug);
40319
- if (subscriptionFlushImmediate) return;
40320
- subscriptionFlushImmediate = setImmediate(() => {
40321
- subscriptionFlushImmediate = null;
40322
- if (closing) {
40323
- pendingSubscriptionPushes.clear();
40324
- return;
40325
- }
40326
- const batch = [...pendingSubscriptionPushes];
40327
- pendingSubscriptionPushes.clear();
40328
- const batchResults = /* @__PURE__ */ new Map();
40329
- for (const [candidate, candidateSlugs] of batch) {
40330
- if (candidate.readyState !== candidate.OPEN) continue;
40331
- for (const candidateSlug of candidateSlugs) {
40332
- if (!registry.isSubscribed(candidate, candidateSlug)) continue;
40333
- let result = batchResults.get(candidateSlug);
40334
- if (!result || result.kind === "limited" && result.scope === "origin") {
40335
- result = snapshotFor(candidateSlug, socketOrigins.get(candidate));
40336
- if (!(result.kind === "limited" && result.scope === "origin")) {
40337
- batchResults.set(candidateSlug, result);
40338
- }
40339
- }
40340
- pushSnapshotResult(candidate, candidateSlug, result);
40341
- }
40342
- }
40343
- });
40344
- subscriptionFlushImmediate.unref?.();
41593
+ if (subscriptionFlushArmed) return;
41594
+ subscriptionFlushArmed = true;
41595
+ const cancel = scheduleSubscriptionFlush(flushSubscriptionPushes);
41596
+ if (subscriptionFlushArmed) cancelSubscriptionFlush = cancel ?? null;
40345
41597
  }
40346
41598
  function cancelPendingSubscriptionPush(ws, slug) {
40347
41599
  if (slug === void 0) {
@@ -40643,8 +41895,12 @@ function createAppSocketServer(deps) {
40643
41895
  offTranscript();
40644
41896
  } catch {
40645
41897
  }
40646
- if (subscriptionFlushImmediate) clearImmediate(subscriptionFlushImmediate);
40647
- subscriptionFlushImmediate = null;
41898
+ try {
41899
+ cancelSubscriptionFlush?.();
41900
+ } catch {
41901
+ }
41902
+ cancelSubscriptionFlush = null;
41903
+ subscriptionFlushArmed = false;
40648
41904
  pendingSubscriptionPushes.clear();
40649
41905
  if (transcriptRefreshTimer) clearTimeout(transcriptRefreshTimer);
40650
41906
  transcriptRefreshTimer = null;