@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 +247 -3
- package/package.json +1 -1
- package/plugin/factiii/skills/scratchpad/SKILL.md +98 -0
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
|
@@ -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.
|