hop2 0.11.36 → 0.11.38
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.
|
@@ -247,6 +247,8 @@ export class Room extends EventEmitter {
|
|
|
247
247
|
private pty: IPty;
|
|
248
248
|
private readonly initialCwd: string;
|
|
249
249
|
private liveCwd: string;
|
|
250
|
+
/** The shell reports its own cwd, so the lsof fallback is unnecessary here. */
|
|
251
|
+
private osc7Seen = false;
|
|
250
252
|
private foregroundProcess = "";
|
|
251
253
|
private lastActivityAt = now(); // ms of the most recent PTY output
|
|
252
254
|
// Terminal bells (BEL outside escape sequences) rung by the app — agents and
|
|
@@ -822,10 +824,11 @@ export class Room extends EventEmitter {
|
|
|
822
824
|
}
|
|
823
825
|
|
|
824
826
|
// The shell reports its own cwd, so the lsof fallback has nothing left to
|
|
825
|
-
// add for this room
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
|
|
827
|
+
// add for this room. Retire the PROBE, not the timer: the same tick also
|
|
828
|
+
// samples the foreground process name, and stopping it outright froze
|
|
829
|
+
// that for every OSC 7 room — the session manager then showed whatever
|
|
830
|
+
// was running when the room started, forever.
|
|
831
|
+
this.osc7Seen = true;
|
|
829
832
|
|
|
830
833
|
if (cwdPath && cwdPath !== this.liveCwd) {
|
|
831
834
|
if (DEBUG_STATE) {
|
|
@@ -869,6 +872,7 @@ export class Room extends EventEmitter {
|
|
|
869
872
|
if (typeof fg === "string" && fg) {
|
|
870
873
|
this.foregroundProcess = fg.replace(/^-/, ""); // strip login-shell "-zsh" dash
|
|
871
874
|
}
|
|
875
|
+
if (this.osc7Seen) return; // cwd comes from the shell itself
|
|
872
876
|
if (process.platform === "darwin") {
|
|
873
877
|
runGatedCwdProbe(pid, updateCwd);
|
|
874
878
|
} else if (process.platform === "linux") {
|
|
@@ -1378,17 +1382,16 @@ export class Room extends EventEmitter {
|
|
|
1378
1382
|
|
|
1379
1383
|
getSummary(): RoomSummary {
|
|
1380
1384
|
const localCliCount = [...this.clients.values()].filter((client) => client.source === "local-cli").length;
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
}
|
|
1385
|
+
// The foreground process name comes from the poll tick, never from a live
|
|
1386
|
+
// read here. node-pty's `.process` getter is a synchronous native call, and
|
|
1387
|
+
// /rooms summarizes EVERY room in one request — 57 of them on this host —
|
|
1388
|
+
// so the "cheap getter" it was billed as cost one blocking syscall per room
|
|
1389
|
+
// per request, several times a minute. That is a request-path cost the
|
|
1390
|
+
// terminal websockets share, and it tracked the host's multi-second
|
|
1391
|
+
// event-loop stalls (2026-09-01: /rooms at 155-817ms with stalls to match,
|
|
1392
|
+
// which is what made scrolling judder). The polled sample is the same
|
|
1393
|
+
// getter read on a jittered timer, so nothing is lost but the stall.
|
|
1394
|
+
const foregroundProcess = this.foregroundProcess;
|
|
1392
1395
|
return {
|
|
1393
1396
|
id: this.id,
|
|
1394
1397
|
cwd: this.initialCwd,
|
|
@@ -1471,6 +1471,24 @@ export const SessionSwitcher = ({
|
|
|
1471
1471
|
// Maps to the create API's `startup` command.
|
|
1472
1472
|
const [createType, setCreateType] = useState<"terminal" | "claude" | "codex">("terminal");
|
|
1473
1473
|
const startupFor = (t: "terminal" | "claude" | "codex") => (t === "claude" ? "claude" : t === "codex" ? "codex" : "");
|
|
1474
|
+
// Rendered by BOTH create forms. submitCreate has always sent createType, but
|
|
1475
|
+
// only the top-bar form let you set it, so creating a session inside a folder
|
|
1476
|
+
// silently produced whatever was last chosen — a plain terminal by default,
|
|
1477
|
+
// with no way to ask for claude or codex from the + on a folder.
|
|
1478
|
+
const createTypePicker = () => (
|
|
1479
|
+
<div className="switcher-create-type" role="radiogroup" aria-label="Session type">
|
|
1480
|
+
{(["terminal", "claude", "codex"] as const).map((t) => (
|
|
1481
|
+
<button
|
|
1482
|
+
key={t}
|
|
1483
|
+
type="button"
|
|
1484
|
+
role="radio"
|
|
1485
|
+
aria-checked={createType === t}
|
|
1486
|
+
className={"create-type" + (createType === t ? " on" : "")}
|
|
1487
|
+
onClick={() => setCreateType(t)}
|
|
1488
|
+
>{t === "terminal" ? "Terminal" : t === "claude" ? "Claude" : "Codex"}</button>
|
|
1489
|
+
))}
|
|
1490
|
+
</div>
|
|
1491
|
+
);
|
|
1474
1492
|
// When set, the next created session is filed here (the folder header's +).
|
|
1475
1493
|
const [createInFolder, setCreateInFolder] = useState<SwitcherFolder | null>(null);
|
|
1476
1494
|
const [, setTick] = useState(0);
|
|
@@ -1529,6 +1547,8 @@ export const SessionSwitcher = ({
|
|
|
1529
1547
|
// than reshuffling the wall.
|
|
1530
1548
|
const frozenOrderRef = useRef<{ mode: "tiers" | "project"; hero: string[]; groups: Array<{ label: string; rows: string[] }> } | null>(null);
|
|
1531
1549
|
useEffect(() => { if (!open) frozenOrderRef.current = null; }, [open]);
|
|
1550
|
+
// A non-empty query, whichever render branch the model picks for it.
|
|
1551
|
+
const searching = filter.trim().length > 0;
|
|
1532
1552
|
const model = useMemo(() => {
|
|
1533
1553
|
// A filter query searches EVERYTHING (parked included); the browsing
|
|
1534
1554
|
// wall shows only unparked sessions.
|
|
@@ -2925,18 +2945,7 @@ export const SessionSwitcher = ({
|
|
|
2925
2945
|
autoFocus
|
|
2926
2946
|
aria-label="New session name"
|
|
2927
2947
|
/>
|
|
2928
|
-
|
|
2929
|
-
{(["terminal", "claude", "codex"] as const).map((t) => (
|
|
2930
|
-
<button
|
|
2931
|
-
key={t}
|
|
2932
|
-
type="button"
|
|
2933
|
-
role="radio"
|
|
2934
|
-
aria-checked={createType === t}
|
|
2935
|
-
className={"create-type" + (createType === t ? " on" : "")}
|
|
2936
|
-
onClick={() => setCreateType(t)}
|
|
2937
|
-
>{t === "terminal" ? "Terminal" : t === "claude" ? "Claude" : "Codex"}</button>
|
|
2938
|
-
))}
|
|
2939
|
-
</div>
|
|
2948
|
+
{createTypePicker()}
|
|
2940
2949
|
<button type="submit">Create</button>
|
|
2941
2950
|
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2942
2951
|
</form>
|
|
@@ -2945,8 +2954,12 @@ export const SessionSwitcher = ({
|
|
|
2945
2954
|
<div className="switcher-scroll" ref={scrollRef} onContextMenu={openWallMenu}>
|
|
2946
2955
|
{/* The host agent's briefing — same digest.json the phone renders.
|
|
2947
2956
|
Hidden while filtering: search is about one session, the briefing
|
|
2948
|
-
is about the fleet.
|
|
2949
|
-
|
|
2957
|
+
is about the fleet. Keyed on the QUERY, not on model.mode: manual
|
|
2958
|
+
mode answers a query with mode "manual" (narrow-in-place, so the
|
|
2959
|
+
folders survive), never "filter", so a mode check left the
|
|
2960
|
+
briefing sitting on top of every search result in the folder
|
|
2961
|
+
layout. */}
|
|
2962
|
+
{!searching && (
|
|
2950
2963
|
<DigestCard sessions={sessions} onOpen={handleTap} />
|
|
2951
2964
|
)}
|
|
2952
2965
|
{model.mode === "filter" ? (
|
|
@@ -3034,6 +3047,7 @@ export const SessionSwitcher = ({
|
|
|
3034
3047
|
autoFocus
|
|
3035
3048
|
aria-label={"New session name in " + folder.name}
|
|
3036
3049
|
/>
|
|
3050
|
+
{createTypePicker()}
|
|
3037
3051
|
<button type="submit">Create</button>
|
|
3038
3052
|
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
3039
3053
|
</form>
|
|
@@ -3088,7 +3102,7 @@ export const SessionSwitcher = ({
|
|
|
3088
3102
|
))}
|
|
3089
3103
|
</>
|
|
3090
3104
|
)}
|
|
3091
|
-
{
|
|
3105
|
+
{!searching && parkedSessions.length > 0 && (
|
|
3092
3106
|
<section className="switcher-group switcher-parked">
|
|
3093
3107
|
<button
|
|
3094
3108
|
type="button"
|
|
@@ -3536,8 +3536,23 @@ button.primary {
|
|
|
3536
3536
|
font-size: 11px; font-weight: 600; color: var(--ink-muted);
|
|
3537
3537
|
padding: 4px 9px; border-radius: 6px; line-height: 1.3;
|
|
3538
3538
|
}
|
|
3539
|
-
.create-type.on {
|
|
3540
|
-
|
|
3539
|
+
.create-type:hover:not(.on) {
|
|
3540
|
+
color: var(--ink);
|
|
3541
|
+
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
|
3542
|
+
}
|
|
3543
|
+
/* The selected mode decides what the new session RUNS, so it has to read at a
|
|
3544
|
+
glance. The old treatment was a --panel pill with a black box-shadow: in
|
|
3545
|
+
dark mode that is #1d1d21 on a faintly lightened track with an invisible
|
|
3546
|
+
shadow, so "which one is on" came down to ink vs ink-muted text. Accent
|
|
3547
|
+
fill, the same signal every other selected control in the app uses. */
|
|
3548
|
+
.create-type.on {
|
|
3549
|
+
background: var(--accent);
|
|
3550
|
+
color: var(--accent-fg);
|
|
3551
|
+
}
|
|
3552
|
+
.create-type:focus-visible {
|
|
3553
|
+
outline: 2px solid var(--accent);
|
|
3554
|
+
outline-offset: 1px;
|
|
3555
|
+
}
|
|
3541
3556
|
.switcher-create-row input {
|
|
3542
3557
|
flex: 1;
|
|
3543
3558
|
min-width: 0;
|
|
@@ -345,6 +345,32 @@ describe("manual folders in the wall", () => {
|
|
|
345
345
|
expect(onClose).toHaveBeenCalled();
|
|
346
346
|
});
|
|
347
347
|
|
|
348
|
+
it("hides the briefing while a query is typed in manual mode", async () => {
|
|
349
|
+
// The briefing was gated on model.mode !== "filter", but manual mode
|
|
350
|
+
// answers a query with mode "manual" (narrow-in-place), never "filter" —
|
|
351
|
+
// so in the folder layout the briefing stayed on top of every search.
|
|
352
|
+
const edition = { generated_at: "2026-09-01T00:00:00Z", summary: "fleet is fine", items: [] };
|
|
353
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => ({
|
|
354
|
+
ok: true,
|
|
355
|
+
json: async () => (String(url).includes("digest-archive") ? { editions: [edition] } : edition)
|
|
356
|
+
})));
|
|
357
|
+
try {
|
|
358
|
+
render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
|
|
359
|
+
fireEvent.click(screen.getByRole("button", { name: "Manual" }));
|
|
360
|
+
expect(await screen.findByText("fleet is fine")).toBeTruthy();
|
|
361
|
+
const input = document.querySelector(".switcher-top input") as HTMLInputElement
|
|
362
|
+
|| document.querySelector("input") as HTMLInputElement;
|
|
363
|
+
fireEvent.change(input, { target: { value: "insid" } });
|
|
364
|
+
expect(screen.queryByText("fleet is fine")).toBeNull();
|
|
365
|
+
expect(screen.getByText("inside")).toBeTruthy();
|
|
366
|
+
// Clearing the query remounts the card, which refetches — hence async.
|
|
367
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
368
|
+
expect(await screen.findByText("fleet is fine")).toBeTruthy();
|
|
369
|
+
} finally {
|
|
370
|
+
vi.unstubAllGlobals();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
348
374
|
it("a query in manual mode still offers the full-history search", () => {
|
|
349
375
|
// Manual keeps its own render branch under a query (narrow-in-place), so
|
|
350
376
|
// the search tail — content hits + the deep button — must ride along or
|
package/hop
CHANGED
|
@@ -891,6 +891,32 @@ function mintSessionInternalName() {
|
|
|
891
891
|
return id;
|
|
892
892
|
}
|
|
893
893
|
|
|
894
|
+
/**
|
|
895
|
+
* The display name a session may actually show.
|
|
896
|
+
*
|
|
897
|
+
* A rename can leave a session claiming a name that ANOTHER session owns as
|
|
898
|
+
* its internal id — rename Altair to "Accessibility", then a real
|
|
899
|
+
* Accessibility room appears. Two sessions answering to one name are
|
|
900
|
+
* indistinguishable in the UI and ambiguous to address (a rename lands on
|
|
901
|
+
* whichever one the resolver reaches first), so the room that OWNS the name
|
|
902
|
+
* keeps it and the borrower falls back to its own.
|
|
903
|
+
*
|
|
904
|
+
* This must run wherever a display name is RESOLVED, not only where runtime
|
|
905
|
+
* metadata is refreshed: getEffectiveSessionConfig gives the durable meta the
|
|
906
|
+
* final word, so a guard applied during reconcile alone was overwritten on the
|
|
907
|
+
* very next read — six colliding pairs still rendered as duplicates in every
|
|
908
|
+
* client on 2026-09-01. Pass `liveIds` when the caller already has the room
|
|
909
|
+
* list; otherwise the live-room check is per name.
|
|
910
|
+
*/
|
|
911
|
+
function resolveDisplayNameClaim(internalName, claimed, liveIds = null) {
|
|
912
|
+
const own = toDisplaySessionName(internalName) || internalName;
|
|
913
|
+
if (!claimed) return own;
|
|
914
|
+
if (claimed === internalName) return claimed;
|
|
915
|
+
const owner = toInternalSessionName(claimed) || claimed;
|
|
916
|
+
const taken = liveIds ? liveIds.has(owner) : hopSessionExists(owner);
|
|
917
|
+
return taken ? own : claimed;
|
|
918
|
+
}
|
|
919
|
+
|
|
894
920
|
function toDisplaySessionName(internalName) {
|
|
895
921
|
if (!internalName || typeof internalName !== 'string') return '';
|
|
896
922
|
return stripLegacySessionPrefix(internalName);
|
|
@@ -1160,14 +1186,14 @@ function getEffectiveSessionConfig(name) {
|
|
|
1160
1186
|
if (!base) {
|
|
1161
1187
|
return {
|
|
1162
1188
|
type: 'terminal',
|
|
1163
|
-
displayName: durable.displayName,
|
|
1189
|
+
displayName: resolveDisplayNameClaim(name, durable.displayName),
|
|
1164
1190
|
folderId: durable.folderId,
|
|
1165
1191
|
createdBy: durable.createdBy,
|
|
1166
1192
|
cwd: durable.cwd
|
|
1167
1193
|
};
|
|
1168
1194
|
}
|
|
1169
1195
|
const merged = { ...base };
|
|
1170
|
-
if (durable.displayName) merged.displayName = durable.displayName;
|
|
1196
|
+
if (durable.displayName) merged.displayName = resolveDisplayNameClaim(name, durable.displayName);
|
|
1171
1197
|
if (durable.folderId !== undefined) merged.folderId = durable.folderId || null;
|
|
1172
1198
|
if (durable.createdBy) merged.createdBy = durable.createdBy;
|
|
1173
1199
|
if (durable.tagline) merged.tagline = durable.tagline;
|
|
@@ -5595,6 +5621,34 @@ const CLAUDE_VALUE_FLAGS = new Set([
|
|
|
5595
5621
|
'--append-system-prompt', '--settings', '--mcp-config', '--agents',
|
|
5596
5622
|
'--effort', '--betas', '--session-id'
|
|
5597
5623
|
]);
|
|
5624
|
+
// The conversation a `--fork-session` launch forked FROM.
|
|
5625
|
+
//
|
|
5626
|
+
// Only meaningful together with --fork-session: in a plain `--resume <id>` the
|
|
5627
|
+
// id names the conversation itself, which is not a parent to fall back to.
|
|
5628
|
+
function forkParentSessionId(launchCmd) {
|
|
5629
|
+
const raw = typeof launchCmd === 'string' ? launchCmd : '';
|
|
5630
|
+
if (!/(?:^|\s)--fork-session(?:\s|=|$)/.test(raw)) return null;
|
|
5631
|
+
const m = raw.match(/(?:^|\s)(?:--resume(?:=|\s+)|-r\s+)([A-Za-z0-9-]{8,})/);
|
|
5632
|
+
return m ? m[1] : null;
|
|
5633
|
+
}
|
|
5634
|
+
|
|
5635
|
+
/**
|
|
5636
|
+
* Drop `--fork-session` from a rebuilt launch command.
|
|
5637
|
+
*
|
|
5638
|
+
* Forking is a one-time act, not a standing property of the session: the
|
|
5639
|
+
* recorded id IS the forked conversation. Resuming it with the flag still
|
|
5640
|
+
* attached forks AGAIN and mints yet another id, so every restore orphans the
|
|
5641
|
+
* previous fork and any fork killed before its first turn loses its whole tail
|
|
5642
|
+
* (2026-09-01: Accessibility-fork's id churned on each restore, and a host
|
|
5643
|
+
* restart left the newest id with no transcript at all).
|
|
5644
|
+
*/
|
|
5645
|
+
function stripForkSessionFlag(command) {
|
|
5646
|
+
return String(command || '')
|
|
5647
|
+
.replace(/(?:^|\s)--fork-session(?=\s|$)/g, ' ')
|
|
5648
|
+
.replace(/\s{2,}/g, ' ')
|
|
5649
|
+
.trim();
|
|
5650
|
+
}
|
|
5651
|
+
|
|
5598
5652
|
function rebuildClaudeLaunch(launchCmd) {
|
|
5599
5653
|
const raw = typeof launchCmd === 'string' ? launchCmd.trim() : '';
|
|
5600
5654
|
if (!raw) return null;
|
|
@@ -5709,12 +5763,33 @@ function restorePlanForSession(entry) {
|
|
|
5709
5763
|
warning: `recorded conversation started in ${rec.cwd} but this session's last directory was ${liveCwd} — that record belongs to another claude; opening a plain shell here instead`
|
|
5710
5764
|
};
|
|
5711
5765
|
}
|
|
5766
|
+
// Set only when the fork recovery below fires, so the rebuilt command keeps
|
|
5767
|
+
// its --fork-session: we are resuming the PARENT and must not write into
|
|
5768
|
+
// the parent's conversation.
|
|
5769
|
+
let reforkFromParent = false;
|
|
5712
5770
|
if (rec && rec.sessionId && !claudeTranscriptExists(rec)) {
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5771
|
+
// A fork whose own transcript never landed is not a lost session.
|
|
5772
|
+
// `--fork-session` mints a new conversation id at launch and claude
|
|
5773
|
+
// only writes that transcript once a turn completes, so a fork killed
|
|
5774
|
+
// before its first turn — a host restart, a crash — always leaves a
|
|
5775
|
+
// recorded id with nothing behind it. The conversation it forked from
|
|
5776
|
+
// is still on disk, and re-forking reproduces exactly what the
|
|
5777
|
+
// recorded command produced the first time. Dropping to a plain shell
|
|
5778
|
+
// here threw away the parent history too, which is the part that
|
|
5779
|
+
// mattered (2026-09-01: Accessibility-fork came back as a bare shell,
|
|
5780
|
+
// stranding a 2.3MB parent conversation).
|
|
5781
|
+
const parentId = forkParentSessionId(rec.launchCmd);
|
|
5782
|
+
if (parentId && parentId !== rec.sessionId
|
|
5783
|
+
&& claudeTranscriptExists({ ...rec, sessionId: parentId })) {
|
|
5784
|
+
rec = { ...rec, sessionId: parentId, reforkedFrom: rec.sessionId };
|
|
5785
|
+
reforkFromParent = true;
|
|
5786
|
+
} else {
|
|
5787
|
+
return {
|
|
5788
|
+
command: null,
|
|
5789
|
+
cwd: rec.cwd || entry.cwd,
|
|
5790
|
+
warning: `recorded claude conversation ${String(rec.sessionId).slice(0, 8)}… has no transcript on this host — opening a plain shell instead`
|
|
5791
|
+
};
|
|
5792
|
+
}
|
|
5718
5793
|
}
|
|
5719
5794
|
if (rec && rec.sessionId) {
|
|
5720
5795
|
// Preserve the original launch command's FLAGS (--dangerously-skip-
|
|
@@ -5741,6 +5816,9 @@ function restorePlanForSession(entry) {
|
|
|
5741
5816
|
base = fromArgv;
|
|
5742
5817
|
isClaudeLaunch = true;
|
|
5743
5818
|
}
|
|
5819
|
+
// The fork already happened; this id is its result. Only a deliberate
|
|
5820
|
+
// re-fork of a parent (above) still needs the flag.
|
|
5821
|
+
if (!reforkFromParent) base = stripForkSessionFlag(base);
|
|
5744
5822
|
// Config-dir prefix: needed for a bare `claude` against a wrapper
|
|
5745
5823
|
// config; a wrapper command (claude-fable) sets it itself. A null
|
|
5746
5824
|
// configDir means the DEFAULT root — pin that explicitly too
|
|
@@ -8769,17 +8847,31 @@ async function ensureExternalHayHostReadyOnce() {
|
|
|
8769
8847
|
|
|
8770
8848
|
writeHayHostStateFile(started);
|
|
8771
8849
|
child.unref();
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8850
|
+
// This host REPLACES one: a record of the previous host was still on
|
|
8851
|
+
// file when we got here. Its PTYs — and every claude running inside them
|
|
8852
|
+
// — died with it, however it died. Re-drive the fleet the way `hop
|
|
8853
|
+
// restore` would instead of waiting for a human to notice dead shells.
|
|
8854
|
+
//
|
|
8855
|
+
// Keyed on the prior record, not on who did the killing. The first
|
|
8856
|
+
// version fired only after a WATCHDOG kill, which left every other death
|
|
8857
|
+
// — a crash, an OOM, a reboot, a deliberate `kill` — to a human, and the
|
|
8858
|
+
// one time it mattered (2026-09-01: host restarted by hand to deploy a
|
|
8859
|
+
// fix) the restore that was supposed to follow never ran, because the
|
|
8860
|
+
// shell it was queued in lived inside the host that had just died. The
|
|
8861
|
+
// fleet sat empty until the user noticed. Deliberate teardown is still
|
|
8862
|
+
// honored: `hop stop --all` clears the record, so there is nothing to
|
|
8863
|
+
// replace and nothing is resurrected; the same goes for first boot.
|
|
8864
|
+
//
|
|
8865
|
+
// The delay lets the new host finish its own startup reconcile first;
|
|
8866
|
+
// the restore reconciler then converges stragglers for 15 minutes.
|
|
8867
|
+
if (existing) {
|
|
8868
|
+
const why = watchdogKilledLiveHost
|
|
8869
|
+
? 'The watchdog killed a host with live sessions'
|
|
8870
|
+
: `Host ${existing.pid} is gone (not stopped by hop) and has been replaced`;
|
|
8871
|
+
log(`[Hay] ${why} — auto-restoring saved sessions in 5s. Agent-created sessions are excluded; \`hop restore --agents\` brings those back.`, YELLOW);
|
|
8780
8872
|
setTimeout(() => {
|
|
8781
8873
|
restoreSavedSessions({}).catch((e) => {
|
|
8782
|
-
log(`[Hay] Auto-restore after the
|
|
8874
|
+
log(`[Hay] Auto-restore after the host replacement failed: ${e?.message || e}. Run \`hop restore\` by hand.`, RED);
|
|
8783
8875
|
});
|
|
8784
8876
|
}, 5000).unref();
|
|
8785
8877
|
}
|
|
@@ -8998,10 +9090,21 @@ async function reconcileExternalRuntimeSessionsUncached() {
|
|
|
8998
9090
|
return { rooms: [], runtimeAvailable: false };
|
|
8999
9091
|
}
|
|
9000
9092
|
|
|
9093
|
+
// Every live room id, collected BEFORE the walk below. The display-name
|
|
9094
|
+
// collision guard asks whether a claimed name is some OTHER room's id, and
|
|
9095
|
+
// filling this set as we went made that answer depend on iteration order:
|
|
9096
|
+
// a session whose claim collided with a room listed LATER kept the name,
|
|
9097
|
+
// so two sessions answered to it. That is ambiguous to address, and a
|
|
9098
|
+
// rename then landed on whichever one the resolver reached first
|
|
9099
|
+
// (2026-09-01: room s_e15f120ce7 claimed "Solstice" from index 0 while the
|
|
9100
|
+
// room actually NAMED Solstice sat at index 1, so renaming "Solstice" to
|
|
9101
|
+
// "hop" retitled the wrong session and the card kept showing the old name).
|
|
9001
9102
|
const runtimeIds = new Set();
|
|
9103
|
+
for (const room of rooms) {
|
|
9104
|
+
if (room && room.id) runtimeIds.add(room.id);
|
|
9105
|
+
}
|
|
9002
9106
|
for (const room of rooms) {
|
|
9003
9107
|
if (!room || !room.id) continue;
|
|
9004
|
-
runtimeIds.add(room.id);
|
|
9005
9108
|
const savedConfig = getSessionConfig(room.id);
|
|
9006
9109
|
const runtimeConfig = getRuntimeSessionMetadata(room.id);
|
|
9007
9110
|
const durableMeta = readDurableIdentity(room.id); // mtime-cached — reconcile runs constantly
|
|
@@ -9033,7 +9136,7 @@ async function reconcileExternalRuntimeSessionsUncached() {
|
|
|
9033
9136
|
// store/runtime entry here let a stale in-memory name undo a
|
|
9034
9137
|
// rename on the very next tick.
|
|
9035
9138
|
const claimed = durableMeta?.displayName || savedConfig?.displayName || runtimeConfig?.displayName;
|
|
9036
|
-
if (claimed &&
|
|
9139
|
+
if (claimed && resolveDisplayNameClaim(room.id, claimed, runtimeIds) !== claimed) {
|
|
9037
9140
|
// Log the rejection once per claim, not once per tick: an
|
|
9038
9141
|
// unresolvable claim (the user renamed to a name another
|
|
9039
9142
|
// live session owns) repeats forever otherwise, and 30+
|
package/package.json
CHANGED