@oar-sh/oar 0.9.7 → 0.9.8

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 (38) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +46 -18
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  6. package/server/claude-worker/claude-session-process.mjs +196 -28
  7. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  8. package/server/public/app/activity-replay-state.mjs +68 -5
  9. package/server/public/app/api-client.js +6 -2
  10. package/server/public/app/bootstrap.js +124 -57
  11. package/server/public/app/context-usage-view.mjs +127 -8
  12. package/server/public/app/conversation-view.js +62 -7
  13. package/server/public/app/pwa-install.js +5 -0
  14. package/server/public/app/scrollbar-hover.mjs +78 -0
  15. package/server/public/app/settings-modal.js +4 -1
  16. package/server/public/app/socket-handlers.js +2 -1
  17. package/server/public/app/store.js +26 -0
  18. package/server/public/app/transcript-separators.mjs +48 -11
  19. package/server/public/app-icon-192.png +0 -0
  20. package/server/public/app-icon-512.png +0 -0
  21. package/server/public/app-icon-light-192.png +0 -0
  22. package/server/public/app-icon-light-512.png +0 -0
  23. package/server/public/app-icon-light-maskable-512.png +0 -0
  24. package/server/public/app-icon-light.svg +4 -0
  25. package/server/public/app-icon-maskable-512.png +0 -0
  26. package/server/public/app-icon.svg +3 -3
  27. package/server/public/favicon.ico +0 -0
  28. package/server/public/index.html +183 -13
  29. package/server/public/manifest.webmanifest +9 -3
  30. package/server/public/sw.js +6 -1
  31. package/server/routes/messages-routes.mjs +3 -1
  32. package/server/routes/sessions-routes.mjs +15 -2
  33. package/server/server-runtime.mjs +10 -8
  34. package/server/services/claude-context-usage.mjs +51 -3
  35. package/server/services/oar-cli-helpers.mjs +67 -1
  36. package/server/services/pwa-manifest-theme.mjs +30 -0
  37. package/server/services/session-worker-registry-service.mjs +18 -1
  38. package/server/services/update-install-service.mjs +9 -3
package/bin/oar.js CHANGED
@@ -6,7 +6,8 @@ import os from 'os';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { EventEmitter } from 'events';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
- import { spawn } from 'child_process';
9
+ import { spawn, spawnSync } from 'child_process';
10
+ import net from 'net';
10
11
 
11
12
  import {
12
13
  migrateStateToOarRoot,
@@ -275,6 +276,7 @@ export async function launchRelay({
275
276
  'Starts the web relay server if needed, then launches gh copilot in the current shell.',
276
277
  '--install-extension installs/updates a user-global web-relay wrapper extension and exits.',
277
278
  '--no-install-extension skips automatic wrapper setup for this run.',
279
+ 'oar start starts only the relay, in the background; oar setup writes the config; oar doctor and oar update are described in the README.',
278
280
  'Install globally with: npm i -g @oar-sh/oar (or from a checkout: npm link)',
279
281
  ].join('\n'));
280
282
  return { code: 0, reason: 'help' };
@@ -511,14 +513,274 @@ async function promptYesNo(question, { defaultYes = false, interactive = true }
511
513
  }
512
514
  }
513
515
 
516
+ function canBind(port, host) {
517
+ return new Promise((resolve) => {
518
+ const probe = net.createServer();
519
+ probe.once('error', (error) => {
520
+ // A host this machine does not have (no IPv6) says nothing about the port.
521
+ resolve(error?.code === 'EADDRNOTAVAIL' || error?.code === 'EAFNOSUPPORT');
522
+ });
523
+ probe.once('listening', () => probe.close(() => resolve(true)));
524
+ if (host) probe.listen(port, host);
525
+ else probe.listen(port);
526
+ });
527
+ }
528
+
529
+ function acceptsConnection(port, host, timeoutMs = 400) {
530
+ return new Promise((resolve) => {
531
+ const socket = net.connect({ port, host });
532
+ let settled = false;
533
+ const finish = (accepted) => {
534
+ if (settled) return;
535
+ settled = true;
536
+ // An accepted connection is closed politely: destroying it reaches the
537
+ // listener as a reset, which a program without an error handler on its
538
+ // sockets would take as a fault.
539
+ if (accepted) socket.end();
540
+ else socket.destroy();
541
+ resolve(accepted);
542
+ };
543
+ socket.setTimeout(timeoutMs, () => finish(false));
544
+ socket.once('connect', () => finish(true));
545
+ socket.on('error', () => finish(false));
546
+ });
547
+ }
548
+
549
+ /**
550
+ * A port is free when nothing accepts a connection on it and it can be bound
551
+ * on the loopback addresses and on all interfaces. One wildcard bind is not
552
+ * enough: Windows allows it beside a listener on 127.0.0.1 alone.
553
+ */
554
+ export async function isPortFree(port) {
555
+ for (const host of ['127.0.0.1', '::1']) {
556
+ if (await acceptsConnection(port, host)) return false;
557
+ }
558
+ for (const host of ['127.0.0.1', '::1', '']) {
559
+ if (!(await canBind(port, host))) return false;
560
+ }
561
+ return true;
562
+ }
563
+
564
+ /** The first free port from `start` on, so a fresh config does not name a port something else holds. */
565
+ export async function findFreePort(start = 3333, { attempts = 20, isPortFreeImpl = isPortFree } = {}) {
566
+ for (let port = start; port < start + attempts && port <= 65535; port += 1) {
567
+ if (await isPortFreeImpl(port)) return port;
568
+ }
569
+ return start;
570
+ }
571
+
572
+ function runQuiet(command, args, { spawnSyncImpl = spawnSync } = {}) {
573
+ try {
574
+ const result = spawnSyncImpl(command, args, { stdio: 'ignore', timeout: 15_000 });
575
+ return result.status === 0;
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+
581
+ // A systemd *user* manager this process can talk to: absent in containers, on
582
+ // macOS, and in a WSL distro without systemd.
583
+ function systemdUserAvailable(options = {}) {
584
+ return process.platform === 'linux' && runQuiet('systemctl', ['--user', 'show-environment'], options);
585
+ }
586
+
587
+ function systemdUnitPath() {
588
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'oar.service');
589
+ }
590
+
591
+ async function waitForRelay({ statusUrl, token, fetchImpl = globalThis.fetch, deadlineMs = 20_000, stillStarting = () => true } = {}) {
592
+ const deadline = Date.now() + deadlineMs;
593
+ for (;;) {
594
+ try {
595
+ const res = await fetchImpl(statusUrl, { headers: { Authorization: `Bearer ${token}` } });
596
+ if (res && res.ok) return true;
597
+ } catch {
598
+ // retry
599
+ }
600
+ if (Date.now() >= deadline || !stillStarting()) return false;
601
+ await new Promise((resolve) => setTimeout(resolve, 300));
602
+ }
603
+ }
604
+
605
+ /**
606
+ * What answers on the relay's port: 'oar' (this relay: its status call accepts
607
+ * the configured token and has the relay's shape), 'taken' (something else
608
+ * answers, or holds the port without answering), or 'none'.
609
+ */
610
+ export async function probeRelayPort({ statusUrl, token = '', fetchImpl = globalThis.fetch, timeoutMs = 2500 } = {}) {
611
+ if (typeof fetchImpl !== 'function' || !statusUrl) return 'none';
612
+ try {
613
+ const response = await fetchImpl(statusUrl, {
614
+ method: 'GET',
615
+ headers: { Authorization: `Bearer ${token}` },
616
+ signal: AbortSignal.timeout(timeoutMs),
617
+ });
618
+ if (!response) return 'none';
619
+ if (!response.ok) return 'taken';
620
+ const body = await Promise.resolve(response.json?.()).catch(() => null);
621
+ return body && typeof body === 'object' && 'cliOnline' in body ? 'oar' : 'taken';
622
+ } catch (error) {
623
+ // A port that accepts the connection and then stays silent is held too.
624
+ return error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 'taken' : 'none';
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Makes sure the relay of a global install runs, without a Copilot session
630
+ * tied to it: an already running relay is left alone, an installed systemd
631
+ * user service is started, and anywhere else the server is started detached,
632
+ * so it outlives this command.
633
+ */
634
+ export async function ensureRelayRunning({
635
+ packageRoot = resolvePackageRoot(),
636
+ layout,
637
+ configPath,
638
+ config,
639
+ cwd = os.homedir(),
640
+ env = process.env,
641
+ nodeBin = process.execPath,
642
+ spawnImpl = spawn,
643
+ spawnSyncImpl = spawnSync,
644
+ fetchImpl = globalThis.fetch,
645
+ isProcessAliveImpl = isProcessAlive,
646
+ lockGraceMs = 5000,
647
+ logger = console,
648
+ } = {}) {
649
+ const port = normalizePort(config?.port) ?? 3333;
650
+ const token = String(config?.authToken || '').trim();
651
+ const statusUrl = `http://localhost:${port}/api/status`;
652
+ const logDir = getLauncherLogDir(env, layout);
653
+ // The relay itself says whether it runs: a lock file can outlive its relay
654
+ // (and its process id can pass to another program), and anything can answer
655
+ // on a port.
656
+ const lockPath = path.join(layout.dataDir, 'relay-server.lock');
657
+ let answer = await probeRelayPort({ statusUrl, token, fetchImpl });
658
+ const lockedByLiveProcess = () => {
659
+ const lock = readRelayLock(lockPath);
660
+ return Number.isInteger(lock?.pid) && isProcessAliveImpl(lock.pid);
661
+ };
662
+ if (answer === 'none' && lockedByLiveProcess()) {
663
+ // A relay that is still starting holds the lock before it listens.
664
+ const deadline = Date.now() + lockGraceMs;
665
+ while (answer === 'none' && Date.now() < deadline) {
666
+ await new Promise((resolve) => setTimeout(resolve, 300));
667
+ answer = await probeRelayPort({ statusUrl, token, fetchImpl });
668
+ }
669
+ if (answer === 'none' && lockedByLiveProcess()) {
670
+ // Nothing listens: the lock is left over from a relay that did not
671
+ // stop cleanly. The server refuses to start beside a lock whose process
672
+ // id is alive, so it goes first.
673
+ logger.log?.(`[oar] Removing a leftover relay lock (${lockPath}); no relay answers on port ${port}.`);
674
+ try { fs.unlinkSync(lockPath); } catch {}
675
+ }
676
+ }
677
+ if (answer === 'oar') return { ok: true, how: 'already', logDir };
678
+ if (answer === 'taken') {
679
+ logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
680
+ logger.error?.('[oar] Stop that program, or pick another port: oar setup --port <port>');
681
+ return { ok: false, how: 'port-taken', logDir };
682
+ }
683
+
684
+ if (fs.existsSync(systemdUnitPath()) && systemdUserAvailable({ spawnSyncImpl })) {
685
+ runQuiet('systemctl', ['--user', 'daemon-reload'], { spawnSyncImpl });
686
+ if (!runQuiet('systemctl', ['--user', 'enable', '--now', 'oar'], { spawnSyncImpl })) {
687
+ logger.error?.('[oar] systemctl --user enable --now oar failed — see: systemctl --user status oar');
688
+ return { ok: false, how: 'service', logDir };
689
+ }
690
+ return { ok: await waitForRelay({ statusUrl, token, fetchImpl }), how: 'service', logDir };
691
+ }
692
+
693
+ const serverDir = path.join(packageRoot, 'server');
694
+ const outFd = openAppendFileDescriptor(path.join(logDir, 'server.log'));
695
+ const errFd = openAppendFileDescriptor(path.join(logDir, 'server-err.log'));
696
+ let child = null;
697
+ try {
698
+ child = spawnImpl(nodeBin, [path.join(serverDir, 'server.js')], {
699
+ cwd: layout.root,
700
+ env: {
701
+ ...env,
702
+ COPILOT_WORKSPACE_ROOT: cwd,
703
+ COPILOT_WEB_RELAY_ROOT: packageRoot,
704
+ COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
705
+ COPILOT_WEB_RELAY_CONFIG: configPath,
706
+ COPILOT_WEB_RELAY_LOG_DIR: logDir,
707
+ COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
708
+ },
709
+ detached: true,
710
+ stdio: ['ignore', outFd, errFd],
711
+ windowsHide: true,
712
+ });
713
+ child.unref?.();
714
+ } catch (error) {
715
+ logger.error?.(`[oar] Failed to launch relay: ${error?.message || error}`);
716
+ return { ok: false, how: 'detached', logDir };
717
+ } finally {
718
+ try { fs.closeSync(outFd); } catch {}
719
+ try { fs.closeSync(errFd); } catch {}
720
+ }
721
+ const ok = await waitForRelay({ statusUrl, token, fetchImpl, stillStarting: () => child.exitCode === null });
722
+ return { ok, how: 'detached', logDir };
723
+ }
724
+
725
+ function printRelayUrl({ config, logger, relayUrl, primaryLanAddress }) {
726
+ const url = relayUrl({ config, lanAddress: primaryLanAddress() });
727
+ logger.log('');
728
+ logger.log(`[oar] Relay URL: ${url}`);
729
+ if (config.localhostOnly) {
730
+ logger.log('[oar] localhostOnly is on — reach it from other devices via the tunnel, or rerun `oar setup` for LAN access.');
731
+ }
732
+ return url;
733
+ }
734
+
735
+ function reportRelayStart(result, logger) {
736
+ // A port held by something else was already explained.
737
+ if (result.how === 'port-taken') return;
738
+ if (!result.ok) {
739
+ logger.error?.(`[oar] The relay did not come up — see ${path.join(result.logDir, 'server-err.log')}`);
740
+ return;
741
+ }
742
+ if (result.how === 'already') {
743
+ logger.log('[oar] The relay is already running. To load a new version, restart it from the web UI (⋯ → Restart web relay).');
744
+ } else if (result.how === 'service') {
745
+ logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
746
+ } else {
747
+ logger.log(`[oar] The relay is running in the background (log: ${path.join(result.logDir, 'server.log')}). After a reboot, start it again with: oar start`);
748
+ }
749
+ }
750
+
751
+ /** `oar start` — the relay alone, in the background, without a Copilot session. */
752
+ async function runStart({ env = process.env, cwd = process.cwd(), logger = console } = {}) {
753
+ const { primaryLanAddress, relayUrl } = await import('../server/services/oar-cli-helpers.mjs');
754
+ const packageRoot = resolvePackageRoot();
755
+ const layout = resolveStateLayout({ packageRoot, env });
756
+ if (layout.checkout) {
757
+ logger.error('[oar] This is a git checkout — start the relay with: npm start');
758
+ return { code: 1, reason: 'git-checkout' };
759
+ }
760
+ const configPath = path.join(layout.configDir, 'config.json');
761
+ const config = readJsonFile(configPath);
762
+ if (!config) {
763
+ logger.error('[oar] No config yet — run: oar setup');
764
+ return { code: 1, reason: 'no-config' };
765
+ }
766
+ const result = await ensureRelayRunning({ packageRoot, layout, configPath, config, cwd, env, logger });
767
+ reportRelayStart(result, logger);
768
+ if (result.ok) printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
769
+ return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
770
+ }
771
+
514
772
  async function runSetup({ argv = [], env = process.env, logger = console } = {}) {
515
773
  const {
516
- buildDefaultConfig, buildSystemdUnit, generateAuthToken, primaryLanAddress, relayUrl,
774
+ buildDefaultConfig, buildServicePath, buildSystemdUnit, generateAuthToken, primaryLanAddress, relayUrl,
517
775
  } = await import('../server/services/oar-cli-helpers.mjs');
518
776
 
519
777
  const packageRoot = resolvePackageRoot();
520
778
  const layout = resolveStateLayout({ packageRoot, env });
521
779
  const useDefaults = argv.includes('--defaults') || !process.stdin.isTTY;
780
+ // `--start` is the installer's mode: beyond the config it sets up whatever
781
+ // keeps the relay running on this host and starts it, without a question.
782
+ const startRequested = argv.includes('--start') && !layout.checkout;
783
+ const portOverride = parsePort(argv, null);
522
784
  const migrateFromIdx = argv.indexOf('--migrate-from');
523
785
  const migrateFromRoot = migrateFromIdx !== -1 ? String(argv[migrateFromIdx + 1] || '').trim() : '';
524
786
 
@@ -564,6 +826,17 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
564
826
  const config = { ...buildDefaultConfig({ token }), ...(existing || {}) };
565
827
  config.authToken = token;
566
828
  config.localhostOnly = !lanAccess;
829
+ if (portOverride) {
830
+ config.port = portOverride;
831
+ } else if (!existing) {
832
+ // A fresh config takes the first free port; an existing one keeps its own,
833
+ // which its running relay may be the one holding.
834
+ const freePort = await findFreePort(config.port);
835
+ if (freePort !== config.port) {
836
+ logger.log(`[oar] Port ${config.port} is in use — using ${freePort}.`);
837
+ config.port = freePort;
838
+ }
839
+ }
567
840
 
568
841
  if (await promptYesNo('Enable the managed cloudflared tunnel (public URL for remote access)?', {
569
842
  defaultYes: false,
@@ -579,40 +852,66 @@ async function runSetup({ argv = [], env = process.env, logger = console } = {})
579
852
  }
580
853
  logger.log(`[oar] Wrote ${configPath}`);
581
854
 
855
+ let serviceInstalled = false;
582
856
  if (process.platform === 'linux' && !layout.checkout) {
583
- if (await promptYesNo('Install a systemd user service so the relay starts on login?', {
584
- defaultYes: false,
585
- interactive: !useDefaults,
586
- })) {
587
- const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
588
- const unitPath = path.join(unitDir, 'oar.service');
589
- fs.mkdirSync(unitDir, { recursive: true });
857
+ const hasSystemd = systemdUserAvailable();
858
+ const wantService = startRequested
859
+ ? hasSystemd
860
+ : await promptYesNo('Install a systemd user service so the relay starts on login?', {
861
+ defaultYes: false,
862
+ interactive: !useDefaults,
863
+ });
864
+ if (wantService) {
865
+ const unitPath = systemdUnitPath();
866
+ fs.mkdirSync(path.dirname(unitPath), { recursive: true });
590
867
  fs.writeFileSync(unitPath, buildSystemdUnit({
591
868
  nodeBin: process.execPath,
592
869
  packageRoot,
593
870
  configPath,
594
871
  dataDir: layout.dataDir,
595
872
  logDir: getLauncherLogDir(env, layout),
873
+ pathEnv: buildServicePath({ nodeBin: process.execPath, envPath: env.PATH, homeDir: os.homedir() }),
596
874
  }));
597
- logger.log(`[oar] Wrote ${unitPath} — enable it with: systemctl --user enable --now oar`);
875
+ serviceInstalled = true;
876
+ if (startRequested) {
877
+ logger.log(`[oar] Wrote ${unitPath}`);
878
+ // Lingering keeps the user's services up while nobody is logged in;
879
+ // without it the relay of a headless host stops at logout.
880
+ const lingering = runQuiet('loginctl', ['enable-linger', os.userInfo().username]);
881
+ if (!lingering) {
882
+ logger.log('[oar] Could not enable lingering — the relay stops when you log out. Enable it with: loginctl enable-linger "$USER"');
883
+ }
884
+ } else {
885
+ logger.log(`[oar] Wrote ${unitPath} — enable it with: systemctl --user enable --now oar`);
886
+ }
598
887
  }
599
888
  } else if (process.platform === 'win32') {
600
889
  logger.log('[oar] Autostart on Windows: enable it in the web UI under Settings → Autostart.');
601
890
  }
602
891
 
603
- const url = relayUrl({ config, lanAddress: primaryLanAddress() });
604
- logger.log('');
605
- logger.log(`[oar] Relay URL: ${url}`);
606
- if (config.localhostOnly) {
607
- logger.log('[oar] localhostOnly is on — reach it from other devices via the tunnel, or rerun `oar setup` for LAN access.');
892
+ let started = null;
893
+ if (startRequested) {
894
+ started = await ensureRelayRunning({ packageRoot, layout, configPath, config, env, logger });
895
+ reportRelayStart(started, logger);
608
896
  }
897
+
898
+ const url = printRelayUrl({ config, logger, relayUrl, primaryLanAddress });
609
899
  try {
610
900
  const qrcode = (await import('qrcode-terminal')).default;
611
901
  qrcode.generate(url, { small: true }, (qr) => logger.log(qr));
612
902
  } catch {
613
903
  // QR is a convenience — the URL above is the contract.
614
904
  }
615
- logger.log('[oar] Start the relay with: oar');
905
+ if (started) {
906
+ return { code: started.ok ? 0 : 1, reason: started.ok ? 'setup-complete' : 'server-not-ready' };
907
+ }
908
+ if (layout.checkout) {
909
+ logger.log('[oar] Start the relay with: npm start');
910
+ } else if (serviceInstalled) {
911
+ logger.log('[oar] Start the relay with: systemctl --user enable --now oar');
912
+ } else {
913
+ logger.log('[oar] Start the relay with: oar start (or `oar` to open a Copilot session with it)');
914
+ }
616
915
  return { code: 0, reason: 'setup-complete' };
617
916
  }
618
917
 
@@ -714,10 +1013,12 @@ async function runUpdate({ argv = [], env = process.env, logger = console, fetch
714
1013
  }
715
1014
 
716
1015
  logger.log(`[oar] Updating @oar-sh/oar ${runningVersion} -> ${target} ...`);
717
- const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1016
+ const { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } = await import('../server/services/oar-cli-helpers.mjs');
1017
+ const npm = resolveNpmInvocation({ packageRoot, env });
718
1018
  const exitCode = await new Promise((resolve) => {
719
- const child = spawnImpl(npmCommand, ['install', '-g', `@oar-sh/oar@${target}`], {
1019
+ const child = spawnImpl(npm.command, ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `@oar-sh/oar@${target}`], {
720
1020
  stdio: 'inherit',
1021
+ env: npm.env,
721
1022
  ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}),
722
1023
  });
723
1024
  child.on('error', (error) => {
@@ -778,6 +1079,7 @@ function main() {
778
1079
  return Promise.resolve({ code: 0, reason: 'version' });
779
1080
  }
780
1081
  if (command === 'setup') return runSetup({ argv: argv.slice(1) });
1082
+ if (command === 'start') return runStart({});
781
1083
  if (command === 'doctor') return runDoctor({});
782
1084
  if (command === 'update') return runUpdate({ argv: argv.slice(1) });
783
1085
  return launchRelay();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -55,17 +55,6 @@ export function claudeUltracodeFlagSettings(effort) {
55
55
  return { ultracode: null, enableWorkflows: null, effortLevel: effort || null };
56
56
  }
57
57
 
58
- /**
59
- * The flag-settings payload that moves a live session onto another auto-compact
60
- * window. `null` clears the flag layer back to the CLI's model-tuned default,
61
- * which is exactly what "Auto" means — leaving the previous window pinned would
62
- * make the setting one-way.
63
- */
64
- export function claudeAutoCompactFlagSettings(autoCompactWindow) {
65
- const window = normalizeAutoCompactWindow(autoCompactWindow);
66
- return { autoCompactWindow: window };
67
- }
68
-
69
58
  /**
70
59
  * The flag-settings payload that pins (or clears) `alwaysThinkingEnabled` on a
71
60
  * live session.