codex-channel-bgos 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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/adapter.d.ts +96 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +677 -0
- package/dist/adapter.js.map +1 -0
- package/dist/agent-hints.d.ts +20 -0
- package/dist/agent-hints.d.ts.map +1 -0
- package/dist/agent-hints.js +92 -0
- package/dist/agent-hints.js.map +1 -0
- package/dist/agent-identity.d.ts +36 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +42 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/attachment-bridge.d.ts +64 -0
- package/dist/attachment-bridge.d.ts.map +1 -0
- package/dist/attachment-bridge.js +216 -0
- package/dist/attachment-bridge.js.map +1 -0
- package/dist/auth-mode.d.ts +34 -0
- package/dist/auth-mode.d.ts.map +1 -0
- package/dist/auth-mode.js +48 -0
- package/dist/auth-mode.js.map +1 -0
- package/dist/bgos-api.d.ts +191 -0
- package/dist/bgos-api.d.ts.map +1 -0
- package/dist/bgos-api.js +225 -0
- package/dist/bgos-api.js.map +1 -0
- package/dist/bgos-ws.d.ts +94 -0
- package/dist/bgos-ws.d.ts.map +1 -0
- package/dist/bgos-ws.js +310 -0
- package/dist/bgos-ws.js.map +1 -0
- package/dist/capabilities.d.ts +28 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +44 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog-sync.d.ts +26 -0
- package/dist/catalog-sync.d.ts.map +1 -0
- package/dist/catalog-sync.js +24 -0
- package/dist/catalog-sync.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +152 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-host.d.ts +44 -0
- package/dist/codex-host.d.ts.map +1 -0
- package/dist/codex-host.js +142 -0
- package/dist/codex-host.js.map +1 -0
- package/dist/commands-sync.d.ts +24 -0
- package/dist/commands-sync.d.ts.map +1 -0
- package/dist/commands-sync.js +43 -0
- package/dist/commands-sync.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/default-commands.d.ts +27 -0
- package/dist/default-commands.d.ts.map +1 -0
- package/dist/default-commands.js +64 -0
- package/dist/default-commands.js.map +1 -0
- package/dist/event-mapper.d.ts +43 -0
- package/dist/event-mapper.d.ts.map +1 -0
- package/dist/event-mapper.js +127 -0
- package/dist/event-mapper.js.map +1 -0
- package/dist/heartbeat.d.ts +75 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +186 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/inbound-handler.d.ts +167 -0
- package/dist/inbound-handler.d.ts.map +1 -0
- package/dist/inbound-handler.js +282 -0
- package/dist/inbound-handler.js.map +1 -0
- package/dist/inbound-input.d.ts +17 -0
- package/dist/inbound-input.d.ts.map +1 -0
- package/dist/inbound-input.js +25 -0
- package/dist/inbound-input.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/install-service.d.ts +8 -0
- package/dist/install-service.d.ts.map +1 -0
- package/dist/install-service.js +66 -0
- package/dist/install-service.js.map +1 -0
- package/dist/last-id-store.d.ts +33 -0
- package/dist/last-id-store.d.ts.map +1 -0
- package/dist/last-id-store.js +101 -0
- package/dist/last-id-store.js.map +1 -0
- package/dist/load-config.d.ts +30 -0
- package/dist/load-config.d.ts.map +1 -0
- package/dist/load-config.js +86 -0
- package/dist/load-config.js.map +1 -0
- package/dist/media-classify.d.ts +37 -0
- package/dist/media-classify.d.ts.map +1 -0
- package/dist/media-classify.js +108 -0
- package/dist/media-classify.js.map +1 -0
- package/dist/media-guard.d.ts +13 -0
- package/dist/media-guard.d.ts.map +1 -0
- package/dist/media-guard.js +159 -0
- package/dist/media-guard.js.map +1 -0
- package/dist/outbound-retry.d.ts +24 -0
- package/dist/outbound-retry.d.ts.map +1 -0
- package/dist/outbound-retry.js +75 -0
- package/dist/outbound-retry.js.map +1 -0
- package/dist/outbound.d.ts +223 -0
- package/dist/outbound.d.ts.map +1 -0
- package/dist/outbound.js +366 -0
- package/dist/outbound.js.map +1 -0
- package/dist/outbox.d.ts +16 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +88 -0
- package/dist/outbox.js.map +1 -0
- package/dist/pair-cli.d.ts +44 -0
- package/dist/pair-cli.d.ts.map +1 -0
- package/dist/pair-cli.js +182 -0
- package/dist/pair-cli.js.map +1 -0
- package/dist/pending-unknown-store.d.ts +26 -0
- package/dist/pending-unknown-store.d.ts.map +1 -0
- package/dist/pending-unknown-store.js +135 -0
- package/dist/pending-unknown-store.js.map +1 -0
- package/dist/processed-ids.d.ts +36 -0
- package/dist/processed-ids.d.ts.map +1 -0
- package/dist/processed-ids.js +55 -0
- package/dist/processed-ids.js.map +1 -0
- package/dist/reply-markers.d.ts +42 -0
- package/dist/reply-markers.d.ts.map +1 -0
- package/dist/reply-markers.js +124 -0
- package/dist/reply-markers.js.map +1 -0
- package/dist/setup/supervisor.d.ts +16 -0
- package/dist/setup/supervisor.d.ts.map +1 -0
- package/dist/setup/supervisor.js +61 -0
- package/dist/setup/supervisor.js.map +1 -0
- package/dist/thread-map.d.ts +12 -0
- package/dist/thread-map.d.ts.map +1 -0
- package/dist/thread-map.js +72 -0
- package/dist/thread-map.js.map +1 -0
- package/dist/tool-progress.d.ts +103 -0
- package/dist/tool-progress.d.ts.map +1 -0
- package/dist/tool-progress.js +216 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/types.d.ts +244 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +28 -0
- package/dist/version.js.map +1 -0
- package/dist/voice-rpc.d.ts +37 -0
- package/dist/voice-rpc.d.ts.map +1 -0
- package/dist/voice-rpc.js +44 -0
- package/dist/voice-rpc.js.map +1 -0
- package/package.json +47 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main adapter for the Codex <-> BGOS channel.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle mirrors the proven Gobot/OpenClaw shape:
|
|
5
|
+
* 1. Wire WS handlers, connect the socket, start the heartbeat.
|
|
6
|
+
* 2. Resolve identity (whoami) with forever exp-backoff retry; stay UP even if
|
|
7
|
+
* identity is not yet resolvable.
|
|
8
|
+
* 3. Run the initial backfill after the first identity success (cold-start
|
|
9
|
+
* seed), then start the 5s poll loop + the 60s outbox replay loop.
|
|
10
|
+
* 4. Inbound messages flow through inbound-handler.ts into codexDispatch,
|
|
11
|
+
* which drives the Codex SDK and posts the reply + tool_progress.
|
|
12
|
+
* 5. On token revoke/rotate enter a fatal latch and watch the secrets dir to
|
|
13
|
+
* self-recover when a new token is applied.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { BgosApi } from "./bgos-api.js";
|
|
19
|
+
import { BgosWs } from "./bgos-ws.js";
|
|
20
|
+
import { BgosOutbound } from "./outbound.js";
|
|
21
|
+
import { CommandsSync } from "./commands-sync.js";
|
|
22
|
+
import { ToolProgressOrchestrator } from "./tool-progress.js";
|
|
23
|
+
import { HeartbeatController } from "./heartbeat.js";
|
|
24
|
+
import { getPackageVersion } from "./version.js";
|
|
25
|
+
import { syncCatalog } from "./catalog-sync.js";
|
|
26
|
+
import { DEFAULT_COMMANDS, resolveCommandSeedMode, shouldSeedDefaults, } from "./default-commands.js";
|
|
27
|
+
import { buildReplyHandle, createInboundHandler, } from "./inbound-handler.js";
|
|
28
|
+
import { pendingUnknownStats } from "./pending-unknown-store.js";
|
|
29
|
+
import { pickCapabilitiesText } from "./capabilities.js";
|
|
30
|
+
import { CodexHost } from "./codex-host.js";
|
|
31
|
+
import { buildCodexInput } from "./inbound-input.js";
|
|
32
|
+
import { parseReply } from "./reply-markers.js";
|
|
33
|
+
import { PairingRevokedError, } from "./types.js";
|
|
34
|
+
const LOG = "[codex-channel-bgos]";
|
|
35
|
+
export class CodexAdapter {
|
|
36
|
+
api;
|
|
37
|
+
outbound;
|
|
38
|
+
commandsSync;
|
|
39
|
+
toolProgress;
|
|
40
|
+
cfg;
|
|
41
|
+
ws;
|
|
42
|
+
heartbeat;
|
|
43
|
+
host;
|
|
44
|
+
assistantToRoute = new Map();
|
|
45
|
+
lastInput = new Map();
|
|
46
|
+
dispatch;
|
|
47
|
+
catalog;
|
|
48
|
+
commandSeedModeOverride;
|
|
49
|
+
pairingId = null;
|
|
50
|
+
pollTimer = null;
|
|
51
|
+
spoolTimer = null;
|
|
52
|
+
started = false;
|
|
53
|
+
onFatal;
|
|
54
|
+
identityReady = false;
|
|
55
|
+
capabilitiesLoaded = false;
|
|
56
|
+
identityRetryTimer = null;
|
|
57
|
+
pollStarted = false;
|
|
58
|
+
lastScopeRefreshAt = 0;
|
|
59
|
+
scopeRefreshInFlight = null;
|
|
60
|
+
currentToken;
|
|
61
|
+
fatalLatched = false;
|
|
62
|
+
fatalNotified = false;
|
|
63
|
+
last401LogAt = 0;
|
|
64
|
+
secretsWatcher = null;
|
|
65
|
+
secretsStatTimer = null;
|
|
66
|
+
secretsDebounce = null;
|
|
67
|
+
constructor(cfg, auth, opts = {}) {
|
|
68
|
+
this.cfg = cfg;
|
|
69
|
+
this.currentToken = cfg.pairingToken;
|
|
70
|
+
this.api = new BgosApi(cfg);
|
|
71
|
+
this.ws = new BgosWs(cfg, this.api);
|
|
72
|
+
this.outbound = new BgosOutbound(this.api);
|
|
73
|
+
this.commandsSync = new CommandsSync(this.api);
|
|
74
|
+
this.toolProgress = new ToolProgressOrchestrator(this.api);
|
|
75
|
+
this.host = new CodexHost({ auth, model: opts.model });
|
|
76
|
+
this.heartbeat = new HeartbeatController({
|
|
77
|
+
version: getPackageVersion(),
|
|
78
|
+
authMode: auth.mode,
|
|
79
|
+
postHeartbeat: (body) => this.api.postHeartbeat(body),
|
|
80
|
+
});
|
|
81
|
+
this.catalog =
|
|
82
|
+
opts.agents ?? [{ route: "codex", name: "Codex" }];
|
|
83
|
+
this.commandSeedModeOverride = opts.commandSeedMode ?? null;
|
|
84
|
+
this.onFatal = opts.onFatal;
|
|
85
|
+
this.outbound.setTypingEmitter((p) => this.ws.emitTyping(p));
|
|
86
|
+
this.outbound.setOutboundReporter(() => this.heartbeat.recordOutbound());
|
|
87
|
+
this.outbound.setLastErrorReporter((code, message) => this.heartbeat.setLastError({
|
|
88
|
+
code,
|
|
89
|
+
message,
|
|
90
|
+
at: new Date().toISOString(),
|
|
91
|
+
}));
|
|
92
|
+
this.dispatch = (args) => this.codexDispatch(args);
|
|
93
|
+
}
|
|
94
|
+
get authMode() {
|
|
95
|
+
return this.host.authMode;
|
|
96
|
+
}
|
|
97
|
+
getCommandSeedMode() {
|
|
98
|
+
return this.commandSeedModeOverride ?? resolveCommandSeedMode();
|
|
99
|
+
}
|
|
100
|
+
getRouteForAssistant(assistantId) {
|
|
101
|
+
return this.assistantToRoute.get(assistantId) ?? null;
|
|
102
|
+
}
|
|
103
|
+
// -------------------------------------------------------------------
|
|
104
|
+
// Lifecycle
|
|
105
|
+
// -------------------------------------------------------------------
|
|
106
|
+
async start() {
|
|
107
|
+
if (this.started)
|
|
108
|
+
return;
|
|
109
|
+
this.started = true;
|
|
110
|
+
const inboundHandler = createInboundHandler({
|
|
111
|
+
outbound: this.outbound,
|
|
112
|
+
getRouteForAssistant: (id) => this.getRouteForAssistant(id),
|
|
113
|
+
getDispatch: () => this.dispatch,
|
|
114
|
+
getSystemPrompt: () => "",
|
|
115
|
+
toolProgress: this.toolProgress,
|
|
116
|
+
onInbound: () => this.heartbeat.recordInbound(),
|
|
117
|
+
onUnknownAssistant: () => this.refreshScopeRateLimited(),
|
|
118
|
+
});
|
|
119
|
+
this.ws.on("inbound_message", (msg) => {
|
|
120
|
+
void inboundHandler(msg);
|
|
121
|
+
});
|
|
122
|
+
this.ws.on("inbound_click", (click) => this.handleInboundClick(click));
|
|
123
|
+
this.ws.on("assistant_bound", (p) => {
|
|
124
|
+
this.assistantToRoute.set(p.assistantId, p.agentRoute);
|
|
125
|
+
void this.seedDefaultCommands(p.assistantId, "bind");
|
|
126
|
+
});
|
|
127
|
+
this.ws.on("assistant_unbound", (p) => {
|
|
128
|
+
this.assistantToRoute.delete(p.assistantId);
|
|
129
|
+
});
|
|
130
|
+
this.ws.on("commands_updated", () => {
|
|
131
|
+
// No-op: user changed commands via the BGOS UI.
|
|
132
|
+
});
|
|
133
|
+
this.ws.on("pairing_revoked", (p) => {
|
|
134
|
+
const reason = p.reason === "rotated" ? "rotated" : "revoked";
|
|
135
|
+
const message = reason === "rotated" ? "Pairing token rotated" : "Pairing revoked";
|
|
136
|
+
this.enterFatalLatch(reason, message);
|
|
137
|
+
});
|
|
138
|
+
this.ws.on("error", (err) => this.handleWsError(err));
|
|
139
|
+
this.ws.on("connect", () => {
|
|
140
|
+
this.heartbeat.setWsConnected(true, this.ws.connectedSince);
|
|
141
|
+
});
|
|
142
|
+
this.ws.on("disconnect", () => {
|
|
143
|
+
this.heartbeat.setWsConnected(false, null);
|
|
144
|
+
});
|
|
145
|
+
this.ws.on("reconnect", () => {
|
|
146
|
+
void this.onReconnect();
|
|
147
|
+
});
|
|
148
|
+
this.ws.on("backfill_ok", () => {
|
|
149
|
+
const code = this.heartbeat.getLastErrorCode();
|
|
150
|
+
if (code === "backfill_failed" || code === "backfill_storm_skipped") {
|
|
151
|
+
this.heartbeat.setLastError(null);
|
|
152
|
+
}
|
|
153
|
+
this.reconcilePendingUnknownError();
|
|
154
|
+
});
|
|
155
|
+
this.ws.on("backfill_error", (err) => {
|
|
156
|
+
this.heartbeat.setLastError({
|
|
157
|
+
code: "backfill_failed",
|
|
158
|
+
message: err instanceof Error ? err.message : String(err),
|
|
159
|
+
at: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
this.ws.on("backfill_storm", (count) => {
|
|
163
|
+
this.heartbeat.setLastError({
|
|
164
|
+
code: "backfill_storm_skipped",
|
|
165
|
+
message: `skipped ${count} messages`,
|
|
166
|
+
at: new Date().toISOString(),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
await this.ws.connect();
|
|
170
|
+
this.heartbeat.start();
|
|
171
|
+
// Fetch the served capability canon once at connect and inject it into the
|
|
172
|
+
// agent's AGENTS.md (best-effort; falls back to the bundled copy).
|
|
173
|
+
void this.loadServedCapabilities();
|
|
174
|
+
const ok = await this.refreshIdentity();
|
|
175
|
+
if (ok) {
|
|
176
|
+
this.identityReady = true;
|
|
177
|
+
await this.ws.triggerBackfill({ initial: true });
|
|
178
|
+
this.startPollLoop();
|
|
179
|
+
}
|
|
180
|
+
else if (!this.fatalLatched) {
|
|
181
|
+
this.scheduleIdentityRetry(1000);
|
|
182
|
+
}
|
|
183
|
+
this.spoolTimer = setInterval(() => {
|
|
184
|
+
void this.outbound.replaySpool();
|
|
185
|
+
}, 60_000);
|
|
186
|
+
this.spoolTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
async stop() {
|
|
189
|
+
if (!this.started)
|
|
190
|
+
return;
|
|
191
|
+
this.started = false;
|
|
192
|
+
this.stopPollLoop();
|
|
193
|
+
if (this.spoolTimer !== null) {
|
|
194
|
+
clearInterval(this.spoolTimer);
|
|
195
|
+
this.spoolTimer = null;
|
|
196
|
+
}
|
|
197
|
+
if (this.identityRetryTimer !== null) {
|
|
198
|
+
clearTimeout(this.identityRetryTimer);
|
|
199
|
+
this.identityRetryTimer = null;
|
|
200
|
+
}
|
|
201
|
+
this.stopSecretsWatch();
|
|
202
|
+
this.heartbeat.stop();
|
|
203
|
+
this.ws.disconnect();
|
|
204
|
+
this.toolProgress.dispose();
|
|
205
|
+
try {
|
|
206
|
+
await this.commandsSync.flushAll();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* best-effort on shutdown */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// -------------------------------------------------------------------
|
|
213
|
+
// Capability bootstrap (fetch-on-connect)
|
|
214
|
+
// -------------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* Fetch the served capability canon once and inject it into the Codex agent's
|
|
217
|
+
* AGENTS.md, replacing the bundled fallback the host wrote at construction.
|
|
218
|
+
* Never throws: any failure (network, 401, 404 on an old backend, malformed
|
|
219
|
+
* body) leaves the bundled copy in place so the daemon is never blocked on
|
|
220
|
+
* this. Runs once per process (start() is guarded), i.e. once at connect.
|
|
221
|
+
*/
|
|
222
|
+
async loadServedCapabilities() {
|
|
223
|
+
if (this.capabilitiesLoaded)
|
|
224
|
+
return;
|
|
225
|
+
this.capabilitiesLoaded = true;
|
|
226
|
+
try {
|
|
227
|
+
const served = await this.api.getCapabilities("codex");
|
|
228
|
+
const picked = pickCapabilitiesText(served);
|
|
229
|
+
if (picked.source === "backend") {
|
|
230
|
+
this.host.applyAgentHints(picked.text);
|
|
231
|
+
// eslint-disable-next-line no-console
|
|
232
|
+
console.log(`${LOG} capability canon applied version=${served.version} chars=${picked.text.length} source=backend`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.warn(`${LOG} served capability canon malformed; keeping bundled fallback`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.warn(`${LOG} capability canon fetch failed; keeping bundled fallback:`, err instanceof Error ? err.message : String(err));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// -------------------------------------------------------------------
|
|
245
|
+
// Codex dispatch
|
|
246
|
+
// -------------------------------------------------------------------
|
|
247
|
+
statusLine() {
|
|
248
|
+
const wsState = this.ws.connectedSince ? "connected" : "connecting";
|
|
249
|
+
return (`Codex daemon online (v${getPackageVersion()}).\n` +
|
|
250
|
+
`Auth: ${this.authMode === "chatgpt" ? "codex login" : "OPENAI_API_KEY"}. ` +
|
|
251
|
+
`Link: ${wsState}. Workspace: ${this.host.workdir}.`);
|
|
252
|
+
}
|
|
253
|
+
async codexDispatch(args) {
|
|
254
|
+
const { chatId, assistantId, command, replyHandle } = args;
|
|
255
|
+
if (command && (command.name === "new" || command.name === "retry" || command.name === "status")) {
|
|
256
|
+
if (command.name === "new") {
|
|
257
|
+
this.host.resetChat(chatId);
|
|
258
|
+
this.lastInput.delete(chatId);
|
|
259
|
+
await replyHandle
|
|
260
|
+
.sendText("Started a fresh conversation. This chat's Codex thread was reset.")
|
|
261
|
+
.catch(() => { });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (command.name === "status") {
|
|
265
|
+
await replyHandle.sendText(this.statusLine()).catch(() => { });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// retry
|
|
269
|
+
const prev = this.lastInput.get(chatId);
|
|
270
|
+
if (prev === undefined) {
|
|
271
|
+
await replyHandle.sendText("Nothing to retry yet.").catch(() => { });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await this.runAndReply(assistantId, chatId, prev, replyHandle);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const files = args.attachments.map((a) => ({
|
|
278
|
+
path: a.localPath,
|
|
279
|
+
mime: a.mimeType,
|
|
280
|
+
name: a.fileName,
|
|
281
|
+
isImage: a.kind === "photo",
|
|
282
|
+
}));
|
|
283
|
+
// A native slash command (not bridge-local) is forwarded to Codex as text.
|
|
284
|
+
const text = command
|
|
285
|
+
? `/${command.name}${command.args ? ` ${command.args}` : ""}`
|
|
286
|
+
: args.text;
|
|
287
|
+
const input = buildCodexInput(text, files);
|
|
288
|
+
this.lastInput.set(chatId, input);
|
|
289
|
+
await this.runAndReply(assistantId, chatId, input, replyHandle);
|
|
290
|
+
}
|
|
291
|
+
async runAndReply(assistantId, chatId, input, replyHandle) {
|
|
292
|
+
await replyHandle.sendTyping().catch(() => { });
|
|
293
|
+
const startedAt = Date.now();
|
|
294
|
+
let toolCount = 0;
|
|
295
|
+
const result = await this.host.runTurn(chatId, input, {
|
|
296
|
+
onTool: (card) => {
|
|
297
|
+
toolCount += 1;
|
|
298
|
+
void replyHandle.sendToolStart(card.name, card.args).catch(() => { });
|
|
299
|
+
},
|
|
300
|
+
onTick: () => {
|
|
301
|
+
void replyHandle.sendTyping().catch(() => { });
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
console.log(`${LOG} codex turn done`, {
|
|
306
|
+
chatId,
|
|
307
|
+
assistantId,
|
|
308
|
+
tools: toolCount,
|
|
309
|
+
chars: result.replyText.length,
|
|
310
|
+
error: result.error ?? undefined,
|
|
311
|
+
ms: Date.now() - startedAt,
|
|
312
|
+
});
|
|
313
|
+
if (result.error && !result.replyText.trim()) {
|
|
314
|
+
await replyHandle.finalizeTurn().catch(() => { });
|
|
315
|
+
await this.outbound
|
|
316
|
+
.sendAgentError({ assistantId, chatId, reason: result.error })
|
|
317
|
+
.catch(() => { });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const parsed = parseReply(result.replyText);
|
|
321
|
+
if (parsed.status) {
|
|
322
|
+
await this.api
|
|
323
|
+
.setStatus(assistantId, { statusText: parsed.status.text || null })
|
|
324
|
+
.catch(() => { });
|
|
325
|
+
}
|
|
326
|
+
const body = parsed.cleanText;
|
|
327
|
+
if (parsed.buttons) {
|
|
328
|
+
await replyHandle
|
|
329
|
+
.sendButtons(body || "Choose an option:", parsed.buttons.options)
|
|
330
|
+
.catch(() => { });
|
|
331
|
+
}
|
|
332
|
+
else if (parsed.ask) {
|
|
333
|
+
if (body)
|
|
334
|
+
await replyHandle.sendText(body).catch(() => { });
|
|
335
|
+
for (const q of parsed.ask.questions) {
|
|
336
|
+
await replyHandle.sendAskUserInput(q.text, q.options, true).catch(() => { });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (body) {
|
|
340
|
+
await replyHandle.sendText(body).catch(() => { });
|
|
341
|
+
}
|
|
342
|
+
else if (parsed.media.length === 0) {
|
|
343
|
+
await replyHandle
|
|
344
|
+
.sendText("(Codex finished the turn without a text reply.)")
|
|
345
|
+
.catch(() => { });
|
|
346
|
+
}
|
|
347
|
+
for (const path of parsed.media) {
|
|
348
|
+
await replyHandle.sendFile(path).catch(() => { });
|
|
349
|
+
}
|
|
350
|
+
await replyHandle.finalizeTurn().catch(() => { });
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A user tapped an inline button or answered an ask question. Feed the choice
|
|
354
|
+
* back to Codex as the next turn (the thread keeps context), so the agent
|
|
355
|
+
* continues naturally. Correlate by (assistantId, chatId).
|
|
356
|
+
*/
|
|
357
|
+
handleInboundClick(click) {
|
|
358
|
+
const route = this.getRouteForAssistant(click.assistantId);
|
|
359
|
+
if (!route)
|
|
360
|
+
return;
|
|
361
|
+
const replyHandle = buildReplyHandle({ outbound: this.outbound, toolProgress: this.toolProgress }, { assistantId: click.assistantId, chatId: click.chatId });
|
|
362
|
+
const choice = (click.callbackData ?? "").trim();
|
|
363
|
+
if (!choice || choice === "__skip__")
|
|
364
|
+
return;
|
|
365
|
+
const text = choice === "__custom__" ? "" : `The user selected: ${choice}`;
|
|
366
|
+
if (!text)
|
|
367
|
+
return;
|
|
368
|
+
const input = buildCodexInput(text, []);
|
|
369
|
+
this.lastInput.set(click.chatId, input);
|
|
370
|
+
void this.runAndReply(click.assistantId, click.chatId, input, replyHandle);
|
|
371
|
+
}
|
|
372
|
+
// -------------------------------------------------------------------
|
|
373
|
+
// Identity
|
|
374
|
+
// -------------------------------------------------------------------
|
|
375
|
+
async refreshIdentity() {
|
|
376
|
+
try {
|
|
377
|
+
const me = await this.api.whoami();
|
|
378
|
+
this.pairingId = me.pairing_id;
|
|
379
|
+
this.heartbeat.setPairingId(this.pairingId);
|
|
380
|
+
const seedMode = this.getCommandSeedMode();
|
|
381
|
+
const emptyManifestAssistantIds = [];
|
|
382
|
+
for (const a of me.assistants ?? []) {
|
|
383
|
+
if (a.agent_route)
|
|
384
|
+
this.assistantToRoute.set(a.assistant_id, a.agent_route);
|
|
385
|
+
if (shouldSeedDefaults(a.command_count, seedMode)) {
|
|
386
|
+
emptyManifestAssistantIds.push(a.assistant_id);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (this.pairingId !== null && this.catalog.length > 0) {
|
|
390
|
+
await syncCatalog(this.api, this.pairingId, this.catalog);
|
|
391
|
+
}
|
|
392
|
+
for (const id of emptyManifestAssistantIds) {
|
|
393
|
+
await this.seedDefaultCommands(id, "startup");
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (err instanceof PairingRevokedError) {
|
|
399
|
+
this.enterFatalLatch("revoked", err.message);
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
// eslint-disable-next-line no-console
|
|
403
|
+
console.warn(`${LOG} refreshIdentity failed (will retry):`, err instanceof Error ? err.message : String(err));
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
scheduleIdentityRetry(delayMs) {
|
|
408
|
+
if (this.fatalLatched || !this.started)
|
|
409
|
+
return;
|
|
410
|
+
const jitter = Math.floor((delayMs % 7) * 30);
|
|
411
|
+
this.identityRetryTimer = setTimeout(() => {
|
|
412
|
+
void (async () => {
|
|
413
|
+
if (this.fatalLatched || !this.started)
|
|
414
|
+
return;
|
|
415
|
+
const ok = await this.refreshIdentity();
|
|
416
|
+
if (ok) {
|
|
417
|
+
this.identityReady = true;
|
|
418
|
+
await this.ws.triggerBackfill({ initial: true });
|
|
419
|
+
this.startPollLoop();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (this.fatalLatched)
|
|
423
|
+
return;
|
|
424
|
+
this.scheduleIdentityRetry(Math.min(delayMs * 2, 60_000));
|
|
425
|
+
})();
|
|
426
|
+
}, delayMs + jitter);
|
|
427
|
+
this.identityRetryTimer.unref?.();
|
|
428
|
+
}
|
|
429
|
+
refreshScopeRateLimited() {
|
|
430
|
+
if (this.scopeRefreshInFlight)
|
|
431
|
+
return this.scopeRefreshInFlight;
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
if (now - this.lastScopeRefreshAt < 10_000)
|
|
434
|
+
return Promise.resolve();
|
|
435
|
+
this.lastScopeRefreshAt = now;
|
|
436
|
+
this.scopeRefreshInFlight = this.refreshIdentity()
|
|
437
|
+
.then(() => { })
|
|
438
|
+
.finally(() => {
|
|
439
|
+
this.scopeRefreshInFlight = null;
|
|
440
|
+
});
|
|
441
|
+
return this.scopeRefreshInFlight;
|
|
442
|
+
}
|
|
443
|
+
reconcilePendingUnknownError() {
|
|
444
|
+
const STUCK_MS = 5 * 60_000;
|
|
445
|
+
const stats = pendingUnknownStats();
|
|
446
|
+
const code = this.heartbeat.getLastErrorCode();
|
|
447
|
+
if (stats.count === 0) {
|
|
448
|
+
if (code === "pending_unknown_stuck")
|
|
449
|
+
this.heartbeat.setLastError(null);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (stats.oldestAt !== null && Date.now() - stats.oldestAt >= STUCK_MS) {
|
|
453
|
+
if (code === null || code === "pending_unknown_stuck") {
|
|
454
|
+
this.heartbeat.setLastError({
|
|
455
|
+
code: "pending_unknown_stuck",
|
|
456
|
+
message: `${stats.count} inbound message(s) await an unbound assistant`,
|
|
457
|
+
at: new Date().toISOString(),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// -------------------------------------------------------------------
|
|
463
|
+
// Poll loop
|
|
464
|
+
// -------------------------------------------------------------------
|
|
465
|
+
startPollLoop() {
|
|
466
|
+
if (this.pollStarted || this.fatalLatched)
|
|
467
|
+
return;
|
|
468
|
+
const raw = process.env.CODEX_BGOS_POLL_INTERVAL;
|
|
469
|
+
const interval = raw !== undefined && raw !== "" ? Number(raw) : 5;
|
|
470
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
471
|
+
return;
|
|
472
|
+
this.pollStarted = true;
|
|
473
|
+
this.pollTimer = setInterval(() => {
|
|
474
|
+
void this.ws.triggerBackfill();
|
|
475
|
+
}, interval * 1000);
|
|
476
|
+
this.pollTimer.unref?.();
|
|
477
|
+
}
|
|
478
|
+
stopPollLoop() {
|
|
479
|
+
this.pollStarted = false;
|
|
480
|
+
if (this.pollTimer !== null) {
|
|
481
|
+
clearInterval(this.pollTimer);
|
|
482
|
+
this.pollTimer = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async onReconnect() {
|
|
486
|
+
if (this.fatalLatched)
|
|
487
|
+
return;
|
|
488
|
+
await this.refreshIdentity();
|
|
489
|
+
await this.ws.triggerBackfill();
|
|
490
|
+
await this.outbound.replaySpool();
|
|
491
|
+
}
|
|
492
|
+
// -------------------------------------------------------------------
|
|
493
|
+
// Fatal latch + re-pair recovery
|
|
494
|
+
// -------------------------------------------------------------------
|
|
495
|
+
handleWsError(err) {
|
|
496
|
+
if (err instanceof PairingRevokedError) {
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
if (now - this.last401LogAt >= 60_000) {
|
|
499
|
+
this.last401LogAt = now;
|
|
500
|
+
// eslint-disable-next-line no-console
|
|
501
|
+
console.error(`${LOG} pairing rejected (401):`, err.message);
|
|
502
|
+
}
|
|
503
|
+
this.enterFatalLatch("revoked", err.message);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// eslint-disable-next-line no-console
|
|
507
|
+
console.warn(`${LOG} WS error:`, err instanceof Error ? err.message : String(err));
|
|
508
|
+
}
|
|
509
|
+
enterFatalLatch(reason, message) {
|
|
510
|
+
if (this.fatalLatched)
|
|
511
|
+
return;
|
|
512
|
+
this.fatalLatched = true;
|
|
513
|
+
const code = reason === "rotated" ? "token_rotated" : "pairing_revoked";
|
|
514
|
+
this.stopPollLoop();
|
|
515
|
+
if (this.identityRetryTimer !== null) {
|
|
516
|
+
clearTimeout(this.identityRetryTimer);
|
|
517
|
+
this.identityRetryTimer = null;
|
|
518
|
+
}
|
|
519
|
+
this.heartbeat.setNetEnabled(false);
|
|
520
|
+
this.heartbeat.setLastError({ code, message, at: new Date().toISOString() });
|
|
521
|
+
this.ws.disconnect();
|
|
522
|
+
this.heartbeat.setWsConnected(false, null);
|
|
523
|
+
this.startSecretsWatch();
|
|
524
|
+
if (!this.fatalNotified) {
|
|
525
|
+
this.fatalNotified = true;
|
|
526
|
+
try {
|
|
527
|
+
this.onFatal?.({ code, message, reason });
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
/* best-effort */
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
resolveSecretsDir() {
|
|
535
|
+
const fromEnv = process.env.CODEX_BGOS_HOME?.trim();
|
|
536
|
+
let root;
|
|
537
|
+
if (fromEnv) {
|
|
538
|
+
root = fromEnv.startsWith("~") ? join(homedir(), fromEnv.slice(1)) : fromEnv;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
root = join(homedir(), ".codex-bgos");
|
|
542
|
+
}
|
|
543
|
+
return join(root, "secrets");
|
|
544
|
+
}
|
|
545
|
+
readSecretsFile() {
|
|
546
|
+
try {
|
|
547
|
+
const path = join(this.resolveSecretsDir(), "bgos.json");
|
|
548
|
+
const raw = readFileSync(path, "utf8");
|
|
549
|
+
return JSON.parse(raw);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
armSecretsWatch() {
|
|
556
|
+
const dir = this.resolveSecretsDir();
|
|
557
|
+
if (!existsSync(dir))
|
|
558
|
+
return;
|
|
559
|
+
try {
|
|
560
|
+
this.secretsWatcher = watch(dir, () => {
|
|
561
|
+
if (this.secretsDebounce !== null)
|
|
562
|
+
return;
|
|
563
|
+
this.secretsDebounce = setTimeout(() => {
|
|
564
|
+
this.secretsDebounce = null;
|
|
565
|
+
this.rearmSecretsWatch();
|
|
566
|
+
void this.checkSecretsForChange();
|
|
567
|
+
}, 300);
|
|
568
|
+
this.secretsDebounce.unref?.();
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
/* stat-poll fallback still covers us */
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
startSecretsWatch() {
|
|
576
|
+
if (this.secretsWatcher !== null || this.secretsStatTimer !== null)
|
|
577
|
+
return;
|
|
578
|
+
this.armSecretsWatch();
|
|
579
|
+
this.secretsStatTimer = setInterval(() => {
|
|
580
|
+
void this.checkSecretsForChange();
|
|
581
|
+
}, 60_000);
|
|
582
|
+
this.secretsStatTimer.unref?.();
|
|
583
|
+
}
|
|
584
|
+
rearmSecretsWatch() {
|
|
585
|
+
if (this.secretsWatcher === null)
|
|
586
|
+
return;
|
|
587
|
+
try {
|
|
588
|
+
this.secretsWatcher.close();
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* ignore */
|
|
592
|
+
}
|
|
593
|
+
this.secretsWatcher = null;
|
|
594
|
+
this.armSecretsWatch();
|
|
595
|
+
}
|
|
596
|
+
stopSecretsWatch() {
|
|
597
|
+
if (this.secretsWatcher !== null) {
|
|
598
|
+
try {
|
|
599
|
+
this.secretsWatcher.close();
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
/* ignore */
|
|
603
|
+
}
|
|
604
|
+
this.secretsWatcher = null;
|
|
605
|
+
}
|
|
606
|
+
if (this.secretsStatTimer !== null) {
|
|
607
|
+
clearInterval(this.secretsStatTimer);
|
|
608
|
+
this.secretsStatTimer = null;
|
|
609
|
+
}
|
|
610
|
+
if (this.secretsDebounce !== null) {
|
|
611
|
+
clearTimeout(this.secretsDebounce);
|
|
612
|
+
this.secretsDebounce = null;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async checkSecretsForChange() {
|
|
616
|
+
if (!this.fatalLatched)
|
|
617
|
+
return;
|
|
618
|
+
const secrets = this.readSecretsFile();
|
|
619
|
+
if (!secrets || !secrets.pairingToken)
|
|
620
|
+
return;
|
|
621
|
+
if (secrets.pairingToken.length < 20)
|
|
622
|
+
return;
|
|
623
|
+
if (secrets.pairingToken === this.currentToken)
|
|
624
|
+
return;
|
|
625
|
+
await this.recover(secrets.pairingToken, secrets.baseUrl);
|
|
626
|
+
}
|
|
627
|
+
async recover(newToken, newBaseUrl) {
|
|
628
|
+
// eslint-disable-next-line no-console
|
|
629
|
+
console.error(`${LOG} re-pair detected, applying new token`);
|
|
630
|
+
this.currentToken = newToken;
|
|
631
|
+
this.api.updateToken(newToken, newBaseUrl);
|
|
632
|
+
this.ws.updateToken(newToken, newBaseUrl);
|
|
633
|
+
this.fatalLatched = false;
|
|
634
|
+
this.fatalNotified = false;
|
|
635
|
+
this.heartbeat.setNetEnabled(true);
|
|
636
|
+
this.heartbeat.setLastError(null);
|
|
637
|
+
this.stopSecretsWatch();
|
|
638
|
+
this.ws.disconnect();
|
|
639
|
+
try {
|
|
640
|
+
await this.ws.connect();
|
|
641
|
+
const ok = await this.refreshIdentity();
|
|
642
|
+
if (ok) {
|
|
643
|
+
this.identityReady = true;
|
|
644
|
+
await this.ws.triggerBackfill();
|
|
645
|
+
}
|
|
646
|
+
this.startPollLoop();
|
|
647
|
+
await this.outbound.replaySpool();
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
// eslint-disable-next-line no-console
|
|
651
|
+
console.warn(`${LOG} recovery reconnect failed:`, err instanceof Error ? err.message : String(err));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// -------------------------------------------------------------------
|
|
655
|
+
// Commands
|
|
656
|
+
// -------------------------------------------------------------------
|
|
657
|
+
async seedDefaultCommands(assistantId, origin) {
|
|
658
|
+
try {
|
|
659
|
+
await this.api.putCommands(assistantId, [...DEFAULT_COMMANDS]);
|
|
660
|
+
// eslint-disable-next-line no-console
|
|
661
|
+
console.log(`${LOG} seeded default commands`, {
|
|
662
|
+
assistantId,
|
|
663
|
+
origin,
|
|
664
|
+
count: DEFAULT_COMMANDS.length,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
// eslint-disable-next-line no-console
|
|
669
|
+
console.warn(`${LOG} default command seed failed (non-fatal):`, {
|
|
670
|
+
assistantId,
|
|
671
|
+
origin,
|
|
672
|
+
error: err instanceof Error ? err.message : String(err),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
//# sourceMappingURL=adapter.js.map
|