frizz 0.13.12 → 0.13.13

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 +3 -3
  2. package/dist/frizz.js +433 -173
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -84,15 +84,15 @@ 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, a third row numbered 3 for typing something else, and a Send answers 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 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%">
88
88
  </p>
89
89
 
90
- Options are numbered: press an option's number or click it to pick it, then Enter to send. A worker marks its own recommendation when it has one. There is always a row for writing something else instead.
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.
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, a fourth numbered row for adding a note, and a Send answers button." 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 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%">
96
96
  </p>
97
97
 
98
98
  ### GitHub
package/dist/frizz.js CHANGED
@@ -182,7 +182,7 @@ var init_local_origin = __esm({
182
182
 
183
183
  // packages/server/src/access-codes.ts
184
184
  import { randomBytes, timingSafeEqual, createHmac } from "node:crypto";
185
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
185
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
186
186
  import { dirname, join as join2 } from "node:path";
187
187
  function memorySessionDirectory(seed = []) {
188
188
  const records = new Map(seed.map((r) => [r.id, r]));
@@ -227,7 +227,7 @@ function secretsMatch(a, b) {
227
227
  return left.length === right.length && timingSafeEqual(left, right);
228
228
  }
229
229
  function fileSessionDirectory(stateDir, now = Date.now) {
230
- const path = join2(stateDir, "sessions.json");
230
+ const path = join2(stateDir, SESSION_DIRECTORY_FILE);
231
231
  let records = [];
232
232
  try {
233
233
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -283,6 +283,39 @@ function loadOrCreateSessionKey(stateDir) {
283
283
  }
284
284
  return rotateSessionKey(stateDir);
285
285
  }
286
+ function adoptSessionState(stateDir, others) {
287
+ if (existsSync(join2(stateDir, SESSION_KEY_FILE))) return void 0;
288
+ const usedAt = (dir) => {
289
+ try {
290
+ const key = statSync(join2(dir, SESSION_KEY_FILE));
291
+ if (!key.isFile() || key.size < 32) return void 0;
292
+ try {
293
+ return Math.max(key.mtimeMs, statSync(join2(dir, SESSION_DIRECTORY_FILE)).mtimeMs);
294
+ } catch {
295
+ return key.mtimeMs;
296
+ }
297
+ } catch {
298
+ return void 0;
299
+ }
300
+ };
301
+ let newest;
302
+ for (const dir of others) {
303
+ if (dir === stateDir) continue;
304
+ const at = usedAt(dir);
305
+ if (at !== void 0 && (!newest || at > newest.at)) newest = { dir, at };
306
+ }
307
+ if (!newest) return void 0;
308
+ mkdirSync(stateDir, { recursive: true });
309
+ for (const name of [SESSION_EPOCH_FILE, SESSION_DIRECTORY_FILE, SESSION_KEY_FILE]) {
310
+ try {
311
+ copyFileSync(join2(newest.dir, name), join2(stateDir, name));
312
+ chmodSync(join2(stateDir, name), 384);
313
+ } catch (error) {
314
+ if (name === SESSION_KEY_FILE) throw error;
315
+ }
316
+ }
317
+ return newest.dir;
318
+ }
286
319
  function rotateSessionKey(stateDir) {
287
320
  const key = randomBytes(32);
288
321
  writeSessionKey(join2(stateDir, SESSION_KEY_FILE), key);
@@ -327,7 +360,7 @@ function loadSessionState(stateDir, epoch = SESSION_EPOCH) {
327
360
  const signedOut = advance.advanced ? directory.revokeAll() : 0;
328
361
  return { key, directory, rotateKey: () => rotateSessionKey(stateDir), advance, signedOut };
329
362
  }
330
- var CODE_BYTES, SESSION_BYTES, SESSION_ID_BYTES, DEFAULT_CODE_TTL_MS, DEFAULT_SESSION_TTL_MS, SESSION_EPOCH, AccessStore, SESSION_KEY_FILE, SESSION_EPOCH_FILE;
363
+ var CODE_BYTES, SESSION_BYTES, SESSION_ID_BYTES, DEFAULT_CODE_TTL_MS, DEFAULT_SESSION_TTL_MS, SESSION_EPOCH, AccessStore, SESSION_KEY_FILE, SESSION_EPOCH_FILE, SESSION_DIRECTORY_FILE;
331
364
  var init_access_codes = __esm({
332
365
  "packages/server/src/access-codes.ts"() {
333
366
  "use strict";
@@ -504,6 +537,7 @@ var init_access_codes = __esm({
504
537
  };
505
538
  SESSION_KEY_FILE = "session-key";
506
539
  SESSION_EPOCH_FILE = "session-epoch";
540
+ SESSION_DIRECTORY_FILE = "sessions.json";
507
541
  }
508
542
  });
509
543
 
@@ -518,7 +552,7 @@ var init_local_path = __esm({
518
552
  });
519
553
 
520
554
  // packages/server/src/local-image.ts
521
- import { createReadStream, realpathSync, statSync } from "node:fs";
555
+ import { createReadStream, realpathSync, statSync as statSync2 } from "node:fs";
522
556
  import { extname, isAbsolute } from "node:path";
523
557
  function resolveLocalImage(rawPath, range) {
524
558
  if (rawPath) rawPath = normalizeLocalPath(rawPath);
@@ -533,7 +567,7 @@ function resolveLocalImage(rawPath, range) {
533
567
  }
534
568
  let size;
535
569
  try {
536
- const stat = statSync(real);
570
+ const stat = statSync2(real);
537
571
  if (!stat.isFile()) return { status: 404 };
538
572
  size = stat.size;
539
573
  } catch {
@@ -944,7 +978,7 @@ import {
944
978
  openSync,
945
979
  readdirSync,
946
980
  rmSync,
947
- statSync as statSync2,
981
+ statSync as statSync3,
948
982
  symlinkSync,
949
983
  writeFileSync as writeFileSync2,
950
984
  writeSync
@@ -989,7 +1023,7 @@ function pruneRunLogs(dir, keep = RETAINED_RUNS, days = RETAINED_DAYS, now = Dat
989
1023
  }
990
1024
  const dated = entries.map((name) => {
991
1025
  try {
992
- return { name, at: statSync2(join4(dir, name)).mtimeMs };
1026
+ return { name, at: statSync3(join4(dir, name)).mtimeMs };
993
1027
  } catch {
994
1028
  return { name, at: 0 };
995
1029
  }
@@ -13468,14 +13502,14 @@ function signoffNudgeText(needsInput) {
13468
13502
  "the write-up.",
13469
13503
  "",
13470
13504
  "**DECIDE RATHER THAN ASK.** Stop only for a decision that is genuinely the human's AND that blocks you",
13471
- "right now: register that one with `mcp__frizz__ask`. Every other open choice INSIDE the task \u2014 a name, a",
13505
+ "right now: register that one with `frizzctl ask`. Every other open choice INSIDE the task \u2014 a name, a",
13472
13506
  "default, a reversible design call \u2014 is yours to make: decide it, say in one line which way you went and",
13473
13507
  "what would reverse it, and carry on. A choice that would ENLARGE the task is not one of those: an",
13474
13508
  "unanswered question is not permission to go build the answer.",
13475
13509
  "",
13476
13510
  "Otherwise, sign off \u2014 a registration, or a fence at the END of your next message:",
13477
13511
  "",
13478
- "- `mcp__frizz__ask` \u2014 you need the human. NOT a fence: the ```question fence is retired, and a fence",
13512
+ "- `frizzctl ask` \u2014 you need the human. NOT a fence: the ```question fence is retired, and a fence",
13479
13513
  " with a question in its body is plain prose. Register it (options with one-line trade-offs, the",
13480
13514
  " recommended one first), then rest normally \u2014 an open registered question is the sign-off.",
13481
13515
  "- `` ```done `` \u2014 genuinely FINISHED. A DISMISSAL: the card is filed away and nobody looks again, so",
@@ -13485,8 +13519,8 @@ function signoffNudgeText(needsInput) {
13485
13519
  "",
13486
13520
  " Frizz CHECKS every line: name something that is not running and you are bumped rather than parked.",
13487
13521
  " A fence that names NOTHING is not a park at all \u2014 if you are not waiting on anything, you are not",
13488
- " awaiting, you are done. Register a PR with `mcp__frizz__watch_pr` and a timer with",
13489
- " `mcp__frizz__timer`; `mcp__frizz__activity` reads back everything you have running, with its id.",
13522
+ " awaiting, you are done. Register a PR with `frizzctl watch-pr add` and a timer with",
13523
+ " `frizzctl timer set`; `frizzctl activity` reads back everything you have running, with its id.",
13490
13524
  "- `` ```awaiting `` with `steps:` \u2014 the human must PERFORM something you cannot: sign in, approve,",
13491
13525
  " merge, press a button you may not. List each step as a `- ` line under `steps:`, written to be",
13492
13526
  " followed cold: a real Markdown link to every page and file it names, every command in a code span,",
@@ -13495,14 +13529,14 @@ function signoffNudgeText(needsInput) {
13495
13529
  " comes back to you as their reply; anything else they need to say comes as a message of their own.",
13496
13530
  "- `` ```awaiting `` with `questions:` \u2014 a question you registered at an EARLIER rest is still open and",
13497
13531
  " you still need its answer. Name every such question (`questions: [qst_\u2026]`) and withdraw the rest with",
13498
- " `mcp__frizz__unask`: a fence that leaves an open question out is refused. A named card is drawn at",
13532
+ " `frizzctl unask <id>`: a fence that leaves an open question out is refused. A named card is drawn at",
13499
13533
  " this rest; an unnamed one stays where you asked it.",
13500
13534
  "",
13501
13535
  "**STILL OWED counts things you are not going to do yourself.** A decision you are RECOMMENDING, a",
13502
13536
  "draft you wrote but did not send, follow-up work you discovered \u2014 all of it dies with the card, even",
13503
13537
  "the part that is someone else's to do. Each ends on a question with your recommendation as option 1,",
13504
13538
  "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",
13505
- "`mcp__frizz__spawn_thread` is the LAST resort, since nothing it learns returns to you or its siblings.",
13539
+ "`frizzctl spawn-thread` is the LAST resort, since nothing it learns returns to you or its siblings.",
13506
13540
  "None of them is a `done`. And what is not",
13507
13541
  'worth a card is not worth a SENTENCE: delete the dangling "one thing to carry forward", never park it',
13508
13542
  "in the handoff.",
@@ -13525,7 +13559,7 @@ function requireEffortOutsideAcp(profile, ctx) {
13525
13559
  function fallbackPort(base) {
13526
13560
  return base + 1e4;
13527
13561
  }
13528
- 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, 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, ThreadPluginReloadResult, 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;
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;
13529
13563
  var init_src = __esm({
13530
13564
  "packages/shared/src/index.ts"() {
13531
13565
  "use strict";
@@ -13863,7 +13897,7 @@ var init_src = __esm({
13863
13897
  questions: external_exports.array(AskQuestion)
13864
13898
  });
13865
13899
  AwaitingHint = external_exports.object({
13866
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "for", "title", "status", "needs_input", "step", "question"]),
13900
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command", "for", "title", "status", "needs_input", "step", "question"]),
13867
13901
  value: external_exports.string()
13868
13902
  });
13869
13903
  RETIRED_AWAITING_KINDS = ["watch", "pr-watch", "human", "ci", "session", "shell", "agent", "timer", "pr", "reason"];
@@ -14021,7 +14055,7 @@ var init_src = __esm({
14021
14055
  * still BOUNDS the poll; the point of the field is that a worker chooses, and one that cannot choose
14022
14056
  * is better bounded than broken. */
14023
14057
  for: external_exports.string().trim().min(1).max(16).optional(),
14024
- /** What `target` names. `issue` (from `mcp__frizz__watch_issue`, 2026-09-14) registers a watcher on a
14058
+ /** What `target` names. `issue` (from `frizzctl watch-issue`, 2026-09-14) registers a watcher on a
14025
14059
  * GitHub issue — same registry, same poll cadence, same drop verb, but a different probe (`gh issue
14026
14060
  * view`), a different GraphQL fragment and a report with no CI in it. ABSENT MEANS A PULL REQUEST,
14027
14061
  * for the same reason `for` is optional on the wire: every `watch_pr` binary dispatched before this
@@ -14109,6 +14143,68 @@ var init_src = __esm({
14109
14143
  dropped: external_exports.boolean(),
14110
14144
  watches: external_exports.array(OwnWatchView)
14111
14145
  }).strict();
14146
+ COMMAND_WATCH_EVERY_MAX_MS = 24 * 60 * 60 * 1e3;
14147
+ COMMAND_WATCH_FOR_MAX_MS = 7 * 24 * 60 * 60 * 1e3;
14148
+ COMMAND_WATCH_COMMAND_MAX = 4e3;
14149
+ 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
+ CommandWatchState = external_exports.enum(["armed", "dropped", "passed", "expired", "cancelled"]);
14157
+ CommandWatchView = external_exports.object({
14158
+ id: external_exports.string(),
14159
+ command: external_exports.string(),
14160
+ /** The cadence in the house duration grammar (`5m`, `1h 30m`). */
14161
+ every: external_exports.string(),
14162
+ state: CommandWatchState,
14163
+ createdAt: external_exports.string(),
14164
+ expiresAt: external_exports.string(),
14165
+ /** How many checks have run since it was armed, the registration's own first check included. */
14166
+ runs: external_exports.number().int().nonnegative(),
14167
+ lastRun: CommandWatchRun.optional()
14168
+ }).strict();
14169
+ AddOwnCommandWatchInput = external_exports.object({
14170
+ slug: ThreadSlug,
14171
+ /** Run with `/bin/sh -c` in the project root, with the server's environment. Exit status 0 is "passed". */
14172
+ command: external_exports.string().trim().min(1).max(COMMAND_WATCH_COMMAND_MAX),
14173
+ /** How often to check, as a DURATION (`5m`; parseAwaitingDurationRaw). Clamped to [1m, 24h] and reported. */
14174
+ every: external_exports.string().trim().min(1).max(16),
14175
+ /** How long to keep checking, as a DURATION. Clamped to COMMAND_WATCH_FOR_MAX_MS and reported. */
14176
+ for: external_exports.string().trim().min(1).max(16)
14177
+ }).strict();
14178
+ AddOwnCommandWatchResult = external_exports.object({
14179
+ /** Present when the call was REFUSED — the thread may not run commands outside the CLI's permission
14180
+ * system, or the command cannot run here at all. A refusal is an answer, not a fault: the text says
14181
+ * why and what to use instead, and nothing was stored. */
14182
+ refused: external_exports.string().optional(),
14183
+ /** The armed row's id. Absent when nothing was armed: a refusal, or a first check that already passed. */
14184
+ id: external_exports.string().optional(),
14185
+ /** This exact command was already watched on this thread, so the call registered nothing new. */
14186
+ alreadyArmed: external_exports.boolean().default(false),
14187
+ /** The FIRST CHECK, run before anything was stored. Exit 0 there means the condition already holds:
14188
+ * nothing is armed, and the worker proceeds now rather than waiting a cadence to be told so. */
14189
+ firstRun: CommandWatchRun.optional(),
14190
+ passed: external_exports.boolean().default(false),
14191
+ expiresAt: external_exports.string().optional(),
14192
+ every: external_exports.string().optional(),
14193
+ /** The `every` / `for` the worker wrote, present ONLY when it was clamped — a clamp nobody is told about
14194
+ * is a worker resting on coverage it does not have. */
14195
+ clamped: external_exports.object({ every: external_exports.string().optional(), for: external_exports.string().optional() }).strict().optional(),
14196
+ watches: external_exports.array(CommandWatchView)
14197
+ }).strict();
14198
+ DropOwnCommandWatchInput = external_exports.object({
14199
+ slug: ThreadSlug,
14200
+ id: external_exports.string().min(1).max(64)
14201
+ }).strict();
14202
+ DropOwnCommandWatchResult = external_exports.object({
14203
+ dropped: external_exports.boolean(),
14204
+ watches: external_exports.array(CommandWatchView)
14205
+ }).strict();
14206
+ ListOwnCommandWatchesInput = external_exports.object({ slug: ThreadSlug }).strict();
14207
+ OwnCommandWatchesResult = external_exports.object({ watches: external_exports.array(CommandWatchView) }).strict();
14112
14208
  AskQuestionKind = external_exports.enum(["question", "multi"]);
14113
14209
  AskedOptionSchema = external_exports.lazy(() => external_exports.object({
14114
14210
  // 400, not the 120 it launched with (2026-09-03): a label is a chip line, but "Post the drafted
@@ -14140,8 +14236,8 @@ var init_src = __esm({
14140
14236
  }).strict());
14141
14237
  AskInput = external_exports.object({
14142
14238
  slug: ThreadSlug,
14143
- // No cap on the count (four until 2026-09-03): the tool's own text says "several at once is one
14144
- // call — register them together", and a cap here told a worker with six to batch and then refused it.
14239
+ // No cap on the count (four until 2026-09-03): the command's own help says several at once is one
14240
+ // run — register them together — and a cap here told a worker with six to batch and then refused it.
14145
14241
  questions: external_exports.array(AskedQuestionSchema).min(1)
14146
14242
  }).strict();
14147
14243
  RegisteredQuestionView = external_exports.object({
@@ -14235,7 +14331,7 @@ var init_src = __esm({
14235
14331
  body: external_exports.string(),
14236
14332
  // fence body minus hint lines, capped server-side; may be ""
14237
14333
  hints: external_exports.array(AwaitingHint).default([]),
14238
- // Present only on a completion the worker REGISTERED (`mcp__frizz__done`, board.registeredDoneFence)
14334
+ // Present only on a completion the worker REGISTERED (`frizzctl done`, board.registeredDoneFence)
14239
14335
  // rather than wrote as a ```done fence in its final message. The two are one fence to every predicate,
14240
14336
  // but the TRANSCRIPT draws a fence card from the message text it parses — so a registered done, which
14241
14337
  // is in no message, needs the client to know it must draw the card itself at the bottom of the thread
@@ -14805,7 +14901,19 @@ var init_src = __esm({
14805
14901
  // Binds the call to the session the tab is looking at, so a stale page cannot deliver a follow-up
14806
14902
  // into a thread that has since been re-dispatched (merged from origin/main, 2026-07-21).
14807
14903
  sessionId: external_exports.string().min(1),
14808
- message: external_exports.string().min(1),
14904
+ // EMPTY only when `answers` ride: the staged answers alone are a message. The server refuses a send
14905
+ // that carries neither.
14906
+ message: external_exports.string(),
14907
+ // REGISTERED-QUESTION ANSWERS SENT WITH THIS MESSAGE — the prompt box's one Send. The server settles
14908
+ // each question exactly as `answerQuestions` does, then delivers ONE turn: the answers in their wire
14909
+ // form (questionAnswerMessage) and `message` under them as the note (withAnswersNote). They ride the
14910
+ // follow-up rather than the scheduler's answer wake so the pair arrives together, in the order the
14911
+ // human wrote it, and as fast as a steer does.
14912
+ answers: external_exports.array(QuestionAnswerSchema).optional(),
14913
+ // 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.
14916
+ hold: external_exports.boolean().optional(),
14809
14917
  // Generated once before the optimistic clear so a transport replay can be idempotent.
14810
14918
  deliveryId: external_exports.string().min(1).max(200).optional(),
14811
14919
  // Retire the worker's live process before delivering, so this message lands in a `claude` that has
@@ -14867,11 +14975,19 @@ var init_src = __esm({
14867
14975
  DeliverQueuedNowInput = external_exports.object({
14868
14976
  slug: ThreadSlug,
14869
14977
  // Same staleness guard as unqueueFollowUp: a stale tab must not preempt a re-dispatched session.
14870
- sessionId: external_exports.string().min(1)
14978
+ sessionId: external_exports.string().min(1),
14979
+ // Hand over what Frizz is HOLDING for the agent's rest ("Send to queue") and stop there — no
14980
+ // interrupt. The "Send now" under a held bubble: the operator changed their mind about waiting, not
14981
+ // about interrupting, so the message goes in as the steer it would have been.
14982
+ releaseOnly: external_exports.boolean().optional()
14871
14983
  }).strict();
14872
14984
  DeliverQueuedNowResult = external_exports.object({
14873
14985
  interrupted: external_exports.boolean(),
14874
- reason: external_exports.string().optional()
14986
+ reason: external_exports.string().optional(),
14987
+ // How many messages Frizz was HOLDING for the agent's rest ("Send to queue") and has now handed over.
14988
+ // Those were never in a provider queue, so on a runtime that cannot be interrupted this is the whole
14989
+ // of what "send now" did — and it is a success the surface must not report as a refusal.
14990
+ released: external_exports.number().int().nonnegative().optional()
14875
14991
  }).strict();
14876
14992
  CompactThreadInput = external_exports.object({
14877
14993
  slug: ThreadSlug,
@@ -14949,7 +15065,7 @@ var init_src = __esm({
14949
15065
  timers: external_exports.array(ThreadTimerView)
14950
15066
  }).strict();
14951
15067
  ThreadActivityItem = external_exports.object({
14952
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue"]),
15068
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "issue", "command"]),
14953
15069
  /** The string a `<kind>:` fence line must carry. For a shell that is the runtime task id the worker
14954
15070
  * was shown; for a PR, `owner/repo#N`; for a timer, its `tmr_…` row id. */
14955
15071
  id: external_exports.string(),
@@ -14998,13 +15114,6 @@ var init_src = __esm({
14998
15114
  intervalSeconds: RecurringIntervalSeconds.optional(),
14999
15115
  enabled: external_exports.boolean().optional()
15000
15116
  }).strict();
15001
- ThreadPluginReloadResult = external_exports.object({
15002
- plugins: external_exports.number().int().min(0),
15003
- commands: external_exports.number().int().min(0),
15004
- agents: external_exports.number().int().min(0),
15005
- mcpServers: external_exports.array(external_exports.string()),
15006
- errorCount: external_exports.number().int().min(0)
15007
- }).strict();
15008
15117
  RenameThreadInput = external_exports.object({
15009
15118
  slug: ThreadSlug,
15010
15119
  title: external_exports.string().trim().min(1).max(200)
@@ -15247,6 +15356,14 @@ ${DISPATCH_TASK_BANNER}
15247
15356
  interactionId: InteractionOpaqueId,
15248
15357
  lifecycle: InteractionLifecycle,
15249
15358
  recordRevision: InteractionRevision
15359
+ }).strict(),
15360
+ external_exports.object({
15361
+ // The machine's PROJECT LIST changed: a reorder, a rename, an icon, a project added or forgotten.
15362
+ // Payload-free for the same reason as above — the list is one cheap read, and a client that
15363
+ // re-reads it cannot apply a stale copy out of order. It is the one event that is not about this
15364
+ // stream's own project: the server publishes it on EVERY open project's stream, because the rail
15365
+ // and the grid draw the same list in every tab whichever board that tab is on.
15366
+ type: external_exports.literal("projects-changed")
15250
15367
  }).strict()
15251
15368
  ]);
15252
15369
  TranscriptEdit = external_exports.object({
@@ -15490,7 +15607,8 @@ ${DISPATCH_TASK_BANNER}
15490
15607
  // the server's from the instant it arrives. "failed": the transport threw, or never answered — the
15491
15608
  // text is kept until the operator retries, edits or dismisses it, and nothing retries it on its own.
15492
15609
  // Additive + optional: an older client renders both as a plain bubble (gray for "sending").
15493
- deliveryState: external_exports.enum(["sending", "pending", "enqueued", "delivered", "unconfirmed", "failed"]).optional(),
15610
+ // "held": the operator sent it TO THE QUEUE — Frizz has it and no transport does, until the agent rests.
15611
+ deliveryState: external_exports.enum(["sending", "pending", "enqueued", "delivered", "unconfirmed", "failed", "held"]).optional(),
15494
15612
  // Why a "failed" send failed, verbatim from the transport. Set only alongside deliveryState "failed".
15495
15613
  deliveryError: external_exports.string().optional(),
15496
15614
  // FRIZZ wrote this user turn, not the human: it is a scheduler wake delivery (isWakeDelivery). The
@@ -15642,8 +15760,9 @@ ${DISPATCH_TASK_BANNER}
15642
15760
  * Three states, not a boolean, because the two "no icon to draw right now" cases must behave
15643
15761
  * differently and a boolean collapses them:
15644
15762
  * · `icon` — one is stored; draw it.
15645
- * · `none` — scanned, nothing found. Draw the monogram and DO NOT request the icon route,
15646
- * which is what stops an iconless project flashing its initials and then swapping.
15763
+ * · `none` — scanned recently, nothing found. Draw the monogram and DO NOT request the icon
15764
+ * route, which is what stops an iconless project flashing its initials and then
15765
+ * swapping. An OLD miss reads `unknown` again, so an icon added since is found.
15647
15766
  * · `unknown` — never scanned. The monogram shows, but the request MUST still go out, because
15648
15767
  * that request is what triggers the (lazy, cached) scan in the first place.
15649
15768
  *
@@ -20120,7 +20239,7 @@ import {
20120
20239
  realpathSync as realpathSync2,
20121
20240
  renameSync,
20122
20241
  rmSync as rmSync3,
20123
- statSync as statSync3,
20242
+ statSync as statSync4,
20124
20243
  writeFileSync as writeFileSync5
20125
20244
  } from "node:fs";
20126
20245
  import { homedir as homedir7 } from "node:os";
@@ -20171,7 +20290,7 @@ function syncDirectory(path) {
20171
20290
  }
20172
20291
  function lockOwnerPath(lockPath) {
20173
20292
  try {
20174
- return statSync3(lockPath).isDirectory() ? join8(lockPath, GLOBAL_LOCK_OWNER) : lockPath;
20293
+ return statSync4(lockPath).isDirectory() ? join8(lockPath, GLOBAL_LOCK_OWNER) : lockPath;
20175
20294
  } catch {
20176
20295
  return lockPath;
20177
20296
  }
@@ -20202,10 +20321,10 @@ function observeLock(lockPath, adapter) {
20202
20321
  }
20203
20322
  try {
20204
20323
  const ownerPath = lockOwnerPath(lockPath);
20205
- let modified = statSync3(lockPath).mtimeMs;
20324
+ let modified = statSync4(lockPath).mtimeMs;
20206
20325
  if (ownerPath !== lockPath) {
20207
20326
  try {
20208
- modified = Math.max(modified, statSync3(ownerPath).mtimeMs);
20327
+ modified = Math.max(modified, statSync4(ownerPath).mtimeMs);
20209
20328
  } catch {
20210
20329
  }
20211
20330
  }
@@ -20536,7 +20655,7 @@ import {
20536
20655
  readFileSync as readFileSync6,
20537
20656
  renameSync as renameSync2,
20538
20657
  rmSync as rmSync4,
20539
- statSync as statSync4,
20658
+ statSync as statSync5,
20540
20659
  writeFileSync as writeFileSync6
20541
20660
  } from "node:fs";
20542
20661
  import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute3, join as join9 } from "node:path";
@@ -20603,7 +20722,7 @@ function quarantine(path, suffix) {
20603
20722
  }
20604
20723
  function pathAge(path) {
20605
20724
  try {
20606
- return Date.now() - statSync4(path).mtimeMs;
20725
+ return Date.now() - statSync5(path).mtimeMs;
20607
20726
  } catch {
20608
20727
  return void 0;
20609
20728
  }
@@ -21009,7 +21128,7 @@ var init_boot_progress = __esm({
21009
21128
 
21010
21129
  // packages/server/src/project-root.ts
21011
21130
  import { createHash as createHash3, randomUUID as randomUUID4 } from "node:crypto";
21012
- import { closeSync as closeSync3, existsSync as existsSync4, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, statSync as statSync5, writeFileSync as writeFileSync8 } from "node:fs";
21131
+ import { closeSync as closeSync3, existsSync as existsSync4, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
21013
21132
  import { homedir as homedir8 } from "node:os";
21014
21133
  import { dirname as dirname8, join as join11, parse, resolve as resolve3 } from "node:path";
21015
21134
  function projectIdPath(root) {
@@ -21103,17 +21222,6 @@ function isHomeDirectory(dir, home = homedir8()) {
21103
21222
  return false;
21104
21223
  }
21105
21224
  }
21106
- function isWorldWritable(dir, platform = process.platform) {
21107
- if (platform === "win32") return false;
21108
- try {
21109
- return (statSync5(dir).mode & 2) !== 0;
21110
- } catch {
21111
- return false;
21112
- }
21113
- }
21114
- function refuseWorldWritableProject(dir) {
21115
- if (isWorldWritable(dir)) throw new Error(`A folder every account can write to cannot be a project: ${dir}`);
21116
- }
21117
21225
  function existingProjectId(dir) {
21118
21226
  const fromFile = readProjectIdFile(dir);
21119
21227
  if (fromFile) return fromFile;
@@ -21190,10 +21298,11 @@ function readRegistry(home = homedir9()) {
21190
21298
  const parsed = JSON.parse(raw);
21191
21299
  if (parsed?.version !== 1 || !Array.isArray(parsed.projects)) return { ...EMPTY, projects: [] };
21192
21300
  const forgotten = Array.isArray(parsed.forgotten) ? parsed.forgotten.filter((id) => typeof id === "string") : [];
21301
+ const host = canonicalPath(launchHostDir(home));
21193
21302
  return {
21194
21303
  version: 1,
21195
21304
  projects: parsed.projects.filter(
21196
- (p) => p && typeof p.id === "string" && typeof p.path === "string" && typeof p.slug === "string"
21305
+ (p) => p && typeof p.id === "string" && typeof p.path === "string" && typeof p.slug === "string" && p.path !== host
21197
21306
  ),
21198
21307
  ...forgotten.length > 0 ? { forgotten } : {}
21199
21308
  };
@@ -21227,6 +21336,12 @@ function writeRegistry(registry, home = homedir9()) {
21227
21336
  }
21228
21337
  throw error;
21229
21338
  }
21339
+ for (const listener of writeListeners) {
21340
+ try {
21341
+ listener(path);
21342
+ } catch {
21343
+ }
21344
+ }
21230
21345
  }
21231
21346
  function slugifyProject(value) {
21232
21347
  const s = slugify(value);
@@ -21297,20 +21412,11 @@ function registerProject(input, home = homedir9()) {
21297
21412
  writeRegistry(registry, home);
21298
21413
  return { entry, action: "created" };
21299
21414
  }
21300
- function listProjects(home = homedir9()) {
21301
- const byRecency = (a, b) => a.lastOpenedAt < b.lastOpenedAt ? 1 : a.lastOpenedAt > b.lastOpenedAt ? -1 : 0;
21302
- return readRegistry(home).projects.map((p) => ({ ...p, stale: !existsSync5(p.path) })).sort((a, b) => {
21303
- if (a.order === void 0 && b.order === void 0) return byRecency(a, b);
21304
- if (a.order === void 0) return 1;
21305
- if (b.order === void 0) return -1;
21306
- return a.order - b.order;
21307
- });
21308
- }
21309
21415
  function findByPath(path, home = homedir9()) {
21310
21416
  const canonical = canonicalPath(path);
21311
21417
  return readRegistry(home).projects.find((p) => p.path === canonical || p.path === path);
21312
21418
  }
21313
- var EMPTY, GENERIC, RESERVED;
21419
+ var EMPTY, GENERIC, RESERVED, writeListeners, ICON_RESCAN_AFTER_MS;
21314
21420
  var init_project_registry = __esm({
21315
21421
  "packages/server/src/project-registry.ts"() {
21316
21422
  "use strict";
@@ -21356,15 +21462,55 @@ var init_project_registry = __esm({
21356
21462
  "api",
21357
21463
  "health"
21358
21464
  ]);
21465
+ writeListeners = /* @__PURE__ */ new Set();
21466
+ ICON_RESCAN_AFTER_MS = 10 * 60 * 1e3;
21467
+ }
21468
+ });
21469
+
21470
+ // packages/server/src/launch-routing.ts
21471
+ import { realpathSync as realpathSync5 } from "node:fs";
21472
+ import { homedir as homedir10 } from "node:os";
21473
+ function classifyLaunch(dir, home = homedir10()) {
21474
+ const candidate = realpathSync5(discoverProjectRoot(dir, home));
21475
+ const refused = isHomeDirectory(candidate, home);
21476
+ if (!refused && (isExistingProjectRoot(candidate) || findByPath(candidate, home) !== void 0)) {
21477
+ return { kind: "open", root: candidate };
21478
+ }
21479
+ return !refused && hasProjectMarker(candidate) ? { kind: "offer", directory: candidate } : { kind: "grid" };
21480
+ }
21481
+ function offerPath(directory) {
21482
+ return `/?add=${encodeURIComponent(directory)}`;
21483
+ }
21484
+ function parseLaunchRoute(body) {
21485
+ if (!body || typeof body !== "object" || Array.isArray(body)) return void 0;
21486
+ const { protocol, path, project, notice } = body;
21487
+ if (protocol !== LAUNCH_ROUTE_PROTOCOL || typeof path !== "string") return void 0;
21488
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\") || /[\u0000-\u001f]/u.test(path)) return void 0;
21489
+ const named = project && typeof project === "object" ? project : void 0;
21490
+ return {
21491
+ path,
21492
+ ...named && typeof named.name === "string" && typeof named.dir === "string" ? { project: { name: named.name, dir: named.dir } } : {},
21493
+ ...typeof notice === "string" && notice.length > 0 ? { notice: notice.slice(0, 500) } : {}
21494
+ };
21495
+ }
21496
+ var LAUNCH_ROUTE_PROTOCOL, LAUNCH_ROUTE_PATH, LAUNCH_TOKEN_HEADER;
21497
+ var init_launch_routing = __esm({
21498
+ "packages/server/src/launch-routing.ts"() {
21499
+ "use strict";
21500
+ init_project_registry();
21501
+ init_project_root();
21502
+ LAUNCH_ROUTE_PROTOCOL = 1;
21503
+ LAUNCH_ROUTE_PATH = "/control/launch";
21504
+ LAUNCH_TOKEN_HEADER = "x-frizz-launch-token";
21359
21505
  }
21360
21506
  });
21361
21507
 
21362
21508
  // src/production.ts
21363
21509
  init_local_origin();
21364
- import { readFileSync as readFileSync14 } from "node:fs";
21365
- import { homedir as homedir11 } from "node:os";
21510
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14 } from "node:fs";
21511
+ import { homedir as homedir12 } from "node:os";
21366
21512
  import { fileURLToPath as fileURLToPath2 } from "node:url";
21367
- import { join as join17 } from "node:path";
21513
+ import { dirname as dirname14, join as join17 } from "node:path";
21368
21514
 
21369
21515
  // src/browser.ts
21370
21516
  import { spawn, execFile } from "node:child_process";
@@ -25459,14 +25605,14 @@ init_frizz_paths();
25459
25605
  import { execFileSync as execFileSync3 } from "node:child_process";
25460
25606
  import { createServer as createServer2 } from "node:net";
25461
25607
  import { randomUUID as randomUUID5 } from "node:crypto";
25462
- import { existsSync as existsSync6, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync10, realpathSync as realpathSync5, rmSync as rmSync8, symlinkSync as symlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
25463
- import { homedir as homedir10, tmpdir as tmpdir2 } from "node:os";
25608
+ import { existsSync as existsSync6, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync10, realpathSync as realpathSync6, rmSync as rmSync8, symlinkSync as symlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
25609
+ import { homedir as homedir11, tmpdir as tmpdir2 } from "node:os";
25464
25610
  import { basename as basename5, dirname as dirname10, join as join13, resolve as resolve4 } from "node:path";
25465
25611
  import { setTimeout as delay2 } from "node:timers/promises";
25466
25612
  init_project_root();
25613
+ init_launch_routing();
25467
25614
  init_logging();
25468
25615
  init_src();
25469
- init_project_registry();
25470
25616
  var PORT_SCAN_COUNT = 100;
25471
25617
  var LAUNCH_TIMEOUT_MS = BOOT_STALL_TIMEOUT_MS;
25472
25618
  var LAUNCH_HARD_TIMEOUT_MS = BOOT_HARD_TIMEOUT_MS;
@@ -25583,7 +25729,7 @@ function boardAddress(url) {
25583
25729
  return url.includes("?") || url.endsWith("/") ? url : `${url}/`;
25584
25730
  }
25585
25731
  var SANDBOX_DROPPED_XDG_ROOTS = ["XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"];
25586
- function prepareSandbox(env = process.env, realHome = homedir10()) {
25732
+ function prepareSandbox(env = process.env, realHome = homedir11()) {
25587
25733
  const home = mkdtempSync(join13(tmpdir2(), "frizz-sandbox-"));
25588
25734
  const inherited = { ...env };
25589
25735
  env.HOME = home;
@@ -25639,34 +25785,46 @@ function cleanupSandbox(home) {
25639
25785
  } catch {
25640
25786
  }
25641
25787
  }
25642
- function resolveLaunchIntent(cwd = process.cwd(), home = homedir10(), env = process.env) {
25643
- const candidate = realpathSync5(discoverProjectRoot(cwd, home));
25644
- const known = isExistingProjectRoot(candidate) || findByPath(candidate, home) !== void 0;
25645
- const refused = isHomeDirectory(candidate, home) || isWorldWritable(candidate);
25646
- if (known && !refused) return { kind: "open", workspace: resolveWorkspace(cwd, home, env) };
25647
- const host = mostRecentProject(home, env) ?? hostWorkspace(home);
25648
- return !refused && hasProjectMarker(candidate) ? { kind: "offer", workspace: host, directory: candidate } : { kind: "grid", workspace: host };
25788
+ function resolveLaunchIntent(cwd = process.cwd(), home = homedir11(), env = process.env) {
25789
+ const launch = classifyLaunch(cwd, home);
25790
+ if (launch.kind === "open") return { kind: "open", workspace: resolveWorkspace(cwd, home, env) };
25791
+ const host = hostWorkspace(home);
25792
+ return launch.kind === "offer" ? { kind: "offer", workspace: host, directory: launch.directory } : { kind: "grid", workspace: host };
25649
25793
  }
25650
- function hostWorkspace(home = homedir10()) {
25794
+ async function askLaunchRoute(port, ownerToken, dir, options2 = {}) {
25795
+ const fetcher = options2.fetcher ?? fetch;
25796
+ const deadline = Date.now() + (options2.patienceMs ?? 6e3);
25797
+ for (; ; ) {
25798
+ const controller = new AbortController();
25799
+ const timeout = setTimeout(() => controller.abort(), options2.timeoutMs ?? 5e3);
25800
+ timeout.unref?.();
25801
+ try {
25802
+ const response = await fetcher(`http://127.0.0.1:${port}${FRIZZ_ROUTE_PREFIX}${LAUNCH_ROUTE_PATH}`, {
25803
+ method: "POST",
25804
+ headers: { "content-type": "application/json", [LAUNCH_TOKEN_HEADER]: ownerToken },
25805
+ body: JSON.stringify({ protocol: LAUNCH_ROUTE_PROTOCOL, dir, ...options2.args?.length ? { args: options2.args } : {} }),
25806
+ signal: controller.signal
25807
+ });
25808
+ if (response.status === 200) return parseLaunchRoute(await response.json());
25809
+ if (response.status !== 503 || Date.now() >= deadline) return void 0;
25810
+ } catch {
25811
+ return void 0;
25812
+ } finally {
25813
+ clearTimeout(timeout);
25814
+ }
25815
+ await delay2(250);
25816
+ }
25817
+ }
25818
+ function hostWorkspace(home = homedir11()) {
25651
25819
  const dir = launchHostDir(home);
25652
25820
  mkdirSync10(dir, { recursive: true });
25653
- const root = realpathSync5(dir);
25821
+ const root = realpathSync6(dir);
25654
25822
  const id = ensureProjectIdFile(root, home);
25655
25823
  const stateDir = projectStateDir(id, home);
25656
25824
  mkdirSync10(stateDir, { recursive: true });
25657
25825
  return { root, id, stateDir, name: basename5(root), identityScope: "repository" };
25658
25826
  }
25659
- function mostRecentProject(home, env) {
25660
- for (const entry of listProjects(home)) {
25661
- if (entry.stale || isHomeDirectory(entry.path, home) || isWorldWritable(entry.path)) continue;
25662
- try {
25663
- return resolveWorkspace(entry.path, home, env);
25664
- } catch {
25665
- }
25666
- }
25667
- return void 0;
25668
- }
25669
- function resolveWorkspace(cwd = process.cwd(), home = homedir10(), env = process.env) {
25827
+ function resolveWorkspace(cwd = process.cwd(), home = homedir11(), env = process.env) {
25670
25828
  let gitRoot;
25671
25829
  try {
25672
25830
  gitRoot = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
@@ -25680,8 +25838,7 @@ function resolveWorkspace(cwd = process.cwd(), home = homedir10(), env = process
25680
25838
  if (!isNotAGitWorktree(error)) throw new Error("unable to resolve Git repository root");
25681
25839
  gitRoot = void 0;
25682
25840
  }
25683
- const root0 = realpathSync5(gitRoot ?? discoverProjectRoot(cwd, home));
25684
- refuseWorldWritableProject(root0);
25841
+ const root0 = realpathSync6(gitRoot ?? discoverProjectRoot(cwd, home));
25685
25842
  const identity = gitRoot ? resolveGitProjectIdentity(root0, home) : void 0;
25686
25843
  const root = identity?.root ?? root0;
25687
25844
  const id = ensureProjectIdFile(root, home, identity?.id);
@@ -25712,7 +25869,7 @@ function workspaceLaunchTarget(workspace2) {
25712
25869
  function workspaceFromLaunchTarget(target2, env = process.env) {
25713
25870
  let root;
25714
25871
  try {
25715
- root = realpathSync5(target2.projectDir);
25872
+ root = realpathSync6(target2.projectDir);
25716
25873
  } catch {
25717
25874
  throw new Error("pinned Frizz workspace is no longer available");
25718
25875
  }
@@ -26483,9 +26640,25 @@ var Supervisor = class {
26483
26640
  if (this.restartCompletion === completion) this.restartCompletion = null;
26484
26641
  }
26485
26642
  }
26643
+ /**
26644
+ * A restart or an update drains the running child and forks its replacement in the project the
26645
+ * server is hosted on. Once that folder is deleted the fork fails with ENOENT, and a child given
26646
+ * any other cwd refuses to boot without its project — and so does the one that would put the old
26647
+ * child back, so the click meant to refresh the board stops it. The running child is unharmed by
26648
+ * the missing folder, so refuse before draining and leave it serving.
26649
+ *
26650
+ * Measured 2026-10-11 on a board hosted on a folder deleted a day later: its Update failed one
26651
+ * step earlier, on npm dying with `uv_cwd ENOENT`, and that accident was all that kept it up.
26652
+ */
26653
+ missingLaunchFolder() {
26654
+ if (existsSync7(this.cwd)) return void 0;
26655
+ return `Frizz is running on ${this.cwd}, which no longer exists, so a new server cannot start there. Stop Frizz and start it again.`;
26656
+ }
26486
26657
  /** Called only by the public supervisor control endpoint, never by ordinary document reloads. */
26487
26658
  restartFromBrowser() {
26488
26659
  if (this.browserRestart) return this.browserRestart;
26660
+ const missing = this.missingLaunchFolder();
26661
+ if (missing) return Promise.resolve({ state: "failed", message: missing });
26489
26662
  const previousBoot = this.boot?.bootId;
26490
26663
  const work = (async () => {
26491
26664
  this.lastRestartFailure = void 0;
@@ -26511,6 +26684,8 @@ var Supervisor = class {
26511
26684
  }
26512
26685
  async updateFromBrowser() {
26513
26686
  if (!this.updateRestart) return { state: "failed", message: "Update & Restart is unavailable in this launcher mode" };
26687
+ const missing = this.missingLaunchFolder();
26688
+ if (missing) return { state: "failed", message: missing };
26514
26689
  if (this.updateMode === "child") return this.updateChildFromBrowser();
26515
26690
  this.emitActivity("updating", "preparing the update \u2014 the running Frizz is untouched until it is ready");
26516
26691
  const candidate = await this.updateRestart();
@@ -27220,12 +27395,13 @@ import {
27220
27395
  mkdirSync as mkdirSync12,
27221
27396
  openSync as openSync6,
27222
27397
  readFileSync as readFileSync12,
27223
- realpathSync as realpathSync6,
27398
+ realpathSync as realpathSync7,
27224
27399
  renameSync as renameSync6,
27225
27400
  rmSync as rmSync9,
27226
27401
  statSync as statSync6,
27227
27402
  writeFileSync as writeFileSync11
27228
27403
  } from "node:fs";
27404
+ import { tmpdir as tmpdir3 } from "node:os";
27229
27405
  import { delimiter as delimiter2, dirname as dirname13, isAbsolute as isAbsolute6, join as join15, relative as relative3, resolve as resolve6, sep as sep4 } from "node:path";
27230
27406
  var SERVER_PROTOCOL = 1;
27231
27407
  var SERVER_DATA_EPOCH = 1;
@@ -27262,9 +27438,18 @@ function serverReleaseSpec(manifest, packageOverride) {
27262
27438
  assertSpec(spec);
27263
27439
  return spec;
27264
27440
  }
27441
+ function serverReleaseMinimum(manifest) {
27442
+ const metadata = record(record(manifest).frizzServer);
27443
+ const minimum = metadata.minimum;
27444
+ if (minimum === void 0) return void 0;
27445
+ if (typeof minimum !== "string" || !VERSION.test(minimum)) throw new Error("invalid minimum Frizz server version");
27446
+ const order = typeof metadata.version === "string" ? compareReleaseVersions(metadata.version, minimum) : null;
27447
+ if (order === null || order < 0) throw new Error("the pinned Frizz server is older than the minimum this launcher declares");
27448
+ return minimum;
27449
+ }
27265
27450
  function containedFile(root, name, directory = false) {
27266
- const path = realpathSync6(join15(root, name));
27267
- const rel = relative3(realpathSync6(root), path);
27451
+ const path = realpathSync7(join15(root, name));
27452
+ const rel = relative3(realpathSync7(root), path);
27268
27453
  if (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute6(rel))
27269
27454
  throw new Error(`Frizz server artifact escapes its package: ${name}`);
27270
27455
  const stat = statSync6(path);
@@ -27286,7 +27471,7 @@ function validateServerGeneration(root, spec, id, compatibility = CURRENT_COMPAT
27286
27471
  containedFile(root, "web-dist/index.html");
27287
27472
  containedFile(root, "runtime/board/index.mjs");
27288
27473
  containedFile(root, "runtime/cc-worker/.claude-plugin/plugin.json");
27289
- return { ...spec, id, root: realpathSync6(root), entry };
27474
+ return { ...spec, id, root: realpathSync7(root), entry };
27290
27475
  }
27291
27476
  function serverGenerationLaunch(generation) {
27292
27477
  return {
@@ -27305,7 +27490,7 @@ function resolveNpmCli(env = process.env, executable = process.execPath) {
27305
27490
  for (const directory of [dirname13(executable), ...(env.PATH ?? env.Path ?? "").split(delimiter2).filter(Boolean)]) {
27306
27491
  candidates.push(join15(directory, "node_modules/npm/bin/npm-cli.js"), join15(directory, "../lib/node_modules/npm/bin/npm-cli.js"));
27307
27492
  try {
27308
- const path = realpathSync6(join15(directory, "npm"));
27493
+ const path = realpathSync7(join15(directory, "npm"));
27309
27494
  if (path.endsWith("npm-cli.js")) candidates.push(path);
27310
27495
  } catch {
27311
27496
  }
@@ -27314,7 +27499,15 @@ function resolveNpmCli(env = process.env, executable = process.execPath) {
27314
27499
  if (!found) throw new Error("npm was not found beside Node or on PATH; install Node with npm to install Frizz server releases");
27315
27500
  return resolve6(found);
27316
27501
  }
27317
- function npmServerPackageInstaller(env = process.env) {
27502
+ var NPM_OPERATION_TIMEOUT_MS = 18e4;
27503
+ function npmFailureSummary(stderr) {
27504
+ const lines = stderr.trim().split("\n");
27505
+ const tail = lines.slice(-8);
27506
+ const cause = lines.find((line) => /^(?:[A-Z]\w*)?Error(?: \[\w+\])?: /u.test(line));
27507
+ return (cause && !tail.includes(cause) ? [cause, ...tail] : tail).join("\n");
27508
+ }
27509
+ function npmServerPackageInstaller(env = process.env, options2 = {}) {
27510
+ const timeoutMs = options2.timeoutMs ?? NPM_OPERATION_TIMEOUT_MS;
27318
27511
  const run2 = (args2, cwd) => new Promise((resolveOutput, reject) => {
27319
27512
  let cli;
27320
27513
  try {
@@ -27328,14 +27521,17 @@ function npmServerPackageInstaller(env = process.env) {
27328
27521
  cwd,
27329
27522
  encoding: "utf8",
27330
27523
  windowsHide: true,
27331
- timeout: 18e4,
27524
+ timeout: timeoutMs,
27332
27525
  killSignal: "SIGKILL",
27333
27526
  maxBuffer: 4 * 1024 * 1024
27334
27527
  }, (error, stdout, stderr) => {
27335
- if (error) {
27336
- const tail = stderr.trim().split("\n").slice(-8).join("\n");
27337
- reject(new Error(`Frizz server npm operation failed: ${tail || error.message}`));
27338
- } else resolveOutput(stdout);
27528
+ if (!error) {
27529
+ resolveOutput(stdout);
27530
+ return;
27531
+ }
27532
+ const limit = timeoutMs >= 6e4 ? `${Math.ceil(timeoutMs / 6e4)}m` : `${Math.ceil(timeoutMs / 1e3)}s`;
27533
+ const why = error.killed ? `npm ${args2[0]} did not finish within ${limit} and was stopped` : npmFailureSummary(stderr) || error.message;
27534
+ reject(new Error(`Frizz server npm operation failed: ${why}`));
27339
27535
  });
27340
27536
  });
27341
27537
  return {
@@ -27359,7 +27555,7 @@ function npmServerPackageInstaller(env = process.env) {
27359
27555
  },
27360
27556
  async latestVersion(packageName) {
27361
27557
  if (!PACKAGE.test(packageName)) throw new Error("invalid Frizz server package name");
27362
- const parsed = JSON.parse(await run2(["view", `${packageName}@latest`, "version", "--json"]));
27558
+ const parsed = JSON.parse(await run2(["view", `${packageName}@latest`, "version", "--json"], tmpdir3()));
27363
27559
  const version = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
27364
27560
  if (typeof version !== "string" || !VERSION.test(version)) throw new Error("npm returned an invalid Frizz server version");
27365
27561
  return version;
@@ -27393,11 +27589,12 @@ function atomicJson2(path, value) {
27393
27589
  }
27394
27590
  }
27395
27591
  var ServerReleaseStore = class {
27396
- constructor(spec, installer, roots = frizzPaths(), compatibility = CURRENT_COMPATIBILITY) {
27592
+ constructor(spec, installer, roots = frizzPaths(), compatibility = CURRENT_COMPATIBILITY, minimum) {
27397
27593
  this.spec = spec;
27398
27594
  this.installer = installer;
27399
27595
  this.roots = roots;
27400
27596
  this.compatibility = compatibility;
27597
+ this.minimum = minimum;
27401
27598
  assertSpec(spec, compatibility);
27402
27599
  const key = createHash4("sha256").update(spec.package).digest("hex").slice(0, 16);
27403
27600
  this.generations = join15(roots.cache, "server-releases", key);
@@ -27447,6 +27644,22 @@ var ServerReleaseStore = class {
27447
27644
  this.markCompatibilityBeforeLaunch();
27448
27645
  return generation;
27449
27646
  }
27647
+ /**
27648
+ * The selected release, when it is older than this shell works with: `load` will start the pinned
27649
+ * one instead. Undefined for a machine with no selection, an unreadable one (`load` reports that),
27650
+ * and any selection at or above the minimum.
27651
+ */
27652
+ belowMinimum() {
27653
+ if (this.minimum === void 0 || !existsSync9(this.selection)) return void 0;
27654
+ let selected;
27655
+ try {
27656
+ selected = record(readJson(this.selection)).version;
27657
+ } catch {
27658
+ return void 0;
27659
+ }
27660
+ if (typeof selected !== "string" || compareReleaseVersions(selected, this.minimum) !== -1) return void 0;
27661
+ return { selected, minimum: this.minimum, pinned: this.spec.version };
27662
+ }
27450
27663
  async load() {
27451
27664
  this.assertMarkerIsSupported();
27452
27665
  if (!existsSync9(this.selection)) return this.prepareInitial(this.spec.version);
@@ -27460,6 +27673,7 @@ var ServerReleaseStore = class {
27460
27673
  if (spec.dataEpoch < this.compatibility.dataEpoch) return this.prepareInitial(this.spec.version);
27461
27674
  if (spec.dataEpoch > this.compatibility.dataEpoch)
27462
27675
  throw new Error("this Frizz server requires a newer launcher or a data migration; restart with a compatible Frizz release");
27676
+ if (this.belowMinimum()) return this.prepareInitial(this.spec.version);
27463
27677
  const root = this.packageRoot(selected.id);
27464
27678
  const generation = !existsSync9(root) ? await this.prepare(spec.version) : validateServerGeneration(root, spec, selected.id, this.compatibility);
27465
27679
  this.markCompatibilityBeforeLaunch();
@@ -27484,7 +27698,7 @@ var ServerReleaseStore = class {
27484
27698
  }
27485
27699
  }
27486
27700
  commit(generation) {
27487
- if (generation.package !== this.spec.package || generation.root !== realpathSync6(this.packageRoot(generation.id)))
27701
+ if (generation.package !== this.spec.package || generation.root !== realpathSync7(this.packageRoot(generation.id)))
27488
27702
  throw new Error("cannot select a Frizz server outside this release store");
27489
27703
  validateServerGeneration(generation.root, generation, generation.id, this.compatibility);
27490
27704
  const { package: packageName, version, protocol, dataEpoch, id } = generation;
@@ -27496,7 +27710,7 @@ var ServerReleaseStore = class {
27496
27710
  init_frizz_paths();
27497
27711
  init_project_launch();
27498
27712
  import { randomUUID as randomUUID8 } from "node:crypto";
27499
- import { mkdirSync as mkdirSync13, readFileSync as readFileSync13, realpathSync as realpathSync7 } from "node:fs";
27713
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync13, realpathSync as realpathSync8 } from "node:fs";
27500
27714
  import { join as join16 } from "node:path";
27501
27715
  var STABLE_SERVER_OWNER_NAME = "frizz-server";
27502
27716
  var STABLE_SERVER_OWNER_PROJECT_ID = "c1fd5810-0f8a-4c1d-91a0-6d7445d28e5a";
@@ -27510,7 +27724,7 @@ function stableServerOwnerTarget(roots) {
27510
27724
  mkdirSync13(resolved.data, { recursive: true, mode: 448 });
27511
27725
  return {
27512
27726
  projectId: STABLE_SERVER_OWNER_PROJECT_ID,
27513
- projectDir: realpathSync7(resolved.data),
27727
+ projectDir: realpathSync8(resolved.data),
27514
27728
  stateDir: join16(resolved.state, STABLE_SERVER_OWNER_NAME)
27515
27729
  };
27516
27730
  }
@@ -27587,6 +27801,9 @@ function releaseHeldLease(lease) {
27587
27801
  return true;
27588
27802
  }
27589
27803
 
27804
+ // src/production.ts
27805
+ init_launch_routing();
27806
+
27590
27807
  // src/preflight.ts
27591
27808
  import { spawnSync } from "node:child_process";
27592
27809
  var SUPPORTED_NODE_LINES = [
@@ -27713,7 +27930,13 @@ var serverOwner;
27713
27930
  process.on("exit", () => {
27714
27931
  if (serverOwner) releaseStableServerOwner(serverOwner);
27715
27932
  });
27716
- var launchIntent;
27933
+ var launchDir = (() => {
27934
+ try {
27935
+ return process.cwd();
27936
+ } catch {
27937
+ return homedir12();
27938
+ }
27939
+ })();
27717
27940
  var workspace = (() => {
27718
27941
  try {
27719
27942
  if (!reexec) {
@@ -27725,21 +27948,26 @@ var workspace = (() => {
27725
27948
  if (!pinned) throw new Error("registry successor is missing its pinned project identity");
27726
27949
  return workspaceFromLaunchTarget(pinned);
27727
27950
  }
27728
- const intent = resolveLaunchIntent();
27729
- launchIntent = intent;
27730
- return intent.workspace;
27951
+ return hostWorkspace();
27731
27952
  } catch (error) {
27732
27953
  return fail(error);
27733
27954
  }
27734
27955
  })();
27735
- process.chdir(workspace.root);
27956
+ function runningBoardPort() {
27957
+ const machine = readStableServerOwner();
27958
+ if (machine.kind === "running") return machine.port;
27959
+ return liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace))?.port;
27960
+ }
27961
+ function controlPorts() {
27962
+ const machine = readStableServerOwner();
27963
+ return [.../* @__PURE__ */ new Set([...machine.kind === "running" ? [machine.port] : [], DEFAULT_PORT, fallbackPort(DEFAULT_PORT)])];
27964
+ }
27736
27965
  if (options.stop) {
27737
27966
  try {
27738
- const control = await resolveLaunchControlTarget({ stateDir: workspace.stateDir, target: workspaceLaunchTarget(workspace) });
27739
- const where = control.host ? workspace.root : `${workspace.root} (the board was launched from ${control.target.projectDir})`;
27967
+ const control = await resolveLaunchControlTarget({ stateDir: workspace.stateDir, target: workspaceLaunchTarget(workspace), ports: controlPorts() });
27740
27968
  const result = await stopProjectLaunch({ stateDir: control.stateDir, target: control.target });
27741
27969
  console.log(
27742
- result.kind !== "stopped" ? `Frizz is not running for ${where}` : result.stale ? `Frizz was not running for ${where}; cleared the record a dead board left behind` : `stopped Frizz for ${where}; running agents keep going \u2014 they are detached daemons`
27970
+ result.kind !== "stopped" ? "Frizz is not running" : result.stale ? "Frizz was not running; cleared the record a dead board left behind" : "stopped Frizz; running agents keep going \u2014 they are detached daemons"
27743
27971
  );
27744
27972
  process.exit(0);
27745
27973
  } catch (error) {
@@ -27747,14 +27975,13 @@ if (options.stop) {
27747
27975
  }
27748
27976
  }
27749
27977
  if (options.status) {
27750
- const control = await resolveLaunchControlTarget({ stateDir: workspace.stateDir, target: workspaceLaunchTarget(workspace) });
27978
+ const control = await resolveLaunchControlTarget({ stateDir: workspace.stateDir, target: workspaceLaunchTarget(workspace), ports: controlPorts() });
27751
27979
  const running = await runningFrizzStatus({ stateDir: control.stateDir, target: control.target });
27752
27980
  if (!running) {
27753
- console.log(`Frizz is not running for ${workspace.root}`);
27981
+ console.log("Frizz is not running");
27754
27982
  process.exit(1);
27755
27983
  }
27756
27984
  console.log(`running: http://127.0.0.1:${running.port}`);
27757
- console.log(`workspace: ${workspace.root}`);
27758
27985
  if (!control.host) console.log(`launched from: ${control.target.projectDir} (one server serves every project)`);
27759
27986
  console.log(`supervisor pid: ${running.pid}`);
27760
27987
  console.log(`version: ${running.version ?? "unknown"}`);
@@ -27762,23 +27989,23 @@ if (options.status) {
27762
27989
  process.exit(0);
27763
27990
  }
27764
27991
  if (options.sessions || options.signOut !== void 0) {
27765
- const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
27766
- if (!running?.port) {
27767
- console.error("frizz: no board is running for this workspace");
27992
+ const port = runningBoardPort();
27993
+ if (!port) {
27994
+ console.error("frizz: Frizz is not running");
27768
27995
  process.exit(1);
27769
27996
  }
27770
- if (options.signOut !== void 0) await signOutSession(running.port, options.signOut);
27771
- await listSessions(running.port);
27997
+ if (options.signOut !== void 0) await signOutSession(port, options.signOut);
27998
+ await listSessions(port);
27772
27999
  }
27773
28000
  if (options.link) {
27774
- const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
27775
- if (!running?.port) {
27776
- console.error("frizz: no board is running for this workspace");
28001
+ const port = runningBoardPort();
28002
+ if (!port) {
28003
+ console.error("frizz: Frizz is not running");
27777
28004
  process.exit(1);
27778
28005
  }
27779
- const response = await fetch(`http://127.0.0.1:${running.port}${SUPERVISOR_ACCESS_CODE_PATH}`, {
28006
+ const response = await fetch(`http://127.0.0.1:${port}${SUPERVISOR_ACCESS_CODE_PATH}`, {
27780
28007
  method: "POST",
27781
- headers: { origin: `http://127.0.0.1:${running.port}` }
28008
+ headers: { origin: `http://127.0.0.1:${port}` }
27782
28009
  }).catch(() => void 0);
27783
28010
  if (!response?.ok) {
27784
28011
  const detail = response ? (await response.json().catch(() => void 0))?.error : void 0;
@@ -27802,7 +28029,7 @@ readout?.plan([
27802
28029
  { key: "browser", label: options.noApp ? "Address" : "Browser" }
27803
28030
  ]);
27804
28031
  readout?.begin("server", "starting");
27805
- logger.info("launcher", `Frizz launcher ${PACKAGE_VERSION} starting for ${workspace.root}`);
28032
+ logger.info("launcher", `Frizz launcher ${PACKAGE_VERSION} starting in ${launchDir}`);
27806
28033
  var target = workspaceLaunchTarget(workspace);
27807
28034
  var expected = expectedOwnerHealth(target, readProjectLaunchOwner(workspace.stateDir));
27808
28035
  async function existingPort() {
@@ -27811,39 +28038,54 @@ async function existingPort() {
27811
28038
  for (const port of new Set(ports)) if (await probeFrizz(port, expected)) return port;
27812
28039
  return void 0;
27813
28040
  }
27814
- function ownSlug() {
28041
+ var legacyIntentCache;
28042
+ function legacyIntent() {
28043
+ return legacyIntentCache ??= resolveLaunchIntent(launchDir);
28044
+ }
28045
+ function legacyProject() {
28046
+ const intent = legacyIntent();
28047
+ if (intent.kind !== "open") return void 0;
28048
+ const { id, root, name } = intent.workspace;
27815
28049
  try {
27816
- return registerProject(
27817
- { dir: workspace.root, id: target.projectId, remoteOwner: resolveProjectLabel(workspace.root)?.split("/")[0] },
27818
- homedir11()
27819
- ).entry?.slug;
28050
+ const slug = registerProject({ dir: root, id, remoteOwner: resolveProjectLabel(root)?.split("/")[0] }, homedir12()).entry?.slug;
28051
+ return slug ? { id, root, name, slug } : void 0;
27820
28052
  } catch {
27821
28053
  return void 0;
27822
28054
  }
27823
28055
  }
27824
- var cachedSlugPath;
27825
- function slugPath() {
27826
- if (cachedSlugPath === void 0) {
27827
- if (launchIntent?.kind === "grid") cachedSlugPath = "/";
27828
- else if (launchIntent?.kind === "offer") cachedSlugPath = `/?add=${encodeURIComponent(launchIntent.directory)}`;
27829
- else {
27830
- const slug = ownSlug();
27831
- cachedSlugPath = slug ? `/project/${slug}` : "";
27832
- }
28056
+ function legacyLanding() {
28057
+ try {
28058
+ const intent = legacyIntent();
28059
+ if (intent.kind === "grid") return { path: "/" };
28060
+ if (intent.kind === "offer") return { path: offerPath(intent.directory) };
28061
+ const project = legacyProject();
28062
+ return project ? { path: `/project/${project.slug}`, project: { name: project.name, dir: project.root } } : { path: "/" };
28063
+ } catch (error) {
28064
+ return { path: "/", notice: `Could not open ${launchDir}: ${error instanceof Error ? error.message : String(error)}` };
27833
28065
  }
27834
- return cachedSlugPath;
27835
28066
  }
27836
- async function joinRunningFrizz() {
27837
- const slug = ownSlug();
27838
- if (!slug) return void 0;
28067
+ async function resolveLanding(port, ownerToken) {
28068
+ const answered = ownerToken ? await askLaunchRoute(port, ownerToken, launchDir) : void 0;
28069
+ if (answered) return answered;
28070
+ logger.info("launcher", "the running server did not say where this launch lands; using this launcher's own rules");
28071
+ return legacyLanding();
28072
+ }
28073
+ async function joinPreSplitFrizz() {
27839
28074
  for (const port of /* @__PURE__ */ new Set([DEFAULT_PORT, fallbackPort(DEFAULT_PORT)])) {
27840
- if (await probeFrizz(port, { projectId: target.projectId, projectDir: target.projectDir, slug }))
27841
- return { port, slug };
28075
+ if (!await readFrizzHealth(port)) continue;
28076
+ let project;
28077
+ try {
28078
+ project = legacyProject();
28079
+ } catch {
28080
+ return void 0;
28081
+ }
28082
+ if (!project) return void 0;
28083
+ if (await probeFrizz(port, { projectId: project.id, projectDir: project.root, slug: project.slug })) return port;
27842
28084
  }
27843
28085
  return void 0;
27844
28086
  }
27845
- async function openOrPrint(port, reused, path = "") {
27846
- const url = `http://127.0.0.1:${port}${path}`;
28087
+ async function openOrPrint(port, reused, landing) {
28088
+ const url = `http://127.0.0.1:${port}${landing.path}`;
27847
28089
  logger.info("launcher", `${reused ? "reusing" : "started"} Frizz at ${url}`);
27848
28090
  readout?.settle("server", "done", reused ? `already running on port ${port}` : `port ${port}`);
27849
28091
  let browser;
@@ -27870,26 +28112,27 @@ async function openOrPrint(port, reused, path = "") {
27870
28112
  const warnings = [];
27871
28113
  if (sandbox) warnings.push(`Sandbox: everything here is throwaway (${sandbox.home}) and is deleted when this terminal closes.`);
27872
28114
  if (sessionNotice && !reused) warnings.push(sessionNotice);
28115
+ if (landing.notice) warnings.push(landing.notice);
27873
28116
  if (!readout) {
27874
- console.log(`${reused ? "reusing" : "started"} Frizz${launchIntent && launchIntent.kind !== "open" ? "" : ` for ${workspace.root}`}`);
28117
+ console.log(`${reused ? "reusing" : "started"} Frizz${landing.project ? ` for ${landing.project.dir}` : ""}`);
27875
28118
  console.log(url);
27876
28119
  if (publicOrigin) console.log(activeAccessLink?.url ?? `${publicOrigin}/`);
27877
28120
  for (const warning of warnings) console.log(warning);
27878
28121
  return;
27879
28122
  }
27880
- const home = homedir11();
28123
+ const home = homedir12();
27881
28124
  readout.ready(
27882
28125
  [
27883
28126
  { label: "Local", value: boardAddress(url), accent: true },
27884
28127
  ...publicOrigin ? [{ label: "Public", value: activeAccessLink?.url ?? `${publicOrigin}/`, accent: true }] : [],
27885
- // Only an `open` launch is ABOUT its workspace; any other is hosted on a project the operator
27886
- // did not pick (launcher.ts resolveLaunchIntent), and naming it here reads as "you opened this".
27887
- ...launchIntent && launchIntent.kind !== "open" ? [] : [{ label: "Project", value: `${workspace.name} \u2014 ${tildePath(workspace.root, home)}` }],
28128
+ // Only a launch that OPENED a project names one: the dashboard and an offer are about no
28129
+ // project, and the folder the server runs on is Frizz's own.
28130
+ ...landing.project ? [{ label: "Project", value: `${landing.project.name} \u2014 ${tildePath(landing.project.dir, home)}` }] : [],
27888
28131
  ...logger.file ? [{ label: "Logs", value: tildePath(logger.file, home) }] : []
27889
28132
  ],
27890
28133
  reused ? (
27891
28134
  // This launch owns nothing and exits immediately, so ctrl-c would not stop what it reopened.
27892
- "reopened the server already running for this project \xB7 stop it from the terminal that started it"
28135
+ "reopened the Frizz already running on this machine \xB7 stop it from the terminal that started it"
27893
28136
  ) : options.debug ? void 0 : publicOrigin ? "press L for a fresh sign-in link \xB7 R to change how this board is reached \xB7 ctrl-c to stop" : "press R to reach this board from a phone or another machine \xB7 ctrl-c to stop",
27894
28137
  {
27895
28138
  ...reused ? { status: `already running on port ${port}` } : {},
@@ -27914,32 +28157,49 @@ async function runSupervisor(port, token, onPrepared = () => {
27914
28157
  target,
27915
28158
  owner.token
27916
28159
  );
27917
- const spec = serverReleaseSpec(
27918
- JSON.parse(readFileSync14(join17(import.meta.dirname, "..", "package.json"), "utf8")),
27919
- process.env.FRIZZ_SERVER_PACKAGE
27920
- );
28160
+ const manifest = JSON.parse(readFileSync14(join17(import.meta.dirname, "..", "package.json"), "utf8"));
28161
+ const spec = serverReleaseSpec(manifest, process.env.FRIZZ_SERVER_PACKAGE);
27921
28162
  const installer = npmServerPackageInstaller(env);
27922
- const store = new ServerReleaseStore(spec, installer);
27923
- activityReadout?.notice("progress", "Starting", "loading the selected Frizz server release");
28163
+ const store = new ServerReleaseStore(spec, installer, void 0, void 0, serverReleaseMinimum(manifest));
28164
+ const raise = store.belowMinimum();
28165
+ if (raise)
28166
+ logger.info("launcher", `the installed Frizz server ${raise.selected} is older than this launcher works with (${raise.minimum}); starting on ${raise.pinned}`);
28167
+ activityReadout?.notice(
28168
+ "progress",
28169
+ "Starting",
28170
+ raise ? `installing Frizz server ${raise.pinned}: ${raise.selected} is older than this launcher works with` : "loading the selected Frizz server release"
28171
+ );
27924
28172
  let active = await store.load();
27925
28173
  let pending;
27926
28174
  let previous = active;
27927
28175
  onPrepared();
27928
28176
  let updateAvailable = false;
27929
28177
  let updateVersion;
28178
+ let probeFailure;
27930
28179
  const refreshUpdateAvailable = async () => {
27931
28180
  try {
27932
28181
  const checkedVersion = active.version;
27933
28182
  const plan = await planRegistryUpdate(spec.package, checkedVersion, installer);
28183
+ probeFailure = void 0;
27934
28184
  if (active.version !== checkedVersion) return;
27935
28185
  updateAvailable = plan !== null;
27936
28186
  updateVersion = plan?.latestVersion;
27937
- } catch {
28187
+ } catch (error) {
28188
+ const message = error instanceof Error ? error.message : String(error);
28189
+ if (message !== probeFailure) logger.warn("launcher", `could not check npm for a newer Frizz server: ${message}`);
28190
+ probeFailure = message;
27938
28191
  }
27939
28192
  };
27940
28193
  void refreshUpdateAvailable();
27941
28194
  const updateAvailablePoll = setInterval(() => void refreshUpdateAvailable(), 30 * 60 * 1e3);
27942
28195
  updateAvailablePoll.unref();
28196
+ try {
28197
+ const projects = dirname14(workspace.stateDir);
28198
+ const adopted = adoptSessionState(workspace.stateDir, readdirSync4(projects).map((name) => join17(projects, name)));
28199
+ if (adopted) logger.info("launcher", `carried the remote sign-in state over from ${adopted}`);
28200
+ } catch (error) {
28201
+ logger.warn("launcher", `could not carry the remote sign-in state over: ${error instanceof Error ? error.message : String(error)}`);
28202
+ }
27943
28203
  const sessions = loadSessionState(workspace.stateDir);
27944
28204
  const epochRecord = sessionEpochLogLine(sessions.advance, sessions.signedOut);
27945
28205
  if (epochRecord) logger.warn("launcher", epochRecord);
@@ -28043,7 +28303,7 @@ async function runSupervisor(port, token, onPrepared = () => {
28043
28303
  paneHost?.dispose();
28044
28304
  remote?.stop();
28045
28305
  const suffix = logger.file ? `
28046
- log: ${tildePath(logger.file, homedir11())}` : "";
28306
+ log: ${tildePath(logger.file, homedir12())}` : "";
28047
28307
  process.stdout.write(
28048
28308
  code === 0 ? `
28049
28309
  Frizz stopped. Running agents keep going \u2014 they are detached daemons.${suffix}
@@ -28080,7 +28340,7 @@ try {
28080
28340
  if (globalClaim.kind === "running") {
28081
28341
  if (options.port && options.port !== globalClaim.port)
28082
28342
  throw new Error(`Frizz is already running on port ${globalClaim.port}; --port cannot change a running server`);
28083
- await openOrPrint(globalClaim.port, true, slugPath());
28343
+ await openOrPrint(globalClaim.port, true, await resolveLanding(globalClaim.port, globalClaim.owner.token));
28084
28344
  process.exit(0);
28085
28345
  }
28086
28346
  serverOwner = globalClaim.lease;
@@ -28092,16 +28352,16 @@ try {
28092
28352
  }
28093
28353
  const existing = await existingPort();
28094
28354
  if (existing) {
28095
- await openOrPrint(existing, true, slugPath());
28355
+ await openOrPrint(existing, true, await resolveLanding(existing, readProjectLaunchOwner(workspace.stateDir)?.token));
28096
28356
  process.exit(0);
28097
28357
  }
28098
- const joined = await joinRunningFrizz();
28099
- if (joined) {
28100
- await openOrPrint(joined.port, true, slugPath());
28358
+ const preSplit = await joinPreSplitFrizz();
28359
+ if (preSplit) {
28360
+ await openOrPrint(preSplit, true, legacyLanding());
28101
28361
  process.exit(0);
28102
28362
  }
28103
28363
  const claim = tryAcquireProjectLaunchOwner(target, "launcher");
28104
- if (claim.kind !== "acquired") throw new Error("Frizz is starting for this project; retry shortly");
28364
+ if (claim.kind !== "acquired") throw new Error("Frizz is starting on this machine; retry shortly");
28105
28365
  let release = await acquireGlobalLaunchLock();
28106
28366
  let portReservation;
28107
28367
  try {
@@ -28127,7 +28387,7 @@ try {
28127
28387
  ]);
28128
28388
  portReservation();
28129
28389
  portReservation = void 0;
28130
- await openOrPrint(port, false, slugPath());
28390
+ await openOrPrint(port, false, await resolveLanding(port, serverOwner.token));
28131
28391
  await running;
28132
28392
  } finally {
28133
28393
  release?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frizz",
3
- "version": "0.13.12",
3
+ "version": "0.13.13",
4
4
  "private": false,
5
5
  "description": "A local web UI for running many coding agents at once",
6
6
  "keywords": [
@@ -66,7 +66,8 @@
66
66
  },
67
67
  "frizzServer": {
68
68
  "package": "frizz-server",
69
- "version": "0.15.22",
69
+ "version": "0.15.23",
70
+ "minimum": "0.15.21",
70
71
  "protocol": 1,
71
72
  "dataEpoch": 1
72
73
  }