copilot-hub 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -22,7 +22,7 @@ Shared packages:
22
22
 
23
23
  The same Telegram chat handles both operations and development:
24
24
 
25
- - commands: `/help`, `/health`, `/bots`, `/create_agent`, `/cancel`
25
+ - commands: `/help`, `/health`, `/bots`, `/create_agent`, `/codex_status`, `/codex_login`, `/cancel`
26
26
  - normal message: handled by the LLM assistant
27
27
 
28
28
  ## Workspace isolation
@@ -129,6 +129,8 @@ Hub commands:
129
129
  - `/health`
130
130
  - `/bots`
131
131
  - `/create_agent`
132
+ - `/codex_status`
133
+ - `/codex_login`
132
134
  - `/cancel`
133
135
 
134
136
  ### 3) Create runtime agent bot(s)
@@ -147,7 +149,14 @@ You need one Telegram bot token per runtime agent.
147
149
  You can use `/bots` in the hub chat to manage policy, reset context, or delete an agent.
148
150
  Default values are already applied, and actions start from that agent workspace folder.
149
151
 
150
- ### 4) Token safety
152
+ ### 4) Switch Codex account from Telegram (optional)
153
+
154
+ - Run `/codex_status` to verify current Codex login state.
155
+ - Run `/codex_login` (or `/codex_switch`) to get a device code link, then complete sign-in from your phone.
156
+ - Optional: run `/codex_switch_key` if you want API-key based switch (`sk-...`).
157
+ - Running agents are restarted automatically after successful switch.
158
+
159
+ ### 5) Token safety
151
160
 
152
161
  - Never commit real bot tokens.
153
162
  - If a token is leaked, regenerate it in `@BotFather` using `/revoke`.
@@ -170,12 +179,19 @@ npm run restart
170
179
  npm run status
171
180
  npm run logs
172
181
  npm run configure
182
+ npm run update
173
183
  npm run test
174
184
  npm run lint
175
185
  npm run format:check
176
186
  npm run check:apps
177
187
  ```
178
188
 
189
+ Global update command:
190
+
191
+ ```bash
192
+ copilot-hub update
193
+ ```
194
+
179
195
  Service mode (optional, OS-native):
180
196
 
181
197
  ```bash
@@ -1,5 +1,7 @@
1
1
  // @ts-nocheck
2
2
  import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { spawn, spawnSync } from "node:child_process";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import express from "express";
5
7
  import { BotManager } from "@copilot-hub/core/bot-manager";
@@ -17,7 +19,9 @@ let botManager = null;
17
19
  let instanceLock = null;
18
20
  let controlPlane = null;
19
21
  let secretStore = null;
22
+ let codexDeviceAuthSession = null;
20
23
  const workerScriptPath = fileURLToPath(new URL("./agent-worker.js", import.meta.url));
24
+ const ANSI_ESCAPE_PATTERN = new RegExp(String.raw `\u001b\[[0-9;]*m`, "g");
21
25
  await bootstrap();
22
26
  async function bootstrap() {
23
27
  try {
@@ -129,6 +133,75 @@ function buildApiApp({ botManager, controlPlane, registryFilePath }) {
129
133
  secretStoreFile: config.secretStoreFilePath,
130
134
  });
131
135
  });
136
+ app.get("/api/system/codex/status", wrapAsync(async (req, res) => {
137
+ const status = readCodexLoginStatus();
138
+ const deviceAuth = getCodexDeviceAuthSnapshot();
139
+ res.json({
140
+ ok: true,
141
+ configured: status.configured,
142
+ codexBin: status.codexBin,
143
+ detail: status.detail,
144
+ deviceAuth,
145
+ });
146
+ }));
147
+ app.post("/api/system/codex/device_auth/start", wrapAsync(async (req, res) => {
148
+ const session = startCodexDeviceAuthSession();
149
+ const ready = await waitForDeviceCode(session, 12_000);
150
+ if (!ready) {
151
+ res.status(400).json({
152
+ error: session.error ||
153
+ "Could not initialize Codex device login flow. Retry '/codex_login' in a few seconds.",
154
+ });
155
+ return;
156
+ }
157
+ res.json({
158
+ ok: true,
159
+ status: session.status,
160
+ loginUrl: session.loginUrl,
161
+ code: session.code,
162
+ });
163
+ }));
164
+ app.post("/api/system/codex/device_auth/cancel", wrapAsync(async (req, res) => {
165
+ const canceled = cancelCodexDeviceAuthSession();
166
+ res.json({
167
+ ok: true,
168
+ canceled,
169
+ });
170
+ }));
171
+ app.post("/api/system/codex/switch_api_key", wrapAsync(async (req, res) => {
172
+ cancelCodexDeviceAuthSession();
173
+ const apiKey = String(req.body?.apiKey ?? "").trim();
174
+ if (!looksLikeCodexApiKey(apiKey)) {
175
+ res.status(400).json({ error: "Field 'apiKey' is invalid." });
176
+ return;
177
+ }
178
+ const login = runCodexCommand(["login", "--with-api-key"], {
179
+ inputText: `${apiKey}\n`,
180
+ });
181
+ if (!login.ok) {
182
+ const message = redactSecret(firstNonEmptyLine(login.errorMessage, login.stderr, login.stdout) ||
183
+ "Codex login failed. Check API key and retry.", apiKey);
184
+ res.status(400).json({ error: message });
185
+ return;
186
+ }
187
+ const status = readCodexLoginStatus();
188
+ if (!status.configured) {
189
+ res.status(400).json({
190
+ error: status.detail || "Codex login verification failed after credential update. Retry once.",
191
+ });
192
+ return;
193
+ }
194
+ const restarted = await restartRunningBots();
195
+ res.json({
196
+ ok: true,
197
+ switched: true,
198
+ configured: true,
199
+ codexBin: status.codexBin,
200
+ detail: status.detail,
201
+ restartedBots: restarted.restartedBotIds,
202
+ restartFailures: restarted.failures,
203
+ });
204
+ }));
132
205
  app.get("/api/extensions/contract", wrapAsync(async (req, res) => {
133
206
  const contract = await controlPlane.runSystemAction(CONTROL_ACTIONS.EXTENSIONS_CONTRACT_GET, {});
134
207
  res.json(contract);
@@ -350,3 +423,289 @@ function parseDeleteModeFromRequest(body) {
350
423
  }
351
424
  return "soft";
352
425
  }
426
+ function startCodexDeviceAuthSession() {
427
+ if (codexDeviceAuthSession && isDeviceAuthActive(codexDeviceAuthSession.status)) {
428
+ return codexDeviceAuthSession;
429
+ }
430
+ const codexBin = String(config.codexBin ?? "codex").trim() || "codex";
431
+ const session = {
432
+ id: createSessionId(),
433
+ status: "starting",
434
+ startedAt: new Date().toISOString(),
435
+ codexBin,
436
+ loginUrl: "",
437
+ code: "",
438
+ logLines: [],
439
+ error: "",
440
+ child: null,
441
+ restartedBots: [],
442
+ restartFailures: [],
443
+ };
444
+ const child = spawn(codexBin, ["login", "--device-auth"], {
445
+ cwd: config.kernelRootPath,
446
+ shell: false,
447
+ stdio: ["ignore", "pipe", "pipe"],
448
+ windowsHide: true,
449
+ env: process.env,
450
+ });
451
+ session.child = child;
452
+ codexDeviceAuthSession = session;
453
+ child.stdout.on("data", (chunk) => {
454
+ appendDeviceAuthOutput(session, chunk);
455
+ });
456
+ child.stderr.on("data", (chunk) => {
457
+ appendDeviceAuthOutput(session, chunk);
458
+ });
459
+ child.once("error", (error) => {
460
+ session.child = null;
461
+ session.status = "failed";
462
+ session.error = sanitizeError(error);
463
+ });
464
+ child.once("exit", (code) => {
465
+ session.child = null;
466
+ if (session.status === "canceled") {
467
+ return;
468
+ }
469
+ if (code === 0) {
470
+ session.status = "succeeded";
471
+ void restartRunningBots()
472
+ .then((restarted) => {
473
+ session.restartedBots = restarted.restartedBotIds;
474
+ session.restartFailures = restarted.failures;
475
+ })
476
+ .catch((error) => {
477
+ session.restartFailures = [{ botId: "*", error: sanitizeError(error) }];
478
+ });
479
+ return;
480
+ }
481
+ session.status = "failed";
482
+ session.error =
483
+ firstNonEmptyLine(session.error, ...session.logLines.slice(-12)) ||
484
+ `Codex login exited with code ${String(code ?? "unknown")}.`;
485
+ });
486
+ return session;
487
+ }
488
+ function cancelCodexDeviceAuthSession() {
489
+ if (!codexDeviceAuthSession || !isDeviceAuthActive(codexDeviceAuthSession.status)) {
490
+ return false;
491
+ }
492
+ codexDeviceAuthSession.status = "canceled";
493
+ codexDeviceAuthSession.error = "Canceled by user.";
494
+ try {
495
+ codexDeviceAuthSession.child?.kill("SIGTERM");
496
+ }
497
+ catch {
498
+ // ignore
499
+ }
500
+ codexDeviceAuthSession.child = null;
501
+ return true;
502
+ }
503
+ function getCodexDeviceAuthSnapshot() {
504
+ const session = codexDeviceAuthSession;
505
+ if (!session) {
506
+ return { status: "idle" };
507
+ }
508
+ return {
509
+ status: session.status,
510
+ startedAt: session.startedAt,
511
+ loginUrl: session.loginUrl || undefined,
512
+ code: session.code || undefined,
513
+ detail: session.error || undefined,
514
+ restartedBots: session.restartedBots,
515
+ restartFailures: session.restartFailures,
516
+ };
517
+ }
518
+ async function waitForDeviceCode(session, timeoutMs) {
519
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10_000;
520
+ const started = Date.now();
521
+ while (Date.now() - started < timeout) {
522
+ if (session.status === "failed" || session.status === "canceled") {
523
+ return false;
524
+ }
525
+ if (session.loginUrl && session.code) {
526
+ if (session.status === "starting") {
527
+ session.status = "pending";
528
+ }
529
+ return true;
530
+ }
531
+ await sleep(150);
532
+ }
533
+ return Boolean(session.loginUrl && session.code);
534
+ }
535
+ function appendDeviceAuthOutput(session, chunk) {
536
+ const text = stripAnsi(String(chunk ?? ""));
537
+ if (!text.trim()) {
538
+ return;
539
+ }
540
+ const lines = text
541
+ .split(/\r?\n/)
542
+ .map((line) => line.trim())
543
+ .filter(Boolean);
544
+ if (lines.length === 0) {
545
+ return;
546
+ }
547
+ session.logLines.push(...lines);
548
+ if (session.logLines.length > 120) {
549
+ session.logLines = session.logLines.slice(-120);
550
+ }
551
+ if (!session.loginUrl) {
552
+ const url = findDeviceLoginUrl(lines);
553
+ if (url) {
554
+ session.loginUrl = url;
555
+ }
556
+ }
557
+ if (!session.code) {
558
+ const code = findDeviceCode(lines);
559
+ if (code) {
560
+ session.code = code;
561
+ }
562
+ }
563
+ if (session.loginUrl && session.code && session.status === "starting") {
564
+ session.status = "pending";
565
+ }
566
+ }
567
+ function findDeviceLoginUrl(lines) {
568
+ for (const line of lines) {
569
+ const urls = line.match(/https?:\/\/\S+/g);
570
+ if (!urls) {
571
+ continue;
572
+ }
573
+ for (const url of urls) {
574
+ if (url.includes("/codex/device")) {
575
+ return url;
576
+ }
577
+ }
578
+ }
579
+ return "";
580
+ }
581
+ function findDeviceCode(lines) {
582
+ for (const line of lines) {
583
+ const match = line.match(/\b[A-Z0-9]{4}-[A-Z0-9]{5}\b/);
584
+ if (match?.[0]) {
585
+ return match[0];
586
+ }
587
+ }
588
+ return "";
589
+ }
590
+ function stripAnsi(value) {
591
+ return String(value ?? "").replace(ANSI_ESCAPE_PATTERN, "");
592
+ }
593
+ function isDeviceAuthActive(status) {
594
+ const value = String(status ?? "")
595
+ .trim()
596
+ .toLowerCase();
597
+ return value === "starting" || value === "pending";
598
+ }
599
+ function createSessionId() {
600
+ return randomBytes(8).toString("hex");
601
+ }
602
+ function sleep(ms) {
603
+ return new Promise((resolve) => setTimeout(resolve, ms));
604
+ }
605
+ function readCodexLoginStatus() {
606
+ const codexBin = String(config.codexBin ?? "codex").trim() || "codex";
607
+ const status = runCodexCommand(["login", "status"]);
608
+ return {
609
+ configured: status.ok,
610
+ codexBin,
611
+ detail: firstNonEmptyLine(status.errorMessage, status.stderr, status.stdout),
612
+ };
613
+ }
614
+ function looksLikeCodexApiKey(value) {
615
+ const key = String(value ?? "").trim();
616
+ if (key.length < 20 || key.length > 4096) {
617
+ return false;
618
+ }
619
+ if (!/^[A-Za-z0-9._-]+$/.test(key)) {
620
+ return false;
621
+ }
622
+ return key.startsWith("sk-");
623
+ }
624
+ function runCodexCommand(args, { inputText = "" } = {}) {
625
+ const codexBin = String(config.codexBin ?? "codex").trim() || "codex";
626
+ const result = spawnSync(codexBin, args, {
627
+ cwd: config.kernelRootPath,
628
+ shell: false,
629
+ stdio: ["pipe", "pipe", "pipe"],
630
+ windowsHide: true,
631
+ encoding: "utf8",
632
+ input: inputText,
633
+ env: process.env,
634
+ });
635
+ if (result.error) {
636
+ return {
637
+ ok: false,
638
+ status: 1,
639
+ stdout: "",
640
+ stderr: "",
641
+ errorMessage: formatCodexSpawnError(codexBin, result.error),
642
+ };
643
+ }
644
+ const status = Number.isInteger(result.status) ? result.status : 1;
645
+ return {
646
+ ok: status === 0,
647
+ status,
648
+ stdout: String(result.stdout ?? "").trim(),
649
+ stderr: String(result.stderr ?? "").trim(),
650
+ errorMessage: "",
651
+ };
652
+ }
653
+ function formatCodexSpawnError(codexBin, error) {
654
+ const code = String(error?.code ?? "")
655
+ .trim()
656
+ .toUpperCase();
657
+ if (code === "ENOENT") {
658
+ return `Codex binary '${codexBin}' was not found.`;
659
+ }
660
+ if (code === "EPERM") {
661
+ return `Codex binary '${codexBin}' cannot be executed (EPERM).`;
662
+ }
663
+ const message = error instanceof Error ? error.message : String(error);
664
+ return `Failed to execute '${codexBin}': ${firstNonEmptyLine(message)}`;
665
+ }
666
+ function firstNonEmptyLine(...values) {
667
+ for (const value of values) {
668
+ const line = String(value ?? "")
669
+ .split(/\r?\n/)
670
+ .map((entry) => entry.trim())
671
+ .find(Boolean);
672
+ if (line) {
673
+ return line;
674
+ }
675
+ }
676
+ return "";
677
+ }
678
+ function redactSecret(text, secret) {
679
+ const input = String(text ?? "");
680
+ const token = String(secret ?? "").trim();
681
+ if (!token) {
682
+ return input;
683
+ }
684
+ return input.split(token).join("[redacted]");
685
+ }
686
+ async function restartRunningBots() {
687
+ const statuses = await botManager.listBotsLive();
688
+ const runningBotIds = statuses
689
+ .filter((entry) => entry?.running === true)
690
+ .map((entry) => String(entry?.id ?? "").trim())
691
+ .filter(Boolean);
692
+ const restartedBotIds = [];
693
+ const failures = [];
694
+ for (const botId of runningBotIds) {
695
+ try {
696
+ await botManager.stopBot(botId);
697
+ await botManager.startBot(botId);
698
+ restartedBotIds.push(botId);
699
+ }
700
+ catch (error) {
701
+ failures.push({
702
+ botId,
703
+ error: sanitizeError(error),
704
+ });
705
+ }
706
+ }
707
+ return {
708
+ restartedBotIds,
709
+ failures,
710
+ };
711
+ }
@@ -3,7 +3,8 @@
3
3
  `control-plane` is the single Telegram hub for Copilot Hub.
4
4
 
5
5
  It handles:
6
- - simple operations commands (`/help`, `/health`, `/bots`, `/create_agent`, `/cancel`)
6
+
7
+ - simple operations commands (`/help`, `/health`, `/bots`, `/create_agent`, `/codex_status`, `/codex_login`, `/cancel`)
7
8
  - LLM development requests through normal chat messages
8
9
 
9
10
  ## Setup
@@ -18,9 +19,11 @@ npm run start
18
19
  ```
19
20
 
20
21
  Required env:
22
+
21
23
  - `HUB_TELEGRAM_TOKEN` (or custom `HUB_TELEGRAM_TOKEN_ENV`)
22
24
 
23
25
  Recommended env:
26
+
24
27
  - `HUB_ENGINE_BASE_URL` (default: `http://127.0.0.1:8787`)
25
28
 
26
29
  ## Workspace and policy guards
@@ -7,6 +7,7 @@ const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
7
7
  const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{20,}$/;
8
8
  const menuSessions = new Map();
9
9
  const createFlows = new Map();
10
+ const codexSwitchFlows = new Map();
10
11
  const POLICY_PROFILES = {
11
12
  safe: {
12
13
  id: "safe",
@@ -75,6 +76,69 @@ export async function maybeHandleHubOpsCommand({ ctx, runtime, channelId }) {
75
76
  }
76
77
  return true;
77
78
  }
79
+ if (command === "/codex_status") {
80
+ try {
81
+ const status = await apiGet("/api/system/codex/status");
82
+ const deviceAuth = status?.deviceAuth ?? {};
83
+ const deviceStatus = String(deviceAuth?.status ?? "idle");
84
+ await ctx.reply([
85
+ "Codex account:",
86
+ `configured: ${status?.configured ? "yes" : "no"}`,
87
+ `binary: ${String(status?.codexBin ?? "-")}`,
88
+ status?.detail ? `detail: ${String(status.detail)}` : "",
89
+ `deviceAuth: ${deviceStatus}`,
90
+ deviceAuth?.code ? `code: ${String(deviceAuth.code)}` : "",
91
+ deviceAuth?.loginUrl ? `link: ${String(deviceAuth.loginUrl)}` : "",
92
+ deviceAuth?.detail ? `deviceDetail: ${String(deviceAuth.detail)}` : "",
93
+ ]
94
+ .filter(Boolean)
95
+ .join("\n"));
96
+ }
97
+ catch (error) {
98
+ await ctx.reply(`Codex status failed: ${sanitizeError(error)}`);
99
+ }
100
+ return true;
101
+ }
102
+ if (command === "/codex_login" || command === "/codex_switch") {
103
+ try {
104
+ const response = await apiPost("/api/system/codex/device_auth/start", {});
105
+ const statusLabel = String(response?.status ?? "pending");
106
+ const loginUrl = String(response?.loginUrl ?? "").trim();
107
+ const code = String(response?.code ?? "").trim();
108
+ if (!loginUrl || !code) {
109
+ await ctx.reply("Codex login flow started, but code details were not ready yet. Retry /codex_login.");
110
+ return true;
111
+ }
112
+ await ctx.reply([
113
+ `Codex login flow: ${statusLabel}`,
114
+ `1) Open: ${loginUrl}`,
115
+ `2) Enter code: ${code}`,
116
+ "3) Finish sign-in on your phone, then run /codex_status",
117
+ "Use /cancel to abort this login flow.",
118
+ ].join("\n"));
119
+ }
120
+ catch (error) {
121
+ await ctx.reply(`Codex login start failed: ${sanitizeError(error)}`);
122
+ }
123
+ return true;
124
+ }
125
+ if (command === "/codex_switch_key") {
126
+ if (createFlows.has(flowKey) || codexSwitchFlows.has(flowKey)) {
127
+ await ctx.reply("Another operation is active. Send /cancel first.");
128
+ return true;
129
+ }
130
+ codexSwitchFlows.set(flowKey, {
131
+ createdAt: Date.now(),
132
+ step: "api_key",
133
+ });
134
+ await ctx.reply([
135
+ "Codex account switch started.",
136
+ "Send your Codex API key now (example: sk-...).",
137
+ "Running agents will restart automatically after successful switch.",
138
+ "Use /cancel to stop.",
139
+ ].join("\n"));
140
+ return true;
141
+ }
78
142
  if (command === "/create_agent") {
79
143
  const existing = createFlows.get(flowKey);
80
144
  if (existing) {
@@ -97,8 +161,19 @@ export async function maybeHandleHubOpsCommand({ ctx, runtime, channelId }) {
97
161
  return true;
98
162
  }
99
163
  if (command === "/cancel") {
100
- const deleted = createFlows.delete(flowKey);
101
- await ctx.reply(deleted ? "Current operation canceled." : "No active operation.");
164
+ const createDeleted = createFlows.delete(flowKey);
165
+ const switchDeleted = codexSwitchFlows.delete(flowKey);
166
+ let remoteCanceled = false;
167
+ try {
168
+ const canceled = await apiPost("/api/system/codex/device_auth/cancel", {});
169
+ remoteCanceled = canceled?.canceled === true;
170
+ }
171
+ catch {
172
+ remoteCanceled = false;
173
+ }
174
+ await ctx.reply(createDeleted || switchDeleted || remoteCanceled
175
+ ? "Current operation canceled."
176
+ : "No active operation.");
102
177
  return true;
103
178
  }
104
179
  return false;
@@ -111,6 +186,13 @@ export async function maybeHandleHubOpsFollowUp({ ctx, runtime, channelId }) {
111
186
  }
112
187
  const chatId = getChatId(ctx);
113
188
  const flowKey = buildFlowKey(runtime?.runtimeId, channelId, chatId);
189
+ const codexFlow = codexSwitchFlows.get(flowKey);
190
+ if (codexFlow) {
191
+ const handled = await handleCodexSwitchFlow({ ctx, flowKey, flow: codexFlow, text });
192
+ if (handled) {
193
+ return true;
194
+ }
195
+ }
114
196
  const flow = createFlows.get(flowKey);
115
197
  if (!flow) {
116
198
  return false;
@@ -196,6 +278,46 @@ export async function maybeHandleHubOpsFollowUp({ ctx, runtime, channelId }) {
196
278
  await ctx.reply("Flow reset. Use /create_agent to start again.");
197
279
  return true;
198
280
  }
281
+ async function handleCodexSwitchFlow({ ctx, flowKey, flow, text }) {
282
+ if (flow.step !== "api_key") {
283
+ codexSwitchFlows.delete(flowKey);
284
+ await ctx.reply("Flow reset. Use /codex_switch_key to start again.");
285
+ return true;
286
+ }
287
+ const apiKey = String(text ?? "").trim();
288
+ if (!looksLikeCodexApiKey(apiKey)) {
289
+ await ctx.reply("Invalid API key format. Send a key starting with 'sk-' or /cancel.");
290
+ return true;
291
+ }
292
+ await maybeDeleteIncomingMessage(ctx);
293
+ try {
294
+ const result = await apiPost("/api/system/codex/switch_api_key", {
295
+ apiKey,
296
+ });
297
+ codexSwitchFlows.delete(flowKey);
298
+ const restartedBots = Array.isArray(result?.restartedBots) ? result.restartedBots : [];
299
+ const restartFailures = Array.isArray(result?.restartFailures) ? result.restartFailures : [];
300
+ const lines = ["Codex account switched successfully."];
301
+ if (result?.detail) {
302
+ lines.push(`status: ${String(result.detail)}`);
303
+ }
304
+ if (restartedBots.length > 0) {
305
+ lines.push(`Agents restarted: ${restartedBots.join(", ")}`);
306
+ }
307
+ else {
308
+ lines.push("No running agents needed restart.");
309
+ }
310
+ if (restartFailures.length > 0) {
311
+ lines.push(`Restart warnings: ${restartFailures.length}. Use /health then /bots to verify state.`);
312
+ }
313
+ await ctx.reply(lines.join("\n"));
314
+ return true;
315
+ }
316
+ catch (error) {
317
+ await ctx.reply(`Codex switch failed:\n${sanitizeError(error)}\nRetry or /cancel.`);
318
+ return true;
319
+ }
320
+ }
199
321
  export async function maybeHandleHubOpsCallback({ ctx }) {
200
322
  const rawData = String(ctx.callbackQuery?.data ?? "").trim();
201
323
  if (!rawData.startsWith("hub:")) {
@@ -320,8 +442,15 @@ function buildHelpText(runtimeName) {
320
442
  "/health",
321
443
  "/bots",
322
444
  "/create_agent",
445
+ "/codex_status",
446
+ "/codex_login",
447
+ "/codex_switch_key",
323
448
  "/cancel",
324
449
  "",
450
+ "Codex account:",
451
+ "/codex_login: switch account with device code flow",
452
+ "/codex_switch_key: switch account with API key",
453
+ "",
325
454
  "Policy guide in /bots:",
326
455
  "Safe: read-only + approval prompts",
327
456
  "Standard: workspace write + approval prompts",
@@ -349,6 +478,12 @@ function cleanupState() {
349
478
  createFlows.delete(key);
350
479
  }
351
480
  }
481
+ for (const [key, flow] of codexSwitchFlows.entries()) {
482
+ const createdAt = Number(flow?.createdAt ?? 0);
483
+ if (!Number.isFinite(createdAt) || now - createdAt > FLOW_TTL_MS) {
484
+ codexSwitchFlows.delete(key);
485
+ }
486
+ }
352
487
  }
353
488
  async function renderBotsMenu(ctx, { editMessage = false, notice = "" } = {}) {
354
489
  const bots = await fetchBots();
@@ -660,6 +795,29 @@ async function editMessageOrReply(ctx, text, options = {}) {
660
795
  function getChatId(ctx) {
661
796
  return String(ctx.chat?.id ?? ctx.callbackQuery?.message?.chat?.id ?? "").trim();
662
797
  }
798
+ function looksLikeCodexApiKey(value) {
799
+ const raw = String(value ?? "").trim();
800
+ if (!raw.startsWith("sk-")) {
801
+ return false;
802
+ }
803
+ if (raw.length < 20 || raw.length > 4096) {
804
+ return false;
805
+ }
806
+ return /^[A-Za-z0-9._-]+$/.test(raw);
807
+ }
808
+ async function maybeDeleteIncomingMessage(ctx) {
809
+ const chatId = ctx.chat?.id;
810
+ const messageId = ctx.message?.message_id;
811
+ if (!chatId || !messageId) {
812
+ return;
813
+ }
814
+ try {
815
+ await ctx.api.deleteMessage(chatId, messageId);
816
+ }
817
+ catch {
818
+ // Best effort only.
819
+ }
820
+ }
663
821
  async function answerCallbackQuerySafe(ctx, text = "") {
664
822
  try {
665
823
  if (text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-hub",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Copilot Hub CLI and runtime bundle",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,6 +57,7 @@
57
57
  "status": "npm run build:scripts --silent && node scripts/dist/cli.mjs status",
58
58
  "logs": "npm run build:scripts --silent && node scripts/dist/cli.mjs logs",
59
59
  "configure": "npm run build:scripts --silent && node scripts/dist/cli.mjs configure",
60
+ "update": "npm run build:scripts --silent && node scripts/dist/cli.mjs update",
60
61
  "test": "npm run test --workspaces --if-present",
61
62
  "lint": "eslint .",
62
63
  "format": "prettier --write \"{scripts,packages}/**/*.{js,mjs,mts,ts,json}\" \"apps/*/src/**/*.{js,ts}\" \"apps/*/package.json\" \"apps/*/test/**/*.{js,ts}\" \"apps/*/tsconfig*.json\" \"{README.md,CONTRIBUTING.md,ARCHITECTURE.md}\" \"eslint.config.mjs\" \"package.json\" \"tsconfig.base.json\"",
@@ -87,6 +87,11 @@ async function main() {
87
87
  runNode(["scripts/dist/service.mjs", ...rawArgs.slice(1)]);
88
88
  return;
89
89
  }
90
+ case "update":
91
+ case "upgrade": {
92
+ await runSelfUpdate();
93
+ return;
94
+ }
90
95
  default: {
91
96
  printUsage();
92
97
  process.exit(1);
@@ -224,6 +229,51 @@ async function maybeOfferServiceInstall() {
224
229
  }
225
230
  writeServicePromptState("accepted");
226
231
  }
232
+ async function runSelfUpdate() {
233
+ const serviceInstalled = isServiceAlreadyInstalled();
234
+ const runningBeforeUpdate = serviceInstalled ? isDaemonRunning() : hasRunningSupervisorWorkers();
235
+ if (serviceInstalled) {
236
+ const stopService = runNodeCapture(["scripts/dist/service.mjs", "stop"], "inherit");
237
+ if (!stopService.ok) {
238
+ console.log("Service stop reported an error. Continuing update attempt.");
239
+ }
240
+ }
241
+ else {
242
+ const stopLocal = runNodeCapture(["scripts/dist/supervisor.mjs", "down"], "inherit");
243
+ if (!stopLocal.ok) {
244
+ console.log("Local stop reported an error. Continuing update attempt.");
245
+ }
246
+ }
247
+ const install = runNpm(["install", "-g", "copilot-hub@latest"], "inherit");
248
+ if (!install.ok) {
249
+ const detail = firstLine(install.errorMessage) || firstLine(install.stderr) || firstLine(install.stdout);
250
+ const normalizedDetail = detail.toLowerCase();
251
+ if (normalizedDetail.includes("ebusy") || normalizedDetail.includes("resource busy")) {
252
+ throw new Error([
253
+ "Update failed because files are locked by another process (EBUSY).",
254
+ "Close other terminals using copilot-hub, then retry 'copilot-hub update'.",
255
+ ].join("\n"));
256
+ }
257
+ throw new Error(`Update failed: ${detail || "Unknown npm error."}`);
258
+ }
259
+ console.log("copilot-hub updated to latest.");
260
+ if (!runningBeforeUpdate) {
261
+ console.log("Services remain stopped. Run 'copilot-hub start' when ready.");
262
+ return;
263
+ }
264
+ if (serviceInstalled) {
265
+ const startService = runNodeCapture(["scripts/dist/service.mjs", "start"], "inherit");
266
+ if (!startService.ok) {
267
+ console.log("Update completed, but service start failed. Run 'copilot-hub start' manually.");
268
+ return;
269
+ }
270
+ return;
271
+ }
272
+ const startLocal = runNodeCapture(["scripts/dist/supervisor.mjs", "up"], "inherit");
273
+ if (!startLocal.ok) {
274
+ console.log("Update completed, but local start failed. Run 'copilot-hub start' manually.");
275
+ }
276
+ }
227
277
  function isServiceSupportedOnCurrentPlatform() {
228
278
  return (process.platform === "win32" || process.platform === "linux" || process.platform === "darwin");
229
279
  }
@@ -238,6 +288,16 @@ function isServiceAlreadyInstalled() {
238
288
  }
239
289
  return status.ok;
240
290
  }
291
+ function isDaemonRunning() {
292
+ const status = runNodeCapture(["scripts/dist/daemon.mjs", "status"], "pipe");
293
+ const output = String(status.combinedOutput ?? "").toLowerCase();
294
+ return output.includes("=== daemon ===") && output.includes("running: yes");
295
+ }
296
+ function hasRunningSupervisorWorkers() {
297
+ const status = runNodeCapture(["scripts/dist/supervisor.mjs", "status"], "pipe");
298
+ const output = String(status.combinedOutput ?? "").toLowerCase();
299
+ return output.includes("running: yes");
300
+ }
241
301
  function readServicePromptState() {
242
302
  if (!fs.existsSync(servicePromptStatePath)) {
243
303
  return null;
@@ -587,6 +647,7 @@ function spawnNpm(args, options) {
587
647
  function printUsage() {
588
648
  console.log([
589
649
  "Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
650
+ " node scripts/dist/cli.mjs <update|upgrade>",
590
651
  "Service management:",
591
652
  " node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
592
653
  ].join("\n"));
@@ -325,6 +325,7 @@ function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}
325
325
  cwd: repoRoot,
326
326
  shell: false,
327
327
  stdio: spawnStdio,
328
+ windowsHide: true,
328
329
  encoding: "utf8",
329
330
  env: process.env,
330
331
  });
@@ -434,6 +434,7 @@ function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}
434
434
  cwd: repoRoot,
435
435
  shell: false,
436
436
  stdio,
437
+ windowsHide: true,
437
438
  encoding: "utf8",
438
439
  env: process.env,
439
440
  });
@@ -94,6 +94,11 @@ async function main() {
94
94
  runNode(["scripts/dist/service.mjs", ...rawArgs.slice(1)]);
95
95
  return;
96
96
  }
97
+ case "update":
98
+ case "upgrade": {
99
+ await runSelfUpdate();
100
+ return;
101
+ }
97
102
  default: {
98
103
  printUsage();
99
104
  process.exit(1);
@@ -258,6 +263,60 @@ async function maybeOfferServiceInstall() {
258
263
  writeServicePromptState("accepted");
259
264
  }
260
265
 
266
+ async function runSelfUpdate() {
267
+ const serviceInstalled = isServiceAlreadyInstalled();
268
+ const runningBeforeUpdate = serviceInstalled ? isDaemonRunning() : hasRunningSupervisorWorkers();
269
+
270
+ if (serviceInstalled) {
271
+ const stopService = runNodeCapture(["scripts/dist/service.mjs", "stop"], "inherit");
272
+ if (!stopService.ok) {
273
+ console.log("Service stop reported an error. Continuing update attempt.");
274
+ }
275
+ } else {
276
+ const stopLocal = runNodeCapture(["scripts/dist/supervisor.mjs", "down"], "inherit");
277
+ if (!stopLocal.ok) {
278
+ console.log("Local stop reported an error. Continuing update attempt.");
279
+ }
280
+ }
281
+
282
+ const install = runNpm(["install", "-g", "copilot-hub@latest"], "inherit");
283
+ if (!install.ok) {
284
+ const detail =
285
+ firstLine(install.errorMessage) || firstLine(install.stderr) || firstLine(install.stdout);
286
+ const normalizedDetail = detail.toLowerCase();
287
+ if (normalizedDetail.includes("ebusy") || normalizedDetail.includes("resource busy")) {
288
+ throw new Error(
289
+ [
290
+ "Update failed because files are locked by another process (EBUSY).",
291
+ "Close other terminals using copilot-hub, then retry 'copilot-hub update'.",
292
+ ].join("\n"),
293
+ );
294
+ }
295
+ throw new Error(`Update failed: ${detail || "Unknown npm error."}`);
296
+ }
297
+
298
+ console.log("copilot-hub updated to latest.");
299
+
300
+ if (!runningBeforeUpdate) {
301
+ console.log("Services remain stopped. Run 'copilot-hub start' when ready.");
302
+ return;
303
+ }
304
+
305
+ if (serviceInstalled) {
306
+ const startService = runNodeCapture(["scripts/dist/service.mjs", "start"], "inherit");
307
+ if (!startService.ok) {
308
+ console.log("Update completed, but service start failed. Run 'copilot-hub start' manually.");
309
+ return;
310
+ }
311
+ return;
312
+ }
313
+
314
+ const startLocal = runNodeCapture(["scripts/dist/supervisor.mjs", "up"], "inherit");
315
+ if (!startLocal.ok) {
316
+ console.log("Update completed, but local start failed. Run 'copilot-hub start' manually.");
317
+ }
318
+ }
319
+
261
320
  function isServiceSupportedOnCurrentPlatform() {
262
321
  return (
263
322
  process.platform === "win32" || process.platform === "linux" || process.platform === "darwin"
@@ -276,6 +335,18 @@ function isServiceAlreadyInstalled() {
276
335
  return status.ok;
277
336
  }
278
337
 
338
+ function isDaemonRunning() {
339
+ const status = runNodeCapture(["scripts/dist/daemon.mjs", "status"], "pipe");
340
+ const output = String(status.combinedOutput ?? "").toLowerCase();
341
+ return output.includes("=== daemon ===") && output.includes("running: yes");
342
+ }
343
+
344
+ function hasRunningSupervisorWorkers() {
345
+ const status = runNodeCapture(["scripts/dist/supervisor.mjs", "status"], "pipe");
346
+ const output = String(status.combinedOutput ?? "").toLowerCase();
347
+ return output.includes("running: yes");
348
+ }
349
+
279
350
  function readServicePromptState() {
280
351
  if (!fs.existsSync(servicePromptStatePath)) {
281
352
  return null;
@@ -693,6 +764,7 @@ function printUsage() {
693
764
  console.log(
694
765
  [
695
766
  "Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
767
+ " node scripts/dist/cli.mjs <update|upgrade>",
696
768
  "Service management:",
697
769
  " node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
698
770
  ].join("\n"),
@@ -385,6 +385,7 @@ function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}
385
385
  cwd: repoRoot,
386
386
  shell: false,
387
387
  stdio: spawnStdio,
388
+ windowsHide: true,
388
389
  encoding: "utf8",
389
390
  env: process.env,
390
391
  });
@@ -532,6 +532,7 @@ function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}
532
532
  cwd: repoRoot,
533
533
  shell: false,
534
534
  stdio,
535
+ windowsHide: true,
535
536
  encoding: "utf8",
536
537
  env: process.env,
537
538
  });