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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(/^&gt;\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(path.join(HAY_WEB_DIR, 'assets', 'view'));
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[path.extname(target).toLowerCase()] || 'application/octet-stream'
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] && segs[1] !== 'manifest.json') {
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
- const resolvedHub = path.resolve(hubPath);
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
- res.writeHead(404);
9926
- res.end('Not Found');
9927
- return;
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
- res.writeHead(404);
10078
- res.end('Not Found');
10079
- return;
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 files = process.argv.slice(3).filter((a) => !a.startsWith('-'));
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 <file...> (HTML, PDF, images — anything a browser renders)', YELLOW);
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
- const session = process.env.HOP_SESSION || 'shared';
13352
- const destDir = path.join(HAY_WEB_DIR, 'assets', 'view', session);
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
- const src = path.resolve(file);
13357
- if (!fs.existsSync(src) || !fs.statSync(src).isFile()) {
13358
- log(`hop view: no such file: ${file}`, RED);
13359
- failed += 1;
13360
- continue;
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
- // Basename only — the URL should read like the file, and the
13363
- // session subdir already namespaces collisions between agents.
13364
- const dest = path.join(destDir, path.basename(src));
13365
- fs.copyFileSync(src, dest);
13366
- console.log(`View: ${state.url}/view/${encodeURIComponent(session)}/${encodeURIComponent(path.basename(src))}/inline`);
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 artifact is usually the deliverable the human is
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
- rebuildArtifactManifest();
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(HAY_WEB_DIR, 'assets', 'view', caller);
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
- rebuildArtifactManifest();
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 {