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.
- package/hay/apps/web/src/App.tsx +2 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +108 -8
- package/hay/apps/web/src/styles.css +34 -0
- package/hay/apps/web/src/utils/switcherModel.ts +19 -0
- package/hop +265 -13
- package/lib/agent-homes.js +86 -0
- package/lib/codex-identity.js +20 -5
- package/package.json +1 -1
- package/tests/agent-homes.unit.test.cjs +53 -0
- package/tests/codex-identity.unit.test.cjs +12 -5
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
|
2338
|
-
// the
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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_-]
|
|
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
|
-
|
|
6710
|
-
|
|
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
|
-
|
|
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_-]
|
|
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
|
-
|
|
12383
|
-
|
|
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 };
|
package/lib/codex-identity.js
CHANGED
|
@@ -22,11 +22,26 @@
|
|
|
22
22
|
|
|
23
23
|
const FLAG_MARK = 'shell_environment_policy.set.HOP_SESSION';
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
});
|