@factiii/runner 0.12.2 → 0.13.1

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/dist/cli.js CHANGED
@@ -12928,7 +12928,8 @@ function hostSpacePaths(spaceDir) {
12928
12928
  state: import_path.default.join(spaceDir, "state"),
12929
12929
  backups: import_path.default.join(spaceDir, "backups"),
12930
12930
  builds: import_path.default.join(spaceDir, "builds"),
12931
- bin: import_path.default.join(spaceDir, "bin")
12931
+ bin: import_path.default.join(spaceDir, "bin"),
12932
+ scratchpad: import_path.default.join(spaceDir, "scratchpad")
12932
12933
  };
12933
12934
  }
12934
12935
  function worktreeFor(paths, workName) {
@@ -13885,6 +13886,7 @@ function hostTarget(spaceDir, extraEnv) {
13885
13886
  const env = {};
13886
13887
  const ports = readSpacePorts(spaceDir);
13887
13888
  if (ports.length) env.FACTIII_PORTS = ports.join(",");
13889
+ env.SCRATCHPAD = paths.scratchpad;
13888
13890
  return { ...env, ...extraEnv?.() };
13889
13891
  },
13890
13892
  agentEnv() {
@@ -14145,6 +14147,9 @@ function createEventSink(send, spaceSlug, notify) {
14145
14147
  emitBareFsChange(postId, change) {
14146
14148
  send(channel(`bare-fs:${postId}`), change);
14147
14149
  },
14150
+ emitScratchpad(payload) {
14151
+ send(channel("scratchpad"), payload);
14152
+ },
14148
14153
  emitActivityUpdate(activity) {
14149
14154
  send(channel("activity-update"), activity);
14150
14155
  }
@@ -17680,6 +17685,8 @@ var NOISE_GLOBS = ["**/.DS_Store", "**/4913", "**/*~", "**/.#*", "**/#*#"];
17680
17685
  var MAX_BATCH = 200;
17681
17686
  var FLUSH_MS = 120;
17682
17687
  var SELF_WRITE_MS = 2e3;
17688
+ var RETRY_MIN_MS = 500;
17689
+ var RETRY_MAX_MS = 15e3;
17683
17690
  var dynamicImport = new Function("s", "return import(s)");
17684
17691
  var importFs = new Function("s", "return import(s)");
17685
17692
  async function resolveReal(path23) {
@@ -17710,13 +17717,16 @@ var WorkspaceWatcher = class {
17710
17717
  * A no-git card's root is the space dir itself, which also
17711
17718
  * holds the runner's own bookkeeping.
17712
17719
  */
17713
- constructor(root, emit, exclude = []) {
17720
+ constructor(root, emit, exclude = [], subscribeImpl) {
17714
17721
  this.root = root;
17715
17722
  this.emit = emit;
17716
17723
  this.exclude = exclude;
17724
+ this.subscribeImpl = subscribeImpl;
17717
17725
  this.sub = null;
17718
17726
  this.pending = emptyPending();
17719
17727
  this.flushTimer = null;
17728
+ this.retryTimer = null;
17729
+ this.retryDelay = RETRY_MIN_MS;
17720
17730
  /** path -> expiry. Events for these are ours (the editor just saved) and must
17721
17731
  * not come back as "changed on disk". */
17722
17732
  this.selfWrites = /* @__PURE__ */ new Map();
@@ -17726,11 +17736,11 @@ var WorkspaceWatcher = class {
17726
17736
  /** Fire-and-forget: callers treat starting a watch as synchronous, and
17727
17737
  * nothing can be emitted before `subscribe` resolves anyway. */
17728
17738
  start() {
17729
- if (this.sub || this.closed) return;
17739
+ if (this.sub || this.closed || this.retryTimer) return;
17730
17740
  void this.open();
17731
17741
  }
17732
17742
  async open() {
17733
- const subscribe = await getSubscribe();
17743
+ const subscribe = this.subscribeImpl ?? await getSubscribe();
17734
17744
  if (!subscribe) return this.stop();
17735
17745
  this.watchRoot = await resolveReal(this.root);
17736
17746
  const exclude = await Promise.all(this.exclude.map(resolveReal));
@@ -17738,7 +17748,7 @@ var WorkspaceWatcher = class {
17738
17748
  const sub = await subscribe(
17739
17749
  this.watchRoot,
17740
17750
  (err, events) => {
17741
- if (err) return this.stop();
17751
+ if (err) return this.retry();
17742
17752
  for (const event of events) this.onEvent(event);
17743
17753
  },
17744
17754
  // Prunes the walk itself, so a pruned tree is never enumerated.
@@ -17755,10 +17765,39 @@ var WorkspaceWatcher = class {
17755
17765
  return;
17756
17766
  }
17757
17767
  this.sub = sub;
17768
+ this.retryDelay = RETRY_MIN_MS;
17758
17769
  } catch {
17759
- this.stop();
17770
+ this.retry();
17760
17771
  }
17761
17772
  }
17773
+ /**
17774
+ * Re-subscribe after the OS dropped the watch — a remount, a wake from
17775
+ * sleep, an inotify limit easing. Treating those as terminal left a runner
17776
+ * watching nothing for the rest of its life. `resync` on the way back up,
17777
+ * since whatever changed while it was down produced no events.
17778
+ */
17779
+ retry() {
17780
+ if (this.closed || this.retryTimer) return;
17781
+ const sub = this.sub;
17782
+ this.sub = null;
17783
+ if (sub) void sub.unsubscribe().catch(() => void 0);
17784
+ const delay = this.retryDelay;
17785
+ this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_MS);
17786
+ this.retryTimer = setTimeout(() => {
17787
+ this.retryTimer = null;
17788
+ if (this.closed) return;
17789
+ void this.open().then(() => {
17790
+ if (this.sub) {
17791
+ this.emit({
17792
+ added: [],
17793
+ changed: [],
17794
+ removed: [],
17795
+ resync: true
17796
+ });
17797
+ }
17798
+ });
17799
+ }, delay);
17800
+ }
17762
17801
  /** Give up and say so once, so the UI falls back to manual refresh rather
17763
17802
  * than showing a silently frozen tree. */
17764
17803
  stop() {
@@ -17794,6 +17833,10 @@ var WorkspaceWatcher = class {
17794
17833
  this.flushTimer = null;
17795
17834
  }
17796
17835
  this.pending = emptyPending();
17836
+ if (this.retryTimer) {
17837
+ clearTimeout(this.retryTimer);
17838
+ this.retryTimer = null;
17839
+ }
17797
17840
  this.selfWrites.clear();
17798
17841
  const sub = this.sub;
17799
17842
  this.sub = null;
@@ -18013,6 +18056,7 @@ var TerminalModeEngine = class {
18013
18056
  paths.worktrees,
18014
18057
  paths.repo,
18015
18058
  paths.workspace,
18059
+ paths.scratchpad,
18016
18060
  `${paths.root}/drops`
18017
18061
  ];
18018
18062
  const watcher = new WorkspaceWatcher(
@@ -19854,6 +19898,204 @@ async function getDriveRoot(accessToken) {
19854
19898
  };
19855
19899
  }
19856
19900
 
19901
+ // ../../shared/all/helpers/scratchpad.ts
19902
+ var SCRATCHPAD_MAX_BYTES = 12e6;
19903
+ var SCRATCHPAD_MAX_ITEMS = 200;
19904
+ var SCRATCHPAD_CHUNK_BYTES = 512 * 1024;
19905
+ var SCRATCHPAD_MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
19906
+ var KIND_BY_EXT = {
19907
+ png: "image",
19908
+ jpg: "image",
19909
+ jpeg: "image",
19910
+ gif: "image",
19911
+ webp: "image",
19912
+ avif: "image",
19913
+ bmp: "image",
19914
+ svg: "image",
19915
+ mp4: "video",
19916
+ webm: "video",
19917
+ mov: "video",
19918
+ m4v: "video",
19919
+ mp3: "audio",
19920
+ wav: "audio",
19921
+ m4a: "audio",
19922
+ ogg: "audio",
19923
+ aac: "audio",
19924
+ flac: "audio",
19925
+ pdf: "pdf",
19926
+ html: "html",
19927
+ htm: "html",
19928
+ md: "markdown",
19929
+ markdown: "markdown",
19930
+ url: "link"
19931
+ };
19932
+ function scratchpadExt(name) {
19933
+ const dot = name.lastIndexOf(".");
19934
+ if (dot <= 0 || dot === name.length - 1) return "";
19935
+ return name.slice(dot + 1).toLowerCase();
19936
+ }
19937
+ function scratchpadKind(name) {
19938
+ return KIND_BY_EXT[scratchpadExt(name)] ?? "text";
19939
+ }
19940
+ function isSafeScratchpadName(name) {
19941
+ if (!name || name.length > 255) return false;
19942
+ if (name.startsWith(".")) return false;
19943
+ return /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(name);
19944
+ }
19945
+
19946
+ // ../../shared/all/helpers/board-agent-core/scratchpad-store.ts
19947
+ function shEscape2(s) {
19948
+ return `'${s.replace(/'/g, `'\\''`)}'`;
19949
+ }
19950
+ var STAT_META = `stat -f '%m %z' "$f" 2>/dev/null || stat -c '%Y %s' "$f" 2>/dev/null`;
19951
+ var TOO_LARGE = "Scratchpad item is too large to open here.";
19952
+ var ScratchpadStore = class {
19953
+ /**
19954
+ * @param target Resolved per call, never held: the space's exec target is
19955
+ * rebuilt as the space provisions, and a captured one would go
19956
+ * on pointing at the tree it was built for.
19957
+ * @param emit Tells connected clients the directory moved. Carries a
19958
+ * timestamp only: the panel refetches the whole listing.
19959
+ */
19960
+ constructor(target, emit) {
19961
+ this.target = target;
19962
+ this.emit = emit;
19963
+ this.watcher = null;
19964
+ }
19965
+ dir() {
19966
+ return this.target().paths.scratchpad;
19967
+ }
19968
+ /**
19969
+ * Everything on the scratchpad, newest first.
19970
+ *
19971
+ * Dotfiles are skipped: the panel is a display surface, and `.DS_Store` is
19972
+ * not something anyone drew. So are subdirectories: the scratchpad is flat,
19973
+ * which is what lets a name be an id everywhere else in this file.
19974
+ */
19975
+ async list() {
19976
+ const dir = this.dir();
19977
+ const out = await this.target().run([
19978
+ "sh",
19979
+ "-c",
19980
+ `mkdir -p ${shEscape2(dir)} && cd ${shEscape2(dir)} && for f in *; do
19981
+ [ -f "$f" ] || continue
19982
+ meta=$(${STAT_META}) || continue
19983
+ echo "$meta $f"
19984
+ done | LC_ALL=C sort -rn | head -n ${SCRATCHPAD_MAX_ITEMS}`
19985
+ ]);
19986
+ this.startWatching();
19987
+ const items = [];
19988
+ for (const line of out.split("\n")) {
19989
+ const match = /^(\d+) (\d+) (.+)$/.exec(line);
19990
+ if (!match) continue;
19991
+ const [, mtime, size, name] = match;
19992
+ if (!isSafeScratchpadName(name)) continue;
19993
+ const bytes = Number(size);
19994
+ items.push({
19995
+ name,
19996
+ kind: scratchpadKind(name),
19997
+ size: bytes,
19998
+ // stat reports seconds; every other timestamp on the wire is ms.
19999
+ modifiedAt: Number(mtime) * 1e3,
20000
+ tooLarge: bytes > SCRATCHPAD_MAX_BYTES
20001
+ });
20002
+ }
20003
+ return items;
20004
+ }
20005
+ /**
20006
+ * One item's bytes, base64. Capped: past SCRATCHPAD_MAX_BYTES the panel is
20007
+ * told to show the path instead, so a 4GB screen recording cannot pin the
20008
+ * data channel for minutes.
20009
+ *
20010
+ * One shell, not a size check and then a read: the panel opens every card at
20011
+ * once, so a second spawn per item is a doubled cost on the whole listing -
20012
+ * and a file that changed between the two calls would have been read past
20013
+ * the cap anyway.
20014
+ */
20015
+ async read(name) {
20016
+ const file = shEscape2(this.resolve(name));
20017
+ const content = await this.target().sh(
20018
+ // Unquoted `$(wc -c)` on purpose: BSD wc pads its count with spaces,
20019
+ // which `[ -gt ]` will not parse. `base64 -w 0` is GNU-only (BSD
20020
+ // spells it -b), so unwrap with tr rather than asking it not to wrap.
20021
+ `[ -f ${file} ] || exit 1
20022
+ if [ $(wc -c < ${file}) -gt ${SCRATCHPAD_MAX_BYTES} ]; then
20023
+ echo '${TOO_LARGE}' >&2; exit 1
20024
+ fi
20025
+ base64 < ${file} | tr -d '
20026
+ '`
20027
+ ).catch((err) => {
20028
+ const text = err instanceof Error ? err.message : "";
20029
+ throw new Error(
20030
+ text.includes(TOO_LARGE) ? TOO_LARGE : "Could not read that item."
20031
+ );
20032
+ });
20033
+ return { content: content.trim(), encoding: "base64" };
20034
+ }
20035
+ /**
20036
+ * One byte range of an item, base64. What a download is built out of.
20037
+ *
20038
+ * No size cap here, unlike `read`: the cap exists because a preview holds
20039
+ * the whole file in one message, and a range does not. `tail | head` rather
20040
+ * than `dd`, which needs a GNU-only flag to promise a full block over a pipe.
20041
+ */
20042
+ async readChunk(name, offset, length) {
20043
+ if (!Number.isInteger(offset) || offset < 0) {
20044
+ throw new Error("Invalid scratchpad offset.");
20045
+ }
20046
+ if (!Number.isInteger(length) || length <= 0 || length > SCRATCHPAD_CHUNK_BYTES) {
20047
+ throw new Error("Invalid scratchpad chunk length.");
20048
+ }
20049
+ const file = shEscape2(this.resolve(name));
20050
+ const content = await this.target().sh(
20051
+ // `tail -c +N` counts from 1, so the first byte is offset 0 plus one.
20052
+ `[ -f ${file} ] || exit 1
20053
+ tail -c +${offset + 1} ${file} | head -c ${length} | base64 | tr -d '
20054
+ '`
20055
+ ).catch(() => {
20056
+ throw new Error("Could not read that item.");
20057
+ });
20058
+ return { content: content.trim(), encoding: "base64" };
20059
+ }
20060
+ async remove(name) {
20061
+ await this.target().run(["rm", "-f", "--", this.resolve(name)]);
20062
+ }
20063
+ /** Files only, and only at the top level: whatever put a directory in there
20064
+ * is not something this panel drew, and `rm -rf` on an agent-writable path
20065
+ * is a worse tool than this job needs. */
20066
+ async clear() {
20067
+ const dir = shEscape2(this.dir());
20068
+ await this.target().run([
20069
+ "sh",
20070
+ "-c",
20071
+ `mkdir -p ${dir} && cd ${dir} && find . -maxdepth 1 -type f -delete`
20072
+ ]);
20073
+ }
20074
+ destroy() {
20075
+ const watcher = this.watcher;
20076
+ this.watcher = null;
20077
+ if (watcher) void watcher.close();
20078
+ }
20079
+ /** Idempotent: every panel that opens calls `list`, and they must not stack
20080
+ * watchers on one directory. */
20081
+ startWatching() {
20082
+ if (this.watcher) return;
20083
+ const watcher = new WorkspaceWatcher(this.dir(), () => {
20084
+ this.emit({ at: Date.now() });
20085
+ });
20086
+ this.watcher = watcher;
20087
+ watcher.start();
20088
+ }
20089
+ /** Absolute path for a name the agent chose. Flat by construction: the name
20090
+ * is validated, never joined with anything a caller supplied. */
20091
+ resolve(name) {
20092
+ if (!isSafeScratchpadName(name)) {
20093
+ throw new Error("Invalid scratchpad item name.");
20094
+ }
20095
+ return `${this.dir()}/${name}`;
20096
+ }
20097
+ };
20098
+
19857
20099
  // ../../shared/all/helpers/board-agent-core/space-core.ts
19858
20100
  var import_promises10 = require("fs/promises");
19859
20101
  var import_path14 = __toESM(require("path"));
@@ -20369,7 +20611,7 @@ var SpaceCore = class {
20369
20611
  status("Preparing workspace\u2026");
20370
20612
  const { paths } = target;
20371
20613
  await target.sh(
20372
- `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin}`
20614
+ `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin} ${paths.scratchpad}`
20373
20615
  );
20374
20616
  await allocateSpacePorts(this.spaceDir());
20375
20617
  await this.ensureSecretsBridge().catch(() => void 0);
@@ -20712,6 +20954,10 @@ var BoardAgentEngine = class _BoardAgentEngine {
20712
20954
  this.emitter = options.emitter;
20713
20955
  this.claude = new ClaudeModeEngine(this.core, this.emitter);
20714
20956
  this.terminal = new TerminalModeEngine(this.core, this.emitter);
20957
+ this.scratchpad = new ScratchpadStore(
20958
+ () => this.core.target(),
20959
+ (payload) => this.emitter.emitScratchpad(payload)
20960
+ );
20715
20961
  this.gate.subscribe(() => this.projectGateOntoRun());
20716
20962
  }
20717
20963
  /** Everything the runner does for this space that isn't a card session, so
@@ -21688,6 +21934,22 @@ var BoardAgentEngine = class _BoardAgentEngine {
21688
21934
  bareListExposed(p) {
21689
21935
  return this.terminal.bareListExposed(p);
21690
21936
  }
21937
+ // ── Scratchpad (→ the space's display surface) ──
21938
+ scratchpadList() {
21939
+ return this.scratchpad.list();
21940
+ }
21941
+ scratchpadRead(p) {
21942
+ return this.scratchpad.read(p.name);
21943
+ }
21944
+ scratchpadReadChunk(p) {
21945
+ return this.scratchpad.readChunk(p.name, p.offset, p.length);
21946
+ }
21947
+ scratchpadDelete(p) {
21948
+ return this.scratchpad.remove(p.name);
21949
+ }
21950
+ scratchpadClear() {
21951
+ return this.scratchpad.clear();
21952
+ }
21691
21953
  // ── Cross-mode coordinators ──
21692
21954
  async cardExecute(payload) {
21693
21955
  const {
@@ -22017,6 +22279,7 @@ ${c.content.trim() || "(no description)"}`
22017
22279
  void this.codexLogin?.cancel();
22018
22280
  this.claude.destroy();
22019
22281
  this.terminal.destroy();
22282
+ this.scratchpad.destroy();
22020
22283
  this.core.destroy();
22021
22284
  }
22022
22285
  /** Route one action/payload to the right engine method, for both transports.
@@ -22347,6 +22610,24 @@ ${c.content.trim() || "(no description)"}`
22347
22610
  return await this.bareListExposed(
22348
22611
  payload
22349
22612
  );
22613
+ case "scratchpadList":
22614
+ return await this.scratchpadList();
22615
+ case "scratchpadRead":
22616
+ return await this.scratchpadRead(
22617
+ payload
22618
+ );
22619
+ case "scratchpadReadChunk":
22620
+ return await this.scratchpadReadChunk(
22621
+ payload
22622
+ );
22623
+ case "scratchpadDelete":
22624
+ await this.scratchpadDelete(
22625
+ payload
22626
+ );
22627
+ return void 0;
22628
+ case "scratchpadClear":
22629
+ await this.scratchpadClear();
22630
+ return void 0;
22350
22631
  case "secureStatus":
22351
22632
  return this.secureStatus();
22352
22633
  default:
@@ -27361,7 +27642,8 @@ var localConfigProvider = {
27361
27642
  "builds",
27362
27643
  "secrets",
27363
27644
  "claude",
27364
- "codex"
27645
+ "codex",
27646
+ "scratchpad"
27365
27647
  ]) {
27366
27648
  import_fs11.default.mkdirSync(import_path23.default.join(dir, sub), { recursive: true });
27367
27649
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factiii/runner",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "description": "Factiii Runner, run Board AI agents on a machine you control. Pairs with the Factiii web/mobile clients over WebRTC.",
5
5
  "license": "ISC",
6
6
  "keywords": [
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: scratchpad
3
+ description: Show the user something, or hand them a file, through the board's Scratchpad panel - by writing to $SCRATCHPAD. Anything you put there renders beside the terminal (image, screenshot, chart, video, audio, page, link, text) and can be saved to their machine or phone. TRIGGER when the user asks to see, show, preview, display or look at something; when you produce an image/diagram/chart/screenshot/recording; when you have a URL worth opening; when an answer is easier to look at than to read in a terminal; and whenever you need to GIVE the user a file - an export, a report, a bundle, a CSV, a log, a dump, anything they asked you to generate, save, download or send them. Writing it to $SCRATCHPAD is how a file gets off the runner and to the user.
4
+ ---
5
+
6
+ # scratchpad
7
+
8
+ The terminal you are running in has a panel beside it. Anything you put in the
9
+ `$SCRATCHPAD` directory shows up there, drawn rather than printed: images
10
+ render, video and audio play, links become clickable, text and markdown are
11
+ shown as they are.
12
+
13
+ ```bash
14
+ cp chart.png "$SCRATCHPAD/"
15
+ ```
16
+
17
+ That is the whole interface. There is no command to run, nothing to install,
18
+ and nothing to tell the panel: it is watching the directory and updates on
19
+ its own within a second.
20
+
21
+ ## What renders as what
22
+
23
+ The **extension decides**, so name the file for what it is:
24
+
25
+ | Write | You get |
26
+ |---|---|
27
+ | `.png .jpg .gif .webp .svg` | the image, click-to-open-full-size |
28
+ | `.mp4 .webm .mov` | a video player |
29
+ | `.mp3 .wav .m4a .ogg` | an audio player |
30
+ | `.html` | the page itself, rendered in a sandboxed frame |
31
+ | `.md` `.txt` and anything unlisted | the text as-is |
32
+ | `.url` | a clickable link (put the URL on the first line) |
33
+
34
+ A link:
35
+
36
+ ```bash
37
+ echo "https://example.com/the-thing" > "$SCRATCHPAD/preview.url"
38
+ ```
39
+
40
+ A note, when the answer is a shape rather than a sentence:
41
+
42
+ ```bash
43
+ cat > "$SCRATCHPAD/rollout-plan.md" <<'MD'
44
+ # Rollout
45
+ 1. staging, behind the flag
46
+ 2. 10% of prod for a day
47
+ 3. everyone
48
+ MD
49
+ ```
50
+
51
+ ## Handing a file to the user
52
+
53
+ Every item on the panel has a Save button, and the panel pulls the file off the
54
+ runner for them. So the scratchpad is not only for looking at: it is how you
55
+ give someone a file.
56
+
57
+ ```bash
58
+ cp coverage-report.csv "$SCRATCHPAD/"
59
+ zip -qr "$SCRATCHPAD/failing-run-logs.zip" logs/
60
+ pg_dump "$DATABASE_URL" > "$SCRATCHPAD/schema-before-migration.sql"
61
+ ```
62
+
63
+ A file meant for keeping does not have to render well. A `.zip` shows as
64
+ nothing much and a `.csv` as raw text, which is fine, because the user is going
65
+ to save it rather than read it in a narrow column. Name it for what it is so
66
+ they know what they are saving, and say in one line that it is there.
67
+
68
+ Nothing else you can do reaches the user's disk. If you write a file into the
69
+ workspace and tell them where it is, they have to go and get it themselves,
70
+ which on a phone means they cannot.
71
+
72
+ ## Rules
73
+
74
+ - **Flat, plain names.** Letters, numbers, dots, dashes, spaces and
75
+ underscores; no subdirectories, and never a leading dot. A name the panel
76
+ cannot show is a drawing the user never sees.
77
+ - **Newest is shown first.** Rewriting a file under the same name replaces its
78
+ card and moves it to the top, which is how you update a drawing rather than
79
+ pile up `chart-2.png`, `chart-3.png`.
80
+ - **Under 12MB to be shown inline.** A bigger file is still listed, and the
81
+ user can download it from the panel, but it will not render on the stage.
82
+ Trim a recording if you want it watched rather than saved.
83
+ - **The board is shared.** One scratchpad per board, not per session, and it
84
+ survives the session that drew on it. Clean up what has gone stale:
85
+ `rm "$SCRATCHPAD/old-thing.png"`.
86
+
87
+ ## When to reach for it
88
+
89
+ Use it whenever the answer is easier to look at than to read: a screenshot of
90
+ the change you just made, a generated chart, a diagram, a recorded repro, a
91
+ preview URL you just exposed, a table too wide for an 80-column shell.
92
+
93
+ And use it for anything the user is meant to end up holding: the export they
94
+ asked for, the report you generated, the logs from the run that failed, the
95
+ backup you just took.
96
+
97
+ Do not narrate it into the terminal as well. Write the file and say, in one
98
+ line, what you put on the scratchpad - the user is looking at the panel.