hop2 0.11.2 → 0.11.4

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/hop CHANGED
@@ -16,6 +16,11 @@ const net = require('net');
16
16
  const WebSocket = require('ws');
17
17
  const { resolveActorFromHeaders, canActorAccessSession } = require('./lib/terminal-access');
18
18
  const { generateSessionName, getThemeChoices, isValidTheme, DEFAULT_THEME } = require('./lib/naming-themes');
19
+ const {
20
+ createFailureTracker,
21
+ createSingleFlight,
22
+ sameHostIncarnation
23
+ } = require('./lib/hay-host-watchdog');
19
24
 
20
25
  const os = require('os');
21
26
  const DAEMON_FLAG = process.argv.includes('--daemon');
@@ -323,6 +328,8 @@ const HAY_HOST_DISCOVERY_CACHE_MS = parsePositiveIntEnv('HOP_HAY_HOST_DISCOVERY_
323
328
  const HAY_HOST_HOME_HASH = crypto.createHash('sha256').update(path.resolve(HOME_DIR)).digest('hex').slice(0, 24);
324
329
  const HAY_HOST_HOME_FLAG = `--hop-home-hash=${HAY_HOST_HOME_HASH}`;
325
330
  let hayHostState = null;
331
+ const hayHostInspectionFlight = createSingleFlight();
332
+ const hayHostEnsureFlight = createSingleFlight();
326
333
  // Session content search: per-room ANSI-stripped tail, cached briefly so a
327
334
  // typing burst in the switcher re-greps without re-fetching from the host.
328
335
  const sessionSearchTextCache = new Map();
@@ -352,7 +359,25 @@ function stripAnsiForSearch(raw) {
352
359
  // only after this many consecutive failed probes spanning at least the window.
353
360
  const HAY_HOST_UNRESPONSIVE_PROBES = 3;
354
361
  const HAY_HOST_UNRESPONSIVE_WINDOW_MS = 60_000;
355
- let hayHostProbeFailures = { pid: 0, count: 0, firstAt: 0 };
362
+ const hayHostProbeFailures = createFailureTracker({
363
+ requiredFailures: HAY_HOST_UNRESPONSIVE_PROBES,
364
+ windowMs: HAY_HOST_UNRESPONSIVE_WINDOW_MS
365
+ });
366
+ const HAY_HOST_DAEMON_STALL_THRESHOLD_MS = 2000;
367
+ const HAY_HOST_DAEMON_STALL_HOLD_MS = HAY_HOST_UNRESPONSIVE_WINDOW_MS;
368
+ let lastDaemonEventLoopStall = { at: 0, driftMs: 0 };
369
+ if (DAEMON_FLAG) {
370
+ const intervalMs = 1000;
371
+ let expectedAt = Date.now() + intervalMs;
372
+ setInterval(() => {
373
+ const at = Date.now();
374
+ const driftMs = Math.max(0, at - expectedAt);
375
+ expectedAt = at + intervalMs;
376
+ if (driftMs >= HAY_HOST_DAEMON_STALL_THRESHOLD_MS) {
377
+ lastDaemonEventLoopStall = { at, driftMs };
378
+ }
379
+ }, intervalMs).unref();
380
+ }
356
381
  // Serialize host spawning across concurrent ensure calls / processes: without
357
382
  // this, N racing `hop` invocations that each see "no host" spawn N hosts
358
383
  // (observed: four in one second), scattering sessions across orphans.
@@ -366,6 +391,12 @@ function parsePositiveIntEnv(name, fallback) {
366
391
  return parsed;
367
392
  }
368
393
 
394
+ function getRecentDaemonEventLoopStall() {
395
+ if (!lastDaemonEventLoopStall.at) return null;
396
+ if (Date.now() - lastDaemonEventLoopStall.at > HAY_HOST_DAEMON_STALL_HOLD_MS) return null;
397
+ return { ...lastDaemonEventLoopStall };
398
+ }
399
+
369
400
  // Password hashing using scrypt (built-in, no external deps)
370
401
  function hashPassword(password) {
371
402
  const salt = crypto.randomBytes(16).toString('hex');
@@ -658,13 +689,60 @@ function getRuntimeSessionMetadata(name) {
658
689
  return runtimeSessionMetadata.get(name) || null;
659
690
  }
660
691
 
692
+ // Durable meta is read on every effective-config lookup, so a tiny cache
693
+ // keeps it off the hot path. Invalidated by mtime, so an external write
694
+ // (the hook, another process) is picked up without a restart.
695
+ const durableIdentityCache = new Map();
696
+ function readDurableIdentity(name) {
697
+ if (!name || !SESSION_NAME_RE.test(name)) return null;
698
+ let mtimeMs = 0;
699
+ try {
700
+ mtimeMs = fs.statSync(path.join(CLAUDE_SESSIONS_DIR, `${name}.meta`)).mtimeMs;
701
+ } catch (e) {
702
+ durableIdentityCache.delete(name);
703
+ return null;
704
+ }
705
+ const hit = durableIdentityCache.get(name);
706
+ if (hit && hit.mtimeMs === mtimeMs) return hit.value;
707
+ const value = readClaudeSessionMeta(name);
708
+ durableIdentityCache.set(name, { mtimeMs, value });
709
+ return value;
710
+ }
711
+
712
+ // THE identity accessor. Everything that asks "what is this session called,
713
+ // where is it filed, who made it" comes through here.
714
+ //
715
+ // The layers are not peers. saved/runtime configs are CACHES that any code
716
+ // path may recreate with defaults — `hop restore` writes one per session
717
+ // with the INTERNAL name — while durable meta is where a user's own answers
718
+ // are recorded and the only layer that survives the daemon. Merging
719
+ // saved-over-runtime and stopping there meant a crash + restore silently
720
+ // reverted every rename and un-filed every folder, because restore's freshly
721
+ // written cache outranked the file that knew better. Meta wins for the
722
+ // user-authored fields; everything else keeps the old precedence.
661
723
  function getEffectiveSessionConfig(name) {
662
724
  const saved = getSessionConfig(name);
663
725
  const runtime = getRuntimeSessionMetadata(name);
664
- if (saved && runtime) {
665
- return { ...runtime, ...saved };
726
+ const base = saved && runtime ? { ...runtime, ...saved } : (saved || runtime || null);
727
+ const durable = readDurableIdentity(name);
728
+ if (!durable) return base;
729
+ if (!base) {
730
+ return {
731
+ type: 'terminal',
732
+ displayName: durable.displayName,
733
+ folderId: durable.folderId,
734
+ createdBy: durable.createdBy,
735
+ cwd: durable.cwd
736
+ };
666
737
  }
667
- return saved || runtime || null;
738
+ const merged = { ...base };
739
+ if (durable.displayName) merged.displayName = durable.displayName;
740
+ if (durable.folderId !== undefined) merged.folderId = durable.folderId || null;
741
+ if (durable.createdBy) merged.createdBy = durable.createdBy;
742
+ if (durable.tagline) merged.tagline = durable.tagline;
743
+ if (durable.parked !== undefined) merged.parked = durable.parked;
744
+ if (durable.archived !== undefined) merged.archived = durable.archived;
745
+ return merged;
668
746
  }
669
747
 
670
748
  function hasSavedSessionConfig(name) {
@@ -711,16 +789,34 @@ function getSessionDisplayName(name) {
711
789
  return toDisplaySessionName(name);
712
790
  }
713
791
 
792
+ // Name lookups are case-insensitive: an exact-case wearer always wins, and
793
+ // otherwise a UNIQUE case-insensitive wearer resolves. Two sessions whose
794
+ // names differ only by case are ambiguous — that falls back to exact-only
795
+ // rather than guessing which one the caller meant.
796
+ function sameNameFold(a, b) {
797
+ return typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
798
+ }
799
+
714
800
  function findInternalSessionName(displayName) {
715
801
  if (!displayName) return null;
802
+ let folded = null;
803
+ let foldedAmbiguous = false;
804
+ const noteFolded = (name, current) => {
805
+ if (!sameNameFold(current, displayName)) return;
806
+ if (folded && folded !== name) foldedAmbiguous = true;
807
+ else folded = name;
808
+ };
716
809
  for (const [name, config] of Object.entries(sessionStore.sessions)) {
717
810
  const current = config?.displayName || toDisplaySessionName(name);
718
811
  if (current === displayName) return name;
812
+ noteFolded(name, current);
719
813
  }
720
814
  for (const [name, config] of runtimeSessionMetadata.entries()) {
721
- if ((config?.displayName || name) === displayName) return name;
815
+ const current = config?.displayName || name;
816
+ if (current === displayName) return name;
817
+ noteFolded(name, current);
722
818
  }
723
- return null;
819
+ return foldedAmbiguous ? null : folded;
724
820
  }
725
821
 
726
822
  // "In use" means a session ACTUALLY WEARS this name right now, or the name is
@@ -1994,6 +2090,9 @@ function saveHopConfig(config) {
1994
2090
  // session should be the session you left, not a summary of it. Set
1995
2091
  // resume_mode to "summary" (cheaper) or "ask" (leave the prompt alone).
1996
2092
  const RESUME_PROMPT_RE = /Resuming the full session|Resume from summary|Resume full session as-is/i;
2093
+ // Claude Code's own chrome — proof the app is up even when the room reports
2094
+ // the wrapper shell as its foreground process (argv launch).
2095
+ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc to interrupt|\? for shortcuts/i;
1997
2096
  function getResumeMode() {
1998
2097
  const mode = String(getHopConfig().resume_mode || '').toLowerCase();
1999
2098
  return mode === 'summary' || mode === 'ask' ? mode : 'full';
@@ -3023,12 +3122,13 @@ async function listActiveSessionsRuntimeFirst() {
3023
3122
  if (!isExternalTerminalBackend()) {
3024
3123
  return { sessions: listActiveSessions(), runtimeAvailable: true };
3025
3124
  }
3125
+ let reconciled;
3026
3126
  try {
3027
- await reconcileExternalRuntimeSessions();
3127
+ reconciled = await reconcileExternalRuntimeSessions();
3028
3128
  } catch (e) {
3029
3129
  return { sessions: [], runtimeAvailable: false };
3030
3130
  }
3031
- const runtimeRooms = await fetchExternalHayRuntimeRooms();
3131
+ const runtimeRooms = reconciled?.rooms;
3032
3132
  if (!Array.isArray(runtimeRooms)) {
3033
3133
  return { sessions: [], runtimeAvailable: false };
3034
3134
  }
@@ -3196,8 +3296,16 @@ function buildSessionEntries(haySessions, opts = {}) {
3196
3296
  if (type !== 'port' && !includeStoredTerminalSessions && !sessionMap.has(name)) {
3197
3297
  continue;
3198
3298
  }
3199
- const displayName = config?.displayName || toDisplaySessionName(name);
3200
- const folderId = config?.folderId || null;
3299
+ // Identity fields go through THE accessor, not this raw store entry.
3300
+ // Reading the store directly silently reverted renames and un-filed
3301
+ // folders whenever a recreated cache (e.g. restore's fresh session
3302
+ // definitions) lacked them — the durable meta knew better the whole
3303
+ // time, and the overwrite branch below was clobbering the correct
3304
+ // value the live-rooms loop had already computed.
3305
+ const effective = getEffectiveSessionConfig(name) || config;
3306
+ const displayName = effective?.displayName || toDisplaySessionName(name);
3307
+ const folderId = effective?.folderId || null;
3308
+ const createdBy = effective?.createdBy || 'user';
3201
3309
  const clientSummary = getSessionClientSummary(name);
3202
3310
  if (!sessionMap.has(name)) {
3203
3311
  sessionMap.set(name, {
@@ -3219,7 +3327,7 @@ function buildSessionEntries(haySessions, opts = {}) {
3219
3327
  startup: config?.startup,
3220
3328
  autoStart: config?.autoStart,
3221
3329
  createdAt: config?.createdAt,
3222
- createdBy: config?.createdBy || 'user',
3330
+ createdBy,
3223
3331
  agentPermitted: config?.agentPermitted === true,
3224
3332
  localCliCount: clientSummary.localCliCount,
3225
3333
  hasLocalCli: clientSummary.hasLocalCli
@@ -3246,7 +3354,7 @@ function buildSessionEntries(haySessions, opts = {}) {
3246
3354
  entry.startup = config?.startup;
3247
3355
  entry.autoStart = config?.autoStart;
3248
3356
  entry.createdAt = config?.createdAt;
3249
- entry.createdBy = config?.createdBy || 'user';
3357
+ entry.createdBy = createdBy;
3250
3358
  entry.agentPermitted = config?.agentPermitted === true;
3251
3359
  entry.localCliCount = localCliCount;
3252
3360
  entry.hasLocalCli = clientSummary.hasLocalCli || localCliCount > 0 || entry.hasLocalCli === true;
@@ -3257,8 +3365,11 @@ function buildSessionEntries(haySessions, opts = {}) {
3257
3365
  if (!name || sessionMap.has(name)) continue;
3258
3366
  const type = config?.type || 'terminal';
3259
3367
  if (type !== 'port' && !includeStoredTerminalSessions) continue;
3260
- const displayName = config?.displayName || toDisplaySessionName(name);
3261
- const folderId = config?.folderId || null;
3368
+ // Same rule as above: identity via THE accessor, mechanics from the
3369
+ // runtime cache.
3370
+ const effective = getEffectiveSessionConfig(name) || config;
3371
+ const displayName = effective?.displayName || toDisplaySessionName(name);
3372
+ const folderId = effective?.folderId || null;
3262
3373
  const clientSummary = getSessionClientSummary(name);
3263
3374
  sessionMap.set(name, {
3264
3375
  name,
@@ -3279,7 +3390,7 @@ function buildSessionEntries(haySessions, opts = {}) {
3279
3390
  startup: config?.startup,
3280
3391
  autoStart: config?.autoStart,
3281
3392
  createdAt: config?.createdAt,
3282
- createdBy: config?.createdBy || 'user',
3393
+ createdBy: effective?.createdBy || 'user',
3283
3394
  agentPermitted: config?.agentPermitted === true,
3284
3395
  localCliCount: clientSummary.localCliCount,
3285
3396
  hasLocalCli: clientSummary.hasLocalCli
@@ -3839,7 +3950,9 @@ function writeClaudeSessionRecord(internalName, rec) {
3839
3950
  if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
3840
3951
  try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
3841
3952
  try {
3842
- fs.writeFileSync(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.json`), JSON.stringify(rec), { mode: 0o600 });
3953
+ // Same crash-safety as the metas: records are what lets a claude
3954
+ // conversation resume at all.
3955
+ writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.json`), JSON.stringify(rec), 0o600);
3843
3956
  } catch (e) { /* best effort */ }
3844
3957
  }
3845
3958
 
@@ -3881,7 +3994,14 @@ function writeClaudeSessionMeta(internalName, meta) {
3881
3994
  if (!internalName || !SESSION_NAME_RE.test(internalName)) return;
3882
3995
  try { fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true }); } catch (e) { /* best effort */ }
3883
3996
  try {
3884
- fs.writeFileSync(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.meta`), JSON.stringify(meta), { mode: 0o600 });
3997
+ // ATOMIC + FSYNCED, not writeFileSync. The thermal-sleep crash proved
3998
+ // the difference with a natural experiment: folders.json (written via
3999
+ // writePrivateFile the same night) SURVIVED, while every meta
4000
+ // rewritten in the prior three days — every rename, every folder
4001
+ // filing — vanished with the un-fsynced page cache. These files are
4002
+ // the restore inventory; they are exactly the data that must outlive
4003
+ // a power cut.
4004
+ writePrivateFile(path.join(CLAUDE_SESSIONS_DIR, `${internalName}.meta`), JSON.stringify(meta), 0o600);
3885
4005
  } catch (e) { /* best effort */ }
3886
4006
  }
3887
4007
 
@@ -4276,6 +4396,35 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4276
4396
  // a second copy on top of it (that is how command lines piled
4277
4397
  // up in a shell that was busy booting).
4278
4398
  if (running) { finish('already-running'); continue; }
4399
+ // Same blindness applies before typing: a session whose
4400
+ // command was launched as argv shows a shell here while
4401
+ // claude runs beneath it. Typing again would land keystrokes
4402
+ // in claude's own prompt. And ABSENCE of claude chrome is not
4403
+ // enough — under a many-session restore storm claude can take
4404
+ // longer than every timeout to paint its first frame, and the
4405
+ // typed "fallback" landed the whole relaunch command in the
4406
+ // composer of the claude that then finished booting. The only
4407
+ // screen that may be typed into is one POSITIVELY showing an
4408
+ // idle shell prompt.
4409
+ let promptVisible = false;
4410
+ try {
4411
+ const grid = await getRoomGridPreviewShared(job.plan.internalName);
4412
+ const text = String(grid?.text || '');
4413
+ if (RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text)) {
4414
+ job.state = 'verify';
4415
+ job.since = Date.now();
4416
+ continue;
4417
+ }
4418
+ const lines = text.split('\n').map((l) => l.trimEnd()).filter((l) => l.trim() !== '');
4419
+ const last = lines.length ? lines[lines.length - 1] : '';
4420
+ promptVisible = /[%$#❯>]\s*$/.test(last);
4421
+ } catch (e) { /* host busy — treat as not visible */ }
4422
+ if (!promptVisible) {
4423
+ // Nothing to safely type into (blank screen, app booting).
4424
+ // Keep waiting; the verify deadline bounds the wait.
4425
+ if (Date.now() - job.since > 30_000) { finish('never-started'); }
4426
+ continue;
4427
+ }
4279
4428
  // A fresh shell reports its own name once it has execed; that
4280
4429
  // is our prompt-ready signal, and it beats guessing with a timer.
4281
4430
  if (Date.now() - job.since < 400) continue;
@@ -4287,7 +4436,25 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4287
4436
  continue;
4288
4437
  }
4289
4438
  if (job.state === 'verify') {
4290
- if (running) {
4439
+ // "Is it running?" cannot be answered by the foreground
4440
+ // process alone. Argv launch runs `shell -lc "<cmd>; exec
4441
+ // shell -l"`, so the foreground process is the WRAPPER SHELL
4442
+ // for the whole life of the command — the very mechanism that
4443
+ // replaced typing made the process check blind. After the
4444
+ // crash restore, claude was sitting at its own resume prompt
4445
+ // while this loop reported "never-started", retyped the
4446
+ // command into that prompt, and gave up without answering it.
4447
+ // The screen is the honest witness: a resume prompt, or
4448
+ // claude's own chrome, means it is up.
4449
+ let screenSaysRunning = false;
4450
+ if (!running) {
4451
+ try {
4452
+ const grid = await getRoomGridPreviewShared(job.plan.internalName);
4453
+ const text = String(grid?.text || '');
4454
+ screenSaysRunning = RESUME_PROMPT_RE.test(text) || CLAUDE_UI_RE.test(text);
4455
+ } catch (e) { /* host busy — try again next tick */ }
4456
+ }
4457
+ if (running || screenSaysRunning) {
4291
4458
  const key = resumeChoiceKey(getResumeMode());
4292
4459
  if (!key) { finish('resumed'); continue; }
4293
4460
  job.state = 'answer-prompt';
@@ -4502,13 +4669,29 @@ function removeClaudeHook() {
4502
4669
  }
4503
4670
 
4504
4671
  // Resolve display name aliases (follows rename chain)
4672
+ // Alias keys resolve case-insensitively too (unique match only), so a
4673
+ // remembered old name keeps working however the caller cases it.
4674
+ function lookupSessionAliasKey(name) {
4675
+ if (sessionAliases[name] !== undefined) return name;
4676
+ let match = null;
4677
+ for (const key of Object.keys(sessionAliases)) {
4678
+ if (sameNameFold(key, name)) {
4679
+ if (match !== null) return null; // ambiguous — exact only
4680
+ match = key;
4681
+ }
4682
+ }
4683
+ return match;
4684
+ }
4685
+
4505
4686
  function resolveDisplayNameAlias(name) {
4506
4687
  if (!name || typeof name !== 'string') return name;
4507
4688
  let current = name;
4508
4689
  const seen = new Set();
4509
- while (sessionAliases[current] && !seen.has(current)) {
4690
+ while (!seen.has(current)) {
4691
+ const key = lookupSessionAliasKey(current);
4692
+ if (key === null || sessionAliases[key] === undefined) break;
4510
4693
  seen.add(current);
4511
- current = sessionAliases[current];
4694
+ current = sessionAliases[key];
4512
4695
  }
4513
4696
  return current;
4514
4697
  }
@@ -4895,10 +5078,16 @@ function resolveSessionDisplayName(displayName) {
4895
5078
  const fallbackInternalName = toInternalSessionName(resolvedDisplayName);
4896
5079
  const fallbackExists = !!fallbackInternalName
4897
5080
  && (hopSessionExists(fallbackInternalName) || !!getEffectiveSessionConfig(fallbackInternalName));
5081
+ // A case-insensitive hit canonicalizes to the name the session actually
5082
+ // wears, so /s/titan redirects to /s/Titan instead of serving the same
5083
+ // session under a differently-cased twin URL.
5084
+ const canonicalDisplayName = internalFromConfig
5085
+ ? (getSessionDisplayName(internalFromConfig) || resolvedDisplayName)
5086
+ : resolvedDisplayName;
4898
5087
  return {
4899
- displayName: resolvedDisplayName,
5088
+ displayName: canonicalDisplayName,
4900
5089
  internalName: internalFromConfig || (fallbackExists ? fallbackInternalName : null),
4901
- aliasRedirect: resolvedDisplayName !== displayName
5090
+ aliasRedirect: canonicalDisplayName !== displayName
4902
5091
  };
4903
5092
  }
4904
5093
 
@@ -4990,6 +5179,14 @@ const resolveVia = (req) => {
4990
5179
  };
4991
5180
 
4992
5181
  function resolveActor(req) {
5182
+ const declared = String(req?.headers?.[ACTOR_HEADER] || '').toLowerCase();
5183
+ if (declared === 'agent' || declared === 'user') return declared;
5184
+ // Undeclared: infer from the auth path. Cookie = a human in the web UI.
5185
+ // Bearer token without the hop CLI's via stamp = automation.
5186
+ if (req?.hopAuthVia === 'token') {
5187
+ const via = String(req?.headers?.[VIA_HEADER] || '').toLowerCase();
5188
+ if (via !== 'cli' && via !== 'web') return 'agent';
5189
+ }
4993
5190
  return resolveActorFromHeaders(req?.headers || {}, ACTOR_HEADER);
4994
5191
  }
4995
5192
 
@@ -5091,7 +5288,8 @@ function writeHayHostStateFile(state) {
5091
5288
  const changed = !hayHostState
5092
5289
  || hayHostState.pid !== state.pid
5093
5290
  || hayHostState.port !== state.port;
5094
- if (!changed) return;
5291
+ const missingStateFile = !fs.existsSync(HAY_HOST_STATE_FILE);
5292
+ if (!changed && !missingStateFile) return;
5095
5293
  hayHostState = {
5096
5294
  pid: state.pid,
5097
5295
  port: state.port,
@@ -5109,7 +5307,11 @@ function writeHayHostStateFile(state) {
5109
5307
  writePrivateFile(HAY_HOST_STATE_FILE, JSON.stringify(hayHostState));
5110
5308
  }
5111
5309
 
5112
- function clearHayHostStateFile() {
5310
+ function clearHayHostStateFile(expectedState = null) {
5311
+ if (expectedState) {
5312
+ const registered = readHayHostStateFile();
5313
+ if (!sameHostIncarnation(registered, expectedState)) return false;
5314
+ }
5113
5315
  hayHostState = null;
5114
5316
  hayHostDiscoveryCache = { expiresAt: 0, hosts: [] };
5115
5317
  try {
@@ -5117,6 +5319,7 @@ function clearHayHostStateFile() {
5117
5319
  fs.unlinkSync(HAY_HOST_STATE_FILE);
5118
5320
  }
5119
5321
  } catch (e) { }
5322
+ return true;
5120
5323
  }
5121
5324
 
5122
5325
  function parseListeningPortForPid(pid) {
@@ -6077,9 +6280,25 @@ function syncSessionMetaFromRooms(rooms) {
6077
6280
  for (const room of rooms) {
6078
6281
  if (!room || typeof room.id !== 'string' || !SESSION_NAME_RE.test(room.id)) continue;
6079
6282
  const cwd = typeof room.cwd === 'string' ? room.cwd : '';
6080
- if (!cwd) continue;
6081
6283
  const existing = readClaudeSessionMeta(room.id);
6082
- if (existing && existing.cwd === cwd) continue;
6284
+ // A room with no cwd used to be skipped entirely — so it never got a
6285
+ // durable meta, and a session that only ever lived in the host's
6286
+ // memory was UNRECOVERABLE the moment the host died: `hop restore`
6287
+ // reads this directory, and a session absent from it cannot even be
6288
+ // named, let alone relaunched. That is how a thermal-sleep crash took
6289
+ // ten sessions with no trace. Every live room now leaves a record;
6290
+ // cwd is filled in when it becomes known.
6291
+ if (!existing) {
6292
+ writeClaudeSessionMeta(room.id, {
6293
+ internalName: room.id,
6294
+ ...(cwd ? { cwd } : {}),
6295
+ createdAt: new Date().toISOString(),
6296
+ updatedAt: new Date().toISOString()
6297
+ });
6298
+ continue;
6299
+ }
6300
+ if (!cwd) continue;
6301
+ if (existing.cwd === cwd) continue;
6083
6302
  // Carry an origin we already know; never invent one. This pass exists
6084
6303
  // to backfill cwd, and defaulting createdBy to 'user' here silently
6085
6304
  // stamped "human" onto every agent session whose origin lived only in
@@ -6152,7 +6371,7 @@ function choosePreferredHayHost(inspectedHosts, preferredHost = null) {
6152
6371
  return ranked[0]?.host || null;
6153
6372
  }
6154
6373
 
6155
- async function inspectExternalHayHosts(force = false) {
6374
+ async function inspectExternalHayHostsOnce(force = false) {
6156
6375
  if (!isExternalTerminalBackend()) return [];
6157
6376
  const candidates = listDiscoveredHayHostCandidates(force);
6158
6377
  if (candidates.length === 0) return [];
@@ -6160,7 +6379,10 @@ async function inspectExternalHayHosts(force = false) {
6160
6379
  const __h0 = Date.now();
6161
6380
  const healthy = await probeExternalHayHostFromHost(host);
6162
6381
  const __h1 = Date.now();
6163
- const rooms = await fetchExternalHayRuntimeRoomsFromHost(host);
6382
+ // A failed liveness probe must not enqueue an expensive /rooms walk
6383
+ // behind it. The fallback watchdog probe below decides whether a known
6384
+ // host is merely slow; room enumeration never decides whether to kill.
6385
+ const rooms = healthy ? await fetchExternalHayRuntimeRoomsFromHost(host) : null;
6164
6386
  const __h2 = Date.now();
6165
6387
  if (__h2 - __h0 > 150) log(`[perf] inspect host pid=${host.pid} port=${host.port}: probe=${__h1-__h0}ms rooms=${__h2-__h1}ms`, YELLOW);
6166
6388
  return { host, healthy, rooms };
@@ -6168,6 +6390,11 @@ async function inspectExternalHayHosts(force = false) {
6168
6390
  return inspected.filter((entry) => entry.healthy === true && Array.isArray(entry.rooms));
6169
6391
  }
6170
6392
 
6393
+ async function inspectExternalHayHosts(force = false) {
6394
+ if (force) return await inspectExternalHayHostsOnce(true);
6395
+ return await hayHostInspectionFlight.run(() => inspectExternalHayHostsOnce(false));
6396
+ }
6397
+
6171
6398
  function hostBelongsToCurrentHome(entry, preferredHost = null) {
6172
6399
  if (!entry || !entry.host) return false;
6173
6400
  if (typeof entry.host.homeHash === 'string') {
@@ -6187,7 +6414,12 @@ function getExternalHayHostStateOrThrow() {
6187
6414
  hayHostState = readHayHostStateFile();
6188
6415
  }
6189
6416
  if (!hayHostState || !isProcessAlive(hayHostState.pid)) {
6190
- clearHayHostStateFile();
6417
+ const registered = readHayHostStateFile();
6418
+ if (registered && isProcessAlive(registered.pid)) {
6419
+ hayHostState = registered;
6420
+ return registered;
6421
+ }
6422
+ clearHayHostStateFile(registered || null);
6191
6423
  throw new Error('External hay host is not running');
6192
6424
  }
6193
6425
  return hayHostState;
@@ -6219,18 +6451,25 @@ async function stopExternalHayHostProcess(hostState, timeoutMs = 1500) {
6219
6451
  return !isProcessAlive(pid);
6220
6452
  }
6221
6453
 
6222
- async function ensureExternalHayHostReady() {
6223
- if (!isExternalTerminalBackend()) return null;
6454
+ function markExternalHayHostResponsive(host) {
6455
+ const recovered = hayHostProbeFailures.recordSuccess(Number(host?.pid));
6456
+ if (recovered) {
6457
+ log(`[Hay] Host ${host.pid} responsive again after ${recovered.count} failed probe(s).`, DIM);
6458
+ }
6459
+ writeHayHostStateFile(host);
6460
+ return host;
6461
+ }
6224
6462
 
6463
+ async function ensureExternalHayHostReadyOnce() {
6225
6464
  const existing = hayHostState || readHayHostStateFile();
6226
6465
  const inspectedExisting = await inspectExternalHayHosts();
6227
6466
  const relevantExisting = inspectedExisting.filter((entry) => hostBelongsToCurrentHome(entry, existing));
6228
6467
  const preferredExisting = choosePreferredHayHost(relevantExisting, existing);
6229
6468
  if (preferredExisting) {
6230
- writeHayHostStateFile(preferredExisting);
6231
- return preferredExisting;
6469
+ return markExternalHayHostResponsive(preferredExisting);
6232
6470
  }
6233
6471
 
6472
+ let stateCleared = false;
6234
6473
  if (existing && isProcessAlive(existing.pid)) {
6235
6474
  // Generous timeout: a host mid-GC or briefly saturated is SLOW, not
6236
6475
  // dead — and "restarting" it kills every live PTY. Only a host that
@@ -6239,29 +6478,58 @@ async function ensureExternalHayHostReady() {
6239
6478
  // alive beats dead).
6240
6479
  const healthy = await probeExternalHayHostFromHost(existing, 5000);
6241
6480
  if (healthy) {
6242
- if (hayHostProbeFailures.pid === existing.pid && hayHostProbeFailures.count > 0) {
6243
- log(`[Hay] Host ${existing.pid} responsive again after ${hayHostProbeFailures.count} failed probe(s).`, DIM);
6244
- }
6245
- hayHostProbeFailures = { pid: 0, count: 0, firstAt: 0 };
6481
+ return markExternalHayHostResponsive(existing);
6482
+ }
6483
+ const failure = hayHostProbeFailures.recordFailure(existing.pid);
6484
+ if (!failure.shouldRestart) {
6485
+ log(`[Hay] Host ${existing.pid} (port ${existing.port}) slow: probe timed out (${failure.count}/${HAY_HOST_UNRESPONSIVE_PROBES} over ${Math.round(failure.failingForMs / 1000)}s). Keeping it alive.`, YELLOW);
6246
6486
  writeHayHostStateFile(existing);
6247
6487
  return existing;
6248
6488
  }
6249
- if (hayHostProbeFailures.pid !== existing.pid) {
6250
- hayHostProbeFailures = { pid: existing.pid, count: 0, firstAt: Date.now() };
6251
- }
6252
- hayHostProbeFailures.count += 1;
6253
- const failingForMs = Date.now() - hayHostProbeFailures.firstAt;
6254
- if (hayHostProbeFailures.count < HAY_HOST_UNRESPONSIVE_PROBES || failingForMs < HAY_HOST_UNRESPONSIVE_WINDOW_MS) {
6255
- log(`[Hay] Host ${existing.pid} (port ${existing.port}) slow: probe timed out (${hayHostProbeFailures.count}/${HAY_HOST_UNRESPONSIVE_PROBES} over ${Math.round(failingForMs / 1000)}s). Keeping it alive.`, YELLOW);
6489
+ const daemonStall = getRecentDaemonEventLoopStall();
6490
+ if (daemonStall) {
6491
+ log(`[Hay] Host ${existing.pid} is slow while the daemon also stalled ~${Math.round(daemonStall.driftMs)}ms. Resetting the watchdog window and keeping the host alive.`, YELLOW);
6492
+ hayHostProbeFailures.reset();
6256
6493
  writeHayHostStateFile(existing);
6257
6494
  return existing;
6258
6495
  }
6259
- log(`[Hay] Host ${existing.pid} (port ${existing.port}) unresponsive for ${Math.round(failingForMs / 1000)}s (${hayHostProbeFailures.count} failed probes). Restarting it.`, YELLOW);
6260
- hayHostProbeFailures = { pid: 0, count: 0, firstAt: 0 };
6261
- await stopExternalHayHostProcess(existing);
6496
+
6497
+ // Every await above gives another process a chance to publish a new host.
6498
+ // Never apply a destructive decision to a state generation we no longer
6499
+ // own, even if the old PID is still around.
6500
+ const registeredBeforeStop = readHayHostStateFile();
6501
+ if (!sameHostIncarnation(registeredBeforeStop, existing)) {
6502
+ hayHostProbeFailures.reset();
6503
+ log(`[Hay] Host state changed while probing ${existing.pid}; aborting the stale restart.`, YELLOW);
6504
+ if (registeredBeforeStop && isProcessAlive(registeredBeforeStop.pid)
6505
+ && await probeExternalHayHostFromHost(registeredBeforeStop, 1500)) {
6506
+ return markExternalHayHostResponsive(registeredBeforeStop);
6507
+ }
6508
+ return existing;
6509
+ }
6510
+
6511
+ log(`[Hay] Host ${existing.pid} (port ${existing.port}) unresponsive for ${Math.round(failure.failingForMs / 1000)}s (${failure.count} failed probes). Restarting it.`, YELLOW);
6512
+ hayHostProbeFailures.reset();
6513
+ const stopped = await stopExternalHayHostProcess(existing);
6514
+ if (!stopped) {
6515
+ throw new Error(`Hay host ${existing.pid} did not stop; refusing to spawn a rival`);
6516
+ }
6517
+ stateCleared = clearHayHostStateFile(existing);
6518
+ if (!stateCleared) {
6519
+ throw new Error('Hay host state changed during restart; refusing to clear or replace it');
6520
+ }
6262
6521
  }
6263
6522
 
6264
- clearHayHostStateFile();
6523
+ if (!stateCleared) {
6524
+ const registered = readHayHostStateFile();
6525
+ if (registered && (!existing || !sameHostIncarnation(registered, existing))) {
6526
+ if (isProcessAlive(registered.pid) && await probeExternalHayHostFromHost(registered, 1500)) {
6527
+ return markExternalHayHostResponsive(registered);
6528
+ }
6529
+ throw new Error('Hay host state changed during recovery; refusing to clear or replace it');
6530
+ }
6531
+ clearHayHostStateFile(registered || null);
6532
+ }
6265
6533
 
6266
6534
  // Spawn lock: concurrent ensure calls (other hop processes, racing web
6267
6535
  // requests) must not each spawn a host. Loser waits for the winner's
@@ -6286,8 +6554,7 @@ async function ensureExternalHayHostReady() {
6286
6554
  await new Promise((resolve) => setTimeout(resolve, 200));
6287
6555
  const spawned = readHayHostStateFile();
6288
6556
  if (spawned && isProcessAlive(spawned.pid) && await probeExternalHayHostFromHost(spawned, 1500)) {
6289
- hayHostState = spawned;
6290
- return spawned;
6557
+ return markExternalHayHostResponsive(spawned);
6291
6558
  }
6292
6559
  }
6293
6560
  // Winner never delivered — fall through and spawn ourselves.
@@ -6295,9 +6562,37 @@ async function ensureExternalHayHostReady() {
6295
6562
  try {
6296
6563
  spawnLockFd = fs.openSync(HAY_HOST_SPAWN_LOCK, 'wx');
6297
6564
  fs.writeSync(spawnLockFd, String(process.pid));
6298
- } catch (e2) { spawnLockFd = null; /* proceed unlocked rather than fail */ }
6565
+ } catch (e2) {
6566
+ throw new Error('Another Hop process is still spawning the Hay host; refusing to spawn a rival');
6567
+ }
6568
+ }
6569
+ try {
6570
+
6571
+ const registeredSurvivor = readHayHostStateFile();
6572
+ if (registeredSurvivor && isProcessAlive(registeredSurvivor.pid)
6573
+ && await probeExternalHayHostFromHost(registeredSurvivor, 1500)) {
6574
+ return markExternalHayHostResponsive(registeredSurvivor);
6299
6575
  }
6576
+
6577
+ // Last look before spawning: an already-running, healthy host for THIS hop
6578
+ // home means we must adopt it, not add a rival. Two hosts split the fleet —
6579
+ // the daemon canonicalizes one, so every room on the loser goes invisible
6580
+ // and dies unrecorded when it is reaped — and both thrash the same state
6581
+ // files. The spawn lock above only serializes racing spawners; a host that
6582
+ // survived a crash (or was started by hand) is outside its scope, which is
6583
+ // how two hosts came to exist across the thermal-sleep crash.
6300
6584
  try {
6585
+ const survivors = (await inspectExternalHayHosts(true))
6586
+ .filter((entry) => entry.healthy && hostBelongsToCurrentHome(entry, null));
6587
+ const adoptable = survivors
6588
+ .map((entry) => entry.host)
6589
+ .filter((host) => host && Number.isInteger(host.pid) && isProcessAlive(host.pid))
6590
+ .sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))[0];
6591
+ if (adoptable) {
6592
+ log(`[host] adopting the running host (pid ${adoptable.pid}, port ${adoptable.port}) instead of spawning a rival`, DIM);
6593
+ return markExternalHayHostResponsive(adoptable);
6594
+ }
6595
+ } catch (e) { /* inspection failed — spawning is still the right move */ }
6301
6596
 
6302
6597
  const hostScript = path.join(__dirname, 'scripts', 'hay-host.js');
6303
6598
  if (!fs.existsSync(hostScript)) {
@@ -6369,6 +6664,11 @@ async function ensureExternalHayHostReady() {
6369
6664
  }
6370
6665
  }
6371
6666
 
6667
+ async function ensureExternalHayHostReady() {
6668
+ if (!isExternalTerminalBackend()) return null;
6669
+ return await hayHostEnsureFlight.run(ensureExternalHayHostReadyOnce);
6670
+ }
6671
+
6372
6672
  async function callExternalHayHostApi(apiPath, method = 'GET', body = null, timeoutMs = 2000) {
6373
6673
  if (!isExternalTerminalBackend()) return null;
6374
6674
  const state = await ensureExternalHayHostReady();
@@ -6940,6 +7240,14 @@ function startAuthProxy(authEmitter) {
6940
7240
  : null;
6941
7241
  const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
6942
7242
  || tokenFromHeader === SESSION_SECRET;
7243
+ // How this request proved itself decides the DEFAULT actor. Humans
7244
+ // reach the API through the web UI (cookie) or the hop CLI (which
7245
+ // stamps x-hop-via); a raw Bearer-token call with neither is, in
7246
+ // practice, always automation — an agent's script, a probe, a cron.
7247
+ // Those callers rarely remember to declare x-hop-actor, and every
7248
+ // one that forgot used to land its sessions in the USER tab
7249
+ // (SelectProbe et al). Explicit headers still win outright.
7250
+ req.hopAuthVia = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ? 'cookie' : 'token';
6943
7251
 
6944
7252
  // API: Auth status (used by local CLI to wait for remote login)
6945
7253
  if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
@@ -11040,15 +11348,26 @@ async function claimUnnamedLocalCliSession(opts = {}) {
11040
11348
  }
11041
11349
 
11042
11350
  function resolveSessionTargetByDisplay(sessionsRes, displayName) {
11043
- const sessions = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : [];
11044
- let target = sessions.find((session) => (
11045
- session && session.type !== 'port' && session.displayName === displayName
11046
- ));
11047
- if (!target && sessionsRes?.aliases && sessionsRes.aliases[displayName]) {
11048
- const resolvedName = sessionsRes.aliases[displayName];
11049
- target = sessions.find((session) => (
11050
- session && session.type !== 'port' && session.displayName === resolvedName
11051
- ));
11351
+ const sessions = (Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : [])
11352
+ .filter((session) => session && session.type !== 'port');
11353
+ // Exact-case match wins; otherwise a UNIQUE case-insensitive wearer
11354
+ // resolves, so `hop attach titan` finds "Titan".
11355
+ const byName = (name) => {
11356
+ const lower = String(name).toLowerCase();
11357
+ let exact = null;
11358
+ const folded = [];
11359
+ for (const session of sessions) {
11360
+ if (session.displayName === name) { exact = session; break; }
11361
+ if (String(session.displayName).toLowerCase() === lower) folded.push(session);
11362
+ }
11363
+ return exact || (folded.length === 1 ? folded[0] : null);
11364
+ };
11365
+ let target = byName(displayName);
11366
+ if (!target && sessionsRes?.aliases) {
11367
+ const aliasKeys = Object.keys(sessionsRes.aliases);
11368
+ const aliasKey = aliasKeys.find((key) => key === displayName)
11369
+ ?? aliasKeys.find((key) => key.toLowerCase() === String(displayName).toLowerCase());
11370
+ if (aliasKey) target = byName(sessionsRes.aliases[aliasKey]);
11052
11371
  }
11053
11372
  return target || null;
11054
11373
  }
@@ -11165,12 +11484,21 @@ function resolveOrCreateLocalTerminalSession(nameArg, opts = {}) {
11165
11484
  };
11166
11485
  }
11167
11486
 
11168
- const existingByList = existingSessions.find((session) => {
11169
- if (!session || session.type === 'port') return false;
11170
- if (session.displayName === requestedDisplay) return true;
11171
- if (session.internalName === requestedDisplay || session.internalName === requested) return true;
11172
- return false;
11173
- });
11487
+ // Case-insensitive so `hop titan` attaches to "Titan" instead of
11488
+ // minting a lowercase twin. Exact matches take priority; a folded
11489
+ // match only counts when it is unambiguous.
11490
+ const matchesName = (candidate, name) => typeof candidate === 'string' && typeof name === 'string'
11491
+ && candidate.toLowerCase() === name.toLowerCase();
11492
+ const eligibleSessions = existingSessions.filter((session) => session && session.type !== 'port');
11493
+ const exactByList = eligibleSessions.find((session) => (
11494
+ session.displayName === requestedDisplay
11495
+ || session.internalName === requestedDisplay || session.internalName === requested
11496
+ ));
11497
+ const foldedByList = exactByList ? [] : eligibleSessions.filter((session) => (
11498
+ matchesName(session.displayName, requestedDisplay)
11499
+ || matchesName(session.internalName, requestedDisplay) || matchesName(session.internalName, requested)
11500
+ ));
11501
+ const existingByList = exactByList || (foldedByList.length === 1 ? foldedByList[0] : null);
11174
11502
  if (existingByList && existingByList.internalName) {
11175
11503
  return {
11176
11504
  internalName: existingByList.internalName,
@@ -11415,7 +11743,144 @@ function suggestNearestCommand(input) {
11415
11743
  return best;
11416
11744
  }
11417
11745
 
11746
+ // One source of truth for CLI usage: the full `hop --help` output and the
11747
+ // per-command `hop <cmd> --help` output both render from this table, so a
11748
+ // command can never exist without help (a stray `hop restore --help` once
11749
+ // fell through and ran a live restore).
11750
+ const HELP_SECTIONS = [
11751
+ { title: 'Usage', lines: [
11752
+ `hop Launch the daemon+tunnel if needed and open a NEW terminal session`,
11753
+ `hop new Same as hop (always opens a new session)`,
11754
+ `hop attach Reattach to an existing session (picks an unattached one; lists them if none are free)`,
11755
+ `hop attach <name> Reattach to an existing session by name`,
11756
+ `hop attach all Reattach to every existing session in turn`,
11757
+ `hop start Start the daemon and tunnel only, then print QR codes (no terminal session)`,
11758
+ `hop local [session] Open a terminal with no daemon or tunnel — local only, not reachable remotely`,
11759
+ `hop stop Stop hop daemon (terminal sessions keep running)`,
11760
+ `hop stop --all Stop daemon, hay-host, and all terminal sessions`,
11761
+ `hop status Show daemon, tunnel, and session status`,
11762
+ `hop doctor Run environment/health checks`,
11763
+ `hop logs [-f] [hay-host] Show daemon logs (-f to follow)`,
11764
+ `hop --help Show this help message`,
11765
+ ] },
11766
+ { title: 'URL & QR', lines: [
11767
+ `hop url Print current tunnel URL`,
11768
+ `hop qr Show tunnel URL as QR code`,
11769
+ `hop qr auth Show authenticator setup QR code`,
11770
+ ] },
11771
+ { title: 'Config', lines: [
11772
+ `hop config Show hop config`,
11773
+ `hop config theme [name] Show or set the session naming theme`,
11774
+ ] },
11775
+ { title: 'Custom Domain', lines: [
11776
+ `hop domain Show current domain config`,
11777
+ `hop domain <hostname> Setup custom domain (e.g., hop.example.com)`,
11778
+ `hop domain-clear Remove custom domain, use random URLs`,
11779
+ ] },
11780
+ { title: 'Security', lines: [
11781
+ `hop password Show password status`,
11782
+ `hop password set Set or change password`,
11783
+ `hop password clear Remove password protection`,
11784
+ ] },
11785
+ { title: 'Multi-user (admin)', lines: [
11786
+ `hop user list List all users`,
11787
+ `hop user add <name> Add a user (creates tunnel + DNS)`,
11788
+ `hop user remove <name> Remove a user`,
11789
+ `hop user export <name> Export credentials for a user`,
11790
+ ] },
11791
+ { title: 'Sessions', lines: [
11792
+ `hop session list [--all|-a] List live sessions (--all/-a adds workspace defs across all workspaces)`,
11793
+ `hop session add <name> [--cwd P] --workspace W Save a terminal definition to a workspace`,
11794
+ `hop session add <name> --port N --workspace W Save a port definition to a workspace`,
11795
+ `hop session rename <old> <new> Rename a session`,
11796
+ `hop session remove <name> Remove a session`,
11797
+ `hop session permit <name> Allow agent access`,
11798
+ `hop session origin <name> <user|agent> Move a session between user/agent sides (switcher filter + restore policy)`,
11799
+ `hop session block <name> Block agent access`,
11800
+ ] },
11801
+ { title: 'Workspaces', lines: [
11802
+ `hop workspace list List workspaces`,
11803
+ `hop workspace create <name> Create an empty workspace`,
11804
+ `hop workspace show <name> Show saved definitions in a workspace`,
11805
+ `hop workspace save <name> Save selected live sessions into a workspace`,
11806
+ `hop workspace load <name> Start sessions defined in a workspace`,
11807
+ `hop workspace delete <name> [--yes] Delete a workspace (asks before deleting a non-empty one)`,
11808
+ ] },
11809
+ { title: 'Restore', lines: [
11810
+ `hop restore [--dry-run] [--agents] Recreate your saved sessions, resuming claude conversations (--agents includes agent-created ones)`,
11811
+ `hop update [--check|--host] Self-update from GitHub (daemon restart; sessions survive; --host cycles the host too)`,
11812
+ `hop claude-hook install Enable full restore (adds a Claude SessionStart hook)`,
11813
+ `hop claude-hook status|remove Check or remove the hook`,
11814
+ `hop agent-skill install Install Hop orchestration instructions for Claude and Codex`,
11815
+ `hop agent-skill status|remove Check or remove the skills`,
11816
+ `hop claude-skill ... Backward-compatible Claude-only alias`,
11817
+ ] },
11818
+ { title: 'Client mode (for users)', lines: [
11819
+ `hop client <credentials> Run tunnel with exported credentials`,
11820
+ ] },
11821
+ { title: 'Math', lines: [
11822
+ `hop math '<latex>' Render a LaTeX formula (inline image where supported, else Unicode)`,
11823
+ ] },
11824
+ { title: 'Maintenance', lines: [
11825
+ `hop health [--restart] Check tunnel health (--restart forces a tunnel restart)`,
11826
+ `hop wipe [--all] [--yes] Remove workspace-defined sessions (--all kills live; --yes skips confirmation)`,
11827
+ `hop wipe --agent Close all agent-created terminal API sessions`,
11828
+ `hop config resume <mode> How restores answer claude's resume question (full|summary|ask)`,
11829
+ ] },
11830
+ ];
11831
+
11832
+ function printFullHelp() {
11833
+ log(`\n${BOLD}Hop - Remote Terminal Access${RESET}\n`, CYAN);
11834
+ for (const section of HELP_SECTIONS) {
11835
+ log(`${BOLD}${section.title}:${RESET}`, RESET);
11836
+ for (const line of section.lines) {
11837
+ log(` ${line}`, RESET);
11838
+ }
11839
+ log('');
11840
+ }
11841
+ }
11842
+
11843
+ // Print every usage line whose command word matches. Returns false when the
11844
+ // table documents nothing for it.
11845
+ function printCommandHelp(cmd) {
11846
+ const matched = [];
11847
+ for (const section of HELP_SECTIONS) {
11848
+ for (const line of section.lines) {
11849
+ const word = (line.match(/^hop\s+(\S+)/) || [])[1] || '';
11850
+ if (word === cmd) matched.push(line);
11851
+ }
11852
+ }
11853
+ if (matched.length === 0) return false;
11854
+ log(`\n${BOLD}Usage:${RESET}`, RESET);
11855
+ for (const line of matched) {
11856
+ log(` ${line}`, RESET);
11857
+ }
11858
+ log('');
11859
+ return true;
11860
+ }
11861
+
11418
11862
  async function main() {
11863
+ // Any command followed by --help/-h prints usage instead of running: help
11864
+ // must never execute the real thing. Bare "help" is NOT intercepted here —
11865
+ // positional args (a session name, latex) may legitimately be "help" — and
11866
+ // `hop client` keeps its own richer help text.
11867
+ const argCmd = process.argv[2];
11868
+ if (argCmd && argCmd !== 'client' && !['--help', '-h', 'help'].includes(argCmd)
11869
+ && process.argv.slice(3).some((a) => a === '--help' || a === '-h')) {
11870
+ if (!printCommandHelp(argCmd)) {
11871
+ if (KNOWN_COMMANDS.includes(argCmd)) {
11872
+ printFullHelp();
11873
+ } else {
11874
+ const suggestion = suggestNearestCommand(argCmd);
11875
+ log(`Unknown command: ${argCmd}`, RED);
11876
+ if (suggestion) log(`Did you mean ${CYAN}hop ${suggestion}${RESET}?`, RESET);
11877
+ log(`Run ${CYAN}hop --help${RESET} for the full command list.`, RESET);
11878
+ process.exit(1);
11879
+ }
11880
+ }
11881
+ process.exit(0);
11882
+ }
11883
+
11419
11884
  // Handle `hop client` - run as a user with exported credentials
11420
11885
  if (process.argv[2] === 'client') {
11421
11886
  // Separate flags from positionals so flag order doesn't matter.
@@ -11852,11 +12317,14 @@ async function main() {
11852
12317
 
11853
12318
  const sessionsRes = await fetchSessionList(state);
11854
12319
  const allSessions = (sessionsRes?.sessions || []).filter((s) => s.type !== 'port');
11855
- let targets = named.length
11856
- ? allSessions.filter((s) => named.includes(s.displayName) || named.includes(s.internalName))
11857
- : allSessions;
12320
+ const namedLower = named.map((n) => n.toLowerCase());
12321
+ const wearsAnyName = (s) => namedLower.includes(String(s.displayName || '').toLowerCase())
12322
+ || namedLower.includes(String(s.internalName || '').toLowerCase());
12323
+ let targets = named.length ? allSessions.filter(wearsAnyName) : allSessions;
11858
12324
  if (named.length) {
11859
- const missing = named.filter((n) => !allSessions.some((s) => s.displayName === n || s.internalName === n));
12325
+ const missing = named.filter((n) => !allSessions.some((s) =>
12326
+ String(s.displayName || '').toLowerCase() === n.toLowerCase()
12327
+ || String(s.internalName || '').toLowerCase() === n.toLowerCase()));
11860
12328
  for (const m of missing) log(`No such session: ${m}`, YELLOW);
11861
12329
  }
11862
12330
  // Freshness: a tagline names the session's DURABLE goal, so it should
@@ -12339,86 +12807,7 @@ async function main() {
12339
12807
  }
12340
12808
 
12341
12809
  if (process.argv[2] === '--help' || process.argv[2] === '-h' || process.argv[2] === 'help') {
12342
- log(`\n${BOLD}Hop - Remote Terminal Access${RESET}\n`, CYAN);
12343
- log(`${BOLD}Usage:${RESET}`, RESET);
12344
- log(` hop Launch the daemon+tunnel if needed and open a NEW terminal session`, RESET);
12345
- log(` hop new Same as hop (always opens a new session)`, RESET);
12346
- log(` hop attach Reattach to an existing session (picks an unattached one; lists them if none are free)`, RESET);
12347
- log(` hop attach <name> Reattach to an existing session by name`, RESET);
12348
- log(` hop attach all Reattach to every existing session in turn`, RESET);
12349
- log(` hop start Start the daemon and tunnel only, then print QR codes (no terminal session)`, RESET);
12350
- log(` hop local [session] Open a terminal with no daemon or tunnel — local only, not reachable remotely`, RESET);
12351
- log(` hop stop Stop hop daemon (terminal sessions keep running)`, RESET);
12352
- log(` hop stop --all Stop daemon, hay-host, and all terminal sessions`, RESET);
12353
- log(` hop status Show daemon, tunnel, and session status`, RESET);
12354
- log(` hop doctor Run environment/health checks`, RESET);
12355
- log(` hop logs [-f] [hay-host] Show daemon logs (-f to follow)`, RESET);
12356
- log(` hop --help Show this help message`, RESET);
12357
- log('');
12358
- log(`${BOLD}URL & QR:${RESET}`, RESET);
12359
- log(` hop url Print current tunnel URL`, RESET);
12360
- log(` hop qr Show tunnel URL as QR code`, RESET);
12361
- log(` hop qr auth Show authenticator setup QR code`, RESET);
12362
- log('');
12363
- log(`${BOLD}Config:${RESET}`, RESET);
12364
- log(` hop config Show hop config`, RESET);
12365
- log(` hop config theme [name] Show or set the session naming theme`, RESET);
12366
- log('');
12367
- log(`${BOLD}Custom Domain:${RESET}`, RESET);
12368
- log(` hop domain Show current domain config`, RESET);
12369
- log(` hop domain <hostname> Setup custom domain (e.g., hop.example.com)`, RESET);
12370
- log(` hop domain-clear Remove custom domain, use random URLs`, RESET);
12371
- log('');
12372
- log(`${BOLD}Security:${RESET}`, RESET);
12373
- log(` hop password Show password status`, RESET);
12374
- log(` hop password set Set or change password`, RESET);
12375
- log(` hop password clear Remove password protection`, RESET);
12376
- log('');
12377
- log(`${BOLD}Multi-user (admin):${RESET}`, RESET);
12378
- log(` hop user list List all users`, RESET);
12379
- log(` hop user add <name> Add a user (creates tunnel + DNS)`, RESET);
12380
- log(` hop user remove <name> Remove a user`, RESET);
12381
- log(` hop user export <name> Export credentials for a user`, RESET);
12382
- log('');
12383
- log(`${BOLD}Sessions:${RESET}`, RESET);
12384
- log(` hop session list [--all|-a] List live sessions (--all/-a adds workspace defs across all workspaces)`, RESET);
12385
- log(` hop session add <name> [--cwd P] --workspace W Save a terminal definition to a workspace`, RESET);
12386
- log(` hop session add <name> --port N --workspace W Save a port definition to a workspace`, RESET);
12387
- log(` hop session rename <old> <new> Rename a session`, RESET);
12388
- log(` hop session remove <name> Remove a session`, RESET);
12389
- log(` hop session permit <name> Allow agent access`, RESET);
12390
- log(` hop session origin <name> <user|agent> Move a session between user/agent sides (switcher filter + restore policy)`, RESET);
12391
- log(` hop session block <name> Block agent access`, RESET);
12392
- log('');
12393
- log(`${BOLD}Workspaces:${RESET}`, RESET);
12394
- log(` hop workspace list List workspaces`, RESET);
12395
- log(` hop workspace create <name> Create an empty workspace`, RESET);
12396
- log(` hop workspace show <name> Show saved definitions in a workspace`, RESET);
12397
- log(` hop workspace save <name> Save selected live sessions into a workspace`, RESET);
12398
- log(` hop workspace load <name> Start sessions defined in a workspace`, RESET);
12399
- log(` hop workspace delete <name> [--yes] Delete a workspace (asks before deleting a non-empty one)`, RESET);
12400
- log('');
12401
- log(`${BOLD}Restore:${RESET}`, RESET);
12402
- log(` hop restore [--dry-run] Recreate your saved sessions, resuming claude conversations`, RESET);
12403
- log(` hop update [--check|--host] Self-update from GitHub (daemon restart; sessions survive; --host cycles the host too)`, RESET);
12404
- log(` hop claude-hook install Enable full restore (adds a Claude SessionStart hook)`, RESET);
12405
- log(` hop claude-hook status|remove Check or remove the hook`, RESET);
12406
- log(` hop agent-skill install Install Hop orchestration instructions for Claude and Codex`, RESET);
12407
- log(` hop agent-skill status|remove Check or remove the skills`, RESET);
12408
- log(` hop claude-skill ... Backward-compatible Claude-only alias`, RESET);
12409
- log('');
12410
- log(`${BOLD}Client mode (for users):${RESET}`, RESET);
12411
- log(` hop client <credentials> Run tunnel with exported credentials`, RESET);
12412
- log('');
12413
- log(`${BOLD}Math:${RESET}`, RESET);
12414
- log(` hop math '<latex>' Render a LaTeX formula (inline image where supported, else Unicode)`, RESET);
12415
- log('');
12416
- log(`${BOLD}Maintenance:${RESET}`, RESET);
12417
- log(` hop health [--restart] Check tunnel health (--restart forces a tunnel restart)`, RESET);
12418
- log(` hop wipe [--all] [--yes] Remove workspace-defined sessions (--all kills live; --yes skips confirmation)`, RESET);
12419
- log(` hop wipe --agent Close all agent-created terminal API sessions`, RESET);
12420
- log(` hop config resume <mode> How restores answer claude's resume question (full|summary|ask)`, RESET);
12421
- log('');
12810
+ printFullHelp();
12422
12811
  process.exit(0);
12423
12812
  }
12424
12813