hop2 0.11.13 → 0.11.14
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 +31 -0
- package/hay/apps/web/src/App.tsx +96 -11
- package/hay/apps/web/src/components/SessionSwitcher.tsx +47 -2
- package/hay/apps/web/src/components/ViewsPanel.tsx +243 -0
- package/hay/apps/web/src/styles.css +109 -0
- package/hay/apps/web/src/utils/fileDrop.ts +19 -3
- package/hay/apps/web/src/utils/switcherModel.ts +12 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +19 -0
- package/hay/apps/web/test/ViewsPanel.test.tsx +118 -0
- package/hay/apps/web/test/fileDrop.test.ts +25 -1
- package/hay-web/assets/digest-archive.json +1024 -292
- package/hay-web/assets/index-Bcl-2CbE.js +133 -0
- package/hay-web/assets/index-Bcl-2CbE.js.map +1 -0
- package/hay-web/assets/{index-DHotLq-D.css → index-DKWcSY-E.css} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +629 -63
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +32 -11
- package/tests/terminal-api.integration.test.cjs +84 -0
- package/hay-web/assets/index--8sYRyoP.js +0 -133
- package/hay-web/assets/index--8sYRyoP.js.map +0 -1
- package/hay-web/assets/index-D4EXRUpb.css +0 -1
- package/hay-web/assets/index-DnSA-VR6.js +0 -133
- package/hay-web/assets/index-DnSA-VR6.js.map +0 -1
- package/hay-web/assets/index-DzlPn2In.js +0 -133
- package/hay-web/assets/index-DzlPn2In.js.map +0 -1
package/hop
CHANGED
|
@@ -224,6 +224,281 @@ function validateConfigDir() {
|
|
|
224
224
|
validateConfigDir();
|
|
225
225
|
|
|
226
226
|
const STATE_FILE = path.join(HOME_DIR, '.tunnel-state');
|
|
227
|
+
// Where `hop view` and `hop port` publish. Deliberately under HOME_DIR and
|
|
228
|
+
// NOT under HAY_WEB_DIR, which resolves to hay/apps/web/dist — vite's own
|
|
229
|
+
// outDir. That directory is gitignored AND emptied on every `npm run build`,
|
|
230
|
+
// so every published deliverable silently vanished the next time anyone
|
|
231
|
+
// rebuilt the web bundle. A view is the thing the human was waiting on; it
|
|
232
|
+
// outlives a build.
|
|
233
|
+
const VIEWS_DIR = path.join(HOME_DIR, 'views');
|
|
234
|
+
// One-time move of anything published before that was understood. Best
|
|
235
|
+
// effort: a failure here just means the old copies stay unreachable, which
|
|
236
|
+
// is where they were headed anyway.
|
|
237
|
+
function migrateLegacyViews() {
|
|
238
|
+
try {
|
|
239
|
+
const legacy = path.join(HAY_WEB_DIR, 'assets', 'view');
|
|
240
|
+
if (!fs.existsSync(legacy)) return;
|
|
241
|
+
for (const sess of fs.readdirSync(legacy)) {
|
|
242
|
+
const src = path.join(legacy, sess);
|
|
243
|
+
if (sess === 'manifest.json' || !fs.statSync(src).isDirectory()) continue;
|
|
244
|
+
const dst = path.join(VIEWS_DIR, sess);
|
|
245
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
246
|
+
for (const f of fs.readdirSync(src)) {
|
|
247
|
+
const to = path.join(dst, f);
|
|
248
|
+
if (!fs.existsSync(to)) fs.copyFileSync(path.join(src, f), to);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
fs.rmSync(legacy, { recursive: true, force: true });
|
|
252
|
+
} catch (e) { /* the new root still works */ }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// A view's TITLE, kept beside the files rather than in their names. An agent
|
|
256
|
+
// hands over "ROC curve, new model vs baseline", not "roc_curve.png", and a
|
|
257
|
+
// filename cannot carry that without becoming a filename nobody can type.
|
|
258
|
+
// One sidecar per session dir so deleting a session's folder takes its
|
|
259
|
+
// captions with it; dotfile so the manifest scan skips it.
|
|
260
|
+
function viewMetaPath(session) { return path.join(VIEWS_DIR, session, '.meta.json'); }
|
|
261
|
+
function readViewMeta(session) {
|
|
262
|
+
try { return JSON.parse(fs.readFileSync(viewMetaPath(session), 'utf8')) || {}; }
|
|
263
|
+
catch (e) { return {}; }
|
|
264
|
+
}
|
|
265
|
+
function writeViewMeta(session, meta) {
|
|
266
|
+
try { fs.writeFileSync(viewMetaPath(session), JSON.stringify(meta, null, 1)); }
|
|
267
|
+
catch (e) { /* a missing caption is not worth failing a publish over */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Markdown, rendered at SERVE time rather than converted at publish time: the
|
|
271
|
+
// stored file stays the file the agent wrote (re-publishable, diffable), and
|
|
272
|
+
// the URL still reads like it. Deliberately small and dependency-free — hop
|
|
273
|
+
// ships as one script and a markdown dependency is not worth a node_modules
|
|
274
|
+
// entry. Covers what an agent actually emits in a result: headings, fenced
|
|
275
|
+
// code, lists, tables, quotes, links, emphasis.
|
|
276
|
+
function renderMarkdown(src, title) {
|
|
277
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
278
|
+
const blocks = [];
|
|
279
|
+
// Fenced code first, so nothing below rewrites its insides.
|
|
280
|
+
let text = src.replace(/```([\w-]*)\n([\s\S]*?)```/g, (m, lang, body) => {
|
|
281
|
+
blocks.push(`<pre class="code"><code>${esc(body.replace(/\n$/, ''))}</code></pre>`);
|
|
282
|
+
return `BLOCK${blocks.length - 1}`;
|
|
283
|
+
});
|
|
284
|
+
text = esc(text);
|
|
285
|
+
const inline = (s) => s
|
|
286
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
287
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img alt="$1" src="$2">')
|
|
288
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
|
|
289
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
290
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
291
|
+
const out = [];
|
|
292
|
+
const lines = text.split('\n');
|
|
293
|
+
let para = [], list = null, table = null;
|
|
294
|
+
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
|
|
295
|
+
const flushList = () => { if (list) { out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.tag}>`); list = null; } };
|
|
296
|
+
const flushTable = () => {
|
|
297
|
+
if (!table) return;
|
|
298
|
+
const cells = (row, tag) => row.map((c) => `<${tag}>${inline(c.trim())}</${tag}>`).join('');
|
|
299
|
+
out.push(`<table><thead><tr>${cells(table.head, 'th')}</tr></thead><tbody>`
|
|
300
|
+
+ table.rows.map((r) => `<tr>${cells(r, 'td')}</tr>`).join('') + '</tbody></table>');
|
|
301
|
+
table = null;
|
|
302
|
+
};
|
|
303
|
+
const flushAll = () => { flushPara(); flushList(); flushTable(); };
|
|
304
|
+
const splitRow = (l) => l.replace(/^\||\|$/g, '').split('|');
|
|
305
|
+
for (let i = 0; i < lines.length; i++) {
|
|
306
|
+
const line = lines[i];
|
|
307
|
+
if (/^BLOCK\d+$/.test(line.trim())) { flushAll(); out.push(line.trim()); continue; }
|
|
308
|
+
if (!line.trim()) { flushAll(); continue; }
|
|
309
|
+
// A table needs its separator row to be a table at all.
|
|
310
|
+
if (!table && line.includes('|') && /^\s*\|?[\s:-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] || '')) {
|
|
311
|
+
flushPara(); flushList();
|
|
312
|
+
table = { head: splitRow(line), rows: [] };
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (table) {
|
|
317
|
+
if (line.includes('|')) { table.rows.push(splitRow(line)); continue; }
|
|
318
|
+
flushTable();
|
|
319
|
+
}
|
|
320
|
+
let m;
|
|
321
|
+
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
|
|
322
|
+
flushAll();
|
|
323
|
+
out.push(`<h${m[1].length}>${inline(m[2])}</h${m[1].length}>`);
|
|
324
|
+
} else if (/^\s*([-*_])\s*\1\s*\1[\s\S]*$/.test(line) && line.trim().length >= 3
|
|
325
|
+
&& /^[-*_\s]+$/.test(line)) {
|
|
326
|
+
flushAll(); out.push('<hr>');
|
|
327
|
+
} else if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) {
|
|
328
|
+
flushPara(); flushTable();
|
|
329
|
+
if (list?.tag !== 'ul') { flushList(); list = { tag: 'ul', items: [] }; }
|
|
330
|
+
list.items.push(m[1]);
|
|
331
|
+
} else if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
|
|
332
|
+
flushPara(); flushTable();
|
|
333
|
+
if (list?.tag !== 'ol') { flushList(); list = { tag: 'ol', items: [] }; }
|
|
334
|
+
list.items.push(m[1]);
|
|
335
|
+
} else if ((m = line.match(/^>\s?(.*)$/))) {
|
|
336
|
+
flushPara(); flushList(); flushTable();
|
|
337
|
+
out.push(`<blockquote>${inline(m[1])}</blockquote>`);
|
|
338
|
+
} else if (list && !para.length) {
|
|
339
|
+
// Lazy continuation: a wrapped bullet is still that bullet.
|
|
340
|
+
// Agents hard-wrap long list items constantly, and treating each
|
|
341
|
+
// wrap as a new block rendered a clean summary as a pile of
|
|
342
|
+
// one-item lists with orphaned paragraphs between them.
|
|
343
|
+
list.items[list.items.length - 1] += ' ' + line.trim();
|
|
344
|
+
} else {
|
|
345
|
+
flushList(); flushTable();
|
|
346
|
+
para.push(line.trim());
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
flushAll();
|
|
350
|
+
const body = out.join('\n').replace(/BLOCK(\d+)/g, (m, i) => blocks[Number(i)]);
|
|
351
|
+
return `<!doctype html><html><head><meta charset="utf-8">
|
|
352
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
353
|
+
<title>${esc(title)}</title><style>
|
|
354
|
+
:root { color-scheme: dark; }
|
|
355
|
+
body { margin:0 auto; padding:24px 18px 64px; max-width:46rem; background:#0b0b12; color:#e8e6f0;
|
|
356
|
+
font:16px/1.65 -apple-system,"SF Pro Text",Helvetica,sans-serif; overflow-wrap:break-word; }
|
|
357
|
+
h1,h2,h3,h4 { line-height:1.25; margin:1.6em 0 .5em; }
|
|
358
|
+
h1 { font-size:1.7rem; } h2 { font-size:1.35rem; } h3 { font-size:1.12rem; }
|
|
359
|
+
a { color:#a78bfa; }
|
|
360
|
+
code { font-family:ui-monospace,"SF Mono",monospace; font-size:.87em; background:#201e2e;
|
|
361
|
+
padding:.12em .38em; border-radius:6px; }
|
|
362
|
+
pre.code { background:#16151f; border:1px solid #2a2740; border-radius:12px; padding:12px 14px;
|
|
363
|
+
overflow-x:auto; } pre.code code { background:none; padding:0; font-size:.85rem; }
|
|
364
|
+
blockquote { margin:1em 0; padding:.2em 0 .2em 1em; border-left:3px solid #3a3556; color:#b9b3d0; }
|
|
365
|
+
table { border-collapse:collapse; width:100%; margin:1.2em 0; font-size:.93rem; display:block; overflow-x:auto; }
|
|
366
|
+
th,td { border:1px solid #2a2740; padding:7px 10px; text-align:left; }
|
|
367
|
+
th { background:#16151f; }
|
|
368
|
+
hr { border:0; border-top:1px solid #2a2740; margin:2em 0; }
|
|
369
|
+
img { max-width:100%; border-radius:10px; }
|
|
370
|
+
</style></head><body>\n${body}\n</body></html>`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Names `--session` will accept, for the error that asks for one. Reading the
|
|
374
|
+
// daemon's own list rather than the views folder: the answer to "where can
|
|
375
|
+
// this go" is every live session, not just the ones that already published.
|
|
376
|
+
function listPublishTargets(state) {
|
|
377
|
+
try {
|
|
378
|
+
const out = execFileSync(process.execPath, ['-e', `
|
|
379
|
+
const http = require('http');
|
|
380
|
+
http.get({ host: '127.0.0.1', port: ${Number(state.port)}, path: '/api/sessions',
|
|
381
|
+
headers: { Cookie: 'tunnel_session=' + process.argv[1] } }, (r) => {
|
|
382
|
+
let b = ''; r.on('data', (c) => b += c);
|
|
383
|
+
r.on('end', () => {
|
|
384
|
+
try {
|
|
385
|
+
const d = JSON.parse(b);
|
|
386
|
+
console.log((d.sessions || []).map((s) => s.internalName).join('\\n'));
|
|
387
|
+
} catch (e) { }
|
|
388
|
+
});
|
|
389
|
+
}).on('error', () => {});
|
|
390
|
+
`, String(state.sessionSecret)], { encoding: 'utf8', timeout: 4000 });
|
|
391
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 12);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Runtime data written into the served assets dir — the briefing's
|
|
398
|
+
// digest.json is the live example — is deleted by every web bundle rebuild,
|
|
399
|
+
// because HAY_WEB_DIR resolves to vite's outDir when a dev build exists.
|
|
400
|
+
// hop-ios's digest tool already mitigates half of this by writing to BOTH
|
|
401
|
+
// candidate roots; the daemon only ever READ the first, so after a rebuild
|
|
402
|
+
// the surviving copy sat unreachable and the wall's briefing 404'd. This
|
|
403
|
+
// completes the other half: fall back to the packaged bundle for anything
|
|
404
|
+
// the dev build doesn't have.
|
|
405
|
+
function packagedAssetFallback(relPath) {
|
|
406
|
+
try {
|
|
407
|
+
const packagedRoot = path.resolve(path.join(__dirname, 'hay-web'));
|
|
408
|
+
if (path.resolve(HAY_WEB_DIR) === packagedRoot) return null; // same dir already
|
|
409
|
+
const candidate = path.resolve(path.join(packagedRoot, relPath));
|
|
410
|
+
if (!candidate.startsWith(packagedRoot + path.sep)) return null;
|
|
411
|
+
return fs.existsSync(candidate) && fs.statSync(candidate).isFile() ? candidate : null;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Per-session view facts for the sessions list: how many, how recent, and
|
|
418
|
+
// what the newest one is called. Read from the manifest (already a cheap
|
|
419
|
+
// single file) rather than re-scanning, and re-read only when it changes —
|
|
420
|
+
// the sessions list is polled by every client, several times a minute.
|
|
421
|
+
let viewSummaryCache = { mtimeMs: -1, bySession: {} };
|
|
422
|
+
function viewSummaryBySession() {
|
|
423
|
+
try {
|
|
424
|
+
const mf = path.join(VIEWS_DIR, 'manifest.json');
|
|
425
|
+
const st = fs.statSync(mf);
|
|
426
|
+
if (st.mtimeMs !== viewSummaryCache.mtimeMs) {
|
|
427
|
+
const items = JSON.parse(fs.readFileSync(mf, 'utf8')).items || [];
|
|
428
|
+
const by = {};
|
|
429
|
+
for (const it of items) {
|
|
430
|
+
const cur = by[it.session];
|
|
431
|
+
// The manifest is newest-first, so the first one wins.
|
|
432
|
+
if (!cur) {
|
|
433
|
+
by[it.session] = {
|
|
434
|
+
count: 1, latestAt: it.mtime, latestName: it.name,
|
|
435
|
+
latestTitle: it.title || '', latestPath: it.path
|
|
436
|
+
};
|
|
437
|
+
} else cur.count += 1;
|
|
438
|
+
}
|
|
439
|
+
viewSummaryCache = { mtimeMs: st.mtimeMs, bySession: by };
|
|
440
|
+
}
|
|
441
|
+
} catch (e) {
|
|
442
|
+
viewSummaryCache = { mtimeMs: -1, bySession: {} };
|
|
443
|
+
}
|
|
444
|
+
return viewSummaryCache.bySession;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The virtual folder both `hop view` and `hop port --attached` publish into:
|
|
448
|
+
// clients browse every view through one manifest, REBUILT from a disk scan
|
|
449
|
+
// rather than read-modify-written, so two agents publishing at once cannot
|
|
450
|
+
// lose each other's entries — last writer wins with the same complete list
|
|
451
|
+
// either way.
|
|
452
|
+
//
|
|
453
|
+
// MODULE scope, not the CLI's: the daemon serves the index too, and rebuilds
|
|
454
|
+
// it on demand when it is missing. When this lived only in the CLI branch, a
|
|
455
|
+
// manifest that disappeared (a wiped build dir, this release's storage move)
|
|
456
|
+
// stayed gone until someone happened to publish again — the browser read
|
|
457
|
+
// "Nothing published yet" over a folder full of files.
|
|
458
|
+
function rebuildViewManifest() {
|
|
459
|
+
try {
|
|
460
|
+
migrateLegacyViews();
|
|
461
|
+
const entries = [];
|
|
462
|
+
for (const sess of fs.readdirSync(VIEWS_DIR)) {
|
|
463
|
+
const dir = path.join(VIEWS_DIR, sess);
|
|
464
|
+
if (sess === 'manifest.json' || sess.startsWith('.')
|
|
465
|
+
|| !fs.statSync(dir).isDirectory()) continue;
|
|
466
|
+
const meta = readViewMeta(sess);
|
|
467
|
+
for (const f of fs.readdirSync(dir)) {
|
|
468
|
+
if (f.startsWith('.')) continue; // the .meta.json sidecar
|
|
469
|
+
const st = fs.statSync(path.join(dir, f));
|
|
470
|
+
if (!st.isFile()) continue;
|
|
471
|
+
const m = meta[f] || {};
|
|
472
|
+
entries.push({
|
|
473
|
+
session: sess,
|
|
474
|
+
name: f,
|
|
475
|
+
// What the agent SAID it was handing over. Clients lead
|
|
476
|
+
// with this and fall back to the filename.
|
|
477
|
+
title: m.title || '',
|
|
478
|
+
// 'server' means a LIVE proxied localhost server rather
|
|
479
|
+
// than a stored file: it can stop working when the server
|
|
480
|
+
// does, and `target` is the proxy itself, so a client can
|
|
481
|
+
// skip the redirect page the scan actually found.
|
|
482
|
+
kind: m.kind === 'server' ? 'server' : 'file',
|
|
483
|
+
target: m.kind === 'server' ? (m.target || '') : undefined,
|
|
484
|
+
port: m.kind === 'server' ? m.port : undefined,
|
|
485
|
+
// The extensionless serving path — see the /view route for
|
|
486
|
+
// why the extension must stay off the URL's end.
|
|
487
|
+
path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
|
|
488
|
+
bytes: st.size,
|
|
489
|
+
mtime: Math.floor(st.mtimeMs / 1000)
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
494
|
+
const body = JSON.stringify({ items: entries }, null, 1);
|
|
495
|
+
fs.mkdirSync(VIEWS_DIR, { recursive: true });
|
|
496
|
+
fs.writeFileSync(path.join(VIEWS_DIR, 'manifest.json'), body);
|
|
497
|
+
return body;
|
|
498
|
+
} catch (e) {
|
|
499
|
+
return null; // the links already printed still work without an index
|
|
500
|
+
}
|
|
501
|
+
}
|
|
227
502
|
const SESSION_SECRET_FILE = path.join(HOME_DIR, '.session_secret');
|
|
228
503
|
// Folders live here, not in the workspace store: saveSessionStore() no-ops
|
|
229
504
|
// without an active workspace (the common case), so folders and their order
|
|
@@ -237,6 +512,7 @@ const DEFAULT_PASSWORD_FILE = path.join(HOME_DIR, '.password_hash');
|
|
|
237
512
|
const SESSION_COOKIE_NAME = 'tunnel_session';
|
|
238
513
|
const LOG_DIR = path.join(HOME_DIR, 'logs');
|
|
239
514
|
const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
515
|
+
const UPLOAD_MAX_BYTES = parsePositiveIntEnv('HOP_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
|
|
240
516
|
const DEFAULT_WORKSPACE_NAME = 'default';
|
|
241
517
|
const HISTORY_DIR_NAME = 'history';
|
|
242
518
|
const WORKSPACE_LOGS_DIR_NAME = 'logs';
|
|
@@ -5350,6 +5626,36 @@ function resolveActor(req) {
|
|
|
5350
5626
|
return resolveActorFromHeaders(req?.headers || {}, ACTOR_HEADER);
|
|
5351
5627
|
}
|
|
5352
5628
|
|
|
5629
|
+
// A dropped file's name is attacker-shaped input: it arrives as a query
|
|
5630
|
+
// param and becomes a filesystem path. Keep the basename only (so "../../.."
|
|
5631
|
+
// and absolute paths collapse to nothing), drop separators and control
|
|
5632
|
+
// characters, and bound the length while preserving the extension — the
|
|
5633
|
+
// extension is what makes an image open as an image downstream.
|
|
5634
|
+
function safeUploadFilename(raw) {
|
|
5635
|
+
let name = typeof raw === 'string' ? raw : '';
|
|
5636
|
+
name = name.replace(/[\x00-\x1f\x7f]/g, '');
|
|
5637
|
+
name = path.basename(name.replace(/\\/g, '/'));
|
|
5638
|
+
name = name.replace(/[/:]/g, '-').trim();
|
|
5639
|
+
if (name === '' || name === '.' || name === '..') return 'dropped-file';
|
|
5640
|
+
if (name.length > 120) {
|
|
5641
|
+
const ext = path.extname(name).slice(0, 16);
|
|
5642
|
+
name = name.slice(0, 120 - ext.length) + ext;
|
|
5643
|
+
}
|
|
5644
|
+
return name;
|
|
5645
|
+
}
|
|
5646
|
+
|
|
5647
|
+
// Never overwrite: a second drop of "screenshot.png" is a second file, not a
|
|
5648
|
+
// replacement of the one the user may still be pointing an agent at.
|
|
5649
|
+
function uniqueUploadPath(dir, filename) {
|
|
5650
|
+
const ext = path.extname(filename);
|
|
5651
|
+
const stem = filename.slice(0, filename.length - ext.length);
|
|
5652
|
+
let candidate = path.join(dir, filename);
|
|
5653
|
+
for (let i = 1; fs.existsSync(candidate) && i < 1000; i++) {
|
|
5654
|
+
candidate = path.join(dir, `${stem}-${i}${ext}`);
|
|
5655
|
+
}
|
|
5656
|
+
return candidate;
|
|
5657
|
+
}
|
|
5658
|
+
|
|
5353
5659
|
function resolveSessionInternalName(name, internalName) {
|
|
5354
5660
|
if (internalName && (getEffectiveSessionConfig(internalName) || hopSessionExists(internalName))) return internalName;
|
|
5355
5661
|
if (!name) return null;
|
|
@@ -7471,14 +7777,78 @@ const STATIC_CONTENT_TYPES = {
|
|
|
7471
7777
|
'.svg': 'image/svg+xml',
|
|
7472
7778
|
'.ico': 'image/x-icon',
|
|
7473
7779
|
'.pdf': 'application/pdf',
|
|
7780
|
+
// Everything below renders natively in a browser AND in the iOS app's
|
|
7781
|
+
// WKWebView, so the only thing standing between a result and the human
|
|
7782
|
+
// was this table. An unlisted extension falls back to octet-stream, which
|
|
7783
|
+
// DOWNLOADS instead of rendering — the exact defeat the feature exists to
|
|
7784
|
+
// avoid, and how .pdf itself behaved until the first live test.
|
|
7785
|
+
'.heic': 'image/heic',
|
|
7786
|
+
'.heif': 'image/heif',
|
|
7787
|
+
'.avif': 'image/avif',
|
|
7788
|
+
'.bmp': 'image/bmp',
|
|
7789
|
+
'.tif': 'image/tiff',
|
|
7790
|
+
'.tiff': 'image/tiff',
|
|
7791
|
+
'.apng': 'image/apng',
|
|
7474
7792
|
'.mp4': 'video/mp4',
|
|
7793
|
+
'.m4v': 'video/mp4',
|
|
7475
7794
|
'.mov': 'video/quicktime',
|
|
7476
7795
|
'.webm': 'video/webm',
|
|
7796
|
+
'.ogv': 'video/ogg',
|
|
7797
|
+
'.mp3': 'audio/mpeg',
|
|
7798
|
+
'.m4a': 'audio/mp4',
|
|
7799
|
+
'.aac': 'audio/aac',
|
|
7800
|
+
'.wav': 'audio/wav',
|
|
7801
|
+
'.flac': 'audio/flac',
|
|
7802
|
+
'.oga': 'audio/ogg',
|
|
7803
|
+
'.ogg': 'audio/ogg',
|
|
7804
|
+
'.opus': 'audio/ogg',
|
|
7477
7805
|
'.txt': 'text/plain; charset=utf-8',
|
|
7806
|
+
'.log': 'text/plain; charset=utf-8',
|
|
7807
|
+
'.xml': 'text/xml; charset=utf-8',
|
|
7808
|
+
'.yaml': 'text/plain; charset=utf-8',
|
|
7809
|
+
'.yml': 'text/plain; charset=utf-8',
|
|
7810
|
+
'.ics': 'text/calendar; charset=utf-8',
|
|
7478
7811
|
'.md': 'text/plain; charset=utf-8'
|
|
7479
7812
|
};
|
|
7480
7813
|
|
|
7814
|
+
// Extensions we RENDER rather than hand over raw, because the raw form is
|
|
7815
|
+
// unreadable on a phone and the rendered form is the actual result.
|
|
7816
|
+
const VIEW_RENDERED = new Set(['.md', '.markdown', '.csv', '.tsv', '.json']);
|
|
7817
|
+
|
|
7818
|
+
/// Delimited data as a table. A CSV is a RESULT — the numbers an agent
|
|
7819
|
+
/// computed — and delivering it as one long line of commas on a phone is the
|
|
7820
|
+
/// "just text" failure with extra steps. Quote-aware because a cell
|
|
7821
|
+
/// containing a comma is the normal case in real data, not an edge case.
|
|
7822
|
+
function renderDelimited(src, title, delim) {
|
|
7823
|
+
const rows = [];
|
|
7824
|
+
let row = [], cell = '', quoted = false;
|
|
7825
|
+
for (let i = 0; i < src.length; i++) {
|
|
7826
|
+
const c = src[i];
|
|
7827
|
+
if (quoted) {
|
|
7828
|
+
if (c === '"' && src[i + 1] === '"') { cell += '"'; i++; }
|
|
7829
|
+
else if (c === '"') quoted = false;
|
|
7830
|
+
else cell += c;
|
|
7831
|
+
} else if (c === '"') quoted = true;
|
|
7832
|
+
else if (c === delim) { row.push(cell); cell = ''; }
|
|
7833
|
+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
|
|
7834
|
+
else if (c !== '\r') cell += c;
|
|
7835
|
+
}
|
|
7836
|
+
if (cell.length || row.length) { row.push(cell); rows.push(row); }
|
|
7837
|
+
const body = rows.filter((r) => r.length && !(r.length === 1 && r[0] === ''));
|
|
7838
|
+
if (!body.length) return renderMarkdown('_(empty)_', title);
|
|
7839
|
+
// Markdown's own table syntax, so one renderer styles both — and a cell
|
|
7840
|
+
// holding a pipe would otherwise break the table it lives in.
|
|
7841
|
+
const esc = (s) => String(s).replace(/\|/g, '\\|').trim();
|
|
7842
|
+
const head = body[0].map(esc);
|
|
7843
|
+
const md = [`# ${title}`, '', `| ${head.join(' | ')} |`,
|
|
7844
|
+
`|${head.map(() => '---').join('|')}|`,
|
|
7845
|
+
...body.slice(1).map((r) => `| ${r.map(esc).join(' | ')} |`),
|
|
7846
|
+
'', `_${body.length - 1} rows_`].join('\n');
|
|
7847
|
+
return renderMarkdown(md, title);
|
|
7848
|
+
}
|
|
7849
|
+
|
|
7481
7850
|
function startAuthProxy(authEmitter) {
|
|
7851
|
+
migrateLegacyViews();
|
|
7482
7852
|
log(`[2/4] Starting Session Manager on port ${PROXY_PORT}...`, BOLD);
|
|
7483
7853
|
|
|
7484
7854
|
const server = http.createServer(async (req, res) => {
|
|
@@ -8064,7 +8434,7 @@ function startAuthProxy(authEmitter) {
|
|
|
8064
8434
|
const viewMatch = req.method === 'GET'
|
|
8065
8435
|
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
|
|
8066
8436
|
if (viewMatch) {
|
|
8067
|
-
const viewRoot = path.resolve(
|
|
8437
|
+
const viewRoot = path.resolve(VIEWS_DIR);
|
|
8068
8438
|
const target = path.resolve(path.join(
|
|
8069
8439
|
viewRoot, decodeURIComponent(viewMatch[1]), decodeURIComponent(viewMatch[2])));
|
|
8070
8440
|
if (!target.startsWith(viewRoot + path.sep)
|
|
@@ -8073,20 +8443,58 @@ function startAuthProxy(authEmitter) {
|
|
|
8073
8443
|
res.end('Not found');
|
|
8074
8444
|
return;
|
|
8075
8445
|
}
|
|
8446
|
+
const viewExt = path.extname(target).toLowerCase();
|
|
8447
|
+
// Markdown is rendered, not dumped: an agent's write-up is a
|
|
8448
|
+
// RESULT, and handing over a wall of raw asterisks is the "just
|
|
8449
|
+
// text" outcome this feature exists to avoid. The stored file
|
|
8450
|
+
// stays the markdown the agent wrote.
|
|
8451
|
+
if (VIEW_RENDERED.has(viewExt)) {
|
|
8452
|
+
const sess = decodeURIComponent(viewMatch[1]);
|
|
8453
|
+
const file = decodeURIComponent(viewMatch[2]);
|
|
8454
|
+
const caption = readViewMeta(sess)[file]?.title || file;
|
|
8455
|
+
const raw = fs.readFileSync(target, 'utf8');
|
|
8456
|
+
let html;
|
|
8457
|
+
if (viewExt === '.csv') html = renderDelimited(raw, caption, ',');
|
|
8458
|
+
else if (viewExt === '.tsv') html = renderDelimited(raw, caption, '\t');
|
|
8459
|
+
else if (viewExt === '.json') {
|
|
8460
|
+
// Pretty-printed in a code block: a minified blob is
|
|
8461
|
+
// machine output, and the human opened this to read it.
|
|
8462
|
+
let pretty = raw;
|
|
8463
|
+
try { pretty = JSON.stringify(JSON.parse(raw), null, 2); } catch (e) { }
|
|
8464
|
+
html = renderMarkdown(`# ${caption}\n\n\`\`\`json\n${pretty}\n\`\`\``, caption);
|
|
8465
|
+
} else html = renderMarkdown(raw, caption);
|
|
8466
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
8467
|
+
res.end(html);
|
|
8468
|
+
return;
|
|
8469
|
+
}
|
|
8076
8470
|
res.writeHead(200, {
|
|
8077
|
-
'Content-Type': STATIC_CONTENT_TYPES[
|
|
8471
|
+
'Content-Type': STATIC_CONTENT_TYPES[viewExt] || 'application/octet-stream'
|
|
8078
8472
|
});
|
|
8079
8473
|
res.end(fs.readFileSync(target));
|
|
8080
8474
|
return;
|
|
8081
8475
|
}
|
|
8476
|
+
// The index. /assets/view/manifest.json is the URL every SHIPPED iOS
|
|
8477
|
+
// build fetches, so it keeps working forever regardless of where the
|
|
8478
|
+
// files actually live; /api/views is the same bytes under a name that
|
|
8479
|
+
// describes what it is, for clients written after the move.
|
|
8480
|
+
if (req.method === 'GET' && (parsedUrl.pathname === '/assets/view/manifest.json'
|
|
8481
|
+
|| parsedUrl.pathname === '/api/views')) {
|
|
8482
|
+
let body = null;
|
|
8483
|
+
try { body = fs.readFileSync(path.join(VIEWS_DIR, 'manifest.json'), 'utf8'); } catch (e) { }
|
|
8484
|
+
// Rebuild rather than answer "nothing published" over a folder
|
|
8485
|
+
// that has files in it.
|
|
8486
|
+
if (body === null) body = rebuildViewManifest();
|
|
8487
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
8488
|
+
res.end(body || '{"items":[]}');
|
|
8489
|
+
return;
|
|
8490
|
+
}
|
|
8082
8491
|
if (req.method === 'GET' && parsedUrl.pathname.startsWith('/assets/view/')) {
|
|
8083
8492
|
const segs = parsedUrl.pathname.slice('/assets/view/'.length).split('/');
|
|
8084
|
-
if (segs.length === 2 && segs[1]
|
|
8493
|
+
if (segs.length === 2 && segs[1]) {
|
|
8085
8494
|
res.writeHead(302, { Location: `/view/${segs[0]}/${segs[1]}/inline` });
|
|
8086
8495
|
res.end();
|
|
8087
8496
|
return;
|
|
8088
8497
|
}
|
|
8089
|
-
// manifest.json falls through to plain static serving
|
|
8090
8498
|
}
|
|
8091
8499
|
|
|
8092
8500
|
if (sessionInfoAuth && sessionInfoAuth.source === 'query' && shouldRedirectToSessionPath(req)) {
|
|
@@ -8396,7 +8804,16 @@ function startAuthProxy(authEmitter) {
|
|
|
8396
8804
|
const visibleEntries = snapshot.entries.filter((s) =>
|
|
8397
8805
|
!(s.type === 'port' && getEffectiveSessionConfig(s.internalName)?.attachedTo)
|
|
8398
8806
|
);
|
|
8807
|
+
// What each session has published, folded into the list every
|
|
8808
|
+
// client already polls. Deliberately NOT a server-side unread
|
|
8809
|
+
// counter: "new" is per-DEVICE (the phone can be behind while the
|
|
8810
|
+
// wall is caught up), so the server states the facts — how many,
|
|
8811
|
+
// how recent, what the newest one is called — and each client
|
|
8812
|
+
// compares latestAt against its own last-seen mark. No new state,
|
|
8813
|
+
// no new poll, and the badge works the same everywhere.
|
|
8814
|
+
const viewsBySession = viewSummaryBySession();
|
|
8399
8815
|
const sessions = visibleEntries.map(s => ({
|
|
8816
|
+
views: viewsBySession[s.internalName] || undefined,
|
|
8400
8817
|
name: s.displayName,
|
|
8401
8818
|
displayName: s.displayName,
|
|
8402
8819
|
internalName: s.internalName,
|
|
@@ -8677,6 +9094,67 @@ function startAuthProxy(authEmitter) {
|
|
|
8677
9094
|
return;
|
|
8678
9095
|
}
|
|
8679
9096
|
|
|
9097
|
+
// API: Upload a dropped file. A browser never reveals a dropped
|
|
9098
|
+
// file's real path, so the old drop handler could only apologise and
|
|
9099
|
+
// teach the OS copy-path gesture. Take the BYTES instead: the file
|
|
9100
|
+
// lands on the host in a per-session staging dir and the terminal is
|
|
9101
|
+
// handed a path the shell (or the agent reading it) can actually
|
|
9102
|
+
// open. Staging — never the session's cwd — so a drop can't clobber
|
|
9103
|
+
// a working file or dirty a repo.
|
|
9104
|
+
if (parsedUrl.pathname === '/api/sessions/upload' && req.method === 'POST') {
|
|
9105
|
+
const internalName = resolveSessionInternalName(parsedUrl.query.name, parsedUrl.query.internalName);
|
|
9106
|
+
// resolveSessionInternalName INVENTS a name for anything it does
|
|
9107
|
+
// not know (the invent-on-connect path), so an unknown session
|
|
9108
|
+
// would otherwise become a staging directory on demand: an
|
|
9109
|
+
// unbounded write surface keyed by a caller-supplied string.
|
|
9110
|
+
// Uploads go only to sessions that actually exist.
|
|
9111
|
+
const uploadSessionExists = internalName
|
|
9112
|
+
&& (getEffectiveSessionConfig(internalName) || hopSessionExists(internalName));
|
|
9113
|
+
if (!internalName || !SESSION_NAME_RE.test(internalName) || !uploadSessionExists) {
|
|
9114
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9115
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
9116
|
+
return;
|
|
9117
|
+
}
|
|
9118
|
+
const declaredLength = Number(req.headers['content-length']);
|
|
9119
|
+
if (Number.isFinite(declaredLength) && declaredLength > UPLOAD_MAX_BYTES) {
|
|
9120
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
9121
|
+
res.end(JSON.stringify({ error: `File too large (max ${Math.floor(UPLOAD_MAX_BYTES / 1048576)}MB)` }));
|
|
9122
|
+
return;
|
|
9123
|
+
}
|
|
9124
|
+
const chunks = [];
|
|
9125
|
+
let received = 0;
|
|
9126
|
+
let aborted = false;
|
|
9127
|
+
req.on('data', (chunk) => {
|
|
9128
|
+
if (aborted) return;
|
|
9129
|
+
received += chunk.length;
|
|
9130
|
+
if (received > UPLOAD_MAX_BYTES) {
|
|
9131
|
+
aborted = true;
|
|
9132
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
9133
|
+
res.end(JSON.stringify({ error: `File too large (max ${Math.floor(UPLOAD_MAX_BYTES / 1048576)}MB)` }));
|
|
9134
|
+
req.destroy();
|
|
9135
|
+
return;
|
|
9136
|
+
}
|
|
9137
|
+
chunks.push(chunk);
|
|
9138
|
+
});
|
|
9139
|
+
req.on('end', () => {
|
|
9140
|
+
if (aborted) return;
|
|
9141
|
+
try {
|
|
9142
|
+
const dir = path.join(HOME_DIR, 'uploads', internalName);
|
|
9143
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9144
|
+
const filePath = uniqueUploadPath(dir, safeUploadFilename(parsedUrl.query.filename));
|
|
9145
|
+
fs.writeFileSync(filePath, Buffer.concat(chunks), { mode: 0o600 });
|
|
9146
|
+
log(`[upload] ${getSessionDisplayName(internalName) || internalName}: ${path.basename(filePath)} (${received} bytes)`, DIM);
|
|
9147
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
9148
|
+
res.end(JSON.stringify({ ok: true, path: filePath, name: path.basename(filePath), bytes: received }));
|
|
9149
|
+
} catch (e) {
|
|
9150
|
+
log(`[upload] failed for ${internalName}: ${e.message}`, YELLOW);
|
|
9151
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
9152
|
+
res.end(JSON.stringify({ error: 'Upload failed' }));
|
|
9153
|
+
}
|
|
9154
|
+
});
|
|
9155
|
+
return;
|
|
9156
|
+
}
|
|
9157
|
+
|
|
8680
9158
|
// API: Session tagline — a short human-readable line describing what
|
|
8681
9159
|
// the session is working on. Written by `hop ai tagline` (the only
|
|
8682
9160
|
// AI-dependent surface; core hop never calls a model). Stored on the
|
|
@@ -9914,7 +10392,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9914
10392
|
let hubPath = isRootAsset
|
|
9915
10393
|
? path.join(HAY_WEB_DIR, parsedUrl.pathname)
|
|
9916
10394
|
: path.join(HAY_WEB_DIR, 'index.html');
|
|
9917
|
-
|
|
10395
|
+
let resolvedHub = path.resolve(hubPath);
|
|
9918
10396
|
const hubRoot = path.resolve(HAY_WEB_DIR);
|
|
9919
10397
|
if (resolvedHub !== hubRoot && !resolvedHub.startsWith(hubRoot + path.sep)) {
|
|
9920
10398
|
res.writeHead(403);
|
|
@@ -9922,9 +10400,14 @@ function startAuthProxy(authEmitter) {
|
|
|
9922
10400
|
return;
|
|
9923
10401
|
}
|
|
9924
10402
|
if (!fs.existsSync(resolvedHub)) {
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
10403
|
+
const fallback = isRootAsset && packagedAssetFallback(parsedUrl.pathname);
|
|
10404
|
+
if (!fallback) {
|
|
10405
|
+
res.writeHead(404);
|
|
10406
|
+
res.end('Not Found');
|
|
10407
|
+
return;
|
|
10408
|
+
}
|
|
10409
|
+
hubPath = fallback;
|
|
10410
|
+
resolvedHub = fallback;
|
|
9928
10411
|
}
|
|
9929
10412
|
const hubExt = path.extname(resolvedHub).toLowerCase();
|
|
9930
10413
|
const hubTypes = STATIC_CONTENT_TYPES;
|
|
@@ -10074,9 +10557,15 @@ function startAuthProxy(authEmitter) {
|
|
|
10074
10557
|
|
|
10075
10558
|
// Check if file exists
|
|
10076
10559
|
if (!fs.existsSync(filePath)) {
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10560
|
+
// Same packaged fallback as hub scope — a rebuild must not take
|
|
10561
|
+
// the briefing out from under a session-scoped page either.
|
|
10562
|
+
const fallback = reqPath.startsWith('/assets/') && packagedAssetFallback(reqPath);
|
|
10563
|
+
if (!fallback) {
|
|
10564
|
+
res.writeHead(404);
|
|
10565
|
+
res.end('Not Found');
|
|
10566
|
+
return;
|
|
10567
|
+
}
|
|
10568
|
+
filePath = fallback;
|
|
10080
10569
|
}
|
|
10081
10570
|
|
|
10082
10571
|
// Determine content type
|
|
@@ -12172,7 +12661,7 @@ async function attachToAllSessions(existingTunnel) {
|
|
|
12172
12661
|
const KNOWN_COMMANDS = [
|
|
12173
12662
|
'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
|
|
12174
12663
|
'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
|
|
12175
|
-
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'notify', 'new'
|
|
12664
|
+
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'new'
|
|
12176
12665
|
];
|
|
12177
12666
|
|
|
12178
12667
|
// Suggest the closest known command for typos (small edit distances only).
|
|
@@ -12285,6 +12774,17 @@ const HELP_SECTIONS = [
|
|
|
12285
12774
|
{ title: 'Math', lines: [
|
|
12286
12775
|
`hop math '<latex>' Render a LaTeX formula (inline image where supported, else Unicode)`,
|
|
12287
12776
|
] },
|
|
12777
|
+
// Was missing entirely: an agent in a session with no skill installed had
|
|
12778
|
+
// no way to learn these existed, and `hop port --help` printed "Unknown
|
|
12779
|
+
// command" because 'port' wasn't in KNOWN_COMMANDS.
|
|
12780
|
+
{ title: 'Views (show the human something a terminal cannot draw)', lines: [
|
|
12781
|
+
`hop view <file...> Publish HTML/PDF/image/video/markdown behind hop's auth and print the link`,
|
|
12782
|
+
`hop view --title "..." Say what the result IS — clients lead with this, not the filename`,
|
|
12783
|
+
`hop view --name x.md - Publish what's on stdin (no temp file needed)`,
|
|
12784
|
+
`hop view --list [--all] What this session has published (--all: the whole fleet)`,
|
|
12785
|
+
`hop view --rm <name> Unpublish one`,
|
|
12786
|
+
`hop port <port> [name] Expose a local web server (live proxy, WebSocket-safe); --standalone to detach`,
|
|
12787
|
+
] },
|
|
12288
12788
|
{ title: 'Maintenance', lines: [
|
|
12289
12789
|
`hop health [--restart] Check tunnel health (--restart forces a tunnel restart)`,
|
|
12290
12790
|
`hop wipe [--all] [--yes] Remove workspace-defined sessions (--all kills live; --yes skips confirmation)`,
|
|
@@ -13300,39 +13800,6 @@ async function main() {
|
|
|
13300
13800
|
process.exit(0);
|
|
13301
13801
|
}
|
|
13302
13802
|
|
|
13303
|
-
// The virtual folder both `hop view` and `hop port --attached` publish
|
|
13304
|
-
// into: clients browse every artifact through one manifest, REBUILT from
|
|
13305
|
-
// a disk scan on every publish rather than read-modify-written, so two
|
|
13306
|
-
// agents publishing at once cannot lose each other's entries — last
|
|
13307
|
-
// writer wins with the same complete list either way.
|
|
13308
|
-
function rebuildArtifactManifest() {
|
|
13309
|
-
try {
|
|
13310
|
-
const viewRoot = path.join(HAY_WEB_DIR, 'assets', 'view');
|
|
13311
|
-
const entries = [];
|
|
13312
|
-
for (const sess of fs.readdirSync(viewRoot)) {
|
|
13313
|
-
const dir = path.join(viewRoot, sess);
|
|
13314
|
-
if (!fs.statSync(dir).isDirectory()) continue;
|
|
13315
|
-
for (const f of fs.readdirSync(dir)) {
|
|
13316
|
-
if (f === 'manifest.json') continue;
|
|
13317
|
-
const st = fs.statSync(path.join(dir, f));
|
|
13318
|
-
if (!st.isFile()) continue;
|
|
13319
|
-
entries.push({
|
|
13320
|
-
session: sess,
|
|
13321
|
-
name: f,
|
|
13322
|
-
// The extensionless serving path — see the /view route
|
|
13323
|
-
// for why the extension must stay off the URL's end.
|
|
13324
|
-
path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
|
|
13325
|
-
bytes: st.size,
|
|
13326
|
-
mtime: Math.floor(st.mtimeMs / 1000)
|
|
13327
|
-
});
|
|
13328
|
-
}
|
|
13329
|
-
}
|
|
13330
|
-
entries.sort((a, b) => b.mtime - a.mtime);
|
|
13331
|
-
fs.writeFileSync(path.join(viewRoot, 'manifest.json'),
|
|
13332
|
-
JSON.stringify({ items: entries }, null, 1));
|
|
13333
|
-
} catch { /* the links already printed still work without a manifest */ }
|
|
13334
|
-
}
|
|
13335
|
-
|
|
13336
13803
|
// hop view <file...> — publish a renderable artifact (HTML, PDF, image)
|
|
13337
13804
|
// behind hop's auth and print the link. The browser is the viewer: every
|
|
13338
13805
|
// hop client either is one (web) or carries one (the iOS app opens these
|
|
@@ -13341,36 +13808,121 @@ async function main() {
|
|
|
13341
13808
|
// the session cookie like everything else. Written for agents (the
|
|
13342
13809
|
// hop-session skill teaches it): show the human the plot, don't describe it.
|
|
13343
13810
|
if (process.argv[2] === 'view') {
|
|
13344
|
-
const
|
|
13811
|
+
const argv = process.argv.slice(3);
|
|
13812
|
+
const flagValue = (name) => {
|
|
13813
|
+
const i = argv.indexOf(name);
|
|
13814
|
+
return i >= 0 ? argv[i + 1] : null;
|
|
13815
|
+
};
|
|
13816
|
+
const title = flagValue('--title') || '';
|
|
13817
|
+
const stdinName = flagValue('--name');
|
|
13818
|
+
const wantList = argv.includes('--list');
|
|
13819
|
+
const removals = argv.reduce((acc, a, i) => (a === '--rm' && argv[i + 1] ? acc.concat(argv[i + 1]) : acc), []);
|
|
13820
|
+
const wantSession = flagValue('--session');
|
|
13821
|
+
const consumed = new Set(['--title', title, '--name', stdinName, '--session', wantSession,
|
|
13822
|
+
'--list', '--rm', ...removals]);
|
|
13823
|
+
const files = argv.filter((a) => !consumed.has(a) && (a === '-' || !a.startsWith('-')));
|
|
13824
|
+
// WHICH session a result belongs to is the whole filing system: the
|
|
13825
|
+
// chip that lights, the panel it appears in, the conversation it
|
|
13826
|
+
// answers. It used to be `HOP_SESSION || 'shared'`, and that fallback
|
|
13827
|
+
// was a silent misdelivery — 'shared' is not a session, so nothing
|
|
13828
|
+
// resolves it to a name, no session's summary counts it, and no chip
|
|
13829
|
+
// ever lights. The result was published to a folder nobody is looking
|
|
13830
|
+
// at. (Not hypothetical: we deliberately scrub HOP_SESSION from
|
|
13831
|
+
// subprocesses to keep claude's session identity straight, and a cron
|
|
13832
|
+
// or MCP-driven agent never had it.)
|
|
13833
|
+
//
|
|
13834
|
+
// So: explicit --session wins, the environment is the convenience
|
|
13835
|
+
// default, and having neither is an ERROR that says how to fix it —
|
|
13836
|
+
// never a quiet write into the void.
|
|
13837
|
+
const session = wantSession || process.env.HOP_SESSION || '';
|
|
13838
|
+
const destDir = path.join(VIEWS_DIR, session);
|
|
13839
|
+
|
|
13840
|
+
if (wantList) {
|
|
13841
|
+
const items = (JSON.parse(rebuildViewManifest() || '{"items":[]}').items || [])
|
|
13842
|
+
.filter((i) => argv.includes('--all') || i.session === session);
|
|
13843
|
+
if (!items.length) { log(`No views published${argv.includes('--all') ? '' : ` by ${session}`} yet.`, DIM); process.exit(0); }
|
|
13844
|
+
for (const i of items) {
|
|
13845
|
+
console.log(`${i.session}/${i.name}${i.title ? ` — ${i.title}` : ''} (${Math.max(1, Math.round(i.bytes / 1024))} KB)`);
|
|
13846
|
+
}
|
|
13847
|
+
process.exit(0);
|
|
13848
|
+
}
|
|
13849
|
+
if (removals.length) {
|
|
13850
|
+
let gone = 0;
|
|
13851
|
+
const meta = readViewMeta(session);
|
|
13852
|
+
for (const name of removals) {
|
|
13853
|
+
const target = path.resolve(path.join(destDir, path.basename(name)));
|
|
13854
|
+
if (!target.startsWith(path.resolve(destDir) + path.sep) || !fs.existsSync(target)) {
|
|
13855
|
+
log(`hop view: not published here: ${name}`, RED);
|
|
13856
|
+
continue;
|
|
13857
|
+
}
|
|
13858
|
+
fs.rmSync(target); delete meta[path.basename(name)]; gone += 1;
|
|
13859
|
+
}
|
|
13860
|
+
writeViewMeta(session, meta);
|
|
13861
|
+
rebuildViewManifest();
|
|
13862
|
+
log(`Removed ${gone} view${gone === 1 ? '' : 's'}.`, gone ? GREEN : RESET);
|
|
13863
|
+
process.exit(gone ? 0 : 1);
|
|
13864
|
+
}
|
|
13345
13865
|
if (files.length === 0) {
|
|
13346
|
-
log('Usage: hop view
|
|
13866
|
+
log('Usage: hop view [--title "what this is"] <file...>', YELLOW);
|
|
13867
|
+
log(' hop view --name report.md --title "..." - (read the file from stdin)', DIM);
|
|
13868
|
+
log(' hop view --list [--all] hop view --rm <name>', DIM);
|
|
13869
|
+
log('HTML, PDF, images, video, markdown — anything a browser renders.', DIM);
|
|
13347
13870
|
process.exit(1);
|
|
13348
13871
|
}
|
|
13349
13872
|
const state = isServerRunning();
|
|
13350
13873
|
if (!state) { printNotRunningError(); process.exit(1); }
|
|
13351
|
-
|
|
13352
|
-
|
|
13874
|
+
if (!session) {
|
|
13875
|
+
log('hop view: no session to publish into.', RED);
|
|
13876
|
+
log(' Inside a hop session this is automatic (HOP_SESSION).', DIM);
|
|
13877
|
+
log(' From anywhere else, name the session the result belongs to:', DIM);
|
|
13878
|
+
log(` hop view --session <name> --title "..." <file>`, CYAN);
|
|
13879
|
+
log(` ${DIM}Sessions:${RESET} ${listPublishTargets(state).join(', ') || '(none)'}`, RESET);
|
|
13880
|
+
process.exit(2);
|
|
13881
|
+
}
|
|
13353
13882
|
fs.mkdirSync(destDir, { recursive: true });
|
|
13883
|
+
const meta = readViewMeta(session);
|
|
13354
13884
|
let failed = 0;
|
|
13885
|
+
const published = [];
|
|
13355
13886
|
for (const file of files) {
|
|
13356
|
-
|
|
13357
|
-
if (
|
|
13358
|
-
|
|
13359
|
-
|
|
13360
|
-
|
|
13887
|
+
let name;
|
|
13888
|
+
if (file === '-') {
|
|
13889
|
+
// Stdin: an agent that GENERATED the result shouldn't have to
|
|
13890
|
+
// invent a temp file to hand it over. Markdown by default —
|
|
13891
|
+
// it renders, and it's what a model writes without being asked.
|
|
13892
|
+
name = path.basename(stdinName || 'result.md');
|
|
13893
|
+
try {
|
|
13894
|
+
fs.writeFileSync(path.join(destDir, name), fs.readFileSync(0));
|
|
13895
|
+
} catch (e) {
|
|
13896
|
+
log(`hop view: could not read stdin: ${e.message}`, RED);
|
|
13897
|
+
failed += 1;
|
|
13898
|
+
continue;
|
|
13899
|
+
}
|
|
13900
|
+
} else {
|
|
13901
|
+
const src = path.resolve(file);
|
|
13902
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) {
|
|
13903
|
+
log(`hop view: no such file: ${file}`, RED);
|
|
13904
|
+
failed += 1;
|
|
13905
|
+
continue;
|
|
13906
|
+
}
|
|
13907
|
+
// Basename only — the URL should read like the file, and the
|
|
13908
|
+
// session subdir already namespaces collisions between agents.
|
|
13909
|
+
name = path.basename(src);
|
|
13910
|
+
fs.copyFileSync(src, path.join(destDir, name));
|
|
13361
13911
|
}
|
|
13362
|
-
//
|
|
13363
|
-
//
|
|
13364
|
-
|
|
13365
|
-
|
|
13366
|
-
|
|
13912
|
+
// One title covers the batch: a caption describes the HANDOFF
|
|
13913
|
+
// ("the before/after plots"), and repeating it per file reads
|
|
13914
|
+
// worse than sharing it.
|
|
13915
|
+
if (title) meta[name] = { ...(meta[name] || {}), title };
|
|
13916
|
+
published.push(name);
|
|
13917
|
+
console.log(`View: ${state.url}/view/${encodeURIComponent(session)}/${encodeURIComponent(name)}/inline`);
|
|
13367
13918
|
}
|
|
13919
|
+
if (published.length) writeViewMeta(session, meta);
|
|
13368
13920
|
if (failed === 0 && files.length > 0) {
|
|
13369
|
-
// One bell: the
|
|
13921
|
+
// One bell: the view is usually the deliverable the human is
|
|
13370
13922
|
// waiting on, and the link scrolls away if nothing pings them.
|
|
13371
13923
|
process.stdout.write('\x07');
|
|
13372
13924
|
}
|
|
13373
|
-
|
|
13925
|
+
rebuildViewManifest();
|
|
13374
13926
|
process.exit(failed > 0 ? 1 : 0);
|
|
13375
13927
|
}
|
|
13376
13928
|
|
|
@@ -13422,7 +13974,7 @@ async function main() {
|
|
|
13422
13974
|
// in-app open on web) navigates through it to the live proxy in
|
|
13423
13975
|
// one hop — same auth, same session cookie, no new plumbing on
|
|
13424
13976
|
// either client.
|
|
13425
|
-
const destDir = path.join(
|
|
13977
|
+
const destDir = path.join(VIEWS_DIR, caller);
|
|
13426
13978
|
fs.mkdirSync(destDir, { recursive: true });
|
|
13427
13979
|
const artifactName = `${label}.html`;
|
|
13428
13980
|
fs.writeFileSync(path.join(destDir, artifactName), [
|
|
@@ -13431,7 +13983,21 @@ async function main() {
|
|
|
13431
13983
|
`<meta http-equiv="refresh" content="0;url=${proxyUrl}">`,
|
|
13432
13984
|
'</head><body>Redirecting to the live server…</body></html>'
|
|
13433
13985
|
].join(''));
|
|
13434
|
-
|
|
13986
|
+
// A running server is not a file, and a row reading "probe.html"
|
|
13987
|
+
// told the human nothing about what it actually was. The metadata
|
|
13988
|
+
// says what it IS and where it really lives, so clients can send
|
|
13989
|
+
// the tap straight to the proxy instead of through the redirect
|
|
13990
|
+
// page, and can label it as something live that can go away.
|
|
13991
|
+
const meta = readViewMeta(caller);
|
|
13992
|
+
meta[artifactName] = {
|
|
13993
|
+
...(meta[artifactName] || {}),
|
|
13994
|
+
title: `${label} — live server on port ${port}`,
|
|
13995
|
+
kind: 'server',
|
|
13996
|
+
target: `/s/${encodeURIComponent(linkName)}/`,
|
|
13997
|
+
port
|
|
13998
|
+
};
|
|
13999
|
+
writeViewMeta(caller, meta);
|
|
14000
|
+
rebuildViewManifest();
|
|
13435
14001
|
console.log(`View: ${proxyUrl}`);
|
|
13436
14002
|
console.log(`Attached to ${caller} — also in its Views.`);
|
|
13437
14003
|
} else {
|