hop2 0.11.20 → 0.11.21
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/web/src/components/SessionSwitcher.tsx +35 -8
- package/hay/apps/web/src/utils/switcherModel.ts +7 -1
- package/hay/apps/web/test/SessionSwitcher.test.tsx +33 -0
- package/hay/apps/web/test/switcherModel.test.ts +25 -0
- package/hay-web/assets/index-CIfGRbUF.js +134 -0
- package/hay-web/assets/index-CIfGRbUF.js.map +1 -0
- package/hay-web/assets/index-DjSB7l7z.js +134 -0
- package/hay-web/assets/index-DjSB7l7z.js.map +1 -0
- package/hay-web/index.html +1 -1
- package/hop +193 -4
- package/hopa +2 -0
- package/mcp/hop-mcp.js +31 -0
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +25 -1
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-DjSB7l7z.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-B8TPcotl.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/hop
CHANGED
|
@@ -500,6 +500,45 @@ function viewSummaryBySession() {
|
|
|
500
500
|
return viewSummaryCache.bySession;
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
// The log's budget. Jian's design: views are "a size-capped recent log" —
|
|
504
|
+
// one kind of entry, one removal gesture, and the cap does the housekeeping
|
|
505
|
+
// nobody wants to do by hand. Oldest FILE views evict first once the fleet's
|
|
506
|
+
// published bytes exceed the budget; live-server rows weigh nothing and are
|
|
507
|
+
// never evicted. Copies only, as always — sources are not hop's to touch.
|
|
508
|
+
const VIEWS_BUDGET_BYTES = (Number(process.env.HOP_VIEWS_BUDGET_MB) > 0
|
|
509
|
+
? Number(process.env.HOP_VIEWS_BUDGET_MB) : 2048) * 1024 * 1024;
|
|
510
|
+
function enforceViewBudget() {
|
|
511
|
+
try {
|
|
512
|
+
const files = [];
|
|
513
|
+
for (const sess of fs.readdirSync(VIEWS_DIR)) {
|
|
514
|
+
const dir = path.join(VIEWS_DIR, sess);
|
|
515
|
+
if (sess === 'manifest.json' || sess.startsWith('.')
|
|
516
|
+
|| !fs.statSync(dir).isDirectory()) continue;
|
|
517
|
+
const meta = readViewMeta(sess);
|
|
518
|
+
for (const f of fs.readdirSync(dir)) {
|
|
519
|
+
if (f.startsWith('.')) continue;
|
|
520
|
+
if (meta[f]?.kind === 'server') continue;
|
|
521
|
+
const st = fs.statSync(path.join(dir, f));
|
|
522
|
+
if (st.isFile()) files.push({ sess, f, bytes: st.size, mtime: st.mtimeMs });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
let total = files.reduce((a, b) => a + b.bytes, 0);
|
|
526
|
+
if (total <= VIEWS_BUDGET_BYTES) return;
|
|
527
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
528
|
+
for (const it of files) {
|
|
529
|
+
if (total <= VIEWS_BUDGET_BYTES) break;
|
|
530
|
+
try {
|
|
531
|
+
fs.rmSync(path.join(VIEWS_DIR, it.sess, it.f));
|
|
532
|
+
const meta = readViewMeta(it.sess);
|
|
533
|
+
delete meta[it.f];
|
|
534
|
+
writeViewMeta(it.sess, meta);
|
|
535
|
+
total -= it.bytes;
|
|
536
|
+
log(`[views] budget: evicted ${it.sess}/${it.f} (${Math.round(it.bytes / 1024)} KB, oldest first)`, DIM);
|
|
537
|
+
} catch (e) { /* the next candidate still evicts */ }
|
|
538
|
+
}
|
|
539
|
+
} catch (e) { /* a failed sweep never blocks a publish */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
503
542
|
// The virtual folder both `hop view` and `hop port --attached` publish into:
|
|
504
543
|
// clients browse every view through one manifest, REBUILT from a disk scan
|
|
505
544
|
// rather than read-modify-written, so two agents publishing at once cannot
|
|
@@ -514,6 +553,7 @@ function viewSummaryBySession() {
|
|
|
514
553
|
function rebuildViewManifest() {
|
|
515
554
|
try {
|
|
516
555
|
migrateLegacyViews();
|
|
556
|
+
enforceViewBudget();
|
|
517
557
|
const entries = [];
|
|
518
558
|
for (const sess of fs.readdirSync(VIEWS_DIR)) {
|
|
519
559
|
const dir = path.join(VIEWS_DIR, sess);
|
|
@@ -4626,6 +4666,7 @@ async function attachExternalTerminalApiClient(sessionName, displayName, opts =
|
|
|
4626
4666
|
startup: opts.startup,
|
|
4627
4667
|
autoStart: opts.autoStart,
|
|
4628
4668
|
createdBy: opts.createdBy,
|
|
4669
|
+
createdVia: opts.createdVia,
|
|
4629
4670
|
agentPermitted: opts.agentPermitted,
|
|
4630
4671
|
workspaceName: opts.workspaceName,
|
|
4631
4672
|
folderId: opts.folderId,
|
|
@@ -5749,6 +5790,53 @@ function pruneSessionAliasesForSession(internalName, displayName) {
|
|
|
5749
5790
|
}
|
|
5750
5791
|
}
|
|
5751
5792
|
|
|
5793
|
+
// A dev server proxied under /s/<name>/ usually emits ROOT-ABSOLUTE URLs —
|
|
5794
|
+
// /@vite/client, /src/main.tsx, /api/... — which the browser resolves against
|
|
5795
|
+
// the tunnel origin, so they arrive with the /s/<name>/ prefix gone, fall
|
|
5796
|
+
// through to the hub shell (or the unknown-API 404), and the port page renders
|
|
5797
|
+
// blank. The Referer still names the session the page came from, so a request
|
|
5798
|
+
// that matched no hop route is sent back to the referring PORT session's
|
|
5799
|
+
// server. Top-level navigations are excluded — following a link out of a port
|
|
5800
|
+
// page must still land on hop, not be swallowed by the proxy. WebSocket
|
|
5801
|
+
// upgrades never carry a path-bearing Referer, so a dev server's HMR socket
|
|
5802
|
+
// stays unrescued: the page renders, and vite quietly retries its socket.
|
|
5803
|
+
function rescuePortReferral(req, res) {
|
|
5804
|
+
const referer = req.headers.referer || req.headers.referrer || '';
|
|
5805
|
+
const match = /\/s\/([^/?#]+)\//.exec(String(referer));
|
|
5806
|
+
if (!match) return false;
|
|
5807
|
+
let name;
|
|
5808
|
+
try { name = decodeURIComponent(match[1]); } catch { return false; }
|
|
5809
|
+
const config = getEffectiveSessionConfig(name);
|
|
5810
|
+
if (!config || config.type !== 'port' || !config.port) return false;
|
|
5811
|
+
const secFetchMode = String(req.headers['sec-fetch-mode'] || '');
|
|
5812
|
+
const isNavigation = secFetchMode === 'navigate' ||
|
|
5813
|
+
(!secFetchMode && String(req.headers.accept || '').includes('text/html'));
|
|
5814
|
+
if (isNavigation) {
|
|
5815
|
+
// A link INSIDE a proxied app is also root-absolute (/admin/growth),
|
|
5816
|
+
// so following it would land on the hub shell. Rescue navigations too
|
|
5817
|
+
// — but never off hop's own surfaces, so leaving a port page for the
|
|
5818
|
+
// hub keeps working: the landing page and hop-owned prefixes stay
|
|
5819
|
+
// hop's, everything else follows the referring app.
|
|
5820
|
+
const p = url.parse(req.url).pathname || '/';
|
|
5821
|
+
const hubOwned = p === '/' || p === '/sessions' ||
|
|
5822
|
+
['/assets/', '/view/', '/api/', '/s/', '/ws', '/health', '/oauth', '/media/'].some((prefix) => p.startsWith(prefix));
|
|
5823
|
+
if (hubOwned) return false;
|
|
5824
|
+
}
|
|
5825
|
+
waitForPort(config.port).then((ready) => {
|
|
5826
|
+
if (!ready) {
|
|
5827
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
5828
|
+
res.end(`Port session "${name}" is not answering on 127.0.0.1:${config.port}.`);
|
|
5829
|
+
return;
|
|
5830
|
+
}
|
|
5831
|
+
const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${config.port}`, ws: false });
|
|
5832
|
+
proxy.web(req, res, {}, () => {
|
|
5833
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
5834
|
+
res.end(`Port session "${name}" dropped the connection.`);
|
|
5835
|
+
});
|
|
5836
|
+
});
|
|
5837
|
+
return true;
|
|
5838
|
+
}
|
|
5839
|
+
|
|
5752
5840
|
function getSessionSwitcherOverlay() {
|
|
5753
5841
|
return `
|
|
5754
5842
|
<style id="hop-session-switcher-style">
|
|
@@ -9324,7 +9412,7 @@ happens until someone clicks it.
|
|
|
9324
9412
|
// off the end of the path; content-type still comes from the real
|
|
9325
9413
|
// file name. The legacy /assets/view/<s>/<f> form redirects here, so
|
|
9326
9414
|
// an edge-cached response is at worst a redirect, never the bytes.
|
|
9327
|
-
const viewMatch = req.method === 'GET'
|
|
9415
|
+
const viewMatch = (req.method === 'GET' || req.method === 'HEAD')
|
|
9328
9416
|
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
|
|
9329
9417
|
if (viewMatch) {
|
|
9330
9418
|
const viewRoot = path.resolve(VIEWS_DIR);
|
|
@@ -9364,10 +9452,48 @@ happens until someone clicks it.
|
|
|
9364
9452
|
res.end(html);
|
|
9365
9453
|
return;
|
|
9366
9454
|
}
|
|
9455
|
+
// Streamed, with Range support. iOS media playback (AVPlayer, and
|
|
9456
|
+
// the player inside WKWebView) opens a video by asking for
|
|
9457
|
+
// `Range: bytes=0-1` and refuses to play at all when the answer
|
|
9458
|
+
// is a 200 with the whole body — which is exactly what
|
|
9459
|
+
// readFileSync produced, and why published .mp4 views spun
|
|
9460
|
+
// forever on the phone. Range also means a 30 MB video no longer
|
|
9461
|
+
// has to fit in the daemon's heap per request.
|
|
9462
|
+
const viewType = STATIC_CONTENT_TYPES[viewExt] || 'application/octet-stream';
|
|
9463
|
+
const viewSize = fs.statSync(target).size;
|
|
9464
|
+
const rangeHeader = String(req.headers.range || '');
|
|
9465
|
+
const rangeMatch = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
9466
|
+
if (rangeMatch && (rangeMatch[1] || rangeMatch[2])) {
|
|
9467
|
+
let start = rangeMatch[1] ? parseInt(rangeMatch[1], 10) : NaN;
|
|
9468
|
+
let end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : NaN;
|
|
9469
|
+
if (Number.isNaN(start)) { // suffix form: bytes=-N
|
|
9470
|
+
start = Math.max(0, viewSize - end);
|
|
9471
|
+
end = viewSize - 1;
|
|
9472
|
+
} else if (Number.isNaN(end) || end >= viewSize) {
|
|
9473
|
+
end = viewSize - 1;
|
|
9474
|
+
}
|
|
9475
|
+
if (start > end || start >= viewSize) {
|
|
9476
|
+
res.writeHead(416, { 'Content-Range': `bytes */${viewSize}` });
|
|
9477
|
+
res.end();
|
|
9478
|
+
return;
|
|
9479
|
+
}
|
|
9480
|
+
res.writeHead(206, {
|
|
9481
|
+
'Content-Type': viewType,
|
|
9482
|
+
'Content-Length': end - start + 1,
|
|
9483
|
+
'Content-Range': `bytes ${start}-${end}/${viewSize}`,
|
|
9484
|
+
'Accept-Ranges': 'bytes'
|
|
9485
|
+
});
|
|
9486
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
9487
|
+
fs.createReadStream(target, { start, end }).pipe(res);
|
|
9488
|
+
return;
|
|
9489
|
+
}
|
|
9367
9490
|
res.writeHead(200, {
|
|
9368
|
-
'Content-Type':
|
|
9491
|
+
'Content-Type': viewType,
|
|
9492
|
+
'Content-Length': viewSize,
|
|
9493
|
+
'Accept-Ranges': 'bytes'
|
|
9369
9494
|
});
|
|
9370
|
-
res.end(
|
|
9495
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
9496
|
+
fs.createReadStream(target).pipe(res);
|
|
9371
9497
|
return;
|
|
9372
9498
|
}
|
|
9373
9499
|
// The index. /assets/view/manifest.json is the URL every SHIPPED iOS
|
|
@@ -10915,6 +11041,10 @@ happens until someone clicks it.
|
|
|
10915
11041
|
startup,
|
|
10916
11042
|
autoStart,
|
|
10917
11043
|
createdBy: actor,
|
|
11044
|
+
// How it arrived (mcp/hopa): the mybot hunt dead-
|
|
11045
|
+
// ended at createdVia null because nothing here ever
|
|
11046
|
+
// recorded the channel.
|
|
11047
|
+
createdVia: resolveVia(req),
|
|
10918
11048
|
agentPermitted: actor === 'agent',
|
|
10919
11049
|
folderId,
|
|
10920
11050
|
workspaceName: targetWorkspaceName || null,
|
|
@@ -11326,6 +11456,11 @@ happens until someone clicks it.
|
|
|
11326
11456
|
// returned 200 HTML, which callers (CLI, agents) misread as success
|
|
11327
11457
|
// when talking to a daemon older than the endpoint they called.
|
|
11328
11458
|
if (parsedUrl.pathname.startsWith('/api/')) {
|
|
11459
|
+
// An /api/ path no hop handler claimed may belong to the app
|
|
11460
|
+
// behind a PORT session — its page calls fetch('/api/...') with
|
|
11461
|
+
// the session prefix gone. hop's own endpoints all matched above,
|
|
11462
|
+
// so they keep priority over this rescue.
|
|
11463
|
+
if (rescuePortReferral(req, res)) return;
|
|
11329
11464
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
11330
11465
|
res.end(JSON.stringify({ error: `Unknown API endpoint: ${parsedUrl.pathname} (daemon may need a restart to pick up new endpoints)` }));
|
|
11331
11466
|
return;
|
|
@@ -11339,6 +11474,11 @@ happens until someone clicks it.
|
|
|
11339
11474
|
return;
|
|
11340
11475
|
}
|
|
11341
11476
|
if (!sessionName) {
|
|
11477
|
+
// Before the hub shell answers: a subresource request referred by
|
|
11478
|
+
// a /s/<name>/ PORT page belongs to that page's server, not to the
|
|
11479
|
+
// hub — this is what keeps an absolute-path dev server (vite and
|
|
11480
|
+
// friends) from rendering a blank page through the proxy.
|
|
11481
|
+
if (rescuePortReferral(req, res)) return;
|
|
11342
11482
|
// Landing (every device): the app shell in hub mode — the session
|
|
11343
11483
|
// grid as the page, connected to NOTHING. Opening a session is the
|
|
11344
11484
|
// user's click, not the server's guess.
|
|
@@ -13643,7 +13783,7 @@ async function attachToAllSessions(existingTunnel) {
|
|
|
13643
13783
|
const KNOWN_COMMANDS = [
|
|
13644
13784
|
'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
|
|
13645
13785
|
'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
|
|
13646
|
-
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'new',
|
|
13786
|
+
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
|
|
13647
13787
|
'mail', 'registration', 'canonical'
|
|
13648
13788
|
];
|
|
13649
13789
|
|
|
@@ -13784,6 +13924,8 @@ const HELP_SECTIONS = [
|
|
|
13784
13924
|
`hop view --list [--all] What this session has published (--all: the whole fleet)`,
|
|
13785
13925
|
`hop view --rm <name> Unpublish one`,
|
|
13786
13926
|
`hop view --clear [--all] Clear published copies — frees hop's space, never touches source files`,
|
|
13927
|
+
` Views auto-evict oldest past the budget (HOP_VIEWS_BUDGET_MB, default 2048)`,
|
|
13928
|
+
`hop whoami [--json] Which session this shell is inside — the name hop view targets`,
|
|
13787
13929
|
`hop port <port> [name] Expose a local web server (live proxy, WebSocket-safe); --standalone to detach`,
|
|
13788
13930
|
] },
|
|
13789
13931
|
{ title: 'Maintenance', lines: [
|
|
@@ -14790,6 +14932,38 @@ async function main() {
|
|
|
14790
14932
|
// notification. For agents, via the hop-session skill: use it only when
|
|
14791
14933
|
// blocked and waiting would waste the human's time (a decision, a dead
|
|
14792
14934
|
// VPN, credentials) — never for routine completions.
|
|
14935
|
+
// hop whoami — which session is this shell inside? The answer an agent
|
|
14936
|
+
// needs before hop view can target anything: HOP_SESSION carries the
|
|
14937
|
+
// INTERNAL name, invisible in any UI once the session is renamed, so
|
|
14938
|
+
// this resolves the display name humans actually see. --json for agents.
|
|
14939
|
+
if (process.argv[2] === 'whoami') {
|
|
14940
|
+
const internal = process.env.HOP_SESSION || '';
|
|
14941
|
+
const asJson = process.argv.includes('--json');
|
|
14942
|
+
if (!internal) {
|
|
14943
|
+
if (asJson) { console.log(JSON.stringify({ insideHopSession: false })); process.exit(1); }
|
|
14944
|
+
log('Not inside a hop session (HOP_SESSION is unset).', YELLOW);
|
|
14945
|
+
log(`Publishing from here needs an explicit target: ${CYAN}hop view --session <name> …${RESET}`, DIM);
|
|
14946
|
+
process.exit(1);
|
|
14947
|
+
}
|
|
14948
|
+
let display = null;
|
|
14949
|
+
const state = isServerRunning();
|
|
14950
|
+
if (state) {
|
|
14951
|
+
try {
|
|
14952
|
+
const res = await fetchSessionList(state);
|
|
14953
|
+
const hit = (res?.sessions || []).find((x) => (x.internalName || x.name) === internal);
|
|
14954
|
+
if (hit) display = hit.displayName || hit.name || null;
|
|
14955
|
+
} catch (e) { /* the env fact still stands */ }
|
|
14956
|
+
}
|
|
14957
|
+
if (asJson) {
|
|
14958
|
+
console.log(JSON.stringify({ insideHopSession: true, internalName: internal, displayName: display }));
|
|
14959
|
+
process.exit(0);
|
|
14960
|
+
}
|
|
14961
|
+
const renamed = display && display !== internal;
|
|
14962
|
+
log(`${BOLD}${display || internal}${RESET}${renamed ? ` ${DIM}(internal: ${internal})${RESET}` : ''}`, GREEN);
|
|
14963
|
+
log(`hop view targets this session automatically; from a scrubbed subprocess, pass ${CYAN}--session ${internal}${RESET}.`, DIM);
|
|
14964
|
+
process.exit(0);
|
|
14965
|
+
}
|
|
14966
|
+
|
|
14793
14967
|
if (process.argv[2] === 'notify') {
|
|
14794
14968
|
const msg = process.argv.slice(3).join(' ').trim();
|
|
14795
14969
|
if (!msg) {
|
|
@@ -14931,6 +15105,21 @@ async function main() {
|
|
|
14931
15105
|
console.log(`View: ${state.url}/view/${encodeURIComponent(session)}/${encodeURIComponent(name)}/inline`);
|
|
14932
15106
|
}
|
|
14933
15107
|
if (published.length) writeViewMeta(session, meta);
|
|
15108
|
+
if (published.length) {
|
|
15109
|
+
// Always say WHICH session this acted on, and how that relates
|
|
15110
|
+
// to the terminal it ran in. An agent carried a --session target
|
|
15111
|
+
// three requests past its purpose because nothing in the output
|
|
15112
|
+
// ever said "not the session you are in" — the human asked where
|
|
15113
|
+
// their files were, and they were in the other room.
|
|
15114
|
+
const here = process.env.HOP_SESSION || '';
|
|
15115
|
+
if (here && here === session) {
|
|
15116
|
+
log(`Session: ${session} (this terminal)`, DIM);
|
|
15117
|
+
} else if (here) {
|
|
15118
|
+
log(`Session: ${session} — NOT this terminal (you are in "${here}")`, YELLOW);
|
|
15119
|
+
} else {
|
|
15120
|
+
log(`Session: ${session} (targeted explicitly; not running inside a session)`, DIM);
|
|
15121
|
+
}
|
|
15122
|
+
}
|
|
14934
15123
|
if (failed === 0 && files.length > 0) {
|
|
14935
15124
|
// One bell: the view is usually the deliverable the human is
|
|
14936
15125
|
// waiting on, and the link scrolls away if nothing pings them.
|
package/hopa
CHANGED
|
@@ -47,6 +47,7 @@ const VERBS = {
|
|
|
47
47
|
'agents': { tool: 'hopx_agents_overview', pos: [] },
|
|
48
48
|
'sessions': { tool: 'hop_list_sessions', pos: [] },
|
|
49
49
|
'info': { tool: 'hop_server_info', pos: [] },
|
|
50
|
+
'whoami': { tool: 'hop_current_session', pos: [] },
|
|
50
51
|
'connect': { tool: 'connect_server', pos: ['base_url'] },
|
|
51
52
|
|
|
52
53
|
'term ls': { tool: 'hop_list_terminals', pos: [] },
|
|
@@ -86,6 +87,7 @@ const USAGE = `hopa — agent-facing hop CLI (terminal orchestration; no admin v
|
|
|
86
87
|
|
|
87
88
|
hopa live overview of agent terminals (same as: hopa agents)
|
|
88
89
|
hopa sessions | info | ledger list sessions / server info / task ledger
|
|
90
|
+
hopa whoami which hop session this process runs inside (for hop view --session)
|
|
89
91
|
|
|
90
92
|
hopa term ls list terminals
|
|
91
93
|
hopa term new [name] [--cwd P] create a terminal
|
package/mcp/hop-mcp.js
CHANGED
|
@@ -3148,6 +3148,11 @@ class HopMCPServer {
|
|
|
3148
3148
|
description: 'Return hop-mcp runtime diagnostics (version, script path, read-mode capabilities).',
|
|
3149
3149
|
inputSchema: { type: 'object', properties: {} }
|
|
3150
3150
|
},
|
|
3151
|
+
{
|
|
3152
|
+
name: 'hop_current_session',
|
|
3153
|
+
description: 'Which hop session THIS process runs inside — from the HOP_SESSION environment the terminal set, resolved to the display name humans see. This is the name to pass to hop view --session when publishing results, and the honest answer to "where am I?". Absent fields mean the process is not inside a hop terminal.',
|
|
3154
|
+
inputSchema: { type: 'object', properties: {} }
|
|
3155
|
+
},
|
|
3151
3156
|
{
|
|
3152
3157
|
name: 'hop_list_sessions',
|
|
3153
3158
|
description: 'List Hop sessions and metadata.',
|
|
@@ -3683,6 +3688,32 @@ class HopMCPServer {
|
|
|
3683
3688
|
return this.wrapJson(this.getServerInfoPayload());
|
|
3684
3689
|
}
|
|
3685
3690
|
|
|
3691
|
+
if (name === 'hop_current_session') {
|
|
3692
|
+
// Answerable WITHOUT a daemon: the env fact stands on its own, and an
|
|
3693
|
+
// agent asking "where am I" mid-outage still deserves the truth.
|
|
3694
|
+
const internal = process.env.HOP_SESSION || null;
|
|
3695
|
+
const payload = {
|
|
3696
|
+
insideHopSession: !!internal,
|
|
3697
|
+
internalName: internal,
|
|
3698
|
+
displayName: null,
|
|
3699
|
+
note: internal
|
|
3700
|
+
? 'Use these names for hop view --session and anywhere a session is addressed.'
|
|
3701
|
+
: 'Not inside a hop terminal (HOP_SESSION unset). Publishing needs an explicit target: hop view --session <name>; hop_list_sessions names the candidates.'
|
|
3702
|
+
};
|
|
3703
|
+
if (internal) {
|
|
3704
|
+
try {
|
|
3705
|
+
this.refreshStateFileConnection();
|
|
3706
|
+
this.ensureConnection();
|
|
3707
|
+
const list = await this.callApi('GET', '/api/sessions');
|
|
3708
|
+
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
|
|
3709
|
+
const hit = sessions.find((x) => (x.internalName || x.name) === internal)
|
|
3710
|
+
|| sessions.find((x) => String(x.internalName || '').toLowerCase() === internal.toLowerCase());
|
|
3711
|
+
if (hit) payload.displayName = hit.displayName || hit.name || null;
|
|
3712
|
+
} catch (e) { /* the env facts still stand */ }
|
|
3713
|
+
}
|
|
3714
|
+
return this.wrapJson(payload);
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3686
3717
|
this.ensureConnection();
|
|
3687
3718
|
|
|
3688
3719
|
switch (name) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hop-session
|
|
3
|
-
description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers rendering math with hop math, handing over results a terminal cannot render — plots, PDFs, images, rendered markdown write-ups — with hop view (always with --title), exposing a running local web server with hop port, ringing the bell for attention, and phone-friendly output.
|
|
3
|
+
description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers finding your session identity with hop whoami, rendering math with hop math, handing over results a terminal cannot render — plots, PDFs, images, rendered markdown write-ups — with hop view (always with --title), exposing a running local web server with hop port, ringing the bell for attention, and phone-friendly output.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Working in a hop terminal
|
|
@@ -24,6 +24,30 @@ formula in your response; do not repeat the raw LaTeX next to it. When you
|
|
|
24
24
|
need the rendered text inside a file or message, capture it:
|
|
25
25
|
`hop math '<latex>'` and copy the command's output verbatim.
|
|
26
26
|
|
|
27
|
+
## Which session are you in?
|
|
28
|
+
|
|
29
|
+
Everything below targets a SESSION, and yours is knowable, not guessable:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
hop whoami # display name + internal name of this terminal's session
|
|
33
|
+
hop whoami --json # {"insideHopSession":true,"internalName":"…","displayName":"…"}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Inside a hop terminal, `HOP_SESSION` holds the session's internal name and
|
|
37
|
+
`hop view` targets it automatically. But subprocesses you spawn may have that
|
|
38
|
+
variable deliberately scrubbed (session-identity hygiene), and a renamed
|
|
39
|
+
session's internal name never matches what the human calls it — so when in
|
|
40
|
+
doubt, ask `hop whoami` and pass the answer explicitly:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
hop view --session "$(hop whoami --json | jq -r .internalName)" --title "…" out.pdf
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Over MCP, the same answer is the `hop_current_session` tool; via the agent
|
|
47
|
+
CLI it is `hopa whoami`. If `hop whoami` says you are NOT in a session,
|
|
48
|
+
never guess a target — list candidates (`hop view --list --all`,
|
|
49
|
+
`hop_list_sessions`) or ask.
|
|
50
|
+
|
|
27
51
|
## Showing results a terminal cannot render (plots, PDFs, write-ups)
|
|
28
52
|
|
|
29
53
|
When the deliverable is a plot, a figure, a report, a PDF, an image, or a
|