papilot 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +126 -2
- package/README.zh-CN.md +127 -0
- package/bin/papilot.mjs +113 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
- package/dist/assets/index-BhQFCMTx.js +131 -0
- package/dist/assets/index-CAV3-87U.css +1 -0
- package/dist/assets/pdf-view-DJo0X38w.js +598 -0
- package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +4 -0
- package/examples/papilot-paper/.latex-review.json +4 -0
- package/examples/papilot-paper/appendix/proofs.tex +60 -0
- package/examples/papilot-paper/appendix/reference.tex +54 -0
- package/examples/papilot-paper/main.tex +57 -0
- package/examples/papilot-paper/preamble.tex +53 -0
- package/examples/papilot-paper/references.bib +158 -0
- package/examples/papilot-paper/sections/01-introduction.tex +66 -0
- package/examples/papilot-paper/sections/02-using.tex +134 -0
- package/examples/papilot-paper/sections/03-background.tex +97 -0
- package/examples/papilot-paper/sections/04-model.tex +185 -0
- package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
- package/examples/papilot-paper/sections/06-building.tex +70 -0
- package/examples/papilot-paper/sections/07-geometry.tex +193 -0
- package/examples/papilot-paper/sections/08-notes.tex +104 -0
- package/examples/papilot-paper/sections/09-agents.tex +106 -0
- package/examples/papilot-paper/sections/10-usage.tex +56 -0
- package/examples/papilot-paper/sections/11-git.tex +62 -0
- package/examples/papilot-paper/sections/12-security.tex +41 -0
- package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
- package/examples/papilot-paper/sections/14-related.tex +38 -0
- package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
- package/package.json +85 -4
- package/scripts/demo.ts +21 -0
- package/scripts/service.ts +35 -0
- package/src/notes.ts +40 -0
- package/src/server/agents.ts +343 -0
- package/src/server/codex.ts +63 -0
- package/src/server/frontend.ts +15 -0
- package/src/server/main.ts +202 -0
- package/src/server/manuscript.ts +337 -0
- package/src/server/paths.ts +10 -0
- package/src/server/pdf.ts +27 -0
- package/src/server/review.ts +53 -0
- package/src/server/security.ts +15 -0
- package/src/server/store.ts +60 -0
- package/src/server/workspace.ts +529 -0
- package/src/shared.ts +28 -0
- package/vendor/latex-review/LICENSE +21 -0
- package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
- package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
- package/vendor/latex-review/dist/web/index.html +18 -0
- package/vendor/latex-review/package-lock.json +3114 -0
- package/vendor/latex-review/package.json +49 -0
- package/vendor/latex-review/src/cli.ts +494 -0
- package/vendor/latex-review/src/client.ts +198 -0
- package/vendor/latex-review/src/core/build.ts +296 -0
- package/vendor/latex-review/src/core/changes.ts +126 -0
- package/vendor/latex-review/src/core/config.ts +270 -0
- package/vendor/latex-review/src/core/diff.ts +54 -0
- package/vendor/latex-review/src/core/files.ts +96 -0
- package/vendor/latex-review/src/core/git.ts +317 -0
- package/vendor/latex-review/src/core/outline.ts +314 -0
- package/vendor/latex-review/src/core/process.ts +111 -0
- package/vendor/latex-review/src/core/publish.ts +339 -0
- package/vendor/latex-review/src/core/review.ts +734 -0
- package/vendor/latex-review/src/core/store.ts +66 -0
- package/vendor/latex-review/src/core/tasks.ts +237 -0
- package/vendor/latex-review/src/render/inline.ts +805 -0
- package/vendor/latex-review/src/render/locate.ts +481 -0
- package/vendor/latex-review/src/render/pdf.ts +281 -0
- package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
- package/vendor/latex-review/src/render/round.ts +236 -0
- package/vendor/latex-review/src/render/worker.ts +10 -0
- package/vendor/latex-review/src/runtime.ts +23 -0
- package/vendor/latex-review/src/server.ts +401 -0
- package/vendor/latex-review/src/types.ts +283 -0
- package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
- package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
- package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
- package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
- package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
- package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
- package/vendor/latex-review/src/web/fonts/README.md +12 -0
- package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- package/vendor/latex-review/src/web/index.html +17 -0
- package/vendor/latex-review/src/web/main.tsx +10 -0
- package/vendor/latex-review/src/web/model.ts +153 -0
- package/vendor/latex-review/src/web/overview.tsx +146 -0
- package/vendor/latex-review/src/web/review.tsx +991 -0
- package/vendor/latex-review/src/web/style.css +1249 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { ROOT, DATA } from '../src/server/paths.ts';
|
|
6
|
+
|
|
7
|
+
if (process.platform !== 'darwin') throw new Error('This service installer is for macOS. On other systems run npm start with your process manager.');
|
|
8
|
+
const label = 'local.papilot';
|
|
9
|
+
const domain = `gui/${process.getuid!()}`;
|
|
10
|
+
const file = path.join(os.homedir(), 'Library/LaunchAgents', label + '.plist');
|
|
11
|
+
if (process.argv[2] === 'stop') {
|
|
12
|
+
execFileSync('launchctl', ['bootout', `${domain}/${label}`], { stdio: 'inherit' });
|
|
13
|
+
} else if (process.argv[2] === 'install') {
|
|
14
|
+
const xml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
15
|
+
fs.mkdirSync(DATA, { recursive: true, mode: 0o700 });
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
+
const env: Record<string, string> = { PATH: [path.join(ROOT, 'node_modules/.bin'), '/Library/TeX/texbin', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':'), HOME: os.homedir(), NODE_ENV: 'production' };
|
|
18
|
+
for (const [key, value] of Object.entries(process.env)) if (key.startsWith('PAPILOT_') && value) env[key] = value;
|
|
19
|
+
const args = ['/usr/bin/caffeinate', '-i', process.execPath, '--import', 'tsx', path.join(ROOT, 'src/server/main.ts')];
|
|
20
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict>
|
|
21
|
+
<key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args.map(a => `<string>${xml(a)}</string>`).join('')}</array>
|
|
22
|
+
<key>WorkingDirectory</key><string>${xml(ROOT)}</string><key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
23
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(env).map(([k, v]) => `<key>${xml(k)}</key><string>${xml(v)}</string>`).join('')}</dict>
|
|
24
|
+
<key>StandardOutPath</key><string>${xml(path.join(DATA, 'service.log'))}</string><key>StandardErrorPath</key><string>${xml(path.join(DATA, 'service.error.log'))}</string>
|
|
25
|
+
<key>ThrottleInterval</key><integer>10</integer></dict></plist>`;
|
|
26
|
+
fs.writeFileSync(file, plist, { mode: 0o600 });
|
|
27
|
+
try { execFileSync('launchctl', ['bootout', `${domain}/${label}`], { stdio: 'ignore' }); } catch { /* first install */ }
|
|
28
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
29
|
+
try { execFileSync('launchctl', ['print', `${domain}/${label}`], { stdio: 'ignore' }); }
|
|
30
|
+
catch { break; }
|
|
31
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
32
|
+
}
|
|
33
|
+
execFileSync('launchctl', ['bootstrap', domain, file], { stdio: 'inherit' });
|
|
34
|
+
console.log(`Installed ${label}. Starts after login and restarts if it exits. Logs: ${DATA}`);
|
|
35
|
+
} else throw new Error('Use install or stop');
|
package/src/notes.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Annotation } from './shared.ts';
|
|
2
|
+
|
|
3
|
+
// PDF notes reach the agent as one plain-text message. The chat shows that
|
|
4
|
+
// message as the notes again, so the format and its reader live together.
|
|
5
|
+
const PREAMBLE = 'Revise the manuscript according to these notes the author marked on the compiled PDF.';
|
|
6
|
+
const QUOTE = ' PDF text: "', REGION = ' Marked region: a figure, equation or other non-text area', NOTE = '\n Note: ';
|
|
7
|
+
|
|
8
|
+
export interface NoteSummary { where: string; quote: string; comment: string }
|
|
9
|
+
|
|
10
|
+
export function notesInstructions(notes: Annotation[]) {
|
|
11
|
+
const items = notes.map((note, index) => {
|
|
12
|
+
const where = [`Page ${note.page + 1}`, note.source ? `${note.source.file} ${note.source.lines[0] === note.source.lines[1] ? `line ${note.source.lines[0]}` : `lines ${note.source.lines[0]}–${note.source.lines[1]}`}` : ''].filter(Boolean).join(' · ');
|
|
13
|
+
const source = note.source?.excerpt ? [' LaTeX source of the marked region:', ' ```latex', ...note.source.excerpt.split('\n').map(line => ' ' + line), ' ```'] : [];
|
|
14
|
+
return [`${index + 1}. ${where}`, ...source, note.quote ? `${QUOTE}${note.quote}"` : REGION, `${NOTE.slice(1)}${note.comment.trim().replace(/\n/g, '\n ')}`].join('\n');
|
|
15
|
+
});
|
|
16
|
+
return `${PREAMBLE} SyncTeX mapped each marked region to the LaTeX file and lines that produced it; the PDF text is what the author saw. Read the current source before editing, and change only what the notes ask for.\n\n${items.join('\n\n')}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The notes in a message written by notesInstructions, or null for any other message.
|
|
20
|
+
export function readNotesInstructions(text: string): NoteSummary[] | null {
|
|
21
|
+
if (!text.startsWith(PREAMBLE)) return null;
|
|
22
|
+
const notes: NoteSummary[] = [];
|
|
23
|
+
for (const block of text.split(/\n\n(?=\d+\. )/).slice(1)) {
|
|
24
|
+
const heading = block.match(/^\d+\. (.*)\n/);
|
|
25
|
+
if (!heading) return null;
|
|
26
|
+
let rest = block.slice(heading[0].length);
|
|
27
|
+
if (rest.startsWith(' LaTeX source of the marked region:\n ```latex\n')) {
|
|
28
|
+
// The excerpt may hold a fence of its own; the closing one is followed by the marked text.
|
|
29
|
+
const end = rest.match(/\n {3}```\n(?= {3}(?:PDF text: "|Marked region: ))/);
|
|
30
|
+
if (!end) return null;
|
|
31
|
+
rest = rest.slice(end.index! + end[0].length);
|
|
32
|
+
}
|
|
33
|
+
const note = rest.indexOf(NOTE);
|
|
34
|
+
if (note < 0) return null;
|
|
35
|
+
const marked = rest.slice(0, note);
|
|
36
|
+
if (marked !== REGION && !(marked.startsWith(QUOTE) && marked.endsWith('"'))) return null;
|
|
37
|
+
notes.push({ where: heading[1], quote: marked === REGION ? '' : marked.slice(QUOTE.length, -1), comment: rest.slice(note + NOTE.length).replace(/\n {3}/g, '\n') });
|
|
38
|
+
}
|
|
39
|
+
return notes.length ? notes : null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import type { AccountInfo, ModelInfo, Options, PermissionResult, Query, SDKMessage, SDKRateLimitInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
12
|
+
import type { AgentId } from '../shared.ts';
|
|
13
|
+
import { DATA, ROOT } from './paths.ts';
|
|
14
|
+
|
|
15
|
+
// Papilot drives every agent through the request and notification surface of
|
|
16
|
+
// the Codex app-server: thread/start, thread/resume, turn/start,
|
|
17
|
+
// turn/interrupt and thread/read, item and turn notifications, and approval
|
|
18
|
+
// requests answered with respond(). Claude and Antigravity get small adapters
|
|
19
|
+
// that translate their own interfaces into that surface, so the task
|
|
20
|
+
// lifecycle (queueing, approvals, pausing, review rounds) stays in one place.
|
|
21
|
+
export interface AgentRuntime extends EventEmitter {
|
|
22
|
+
ready: boolean;
|
|
23
|
+
start(): Promise<void>;
|
|
24
|
+
request(method: string, params: any, timeout?: number): Promise<any>;
|
|
25
|
+
respond(id: string | number, result: unknown): void;
|
|
26
|
+
reject(id: string | number, message: string): void;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AGENT_NAMES: Record<AgentId, string> = { codex: 'Codex', claude: 'Claude', antigravity: 'Antigravity' };
|
|
31
|
+
|
|
32
|
+
interface Thread { cwd: string; instructions: string; model: string | null; started: boolean }
|
|
33
|
+
interface Turn { id: string; threadId: string; clientId?: string; status: 'inProgress' | 'completed' | 'interrupted' | 'failed'; stop: () => void }
|
|
34
|
+
interface Item { id: string; type: string; [key: string]: unknown }
|
|
35
|
+
|
|
36
|
+
// Shared bookkeeping: threads, turns and the notifications Papilot expects.
|
|
37
|
+
abstract class Adapter extends EventEmitter implements AgentRuntime {
|
|
38
|
+
ready = false;
|
|
39
|
+
protected threads = new Map<string, Thread>();
|
|
40
|
+
protected turns = new Map<string, Turn>();
|
|
41
|
+
abstract start(): Promise<void>;
|
|
42
|
+
protected abstract run(turn: Turn, thread: Thread, text: string, effort: string | null): void;
|
|
43
|
+
protected account(): unknown { return { account: null, requiresOpenaiAuth: false }; }
|
|
44
|
+
protected models(): unknown { return { data: [] }; }
|
|
45
|
+
protected limits(): unknown { throw new Error('This agent does not report usage.'); }
|
|
46
|
+
async request(method: string, params: any = {}): Promise<any> {
|
|
47
|
+
if (method === 'account/read') return this.account();
|
|
48
|
+
if (method === 'model/list') return this.models();
|
|
49
|
+
if (method === 'account/rateLimits/read') return this.limits();
|
|
50
|
+
if (method === 'thread/start' || method === 'thread/resume') {
|
|
51
|
+
const id = method === 'thread/start' ? randomUUID() : String(params.threadId);
|
|
52
|
+
const known = this.threads.get(id);
|
|
53
|
+
this.threads.set(id, { cwd: params.cwd, instructions: params.developerInstructions || '', model: params.model || known?.model || null, started: method === 'thread/resume' || Boolean(known?.started) });
|
|
54
|
+
return { thread: { id }, model: params.model || null };
|
|
55
|
+
}
|
|
56
|
+
if (method === 'turn/start') {
|
|
57
|
+
const thread = this.threads.get(String(params.threadId));
|
|
58
|
+
if (!thread) throw new Error('Start or resume the thread before starting a turn.');
|
|
59
|
+
const text = (params.input || []).map((part: { text?: string }) => part.text || '').join('\n');
|
|
60
|
+
const turn: Turn = { id: randomUUID(), threadId: String(params.threadId), clientId: params.clientUserMessageId, status: 'inProgress', stop: () => {} };
|
|
61
|
+
this.turns.set(turn.id, turn);
|
|
62
|
+
// Papilot records the turn ID before the first event arrives.
|
|
63
|
+
setImmediate(() => this.run(turn, { ...thread, cwd: params.cwd || thread.cwd }, text, params.effort || null));
|
|
64
|
+
return { turn: { id: turn.id } };
|
|
65
|
+
}
|
|
66
|
+
if (method === 'turn/interrupt') { this.turns.get(String(params.turnId))?.stop(); return {}; }
|
|
67
|
+
if (method === 'thread/read') {
|
|
68
|
+
const turns = [...this.turns.values()].filter(turn => turn.threadId === params.threadId)
|
|
69
|
+
.map(turn => ({ id: turn.id, status: turn.status, items: turn.clientId ? [{ type: 'userMessage', clientId: turn.clientId }] : [] }));
|
|
70
|
+
return { thread: { id: params.threadId, status: { type: 'loaded' }, turns } };
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`${method} is not supported by this agent.`);
|
|
73
|
+
}
|
|
74
|
+
respond(_id: string | number, _result: unknown) {}
|
|
75
|
+
reject(_id: string | number, _message: string) {}
|
|
76
|
+
close() { for (const turn of this.turns.values()) if (turn.status === 'inProgress') turn.stop(); }
|
|
77
|
+
protected notify(method: string, params: Record<string, unknown>) { this.emit('notification', { method, params }); }
|
|
78
|
+
protected item(turn: Turn, item: Item, done = true) { this.notify(done ? 'item/completed' : 'item/started', { threadId: turn.threadId, turnId: turn.id, item }); }
|
|
79
|
+
protected finish(turn: Turn, status: Turn['status'], error?: string) {
|
|
80
|
+
if (turn.status !== 'inProgress') return;
|
|
81
|
+
turn.status = status;
|
|
82
|
+
this.notify('turn/completed', { threadId: turn.threadId, turn: { id: turn.id, status, ...(error ? { error: { message: error } } : {}) } });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Tool calls shown in the conversation: commands, file edits, and the rest by name.
|
|
87
|
+
function toolItem(id: string, name: string, input: Record<string, any>): Item {
|
|
88
|
+
const file = input.file_path || input.notebook_path || input.Path || input.TargetFile || input.AbsolutePath;
|
|
89
|
+
if (name === 'Bash' || name === 'run_command') return { id, type: 'commandExecution', command: String(input.command || input.CommandLine || ''), status: 'inProgress' };
|
|
90
|
+
if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'write_to_file', 'write_file', 'edit_file', 'create_file', 'replace_file_content', 'multi_replace_file_content', 'sed_file', 'notebook_edit'].includes(name) && file) return { id, type: 'fileChange', changes: [{ path: String(file) }], status: 'inProgress' };
|
|
91
|
+
const target = file || input.pattern || input.Query || input.query || input.url || input.SearchPath || input.DirectoryPath || input.description || '';
|
|
92
|
+
return { id, type: 'toolCall', text: `${name}${target ? ` ${String(target).slice(0, 200)}` : ''}`, status: 'inProgress' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CLAUDE_KEY = 'Claude needs an Anthropic API key: Anthropic does not allow a Claude subscription login through the Claude Agent SDK. Start Papilot with ANTHROPIC_API_KEY set to use Claude.';
|
|
96
|
+
let claude: Promise<typeof import('@anthropic-ai/claude-agent-sdk')['query']> | null = null;
|
|
97
|
+
// The Claude Agent SDK carries Claude Code, a large download, so an installed
|
|
98
|
+
// Papilot fetches it into the data folder the first time Claude can be used.
|
|
99
|
+
export function loadClaude() {
|
|
100
|
+
claude ??= (async () => {
|
|
101
|
+
try { return (await import('@anthropic-ai/claude-agent-sdk')).query; }
|
|
102
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw error; }
|
|
103
|
+
const prefix = path.join(DATA, 'agents'), resolve = () => createRequire(path.join(prefix, 'package.json')).resolve('@anthropic-ai/claude-agent-sdk');
|
|
104
|
+
let entry: string;
|
|
105
|
+
try { entry = resolve(); } catch {
|
|
106
|
+
const env = process.env;
|
|
107
|
+
if (!(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.CLAUDE_CODE_USE_BEDROCK || env.CLAUDE_CODE_USE_VERTEX || env.CLAUDE_CODE_USE_FOUNDRY)) throw new Error(CLAUDE_KEY);
|
|
108
|
+
const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).devDependencies['@anthropic-ai/claude-agent-sdk'];
|
|
109
|
+
console.log('Installing the Claude Agent SDK for Claude tasks (first use only)...');
|
|
110
|
+
await promisify(execFile)('npm', ['install', '--prefix', prefix, `@anthropic-ai/claude-agent-sdk@${version}`, '--no-audit', '--no-fund', '--loglevel=error'], { maxBuffer: 16 * 1024 * 1024 });
|
|
111
|
+
entry = resolve();
|
|
112
|
+
}
|
|
113
|
+
return (await import(pathToFileURL(entry).href)).query;
|
|
114
|
+
})().catch(error => { claude = null; throw error; });
|
|
115
|
+
return claude;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Claude runs through the Claude Agent SDK, which runs the Claude Code binary
|
|
119
|
+
// with the login already on this host (a Claude subscription, or an API key
|
|
120
|
+
// if ANTHROPIC_API_KEY is set). Papilot never reads or stores the credential.
|
|
121
|
+
// Edits in the task worktree are accepted, commands run in Claude Code's
|
|
122
|
+
// sandbox, and anything that needs approval comes to Papilot as a request.
|
|
123
|
+
export class ClaudeAgent extends Adapter {
|
|
124
|
+
private info: { account: AccountInfo | null; models: ModelInfo[] } = { account: null, models: [] };
|
|
125
|
+
private rateLimits = new Map<string, SDKRateLimitInfo>();
|
|
126
|
+
private defaults = new Map<string, string>();
|
|
127
|
+
private approvals = new Map<string, { resolve: (result: PermissionResult) => void; input: Record<string, unknown>; kind: 'tool' | 'question'; questions: string[] }>();
|
|
128
|
+
constructor(private load: () => Promise<typeof import('@anthropic-ai/claude-agent-sdk')['query']> = loadClaude) { super(); }
|
|
129
|
+
async start() {
|
|
130
|
+
if (this.ready) return;
|
|
131
|
+
const query = await this.load();
|
|
132
|
+
// Reading the account and models starts Claude Code without sending a prompt.
|
|
133
|
+
let finish = () => {};
|
|
134
|
+
const idle = (async function* (): AsyncGenerator<SDKUserMessage> { await new Promise<void>(resolve => { finish = resolve; }); })();
|
|
135
|
+
const probe = query({ prompt: idle, options: { cwd: os.homedir(), settingSources: [] } });
|
|
136
|
+
try {
|
|
137
|
+
const [account, models] = await Promise.all([probe.accountInfo(), probe.supportedModels()]);
|
|
138
|
+
this.info = { account, models };
|
|
139
|
+
this.ready = true;
|
|
140
|
+
} finally { finish(); probe.close(); }
|
|
141
|
+
}
|
|
142
|
+
// Claude tasks need an API key (or a cloud provider's own credentials):
|
|
143
|
+
// Anthropic does not accept a Claude subscription login through the SDK.
|
|
144
|
+
protected account() {
|
|
145
|
+
const account = this.info.account, keyed = Boolean(account?.apiKeySource && !['none', 'oauth'].includes(account.apiKeySource));
|
|
146
|
+
const external = Boolean(account?.apiProvider && account.apiProvider !== 'firstParty');
|
|
147
|
+
if (keyed || external) return { account: { email: account?.email, type: external ? account!.apiProvider : 'API key' }, requiresOpenaiAuth: false };
|
|
148
|
+
return { account: null, requiresOpenaiAuth: true, reason: CLAUDE_KEY };
|
|
149
|
+
}
|
|
150
|
+
protected models() {
|
|
151
|
+
const all = this.info.models, preferred = all.find(model => model.value === 'default');
|
|
152
|
+
const data = all.filter(model => model.value !== 'default').map(model => ({
|
|
153
|
+
model: model.value, displayName: model.displayName,
|
|
154
|
+
isDefault: Boolean(preferred?.resolvedModel ? model.resolvedModel === preferred.resolvedModel : model.value === 'opus'),
|
|
155
|
+
supportedReasoningEfforts: (model.supportedEffortLevels || []).map(effort => ({ reasoningEffort: effort })),
|
|
156
|
+
defaultReasoningEffort: this.defaults.get(model.value) || null,
|
|
157
|
+
}));
|
|
158
|
+
return { data };
|
|
159
|
+
}
|
|
160
|
+
// Rolling limit windows come with each turn's rate limit events.
|
|
161
|
+
protected limits() {
|
|
162
|
+
const windows = (['five_hour', 'seven_day'] as const).map(type => this.rateLimits.get(type)).filter((info): info is SDKRateLimitInfo => Boolean(info && typeof info.utilization === 'number'))
|
|
163
|
+
.map(info => ({ usedPercent: info.utilization! <= 1 ? info.utilization! * 100 : info.utilization!, windowDurationMins: info.rateLimitType === 'five_hour' ? 300 : 10080, resetsAt: info.resetsAt ? (info.resetsAt > 1e12 ? Math.round(info.resetsAt / 1000) : info.resetsAt) : null }));
|
|
164
|
+
if (!windows.length) throw new Error('Claude has not reported usage yet.');
|
|
165
|
+
const rejected = [...this.rateLimits.values()].some(info => info.status === 'rejected');
|
|
166
|
+
return { ordinaryUsageAllowed: !rejected, rateLimits: { planType: this.info.account?.subscriptionType?.replace(/^Claude\s+/i, '').toLowerCase() || null, primary: windows[0], secondary: windows[1] || null, credits: null, rateLimitReachedType: rejected ? 'rate_limit_reached' : null } };
|
|
167
|
+
}
|
|
168
|
+
respond(id: string | number, result: any) {
|
|
169
|
+
const wait = this.approvals.get(String(id));
|
|
170
|
+
if (!wait) throw new Error('This request has ended.');
|
|
171
|
+
this.approvals.delete(String(id));
|
|
172
|
+
if (wait.kind === 'question') {
|
|
173
|
+
const answers = Object.fromEntries(wait.questions.map((question, index) => [question, String(result?.answers?.[index]?.answers?.[0] || '')]));
|
|
174
|
+
wait.resolve({ behavior: 'allow', updatedInput: { ...wait.input, answers } });
|
|
175
|
+
} else wait.resolve(result?.decision === 'accept' ? { behavior: 'allow', updatedInput: wait.input } : { behavior: 'deny', message: 'The user declined this action.', interrupt: result?.decision === 'cancel' });
|
|
176
|
+
}
|
|
177
|
+
reject(id: string | number, message: string) {
|
|
178
|
+
const wait = this.approvals.get(String(id));
|
|
179
|
+
if (wait) { this.approvals.delete(String(id)); wait.resolve({ behavior: 'deny', message }); }
|
|
180
|
+
}
|
|
181
|
+
protected run(turn: Turn, thread: Thread, text: string, effort: string | null) { void this.execute(turn, thread, text, effort); }
|
|
182
|
+
private async execute(turn: Turn, thread: Thread, text: string, effort: string | null) {
|
|
183
|
+
const abort = new AbortController();
|
|
184
|
+
turn.stop = () => abort.abort();
|
|
185
|
+
const tools = new Map<string, Item>(), texts = new Map<string, string>();
|
|
186
|
+
let error = '';
|
|
187
|
+
try {
|
|
188
|
+
const query = await this.load();
|
|
189
|
+
const options: Options = {
|
|
190
|
+
cwd: thread.cwd, abortController: abort, includePartialMessages: true,
|
|
191
|
+
...(thread.model ? { model: thread.model } : {}), ...(effort ? { effort: effort as Options['effort'] } : {}),
|
|
192
|
+
// The thread ID is the Claude session ID, so a later turn resumes it.
|
|
193
|
+
...(thread.started ? { resume: turn.threadId } : { sessionId: turn.threadId }),
|
|
194
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append: thread.instructions },
|
|
195
|
+
settingSources: ['project'], permissionMode: 'acceptEdits',
|
|
196
|
+
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
|
197
|
+
canUseTool: (name, input, { signal, title, decisionReason }) => new Promise<PermissionResult>(resolve => {
|
|
198
|
+
const id = randomUUID(), question = name === 'AskUserQuestion' && Array.isArray(input.questions);
|
|
199
|
+
const questions = question ? (input.questions as { question: string; header?: string; options?: { label: string; description?: string }[] }[]) : [];
|
|
200
|
+
this.approvals.set(id, { resolve, input, kind: question ? 'question' : 'tool', questions: questions.map(q => q.question) });
|
|
201
|
+
signal.addEventListener('abort', () => { if (this.approvals.delete(id)) { resolve({ behavior: 'deny', message: 'The turn was stopped.' }); this.notify('serverRequest/resolved', { threadId: turn.threadId, requestId: id }); } }, { once: true });
|
|
202
|
+
const shown = toolItem(id, name, input);
|
|
203
|
+
this.emit('request', question
|
|
204
|
+
? { id, method: 'item/tool/requestUserInput', params: { threadId: turn.threadId, turnId: turn.id, questions: questions.map((q, index) => ({ id: String(index), header: q.header, question: q.question, options: q.options })) } }
|
|
205
|
+
: { id, method: shown.type === 'fileChange' ? 'item/fileChange/requestApproval' : 'item/commandExecution/requestApproval', params: { threadId: turn.threadId, turnId: turn.id, reason: title || decisionReason || `Claude wants to use ${name}.`, command: shown.type === 'commandExecution' ? shown.command : shown.type === 'fileChange' ? `Edit ${(shown.changes as { path: string }[])[0].path}` : shown.text } });
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
for await (const message of query({ prompt: text, options }) as AsyncIterable<SDKMessage>) {
|
|
209
|
+
if (!thread.started && 'session_id' in message && message.session_id) { thread.started = true; this.threads.set(turn.threadId, { ...thread, started: true }); }
|
|
210
|
+
this.translate(turn, thread, message, tools, texts, value => { error = value; });
|
|
211
|
+
}
|
|
212
|
+
this.finish(turn, abort.signal.aborted ? 'interrupted' : error ? 'failed' : 'completed', error ? explain(error) : undefined);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
this.finish(turn, abort.signal.aborted ? 'interrupted' : 'failed', abort.signal.aborted ? undefined : explain(e instanceof Error ? e.message : String(e)));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
private translate(turn: Turn, thread: Thread, message: SDKMessage, tools: Map<string, Item>, texts: Map<string, string>, fail: (error: string) => void) {
|
|
218
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
219
|
+
if (message.effort && thread.model) this.defaults.set(thread.model, message.effort);
|
|
220
|
+
} else if (message.type === 'stream_event' && !message.parent_tool_use_id) {
|
|
221
|
+
const event = message.event as { type: string; index?: number; message?: { id: string }; delta?: { type: string; text?: string } };
|
|
222
|
+
if (event.type === 'message_start' && event.message) texts.set('current', event.message.id);
|
|
223
|
+
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
|
224
|
+
this.notify('item/agentMessage/delta', { threadId: turn.threadId, turnId: turn.id, itemId: `${texts.get('current')}:${event.index}`, delta: event.delta.text });
|
|
225
|
+
}
|
|
226
|
+
} else if (message.type === 'assistant' && !message.parent_tool_use_id) {
|
|
227
|
+
message.message.content.forEach((block, index) => {
|
|
228
|
+
if (block.type === 'text' && block.text.trim()) this.item(turn, { id: `${message.message.id}:${index}`, type: 'agentMessage', text: block.text });
|
|
229
|
+
else if (block.type === 'tool_use') { const item = toolItem(block.id, block.name, block.input as Record<string, any>); tools.set(block.id, item); this.item(turn, item, false); }
|
|
230
|
+
});
|
|
231
|
+
} else if (message.type === 'user' && !message.parent_tool_use_id && Array.isArray(message.message.content)) {
|
|
232
|
+
for (const block of message.message.content) {
|
|
233
|
+
if (block.type !== 'tool_result' || !tools.has(block.tool_use_id)) continue;
|
|
234
|
+
const item = tools.get(block.tool_use_id)!, output = typeof block.content === 'string' ? block.content : (block.content || []).map(part => part.type === 'text' ? part.text : '').join('\n');
|
|
235
|
+
this.item(turn, { ...item, status: block.is_error ? 'failed' : 'completed', ...(item.type === 'commandExecution' ? { aggregatedOutput: output.slice(-40000) } : {}) });
|
|
236
|
+
}
|
|
237
|
+
} else if (message.type === 'rate_limit_event') {
|
|
238
|
+
if (message.rate_limit_info.rateLimitType) this.rateLimits.set(message.rate_limit_info.rateLimitType, message.rate_limit_info);
|
|
239
|
+
this.notify('account/rateLimits/updated', {});
|
|
240
|
+
} else if (message.type === 'result' && message.subtype !== 'success') fail(message.errors?.join(' ') || 'Claude stopped with an error.');
|
|
241
|
+
else if (message.type === 'result' && message.is_error) fail(message.result || 'Claude stopped with an error.');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Anthropic answers 403 when a Claude subscription login is used through the
|
|
246
|
+
// Agent SDK; say so instead of the bare status.
|
|
247
|
+
function explain(error: string) {
|
|
248
|
+
return /403[^\n]*Request not allowed/i.test(error)
|
|
249
|
+
? `Anthropic refused the request (${error.trim()}). A Claude subscription login cannot be used through the Claude Agent SDK; give Papilot an Anthropic API key (ANTHROPIC_API_KEY) to run Claude.`
|
|
250
|
+
: error;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Antigravity runs as `agy -p` with streamed JSON, one process per turn,
|
|
254
|
+
// resuming its conversation by ID. Headless agy cannot ask for permission, so
|
|
255
|
+
// it runs in accept-edits mode: it edits files inside the task worktree, and
|
|
256
|
+
// anything else that needs approval (shell commands, edits elsewhere) is
|
|
257
|
+
// declined by agy itself and reported in the conversation.
|
|
258
|
+
export class AntigravityAgent extends Adapter {
|
|
259
|
+
private catalog: { model: string; displayName: string; efforts: string[]; ids: Record<string, string>; isDefault: boolean; defaultEffort: string | null }[] = [];
|
|
260
|
+
private conversations: Record<string, string> = {};
|
|
261
|
+
constructor(private binary = process.env.PAPILOT_AGY_BIN || 'agy', private file = path.join(DATA, 'antigravity-conversations.json'),
|
|
262
|
+
private launch: (command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => ChildProcess = (command, args, options) => spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] })) {
|
|
263
|
+
super();
|
|
264
|
+
try { this.conversations = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { /* None yet. */ }
|
|
265
|
+
}
|
|
266
|
+
// Some regions need a proxy for Antigravity; PAPILOT_AGY_PROXY applies only to agy.
|
|
267
|
+
private env() {
|
|
268
|
+
const proxy = process.env.PAPILOT_AGY_PROXY;
|
|
269
|
+
return proxy ? { ...process.env, HTTPS_PROXY: proxy, HTTP_PROXY: proxy, https_proxy: proxy, http_proxy: proxy, NO_PROXY: '127.0.0.1,localhost' } : process.env;
|
|
270
|
+
}
|
|
271
|
+
async start() {
|
|
272
|
+
if (this.ready) return;
|
|
273
|
+
const { stdout } = await promisify(execFile)(this.binary, ['models'], { env: this.env(), timeout: 60000 });
|
|
274
|
+
let preferred = '';
|
|
275
|
+
try { preferred = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.gemini/antigravity-cli/settings.json'), 'utf8')).model || ''; } catch { /* No saved choice. */ }
|
|
276
|
+
this.catalog = groupModels(stdout, preferred);
|
|
277
|
+
this.ready = true;
|
|
278
|
+
}
|
|
279
|
+
protected account() { return { account: this.ready ? { type: 'Google account' } : null, requiresOpenaiAuth: false }; }
|
|
280
|
+
protected models() {
|
|
281
|
+
return { data: this.catalog.map(model => ({ model: model.model, displayName: model.displayName, isDefault: model.isDefault, supportedReasoningEfforts: model.efforts.map(effort => ({ reasoningEffort: effort })), defaultReasoningEffort: model.defaultEffort })) };
|
|
282
|
+
}
|
|
283
|
+
protected run(turn: Turn, thread: Thread, text: string, effort: string | null) {
|
|
284
|
+
const conversation = this.conversations[turn.threadId], entry = this.catalog.find(model => model.model === thread.model);
|
|
285
|
+
const model = entry ? entry.ids[effort || entry.defaultEffort || ''] || Object.values(entry.ids)[0] : thread.model;
|
|
286
|
+
// agy has no separate system prompt: the first turn carries Papilot's instructions.
|
|
287
|
+
const prompt = conversation ? text : `${thread.instructions}\n\n---\n\n${text}`;
|
|
288
|
+
const child = this.launch(this.binary, ['-p', prompt, '--output-format', 'stream-json', '--mode', 'accept-edits', ...(model ? ['--model', model] : []), ...(conversation ? ['--conversation', conversation] : [])], { cwd: thread.cwd, env: this.env() });
|
|
289
|
+
let stopped = false, result: { status?: string; error?: string; denied_actions?: { display_name?: string; action?: string }[] } | null = null, stderr = '';
|
|
290
|
+
turn.stop = () => { stopped = true; child.kill('SIGINT'); setTimeout(() => child.kill('SIGKILL'), 5000).unref(); };
|
|
291
|
+
const texts = new Map<number, string>();
|
|
292
|
+
createInterface({ input: child.stdout! }).on('line', line => {
|
|
293
|
+
let event: any;
|
|
294
|
+
try { event = JSON.parse(line); } catch { if (line.trim()) stderr = (stderr + '\n' + line).slice(-4000); return; }
|
|
295
|
+
if (event.event === 'init' && event.conversation_id && !conversation) { this.conversations[turn.threadId] = event.conversation_id; this.save(); }
|
|
296
|
+
const step = event.step_update;
|
|
297
|
+
if (step?.step_type === 'agent_response') {
|
|
298
|
+
const id = `${turn.id}:${step.step_index}`;
|
|
299
|
+
if (step.text_delta) { texts.set(step.step_index, (texts.get(step.step_index) || '') + step.text_delta); this.notify('item/agentMessage/delta', { threadId: turn.threadId, turnId: turn.id, itemId: id, delta: step.text_delta }); }
|
|
300
|
+
if (step.state === 'DONE' && texts.get(step.step_index)?.trim()) this.item(turn, { id, type: 'agentMessage', text: texts.get(step.step_index)!.trim() });
|
|
301
|
+
} else if (step?.step_type === 'tool' && step.tool_name) {
|
|
302
|
+
const item = toolItem(`${turn.id}:${step.step_index}`, step.tool_name, step.tool_info?.parameters || {});
|
|
303
|
+
if (step.state === 'ACTIVE') this.item(turn, item, false);
|
|
304
|
+
else this.item(turn, { ...item, status: step.state === 'ERROR' ? 'failed' : 'completed', ...(item.type === 'commandExecution' && step.tool_info?.error?.message ? { aggregatedOutput: step.tool_info.error.message } : {}) });
|
|
305
|
+
}
|
|
306
|
+
if (event.event === 'result') result = event.result;
|
|
307
|
+
});
|
|
308
|
+
child.stderr?.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-4000); });
|
|
309
|
+
child.on('error', error => this.finish(turn, 'failed', error.message));
|
|
310
|
+
child.on('close', () => {
|
|
311
|
+
if (stopped) return this.finish(turn, 'interrupted');
|
|
312
|
+
const denied = (result?.denied_actions || []).map(action => action.display_name || action.action).filter(Boolean);
|
|
313
|
+
if (denied.length) this.item(turn, { id: `${turn.id}:denied`, type: 'agentMessage', text: `Antigravity could not run ${[...new Set(denied)].join(', ')}: in Papilot it works without approval prompts, so it edits files in the task worktree but does not run commands.` });
|
|
314
|
+
if (result?.status === 'SUCCESS') this.finish(turn, 'completed');
|
|
315
|
+
else this.finish(turn, 'failed', result?.error || stderr.trim().split('\n').filter(line => !line.startsWith('{')).slice(-3).join(' ') || 'Antigravity stopped without a result.');
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
private save() {
|
|
319
|
+
try { fs.mkdirSync(path.dirname(this.file), { recursive: true }); fs.writeFileSync(this.file, JSON.stringify(this.conversations)); } catch { /* A later turn starts a new conversation. */ }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// `agy models` lists effort variants as separate models ("Gemini 4 Argon (High)");
|
|
324
|
+
// group them so effort is chosen like other agents' reasoning effort.
|
|
325
|
+
export function groupModels(listing: string, preferred = '') {
|
|
326
|
+
const order = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
327
|
+
const groups = new Map<string, { model: string; displayName: string; efforts: string[]; ids: Record<string, string>; isDefault: boolean; defaultEffort: string | null }>();
|
|
328
|
+
for (const line of listing.split('\n')) {
|
|
329
|
+
const [id, name] = line.split('\t').map(part => part?.trim());
|
|
330
|
+
if (!id || !name || id.includes(' ')) continue;
|
|
331
|
+
const variant = /^(.*) \((Minimal|Low|Medium|High|XHigh|Max)\)$/.exec(name), level = variant?.[2].toLowerCase();
|
|
332
|
+
const base = variant && id.endsWith('-' + level) ? id.slice(0, -level!.length - 1) : id;
|
|
333
|
+
const group = groups.get(base) || { model: base, displayName: variant && id !== base ? variant[1] : name, efforts: [], ids: {}, isDefault: false, defaultEffort: null };
|
|
334
|
+
if (variant && id !== base) { group.efforts.push(level!); group.ids[level!] = id; } else group.ids[''] = id;
|
|
335
|
+
if (name === preferred) { group.isDefault = true; if (variant && id !== base) group.defaultEffort = level!; }
|
|
336
|
+
groups.set(base, group);
|
|
337
|
+
}
|
|
338
|
+
for (const group of groups.values()) {
|
|
339
|
+
group.efforts.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
340
|
+
group.defaultEffort ||= group.efforts.includes('medium') ? 'medium' : group.efforts[0] || null;
|
|
341
|
+
}
|
|
342
|
+
return [...groups.values()];
|
|
343
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { CODEX, ROOT } from './paths.ts';
|
|
5
|
+
|
|
6
|
+
export class RpcResponseError extends Error {}
|
|
7
|
+
|
|
8
|
+
export class Codex extends EventEmitter {
|
|
9
|
+
child: ChildProcessWithoutNullStreams | null = null;
|
|
10
|
+
private sequence = 0;
|
|
11
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
|
12
|
+
private starting: Promise<void> | null = null;
|
|
13
|
+
ready = false;
|
|
14
|
+
stderr = '';
|
|
15
|
+
constructor(private binary = CODEX, private args = ['app-server', '--listen', 'stdio://']) { super(); }
|
|
16
|
+
start(): Promise<void> {
|
|
17
|
+
if (this.ready) return Promise.resolve();
|
|
18
|
+
if (this.starting) return this.starting;
|
|
19
|
+
this.starting = this.launch().finally(() => { this.starting = null; });
|
|
20
|
+
return this.starting;
|
|
21
|
+
}
|
|
22
|
+
private async launch() {
|
|
23
|
+
this.stderr = '';
|
|
24
|
+
const child = spawn(this.binary, this.args, { cwd: ROOT, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
25
|
+
this.child = child;
|
|
26
|
+
const lines = createInterface({ input: child.stdout });
|
|
27
|
+
lines.on('line', line => { try { this.receive(JSON.parse(line)); } catch { /* non-protocol stdout is ignored */ } });
|
|
28
|
+
child.stderr.on('data', chunk => { this.stderr = (this.stderr + chunk.toString()).slice(-12000); });
|
|
29
|
+
const failed = (error: Error) => {
|
|
30
|
+
if (this.child !== child) return;
|
|
31
|
+
this.ready = false; this.child = null;
|
|
32
|
+
for (const wait of this.pending.values()) { clearTimeout(wait.timer); wait.reject(error); }
|
|
33
|
+
this.pending.clear(); this.emit('disconnected', error);
|
|
34
|
+
};
|
|
35
|
+
child.once('error', failed);
|
|
36
|
+
child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`)));
|
|
37
|
+
await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: '0.1.0' } });
|
|
38
|
+
this.notify('initialized', {}); this.ready = true;
|
|
39
|
+
}
|
|
40
|
+
private receive(message: any) {
|
|
41
|
+
if (message.method) { this.emit(message.id === undefined ? 'notification' : 'request', message); return; }
|
|
42
|
+
const wait = this.pending.get(message.id);
|
|
43
|
+
if (!wait) return;
|
|
44
|
+
clearTimeout(wait.timer); this.pending.delete(message.id);
|
|
45
|
+
if (message.error) wait.reject(new RpcResponseError(message.error.message || JSON.stringify(message.error)));
|
|
46
|
+
else wait.resolve(message.result);
|
|
47
|
+
}
|
|
48
|
+
request(method: string, params: unknown, timeout = 60000): Promise<any> {
|
|
49
|
+
if (!this.child) return Promise.reject(new Error('Codex is not connected.'));
|
|
50
|
+
const id = ++this.sequence;
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Codex request timed out: ${method}. Check the task status before retrying.`)); }, timeout);
|
|
53
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
54
|
+
this.child!.stdin.write(JSON.stringify({ id, method, params }) + '\n', error => {
|
|
55
|
+
if (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
notify(method: string, params: unknown) { this.child?.stdin.write(JSON.stringify({ method, params }) + '\n'); }
|
|
60
|
+
respond(id: string | number, result: unknown) { if (!this.child) throw new Error('Codex disconnected. Resume the task before retrying.'); this.child.stdin.write(JSON.stringify({ id, result }) + '\n'); }
|
|
61
|
+
reject(id: string | number, message: string) { this.child?.stdin.write(JSON.stringify({ id, error: { code: -32601, message } }) + '\n'); }
|
|
62
|
+
close() { this.child?.stdin.end(); this.child?.kill('SIGTERM'); }
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import staticFiles from '@fastify/static';
|
|
2
|
+
import type { FastifyInstance } from 'fastify';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function registerFrontend(app: FastifyInstance, root: string) {
|
|
7
|
+
// Resolve assets at request time, so a frontend build can be deployed without
|
|
8
|
+
// restarting the server or interrupting active Codex turns.
|
|
9
|
+
await app.register(staticFiles, { root, prefix: '/', wildcard: true, cacheControl: false });
|
|
10
|
+
app.setNotFoundHandler((request, reply) => {
|
|
11
|
+
const pathname = new URL(request.url, 'http://localhost').pathname;
|
|
12
|
+
if (/^\/(api|review|assets)(\/|$)/.test(pathname)) return reply.code(404).send({ error: 'Not found' });
|
|
13
|
+
return reply.type('text/html').send(fs.readFileSync(path.join(root, 'index.html')));
|
|
14
|
+
});
|
|
15
|
+
}
|