codeam-cli 2.65.10 → 2.65.12

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/CHANGELOG.md +36 -0
  2. package/dist/index.js +432 -46
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to `codeam-cli` are documented here.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.65.11] — 2026-08-19
8
+
9
+ ### Documentation
10
+
11
+ - **cli:** Record the 2026-08-18 baton hand-off + goodbye-heartbeat invariants
12
+
13
+ ### Fixed
14
+
15
+ - **cli:** Take-control never wedges on "Switching…" again
16
+ - **cli:** Say goodbye before exiting so mobile stops showing ONLINE
17
+ - **cli:** LOCAL_DRIVE mirrors the transcript only — no raw TUI bytes to mobile
18
+
19
+ ### Tests
20
+
21
+ - **cli:** Real baton local integration test (take-control before first turn, handback, goodbye heartbeat)
22
+
23
+ ## [2.65.10] — 2026-08-19
24
+
25
+ ### Fixed
26
+
27
+ - **cli:** Codespace sessions report the user's repo, not the shared wrapper hostname
28
+ - **cli:** Dedupe the native ACP model list and validate model/tier at the wire boundary
29
+ - **cli:** Welcome card greets first-time users correctly and never shows a raw internal path
30
+
31
+ ## [2.65.9] — 2026-08-19
32
+
33
+ ### Fixed
34
+
35
+ - **cli:** Report an ineligible-tier credential to the backend on startup failure
36
+
37
+ ## [2.65.8] — 2026-08-18
38
+
39
+ ### Fixed
40
+
41
+ - **cli:** Baton re-affirms its state on the relay heartbeat so the 1h backend snapshot never expires under a live session
42
+
7
43
  ## [2.65.7] — 2026-08-15
8
44
 
9
45
  ### Fixed
package/dist/index.js CHANGED
@@ -8079,7 +8079,7 @@ function readAnonId() {
8079
8079
  }
8080
8080
  function superProperties() {
8081
8081
  return {
8082
- cliVersion: true ? "2.65.10" : "0.0.0-dev",
8082
+ cliVersion: true ? "2.65.12" : "0.0.0-dev",
8083
8083
  nodeVersion: process.version,
8084
8084
  platform: process.platform,
8085
8085
  arch: process.arch,
@@ -8321,7 +8321,7 @@ var http = __toESM(require("http"));
8321
8321
  // package.json
8322
8322
  var package_default = {
8323
8323
  name: "codeam-cli",
8324
- version: "2.65.10",
8324
+ version: "2.65.12",
8325
8325
  description: "Workflow-continuity bridge for AI coding agents. Wrap Claude Code or Codex in a PTY and supervise, approve, and redirect the session from any device \u2014 async. The terminal companion for CodeAgent Mobile.",
8326
8326
  type: "commonjs",
8327
8327
  main: "dist/index.js",
@@ -9542,12 +9542,30 @@ var CommandRelayService = class _CommandRelayService {
9542
9542
  this.connectSSE();
9543
9543
  }
9544
9544
  }
9545
+ /**
9546
+ * Stop the relay. Kept SYNCHRONOUS for the many callers that just tear down
9547
+ * — the goodbye heartbeat is fired but NOT awaited here.
9548
+ *
9549
+ * ⚠️ On a shutdown path that calls `process.exit()` right after, use
9550
+ * {@link stopAndFlush} (or {@link stopRelayWithGoodbye}) instead: the process
9551
+ * dies before the fire-and-forget POST hits the wire, so the backend never
9552
+ * learns the CLI went away and mobile keeps showing the session ONLINE until
9553
+ * the 30 s Redis heartbeat key expires — silently, since nothing publishes on
9554
+ * expiry (the 2026-08-18 "closing Claude Code leaves mobile online" report).
9555
+ */
9545
9556
  stop() {
9557
+ void this.stopAndFlush();
9558
+ }
9559
+ /**
9560
+ * Same teardown as {@link stop}, but resolves only once the `online:false`
9561
+ * heartbeat POST has settled — so a caller can `await` it before exiting.
9562
+ * Never rejects (`sendHeartbeat` swallows its own transport errors).
9563
+ */
9564
+ async stopAndFlush() {
9546
9565
  if (!this._running) return;
9547
9566
  this._running = false;
9548
9567
  this.cleanup();
9549
- this.sendHeartbeat(false).catch(() => {
9550
- });
9568
+ await this.sendHeartbeat(false);
9551
9569
  }
9552
9570
  async sendResult(commandId, status2, result) {
9553
9571
  if (this.pairingInvalid) return;
@@ -9824,7 +9842,7 @@ var CommandRelayService = class _CommandRelayService {
9824
9842
  // fresh + clear the "CLI update available" banner after a self-update
9825
9843
  // (a codespace that reinstalls @latest reconnects via heartbeat, not
9826
9844
  // pair/reconnect). Older backends ignore the extra field.
9827
- ..."2.65.10" ? { ideVersion: "2.65.10" } : {}
9845
+ ..."2.65.12" ? { ideVersion: "2.65.12" } : {}
9828
9846
  }).then(() => log.trace("relay", `heartbeat ok online=${online}`)).catch((err) => log.trace("relay", `heartbeat failed online=${online}`, err));
9829
9847
  }
9830
9848
  /**
@@ -9905,6 +9923,21 @@ var CommandRelayService = class _CommandRelayService {
9905
9923
  }
9906
9924
  }
9907
9925
  };
9926
+ var RELAY_GOODBYE_TIMEOUT_MS = 1500;
9927
+ async function stopRelayWithGoodbye(relay, timeoutMs = RELAY_GOODBYE_TIMEOUT_MS) {
9928
+ let timer;
9929
+ try {
9930
+ await Promise.race([
9931
+ relay.stopAndFlush(),
9932
+ new Promise((resolve10) => {
9933
+ timer = setTimeout(resolve10, timeoutMs);
9934
+ })
9935
+ ]);
9936
+ } catch {
9937
+ } finally {
9938
+ if (timer) clearTimeout(timer);
9939
+ }
9940
+ }
9908
9941
 
9909
9942
  // src/services/file-watcher.service.ts
9910
9943
  var import_child_process4 = require("child_process");
@@ -15250,6 +15283,7 @@ function parseHistoryFile(filePath) {
15250
15283
  if (!msg) continue;
15251
15284
  const text = extractText(msg["content"]).trim();
15252
15285
  if (!text) continue;
15286
+ if (type === "user" && isLocalCommandEcho(text)) continue;
15253
15287
  const ts = r["timestamp"];
15254
15288
  const timestamp = typeof ts === "string" ? ts : typeof ts === "number" ? new Date(ts).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
15255
15289
  const uuid = typeof r["uuid"] === "string" ? r["uuid"] : `${Date.now()}-${Math.random()}`;
@@ -15314,6 +15348,175 @@ function listResumableSessions(cwd) {
15314
15348
  out2.sort((a, b) => b.timestamp - a.timestamp);
15315
15349
  return out2;
15316
15350
  }
15351
+ function isLocalCommandEcho(text) {
15352
+ return /^\s*<(command-name|local-command-[a-z]+)>/.test(text);
15353
+ }
15354
+ var CLEAR_COMMAND_ECHO = "<command-name>/clear</command-name>";
15355
+ var SWITCH_PROBE_BYTES = 256 * 1024;
15356
+ function isClearedConversationFile(filePath) {
15357
+ let fd;
15358
+ try {
15359
+ fd = fs17.openSync(filePath, "r");
15360
+ } catch {
15361
+ return false;
15362
+ }
15363
+ let raw;
15364
+ try {
15365
+ const buf = Buffer.alloc(SWITCH_PROBE_BYTES);
15366
+ const n = fs17.readSync(fd, buf, 0, SWITCH_PROBE_BYTES, 0);
15367
+ raw = buf.toString("utf8", 0, n);
15368
+ } catch {
15369
+ return false;
15370
+ } finally {
15371
+ fs17.closeSync(fd);
15372
+ }
15373
+ if (!raw.includes(CLEAR_COMMAND_ECHO) && !raw.includes("SessionStart:clear")) return false;
15374
+ for (const line of raw.split("\n")) {
15375
+ if (!line.trim()) continue;
15376
+ let r;
15377
+ try {
15378
+ r = JSON.parse(line);
15379
+ } catch {
15380
+ continue;
15381
+ }
15382
+ if (r["type"] === "user" && !r["isMeta"]) {
15383
+ const msg = r["message"];
15384
+ if (extractText(msg?.["content"]).trimStart().startsWith(CLEAR_COMMAND_ECHO)) return true;
15385
+ }
15386
+ if (r["type"] === "attachment") {
15387
+ const att = r["attachment"];
15388
+ if (typeof att?.["hookName"] === "string" && att["hookName"].startsWith("SessionStart:clear")) {
15389
+ return true;
15390
+ }
15391
+ }
15392
+ }
15393
+ return false;
15394
+ }
15395
+ function appendedTailHasResumeMarker(filePath, fromOffset) {
15396
+ let fd;
15397
+ try {
15398
+ fd = fs17.openSync(filePath, "r");
15399
+ } catch {
15400
+ return false;
15401
+ }
15402
+ try {
15403
+ const size = fs17.fstatSync(fd).size;
15404
+ const len = Math.min(Math.max(size - fromOffset, 0), SWITCH_PROBE_BYTES);
15405
+ if (len <= 0) return false;
15406
+ const buf = Buffer.alloc(len);
15407
+ const n = fs17.readSync(fd, buf, 0, len, fromOffset);
15408
+ const raw = buf.toString("utf8", 0, n);
15409
+ if (!raw.includes('"last-prompt"')) return false;
15410
+ for (const line of raw.split("\n")) {
15411
+ if (!line.trim()) continue;
15412
+ try {
15413
+ if (JSON.parse(line)["type"] === "last-prompt") return true;
15414
+ } catch {
15415
+ }
15416
+ }
15417
+ return false;
15418
+ } catch {
15419
+ return false;
15420
+ } finally {
15421
+ fs17.closeSync(fd);
15422
+ }
15423
+ }
15424
+ function watchConversationSwitch(cwd, opts, onSwitch, projectsRoot) {
15425
+ const root = projectsRoot ?? path21.join(os15.homedir(), ".claude", "projects");
15426
+ let currentId = opts.currentId;
15427
+ const baseline = /* @__PURE__ */ new Map();
15428
+ const unclassified = /* @__PURE__ */ new Map();
15429
+ let dirWatcher = null;
15430
+ let rootWatcher = null;
15431
+ let closed = false;
15432
+ const fileSize = (file) => {
15433
+ try {
15434
+ return fs17.statSync(file).size;
15435
+ } catch {
15436
+ return null;
15437
+ }
15438
+ };
15439
+ const switchTo = (dir, id, kind) => {
15440
+ const leaving = fileSize(path21.join(dir, `${currentId}.jsonl`));
15441
+ if (leaving !== null) baseline.set(currentId, leaving);
15442
+ baseline.delete(id);
15443
+ unclassified.delete(id);
15444
+ currentId = id;
15445
+ onSwitch(id, { kind });
15446
+ };
15447
+ const scan = (dir) => {
15448
+ if (closed) return;
15449
+ let entries;
15450
+ try {
15451
+ entries = fs17.readdirSync(dir);
15452
+ } catch {
15453
+ return;
15454
+ }
15455
+ for (const name of entries) {
15456
+ if (!name.endsWith(".jsonl")) continue;
15457
+ const id = name.slice(0, -".jsonl".length);
15458
+ if (id === currentId) continue;
15459
+ const file = path21.join(dir, name);
15460
+ const size = fileSize(file);
15461
+ if (size === null) continue;
15462
+ const known = baseline.get(id);
15463
+ if (known !== void 0) {
15464
+ if (size > known && appendedTailHasResumeMarker(file, known)) {
15465
+ switchTo(dir, id, "resumed");
15466
+ return;
15467
+ }
15468
+ continue;
15469
+ }
15470
+ if (unclassified.get(id) === size) continue;
15471
+ unclassified.set(id, size);
15472
+ if (isClearedConversationFile(file)) {
15473
+ switchTo(dir, id, "new");
15474
+ return;
15475
+ }
15476
+ }
15477
+ };
15478
+ const attachDir = (initial) => {
15479
+ const dir = resolveHistoryDir(cwd, root);
15480
+ if (!dir) return false;
15481
+ rootWatcher?.close();
15482
+ rootWatcher = null;
15483
+ if (initial) {
15484
+ try {
15485
+ for (const name of fs17.readdirSync(dir)) {
15486
+ if (!name.endsWith(".jsonl")) continue;
15487
+ const id = name.slice(0, -".jsonl".length);
15488
+ if (id === currentId) continue;
15489
+ const size = fileSize(path21.join(dir, name));
15490
+ if (size !== null) baseline.set(id, size);
15491
+ }
15492
+ } catch {
15493
+ }
15494
+ }
15495
+ try {
15496
+ dirWatcher = fs17.watch(dir, { persistent: false }, () => scan(dir));
15497
+ } catch {
15498
+ return false;
15499
+ }
15500
+ scan(dir);
15501
+ return true;
15502
+ };
15503
+ if (!attachDir(true)) {
15504
+ try {
15505
+ fs17.mkdirSync(root, { recursive: true });
15506
+ rootWatcher = fs17.watch(root, { persistent: false }, () => {
15507
+ if (!closed && !dirWatcher) attachDir(false);
15508
+ });
15509
+ } catch {
15510
+ }
15511
+ }
15512
+ return () => {
15513
+ closed = true;
15514
+ dirWatcher?.close();
15515
+ rootWatcher?.close();
15516
+ dirWatcher = null;
15517
+ rootWatcher = null;
15518
+ };
15519
+ }
15317
15520
 
15318
15521
  // src/agents/claude/runtime.ts
15319
15522
  var ClaudeRuntimeStrategy = class {
@@ -15403,6 +15606,14 @@ var ClaudeRuntimeStrategy = class {
15403
15606
  parseHistoryFile(filePath) {
15404
15607
  return parseHistoryFile(filePath);
15405
15608
  }
15609
+ /**
15610
+ * Baton: follow the native TUI through `/clear` (new conversation id, new
15611
+ * JSONL) and `/resume` (an existing one) — see
15612
+ * {@link history.watchConversationSwitch}.
15613
+ */
15614
+ watchConversationSwitch(cwd, opts, onSwitch) {
15615
+ return watchConversationSwitch(cwd, opts, onSwitch);
15616
+ }
15406
15617
  getCurrentUsage(historyDir) {
15407
15618
  return getCurrentUsage(historyDir);
15408
15619
  }
@@ -18104,25 +18315,27 @@ async function waitForCommandOnPath(cmd, opts = {}) {
18104
18315
  }
18105
18316
  return check();
18106
18317
  }
18107
- function augmentUserLocalBinPaths() {
18108
- const home = import_os4.default.homedir();
18318
+ function augmentUserLocalBinPaths(deps = {}) {
18319
+ const env = deps.env ?? process.env;
18320
+ const home = deps.homedir ?? import_os4.default.homedir();
18321
+ const p2 = deps.pathApi ?? import_path4.default;
18109
18322
  const candidates = [
18110
18323
  // XDG-style per-user bin — npm's default global-prefix bin dir on most
18111
18324
  // Linux setups (`npm config set prefix ~/.local` or an nvm-less
18112
18325
  // per-user npm), and where curl-based agent installers commonly land.
18113
- import_path4.default.join(home, ".local", "bin"),
18326
+ p2.join(home, ".local", "bin"),
18114
18327
  // Common explicit npm global-prefix conventions seen in the wild
18115
18328
  // (`npm config set prefix ~/.npm-global`, and Debian/Fedora's
18116
18329
  // `~/.local/share/npm` layout for `npm config set prefix
18117
18330
  // ~/.local/share/npm`).
18118
- import_path4.default.join(home, ".npm-global", "bin"),
18119
- import_path4.default.join(home, ".local", "share", "npm", "bin")
18331
+ p2.join(home, ".npm-global", "bin"),
18332
+ p2.join(home, ".local", "share", "npm", "bin")
18120
18333
  ];
18121
- const parts = (process.env.PATH ?? "").split(import_path4.default.delimiter).filter((p2) => p2.length > 0);
18334
+ const parts = (env.PATH ?? "").split(p2.delimiter).filter((s) => s.length > 0);
18122
18335
  const existing = new Set(parts);
18123
18336
  const additions = candidates.filter((dir) => !existing.has(dir));
18124
18337
  if (additions.length === 0) return;
18125
- process.env.PATH = [...additions, ...parts].join(import_path4.default.delimiter);
18338
+ env.PATH = [...additions, ...parts].join(p2.delimiter);
18126
18339
  }
18127
18340
  function resolveCursorAgentBinary(deps = {}) {
18128
18341
  const existsSync29 = deps.existsSync ?? import_fs.default.existsSync;
@@ -21753,7 +21966,7 @@ async function autoUpgradeBeforeCriticalCommand() {
21753
21966
  if (process.env.NODE_ENV === "test") return;
21754
21967
  if (process.env.CODEAM_DISABLE_UPDATE_CHECK === "1") return;
21755
21968
  if (process.env.CI) return;
21756
- const current2 = true ? "2.65.10" : null;
21969
+ const current2 = true ? "2.65.12" : null;
21757
21970
  if (!current2) return;
21758
21971
  const cache = readCache();
21759
21972
  const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
@@ -21770,7 +21983,7 @@ function checkForUpdates() {
21770
21983
  if (process.env.CODEAM_DISABLE_UPDATE_CHECK === "1") return;
21771
21984
  if (process.env.CI) return;
21772
21985
  if (!process.stdout.isTTY) return;
21773
- const current2 = true ? "2.65.10" : null;
21986
+ const current2 = true ? "2.65.12" : null;
21774
21987
  if (!current2) return;
21775
21988
  const cache = readCache();
21776
21989
  const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
@@ -21790,7 +22003,7 @@ var SELF_UPDATE_INTERVAL_MS = 60 * 60 * 1e3;
21790
22003
  var SELF_UPDATE_VIEW_TIMEOUT_MS = 3e4;
21791
22004
  var SELF_UPDATE_INSTALL_TIMEOUT_MS = 18e4;
21792
22005
  function currentCliVersion() {
21793
- return true ? "2.65.10" : null;
22006
+ return true ? "2.65.12" : null;
21794
22007
  }
21795
22008
  function runCmd(cmd, args2, timeoutMs) {
21796
22009
  return new Promise((resolve10) => {
@@ -26585,7 +26798,7 @@ var sessionTerminated = async (ctx, cmd) => {
26585
26798
  } catch {
26586
26799
  }
26587
26800
  ctx.outputSvc.dispose();
26588
- ctx.relay.stop();
26801
+ await stopRelayWithGoodbye(ctx.relay);
26589
26802
  process.exit(0);
26590
26803
  };
26591
26804
  var shutdownSession = async (ctx, cmd) => {
@@ -26614,7 +26827,7 @@ var shutdownSession = async (ctx, cmd) => {
26614
26827
  } catch {
26615
26828
  }
26616
26829
  ctx.outputSvc.dispose();
26617
- ctx.relay.stop();
26830
+ await stopRelayWithGoodbye(ctx.relay);
26618
26831
  process.exit(0);
26619
26832
  };
26620
26833
  var showInstallCommand = async (ctx, cmd, parsed) => {
@@ -33869,6 +34082,7 @@ var _acpStartSeam = {
33869
34082
  var PROTOCOL_VERSION3 = 1;
33870
34083
  var FATAL_STARTUP_RE = /IneligibleTierError|UNSUPPORTED_CLIENT|no longer supported for Gemini Code Assist|not eligible for Gemini Code Assist|Error authenticating|ProjectIdRequiredError/i;
33871
34084
  var NEWSESSION_TIMEOUT_MS = 12e4;
34085
+ var LOADSESSION_TIMEOUT_MS = 6e4;
33872
34086
  var PROMPT_IDLE_TIMEOUT_MS = 9e4;
33873
34087
  var PROMPT_ACTIVE_IDLE_TIMEOUT_MS = 6e5;
33874
34088
  var CLIENT_CAPABILITIES = {
@@ -34383,13 +34597,30 @@ var AcpClient = class {
34383
34597
  log.info("acpClient", `loadSession \u2192 sessionId=${sessionId.slice(0, 8)}`);
34384
34598
  this.opts.beginLoadReplay?.();
34385
34599
  let loaded;
34600
+ let loadTimer;
34386
34601
  try {
34387
- loaded = await this.connection.loadSession({
34388
- sessionId,
34389
- cwd: this.opts.cwd,
34390
- mcpServers: this.opts.mcpServers ?? []
34391
- });
34602
+ loaded = await Promise.race([
34603
+ this.connection.loadSession({
34604
+ sessionId,
34605
+ cwd: this.opts.cwd,
34606
+ mcpServers: this.opts.mcpServers ?? []
34607
+ }),
34608
+ new Promise((_2, reject) => {
34609
+ loadTimer = setTimeout(() => {
34610
+ const tail = this.recentStderr.slice(-4).join(" | ");
34611
+ reject(
34612
+ new Error(
34613
+ `ACP_LOAD_SESSION_TIMEOUT: ${this.opts.adapter.requiresAgentBinary} did not answer session/load for ${sessionId.slice(
34614
+ 0,
34615
+ 8
34616
+ )} within ${Math.round(LOADSESSION_TIMEOUT_MS / 1e3)}s${tail ? ` \u2014 last output: ${tail}` : ""}`
34617
+ )
34618
+ );
34619
+ }, LOADSESSION_TIMEOUT_MS);
34620
+ })
34621
+ ]);
34392
34622
  } finally {
34623
+ if (loadTimer) clearTimeout(loadTimer);
34393
34624
  this.opts.endLoadReplay?.();
34394
34625
  }
34395
34626
  this.sessionId = sessionId;
@@ -38676,7 +38907,7 @@ async function sessionShutdownH(ctx) {
38676
38907
  } catch {
38677
38908
  }
38678
38909
  }
38679
- relay.stop();
38910
+ await stopRelayWithGoodbye(relay);
38680
38911
  closeAllTerminals();
38681
38912
  await client3.stop();
38682
38913
  process.exit(0);
@@ -39975,7 +40206,7 @@ async function runAcpSession(opts) {
39975
40206
  const shutdown = async (signal) => {
39976
40207
  showInfo(`Shutting down ACP session (${signal})\u2026`);
39977
40208
  clearTimeout(prewarmTimer);
39978
- relay.stop();
40209
+ await stopRelayWithGoodbye(relay);
39979
40210
  void fileWatcher.stop();
39980
40211
  turnFiles.stop();
39981
40212
  closeAllTerminals();
@@ -40444,7 +40675,27 @@ var OutputService = class _OutputService {
40444
40675
  ...lines.slice(banner.endIdx + 1)
40445
40676
  ];
40446
40677
  }
40678
+ /**
40679
+ * Mute/unmute the CHAT-output publish path. Every frame this service would
40680
+ * emit (clear / user_message / new_turn / text / chrome_steps / selectors /
40681
+ * banner / input_suggestion) is dropped while muted; `push()` and the tick
40682
+ * still run, so the detection side-effects (session id, rate limit,
40683
+ * terminal-turn gate) are untouched.
40684
+ *
40685
+ * ⚠️ The one caller is the baton's {@link NativeTuiDriver}: while LOCAL_DRIVE
40686
+ * holds the baton, the mobile view is the read-only TRANSCRIPT mirror, never
40687
+ * a screen-scrape — piping the native TUI's PTY bytes through here published
40688
+ * raw Claude Code chrome (box-drawing rules, `❯`, "auto mode on (shift+tab to
40689
+ * cycle) · esc to interrupt") into the chat as if it were agent output.
40690
+ * Terminal-panel frames (`sendTerminalChunk`/`sendTerminalExit`) go straight
40691
+ * to the emitter and are deliberately NOT affected.
40692
+ */
40693
+ setPublishSuppressed(suppressed) {
40694
+ this.publishSuppressed = suppressed;
40695
+ }
40696
+ publishSuppressed = false;
40447
40697
  async send(body, opts = {}) {
40698
+ if (this.publishSuppressed) return;
40448
40699
  const outcome = await this.emitter.send(body, opts);
40449
40700
  if (outcome.dead && this.pty.isActive) {
40450
40701
  this.dispose();
@@ -41135,11 +41386,18 @@ function provisionSkillsForStart(home = import_node_os13.default.homedir()) {
41135
41386
  }
41136
41387
 
41137
41388
  // src/baton/baton-controller.ts
41138
- var BatonController = class {
41389
+ var BatonController = class _BatonController {
41139
41390
  constructor(deps) {
41140
41391
  this.deps = deps;
41141
41392
  }
41142
41393
  deps;
41394
+ /**
41395
+ * Default upper bound on a whole hand-off. A cold `newSession` on claude is
41396
+ * ~20 s, so 45 s leaves generous headroom for a healthy switch while still
41397
+ * failing FAST enough that the user gets an honest error instead of a
41398
+ * permanent "Switching…".
41399
+ */
41400
+ static DEFAULT_SWITCH_TIMEOUT_MS = 45e3;
41143
41401
  _state = "LOCAL_DRIVE";
41144
41402
  _active = "local_tui";
41145
41403
  _conversationId = null;
@@ -41190,6 +41448,21 @@ var BatonController = class {
41190
41448
  this._conversationId = conversationId;
41191
41449
  this.setState("LOCAL_DRIVE");
41192
41450
  }
41451
+ /**
41452
+ * The native TUI SWITCHED conversation while driving (Claude `/clear` →
41453
+ * a new id, `/resume` → an existing one): the process and the pairing are
41454
+ * unchanged, but the conversation the user is in has another id. Re-point
41455
+ * the baton at it so the read-only mirror follows that transcript and a
41456
+ * later Take Control resumes THAT conversation instead of the abandoned one. Guarded to LOCAL_DRIVE — only
41457
+ * the terminal can switch its own conversation, and during a hand-off /
41458
+ * MOBILE_DRIVE the native watcher is torn down anyway (`stop()`). Same id →
41459
+ * no-op. Re-publishes LOCAL_DRIVE so the mirror re-arms + mobile learns the id.
41460
+ */
41461
+ switchConversation(conversationId) {
41462
+ if (this._state !== "LOCAL_DRIVE" || this._conversationId === conversationId) return;
41463
+ this._conversationId = conversationId;
41464
+ this.setState("LOCAL_DRIVE");
41465
+ }
41193
41466
  async takeControl() {
41194
41467
  await this.switchDriver(
41195
41468
  "LOCAL_DRIVE",
@@ -41216,24 +41489,79 @@ var BatonController = class {
41216
41489
  this.setState("SWITCHING");
41217
41490
  const priorActive = this._active;
41218
41491
  const priorConversationId = this._conversationId;
41219
- try {
41492
+ const timeoutMs = this.deps.switchTimeoutMs ?? _BatonController.DEFAULT_SWITCH_TIMEOUT_MS;
41493
+ let stoppedCurrent = false;
41494
+ const handoff = (async () => {
41220
41495
  await current2.whenSafeToYield();
41221
41496
  await current2.stop();
41222
- this._conversationId = await next.start(this._conversationId ?? void 0);
41497
+ stoppedCurrent = true;
41498
+ return next.start(priorConversationId ?? void 0);
41499
+ })();
41500
+ handoff.catch(() => void 0);
41501
+ try {
41502
+ const conversationId = await withDeadline(
41503
+ handoff,
41504
+ timeoutMs,
41505
+ `BATON_SWITCH_TIMEOUT: ${from} \u2192 ${to} hand-off did not complete within ${Math.round(
41506
+ timeoutMs / 1e3
41507
+ )}s`
41508
+ );
41509
+ this._conversationId = conversationId;
41223
41510
  this._active = nextKind;
41224
41511
  this.setState(to);
41225
41512
  } catch (err) {
41513
+ await this.recoverFromFailedSwitch(
41514
+ current2,
41515
+ next,
41516
+ stoppedCurrent,
41517
+ priorConversationId,
41518
+ timeoutMs
41519
+ );
41226
41520
  this._active = priorActive;
41227
- this._conversationId = priorConversationId;
41228
41521
  this.setState(from);
41229
41522
  throw err;
41230
41523
  }
41231
41524
  }
41525
+ /**
41526
+ * Undo a half-done hand-off. The `next` driver may still be starting behind
41527
+ * the deadline — stop it so a second adapter/PTY can't race the revived one.
41528
+ * And when `current` was ALREADY stopped before the failure, reverting only
41529
+ * the STATE would leave the user staring at a dead terminal (or mobile at a
41530
+ * dead adapter), so bring it back on the same conversation. Both best-effort
41531
+ * and bounded: recovery must never itself hang the controller.
41532
+ */
41533
+ async recoverFromFailedSwitch(current2, next, stoppedCurrent, priorConversationId, timeoutMs) {
41534
+ this._conversationId = priorConversationId;
41535
+ await withDeadline(next.stop(), timeoutMs, "BATON_RECOVERY_STOP_TIMEOUT").catch(
41536
+ () => void 0
41537
+ );
41538
+ if (!stoppedCurrent) return;
41539
+ try {
41540
+ const revived = await withDeadline(
41541
+ current2.start(priorConversationId ?? void 0),
41542
+ timeoutMs,
41543
+ "BATON_RECOVERY_START_TIMEOUT"
41544
+ );
41545
+ if (revived) this._conversationId = revived;
41546
+ } catch {
41547
+ }
41548
+ }
41232
41549
  setState(state) {
41233
41550
  this._state = state;
41234
41551
  this.deps.publishState(state, this._active, this._conversationId);
41235
41552
  }
41236
41553
  };
41554
+ function withDeadline(promise, ms, message) {
41555
+ let timer;
41556
+ return Promise.race([
41557
+ promise,
41558
+ new Promise((_2, reject) => {
41559
+ timer = setTimeout(() => reject(new Error(message)), ms);
41560
+ })
41561
+ ]).finally(() => {
41562
+ if (timer) clearTimeout(timer);
41563
+ });
41564
+ }
41237
41565
 
41238
41566
  // src/baton/terminal.ts
41239
41567
  var TUI_MODE_RESET = "\x1B[?1004l\x1B[?2004l\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1006l\x1B[?25h";
@@ -41271,6 +41599,7 @@ var NativeTuiDriver = class _NativeTuiDriver {
41271
41599
  deps.opts.pluginAuthToken,
41272
41600
  deps.runtime
41273
41601
  );
41602
+ this.outputSvc.setPublishSuppressed(true);
41274
41603
  this.keepAliveCtx = { inCodespace: false, codespaceName: void 0 };
41275
41604
  this.setKeepAlive = buildKeepAlive(this.keepAliveCtx).apply;
41276
41605
  }
@@ -41285,18 +41614,25 @@ var NativeTuiDriver = class _NativeTuiDriver {
41285
41614
  static QUICK_DISCOVER_MS = 8e3;
41286
41615
  outputSvc;
41287
41616
  historySvc;
41617
+ /** Unsubscribe for the runtime's conversation-switch watcher (armed per
41618
+ * `start()`, torn down in `stop()`), or null when not armed. */
41619
+ unwatchSwitch = null;
41288
41620
  setKeepAlive;
41289
41621
  keepAliveCtx;
41290
41622
  async start(resumeId) {
41291
41623
  if (resumeId !== void 0) {
41292
41624
  await this.deps.runtime.syncTranscriptForNativeResume?.(this.deps.opts.cwd, resumeId);
41293
41625
  await this.agent.restart(resumeId, false);
41626
+ this.armSwitchWatch(resumeId);
41294
41627
  return resumeId;
41295
41628
  }
41296
41629
  const spawnedAt = this.now();
41297
41630
  await this.agent.spawn();
41298
41631
  const preMinted = this.agent.spawnedSessionId;
41299
- if (preMinted) return preMinted;
41632
+ if (preMinted) {
41633
+ this.armSwitchWatch(preMinted);
41634
+ return preMinted;
41635
+ }
41300
41636
  const discover = this.deps.runtime.discoverSessionId;
41301
41637
  if (!discover) {
41302
41638
  throw new Error("NativeTuiDriver: agent did not expose a session id after spawn");
@@ -41312,7 +41648,26 @@ var NativeTuiDriver = class _NativeTuiDriver {
41312
41648
  });
41313
41649
  return null;
41314
41650
  }
41651
+ /**
41652
+ * Follow the native TUI if it switches conversation mid-session (Claude
41653
+ * `/clear` / `/resume`): the runtime's event-driven watcher reports the id
41654
+ * now being driven, we point the PTY-side history at it and tell the
41655
+ * controller to rebind. Armed once per `start()` (fresh or resume) on the
41656
+ * id the TUI is driving; inert for agents without the hook.
41657
+ */
41658
+ armSwitchWatch(currentId) {
41659
+ this.unwatchSwitch?.();
41660
+ this.unwatchSwitch = null;
41661
+ const watch3 = this.deps.runtime.watchConversationSwitch;
41662
+ if (!watch3) return;
41663
+ this.unwatchSwitch = watch3(this.deps.opts.cwd, { currentId }, (id) => {
41664
+ this.historySvc.setCurrentConversationId(id);
41665
+ this.deps.onConversationSwitch?.(id);
41666
+ });
41667
+ }
41315
41668
  async stop() {
41669
+ this.unwatchSwitch?.();
41670
+ this.unwatchSwitch = null;
41316
41671
  this.agent.kill();
41317
41672
  parkTerminalForReadonly();
41318
41673
  }
@@ -41375,10 +41730,17 @@ var AcpDriver = class {
41375
41730
  session = null;
41376
41731
  budgetReachedPosted = false;
41377
41732
  async start(resumeId) {
41733
+ const resumable = resumeId !== void 0 && this.hasTranscript(resumeId);
41734
+ if (resumeId !== void 0 && !resumable) {
41735
+ log.info(
41736
+ "batonAcp",
41737
+ `resume id ${resumeId.slice(0, 8)} has no transcript on disk (zero turns yet) \u2014 starting a fresh ACP session instead of session/load`
41738
+ );
41739
+ }
41378
41740
  let started;
41379
41741
  try {
41380
41742
  started = await this.deps.client.start();
41381
- if (resumeId !== void 0) {
41743
+ if (resumable && resumeId !== void 0) {
41382
41744
  await this.deps.runtime.syncTranscriptForAcpResume?.(this.deps.opts.cwd, resumeId);
41383
41745
  this.deps.streaming.beginLoadReplay();
41384
41746
  try {
@@ -41391,13 +41753,30 @@ var AcpDriver = class {
41391
41753
  await this.deps.client.stop().catch(() => void 0);
41392
41754
  throw err;
41393
41755
  }
41394
- const conversationId = resumeId !== void 0 ? resumeId : started.sessionId;
41756
+ const conversationId = resumable && resumeId !== void 0 ? resumeId : started.sessionId;
41395
41757
  this.acpSessionId = conversationId;
41396
41758
  this.agentCaps = started.initialize.agentCapabilities;
41397
41759
  this.session = null;
41398
41760
  this.budgetReachedPosted = false;
41399
41761
  return conversationId;
41400
41762
  }
41763
+ /**
41764
+ * Does the agent's own on-disk transcript for `conversationId` exist yet?
41765
+ * `resolveHistoryFile` returns null when the file isn't there (every baton
41766
+ * runtime `existsSync`-checks), which is exactly "the native TUI has run zero
41767
+ * turns on this id" — the only case where `session/load` has nothing to load.
41768
+ * A runtime without the hook can't be a baton runtime (see `gate.ts`), but if
41769
+ * one ever is, assume resumable so behaviour is unchanged.
41770
+ */
41771
+ hasTranscript(conversationId) {
41772
+ const resolve10 = this.deps.runtime.resolveHistoryFile;
41773
+ if (typeof resolve10 !== "function") return true;
41774
+ try {
41775
+ return resolve10.call(this.deps.runtime, this.deps.opts.cwd, conversationId) !== null;
41776
+ } catch {
41777
+ return true;
41778
+ }
41779
+ }
41401
41780
  async stop() {
41402
41781
  await this.deps.client.stop();
41403
41782
  this.acpSessionId = null;
@@ -41535,8 +41914,8 @@ var TranscriptMirror = class {
41535
41914
  if (!file) return false;
41536
41915
  this.attached = true;
41537
41916
  this.emit(file);
41538
- const watch2 = this.deps.watch ?? defaultWatch;
41539
- this.unwatch = watch2(file, () => this.emit(file));
41917
+ const watch3 = this.deps.watch ?? defaultWatch;
41918
+ this.unwatch = watch3(file, () => this.emit(file));
41540
41919
  return true;
41541
41920
  }
41542
41921
  clearPoll() {
@@ -41732,8 +42111,7 @@ async function runBatonSession(opts) {
41732
42111
  nativeDriver.handlePtyData(raw);
41733
42112
  },
41734
42113
  onExit(code) {
41735
- teardown();
41736
- process.exit(code);
42114
+ void teardown().finally(() => process.exit(code));
41737
42115
  }
41738
42116
  });
41739
42117
  nativeDriver = new NativeTuiDriver({
@@ -41752,10 +42130,17 @@ async function runBatonSession(opts) {
41752
42130
  // calls this once the user's first terminal turn creates the transcript.
41753
42131
  // Safe forward-ref — `controller` is initialised long before `begin()` runs,
41754
42132
  // and onLateBind only fires from inside `begin()`'s spawn.
41755
- onLateBind: (id) => controller.rebindConversation(id)
42133
+ onLateBind: (id) => controller.rebindConversation(id),
42134
+ // The native TUI switched conversation mid-drive (Claude `/clear` mints a
42135
+ // new id + JSONL; `/resume` re-opens an existing one): rebind so the mirror
42136
+ // follows that transcript and a later Take Control resumes THAT
42137
+ // conversation. Owner report 2026-08-18: after `/clear` (+`/rename`) the
42138
+ // mobile went silent — the mirror kept tailing the abandoned file.
42139
+ onConversationSwitch: (id) => controller.switchConversation(id)
41756
42140
  });
41757
42141
  let mirror = null;
41758
42142
  let firstLocalDrive = true;
42143
+ let lastPublished = null;
41759
42144
  const startMirror = (conversationId, fresh) => {
41760
42145
  mirror?.stop();
41761
42146
  mirror = new TranscriptMirror({
@@ -41788,11 +42173,13 @@ async function runBatonSession(opts) {
41788
42173
  publishState: (state, driver, conversationId) => {
41789
42174
  publishBatonState(state, driver, conversationId);
41790
42175
  if (state === "LOCAL_DRIVE" && conversationId) {
41791
- startMirror(conversationId, firstLocalDrive);
42176
+ const switched = lastPublished === "LOCAL_DRIVE";
42177
+ startMirror(conversationId, firstLocalDrive || switched);
41792
42178
  firstLocalDrive = false;
41793
42179
  } else if (state !== "LOCAL_DRIVE") {
41794
42180
  mirror?.stop();
41795
42181
  }
42182
+ lastPublished = state;
41796
42183
  }
41797
42184
  });
41798
42185
  const dispatchActive = (cmd) => controller.activeSessionDriver.dispatch(cmd);
@@ -41813,19 +42200,18 @@ async function runBatonSession(opts) {
41813
42200
  publish: publishBatonState
41814
42201
  })
41815
42202
  );
41816
- function teardown() {
42203
+ async function teardown() {
41817
42204
  if (torn) return;
41818
42205
  torn = true;
41819
42206
  process.removeListener("SIGINT", onSignal);
41820
42207
  process.removeListener("SIGTERM", onSignal);
41821
42208
  process.removeListener("SIGHUP", onSignal);
41822
42209
  mirror?.stop();
41823
- relay.stop();
41824
42210
  void controller.shutdown();
42211
+ await stopRelayWithGoodbye(relay);
41825
42212
  }
41826
42213
  const onSignal = () => {
41827
- teardown();
41828
- process.exit(0);
42214
+ void teardown().finally(() => process.exit(0));
41829
42215
  };
41830
42216
  process.once("SIGINT", onSignal);
41831
42217
  process.once("SIGTERM", onSignal);
@@ -42212,12 +42598,12 @@ async function start(requestedAgent, presetSession) {
42212
42598
  outputSvc.push(raw);
42213
42599
  streamingEmitter?.push(raw);
42214
42600
  },
42215
- onExit(code) {
42601
+ async onExit(code) {
42216
42602
  process.removeListener("SIGINT", sigintHandler);
42217
42603
  process.removeListener("SIGTERM", sigintHandler);
42218
42604
  process.removeListener("SIGHUP", sigintHandler);
42219
42605
  outputSvc.dispose();
42220
- relay.stop();
42606
+ await stopRelayWithGoodbye(relay);
42221
42607
  void fileWatcher?.stop();
42222
42608
  turnFiles?.stop();
42223
42609
  void beads?.watcher.stop();
@@ -42272,7 +42658,7 @@ async function start(requestedAgent, presetSession) {
42272
42658
  shuttingDown = true;
42273
42659
  agent.kill();
42274
42660
  outputSvc.dispose();
42275
- relay.stop();
42661
+ await stopRelayWithGoodbye(relay);
42276
42662
  void fileWatcher?.stop();
42277
42663
  void beads?.watcher.stop();
42278
42664
  void streamingEmitter?.stop();
@@ -44868,7 +45254,7 @@ function checkChokidar() {
44868
45254
  }
44869
45255
  async function doctor(args2 = []) {
44870
45256
  const json = args2.includes("--json");
44871
- const cliVersion = true ? "2.65.10" : "0.0.0-dev";
45257
+ const cliVersion = true ? "2.65.12" : "0.0.0-dev";
44872
45258
  const apiBase2 = resolveApiBaseUrl();
44873
45259
  const diagnosticId = (0, import_node_crypto13.randomUUID)();
44874
45260
  log.info("doctor", `run id=${diagnosticId} cli=${cliVersion}`);
@@ -45259,7 +45645,7 @@ async function mcpRun(args2) {
45259
45645
  // src/commands/version.ts
45260
45646
  var import_picocolors15 = __toESM(require("picocolors"));
45261
45647
  function version2() {
45262
- const v = true ? "2.65.10" : "unknown";
45648
+ const v = true ? "2.65.12" : "unknown";
45263
45649
  console.log(`${import_picocolors15.default.bold("codeam-cli")} ${import_picocolors15.default.cyan(v)}`);
45264
45650
  }
45265
45651
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeam-cli",
3
- "version": "2.65.10",
3
+ "version": "2.65.12",
4
4
  "description": "Workflow-continuity bridge for AI coding agents. Wrap Claude Code or Codex in a PTY and supervise, approve, and redirect the session from any device — async. The terminal companion for CodeAgent Mobile.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",