pi-kanban 0.3.1 → 2.0.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +61 -21
  3. package/cli.js +1586 -0
  4. package/extensions/kanban.ts +330 -367
  5. package/extensions/package.json +1 -0
  6. package/extensions/tools/ask.ts +85 -0
  7. package/extensions/tools/board-api.ts +142 -0
  8. package/extensions/tools/cli-path.ts +34 -0
  9. package/extensions/tools/dispatch.ts +56 -0
  10. package/extensions/tools/inbox.ts +86 -0
  11. package/extensions/tools/kanban-command.ts +27 -0
  12. package/extensions/tools/scratchpad.ts +70 -0
  13. package/extensions/tsconfig.json +16 -0
  14. package/lib/agent-dir.js +20 -0
  15. package/lib/agent-log.js +64 -0
  16. package/lib/ask.js +252 -0
  17. package/lib/atomic-write.js +30 -0
  18. package/lib/board-dir.js +63 -0
  19. package/lib/claude-settings.js +21 -0
  20. package/lib/companion-host.js +142 -0
  21. package/lib/companions.js +136 -0
  22. package/lib/contain.js +78 -0
  23. package/lib/dispatch-reports.js +86 -0
  24. package/lib/file-url.js +38 -0
  25. package/lib/folder-dialog.js +91 -0
  26. package/lib/frame-policy.js +79 -0
  27. package/lib/git-branch.js +37 -102
  28. package/lib/http-error.js +7 -0
  29. package/lib/inline-assets.js +238 -0
  30. package/lib/linked-docs.js +56 -0
  31. package/lib/live-sessions.js +86 -0
  32. package/lib/model-windows.js +48 -0
  33. package/lib/net-guard.js +277 -0
  34. package/lib/one-line.js +6 -0
  35. package/lib/open-editor.js +173 -0
  36. package/lib/panes.js +213 -0
  37. package/lib/parsers.js +1434 -0
  38. package/lib/pi-agents.js +178 -0
  39. package/lib/pi-git-branch.js +112 -0
  40. package/lib/pi-models.js +34 -0
  41. package/lib/pi-sessions.js +240 -0
  42. package/lib/pi-transcript.js +354 -0
  43. package/lib/preview-action.js +55 -0
  44. package/lib/priority.js +45 -0
  45. package/lib/proc-stats.js +82 -0
  46. package/lib/retention.js +191 -0
  47. package/lib/scratch-files.js +47 -0
  48. package/lib/scratchpad-dir.js +95 -0
  49. package/lib/session-cache.js +32 -0
  50. package/lib/session-events.js +275 -0
  51. package/lib/session-pins.js +99 -0
  52. package/lib/show.js +279 -0
  53. package/lib/stamped-json-file.js +49 -0
  54. package/lib/task-counts.js +90 -0
  55. package/lib/task-dir.js +26 -0
  56. package/lib/task-store.js +44 -47
  57. package/lib/terminal-client.js +248 -0
  58. package/lib/terminal-host.js +113 -0
  59. package/lib/terminal.js +1116 -0
  60. package/lib/user-groups.js +384 -0
  61. package/lib/worktrees.js +132 -0
  62. package/package.json +39 -15
  63. package/public/app.js +14349 -3448
  64. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  65. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  66. package/public/fonts/fonts.css +244 -0
  67. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  68. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  69. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  70. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  71. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  72. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  73. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  74. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  75. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  76. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  77. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  78. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  79. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  80. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  81. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  82. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  83. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  84. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  85. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  86. package/public/group-tree.js +21 -0
  87. package/public/icons/icon-192.png +0 -0
  88. package/public/icons/icon-512.png +0 -0
  89. package/public/icons/icon-svg.svg +7 -5
  90. package/public/index.html +493 -328
  91. package/public/link-url.js +18 -0
  92. package/public/manifest.json +7 -7
  93. package/public/project-match.js +34 -0
  94. package/public/style.css +8301 -2251
  95. package/public/sw.js +4 -3
  96. package/public/terminal-frame.js +437 -0
  97. package/public/terminal.html +26 -0
  98. package/public/themes.css +876 -0
  99. package/public/tree-highlight.js +196 -0
  100. package/server.js +3368 -467
  101. package/skills/ask/SKILL.md +29 -0
  102. package/skills/ask/references/playground.md +25 -0
  103. package/skills/dispatch/SKILL.md +51 -0
  104. package/skills/dispatch/references/orchestration-patterns.md +50 -0
  105. package/skills/kanban/SKILL.md +46 -0
  106. package/skills/show/SKILL.md +41 -0
  107. package/skills/show/references/NOTICE.md +27 -0
  108. package/skills/show/references/chart.md +58 -0
  109. package/skills/show/references/diagram/architecture.md +18 -0
  110. package/skills/show/references/diagram/er.md +10 -0
  111. package/skills/show/references/diagram/flow.md +13 -0
  112. package/skills/show/references/diagram/layers.md +10 -0
  113. package/skills/show/references/diagram/sequence.md +19 -0
  114. package/skills/show/references/diagram/state.md +12 -0
  115. package/skills/show/references/diagram/swimlane.md +10 -0
  116. package/skills/show/references/diagram/timeline.md +9 -0
  117. package/skills/show/references/diagram/tree.md +10 -0
  118. package/skills/show/references/diagram.md +87 -0
  119. package/skills/show/references/explain.md +100 -0
  120. package/skills/show/references/html-contract.md +18 -0
  121. package/skills/show/references/mockup.md +30 -0
  122. package/skills/visualize/SKILL.md +73 -0
  123. package/skills/visualize/blocks.html +68 -0
  124. package/skills/visualize/template.html +179 -0
  125. package/lib/pi-parsers.js +0 -1000
  126. package/themes/kanban-dark-default.json +0 -21
  127. package/themes/kanban-default.json +0 -21
  128. package/themes/pi-dark.json +0 -21
  129. package/themes/pi-light.json +0 -21
@@ -0,0 +1,277 @@
1
+ 'use strict';
2
+
3
+ // Network boundary for a local-only dev server.
4
+ //
5
+ // Binding loopback is necessary but not sufficient. Three distinct attackers:
6
+ //
7
+ // * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
8
+ // outright; the handshake never completes off-box.
9
+ // * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
10
+ // name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
11
+ // The page's origin *is* evil.com:3541, so the request is same-origin and
12
+ // CORS never fires; the connection is to loopback, so the bind is satisfied.
13
+ // The only thing that differs from a legitimate request is the Host header,
14
+ // which script cannot forge. A Host allowlist is the entire defense.
15
+ // * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
16
+ // allowlisted value, so hostGuard passes it; this needs a separate Origin /
17
+ // Sec-Fetch-Site check on state-changing methods.
18
+ //
19
+ // There is no authentication. Exposing this to a network means trusting everyone
20
+ // on it with the user's pi history.
21
+
22
+ const LOOPBACK_HOSTS = new Set([
23
+ 'localhost',
24
+ '127.0.0.1',
25
+ '::1',
26
+ '0:0:0:0:0:0:0:1',
27
+ '::ffff:127.0.0.1',
28
+ ]);
29
+
30
+ // Hoisted: parseHostHeader runs on every request, and neither the regex nor the
31
+ // predicate depends on the argument.
32
+ const PORT_RE = /^[0-9]{1,5}$/;
33
+ // The port is validated but not compared — the listen(0) fallbacks make the real
34
+ // port dynamic, so only the host is allowlisted.
35
+ const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
36
+ const KEEP_ALIVE_MS = 65000;
37
+ // A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
38
+ const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
39
+
40
+ function isLoopbackAddress(host) {
41
+ if (!host) return false;
42
+ // An Origin or URL host carries an IPv6 literal in brackets.
43
+ const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
44
+ return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
45
+ }
46
+
47
+ // Hand-parsed rather than via new URL(), which is far more permissive than we
48
+ // want here and happily accepts things no real client sends. Returns the bare
49
+ // hostname, lowercased, or null if the header is malformed.
50
+ //
51
+ // The port is deliberately ignored: ports are dynamic in these apps because of
52
+ // the listen(0) fallback when the default is busy, so pinning one would break
53
+ // the fallback path.
54
+ function parseHostHeader(value) {
55
+ if (typeof value !== 'string') return null;
56
+ const raw = value.trim();
57
+ if (!raw || raw.length > 259) return null;
58
+
59
+ // Reject whitespace and control characters. Written as a code-point scan rather
60
+ // than a character class so it cannot be misread — a stray range in a regex
61
+ // here would silently reject legitimate hostnames containing a dash.
62
+ for (let i = 0; i < raw.length; i++) {
63
+ const code = raw.charCodeAt(i);
64
+ if (code <= 0x20 || code === 0x7f) return null;
65
+ }
66
+ if (raw.includes('@')) return null; // userinfo has no business in a Host header
67
+
68
+ let host;
69
+ if (raw.startsWith('[')) {
70
+ const end = raw.indexOf(']');
71
+ if (end === -1) return null;
72
+ host = raw.slice(1, end);
73
+ const rest = raw.slice(end + 1);
74
+ if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
75
+ } else {
76
+ const parts = raw.split(':');
77
+ if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
78
+ host = parts[0];
79
+ if (parts.length === 2 && !validPort(parts[1])) return null;
80
+ }
81
+
82
+ if (!host) return null;
83
+ if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
84
+ if (!host) return null;
85
+ return host.toLowerCase();
86
+ }
87
+
88
+ function parseList(value) {
89
+ return String(value || '')
90
+ .split(',')
91
+ .map((s) => s.trim().toLowerCase())
92
+ .filter(Boolean);
93
+ }
94
+
95
+ function getFlag(name) {
96
+ const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
97
+ if (idx === -1) return null;
98
+ const arg = process.argv[idx];
99
+ if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
100
+ const next = process.argv[idx + 1];
101
+ return next && !next.startsWith('--') ? next : null;
102
+ }
103
+
104
+ /**
105
+ * @param {{appName?: string, selfFramedPaths?: string[]}} [config]
106
+ */
107
+ function createNetGuard(config = {}) {
108
+ const appName = config.appName || 'This app';
109
+
110
+ const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
111
+ const BIND_HOST_LC = BIND_HOST.toLowerCase();
112
+ const EXPOSED = !isLoopbackAddress(BIND_HOST);
113
+ const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
114
+ const extraHosts = new Set(parseList(ALLOWED_HOSTS));
115
+
116
+ // Resolved only once listen() succeeds — the requested port may have been busy.
117
+ let actualPort = null;
118
+
119
+ function hostAllowed(host) {
120
+ if (!host) return false;
121
+ if (isLoopbackAddress(host)) return true;
122
+ if (extraHosts.has(host)) return true;
123
+ // Binding a specific non-loopback address implies that address is a valid name for us.
124
+ if (EXPOSED && host === BIND_HOST_LC) return true;
125
+ return false;
126
+ }
127
+
128
+ function hostGuard(req, res, next) {
129
+ const host = parseHostHeader(req.headers.host);
130
+ if (hostAllowed(host)) return next();
131
+ res.status(403).type('text/plain').send(
132
+ `403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
133
+ `${appName} only answers requests addressed to localhost. This protects you\n` +
134
+ `from DNS rebinding, where a website you visit re-points its own hostname at\n` +
135
+ `127.0.0.1 in order to read your local data.\n\n` +
136
+ `If you meant to reach this from another machine, restart with:\n` +
137
+ ` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
138
+ `Only do that on a network you trust - there is no authentication.\n`
139
+ );
140
+ }
141
+
142
+ // true = allow, false = block, null = no Origin header, caller decides.
143
+ function originVerdict(origin) {
144
+ if (!origin || origin === 'null') return null;
145
+ let parsed;
146
+ try { parsed = new URL(origin); } catch { return false; }
147
+ if (!isLoopbackAddress(parsed.hostname)) return false;
148
+ // Same port, any loopback spelling. A different local port is a different app
149
+ // and has no business driving this one.
150
+ if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
151
+ return true;
152
+ }
153
+
154
+ function originGuard(req, res, next) {
155
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
156
+
157
+ const verdict = originVerdict(req.headers.origin);
158
+ if (verdict === true) return next();
159
+ if (verdict === false) {
160
+ return res.status(403).type('text/plain')
161
+ .send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
162
+ }
163
+
164
+ // No Origin header: a non-browser client (curl, the CLI, the pi extension).
165
+ // Trust it unless the browser explicitly tells us otherwise.
166
+ const site = req.headers['sec-fetch-site'];
167
+ if (site && site !== 'same-origin' && site !== 'none') {
168
+ return res.status(403).type('text/plain')
169
+ .send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
170
+ }
171
+ return next();
172
+ }
173
+
174
+ // For a WebSocket upgrade, which originGuard must not judge: the handshake is a
175
+ // GET, and WebSocket has no CORS, so any page can open one. Browsers always send
176
+ // Origin on the handshake, so a missing or `null` one is refused, not trusted.
177
+ // Returns null when allowed, or the reason it was refused.
178
+ function upgradeVerdict(req) {
179
+ if (!hostAllowed(parseHostHeader(req.headers.host))) return 'unrecognized Host header';
180
+ if (originVerdict(req.headers.origin) !== true) return 'cross-origin or missing Origin';
181
+ return null;
182
+ }
183
+
184
+ // These pages may be framed by this app's own port under its loopback names, for a page
185
+ // that frames itself from its other name to get its own renderer process.
186
+ const selfFramed = new Set(config.selfFramedPaths || []);
187
+
188
+ function frameGuard(req, res, next) {
189
+ res.setHeader('X-Content-Type-Options', 'nosniff');
190
+ res.setHeader('Referrer-Policy', 'no-referrer');
191
+ if (selfFramed.has(req.path)) {
192
+ const port = req.socket.localPort;
193
+ res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}`);
194
+ return next();
195
+ }
196
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
197
+ res.setHeader('X-Frame-Options', 'DENY');
198
+ next();
199
+ }
200
+
201
+ /**
202
+ * Bind BIND_HOST, then opportunistically serve the other loopback family on the
203
+ * same resolved port via a second server sharing the same request handler.
204
+ *
205
+ * That second bind is what keeps `http://localhost:<port>` working no matter how
206
+ * the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
207
+ * where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
208
+ * port means every existing URL, startup banner and postMessage origin is
209
+ * unchanged. A family the host does not have is skipped. A port that another
210
+ * process holds on the other family is treated as busy: the primary closes and
211
+ * emits EADDRINUSE (port 0 picks another random port instead), because that process would answer `localhost:<port>` (and a
212
+ * cross-site frame on it) in our name. onReady waits for the second bind.
213
+ *
214
+ * Returns the primary server, so callers keep attaching their own 'error'
215
+ * handler for the EADDRINUSE fallback.
216
+ *
217
+ * @param {any} app express app (or any http request handler)
218
+ * @param {number} port 0 selects a free port
219
+ * @param {(port: number) => void} [onReady]
220
+ * `onUpgrade` is attached to both servers; a caller that attached it to the
221
+ * returned one alone would drop every WebSocket that arrives over the other family.
222
+ *
223
+ * @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
224
+ */
225
+ function listenLoopback(app, port, onReady, opts = {}) {
226
+ const http = require('node:http');
227
+ const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
228
+ const makeServer = (handler) => {
229
+ const s = http.createServer(serverOpts, handler);
230
+ // Node's 5 s default closes idle sockets that the browser would reuse, and a
231
+ // fresh loopback connect is the one that some Windows network filters drop.
232
+ s.keepAliveTimeout = KEEP_ALIVE_MS;
233
+ if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
234
+ return s;
235
+ };
236
+ const server = makeServer(app);
237
+ let secondary = null;
238
+ let randomTries = 0;
239
+
240
+ server.on('listening', () => {
241
+ const boundPort = server.address().port;
242
+ actualPort = boundPort;
243
+ // Not actualPort: several servers can listen through one guard, and another one can bind
244
+ // while this one waits for its second family.
245
+ const ready = () => { if (onReady) onReady(boundPort); };
246
+ if (EXPOSED) return ready();
247
+ const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
248
+ try {
249
+ secondary = makeServer(app);
250
+ secondary.once('listening', ready);
251
+ secondary.on('error', (err) => {
252
+ if (err.code !== 'EADDRINUSE') return ready();
253
+ // A caller asking for port 0 has no fallback of its own, so it gets another random port here.
254
+ if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
255
+ server.close();
256
+ server.emit('error', err);
257
+ });
258
+ secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
259
+ } catch {
260
+ ready();
261
+ }
262
+ });
263
+ server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
264
+
265
+ server.listen(port, BIND_HOST);
266
+ return server;
267
+ }
268
+
269
+ function exposureWarning() {
270
+ if (!EXPOSED) return null;
271
+ return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
272
+ }
273
+
274
+ return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
275
+ }
276
+
277
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
@@ -0,0 +1,6 @@
1
+ function oneLine(text, max) {
2
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
3
+ return String(text ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, max);
4
+ }
5
+
6
+ module.exports = { oneLine };
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ // Run a command without ever handing a string to a shell.
4
+ //
5
+ // The naive fix for command injection here — dropping `shell: true` — does not
6
+ // work on Windows, and fails in two different ways:
7
+ //
8
+ // * `spawn('code', args)` throws ENOENT, because the launcher on PATH is
9
+ // `code.cmd` and Node does not consult PATHEXT.
10
+ // * Resolving to `code.cmd` and spawning that throws EINVAL, because since the
11
+ // CVE-2024-27980 fix (Node 18.20.2 / 20.12.2 / 21.7.3+) a .bat/.cmd target
12
+ // may only be spawned with `shell: true` — the very thing we are removing.
13
+ //
14
+ // So on Windows we resolve the real .exe sitting behind the .cmd shim and spawn
15
+ // that directly. Only when no such .exe exists (npm-style shims, which are node
16
+ // scripts with no binary twin) do we fall back to cmd.exe, and that path applies
17
+ // a character allowlist so the quoting cannot be broken out of.
18
+ //
19
+ // Forked from scripts/security-lib/open-editor.js in the claude-code-hub repo; this copy is
20
+ // maintained here.
21
+
22
+ const { spawn } = require('node:child_process');
23
+ const path = require('node:path');
24
+ const fs = require('node:fs');
25
+
26
+ const IS_WIN = process.platform === 'win32';
27
+
28
+ // Characters that survive cmd.exe's double quotes and would let an argument
29
+ // escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
30
+ // matter: '"' ends the quoted run, '%' triggers variable expansion whose value
31
+ // could itself contain a quote. Neither is legal in an NTFS path anyway.
32
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
33
+ const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
34
+
35
+ function fail(message, status) {
36
+ return Object.assign(new Error(message), { status });
37
+ }
38
+
39
+ // The PATH walk below stats PATH.length × PATHEXT.length candidates — on a
40
+ // typical Windows box ~1200 stats, several milliseconds. The answer cannot change
41
+ // within a process unless PATH itself does, so it is memoized on both.
42
+ const whichCache = new Map();
43
+
44
+ // which(1). On Windows this walks PATHEXT so we learn the resolved extension —
45
+ // that .cmd-vs-.exe distinction is what selects the launch strategy below.
46
+ function whichSync(cmd) {
47
+ if (!cmd) return null;
48
+ if (cmd.includes('/') || cmd.includes('\\')) {
49
+ const abs = path.resolve(cmd);
50
+ try { return fs.statSync(abs).isFile() ? abs : null; } catch { return null; }
51
+ }
52
+ const key = `${process.env.PATH || ''}\u0000${process.env.PATHEXT || ''}\u0000${cmd}`;
53
+ if (whichCache.has(key)) return whichCache.get(key);
54
+ const found = whichUncached(cmd);
55
+ whichCache.set(key, found);
56
+ return found;
57
+ }
58
+
59
+ function whichUncached(cmd) {
60
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
61
+ const exts = IS_WIN
62
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
63
+ : [''];
64
+ for (const dir of dirs) {
65
+ for (const ext of exts) {
66
+ const candidate = path.join(dir, cmd + ext);
67
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // A native tool's .cmd shim normally lives at <install>/bin/foo.cmd next to
74
+ // <install>/foo.exe. Returns that .exe so we can skip the shell entirely.
75
+ // npm-installed shims have no such twin and correctly return null here.
76
+ function exeBehindShim(shimPath) {
77
+ const bin = path.dirname(shimPath);
78
+ const root = path.dirname(bin);
79
+ const stem = path.basename(shimPath, path.extname(shimPath));
80
+ const candidates = [path.join(root, `${stem}.exe`), path.join(bin, `${stem}.exe`)];
81
+ for (const candidate of candidates) {
82
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* next */ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Validate a request-supplied path before it becomes a command argument, and
89
+ * return its resolved absolute form. Lives here rather than in each server so the
90
+ * rule cannot drift between the apps that share this helper.
91
+ *
92
+ * Deliberately not a containment check: opening an arbitrary project directory is
93
+ * the feature these endpoints exist for. The network boundary is the control.
94
+ *
95
+ * @param {unknown} value
96
+ * @param {string} label used in the error message
97
+ * @returns {string} resolved absolute path
98
+ */
99
+ function assertOpenTarget(value, label = 'path') {
100
+ if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
101
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
102
+ if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
103
+ const resolved = path.resolve(value);
104
+ if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
105
+ return resolved;
106
+ }
107
+
108
+ /**
109
+ * Run a command without a shell and capture its output. On Windows a .cmd
110
+ * shim resolves to the real .exe behind it, with a fallback to an explicitly
111
+ * quoted cmd.exe line guarded by the character allowlist. Callers must still
112
+ * validate every argument — this stops shell metacharacters, not bad flags.
113
+ * @param {string} cmd
114
+ * @param {string[]} args
115
+ * @param {{cwd?: string, timeout?: number}} [opts]
116
+ * @returns {Promise<{stdout: string, stderr: string}>}
117
+ */
118
+ function execNoShell(cmd, args, opts = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ const resolved = whichSync(cmd);
121
+ if (!resolved) return reject(fail(`Command not found on PATH: ${cmd}`, 500));
122
+
123
+ let file = resolved;
124
+ let argv = args;
125
+ const spawnOpts = {
126
+ cwd: opts.cwd || undefined,
127
+ env: opts.env,
128
+ timeout: opts.timeout,
129
+ windowsHide: true,
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ };
132
+
133
+ const ext = path.extname(resolved).toLowerCase();
134
+ if (IS_WIN && (ext === '.cmd' || ext === '.bat')) {
135
+ const exe = exeBehindShim(resolved);
136
+ if (exe) {
137
+ file = exe;
138
+ } else {
139
+ for (const arg of [resolved, ...args]) {
140
+ if (WIN_CMD_FORBIDDEN.test(String(arg))) {
141
+ return reject(fail('Argument contains a character that cannot be passed safely', 400));
142
+ }
143
+ }
144
+ const line = [resolved, ...args].map((a) => `"${a}"`).join(' ');
145
+ file = process.env.ComSpec || 'cmd.exe';
146
+ argv = ['/d', '/s', '/c', `"${line}"`];
147
+ spawnOpts.windowsVerbatimArguments = true;
148
+ }
149
+ }
150
+
151
+ let child;
152
+ try {
153
+ child = spawn(file, argv, spawnOpts);
154
+ } catch (err) {
155
+ return reject(err);
156
+ }
157
+
158
+ let stdout = '';
159
+ let stderr = '';
160
+ child.stdout.on('data', (d) => { stdout += d; });
161
+ child.stderr.on('data', (d) => { stderr += d; });
162
+ child.on('error', reject);
163
+ child.on('close', (code, signal) => {
164
+ if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
165
+ // The claude CLI reports its own failures on stdout, so stderr alone is
166
+ // often empty and the caller would surface only the exit code.
167
+ if (code !== 0) return reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code}`));
168
+ resolve({ stdout, stderr });
169
+ });
170
+ });
171
+ }
172
+
173
+ module.exports = { assertOpenTarget, execNoShell, whichSync, exeBehindShim };
package/lib/panes.js ADDED
@@ -0,0 +1,213 @@
1
+ // Per-session pane layout: the tabs next to Board. The file is the only copy, shared by every
2
+ // board process on the config dir, the CLI and the agent. Which tab is on screen is each
3
+ // browser tab's own state, not stored here. Expiry: docs/retention.md.
4
+
5
+ const path = require('node:path');
6
+ const { httpError } = require('./http-error');
7
+ const { canonicalPath } = require('./linked-docs');
8
+ const { isLoopbackAddress } = require('./net-guard');
9
+ const { oneLine } = require('./one-line');
10
+ const { pruneMap } = require('./retention');
11
+
12
+ const MAX_PANES = 50;
13
+ const MAX_TITLE = 100;
14
+ const FILE_KINDS = new Set(['html', 'markdown', 'text', 'image']);
15
+ const KINDS = new Set([...FILE_KINDS, 'url', 'message']);
16
+ const MAX_MESSAGE_TARGET = 300;
17
+ const isFileKind = (kind) => FILE_KINDS.has(kind);
18
+
19
+ const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
20
+
21
+ // A frame with allow-scripts plus allow-same-origin on the board's origin can reach
22
+ // into its parent and drop its own sandbox. Every name the board is served under counts.
23
+ function isOwnOrigin(url, { boardPort, boardHosts = [] }) {
24
+ let u;
25
+ try {
26
+ u = new URL(url);
27
+ } catch {
28
+ return false;
29
+ }
30
+ const loopback = isLoopbackAddress(u.hostname);
31
+ return u.protocol === 'http:' && portOf(u) === boardPort && (loopback || boardHosts.includes(u.hostname));
32
+ }
33
+
34
+ const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
35
+
36
+ const targetKey = (kind, target) => (isFileKind(kind) ? `file:${canonicalPath(target)}` : `${kind}:${target}`);
37
+ const paneKey = (p) => (p.companion ? `companion:${p.companion}` : targetKey(p.kind, p.target));
38
+
39
+ function defaultTitle(kind, target) {
40
+ if (kind === 'message') return 'Message';
41
+ if (kind !== 'url') return path.basename(target);
42
+ try {
43
+ return new URL(target).host;
44
+ } catch {
45
+ return target;
46
+ }
47
+ }
48
+
49
+ const paneTitle = (kind, target, title) => oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE);
50
+
51
+ const idNumber = (id) => Number(id.slice(1)) || 0;
52
+
53
+ function cleanLayout(raw) {
54
+ if (!raw || !Array.isArray(raw.panes) || !Number.isFinite(raw.updatedAt)) return null;
55
+ const ids = new Set();
56
+ const panes = raw.panes.filter((p) => {
57
+ const ok = p && typeof p.id === 'string' && !ids.has(p.id) && KINDS.has(p.kind) && typeof p.target === 'string' && p.target;
58
+ if (ok) ids.add(p.id);
59
+ return ok;
60
+ });
61
+ const nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, ...panes.map((p) => idNumber(p.id) + 1));
62
+ const rev = Number.isInteger(raw.rev) && raw.rev > 0 ? raw.rev : 1;
63
+ return { rev, nextId, panes: panes.slice(0, MAX_PANES), updatedAt: raw.updatedAt };
64
+ }
65
+
66
+ const publicLayout = (l) => ({ rev: l.rev, panes: l.panes.map((p) => ({ ...p })), updatedAt: l.updatedAt });
67
+
68
+ /**
69
+ * Every write re-reads the file first, so two boards on one config dir add to each other's
70
+ * layouts instead of overwriting them. The read-to-rename window is left open: both writers
71
+ * are one user's own board tabs and CLI calls.
72
+ *
73
+ * @param {object} o
74
+ * @param {() => object|null|undefined} o.load returns `{version: 2, sessions: {[id]: {rev, nextId, panes, updatedAt}}}`,
75
+ * null when there is no file, or undefined when the file has not changed since the last load or save
76
+ * @param {(data: object) => void} o.save
77
+ * @param {(sessionId: string, layout: object) => void} [o.onChange] called for each layout whose rev
78
+ * moved, from this store's writes and from `reload()` alike
79
+ */
80
+ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
81
+ let bySession = new Map();
82
+
83
+ function reload() {
84
+ const data = load();
85
+ if (data === undefined) return;
86
+ const next = new Map();
87
+ const saved = data?.sessions;
88
+ if (saved && typeof saved === 'object') {
89
+ for (const [id, raw] of Object.entries(saved)) {
90
+ const layout = cleanLayout(raw);
91
+ if (layout) next.set(id, layout);
92
+ }
93
+ }
94
+ const before = bySession;
95
+ bySession = next;
96
+ for (const [id, layout] of next) {
97
+ if (before.get(id)?.rev !== layout.rev) onChange(id, publicLayout(layout));
98
+ }
99
+ }
100
+ reload();
101
+
102
+ const persist = () => save({ version: 2, sessions: Object.fromEntries(bySession) });
103
+
104
+ function write(sessionId, layout) {
105
+ layout.rev += 1;
106
+ layout.updatedAt = now();
107
+ bySession.set(sessionId, layout);
108
+ persist();
109
+ onChange(sessionId, publicLayout(layout));
110
+ return publicLayout(layout);
111
+ }
112
+
113
+ function get(sessionId) {
114
+ const layout = bySession.get(sessionId);
115
+ return publicLayout(layout || emptyLayout());
116
+ }
117
+
118
+ // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
119
+ // `show` marks a show card's file, which the board renders with the card's theme. `companion`
120
+ // names the board-hosted tool a url pane shows, which the board starts again when its port is gone;
121
+ // a session has one pane per companion, whatever port its URL names.
122
+ function add(sessionId, { kind, target, title, show, companion }) {
123
+ if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
124
+ if (kind === 'message' && target.length > MAX_MESSAGE_TARGET) {
125
+ throw httpError(400, `A message target holds at most ${MAX_MESSAGE_TARGET} characters`, 'bad_target');
126
+ }
127
+ reload();
128
+ const layout = bySession.get(sessionId) || emptyLayout();
129
+ const tool = kind === 'url' && companion;
130
+ const key = paneKey({ kind, target, companion: tool });
131
+ const existing = layout.panes.find((p) => paneKey(p) === key);
132
+ if (existing) return { pane: { ...existing }, added: false, layout: publicLayout(layout) };
133
+ if (layout.panes.length >= MAX_PANES) throw httpError(409, `A session holds at most ${MAX_PANES} panes`, 'pane_limit');
134
+ const pane = {
135
+ id: `p${layout.nextId++}`,
136
+ kind,
137
+ target,
138
+ title: paneTitle(kind, target, title),
139
+ ...(show && { show: true }),
140
+ ...(tool && { companion }),
141
+ addedAt: now(),
142
+ };
143
+ layout.panes.push(pane);
144
+ return { pane: { ...pane }, added: true, layout: write(sessionId, layout) };
145
+ }
146
+
147
+ // Returns the new layout, or null when the session has no such pane. An emptied layout stays
148
+ // until retention drops it, so its ids and rev keep counting up.
149
+ function remove(sessionId, paneId) {
150
+ reload();
151
+ const layout = bySession.get(sessionId);
152
+ const i = layout ? layout.panes.findIndex((p) => p.id === paneId) : -1;
153
+ if (i < 0) return null;
154
+ layout.panes.splice(i, 1);
155
+ return write(sessionId, layout);
156
+ }
157
+
158
+ // Drops the show panes of these files; returns the new layout, or null when none matched.
159
+ function removeShow(sessionId, files) {
160
+ reload();
161
+ const layout = bySession.get(sessionId);
162
+ if (!layout) return null;
163
+ const keys = new Set(files.map((f) => targetKey('html', f)));
164
+ const kept = layout.panes.filter((p) => !(p.show && keys.has(targetKey(p.kind, p.target))));
165
+ if (kept.length === layout.panes.length) return null;
166
+ layout.panes = kept;
167
+ return write(sessionId, layout);
168
+ }
169
+
170
+ // An empty title gives the pane its default title back. Returns null when the session has no such pane.
171
+ function rename(sessionId, paneId, title) {
172
+ reload();
173
+ const layout = bySession.get(sessionId);
174
+ const pane = layout?.panes.find((p) => p.id === paneId);
175
+ if (!pane) return null;
176
+ const next = paneTitle(pane.kind, pane.target, title);
177
+ if (next === pane.title) return get(sessionId);
178
+ pane.title = next;
179
+ return write(sessionId, layout);
180
+ }
181
+
182
+ // `order` is every pane id of the session, in the new tab order.
183
+ function reorder(sessionId, order) {
184
+ reload();
185
+ const layout = bySession.get(sessionId);
186
+ const ids = layout ? layout.panes.map((p) => p.id) : [];
187
+ const valid = Array.isArray(order) && order.length === ids.length && new Set(order).size === ids.length;
188
+ if (!valid || !order.every((id) => ids.includes(id))) throw httpError(400, 'order must list every pane id once', 'bad_order');
189
+ if (!layout || order.every((id, i) => id === ids[i])) return get(sessionId);
190
+ layout.panes = order.map((id) => layout.panes.find((p) => p.id === id));
191
+ return write(sessionId, layout);
192
+ }
193
+
194
+ // Returns how many layouts went; writes only when one did.
195
+ function prune({ known, maxAgeMs }) {
196
+ reload();
197
+ const removed = pruneMap(bySession, (l) => l.updatedAt, { known, maxAgeMs, now: now() });
198
+ if (removed) persist();
199
+ return removed;
200
+ }
201
+
202
+ function fileTargets() {
203
+ const out = new Set();
204
+ for (const layout of bySession.values()) {
205
+ for (const p of layout.panes) if (isFileKind(p.kind)) out.add(p.target);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ return { get, add, remove, removeShow, rename, reorder, prune, reload, fileTargets };
211
+ }
212
+
213
+ module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };