frizz 0.13.13 → 0.13.14

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 (3) hide show
  1. package/README.md +21 -13
  2. package/dist/frizz.js +470 -75
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -43,7 +43,7 @@ $ npx frizz
43
43
  A browser tab opens on the dashboard at `http://127.0.0.1:9393/`. Add a **project** there — any folder: a repo, a jj checkout, or a folder of scripts. Frizz has no opinion about version control and does not require Git. Each project gets its own board at `/project/<name>`, and from then on running `npx frizz` inside that folder opens its board. Frizz always listens on port 9393 (19393 if something else holds it), and one server serves every project on the machine, so a second run joins the server already running rather than starting another. Runs on macOS, Linux, and Windows.
44
44
 
45
45
  <p align="center">
46
- <img src="assets/board.png" alt="The Frizz board: the project rail down the left, the composer and the queue of threads beside it, and on the right a card where an agent is asking an answerable question with numbered options drawn as keycaps, with its snooze clock and done check in the card's header." width="100%">
46
+ <img src="assets/board.png" alt="The Frizz board: the project rail down the left, the composer and the queue of threads beside it, and on the right a card where an agent is asking an answerable question with lettered options, with its snooze clock and done check in the card's header." width="100%">
47
47
  </p>
48
48
 
49
49
  <br/>
@@ -84,21 +84,23 @@ The queue is strict about what earns a card, which is what keeps it a real todo
84
84
  **Threads are built to run without you.** A worker keeps going until it reaches something only you can settle — a product call, a fork where guessing wrong is expensive to undo, an irreversible action — and then it hands back an answerable *question* rather than a wall of text for you to re-read and interpret.
85
85
 
86
86
  <p align="center">
87
- <img src="assets/question.png" alt="A question card titled Question: 'Should the settings store use SQLite or a JSON file?' with two options numbered 1 and 2 on keycaps, the first tagged RECOMMENDED, and a third row numbered 3 for typing something else, above the prompt box and its Send button." width="100%">
87
+ <img src="assets/question.png" alt="A question card titled Question: 'Should the settings store use SQLite or a JSON file?' with two options lettered A and B, the first tagged RECOMMENDED, and a third row lettered C for typing something else, above the prompt box and its Send button." width="100%">
88
88
  </p>
89
89
 
90
- Options are numbered: press an option's number or click it to pick it, then Enter to send it, along with anything typed in the prompt box below. A worker marks its own recommendation when it has one. There is always a row for writing something else instead.
90
+ Options are lettered: click one to pick it, then Enter to send it, along with anything typed in the prompt box below. A worker marks its own recommendation when it has one. There is always a row for writing something else instead.
91
91
 
92
92
  When the answer isn't one thing, the same card takes several: check any combination and add a note.
93
93
 
94
94
  <p align="center">
95
- <img src="assets/question-multi.png" alt="A question card titled Select multiple: 'Which of these findings should I fix in this pass?' with three numbered checkbox options, the first two ticked, and a fourth numbered row for adding a note, above the prompt box, whose Send button is lit." width="100%">
95
+ <img src="assets/question-multi.png" alt="A question card titled Select multiple: 'Which of these findings should I fix in this pass?' with three lettered checkbox options, the first two ticked, and a fourth lettered row for adding a note, above the prompt box, whose Send button is lit." width="100%">
96
96
  </p>
97
97
 
98
98
  ### GitHub
99
99
 
100
100
  Browse the repo's issues and pull requests from the composer, select any number of them, and each becomes its own thread.
101
101
 
102
+ Sign Frizz in once from Settings → GitHub, with a device code. The sign-in is read-only with one exception GitHub builds in: it can read code, issues, pull requests and checks on the accounts where you install [the Frizz GitHub App](https://github.com/apps/frizz-agent-console), plus public repos, and the only thing it can do beyond reading is comment on a commit. It's what watches pull requests and issues for your workers, overnight included, and it's separate from any `gh` sign-in of your own. It's stored encrypted, with a key unique to your machine: in the keychain on a Mac, and in a separate private file elsewhere. That keeps it from token scanners and copied files, but not from a program running as you that goes after Frizz on purpose.
103
+
102
104
  <p align="center">
103
105
  <img src="assets/github.png" alt="The GitHub picker open over the composer, listing real open issues from colinhacks/zod with numbers, authors, and reaction counts; three are checked and a Start investigations button is enabled." width="100%">
104
106
  </p>
@@ -121,6 +123,12 @@ Give a thread a standing goal. Frizz sends it as a prompt every time the agent c
121
123
  <img src="assets/goal.png" alt="The goal panel open above a queue card's prompt box: a goal saying to keep going until the test suite is green, sent at every rest and every 30 minutes." width="100%">
122
124
  </p>
123
125
 
126
+ ### Launch prefix
127
+
128
+ Start every worker through a command of your own: a secret injector, a token proxy, a sandbox. Set it per project under the gear beside **Claude Code** or **Codex** in the model picker. With `op run --env-file .env --` there, a new Claude worker starts as `op run --env-file .env -- claude …`, so the variables 1Password injects are in the worker's environment and nowhere on disk.
129
+
130
+ The command gets the worker's environment, and must run the command after it with stdin and stdout passed through and stay running until it exits (`exec` does both). Frizz splits the prefix into words like a shell would but never runs it in one, so for variables or pipes, write the shell in: `sh -c 'export ANTHROPIC_BASE_URL="$(my-proxy url)"; exec "$@"' sh`. Claude picks up a change on the next thread you dispatch. Codex's prefix wraps the app-server a project's Codex threads share, and Frizz restarts it once no Codex turn is running. With a prefix set, Frizz no longer refuses to dispatch when it finds no local sign-in, since the prefix may be what supplies the credentials.
131
+
124
132
  <br/>
125
133
 
126
134
  <h2 align="center">CLI</h2>
@@ -142,18 +150,18 @@ Options:
142
150
  --no-app print the URL without opening a browser
143
151
  --port <port> request a fixed port for a new workspace server
144
152
  --sandbox a disposable Frizz to try things in: throwaway home and project, its
145
- own port, deleted when this terminal closes; credentials (gh,
146
- cloudflared, Claude, Codex, the machine's frizz.sh key) are shared
153
+ own port, deleted when this terminal closes; credentials (Frizz's
154
+ GitHub sign-in, cloudflared, Claude, Codex, the machine's frizz.sh
155
+ key) are shared
147
156
  --link print a fresh single-use access link for the running board
148
157
  --debug stream the full event feed to the terminal instead of the compact readout
149
158
  -h, --help show this help
150
159
 
151
160
 
152
161
  To reach the board from a phone or another machine, press R in the terminal running it: a short
153
- walkthrough sets up a private frizz.sh name (no account needed), a Cloudflare
154
- Tunnel, Tailscale, or a proxy of your own, and
155
- remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
156
- shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.
162
+ walkthrough sets up a private frizz.sh subdomain (no account needed), a Cloudflare Tunnel,
163
+ Tailscale, or a proxy of your own, and remembers the choice, so a plain launch serves it from then
164
+ on. The board stays on loopback and shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.
157
165
  ```
158
166
 
159
167
  <br/>
@@ -170,7 +178,7 @@ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link
170
178
  <details>
171
179
  <summary><b>Does anything leave my machine?</b></summary>
172
180
 
173
- > Nothing from Frizz. There's no account, no telemetry, and the server binds to `127.0.0.1`; reaching it from another device is something you switch on yourself (press R in its terminal). The agents themselves talk to their providers, and `gh` talks to GitHub, but Frizz is a local process looking at local files.
181
+ > Nothing from Frizz. There's no account, no telemetry, and the server binds to `127.0.0.1`; reaching it from another device is something you switch on yourself (press R in its terminal). The agents themselves talk to their providers, and Frizz reads GitHub directly with the read-only sign-in you give it, but Frizz is a local process looking at local files.
174
182
 
175
183
  </details>
176
184
 
@@ -205,7 +213,7 @@ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link
205
213
  <details>
206
214
  <summary><b>Can I reach it from another machine?</b></summary>
207
215
 
208
- > Yes. Press **R** in the terminal running Frizz. A short walkthrough sets up one of four ways to reach the board — a name on frizz.sh, a Cloudflare Tunnel you own, Tailscale, or any proxy you run — checks what each needs, prints the commands, and remembers your choice. From then on a plain `npx frizz` serves it; pick **Off** in the same place to go back to loopback only.
216
+ > Yes. Press **R** in the terminal running Frizz. A short walkthrough sets up one of four ways to reach the board — a frizz.sh subdomain, a Cloudflare Tunnel you own, Tailscale, or any proxy you run — checks what each needs, prints the commands, and remembers your choice. From then on a plain `npx frizz` serves it; pick **Off** in the same place to go back to loopback only.
209
217
  >
210
218
  > To try any of this without touching the board you run, launch a second one with `npx frizz --sandbox` — a throwaway home and project on its own port, deleted on ctrl-c.
211
219
  >
@@ -216,7 +224,7 @@ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link
216
224
  <details>
217
225
  <summary><b>Can I reach it from anywhere, not just my LAN?</b></summary>
218
226
 
219
- > Same answer: press **R** and pick a private frizz.sh name (unguessable, no account), a Cloudflare Tunnel, or Tailscale. (Custom frizz.sh names are paused for now; a board that already holds one keeps it.) Each is reachable from anywhere the transport is — a frizz.sh name and a Cloudflare Tunnel from the open internet, Tailscale from your own devices.
227
+ > Same answer: press **R** and pick a private frizz.sh subdomain (unguessable, no account), a Cloudflare Tunnel, or Tailscale. (Custom frizz.sh subdomains are paused for now; a board that already holds one keeps it.) Each is reachable from anywhere the transport is — a frizz.sh subdomain and a Cloudflare Tunnel from the open internet, Tailscale from your own devices.
220
228
  >
221
229
  > Frizz has no accounts, so the single-use sign-in link **is** the door: a phone that scans it gets a session; nobody else gets in. Sessions are per device and can be listed and revoked with `npx frizz --sessions` and `npx frizz --sign-out`. Every project added or removed, thread started, message sent and question answered is recorded in `logs/audit.jsonl` under Frizz's data folder, with the session id of the device that did it and whether it came through that public address.
222
230
 
package/dist/frizz.js CHANGED
@@ -13145,6 +13145,15 @@ var init_interactions = __esm({
13145
13145
  }
13146
13146
  });
13147
13147
 
13148
+ // packages/shared/src/launch-prefix.ts
13149
+ var LAUNCH_PREFIX_MAX_LENGTH;
13150
+ var init_launch_prefix = __esm({
13151
+ "packages/shared/src/launch-prefix.ts"() {
13152
+ "use strict";
13153
+ LAUNCH_PREFIX_MAX_LENGTH = 2048;
13154
+ }
13155
+ });
13156
+
13148
13157
  // packages/shared/src/claim.ts
13149
13158
  function isAnonymousClaimName(name) {
13150
13159
  return ANONYMOUS_CLAIM_PATTERN.test(name);
@@ -13298,6 +13307,17 @@ var init_code_fences = __esm({
13298
13307
  }
13299
13308
  });
13300
13309
 
13310
+ // packages/shared/src/question-fence.ts
13311
+ var import_yaml;
13312
+ var init_question_fence = __esm({
13313
+ "packages/shared/src/question-fence.ts"() {
13314
+ "use strict";
13315
+ import_yaml = __toESM(require_dist(), 1);
13316
+ init_code_fences();
13317
+ init_src();
13318
+ }
13319
+ });
13320
+
13301
13321
  // packages/shared/src/delta.ts
13302
13322
  var init_delta = __esm({
13303
13323
  "packages/shared/src/delta.ts"() {
@@ -13502,16 +13522,18 @@ function signoffNudgeText(needsInput) {
13502
13522
  "the write-up.",
13503
13523
  "",
13504
13524
  "**DECIDE RATHER THAN ASK.** Stop only for a decision that is genuinely the human's AND that blocks you",
13505
- "right now: register that one with `frizzctl ask`. Every other open choice INSIDE the task \u2014 a name, a",
13525
+ "right now: ask that one in a ```question fence. Every other open choice INSIDE the task \u2014 a name, a",
13506
13526
  "default, a reversible design call \u2014 is yours to make: decide it, say in one line which way you went and",
13507
13527
  "what would reverse it, and carry on. A choice that would ENLARGE the task is not one of those: an",
13508
13528
  "unanswered question is not permission to go build the answer.",
13509
13529
  "",
13510
- "Otherwise, sign off \u2014 a registration, or a fence at the END of your next message:",
13530
+ "Otherwise, sign off \u2014 a question in your prose, or a fence at the END of your next message:",
13511
13531
  "",
13512
- "- `frizzctl ask` \u2014 you need the human. NOT a fence: the ```question fence is retired, and a fence",
13513
- " with a question in its body is plain prose. Register it (options with one-line trade-offs, the",
13514
- " recommended one first), then rest normally \u2014 an open registered question is the sign-off.",
13532
+ "- `` ```question `` \u2014 you need the human's decision. Write each fence right under the paragraph it",
13533
+ " decides, never stacked at the bottom: `id:` (a slug of your choosing), `kind:` (`choice`, `multi`,",
13534
+ " `text` or `secret`), a one-line `question:`, and `options:` with one-line trade-offs, the recommended",
13535
+ " one first. Frizz registers it when the message lands, and a question asked at this rest is its",
13536
+ " sign-off: rest normally.",
13515
13537
  "- `` ```done `` \u2014 genuinely FINISHED. A DISMISSAL: the card is filed away and nobody looks again, so",
13516
13538
  " if anything is still owed, it is not done. Body: 1-3 sentences, then bullets, each opening with a",
13517
13539
  " **bolded verb phrase**.",
@@ -13527,14 +13549,14 @@ function signoffNudgeText(needsInput) {
13527
13549
  " every value the act needs written in. Frizz reads them verbatim. Steps name the HUMAN as the wait,",
13528
13550
  " so the fence needs no other name and no `for:`, and the thread goes into their queue. Their Done",
13529
13551
  " comes back to you as their reply; anything else they need to say comes as a message of their own.",
13530
- "- `` ```awaiting `` with `questions:` \u2014 a question you registered at an EARLIER rest is still open and",
13531
- " you still need its answer. Name every such question (`questions: [qst_\u2026]`) and withdraw the rest with",
13532
- " `frizzctl unask <id>`: a fence that leaves an open question out is refused. A named card is drawn at",
13533
- " this rest; an unnamed one stays where you asked it.",
13552
+ "- A question you asked at an EARLIER rest, whose answer you still need: write a ```question fence",
13553
+ " carrying its `id:` alone. That draws its card in this message and keeps the thread in the human's",
13554
+ " queue. One you no longer need, withdraw with `frizzctl unask <id>`; one you leave unmentioned stays",
13555
+ " answerable where you asked it.",
13534
13556
  "",
13535
13557
  "**STILL OWED counts things you are not going to do yourself.** A decision you are RECOMMENDING, a",
13536
13558
  "draft you wrote but did not send, follow-up work you discovered \u2014 all of it dies with the card, even",
13537
- "the part that is someone else's to do. Each ends on a question with your recommendation as option 1,",
13559
+ "the part that is someone else's to do. Each ends on a question with your recommendation as option A,",
13538
13560
  "or you DO it first \u2014 a sub-agent's result comes BACK to you, so it lands on your card; a new card via",
13539
13561
  "`frizzctl spawn-thread` is the LAST resort, since nothing it learns returns to you or its siblings.",
13540
13562
  "None of them is a `done`. And what is not",
@@ -13556,23 +13578,37 @@ function requireEffortOutsideAcp(profile, ctx) {
13556
13578
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["effort"], message: `effort is required for a ${profile.backend} profile` });
13557
13579
  }
13558
13580
  }
13581
+ function formatElapsed(ms) {
13582
+ if (!Number.isFinite(ms) || ms < 0) return "an unknown time";
13583
+ const s = Math.floor(ms / 1e3);
13584
+ if (s < 60) return `${s}s`;
13585
+ const m = Math.floor(s / 60);
13586
+ if (m < 60) return `${m}m`;
13587
+ const h = Math.floor(m / 60);
13588
+ if (h < 24) return m % 60 === 0 ? `${h}h` : `${h}h ${m % 60}m`;
13589
+ const d = Math.floor(h / 24);
13590
+ return h % 24 === 0 ? `${d}d` : `${d}d ${h % 24}h`;
13591
+ }
13559
13592
  function fallbackPort(base) {
13560
13593
  return base + 1e4;
13561
13594
  }
13562
- var import_yaml, ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_TEXT_EXTENSIONS, ATTACHMENT_OFFICE_EXTENSIONS, ATTACHMENT_DATA_EXTENSIONS, ATTACHMENT_ARCHIVE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ClaudeModel, AcpAgent, AcpAgentModel, AcpAgentModels, AcpAgentModelsInput, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, AwaitingHint, RETIRED_AWAITING_KINDS, RETIRED_LINE_RE, AWAITING_FOR_MAX_MS, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, RecurringIntervalSeconds, ThreadRecurringPrompt, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, SIGNOFF_NUDGE_MESSAGE_NEEDS_INPUT, ThreadWatchKind, GithubChecksState, GithubMergeState, GithubWatchStatus, GithubIssueStatus, ThreadWatchView, PR_WATCH_FOR_MAX_MS, PR_WATCH_DEFAULT_FOR_MS, PrWatchView, AddOwnPrWatchInput, AddOwnPrWatchResult, DropOwnPrWatchInput, DropOwnPrWatchResult, OwnWatchKind, AddOwnWatchInput, OwnWatchView, AddOwnWatchResult, DropOwnWatchInput, DropOwnWatchResult, COMMAND_WATCH_EVERY_MAX_MS, COMMAND_WATCH_FOR_MAX_MS, COMMAND_WATCH_COMMAND_MAX, COMMAND_WATCH_RUN_TIMEOUT_MS, CommandWatchRun, CommandWatchState, CommandWatchView, AddOwnCommandWatchInput, AddOwnCommandWatchResult, DropOwnCommandWatchInput, DropOwnCommandWatchResult, ListOwnCommandWatchesInput, OwnCommandWatchesResult, AskQuestionKind, AskedOptionSchema, AskedQuestionSchema, AskInput, RegisteredQuestionView, SECRET_TTL_MS, AskResult, UnaskInput, UnaskResult, QuestionAnswerSchema, AnswerQuestionsInput, AnswerQuestionsResult, DismissQuestionsInput, DismissQuestionsResult, SettledQuestionView, ThreadSettledQuestionsResult, MarkOwnDoneInput, MarkOwnDoneResult, ListOwnPrWatchesInput, OwnPrWatchesResult, ThreadTimerView, ThreadFence, LimitWindow, LimitPause, ProviderError, ThreadLinkView, UpsertOwnLinkInput, UpsertOwnLinkResult, DropOwnLinkInput, DropOwnLinkResult, LiveTool, ThreadView, BoardErrorItem, BoardSnapshot, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountBackend, AccountLogoutInput, AccountLogoutResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshotShape, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, AdoptSessionInput, FollowUpInput, SteerThreadInput, SteerThreadResult, DismissFailedFollowUpInput, DismissFailedFollowUpResult, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, CompactThreadInput, SetThreadSnoozeInput, SetThreadPinnedInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, ThreadActivityItem, ListOwnThreadActivityInput, OwnThreadActivityResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetOwnThreadTitleInput, SetOwnThreadTitleResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, ThreadSkillSource, ThreadSkill, ThreadSkillsInput, ThreadSkillsResult, SetThreadProfileInput, SetThreadProfileResult, UpgradeThreadModelInput, UpgradeThreadModelResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, HUMAN_GAP_FLOOR_MS, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, PR_WATCH_FINISHED, PR_WATCH_CI_PASSED, PR_WATCH_CI_GATED, PR_WATCH_STATE, PR_WATCH_CI_FAILED, GithubStatus, GithubRefCard, GithubRefPreviewInput, GithubRefPreviewResult, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, ServerEvent, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, EditedFile, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketFilePath, SocketTranscriptClientMsg, SocketFileClientMsg, SocketClientMsg, ProjectCard, ProjectRailCounts, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT;
13595
+ var import_yaml2, ATTACHMENT_IMAGE_EXTENSIONS, ATTACHMENT_TEXT_EXTENSIONS, ATTACHMENT_OFFICE_EXTENSIONS, ATTACHMENT_DATA_EXTENSIONS, ATTACHMENT_ARCHIVE_EXTENSIONS, ATTACHMENT_DOC_EXTENSIONS, ATTACHMENT_EXTENSIONS, ATTACHMENT_MAX_BASE64_CHARS, ATTACHMENT_MAX_BYTES, ATTACHMENT_EXT_SET, ATTACHMENT_ACCEPT, FrizzStatus, BlockMechanism, RuntimeState, Backend, CodexModel, ClaudeModel, AcpAgent, AcpAgentModel, AcpAgentModels, AcpAgentModelsInput, ThreadProfileOption, ThreadAgent, SubAgentView, BgShellView, CompletionHoldOp, CompletionHold, AskOption, AskQuestion, PendingAsk, AwaitingHint, RETIRED_AWAITING_KINDS, RETIRED_LINE_RE, AWAITING_FOR_MAX_MS, SnoozeUntil, SNOOZE_PROMPT_MAX, SnoozePrompt, RECURRING_PROMPT_MAX, RECURRING_MIN_INTERVAL_SECONDS, RECURRING_MAX_INTERVAL_SECONDS, RecurringPromptText, RecurringIntervalSeconds, ThreadRecurringPrompt, TIMER_PROMPT_MAX, TIMER_MAX_DELAY_SECONDS, TimerPromptText, SIGNOFF_NUDGE_MARKER, SIGNOFF_NUDGE_MESSAGE, SIGNOFF_NUDGE_MESSAGE_NEEDS_INPUT, ThreadWatchKind, GithubChecksState, GithubMergeState, GithubWatchStatus, GithubIssueStatus, CommandWatchRun, MonitorStatus, ThreadWatchView, PR_WATCH_FOR_MAX_MS, PR_WATCH_DEFAULT_FOR_MS, PrWatchView, AddOwnPrWatchInput, AddOwnPrWatchResult, DropOwnPrWatchInput, DropOwnPrWatchResult, OwnWatchKind, AddOwnWatchInput, OwnWatchView, AddOwnWatchResult, DropOwnWatchInput, DropOwnWatchResult, COMMAND_WATCH_EVERY_MAX_MS, COMMAND_WATCH_FOR_MAX_MS, COMMAND_WATCH_COMMAND_MAX, COMMAND_WATCH_RUN_TIMEOUT_MS, CommandWatchState, CommandWatchView, AddOwnCommandWatchInput, AddOwnCommandWatchResult, DropOwnCommandWatchInput, DropOwnCommandWatchResult, ListOwnCommandWatchesInput, OwnCommandWatchesResult, MONITOR_FLOOD_MAX_EVENTS, MONITOR_FLOOD_WINDOW_MS, MONITOR_DESCRIPTION_MAX, AddOwnMonitorInput, AddOwnMonitorResult, MONITOR_FLOOD_REASON, AskQuestionKind, AskedOptionSchema, AskedQuestionSchema, AskInput, RegisteredQuestionView, QuestionFenceFaultView, ClosedQuestionView, SECRET_TTL_MS, AskResult, UnaskInput, UnaskResult, QuestionAnswerSchema, AnswerQuestionsInput, AnswerQuestionsResult, DismissQuestionsInput, DismissQuestionsResult, SettledQuestionView, ThreadSettledQuestionsResult, ANSWER_ROW_ID_PATTERN, ANSWER_ROW_ID, MarkOwnDoneInput, MarkOwnDoneResult, ListOwnPrWatchesInput, OwnPrWatchesResult, ThreadTimerView, ThreadFence, LimitWindow, LimitPause, ProviderError, ThreadLinkView, UpsertOwnLinkInput, UpsertOwnLinkResult, DropOwnLinkInput, DropOwnLinkResult, LiveTool, ThreadView, BoardErrorItem, BoardSnapshot, BoardState, QuotaWindow, ProviderQuota, QuotaSnapshot, ProviderAuth, AccountEmails, AuthSnapshot, AccountBackend, AccountLogoutInput, AccountLogoutResult, PermissionMode, LocalFileOpener, Settings, DispatchProviderPreferences, DispatchPreferences, DispatchProfileSnapshotShape, DispatchProfileSnapshot, SetDispatchPreferenceInput, DispatchInput, ADOPT_THREAD_MESSAGE_MAX_CHARS, AdoptThreadInput, AdoptThreadResult, AdoptSessionInput, FollowUpInput, SteerThreadInput, SteerThreadResult, SpawnThreadInput, SpawnThreadResult, SpawnChildInput, SpawnChildResult, DismissFailedFollowUpInput, DismissFailedFollowUpResult, UnqueueFollowUpInput, UnqueueFollowUpResult, DeliverQueuedNowInput, DeliverQueuedNowResult, CompactThreadInput, SetThreadSnoozeInput, SetThreadPinnedInput, ReorderPinnedThreadsInput, SetThreadConsolePinnedInput, ReorderConsolePinnedThreadsInput, SetThreadRecurringPromptInput, SetOwnThreadRecurringPromptInput, SetOwnThreadRecurringPromptResult, GetOwnThreadRecurringPromptInput, OwnThreadRecurringPromptResult, SetOwnThreadTimerInput, CancelOwnThreadTimerInput, ListOwnThreadTimersInput, OwnThreadTimersResult, ThreadActivityItem, ListOwnThreadActivityInput, OwnThreadActivityResult, ThreadBand, ThreadListing, ListThreadsInput, ListThreadsResult, SetOwnThreadTimerResult, CancelOwnThreadTimerResult, SetOwnThreadStopHookInput, SetOwnThreadHeartbeatInput, RenameThreadInput, AiRenameThreadInput, AiRenameThreadResult, SetOwnThreadTitleInput, SetOwnThreadTitleResult, SetThreadPermissionInput, SetThreadPermissionResult, ThreadProfileOptionsInput, ThreadProfileOptionsResult, ThreadSkillSource, ThreadSkill, ThreadSkillsInput, ThreadSkillsResult, McpTarget, McpServerState, McpServerReading, McpStatusResult, McpSignInInput, McpSignInState, McpSignInResult, McpSignInAttemptInput, McpSignInPollResult, McpSignInFinishInput, SetThreadProfileInput, SetThreadProfileResult, UpgradeThreadModelInput, UpgradeThreadModelResult, DISPATCH_TASK_BANNER, DISPATCH_TASK_BANNER_MARKER, HUMAN_GAP_FLOOR_MS, GithubWakeItem, GithubWakeSteer, WAKE_SCOPE, WAKE_REF, WAKE_SINGLE, WAKE_MULTI_HEAD, PR_WATCH_FINISHED, PR_WATCH_CI_PASSED, PR_WATCH_CI_GATED, PR_WATCH_STATE, PR_WATCH_CI_FAILED, GithubStatus, GithubAccount, GithubRefCard, GithubRefPreviewInput, GithubRefPreviewResult, GithubItem, GithubListInput, GithubListResult, GithubBatchInput, GithubBatchResult, BoardMeta, BoardDeltaEvent, ServerEvent, BoardDeltasInput, BoardDeltasResult, TranscriptEdit, TranscriptTodo, TranscriptToolCall, TranscriptPart, TranscriptMessage, TranscriptPageCursor, EditedFile, TranscriptPage, TranscriptEarlierInput, SocketTranscriptSlug, SocketFilePath, SocketTranscriptClientMsg, SocketFileClientMsg, SocketBoardClientMsg, SocketClientMsg, ProjectCard, ProjectRailCounts, PROJECT_ICON_MAX_BASE64_CHARS, DirectoryPickResult, ThreadLocation, FRIZZ_ROUTE_PREFIX, DEFAULT_PORT;
13563
13596
  var init_src = __esm({
13564
13597
  "packages/shared/src/index.ts"() {
13565
13598
  "use strict";
13566
- import_yaml = __toESM(require_dist(), 1);
13599
+ import_yaml2 = __toESM(require_dist(), 1);
13567
13600
  init_zod();
13568
13601
  init_interactions();
13602
+ init_launch_prefix();
13569
13603
  init_thread_slug();
13570
13604
  init_claim();
13571
13605
  init_claude_editions();
13572
13606
  init_code_fences();
13607
+ init_question_fence();
13573
13608
  init_delta();
13574
13609
  init_drainable_worker();
13575
13610
  init_interactions();
13611
+ init_launch_prefix();
13576
13612
  init_receipt_bus();
13577
13613
  init_relay_protocol();
13578
13614
  init_shell_writes();
@@ -13897,7 +13933,7 @@ var init_src = __esm({
13897
13933
  questions: external_exports.array(AskQuestion)
13898
13934
  });
13899
13935
  AwaitingHint = external_exports.object({
13900
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command", "for", "title", "status", "needs_input", "step", "question"]),
13936
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command", "thread", "for", "title", "status", "needs_input", "step", "question"]),
13901
13937
  value: external_exports.string()
13902
13938
  });
13903
13939
  RETIRED_AWAITING_KINDS = ["watch", "pr-watch", "human", "ci", "session", "shell", "agent", "timer", "pr", "reason"];
@@ -13938,7 +13974,7 @@ var init_src = __esm({
13938
13974
  SIGNOFF_NUDGE_MARKER = "**This message is from frizz, not from the human.**";
13939
13975
  SIGNOFF_NUDGE_MESSAGE = signoffNudgeText(false);
13940
13976
  SIGNOFF_NUDGE_MESSAGE_NEEDS_INPUT = signoffNudgeText(true);
13941
- ThreadWatchKind = external_exports.enum(["shell", "github", "timer"]);
13977
+ ThreadWatchKind = external_exports.enum(["shell", "github", "timer", "command"]);
13942
13978
  GithubChecksState = external_exports.enum(["none", "running", "passing", "failing"]);
13943
13979
  GithubMergeState = external_exports.enum(["mergeable", "blocked", "conflicting", "unknown"]);
13944
13980
  GithubWatchStatus = external_exports.object({
@@ -13976,7 +14012,16 @@ var init_src = __esm({
13976
14012
  /** A digest of WHICH jobs are failing right now. The head commit alone cannot express a re-run of the
13977
14013
  * same job, or a slower job going red after the first — both of which are a second failure the worker
13978
14014
  * must hear about. Empty when nothing is failing. */
13979
- failureSig: external_exports.string().optional()
14015
+ failureSig: external_exports.string().optional(),
14016
+ /** WHO THE PR IS WAITING ON, the facts behind "will anything move here unless we act?"
14017
+ * (awaiting.prWaitsOnNoOne). Whether GitHub will merge it by itself (`queued` in a merge queue, `auto`
14018
+ * with auto-merge on, `none`), whether the signed-in `gh` account opened it and may merge it, and
14019
+ * whether a review is still requested of someone. Absent means unknown: the `gh` fallback and a
14020
+ * reading taken before 2026-10-27 carry none, and an unknown never counts as "nobody". */
14021
+ mergePath: external_exports.enum(["none", "queued", "auto"]).optional(),
14022
+ viewerAuthored: external_exports.boolean().optional(),
14023
+ viewerCanMerge: external_exports.boolean().optional(),
14024
+ reviewRequested: external_exports.boolean().optional()
13980
14025
  }).strict();
13981
14026
  GithubIssueStatus = external_exports.object({
13982
14027
  /** OPEN | CLOSED, lowercased. A closed issue ends the wait outright. */
@@ -13992,6 +14037,20 @@ var init_src = __esm({
13992
14037
  /** When frizz last heard from GitHub, for the same reason `GithubWatchStatus.polledAt` carries it. */
13993
14038
  polledAt: external_exports.string()
13994
14039
  }).strict();
14040
+ CommandWatchRun = external_exports.object({
14041
+ at: external_exports.string(),
14042
+ outcome: external_exports.enum(["exit", "timeout", "signal", "error"]),
14043
+ exitCode: external_exports.number().int().nullable(),
14044
+ output: external_exports.string()
14045
+ }).strict();
14046
+ MonitorStatus = external_exports.object({
14047
+ /** The worker's own words for what it watches ("errors in deploy.log"), when it gave any. */
14048
+ description: external_exports.string().optional(),
14049
+ /** Events printed so far: each batch of lines that woke (or will wake) the thread is one. */
14050
+ events: external_exports.number().int().nonnegative(),
14051
+ /** The newest event's text, trimmed, and when it was printed. */
14052
+ lastEvent: external_exports.object({ at: external_exports.string(), text: external_exports.string() }).strict().optional()
14053
+ }).strict();
13995
14054
  ThreadWatchView = external_exports.object({
13996
14055
  id: external_exports.string(),
13997
14056
  kind: ThreadWatchKind,
@@ -14013,7 +14072,22 @@ var init_src = __esm({
14013
14072
  * worker's prompt is the row's NAME (a `tmr_…` id names nothing to a human) and the fire instant is
14014
14073
  * its status. Unlike `github` there is no polled half to be absent: a timer that exists is fully
14015
14074
  * known, so a timer row always carries this. */
14016
- timer: external_exports.object({ fireAt: external_exports.string(), prompt: external_exports.string() }).strict().optional()
14075
+ timer: external_exports.object({ fireAt: external_exports.string(), prompt: external_exports.string() }).strict().optional(),
14076
+ /** `command` rows only: the armed command watch's registration and its last check — what the worker's
14077
+ * own read-back carries (CommandWatchView), minus the id and state the row already has. The command is
14078
+ * the row's NAME and the cadence its status. Until 2026-10-13 a command watch had no row at all, so a
14079
+ * thread parked on nothing else drew a card that said only "Waiting for an external update." */
14080
+ command: external_exports.object({
14081
+ command: external_exports.string(),
14082
+ /** The cadence in the house duration grammar (`2m`, `1h 30m`). */
14083
+ every: external_exports.string(),
14084
+ expiresAt: external_exports.string(),
14085
+ runs: external_exports.number().int().nonnegative(),
14086
+ lastRun: CommandWatchRun.optional(),
14087
+ /** A MONITOR's own reading (`frizzctl monitor`), and present on nothing else: the row is a running
14088
+ * script, so its status is how many events it has printed, not a cadence. */
14089
+ monitor: MonitorStatus.optional()
14090
+ }).strict().optional()
14017
14091
  }).strict();
14018
14092
  PR_WATCH_FOR_MAX_MS = 365 * 24 * 60 * 60 * 1e3;
14019
14093
  PR_WATCH_DEFAULT_FOR_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -14147,12 +14221,6 @@ var init_src = __esm({
14147
14221
  COMMAND_WATCH_FOR_MAX_MS = 7 * 24 * 60 * 60 * 1e3;
14148
14222
  COMMAND_WATCH_COMMAND_MAX = 4e3;
14149
14223
  COMMAND_WATCH_RUN_TIMEOUT_MS = 2 * 6e4;
14150
- CommandWatchRun = external_exports.object({
14151
- at: external_exports.string(),
14152
- outcome: external_exports.enum(["exit", "timeout", "signal", "error"]),
14153
- exitCode: external_exports.number().int().nullable(),
14154
- output: external_exports.string()
14155
- }).strict();
14156
14224
  CommandWatchState = external_exports.enum(["armed", "dropped", "passed", "expired", "cancelled"]);
14157
14225
  CommandWatchView = external_exports.object({
14158
14226
  id: external_exports.string(),
@@ -14164,7 +14232,11 @@ var init_src = __esm({
14164
14232
  expiresAt: external_exports.string(),
14165
14233
  /** How many checks have run since it was armed, the registration's own first check included. */
14166
14234
  runs: external_exports.number().int().nonnegative(),
14167
- lastRun: CommandWatchRun.optional()
14235
+ lastRun: CommandWatchRun.optional(),
14236
+ /** Present on a MONITOR (`frizzctl monitor`) and nothing else: a script the server keeps RUNNING, whose
14237
+ * every printed line is a wake. A monitor has no cadence and no checks, so `every` reads `0s` and
14238
+ * `runs` 0 on one; read this instead. */
14239
+ monitor: MonitorStatus.optional()
14168
14240
  }).strict();
14169
14241
  AddOwnCommandWatchInput = external_exports.object({
14170
14242
  slug: ThreadSlug,
@@ -14205,6 +14277,38 @@ var init_src = __esm({
14205
14277
  }).strict();
14206
14278
  ListOwnCommandWatchesInput = external_exports.object({ slug: ThreadSlug }).strict();
14207
14279
  OwnCommandWatchesResult = external_exports.object({ watches: external_exports.array(CommandWatchView) }).strict();
14280
+ MONITOR_FLOOD_MAX_EVENTS = 30;
14281
+ MONITOR_FLOOD_WINDOW_MS = 10 * 6e4;
14282
+ MONITOR_DESCRIPTION_MAX = 120;
14283
+ AddOwnMonitorInput = external_exports.object({
14284
+ slug: ThreadSlug,
14285
+ /** Run with `/bin/sh -c` in the project root, with the server's environment. Each stdout line is an event. */
14286
+ command: external_exports.string().trim().min(1).max(COMMAND_WATCH_COMMAND_MAX),
14287
+ /** What it watches, in the worker's words — the name on its row and drawer, and the label on every wake.
14288
+ * Required (2026-11-03), as a sub-agent's description is: without one the row could only show the script. */
14289
+ description: external_exports.string().trim().min(1).max(MONITOR_DESCRIPTION_MAX),
14290
+ /** How long to keep it running, as a DURATION. Clamped to COMMAND_WATCH_FOR_MAX_MS and reported. */
14291
+ for: external_exports.string().trim().min(1).max(16)
14292
+ }).strict();
14293
+ AddOwnMonitorResult = external_exports.object({
14294
+ /** Present when the call was REFUSED: the gate, the caps, or a script that could not run here. Nothing
14295
+ * is armed, and the text says why and what to use instead. */
14296
+ refused: external_exports.string().optional(),
14297
+ id: external_exports.string().optional(),
14298
+ /** This exact command already runs as a monitor on this thread; nothing new was registered. */
14299
+ alreadyArmed: external_exports.boolean().default(false),
14300
+ /** Still running when registration stopped watching it (MONITOR_REGISTRATION_WINDOW_MS). */
14301
+ running: external_exports.boolean().default(false),
14302
+ /** The events it printed while registration watched — handed back here rather than woken. */
14303
+ events: external_exports.array(external_exports.string()).default([]),
14304
+ /** It EXITED while registration watched: how, and the end of what it printed. Nothing stays armed. */
14305
+ exited: CommandWatchRun.optional(),
14306
+ expiresAt: external_exports.string().optional(),
14307
+ /** The `for` the worker wrote, present ONLY when it was clamped. */
14308
+ clamped: external_exports.object({ for: external_exports.string() }).strict().optional(),
14309
+ watches: external_exports.array(CommandWatchView)
14310
+ }).strict();
14311
+ MONITOR_FLOOD_REASON = `it printed ${MONITOR_FLOOD_MAX_EVENTS} events inside ${formatElapsed(MONITOR_FLOOD_WINDOW_MS)}, and every event is a turn of yours. Register it again with a tighter filter: print only the lines you would act on (\`grep --line-buffered\`), never raw logs.`;
14208
14312
  AskQuestionKind = external_exports.enum(["question", "multi"]);
14209
14313
  AskedOptionSchema = external_exports.lazy(() => external_exports.object({
14210
14314
  // 400, not the 120 it launched with (2026-09-03): a label is a chip line, but "Post the drafted
@@ -14223,15 +14327,17 @@ var init_src = __esm({
14223
14327
  // 4000, not the 600 it launched with (2026-09-03): this field is the whole card context — everything
14224
14328
  // the human needs to answer cold — and a question that carries its own evidence needs the room. The
14225
14329
  // answer restates it, so QuestionAnswerSchema's cap matches.
14330
+ handle: external_exports.string().trim().min(1).max(64).optional(),
14226
14331
  question: external_exports.string().trim().min(1).max(4e3),
14332
+ context: external_exports.string().trim().max(2e4).optional(),
14227
14333
  header: external_exports.string().trim().max(24).optional(),
14228
14334
  kind: AskQuestionKind,
14229
14335
  danger: external_exports.boolean().optional(),
14230
14336
  secret: external_exports.boolean().optional(),
14231
14337
  // UNBOUNDED, deliberately. This carried `.max(8)` from launch until 2026-09-03, when the maintainer
14232
14338
  // asked for the cap to go ("allow arbitrary numbers of options"): a `multi` over a long list — which
14233
- // gates to run, which of twenty findings to act on — is a real shape, and the card numbers past 9
14234
- // (only 1–9 are keys) already. The count is the worker's to choose; the answer's `chosen` is unbounded to match.
14339
+ // gates to run, which of twenty findings to act on — is a real shape, and the card letters past 26
14340
+ // (`AA.`) already. The count is the worker's to choose; the answer's `chosen` is unbounded to match.
14235
14341
  options: external_exports.array(AskedOptionSchema).optional()
14236
14342
  }).strict());
14237
14343
  AskInput = external_exports.object({
@@ -14244,11 +14350,32 @@ var init_src = __esm({
14244
14350
  /** Minted by frizz. The worker never chose it, which is why an answer RESTATES the question text —
14245
14351
  * an id alone cannot be correlated back to what was asked. */
14246
14352
  id: external_exports.string(),
14353
+ /** The worker's own id for it, from its ```question fence — what `unask` and every correction name it
14354
+ * by. Absent on a question `ask` registered, which only ever had the minted id. */
14355
+ handle: external_exports.string().optional(),
14247
14356
  spec: AskedQuestionSchema,
14248
14357
  askedAt: external_exports.string(),
14249
14358
  /** A `secret` question only: the path its value will be served at. Known at registration, so the
14250
14359
  * worker can prepare the command that reads it before the human has answered. */
14251
- secretPath: external_exports.string().optional()
14360
+ secretPath: external_exports.string().optional(),
14361
+ /** ON THE BOARD ONLY: does this question STAND at the thread's current rest — asked at it, or
14362
+ * referenced by it (server question-fences.ts standingQuestions)? `false` is a question that has
14363
+ * FADED: still open and answerable where it was asked, but not what this rest is waiting on, so it
14364
+ * neither queues the thread nor marks its row. Absent — the worker's own read-back, a snapshot from
14365
+ * before 2026-10-13 — reads as standing (hasStandingQuestion). */
14366
+ standing: external_exports.boolean().optional()
14367
+ }).strict();
14368
+ QuestionFenceFaultView = external_exports.object({
14369
+ at: external_exports.string(),
14370
+ id: external_exports.string().optional(),
14371
+ faults: external_exports.array(external_exports.string())
14372
+ }).strict();
14373
+ ClosedQuestionView = external_exports.object({
14374
+ id: external_exports.string(),
14375
+ /** The worker's own id for it — what its fence in the transcript carries. */
14376
+ handle: external_exports.string(),
14377
+ state: external_exports.enum(["dismissed", "withdrawn"]),
14378
+ askedAt: external_exports.string()
14252
14379
  }).strict();
14253
14380
  SECRET_TTL_MS = 15 * 6e4;
14254
14381
  AskResult = external_exports.object({
@@ -14262,6 +14389,14 @@ var init_src = __esm({
14262
14389
  }).strict();
14263
14390
  UnaskResult = external_exports.object({
14264
14391
  withdrawn: external_exports.boolean(),
14392
+ /** The question the id resolved to, by the name the WORKER knows it by (its fence's `id:`, or the
14393
+ * `qst_…` an `ask` minted) — absent when the id names nothing on this thread. It is the ROOT's: a
14394
+ * follow-up rides inside its parent's row and cannot be withdrawn alone, so `unask sqlite-path`
14395
+ * withdraws nothing and answers `settings-store` here — the id that takes the pair back. */
14396
+ id: external_exports.string().optional(),
14397
+ /** What that question was when the call arrived. `open` beside `withdrawn: true` is the ordinary
14398
+ * withdrawal; anything else is why nothing was withdrawn — and `answered` means a wake is on its way. */
14399
+ state: external_exports.enum(["open", "answered", "withdrawn", "dismissed"]).optional(),
14265
14400
  open: external_exports.array(RegisteredQuestionView)
14266
14401
  }).strict();
14267
14402
  QuestionAnswerSchema = external_exports.lazy(() => external_exports.object({
@@ -14305,6 +14440,8 @@ var init_src = __esm({
14305
14440
  ThreadSettledQuestionsResult = external_exports.object({
14306
14441
  questions: external_exports.array(SettledQuestionView)
14307
14442
  }).strict();
14443
+ ANSWER_ROW_ID_PATTERN = "[a-z0-9][a-z0-9._-]{0,63}";
14444
+ ANSWER_ROW_ID = new RegExp(`^${ANSWER_ROW_ID_PATTERN}$`);
14308
14445
  MarkOwnDoneInput = external_exports.object({
14309
14446
  slug: ThreadSlug,
14310
14447
  body: external_exports.string().trim().min(1).max(2e4)
@@ -14475,6 +14612,20 @@ var init_src = __esm({
14475
14612
  // outlive the message that carried it. These rows can, and they carry the question itself rather than
14476
14613
  // merely asserting one exists — the card renders from this instead of re-parsing prose.
14477
14614
  questions: external_exports.array(RegisteredQuestionView).default([]),
14615
+ /** The ```question fences of this thread that could NOT register, from its latest sync — every one in
14616
+ * the server's ring of fenced messages, not only the current rest's. Present only when there is one.
14617
+ * A refused fence has no row, and so does a fence the registrar has not reached yet; this is what
14618
+ * tells them apart, so the transcript draws the first as the code block it is and only the second as
14619
+ * the question it is about to become (web lib/questionFaults). Its other job is the reused id: a fence
14620
+ * refused for naming a spent id must not move that id's card, which a name match alone would do. */
14621
+ questionFaults: external_exports.array(QuestionFenceFaultView).optional(),
14622
+ /** The questions this thread asked with a ```question fence that CLOSED WITHOUT AN ANSWER — dismissed
14623
+ * (the human's ×, or Frizz cancelling a question an autonomous thread wrote) or withdrawn by the
14624
+ * worker. Present only when there is one. Their fences are still in the transcript, and without this a
14625
+ * fence with no open and no answered row reads as a question about to become answerable; with it the
14626
+ * fence draws its question closed, saying which way (web ClosedQuestionCard). Compact on purpose — the
14627
+ * fence itself carries the question — because the board ships every thread on every keyframe. */
14628
+ closedQuestions: external_exports.array(ClosedQuestionView).optional(),
14478
14629
  /** The answer the human has already SENT that the worker has not received yet, as the exact message
14479
14630
  * the delivery will carry (board.answersInFlight → questionAnswerMessage). The chat parses it with
14480
14631
  * the same reader it uses on the delivered turn, so the in-flight card and the landed one are the
@@ -14514,6 +14665,16 @@ var init_src = __esm({
14514
14665
  foreign: external_exports.boolean().optional(),
14515
14666
  // ui.db lifecycle for session threads (open|archived) — written ONLY by explicit Archive/Reopen.
14516
14667
  state: external_exports.enum(["open", "archived"]).optional(),
14668
+ // The instant the thread was marked done (session.archived_at), present while it is archived. The
14669
+ // Done band's order: the one marked done most recently is at its top (maintainer 2026-10-23). Absent
14670
+ // on a thread archived before the column existed, which the band places by its rest time instead.
14671
+ archivedAt: external_exports.string().optional(),
14672
+ // The instant this thread last ENTERED the Running band (session.running_since), present while it is
14673
+ // in it: the board stamps it the first build that finds the thread there and clears it the first
14674
+ // build that does not. The Running band's order: the thread that most recently switched back to
14675
+ // running is at its top (maintainer 2026-10-23). A thread that stays in the band keeps its instant, so
14676
+ // a working rest, a sub-agent returning or a mid-turn message never moves it.
14677
+ runningSince: external_exports.string().optional(),
14517
14678
  // Exact durable user snooze. While this instant is in the future, an otherwise-resting thread is
14518
14679
  // suppressed from Queue and shown dimmed in Held. Hard interactive gates (question, permission,
14519
14680
  // native approval, crash) deliberately break through it. Expired values are cleared server-side.
@@ -14527,6 +14688,23 @@ var init_src = __esm({
14527
14688
  // this instant's order. Lifecycle metadata like the snooze: written only by the pin/unpin verb, and
14528
14689
  // it outranks every derived state (a pinned thread that finishes stays pinned).
14529
14690
  pinnedAt: external_exports.string().optional(),
14691
+ // The instant the human pinned this thread on the CONSOLE's "By status" view (absent = not pinned
14692
+ // there). A second shelf, independent of `pinnedAt`: pinning on the Console does not pin on the
14693
+ // project's board, nor the other way round. The instant is the Console's pinned band's order across
14694
+ // every project, and like the project pin it outranks every derived state.
14695
+ consolePinnedAt: external_exports.string().optional(),
14696
+ /** CHILD THREADS (Settings.childThreads): the thread that spawned this one with `frizzctl spawn-child`.
14697
+ * Present only while that thread still exists. A child is never a row of its own on the board — not in
14698
+ * any band, the queue, a count or a badge — and is drawn under its parent instead, unless it stands at a
14699
+ * gate only the human can settle (drawnUnderParent). */
14700
+ parentSlug: external_exports.string().optional(),
14701
+ /** This child's rest is one that would have put it in the human's queue, so its handoff goes to its
14702
+ * PARENT instead (board.ts deriveChildHandoff; the scheduler delivers it once per rest). A permission
14703
+ * prompt, a secret request and a usage-limit stop are not handoffs: they still queue for the human. */
14704
+ handoffToParent: external_exports.literal(true).optional(),
14705
+ /** The instant of the rest `handoffToParent` describes (the tailer's lastAssistantAt), so the scheduler
14706
+ * can tell a verdict about the rest it sees from one a cached board still carries about an older rest. */
14707
+ handoffRestAt: external_exports.string().optional(),
14530
14708
  /** The EVENT snooze on the resting card is armed for this exact rest — the human has said "hide this
14531
14709
  * until something reports". Distinct from `snoozedUntil`, which is a wall-clock park on the whole
14532
14710
  * thread: this one has no deadline and clears itself when the thread comes to a NEW rest.
@@ -14535,8 +14713,20 @@ var init_src = __esm({
14535
14713
  * the reasoning that the card states a FACT the drawer must keep showing or it blanks at rest and
14536
14714
  * reads as "the agent died". That reasoning holds for a thread nobody has parked; once the human has
14537
14715
  * explicitly parked THIS rest, showing them the same card with the same button one surface over is
14538
- * not information, and they said so. */
14716
+ * not information, and they said so.
14717
+ *
14718
+ * The header's "Until activity" (SnoozeMenu, 2026-10-19) arms the same snooze on any rested thread. */
14539
14719
  bgSnoozed: external_exports.boolean().optional(),
14720
+ /** Something of the thread's own can wake it without a person — a running sub-agent or shell, an
14721
+ * armed watch or timer, a Goal that fires on rest or on a clock (board.wakesOnItsOwn). The snooze
14722
+ * menu greys out "Until activity" without it, and the server stops honouring that snooze once it
14723
+ * goes false. Absent on a dead worker and on an archived thread. */
14724
+ wakesOnItsOwn: external_exports.boolean().optional(),
14725
+ /** A QUIET PARK THE BOARD REFUSED because an earlier `needs_input` handoff still stands: the human has
14726
+ * neither typed to the thread nor snoozed a rest since it (board handoffStands). The surfaces keep that
14727
+ * handoff's fence drawn (web lib/fenceBlocks heldHandoffIndex), since it is what the thread is in the
14728
+ * queue for. Set only while it is what queued the thread. */
14729
+ heldHandoff: external_exports.boolean().optional(),
14540
14730
  // Which Claude transport serves this thread. "broker" — a session-broker-owned Agent SDK session with
14541
14731
  // a typed control channel — is the only one there is; ABSENT means a row dispatched before the broker
14542
14732
  // became the sole transport, which frizz can no longer reach that way. Only the broker can be asked to
@@ -14690,6 +14880,11 @@ var init_src = __esm({
14690
14880
  // falls back to rendering the plain `errors` strings.
14691
14881
  errorItems: external_exports.array(BoardErrorItem).optional()
14692
14882
  });
14883
+ BoardState = external_exports.object({
14884
+ board: BoardSnapshot,
14885
+ seq: external_exports.number().int().min(0),
14886
+ bootId: external_exports.string()
14887
+ });
14693
14888
  QuotaWindow = external_exports.object({
14694
14889
  key: external_exports.string(),
14695
14890
  // stable id: "5h" | "weekly" (provider-neutral)
@@ -14705,8 +14900,20 @@ var init_src = __esm({
14705
14900
  planType: external_exports.string().optional(),
14706
14901
  // "pro" / "max" / etc. when the source reports it
14707
14902
  windows: external_exports.array(QuotaWindow),
14708
- detail: external_exports.string().optional()
14903
+ detail: external_exports.string().optional(),
14709
14904
  // why unavailable, or an extra note
14905
+ // Recovery evidence from a LIVE Codex read only. Rollout quota has no account identity or freshness
14906
+ // guarantee and must never wake a failed thread. accountKey is a hash, not a credential/account id.
14907
+ codexRecovery: external_exports.object({
14908
+ observedAt: external_exports.number(),
14909
+ // request start, unix milliseconds (not the later cache/read time)
14910
+ accountKey: external_exports.string().optional(),
14911
+ creditsAvailable: external_exports.boolean().optional(),
14912
+ ordinaryUsageAllowed: external_exports.boolean().nullable().optional(),
14913
+ // null is explicitly unavailable
14914
+ blocked: external_exports.boolean().optional()
14915
+ // explicit workspace/spend-control denial beats apparent headroom
14916
+ }).optional()
14710
14917
  });
14711
14918
  QuotaSnapshot = external_exports.object({
14712
14919
  claude: ProviderQuota,
@@ -14768,6 +14975,16 @@ var init_src = __esm({
14768
14975
  * Machine-level: which chrome you want is a property of the person, not the repo.
14769
14976
  */
14770
14977
  projectRail: external_exports.boolean(),
14978
+ /**
14979
+ * CHILD THREADS, an experiment (2026-11-04). On, a worker may run `frizzctl spawn-child`: a new thread
14980
+ * in this project, on either harness, linked to the thread that spawned it. Its rests that would
14981
+ * have queued for the human wake its parent instead, it can own its own PR watchers, and it sits
14982
+ * under its parent on the board rather than in the queue. Off (the default), the command refuses and
14983
+ * the worker contract never mentions it. A child spawned while it was on keeps its link either way.
14984
+ *
14985
+ * Machine-level, like the rail: whether to try an experiment is the person's call, not the repo's.
14986
+ */
14987
+ childThreads: external_exports.boolean(),
14771
14988
  // There is no `font` key any more. The interface rendered in one of two type families as a machine
14772
14989
  // setting until 2026-09-19 (maintainer: "let's drop monospace as an option"); every surface is sans
14773
14990
  // now, and index.html pins `data-font="sans"` on <html> directly. Settings is a non-strict object,
@@ -14807,6 +15024,19 @@ var init_src = __esm({
14807
15024
  command: external_exports.string().trim().min(1).max(1024),
14808
15025
  args: external_exports.array(external_exports.string().max(1024)).max(32).optional()
14809
15026
  })).max(32).optional(),
15027
+ // A command each runtime's workers are started THROUGH, per runtime: `op run --env-file .env --`
15028
+ // makes a new Claude worker start as `op run --env-file .env -- /path/to/claude …`. Kept as the text
15029
+ // the operator typed and split into words by parseLaunchPrefix (launch-prefix.ts), the one parser the
15030
+ // settings field and the spawn share; it is never run in a shell. Claude applies it at every fork of a
15031
+ // thread's worker; Codex to the app-server the project's Codex threads share, which is replaced at
15032
+ // the next moment no Codex turn is running once the prefix changes. ACP agents need no key here:
15033
+ // their `command` above is already the operator's own. A string rather than a refined argv so a
15034
+ // stored value the parser later stops accepting fails ITS launch, not the whole settings parse
15035
+ // (getSettings falls back to every default on a parse miss). Optional so an old blob parses.
15036
+ launchPrefix: external_exports.object({
15037
+ claude: external_exports.string().max(LAUNCH_PREFIX_MAX_LENGTH).optional(),
15038
+ codex: external_exports.string().max(LAUNCH_PREFIX_MAX_LENGTH).optional()
15039
+ }).optional(),
14810
15040
  // The GitHub batch-dispatch prompt template (the picker's per-item worker prompt). Optional: when
14811
15041
  // unset OR blank the server falls back to its exported DEFAULT_GITHUB_PROMPT. Substitution tokens
14812
15042
  // the server fills: {repo} {n} {title} {url} {labels} {body}. The leading `THREAD: <slug>` tag is
@@ -14911,8 +15141,10 @@ var init_src = __esm({
14911
15141
  // human wrote it, and as fast as a steer does.
14912
15142
  answers: external_exports.array(QuestionAnswerSchema).optional(),
14913
15143
  // SEND TO THE QUEUE: while the agent is mid-turn, Frizz keeps this message and delivers it when the
14914
- // thread comes to rest, so it opens the next turn instead of steering this one. At rest it is an
14915
- // ordinary send — there is nothing to wait for. See `held` in the server's delivery-ledger.ts.
15144
+ // thread comes to rest, so it opens the next turn instead of steering this one. The queue goes out ONE
15145
+ // message per turn (2026-10-20), so a message also waits, even at rest, behind one already held or one
15146
+ // whose turn has not ended. With nothing ahead of it, it is an ordinary send. See `held` and
15147
+ // nextHeldRelease in the server's delivery-ledger.ts.
14916
15148
  hold: external_exports.boolean().optional(),
14917
15149
  // Generated once before the optimistic clear so a transport replay can be idempotent.
14918
15150
  deliveryId: external_exports.string().min(1).max(200).optional(),
@@ -14922,17 +15154,19 @@ var init_src = __esm({
14922
15154
  // newer frizz build in place (hooks are read once, at startup). Everything else that needs a fresh
14923
15155
  // process derives it server-side; see needsFreshProcessForLimit.
14924
15156
  freshProcess: external_exports.boolean().optional(),
14925
- // PREEMPT the operation the worker is running right now, so this message is read at once instead of
14926
- // when that operation finishes. The operator's "Interrupt and send" verb, and opt-in for the same
14927
- // reason `freshProcess` is: it costs the in-flight tool call's result. (It no longer costs the
14928
- // worker's sub-agents: since 2026-09-24 the broker's SDK query declares `perTaskStopAffordance`,
14929
- // so the interrupt aborts only the turn and background agents run on.)
15157
+ // SEND NOW: this message is read at once instead of when the operation the worker is running
15158
+ // finishes. The operator's forced send (⌘⏎), and opt-in because it can cost work in flight. Since
15159
+ // 2026-10-13 it usually does not: the runtime moves a running command or foreground sub-agent to the
15160
+ // background and takes the message into the same turn. Only when it has nothing to move — the model
15161
+ // is mid-answer, a wake Frizz wrote is waiting too — is the turn aborted, as it always was before,
15162
+ // and that costs the in-flight call its result. (An abort has not cost the worker's BACKGROUND
15163
+ // sub-agents since 2026-09-24: the broker's SDK query declares `perTaskStopAffordance`.)
14930
15164
  //
14931
15165
  // It exists because delivery is ALREADY as fast as queueing can be. Measured over 14 days of this
14932
15166
  // project's own transcripts, Claude Code drains its queue at the first sampling boundary that
14933
15167
  // exists; the wait an operator feels is the remaining time of whatever was in flight (a long `Bash`,
14934
15168
  // or one 73–133s reasoning+answer generation), which put mid-turn operator prose at p50 13.8s,
14935
- // p90 49s, p99 2.5m. Preempting is the only lever left.
15169
+ // p90 49s, p99 2.5m. Getting that operation out of the way is the only lever left.
14936
15170
  //
14937
15171
  // Broker-backed Claude only — that is every Claude thread dispatched since the broker cutover. On
14938
15172
  // any other runtime the message is delivered normally and this is ignored, never refused: a send
@@ -14947,16 +15181,38 @@ var init_src = __esm({
14947
15181
  SteerThreadInput = external_exports.object({
14948
15182
  from: ThreadSlug,
14949
15183
  to: ThreadSlug,
15184
+ project: external_exports.string().min(1).max(200).optional(),
14950
15185
  message: external_exports.string().min(1).max(1e5)
14951
15186
  }).strict();
14952
15187
  SteerThreadResult = external_exports.object({
14953
15188
  slug: ThreadSlug,
14954
15189
  title: external_exports.string(),
15190
+ // The target's project URL slug — present only when it is NOT the caller's project, so the receipt's
15191
+ // link can name it (`/project/<project>/thread/<slug>`) and a reply can be addressed back.
15192
+ project: external_exports.string().optional(),
14955
15193
  // What the target was doing when the message arrived: `running` ⇒ it reads it at its next tool
14956
15194
  // boundary; `resting` ⇒ the message started its next turn; `reopened` ⇒ it was marked done and this
14957
15195
  // message put it back on the board.
14958
15196
  delivery: external_exports.enum(["running", "resting", "reopened"])
14959
15197
  }).strict();
15198
+ SpawnThreadInput = DispatchInput.pick({ title: true, prompt: true, model: true, backend: true, effort: true }).extend({
15199
+ from: ThreadSlug,
15200
+ project: external_exports.string().min(1).max(200)
15201
+ }).strict();
15202
+ SpawnThreadResult = external_exports.object({
15203
+ slug: ThreadSlug,
15204
+ sessionId: external_exports.string(),
15205
+ // The new thread's project URL slug — present only when it is NOT the caller's project, as on a steer.
15206
+ project: external_exports.string().optional()
15207
+ }).strict();
15208
+ SpawnChildInput = DispatchInput.pick({ title: true, prompt: true, model: true, backend: true, effort: true }).extend({
15209
+ from: ThreadSlug
15210
+ }).strict();
15211
+ SpawnChildResult = external_exports.union([
15212
+ external_exports.object({ slug: ThreadSlug, sessionId: external_exports.string() }).strict(),
15213
+ // The experiment is off, or the caller is itself a child. Exit 2 at the command, like every refusal.
15214
+ external_exports.object({ refused: external_exports.string() }).strict()
15215
+ ]);
14960
15216
  DismissFailedFollowUpInput = external_exports.object({
14961
15217
  slug: ThreadSlug,
14962
15218
  deliveryId: external_exports.string().min(1).max(200)
@@ -15009,6 +15265,17 @@ var init_src = __esm({
15009
15265
  sessionId: external_exports.string().min(1),
15010
15266
  pinned: external_exports.boolean()
15011
15267
  }).strict();
15268
+ ReorderPinnedThreadsInput = external_exports.object({
15269
+ slugs: external_exports.array(ThreadSlug).max(500)
15270
+ }).strict();
15271
+ SetThreadConsolePinnedInput = external_exports.object({
15272
+ slug: ThreadSlug,
15273
+ sessionId: external_exports.string().min(1),
15274
+ pinned: external_exports.boolean()
15275
+ }).strict();
15276
+ ReorderConsolePinnedThreadsInput = external_exports.object({
15277
+ pins: external_exports.array(external_exports.object({ slug: ThreadSlug, at: SnoozeUntil }).strict()).max(500)
15278
+ }).strict();
15012
15279
  SetThreadRecurringPromptInput = external_exports.object({
15013
15280
  slug: ThreadSlug,
15014
15281
  sessionId: external_exports.string().min(1),
@@ -15065,7 +15332,7 @@ var init_src = __esm({
15065
15332
  timers: external_exports.array(ThreadTimerView)
15066
15333
  }).strict();
15067
15334
  ThreadActivityItem = external_exports.object({
15068
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command"]),
15335
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command", "thread"]),
15069
15336
  /** The string a `<kind>:` fence line must carry. For a shell that is the runtime task id the worker
15070
15337
  * was shown; for a PR, `owner/repo#N`; for a timer, its `tmr_…` row id. */
15071
15338
  id: external_exports.string(),
@@ -15094,6 +15361,62 @@ var init_src = __esm({
15094
15361
  * agent to read out the current set of watchers and questions?"). */
15095
15362
  questions: external_exports.array(RegisteredQuestionView).default([])
15096
15363
  }).strict();
15364
+ ThreadBand = external_exports.enum(["rested", "active", "snoozed", "done"]);
15365
+ ThreadListing = external_exports.object({
15366
+ slug: ThreadSlug,
15367
+ /** The title the sidebar shows (displayTitle). */
15368
+ title: external_exports.string(),
15369
+ band: ThreadBand,
15370
+ runtime: RuntimeState,
15371
+ backend: Backend.optional(),
15372
+ model: external_exports.string().optional(),
15373
+ /** The human pinned it to the top of this board's sidebar. */
15374
+ pinned: external_exports.literal(true).optional(),
15375
+ /** The caption of the tool call in flight, while a turn is running ("Running the focused tests"). */
15376
+ doing: external_exports.string().optional(),
15377
+ /** The first line of its newest message — the verdict line of a handoff. Capped. */
15378
+ lastLine: external_exports.string().optional(),
15379
+ /** The signal fence its last rest ended on, while the board still reads it. */
15380
+ fence: external_exports.enum(["done", "awaiting"]).optional(),
15381
+ /** Questions standing at its current rest (hasStandingQuestion's rule), plus a native ask or a
15382
+ * permission prompt the human has not answered. */
15383
+ questions: external_exports.number().int(),
15384
+ /** Its own sub-agents still running (direct children only, as the board counts them). */
15385
+ subAgents: external_exports.number().int(),
15386
+ /** Background shells still running. */
15387
+ shells: external_exports.number().int(),
15388
+ /** CHILD THREADS it started (`frizzctl spawn-child`) that are still open. A child is never a row of its
15389
+ * own here, as on the board (drawnUnderParent), so this is how another thread finds one to steer. */
15390
+ children: external_exports.array(external_exports.object({ slug: ThreadSlug, title: external_exports.string(), runtime: RuntimeState }).strict()).optional(),
15391
+ /** What its armed watches are on: `owner/repo#N` for a PR or issue, the prompt for a timer, the
15392
+ * command for a command watch. */
15393
+ watches: external_exports.array(external_exports.string()),
15394
+ /** Killed by a usage limit, waiting for the window to reset. */
15395
+ limited: external_exports.literal(true).optional(),
15396
+ /** Its process is gone with the turn unfinished. */
15397
+ crashed: external_exports.literal(true).optional(),
15398
+ spawnedAt: external_exports.string().optional(),
15399
+ /** When its own turn last came to rest (ThreadView.lastAssistantAt). */
15400
+ restedAt: external_exports.string().optional(),
15401
+ lastActivityAt: external_exports.string().optional(),
15402
+ /** A wall-clock snooze still in the future. */
15403
+ snoozedUntil: external_exports.string().optional(),
15404
+ archivedAt: external_exports.string().optional()
15405
+ }).strict();
15406
+ ListThreadsInput = external_exports.object({
15407
+ /** Another project's board — its slug, id or registered name — instead of the one the call lands on. */
15408
+ project: external_exports.string().trim().min(1).optional(),
15409
+ /** Include the Done band (archived threads), which on a long-lived board is nearly every thread. */
15410
+ all: external_exports.boolean().optional()
15411
+ }).strict();
15412
+ ListThreadsResult = external_exports.object({
15413
+ /** The board's project: its URL slug, its registry id, and the name the board shows. */
15414
+ project: external_exports.object({ slug: external_exports.string(), id: external_exports.string(), name: external_exports.string() }).strict(),
15415
+ /** Rested, then active, then snoozed, then done; newest activity first within each. */
15416
+ threads: external_exports.array(ThreadListing),
15417
+ /** Every band's size, the Done band's included when its threads are not listed. */
15418
+ counts: external_exports.object({ rested: external_exports.number().int(), active: external_exports.number().int(), snoozed: external_exports.number().int(), done: external_exports.number().int() }).strict()
15419
+ }).strict();
15097
15420
  SetOwnThreadTimerResult = external_exports.object({
15098
15421
  id: external_exports.string(),
15099
15422
  fireAt: external_exports.string(),
@@ -15159,6 +15482,42 @@ var init_src = __esm({
15159
15482
  }).strict();
15160
15483
  ThreadSkillsInput = external_exports.object({ slug: ThreadSlug }).strict();
15161
15484
  ThreadSkillsResult = external_exports.object({ skills: external_exports.array(ThreadSkill).max(1024) }).strict();
15485
+ McpTarget = external_exports.union([
15486
+ external_exports.object({ slug: ThreadSlug }).strict(),
15487
+ external_exports.object({ backend: Backend, model: external_exports.string().trim().max(200).optional() }).strict()
15488
+ ]);
15489
+ McpServerState = external_exports.enum(["connected", "needs-auth", "failed", "pending", "disabled"]);
15490
+ McpServerReading = external_exports.object({
15491
+ name: external_exports.string().min(1).max(256),
15492
+ state: McpServerState,
15493
+ tools: external_exports.number().int().nonnegative().optional(),
15494
+ error: external_exports.string().max(2e3).optional(),
15495
+ remote: external_exports.boolean().optional()
15496
+ }).strict();
15497
+ McpStatusResult = external_exports.object({
15498
+ backend: Backend,
15499
+ /** The agent's display name, for the empty state ("Gemini CLI doesn't support …"). */
15500
+ agent: external_exports.string().max(200),
15501
+ supported: external_exports.boolean(),
15502
+ servers: external_exports.array(McpServerReading).max(256),
15503
+ /**
15504
+ * Why the servers can't be read right now (Claude Code isn't signed in on this machine), drawn as the
15505
+ * menu's note. An answer, not an error: every prompt box asks on page load, and a machine without the
15506
+ * agent's login is an ordinary machine, not a failing server.
15507
+ */
15508
+ note: external_exports.string().max(500).optional()
15509
+ }).strict();
15510
+ McpSignInInput = external_exports.object({ target: McpTarget, server: external_exports.string().min(1).max(256) }).strict();
15511
+ McpSignInState = external_exports.enum(["waiting", "done", "failed", "expired", "cancelled"]);
15512
+ McpSignInResult = external_exports.object({
15513
+ attemptId: external_exports.string().min(1).max(64),
15514
+ /** Absent when the agent finished without the human (a refresh token was still good). */
15515
+ authUrl: external_exports.string().url().max(8192).optional(),
15516
+ state: McpSignInState
15517
+ }).strict();
15518
+ McpSignInAttemptInput = external_exports.object({ attemptId: external_exports.string().min(1).max(64) }).strict();
15519
+ McpSignInPollResult = external_exports.object({ state: McpSignInState, error: external_exports.string().max(2e3).optional() }).strict();
15520
+ McpSignInFinishInput = external_exports.object({ attemptId: external_exports.string().min(1).max(64), callbackUrl: external_exports.string().trim().min(1).max(8192) }).strict();
15162
15521
  SetThreadProfileInput = external_exports.object({
15163
15522
  slug: ThreadSlug,
15164
15523
  model: external_exports.string().trim().min(1).max(200),
@@ -15219,6 +15578,21 @@ ${DISPATCH_TASK_BANNER}
15219
15578
  nameWithOwner: external_exports.string().nullable(),
15220
15579
  authed: external_exports.boolean()
15221
15580
  });
15581
+ GithubAccount = external_exports.object({
15582
+ signedIn: external_exports.boolean(),
15583
+ /** "app" — Frizz's GitHub App, signed in here; "token" — a stored token; "env" — FRIZZ_GITHUB_TOKEN. */
15584
+ source: external_exports.enum(["app", "token", "env"]).optional(),
15585
+ login: external_exports.string().nullable(),
15586
+ /** Why a stored sign-in stopped working. */
15587
+ problem: external_exports.string().optional(),
15588
+ /** Whether this build knows Frizz's GitHub App, i.e. whether "Sign in" can be offered at all. */
15589
+ appRegistered: external_exports.boolean(),
15590
+ signIn: external_exports.discriminatedUnion("phase", [
15591
+ external_exports.object({ phase: external_exports.literal("idle") }),
15592
+ external_exports.object({ phase: external_exports.literal("pending"), userCode: external_exports.string(), verificationUri: external_exports.string(), expiresAt: external_exports.number() }),
15593
+ external_exports.object({ phase: external_exports.literal("failed"), message: external_exports.string() })
15594
+ ])
15595
+ });
15222
15596
  GithubRefCard = external_exports.object({
15223
15597
  ref: external_exports.string(),
15224
15598
  kind: external_exports.enum(["issue", "pr", "commit"]),
@@ -15318,6 +15692,14 @@ ${DISPATCH_TASK_BANNER}
15318
15692
  // same pre-restart back-compat reason as on BoardSnapshot.
15319
15693
  errorItems: external_exports.array(BoardErrorItem).optional()
15320
15694
  });
15695
+ BoardDeltaEvent = external_exports.object({
15696
+ type: external_exports.literal("board-delta"),
15697
+ seq: external_exports.number(),
15698
+ bootId: external_exports.string(),
15699
+ upserts: external_exports.array(ThreadView),
15700
+ removed: external_exports.array(ThreadSlug),
15701
+ meta: BoardMeta.optional()
15702
+ });
15321
15703
  ServerEvent = external_exports.discriminatedUnion("type", [
15322
15704
  external_exports.object({
15323
15705
  type: external_exports.literal("board"),
@@ -15329,17 +15711,7 @@ ${DISPATCH_TASK_BANNER}
15329
15711
  seq: external_exports.number().optional(),
15330
15712
  bootId: external_exports.string().optional()
15331
15713
  }),
15332
- external_exports.object({
15333
- // Keyed per-thread delta. `upserts` are COMPLETE ThreadViews for threads whose serialization
15334
- // changed (or are new); `removed` are ids gone from the board; `meta` is present only when a
15335
- // board-level field changed. Emitted only by a post-restart server → seq/bootId are required here.
15336
- type: external_exports.literal("board-delta"),
15337
- seq: external_exports.number(),
15338
- bootId: external_exports.string(),
15339
- upserts: external_exports.array(ThreadView),
15340
- removed: external_exports.array(ThreadSlug),
15341
- meta: BoardMeta.optional()
15342
- }),
15714
+ BoardDeltaEvent,
15343
15715
  external_exports.object({
15344
15716
  type: external_exports.literal("notify"),
15345
15717
  slug: ThreadSlug,
@@ -15366,6 +15738,14 @@ ${DISPATCH_TASK_BANNER}
15366
15738
  type: external_exports.literal("projects-changed")
15367
15739
  }).strict()
15368
15740
  ]);
15741
+ BoardDeltasInput = external_exports.object({
15742
+ bootId: external_exports.string().min(1).max(200),
15743
+ seq: external_exports.number().int().min(0)
15744
+ });
15745
+ BoardDeltasResult = external_exports.union([
15746
+ external_exports.object({ bootId: external_exports.string(), deltas: external_exports.array(BoardDeltaEvent) }),
15747
+ external_exports.object({ bootId: external_exports.string(), resync: external_exports.enum(["boot", "expired", "ahead"]) })
15748
+ ]);
15369
15749
  TranscriptEdit = external_exports.object({
15370
15750
  file: external_exports.string(),
15371
15751
  old: external_exports.string(),
@@ -15733,7 +16113,12 @@ ${DISPATCH_TASK_BANNER}
15733
16113
  external_exports.object({ t: external_exports.literal("sub"), topic: external_exports.literal("file"), path: SocketFilePath }).strict(),
15734
16114
  external_exports.object({ t: external_exports.literal("unsub"), topic: external_exports.literal("file"), path: SocketFilePath }).strict()
15735
16115
  ]);
15736
- SocketClientMsg = external_exports.union([SocketTranscriptClientMsg, SocketFileClientMsg]);
16116
+ SocketBoardClientMsg = external_exports.object({
16117
+ t: external_exports.literal("board"),
16118
+ bootId: external_exports.string().min(1).max(200),
16119
+ seq: external_exports.number().int().min(0)
16120
+ }).strict();
16121
+ SocketClientMsg = external_exports.union([SocketTranscriptClientMsg, SocketFileClientMsg, SocketBoardClientMsg]);
15737
16122
  ProjectCard = external_exports.object({
15738
16123
  id: external_exports.string(),
15739
16124
  slug: external_exports.string(),
@@ -15743,12 +16128,15 @@ ${DISPATCH_TASK_BANNER}
15743
16128
  /** The directory is gone — moved or deleted. The card stays so it can be reopened or forgotten. */
15744
16129
  stale: external_exports.boolean(),
15745
16130
  /**
15746
- * When this project's icon was last established, or absent if it never has been.
16131
+ * A hash of the icon's bytes; for a project with no icon, when it was last scanned. Absent if it
16132
+ * never has been.
15747
16133
  *
15748
16134
  * Carried purely so the client can hang it off the `/_frizz/project-icon` URL: the icon bytes are
15749
16135
  * cached hard (a rail of forty squares is forty requests, and none of them should recur), which
15750
16136
  * means a newly uploaded icon would otherwise stay invisible behind the cached old one. A changed
15751
16137
  * version is a changed URL, so the swap is immediate without weakening the caching for everyone.
16138
+ * It is the HASH, not the scan time, because a rescan restamps the time every ten minutes whether
16139
+ * or not the picture changed, and every restamp was a fresh download of every icon.
15752
16140
  *
15753
16141
  * Deliberately NOT "does this project have an icon". Answering that for a project nobody has
15754
16142
  * scanned yet would mean scanning it, and this list is one file read on purpose.
@@ -21281,7 +21669,7 @@ var init_project_root = __esm({
21281
21669
  });
21282
21670
 
21283
21671
  // packages/server/src/project-registry.ts
21284
- import { closeSync as closeSync4, existsSync as existsSync5, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync9, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
21672
+ import { closeSync as closeSync4, existsSync as existsSync5, fsyncSync as fsyncSync4, lstatSync, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync9, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync7, unlinkSync, writeFileSync as writeFileSync9 } from "node:fs";
21285
21673
  import { homedir as homedir9 } from "node:os";
21286
21674
  import { basename as basename4, dirname as dirname9, join as join12 } from "node:path";
21287
21675
  function registryPath(home = homedir9()) {
@@ -21448,6 +21836,7 @@ var init_project_registry = __esm({
21448
21836
  "term",
21449
21837
  "attach",
21450
21838
  "local-image",
21839
+ "local-file",
21451
21840
  "local-visualization",
21452
21841
  // `control` answers `/control/stop` and `/control/status` — the supervisor channel the board polls
21453
21842
  // every few seconds. It was missing from this list, so a directory called `control` could have taken
@@ -24025,7 +24414,7 @@ async function runDeviceFlow(options2) {
24025
24414
  const clientId = options2.clientId === void 0 ? githubClientId() : options2.clientId;
24026
24415
  if (!clientId) {
24027
24416
  throw new GithubSignInError(
24028
- "custom frizz.sh names are not available in this version of Frizz yet: its GitHub sign-in is not set up. A private frizz.sh name needs no account and works today.",
24417
+ "custom frizz.sh subdomains are not available in this version of Frizz yet: its GitHub sign-in is not set up. A private frizz.sh subdomain needs no account and works today.",
24029
24418
  "not-configured"
24030
24419
  );
24031
24420
  }
@@ -24563,7 +24952,7 @@ var CUSTOM_NAMES_OFFERED = false;
24563
24952
  function customNamesOffered(env = process.env) {
24564
24953
  return CUSTOM_NAMES_OFFERED || env.FRIZZ_CUSTOM_NAMES === "1";
24565
24954
  }
24566
- var CUSTOM_NAMES_PAUSED = "custom frizz.sh names are paused for now \u2014 press enter for a private name (no account needed), or answer with the hostname of a tunnel you run";
24955
+ var CUSTOM_NAMES_PAUSED = "custom frizz.sh subdomains are paused for now \u2014 press enter for a private subdomain (no account needed), or answer with the hostname of a tunnel you run";
24567
24956
  function isClaimedConfig(config) {
24568
24957
  return typeof config.claim === "string" && config.claim.length > 0;
24569
24958
  }
@@ -24583,7 +24972,7 @@ async function reconcileCloudConfig(config, port, home = homedir5(), onNotice, o
24583
24972
  if (!label) return config;
24584
24973
  if (!isAnonymousClaimName(label) && !customNamesOffered()) {
24585
24974
  throw new Error(
24586
- `${config.hostname} needs moving onto the Frizz relay, which means claiming it again, and custom frizz.sh names are paused for now \u2014 press R to pick a private name or another setup`
24975
+ `${config.hostname} needs moving onto the Frizz relay, which means claiming it again, and custom frizz.sh subdomains are paused for now \u2014 press R to pick a private subdomain or another setup`
24587
24976
  );
24588
24977
  }
24589
24978
  onNotice?.(
@@ -24665,7 +25054,7 @@ async function establishCloudConfig(answer, port, home = homedir5(), origin, git
24665
25054
  }
24666
25055
  if (!REGISTRAR_IS_LIVE && !origin && !process.env.FRIZZ_REGISTRAR) {
24667
25056
  throw new Error(
24668
- "claiming a name on frizz.sh is not available yet.\n Reach this board through a tunnel of your own instead \u2014 see docs/remote-access.md \u2014 and\n answer with its hostname rather than a bare name."
25057
+ "claiming a frizz.sh subdomain is not available yet.\n Reach this board through a tunnel of your own instead \u2014 see docs/remote-access.md \u2014 and\n answer with its hostname rather than a bare name."
24669
25058
  );
24670
25059
  }
24671
25060
  const anonymous = answer === "" || isAnonymousClaimName(answer);
@@ -24681,7 +25070,7 @@ async function establishCloudConfig(answer, port, home = homedir5(), origin, git
24681
25070
  token = authorization.token;
24682
25071
  if (authorization.login) console.log(` claiming ${name}.frizz.sh for GitHub user ${authorization.login}`);
24683
25072
  } else {
24684
- console.log(" claiming a private name on frizz.sh \u2014 no account needed");
25073
+ console.log(" claiming a private frizz.sh subdomain \u2014 no account needed");
24685
25074
  }
24686
25075
  let result;
24687
25076
  try {
@@ -24881,8 +25270,8 @@ async function probeTailscale() {
24881
25270
  // src/remote-pane.ts
24882
25271
  init_src();
24883
25272
  var CHOICES = [
24884
- { kind: "private", title: "Private name", blurb: "an unguessable name on frizz.sh \u2014 no account, nothing to install" },
24885
- { kind: "frizz", title: "Custom name", blurb: "<name>.frizz.sh of your choosing; needs a GitHub account" },
25273
+ { kind: "private", title: "frizz.sh subdomain", blurb: "a private, unguessable address; no account, nothing to install" },
25274
+ { kind: "frizz", title: "Custom subdomain", blurb: "<name>.frizz.sh of your choosing; needs a GitHub account" },
24886
25275
  { kind: "cloudflare", title: "Cloudflare Tunnel", blurb: "a domain you own on Cloudflare; cloudflared on this machine" },
24887
25276
  { kind: "tailscale", title: "Tailscale", blurb: "your tailnet; tailscale serve does the TLS" },
24888
25277
  { kind: "other", title: "Something else", blurb: "any proxy or tunnel you run \u2014 tell Frizz its address" },
@@ -24892,7 +25281,7 @@ function choicesFor(current) {
24892
25281
  if (customNamesOffered()) return CHOICES;
24893
25282
  if (kindOf(current) === "frizz") {
24894
25283
  return CHOICES.map(
24895
- (choice) => choice.kind === "frizz" ? { ...choice, blurb: `${current.hostname} \u2014 kept and renewed; new custom names are paused` } : choice
25284
+ (choice) => choice.kind === "frizz" ? { ...choice, blurb: `${current.hostname} \u2014 kept and renewed; new custom subdomains are paused` } : choice
24896
25285
  );
24897
25286
  }
24898
25287
  return CHOICES.filter((choice) => choice.kind !== "frizz");
@@ -24953,7 +25342,7 @@ function createRemotePane(options2) {
24953
25342
  "",
24954
25343
  ...choices().map((choice, index) => {
24955
25344
  const marker = index === s.index ? "\u276F" : " ";
24956
- const title = choice.title.padEnd(18);
25345
+ const title = choice.title.padEnd(20);
24957
25346
  const now = choice.kind === current ? ` ${DIM}(current)${RESET2}` : "";
24958
25347
  return `${marker} ${title}${DIM}${choice.blurb}${RESET2}${now}`;
24959
25348
  }),
@@ -25008,10 +25397,10 @@ function createRemotePane(options2) {
25008
25397
  const formHead = (kind) => {
25009
25398
  if (kind === "frizz") {
25010
25399
  return [
25011
- "Custom frizz.sh name",
25400
+ "Custom frizz.sh subdomain",
25012
25401
  "",
25013
25402
  ...wrap(
25014
- "Claims <name>.frizz.sh of your choosing, tied to your GitHub account so names cannot be squatted. The board dials out to frizz.sh \u2014 no port, no DNS record, no tunnel binary. One name per account; a name nobody runs for 30 days is released. (A private name needs none of this.)"
25403
+ "Claims <name>.frizz.sh of your choosing, tied to your GitHub account so names cannot be squatted. The board dials out to frizz.sh \u2014 no port, no DNS record, no tunnel binary. One name per account; a name nobody runs for 30 days is released. (A private subdomain needs none of this.)"
25015
25404
  ),
25016
25405
  ...options2.sandbox ? ["", ...wrap("This is a sandbox, but a claim is real: it binds this machine's one name to your account, and your real board keeps it. Only the setup saved here is thrown away.")] : [],
25017
25406
  "",
@@ -25035,7 +25424,7 @@ function createRemotePane(options2) {
25035
25424
  " cloudflared tunnel route dns my-board board.example.com",
25036
25425
  "",
25037
25426
  ...wrap(
25038
- `Then name them here. Frizz writes ~/.cloudflared/frizz.yml (ingress to http://127.0.0.1:${options2.port}) and runs the tunnel beside the board, so the two share a lifetime.`
25427
+ `Point it at this board in ~/.cloudflared/frizz.yml (ingress to http://127.0.0.1:${options2.port}; https://frizz.sh/docs/cloudflare-tunnel has the file), then name them here. Frizz runs the tunnel beside the board, so the two share a lifetime.`
25039
25428
  )
25040
25429
  ];
25041
25430
  }
@@ -25145,7 +25534,7 @@ function createRemotePane(options2) {
25145
25534
  };
25146
25535
  const claimPrivate = async () => {
25147
25536
  const back = { name: "menu", index: 0 };
25148
- screen = { name: "busy", message: "claiming a private name on frizz.sh\u2026" };
25537
+ screen = { name: "busy", message: "claiming a private frizz.sh subdomain\u2026" };
25149
25538
  paint();
25150
25539
  try {
25151
25540
  const next = await options2.claim("", { signal: new AbortController().signal, onDeviceCode: () => {
@@ -25618,7 +26007,7 @@ var LAUNCH_TIMEOUT_MS = BOOT_STALL_TIMEOUT_MS;
25618
26007
  var LAUNCH_HARD_TIMEOUT_MS = BOOT_HARD_TIMEOUT_MS;
25619
26008
  var RETIRED_NETWORK_FLAGS = ["--host", "--allowed-host", "--public-origin", "--cloud"];
25620
26009
  function retiredFlagMessage(flag) {
25621
- return `${flag} was retired \u2014 how a board is reached is set up from the running board now: press R in its terminal to pick a private frizz.sh name, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
26010
+ return `${flag} was retired \u2014 how a board is reached is set up from the running board now: press R in its terminal to pick a private frizz.sh subdomain, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
25622
26011
  }
25623
26012
  function parseCliArgs(argv) {
25624
26013
  const args2 = new Set(argv);
@@ -25760,9 +26149,13 @@ function shareCredentials(realHome, home, realEnv, sandboxEnv) {
25760
26149
  mkdirSync10(dirname10(realKey), { recursive: true });
25761
26150
  mkdirSync10(dirname10(sandboxKey), { recursive: true });
25762
26151
  link(realKey, sandboxKey);
25763
- if (existsSync6(join13(realHome, ".config", "gh"))) {
25764
- mkdirSync10(join13(home, ".config"), { recursive: true });
25765
- link(join13(realHome, ".config", "gh"), join13(home, ".config", "gh"));
26152
+ const realGithub = join13(frizzPaths({ home: realHome, env: realEnv }).data, "github");
26153
+ if (existsSync6(realGithub)) {
26154
+ const sandboxGithub = join13(frizzPaths({ home, env: sandboxEnv }).data, "github");
26155
+ mkdirSync10(dirname10(sandboxGithub), { recursive: true });
26156
+ link(realGithub, sandboxGithub);
26157
+ const keychain = join13(realHome, "Library", "Keychains", "login.keychain-db");
26158
+ if (process.platform === "darwin" && existsSync6(keychain)) sandboxEnv.FRIZZ_GITHUB_KEYCHAIN = keychain;
25766
26159
  }
25767
26160
  const cloudflared = join13(realHome, ".cloudflared");
25768
26161
  if (existsSync6(cloudflared)) {
@@ -27347,7 +27740,7 @@ function createNpmRegistryReleaseAdapter(options2 = {}) {
27347
27740
  return settle({ code: null, signal: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
27348
27741
  }
27349
27742
  const started = Date.now();
27350
- const child = spawn3(npm.command, [...npm.prefixArgs, "exec", "--yes", `--package=${packageSpec}`, "--", bin, ...args2], {
27743
+ const child = spawn3(npm.command, [...npm.prefixArgs, "exec", "--yes", "--min-release-age=0", `--package=${packageSpec}`, "--", bin, ...args2], {
27351
27744
  cwd,
27352
27745
  env,
27353
27746
  stdio: ["ignore", "pipe", "pipe"],
@@ -27543,6 +27936,7 @@ function npmServerPackageInstaller(env = process.env, options2 = {}) {
27543
27936
  prefix,
27544
27937
  "--global=false",
27545
27938
  "--ignore-scripts",
27939
+ "--min-release-age=0",
27546
27940
  "--no-audit",
27547
27941
  "--no-fund",
27548
27942
  "--engine-strict",
@@ -27898,8 +28292,9 @@ Options:
27898
28292
  --no-app print the URL without opening a browser
27899
28293
  --port <port> request a fixed port for a new workspace server
27900
28294
  --sandbox a disposable Frizz to try things in: throwaway home and project, its
27901
- own port, deleted when this terminal closes; credentials (gh,
27902
- cloudflared, Claude, Codex, the machine's frizz.sh key) are shared
28295
+ own port, deleted when this terminal closes; credentials (Frizz's
28296
+ GitHub sign-in, cloudflared, Claude, Codex, the machine's frizz.sh
28297
+ key) are shared
27903
28298
  --link print a fresh single-use access link for the running board
27904
28299
  --sessions list the devices signed in to the running board
27905
28300
  --sign-out <id|all> sign one device out, or every one of them
@@ -27910,10 +28305,10 @@ Options:
27910
28305
 
27911
28306
 
27912
28307
  To reach the board from a phone or another machine, press R in the terminal running it: a short
27913
- walkthrough sets up a private frizz.sh name (no account needed), a Cloudflare
27914
- Tunnel, Tailscale, or a proxy of your own, and
27915
- remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
27916
- shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.`
28308
+ walkthrough sets up a private frizz.sh subdomain (no account needed), a Cloudflare Tunnel,
28309
+ Tailscale, or a proxy of your own, and remembers the choice, so a plain launch serves it from then
28310
+ on. The board stays on loopback and shows a single-use sign-in link as a QR; press L for a fresh
28311
+ one, or run --link from another shell.`
27917
28312
  );
27918
28313
  process.exit(0);
27919
28314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frizz",
3
- "version": "0.13.13",
3
+ "version": "0.13.14",
4
4
  "private": false,
5
5
  "description": "A local web UI for running many coding agents at once",
6
6
  "keywords": [
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "frizzServer": {
68
68
  "package": "frizz-server",
69
- "version": "0.15.23",
69
+ "version": "0.15.45",
70
70
  "minimum": "0.15.21",
71
71
  "protocol": 1,
72
72
  "dataEpoch": 1