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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/adapter.d.ts +96 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +677 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/agent-hints.d.ts +20 -0
  8. package/dist/agent-hints.d.ts.map +1 -0
  9. package/dist/agent-hints.js +92 -0
  10. package/dist/agent-hints.js.map +1 -0
  11. package/dist/agent-identity.d.ts +36 -0
  12. package/dist/agent-identity.d.ts.map +1 -0
  13. package/dist/agent-identity.js +42 -0
  14. package/dist/agent-identity.js.map +1 -0
  15. package/dist/attachment-bridge.d.ts +64 -0
  16. package/dist/attachment-bridge.d.ts.map +1 -0
  17. package/dist/attachment-bridge.js +216 -0
  18. package/dist/attachment-bridge.js.map +1 -0
  19. package/dist/auth-mode.d.ts +34 -0
  20. package/dist/auth-mode.d.ts.map +1 -0
  21. package/dist/auth-mode.js +48 -0
  22. package/dist/auth-mode.js.map +1 -0
  23. package/dist/bgos-api.d.ts +191 -0
  24. package/dist/bgos-api.d.ts.map +1 -0
  25. package/dist/bgos-api.js +225 -0
  26. package/dist/bgos-api.js.map +1 -0
  27. package/dist/bgos-ws.d.ts +94 -0
  28. package/dist/bgos-ws.d.ts.map +1 -0
  29. package/dist/bgos-ws.js +310 -0
  30. package/dist/bgos-ws.js.map +1 -0
  31. package/dist/capabilities.d.ts +28 -0
  32. package/dist/capabilities.d.ts.map +1 -0
  33. package/dist/capabilities.js +44 -0
  34. package/dist/capabilities.js.map +1 -0
  35. package/dist/catalog-sync.d.ts +26 -0
  36. package/dist/catalog-sync.d.ts.map +1 -0
  37. package/dist/catalog-sync.js +24 -0
  38. package/dist/catalog-sync.js.map +1 -0
  39. package/dist/cli.d.ts +3 -0
  40. package/dist/cli.d.ts.map +1 -0
  41. package/dist/cli.js +152 -0
  42. package/dist/cli.js.map +1 -0
  43. package/dist/codex-host.d.ts +44 -0
  44. package/dist/codex-host.d.ts.map +1 -0
  45. package/dist/codex-host.js +142 -0
  46. package/dist/codex-host.js.map +1 -0
  47. package/dist/commands-sync.d.ts +24 -0
  48. package/dist/commands-sync.d.ts.map +1 -0
  49. package/dist/commands-sync.js +43 -0
  50. package/dist/commands-sync.js.map +1 -0
  51. package/dist/config.d.ts +4 -0
  52. package/dist/config.d.ts.map +1 -0
  53. package/dist/config.js +36 -0
  54. package/dist/config.js.map +1 -0
  55. package/dist/default-commands.d.ts +27 -0
  56. package/dist/default-commands.d.ts.map +1 -0
  57. package/dist/default-commands.js +64 -0
  58. package/dist/default-commands.js.map +1 -0
  59. package/dist/event-mapper.d.ts +43 -0
  60. package/dist/event-mapper.d.ts.map +1 -0
  61. package/dist/event-mapper.js +127 -0
  62. package/dist/event-mapper.js.map +1 -0
  63. package/dist/heartbeat.d.ts +75 -0
  64. package/dist/heartbeat.d.ts.map +1 -0
  65. package/dist/heartbeat.js +186 -0
  66. package/dist/heartbeat.js.map +1 -0
  67. package/dist/inbound-handler.d.ts +167 -0
  68. package/dist/inbound-handler.d.ts.map +1 -0
  69. package/dist/inbound-handler.js +282 -0
  70. package/dist/inbound-handler.js.map +1 -0
  71. package/dist/inbound-input.d.ts +17 -0
  72. package/dist/inbound-input.d.ts.map +1 -0
  73. package/dist/inbound-input.js +25 -0
  74. package/dist/inbound-input.js.map +1 -0
  75. package/dist/index.d.ts +25 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +18 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/install-service.d.ts +8 -0
  80. package/dist/install-service.d.ts.map +1 -0
  81. package/dist/install-service.js +66 -0
  82. package/dist/install-service.js.map +1 -0
  83. package/dist/last-id-store.d.ts +33 -0
  84. package/dist/last-id-store.d.ts.map +1 -0
  85. package/dist/last-id-store.js +101 -0
  86. package/dist/last-id-store.js.map +1 -0
  87. package/dist/load-config.d.ts +30 -0
  88. package/dist/load-config.d.ts.map +1 -0
  89. package/dist/load-config.js +86 -0
  90. package/dist/load-config.js.map +1 -0
  91. package/dist/media-classify.d.ts +37 -0
  92. package/dist/media-classify.d.ts.map +1 -0
  93. package/dist/media-classify.js +108 -0
  94. package/dist/media-classify.js.map +1 -0
  95. package/dist/media-guard.d.ts +13 -0
  96. package/dist/media-guard.d.ts.map +1 -0
  97. package/dist/media-guard.js +159 -0
  98. package/dist/media-guard.js.map +1 -0
  99. package/dist/outbound-retry.d.ts +24 -0
  100. package/dist/outbound-retry.d.ts.map +1 -0
  101. package/dist/outbound-retry.js +75 -0
  102. package/dist/outbound-retry.js.map +1 -0
  103. package/dist/outbound.d.ts +223 -0
  104. package/dist/outbound.d.ts.map +1 -0
  105. package/dist/outbound.js +366 -0
  106. package/dist/outbound.js.map +1 -0
  107. package/dist/outbox.d.ts +16 -0
  108. package/dist/outbox.d.ts.map +1 -0
  109. package/dist/outbox.js +88 -0
  110. package/dist/outbox.js.map +1 -0
  111. package/dist/pair-cli.d.ts +44 -0
  112. package/dist/pair-cli.d.ts.map +1 -0
  113. package/dist/pair-cli.js +182 -0
  114. package/dist/pair-cli.js.map +1 -0
  115. package/dist/pending-unknown-store.d.ts +26 -0
  116. package/dist/pending-unknown-store.d.ts.map +1 -0
  117. package/dist/pending-unknown-store.js +135 -0
  118. package/dist/pending-unknown-store.js.map +1 -0
  119. package/dist/processed-ids.d.ts +36 -0
  120. package/dist/processed-ids.d.ts.map +1 -0
  121. package/dist/processed-ids.js +55 -0
  122. package/dist/processed-ids.js.map +1 -0
  123. package/dist/reply-markers.d.ts +42 -0
  124. package/dist/reply-markers.d.ts.map +1 -0
  125. package/dist/reply-markers.js +124 -0
  126. package/dist/reply-markers.js.map +1 -0
  127. package/dist/setup/supervisor.d.ts +16 -0
  128. package/dist/setup/supervisor.d.ts.map +1 -0
  129. package/dist/setup/supervisor.js +61 -0
  130. package/dist/setup/supervisor.js.map +1 -0
  131. package/dist/thread-map.d.ts +12 -0
  132. package/dist/thread-map.d.ts.map +1 -0
  133. package/dist/thread-map.js +72 -0
  134. package/dist/thread-map.js.map +1 -0
  135. package/dist/tool-progress.d.ts +103 -0
  136. package/dist/tool-progress.d.ts.map +1 -0
  137. package/dist/tool-progress.js +216 -0
  138. package/dist/tool-progress.js.map +1 -0
  139. package/dist/types.d.ts +244 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +16 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/version.d.ts +2 -0
  144. package/dist/version.d.ts.map +1 -0
  145. package/dist/version.js +28 -0
  146. package/dist/version.js.map +1 -0
  147. package/dist/voice-rpc.d.ts +37 -0
  148. package/dist/voice-rpc.d.ts.map +1 -0
  149. package/dist/voice-rpc.js +44 -0
  150. package/dist/voice-rpc.js.map +1 -0
  151. package/package.json +47 -0
@@ -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