pi2web 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { RemotePiServer } from './server.js';
3
+ import { VoiceManager } from './voice.js';
4
+ import { MailNotifier } from './mail-notifier.js';
5
+ import pkg from '../package.json' with { type: 'json' };
6
+ const args = process.argv.slice(2);
7
+ const value = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] ?? fallback : fallback; };
8
+ const envSecret = (flag) => { const name = value(flag, '').trim(); if (!name)
9
+ return undefined; const result = process.env[name]; if (!result) {
10
+ console.error(`${flag} references missing environment variable ${name}`);
11
+ process.exit(1);
12
+ } return result; };
13
+ if (args.includes('--help') || args.includes('-h')) {
14
+ console.log(`pi2web ${pkg.version}\n\nUsage: pi2web [options]\n\nOptions:\n --host <address> Listen address (default: 127.0.0.1)\n --port <number> Listen port (default: 11318)\n --data-dir <path> Metadata directory (default: ~/.pi/remote-pi)\n --maildispatch-endpoint <url> MailDispatch message API endpoint\n --maildispatch-api-key-env <name> Environment variable containing the MailDispatch API key\n --maildispatch-notify-to <email> Email address notified when an Agent task settles\n --maildispatch-sender-id <id> Optional MailDispatch sender ID\n --voice-base-url <url> OpenAI-compatible TTS endpoint, e.g. Speaches or openai-edge-tts\n --voice-stt-model <id> Optional speech-to-text model ID; omit for TTS-only mode\n --voice-tts-model <id> Text-to-speech model ID\n --voice-tts-voice <id> TTS voice ID\n --voice-tts-format <pcm|mp3> TTS response format (default: pcm)\n --voice-api-key-env <name> Environment variable containing the speech API key\n --voice-summary-base-url <url> OpenAI-compatible summary LLM endpoint\n --voice-summary-model <id> Summary LLM model ID\n --voice-summary-api-key-env <name> Environment variable containing the summary API key\n --voice-language <tag> Spoken summary language (default: zh-CN)\n --voice-request-timeout <ms> Speech and summary request timeout (default: 120000)\n --rotate-access-token Generate a new pairing code and exit\n --version Print version\n -h, --help Show help\n\nMail notifications require endpoint, API-key environment variable, and recipient. Voice is disabled unless --voice-base-url is set. The Web UI is served at the same address. Its pairing code must be entered manually; the browser asks before saving it locally.`);
15
+ process.exit(0);
16
+ }
17
+ if (args.includes('--version') || args.includes('-v')) {
18
+ console.log(pkg.version);
19
+ process.exit(0);
20
+ }
21
+ const host = value('--host', '127.0.0.1'), port = Number(value('--port', '11318'));
22
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
23
+ console.error('Invalid --port');
24
+ process.exit(1);
25
+ }
26
+ const voiceBaseUrl = value('--voice-base-url', '');
27
+ let voice;
28
+ if (voiceBaseUrl) {
29
+ const required = (name) => { const result = value(name, '').trim(); if (!result) {
30
+ console.error(`${name} is required when voice is enabled`);
31
+ process.exit(1);
32
+ } return result; };
33
+ const ttsFormat = value('--voice-tts-format', 'pcm').trim();
34
+ if (ttsFormat !== 'pcm' && ttsFormat !== 'mp3') {
35
+ console.error('--voice-tts-format must be pcm or mp3');
36
+ process.exit(1);
37
+ }
38
+ const requestTimeoutMs = Number(value('--voice-request-timeout', '120000'));
39
+ if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1000) {
40
+ console.error('--voice-request-timeout must be an integer of at least 1000 milliseconds');
41
+ process.exit(1);
42
+ }
43
+ const config = { speechBaseUrl: voiceBaseUrl, sttModel: value('--voice-stt-model', '').trim() || undefined, ttsModel: required('--voice-tts-model'), ttsVoice: required('--voice-tts-voice'), ttsFormat, speechApiKey: envSecret('--voice-api-key-env'), summaryBaseUrl: required('--voice-summary-base-url'), summaryModel: required('--voice-summary-model'), summaryApiKey: envSecret('--voice-summary-api-key-env'), language: value('--voice-language', 'zh-CN'), requestTimeoutMs };
44
+ voice = new VoiceManager(config);
45
+ }
46
+ const mailEndpoint = value('--maildispatch-endpoint', '').trim(), mailKeyEnv = value('--maildispatch-api-key-env', '').trim(), mailRecipient = value('--maildispatch-notify-to', '').trim();
47
+ const mailParts = [mailEndpoint, mailKeyEnv, mailRecipient].filter(Boolean).length;
48
+ if (mailParts > 0 && mailParts < 3) {
49
+ console.error('--maildispatch-endpoint, --maildispatch-api-key-env, and --maildispatch-notify-to must be configured together');
50
+ process.exit(1);
51
+ }
52
+ let mailNotifier;
53
+ if (mailParts === 3) {
54
+ let endpoint;
55
+ try {
56
+ endpoint = new URL(mailEndpoint);
57
+ }
58
+ catch {
59
+ console.error('--maildispatch-endpoint must be a valid URL');
60
+ process.exit(1);
61
+ }
62
+ if (!['http:', 'https:'].includes(endpoint.protocol)) {
63
+ console.error('--maildispatch-endpoint must use http or https');
64
+ process.exit(1);
65
+ }
66
+ mailNotifier = new MailNotifier({ endpoint: endpoint.toString(), apiKey: envSecret('--maildispatch-api-key-env'), recipient: mailRecipient, senderId: value('--maildispatch-sender-id', '').trim() || undefined });
67
+ }
68
+ const server = new RemotePiServer({ host, port, dataDir: value('--data-dir', process.env.PI_REMOTE_DIR ?? `${process.env.HOME ?? '.'}/.pi/remote-pi`), voice, mailNotifier });
69
+ if (args.includes('--rotate-access-token')) {
70
+ await server.auth.init();
71
+ console.log(await server.auth.rotate());
72
+ process.exit(0);
73
+ }
74
+ if (args.includes('--print-access-token')) {
75
+ console.error('The pairing code is only shown on first startup or rotation.');
76
+ process.exit(1);
77
+ }
78
+ try {
79
+ await server.start();
80
+ const address = server.address();
81
+ console.log(`Remote Pi is ready: http://${host}:${address?.port ?? port}`);
82
+ console.log('Open this URL and enter the pairing code manually.');
83
+ }
84
+ catch (error) {
85
+ console.error(`Failed to start Remote Pi: ${error.message}`);
86
+ process.exit(1);
87
+ }
88
+ const shutdown = async () => { await server.stop(); process.exit(0); };
89
+ process.once('SIGINT', shutdown);
90
+ process.once('SIGTERM', shutdown);
@@ -0,0 +1,139 @@
1
+ import Database from 'better-sqlite3';
2
+ import { chmodSync, mkdirSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ const asTime = (value) => { const time = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value ?? ''); return Number.isFinite(time) ? time : Date.now(); };
5
+ /** SQLite-backed Remote Pi metadata. Pi conversation JSONL files remain the source of truth. */
6
+ export class MetadataStore {
7
+ dataDir;
8
+ db;
9
+ pendingAgents = new Map();
10
+ archivedAgents = new Set();
11
+ flushTimer;
12
+ constructor(dataDir) {
13
+ this.dataDir = dataDir;
14
+ }
15
+ get file() { return path.join(this.dataDir, 'remote-pi.db'); }
16
+ init() {
17
+ if (this.db)
18
+ return;
19
+ mkdirSync(this.dataDir, { recursive: true });
20
+ try {
21
+ chmodSync(this.dataDir, 0o700);
22
+ }
23
+ catch { }
24
+ const db = this.db = new Database(this.file);
25
+ try {
26
+ chmodSync(this.file, 0o600);
27
+ }
28
+ catch { }
29
+ db.pragma('journal_mode = WAL');
30
+ db.pragma('synchronous = NORMAL');
31
+ db.pragma('busy_timeout = 5000');
32
+ db.pragma('foreign_keys = ON');
33
+ db.exec(`
34
+ CREATE TABLE IF NOT EXISTS workspaces (
35
+ id TEXT PRIMARY KEY,
36
+ label TEXT NOT NULL,
37
+ root_path TEXT NOT NULL UNIQUE,
38
+ created_at INTEGER NOT NULL
39
+ );
40
+ CREATE TABLE IF NOT EXISTS agents (
41
+ id TEXT PRIMARY KEY,
42
+ workspace_id TEXT NOT NULL,
43
+ session_id TEXT NOT NULL,
44
+ session_file TEXT,
45
+ session_name TEXT,
46
+ cwd TEXT NOT NULL,
47
+ status TEXT NOT NULL,
48
+ created_at INTEGER NOT NULL,
49
+ last_active_at INTEGER NOT NULL,
50
+ archived_at INTEGER,
51
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE
52
+ );
53
+ CREATE TABLE IF NOT EXISTS settings (
54
+ key TEXT PRIMARY KEY,
55
+ value TEXT NOT NULL,
56
+ updated_at INTEGER NOT NULL
57
+ );
58
+ CREATE TABLE IF NOT EXISTS sessions (
59
+ session_id TEXT PRIMARY KEY,
60
+ session_file TEXT NOT NULL UNIQUE,
61
+ workspace_id TEXT,
62
+ cwd TEXT NOT NULL,
63
+ name TEXT,
64
+ parent_session_path TEXT,
65
+ created_at INTEGER NOT NULL,
66
+ last_active_at INTEGER NOT NULL,
67
+ message_count INTEGER NOT NULL DEFAULT 0,
68
+ first_message TEXT NOT NULL DEFAULT '',
69
+ file_size INTEGER NOT NULL DEFAULT 0,
70
+ file_mtime INTEGER NOT NULL DEFAULT 0,
71
+ archived_at INTEGER,
72
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL
73
+ );
74
+ CREATE INDEX IF NOT EXISTS agents_active_recent ON agents(last_active_at DESC,id DESC) WHERE archived_at IS NULL;
75
+ CREATE INDEX IF NOT EXISTS agents_workspace_active_recent ON agents(workspace_id,last_active_at DESC,id DESC) WHERE archived_at IS NULL;
76
+ CREATE INDEX IF NOT EXISTS sessions_active_recent ON sessions(last_active_at DESC,session_id DESC) WHERE archived_at IS NULL;
77
+ CREATE INDEX IF NOT EXISTS sessions_workspace_active_recent ON sessions(workspace_id,last_active_at DESC,session_id DESC) WHERE archived_at IS NULL;
78
+ CREATE INDEX IF NOT EXISTS sessions_cwd_active_recent ON sessions(cwd,last_active_at DESC,session_id DESC) WHERE archived_at IS NULL;
79
+ `);
80
+ db.pragma('user_version = 1');
81
+ this.archivedAgents = new Set(db.prepare('SELECT id FROM agents WHERE archived_at IS NOT NULL').all().map(row => row.id));
82
+ }
83
+ get database() { if (!this.db)
84
+ throw new Error('Metadata store is not initialized'); return this.db; }
85
+ getSetting(key) { const row = this.database.prepare('SELECT value FROM settings WHERE key=?').get(key); if (!row)
86
+ return; try {
87
+ return JSON.parse(row.value);
88
+ }
89
+ catch {
90
+ return;
91
+ } }
92
+ setSetting(key, value) { this.database.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').run(key, JSON.stringify(value), Date.now()); }
93
+ listWorkspaces() { return this.database.prepare('SELECT id,label,root_path,created_at FROM workspaces ORDER BY created_at,id').all().map(row => ({ id: row.id, label: row.label, rootPath: row.root_path, createdAt: new Date(row.created_at).toISOString() })); }
94
+ saveWorkspace(workspace) { this.database.prepare(`INSERT INTO workspaces(id,label,root_path,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET label=excluded.label,root_path=excluded.root_path`).run(workspace.id, workspace.label, workspace.rootPath, asTime(workspace.createdAt)); }
95
+ listAgents() { return this.database.prepare('SELECT * FROM agents WHERE archived_at IS NULL ORDER BY last_active_at DESC,id DESC').all().map(row => ({ agentId: row.id, workspaceId: row.workspace_id, sessionId: row.session_id, sessionFile: row.session_file ?? undefined, sessionName: row.session_name ?? undefined, cwd: row.cwd, status: row.status, createdAt: new Date(row.created_at).toISOString(), lastActiveAt: new Date(row.last_active_at).toISOString() })); }
96
+ writeAgent(record, archivedAt) { this.database.prepare(`INSERT INTO agents(id,workspace_id,session_id,session_file,session_name,cwd,status,created_at,last_active_at,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,session_id=excluded.session_id,session_file=excluded.session_file,session_name=excluded.session_name,cwd=excluded.cwd,status=excluded.status,last_active_at=excluded.last_active_at,archived_at=excluded.archived_at`).run(record.agentId, record.workspaceId, record.sessionId, record.sessionFile ?? null, record.sessionName ?? null, record.cwd, record.status, asTime(record.createdAt), asTime(record.lastActiveAt), archivedAt); }
97
+ saveAgent(record) { if (this.archivedAgents.has(record.agentId))
98
+ return; this.pendingAgents.delete(record.agentId); this.writeAgent(record, null); }
99
+ scheduleAgent(record) { if (this.archivedAgents.has(record.agentId))
100
+ return; this.pendingAgents.set(record.agentId, { ...record }); if (!this.flushTimer)
101
+ this.flushTimer = setTimeout(() => this.flush(), 1000); }
102
+ archiveAgent(record) { this.archivedAgents.add(record.agentId); this.pendingAgents.delete(record.agentId); this.writeAgent(record, Date.now()); }
103
+ flush() { if (this.flushTimer) {
104
+ clearTimeout(this.flushTimer);
105
+ this.flushTimer = undefined;
106
+ } if (!this.pendingAgents.size)
107
+ return; const records = [...this.pendingAgents.values()]; this.pendingAgents.clear(); this.database.transaction((items) => { for (const item of items)
108
+ this.writeAgent(item, null); })(records); }
109
+ syncSessions(workspaceId, cwd, sessions) {
110
+ const db = this.database, upsert = db.prepare(`INSERT INTO sessions(session_id,session_file,workspace_id,cwd,name,parent_session_path,created_at,last_active_at,message_count,first_message,file_size,file_mtime,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,NULL) ON CONFLICT(session_id) DO UPDATE SET session_file=excluded.session_file,workspace_id=excluded.workspace_id,cwd=excluded.cwd,name=excluded.name,parent_session_path=excluded.parent_session_path,last_active_at=excluded.last_active_at,message_count=excluded.message_count,first_message=excluded.first_message,file_size=excluded.file_size,file_mtime=excluded.file_mtime,archived_at=NULL`);
111
+ const remove = db.prepare('DELETE FROM sessions WHERE session_file=?');
112
+ db.transaction(() => { const paths = new Set(); for (const session of sessions) {
113
+ paths.add(session.path);
114
+ let size = 0, mtime = asTime(session.modified);
115
+ try {
116
+ const stat = statSync(session.path);
117
+ size = stat.size;
118
+ mtime = stat.mtimeMs;
119
+ }
120
+ catch { }
121
+ upsert.run(session.id, session.path, workspaceId, cwd, session.name ?? null, session.parentSessionPath ?? null, asTime(session.created), asTime(session.modified), session.messageCount, session.firstMessage, size, mtime);
122
+ } for (const row of db.prepare('SELECT session_file FROM sessions WHERE cwd=? AND archived_at IS NULL').all(cwd))
123
+ if (!paths.has(row.session_file))
124
+ remove.run(row.session_file); })();
125
+ }
126
+ upsertSessionFromAgent(record, summary) { const sessionFile = typeof summary.sessionFile === 'string' ? summary.sessionFile : record.sessionFile; if (!sessionFile)
127
+ return; let size = 0, mtime = asTime(record.lastActiveAt); try {
128
+ const stat = statSync(sessionFile);
129
+ size = stat.size;
130
+ mtime = stat.mtimeMs;
131
+ }
132
+ catch {
133
+ return;
134
+ } const sessionId = typeof summary.sessionId === 'string' ? summary.sessionId : record.sessionId, name = typeof summary.sessionName === 'string' ? summary.sessionName : record.sessionName, messageCount = Number(summary.stats?.totalMessages) || 0; this.database.prepare(`INSERT INTO sessions(session_id,session_file,workspace_id,cwd,name,created_at,last_active_at,message_count,first_message,file_size,file_mtime,archived_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL) ON CONFLICT(session_id) DO UPDATE SET session_file=excluded.session_file,workspace_id=excluded.workspace_id,cwd=excluded.cwd,name=excluded.name,last_active_at=excluded.last_active_at,message_count=excluded.message_count,file_size=excluded.file_size,file_mtime=excluded.file_mtime,archived_at=NULL`).run(sessionId, sessionFile, record.workspaceId, record.cwd, name ?? null, asTime(record.createdAt), Math.max(asTime(record.lastActiveAt), mtime), messageCount, '', size, mtime); }
135
+ listSessions(cwd, limit, offset = 0) { const bounded = limit === undefined ? undefined : Math.min(200, Math.max(1, Math.trunc(limit))); const sql = `SELECT session_file AS path,session_id AS id,cwd,name,parent_session_path AS parentSessionPath,created_at,last_active_at,message_count AS messageCount,first_message AS firstMessage FROM sessions WHERE cwd=? AND archived_at IS NULL ORDER BY last_active_at DESC,session_id DESC${bounded === undefined ? '' : ' LIMIT ? OFFSET ?'}`; const rows = (bounded === undefined ? this.database.prepare(sql).all(cwd) : this.database.prepare(sql).all(cwd, bounded, Math.max(0, Math.trunc(offset)))); return rows.map(row => ({ ...row, created: new Date(row.created_at).toISOString(), modified: new Date(row.last_active_at).toISOString(), allMessagesText: '' })); }
136
+ hasSession(cwd, sessionFile) { return !!this.database.prepare('SELECT 1 FROM sessions WHERE cwd=? AND session_file=? AND archived_at IS NULL').get(cwd, sessionFile); }
137
+ close() { if (!this.db)
138
+ return; this.flush(); this.db.pragma('wal_checkpoint(TRUNCATE)'); this.db.close(); this.db = undefined; }
139
+ }
@@ -0,0 +1,85 @@
1
+ import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
2
+ import { EventEmitter } from 'node:events';
3
+ import { randomUUID } from 'node:crypto';
4
+ /** Real in-process Pi SDK backend with session/model/extension controls exposed to remote clients. */
5
+ export class SdkBackend {
6
+ session;
7
+ agentId;
8
+ cwd;
9
+ disposed = false;
10
+ events = new EventEmitter();
11
+ pendingUi = new Map();
12
+ constructor(session, agentId, cwd) {
13
+ this.session = session;
14
+ this.agentId = agentId;
15
+ this.cwd = cwd;
16
+ }
17
+ static async create(agentId, cwd, sessionFile) {
18
+ const sessionManager = sessionFile ? SessionManager.open(sessionFile) : SessionManager.create(cwd);
19
+ const { session, modelFallbackMessage } = await createAgentSession({ cwd, sessionManager });
20
+ if (modelFallbackMessage && !session.state.model) {
21
+ session.dispose();
22
+ throw Object.assign(new Error(modelFallbackMessage), { code: 'PI_MODEL_UNAVAILABLE' });
23
+ }
24
+ const backend = new SdkBackend(session, agentId, cwd);
25
+ await session.bindExtensions({ uiContext: backend.extensionUi() });
26
+ return backend;
27
+ }
28
+ prompt(message) { return this.session.prompt(message); }
29
+ steer(message) { return this.session.steer(message); }
30
+ followUp(message) { return this.session.followUp(message); }
31
+ abort() { return this.session.abort(); }
32
+ async getState() { return { agentId: this.agentId, sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, cwd: this.cwd, status: this.disposed ? 'stopped' : this.session.isStreaming ? 'streaming' : 'idle' }; }
33
+ async getMessages() { return [...this.session.state.messages]; }
34
+ async getCapabilities() {
35
+ const current = this.session.model;
36
+ const available = this.session.modelRegistry.getAvailable();
37
+ return { model: current ? this.modelInfo(current) : null, models: available.map(model => this.modelInfo(model)), thinkingLevel: this.session.thinkingLevel, thinkingLevels: this.session.getAvailableThinkingLevels(), supportsThinking: this.session.supportsThinking() };
38
+ }
39
+ async getSession() { return { ...await this.getSessionInfo(), leafId: this.session.sessionManager.getLeafId(), entries: this.session.sessionManager.getEntries(), tree: this.session.sessionManager.getTree(), userMessages: this.session.getUserMessagesForForking() }; }
40
+ async getSessionInfo() { return { sessionId: this.session.sessionId, sessionFile: this.session.sessionFile, sessionName: this.session.sessionName, stats: this.session.getSessionStats(), contextUsage: this.session.getContextUsage() }; }
41
+ compact(instructions) { return this.session.compact(instructions); }
42
+ async setModel(provider, modelId) { const model = this.session.modelRegistry.find(provider, modelId); if (!model)
43
+ throw Object.assign(new Error('Model not found'), { code: 'MODEL_NOT_FOUND' }); await this.session.setModel(model); }
44
+ async setThinkingLevel(level) { if (!['off', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(level))
45
+ throw Object.assign(new Error('Invalid thinking level'), { code: 'INVALID_THINKING_LEVEL' }); this.session.setThinkingLevel(level); }
46
+ async setSessionName(name) { const value = name.trim(); if (!value || value.length > 200)
47
+ throw Object.assign(new Error('Session name must be 1-200 characters'), { code: 'INVALID_SESSION_NAME' }); this.session.setSessionName(value); }
48
+ navigate(entryId) { return this.session.navigateTree(entryId); }
49
+ async fork(entryId) { const entry = this.session.sessionManager.getEntry(entryId); if (!entry || entry.type !== 'message' || entry.message?.role !== 'user')
50
+ throw Object.assign(new Error('A user message entry is required'), { code: 'SESSION_ENTRY_NOT_FOUND' }); const content = entry.message.content; const selectedText = typeof content === 'string' ? content : Array.isArray(content) ? content.filter((part) => part.type === 'text').map((part) => part.text).join('\n') : ''; let sessionFile; if (entry.parentId) {
51
+ sessionFile = this.session.sessionManager.createBranchedSession(entry.parentId);
52
+ }
53
+ else {
54
+ const current = this.session.sessionFile;
55
+ if (!current)
56
+ throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' });
57
+ sessionFile = SessionManager.create(this.cwd, this.session.sessionManager.getSessionDir(), { parentSession: current }).getSessionFile();
58
+ } return { sessionFile, selectedText }; }
59
+ async extensionResponse(requestId, value) { const pending = this.pendingUi.get(requestId); if (!pending)
60
+ throw Object.assign(new Error('Extension UI request not found'), { code: 'EXTENSION_REQUEST_NOT_FOUND' }); clearTimeout(pending.timer); this.pendingUi.delete(requestId); pending.resolve(value); }
61
+ subscribe(listener) { const fromSession = (event) => listener(event); const unsubscribe = this.session.subscribe(fromSession); this.events.on('event', listener); return () => { unsubscribe(); this.events.off('event', listener); }; }
62
+ async dispose() { if (this.disposed)
63
+ return; for (const [id, pending] of this.pendingUi) {
64
+ clearTimeout(pending.timer);
65
+ pending.resolve(undefined);
66
+ this.pendingUi.delete(id);
67
+ } await this.session.abort(); this.session.dispose(); this.disposed = true; }
68
+ modelInfo(model) { return { provider: model.provider, id: model.id, name: model.name, reasoning: !!model.reasoning, contextWindow: model.contextWindow, maxTokens: model.maxTokens }; }
69
+ request(kind, payload) { const requestId = randomUUID(); return new Promise(resolve => { const timer = setTimeout(() => { this.pendingUi.delete(requestId); resolve(undefined); }, 5 * 60_000); this.pendingUi.set(requestId, { resolve, timer }); this.events.emit('event', { type: 'extension_ui_request', requestId, kind, ...payload }); }); }
70
+ extensionUi() {
71
+ return {
72
+ select: async (title, options, opts) => await this.request('select', { title, options, opts }),
73
+ confirm: async (title, message, opts) => Boolean(await this.request('confirm', { title, message, opts })),
74
+ input: async (title, placeholder, opts) => await this.request('input', { title, placeholder, opts }),
75
+ editor: async (title, prefill) => await this.request('editor', { title, prefill }),
76
+ notify: (message, type) => this.events.emit('event', { type: 'extension_ui_notify', message, notificationType: type ?? 'info' }),
77
+ setStatus: (key, text) => this.events.emit('event', { type: 'extension_ui_status', key, text }),
78
+ setWidget: (key, content, options) => this.events.emit('event', { type: 'extension_ui_widget', key, content: Array.isArray(content) ? content : undefined, options }),
79
+ setTitle: (title) => this.events.emit('event', { type: 'extension_ui_title', title }),
80
+ setWorkingMessage: (message) => this.events.emit('event', { type: 'extension_ui_working_message', message }), setWorkingVisible: () => { }, setWorkingIndicator: () => { }, setHiddenThinkingLabel: () => { },
81
+ onTerminalInput: () => () => { }, setFooter: () => { }, setHeader: () => { }, pasteToEditor: () => { }, setEditorText: () => { }, getEditorText: () => '', addAutocompleteProvider: () => { }, setEditorComponent: () => { }, custom: async () => { throw Object.assign(new Error('Custom extension components are unsupported'), { code: 'EXTENSION_CUSTOM_UI_UNSUPPORTED' }); }
82
+ };
83
+ }
84
+ }
85
+ export const createSdkBackend = (agentId, cwd, sessionFile) => SdkBackend.create(agentId, cwd, sessionFile);