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 — retire its timer. Most rooms run a shell that emits
826
- // OSC 7, so this is what keeps the global probe budget for the few that
827
- // genuinely need it.
828
- this.stopCwdPolling();
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
- // Read the foreground process name fresh (cheap getter) so the session
1382
- // manager reflects what's running now; fall back to the last polled sample.
1383
- let foregroundProcess = this.foregroundProcess;
1384
- try {
1385
- const live = (this.pty as unknown as { process?: string }).process;
1386
- if (typeof live === "string" && live) {
1387
- foregroundProcess = live.replace(/^-/, "");
1388
- }
1389
- } catch {
1390
- /* keep the polled fallback */
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
- <div className="switcher-create-type" role="radiogroup" aria-label="Session type">
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
- {model.mode !== "filter" && (
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
- {model.mode !== "filter" && parkedSessions.length > 0 && (
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 { background: var(--panel); color: var(--ink);
3540
- box-shadow: 0 1px 2px rgba(0,0,0,0.12); }
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
- return {
5714
- command: null,
5715
- cwd: rec.cwd || entry.cwd,
5716
- warning: `recorded claude conversation ${String(rec.sessionId).slice(0, 8)}… has no transcript on this host — opening a plain shell instead`
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
- if (watchdogKilledLiveHost) {
8773
- // The old host's PTYs — and every claude running inside them — died
8774
- // with it. Re-drive the fleet the way `hop restore` would instead of
8775
- // waiting for a human to notice dead shells (2026-08-31: a watchdog
8776
- // restart silently orphaned ~36 claude sessions). The delay lets the
8777
- // new host finish its own startup reconcile first; the restore
8778
- // reconciler then converges stragglers for 15 minutes.
8779
- log('[Hay] The watchdog killed a host with live sessions — auto-restoring saved sessions in 5s. Agent-created sessions are excluded; `hop restore --agents` brings those back.', YELLOW);
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 watchdog restart failed: ${e?.message || e}. Run \`hop restore\` by hand.`, RED);
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 && claimed !== room.id && runtimeIds.has(toInternalSessionName(claimed) || 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.36",
3
+ "version": "0.11.38",
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": {