appback-remoteagent 0.13.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/.env.example +39 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/bin/remoteagent.js +2 -0
- package/dist/adapters/claude-adapter.js +78 -0
- package/dist/adapters/codex-adapter.js +241 -0
- package/dist/adapters/provider-adapter.js +1 -0
- package/dist/adapters/shell-adapter.js +44 -0
- package/dist/adapters/windows-shell.js +111 -0
- package/dist/bot.js +2135 -0
- package/dist/config.js +170 -0
- package/dist/index.js +534 -0
- package/dist/secret-helper.js +24 -0
- package/dist/services/agent-memory-service.js +737 -0
- package/dist/services/bot-management-service.js +626 -0
- package/dist/services/bridge-service.js +807 -0
- package/dist/services/local-ui-service.js +533 -0
- package/dist/services/provider-setup-service.js +284 -0
- package/dist/services/remote-shell-service.js +97 -0
- package/dist/store/file-store.js +690 -0
- package/dist/telegram-fetch.js +85 -0
- package/dist/types.js +1 -0
- package/docs/ARCHITECTURE.md +170 -0
- package/docs/COKACDIR_NOTES.md +79 -0
- package/docs/ERROR_NORMALIZATION.md +46 -0
- package/docs/MINI_APP.md +112 -0
- package/docs/MVP.md +108 -0
- package/docs/OPERATIONS.md +181 -0
- package/docs/RELEASING.md +87 -0
- package/docs/SESSION_DIRECTORY_PLAN.md +506 -0
- package/package.json +47 -0
- package/scripts/bump-version.sh +23 -0
- package/scripts/finish-claude-login.sh +48 -0
- package/scripts/install-claude.sh +6 -0
- package/scripts/install-codex.sh +8 -0
- package/scripts/install.ps1 +51 -0
- package/scripts/install.sh +101 -0
- package/scripts/mock-adapter.sh +7 -0
- package/scripts/restart-after-bot-op.sh +118 -0
- package/scripts/selftest-telegram-update.mjs +359 -0
- package/scripts/start-claude-login.sh +4 -0
- package/scripts/start.ps1 +39 -0
- package/scripts/start.sh +54 -0
- package/scripts/stop.ps1 +40 -0
- package/scripts/stop.sh +39 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { stopSpawnedExecution } from "../adapters/windows-shell.js";
|
|
6
|
+
const MODEL_PRESETS = {
|
|
7
|
+
codex: ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.2", "gpt-5.1-codex-max"],
|
|
8
|
+
claude: ["sonnet", "opus", "haiku"],
|
|
9
|
+
};
|
|
10
|
+
export class BridgeService {
|
|
11
|
+
store;
|
|
12
|
+
adapters;
|
|
13
|
+
defaultWorkspace;
|
|
14
|
+
workspaceRoot;
|
|
15
|
+
isProviderInstalled;
|
|
16
|
+
preferredStartMode;
|
|
17
|
+
defaultCodexSandboxMode;
|
|
18
|
+
sessionLocks = new Map();
|
|
19
|
+
constructor(store, adapters, defaultWorkspace, workspaceRoot, isProviderInstalled, preferredStartMode, defaultCodexSandboxMode) {
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.adapters = adapters;
|
|
22
|
+
this.defaultWorkspace = defaultWorkspace;
|
|
23
|
+
this.workspaceRoot = workspaceRoot;
|
|
24
|
+
this.isProviderInstalled = isProviderInstalled;
|
|
25
|
+
this.preferredStartMode = preferredStartMode;
|
|
26
|
+
this.defaultCodexSandboxMode = defaultCodexSandboxMode;
|
|
27
|
+
}
|
|
28
|
+
async createSession(botId, chatId, cwd) {
|
|
29
|
+
const provider = await this.resolveStartProvider();
|
|
30
|
+
return this.startSession(botId, chatId, provider, cwd);
|
|
31
|
+
}
|
|
32
|
+
async switchSession(botId, chatId, sessionId) {
|
|
33
|
+
const selector = sessionId.trim();
|
|
34
|
+
if (!selector) {
|
|
35
|
+
throw new Error("Session selector is required.");
|
|
36
|
+
}
|
|
37
|
+
const session = await this.resolveSessionSelector(selector);
|
|
38
|
+
if (!session) {
|
|
39
|
+
throw new Error(`Session was not found: ${selector}`);
|
|
40
|
+
}
|
|
41
|
+
return this.store.bindChatToSession(botId, chatId, session.sessionId);
|
|
42
|
+
}
|
|
43
|
+
async isChatBoundToSession(botId, chatId, sessionId) {
|
|
44
|
+
const chatSession = await this.store.getChatSession(botId, chatId);
|
|
45
|
+
return chatSession?.session.sessionId === sessionId;
|
|
46
|
+
}
|
|
47
|
+
async startSession(botId, chatId, provider, cwd) {
|
|
48
|
+
this.ensureConfigured(provider);
|
|
49
|
+
const explicitWorkspace = cwd?.trim();
|
|
50
|
+
const managedWorkspace = explicitWorkspace ? undefined : await this.createManagedWorkspace();
|
|
51
|
+
const workspace = explicitWorkspace
|
|
52
|
+
? this.resolveWorkspace(undefined, explicitWorkspace)
|
|
53
|
+
: managedWorkspace.workspace;
|
|
54
|
+
if (explicitWorkspace) {
|
|
55
|
+
await this.ensureWorkspaceExists(workspace);
|
|
56
|
+
}
|
|
57
|
+
await this.store.createSessionForChat(botId, chatId, workspace, provider, managedWorkspace?.workspaceUid);
|
|
58
|
+
const chatSession = await this.store.upsertProviderForChat(botId, chatId, provider, {
|
|
59
|
+
cwd: workspace,
|
|
60
|
+
pairedAt: new Date().toISOString(),
|
|
61
|
+
sessionId: undefined,
|
|
62
|
+
model: provider === "codex" ? "gpt-5.5" : "sonnet",
|
|
63
|
+
sandboxMode: provider === "codex" ? this.defaultCodexSandboxMode : undefined,
|
|
64
|
+
lastUsedAt: undefined,
|
|
65
|
+
}, workspace);
|
|
66
|
+
await this.store.ensureDefaultStartMode(provider);
|
|
67
|
+
return chatSession;
|
|
68
|
+
}
|
|
69
|
+
async attachPair(botId, chatId, provider, sessionId) {
|
|
70
|
+
this.ensureConfigured(provider);
|
|
71
|
+
const existing = await this.store.getChatSession(botId, chatId);
|
|
72
|
+
const normalizedSessionId = sessionId.trim();
|
|
73
|
+
if (!normalizedSessionId) {
|
|
74
|
+
throw new Error("Session id is required.");
|
|
75
|
+
}
|
|
76
|
+
let workspace;
|
|
77
|
+
if (provider === "codex") {
|
|
78
|
+
const detectedWorkspace = await this.readCodexSessionWorkspace(normalizedSessionId);
|
|
79
|
+
if (detectedWorkspace) {
|
|
80
|
+
workspace = detectedWorkspace;
|
|
81
|
+
await this.ensureWorkspaceExists(workspace);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
workspace = this.resolveWorkspace(existing?.session[provider]?.cwd ?? existing?.session.workspace, undefined);
|
|
85
|
+
await this.ensureWorkspaceExists(workspace);
|
|
86
|
+
await this.verifyCodexAttachWorkspace(botId, chatId, normalizedSessionId, workspace, existing?.session.codex);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
workspace = this.resolveWorkspace(existing?.session[provider]?.cwd ?? existing?.session.workspace, undefined);
|
|
91
|
+
await this.ensureWorkspaceExists(workspace);
|
|
92
|
+
}
|
|
93
|
+
const chatSession = await this.store.upsertProviderForChat(botId, chatId, provider, {
|
|
94
|
+
cwd: workspace,
|
|
95
|
+
pairedAt: new Date().toISOString(),
|
|
96
|
+
sessionId: normalizedSessionId,
|
|
97
|
+
model: existing?.session[provider]?.model ?? (provider === "codex" ? "gpt-5.5" : "sonnet"),
|
|
98
|
+
sandboxMode: provider === "codex"
|
|
99
|
+
? (existing?.session[provider]?.sandboxMode ?? this.defaultCodexSandboxMode)
|
|
100
|
+
: undefined,
|
|
101
|
+
lastUsedAt: undefined,
|
|
102
|
+
}, workspace);
|
|
103
|
+
await this.store.ensureDefaultStartMode(provider);
|
|
104
|
+
return chatSession;
|
|
105
|
+
}
|
|
106
|
+
async resolveStartProvider(requested) {
|
|
107
|
+
const normalized = requested?.trim().toLowerCase();
|
|
108
|
+
if (normalized) {
|
|
109
|
+
if (normalized !== "codex" && normalized !== "claude") {
|
|
110
|
+
throw new Error("Provider must be codex or claude.");
|
|
111
|
+
}
|
|
112
|
+
const explicit = normalized;
|
|
113
|
+
if (!this.isProviderAvailable(explicit)) {
|
|
114
|
+
throw new Error(this.formatInstallGuidance(explicit));
|
|
115
|
+
}
|
|
116
|
+
return explicit;
|
|
117
|
+
}
|
|
118
|
+
const savedDefault = await this.store.getDefaultStartMode();
|
|
119
|
+
const candidates = [savedDefault, this.preferredStartMode, ...this.listAvailableProviders()]
|
|
120
|
+
.filter((value, index, items) => Boolean(value) && items.indexOf(value) === index);
|
|
121
|
+
const provider = candidates.find((item) => this.isProviderAvailable(item));
|
|
122
|
+
if (!provider) {
|
|
123
|
+
throw new Error(this.formatInstallGuidance());
|
|
124
|
+
}
|
|
125
|
+
return provider;
|
|
126
|
+
}
|
|
127
|
+
listAvailableProviders() {
|
|
128
|
+
return ["codex", "claude"].filter((provider) => this.isProviderAvailable(provider));
|
|
129
|
+
}
|
|
130
|
+
async rememberDefaultStartMode(provider) {
|
|
131
|
+
if (this.isProviderAvailable(provider)) {
|
|
132
|
+
await this.store.ensureDefaultStartMode(provider);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
formatInstallGuidance(requested) {
|
|
136
|
+
const available = this.listAvailableProviders();
|
|
137
|
+
const availableLine = available.length > 0
|
|
138
|
+
? `Installed modes: ${available.join(", ")}`
|
|
139
|
+
: "Installed modes: none";
|
|
140
|
+
if (requested) {
|
|
141
|
+
const nextStep = requested === "claude"
|
|
142
|
+
? "Install Claude Code on this machine with /install claude, finish the CLI login with /login claude, then run /start claude."
|
|
143
|
+
: "Install Codex CLI on this machine, then run /install codex or /start codex.";
|
|
144
|
+
return [
|
|
145
|
+
`${requested} is not installed on this machine yet.`,
|
|
146
|
+
availableLine,
|
|
147
|
+
nextStep,
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
return [
|
|
151
|
+
"No installed coding mode was found on this machine.",
|
|
152
|
+
availableLine,
|
|
153
|
+
"Install Codex CLI or Claude Code first. You can run /install codex or /install claude, then use /start.",
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
async setCodexSandboxMode(botId, chatId, sandboxMode) {
|
|
157
|
+
const chatSession = await this.requireChat(botId, chatId);
|
|
158
|
+
this.ensurePaired(chatSession, "codex");
|
|
159
|
+
const codex = chatSession.session.codex;
|
|
160
|
+
return this.store.upsertProviderForChat(botId, chatId, "codex", {
|
|
161
|
+
...codex,
|
|
162
|
+
sandboxMode,
|
|
163
|
+
}, chatSession.session.workspace);
|
|
164
|
+
}
|
|
165
|
+
async rememberTelegramContact(contact) {
|
|
166
|
+
await this.store.rememberTelegramContact(contact);
|
|
167
|
+
}
|
|
168
|
+
async setModel(botId, chatId, model) {
|
|
169
|
+
const chatSession = await this.requireChat(botId, chatId);
|
|
170
|
+
const provider = chatSession.session.mode;
|
|
171
|
+
this.ensurePaired(chatSession, provider);
|
|
172
|
+
const nextModel = this.resolveSelectableModel(provider, model);
|
|
173
|
+
if (!nextModel) {
|
|
174
|
+
throw new Error("Model name is required.");
|
|
175
|
+
}
|
|
176
|
+
const providerSession = chatSession.session[provider];
|
|
177
|
+
return this.store.upsertProviderForChat(botId, chatId, provider, {
|
|
178
|
+
...providerSession,
|
|
179
|
+
model: nextModel,
|
|
180
|
+
}, chatSession.session.workspace);
|
|
181
|
+
}
|
|
182
|
+
async formatModelSelection(botId, chatId) {
|
|
183
|
+
const chatSession = await this.requireChat(botId, chatId);
|
|
184
|
+
const provider = chatSession.session.mode;
|
|
185
|
+
this.ensurePaired(chatSession, provider);
|
|
186
|
+
const providerSession = chatSession.session[provider];
|
|
187
|
+
const presets = MODEL_PRESETS[provider] ?? [];
|
|
188
|
+
const lines = [
|
|
189
|
+
`session: ${chatSession.session.publicId}`,
|
|
190
|
+
`mode: ${provider}`,
|
|
191
|
+
`currentModel: ${providerSession.model ?? this.defaultModelFor(provider)}`,
|
|
192
|
+
"availablePresets:",
|
|
193
|
+
...presets.map((item, index) => ` ${index + 1}. ${item}`),
|
|
194
|
+
"",
|
|
195
|
+
"Use `/model <name>` or `/model <number>` to change it.",
|
|
196
|
+
];
|
|
197
|
+
if (presets.length === 0) {
|
|
198
|
+
lines.splice(3, 1, "availablePresets: none");
|
|
199
|
+
}
|
|
200
|
+
return lines.join("\n");
|
|
201
|
+
}
|
|
202
|
+
async status(botId, chatId) {
|
|
203
|
+
return this.store.getChatSession(botId, chatId);
|
|
204
|
+
}
|
|
205
|
+
async listSessions(botId) {
|
|
206
|
+
return this.store.listSessions(botId);
|
|
207
|
+
}
|
|
208
|
+
async listActiveSessionIds(botId) {
|
|
209
|
+
return this.store.listActiveSessionIds(botId);
|
|
210
|
+
}
|
|
211
|
+
async sessionEvents(sessionId, limit) {
|
|
212
|
+
const session = await this.store.getSession(sessionId);
|
|
213
|
+
if (!session) {
|
|
214
|
+
throw new Error(`Session was not found: ${sessionId}`);
|
|
215
|
+
}
|
|
216
|
+
return this.store.readLogs(sessionId, limit);
|
|
217
|
+
}
|
|
218
|
+
async logSystem(botId, chatId, text) {
|
|
219
|
+
const chatSession = await this.store.getChatSession(botId, chatId);
|
|
220
|
+
if (!chatSession) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await this.log({
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
remoteSessionId: chatSession.session.sessionId,
|
|
226
|
+
botId,
|
|
227
|
+
chatId,
|
|
228
|
+
provider: "system",
|
|
229
|
+
direction: "system",
|
|
230
|
+
text,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
async reset(botId, chatId) {
|
|
234
|
+
const chatSession = await this.store.getChatSession(botId, chatId);
|
|
235
|
+
if (chatSession) {
|
|
236
|
+
await this.log({
|
|
237
|
+
timestamp: new Date().toISOString(),
|
|
238
|
+
remoteSessionId: chatSession.session.sessionId,
|
|
239
|
+
botId,
|
|
240
|
+
chatId,
|
|
241
|
+
provider: "system",
|
|
242
|
+
direction: "system",
|
|
243
|
+
text: "Chat binding reset.",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
await this.store.resetChat(botId, chatId);
|
|
247
|
+
}
|
|
248
|
+
async stopActiveRun(botId, chatId) {
|
|
249
|
+
const chatSession = await this.store.getChatSession(botId, chatId);
|
|
250
|
+
if (!chatSession) {
|
|
251
|
+
return { stopped: false };
|
|
252
|
+
}
|
|
253
|
+
const stopped = stopSpawnedExecution(chatSession.session.sessionId);
|
|
254
|
+
if (stopped) {
|
|
255
|
+
await this.log({
|
|
256
|
+
timestamp: new Date().toISOString(),
|
|
257
|
+
remoteSessionId: chatSession.session.sessionId,
|
|
258
|
+
botId,
|
|
259
|
+
chatId,
|
|
260
|
+
provider: "system",
|
|
261
|
+
direction: "system",
|
|
262
|
+
text: "Active provider execution was stopped by the user.",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return { stopped, sessionPublicId: chatSession.session.publicId };
|
|
266
|
+
}
|
|
267
|
+
async stopSessionRun(sessionId, botId, chatId, reason = "Session execution was stopped.") {
|
|
268
|
+
const session = await this.store.getSession(sessionId);
|
|
269
|
+
if (!session) {
|
|
270
|
+
return { stopped: false };
|
|
271
|
+
}
|
|
272
|
+
const stopped = stopSpawnedExecution(session.sessionId);
|
|
273
|
+
if (stopped) {
|
|
274
|
+
await this.log({
|
|
275
|
+
timestamp: new Date().toISOString(),
|
|
276
|
+
remoteSessionId: session.sessionId,
|
|
277
|
+
botId,
|
|
278
|
+
chatId,
|
|
279
|
+
provider: "system",
|
|
280
|
+
direction: "system",
|
|
281
|
+
text: reason,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return { stopped, sessionPublicId: session.publicId };
|
|
285
|
+
}
|
|
286
|
+
async routeMessage(botId, chatId, message) {
|
|
287
|
+
const chatSession = await this.requireChat(botId, chatId);
|
|
288
|
+
await this.log({
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
remoteSessionId: chatSession.session.sessionId,
|
|
291
|
+
botId,
|
|
292
|
+
chatId,
|
|
293
|
+
provider: "telegram",
|
|
294
|
+
direction: "in",
|
|
295
|
+
text: message,
|
|
296
|
+
});
|
|
297
|
+
return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId));
|
|
298
|
+
}
|
|
299
|
+
async routeSessionMessage(sessionId, message) {
|
|
300
|
+
const session = await this.store.getSession(sessionId);
|
|
301
|
+
if (!session) {
|
|
302
|
+
throw new Error(`Session was not found: ${sessionId}`);
|
|
303
|
+
}
|
|
304
|
+
await this.log({
|
|
305
|
+
timestamp: new Date().toISOString(),
|
|
306
|
+
remoteSessionId: session.sessionId,
|
|
307
|
+
provider: "pc-ui",
|
|
308
|
+
direction: "in",
|
|
309
|
+
text: message,
|
|
310
|
+
});
|
|
311
|
+
return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "pc-ui"));
|
|
312
|
+
}
|
|
313
|
+
async routeSessionMessageForChat(sessionId, botId, chatId, message) {
|
|
314
|
+
const session = await this.store.getSession(sessionId);
|
|
315
|
+
if (!session) {
|
|
316
|
+
throw new Error(`Session was not found: ${sessionId}`);
|
|
317
|
+
}
|
|
318
|
+
await this.log({
|
|
319
|
+
timestamp: new Date().toISOString(),
|
|
320
|
+
remoteSessionId: session.sessionId,
|
|
321
|
+
botId,
|
|
322
|
+
chatId,
|
|
323
|
+
provider: "telegram",
|
|
324
|
+
direction: "in",
|
|
325
|
+
text: message,
|
|
326
|
+
});
|
|
327
|
+
return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId));
|
|
328
|
+
}
|
|
329
|
+
formatStatus(chatSession) {
|
|
330
|
+
if (!chatSession) {
|
|
331
|
+
return "No paired session yet. Use /start, /start codex, /start claude, or /attach ...";
|
|
332
|
+
}
|
|
333
|
+
const { session } = chatSession;
|
|
334
|
+
const codex = session.codex
|
|
335
|
+
? this.describeProviderSession(session.codex)
|
|
336
|
+
: "- codex: not paired";
|
|
337
|
+
const claude = session.claude
|
|
338
|
+
? this.describeProviderSession(session.claude)
|
|
339
|
+
: "- claude: not paired";
|
|
340
|
+
return [
|
|
341
|
+
`session: ${session.publicId}`,
|
|
342
|
+
`bot: ${chatSession.botId ?? chatSession.binding.botId ?? "unknown"}`,
|
|
343
|
+
`chat: ${chatSession.chatId}`,
|
|
344
|
+
`mode: ${session.mode}`,
|
|
345
|
+
`workspace: ${session.workspace}`,
|
|
346
|
+
...this.describeEffectiveAccess(session),
|
|
347
|
+
codex,
|
|
348
|
+
claude,
|
|
349
|
+
`createdAt: ${session.createdAt}`,
|
|
350
|
+
`updatedAt: ${session.updatedAt}`,
|
|
351
|
+
].join("\n");
|
|
352
|
+
}
|
|
353
|
+
formatResponses(responses) {
|
|
354
|
+
return responses.map((response) => {
|
|
355
|
+
const sessionLabel = response.publicSessionId ?? response.sessionId;
|
|
356
|
+
const header = `[${response.provider.toUpperCase()} | ${sessionLabel}]`;
|
|
357
|
+
return `${header}\n${response.output}`;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
formatCurrentSession(chatSession) {
|
|
361
|
+
if (!chatSession) {
|
|
362
|
+
return "No active session is bound to this chat.";
|
|
363
|
+
}
|
|
364
|
+
const { session } = chatSession;
|
|
365
|
+
return [
|
|
366
|
+
`session: ${session.publicId}`,
|
|
367
|
+
`bot: ${chatSession.botId ?? chatSession.binding.botId ?? "unknown"}`,
|
|
368
|
+
`chat: ${chatSession.chatId}`,
|
|
369
|
+
`mode: ${session.mode}`,
|
|
370
|
+
`workspace: ${session.workspace}`,
|
|
371
|
+
...this.describeEffectiveAccess(session),
|
|
372
|
+
`updatedAt: ${session.updatedAt}`,
|
|
373
|
+
].join("\n");
|
|
374
|
+
}
|
|
375
|
+
formatSessionList(sessions, currentSessionId, limit = 10) {
|
|
376
|
+
if (sessions.length === 0) {
|
|
377
|
+
return "No sessions found.";
|
|
378
|
+
}
|
|
379
|
+
const rows = sessions
|
|
380
|
+
.slice(0, limit)
|
|
381
|
+
.map((session, index) => {
|
|
382
|
+
const marker = session.sessionId === currentSessionId ? "*" : " ";
|
|
383
|
+
return `${marker} ${index + 1}. [${session.publicId}] ${this.workspaceLabel(session.workspace)}\n mode: ${session.mode}\n updatedAt: ${session.updatedAt}`;
|
|
384
|
+
});
|
|
385
|
+
return [
|
|
386
|
+
`Sessions (${Math.min(limit, sessions.length)}/${sessions.length})`,
|
|
387
|
+
...rows,
|
|
388
|
+
].join("\n");
|
|
389
|
+
}
|
|
390
|
+
async formatSessionListDetailed(sessions, currentSessionId, activeSessionIds) {
|
|
391
|
+
if (sessions.length === 0) {
|
|
392
|
+
return "No sessions found.";
|
|
393
|
+
}
|
|
394
|
+
const rows = [];
|
|
395
|
+
for (const [index, session] of sessions.entries()) {
|
|
396
|
+
const marker = session.sessionId === currentSessionId ? "*" : " ";
|
|
397
|
+
const status = activeSessionIds.has(session.sessionId) ? "active" : "unused";
|
|
398
|
+
const workspaceSize = await this.workspaceSizeLabel(session);
|
|
399
|
+
rows.push([
|
|
400
|
+
`${marker} ${index + 1}. [${session.publicId}] ${this.workspaceLabel(session.workspace)}`,
|
|
401
|
+
` status: ${status}`,
|
|
402
|
+
` mode: ${session.mode}`,
|
|
403
|
+
` size: ${workspaceSize}`,
|
|
404
|
+
` updatedAt: ${session.updatedAt}`,
|
|
405
|
+
].join("\n"));
|
|
406
|
+
}
|
|
407
|
+
return [
|
|
408
|
+
`Sessions (${sessions.length}/${sessions.length})`,
|
|
409
|
+
...rows,
|
|
410
|
+
].join("\n");
|
|
411
|
+
}
|
|
412
|
+
async resolveSessionSelector(selector, botId) {
|
|
413
|
+
const sessions = await this.store.listSessions(botId);
|
|
414
|
+
const trimmed = selector.trim();
|
|
415
|
+
if (/^\d+$/.test(trimmed)) {
|
|
416
|
+
const index = Number.parseInt(trimmed, 10);
|
|
417
|
+
if (index >= 1 && index <= sessions.length) {
|
|
418
|
+
return sessions[index - 1];
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const normalized = trimmed.toUpperCase();
|
|
422
|
+
return sessions.find((session) => session.publicId.toUpperCase() === normalized || session.sessionId === trimmed);
|
|
423
|
+
}
|
|
424
|
+
resolveProviders(mode) {
|
|
425
|
+
return [mode];
|
|
426
|
+
}
|
|
427
|
+
async requireChat(botId, chatId) {
|
|
428
|
+
const chatSession = await this.store.getChatSession(botId, chatId);
|
|
429
|
+
if (!chatSession) {
|
|
430
|
+
throw new Error("No paired session for this chat yet. Run `/start codex`, `/start claude`, or `/attach ...` first.");
|
|
431
|
+
}
|
|
432
|
+
return chatSession;
|
|
433
|
+
}
|
|
434
|
+
ensurePaired(chatSession, provider) {
|
|
435
|
+
if (!chatSession.session[provider]?.cwd) {
|
|
436
|
+
throw new Error(`This chat is not paired with ${provider}. Run \`/start ${provider}\` or \`/attach ${provider} <session_id>\`.`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
ensureProviderSession(session, provider) {
|
|
440
|
+
const providerSession = session[provider];
|
|
441
|
+
if (!providerSession?.cwd) {
|
|
442
|
+
throw new Error(`Session ${session.sessionId} is not paired with ${provider}.`);
|
|
443
|
+
}
|
|
444
|
+
return providerSession;
|
|
445
|
+
}
|
|
446
|
+
ensureConfigured(provider) {
|
|
447
|
+
if (!this.isProviderAvailable(provider)) {
|
|
448
|
+
throw new Error(this.formatInstallGuidance(provider));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
isProviderAvailable(provider) {
|
|
452
|
+
return this.isProviderInstalled(provider) && Boolean(this.adapters[provider]);
|
|
453
|
+
}
|
|
454
|
+
async log(entry) {
|
|
455
|
+
await this.store.appendLog(entry.remoteSessionId, JSON.stringify(entry));
|
|
456
|
+
}
|
|
457
|
+
async withSessionLock(sessionId, task) {
|
|
458
|
+
const previous = this.sessionLocks.get(sessionId) ?? Promise.resolve();
|
|
459
|
+
let release;
|
|
460
|
+
const current = new Promise((resolve) => {
|
|
461
|
+
release = resolve;
|
|
462
|
+
});
|
|
463
|
+
const queued = previous.catch(() => undefined).then(() => current);
|
|
464
|
+
this.sessionLocks.set(sessionId, queued);
|
|
465
|
+
await previous.catch(() => undefined);
|
|
466
|
+
try {
|
|
467
|
+
return await task();
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
release();
|
|
471
|
+
if (this.sessionLocks.get(sessionId) === queued) {
|
|
472
|
+
this.sessionLocks.delete(sessionId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async routeSession(session, message, requestSource, botId, chatId) {
|
|
477
|
+
const responses = [];
|
|
478
|
+
const providers = this.resolveProviders(session.mode);
|
|
479
|
+
for (const provider of providers) {
|
|
480
|
+
const providerSession = this.ensureProviderSession(session, provider);
|
|
481
|
+
this.ensureConfigured(provider);
|
|
482
|
+
const response = await this.adapters[provider].send({
|
|
483
|
+
botId,
|
|
484
|
+
chatId: chatId ?? requestSource,
|
|
485
|
+
remoteSessionId: session.sessionId,
|
|
486
|
+
publicSessionId: session.publicId,
|
|
487
|
+
cwd: providerSession.cwd,
|
|
488
|
+
sessionId: providerSession.sessionId,
|
|
489
|
+
message,
|
|
490
|
+
model: providerSession.model,
|
|
491
|
+
sandboxMode: providerSession.sandboxMode,
|
|
492
|
+
});
|
|
493
|
+
const updatedProviderSession = {
|
|
494
|
+
...providerSession,
|
|
495
|
+
cwd: response.cwd,
|
|
496
|
+
sessionId: response.sessionId,
|
|
497
|
+
lastUsedAt: new Date().toISOString(),
|
|
498
|
+
};
|
|
499
|
+
const stillBound = chatId
|
|
500
|
+
? await this.store.getChatSession(botId ?? "telegram", chatId)
|
|
501
|
+
: undefined;
|
|
502
|
+
if (chatId && stillBound?.session.sessionId === session.sessionId) {
|
|
503
|
+
await this.store.upsertProviderForChat(botId ?? "telegram", chatId, provider, updatedProviderSession, session.workspace);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
await this.store.upsertProviderForSession(session.sessionId, provider, updatedProviderSession, session.workspace);
|
|
507
|
+
}
|
|
508
|
+
session[provider] = updatedProviderSession;
|
|
509
|
+
await this.log({
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
remoteSessionId: session.sessionId,
|
|
512
|
+
botId,
|
|
513
|
+
chatId,
|
|
514
|
+
provider,
|
|
515
|
+
direction: "out",
|
|
516
|
+
sessionId: response.sessionId,
|
|
517
|
+
text: response.output,
|
|
518
|
+
});
|
|
519
|
+
responses.push({
|
|
520
|
+
...response,
|
|
521
|
+
publicSessionId: session.publicId,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return responses;
|
|
525
|
+
}
|
|
526
|
+
async verifyCodexAttachWorkspace(botId, chatId, providerSessionId, requestedWorkspace, existingSession) {
|
|
527
|
+
const actualWorkspace = await this.readCodexSessionWorkspace(providerSessionId)
|
|
528
|
+
?? await this.probeCodexAttachWorkspace(botId, chatId, providerSessionId, requestedWorkspace, existingSession);
|
|
529
|
+
if (!actualWorkspace) {
|
|
530
|
+
throw new Error("Attach blocked: could not verify the resumed Codex session working directory.");
|
|
531
|
+
}
|
|
532
|
+
if (!this.pathsMatch(actualWorkspace, requestedWorkspace)) {
|
|
533
|
+
throw new Error(`Attach blocked: resumed Codex session workspace is '${actualWorkspace}', not requested '${requestedWorkspace}'.`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async readCodexSessionWorkspace(providerSessionId) {
|
|
537
|
+
const sessionsRoot = path.join(os.homedir(), ".codex", "sessions");
|
|
538
|
+
const sessionFile = await this.findCodexSessionFile(sessionsRoot, providerSessionId);
|
|
539
|
+
if (!sessionFile) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
const raw = await fs.readFile(sessionFile, "utf8").catch(() => undefined);
|
|
543
|
+
if (!raw) {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
const firstLine = raw.split(/\r?\n/, 1)[0]?.trim();
|
|
547
|
+
if (!firstLine) {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const event = JSON.parse(firstLine);
|
|
552
|
+
if (event.type === "session_meta" && typeof event.payload?.cwd === "string" && event.payload.cwd.trim()) {
|
|
553
|
+
return event.payload.cwd.trim();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
async findCodexSessionFile(root, providerSessionId) {
|
|
562
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
563
|
+
for (const entry of entries) {
|
|
564
|
+
const entryPath = path.join(root, entry.name);
|
|
565
|
+
if (entry.isDirectory()) {
|
|
566
|
+
const nested = await this.findCodexSessionFile(entryPath, providerSessionId);
|
|
567
|
+
if (nested) {
|
|
568
|
+
return nested;
|
|
569
|
+
}
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (entry.isFile() && entry.name.endsWith(`${providerSessionId}.jsonl`)) {
|
|
573
|
+
return entryPath;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
async probeCodexAttachWorkspace(botId, chatId, providerSessionId, requestedWorkspace, existingSession) {
|
|
579
|
+
this.ensureConfigured("codex");
|
|
580
|
+
const probe = await this.adapters.codex.send({
|
|
581
|
+
botId,
|
|
582
|
+
chatId,
|
|
583
|
+
remoteSessionId: existingSession?.sessionId ?? providerSessionId,
|
|
584
|
+
cwd: requestedWorkspace,
|
|
585
|
+
sessionId: providerSessionId,
|
|
586
|
+
model: existingSession?.model,
|
|
587
|
+
sandboxMode: existingSession?.sandboxMode,
|
|
588
|
+
message: [
|
|
589
|
+
"Safety check for workspace attach.",
|
|
590
|
+
"Reply with JSON only.",
|
|
591
|
+
"{\"cwd\":\"<current working directory>\"}",
|
|
592
|
+
].join("\n"),
|
|
593
|
+
});
|
|
594
|
+
return this.extractCodexCwd(probe.output);
|
|
595
|
+
}
|
|
596
|
+
extractCodexCwd(output) {
|
|
597
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
598
|
+
if (jsonMatch) {
|
|
599
|
+
try {
|
|
600
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
601
|
+
if (typeof parsed.cwd === "string" && parsed.cwd.trim()) {
|
|
602
|
+
return parsed.cwd.trim();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Fall back to line-based parsing below.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
for (const line of output.split(/\r?\n/)) {
|
|
610
|
+
const trimmed = line.trim();
|
|
611
|
+
if (trimmed.startsWith("cwd:")) {
|
|
612
|
+
return trimmed.slice(4).trim().replace(/^["'`]+|["'`]+$/g, "");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
pathsMatch(left, right) {
|
|
618
|
+
return this.normalizeComparablePath(left) === this.normalizeComparablePath(right);
|
|
619
|
+
}
|
|
620
|
+
normalizeComparablePath(value) {
|
|
621
|
+
const trimmed = value.trim().replace(/^["'`]+|["'`]+$/g, "");
|
|
622
|
+
const slashed = trimmed.replace(/\//g, "\\");
|
|
623
|
+
const lower = slashed.toLowerCase();
|
|
624
|
+
const wslMatch = lower.match(/^\\\\wsl\.localhost\\([^\\]+)\\(.+)$/);
|
|
625
|
+
if (wslMatch) {
|
|
626
|
+
return `/${wslMatch[2].replace(/\\/g, "/")}`;
|
|
627
|
+
}
|
|
628
|
+
const mntMatch = trimmed.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
|
629
|
+
if (mntMatch) {
|
|
630
|
+
return `${mntMatch[1].toLowerCase()}:\\${mntMatch[2].replace(/\//g, "\\").toLowerCase()}`;
|
|
631
|
+
}
|
|
632
|
+
if (/^[a-zA-Z]:\\/.test(trimmed)) {
|
|
633
|
+
return `${trimmed[0].toLowerCase()}:${trimmed.slice(2).replace(/\//g, "\\").toLowerCase()}`;
|
|
634
|
+
}
|
|
635
|
+
if (trimmed.startsWith("/")) {
|
|
636
|
+
return trimmed.replace(/\\/g, "/").toLowerCase();
|
|
637
|
+
}
|
|
638
|
+
return lower;
|
|
639
|
+
}
|
|
640
|
+
describeProviderSession(session) {
|
|
641
|
+
const state = session.sessionId
|
|
642
|
+
? "attached"
|
|
643
|
+
: "pending-first-run";
|
|
644
|
+
const details = [`- ${session.provider}: ${state} @ ${session.cwd}`];
|
|
645
|
+
details.push(` model: ${session.model ?? this.defaultModelFor(session.provider)}`);
|
|
646
|
+
if (session.provider === "codex") {
|
|
647
|
+
details.push(` sandbox: ${this.effectiveCodexSandboxMode(session)}`);
|
|
648
|
+
}
|
|
649
|
+
if (session.lastUsedAt) {
|
|
650
|
+
details.push(` lastUsedAt: ${session.lastUsedAt}`);
|
|
651
|
+
}
|
|
652
|
+
return details.join("\n");
|
|
653
|
+
}
|
|
654
|
+
resolveWorkspace(current, next) {
|
|
655
|
+
return next?.trim() || current || this.defaultWorkspace;
|
|
656
|
+
}
|
|
657
|
+
defaultModelFor(provider) {
|
|
658
|
+
return provider === "codex" ? "gpt-5.5" : "sonnet";
|
|
659
|
+
}
|
|
660
|
+
describeEffectiveAccess(session) {
|
|
661
|
+
if (!session.codex) {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
const sandboxMode = this.effectiveCodexSandboxMode(session.codex);
|
|
665
|
+
return [
|
|
666
|
+
`effectiveSandbox: ${sandboxMode}`,
|
|
667
|
+
`writableRoots: ${this.describeWritableRoots(session.workspace, sandboxMode)}`,
|
|
668
|
+
];
|
|
669
|
+
}
|
|
670
|
+
effectiveCodexSandboxMode(session) {
|
|
671
|
+
return session.sandboxMode ?? this.defaultCodexSandboxMode ?? "read-only";
|
|
672
|
+
}
|
|
673
|
+
describeWritableRoots(workspace, sandboxMode) {
|
|
674
|
+
if (sandboxMode === "danger-full-access") {
|
|
675
|
+
return `unrestricted (session workspace: ${workspace})`;
|
|
676
|
+
}
|
|
677
|
+
if (sandboxMode === "workspace-write") {
|
|
678
|
+
return `${workspace}, /tmp`;
|
|
679
|
+
}
|
|
680
|
+
return "/tmp only";
|
|
681
|
+
}
|
|
682
|
+
describeTelegramContactChat(contact) {
|
|
683
|
+
if (contact.chatType === "private") {
|
|
684
|
+
const parts = [contact.firstName, contact.lastName].filter(Boolean);
|
|
685
|
+
const name = parts.join(" ").trim();
|
|
686
|
+
if (name) {
|
|
687
|
+
return `${name} (${contact.chatId})`;
|
|
688
|
+
}
|
|
689
|
+
if (contact.username) {
|
|
690
|
+
return `@${contact.username} (${contact.chatId})`;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return `${contact.chatType} ${contact.title ? `${contact.title} ` : ""}(${contact.chatId})`.trim();
|
|
694
|
+
}
|
|
695
|
+
resolveSelectableModel(provider, input) {
|
|
696
|
+
const value = input.trim();
|
|
697
|
+
if (!value) {
|
|
698
|
+
return value;
|
|
699
|
+
}
|
|
700
|
+
const presets = MODEL_PRESETS[provider] ?? [];
|
|
701
|
+
if (presets.length === 0) {
|
|
702
|
+
return value;
|
|
703
|
+
}
|
|
704
|
+
if (/^\d+$/.test(value)) {
|
|
705
|
+
const index = Number.parseInt(value, 10);
|
|
706
|
+
if (index >= 1 && index <= presets.length) {
|
|
707
|
+
return presets[index - 1];
|
|
708
|
+
}
|
|
709
|
+
throw new Error(this.formatUnknownModel(provider, value, presets));
|
|
710
|
+
}
|
|
711
|
+
const matched = presets.find((item) => item.toLowerCase() === value.toLowerCase());
|
|
712
|
+
if (matched) {
|
|
713
|
+
return matched;
|
|
714
|
+
}
|
|
715
|
+
throw new Error(this.formatUnknownModel(provider, value, presets));
|
|
716
|
+
}
|
|
717
|
+
formatUnknownModel(provider, value, presets) {
|
|
718
|
+
return [
|
|
719
|
+
`Unknown ${provider} model: ${value}`,
|
|
720
|
+
"Choose one of:",
|
|
721
|
+
...presets.map((item, index) => ` ${index + 1}. ${item}`),
|
|
722
|
+
"",
|
|
723
|
+
"Use `/model` to see the list again.",
|
|
724
|
+
].join("\n");
|
|
725
|
+
}
|
|
726
|
+
async createManagedWorkspace() {
|
|
727
|
+
await fs.mkdir(this.workspaceRoot, { recursive: true });
|
|
728
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
729
|
+
const workspaceUid = this.generateWorkspaceUid();
|
|
730
|
+
const workspace = path.join(this.workspaceRoot, workspaceUid);
|
|
731
|
+
try {
|
|
732
|
+
await fs.mkdir(workspace);
|
|
733
|
+
return { workspace, workspaceUid };
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
if (error.code === "EEXIST") {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
throw new Error("Could not allocate a managed workspace directory.");
|
|
743
|
+
}
|
|
744
|
+
generateWorkspaceUid() {
|
|
745
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
746
|
+
const bytes = randomBytes(8);
|
|
747
|
+
let value = "";
|
|
748
|
+
for (const byte of bytes) {
|
|
749
|
+
value += alphabet[byte % alphabet.length];
|
|
750
|
+
}
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
753
|
+
workspaceLabel(workspace) {
|
|
754
|
+
const normalized = workspace.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
755
|
+
const parts = normalized.split("/");
|
|
756
|
+
return parts[parts.length - 1] || workspace;
|
|
757
|
+
}
|
|
758
|
+
async workspaceSizeLabel(session) {
|
|
759
|
+
if (!session.workspaceUid || !this.isManagedWorkspace(session.workspace)) {
|
|
760
|
+
return "external";
|
|
761
|
+
}
|
|
762
|
+
const bytes = await this.directorySize(session.workspace).catch(() => undefined);
|
|
763
|
+
return bytes === undefined ? "unknown" : this.formatBytes(bytes);
|
|
764
|
+
}
|
|
765
|
+
isManagedWorkspace(workspace) {
|
|
766
|
+
const relative = path.relative(this.workspaceRoot, workspace);
|
|
767
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
768
|
+
}
|
|
769
|
+
async directorySize(directory) {
|
|
770
|
+
const stat = await fs.stat(directory);
|
|
771
|
+
if (!stat.isDirectory()) {
|
|
772
|
+
return stat.size;
|
|
773
|
+
}
|
|
774
|
+
let total = 0;
|
|
775
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
776
|
+
for (const entry of entries) {
|
|
777
|
+
const entryPath = path.join(directory, entry.name);
|
|
778
|
+
if (entry.isSymbolicLink()) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (entry.isDirectory()) {
|
|
782
|
+
total += await this.directorySize(entryPath);
|
|
783
|
+
}
|
|
784
|
+
else if (entry.isFile()) {
|
|
785
|
+
total += (await fs.stat(entryPath)).size;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return total;
|
|
789
|
+
}
|
|
790
|
+
formatBytes(bytes) {
|
|
791
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
792
|
+
let value = bytes;
|
|
793
|
+
let unitIndex = 0;
|
|
794
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
795
|
+
value /= 1024;
|
|
796
|
+
unitIndex += 1;
|
|
797
|
+
}
|
|
798
|
+
const digits = value >= 10 || unitIndex === 0 ? 0 : 1;
|
|
799
|
+
return `${value.toFixed(digits)}${units[unitIndex]}`;
|
|
800
|
+
}
|
|
801
|
+
async ensureWorkspaceExists(cwd) {
|
|
802
|
+
const stat = await fs.stat(cwd).catch(() => undefined);
|
|
803
|
+
if (!stat?.isDirectory()) {
|
|
804
|
+
throw new Error(`Workspace path does not exist: ${cwd}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|