@polderlabs/bizar-omp 0.7.8 → 0.7.10

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 (82) hide show
  1. package/README.md +35 -4
  2. package/dist/cli/agents.d.ts.map +1 -1
  3. package/dist/cli/agents.js +46 -23
  4. package/dist/cli/agents.js.map +1 -1
  5. package/dist/cli/doctor.d.ts +4 -4
  6. package/dist/cli/install.d.ts +126 -9
  7. package/dist/cli/install.d.ts.map +1 -1
  8. package/dist/cli/install.js +19 -4
  9. package/dist/cli/install.js.map +1 -1
  10. package/dist/cli/mobile-claim.d.ts +143 -0
  11. package/dist/cli/mobile-claim.d.ts.map +1 -0
  12. package/dist/cli/mobile-claim.js +399 -0
  13. package/dist/cli/mobile-claim.js.map +1 -0
  14. package/dist/cli/omb-commands.d.ts +1 -1
  15. package/dist/cli/omb-commands.d.ts.map +1 -1
  16. package/dist/cli/omb-commands.js +22 -1
  17. package/dist/cli/omb-commands.js.map +1 -1
  18. package/dist/cli/omb-mobile.d.ts +47 -0
  19. package/dist/cli/omb-mobile.d.ts.map +1 -0
  20. package/dist/cli/omb-mobile.js +307 -0
  21. package/dist/cli/omb-mobile.js.map +1 -0
  22. package/dist/cli/omb-sessions.d.ts +64 -0
  23. package/dist/cli/omb-sessions.d.ts.map +1 -0
  24. package/dist/cli/omb-sessions.js +231 -0
  25. package/dist/cli/omb-sessions.js.map +1 -0
  26. package/dist/cli/omb-update.d.ts.map +1 -1
  27. package/dist/cli/omb-update.js +17 -2
  28. package/dist/cli/omb-update.js.map +1 -1
  29. package/dist/cli/omb.js +20 -15
  30. package/dist/cli/omb.js.map +1 -1
  31. package/dist/cli/omp-mobile-dispatch.d.ts +10 -0
  32. package/dist/cli/omp-mobile-dispatch.d.ts.map +1 -0
  33. package/dist/cli/omp-mobile-dispatch.js +148 -0
  34. package/dist/cli/omp-mobile-dispatch.js.map +1 -0
  35. package/dist/core/acceptance.d.ts.map +1 -1
  36. package/dist/core/acceptance.js +22 -2
  37. package/dist/core/acceptance.js.map +1 -1
  38. package/dist/core/transitions.d.ts.map +1 -1
  39. package/dist/core/transitions.js +17 -1
  40. package/dist/core/transitions.js.map +1 -1
  41. package/dist/extension.d.ts.map +1 -1
  42. package/dist/extension.js +62 -23
  43. package/dist/extension.js.map +1 -1
  44. package/dist/omp/compatibility-receipt.d.ts +69 -0
  45. package/dist/omp/compatibility-receipt.d.ts.map +1 -0
  46. package/dist/omp/compatibility-receipt.js +60 -0
  47. package/dist/omp/compatibility-receipt.js.map +1 -0
  48. package/dist/omp/compatibility.d.ts +5 -5
  49. package/dist/omp/compatibility.d.ts.map +1 -1
  50. package/dist/omp/compatibility.js +2 -2
  51. package/dist/omp/compatibility.js.map +1 -1
  52. package/dist/omp/daemon-service.d.ts +6 -0
  53. package/dist/omp/daemon-service.d.ts.map +1 -1
  54. package/dist/omp/daemon-service.js +452 -14
  55. package/dist/omp/daemon-service.js.map +1 -1
  56. package/dist/omp/omb-sessions.d.ts +10 -0
  57. package/dist/omp/omb-sessions.d.ts.map +1 -1
  58. package/dist/omp/omb-sessions.js +8 -6
  59. package/dist/omp/omb-sessions.js.map +1 -1
  60. package/dist/omp/tmux.d.ts +17 -0
  61. package/dist/omp/tmux.d.ts.map +1 -1
  62. package/dist/omp/tmux.js +51 -7
  63. package/dist/omp/tmux.js.map +1 -1
  64. package/dist/scripts/emit-compatibility-receipt.d.ts +2 -0
  65. package/dist/scripts/emit-compatibility-receipt.d.ts.map +1 -0
  66. package/dist/scripts/emit-compatibility-receipt.js +33 -0
  67. package/dist/scripts/emit-compatibility-receipt.js.map +1 -0
  68. package/dist/tools/workflow.d.ts.map +1 -1
  69. package/dist/tools/workflow.js +11 -1
  70. package/dist/tools/workflow.js.map +1 -1
  71. package/docs/07-bizarharness-integration.md +44 -0
  72. package/docs/compatibility/baseline.json +9 -8
  73. package/docs/compatibility/supported-surfaces.json +2 -1
  74. package/docs/development/ci-release-implementation-guide.md +3 -3
  75. package/docs/releases/0.7.10.md +27 -0
  76. package/docs/releases/0.7.9.md +18 -0
  77. package/docs/releases/npm-publishing.md +12 -12
  78. package/docs/releases/support-matrix.md +8 -1
  79. package/package.json +6 -4
  80. package/skills/omp-native-development/SKILL.md +1 -1
  81. package/skills/omp-native-development/references/accuracy-and-versioning.md +1 -1
  82. package/skills/omp-native-development/references/source-manifest.json +3 -3
@@ -1,9 +1,11 @@
1
1
  import { createServer } from "node:http";
2
2
  import { spawn } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { deferred } from "./daemon.js";
4
5
  import { randomBytes } from "node:crypto";
5
- import { mkdirSync, statSync } from "node:fs";
6
- import { resolve } from "node:path";
6
+ import { mkdirSync, realpathSync, statSync } from "node:fs";
7
+ import { readdir } from "node:fs/promises";
8
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
7
9
  import { listOmbSessions, ombSessionDisplayName, ombSessionJournalPath, readOmbSessionMessages } from "./omb-sessions.js";
8
10
  import { ombSessionName } from "./tmux.js";
9
11
  import { DAEMON_PROTOCOL_VERSION, DaemonError, daemonStateDir, writeSnapshot } from "./daemon.js";
@@ -11,8 +13,11 @@ import { captureOmbSessionConsole, createOmbSession, deleteOmbSession, forgetOmb
11
13
  import { DASHBOARD_PATH, dashboardPageHtml } from "./dashboard-html.js";
12
14
  import { OMB_APPROVAL_MODES, OMB_THINKING_LEVELS, loadOmbSettings, saveOmbSettings } from "./omb-settings.js";
13
15
  import { startTray, stopTray } from "./tray.js";
16
+ import { acquireMobileSessionLease, listClaimsDetailed } from "../cli/mobile-claim.js";
17
+ import { expandDirectoryPath } from "../cli/directory-selector.js";
14
18
  import { deleteCustomOmpProvider, listCustomOmpProviders, OMP_PROVIDER_APIS, saveCustomOmpProvider, } from "./omp-provider-config.js";
15
19
  const SCAN_INTERVAL_MS = 5_000;
20
+ const MOBILE_HOST_ENTRY = fileURLToPath(new URL("../../mobile/packages/host/dist/cli/main.js", import.meta.url));
16
21
  export const DASHBOARD_MAX_BODY_BYTES = 64 * 1024;
17
22
  export const DASHBOARD_BODY_TIMEOUT_MS = 10_000;
18
23
  /** One-time dashboard access codes live a short while and are never reused. */
@@ -25,6 +30,86 @@ class DashboardRequestError extends Error {
25
30
  this.status = status;
26
31
  }
27
32
  }
33
+ const MOBILE_PROJECT_ID_TTL_MS = 10 * 60_000;
34
+ const MOBILE_PROJECT_LIMIT = 512;
35
+ const MOBILE_PROJECT_CHOICES_LIMIT = MOBILE_PROJECT_LIMIT * 4;
36
+ const MOBILE_PROJECT_DEPTH = 5;
37
+ const IGNORED_PROJECT_DIRECTORIES = new Set([".git", "node_modules", ".cache", ".turbo", ".next", ".venv", "venv"]);
38
+ /** A mobile delete is allowed only after one authoritative fresh scan proves
39
+ * the exact host-owned generation is fully stopped and no longer observable. */
40
+ export function isSafeMobileSessionRemoval(session) {
41
+ return session.authorityKnown && session.hosted && session.state === "stopped" && !session.attached && !session.uncertainLiveness && session.processObserved !== true;
42
+ }
43
+ /** Discover only directories the local OMB Hub already treats as project choices. */
44
+ async function discoverMobileProjectDirectories(cwd, projectsFolder, sessions) {
45
+ const candidates = new Map();
46
+ const add = (path, suggestedLabel) => {
47
+ try {
48
+ if (!isDirectory(path))
49
+ return;
50
+ const canonical = realpathSync(path);
51
+ if (!candidates.has(canonical))
52
+ candidates.set(canonical, suggestedLabel ?? "");
53
+ }
54
+ catch { /* A project removed after the hub scan is omitted. */ }
55
+ };
56
+ const current = (() => { try {
57
+ return realpathSync(cwd);
58
+ }
59
+ catch {
60
+ return resolve(cwd);
61
+ } })();
62
+ add(current);
63
+ const rootPath = projectsFolder.trim() ? expandDirectoryPath(projectsFolder, cwd) : "";
64
+ let canonicalRoot = "";
65
+ if (rootPath) {
66
+ add(rootPath, "Projects folder");
67
+ try {
68
+ canonicalRoot = realpathSync(rootPath);
69
+ }
70
+ catch {
71
+ canonicalRoot = "";
72
+ }
73
+ if (canonicalRoot) {
74
+ const visit = async (directory, depth) => {
75
+ if (depth >= MOBILE_PROJECT_DEPTH || candidates.size >= MOBILE_PROJECT_LIMIT)
76
+ return;
77
+ let entries;
78
+ try {
79
+ entries = await readdir(directory, { withFileTypes: true });
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
85
+ if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_PROJECT_DIRECTORIES.has(entry.name))
86
+ continue;
87
+ const path = resolve(directory, entry.name);
88
+ const label = relative(canonicalRoot, path).replaceAll("\\", "/");
89
+ add(path, label);
90
+ if (candidates.size >= MOBILE_PROJECT_LIMIT)
91
+ break;
92
+ await visit(path, depth + 1);
93
+ }
94
+ };
95
+ await visit(canonicalRoot, 0);
96
+ }
97
+ }
98
+ for (const session of sessions) {
99
+ if (session.cwd !== "?")
100
+ add(session.cwd, session.project);
101
+ if (candidates.size >= MOBILE_PROJECT_LIMIT)
102
+ break;
103
+ }
104
+ return [...candidates.entries()].slice(0, MOBILE_PROJECT_LIMIT).map(([path, suggested]) => {
105
+ const currentProject = path === current;
106
+ const fromRoot = canonicalRoot === "" ? "" : relative(canonicalRoot, path);
107
+ const insideRoot = canonicalRoot !== "" && (fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot)));
108
+ const relativeLabel = insideRoot && path !== canonicalRoot ? relative(canonicalRoot, path).replaceAll("\\", "/") : "";
109
+ const label = (currentProject ? `${basename(path)} (current)` : relativeLabel || suggested || basename(path)).replace(/[\u0000-\u001f\u007f]/g, "�").slice(0, 120);
110
+ return { cwd: path, label, current: currentProject };
111
+ });
112
+ }
28
113
  /**
29
114
  * Session fields the browser needs. The scan records the pane command, the pid,
30
115
  * and the native-session flag for the hub, but none of them reach the page: the
@@ -309,6 +394,18 @@ function dashboardSession(session) {
309
394
  displayName: session.displayName ?? ombSessionDisplayName(session),
310
395
  };
311
396
  }
397
+ function mobileHubSettings(settings) {
398
+ return {
399
+ version: settings.version,
400
+ projectsFolderConfigured: settings.projectsFolder.trim() !== "",
401
+ agentsScope: settings.agentsScope,
402
+ sessionFilter: settings.sessionFilter,
403
+ autoAttachLatest: settings.autoAttachLatest,
404
+ mouseScrolling: settings.mouseScrolling,
405
+ showStoppedSessions: settings.showStoppedSessions,
406
+ trayEnabled: settings.trayEnabled,
407
+ };
408
+ }
312
409
  /**
313
410
  * Long-running half of the daemon: an authenticated HTTP boundary on
314
411
  * 127.0.0.1 plus a scan loop that keeps the on-disk snapshot fresh. Spawned
@@ -318,6 +415,7 @@ function dashboardSession(session) {
318
415
  export function startDaemonService(options = {}) {
319
416
  const log = options.log ?? (() => { });
320
417
  const intervalMs = options.intervalMs ?? SCAN_INTERVAL_MS;
418
+ const bodyTimeoutMs = options.bodyTimeoutMs ?? DASHBOARD_BODY_TIMEOUT_MS;
321
419
  const env = options.env ?? process.env;
322
420
  const token = randomBytes(24).toString("hex");
323
421
  mkdirSync(daemonStateDir(env), { recursive: true, mode: 0o700 });
@@ -326,6 +424,11 @@ export function startDaemonService(options = {}) {
326
424
  let scanInFlight;
327
425
  let lastScanAt;
328
426
  let lastScanError;
427
+ let mobileHost;
428
+ let mobileOutput = "";
429
+ let mobilePair;
430
+ let mobileExit;
431
+ const mobileProjectChoices = new Map();
329
432
  const ready = deferred();
330
433
  async function scan(force = false) {
331
434
  if (scanInFlight) {
@@ -357,25 +460,46 @@ export function startDaemonService(options = {}) {
357
460
  }
358
461
  }
359
462
  async function requireCurrentSession(name, body) {
360
- if (!sessions.some(candidate => candidate.name === name))
361
- throw new DashboardRequestError(404, "unknown session");
362
463
  const generation = typeof body.generation === "string" ? body.generation : "";
363
464
  if (!generation)
364
465
  throw new DashboardRequestError(400, "generation is required");
365
466
  let fresh;
366
467
  try {
367
- fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, requireMultiplexer: true });
468
+ fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
368
469
  }
369
470
  catch {
370
471
  throw new DashboardRequestError(503, "session authority is unavailable");
371
472
  }
372
- const current = fresh.find(candidate => candidate.name === name);
373
- if (!current || current.generation !== generation)
473
+ const matches = fresh.filter(candidate => candidate.name === name);
474
+ if (matches.length === 0)
475
+ throw new DashboardRequestError(404, "unknown session");
476
+ if (matches.length !== 1 || matches[0]?.generation !== generation)
374
477
  throw new DashboardRequestError(409, "session changed externally");
478
+ const current = matches[0];
375
479
  if (!current.authorityKnown)
376
480
  throw new DashboardRequestError(503, "session authority is unavailable");
377
481
  return current;
378
482
  }
483
+ async function withUnclaimedSession(name, generation, action) {
484
+ let lease;
485
+ try {
486
+ lease = acquireMobileSessionLease(name, env);
487
+ }
488
+ catch {
489
+ throw new DashboardRequestError(409, "session ownership is changing");
490
+ }
491
+ try {
492
+ const claims = listClaimsDetailed(env);
493
+ if (claims.unreadable.length > 0 || claims.claims.some(claim => claim.session === name)) {
494
+ throw new DashboardRequestError(409, "session is claimed for mobile ownership");
495
+ }
496
+ const current = await requireCurrentSession(name, { generation });
497
+ return await action(current);
498
+ }
499
+ finally {
500
+ lease.release();
501
+ }
502
+ }
379
503
  // Dashboard access codes: the bearer token must never land in browser
380
504
  // history, so `omb dashboard` mints a code, the page trades that code for the
381
505
  // token once, and the token is kept in sessionStorage only.
@@ -416,12 +540,39 @@ export function startDaemonService(options = {}) {
416
540
  throw new DashboardRequestError(413, "request body is too large");
417
541
  const chunks = [];
418
542
  let size = 0;
419
- for await (const chunk of req) {
420
- const buffer = Buffer.from(chunk);
421
- size += buffer.length;
422
- if (size > DASHBOARD_MAX_BODY_BYTES)
423
- throw new DashboardRequestError(413, "request body is too large");
424
- chunks.push(buffer);
543
+ // Size alone is not a bound: a client can hold the connection open and
544
+ // trickle bytes forever. The dashboard session exchange is unauthenticated
545
+ // and reads the body before checking any token, so without a deadline a
546
+ // local caller can pin a socket indefinitely.
547
+ let deadline;
548
+ const expiry = new Promise((_, reject) => {
549
+ deadline = setTimeout(() => reject(new DashboardRequestError(408, "request body timed out")), bodyTimeoutMs);
550
+ deadline.unref?.();
551
+ });
552
+ try {
553
+ // Racing the read against the deadline is what bounds it; the timer alone
554
+ // only fires once the read loop is idle between chunks.
555
+ await Promise.race([
556
+ (async () => {
557
+ for await (const chunk of req) {
558
+ const buffer = Buffer.from(chunk);
559
+ size += buffer.length;
560
+ if (size > DASHBOARD_MAX_BODY_BYTES)
561
+ throw new DashboardRequestError(413, "request body is too large");
562
+ chunks.push(buffer);
563
+ }
564
+ })(),
565
+ expiry,
566
+ ]);
567
+ }
568
+ catch (error) {
569
+ // The socket is deliberately not destroyed here: the handler still needs
570
+ // to write the status the client is waiting for, and tearing the
571
+ // connection down first would surface as a bare "socket hang up".
572
+ throw error;
573
+ }
574
+ finally {
575
+ clearTimeout(deadline);
425
576
  }
426
577
  if (!chunks.length)
427
578
  return {};
@@ -491,6 +642,286 @@ export function startDaemonService(options = {}) {
491
642
  res.end('{"error":"unauthorized"}\n');
492
643
  return;
493
644
  }
645
+ if (path === "/mobile/pair" && req.method === "POST") {
646
+ const reuseMobileHost = Boolean(mobileHost && !mobileExit && mobilePair);
647
+ if (!mobileHost || mobileHost.exitCode !== null || mobileHost.signalCode !== null) {
648
+ mobilePair = undefined;
649
+ mobileOutput = "";
650
+ mobileExit = undefined;
651
+ const entry = env.BIZAROMP_MOBILE_ENTRY || MOBILE_HOST_ENTRY;
652
+ mobileHost = spawn(process.execPath, [entry, "pair", "--json"], {
653
+ cwd: process.cwd(),
654
+ env: { ...env, BIZAR_OMB_DAEMON_PORT: String(service.port), BIZAR_OMB_DAEMON_TOKEN: token },
655
+ stdio: ["pipe", "pipe", "pipe"],
656
+ });
657
+ mobileHost.on("error", error => { mobileExit = { code: 1, signal: null }; log(`mobile host failed to start: ${error.message}`); });
658
+ mobileHost.stdout?.setEncoding("utf8");
659
+ mobileHost.stderr?.setEncoding("utf8");
660
+ mobileHost.stdout?.on("data", (chunk) => {
661
+ mobileOutput = (mobileOutput + String(chunk)).slice(-64 * 1024);
662
+ const start = Math.max(mobileOutput.lastIndexOf("{\n \"deepLink\""), mobileOutput.lastIndexOf("{\"deepLink\""));
663
+ if (start >= 0) {
664
+ try {
665
+ const prettyEnd = mobileOutput.indexOf("\n}", start);
666
+ const lineEnd = mobileOutput.indexOf("\n", start);
667
+ const end = prettyEnd >= 0 ? prettyEnd + 2 : lineEnd;
668
+ if (end >= 0)
669
+ mobilePair = JSON.parse(mobileOutput.slice(start, end));
670
+ }
671
+ catch { /* wait for complete JSON */ }
672
+ }
673
+ });
674
+ mobileHost.stderr?.on("data", (chunk) => { log(`mobile host: ${String(chunk).trim().slice(0, 1000)}`); });
675
+ mobileHost.on("exit", (code, signal) => { mobileExit = { code, signal }; });
676
+ }
677
+ else if (reuseMobileHost) {
678
+ // Every explicit `omb mobile pair` gets a fresh one-time token. The
679
+ // host renews only its pairing window over this private pipe; restarting
680
+ // it here would tear down authenticated sockets and re-provision a
681
+ // Quick Tunnel with a different public hostname.
682
+ mobilePair = undefined;
683
+ mobileOutput = "";
684
+ if (!mobileHost.stdin?.write("pair\n")) {
685
+ json(res, 503, { error: "mobile host could not renew its pairing window" });
686
+ return;
687
+ }
688
+ }
689
+ const deadline = Date.now() + 60_000;
690
+ while (!mobilePair && !mobileExit && Date.now() < deadline)
691
+ await new Promise(resolve => setTimeout(resolve, 50));
692
+ if (!mobilePair) {
693
+ json(res, 503, { error: mobileExit ? `mobile host exited (${mobileExit.code ?? mobileExit.signal})` : "mobile pairing startup timed out" });
694
+ return;
695
+ }
696
+ json(res, 200, mobilePair);
697
+ return;
698
+ }
699
+ if (path === "/mobile/status" && req.method === "GET") {
700
+ json(res, 200, {
701
+ running: Boolean(mobileHost && !mobileExit),
702
+ pid: mobileHost?.pid ?? null,
703
+ hostId: typeof mobilePair?.hostId === "string" ? mobilePair.hostId : null,
704
+ endpoint: typeof mobilePair?.endpoint === "string" ? mobilePair.endpoint : null,
705
+ hostKeyFingerprint: typeof mobilePair?.hostKeyFingerprint === "string" ? mobilePair.hostKeyFingerprint : null,
706
+ pairingExpiresAt: typeof mobilePair?.expiresAt === "number" ? mobilePair.expiresAt : null,
707
+ });
708
+ return;
709
+ }
710
+ if (path === "/mobile/projects" && req.method === "GET") {
711
+ let knownSessions;
712
+ try {
713
+ knownSessions = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
714
+ }
715
+ catch {
716
+ json(res, 503, { authorityKnown: false, reason: "session authority is unavailable", projects: [] });
717
+ return;
718
+ }
719
+ const settings = loadOmbSettings(env);
720
+ const discovered = await discoverMobileProjectDirectories(process.cwd(), settings.projectsFolder, knownSessions);
721
+ const now = Date.now();
722
+ for (const [id, choice] of mobileProjectChoices)
723
+ if (choice.expiresAt <= now)
724
+ mobileProjectChoices.delete(id);
725
+ const projects = discovered.map(project => {
726
+ while (mobileProjectChoices.size >= MOBILE_PROJECT_CHOICES_LIMIT) {
727
+ const oldest = mobileProjectChoices.keys().next().value;
728
+ if (oldest === undefined)
729
+ break;
730
+ mobileProjectChoices.delete(oldest);
731
+ }
732
+ const id = randomBytes(24).toString("base64url");
733
+ mobileProjectChoices.set(id, { ...project, expiresAt: now + MOBILE_PROJECT_ID_TTL_MS });
734
+ return { id, label: project.label, current: project.current };
735
+ });
736
+ json(res, 200, { authorityKnown: true, projectsFolderConfigured: Boolean(settings.projectsFolder.trim()), projects });
737
+ return;
738
+ }
739
+ if (path === "/mobile/settings" && req.method === "GET") {
740
+ json(res, 200, mobileHubSettings(loadOmbSettings(env)));
741
+ return;
742
+ }
743
+ if (path === "/mobile/settings" && req.method === "PATCH") {
744
+ const body = await readBody(req);
745
+ const version = body.version;
746
+ const allowedKeys = new Set(["version", "agentsScope", "sessionFilter", "autoAttachLatest", "mouseScrolling", "showStoppedSessions", "trayEnabled"]);
747
+ const keys = Object.keys(body);
748
+ if (keys.some(key => !allowedKeys.has(key)) || typeof version !== "number" || !Number.isSafeInteger(version) || version < 0 || keys.length < 2) {
749
+ json(res, 400, { error: "invalid OMB Hub settings update" });
750
+ return;
751
+ }
752
+ const previous = loadOmbSettings(env);
753
+ const next = { ...previous };
754
+ if (body.agentsScope !== undefined) {
755
+ if (body.agentsScope !== "cwd" && body.agentsScope !== "all") {
756
+ json(res, 400, { error: "agentsScope is invalid" });
757
+ return;
758
+ }
759
+ next.agentsScope = body.agentsScope;
760
+ }
761
+ if (body.sessionFilter !== undefined) {
762
+ if (body.sessionFilter !== "active" && body.sessionFilter !== "stopped" && body.sessionFilter !== "all") {
763
+ json(res, 400, { error: "sessionFilter is invalid" });
764
+ return;
765
+ }
766
+ next.sessionFilter = body.sessionFilter;
767
+ }
768
+ for (const key of ["autoAttachLatest", "mouseScrolling", "showStoppedSessions", "trayEnabled"]) {
769
+ if (body[key] !== undefined && typeof body[key] !== "boolean") {
770
+ json(res, 400, { error: `${key} must be a boolean` });
771
+ return;
772
+ }
773
+ if (typeof body[key] === "boolean")
774
+ next[key] = body[key];
775
+ }
776
+ if (previous.version !== version) {
777
+ json(res, 409, { error: "settings changed externally", settings: mobileHubSettings(previous) });
778
+ return;
779
+ }
780
+ let saved;
781
+ try {
782
+ saved = saveOmbSettings(next, env);
783
+ }
784
+ catch (error) {
785
+ if (error instanceof Error && error.message === "settings changed externally") {
786
+ json(res, 409, { error: "settings changed externally", settings: mobileHubSettings(loadOmbSettings(env)) });
787
+ return;
788
+ }
789
+ throw error;
790
+ }
791
+ if (previous.mouseScrolling !== next.mouseScrolling) {
792
+ await Promise.all(sessions.filter(session => session.hosted !== false && session.state !== "stopped")
793
+ .map(session => setOmbSessionMouse(session.name, next.mouseScrolling, session.cwd === "?" ? process.cwd() : session.cwd, env)));
794
+ }
795
+ if (previous.trayEnabled !== next.trayEnabled) {
796
+ if (next.trayEnabled)
797
+ await startTray(env);
798
+ else
799
+ await stopTray(env);
800
+ }
801
+ await scan(true);
802
+ json(res, 200, { ok: true, settings: mobileHubSettings(saved) });
803
+ return;
804
+ }
805
+ if (path === "/mobile/sessions/create" && req.method === "POST") {
806
+ const body = await readBody(req);
807
+ const projectId = typeof body.projectId === "string" ? body.projectId : "";
808
+ const task = typeof body.task === "string" ? body.task.trim() : "";
809
+ const requestedId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
810
+ if (!/^[A-Za-z0-9_-]{32}$/.test(projectId) || ("cwd" in body) || task.length > 20_000 || (body.sessionId !== undefined && (typeof body.sessionId !== "string" || requestedId.length > 120 || !/^[A-Za-z0-9 _.-]*$/.test(requestedId)))) {
811
+ json(res, 400, { error: "invalid OMB session creation request" });
812
+ return;
813
+ }
814
+ const choice = mobileProjectChoices.get(projectId);
815
+ mobileProjectChoices.delete(projectId);
816
+ if (!choice || choice.expiresAt <= Date.now()) {
817
+ json(res, 409, { error: "project choice expired; refresh the project list" });
818
+ return;
819
+ }
820
+ let currentCwd;
821
+ try {
822
+ currentCwd = realpathSync(choice.cwd);
823
+ }
824
+ catch {
825
+ json(res, 409, { error: "selected project is no longer available" });
826
+ return;
827
+ }
828
+ if (currentCwd !== choice.cwd || !isDirectory(currentCwd)) {
829
+ json(res, 409, { error: "selected project changed; refresh the project list" });
830
+ return;
831
+ }
832
+ const sessionId = requestedId || `mobile-${Date.now().toString(36)}-${randomBytes(5).toString("hex")}`;
833
+ const sessionName = ombSessionName(currentCwd, sessionId, env.PI_PROFILE);
834
+ let fresh;
835
+ try {
836
+ fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
837
+ }
838
+ catch {
839
+ json(res, 503, { error: "session authority is unavailable" });
840
+ return;
841
+ }
842
+ if (fresh.some(session => session.name === sessionName)) {
843
+ json(res, 409, { error: "a session with that name already exists" });
844
+ return;
845
+ }
846
+ const settings = loadOmbSettings(env);
847
+ const args = [];
848
+ if (settings.defaultModel)
849
+ args.push("--model", settings.defaultModel);
850
+ if (settings.defaultThinking)
851
+ args.push("--thinking", settings.defaultThinking);
852
+ if (settings.defaultApprovalMode)
853
+ args.push("--approval-mode", settings.defaultApprovalMode);
854
+ if (task)
855
+ args.push(task);
856
+ const created = await createOmbSession(currentCwd, args, env, sessionId);
857
+ if (created.code !== 0) {
858
+ json(res, 409, { error: "OMP could not start the OMB session" });
859
+ return;
860
+ }
861
+ await scan(true);
862
+ json(res, 200, { ok: true });
863
+ return;
864
+ }
865
+ if (path === "/mobile/stop" && req.method === "POST") {
866
+ if (mobileHost && !mobileExit)
867
+ mobileHost.kill("SIGTERM");
868
+ json(res, 200, { ok: true });
869
+ return;
870
+ }
871
+ if (path === "/mobile/sessions/rename" && req.method === "POST") {
872
+ const body = await readBody(req);
873
+ const name = typeof body.name === "string" ? body.name : "";
874
+ const generation = typeof body.generation === "string" ? body.generation : "";
875
+ const newId = typeof body.newId === "string" ? body.newId.trim() : "";
876
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(name) || !/^[a-f0-9]{32}$/.test(generation) || !newId || newId.length > 120 || /[\\/\u0000-\u001f\u007f]/.test(newId)) {
877
+ json(res, 400, { error: "invalid OMB session identity or label" });
878
+ return;
879
+ }
880
+ const outcome = await withUnclaimedSession(name, generation, async (current) => {
881
+ if (!current.hosted || current.state === "unknown" || current.uncertainLiveness) {
882
+ throw new DashboardRequestError(409, "session cannot be renamed in its current state");
883
+ }
884
+ const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
885
+ const nextName = ombSessionName(projectCwd, newId, env.PI_PROFILE);
886
+ const code = await renameOmbSession(name, nextName, projectCwd, env);
887
+ if (code !== 0)
888
+ throw new DashboardRequestError(409, "session could not be renamed");
889
+ return { ok: true };
890
+ });
891
+ await scan(true);
892
+ json(res, 200, outcome);
893
+ return;
894
+ }
895
+ if (path === "/mobile/sessions/delete" && req.method === "POST") {
896
+ const body = await readBody(req);
897
+ const name = typeof body.name === "string" ? body.name : "";
898
+ const generation = typeof body.generation === "string" ? body.generation : "";
899
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(name) || !/^[a-f0-9]{32}$/.test(generation)) {
900
+ json(res, 400, { error: "invalid OMB session identity" });
901
+ return;
902
+ }
903
+ const outcome = await withUnclaimedSession(name, generation, async (current) => {
904
+ // Mobile removal is destructive. It is only safe for a fresh,
905
+ // host-owned row whose multiplexer and process observations prove that
906
+ // the exact generation is stopped and unattached. In particular,
907
+ // hosted rows must not be treated as removable merely because their
908
+ // liveness is not uncertain: an active hosted OMP session can still be
909
+ // the one writer for a native session.
910
+ if (!isSafeMobileSessionRemoval(current)) {
911
+ throw new DashboardRequestError(409, "session liveness is not safe to remove");
912
+ }
913
+ const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
914
+ const code = current.hosted
915
+ ? await deleteOmbSession(name, projectCwd, env)
916
+ : forgetOmbSession(name, env);
917
+ if (code !== 0)
918
+ throw new DashboardRequestError(409, "session removal could not be confirmed");
919
+ return { ok: true };
920
+ });
921
+ await scan(true);
922
+ json(res, 200, outcome);
923
+ return;
924
+ }
494
925
  if (path === "/dash/code" && req.method === "POST") {
495
926
  json(res, 200, { code: mintDashCode(), path: DASHBOARD_PATH, port: service.port, expiresInMs: DASH_CODE_TTL_MS });
496
927
  return;
@@ -905,7 +1336,8 @@ export function startDaemonService(options = {}) {
905
1336
  const name = decodeURIComponent(deleteMatch[1]);
906
1337
  const body = await readBody(req);
907
1338
  const current = await requireCurrentSession(name, body);
908
- const code = current.hosted === false ? forgetOmbSession(name, env) : await deleteOmbSession(name, process.cwd(), env);
1339
+ const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
1340
+ const code = current.hosted === false ? forgetOmbSession(name, env) : await deleteOmbSession(name, projectCwd, env);
909
1341
  await scan(true);
910
1342
  json(res, code === 0 ? 200 : 500, { code });
911
1343
  return;
@@ -936,6 +1368,12 @@ export function startDaemonService(options = {}) {
936
1368
  close: async () => {
937
1369
  if (scanTimer)
938
1370
  clearInterval(scanTimer);
1371
+ if (mobileHost && !mobileExit) {
1372
+ mobileHost.kill("SIGTERM");
1373
+ await Promise.race([new Promise(resolve => mobileHost?.once("exit", () => resolve())), new Promise(resolve => setTimeout(resolve, 5_000))]);
1374
+ if (!mobileExit)
1375
+ mobileHost.kill("SIGKILL");
1376
+ }
939
1377
  // Run every SSE cleanup so poll/keepalive timers drop the response
940
1378
  // reference immediately — otherwise the timers themselves keep the
941
1379
  // socket (and therefore server.close()) alive past shutdown.