@foxden-app/foxclaw 0.5.78 → 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,61 @@
1
+ import type { Event, PermissionRequest, QuestionRequest, SessionStatus } from '@opencode-ai/sdk/v2';
2
+ export interface OpencodeTextEvent {
3
+ kind: 'text';
4
+ sessionId: string;
5
+ messageId: string;
6
+ partId: string;
7
+ text: string;
8
+ delta: string | null;
9
+ }
10
+ export interface OpencodeToolEvent {
11
+ kind: 'tool';
12
+ sessionId: string;
13
+ messageId: string;
14
+ partId: string;
15
+ callId: string;
16
+ tool: string;
17
+ status: string;
18
+ title: string | null;
19
+ error: string | null;
20
+ }
21
+ export type OpencodeBridgeEvent = OpencodeTextEvent | OpencodeToolEvent | {
22
+ kind: 'permission';
23
+ request: PermissionRequest;
24
+ } | {
25
+ kind: 'permissionResolved';
26
+ sessionId: string;
27
+ requestId: string;
28
+ } | {
29
+ kind: 'question';
30
+ request: QuestionRequest;
31
+ } | {
32
+ kind: 'questionResolved';
33
+ sessionId: string;
34
+ requestId: string;
35
+ } | {
36
+ kind: 'status';
37
+ sessionId: string;
38
+ status: SessionStatus;
39
+ } | {
40
+ kind: 'idle';
41
+ sessionId: string;
42
+ } | {
43
+ kind: 'error';
44
+ sessionId: string | null;
45
+ message: string;
46
+ };
47
+ /**
48
+ * Turns OpenCode's public SSE events into the small, stable event surface used
49
+ * by the Telegram bridge. State is intentionally kept here so reconnect and
50
+ * delta handling can be tested without starting a real server.
51
+ */
52
+ export declare class OpencodeEventNormalizer {
53
+ private readonly roles;
54
+ private readonly textByPart;
55
+ private readonly sessionByMessage;
56
+ reset(): void;
57
+ accept(event: Event): OpencodeBridgeEvent[];
58
+ private normalizePart;
59
+ private clearSession;
60
+ }
61
+ export declare function formatOpencodeError(error: unknown): string;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Turns OpenCode's public SSE events into the small, stable event surface used
3
+ * by the Telegram bridge. State is intentionally kept here so reconnect and
4
+ * delta handling can be tested without starting a real server.
5
+ */
6
+ export class OpencodeEventNormalizer {
7
+ roles = new Map();
8
+ textByPart = new Map();
9
+ sessionByMessage = new Map();
10
+ reset() {
11
+ this.roles.clear();
12
+ this.textByPart.clear();
13
+ this.sessionByMessage.clear();
14
+ }
15
+ accept(event) {
16
+ switch (event.type) {
17
+ case 'message.updated': {
18
+ this.roles.set(event.properties.info.id, event.properties.info.role);
19
+ this.sessionByMessage.set(event.properties.info.id, event.properties.info.sessionID);
20
+ return [];
21
+ }
22
+ case 'message.part.delta': {
23
+ if (event.properties.field !== 'text' || this.roles.get(event.properties.messageID) !== 'assistant') {
24
+ return [];
25
+ }
26
+ const key = partKey(event.properties.messageID, event.properties.partID);
27
+ const text = `${this.textByPart.get(key) ?? ''}${event.properties.delta}`;
28
+ this.textByPart.set(key, text);
29
+ return [{
30
+ kind: 'text',
31
+ sessionId: event.properties.sessionID,
32
+ messageId: event.properties.messageID,
33
+ partId: event.properties.partID,
34
+ text,
35
+ delta: event.properties.delta,
36
+ }];
37
+ }
38
+ case 'message.part.updated':
39
+ return this.normalizePart(event.properties.part);
40
+ case 'permission.asked':
41
+ return [{ kind: 'permission', request: event.properties }];
42
+ case 'permission.replied':
43
+ return [{
44
+ kind: 'permissionResolved',
45
+ sessionId: event.properties.sessionID,
46
+ requestId: event.properties.requestID,
47
+ }];
48
+ case 'question.asked':
49
+ return [{ kind: 'question', request: event.properties }];
50
+ case 'question.replied':
51
+ case 'question.rejected':
52
+ return [{
53
+ kind: 'questionResolved',
54
+ sessionId: event.properties.sessionID,
55
+ requestId: event.properties.requestID,
56
+ }];
57
+ case 'session.status':
58
+ if (event.properties.status.type === 'idle')
59
+ this.clearSession(event.properties.sessionID);
60
+ return [{ kind: 'status', sessionId: event.properties.sessionID, status: event.properties.status }];
61
+ case 'session.idle':
62
+ this.clearSession(event.properties.sessionID);
63
+ return [{ kind: 'idle', sessionId: event.properties.sessionID }];
64
+ case 'session.error':
65
+ if (event.properties.sessionID)
66
+ this.clearSession(event.properties.sessionID);
67
+ return [{
68
+ kind: 'error',
69
+ sessionId: event.properties.sessionID ?? null,
70
+ message: formatOpencodeError(event.properties.error),
71
+ }];
72
+ default:
73
+ return [];
74
+ }
75
+ }
76
+ normalizePart(part) {
77
+ if (part.type === 'text') {
78
+ if (this.roles.get(part.messageID) !== 'assistant')
79
+ return [];
80
+ const key = partKey(part.messageID, part.id);
81
+ const previous = this.textByPart.get(key) ?? '';
82
+ this.textByPart.set(key, part.text);
83
+ if (part.text === previous)
84
+ return [];
85
+ return [{
86
+ kind: 'text',
87
+ sessionId: part.sessionID,
88
+ messageId: part.messageID,
89
+ partId: part.id,
90
+ text: part.text,
91
+ delta: part.text.startsWith(previous) ? part.text.slice(previous.length) : null,
92
+ }];
93
+ }
94
+ if (part.type !== 'tool')
95
+ return [];
96
+ const state = part.state;
97
+ return [{
98
+ kind: 'tool',
99
+ sessionId: part.sessionID,
100
+ messageId: part.messageID,
101
+ partId: part.id,
102
+ callId: part.callID,
103
+ tool: part.tool,
104
+ status: state.status,
105
+ title: 'title' in state && typeof state.title === 'string' ? state.title : null,
106
+ error: state.status === 'error' ? state.error : null,
107
+ }];
108
+ }
109
+ clearSession(sessionId) {
110
+ for (const [messageId, owner] of this.sessionByMessage) {
111
+ if (owner !== sessionId)
112
+ continue;
113
+ this.sessionByMessage.delete(messageId);
114
+ this.roles.delete(messageId);
115
+ for (const key of this.textByPart.keys()) {
116
+ if (key.startsWith(`${messageId}:`))
117
+ this.textByPart.delete(key);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ function partKey(messageId, partId) {
123
+ return `${messageId}:${partId}`;
124
+ }
125
+ export function formatOpencodeError(error) {
126
+ if (!error)
127
+ return 'OpenCode session failed';
128
+ if (typeof error === 'string')
129
+ return error;
130
+ if (typeof error === 'object') {
131
+ const record = error;
132
+ if (typeof record.data?.message === 'string')
133
+ return record.data.message;
134
+ if (typeof record.message === 'string')
135
+ return record.message;
136
+ if (typeof record.name === 'string')
137
+ return record.name;
138
+ }
139
+ try {
140
+ return JSON.stringify(error);
141
+ }
142
+ catch {
143
+ return String(error);
144
+ }
145
+ }
@@ -0,0 +1,14 @@
1
+ import type { AppConfig } from '../config.js';
2
+ import type { Logger } from '../logger.js';
3
+ import type { BridgeStore } from '../store/database.js';
4
+ import { OpencodeBridgeCore } from './controller.js';
5
+ /** Keeps the optional OpenCode Telegram bot lifecycle out of the Codex runtime branches. */
6
+ export declare class OpencodeTelegramRuntime {
7
+ private readonly bot;
8
+ private readonly app;
9
+ private readonly core;
10
+ constructor(config: AppConfig, store: BridgeStore, logger: Logger);
11
+ start(): Promise<void>;
12
+ stop(): Promise<void>;
13
+ getRuntimeStatus(): ReturnType<OpencodeBridgeCore['getRuntimeStatus']>;
14
+ }
@@ -0,0 +1,29 @@
1
+ import { TelegramMessagingPort } from '../channels/telegram/telegram_messaging_port.js';
2
+ import { getOpencodeTelegramCommands } from '../i18n.js';
3
+ import { TelegramGateway } from '../telegram/gateway.js';
4
+ import { OpencodeAppClient } from './client.js';
5
+ import { OpencodeBridgeCore } from './controller.js';
6
+ /** Keeps the optional OpenCode Telegram bot lifecycle out of the Codex runtime branches. */
7
+ export class OpencodeTelegramRuntime {
8
+ bot;
9
+ app;
10
+ core;
11
+ constructor(config, store, logger) {
12
+ if (!config.opencodeBotToken)
13
+ throw new Error('OPENCODE_BOT_TOKEN is required for the OpenCode runtime');
14
+ this.bot = new TelegramGateway(config.opencodeBotToken, config.tgAllowedUserId, config.tgAllowedChatId, config.telegramPollIntervalMs, store, logger, true, getOpencodeTelegramCommands);
15
+ this.app = new OpencodeAppClient(config.opencodeCliBin, config.opencodeServerPassword, config.opencodeServerStatePath, config.opencodeServerLogPath, logger);
16
+ this.core = new OpencodeBridgeCore(config, store, logger, this.bot, this.app, new TelegramMessagingPort(this.bot));
17
+ this.core.registerInboundHandlers();
18
+ }
19
+ async start() {
20
+ await this.bot.initializeIdentity();
21
+ await this.core.start();
22
+ }
23
+ async stop() {
24
+ await this.core.stop();
25
+ }
26
+ getRuntimeStatus() {
27
+ return this.core.getRuntimeStatus();
28
+ }
29
+ }
@@ -60,6 +60,7 @@ export declare class BridgeStore {
60
60
  } | null;
61
61
  getBinding(chatId: string): ThreadBinding | null;
62
62
  setBinding(chatId: string, threadId: string, cwd: string | null): void;
63
+ listBindings(): ThreadBinding[];
63
64
  clearBinding(chatId: string): void;
64
65
  getChatSettings(chatId: string): ChatSessionSettings | null;
65
66
  setChatSettings(chatId: string, model: string | null, reasoningEffort: ReasoningEffortValue | null, locale?: AppLocale | null): void;
@@ -287,6 +287,15 @@ export class BridgeStore {
287
287
  ON CONFLICT(chat_id) DO UPDATE SET thread_id = excluded.thread_id, cwd = excluded.cwd, updated_at = excluded.updated_at
288
288
  `).run(chatId, threadId, cwd, Date.now());
289
289
  }
290
+ listBindings() {
291
+ const rows = this.db.prepare('SELECT chat_id, thread_id, cwd, updated_at FROM chat_bindings ORDER BY updated_at DESC').all();
292
+ return rows.map((row) => ({
293
+ chatId: String(row.chat_id),
294
+ threadId: String(row.thread_id),
295
+ cwd: row.cwd === null ? null : String(row.cwd),
296
+ updatedAt: Number(row.updated_at),
297
+ }));
298
+ }
290
299
  clearBinding(chatId) {
291
300
  this.db.prepare('DELETE FROM chat_bindings WHERE chat_id = ?').run(chatId);
292
301
  }
@@ -5,6 +5,10 @@ import type { Logger } from '../logger.js';
5
5
  import type { TelegramMessageEntity } from './addressing.js';
6
6
  import type { TelegramInboundAttachment } from './media.js';
7
7
  import type { TelegramInputRichMessage } from './rich.js';
8
+ export type TelegramCommandProvider = (locale: 'en' | 'zh') => Array<{
9
+ command: string;
10
+ description: string;
11
+ }>;
8
12
  export interface TelegramTextEvent {
9
13
  chatId: string;
10
14
  topicId: number | null;
@@ -45,11 +49,12 @@ export declare class TelegramGateway extends EventEmitter {
45
49
  private readonly store;
46
50
  private readonly logger;
47
51
  private readonly namespacedScopes;
52
+ private readonly commandProvider;
48
53
  private running;
49
54
  private botKey;
50
55
  private botUsername;
51
56
  private botUserId;
52
- constructor(botToken: string, allowedUserId: string, allowedChatId: string | null, pollIntervalMs: number, store: BridgeStore, logger: Logger, namespacedScopes?: boolean);
57
+ constructor(botToken: string, allowedUserId: string, allowedChatId: string | null, pollIntervalMs: number, store: BridgeStore, logger: Logger, namespacedScopes?: boolean, commandProvider?: TelegramCommandProvider);
53
58
  get username(): string | null;
54
59
  get identity(): string | null;
55
60
  initializeIdentity(): Promise<string>;
@@ -12,11 +12,12 @@ export class TelegramGateway extends EventEmitter {
12
12
  store;
13
13
  logger;
14
14
  namespacedScopes;
15
+ commandProvider;
15
16
  running = false;
16
17
  botKey;
17
18
  botUsername = null;
18
19
  botUserId = null;
19
- constructor(botToken, allowedUserId, allowedChatId, pollIntervalMs, store, logger, namespacedScopes = false) {
20
+ constructor(botToken, allowedUserId, allowedChatId, pollIntervalMs, store, logger, namespacedScopes = false, commandProvider = getTelegramCommands) {
20
21
  super();
21
22
  this.botToken = botToken;
22
23
  this.allowedUserId = allowedUserId;
@@ -25,6 +26,7 @@ export class TelegramGateway extends EventEmitter {
25
26
  this.store = store;
26
27
  this.logger = logger;
27
28
  this.namespacedScopes = namespacedScopes;
29
+ this.commandProvider = commandProvider;
28
30
  this.botKey = `telegram:${crypto.createHash('sha256').update(this.botToken).digest('hex').slice(0, 8)}`;
29
31
  }
30
32
  get username() {
@@ -224,14 +226,14 @@ export class TelegramGateway extends EventEmitter {
224
226
  }
225
227
  async registerCommands() {
226
228
  await callTelegramApi(this.botToken, 'setMyCommands', {
227
- commands: getTelegramCommands('zh'),
229
+ commands: this.commandProvider('zh'),
228
230
  });
229
231
  await callTelegramApi(this.botToken, 'setMyCommands', {
230
- commands: getTelegramCommands('en'),
232
+ commands: this.commandProvider('en'),
231
233
  language_code: 'en',
232
234
  });
233
235
  await callTelegramApi(this.botToken, 'setMyCommands', {
234
- commands: getTelegramCommands('zh'),
236
+ commands: this.commandProvider('zh'),
235
237
  language_code: 'zh',
236
238
  });
237
239
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.78",
4
- "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
3
+ "version": "0.6.3",
4
+ "description": "Foxden local execution claw for controlling Codex and OpenCode from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
7
7
  "bin": {
@@ -55,6 +55,7 @@
55
55
  "prepack": "npm run build"
56
56
  },
57
57
  "dependencies": {
58
+ "@opencode-ai/sdk": "1.18.15",
58
59
  "dotenv": "^16.6.1",
59
60
  "qrcode-terminal": "^0.12.0"
60
61
  },