papergod 0.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +244 -0
  3. package/ROADMAP.md +171 -0
  4. package/example/main.tex +360 -0
  5. package/frontend/src/components/ui/badge.jsx +5 -0
  6. package/frontend/src/components/ui/button.jsx +24 -0
  7. package/frontend/src/components/workbench.jsx +182 -0
  8. package/frontend/src/lib/utils.js +6 -0
  9. package/frontend/src/main.jsx +19 -0
  10. package/frontend/src/theme.css +256 -0
  11. package/frontend/vite.config.js +23 -0
  12. package/package.json +73 -0
  13. package/papergod-demo.png +0 -0
  14. package/public/app.js +5480 -0
  15. package/public/brand/papergod-logo.png +0 -0
  16. package/public/i18n.js +95 -0
  17. package/public/index.html +480 -0
  18. package/public/pdf-sentence-mapping.js +142 -0
  19. package/public/react/app.js +209 -0
  20. package/public/react/assets/addon-fit-YJmn1quW.js +12 -0
  21. package/public/react/assets/addon-web-links-BWjmmSgS.js +12 -0
  22. package/public/react/assets/main.css +32 -0
  23. package/public/react/assets/xterm-BqvuqXEL.js +27 -0
  24. package/public/style.css +1462 -0
  25. package/src/cli.js +128 -0
  26. package/src/server/agent-adapters.js +1240 -0
  27. package/src/server/agent-errors.js +105 -0
  28. package/src/server/agent-runtime.js +81 -0
  29. package/src/server/agent.js +173 -0
  30. package/src/server/app-version.js +86 -0
  31. package/src/server/change-history.js +114 -0
  32. package/src/server/document-structure.js +174 -0
  33. package/src/server/index.js +1442 -0
  34. package/src/server/latex-structure.js +344 -0
  35. package/src/server/latex.js +67 -0
  36. package/src/server/library-engine.js +193 -0
  37. package/src/server/library-files.js +134 -0
  38. package/src/server/literature-review.js +122 -0
  39. package/src/server/orchestration-engine.js +662 -0
  40. package/src/server/paragraph-analysis.js +300 -0
  41. package/src/server/project-resources.js +290 -0
  42. package/src/server/project-store.js +808 -0
  43. package/src/server/prompt-manifest.js +300 -0
  44. package/src/server/references.js +425 -0
  45. package/src/server/review-panel.js +263 -0
  46. package/src/server/revise-workflow.js +278 -0
  47. package/src/server/revision-engine.js +607 -0
  48. package/src/server/security.js +16 -0
  49. package/src/server/text-extraction.js +149 -0
  50. package/src/server/workspace-browser.js +49 -0
  51. package/src/server/workspace-registry.js +143 -0
  52. package/src/server/workspace-terminal.js +99 -0
  53. package/src/server/workspace.js +223 -0
  54. package/src/server/zotero.js +98 -0
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { createAgentRun, updateAgentRun } from './project-resources.js';
3
+ import { runWritingAgent } from './agent-adapters.js';
4
+
5
+ const MAX_TEXT_CHARS = 2_000_000;
6
+ const MAX_CANDIDATES = 16;
7
+ const SENTINEL = '[[PAPERGOD_PATTERN_EXTRACTION]]';
8
+
9
+ const ACADEMIC_PATTERN = /\b(we (propose|show|demonstrate|present|introduce|develop|provide|examine|investigate|consider|find|report|observe|argue|hypothesize|conclude|explore|evaluate|compare|analyse|analyze|design|implement|establish|highlight|emphasize|suggest)|it (is|has been|was) (shown|found|demonstrated|observed|reported)|can be used to|plays (a|an) (key|important|crucial|central|significant) role|has been (widely|extensively|successfully) (used|studied|applied|adopted)|is one of the most|the results (indicate|suggest|show|demonstrate)|this (suggests|indicates|implies) that|our (results|findings|experiments|analysis|approach|method|framework|model)|in this (paper|work|study|article|section)|a wide range of|of particular interest|we (thus|therefore|consequently)|as a result,|in contrast,|compared with|consistent with|in addition,|furthermore,|moreover,|nevertheless,|on the other hand)\b/i;
10
+
11
+ function now() { return new Date().toISOString(); }
12
+ function id(prefix) { return `${prefix}_${randomUUID()}`; }
13
+ function clean(value) { return typeof value === 'string' ? value.trim() : ''; }
14
+ function problem(message, status = 400, code = '') {
15
+ const error = new Error(message);
16
+ error.status = status;
17
+ if (code) error.code = code;
18
+ return error;
19
+ }
20
+
21
+ function cleanPlainText(text) {
22
+ return String(text || '')
23
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ' ')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ }
27
+
28
+ function splitSentences(text) {
29
+ return cleanPlainText(text)
30
+ .split(/(?<=[.!?])\s+/)
31
+ .map((sentence) => sentence.trim())
32
+ .filter((sentence) => sentence.length >= 30 && sentence.length <= 450);
33
+ }
34
+
35
+ function generalizeTemplate(sentence) {
36
+ let template = sentence;
37
+ let slotIndex = 0;
38
+ const slotify = (hint) => { slotIndex += 1; return `{slot${slotIndex}}`; };
39
+ template = template.replace(/\(([^)]*)\)/g, (_match, inner) => slotify(clean(inner).slice(0, 60)));
40
+ template = template.replace(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3}\b/g, (word) => slotify(word));
41
+ template = template.replace(/\b\d+(?:[.,]\d+)+\b/g, () => slotify('number'));
42
+ template = template.replace(/\b\d+\b/g, () => slotify('number'));
43
+ template = template.replace(/['"“”‘’]/g, '');
44
+ return clean(template);
45
+ }
46
+
47
+ function detectSlots(template) {
48
+ return [...template.matchAll(/\{([a-zA-Z][\w-]*)\}/g)]
49
+ .map((match) => match[1])
50
+ .filter((name, index, names) => names.indexOf(name) === index)
51
+ .map((name) => ({ name, description: 'Replace with the concrete expression from your own writing', required: true }));
52
+ }
53
+
54
+ export function composeMockTextCandidates(text, source) {
55
+ const sentences = splitSentences(text);
56
+ const patterns = [];
57
+ for (const sentence of sentences) {
58
+ if (!ACADEMIC_PATTERN.test(sentence)) continue;
59
+ const template = generalizeTemplate(sentence);
60
+ if (!template) continue;
61
+ const slots = detectSlots(template);
62
+ patterns.push({
63
+ kind: 'sentence-patterns',
64
+ value: {
65
+ name: `PDF pattern ${patterns.length + 1}`,
66
+ template,
67
+ description: 'Reusable academic pattern extracted from a PDF by deterministic rules (academic framing phrase + slot generalization).',
68
+ tags: ['extracted', 'pdf'],
69
+ sectionTypes: [],
70
+ slots,
71
+ source,
72
+ },
73
+ });
74
+ if (patterns.length >= MAX_CANDIDATES) break;
75
+ }
76
+ return { patterns, vocabulary: [] };
77
+ }
78
+
79
+ function buildExternalPrompt(text, prompt, source) {
80
+ return `You are extracting reusable academic sentence patterns from a research PDF. Use only the supplied text. Return a list of suggestions where each suggestion has: originalText = one exact sentence from the text, suggestedText = a generalized template with concrete entities replaced by {slot1}, {slot2}, ... and description = when a writer should use this pattern. Prefer sentences with academic framing phrases (we propose, it has been shown, plays a key role, the results indicate, etc.). Extract ${MAX_CANDIDATES} patterns or fewer. Return exactly one suggestion that replaces the entire text ${SENTINEL} with the JSON array of {originalText, suggestedText, description} entries.
81
+
82
+ Additional instruction: ${prompt || 'Focus on framing and transition patterns that are widely reusable.'}
83
+
84
+ Source: ${source}
85
+
86
+ Document text:
87
+ <document>
88
+ ${text.slice(0, 400_000)}
89
+ </document>`;
90
+ }
91
+
92
+ export async function extractTextCandidates(workspaceRoot, input = {}, options = {}) {
93
+ const text = typeof input.text === 'string' ? input.text : '';
94
+ if (!text.trim()) throw problem('PDF text is required');
95
+ if (text.length > MAX_TEXT_CHARS) throw problem(`PDF text exceeds ${MAX_TEXT_CHARS} characters`);
96
+ const source = clean(input.source) || 'PDF document';
97
+ const provider = options.provider || 'mock';
98
+ if (provider === 'mock') {
99
+ const candidates = composeMockTextCandidates(text, source);
100
+ return { candidates, provider, note: 'Mock extraction uses deterministic rules; for higher-quality patterns run with a configured external Agent.' };
101
+ }
102
+ const startedAt = now();
103
+ const run = await createAgentRun(workspaceRoot, {
104
+ provider, operation: 'extract-patterns', status: 'running', prompt: input.prompt || 'Extract reusable academic sentence patterns.',
105
+ input: JSON.stringify({ source, characters: text.length }), output: '', error: '', startedAt, finishedAt: '',
106
+ });
107
+ try {
108
+ const result = await runWritingAgent(provider, {
109
+ content: SENTINEL,
110
+ prompt: buildExternalPrompt(text, input.prompt, source),
111
+ resourceContext: '', resourceIds: [],
112
+ }, { workspaceRoot, commands: options.commands || {}, signal: options.signal });
113
+ const proposal = result.suggestions?.find((item) => item.originalText === SENTINEL) || result.suggestions?.[0];
114
+ if (!proposal?.suggestedText?.trim()) throw problem('Agent did not return extracted patterns', 502);
115
+ let entries = [];
116
+ try {
117
+ const parsed = JSON.parse(proposal.suggestedText);
118
+ entries = Array.isArray(parsed) ? parsed : [];
119
+ } catch {
120
+ const fence = proposal.suggestedText.match(/```(?:json)?\s*([\s\S]*?)```/i);
121
+ try { entries = JSON.parse(fence?.[1] || proposal.suggestedText); } catch { entries = []; }
122
+ }
123
+ const patterns = entries.slice(0, MAX_CANDIDATES).map((entry, index) => {
124
+ const template = clean(entry.suggestedText || entry.template || '');
125
+ const original = clean(entry.originalText || '');
126
+ if (!template) return null;
127
+ const slots = detectSlots(template);
128
+ return {
129
+ kind: 'sentence-patterns',
130
+ value: {
131
+ name: `PDF pattern ${index + 1}`,
132
+ template,
133
+ description: clean(entry.description) || `Extracted from ${source}${original ? ` — original: ${original.slice(0, 160)}` : ''}`,
134
+ tags: ['extracted', 'pdf'],
135
+ sectionTypes: [],
136
+ slots,
137
+ source,
138
+ },
139
+ };
140
+ }).filter(Boolean);
141
+ if (!patterns.length) throw problem('Agent returned no usable patterns', 502);
142
+ const finishedAt = now();
143
+ await updateAgentRun(workspaceRoot, run.id, { status: 'complete', output: JSON.stringify({ count: patterns.length, summary: patterns[0].value.template }), finishedAt });
144
+ return { candidates: { patterns, vocabulary: [] }, provider, note: '', runId: run.id };
145
+ } catch (error) {
146
+ await updateAgentRun(workspaceRoot, run.id, { status: 'failed', error: String(error?.message || 'Extraction failed').slice(0, 4000), finishedAt: now() });
147
+ throw error;
148
+ }
149
+ }
@@ -0,0 +1,49 @@
1
+ import { homedir } from 'os';
2
+ import { isAbsolute, relative, resolve, sep } from 'path';
3
+ import { readdir, realpath, stat } from 'fs/promises';
4
+
5
+ function isWithin(root, candidate) {
6
+ if (root === candidate) return true;
7
+ const rel = relative(root, candidate);
8
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
9
+ }
10
+
11
+ async function canonical(path) {
12
+ try { return await realpath(path); } catch { return resolve(path); }
13
+ }
14
+
15
+ async function isGitRepository(path) {
16
+ try { return (await stat(resolve(path, '.git'))).isDirectory() || (await stat(resolve(path, '.git'))).isFile(); }
17
+ catch { return false; }
18
+ }
19
+
20
+ export async function browseWorkspaceDirectories(requestedPath = '', { root = homedir() } = {}) {
21
+ const browseRoot = await canonical(resolve(root));
22
+ const requested = typeof requestedPath === 'string' ? requestedPath.trim() : '';
23
+ const lexical = requested ? resolve(browseRoot, requested) : browseRoot;
24
+ const candidate = await canonical(lexical);
25
+ if (!isWithin(browseRoot, candidate)) {
26
+ throw Object.assign(new Error('Folder browsing is restricted to your home directory. Paste an absolute path to use another location.'), { status: 403, code: 'BROWSE_OUTSIDE_ROOT' });
27
+ }
28
+ let info;
29
+ try { info = await stat(candidate); }
30
+ catch (error) {
31
+ throw Object.assign(new Error(error.code === 'EACCES' ? 'Permission denied.' : 'Folder is unavailable.'), { status: 400, code: error.code || 'BROWSE_FAILED' });
32
+ }
33
+ if (!info.isDirectory()) throw Object.assign(new Error('The selected path is not a folder.'), { status: 400, code: 'INVALID_WORKSPACE_PATH' });
34
+ let entries;
35
+ try { entries = await readdir(candidate, { withFileTypes: true }); }
36
+ catch (error) {
37
+ throw Object.assign(new Error(error.code === 'EACCES' ? 'Permission denied.' : error.message), { status: 400, code: error.code || 'BROWSE_FAILED' });
38
+ }
39
+ const directories = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).sort((a, b) => a.name.localeCompare(b.name));
40
+ return {
41
+ rootPath: browseRoot,
42
+ currentPath: candidate,
43
+ parentPath: candidate === browseRoot ? null : resolve(candidate, '..'),
44
+ entries: await Promise.all(directories.map(async (entry) => {
45
+ const path = resolve(candidate, entry.name);
46
+ return { name: entry.name, path, git: await isGitRepository(path) };
47
+ })),
48
+ };
49
+ }
@@ -0,0 +1,143 @@
1
+ import { createHash } from 'crypto';
2
+ import { homedir } from 'os';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'path';
4
+ import { mkdir, readFile, realpath, rename, stat, writeFile } from 'fs/promises';
5
+
6
+ export const DEFAULT_WORKSPACE_REGISTRY_FILE = join(homedir(), '.papergod', 'workspaces.json');
7
+
8
+ function workspaceId(path) {
9
+ return `workspace_${createHash('sha256').update(path).digest('hex').slice(0, 16)}`;
10
+ }
11
+
12
+ async function canonicalDirectory(path) {
13
+ if (typeof path !== 'string' || !path.trim() || path.includes('\0')) {
14
+ throw Object.assign(new Error('Workspace path is required.'), { status: 400, code: 'INVALID_WORKSPACE_PATH' });
15
+ }
16
+ if (!isAbsolute(path.trim())) {
17
+ throw Object.assign(new Error('Use an absolute folder path.'), { status: 400, code: 'INVALID_WORKSPACE_PATH' });
18
+ }
19
+ const target = resolve(path.trim());
20
+ let info;
21
+ try {
22
+ info = await stat(target);
23
+ } catch (error) {
24
+ if (error.code === 'ENOENT') {
25
+ throw Object.assign(new Error('The selected folder does not exist.'), { status: 404, code: 'WORKSPACE_NOT_FOUND' });
26
+ }
27
+ throw error;
28
+ }
29
+ if (!info.isDirectory()) {
30
+ throw Object.assign(new Error('The selected path is not a folder.'), { status: 400, code: 'INVALID_WORKSPACE_PATH' });
31
+ }
32
+ return await realpath(target);
33
+ }
34
+
35
+ function emptyRegistry() {
36
+ return { version: 1, activePath: '', workspaces: [] };
37
+ }
38
+
39
+ async function readRegistry(file) {
40
+ try {
41
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
42
+ return {
43
+ version: 1,
44
+ activePath: typeof parsed.activePath === 'string' ? parsed.activePath : '',
45
+ workspaces: Array.isArray(parsed.workspaces) ? parsed.workspaces.filter((item) => item && typeof item.path === 'string') : [],
46
+ };
47
+ } catch (error) {
48
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return emptyRegistry();
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ async function writeRegistry(file, registry) {
54
+ await mkdir(dirname(file), { recursive: true });
55
+ const temporary = `${file}.${process.pid}.tmp`;
56
+ await writeFile(temporary, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
57
+ await rename(temporary, file);
58
+ }
59
+
60
+ export function createWorkspaceRegistry({ file = DEFAULT_WORKSPACE_REGISTRY_FILE } = {}) {
61
+ let operationQueue = Promise.resolve();
62
+ const serialize = (operation) => {
63
+ const result = operationQueue.then(operation, operation);
64
+ operationQueue = result.catch(() => {});
65
+ return result;
66
+ };
67
+
68
+ async function add(path, { activate = false } = {}) {
69
+ const canonical = await canonicalDirectory(path);
70
+ const registry = await readRegistry(file);
71
+ const timestamp = new Date().toISOString();
72
+ let entry = registry.workspaces.find((item) => item.path === canonical);
73
+ if (!entry) {
74
+ entry = { id: workspaceId(canonical), name: basename(canonical) || canonical, path: canonical, addedAt: timestamp, lastOpenedAt: timestamp };
75
+ registry.workspaces.push(entry);
76
+ }
77
+ if (activate) {
78
+ entry.lastOpenedAt = timestamp;
79
+ registry.activePath = canonical;
80
+ }
81
+ await writeRegistry(file, registry);
82
+ return { ...entry };
83
+ }
84
+
85
+ async function activate(idOrPath) {
86
+ const registry = await readRegistry(file);
87
+ let entry = registry.workspaces.find((item) => item.id === idOrPath);
88
+ if (!entry && typeof idOrPath === 'string' && isAbsolute(idOrPath)) {
89
+ const canonical = await canonicalDirectory(idOrPath);
90
+ entry = registry.workspaces.find((item) => item.path === canonical);
91
+ }
92
+ if (!entry) throw Object.assign(new Error('Workspace is not registered.'), { status: 404, code: 'WORKSPACE_NOT_FOUND' });
93
+ entry.path = await canonicalDirectory(entry.path);
94
+ entry.name = basename(entry.path) || entry.path;
95
+ entry.lastOpenedAt = new Date().toISOString();
96
+ registry.activePath = entry.path;
97
+ await writeRegistry(file, registry);
98
+ return { ...entry };
99
+ }
100
+
101
+ async function getActive() {
102
+ const registry = await readRegistry(file);
103
+ if (!registry.activePath) return null;
104
+ try {
105
+ const canonical = await canonicalDirectory(registry.activePath);
106
+ const entry = registry.workspaces.find((item) => item.path === registry.activePath || item.path === canonical);
107
+ return entry
108
+ ? { ...entry, path: canonical, name: basename(canonical) || canonical }
109
+ : { id: workspaceId(canonical), name: basename(canonical) || canonical, path: canonical };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ async function list(activePath = '') {
116
+ const registry = await readRegistry(file);
117
+ const effectiveActive = activePath || registry.activePath;
118
+ const workspaces = [];
119
+ let changed = false;
120
+ for (const entry of registry.workspaces) {
121
+ try {
122
+ const canonical = await canonicalDirectory(entry.path);
123
+ if (canonical !== entry.path) changed = true;
124
+ workspaces.push({ ...entry, path: canonical, name: basename(canonical) || canonical, available: true, active: canonical === effectiveActive });
125
+ } catch {
126
+ workspaces.push({ ...entry, available: false, active: entry.path === effectiveActive });
127
+ }
128
+ }
129
+ if (changed) {
130
+ registry.workspaces = workspaces.map(({ available: _available, active: _active, ...entry }) => entry);
131
+ await writeRegistry(file, registry);
132
+ }
133
+ return workspaces.sort((a, b) => Number(b.active) - Number(a.active) || String(b.lastOpenedAt).localeCompare(String(a.lastOpenedAt)));
134
+ }
135
+
136
+ return {
137
+ add: (...args) => serialize(() => add(...args)),
138
+ activate: (...args) => serialize(() => activate(...args)),
139
+ getActive: (...args) => serialize(() => getActive(...args)),
140
+ list: (...args) => serialize(() => list(...args)),
141
+ file,
142
+ };
143
+ }
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { basename } from 'path';
3
+ import { spawn as spawnPty } from 'node-pty';
4
+
5
+ const MAX_HISTORY = 200_000;
6
+ const MAX_INPUT = 64_000;
7
+
8
+ function shellLaunch(env = process.env, platform = process.platform) {
9
+ if (platform === 'win32') return { command: env.ComSpec || 'cmd.exe', args: [] };
10
+ const command = env.SHELL || '/bin/bash';
11
+ return { command, args: ['bash', 'zsh', 'fish', 'ksh'].includes(basename(command)) ? ['-l'] : [] };
12
+ }
13
+
14
+ export function createWorkspaceTerminalManager({ env = process.env, platform = process.platform } = {}) {
15
+ const sessions = new Map();
16
+ const workspaceSessions = new Map();
17
+
18
+ function publicSession(session) {
19
+ return { id: session.id, workspace: session.workspace, status: session.status, pid: session.pty.pid, startedAt: session.startedAt, exitCode: session.exitCode };
20
+ }
21
+
22
+ function broadcast(session, event, payload) {
23
+ const body = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
24
+ for (const response of session.listeners) response.write(body);
25
+ }
26
+
27
+ function start(workspace) {
28
+ const existingId = workspaceSessions.get(workspace);
29
+ const existing = existingId && sessions.get(existingId);
30
+ if (existing?.status === 'running') return publicSession(existing);
31
+ if (existing) sessions.delete(existing.id);
32
+ const launch = shellLaunch(env, platform);
33
+ const pty = spawnPty(launch.command, launch.args, {
34
+ name: 'xterm-256color', cols: 100, rows: 30, cwd: workspace,
35
+ env: { ...env, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'papergod' },
36
+ });
37
+ const session = { id: `terminal_${randomUUID()}`, workspace, pty, status: 'running', exitCode: null, startedAt: new Date().toISOString(), history: '', listeners: new Set() };
38
+ sessions.set(session.id, session);
39
+ workspaceSessions.set(workspace, session.id);
40
+ pty.onData((data) => {
41
+ session.history = `${session.history}${data}`.slice(-MAX_HISTORY);
42
+ broadcast(session, 'output', { data });
43
+ });
44
+ pty.onExit(({ exitCode, signal }) => {
45
+ session.status = 'exited';
46
+ session.exitCode = exitCode;
47
+ broadcast(session, 'exit', { exitCode, signal });
48
+ for (const response of session.listeners) response.end();
49
+ session.listeners.clear();
50
+ });
51
+ return publicSession(session);
52
+ }
53
+
54
+ function requireSession(id) {
55
+ const session = sessions.get(id);
56
+ if (!session) throw Object.assign(new Error('Terminal session not found.'), { status: 404, code: 'TERMINAL_NOT_FOUND' });
57
+ return session;
58
+ }
59
+
60
+ function attach(id, response) {
61
+ const session = requireSession(id);
62
+ session.listeners.add(response);
63
+ response.write(`event: ready\ndata: ${JSON.stringify({ session: publicSession(session), history: session.history })}\n\n`);
64
+ if (session.status !== 'running') response.write(`event: exit\ndata: ${JSON.stringify({ exitCode: session.exitCode })}\n\n`);
65
+ return () => session.listeners.delete(response);
66
+ }
67
+
68
+ function input(id, data) {
69
+ const session = requireSession(id);
70
+ if (session.status !== 'running') throw Object.assign(new Error('Terminal has exited.'), { status: 409, code: 'TERMINAL_EXITED' });
71
+ if (typeof data !== 'string' || data.length > MAX_INPUT) throw Object.assign(new Error('Terminal input must be a string up to 64 KB.'), { status: 400, code: 'INVALID_TERMINAL_INPUT' });
72
+ session.pty.write(data);
73
+ }
74
+
75
+ function resize(id, cols, rows) {
76
+ const session = requireSession(id);
77
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 10 || cols > 400 || rows < 4 || rows > 200) {
78
+ throw Object.assign(new Error('Invalid terminal dimensions.'), { status: 400, code: 'INVALID_TERMINAL_SIZE' });
79
+ }
80
+ if (session.status === 'running') session.pty.resize(cols, rows);
81
+ }
82
+
83
+ function close(id) {
84
+ const session = requireSession(id);
85
+ if (session.status === 'running') session.pty.kill();
86
+ sessions.delete(id);
87
+ if (workspaceSessions.get(session.workspace) === id) workspaceSessions.delete(session.workspace);
88
+ for (const response of session.listeners) response.end();
89
+ session.listeners.clear();
90
+ }
91
+
92
+ function closeAll() {
93
+ for (const id of [...sessions.keys()]) {
94
+ try { close(id); } catch {}
95
+ }
96
+ }
97
+
98
+ return { start, attach, input, resize, close, closeAll, get: (id) => publicSession(requireSession(id)) };
99
+ }