codex-to-im 0.1.2 → 0.1.5

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
@@ -86,6 +86,15 @@ http://127.0.0.1:4781
86
86
 
87
87
  If that port is already occupied, the app automatically finds an available local port and prints the actual address to the terminal when starting.
88
88
 
89
+ By default, the web workbench only accepts local access.
90
+
91
+ If you want to open it from your phone or another device on the same LAN, enable `允许局域网访问 Web 控制台` in the `配置` page. When enabled:
92
+
93
+ - the workbench shows detected LAN URLs
94
+ - the workbench displays an access token
95
+ - LAN devices see a login page before they can view or modify settings
96
+ - you can also copy a ready-to-use login link that includes `?token=...`
97
+
89
98
  If you forget the current address, run:
90
99
 
91
100
  ```bash
@@ -114,6 +123,8 @@ codex-to-im stop
114
123
  6. Bind a Feishu or Weixin chat to the target thread
115
124
  7. Continue the same Codex thread from IM
116
125
 
126
+ If LAN access is enabled, the easiest path is to copy the LAN login link from the local workbench and open it on your phone or another device on the same network.
127
+
117
128
  Useful command:
118
129
 
119
130
  - `/history` shows the latest N messages of the current session
package/README_CN.md CHANGED
@@ -86,6 +86,15 @@ http://127.0.0.1:4781
86
86
 
87
87
  如果默认端口已被占用,应用会自动选择一个可用端口,并在启动时把实际地址打印到命令行。
88
88
 
89
+ 默认情况下,Web 工作台只允许本机访问。
90
+
91
+ 如果你需要在手机或同一局域网里的其他设备上打开配置页,可以在“配置”页里勾选“允许局域网访问 Web 控制台”。开启后:
92
+
93
+ - 工作台会显示当前可用的局域网地址
94
+ - 工作台会生成并展示一个访问 token
95
+ - 局域网设备访问时会先进入登录页,输入 token 后才能查看和修改配置
96
+ - 也可以直接复制页面里的局域网登录链接,链接里会附带 `?token=...`
97
+
89
98
  如果你忘了当前地址,可以执行:
90
99
 
91
100
  ```bash
@@ -114,6 +123,8 @@ codex-to-im stop
114
123
  6. 把飞书或微信聊天绑定到目标 thread
115
124
  7. 在 IM 中继续同一条 Codex 会话
116
125
 
126
+ 如果开启了局域网访问,推荐在本机工作台里复制局域网登录链接,再发给你的手机或局域网里的其他设备。
127
+
117
128
  常用命令补充:
118
129
 
119
130
  - `/history` 查看当前会话最近 N 条消息
@@ -43,9 +43,15 @@ CTI_DEFAULT_MODE=code
43
43
  # Allow Codex to run when CTI_DEFAULT_WORKDIR is not inside a trusted Git repo.
44
44
  # This project defaults it to true so first-time setup works more smoothly.
45
45
  CTI_CODEX_SKIP_GIT_REPO_CHECK=true
46
-
47
- # ── Telegram ──
48
- CTI_TG_BOT_TOKEN=your-telegram-bot-token
46
+
47
+ # ── Web 控制台访问 ──
48
+ # 默认仅允许本机访问本地工作台。开启后,局域网设备访问时需要先输入 token
49
+ # 可以在 Web 工作台里直接勾选并自动生成 token。
50
+ # CTI_UI_ALLOW_LAN=false
51
+ # CTI_UI_ACCESS_TOKEN=your-random-access-token
52
+
53
+ # ── Telegram ──
54
+ CTI_TG_BOT_TOKEN=your-telegram-bot-token
49
55
  # Chat ID for authorization (at least one of CHAT_ID or ALLOWED_USERS is required)
50
56
  # Get it: send a message to the bot, then visit https://api.telegram.org/botYOUR_TOKEN/getUpdates
51
57
  CTI_TG_CHAT_ID=your-chat-id
package/dist/daemon.mjs CHANGED
@@ -195849,6 +195849,8 @@ function loadConfig() {
195849
195849
  defaultMode: env.get("CTI_DEFAULT_MODE") || "code",
195850
195850
  historyMessageLimit: parsePositiveInt(env.get("CTI_HISTORY_MESSAGE_LIMIT")) ?? 8,
195851
195851
  codexSkipGitRepoCheck: env.has("CTI_CODEX_SKIP_GIT_REPO_CHECK") ? env.get("CTI_CODEX_SKIP_GIT_REPO_CHECK") === "true" : true,
195852
+ uiAllowLan: env.get("CTI_UI_ALLOW_LAN") === "true",
195853
+ uiAccessToken: env.get("CTI_UI_ACCESS_TOKEN") || void 0,
195852
195854
  tgBotToken: env.get("CTI_TG_BOT_TOKEN") || void 0,
195853
195855
  tgChatId: env.get("CTI_TG_CHAT_ID") || void 0,
195854
195856
  tgAllowedUsers: splitCsv(env.get("CTI_TG_ALLOWED_USERS")),
@@ -196775,6 +196777,7 @@ import os2 from "node:os";
196775
196777
  import path3 from "node:path";
196776
196778
  var ACTIVE_WINDOW_MS = 15 * 60 * 1e3;
196777
196779
  var MAX_SESSION_META_BYTES = 4 * 1024 * 1024;
196780
+ var MAX_SESSION_TITLE_SCAN_BYTES = 512 * 1024;
196778
196781
  var TITLE_MAX_CHARS = 72;
196779
196782
  function getCodexHome() {
196780
196783
  return process.env.CODEX_HOME || path3.join(os2.homedir(), ".codex");
@@ -196832,6 +196835,24 @@ function readFirstLine(filePath, maxBytes = MAX_SESSION_META_BYTES) {
196832
196835
  fs3.closeSync(fd);
196833
196836
  }
196834
196837
  }
196838
+ function readFilePrefix(filePath, maxBytes = MAX_SESSION_TITLE_SCAN_BYTES) {
196839
+ const fd = fs3.openSync(filePath, "r");
196840
+ try {
196841
+ const buffer = Buffer.alloc(Math.min(maxBytes, 64 * 1024));
196842
+ const chunks = [];
196843
+ let offset = 0;
196844
+ while (offset < maxBytes) {
196845
+ const bytesToRead = Math.min(buffer.length, maxBytes - offset);
196846
+ const bytesRead = fs3.readSync(fd, buffer, 0, bytesToRead, offset);
196847
+ if (bytesRead <= 0) break;
196848
+ chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
196849
+ offset += bytesRead;
196850
+ }
196851
+ return Buffer.concat(chunks).toString("utf-8");
196852
+ } finally {
196853
+ fs3.closeSync(fd);
196854
+ }
196855
+ }
196835
196856
  function walkSessionFiles(dirPath, target) {
196836
196857
  let entries;
196837
196858
  try {
@@ -196853,6 +196874,7 @@ function walkSessionFiles(dirPath, target) {
196853
196874
  function isDesktopLike(meta) {
196854
196875
  const originator = meta?.originator?.toLowerCase() || "";
196855
196876
  const source = meta?.source?.toLowerCase() || "";
196877
+ if (source === "exec") return false;
196856
196878
  return originator.includes("desktop") || source === "vscode" || source === "desktop";
196857
196879
  }
196858
196880
  function loadThreadNameIndex(archivedThreadIds) {
@@ -196884,6 +196906,27 @@ function loadThreadNameIndex(archivedThreadIds) {
196884
196906
  }
196885
196907
  return new Map(Array.from(titles.entries()).map(([threadId, entry]) => [threadId, entry.title]));
196886
196908
  }
196909
+ function buildFallbackTitle(threadId, filePath, cwd) {
196910
+ try {
196911
+ const content = readFilePrefix(filePath);
196912
+ for (const line of content.split(/\r?\n/)) {
196913
+ if (!line.trim()) continue;
196914
+ let parsed;
196915
+ try {
196916
+ parsed = JSON.parse(line);
196917
+ } catch {
196918
+ continue;
196919
+ }
196920
+ if (!isSessionEventLine(parsed) || parsed.payload?.type !== "user_message") continue;
196921
+ const firstUserMessage = trimTitle(normalizeFreeText(parsed.payload.message || ""));
196922
+ if (firstUserMessage) return firstUserMessage;
196923
+ }
196924
+ } catch {
196925
+ }
196926
+ const dirName = trimTitle(path3.basename(cwd || ""));
196927
+ if (dirName) return dirName;
196928
+ return `Session ${threadId.slice(0, 8)}`;
196929
+ }
196887
196930
  function parseDesktopSession(filePath, threadNames, archivedThreadIds) {
196888
196931
  const firstLine = readFirstLine(filePath);
196889
196932
  if (!firstLine) return null;
@@ -196909,8 +196952,7 @@ function parseDesktopSession(filePath, threadNames, archivedThreadIds) {
196909
196952
  const lastEventAt = stat.mtime.toISOString();
196910
196953
  const firstSeenAt = parsed.payload.timestamp || parsed.timestamp || stat.birthtime.toISOString();
196911
196954
  const threadId = parsed.payload.id;
196912
- const title = threadNames.get(threadId);
196913
- if (!title) return null;
196955
+ const title = threadNames.get(threadId) || buildFallbackTitle(threadId, filePath, cwd);
196914
196956
  return {
196915
196957
  threadId,
196916
196958
  filePath,
@@ -196944,7 +196986,6 @@ function listDesktopSessions(limit = 12) {
196944
196986
  if (!fs3.existsSync(root)) return [];
196945
196987
  const archivedThreadIds = loadArchivedThreadIds();
196946
196988
  const threadNames = loadThreadNameIndex(archivedThreadIds);
196947
- if (threadNames.size === 0) return [];
196948
196989
  const files = [];
196949
196990
  walkSessionFiles(root, files);
196950
196991
  const sessions = [];
@@ -204421,6 +204462,7 @@ function getState() {
204421
204462
  running: false,
204422
204463
  startedAt: null,
204423
204464
  loopAborts: /* @__PURE__ */ new Map(),
204465
+ reconcileTimer: null,
204424
204466
  activeTasks: /* @__PURE__ */ new Map(),
204425
204467
  sessionLocks: /* @__PURE__ */ new Map(),
204426
204468
  autoStartChecked: false
@@ -204444,37 +204486,85 @@ function processWithSessionLock(sessionId, fn) {
204444
204486
  });
204445
204487
  return current;
204446
204488
  }
204447
- async function start() {
204489
+ function getActiveChannelTypes(state = getState()) {
204490
+ return Array.from(state.adapters.keys()).sort();
204491
+ }
204492
+ function notifyAdapterSetChanged() {
204493
+ const { lifecycle } = getBridgeContext();
204494
+ lifecycle.onBridgeAdaptersChanged?.(getActiveChannelTypes());
204495
+ }
204496
+ async function stopAdapterInstance(channelType) {
204448
204497
  const state = getState();
204449
- if (state.running) return;
204450
- const { store, lifecycle } = getBridgeContext();
204451
- const bridgeEnabled = store.getSetting("remote_bridge_enabled") === "true";
204452
- if (!bridgeEnabled) {
204453
- console.log("[bridge-manager] Bridge not enabled (remote_bridge_enabled != true)");
204454
- return;
204498
+ const adapter = state.adapters.get(channelType);
204499
+ if (!adapter) return;
204500
+ state.loopAborts.get(channelType)?.abort();
204501
+ state.loopAborts.delete(channelType);
204502
+ try {
204503
+ await adapter.stop();
204504
+ console.log(`[bridge-manager] Stopped adapter: ${channelType}`);
204505
+ } catch (err) {
204506
+ console.error(`[bridge-manager] Error stopping adapter ${channelType}:`, err);
204455
204507
  }
204508
+ state.adapters.delete(channelType);
204509
+ state.adapterMeta.delete(channelType);
204510
+ }
204511
+ async function syncConfiguredAdapters(options) {
204512
+ const state = getState();
204513
+ const { store } = getBridgeContext();
204514
+ let changed = false;
204456
204515
  for (const channelType of getRegisteredTypes()) {
204457
- const settingKey = `bridge_${channelType}_enabled`;
204458
- if (store.getSetting(settingKey) !== "true") continue;
204516
+ const enabled = store.getSetting(`bridge_${channelType}_enabled`) === "true";
204517
+ const existing = state.adapters.get(channelType);
204518
+ if (!enabled) {
204519
+ if (existing) {
204520
+ await stopAdapterInstance(channelType);
204521
+ changed = true;
204522
+ }
204523
+ continue;
204524
+ }
204525
+ if (existing) {
204526
+ continue;
204527
+ }
204459
204528
  const adapter = createAdapter(channelType);
204460
204529
  if (!adapter) continue;
204461
204530
  const configError = adapter.validateConfig();
204462
- if (!configError) {
204463
- registerAdapter(adapter);
204464
- } else {
204531
+ if (configError) {
204465
204532
  console.warn(`[bridge-manager] ${channelType} adapter not valid:`, configError);
204533
+ continue;
204466
204534
  }
204467
- }
204468
- let startedCount = 0;
204469
- for (const [type, adapter] of state.adapters) {
204470
204535
  try {
204536
+ state.adapters.set(channelType, adapter);
204537
+ state.adapterMeta.set(channelType, {
204538
+ lastMessageAt: null,
204539
+ lastError: null
204540
+ });
204471
204541
  await adapter.start();
204472
- console.log(`[bridge-manager] Started adapter: ${type}`);
204473
- startedCount++;
204542
+ console.log(`[bridge-manager] Started adapter: ${channelType}`);
204543
+ if (options.startLoops && state.running && adapter.isRunning()) {
204544
+ runAdapterLoop(adapter);
204545
+ }
204546
+ changed = true;
204474
204547
  } catch (err) {
204475
- console.error(`[bridge-manager] Failed to start adapter ${type}:`, err);
204548
+ state.adapters.delete(channelType);
204549
+ state.adapterMeta.delete(channelType);
204550
+ console.error(`[bridge-manager] Failed to start adapter ${channelType}:`, err);
204476
204551
  }
204477
204552
  }
204553
+ if (changed) {
204554
+ notifyAdapterSetChanged();
204555
+ }
204556
+ }
204557
+ async function start() {
204558
+ const state = getState();
204559
+ if (state.running) return;
204560
+ const { store, lifecycle } = getBridgeContext();
204561
+ const bridgeEnabled = store.getSetting("remote_bridge_enabled") === "true";
204562
+ if (!bridgeEnabled) {
204563
+ console.log("[bridge-manager] Bridge not enabled (remote_bridge_enabled != true)");
204564
+ return;
204565
+ }
204566
+ await syncConfiguredAdapters({ startLoops: false });
204567
+ const startedCount = state.adapters.size;
204478
204568
  if (startedCount === 0) {
204479
204569
  console.warn("[bridge-manager] No adapters started successfully, bridge not activated");
204480
204570
  state.adapters.clear();
@@ -204489,6 +204579,11 @@ async function start() {
204489
204579
  runAdapterLoop(adapter);
204490
204580
  }
204491
204581
  }
204582
+ state.reconcileTimer = setInterval(() => {
204583
+ void syncConfiguredAdapters({ startLoops: true }).catch((err) => {
204584
+ console.error("[bridge-manager] Adapter reconcile failed:", err);
204585
+ });
204586
+ }, 5e3);
204492
204587
  console.log(`[bridge-manager] Bridge started with ${startedCount} adapter(s)`);
204493
204588
  }
204494
204589
  async function stop() {
@@ -204496,27 +204591,37 @@ async function stop() {
204496
204591
  if (!state.running) return;
204497
204592
  const { lifecycle } = getBridgeContext();
204498
204593
  state.running = false;
204594
+ if (state.reconcileTimer) {
204595
+ clearInterval(state.reconcileTimer);
204596
+ state.reconcileTimer = null;
204597
+ }
204499
204598
  for (const [, abort] of state.loopAborts) {
204500
204599
  abort.abort();
204501
204600
  }
204502
204601
  state.loopAborts.clear();
204503
- for (const [type, adapter] of state.adapters) {
204504
- try {
204505
- await adapter.stop();
204506
- console.log(`[bridge-manager] Stopped adapter: ${type}`);
204507
- } catch (err) {
204508
- console.error(`[bridge-manager] Error stopping adapter ${type}:`, err);
204509
- }
204602
+ for (const type of Array.from(state.adapters.keys())) {
204603
+ await stopAdapterInstance(type);
204510
204604
  }
204511
- state.adapters.clear();
204512
- state.adapterMeta.clear();
204513
204605
  state.startedAt = null;
204514
204606
  lifecycle.onBridgeStop?.();
204515
204607
  console.log("[bridge-manager] Bridge stopped");
204516
204608
  }
204517
- function registerAdapter(adapter) {
204609
+ function getStatus() {
204518
204610
  const state = getState();
204519
- state.adapters.set(adapter.channelType, adapter);
204611
+ return {
204612
+ running: state.running,
204613
+ startedAt: state.startedAt,
204614
+ adapters: Array.from(state.adapters.entries()).map(([type, adapter]) => {
204615
+ const meta = state.adapterMeta.get(type);
204616
+ return {
204617
+ channelType: adapter.channelType,
204618
+ running: adapter.isRunning(),
204619
+ connectedAt: state.startedAt,
204620
+ lastMessageAt: meta?.lastMessageAt ?? null,
204621
+ error: meta?.lastError ?? null
204622
+ };
204623
+ })
204624
+ };
204520
204625
  }
204521
204626
  function runAdapterLoop(adapter) {
204522
204627
  const state = getState();
@@ -205256,6 +205361,7 @@ function now() {
205256
205361
  }
205257
205362
  var JsonFileStore = class {
205258
205363
  settings;
205364
+ dynamicSettings;
205259
205365
  sessions = /* @__PURE__ */ new Map();
205260
205366
  bindings = /* @__PURE__ */ new Map();
205261
205367
  messages = /* @__PURE__ */ new Map();
@@ -205264,8 +205370,9 @@ var JsonFileStore = class {
205264
205370
  dedupKeys = /* @__PURE__ */ new Map();
205265
205371
  locks = /* @__PURE__ */ new Map();
205266
205372
  auditLog = [];
205267
- constructor(settingsMap) {
205373
+ constructor(settingsMap, options) {
205268
205374
  this.settings = settingsMap;
205375
+ this.dynamicSettings = options?.dynamicSettings === true;
205269
205376
  ensureDir2(DATA_DIR2);
205270
205377
  ensureDir2(MESSAGES_DIR);
205271
205378
  this.loadAll();
@@ -205360,7 +205467,19 @@ var JsonFileStore = class {
205360
205467
  return msgs;
205361
205468
  }
205362
205469
  // ── Settings ──
205470
+ refreshSettings() {
205471
+ if (!this.dynamicSettings) return;
205472
+ try {
205473
+ const next = configToSettings(loadConfig());
205474
+ this.settings = new Map([
205475
+ ...this.settings,
205476
+ ...next
205477
+ ]);
205478
+ } catch {
205479
+ }
205480
+ }
205363
205481
  getSetting(key) {
205482
+ this.refreshSettings();
205364
205483
  return this.settings.get(key) ?? null;
205365
205484
  }
205366
205485
  // ── Channel Bindings ──
@@ -205393,7 +205512,7 @@ var JsonFileStore = class {
205393
205512
  sdkSessionId: data.sdkSessionId ?? "",
205394
205513
  workingDirectory: data.workingDirectory,
205395
205514
  model: data.model,
205396
- mode: this.settings.get("bridge_default_mode") || "code",
205515
+ mode: this.getSetting("bridge_default_mode") || "code",
205397
205516
  active: true,
205398
205517
  createdAt: now(),
205399
205518
  updatedAt: now()
@@ -205441,7 +205560,7 @@ var JsonFileStore = class {
205441
205560
  const session = {
205442
205561
  id: uuid(),
205443
205562
  name,
205444
- working_directory: cwd || this.settings.get("bridge_default_work_dir") || process.cwd(),
205563
+ working_directory: cwd || this.getSetting("bridge_default_work_dir") || process.cwd(),
205445
205564
  model,
205446
205565
  system_prompt: systemPrompt
205447
205566
  };
@@ -206248,13 +206367,16 @@ function writeStatus(info) {
206248
206367
  fs9.writeFileSync(tmp, JSON.stringify(merged, null, 2), "utf-8");
206249
206368
  fs9.renameSync(tmp, STATUS_FILE);
206250
206369
  }
206370
+ function getRunningChannels() {
206371
+ return getStatus().adapters.map((adapter) => adapter.channelType).sort();
206372
+ }
206251
206373
  async function main() {
206252
206374
  const config2 = loadConfig();
206253
206375
  setupLogger();
206254
206376
  const runId = crypto9.randomUUID();
206255
206377
  console.log(`[codex-to-im] Starting bridge (run_id: ${runId})`);
206256
206378
  const settings = configToSettings(config2);
206257
- const store = new JsonFileStore(settings);
206379
+ const store = new JsonFileStore(settings, { dynamicSettings: true });
206258
206380
  const pendingPerms = new PendingPermissions();
206259
206381
  const llm = await resolveProvider(config2, pendingPerms);
206260
206382
  console.log(`[codex-to-im] Runtime: ${config2.runtime}`);
@@ -206269,17 +206391,27 @@ async function main() {
206269
206391
  onBridgeStart: () => {
206270
206392
  fs9.mkdirSync(RUNTIME_DIR, { recursive: true });
206271
206393
  fs9.writeFileSync(PID_FILE, String(process.pid), "utf-8");
206394
+ const channels = getRunningChannels();
206272
206395
  writeStatus({
206273
206396
  running: true,
206274
206397
  pid: process.pid,
206275
206398
  runId,
206276
206399
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
206277
- channels: config2.enabledChannels
206400
+ channels
206401
+ });
206402
+ console.log(`[codex-to-im] Bridge started (PID: ${process.pid}, channels: ${channels.join(", ")})`);
206403
+ },
206404
+ onBridgeAdaptersChanged: (channels) => {
206405
+ writeStatus({
206406
+ running: true,
206407
+ pid: process.pid,
206408
+ runId,
206409
+ channels
206278
206410
  });
206279
- console.log(`[codex-to-im] Bridge started (PID: ${process.pid}, channels: ${config2.enabledChannels.join(", ")})`);
206411
+ console.log(`[codex-to-im] Active channels updated: ${channels.join(", ") || "none"}`);
206280
206412
  },
206281
206413
  onBridgeStop: () => {
206282
- writeStatus({ running: false });
206414
+ writeStatus({ running: false, channels: [] });
206283
206415
  console.log("[codex-to-im] Bridge stopped");
206284
206416
  }
206285
206417
  }