@foxden-app/foxclaw 0.5.77 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ import crypto from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
8
+ import { OpencodeEventNormalizer } from './events.js';
9
+ const START_TIMEOUT_MS = 15_000;
10
+ const POLL_INTERVAL_MS = 500;
11
+ /** Owns one password-protected localhost `opencode serve` process. */
12
+ export class OpencodeAppClient extends EventEmitter {
13
+ cliBin;
14
+ configuredPassword;
15
+ statePath;
16
+ logPath;
17
+ logger;
18
+ childEnv;
19
+ normalizer = new OpencodeEventNormalizer();
20
+ client = null;
21
+ child = null;
22
+ serverState = null;
23
+ connected = false;
24
+ sseAbort = null;
25
+ sseLoop = null;
26
+ pollers = new Map();
27
+ desiredRunning = false;
28
+ starting = null;
29
+ reconnectTimer = null;
30
+ constructor(cliBin, configuredPassword, statePath, logPath, logger, childEnv = null) {
31
+ super();
32
+ this.cliBin = cliBin;
33
+ this.configuredPassword = configuredPassword;
34
+ this.statePath = statePath;
35
+ this.logPath = logPath;
36
+ this.logger = logger;
37
+ this.childEnv = childEnv;
38
+ }
39
+ getClient() {
40
+ if (!this.client)
41
+ throw new Error('OpenCode client is not connected');
42
+ return this.client;
43
+ }
44
+ isConnected() {
45
+ return this.connected;
46
+ }
47
+ getServerStatus() {
48
+ return {
49
+ pid: this.serverState?.pid ?? null,
50
+ port: this.serverState?.port ?? null,
51
+ running: Boolean(this.serverState && isProcessAlive(this.serverState.pid)),
52
+ connected: this.connected,
53
+ url: this.serverState?.url ?? null,
54
+ version: this.serverState?.version ?? null,
55
+ managed: this.serverState !== null,
56
+ };
57
+ }
58
+ async start() {
59
+ this.desiredRunning = true;
60
+ if (this.connected)
61
+ return;
62
+ if (!this.starting) {
63
+ this.starting = (async () => {
64
+ if (await this.attachPersistedServer())
65
+ return;
66
+ await this.spawnServer();
67
+ })().finally(() => {
68
+ this.starting = null;
69
+ });
70
+ }
71
+ await this.starting;
72
+ }
73
+ async stop(options = {}) {
74
+ this.desiredRunning = false;
75
+ if (this.reconnectTimer) {
76
+ clearTimeout(this.reconnectTimer);
77
+ this.reconnectTimer = null;
78
+ }
79
+ this.connected = false;
80
+ for (const controller of this.pollers.values())
81
+ controller.abort();
82
+ this.pollers.clear();
83
+ this.sseAbort?.abort();
84
+ if (this.sseLoop) {
85
+ await Promise.race([this.sseLoop.catch(() => { }), sleep(2_000)]);
86
+ }
87
+ this.sseAbort = null;
88
+ this.sseLoop = null;
89
+ const state = this.serverState;
90
+ if (options.terminateServer && state && isProcessAlive(state.pid)) {
91
+ await terminateProcessGroup(state.pid);
92
+ this.clearStateForPid(state.pid);
93
+ }
94
+ this.child = null;
95
+ this.client = null;
96
+ this.serverState = null;
97
+ this.connected = false;
98
+ this.normalizer.reset();
99
+ }
100
+ async restart() {
101
+ await this.stop({ terminateServer: true });
102
+ await this.start();
103
+ }
104
+ watchSessionUntilIdle(sessionId, directory) {
105
+ this.pollers.get(sessionId)?.abort();
106
+ const controller = new AbortController();
107
+ this.pollers.set(sessionId, controller);
108
+ void this.pollSession(sessionId, directory, controller).finally(() => {
109
+ if (this.pollers.get(sessionId) === controller)
110
+ this.pollers.delete(sessionId);
111
+ });
112
+ }
113
+ async recoverPendingRequests(directories) {
114
+ const unique = [...new Set(directories.filter(Boolean))];
115
+ await Promise.all(unique.map((directory) => this.recoverPendingForDirectory(directory)));
116
+ }
117
+ async pollSession(sessionId, directory, controller) {
118
+ let observedBusy = false;
119
+ while (!controller.signal.aborted && this.connected) {
120
+ try {
121
+ const response = await this.getClient().session.status({ directory });
122
+ if (response.error)
123
+ throw new Error(formatSdkError(response.error));
124
+ const status = response.data?.[sessionId];
125
+ if (status?.type === 'busy' || status?.type === 'retry')
126
+ observedBusy = true;
127
+ if (status?.type === 'idle' || (observedBusy && status === undefined)) {
128
+ this.emit('event', { kind: 'idle', sessionId });
129
+ return;
130
+ }
131
+ }
132
+ catch (error) {
133
+ this.logger.warn('opencode.session.poll_failed', {
134
+ sessionId,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ }
138
+ await sleep(POLL_INTERVAL_MS, controller.signal);
139
+ }
140
+ }
141
+ async attachPersistedServer() {
142
+ const state = this.readState();
143
+ if (!state)
144
+ return false;
145
+ if (!isProcessAlive(state.pid)) {
146
+ this.clearStateForPid(state.pid);
147
+ return false;
148
+ }
149
+ try {
150
+ const health = await probeHealth(state.url, state.username, state.password);
151
+ await this.adoptServer({ ...state, version: health.version ?? state.version });
152
+ this.logger.info('opencode.serve.attached', { pid: state.pid, port: state.port, version: health.version });
153
+ return true;
154
+ }
155
+ catch (error) {
156
+ throw new Error(`A managed opencode serve process (${state.pid}) is still running but cannot be attached: ${error instanceof Error ? error.message : String(error)}`);
157
+ }
158
+ }
159
+ async spawnServer() {
160
+ const port = await reservePort();
161
+ const username = 'opencode';
162
+ const password = this.configuredPassword || crypto.randomBytes(32).toString('base64url');
163
+ const url = `http://127.0.0.1:${port}`;
164
+ const args = ['serve', '--hostname=127.0.0.1', `--port=${port}`, '--print-logs'];
165
+ fs.mkdirSync(path.dirname(this.logPath), { recursive: true, mode: 0o700 });
166
+ const stdoutFd = fs.openSync(this.logPath, 'a', 0o600);
167
+ const stderrFd = fs.openSync(this.logPath, 'a', 0o600);
168
+ let child;
169
+ try {
170
+ child = spawn(this.cliBin, args, {
171
+ detached: true,
172
+ stdio: ['ignore', stdoutFd, stderrFd],
173
+ env: {
174
+ ...process.env,
175
+ ...(this.childEnv ?? {}),
176
+ OPENCODE_SERVER_USERNAME: username,
177
+ OPENCODE_SERVER_PASSWORD: password,
178
+ },
179
+ });
180
+ }
181
+ finally {
182
+ fs.closeSync(stdoutFd);
183
+ fs.closeSync(stderrFd);
184
+ }
185
+ try {
186
+ await waitForSpawn(child);
187
+ }
188
+ catch (error) {
189
+ throw new Error(`Failed to start opencode serve: ${error instanceof Error ? error.message : String(error)}`);
190
+ }
191
+ if (!child.pid)
192
+ throw new Error('Failed to start opencode serve: child PID is unavailable');
193
+ child.unref();
194
+ this.child = child;
195
+ const state = {
196
+ pid: child.pid,
197
+ port,
198
+ url,
199
+ username,
200
+ password,
201
+ command: [this.cliBin, ...args].join(' '),
202
+ logPath: this.logPath,
203
+ startedAt: new Date().toISOString(),
204
+ version: null,
205
+ };
206
+ this.writeState(state);
207
+ this.serverState = state;
208
+ child.once('exit', (code, signal) => {
209
+ if (this.child !== child)
210
+ return;
211
+ this.child = null;
212
+ this.clearStateForPid(child.pid);
213
+ this.handleDisconnect({ source: 'process-exit', message: `code=${code ?? 'null'} signal=${signal ?? 'null'}` });
214
+ });
215
+ child.once('error', (error) => {
216
+ if (this.child !== child)
217
+ return;
218
+ this.child = null;
219
+ this.clearStateForPid(child.pid);
220
+ this.handleDisconnect({ source: 'process-error', message: error.message });
221
+ });
222
+ try {
223
+ const health = await waitForHealth(url, username, password, child);
224
+ this.logger.debug('opencode.serve.ready', { pid: child.pid, port, version: health.version });
225
+ await this.adoptServer({ ...state, version: health.version ?? null });
226
+ this.writeState(this.serverState);
227
+ this.logger.info('opencode.serve.started', { pid: child.pid, port, version: health.version });
228
+ }
229
+ catch (error) {
230
+ await terminateProcessGroup(child.pid);
231
+ this.clearStateForPid(child.pid);
232
+ throw error;
233
+ }
234
+ }
235
+ async adoptServer(state) {
236
+ this.logger.debug('opencode.serve.adopting', { pid: state.pid, port: state.port });
237
+ this.serverState = state;
238
+ this.client = createOpencodeClient({
239
+ baseUrl: state.url,
240
+ headers: authHeaders(state.username, state.password),
241
+ });
242
+ this.connected = true;
243
+ this.emit('connected');
244
+ this.startSseLoop();
245
+ this.logger.debug('opencode.serve.adopted', { pid: state.pid, port: state.port });
246
+ }
247
+ startSseLoop() {
248
+ const controller = new AbortController();
249
+ this.sseAbort = controller;
250
+ this.sseLoop = this.runSseLoop(controller).catch((error) => {
251
+ if (controller.signal.aborted)
252
+ return;
253
+ this.logger.error('opencode.sse.failed', { error: error instanceof Error ? error.message : String(error) });
254
+ this.handleDisconnect({ source: 'sse', message: error instanceof Error ? error.message : String(error) });
255
+ });
256
+ }
257
+ async runSseLoop(controller) {
258
+ while (!controller.signal.aborted) {
259
+ try {
260
+ const subscribed = await this.getClient().global.event({ signal: controller.signal });
261
+ this.logger.info('opencode.sse.connected');
262
+ for await (const globalEvent of subscribed.stream) {
263
+ const event = unwrapGlobalEvent(globalEvent);
264
+ if (event)
265
+ this.acceptEvent(event);
266
+ }
267
+ }
268
+ catch (error) {
269
+ if (controller.signal.aborted)
270
+ return;
271
+ const state = this.serverState;
272
+ if (state && !isProcessAlive(state.pid)) {
273
+ this.clearStateForPid(state.pid);
274
+ this.handleDisconnect({ source: 'sse-process-exit', message: error instanceof Error ? error.message : String(error) });
275
+ return;
276
+ }
277
+ this.logger.warn('opencode.sse.reconnect', { error: error instanceof Error ? error.message : String(error) });
278
+ }
279
+ if (!controller.signal.aborted)
280
+ await sleep(1_000, controller.signal);
281
+ }
282
+ }
283
+ acceptEvent(event) {
284
+ for (const normalized of this.normalizer.accept(event)) {
285
+ if (normalized.kind === 'idle')
286
+ this.pollers.get(normalized.sessionId)?.abort();
287
+ this.emit('event', normalized);
288
+ }
289
+ }
290
+ async recoverPendingForDirectory(directory) {
291
+ if (!this.client)
292
+ return;
293
+ this.logger.debug('opencode.pending.recover_start', { directory: directory ?? null });
294
+ const [permissions, questions] = await Promise.all([
295
+ this.client.permission.list({ ...(directory ? { directory } : {}) }),
296
+ this.client.question.list({ ...(directory ? { directory } : {}) }),
297
+ ]);
298
+ if (!permissions.error) {
299
+ for (const request of permissions.data ?? []) {
300
+ this.emit('event', { kind: 'permission', request: request });
301
+ }
302
+ }
303
+ if (!questions.error) {
304
+ for (const request of questions.data ?? []) {
305
+ this.emit('event', { kind: 'question', request: request });
306
+ }
307
+ }
308
+ this.logger.debug('opencode.pending.recover_done', { directory: directory ?? null });
309
+ }
310
+ handleDisconnect(detail) {
311
+ const wasConnected = this.connected;
312
+ this.connected = false;
313
+ this.sseAbort?.abort();
314
+ this.client = null;
315
+ this.normalizer.reset();
316
+ if (wasConnected) {
317
+ this.emit('disconnected', detail);
318
+ this.scheduleReconnect();
319
+ }
320
+ }
321
+ scheduleReconnect() {
322
+ if (!this.desiredRunning || this.reconnectTimer)
323
+ return;
324
+ this.reconnectTimer = setTimeout(() => {
325
+ this.reconnectTimer = null;
326
+ if (!this.desiredRunning || this.connected)
327
+ return;
328
+ void this.start().catch((error) => {
329
+ this.logger.error('opencode.serve.reconnect_failed', { error: error instanceof Error ? error.message : String(error) });
330
+ this.scheduleReconnect();
331
+ });
332
+ }, 1_000);
333
+ }
334
+ writeState(state) {
335
+ fs.mkdirSync(path.dirname(this.statePath), { recursive: true, mode: 0o700 });
336
+ const tempPath = `${this.statePath}.${process.pid}.tmp`;
337
+ fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
338
+ fs.chmodSync(tempPath, 0o600);
339
+ fs.renameSync(tempPath, this.statePath);
340
+ fs.chmodSync(this.statePath, 0o600);
341
+ }
342
+ readState() {
343
+ try {
344
+ const value = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
345
+ if (!Number.isInteger(value.pid) || !Number.isInteger(value.port)
346
+ || typeof value.url !== 'string' || value.url !== `http://127.0.0.1:${value.port}`
347
+ || typeof value.username !== 'string' || typeof value.password !== 'string'
348
+ || typeof value.startedAt !== 'string')
349
+ return null;
350
+ return value;
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ clearStateForPid(pid) {
357
+ const current = this.readState();
358
+ if (current && current.pid !== pid)
359
+ return;
360
+ try {
361
+ fs.unlinkSync(this.statePath);
362
+ }
363
+ catch (error) {
364
+ if (error.code !== 'ENOENT') {
365
+ this.logger.warn('opencode.serve.state_clear_failed', { error: error instanceof Error ? error.message : String(error) });
366
+ }
367
+ }
368
+ }
369
+ }
370
+ function unwrapGlobalEvent(value) {
371
+ const payload = value.payload;
372
+ return typeof payload.type === 'string' && payload.properties && typeof payload.properties === 'object'
373
+ ? payload
374
+ : null;
375
+ }
376
+ async function waitForHealth(url, username, password, child) {
377
+ const deadline = Date.now() + START_TIMEOUT_MS;
378
+ while (Date.now() < deadline) {
379
+ if (child.exitCode !== null || child.signalCode !== null) {
380
+ throw new Error(`opencode serve exited before becoming ready (code=${child.exitCode ?? 'null'}, signal=${child.signalCode ?? 'null'})`);
381
+ }
382
+ try {
383
+ return await probeHealth(url, username, password);
384
+ }
385
+ catch {
386
+ await sleep(200);
387
+ }
388
+ }
389
+ throw new Error(`Timed out waiting for opencode serve at ${url}`);
390
+ }
391
+ async function probeHealth(url, username, password) {
392
+ const response = await fetch(`${url}/global/health`, {
393
+ headers: authHeaders(username, password),
394
+ signal: AbortSignal.timeout(1_000),
395
+ });
396
+ if (response.status === 401)
397
+ throw new Error('OpenCode server rejected its stored credentials');
398
+ if (!response.ok)
399
+ throw new Error(`OpenCode health check returned HTTP ${response.status}`);
400
+ const value = await response.json();
401
+ if (value.healthy !== true)
402
+ throw new Error('OpenCode health check did not report healthy=true');
403
+ return { healthy: true, ...(typeof value.version === 'string' ? { version: value.version } : {}) };
404
+ }
405
+ function authHeaders(username, password) {
406
+ return { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` };
407
+ }
408
+ function reservePort() {
409
+ return new Promise((resolve, reject) => {
410
+ const server = net.createServer();
411
+ server.once('error', reject);
412
+ server.listen(0, '127.0.0.1', () => {
413
+ const address = server.address();
414
+ if (!address || typeof address === 'string') {
415
+ server.close();
416
+ reject(new Error('Failed to reserve a local TCP port'));
417
+ return;
418
+ }
419
+ server.close((error) => error ? reject(error) : resolve(address.port));
420
+ });
421
+ });
422
+ }
423
+ function waitForSpawn(child) {
424
+ return new Promise((resolve, reject) => {
425
+ const onSpawn = () => {
426
+ child.off('error', onError);
427
+ resolve();
428
+ };
429
+ const onError = (error) => {
430
+ child.off('spawn', onSpawn);
431
+ reject(error);
432
+ };
433
+ child.once('spawn', onSpawn);
434
+ child.once('error', onError);
435
+ });
436
+ }
437
+ function isProcessAlive(pid) {
438
+ if (!Number.isInteger(pid) || pid <= 0)
439
+ return false;
440
+ try {
441
+ process.kill(pid, 0);
442
+ return true;
443
+ }
444
+ catch (error) {
445
+ return error.code === 'EPERM';
446
+ }
447
+ }
448
+ async function terminateProcessGroup(pid) {
449
+ try {
450
+ process.kill(-pid, 'SIGTERM');
451
+ }
452
+ catch {
453
+ try {
454
+ process.kill(pid, 'SIGTERM');
455
+ }
456
+ catch {
457
+ return;
458
+ }
459
+ }
460
+ const deadline = Date.now() + 5_000;
461
+ while (Date.now() < deadline && isProcessAlive(pid))
462
+ await sleep(100);
463
+ if (!isProcessAlive(pid))
464
+ return;
465
+ try {
466
+ process.kill(-pid, 'SIGKILL');
467
+ }
468
+ catch {
469
+ try {
470
+ process.kill(pid, 'SIGKILL');
471
+ }
472
+ catch { /* already gone */ }
473
+ }
474
+ }
475
+ function sleep(ms, signal) {
476
+ return new Promise((resolve) => {
477
+ if (signal?.aborted)
478
+ return resolve();
479
+ const timer = setTimeout(resolve, ms);
480
+ signal?.addEventListener('abort', () => {
481
+ clearTimeout(timer);
482
+ resolve();
483
+ }, { once: true });
484
+ });
485
+ }
486
+ export function formatSdkError(error) {
487
+ if (typeof error === 'object' && error !== null) {
488
+ const value = error;
489
+ if (typeof value.data?.message === 'string')
490
+ return value.data.message;
491
+ if (typeof value.message === 'string')
492
+ return value.message;
493
+ if (typeof value._tag === 'string')
494
+ return value._tag;
495
+ if (typeof value.name === 'string')
496
+ return value.name;
497
+ }
498
+ return String(error);
499
+ }
@@ -0,0 +1,137 @@
1
+ import type { PermissionRuleset } from '@opencode-ai/sdk/v2';
2
+ import type { AppConfig } from '../config.js';
3
+ import type { TelegramMessagingPort } from '../channels/telegram/telegram_messaging_port.js';
4
+ import type { Logger } from '../logger.js';
5
+ import type { BridgeStore } from '../store/database.js';
6
+ import type { TelegramGateway } from '../telegram/gateway.js';
7
+ import type { OpencodeAppClient } from './client.js';
8
+ /** Telegram-facing OpenCode runtime. It shares FoxClaw's gateway/store/rendering primitives. */
9
+ export declare class OpencodeBridgeCore {
10
+ private readonly config;
11
+ private readonly store;
12
+ private readonly logger;
13
+ private readonly bot;
14
+ private readonly app;
15
+ private readonly messaging;
16
+ private readonly activeTurns;
17
+ private readonly watchers;
18
+ private readonly queuedPrompts;
19
+ private readonly permissions;
20
+ private readonly questions;
21
+ private readonly setupActions;
22
+ private readonly latestVoiceText;
23
+ private readonly locks;
24
+ private readonly finishingSessions;
25
+ private readonly handlingPermissionIds;
26
+ private readonly handlingQuestionIds;
27
+ private disconnectCleanup;
28
+ private started;
29
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger, bot: TelegramGateway, app: OpencodeAppClient, messaging: TelegramMessagingPort);
30
+ registerInboundHandlers(): void;
31
+ start(): Promise<void>;
32
+ stop(): Promise<void>;
33
+ get isRunning(): boolean;
34
+ get activeTurnCount(): number;
35
+ getRuntimeStatus(): {
36
+ connected: boolean;
37
+ activeTurns: number;
38
+ botUsername: string | null;
39
+ server: ReturnType<OpencodeAppClient['getServerStatus']>;
40
+ };
41
+ private withLock;
42
+ private reportError;
43
+ private unsupportedCommandMessage;
44
+ private localeForScope;
45
+ private handleText;
46
+ private handleCommand;
47
+ private showHelp;
48
+ private createAndBind;
49
+ private showThreads;
50
+ private resolveSessionTarget;
51
+ private openSession;
52
+ private watchSession;
53
+ private unwatchSession;
54
+ private showStatus;
55
+ private showSetup;
56
+ private setupKeyboard;
57
+ private sendOrEditPanel;
58
+ private listProviders;
59
+ private listModels;
60
+ private showModels;
61
+ private setModel;
62
+ private setVariant;
63
+ private setMode;
64
+ private setAgent;
65
+ private setAccess;
66
+ private applyAccess;
67
+ private setActiveMode;
68
+ private writePrefs;
69
+ private sendWithBehavior;
70
+ private takeOver;
71
+ private dispatchPrompt;
72
+ private startTrackedTurn;
73
+ private buildPromptParts;
74
+ private stageAttachments;
75
+ private showHistory;
76
+ private renameSession;
77
+ private forkSession;
78
+ private undoSession;
79
+ private redoSession;
80
+ private archiveBoundSession;
81
+ private archiveCachedSession;
82
+ private archiveSession;
83
+ private unarchiveSession;
84
+ private showDiff;
85
+ private showWhere;
86
+ private findFiles;
87
+ private compactSession;
88
+ private showLoaded;
89
+ private statusesForSessions;
90
+ private showSkills;
91
+ private showMcp;
92
+ private showProviders;
93
+ private showAuth;
94
+ private showPlugins;
95
+ private showFeatures;
96
+ private showConfig;
97
+ private runReview;
98
+ private showRichDemo;
99
+ private handleVoiceCommand;
100
+ private showFastUnsupported;
101
+ private abort;
102
+ private requireBoundSession;
103
+ private effectiveModel;
104
+ private handleAppEvent;
105
+ private cleanupAfterDisconnect;
106
+ private recoverAfterReconnect;
107
+ private scopesForSession;
108
+ private isOwnScope;
109
+ private cwdForSession;
110
+ private handleTextEvent;
111
+ private handleToolEvent;
112
+ private scheduleTurnFlush;
113
+ private flushTurn;
114
+ private flushTurnNow;
115
+ private scheduleToolFlush;
116
+ private flushTools;
117
+ private flushToolsNow;
118
+ private scheduleWatchFlush;
119
+ private flushWatch;
120
+ private flushWatchNow;
121
+ private finishSession;
122
+ private finishSessionNow;
123
+ private clearTurnTimers;
124
+ private handlePermission;
125
+ private resolvePermission;
126
+ private approveFromCommand;
127
+ private replyPermission;
128
+ private handleQuestion;
129
+ private resolveQuestion;
130
+ private answerFromCommand;
131
+ private maybeSubmitQuestion;
132
+ private handleCallback;
133
+ private send;
134
+ private sendFinalChunk;
135
+ private editFinalChunk;
136
+ }
137
+ export declare function permissionRules(access: string): PermissionRuleset;