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/README.md +7 -1
- package/hay/apps/server/package.json +3 -1
- package/hay/apps/server/src/rooms.ts +142 -33
- package/hay/apps/server/src/screenGrid.ts +145 -0
- package/hay/apps/server/test/rooms.test.ts +104 -0
- package/hay/apps/web/src/App.tsx +70 -105
- package/hay/apps/web/src/components/SessionSwitcher.tsx +126 -15
- package/hay/apps/web/src/styles.css +13 -0
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay/package-lock.json +17 -0
- package/hay/packages/shared/src/protocol.ts +6 -1
- package/hay-web/assets/{index-BohAWpcL.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-isX6Rkr2.js +132 -0
- package/hay-web/assets/index-isX6Rkr2.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +539 -150
- package/hopa +6 -1
- package/lib/hay-host-watchdog.js +97 -0
- package/mcp/hop-mcp.js +6 -1
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +14 -1
- package/scripts/salvage-and-restart-host.js +116 -0
- package/tests/hay-host-watchdog.unit.test.cjs +109 -0
- package/tests/terminal-api.integration.test.cjs +100 -0
- package/hay-web/assets/index-DfGT3DuO.js +0 -130
- package/hay-web/assets/index-DfGT3DuO.js.map +0 -1
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3200
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
3261
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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[
|
|
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:
|
|
5088
|
+
displayName: canonicalDisplayName,
|
|
4900
5089
|
internalName: internalFromConfig || (fallbackExists ? fallbackInternalName : null),
|
|
4901
|
-
aliasRedirect:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
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
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
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
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
11045
|
-
|
|
11046
|
-
|
|
11047
|
-
|
|
11048
|
-
const
|
|
11049
|
-
|
|
11050
|
-
|
|
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
|
-
|
|
11169
|
-
|
|
11170
|
-
|
|
11171
|
-
|
|
11172
|
-
|
|
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
|
-
|
|
11856
|
-
|
|
11857
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|