pindeck 2.9.0 → 3.0.0-beta.2

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.
@@ -0,0 +1,53 @@
1
+ /* JSON-line subprocess transport. Requests and notifications never outlive their process. */
2
+ import { spawn } from 'node:child_process';
3
+ import readline from 'node:readline';
4
+ export class AgentProcess {
5
+ constructor({ binary, args, cwd, env, onMessage = () => {}, onExit = () => {}, onStderr = () => {} }) {
6
+ Object.assign(this, { onMessage, onExit });
7
+ this.pending = new Map(); this.seq = 0; this.closed = false; this.stderr = '';
8
+ this.proc = spawn(binary, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
9
+ this.exited = new Promise(resolve => this.proc.once('close', resolve).once('error', () => this.proc.pid === undefined && resolve()));
10
+ readline.createInterface({ input: this.proc.stdout }).on('line', line => {
11
+ if (this.closed) return;
12
+ let m; try { m = JSON.parse(line); } catch { return; }
13
+ const id = m.type === 'control_response' ? m.response?.request_id : m.id;
14
+ const pending = this.pending.get(id);
15
+ if (pending && (m.type === 'control_response' || !m.method)) {
16
+ this.pending.delete(id); clearTimeout(pending.timer);
17
+ const err = m.error || (m.response?.subtype === 'error' && m.response.error);
18
+ if (err) pending.reject(new Error(err.message || String(err)));
19
+ else pending.resolve(m.type === 'control_response' ? m.response.response : m.result);
20
+ } else this.onMessage(m);
21
+ });
22
+ this.proc.stderr.on('data', d => { this.stderr = (this.stderr + d).slice(-2000); onStderr(String(d)); });
23
+ this.proc.stdin.on('error', e => this.end(e));
24
+ this.proc.on('error', e => this.end(e));
25
+ this.proc.on('exit', (code, signal) => this.end(new Error(`Agent stopped (${signal || code}). ${this.stderr.trim()}`)));
26
+ }
27
+ write(value) { if (this.closed) throw new Error('Agent is not running.'); this.proc.stdin.write(JSON.stringify(value) + '\n'); }
28
+ request(method, params, { claude = false, timeout = 30000 } = {}) {
29
+ const id = claude ? `pindeck-${++this.seq}` : ++this.seq;
30
+ return new Promise((resolve, reject) => {
31
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Agent timed out: ${method}`)); }, timeout);
32
+ this.pending.set(id, { resolve, reject, timer });
33
+ try { this.write(claude ? { type: 'control_request', request_id: id, request: { subtype: method, ...params } } : { id, method, params }); }
34
+ catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); }
35
+ });
36
+ }
37
+ end(error) {
38
+ if (this.closed) return;
39
+ this.closed = true;
40
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); }
41
+ this.pending.clear(); this.onExit(error);
42
+ }
43
+ /* End of input first (the CLI finishes and exits), SIGTERM after 1.5 s, SIGKILL after 4 s.
44
+ Resolves once the process has exited, so shutdown can wait for it. */
45
+ stop() {
46
+ this.end(new Error('Agent stopped.'));
47
+ const proc = this.proc;
48
+ if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) return Promise.resolve();
49
+ proc.stdin.end();
50
+ const term = setTimeout(() => proc.kill('SIGTERM'), 1500), kill = setTimeout(() => proc.kill('SIGKILL'), 4000);
51
+ return this.exited.finally(() => { clearTimeout(term); clearTimeout(kill); });
52
+ }
53
+ }
@@ -0,0 +1,108 @@
1
+ /* All Codex writes pass through this host-owned bridge, never through a shell. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { writeAtomic } from './deck-files.mjs';
7
+ const exec = promisify(execFile);
8
+ const text = value => ({ type: 'inputText', text: String(value) });
9
+ const schema = properties => ({ type: 'object', properties, additionalProperties: false });
10
+ const str = { type: 'string' };
11
+ export const renderedImagePath = result => result.match(/^(.+\.png) \(\d+ × \d+ px\)/m)?.[1];
12
+ export const deckTools = [
13
+ { type: 'function', name: 'pindeck_read', description: 'Read a deck text file, attached image, screenshot, or PDF page. For PDFs supply a 1-based page; returns page text and its rendered image. For text use offset to read beyond the first 120000 characters.', inputSchema: { ...schema({ path: str, page: { type: 'integer', minimum: 1 }, offset: { type: 'integer', minimum: 0 } }), required: ['path'] } },
14
+ { type: 'function', name: 'pindeck_edit', description: 'Replace one exact unique text occurrence in a file of the deck folder: the deck file, theme.css or another of its files. Read the file first. brand.css, vendor/, .git/ and other decks in the folder are locked.', inputSchema: { ...schema({ path: str, oldText: str, newText: str }), required: ['path', 'oldText', 'newText'] } },
15
+ { type: 'function', name: 'pindeck_write', description: 'Create a new file in the deck folder (missing folders are created), or replace all of an existing one. Prefer pindeck_edit for changes to existing files. The same files are locked as for pindeck_edit.', inputSchema: { ...schema({ path: str, content: str }), required: ['path', 'content'] } },
16
+ { type: 'function', name: 'pindeck_command', description: 'Run a PinDeck command: layout, shot, slides, pdf, or pdf-figure. Pass individual arguments in an array, never a shell command. Use pindeck_read on returned image paths.', inputSchema: { ...schema({ command: { enum: ['layout', 'shot', 'slides', 'pdf', 'pdf-figure'] }, args: { type: 'array', items: str, maxItems: 100 } }), required: ['command', 'args'] } },
17
+ ];
18
+ /* Codex may read the deck, screenshots, attached PDFs and asset roots, and write any file of the deck folder
19
+ except the locked ones: the same rules as Claude's --allowedTools / --disallowedTools. locked: patterns
20
+ such as brand.css or vendor/** (or a function returning them, as the other decks in the folder change);
21
+ env: an object or a function (the server's address is known only once it listens). */
22
+ export function createDeckTools({ deckDir, readDirs = [], cliFile, env, pdfTextFile, locked = ['brand.css', 'vendor/**', '.git/**'] }) {
23
+ const deck = fs.realpathSync(deckDir);
24
+ // looked up when used: the state folders (screenshots, attachments) are created once the server listens
25
+ const roots = () => [deck, ...readDirs.flatMap(d => { try { return [fs.realpathSync(d)]; } catch { return []; } })];
26
+ const inside = (root, file) => file === root || file.startsWith(root + path.sep);
27
+ const envNow = () => (typeof env === 'function' ? env() : env);
28
+ const isLocked = rel => (typeof locked === 'function' ? locked() : locked)
29
+ .some(p => p.endsWith('/**') ? rel === p.slice(0, -3) || rel.startsWith(p.slice(0, -2)) : rel === p);
30
+ function checked(file, write = false) {
31
+ if (typeof file !== 'string' || !file || file.includes('\0')) throw new Error('Invalid file path.');
32
+ const full = path.resolve(deck, file);
33
+ if (write) {
34
+ const rel = path.relative(deck, full).split(path.sep).join('/');
35
+ if (!rel || !inside(deck, full)) throw new Error('Only files inside the deck folder can be edited.');
36
+ if (isLocked(rel)) throw new Error(`${rel} is locked: it cannot be edited in PinDeck.`);
37
+ }
38
+ // Check every component, including missing-file parents, to reject symlink escapes.
39
+ let existing = full;
40
+ while (!fs.existsSync(existing)) { const parent = path.dirname(existing); if (parent === existing) throw new Error('Missing file.'); existing = parent; }
41
+ const real = fs.realpathSync(existing);
42
+ if (!(write ? [deck] : roots()).some(root => inside(root, real)) || (write && real !== existing)) throw new Error('File is outside the permitted directories or is a symlink.');
43
+ if (write && fs.existsSync(full) && (fs.lstatSync(full).isSymbolicLink() || fs.statSync(full).nlink !== 1)) throw new Error('Linked files cannot be edited.');
44
+ return full;
45
+ }
46
+ async function command(name, args, signal) {
47
+ if (!['layout', 'shot', 'slides', 'pdf', 'pdf-figure'].includes(name) || !Array.isArray(args) || args.length > 100 || args.some(a => typeof a !== 'string' || a.length > 4096 || a.includes('\0'))) throw new Error('Invalid PinDeck command.');
48
+ if (name === 'pdf-figure') {
49
+ checked(args[0]);
50
+ // Host renderer can create figures: disallow symlinked output directories too.
51
+ const figures = path.join(deck, 'figures');
52
+ if (fs.existsSync(figures) && fs.realpathSync(figures) !== figures) throw new Error('Linked figures directory is not allowed.');
53
+ for (const a of args.slice(2).filter(a => /^figures\//.test(a))) {
54
+ const full = path.resolve(deck, a);
55
+ if (!inside(figures, full) || (fs.existsSync(full) && (fs.lstatSync(full).isSymbolicLink() || fs.statSync(full).nlink !== 1))) throw new Error('Linked or invalid output file.');
56
+ }
57
+ }
58
+ const { stdout } = await exec(process.execPath, [cliFile, name, ...args], { cwd: deck, env: { ...process.env, ...envNow() }, signal, timeout: 120000, maxBuffer: 2 * 1024 * 1024 });
59
+ return stdout;
60
+ }
61
+ async function read(file, page, offset, signal) {
62
+ const full = checked(file), ext = path.extname(full).toLowerCase();
63
+ if (ext === '.pdf') {
64
+ if (!Number.isInteger(page) || page < 1) throw new Error('Specify a PDF page number, starting at 1.');
65
+ const result = await command('pdf-figure', [full, String(page)], signal);
66
+ const png = renderedImagePath(result);
67
+ if (!png) throw new Error('PDF renderer did not return an image path.');
68
+ let words = '';
69
+ if (process.platform === 'darwin' && pdfTextFile) {
70
+ try { words = (await exec('/usr/bin/osascript', ['-l', 'JavaScript', pdfTextFile, full, String(page)], { signal, timeout: 30000, maxBuffer: 1024 * 1024 })).stdout; }
71
+ catch (e) { if (signal?.aborted) throw e; words = 'Text extraction unavailable; inspect the page image.'; }
72
+ }
73
+ return [text(words), ...await read(png, undefined, 0, signal)];
74
+ }
75
+ if (!fs.statSync(full).isFile()) throw new Error('Read a file, not a directory.');
76
+ if (fs.statSync(full).size > 12 * 1024 * 1024) throw new Error('File exceeds the 12 MB read limit.');
77
+ const data = fs.readFileSync(full);
78
+ const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }[ext];
79
+ if (mime) return [{ type: 'inputImage', imageUrl: `data:${mime};base64,${data.toString('base64')}` }];
80
+ if (!['.html', '.htm', '.css', '.md', '.txt', '.svg', '.json', '.js', '.mjs', '.csv'].includes(ext)) throw new Error('Read supports text, PNG/JPEG/WebP/GIF images, and rendered PDF pages. Audio is embedded by path, not transcribed.');
81
+ const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
82
+ return [text(data.toString('utf8').slice(start, start + 120000))];
83
+ }
84
+ return async (name, args, signal) => {
85
+ signal?.throwIfAborted();
86
+ if (name === 'pindeck_read') return read(args.path, args.page, args.offset, signal);
87
+ if (name === 'pindeck_command') return [text(await command(args.command, args.args, signal))];
88
+ if (name === 'pindeck_edit') {
89
+ const full = checked(args.path, true);
90
+ if (typeof args.oldText !== 'string' || !args.oldText || typeof args.newText !== 'string' || args.newText.length > 2e6) throw new Error('Supply nonempty oldText and replacement text.');
91
+ const old = fs.readFileSync(full, 'utf8');
92
+ if (old.indexOf(args.oldText) < 0 || old.indexOf(args.oldText) !== old.lastIndexOf(args.oldText)) throw new Error('oldText must match exactly once. Re-read the file.');
93
+ signal?.throwIfAborted();
94
+ writeAtomic(full, old.replace(args.oldText, () => args.newText));
95
+ return [text('Edited ' + path.relative(deck, full))];
96
+ }
97
+ if (name === 'pindeck_write') {
98
+ const full = checked(args.path, true);
99
+ if (typeof args.content !== 'string' || args.content.length > 2e6) throw new Error('Supply the file content as text (at most 2 MB).');
100
+ if (fs.existsSync(full) && !fs.statSync(full).isFile()) throw new Error('That path is a folder, not a file.');
101
+ signal?.throwIfAborted();
102
+ fs.mkdirSync(path.dirname(full), { recursive: true });
103
+ writeAtomic(full, args.content);
104
+ return [text('Wrote ' + path.relative(deck, full))];
105
+ }
106
+ throw new Error('Unknown PinDeck tool.');
107
+ };
108
+ }
@@ -1,8 +1,9 @@
1
1
  /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
- (status), the API (refusals before any side effect), the CLI (its error text) and Claude's system prompt.
2
+ (status), the API (refusals before any side effect), the CLI (its error text) and the assistant's system prompt.
3
3
 
4
- macOS: everything, as before. Linux (beta): editing, the chat and live reload. Features that need macOS
5
- (PDFKit, PowerPoint/Keynote, afconvert) are off with a reason. Chrome features (screenshots, headless
4
+ macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
5
+ pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
6
+ waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
6
7
  layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
7
8
  turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
8
9
 
@@ -51,6 +52,9 @@ export function detectCapabilities({ platform = process.platform, env = process.
51
52
  const claude = findExecutable(claudeBin, { env });
52
53
  caps.claude = claude.path ? on() : off(`Claude Code is not available on the server (${claude.reason}). ` +
53
54
  `Install and log in to the claude CLI there, or set CLAUDE_BIN. Editing by hand still works.`);
55
+ const codex = findExecutable(env.CODEX_BIN || "codex", { env });
56
+ caps.codex = codex.path ? on() : off(`OpenAI Codex is not available on the server (${codex.reason}). ` +
57
+ `Install and log in to the codex CLI there, or set CODEX_BIN. Editing by hand still works.`);
54
58
 
55
59
  let chrome = null, chromeWhy = "";
56
60
  if (env.CHROME_BIN) {
@@ -71,11 +75,16 @@ export function detectCapabilities({ platform = process.platform, env = process.
71
75
  caps.headlessLayout = chromeCap;
72
76
  caps.pdfExport = chromeCap;
73
77
 
74
- caps.pdfPages = mac ? on() : off("Rendering PDF pages to pictures needs macOS (PDFKit). Attached PDFs can still be read.");
75
- caps.pptxImport = mac ? on() : off("PowerPoint import is not available on Linux in this beta.");
76
- caps.pptxPictures = mac && OFFICE_APPS.some((a) => fs.existsSync(a)) ? on()
77
- : off(mac ? "Pictures of PowerPoint slides need PowerPoint or Keynote." : "Pictures of PowerPoint slides need macOS with PowerPoint or Keynote.");
78
- caps.audioConvert = mac ? on() : off("Waveforms for audio other than WAV need macOS (afconvert).");
78
+ // Linux: Poppler renders PDF pages, LibreOffice exports PowerPoint slides to PDF, ffmpeg decodes audio
79
+ const has = (name) => !!findExecutable(name, { env }).path;
80
+ const poppler = has("pdftoppm") && has("pdfinfo"), soffice = has("soffice") || has("libreoffice");
81
+ caps.pdfPages = mac || poppler ? on()
82
+ : off("Pictures of PDF pages need Poppler on the server (pdftoppm and pdfinfo; install poppler-utils). Attached PDFs can still be read.");
83
+ caps.pptxImport = on(); // the import itself reads the .pptx file: no other program needed
84
+ caps.pptxPictures = mac ? (OFFICE_APPS.some((a) => fs.existsSync(a)) ? on() : off("Pictures of PowerPoint slides need PowerPoint or Keynote."))
85
+ : soffice && poppler ? on()
86
+ : off("Pictures of PowerPoint slides need LibreOffice (soffice) and Poppler (pdftoppm, pdfinfo) on the server.");
87
+ caps.audioConvert = mac || has("ffmpeg") ? on() : off("Waveforms for audio other than WAV need ffmpeg on the server.");
79
88
  return caps;
80
89
  }
81
90
 
package/lib/claude.mjs CHANGED
@@ -1,75 +1,42 @@
1
- /* A long-running Claude Code process in stream-json mode.
2
-
3
- One process serves the whole chat: messages are written to stdin as JSON lines,
4
- events come back on stdout. It runs under the user's normal Claude login. */
5
-
6
- import { spawn } from "node:child_process";
7
- import readline from "node:readline";
8
-
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const exec = promisify(execFile);
4
+ import { AgentProcess } from './agent-process.mjs';
5
+ import { normalizeModels } from './models.mjs';
6
+ /* A long-running Claude Code process in stream-json mode. One process serves the whole chat; it runs under
7
+ the user's normal Claude login. env may be a function: the server's address is known only once it listens. */
9
8
  export class ClaudeSession {
10
- constructor({ cwd, args, env, onEvent, onExit, onStderr }) {
11
- Object.assign(this, { cwd, args, env, onEvent, onExit, onStderr });
12
- this.proc = null;
13
- this.sessionId = null;
14
- this.reqId = 0;
15
- }
16
-
17
- get running() { return !!this.proc; }
18
-
19
- start(resumeId) {
20
- const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
21
- ...(typeof this.args === "function" ? this.args() : this.args), ...(resumeId ? ["--resume", resumeId] : [])];
22
- const proc = spawn(process.env.CLAUDE_BIN || "claude", args, {
23
- cwd: this.cwd, env: { ...process.env, ...(typeof this.env === "function" ? this.env() : this.env) }, stdio: ["pipe", "pipe", "pipe"],
24
- });
25
- this.proc = proc;
26
- readline.createInterface({ input: proc.stdout }).on("line", (line) => {
27
- if (this.proc !== proc && this.proc) return; // a stopped process that is still talking
28
- if (!line.trim()) return;
29
- let ev;
30
- try { ev = JSON.parse(line); } catch { return; }
31
- if (ev.session_id) this.sessionId = ev.session_id;
32
- this.onEvent(ev);
9
+ constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; }
10
+ get running() { return !!this.transport && !this.transport.closed; }
11
+ async start(resumeId) {
12
+ if (this.running) return;
13
+ this.stopped = false;
14
+ const env = typeof this.env === 'function' ? this.env() : this.env;
15
+ try {
16
+ const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
17
+ { env: { ...process.env, ...env }, timeout: 15000, maxBuffer: 65536 });
18
+ if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
19
+ } catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
20
+ if (this.stopped) throw new Error('Agent stopped.');
21
+ const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env,
22
+ args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
23
+ ...(typeof this.args === 'function' ? this.args() : this.args || []), ...(resumeId ? ['--resume', resumeId] : [])],
24
+ onStderr: this.onStderr,
25
+ onMessage: ev => {
26
+ if (this.transport !== transport) return;
27
+ if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
28
+ response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
29
+ if (ev.session_id) this.sessionId = ev.session_id;
30
+ this.onEvent?.(ev);
31
+ },
32
+ onExit: error => { if (this.transport === transport) this.onExit?.(error); },
33
33
  });
34
- proc.stderr.on("data", (d) => this.onStderr?.(String(d)));
35
- proc.stdin.on("error", () => {}); // writing to a process that has died: its exit is reported below
36
- // a process that could not start (no `claude` on the PATH) reports an error and never exits
37
- let ended = false;
38
- const end = (code, signal) => {
39
- if (ended) return;
40
- ended = true;
41
- // stale: a newer process has been started since (model change, new chat); its turn is not ours to end
42
- const stale = this.proc !== proc && this.proc !== null;
43
- if (this.proc === proc) this.proc = null;
44
- this.onExit(code, signal, { stale });
45
- };
46
- proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
47
- proc.on("exit", end);
48
- proc.exited = new Promise((resolve) => proc.once("close", resolve).once("error", () => proc.pid === undefined && resolve()));
49
- }
50
-
51
- send(content) {
52
- this.#write({ type: "user", message: { role: "user", content } });
53
- }
54
-
55
- interrupt() {
56
- this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
57
- }
58
-
59
- /* End of input first (Claude Code finishes and exits), SIGTERM after 1.5 s, SIGKILL after 4 s.
60
- Resolves once the process has exited, so shutdown can wait for it. */
61
- stop() {
62
- if (!this.proc) return Promise.resolve();
63
- const p = this.proc;
64
- this.proc = null;
65
- p.stdin.end();
66
- const term = setTimeout(() => p.kill("SIGTERM"), 1500);
67
- const kill = setTimeout(() => p.kill("SIGKILL"), 4000);
68
- return p.exited.finally(() => { clearTimeout(term); clearTimeout(kill); });
69
- }
70
-
71
- #write(obj) {
72
- if (!this.proc) throw new Error("Claude Code is not running");
73
- this.proc.stdin.write(JSON.stringify(obj) + "\n");
34
+ this.transport = transport;
35
+ try { const init = await transport.request('initialize', {}, { claude: true }); this.models = normalizeModels('claude', init?.models); }
36
+ catch (e) { this.stop(); throw e; }
74
37
  }
38
+ async send(content) { this.transport.write({ type: 'user', message: { role: 'user', content } }); }
39
+ async interrupt() { if (this.running) await this.transport.request('interrupt', {}, { claude: true, timeout: 5000 }); }
40
+ /* Resolves once the process has exited, so shutdown can wait for it. */
41
+ stop() { this.stopped = true; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
75
42
  }
package/lib/codex.mjs ADDED
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import { AgentProcess } from './agent-process.mjs';
3
+ import { normalizeModels } from './models.mjs';
4
+ import { deckTools } from './agent-tools.mjs';
5
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
6
+ export const CODEX_CONFIG = {
7
+ 'features.shell_tool': false, 'features.unified_exec': false, 'features.js_repl': false,
8
+ 'features.apps': false, 'features.plugins': false, 'features.multi_agent': false,
9
+ 'features.hooks': false, 'features.memory_tool': false, 'features.shell_snapshot': false,
10
+ 'web_search': 'disabled', 'project_doc_max_bytes': 0, 'notify': [],
11
+ };
12
+ const toml = value => JSON.stringify(value);
13
+ const withoutNull = value => Array.isArray(value) ? value.map(withoutNull) : value && typeof value === 'object'
14
+ ? Object.fromEntries(Object.entries(value).filter(([, v]) => v != null).map(([k,v]) => [k, withoutNull(v)])) : value;
15
+ export class CodexSession {
16
+ constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; this.turnId = null; this.controller = null; }
17
+ get running() { return !!this.transport && !this.transport.closed; }
18
+ async connect() {
19
+ if (this.running) return;
20
+ const transport = new AgentProcess({ binary: process.env.CODEX_BIN || 'codex', cwd: this.cwd,
21
+ args: ['app-server', ...Object.entries(CODEX_CONFIG).flatMap(([k,v]) => ['-c', `${k}=${toml(v)}`])],
22
+ onStderr: this.onStderr,
23
+ onMessage: m => { if (this.transport === transport) this.message(m).catch(e => this.onStderr?.(String(e))); },
24
+ onExit: error => { if (this.transport === transport) { this.controller?.abort(); this.onExit?.(error); } },
25
+ });
26
+ this.transport = transport;
27
+ try {
28
+ await transport.request('initialize', { clientInfo: { name: 'pindeck', version: VERSION }, capabilities: { experimentalApi: true } });
29
+ transport.write({ method: 'initialized', params: {} });
30
+ const auth = await transport.request('account/read', { refreshToken: false });
31
+ if (auth.requiresOpenaiAuth && !auth.account) throw new Error('Codex is not signed in. Run codex login, then refresh models.');
32
+ this.models = []; let cursor;
33
+ do { const list = await transport.request('model/list', { includeHidden: false, ...(cursor ? { cursor } : {}) });
34
+ this.models.push(...normalizeModels('codex', list.data)); cursor = list.nextCursor;
35
+ } while (cursor);
36
+ } catch (e) { this.stop(); throw e; }
37
+ }
38
+ async start(resumeId) {
39
+ await this.connect();
40
+ try {
41
+ const { config = {} } = await this.transport.request('config/read', { includeLayers: false, cwd: this.cwd });
42
+ // Disable inherited connectors and hooks before starting any thread. No configuration files are changed.
43
+ const overrides = { ...CODEX_CONFIG };
44
+ overrides.mcp_servers = Object.fromEntries(Object.entries(config.mcp_servers || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
45
+ overrides.plugins = Object.fromEntries(Object.entries(config.plugins || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
46
+ overrides.hooks = Object.fromEntries(Object.entries(config.hooks || {}).map(([name, value]) => [name, Array.isArray(value) ? [] : value]));
47
+ const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
48
+ const params = { cwd: this.cwd, model: opts.model, modelProvider: 'openai',
49
+ approvalPolicy: 'never', sandbox: 'read-only', config: withoutNull(overrides),
50
+ developerInstructions: this.instructions, ...(resumeId ? { threadId: resumeId } : { dynamicTools: deckTools }) };
51
+ const result = await this.transport.request(resumeId ? 'thread/resume' : 'thread/start', params);
52
+ this.sessionId = result.thread.id;
53
+ this.onEvent?.({ type: 'system', subtype: 'init', session_id: this.sessionId, model: result.model || opts.model });
54
+ } catch (e) { this.stop(); throw e; }
55
+ }
56
+ async send(content) {
57
+ if (!this.sessionId || this.controller) throw new Error('Codex is not ready for another turn.');
58
+ const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
59
+ this.controller = new AbortController(); this.turnId = null; this.cancelRequested = false;
60
+ try {
61
+ const result = await this.transport.request('turn/start', { threadId: this.sessionId, input: content,
62
+ model: opts.model, ...(opts.effort ? { effort: opts.effort } : {}),
63
+ approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } });
64
+ this.turnId = result.turn.id;
65
+ if (this.cancelRequested) await this.interrupt();
66
+ } catch (e) { this.controller?.abort(); this.controller = null; throw e; }
67
+ }
68
+ async message(m) {
69
+ const p = m.params || {};
70
+ if (m.id !== undefined && m.method) {
71
+ let result;
72
+ if (m.method === 'item/tool/call') {
73
+ try {
74
+ if (!this.controller || this.controller.signal.aborted || p.threadId !== this.sessionId || (this.turnId && p.turnId !== this.turnId)) throw new Error('Inactive turn.');
75
+ const signal = this.controller.signal;
76
+ const items = await this.tools(p.tool, p.arguments, signal);
77
+ signal.throwIfAborted(); result = { success: true, contentItems: items };
78
+ } catch (e) { result = { success: false, contentItems: [{ type: 'inputText', text: e.message }] }; }
79
+ } else if (m.method.includes('requestApproval') && m.method.includes('permissions')) result = { permissions: {}, scope: 'turn' };
80
+ else if (m.method.includes('requestApproval')) result = { decision: 'decline' };
81
+ else if (m.method.includes('elicitation')) result = { action: 'decline', content: null };
82
+ else if (m.method.includes('requestUserInput')) result = { answers: {} };
83
+ else { this.transport?.write({ id: m.id, error: { code: -32601, message: 'Unsupported request' } }); return; }
84
+ if (this.running) this.transport.write({ id: m.id, result });
85
+ return;
86
+ }
87
+ if (p.threadId !== this.sessionId || !this.controller) return;
88
+ const eventTurnId = p.turnId || p.turn?.id;
89
+ if (eventTurnId && this.turnId && eventTurnId !== this.turnId) return;
90
+ if (m.method === 'turn/started') this.turnId = p.turn.id;
91
+ const item = p.item;
92
+ if (m.method === 'item/completed' && item?.type === 'agentMessage') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'text', text: item.text }] } });
93
+ if (m.method === 'item/started' && item?.type === 'dynamicToolCall') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'tool_use', id: item.id, name: item.tool, input: item.arguments }] } });
94
+ if (m.method === 'item/completed' && item?.type === 'dynamicToolCall' && item.success === false) this.onEvent?.({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: item.id, is_error: true, content: (item.contentItems || []).map(c => ({ text: c.text || '' })) }] } });
95
+ if (m.method === 'turn/completed') {
96
+ this.controller.abort(); this.controller = null; this.turnId = null;
97
+ this.onEvent?.({ type: 'result', subtype: p.turn.status === 'completed' ? 'success' : 'error',
98
+ is_error: p.turn.status !== 'completed', result: p.turn.error?.message || (p.turn.status === 'interrupted' ? 'Stopped.' : p.turn.status) });
99
+ }
100
+ }
101
+ async interrupt() {
102
+ this.cancelRequested = true; this.controller?.abort();
103
+ if (this.turnId && this.running) await this.transport.request('turn/interrupt', { threadId: this.sessionId, turnId: this.turnId });
104
+ }
105
+ stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; return t ? t.stop() : Promise.resolve(); }
106
+ }
@@ -54,3 +54,41 @@ export function revision(dir, files) {
54
54
  }
55
55
  return h.digest("hex").slice(0, 16);
56
56
  }
57
+
58
+ /* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
59
+ time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
60
+ export/ folders are left out; so are symlinks, which point elsewhere. */
61
+ const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
62
+ export const KEEP_BYTES = 2 * 1024 * 1024;
63
+ export function scanFiles(dir, maxBytes = KEEP_BYTES) {
64
+ const files = new Map();
65
+ const walk = (rel) => {
66
+ let entries = [];
67
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
68
+ for (const e of entries) {
69
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
70
+ const r = rel ? `${rel}/${e.name}` : e.name;
71
+ if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
72
+ if (!e.isFile()) continue;
73
+ try {
74
+ const st = fs.statSync(path.join(dir, r));
75
+ files.set(r, { size: st.size, mtimeMs: st.mtimeMs, data: st.size <= maxBytes ? fs.readFileSync(path.join(dir, r)) : null });
76
+ } catch {}
77
+ }
78
+ };
79
+ walk("");
80
+ return files;
81
+ }
82
+
83
+ /* What changed between two scans: { rel: { before, after } } with the content on each side (null: the
84
+ file did not exist), and the files that changed but are too large to keep a copy of. */
85
+ export function scanChanges(before, after) {
86
+ const changed = {}, tooLarge = [];
87
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
88
+ const b = before.get(rel), a = after.get(rel);
89
+ if (b && a && b.size === a.size && (b.data && a.data ? b.data.equals(a.data) : b.mtimeMs === a.mtimeMs)) continue;
90
+ if ((b && !b.data) || (a && !a.data)) { tooLarge.push(rel); continue; }
91
+ changed[rel] = { before: b ? b.data : null, after: a ? a.data : null };
92
+ }
93
+ return { changed, tooLarge };
94
+ }
@@ -0,0 +1,41 @@
1
+ /* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
2
+ with class "slides" (inside <div class="reveal">). Other frameworks are recognised by their markup and
3
+ scripts, so the user gets a clear "not supported (yet)" before PinDeck changes anything, instead of an
4
+ editor without slides. Only tags and scripts are looked at, never the slides' text, so a talk that
5
+ mentions "impress.js" is not taken for one. */
6
+
7
+ import { parse, findSlides } from "./eids.mjs";
8
+
9
+ const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
10
+ ["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
11
+ ["Slidev", /slidev/i],
12
+ ["impress.js", /id=["']impress["']|impress(\.min)?\.js/i],
13
+ ["remark", /remark(-latest)?(\.min)?\.js|remark\.create\s*\(/i],
14
+ ["Spectacle", /<script[^>]+src=["'][^"']*spectacle/i],
15
+ ["Shower", /class=["'][^"']*\bshower\b|shower-core/i],
16
+ ["deck.js", /deck-container|deck\.core(\.min)?\.js/i],
17
+ ["bespoke.js", /bespoke(\.min)?\.js|bespoke\.from\s*\(/i],
18
+ ];
19
+
20
+ /* Tags and script contents only: the slides' text could name any framework */
21
+ const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
22
+
23
+ /* { name, supported }: reveal.js decks are supported, including one without slides yet */
24
+ export function detectFramework(html) {
25
+ if (findSlides(parse(html)).length) return { name: "reveal.js", supported: true };
26
+ const tags = markup(html);
27
+ const other = OTHERS.find(([, re]) => re.test(tags));
28
+ if (other) return { name: other[0], supported: false };
29
+ if (/class=["'][^"']*\breveal\b/i.test(tags) || /Reveal\.initialize\s*\(/.test(tags)) return { name: "reveal.js", supported: true };
30
+ return { name: null, supported: false };
31
+ }
32
+
33
+ /* The message for a deck PinDeck cannot edit, or null */
34
+ export function unsupportedMessage(html, file) {
35
+ const fw = detectFramework(html);
36
+ if (fw.supported) return null;
37
+ const what = fw.name ? `${file} is a ${fw.name} deck.` :
38
+ `${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
39
+ return `${what} PinDeck works with reveal.js decks only for now; other frameworks may follow. ` +
40
+ `Nothing was changed.`;
41
+ }
@@ -0,0 +1,74 @@
1
+ /* The deck's own instructions for Claude (CLAUDE.md, AGENTS.md), with @path imports expanded.
2
+
3
+ PinDeck does not load CLAUDE.md files from parent folders the way Claude Code does: those are usually
4
+ written for coding work PinDeck's Claude cannot do (tests, commits, package managers), they change
5
+ with where the deck happens to sit, and on a shared server a parent folder may be writable by others.
6
+ A deck pulls in shared rules explicitly instead: "Follow @../shared/STYLE.md" includes that file.
7
+
8
+ An import is "@" followed by a path (relative to the file it is in, absolute, or ~/...), at the start
9
+ of a line or after a space or "(". It is expanded only when that path is a text file: an e-mail address
10
+ or an @mention stays as it is. Imports in code spans and code blocks are left alone. Included files may
11
+ import others, up to 5 levels deep; each file is included once (no cycles), and all included text
12
+ together is limited to 200 KB. The imported text follows the file that imports it. */
13
+
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ const MAX_DEPTH = 5, MAX_BYTES = 200_000;
19
+
20
+ /* Apply fn to the text outside code: fenced blocks (``` or ~~~) and `code spans` stay as they are */
21
+ function outsideCode(text, fn) {
22
+ let fence = null;
23
+ return text.split("\n").map((line) => {
24
+ const f = /^\s*(```|~~~)/.exec(line);
25
+ if (f) { fence = fence ? (fence === f[1] ? null : fence) : f[1]; return line; }
26
+ if (fence) return line;
27
+ return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : fn(part))).join("");
28
+ }).join("\n");
29
+ }
30
+
31
+ const resolveRef = (ref, dir) => (ref === "~" || ref.startsWith("~/") ? path.join(os.homedir(), ref.slice(1))
32
+ : path.resolve(dir, ref));
33
+
34
+ function readText(file) {
35
+ try {
36
+ if (!fs.statSync(file).isFile()) return null;
37
+ const buf = fs.readFileSync(file);
38
+ return buf.includes(0) ? null : buf.toString("utf8");
39
+ } catch { return null; }
40
+ }
41
+
42
+ function expand(file, text, depth, seen, budget) {
43
+ const dir = path.dirname(file), found = [];
44
+ const body = outsideCode(text, (part) => part.replace(/(^|[\s(])@([^\s`()<>"']+)/g, (m, pre, raw) => {
45
+ const ref = raw.replace(/[.,:;!?]+$/, ""), tail = raw.slice(ref.length); // "see @STYLE.md." ends a sentence
46
+ const abs = resolveRef(ref, dir);
47
+ const content = readText(abs);
48
+ if (content == null) return m; // not a file: an e-mail address, an @mention, a typo
49
+ let real = abs;
50
+ try { real = fs.realpathSync(abs); } catch {}
51
+ const note = (why) => `${pre}${ref} (${why})${tail}`;
52
+ if (seen.has(real)) return note("included once, above or below");
53
+ if (depth >= MAX_DEPTH) return note("not included: imports nested too deep");
54
+ if (Buffer.byteLength(content) > budget.left) return note("not included: imported files over 200 KB in all");
55
+ budget.left -= Buffer.byteLength(content);
56
+ seen.add(real);
57
+ found.push({ ref, abs, content });
58
+ return note("included below");
59
+ }));
60
+ const parts = [body.trim()];
61
+ for (const imp of found) {
62
+ parts.push(`### ${imp.ref} (included by ${path.basename(file)})\n` + expand(imp.abs, imp.content, depth + 1, seen, budget));
63
+ }
64
+ return parts.filter(Boolean).join("\n\n");
65
+ }
66
+
67
+ /* A deck instruction file with its imports, or null when it is missing or empty */
68
+ export function readInstructions(file) {
69
+ const text = readText(file);
70
+ if (text == null || !text.trim()) return null;
71
+ let real = file;
72
+ try { real = fs.realpathSync(file); } catch {}
73
+ return expand(file, text, 0, new Set([real]), { left: MAX_BYTES });
74
+ }