@factiii/runner 0.12.2 → 0.13.0

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
  }
@@ -18013,6 +18018,7 @@ var TerminalModeEngine = class {
18013
18018
  paths.worktrees,
18014
18019
  paths.repo,
18015
18020
  paths.workspace,
18021
+ paths.scratchpad,
18016
18022
  `${paths.root}/drops`
18017
18023
  ];
18018
18024
  const watcher = new WorkspaceWatcher(
@@ -19854,6 +19860,204 @@ async function getDriveRoot(accessToken) {
19854
19860
  };
19855
19861
  }
19856
19862
 
19863
+ // ../../shared/all/helpers/scratchpad.ts
19864
+ var SCRATCHPAD_MAX_BYTES = 12e6;
19865
+ var SCRATCHPAD_MAX_ITEMS = 200;
19866
+ var SCRATCHPAD_CHUNK_BYTES = 512 * 1024;
19867
+ var SCRATCHPAD_MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
19868
+ var KIND_BY_EXT = {
19869
+ png: "image",
19870
+ jpg: "image",
19871
+ jpeg: "image",
19872
+ gif: "image",
19873
+ webp: "image",
19874
+ avif: "image",
19875
+ bmp: "image",
19876
+ svg: "image",
19877
+ mp4: "video",
19878
+ webm: "video",
19879
+ mov: "video",
19880
+ m4v: "video",
19881
+ mp3: "audio",
19882
+ wav: "audio",
19883
+ m4a: "audio",
19884
+ ogg: "audio",
19885
+ aac: "audio",
19886
+ flac: "audio",
19887
+ pdf: "pdf",
19888
+ html: "html",
19889
+ htm: "html",
19890
+ md: "markdown",
19891
+ markdown: "markdown",
19892
+ url: "link"
19893
+ };
19894
+ function scratchpadExt(name) {
19895
+ const dot = name.lastIndexOf(".");
19896
+ if (dot <= 0 || dot === name.length - 1) return "";
19897
+ return name.slice(dot + 1).toLowerCase();
19898
+ }
19899
+ function scratchpadKind(name) {
19900
+ return KIND_BY_EXT[scratchpadExt(name)] ?? "text";
19901
+ }
19902
+ function isSafeScratchpadName(name) {
19903
+ if (!name || name.length > 255) return false;
19904
+ if (name.startsWith(".")) return false;
19905
+ return /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(name);
19906
+ }
19907
+
19908
+ // ../../shared/all/helpers/board-agent-core/scratchpad-store.ts
19909
+ function shEscape2(s) {
19910
+ return `'${s.replace(/'/g, `'\\''`)}'`;
19911
+ }
19912
+ var STAT_META = `stat -f '%m %z' "$f" 2>/dev/null || stat -c '%Y %s' "$f" 2>/dev/null`;
19913
+ var TOO_LARGE = "Scratchpad item is too large to open here.";
19914
+ var ScratchpadStore = class {
19915
+ /**
19916
+ * @param target Resolved per call, never held: the space's exec target is
19917
+ * rebuilt as the space provisions, and a captured one would go
19918
+ * on pointing at the tree it was built for.
19919
+ * @param emit Tells connected clients the directory moved. Carries a
19920
+ * timestamp only: the panel refetches the whole listing.
19921
+ */
19922
+ constructor(target, emit) {
19923
+ this.target = target;
19924
+ this.emit = emit;
19925
+ this.watcher = null;
19926
+ }
19927
+ dir() {
19928
+ return this.target().paths.scratchpad;
19929
+ }
19930
+ /**
19931
+ * Everything on the scratchpad, newest first.
19932
+ *
19933
+ * Dotfiles are skipped: the panel is a display surface, and `.DS_Store` is
19934
+ * not something anyone drew. So are subdirectories: the scratchpad is flat,
19935
+ * which is what lets a name be an id everywhere else in this file.
19936
+ */
19937
+ async list() {
19938
+ const dir = this.dir();
19939
+ this.startWatching();
19940
+ const out = await this.target().run([
19941
+ "sh",
19942
+ "-c",
19943
+ `mkdir -p ${shEscape2(dir)} && cd ${shEscape2(dir)} && for f in *; do
19944
+ [ -f "$f" ] || continue
19945
+ meta=$(${STAT_META}) || continue
19946
+ echo "$meta $f"
19947
+ done | LC_ALL=C sort -rn | head -n ${SCRATCHPAD_MAX_ITEMS}`
19948
+ ]);
19949
+ const items = [];
19950
+ for (const line of out.split("\n")) {
19951
+ const match = /^(\d+) (\d+) (.+)$/.exec(line);
19952
+ if (!match) continue;
19953
+ const [, mtime, size, name] = match;
19954
+ if (!isSafeScratchpadName(name)) continue;
19955
+ const bytes = Number(size);
19956
+ items.push({
19957
+ name,
19958
+ kind: scratchpadKind(name),
19959
+ size: bytes,
19960
+ // stat reports seconds; every other timestamp on the wire is ms.
19961
+ modifiedAt: Number(mtime) * 1e3,
19962
+ tooLarge: bytes > SCRATCHPAD_MAX_BYTES
19963
+ });
19964
+ }
19965
+ return items;
19966
+ }
19967
+ /**
19968
+ * One item's bytes, base64. Capped: past SCRATCHPAD_MAX_BYTES the panel is
19969
+ * told to show the path instead, so a 4GB screen recording cannot pin the
19970
+ * data channel for minutes.
19971
+ *
19972
+ * One shell, not a size check and then a read: the panel opens every card at
19973
+ * once, so a second spawn per item is a doubled cost on the whole listing -
19974
+ * and a file that changed between the two calls would have been read past
19975
+ * the cap anyway.
19976
+ */
19977
+ async read(name) {
19978
+ const file = shEscape2(this.resolve(name));
19979
+ const content = await this.target().sh(
19980
+ // Unquoted `$(wc -c)` on purpose: BSD wc pads its count with spaces,
19981
+ // which `[ -gt ]` will not parse. `base64 -w 0` is GNU-only (BSD
19982
+ // spells it -b), so unwrap with tr rather than asking it not to wrap.
19983
+ `[ -f ${file} ] || exit 1
19984
+ if [ $(wc -c < ${file}) -gt ${SCRATCHPAD_MAX_BYTES} ]; then
19985
+ echo '${TOO_LARGE}' >&2; exit 1
19986
+ fi
19987
+ base64 < ${file} | tr -d '
19988
+ '`
19989
+ ).catch((err) => {
19990
+ const text = err instanceof Error ? err.message : "";
19991
+ throw new Error(
19992
+ text.includes(TOO_LARGE) ? TOO_LARGE : "Could not read that item."
19993
+ );
19994
+ });
19995
+ return { content: content.trim(), encoding: "base64" };
19996
+ }
19997
+ /**
19998
+ * One byte range of an item, base64. What a download is built out of.
19999
+ *
20000
+ * No size cap here, unlike `read`: the cap exists because a preview holds
20001
+ * the whole file in one message, and a range does not. `tail | head` rather
20002
+ * than `dd`, which needs a GNU-only flag to promise a full block over a pipe.
20003
+ */
20004
+ async readChunk(name, offset, length) {
20005
+ if (!Number.isInteger(offset) || offset < 0) {
20006
+ throw new Error("Invalid scratchpad offset.");
20007
+ }
20008
+ if (!Number.isInteger(length) || length <= 0 || length > SCRATCHPAD_CHUNK_BYTES) {
20009
+ throw new Error("Invalid scratchpad chunk length.");
20010
+ }
20011
+ const file = shEscape2(this.resolve(name));
20012
+ const content = await this.target().sh(
20013
+ // `tail -c +N` counts from 1, so the first byte is offset 0 plus one.
20014
+ `[ -f ${file} ] || exit 1
20015
+ tail -c +${offset + 1} ${file} | head -c ${length} | base64 | tr -d '
20016
+ '`
20017
+ ).catch(() => {
20018
+ throw new Error("Could not read that item.");
20019
+ });
20020
+ return { content: content.trim(), encoding: "base64" };
20021
+ }
20022
+ async remove(name) {
20023
+ await this.target().run(["rm", "-f", "--", this.resolve(name)]);
20024
+ }
20025
+ /** Files only, and only at the top level: whatever put a directory in there
20026
+ * is not something this panel drew, and `rm -rf` on an agent-writable path
20027
+ * is a worse tool than this job needs. */
20028
+ async clear() {
20029
+ const dir = shEscape2(this.dir());
20030
+ await this.target().run([
20031
+ "sh",
20032
+ "-c",
20033
+ `mkdir -p ${dir} && cd ${dir} && find . -maxdepth 1 -type f -delete`
20034
+ ]);
20035
+ }
20036
+ destroy() {
20037
+ const watcher = this.watcher;
20038
+ this.watcher = null;
20039
+ if (watcher) void watcher.close();
20040
+ }
20041
+ /** Idempotent: every panel that opens calls `list`, and they must not stack
20042
+ * watchers on one directory. */
20043
+ startWatching() {
20044
+ if (this.watcher) return;
20045
+ const watcher = new WorkspaceWatcher(this.dir(), () => {
20046
+ this.emit({ at: Date.now() });
20047
+ });
20048
+ this.watcher = watcher;
20049
+ watcher.start();
20050
+ }
20051
+ /** Absolute path for a name the agent chose. Flat by construction: the name
20052
+ * is validated, never joined with anything a caller supplied. */
20053
+ resolve(name) {
20054
+ if (!isSafeScratchpadName(name)) {
20055
+ throw new Error("Invalid scratchpad item name.");
20056
+ }
20057
+ return `${this.dir()}/${name}`;
20058
+ }
20059
+ };
20060
+
19857
20061
  // ../../shared/all/helpers/board-agent-core/space-core.ts
19858
20062
  var import_promises10 = require("fs/promises");
19859
20063
  var import_path14 = __toESM(require("path"));
@@ -20369,7 +20573,7 @@ var SpaceCore = class {
20369
20573
  status("Preparing workspace\u2026");
20370
20574
  const { paths } = target;
20371
20575
  await target.sh(
20372
- `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin}`
20576
+ `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin} ${paths.scratchpad}`
20373
20577
  );
20374
20578
  await allocateSpacePorts(this.spaceDir());
20375
20579
  await this.ensureSecretsBridge().catch(() => void 0);
@@ -20712,6 +20916,10 @@ var BoardAgentEngine = class _BoardAgentEngine {
20712
20916
  this.emitter = options.emitter;
20713
20917
  this.claude = new ClaudeModeEngine(this.core, this.emitter);
20714
20918
  this.terminal = new TerminalModeEngine(this.core, this.emitter);
20919
+ this.scratchpad = new ScratchpadStore(
20920
+ () => this.core.target(),
20921
+ (payload) => this.emitter.emitScratchpad(payload)
20922
+ );
20715
20923
  this.gate.subscribe(() => this.projectGateOntoRun());
20716
20924
  }
20717
20925
  /** Everything the runner does for this space that isn't a card session, so
@@ -21688,6 +21896,22 @@ var BoardAgentEngine = class _BoardAgentEngine {
21688
21896
  bareListExposed(p) {
21689
21897
  return this.terminal.bareListExposed(p);
21690
21898
  }
21899
+ // ── Scratchpad (→ the space's display surface) ──
21900
+ scratchpadList() {
21901
+ return this.scratchpad.list();
21902
+ }
21903
+ scratchpadRead(p) {
21904
+ return this.scratchpad.read(p.name);
21905
+ }
21906
+ scratchpadReadChunk(p) {
21907
+ return this.scratchpad.readChunk(p.name, p.offset, p.length);
21908
+ }
21909
+ scratchpadDelete(p) {
21910
+ return this.scratchpad.remove(p.name);
21911
+ }
21912
+ scratchpadClear() {
21913
+ return this.scratchpad.clear();
21914
+ }
21691
21915
  // ── Cross-mode coordinators ──
21692
21916
  async cardExecute(payload) {
21693
21917
  const {
@@ -22017,6 +22241,7 @@ ${c.content.trim() || "(no description)"}`
22017
22241
  void this.codexLogin?.cancel();
22018
22242
  this.claude.destroy();
22019
22243
  this.terminal.destroy();
22244
+ this.scratchpad.destroy();
22020
22245
  this.core.destroy();
22021
22246
  }
22022
22247
  /** Route one action/payload to the right engine method, for both transports.
@@ -22347,6 +22572,24 @@ ${c.content.trim() || "(no description)"}`
22347
22572
  return await this.bareListExposed(
22348
22573
  payload
22349
22574
  );
22575
+ case "scratchpadList":
22576
+ return await this.scratchpadList();
22577
+ case "scratchpadRead":
22578
+ return await this.scratchpadRead(
22579
+ payload
22580
+ );
22581
+ case "scratchpadReadChunk":
22582
+ return await this.scratchpadReadChunk(
22583
+ payload
22584
+ );
22585
+ case "scratchpadDelete":
22586
+ await this.scratchpadDelete(
22587
+ payload
22588
+ );
22589
+ return void 0;
22590
+ case "scratchpadClear":
22591
+ await this.scratchpadClear();
22592
+ return void 0;
22350
22593
  case "secureStatus":
22351
22594
  return this.secureStatus();
22352
22595
  default:
@@ -27361,7 +27604,8 @@ var localConfigProvider = {
27361
27604
  "builds",
27362
27605
  "secrets",
27363
27606
  "claude",
27364
- "codex"
27607
+ "codex",
27608
+ "scratchpad"
27365
27609
  ]) {
27366
27610
  import_fs11.default.mkdirSync(import_path23.default.join(dir, sub), { recursive: true });
27367
27611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factiii/runner",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
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.