hop2 0.11.72 → 0.11.74

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.
@@ -264,6 +264,7 @@ type SessionInfo = {
264
264
  createdBy?: "user" | "agent";
265
265
  createdVia?: string | null;
266
266
  agent?: "claude" | "codex" | null;
267
+ agentHome?: { dir: string; label: string; isDefault: boolean } | null;
267
268
  // Server-decided: a turn in flight, or the last one finished — and whether
268
269
  // a human has opened the session since it finished (see lib/agent-phase.js).
269
270
  agentPhase?: "working" | "done" | null;
@@ -3272,6 +3273,7 @@ const App = () => {
3272
3273
  createdBy: s.createdBy === "agent" ? "agent" : "user",
3273
3274
  createdVia: typeof s.createdVia === "string" ? s.createdVia : null,
3274
3275
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3276
+ agentHome: s.agentHome && typeof s.agentHome.dir === "string" ? s.agentHome : null,
3275
3277
  agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3276
3278
  turnSeen: s.turnSeen !== false,
3277
3279
  checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
@@ -35,8 +35,7 @@ import {
35
35
  type SwitcherFolder,
36
36
  type SwitcherSession,
37
37
  type SwitcherSortMode,
38
- insertAfter
39
- } from "../utils/switcherModel";
38
+ insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
40
39
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
41
40
  import { DigestCard } from "./DigestCard";
42
41
  import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
@@ -124,7 +123,7 @@ type Props = {
124
123
 
125
124
  type Sheet = {
126
125
  session: SwitcherSession;
127
- mode: "menu" | "rename" | "folder";
126
+ mode: "menu" | "rename" | "folder" | "account";
128
127
  // Viewport point the menu anchors to — the ... button or the long-press
129
128
  // finger position. A bottom sheet made the thumb travel the whole screen
130
129
  // for actions about the element it was already touching.
@@ -1501,7 +1500,7 @@ export const SessionSwitcher = ({
1501
1500
  // buttons — right-click is a shortcut, never a second vocabulary.
1502
1501
  const openWallMenu = (e: ReactMouseEvent) => {
1503
1502
  // Only the empty wall — a right-click on a card belongs to that card.
1504
- if ((e.target as HTMLElement)?.closest?.(".switcher-card, .switcher-group-label, .switcher-top")) return;
1503
+ if ((e.target as HTMLElement)?.closest?.(".switcher-card, .switcher-row, .switcher-group-label, .switcher-top")) return;
1505
1504
  e.preventDefault();
1506
1505
  setMenu({
1507
1506
  x: e.clientX,
@@ -1704,6 +1703,19 @@ export const SessionSwitcher = ({
1704
1703
  // What a new session boots into: a plain shell, or straight into an agent.
1705
1704
  // Maps to the create API's `startup` command.
1706
1705
  const [createType, setCreateType] = useState<"terminal" | "claude" | "codex">("terminal");
1706
+ // The agents' logins on the host (one per config root). Fetched when the
1707
+ // wall opens; labels and pickers appear only where there is a choice.
1708
+ const [agentHomes, setAgentHomes] = useState<AgentHomes>({ claude: [], codex: [] });
1709
+ const loadAgentHomes = () => {
1710
+ fetch("/api/agent-homes", { cache: "no-store" })
1711
+ .then((r) => (r.ok ? r.json() : null))
1712
+ .then((d) => { if (d && Array.isArray(d.claude) && Array.isArray(d.codex)) setAgentHomes(d); })
1713
+ .catch(() => { /* no accounts info: nothing labelled */ });
1714
+ };
1715
+ useEffect(() => { loadAgentHomes(); }, []);
1716
+ const homesFor = (agent?: string | null): AgentHome[] => (agent === "claude" || agent === "codex" ? agentHomes[agent] : []);
1717
+ // The account a NEW agent session starts in; "" = the agent's default.
1718
+ const [createHome, setCreateHome] = useState("");
1707
1719
  const startupFor = (t: "terminal" | "claude" | "codex") => (t === "claude" ? "claude" : t === "codex" ? "codex" : "");
1708
1720
  // Rendered by BOTH create forms. submitCreate has always sent createType, but
1709
1721
  // only the top-bar form let you set it, so creating a session inside a folder
@@ -1721,6 +1733,19 @@ export const SessionSwitcher = ({
1721
1733
  onClick={() => setCreateType(t)}
1722
1734
  >{t === "terminal" ? "Terminal" : t === "claude" ? "Claude" : "Codex"}</button>
1723
1735
  ))}
1736
+ {createType !== "terminal" && homesFor(createType).length > 1 && (
1737
+ <select
1738
+ className="create-account"
1739
+ aria-label={`${createType === "claude" ? "Claude" : "Codex"} account`}
1740
+ title="Which login this session runs as (its config directory)"
1741
+ value={createHome}
1742
+ onChange={(e) => setCreateHome(e.target.value)}
1743
+ >
1744
+ {homesFor(createType).map((h) => (
1745
+ <option key={h.dir} value={h.isDefault ? "" : h.dir}>{describeAccount(h)}</option>
1746
+ ))}
1747
+ </select>
1748
+ )}
1724
1749
  </div>
1725
1750
  );
1726
1751
  // When set, the next created session is filed here (the folder header's +).
@@ -2334,14 +2359,21 @@ export const SessionSwitcher = ({
2334
2359
  onClose();
2335
2360
  return;
2336
2361
  }
2337
- // Opening a parked session IS unparking it — best-effort, never blocking
2338
- // the switch itself.
2362
+ // Opening a parked session IS unparking it. Best-effort and not awaited
2363
+ // for a parked one; a STOPPED one waits for it, because the daemon
2364
+ // refuses to restart a stopped session until it is unparked (a viewer's
2365
+ // reconnect must not undo "Stop & park").
2339
2366
  if (session.parked) {
2340
- fetch("/api/sessions/park", {
2367
+ const unparked = fetch("/api/sessions/park", {
2341
2368
  method: "POST",
2342
2369
  headers: { "Content-Type": "application/json" },
2343
2370
  body: JSON.stringify({ internalName: sessionKey(session), parked: false })
2344
2371
  }).catch(() => { /* daemon will still show it parked; harmless */ });
2372
+ if (session.archived) {
2373
+ onNotice("Resuming " + (session.displayName || session.name) + "…");
2374
+ void unparked.then(() => handleTap({ ...session, parked: false, archived: false }));
2375
+ return;
2376
+ }
2345
2377
  }
2346
2378
  // A DEAD terminal session starts IN PLACE instead of falling through to
2347
2379
  // full screen — the last accidental door into the mode. Attaching is what
@@ -2531,6 +2563,25 @@ export const SessionSwitcher = ({
2531
2563
 
2532
2564
  // Fork (same tool) or hand off (target names the other tool). One call
2533
2565
  // for both so the two can never disagree about the endpoint.
2566
+ const switchAccount = async (s: SwitcherSession, home: AgentHome) => {
2567
+ setSheet(null);
2568
+ if (home.login && !home.login.loggedIn && !window.confirm(`The ${home.label} account is logged out. Switch anyway? You will need to run /login in the session.`)) return;
2569
+ onNotice(`Moving ${s.displayName} to the ${home.label} account…`);
2570
+ try {
2571
+ const res = await fetch("/api/sessions/agent-home", {
2572
+ method: "POST",
2573
+ headers: { "Content-Type": "application/json" },
2574
+ body: JSON.stringify({ name: sessionKey(s), home: home.dir })
2575
+ });
2576
+ const data = await res.json().catch(() => null) as { ok?: boolean; error?: string; restarted?: boolean } | null;
2577
+ if (!res.ok || !data?.ok) { onNotice(data?.error || `Switch failed (${res.status})`); return; }
2578
+ onNotice(data.restarted ? `${s.displayName} now runs as ${home.label}` : `${s.displayName} is set to ${home.label}; it switches on its next start`);
2579
+ onRefresh();
2580
+ } catch {
2581
+ onNotice("Switch failed");
2582
+ }
2583
+ };
2584
+
2534
2585
  const forkSession = async (s: SwitcherSession, target?: "claude" | "codex") => {
2535
2586
  setSheet(null);
2536
2587
  onNotice(target ? `Handing off to ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
@@ -2740,7 +2791,10 @@ export const SessionSwitcher = ({
2740
2791
  const res = await fetch("/api/sessions", {
2741
2792
  method: "POST",
2742
2793
  headers: { "Content-Type": "application/json" },
2743
- body: JSON.stringify({ name: next, type: "terminal", port: null, startup: startupFor(createType), cwd: createCwd.trim() || undefined })
2794
+ body: JSON.stringify({
2795
+ name: next, type: "terminal", port: null, startup: startupFor(createType), cwd: createCwd.trim() || undefined,
2796
+ agentHome: createType !== "terminal" && createHome && homesFor(createType).some((h) => h.dir === createHome) ? createHome : undefined
2797
+ })
2744
2798
  });
2745
2799
  const data = await res.json().catch(() => ({} as { name?: string; displayName?: string; internalName?: string; error?: string }));
2746
2800
  if (!res.ok || !data.name) {
@@ -3033,6 +3087,18 @@ export const SessionSwitcher = ({
3033
3087
  ⏰ {s.checkbacks}
3034
3088
  </button>
3035
3089
  )}
3090
+ {s.agentHome && homesFor(s.agent).length > 1 && (() => {
3091
+ const known = homesFor(s.agent).find((h) => h.dir === s.agentHome?.dir);
3092
+ const out = known?.login && !known.login.loggedIn;
3093
+ return (
3094
+ <span
3095
+ className={"switcher-chip account" + (s.agentHome.isDefault ? " default" : "") + (out ? " logged-out" : "")}
3096
+ title={`Runs as ${known ? describeAccount(known) : s.agentHome.label} — ${s.agentHome.dir}${out ? ". Logged out: run /login in the session, or switch it to another account." : ""}`}
3097
+ >
3098
+ {out ? "⚠ " : ""}{s.agentHome.label}
3099
+ </span>
3100
+ );
3101
+ })()}
3036
3102
  {phase && (
3037
3103
  <span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
3038
3104
  <span className="phase-dot" aria-hidden="true" />{phaseLabel(phase)}
@@ -3577,6 +3643,7 @@ export const SessionSwitcher = ({
3577
3643
  const width = 264;
3578
3644
  const estH = sheet.mode === "rename" ? 150
3579
3645
  : sheet.mode === "folder" ? 100 + 34 * (folders.length + 3)
3646
+ : sheet.mode === "account" ? 110 + 34 * homesFor(sheet.session.agent).length
3580
3647
  : 330;
3581
3648
  const left = Math.min(Math.max(8, sheet.anchor.x - width), window.innerWidth - width - 8);
3582
3649
  let top = sheet.anchor.y + 8;
@@ -3604,6 +3671,34 @@ export const SessionSwitcher = ({
3604
3671
  <button type="submit">Save</button>
3605
3672
  <button type="button" onClick={() => setSheet({ session: sheetSession, mode: "menu", anchor: sheet.anchor })}>✕</button>
3606
3673
  </form>
3674
+ ) : sheet.mode === "account" ? (
3675
+ // Another login for this session: the daemon copies the
3676
+ // conversation into that account's config root and relaunches
3677
+ // the agent there (refused while it is mid-turn).
3678
+ <>
3679
+ <div className="switcher-sheet-note">
3680
+ Run {sheetSession.displayName} as another {sheetSession.agent === "codex" ? "Codex" : "Claude"} account.
3681
+ The conversation comes along; the agent restarts.
3682
+ </div>
3683
+ {homesFor(sheetSession.agent).map((h) => {
3684
+ const here = sheetSession.agentHome?.dir === h.dir;
3685
+ return (
3686
+ <button
3687
+ key={h.dir}
3688
+ type="button"
3689
+ className={here ? "current" : undefined}
3690
+ disabled={here}
3691
+ title={h.dir}
3692
+ onClick={() => { void switchAccount(sheetSession, h); }}
3693
+ >
3694
+ {describeAccount(h)}{here ? " ✓" : ""}
3695
+ </button>
3696
+ );
3697
+ })}
3698
+ <button type="button" className="back" onClick={() => setSheet({ session: sheetSession, mode: "menu", anchor: sheet.anchor })}>
3699
+ ← Back
3700
+ </button>
3701
+ </>
3607
3702
  ) : sheet.mode === "folder" ? (
3608
3703
  // Filing from the menu: drag-and-drop needs a pointer, a free
3609
3704
  // hand, and the folder on screen at the same time as the card —
@@ -3690,6 +3785,11 @@ export const SessionSwitcher = ({
3690
3785
  >
3691
3786
  Move to folder…
3692
3787
  </button>
3788
+ {homesFor(sheetSession.agent).length > 1 && (
3789
+ <button type="button" onClick={() => setSheet({ session: sheetSession, mode: "account", anchor: sheet.anchor })}>
3790
+ Account: {sheetSession.agentHome?.label || "default"}…
3791
+ </button>
3792
+ )}
3693
3793
  {sheetSession.type !== "port" && <div className="switcher-sheet-sep" aria-hidden="true" />}
3694
3794
  {sheetSession.type !== "port" && (
3695
3795
  <button type="button" onClick={toggleAgentAccess}>
@@ -2363,6 +2363,40 @@ button.primary {
2363
2363
  color: var(--ink-muted);
2364
2364
  }
2365
2365
 
2366
+ /* Which login the agent runs as — shown only when there is more than one.
2367
+ The default account stays quiet; another account is outlined so a
2368
+ session spending a different quota is visible at a glance. */
2369
+ .switcher-chip.account {
2370
+ border: 1px solid var(--accent, var(--line));
2371
+ background: transparent;
2372
+ color: var(--ink);
2373
+ text-transform: none;
2374
+ }
2375
+ .switcher-chip.account.default {
2376
+ border-color: var(--line);
2377
+ color: var(--ink-muted);
2378
+ }
2379
+ .switcher-chip.account.logged-out {
2380
+ border-color: #d97706;
2381
+ color: #b45309;
2382
+ }
2383
+ .switcher-create-type .create-account {
2384
+ margin-left: 6px;
2385
+ font: inherit;
2386
+ font-size: 12px;
2387
+ padding: 2px 4px;
2388
+ border-radius: 6px;
2389
+ border: 1px solid var(--line);
2390
+ background: var(--panel);
2391
+ color: var(--ink);
2392
+ }
2393
+ .switcher-sheet-note {
2394
+ padding: 6px 12px 8px;
2395
+ font-size: 12px;
2396
+ line-height: 1.4;
2397
+ color: var(--ink-muted);
2398
+ }
2399
+
2366
2400
  .switcher-more {
2367
2401
  flex-shrink: 0;
2368
2402
  width: 26px;
@@ -1,3 +1,20 @@
1
+ /** One login of an agent: a config root on the host. */
2
+ export type AgentHome = {
3
+ dir: string;
4
+ label: string;
5
+ isDefault: boolean;
6
+ /** Who the root is logged in as, when the host could ask (null = unknown). */
7
+ login?: { loggedIn: boolean; email: string | null } | null;
8
+ };
9
+ /** "fable · a@b.org", "claude2 · logged out", or just the label. */
10
+ export const describeAccount = (h: AgentHome): string => {
11
+ const name = h.isDefault ? "default" : h.label;
12
+ if (!h.login) return name;
13
+ if (!h.login.loggedIn) return `${name} · logged out`;
14
+ return h.login.email ? `${name} · ${h.login.email}` : name;
15
+ };
16
+ export type AgentHomes = { claude: AgentHome[]; codex: AgentHome[] };
17
+
1
18
  // Tiered model for the mobile session switcher. Pure data shaping so it can be
2
19
  // unit-tested without the DOM: the component just renders what this returns.
3
20
  //
@@ -35,6 +52,8 @@ export type SwitcherSession = {
35
52
  foregroundProcess?: string;
36
53
  /** What the session runs, by its hooks' records: claude, codex, or nothing known. */
37
54
  agent?: "claude" | "codex" | null;
55
+ /** The account (config root) the agent runs as: CLAUDE_CONFIG_DIR / CODEX_HOME. */
56
+ agentHome?: AgentHome | null;
38
57
  /** Server-decided: a turn in flight / the last turn finished; turnSeen = a human opened it since. */
39
58
  agentPhase?: "working" | "done" | null;
40
59
  turnSeen?: boolean;
package/hop CHANGED
@@ -21,6 +21,7 @@ const { AuthSessionStore, isSessionToken: isAuthSessionToken } = require('./lib/
21
21
  const { agentPhase, screenSaysWorking } = require('./lib/agent-phase');
22
22
  const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('./lib/identity-snapshot');
23
23
  const { withCodexIdentity } = require('./lib/codex-identity');
24
+ const agentHomesLib = require('./lib/agent-homes');
24
25
  const { resolveCallerSession, sessionFromEnvLine } = require('./lib/session-identity');
25
26
  const checkbacks = require('./lib/checkbacks');
26
27
  const digestReads = require('./lib/digest-reads');
@@ -4678,7 +4679,7 @@ function buildSessionEntries(haySessions, opts = {}) {
4678
4679
  createdAt: config?.createdAt,
4679
4680
  createdBy: config?.createdBy || 'user',
4680
4681
  agentPermitted: config?.agentPermitted === true,
4681
- agent: sessionAgentKind(s.name),
4682
+ ...sessionAgentFields(s.name, config),
4682
4683
  localCliCount,
4683
4684
  hasLocalCli: clientSummary.hasLocalCli || localCliCount > 0 || s.hasLocalCli === true,
4684
4685
  ...getSessionSeenInfo(s.name)
@@ -4723,7 +4724,7 @@ function buildSessionEntries(haySessions, opts = {}) {
4723
4724
  createdAt: config?.createdAt,
4724
4725
  createdBy,
4725
4726
  agentPermitted: config?.agentPermitted === true,
4726
- agent: sessionAgentKind(name),
4727
+ ...sessionAgentFields(name, config),
4727
4728
  localCliCount: clientSummary.localCliCount,
4728
4729
  hasLocalCli: clientSummary.hasLocalCli,
4729
4730
  ...getSessionSeenInfo(name)
@@ -4788,7 +4789,7 @@ function buildSessionEntries(haySessions, opts = {}) {
4788
4789
  createdAt: config?.createdAt,
4789
4790
  createdBy: effective?.createdBy || 'user',
4790
4791
  agentPermitted: config?.agentPermitted === true,
4791
- agent: sessionAgentKind(name),
4792
+ ...sessionAgentFields(name, config),
4792
4793
  localCliCount: clientSummary.localCliCount,
4793
4794
  hasLocalCli: clientSummary.hasLocalCli,
4794
4795
  ...getSessionSeenInfo(name)
@@ -4853,6 +4854,12 @@ async function listAllSessionEntriesRuntimeFirst() {
4853
4854
  return { entries, runtimeAvailable: runtime.runtimeAvailable, activeDisplayNames };
4854
4855
  }
4855
4856
 
4857
+ /** Stopped with "Stop & park": archived in the saved config or the durable meta. */
4858
+ function isSessionStopped(internalName) {
4859
+ return getEffectiveSessionConfig(internalName)?.archived === true
4860
+ || readClaudeSessionMeta(internalName)?.archived === true;
4861
+ }
4862
+
4856
4863
  async function runtimeTerminalSessionExists(sessionName) {
4857
4864
  if (!sessionName) return false;
4858
4865
  const runtime = await listActiveSessionsRuntimeFirst();
@@ -5588,6 +5595,105 @@ function readTurnInfo(internalName) {
5588
5595
  * the foreground process name, which reports codex as "node" and any
5589
5596
  * argv-launched restore as the wrapper shell. 'claude' | 'codex' | null.
5590
5597
  */
5598
+ // ---- Accounts (config roots) ---------------------------------------------
5599
+ // One Claude login per CLAUDE_CONFIG_DIR, one Codex login per CODEX_HOME
5600
+ // (lib/agent-homes.js). Discovered from the home directory, cached briefly:
5601
+ // the session list asks per session.
5602
+ let agentHomesCache = { at: 0, value: null };
5603
+ function listAgentHomes(agent) {
5604
+ if (!agentHomesCache.value || Date.now() - agentHomesCache.at > 30_000) {
5605
+ let entries = [];
5606
+ try { entries = fs.readdirSync(os.homedir()); } catch (e) { /* none */ }
5607
+ const exists = (p) => { try { fs.statSync(p); return true; } catch (e) { return false; } };
5608
+ const opts = { homeDir: os.homedir(), entries, exists };
5609
+ agentHomesCache = {
5610
+ at: Date.now(),
5611
+ value: {
5612
+ claude: agentHomesLib.discoverAgentHomes('claude', { ...opts, envDir: process.env.CLAUDE_CONFIG_DIR || null }),
5613
+ codex: agentHomesLib.discoverAgentHomes('codex', { ...opts, envDir: process.env.CODEX_HOME || null })
5614
+ }
5615
+ };
5616
+ }
5617
+ return agent ? (agentHomesCache.value[agent] || []) : agentHomesCache.value;
5618
+ }
5619
+ // Who each root is logged in as: `claude auth status` (JSON: loggedIn,
5620
+ // email) and `codex login status`, run with the root pinned. Both answer
5621
+ // in well under a second; cached for two minutes. Two roots can hold the
5622
+ // SAME account (a copy of a config dir), and a root can be logged out —
5623
+ // the wall shows both before anyone switches a session into it.
5624
+ const agentLoginCache = new Map(); // `${agent}:${dir}` -> { at, value }
5625
+ let agentBinCache = null;
5626
+ function agentBinary(agent) {
5627
+ if (!agentBinCache) agentBinCache = {};
5628
+ if (!(agent in agentBinCache)) {
5629
+ let bin = null;
5630
+ try { bin = execSync(`/bin/zsh -lc 'whence -p ${agent}'`, { encoding: 'utf8', timeout: 5000 }).trim().split('\n').pop() || null; } catch (e) { bin = null; }
5631
+ agentBinCache[agent] = bin;
5632
+ }
5633
+ return agentBinCache[agent];
5634
+ }
5635
+ function probeAgentLogin(agent, home) {
5636
+ const key = `${agent}:${home.dir}`;
5637
+ const hit = agentLoginCache.get(key);
5638
+ if (hit && Date.now() - hit.at < 120_000) return Promise.resolve(hit.value);
5639
+ const bin = agentBinary(agent);
5640
+ if (!bin) return Promise.resolve(null);
5641
+ const env = { ...process.env };
5642
+ const varName = agentHomesLib.ENV_VAR[agent];
5643
+ if (home.isDefault) delete env[varName]; else env[varName] = home.dir;
5644
+ const args = agent === 'claude' ? ['auth', 'status'] : ['login', 'status'];
5645
+ return new Promise((resolve) => {
5646
+ execFile(bin, args, { env, timeout: 8000, encoding: 'utf8' }, (err, stdout, stderr) => {
5647
+ const out = `${stdout || ''}${stderr || ''}`;
5648
+ let value = null;
5649
+ if (agent === 'claude') {
5650
+ try {
5651
+ const j = JSON.parse(out.slice(out.indexOf('{'), out.lastIndexOf('}') + 1));
5652
+ value = { loggedIn: j.loggedIn === true, email: typeof j.email === 'string' ? j.email : null };
5653
+ } catch (e) { value = null; }
5654
+ } else if (out.trim()) {
5655
+ value = { loggedIn: /logged in/i.test(out) && !/not logged in/i.test(out), email: null };
5656
+ }
5657
+ agentLoginCache.set(key, { at: Date.now(), value });
5658
+ resolve(value);
5659
+ });
5660
+ });
5661
+ }
5662
+ async function listAgentHomesWithLogins() {
5663
+ const all = listAgentHomes();
5664
+ const out = {};
5665
+ for (const agent of Object.keys(all)) {
5666
+ out[agent] = await Promise.all(all[agent].map(async (h) => ({ ...h, login: await probeAgentLogin(agent, h) })));
5667
+ }
5668
+ return out;
5669
+ }
5670
+
5671
+ /** Codex rollouts can live under any Codex account's root. */
5672
+ function codexRolloutRoots() {
5673
+ return Array.from(new Set([...getCodexConfigRoots(), ...listAgentHomes('codex').map((h) => h.dir)]));
5674
+ }
5675
+ /**
5676
+ * Which account a session's agent runs as. Claude: the root its SessionStart
5677
+ * record saw (CLAUDE_CONFIG_DIR at launch; null = default), else the one the
5678
+ * session was created with. Codex: CODEX_HOME from the session's env.
5679
+ */
5680
+ function sessionAgentHome(internalName, agent, config) {
5681
+ if (agent === 'claude') {
5682
+ const rec = readClaudeSessionRecord(internalName);
5683
+ const dir = rec ? (rec.configDir || null) : (config?.env?.CLAUDE_CONFIG_DIR || null);
5684
+ return agentHomesLib.describeAgentHome(dir, 'claude', os.homedir());
5685
+ }
5686
+ if (agent === 'codex') {
5687
+ return agentHomesLib.describeAgentHome(config?.env?.CODEX_HOME || null, 'codex', os.homedir());
5688
+ }
5689
+ return null;
5690
+ }
5691
+ /** The list's agent fields for one session: the tool, and the account it runs as. */
5692
+ function sessionAgentFields(internalName, config) {
5693
+ const agent = sessionAgentKind(internalName);
5694
+ return { agent, agentHome: agent ? sessionAgentHome(internalName, agent, config) : null };
5695
+ }
5696
+
5591
5697
  function sessionAgentKind(internalName) {
5592
5698
  if (readClaudeSessionRecord(internalName)?.sessionId) return 'claude';
5593
5699
  const turn = readTurnInfo(internalName);
@@ -5597,7 +5703,7 @@ function sessionAgentKind(internalName) {
5597
5703
  /** A codex rollout by its session id: the id is in the file's name. */
5598
5704
  function findCodexRolloutById(sessionId) {
5599
5705
  if (!sessionId || !/^[A-Za-z0-9-]{8,}$/.test(sessionId)) return null;
5600
- for (const root of getCodexConfigRoots()) {
5706
+ for (const root of codexRolloutRoots()) {
5601
5707
  const sessions = path.join(root, 'sessions');
5602
5708
  let years = [];
5603
5709
  try { years = fs.readdirSync(sessions).sort().reverse(); } catch (e) { continue; }
@@ -6032,7 +6138,7 @@ function claudeConfigRootsForRestore(recConfigDir) {
6032
6138
  push(path.join(os.homedir(), '.claude'));
6033
6139
  try {
6034
6140
  for (const dirEntry of fs.readdirSync(os.homedir())) {
6035
- if (/^\.claude[_-][A-Za-z0-9_-]+$/.test(dirEntry)) push(path.join(os.homedir(), dirEntry));
6141
+ if (/^\.claude[_-]?[A-Za-z0-9][A-Za-z0-9_-]*$/.test(dirEntry)) push(path.join(os.homedir(), dirEntry));
6036
6142
  }
6037
6143
  } catch (e) { /* home unreadable — stick with the defaults */ }
6038
6144
  return roots;
@@ -6288,6 +6394,91 @@ function rebuildClaudeLaunch(launchCmd) {
6288
6394
  // start the shell in. For a claude session we use the cwd recorded by the hook
6289
6395
  // (where claude started), since `claude --resume` resolves the conversation from
6290
6396
  // that project directory.
6397
+ /**
6398
+ * The environment a new session needs to run as `homeDir`'s account, for
6399
+ * the agent its startup command launches. Null = nothing to pin (no agent,
6400
+ * or the default account).
6401
+ */
6402
+ function accountEnvForLaunch(launchCmd, homeDir) {
6403
+ if (!homeDir) return null;
6404
+ const word = (String(launchCmd || '').trim().split(/\s+/)[0] || '').split('/').pop();
6405
+ const agent = word === 'codex' ? 'codex' : /^claude/.test(word) ? 'claude' : null;
6406
+ if (!agent) return { error: 'An account applies only to a Claude or Codex session' };
6407
+ const home = listAgentHomes(agent).find((h) => h.dir === homeDir);
6408
+ if (!home) return { error: `No ${agent} account at ${homeDir}` };
6409
+ return home.isDefault ? null : { env: agentHomesLib.envForAgentHome(agent, home.dir) };
6410
+ }
6411
+
6412
+ /**
6413
+ * Move a session to another account and relaunch it there, conversation
6414
+ * intact: the transcript (Claude) or rollout (Codex) is copied to the same
6415
+ * place under the new root — the old copy stays as history — the record and
6416
+ * the session's env are re-pinned, and the session restarts through
6417
+ * restore. Nothing changes while the agent is mid-turn.
6418
+ */
6419
+ async function switchSessionAgentHome(name, homeDir) {
6420
+ const internalName = resolveSessionInternalName(name, name);
6421
+ if (!internalName) return { status: 404, body: { error: 'No such session' } };
6422
+ const agent = sessionAgentKind(internalName);
6423
+ if (!agent) return { status: 409, body: { error: 'Not a Claude or Codex session' } };
6424
+ const home = listAgentHomes(agent).find((h) => h.dir === homeDir);
6425
+ if (!home) return { status: 400, body: { error: `No ${agent} account at ${homeDir}` } };
6426
+ const config = getSessionConfig(internalName) || getEffectiveSessionConfig(internalName) || {};
6427
+ const current = sessionAgentHome(internalName, agent, config);
6428
+ if (current && current.dir === home.dir) return { status: 200, body: { ok: true, unchanged: true, home } };
6429
+
6430
+ const probe = await restoreSavedSessions({ dryRun: true, includeAgents: true, only: [internalName], replaceIdleAgent: true });
6431
+ const busy = (probe?.skipped || []).find((x) => /busy/.test(String(x.reason || '')));
6432
+ if (busy) return { status: 409, body: { error: `${getSessionDisplayName(internalName) || internalName} is mid-turn — switch accounts when it is idle` } };
6433
+
6434
+ const copyInto = (file, fromRoot) => {
6435
+ const target = fromRoot ? agentHomesLib.transcriptTarget(file, fromRoot, home.dir) : null;
6436
+ if (!target) throw new Error(`conversation file ${file} is not under a known ${agent} root`);
6437
+ fs.mkdirSync(path.dirname(target), { recursive: true });
6438
+ if (!fs.existsSync(target)) fs.copyFileSync(file, target);
6439
+ return target;
6440
+ };
6441
+ let moved;
6442
+ if (agent === 'claude') {
6443
+ const rec = readClaudeSessionRecord(internalName);
6444
+ if (!rec) return { status: 409, body: { error: 'No recorded Claude conversation to move' } };
6445
+ const found = findClaudeTranscriptById(rec.sessionId);
6446
+ if (!found) return { status: 409, body: { error: `Conversation ${rec.sessionId.slice(0, 8)}… has no transcript on this host` } };
6447
+ const fromRoot = found.configDir || agentHomesLib.defaultHome('claude', os.homedir());
6448
+ moved = copyInto(found.file, fromRoot);
6449
+ // The newer layout keeps subagent transcripts in a sibling directory.
6450
+ const sibling = found.file.endsWith('.jsonl') ? found.file.slice(0, -'.jsonl'.length) : null;
6451
+ if (sibling && fs.existsSync(sibling) && fs.statSync(sibling).isDirectory()) {
6452
+ const target = agentHomesLib.transcriptTarget(sibling, fromRoot, home.dir);
6453
+ if (target && !fs.existsSync(target)) fs.cpSync(sibling, target, { recursive: true });
6454
+ }
6455
+ writeClaudeSessionRecord(internalName, {
6456
+ ...rec,
6457
+ configDir: home.isDefault ? null : home.dir,
6458
+ launchCmd: agentHomesLib.stripConfigPrefix(rec.launchCmd || `claude --resume ${rec.sessionId}`),
6459
+ updatedAt: new Date().toISOString()
6460
+ });
6461
+ } else {
6462
+ const turn = readTurnInfo(internalName);
6463
+ const rootId = turn?.sessionId ? codexRootThreadId(turn.sessionId) : null;
6464
+ const file = rootId ? findCodexRolloutById(rootId) : null;
6465
+ if (!file) return { status: 409, body: { error: 'No recorded Codex conversation to move' } };
6466
+ moved = copyInto(file, codexRolloutRoots().find((r) => file.startsWith(r + path.sep)));
6467
+ }
6468
+ // The session's own env carries the account into every later launch.
6469
+ const varName = agentHomesLib.ENV_VAR[agent];
6470
+ const env = { ...(config.env || {}) };
6471
+ delete env[varName];
6472
+ if (!home.isDefault) env[varName] = home.dir;
6473
+ const base = getSessionConfig(internalName) || getEffectiveSessionConfig(internalName);
6474
+ if (base) setSessionConfig(internalName, { ...base, env: Object.keys(env).length ? env : undefined, updatedAt: new Date().toISOString() });
6475
+
6476
+ const result = await restoreSavedSessions({ includeAgents: true, only: [internalName], replaceIdleAgent: true });
6477
+ const restarted = (result?.restored || []).length > 0;
6478
+ log(`[account] ${internalName} (${agent}) → ${home.label} (${home.dir})${restarted ? ', relaunched' : ''}`, CYAN);
6479
+ return { status: 200, body: { ok: true, home, moved, restarted, skipped: result?.skipped || [] } };
6480
+ }
6481
+
6291
6482
  function restorePlanForSession(entry) {
6292
6483
  let rec = readClaudeSessionRecord(entry.internalName);
6293
6484
  // Codex has no SessionStart record; its notify hook leaves only the
@@ -6706,8 +6897,12 @@ async function roomIsRedoable(entry) {
6706
6897
  } catch (e) { return { ok: false, reason: 'screen unavailable' }; }
6707
6898
  if (TRUST_PROMPT_RE.test(text)) return { ok: true, reason: 'parked at the workspace-trust dialog' };
6708
6899
  if (RESUME_PROMPT_RE.test(text)) return { ok: true, reason: 'parked at the resume prompt' };
6709
- if (CLAUDE_UI_RE.test(text)) return { ok: false, reason: 'claude is running' };
6710
- if (fg && !isShellProcName(fg)) return { ok: false, reason: `${fg} is running` };
6900
+ // An agent at its prompt is not redoable for a repair restore, but it
6901
+ // IS safe to restart on purpose (an account switch): `agentIdle` says so
6902
+ // — the screen shows no turn in flight ("esc to interrupt").
6903
+ const agentIdle = !screenSaysWorking(text);
6904
+ if (CLAUDE_UI_RE.test(text)) return { ok: false, reason: 'claude is running', agentIdle };
6905
+ if (fg && !isShellProcName(fg)) return { ok: false, reason: `${fg} is running`, agentIdle: agentIdle && /^(claude|codex|node)$/.test(fg.split('/').pop()) };
6711
6906
  // The grid text can carry stray control bytes a shell left on: ESC c
6712
6907
  // (reset), CSI sequences, and a trailing SGR mouse-report fragment
6713
6908
  // ("...% 35;11;17M") from an app that had mouse tracking on — angler
@@ -6749,7 +6944,7 @@ function resolveRestoreTargets(names, snapshot) {
6749
6944
  }
6750
6945
  return { resolved, unknown };
6751
6946
  }
6752
- async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = false, only = null } = {}) {
6947
+ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = false, only = null, replaceIdleAgent = false } = {}) {
6753
6948
  // Folders first: a session whose meta lost its folder (records deleted,
6754
6949
  // meta rebuilt) is put back where the newest identity snapshot says it
6755
6950
  // was, so the restored wall is filed the way the user left it.
@@ -6775,7 +6970,9 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6775
6970
  const entry = snapshot.entries.find((e) => e.internalName === internalName);
6776
6971
  const verdict = await roomIsRedoable(entry);
6777
6972
  const display = getSessionDisplayName(internalName) || internalName;
6778
- if (!verdict.ok) {
6973
+ // A deliberate restart (account switch) may replace an agent that
6974
+ // sits idle at its prompt — never one mid-turn.
6975
+ if (!verdict.ok && !(replaceIdleAgent && verdict.agentIdle)) {
6779
6976
  skipped.push({ name: display, reason: `busy — ${verdict.reason}` });
6780
6977
  onlySet.delete(internalName);
6781
6978
  continue;
@@ -7157,7 +7354,7 @@ function getClaudeConfigRoots() {
7157
7354
  push(path.join(os.homedir(), '.claude'));
7158
7355
  try {
7159
7356
  for (const entry of fs.readdirSync(os.homedir())) {
7160
- if (/^\.claude[_-][A-Za-z0-9_-]+$/.test(entry)) push(path.join(os.homedir(), entry));
7357
+ if (/^\.claude[_-]?[A-Za-z0-9][A-Za-z0-9_-]*$/.test(entry)) push(path.join(os.homedir(), entry));
7161
7358
  }
7162
7359
  } catch (e) { /* home unreadable — defaults stand */ }
7163
7360
  return roots.filter((p) => { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } });
@@ -12013,6 +12210,9 @@ happens until someone clicks it.
12013
12210
  cwd: s.cwd,
12014
12211
  foregroundProcess: s.foregroundProcess,
12015
12212
  agent: s.agent || null,
12213
+ // Which account (config root) the agent runs as — the wall labels
12214
+ // it when more than one is set up.
12215
+ agentHome: s.agentHome || null,
12016
12216
  // working / done + whether the human has opened it since the
12017
12217
  // turn ended (server-side seen witness, so every device agrees).
12018
12218
  ...(phaseBySession.get(s.internalName) || { agentPhase: null, turnSeen: true, turnAt: null }),
@@ -12379,8 +12579,10 @@ happens until someone clicks it.
12379
12579
  cwd: plan?.cwd || config.cwd,
12380
12580
  updatedAt: new Date().toISOString()
12381
12581
  };
12382
- if (savedConfig) setSessionConfig(resolved, next);
12383
- else rememberRuntimeSessionMetadata(resolved, next);
12582
+ // Always SAVED: a stopped session has no room, and a
12583
+ // runtime-only definition went with it — a stopped plain
12584
+ // terminal vanished from the list instead of parking.
12585
+ setSessionConfig(resolved, next);
12384
12586
  const durable = readClaudeSessionMeta(resolved) || {};
12385
12587
  writeClaudeSessionMeta(resolved, { ...durable, internalName: resolved, parked: true, archived: true, cwd: next.cwd });
12386
12588
  // Stop the process — records, metas, and buffers all stay.
@@ -12683,7 +12885,7 @@ happens until someone clicks it.
12683
12885
  req.on('data', chunk => body += chunk);
12684
12886
  req.on('end', async () => {
12685
12887
  try {
12686
- let { name, type, port, cwd, folderId, startRuntime, workspace, workspaceName, attachedTo, startup } = JSON.parse(body);
12888
+ let { name, type, port, cwd, folderId, startRuntime, workspace, workspaceName, attachedTo, startup, agentHome } = JSON.parse(body);
12687
12889
  // The web create form takes the directory as the user
12688
12890
  // typed it, ~ included; the host only checks that the
12689
12891
  // path exists, so an unexpanded ~ silently fell back to
@@ -12833,8 +13035,18 @@ happens until someone clicks it.
12833
13035
  return;
12834
13036
  }
12835
13037
  } else if (isExternalTerminalBackend()) {
13038
+ // The account it runs as: a chosen non-default
13039
+ // root rides in the room's environment (and is
13040
+ // remembered there for restore).
13041
+ const homeEnv = accountEnvForLaunch(launchCmd, typeof agentHome === 'string' ? agentHome : null);
13042
+ if (homeEnv && homeEnv.error) {
13043
+ res.writeHead(400, { 'Content-Type': 'application/json' });
13044
+ res.end(JSON.stringify({ error: homeEnv.error }));
13045
+ return;
13046
+ }
12836
13047
  await ensureExternalRuntimeSession(internalName, cwd, displayName, {
12837
13048
  folderId: targetFolderId,
13049
+ env: homeEnv?.env || undefined,
12838
13050
  // Persist the launch so `hop restore` brings the
12839
13051
  // app back, not a bare shell. Plain terminals stay
12840
13052
  // runtime-only as before.
@@ -13072,6 +13284,33 @@ happens until someone clicks it.
13072
13284
  }));
13073
13285
  return;
13074
13286
  }
13287
+ if (parsedUrl.pathname === '/api/agent-homes' && req.method === 'GET') {
13288
+ agentHomesCache.at = 0; // a fresh look: someone may have just logged in somewhere
13289
+ listAgentHomesWithLogins().then((homes) => {
13290
+ res.writeHead(200, { 'Content-Type': 'application/json' });
13291
+ res.end(JSON.stringify(homes));
13292
+ }).catch(() => {
13293
+ res.writeHead(200, { 'Content-Type': 'application/json' });
13294
+ res.end(JSON.stringify(listAgentHomes()));
13295
+ });
13296
+ return;
13297
+ }
13298
+ if (parsedUrl.pathname === '/api/sessions/agent-home' && req.method === 'POST') {
13299
+ let body = '';
13300
+ req.on('data', chunk => body += chunk);
13301
+ req.on('end', async () => {
13302
+ try {
13303
+ const parsed = body ? JSON.parse(body) : {};
13304
+ const result = await switchSessionAgentHome(String(parsed.name || ''), String(parsed.home || ''));
13305
+ res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
13306
+ res.end(JSON.stringify(result.body));
13307
+ } catch (e) {
13308
+ res.writeHead(500, { 'Content-Type': 'application/json' });
13309
+ res.end(JSON.stringify({ error: `Switch failed: ${e?.message || e}` }));
13310
+ }
13311
+ });
13312
+ return;
13313
+ }
13075
13314
  if (parsedUrl.pathname === '/api/sessions/restore' && req.method === 'POST') {
13076
13315
  let body = '';
13077
13316
  req.on('data', chunk => body += chunk);
@@ -14244,6 +14483,19 @@ happens until someone clicks it.
14244
14483
  if (isExternalTerminalBackend()) {
14245
14484
  (async () => {
14246
14485
  try {
14486
+ // A STOPPED session stays stopped: a viewer that still had
14487
+ // it open (a wall tile, the phone) reconnects the moment
14488
+ // its room ends, and creating the room again relaunched
14489
+ // the agent — "Stop & park" undid itself within a second
14490
+ // (retro, 2026-10-08, respawned twice). Only a deliberate
14491
+ // open clears the flag (the client unparks first); until
14492
+ // then a reconnect of a stopped, roomless session is
14493
+ // refused.
14494
+ if (isSessionStopped(sessionName) && !(await runtimeTerminalSessionExists(sessionName))) {
14495
+ socket.write('HTTP/1.1 409 Session Stopped\r\n\r\n');
14496
+ socket.destroy();
14497
+ return;
14498
+ }
14247
14499
  await reconcileExternalRuntimeSessions();
14248
14500
  await ensureExternalRuntimeSession(sessionName, sessionConfig?.cwd, getSessionDisplayName(sessionName), {
14249
14501
  shell: sessionConfig?.shell,
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ // Which ACCOUNT an agent session runs as.
3
+ //
4
+ // Claude Code keeps one login per config root (CLAUDE_CONFIG_DIR: its
5
+ // credentials, settings and transcripts all live under it), Codex one per
6
+ // CODEX_HOME. Two logins on one machine are therefore two roots —
7
+ // ~/.claude and ~/.claude_fable, say — and which one a session started
8
+ // under decides whose quota it spends and whose transcript store it writes.
9
+ // Nothing on the wall said which. These helpers name the roots, label them
10
+ // for a chip, and move a conversation between them.
11
+
12
+ const path = require('path');
13
+
14
+ const DEFAULT_BASENAME = { claude: '.claude', codex: '.codex' };
15
+ const ENV_VAR = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME' };
16
+
17
+ const defaultHome = (agent, homeDir) => path.join(homeDir, DEFAULT_BASENAME[agent] || `.${agent}`);
18
+
19
+ /** "default" for the agent's own root, else the part after `.claude_` / `.codex-`, else the basename. */
20
+ function agentHomeLabel(dir, agent, homeDir) {
21
+ if (!dir || dir === defaultHome(agent, homeDir)) return 'default';
22
+ const base = path.basename(dir);
23
+ const m = new RegExp(`^\\.?${agent}[_-](.+)$`).exec(base);
24
+ return m ? m[1] : base.replace(/^\./, '');
25
+ }
26
+
27
+ /** { dir, label, isDefault } for a root (null/undefined = the default root). */
28
+ function describeAgentHome(dir, agent, homeDir) {
29
+ const resolved = dir || defaultHome(agent, homeDir);
30
+ return { dir: resolved, label: agentHomeLabel(resolved, agent, homeDir), isDefault: resolved === defaultHome(agent, homeDir) };
31
+ }
32
+
33
+ // A root is an ACCOUNT only when it holds a login's footprint — not every
34
+ // `.claude-something` directory does (`.claude-science` held one settings
35
+ // file; `.codexbar` is another app altogether).
36
+ const ACCOUNT_MARKERS = {
37
+ claude: ['.claude.json', 'projects'],
38
+ codex: ['auth.json', 'config.toml', 'sessions']
39
+ };
40
+
41
+ /**
42
+ * Every account root on this machine for one agent: the default first, then
43
+ * sibling installs (`.claude_x`, `.claude-y`, `.claude2`), then whatever the
44
+ * environment points at. `entries` is the home directory's listing;
45
+ * `exists(path)` checks a path. The default root is always listed when it
46
+ * exists at all — its login may live outside it (~/.claude.json).
47
+ */
48
+ function discoverAgentHomes(agent, { homeDir, entries, exists, envDir }) {
49
+ const out = [];
50
+ const markers = ACCOUNT_MARKERS[agent] || [];
51
+ const push = (dir, needMarker) => {
52
+ if (!dir || out.some((h) => h.dir === dir)) return;
53
+ if (!exists(dir)) return;
54
+ if (needMarker && !markers.some((m) => exists(path.join(dir, m)))) return;
55
+ out.push(describeAgentHome(dir, agent, homeDir));
56
+ };
57
+ push(defaultHome(agent, homeDir), false);
58
+ const re = new RegExp(`^\\.${agent}[_-]?[A-Za-z0-9][A-Za-z0-9_-]*$`);
59
+ for (const e of [...(entries || [])].sort()) if (re.test(e)) push(path.join(homeDir, e), true);
60
+ push(envDir, true);
61
+ return out;
62
+ }
63
+
64
+ /** The environment that pins a launch to a root. */
65
+ const envForAgentHome = (agent, dir) => ({ [ENV_VAR[agent]]: dir });
66
+
67
+ /**
68
+ * A recorded launch command without its config-root pin — `CLAUDE_CONFIG_DIR=… `,
69
+ * `env -u CLAUDE_CONFIG_DIR `, or a wrapper launcher (`claude-fable`) that
70
+ * pins one itself — so restore re-pins it from the record's configDir.
71
+ */
72
+ function stripConfigPrefix(launchCmd) {
73
+ let s = String(launchCmd || '').trim();
74
+ s = s.replace(/^(?:env\s+-u\s+CLAUDE_CONFIG_DIR\s+|CLAUDE_CONFIG_DIR=(?:'[^']*'|"[^"]*"|\S+)\s+)+/, '');
75
+ s = s.replace(/^(?:\S*\/)?claude[_-][A-Za-z0-9_-]+(?=\s|$)/, 'claude');
76
+ return s;
77
+ }
78
+
79
+ /** Where a transcript/rollout lands when its root changes: the same relative path under the new root. */
80
+ function transcriptTarget(sourceFile, sourceRoot, targetRoot) {
81
+ const rel = path.relative(sourceRoot, sourceFile);
82
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
83
+ return path.join(targetRoot, rel);
84
+ }
85
+
86
+ module.exports = { agentHomeLabel, describeAgentHome, discoverAgentHomes, envForAgentHome, stripConfigPrefix, transcriptTarget, defaultHome, ENV_VAR };
@@ -22,11 +22,26 @@
22
22
 
23
23
  const FLAG_MARK = 'shell_environment_policy.set.HOP_SESSION';
24
24
 
25
- /** The per-launch overrides that pin a codex thread to a hop session. */
26
- function codexIdentityFlags(internalName) {
25
+ // Codex's startup update check is OFF in a hop launch. Its "Update now"
26
+ // runs npm, prints "Please restart Codex" and exits — and what is left is
27
+ // a hop session with a bare shell and no agent, nothing for `hop recover`
28
+ // to recover (seen 2026-10-08: "alphagenome" never got its first turn).
29
+ // A session is a long-lived place, not where Codex gets upgraded; the
30
+ // person updates Codex on purpose, and the next launch picks it up. A
31
+ // command that sets the key itself keeps its own choice.
32
+ const UPDATE_KEY = 'check_for_update_on_startup';
33
+ const NO_UPDATE_CHECK = `-c ${UPDATE_KEY}=false`;
34
+
35
+ /**
36
+ * The per-launch overrides that pin a codex thread to a hop session, plus
37
+ * the update check switched off unless `keepUpdateCheck` says the command
38
+ * already decided.
39
+ */
40
+ function codexIdentityFlags(internalName, { keepUpdateCheck = false } = {}) {
27
41
  const id = String(internalName || '').replace(/[^A-Za-z0-9_-]/g, '');
28
42
  if (!id) return '';
29
- return `--no-daemon -c '${FLAG_MARK}="${id}"' -c 'mcp_servers.hop.env.HOP_SESSION="${id}"'`;
43
+ const identity = `--no-daemon -c '${FLAG_MARK}="${id}"' -c 'mcp_servers.hop.env.HOP_SESSION="${id}"'`;
44
+ return keepUpdateCheck ? identity : `${identity} ${NO_UPDATE_CHECK}`;
30
45
  }
31
46
 
32
47
  /**
@@ -36,7 +51,7 @@ function codexIdentityFlags(internalName) {
36
51
  */
37
52
  function withCodexIdentity(command, internalName) {
38
53
  const raw = typeof command === 'string' ? command : '';
39
- const flags = codexIdentityFlags(internalName);
54
+ const flags = codexIdentityFlags(internalName, { keepUpdateCheck: raw.includes(UPDATE_KEY) });
40
55
  if (!flags) return raw;
41
56
  if (raw.includes(FLAG_MARK)) return raw;
42
57
  const m = /^(\s*(?:(?:command|exec)\s+)?)codex(?=\s|$)/.exec(raw);
@@ -45,4 +60,4 @@ function withCodexIdentity(command, internalName) {
45
60
  return `${m[1]}codex ${flags}${rest}`;
46
61
  }
47
62
 
48
- module.exports = { withCodexIdentity, codexIdentityFlags, FLAG_MARK };
63
+ module.exports = { withCodexIdentity, codexIdentityFlags, FLAG_MARK, NO_UPDATE_CHECK };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.72",
3
+ "version": "0.11.74",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -0,0 +1,53 @@
1
+ // Accounts = config roots: which one a session runs as, how it is labelled,
2
+ // and how a conversation moves between them.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { agentHomeLabel, describeAgentHome, discoverAgentHomes, envForAgentHome, stripConfigPrefix, transcriptTarget } = require('../lib/agent-homes');
6
+
7
+ const HOME = '/Users/j';
8
+
9
+ test('labels: the agent\'s own root is "default", a sibling install is its suffix', () => {
10
+ assert.equal(agentHomeLabel('/Users/j/.claude', 'claude', HOME), 'default');
11
+ assert.equal(agentHomeLabel(null, 'claude', HOME), 'default');
12
+ assert.equal(agentHomeLabel('/Users/j/.claude_fable', 'claude', HOME), 'fable');
13
+ assert.equal(agentHomeLabel('/Users/j/.codex-work', 'codex', HOME), 'work');
14
+ assert.equal(agentHomeLabel('/opt/claude-cfg', 'claude', HOME), 'cfg');
15
+ assert.equal(agentHomeLabel('/opt/lab-accounts', 'claude', HOME), 'lab-accounts');
16
+ assert.deepEqual(describeAgentHome(undefined, 'codex', HOME), { dir: '/Users/j/.codex', label: 'default', isDefault: true });
17
+ });
18
+
19
+ test('discovery: default first, account-bearing sibling installs sorted, the environment\'s root last', () => {
20
+ const paths = new Set([
21
+ '/Users/j/.claude',
22
+ '/Users/j/.claude_fable', '/Users/j/.claude_fable/.claude.json',
23
+ '/Users/j/.claude2', '/Users/j/.claude2/projects',
24
+ '/Users/j/.claude-science', '/Users/j/.claude-science/settings.json', // no login footprint
25
+ '/tmp/other', '/tmp/other/projects'
26
+ ]);
27
+ const homes = discoverAgentHomes('claude', { homeDir: HOME, entries: ['.claude_fable', '.claude2', '.claude-science', '.claude.json', '.claude', 'notes'], exists: (d) => paths.has(d), envDir: '/tmp/other' });
28
+ assert.deepEqual(homes.map((h) => [h.label, h.isDefault]), [['default', true], ['claude2', false], ['fable', false], ['other', false]]);
29
+ // .codexbar is another app: no codex footprint, not an account.
30
+ const codex = discoverAgentHomes('codex', { homeDir: HOME, entries: ['.codex', '.codexbar'], exists: (d) => d === '/Users/j/.codex' || d === '/Users/j/.codexbar', envDir: null });
31
+ assert.deepEqual(codex.map((h) => h.label), ['default']);
32
+ assert.deepEqual(discoverAgentHomes('codex', { homeDir: HOME, entries: [], exists: () => false, envDir: null }), []);
33
+ });
34
+
35
+ test('the launch pin is an environment variable per agent', () => {
36
+ assert.deepEqual(envForAgentHome('claude', '/Users/j/.claude_fable'), { CLAUDE_CONFIG_DIR: '/Users/j/.claude_fable' });
37
+ assert.deepEqual(envForAgentHome('codex', '/Users/j/.codex'), { CODEX_HOME: '/Users/j/.codex' });
38
+ });
39
+
40
+ test('a recorded launch loses its old pin so restore can re-pin it', () => {
41
+ assert.equal(stripConfigPrefix("CLAUDE_CONFIG_DIR='/Users/j/.claude_fable' claude --resume abc"), 'claude --resume abc');
42
+ assert.equal(stripConfigPrefix('env -u CLAUDE_CONFIG_DIR claude --permission-mode auto --resume abc'), 'claude --permission-mode auto --resume abc');
43
+ assert.equal(stripConfigPrefix('claude-fable --dangerously-skip-permissions --resume abc'), 'claude --dangerously-skip-permissions --resume abc');
44
+ assert.equal(stripConfigPrefix('/usr/local/bin/claude_lab --resume abc'), 'claude --resume abc');
45
+ assert.equal(stripConfigPrefix('claude --resume abc'), 'claude --resume abc');
46
+ assert.equal(stripConfigPrefix('claude-code-x'), 'claude');
47
+ });
48
+
49
+ test('a transcript keeps its relative place under the new root', () => {
50
+ assert.equal(transcriptTarget('/Users/j/.claude/projects/-Users-j-Code/abc.jsonl', '/Users/j/.claude', '/Users/j/.claude_fable'), '/Users/j/.claude_fable/projects/-Users-j-Code/abc.jsonl');
51
+ assert.equal(transcriptTarget('/Users/j/.codex/sessions/2026/10/08/rollout-x.jsonl', '/Users/j/.codex', '/Users/j/.codex-work'), '/Users/j/.codex-work/sessions/2026/10/08/rollout-x.jsonl');
52
+ assert.equal(transcriptTarget('/elsewhere/abc.jsonl', '/Users/j/.claude', '/Users/j/.claude_fable'), null);
53
+ });
@@ -3,18 +3,25 @@
3
3
  // session they belong to.
4
4
  const test = require('node:test');
5
5
  const assert = require('node:assert/strict');
6
- const { withCodexIdentity, codexIdentityFlags } = require('../lib/codex-identity');
6
+ const { withCodexIdentity, codexIdentityFlags, NO_UPDATE_CHECK } = require('../lib/codex-identity');
7
7
 
8
- test('a bare codex launch gets both overrides', () => {
8
+ test('a bare codex launch gets both overrides, and the startup update check off', () => {
9
9
  const out = withCodexIdentity('codex', 's_82da12887f');
10
- assert.equal(out, `codex --no-daemon -c 'shell_environment_policy.set.HOP_SESSION="s_82da12887f"' -c 'mcp_servers.hop.env.HOP_SESSION="s_82da12887f"'`);
10
+ assert.equal(out, `codex --no-daemon -c 'shell_environment_policy.set.HOP_SESSION="s_82da12887f"' -c 'mcp_servers.hop.env.HOP_SESSION="s_82da12887f"' -c check_for_update_on_startup=false`);
11
+ assert.equal(NO_UPDATE_CHECK, '-c check_for_update_on_startup=false');
12
+ });
13
+
14
+ test('a command that decides the update check itself keeps its own choice — no duplicate -c', () => {
15
+ const on = withCodexIdentity('codex -c check_for_update_on_startup=true', 's_1');
16
+ assert.equal(on, `codex ${codexIdentityFlags('s_1', { keepUpdateCheck: true })} -c check_for_update_on_startup=true`);
17
+ assert.equal((on.match(/check_for_update_on_startup/g) || []).length, 1);
11
18
  });
12
19
 
13
20
  test('a resume keeps its arguments after the flags; command/exec prefixes survive', () => {
14
21
  assert.equal(withCodexIdentity("codex resume '01a0e0dc-314d'", 'surf'),
15
22
  `codex ${codexIdentityFlags('surf')} resume '01a0e0dc-314d'`);
16
23
  assert.equal(withCodexIdentity('command codex --full-auto -c check_for_update_on_startup=false', 's_1'),
17
- `command codex ${codexIdentityFlags('s_1')} --full-auto -c check_for_update_on_startup=false`);
24
+ `command codex ${codexIdentityFlags('s_1', { keepUpdateCheck: true })} --full-auto -c check_for_update_on_startup=false`);
18
25
  });
19
26
 
20
27
  test('anything else is untouched: shells, claude, a codex somewhere later in the line, an already pinned launch', () => {
@@ -28,5 +35,5 @@ test('anything else is untouched: shells, claude, a codex somewhere later in the
28
35
  });
29
36
 
30
37
  test('the id is sanitised — it lands inside a quoted config value', () => {
31
- assert.equal(codexIdentityFlags('bad"id;rm -rf'), `--no-daemon -c 'shell_environment_policy.set.HOP_SESSION="badidrm-rf"' -c 'mcp_servers.hop.env.HOP_SESSION="badidrm-rf"'`);
38
+ assert.equal(codexIdentityFlags('bad"id;rm -rf', { keepUpdateCheck: true }), `--no-daemon -c 'shell_environment_policy.set.HOP_SESSION="badidrm-rf"' -c 'mcp_servers.hop.env.HOP_SESSION="badidrm-rf"'`);
32
39
  });