hop2 0.11.21 → 0.11.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/server/src/rooms.ts +9 -7
- package/hay/apps/web/src/App.tsx +57 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +6 -4
- package/hay/apps/web/src/utils/scrollFlywheel.ts +177 -20
- package/hay/apps/web/test/scrollFlywheel.test.ts +212 -0
- package/hay-web/assets/index-COQpy7gK.js +134 -0
- package/hay-web/assets/index-COQpy7gK.js.map +1 -0
- package/hay-web/assets/index-CXZrhZBl.js +134 -0
- package/hay-web/assets/index-CXZrhZBl.js.map +1 -0
- package/hay-web/assets/index-Chtgn1Ot.js +134 -0
- package/hay-web/assets/index-Chtgn1Ot.js.map +1 -0
- package/hay-web/assets/index-DcF33Fu1.js +134 -0
- package/hay-web/assets/index-DcF33Fu1.js.map +1 -0
- package/hay-web/assets/index-JN4pNZh2.js +134 -0
- package/hay-web/assets/index-JN4pNZh2.js.map +1 -0
- package/hay-web/assets/index-tyqviMzG.js +134 -0
- package/hay-web/assets/index-tyqviMzG.js.map +1 -0
- package/hay-web/index.html +1 -1
- package/hop +177 -47
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -1
- package/scripts/salvage-and-restart-host.js +6 -1
- package/tests/terminal-api.integration.test.cjs +129 -27
package/hay-web/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
|
8
8
|
<title>Hay</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-COQpy7gK.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-B8TPcotl.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/hop
CHANGED
|
@@ -539,6 +539,26 @@ function enforceViewBudget() {
|
|
|
539
539
|
} catch (e) { /* a failed sweep never blocks a publish */ }
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
// Does the proxied session behind a Views server row still exist? Answered
|
|
543
|
+
// from the ports ledger on disk (plus the in-memory store, when this is the
|
|
544
|
+
// daemon), so the CLI and the daemon agree.
|
|
545
|
+
function portSessionIsKnown(target) {
|
|
546
|
+
const seg = String(target || '').match(/^\/s\/([^/]+)/);
|
|
547
|
+
const nm = seg ? decodeURIComponent(seg[1]) : '';
|
|
548
|
+
if (!nm) return false;
|
|
549
|
+
const matches = (cfg, key) => cfg?.type === 'port' && (key === nm || cfg.displayName === nm);
|
|
550
|
+
for (const [key, cfg] of Object.entries(sessionStore?.sessions || {})) {
|
|
551
|
+
if (matches(cfg, key)) return true;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const raw = JSON.parse(fs.readFileSync(PORTS_FILE, 'utf8'));
|
|
555
|
+
for (const [key, cfg] of Object.entries(raw.ports || {})) {
|
|
556
|
+
if (matches(cfg, key)) return true;
|
|
557
|
+
}
|
|
558
|
+
} catch (e) { /* no ledger yet */ }
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
|
|
542
562
|
// The virtual folder both `hop view` and `hop port --attached` publish into:
|
|
543
563
|
// clients browse every view through one manifest, REBUILT from a disk scan
|
|
544
564
|
// rather than read-modify-written, so two agents publishing at once cannot
|
|
@@ -578,6 +598,15 @@ function rebuildViewManifest() {
|
|
|
578
598
|
kind: m.kind === 'server' ? 'server' : 'file',
|
|
579
599
|
target: m.kind === 'server' ? (m.target || '') : undefined,
|
|
580
600
|
port: m.kind === 'server' ? m.port : undefined,
|
|
601
|
+
// Whether the proxied session behind a server row still
|
|
602
|
+
// EXISTS. A row whose session evaporated used to open the
|
|
603
|
+
// hop wall (proxy miss → SPA fallback) with no hint why;
|
|
604
|
+
// clients can now say "server gone" instead.
|
|
605
|
+
// Read from the ports LEDGER, not sessionStore: the
|
|
606
|
+
// manifest is rebuilt inside the `hop view` CLI process
|
|
607
|
+
// as often as inside the daemon, and the CLI's store is
|
|
608
|
+
// empty — every server row read dead from there.
|
|
609
|
+
live: m.kind === 'server' ? portSessionIsKnown(m.target) : undefined,
|
|
581
610
|
// The extensionless serving path — see the /view route for
|
|
582
611
|
// why the extension must stay off the URL's end.
|
|
583
612
|
path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
|
|
@@ -601,6 +630,13 @@ const SESSION_SECRET_FILE = path.join(HOME_DIR, '.session_secret');
|
|
|
601
630
|
// evaporated on every daemon restart. Session MEMBERSHIP is persisted per
|
|
602
631
|
// session in its durable meta, so a folder and its contents survive together.
|
|
603
632
|
const FOLDERS_FILE = path.join(HOME_DIR, 'folders.json');
|
|
633
|
+
// Port sessions (`hop port`, `hop session add --port`) outside a workspace.
|
|
634
|
+
// The daemon boots with NO active workspace, so saveSessionStore() is a
|
|
635
|
+
// no-op and every port definition lived in memory only — each restart
|
|
636
|
+
// forgot them all while their backends kept running, and every Views row
|
|
637
|
+
// pointing at one silently opened the hop wall instead (the proxy route no
|
|
638
|
+
// longer matched, the SPA fallback answered). Same shape as folders.json.
|
|
639
|
+
const PORTS_FILE = path.join(HOME_DIR, 'ports.json');
|
|
604
640
|
const DEFAULT_SECRET_FILE = path.join(HOME_DIR, '.auth_secret');
|
|
605
641
|
const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
|
|
606
642
|
const HOP_CONFIG_FILE = path.join(HOME_DIR, '.config.json');
|
|
@@ -833,6 +869,20 @@ function toInternalSessionName(displayName) {
|
|
|
833
869
|
return displayName;
|
|
834
870
|
}
|
|
835
871
|
|
|
872
|
+
// Invariant session id for NEW sessions: opaque, never derived from the
|
|
873
|
+
// display name. Sessions created before this simply keep their original
|
|
874
|
+
// name as their id (the state every rename already produced), so nothing
|
|
875
|
+
// migrates. The id is machine-facing (HOP_SESSION, room keys, record
|
|
876
|
+
// files) and must never be shown to the user; the display name is the
|
|
877
|
+
// only name a human sees, and renaming touches only that.
|
|
878
|
+
function mintSessionInternalName() {
|
|
879
|
+
let id;
|
|
880
|
+
do {
|
|
881
|
+
id = 's_' + crypto.randomBytes(5).toString('hex');
|
|
882
|
+
} while (hopSessionExists(id) || hasSavedSessionConfig(id));
|
|
883
|
+
return id;
|
|
884
|
+
}
|
|
885
|
+
|
|
836
886
|
function toDisplaySessionName(internalName) {
|
|
837
887
|
if (!internalName || typeof internalName !== 'string') return '';
|
|
838
888
|
return stripLegacySessionPrefix(internalName);
|
|
@@ -1023,6 +1073,7 @@ let sessionStore = initialWorkspaceState.store;
|
|
|
1023
1073
|
const runtimeSessionMetadata = new Map();
|
|
1024
1074
|
// Folders are organization the user authored; they must be there on boot.
|
|
1025
1075
|
loadFolders();
|
|
1076
|
+
loadPorts();
|
|
1026
1077
|
// internalName -> the display-name claim we last rejected for it, so the
|
|
1027
1078
|
// collision guard reports each rejection once instead of every reconcile.
|
|
1028
1079
|
const reportedNameCollisions = new Map();
|
|
@@ -1142,10 +1193,13 @@ function setSessionConfig(name, config) {
|
|
|
1142
1193
|
const folderId = config?.folderId || null;
|
|
1143
1194
|
ensureSessionOrder(name, folderId);
|
|
1144
1195
|
saveSessionStore();
|
|
1196
|
+
if (config?.type === 'port') savePorts();
|
|
1145
1197
|
}
|
|
1146
1198
|
|
|
1147
1199
|
function deleteSessionConfig(name) {
|
|
1200
|
+
const wasPort = sessionStore.sessions[name]?.type === 'port';
|
|
1148
1201
|
delete sessionStore.sessions[name];
|
|
1202
|
+
if (wasPort) savePorts();
|
|
1149
1203
|
removeSessionFromOrder(name);
|
|
1150
1204
|
saveSessionStore();
|
|
1151
1205
|
// A deleted session should not be resurrected by `hop restore`, so drop its
|
|
@@ -1430,6 +1484,29 @@ function saveFolders() {
|
|
|
1430
1484
|
} catch (e) { /* best effort: folders are organization, not state */ }
|
|
1431
1485
|
}
|
|
1432
1486
|
|
|
1487
|
+
function savePorts() {
|
|
1488
|
+
try {
|
|
1489
|
+
const ports = {};
|
|
1490
|
+
for (const [name, cfg] of Object.entries(sessionStore.sessions)) {
|
|
1491
|
+
if (cfg?.type === 'port') ports[name] = cfg;
|
|
1492
|
+
}
|
|
1493
|
+
writePrivateFile(PORTS_FILE, JSON.stringify({ ports }, null, 1));
|
|
1494
|
+
} catch (e) { /* the in-memory definition still serves this run */ }
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function loadPorts() {
|
|
1498
|
+
try {
|
|
1499
|
+
const raw = JSON.parse(fs.readFileSync(PORTS_FILE, 'utf8'));
|
|
1500
|
+
for (const [name, cfg] of Object.entries(raw.ports || {})) {
|
|
1501
|
+
if (!cfg || cfg.type !== 'port' || !Number.isInteger(cfg.port)) continue;
|
|
1502
|
+
// A workspace-defined port outranks the ledger's copy.
|
|
1503
|
+
if (sessionStore.sessions[name]) continue;
|
|
1504
|
+
sessionStore.sessions[name] = cfg;
|
|
1505
|
+
ensureSessionOrder(name, cfg.folderId || null);
|
|
1506
|
+
}
|
|
1507
|
+
} catch (e) { /* no ports yet */ }
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1433
1510
|
function loadFolders() {
|
|
1434
1511
|
try {
|
|
1435
1512
|
const raw = JSON.parse(fs.readFileSync(FOLDERS_FILE, 'utf8'));
|
|
@@ -5020,21 +5097,40 @@ function claudeConfigRootsForRestore(recConfigDir) {
|
|
|
5020
5097
|
// resume; the caller falls back to a plain shell with a visible warning.
|
|
5021
5098
|
function resolveClaudeTranscriptPath(rec) {
|
|
5022
5099
|
if (!rec || !rec.sessionId || !rec.cwd) return null;
|
|
5100
|
+
const findIn = (base) => {
|
|
5101
|
+
const file = path.join(base, `${rec.sessionId}.jsonl`);
|
|
5102
|
+
try { fs.accessSync(file); return file; } catch (e) { /* next */ }
|
|
5103
|
+
// Newer layout: a per-session DIRECTORY. Only counts when it holds a
|
|
5104
|
+
// real transcript jsonl: a dir with only subagents/ metadata is a
|
|
5105
|
+
// conversation that never persisted (resume would fail on it).
|
|
5106
|
+
try {
|
|
5107
|
+
const dir = path.join(base, rec.sessionId);
|
|
5108
|
+
if (fs.statSync(dir).isDirectory()) {
|
|
5109
|
+
const jsonl = fs.readdirSync(dir).find((f) => f.endsWith('.jsonl'));
|
|
5110
|
+
if (jsonl) return path.join(dir, jsonl);
|
|
5111
|
+
}
|
|
5112
|
+
} catch (e) { /* next */ }
|
|
5113
|
+
return null;
|
|
5114
|
+
};
|
|
5023
5115
|
for (const root of claudeConfigRootsForRestore(rec.configDir)) {
|
|
5024
5116
|
for (const slug of claudeProjectDirCandidates(rec.cwd)) {
|
|
5025
|
-
const
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5117
|
+
const hit = findIn(path.join(root, 'projects', slug));
|
|
5118
|
+
if (hit) return hit;
|
|
5119
|
+
}
|
|
5120
|
+
}
|
|
5121
|
+
// The cwd-derived slug is a HINT, not the identity: the session id is
|
|
5122
|
+
// unique across the whole store, and claude itself resumes by id. A
|
|
5123
|
+
// record whose cwd was healed (Accessibility-fork: conversation written
|
|
5124
|
+
// from inside the transcript dir, so its file lives under a doubly-
|
|
5125
|
+
// encoded slug) would otherwise read as "no transcript on this host"
|
|
5126
|
+
// and restore to a bare shell, when the transcript is right there.
|
|
5127
|
+
for (const root of claudeConfigRootsForRestore(rec.configDir)) {
|
|
5128
|
+
const projects = path.join(root, 'projects');
|
|
5129
|
+
let slugs = [];
|
|
5130
|
+
try { slugs = fs.readdirSync(projects); } catch (e) { continue; }
|
|
5131
|
+
for (const slug of slugs) {
|
|
5132
|
+
const hit = findIn(path.join(projects, slug));
|
|
5133
|
+
if (hit) return hit;
|
|
5038
5134
|
}
|
|
5039
5135
|
}
|
|
5040
5136
|
return null;
|
|
@@ -5216,7 +5312,19 @@ function restorePlanForSession(entry) {
|
|
|
5216
5312
|
// arbiter: when the record disagrees with it, the record is not this
|
|
5217
5313
|
// room's primary conversation. Reopen a plain shell in the RIGHT place
|
|
5218
5314
|
// rather than resume the wrong history.
|
|
5219
|
-
|
|
5315
|
+
// The transcript store is never a workspace (same rule as the hook and
|
|
5316
|
+
// fork): a meta cwd inside ~/.claude*/projects/* means the room's shell
|
|
5317
|
+
// was left standing in the transcript dir at some point (the
|
|
5318
|
+
// Accessibility-fork incident): a poisoned arbiter. Trusting it made
|
|
5319
|
+
// this guard fire against a CORRECT record and drop the room to a plain
|
|
5320
|
+
// shell in the transcript dir, which is how the fork came back as 'zsh
|
|
5321
|
+
// in ~/.claude/projects/-Users-jianzhou' with its conversation gone.
|
|
5322
|
+
const storePath = (c) => /[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(c || ''));
|
|
5323
|
+
const metaCwd = readClaudeSessionMeta(entry.internalName)?.cwd;
|
|
5324
|
+
const liveCwd = (metaCwd && !storePath(metaCwd) ? metaCwd : null)
|
|
5325
|
+
|| (entry.cwd && !storePath(entry.cwd) ? entry.cwd : null)
|
|
5326
|
+
|| (rec?.cwd && !storePath(rec.cwd) ? rec.cwd : null)
|
|
5327
|
+
|| metaCwd || entry.cwd;
|
|
5220
5328
|
if (rec && rec.sessionId && liveCwd && rec.cwd && rec.cwd !== liveCwd) {
|
|
5221
5329
|
// The incumbent record disagrees with the room's real directory. The
|
|
5222
5330
|
// hook parks a rival record at <session>.other.json when it refuses
|
|
@@ -8145,7 +8253,11 @@ async function reconcileExternalRuntimeSessionsUncached() {
|
|
|
8145
8253
|
// workspaces is a different tree entirely. That is how a session
|
|
8146
8254
|
// working in ~/Code/nebula-slides came back in the slides tool's
|
|
8147
8255
|
// agent-home workspace and read as "my session died".
|
|
8148
|
-
|
|
8256
|
+
// ...but never persist a shell that wandered INTO the transcript
|
|
8257
|
+
// store (~/.claude*/projects/*): that is not a workspace, and a meta
|
|
8258
|
+
// holding it poisons restore's cwd arbiter (Accessibility-fork).
|
|
8259
|
+
const roomInStore = /[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(room.cwd || ''));
|
|
8260
|
+
if (room.cwd && typeof room.cwd === 'string' && durableMeta && durableMeta.cwd !== room.cwd && !roomInStore) {
|
|
8149
8261
|
writeClaudeSessionMeta(room.id, { ...durableMeta, internalName: room.id, cwd: room.cwd });
|
|
8150
8262
|
}
|
|
8151
8263
|
rememberExternalRuntimeSession(room.id, room.hostPort);
|
|
@@ -9541,8 +9653,11 @@ happens until someone clicks it.
|
|
|
9541
9653
|
return;
|
|
9542
9654
|
}
|
|
9543
9655
|
const meta = readViewMeta(sess);
|
|
9544
|
-
|
|
9545
|
-
|
|
9656
|
+
// A LIVE server row is not a file to delete — it dies with its
|
|
9657
|
+
// server. A DEAD one (session gone, backend forgotten) is exactly
|
|
9658
|
+
// the stale entry a human wants to clear, so it goes.
|
|
9659
|
+
if (meta[name]?.kind === 'server' && portSessionIsKnown(meta[name]?.target)) {
|
|
9660
|
+
respond(400, { ok: false, error: 'This server is still running — its row disappears when it stops' });
|
|
9546
9661
|
return;
|
|
9547
9662
|
}
|
|
9548
9663
|
try {
|
|
@@ -10417,36 +10532,34 @@ happens until someone clicks it.
|
|
|
10417
10532
|
const actorCreatedBy = resolveActor(req) === 'agent' ? 'agent' : 'user';
|
|
10418
10533
|
const createdVia = resolveVia(req) || (actorCreatedBy === 'agent' ? 'agent-api' : 'web');
|
|
10419
10534
|
const displayName = name;
|
|
10420
|
-
|
|
10421
|
-
if (!internalName || !displayName) {
|
|
10535
|
+
if (!displayName || !toInternalSessionName(displayName)) {
|
|
10422
10536
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
10423
10537
|
res.end(JSON.stringify({ error: 'Invalid session name' }));
|
|
10424
10538
|
return;
|
|
10425
10539
|
}
|
|
10426
|
-
// Durable origin marker, mirroring the terminal-API path:
|
|
10427
|
-
// runtime config dies with the daemon, so without this an
|
|
10428
|
-
// agent session reads as human after the next restart.
|
|
10429
|
-
if (actorCreatedBy === 'agent') {
|
|
10430
|
-
const durableOrigin = readClaudeSessionMeta(internalName) || {};
|
|
10431
|
-
writeClaudeSessionMeta(internalName, {
|
|
10432
|
-
...durableOrigin,
|
|
10433
|
-
internalName,
|
|
10434
|
-
createdBy: 'agent',
|
|
10435
|
-
createdVia,
|
|
10436
|
-
createdAt: durableOrigin.createdAt || new Date().toISOString()
|
|
10437
|
-
});
|
|
10438
|
-
}
|
|
10439
|
-
|
|
10440
10540
|
if (isDisplayNameInUse(displayName)) {
|
|
10441
10541
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
10442
10542
|
res.end(JSON.stringify({ error: 'Session name already in use' }));
|
|
10443
10543
|
return;
|
|
10444
10544
|
}
|
|
10445
|
-
|
|
10446
|
-
|
|
10447
|
-
|
|
10448
|
-
|
|
10449
|
-
|
|
10545
|
+
// Identity is MINTED, not derived: reusing a display name
|
|
10546
|
+
// that some renamed-away session was BORN with used to
|
|
10547
|
+
// collide with that session's internal id.
|
|
10548
|
+
const internalName = mintSessionInternalName();
|
|
10549
|
+
// Durable meta is the naming AUTHORITY (docs/
|
|
10550
|
+
// session-identity.md), and with minted ids it is the
|
|
10551
|
+
// only place the display name survives a daemon restart:
|
|
10552
|
+
// runtime config dies with the process, and the id can no
|
|
10553
|
+
// longer stand in for the name. Written for EVERY create,
|
|
10554
|
+
// after the refusals so a 409 writes nothing.
|
|
10555
|
+
writeClaudeSessionMeta(internalName, {
|
|
10556
|
+
internalName,
|
|
10557
|
+
displayName,
|
|
10558
|
+
createdBy: actorCreatedBy,
|
|
10559
|
+
createdVia,
|
|
10560
|
+
folderId: folderId || undefined,
|
|
10561
|
+
createdAt: new Date().toISOString()
|
|
10562
|
+
});
|
|
10450
10563
|
type = (type || 'terminal').toLowerCase();
|
|
10451
10564
|
if (type !== 'terminal' && type !== 'port') {
|
|
10452
10565
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -10477,7 +10590,15 @@ happens until someone clicks it.
|
|
|
10477
10590
|
res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
|
|
10478
10591
|
return;
|
|
10479
10592
|
}
|
|
10480
|
-
|
|
10593
|
+
// NOT getEffectiveSessionConfig: that synthesizes
|
|
10594
|
+
// type:'terminal' from durable meta when nothing else
|
|
10595
|
+
// exists — and this create already wrote its own meta
|
|
10596
|
+
// above, so a brand-new port session saw ITSELF and
|
|
10597
|
+
// refused with "Terminal session already exists".
|
|
10598
|
+
// Every `hop port` with a fresh name failed this way.
|
|
10599
|
+
// Only a saved or runtime TERMINAL config is a real
|
|
10600
|
+
// collision.
|
|
10601
|
+
const existingConfig = getSessionConfig(internalName) || getRuntimeSessionMetadata(internalName);
|
|
10481
10602
|
if (existingConfig && existingConfig.type === 'terminal') {
|
|
10482
10603
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
10483
10604
|
res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
|
|
@@ -10548,7 +10669,7 @@ happens until someone clicks it.
|
|
|
10548
10669
|
}
|
|
10549
10670
|
}
|
|
10550
10671
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
10551
|
-
res.end(JSON.stringify({ ok: true, name: displayName, type }));
|
|
10672
|
+
res.end(JSON.stringify({ ok: true, name: displayName, displayName, internalName, type }));
|
|
10552
10673
|
} catch (e) {
|
|
10553
10674
|
const status = Number.isInteger(e?.status) ? e.status : 500;
|
|
10554
10675
|
const message = e?.message || 'Failed to create session';
|
|
@@ -10972,7 +11093,9 @@ happens until someone clicks it.
|
|
|
10972
11093
|
res.end(JSON.stringify({ error: 'Session name already in use' }));
|
|
10973
11094
|
return;
|
|
10974
11095
|
}
|
|
10975
|
-
|
|
11096
|
+
// Minted, not derived from the display name (see
|
|
11097
|
+
// mintSessionInternalName): renames stay label-only.
|
|
11098
|
+
const internalName = mintSessionInternalName();
|
|
10976
11099
|
if (!internalName) {
|
|
10977
11100
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
10978
11101
|
res.end(JSON.stringify({ error: 'Invalid session name' }));
|
|
@@ -11023,12 +11146,19 @@ happens until someone clicks it.
|
|
|
11023
11146
|
folderId,
|
|
11024
11147
|
workspaceName: targetWorkspaceName || null
|
|
11025
11148
|
});
|
|
11026
|
-
// Durable
|
|
11027
|
-
//
|
|
11028
|
-
//
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11149
|
+
// Durable meta for EVERY create (naming authority; see
|
|
11150
|
+
// docs/session-identity.md): with minted ids the display
|
|
11151
|
+
// name survives restarts nowhere else. createdBy rides
|
|
11152
|
+
// along so `hop restore` keeps agent workers out of the
|
|
11153
|
+
// default restore set, as before.
|
|
11154
|
+
writeClaudeSessionMeta(internalName, {
|
|
11155
|
+
internalName,
|
|
11156
|
+
displayName,
|
|
11157
|
+
createdBy: actor,
|
|
11158
|
+
createdVia: resolveVia(req),
|
|
11159
|
+
folderId: folderId || undefined,
|
|
11160
|
+
createdAt: new Date().toISOString()
|
|
11161
|
+
});
|
|
11032
11162
|
|
|
11033
11163
|
const entry = await attachTerminalApiClient(internalName, displayName, {
|
|
11034
11164
|
actor,
|
package/package.json
CHANGED
package/scripts/hay-host.js
CHANGED
|
@@ -54,6 +54,31 @@ const FALLBACK_CWD = process.env.HAY_HOST_CWD || process.cwd();
|
|
|
54
54
|
// Where we stash each room's tail buffer on shutdown so `hop restore` can replay
|
|
55
55
|
// a plain-shell session's last screen. Mirrors the daemon's HOP_HOME (~/.hop2).
|
|
56
56
|
const HOP_HOME = process.env.HOP_HOME || path.join(os.homedir(), '.hop2');
|
|
57
|
+
|
|
58
|
+
// EPHEMERAL homes die with their daemon. Integration tests spawn a daemon in
|
|
59
|
+
// a mkdtemp HOP_HOME, which spawns this host — and a test run killed
|
|
60
|
+
// mid-flight (a timeout, a crash) leaked the host forever: 18 of them were
|
|
61
|
+
// found idling with 242MB after a week of test runs. A host under the OS
|
|
62
|
+
// temp dir polls its daemon's pid from .tunnel-state and exits once it has
|
|
63
|
+
// been gone for two checks. A REAL home never does this: the production
|
|
64
|
+
// host deliberately outlives daemon restarts — that is what keeps sessions
|
|
65
|
+
// alive through deploys.
|
|
66
|
+
if (HOP_HOME.startsWith(os.tmpdir())) {
|
|
67
|
+
let daemonMissing = 0;
|
|
68
|
+
setInterval(() => {
|
|
69
|
+
let pid = null;
|
|
70
|
+
try { pid = Number(JSON.parse(fs.readFileSync(path.join(HOP_HOME, '.tunnel-state'), 'utf8')).pid); } catch (e) { /* not written yet */ }
|
|
71
|
+
let alive = false;
|
|
72
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
73
|
+
try { process.kill(pid, 0); alive = true; } catch (e) { /* dead */ }
|
|
74
|
+
}
|
|
75
|
+
daemonMissing = alive ? 0 : daemonMissing + 1;
|
|
76
|
+
if (daemonMissing >= 2) {
|
|
77
|
+
console.error('[hay-host] ephemeral home and the daemon is gone — exiting');
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
}, 30000).unref();
|
|
81
|
+
}
|
|
57
82
|
const BUFFER_DIR = path.join(HOP_HOME, 'session-buffers');
|
|
58
83
|
const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
|
|
59
84
|
|
|
@@ -382,7 +407,13 @@ async function main() {
|
|
|
382
407
|
const room = watchRoomEnd(rooms.getRoom(roomId, { cols, rows }, existingCwdOr(cwd, FALLBACK_CWD)));
|
|
383
408
|
if (Date.now() - __t0 > 100) console.log(`[hay-host] slow room create (ws) room=${roomId} ${Date.now() - __t0}ms`);
|
|
384
409
|
|
|
385
|
-
|
|
410
|
+
// ABSENT is not ZERO: Number(null) === 0, and 0 is the explicit
|
|
411
|
+
// "no snapshot" contract for claim sockets — so every attach that
|
|
412
|
+
// simply omitted replay= (salvage, CLI reattach) silently got no
|
|
413
|
+
// screen: the 2026-08-17 host cycle salvaged 23 rooms and captured
|
|
414
|
+
// nothing, each waiting out a 20s timeout (the 8-minute freeze).
|
|
415
|
+
const replayParam = wsUrl.searchParams.get('replay');
|
|
416
|
+
const replayRaw = replayParam === null || replayParam === '' ? NaN : Number(replayParam);
|
|
386
417
|
// The viewer's actual terminal colors (hex, no '#'). TUI apps pick a
|
|
387
418
|
// light or dark theme from the background they're told — so a room
|
|
388
419
|
// must report the background the USER is looking at, not a guess.
|
|
@@ -50,7 +50,12 @@ const stripAnsi = (raw) => String(raw)
|
|
|
50
50
|
|
|
51
51
|
// Capture one room's snapshot (the ~1.5MB replay tail) over WS.
|
|
52
52
|
const salvageRoom = (port, id) => new Promise((resolve) => {
|
|
53
|
-
|
|
53
|
+
// Explicit replay: an ABSENT param read as 0 ("no snapshot") on the host
|
|
54
|
+
// (Number(null)===0), and every room timed out. source=monitor + nudge=0:
|
|
55
|
+
// salvage is a spectator — no equal-size wiggle SIGWINCHed into 23 running
|
|
56
|
+
// claudes, no presence stamp, no seen-witness. Raw tail (above the
|
|
57
|
+
// serialized ceiling) so the salvaged text is scrollback, not one screen.
|
|
58
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=${encodeURIComponent(id)}&name=salvage&source=monitor&replay=1572864&nudge=0&cols=200&rows=50`);
|
|
54
59
|
const timer = setTimeout(() => { try { ws.close(); } catch (e) {} resolve('timeout'); }, 20000);
|
|
55
60
|
ws.on('message', (data) => {
|
|
56
61
|
try {
|