hop2 0.11.20 → 0.11.21

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