@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.12

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 (88) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +34 -60
  3. package/dist/bin/migrate-relay-db.mjs +104 -0
  4. package/dist/bin/self-relay-server.mjs +17 -10
  5. package/dist/shared/app-server-events.cjs +15 -3
  6. package/dist/shared/file-mentions.cjs +35 -0
  7. package/dist/shared/server-requests.cjs +86 -58
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +6 -11
  10. package/dist/src/file-upload-session.mjs +124 -0
  11. package/dist/src/lan-socket-server.mjs +51 -8
  12. package/dist/src/managed-app-server.mjs +21 -10
  13. package/dist/src/onboarding.mjs +3 -3
  14. package/dist/src/platform/windows/codex-runtime.mjs +138 -4
  15. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  16. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  17. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  18. package/dist/src/platform/windows/resident-start.js +8 -0
  19. package/dist/src/platform/windows/resident-startup.ps1 +6 -3
  20. package/dist/src/platform/windows/service-host.mjs +0 -9
  21. package/dist/src/self-relay/admin-page.mjs +1 -1
  22. package/dist/src/self-relay/admin-state.mjs +8 -6
  23. package/dist/src/self-relay/connector.mjs +84 -12
  24. package/dist/src/self-relay/email-accounts.mjs +125 -0
  25. package/dist/src/self-relay/lifecycle.mjs +52 -100
  26. package/dist/src/self-relay/mysql-state.mjs +104 -0
  27. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  28. package/dist/src/self-relay/server.mjs +155 -38
  29. package/dist/src/server.mjs +736 -610
  30. package/dist/src/service-doctor.mjs +1 -1
  31. package/dist/src/service-install.mjs +1 -4
  32. package/dist/src/shared-app-server.mjs +274 -272
  33. package/dist/src/shared-catalog.mjs +1 -11
  34. package/dist/src/shared-recovery.mjs +5 -5
  35. package/dist/src/skill-install.mjs +0 -13
  36. package/package.json +2 -1
  37. package/dist/src/debug-ui.mjs +0 -131
  38. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  39. package/dist/web/activity-view.js +0 -283
  40. package/dist/web/approval-ui.js +0 -188
  41. package/dist/web/capabilities.js +0 -50
  42. package/dist/web/chat-transport.js +0 -196
  43. package/dist/web/chat.css +0 -778
  44. package/dist/web/chat.js +0 -3815
  45. package/dist/web/client-platform.js +0 -11
  46. package/dist/web/community.css +0 -82
  47. package/dist/web/community.html +0 -36
  48. package/dist/web/composer-controller.js +0 -97
  49. package/dist/web/conversation-controller.js +0 -99
  50. package/dist/web/disclosure-state-controller.js +0 -95
  51. package/dist/web/draft-controller.js +0 -103
  52. package/dist/web/harmony-platform.js +0 -52
  53. package/dist/web/highlight-powershell.min.js +0 -39
  54. package/dist/web/highlight.min.js +0 -1262
  55. package/dist/web/history-cache.js +0 -240
  56. package/dist/web/history-controller.js +0 -167
  57. package/dist/web/index.html +0 -288
  58. package/dist/web/lan-session.js +0 -122
  59. package/dist/web/link-action-controller.js +0 -212
  60. package/dist/web/message-send-controller.js +0 -177
  61. package/dist/web/message-view.js +0 -98
  62. package/dist/web/p2p-data-channel.js +0 -50
  63. package/dist/web/p2p-diagnostics.js +0 -28
  64. package/dist/web/p2p-probe.js +0 -123
  65. package/dist/web/page-resume.js +0 -28
  66. package/dist/web/pending-message-store.js +0 -108
  67. package/dist/web/queue-controller.js +0 -82
  68. package/dist/web/resources.json +0 -1
  69. package/dist/web/self-relay-session.js +0 -129
  70. package/dist/web/server-requests.js +0 -67
  71. package/dist/web/station-connection-controller.js +0 -75
  72. package/dist/web/task-list-view.js +0 -289
  73. package/dist/web/thread-attention-controller.js +0 -124
  74. package/dist/web/thread-context-controller.js +0 -30
  75. package/dist/web/thread-list-controller.js +0 -61
  76. package/dist/web/thread-list-sync.js +0 -86
  77. package/dist/web/thread-title-controller.js +0 -57
  78. package/dist/web/timeline-formatters.js +0 -249
  79. package/dist/web/timeline-reducer.js +0 -77
  80. package/dist/web/timeline-renderer.js +0 -161
  81. package/dist/web/timeline-scroll-controller.js +0 -50
  82. package/dist/web/timeline-sync.js +0 -67
  83. package/dist/web/usage-controller.js +0 -258
  84. package/dist/web/vendor/README.md +0 -14
  85. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  86. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  87. package/dist/web/vendor/highlight.min.js +0 -1262
  88. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # Code Relax Bridge(Windows 私测包)
2
-
3
- Bridge 让 HarmonyOS 客户端控制 Windows 上的 Codex Desktop。当前要求 Windows x64、Node >=22.12 和已登录的 Codex Desktop;npm 包不包含 Node,也不修改官方程序文件。
4
-
5
- ## 安装
6
-
1
+ # Code Relax Bridge(Windows 私测包)
2
+
3
+ Bridge 让 HarmonyOS 客户端控制 Windows 上的受管 Codex 内核。当前要求 Windows x64、Node >=22.12,以及已登录的 Codex Desktop 配套内核或独立 Codex CLI;npm 包不包含 Node,也不修改官方程序文件。
4
+
5
+ ## 安装
6
+
7
7
  公开 beta 已发布到 npm,安装或更新使用:
8
8
 
9
9
  ```powershell
@@ -11,72 +11,62 @@ npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax --foreground
11
11
  ```
12
12
 
13
13
  首次安装前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codex。更新无需先停止正在运行的 Bridge;安装器会短暂停止、替换并恢复服务。npm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
14
-
15
- `setup` 立即启动 Bridge 和固定 45839 受管 app-server,并启用当前用户登录自启;自启不打开 Codex。受管实例健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍在运行,不中断现有任务,允许两个 app-server 暂时共存,并在手机提示立即或稍后重启。
16
-
14
+
15
+ `setup` 立即启动 Bridge 和固定 45839 受管 app-server,并启用当前用户登录自启;自启不打开 Codex。受管实例健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍未接入,安装器会关闭并重新打开 Desktop;安装前应保存工作。普通更新无接入或启动链迁移需求时复用现有内核。
16
+
17
17
  ## Codex 内核
18
-
19
- ```powershell
20
- relax shortcuts # 创建 Codex 内核桌面快捷方式
21
- relax shared-start # Codex 内核、Desktop、Bridge
22
- relax start # 启动/复用常驻 Bridge 和已配置中继
18
+
19
+ ```powershell
20
+ relax start # 启动/复用 Bridge、受管 Codex 内核和已配置中继
23
21
  relax pair # 启动/复用服务并打开配对二维码
24
22
  relax status
25
23
  relax stop
26
- ```
27
-
24
+ ```
25
+
28
26
  Codex 内核由官方 `codex app-server --listen ws://127.0.0.1:45839` 提供执行服务,Desktop 和 Bridge 均可连接;它只监听回环地址。Bridge 独立报告内核健康、Desktop 接入和手机可写状态;Desktop 退出后,手机仍可操作内核中的任务。同一 thread 已被另一个实例持有时,手机只读且可重新检测。
29
-
30
- 手机工作站详情可确认让 Desktop 接入 Codex 内核,独立进程逐步执行并保留 Bridge/中继连接,见[启用流程](../docs/shared-recovery.md)。本地 `shared-start` 在 Desktop 未接入受管服务时要求先自行退出 Desktop,不强制结束任务。Bridge 状态匹配且健康时直接复用;未知进程占用 45831/45839 时拒绝接管。`stop` 只停止 Bridge,不停止 Desktop 或受管 app-server。
31
-
27
+
28
+ 手机工作站详情可确认让 Desktop 接入 Codex 内核,独立进程逐步执行并保留 Bridge/中继连接,见[启用流程](../docs/shared-recovery.md)。本机恢复命令为 `relax shared-recover --confirm`,查看恢复结果用 `relax shared-recovery-status`。Bridge 状态匹配且健康时直接复用;未知进程占用 45831/45839 时拒绝接管。`stop` 只停止 Bridge,不停止 Desktop 或受管 app-server。
29
+
32
30
  受管 app-server 通过隐藏控制台启动,使后续控制台子进程继承隐藏窗口。手机的项目、任务列表和新建任务均使用受管内核,不依赖 Desktop 工具管道;项目和任务 ID 保持原生值。
33
-
31
+
34
32
  受管 app-server 未就绪时,手机停留在连接界面并显示“Codex 内核未就绪”;Bridge 保留健康和恢复接口,聊天请求返回错误。
35
-
36
- ## Code Relax 中继
37
-
38
- 私有中继仍需要本机 Bridge:
39
-
40
- ```powershell
41
- relax relay-shared-start --json
33
+
34
+ ## Code Relax 中继
35
+
36
+ 私有中继仍需要本机 Bridge:
37
+
38
+ ```powershell
39
+ relax start --json
42
40
  relax relay-start --json
43
41
  relax relay-status --json
44
42
  relax pair --json
45
43
  relax relay-stop --json
46
44
  relax relay-revoke --json
47
- ```
48
-
49
- `relay-shared-start` 先准备 Codex 内核和 Bridge,再连接公网私有中继。每台移动设备通过 `pair` 配对一次;新增设备不替换已有设备。连接器把 Windows hostname 和当前 Wi-Fi/有线网络候选经已认证 WebSocket 临时发送给手机,手机本地保存电脑名,只在设备身份验证通过后保存可达地址;服务器不持久化这些 Code Relax 中继 hostname、IP 或网络名。OpenAI 官方认证和设备配对仍直接访问 OpenAI;完成后,手机使用统一配对产生的设备凭据,通过独立元数据 WebSocket 把官方环境 ID、名称和 hostname 写入设备记录,不上传 OpenAI access token、配对码或登录态。停止连接器不撤销授权。
50
-
51
- ## Bridge 调试页面
52
-
53
- ```powershell
54
- relax dev --json
55
- relax dev-status --json
56
- relax dev-stop --json
57
- ```
58
-
59
- 调试页面与 Bridge 共用进程和端口(默认 45831),只额外提供白名单静态资源,用于局域网页面调试。`dev-stop` 只关闭静态页面,不影响 Bridge WebSocket。Code Relax 中继和 OpenAI 中继均使用 HAP 内置页面。
60
-
61
- ## 更新与卸载
62
-
63
- 更新固定服务:
64
-
45
+ ```
46
+
47
+ `start` 启动内核和 Bridge,并恢复已配置中继;`relay-start` 单独管理中继连接器。每台移动设备通过 `pair` 配对一次;新增设备不替换已有设备。连接器把 Windows hostname 和当前 Wi-Fi/有线网络候选经已认证 WebSocket 临时发送给手机,手机本地保存电脑名,只在设备身份验证通过后保存可达地址;服务器不持久化这些 Code Relax 中继 hostname、IP 或网络名。OpenAI 官方认证和设备配对仍直接访问 OpenAI;完成后,手机使用统一配对产生的设备凭据,通过独立元数据 WebSocket 把官方环境 ID、名称和 hostname 写入设备记录,不上传 OpenAI access token、配对码或登录态。停止连接器不撤销授权。
48
+
49
+ Bridge 的局域网端口仅提供鉴权 WebSocket,普通 HTTP 请求返回 404;手机聊天页面随 HAP 安装。
50
+
51
+ ## 更新与卸载
52
+
53
+ 更新固定服务:
54
+
65
55
  ```powershell
66
56
  npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax --foreground-scripts
67
57
  ```
68
58
 
69
59
  `setup` 先暂存并校验新版本,再短暂停止 Bridge/中继完成切换;原服务此前正在运行时会自动恢复,新版本启动失败则回滚并启动旧版本。无需提前执行 `stop`。Node 路径变化或原 Node 被删除后需重新 setup。升级可能因 Node 可执行文件路径变化而再次触发 Windows 防火墙授权。
70
-
71
- 卸载:
72
-
73
- ```powershell
60
+
61
+ 卸载:
62
+
63
+ ```powershell
74
64
  relax uninstall
75
65
  npm uninstall -g @walkhi/code-relax
76
- ```
77
-
66
+ ```
67
+
78
68
  `uninstall` 停止 Bridge 后先核对并清理 Code Relax 所有的当前用户 `CODEX_APP_SERVER_WS_URL`;用户后来修改或不归属 Code Relax 的值会保留。随后删除固定程序和本包管理且未修改的 Skill;保留其他配置、Token、日志、长期设备凭据、附件和自定义 Skill。它不卸载 Node、Codex Desktop,也不强制结束完整控制的执行进程。
79
-
80
- ## 发布边界
81
-
69
+
70
+ ## 发布边界
71
+
82
72
  registry 测试包使用 `@walkhi/code-relax@beta`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。安装摘要通过 `--foreground-scripts` 显示,首次安装或启动链迁移时干净重启 Desktop,分别报告已安装与实际运行版本;登录启动使用当前用户任务计划。通信协议仍为 7。详细规则见 [npm 分发](../docs/npm-distribution.md)。
@@ -7,8 +7,7 @@ import { installService, uninstallService, packageRoot } from '../src/service-in
7
7
  import { installSkill, removeSkill, inspectSkill } from '../src/skill-install.mjs';
8
8
  import { doctor } from '../src/service-doctor.mjs';
9
9
  import { launchDesktop } from '../src/desktop-launcher.mjs';
10
- import { desktopHost, desktopShortcuts, residentStartup, resolveStateRoot } from '../src/platform/windows/desktop-host.mjs';
11
- import { debugUi } from '../src/debug-ui.mjs';
10
+ import { desktopHost, removeDesktopShortcut, residentStartup, resolveStateRoot } from '../src/platform/windows/desktop-host.mjs';
12
11
  import { selfRelay } from '../src/self-relay/lifecycle.mjs';
13
12
  import { fileURLToPath } from 'node:url';
14
13
  import path from 'node:path';
@@ -18,32 +17,28 @@ import { setTimeout as delay } from 'node:timers/promises';
18
17
  import { requestRecovery, recoveryStatus, recoveryWarning } from '../src/shared-recovery.mjs';
19
18
  import { configureManagedEnvironment, clearManagedEnvironment } from '../src/platform/windows/user-environment.mjs';
20
19
  import { resolveCodexRuntime } from '../src/platform/windows/codex-runtime.mjs';
20
+ import { controlRequest } from '../src/service-control.mjs';
21
+ import { processAlive } from '../src/background-process.mjs';
21
22
 
22
23
  const help = `Code Relax(Windows)
23
- 用法:relax <setup|doctor|skill-install|skill-remove|uninstall|start|shared-start|shortcuts|pair|status|stop> [--json]
24
+ 用法:relax <命令> [--json];下列命令均可用
24
25
  setup 部署并启动固定目录服务、受管 app-server 和登录自启动;安全配置 Desktop 后续接入
25
- start 启动或复用 Bridge,并恢复已配置的私有中继
26
- shared-start 启动或复用 Codex 内核,再启动 Desktop、Bridge 和已配置的私有中继
26
+ start 启动或复用 Bridge 和 Codex 内核,并恢复已配置的私有中继
27
27
  shared-recover --confirm 重新打开 Desktop 并接入 Codex 内核;不强制重启已有内核
28
28
  shared-recovery-status 查看最近一次恢复的实际阶段和结果
29
29
  autostart-enable / autostart-disable / autostart-status 管理当前用户登录时启动 Bridge
30
- shortcuts 创建 Codex 内核桌面快捷方式
31
30
  pair 启动或复用工作站服务,并打开一次性配对二维码
32
31
  status 查看服务健康状态
33
32
  stop 停止服务
34
- dev 在 Bridge 端口开启调试页面(实时读取当前源码/包资源)
35
- dev-status 查看 Bridge 调试页面状态
36
- dev-stop 关闭调试页面,不停止 Bridge
37
33
  relay-start 启动或复用私有中继,已有授权直接恢复,首次授权生成二维码
38
- relay-shared-start 启动 Codex 内核和 Bridge 后连接私有中继(需先退出当前 Codex)
39
- relay-status 查看私有中继连接器及手机连接状态
34
+ relay-status 查看私有中继连接器及移动端连接状态
40
35
  relay-stop 停止私有中继连接器,不停止 Bridge 或 Codex
41
36
  relay-revoke 撤销这台电脑的全部移动设备授权(停止连接器不会撤销授权)
42
37
  doctor 检查安装、Node、Codex 内核、网络和 Skill,提示防火墙处理方式
43
38
  skill-install 安装/更新当前用户的 Codex Skill,不覆盖自定义文件
44
39
  skill-remove 移除本包安装且未修改的 Skill
45
40
  uninstall 停止并移除固定目录服务和本包 Skill,清理自有 Desktop 环境变量,保留其他配置与附件
46
- 默认监听 0.0.0.0,手机与电脑需在同一受信任局域网。
41
+ 默认监听 0.0.0.0,移动端与电脑需在同一受信任局域网。
47
42
  --json 输出机器可读结果;统一二维码不含 LAN 地址、Bridge Token 或中继地址。`;
48
43
 
49
44
  async function main() {
@@ -52,7 +47,7 @@ async function main() {
52
47
  console.log(help);
53
48
  return;
54
49
  }
55
- if (!['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'relay-shared-start', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop', 'dev', 'dev-status', 'dev-stop', 'setup', 'doctor', 'skill-install', 'skill-remove', 'uninstall', 'shared-start', 'shortcuts', 'start', 'pair', 'status', 'stop'].includes(command)
50
+ if (!['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop', 'setup', 'doctor', 'skill-install', 'skill-remove', 'uninstall', 'start', 'pair', 'status', 'stop'].includes(command)
56
51
  || flags.some(flag => flag !== '--json' && !(command === 'shared-recover' && flag === '--confirm'))) {
57
52
  throw new Error('无效命令或参数。使用 --help 查看用法。');
58
53
  }
@@ -60,30 +55,13 @@ async function main() {
60
55
  const json = flags.includes('--json');
61
56
  const paths = runtimePaths();
62
57
  const { stateRoot, serviceRoot } = paths;
63
- if (['relay-shared-start', 'relay-revoke', 'relay-start', 'relay-status', 'relay-stop'].includes(command)) {
64
- if (command === 'relay-shared-start') {
65
- const existing = await selfRelay('relay-status', stateRoot);
66
- if (existing.running && existing.transportMode !== 'lan-shared') throw new Error('旧连接器仍在运行,请先执行 relay-stop;不会自动中断手机连接。');
67
- const result = await new Promise((resolve, reject) => {
68
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'shared-start', '--json'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
69
- let output = '', errors = '';
70
- child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { errors += chunk; });
71
- child.once('error', reject);
72
- child.once('exit', code => { if (code) reject(new Error(errors.trim().replace(/^(?:Code Relax:)+/u, '') || 'Codex 内核启动失败')); else { try { resolve(JSON.parse(output)); } catch { reject(new Error('Codex 内核启动结果无法确认,未启动中继')); } } });
73
- });
74
- if (!result.bridgeReady) { console.log(json ? JSON.stringify(result) : result.displayText); return; }
75
- }
76
- const result = await selfRelay(command === 'relay-shared-start' ? 'relay-start' : command, stateRoot, command === 'relay-shared-start' ? 'lan-shared' : '');
58
+ if (['relay-revoke', 'relay-start', 'relay-status', 'relay-stop'].includes(command)) {
59
+ const result = await selfRelay(command, stateRoot);
77
60
  console.log(json ? JSON.stringify(result) : `${result.displayText}${result.qrPath ? '\n二维码:' + result.qrPath : ''}`);
78
61
  return;
79
62
  }
80
- if (['dev', 'dev-status', 'dev-stop'].includes(command)) {
81
- const result = await debugUi(command, stateRoot);
82
- console.log(json ? JSON.stringify(result) : result.message || JSON.stringify(result));
83
- return;
84
- }
85
63
  const installedEntry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
86
- if (['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'shared-start', 'shortcuts', 'start', 'pair', 'status', 'stop'].includes(command) && path.resolve(fileURLToPath(import.meta.url)).toLowerCase() !== installedEntry.toLowerCase()) {
64
+ if (['autostart-enable', 'autostart-disable', 'autostart-status', 'shared-recover', 'shared-recovery-status', 'start', 'pair', 'status', 'stop'].includes(command) && path.resolve(fileURLToPath(import.meta.url)).toLowerCase() !== installedEntry.toLowerCase()) {
87
65
  if (!fs.existsSync(installedEntry)) throw new Error('尚未部署服务,请先执行 relax setup。');
88
66
  const executable = readRecord(path.join(serviceRoot, 'installation.json'))?.nodeExecutable;
89
67
  if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新执行 relax setup。');
@@ -92,7 +70,6 @@ async function main() {
92
70
  process.exitCode = code ?? 1;
93
71
  return;
94
72
  }
95
- host.migrateState(stateRoot);
96
73
  const service = lifecycle({ stateRoot, host });
97
74
  if (command.startsWith('autostart-')) {
98
75
  const installation = readRecord(path.join(serviceRoot, 'installation.json'));
@@ -122,13 +99,6 @@ async function main() {
122
99
  console.log(json ? JSON.stringify(result) : `${result.displayText}\n二维码:${result.qrPath}`);
123
100
  return;
124
101
  }
125
- if (command === 'shortcuts') {
126
- const installation = readRecord(path.join(serviceRoot, 'installation.json'));
127
- if (!installation?.nodeExecutable) throw new Error('请先执行 relax setup。');
128
- const shortcuts = await desktopShortcuts({ serviceRoot, nodeExecutable: installation.nodeExecutable });
129
- console.log(json ? JSON.stringify({ shortcuts }) : `桌面快捷方式已创建:\n${shortcuts.join('\n')}`);
130
- return;
131
- }
132
102
  if (command === 'doctor') {
133
103
  const result = await doctor({ paths, host, service });
134
104
  console.log(json ? JSON.stringify(result) : result.checks.map(item => `[${item.status}] ${item.message}`).join('\n'));
@@ -148,7 +118,7 @@ async function main() {
148
118
  const result = await uninstallService(paths, service, async () => {
149
119
  environment = await clearManagedEnvironment(stateRoot);
150
120
  const installation = readRecord(path.join(serviceRoot, 'installation.json'));
151
- if (installation?.nodeExecutable) await desktopShortcuts({ serviceRoot, nodeExecutable: installation.nodeExecutable }, true);
121
+ if (installation?.nodeExecutable) await removeDesktopShortcut({ serviceRoot, nodeExecutable: installation.nodeExecutable });
152
122
  if (installation?.nodeExecutable) await residentStartup({ serviceRoot, nodeExecutable: installation.nodeExecutable }, 'disable');
153
123
  });
154
124
  result.environment = environment;
@@ -164,18 +134,21 @@ async function main() {
164
134
  if (major < 22 || (major === 22 && minor < 12)) throw new Error('需要 Node >=22.12。');
165
135
  const physicalStateRoot = await resolveStateRoot(stateRoot);
166
136
  // Preserve the effective package view; never replace a newer file already in it.
167
- for (const name of ['bridge-config.json', 'bridge-session.json', 'bridge-startup-context.json', 'execution-mode.json',
168
- 'self-relay-config.json', 'self-relay-session.json', 'lan-device-authorizations.json', 'shared-desktop.json',
169
- 'managed-app-server.json', 'managed-environment.json', 'shared-recovery.json', 'shared-recovery.lock', 'debug-ui-session.json']) {
137
+ for (const name of ['bridge-config.json', 'bridge-session.json', 'bridge-startup-context.json',
138
+ 'self-relay-config.json', 'self-relay-session.json', 'lan-device-authorizations.json',
139
+ 'managed-app-server.json', 'managed-environment.json', 'shared-recovery.json', 'shared-recovery.lock']) {
170
140
  const source = path.join(stateRoot, name), target = path.join(physicalStateRoot, name);
171
141
  if (fs.existsSync(source) && !fs.existsSync(target)) fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
172
142
  }
173
143
  const hadInstalledService = fs.existsSync(path.join(serviceRoot, 'installation.json'));
144
+ const previousInstallation = readRecord(path.join(serviceRoot, 'installation.json'));
145
+ if (previousInstallation?.nodeExecutable) await removeDesktopShortcut({ serviceRoot, nodeExecutable: previousInstallation.nodeExecutable });
174
146
  const desktopBeforeSetup = await desktopHost.inspect();
175
147
  const runtime = await resolveCodexRuntime(physicalStateRoot, { refresh: true });
176
148
  const desktopNeedsAttach = desktopBeforeSetup.running && !await host.sharedServiceUrl();
177
149
  const previous = hadInstalledService ? await service.status() : { processRunning: false };
178
- const previousRelay = await selfRelay('relay-status', stateRoot);
150
+ const legacyRelaySession = readRecord(path.join(stateRoot, 'self-relay-session.json'));
151
+ const previousRelay = legacyRelaySession ? await controlRequest(legacyRelaySession) : await selfRelay('relay-status', stateRoot);
179
152
  const resumeAfterUpdate = previous.processRunning || previousRelay.running;
180
153
  const executable = fs.realpathSync(process.execPath);
181
154
  const startInstalled = async () => {
@@ -195,7 +168,14 @@ async function main() {
195
168
  beforeActivate: async () => {
196
169
  if (desktopNeedsAttach) await desktopHost.closeDesktop(desktopBeforeSetup.desktop);
197
170
  stoppedForUpdate = resumeAfterUpdate;
198
- if (previousRelay.running) await selfRelay('relay-stop', stateRoot);
171
+ if (previousRelay.running && legacyRelaySession) {
172
+ await controlRequest(legacyRelaySession, 'POST');
173
+ const deadline = Date.now() + 10000;
174
+ while (processAlive(legacyRelaySession.pid)) {
175
+ if (Date.now() >= deadline) throw new Error('旧中继进程未在 10 秒内退出。');
176
+ await delay(100);
177
+ }
178
+ } else if (previousRelay.running) await selfRelay('relay-stop', stateRoot);
199
179
  if (previous.processRunning) await service.stop();
200
180
  },
201
181
  verifyActivation: async () => { await startInstalled(); },
@@ -240,7 +220,7 @@ async function main() {
240
220
  managedAppServerMigrated = true;
241
221
  managedAppServerMigration.restarted = true;
242
222
  if (desktopAttached) {
243
- await launchDesktop('shared', { stateRoot: physicalStateRoot, host: desktopHost });
223
+ await launchDesktop({ stateRoot: physicalStateRoot, host: desktopHost });
244
224
  desktopRestarted = true;
245
225
  desktopRestartReason = 'app-server-migration';
246
226
  bridge = await service.status();
@@ -250,7 +230,7 @@ async function main() {
250
230
  if (desktopNeedsAttach && !desktopRestarted) {
251
231
  const currentDesktop = await desktopHost.inspect();
252
232
  if (currentDesktop.running) await desktopHost.closeDesktop(currentDesktop.desktop);
253
- await launchDesktop('shared', { stateRoot: physicalStateRoot, host: desktopHost });
233
+ await launchDesktop({ stateRoot: physicalStateRoot, host: desktopHost });
254
234
  desktopRestarted = true;
255
235
  desktopRestartReason = 'attach-managed-server';
256
236
  bridge = await service.status();
@@ -269,7 +249,7 @@ async function main() {
269
249
  const migrationText = managedAppServerMigrated ? '已自动重建旧启动链的 app-server;安装前尚未完成的任务可能已中断。\n' : '';
270
250
  const restartText = desktopRestarted
271
251
  ? 'Codex Desktop 已干净重启并重新接入受管 app-server。'
272
- : desktopBeforeSetup.desktop ? 'Codex Desktop 使用同一受管 app-server;手机控制不依赖 Desktop 是否打开。'
252
+ : desktopBeforeSetup.desktop ? 'Codex Desktop 使用同一受管 app-server;移动端控制不依赖 Desktop 是否打开。'
273
253
  : '使用独立 Codex CLI;无需安装 Codex Desktop。';
274
254
  console.log(json ? JSON.stringify({ ...installed, nextSteps: desktopSetupSteps, displayText: `${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}` }) : `服务已安装:${installed.serviceRoot}\n使用已安装的 Node:${installed.nodeExecutable}\n受管 app-server 已就绪;首次联网可能需要 Windows 防火墙授权。\n${migrationText}${desktopSetupSteps.join('\n')}\n${restartText}`);
275
255
  return;
@@ -279,26 +259,20 @@ async function main() {
279
259
  console.log(json ? JSON.stringify(result) : result.message);
280
260
  return;
281
261
  }
282
- let desktop;
283
- if (command === 'shared-start') {
284
- if (!json) console.log('正在检查 Codex 的启动模式…');
285
- desktop = await launchDesktop('shared', { stateRoot, host: desktopHost });
286
- delete process.env.CODEX_APP_TOOLS_PIPE_PATH;
287
- }
288
- if (!json && (desktop || command === 'start')) console.log('正在检查电脑连接并准备 Bridge…');
289
- const result = await service[desktop ? 'start' : command]();
262
+ if (!json && command === 'start') console.log('正在检查电脑连接并准备 Bridge…');
263
+ const result = await service[command]();
290
264
  if (command === 'status') {
291
265
  console.log(json ? JSON.stringify(result) : result.running
292
266
  ? `服务正常:${result.mode},版本 ${result.version || '未知'},端口 ${result.port},PID ${result.pid}` : '服务未就绪。');
293
267
  return;
294
268
  }
295
269
  const { accessUrl, qrPath: _legacyQrPath, ...metadata } = result;
296
- const notice = '本机服务检查通过,手机连通性尚未验证。若电脑弹出 Node 网络访问提示,请先处理 Windows 防火墙授权,再用手机连接。';
270
+ const notice = '本机服务检查通过,移动端连通性尚未验证。若电脑弹出 Node 网络访问提示,请先处理 Windows 防火墙授权,再用移动端连接。';
297
271
  let relay;
298
272
  if (fs.existsSync(path.join(stateRoot, 'self-relay-config.json'))) relay = await selfRelay('relay-start', stateRoot);
299
273
  const nextSteps = phonePairingSteps(metadata.transportMode, relay?.paired === true);
300
274
  const displayText = pairingDisplayText(nextSteps, notice);
301
- const output = { ...metadata, ...(desktop ? { desktop, bridgeReady: true } : {}), ...(relay ? { relayConnected: relay.relayConnected,
275
+ const output = { ...metadata, ...(relay ? { relayConnected: relay.relayConnected,
302
276
  paired: relay.paired, clientCount: relay.clientCount, ...(relay.qrPath ? { qrPath: relay.qrPath, expiresAt: relay.expiresAt } : {}) } : {}),
303
277
  nextSteps, displayText, mode: 'lan', listenAddress: '0.0.0.0', address: new URL(accessUrl).host,
304
278
  lanAccess: 'unverified', notice };
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import mysql from 'mysql2/promise';
6
+ import { deviceStore } from '../src/self-relay/device-store.mjs';
7
+ import { relayAdminState } from '../src/self-relay/admin-state.mjs';
8
+
9
+ const command = process.argv[2];
10
+ if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY_MYSQL_CONFIG_FILE) {
11
+ throw new Error('Use --schema, --import or --check with RELAY_MYSQL_CONFIG_FILE');
12
+ }
13
+ const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
14
+ const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
15
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
16
+ const migrations = ['001_relay_core', '002_email_accounts'];
17
+ try {
18
+ if (command === '--schema') {
19
+ const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
20
+ const [applied] = existing.length ? await db.query('SELECT version, sha256 FROM schema_migrations') : [[]];
21
+ const known = new Map(applied.map(row => [row.version, row.sha256]));
22
+ if (known.size !== applied.length || [...known.keys()].some(version => !migrations.includes(version))) throw new Error('Unexpected schema version');
23
+ for (const version of migrations) {
24
+ const source = fs.readFileSync(fileURLToPath(new URL(`../database/${version}.sql`, import.meta.url)), 'utf8');
25
+ const sha256 = createHash('sha256').update(source).digest('hex');
26
+ if (known.has(version)) {
27
+ if (known.get(version) !== sha256) throw new Error(`Migration changed: ${version}`);
28
+ continue;
29
+ }
30
+ if (version !== migrations[0] && !known.has(migrations[migrations.indexOf(version) - 1])) throw new Error('Previous migration missing');
31
+ const statements = source.split('\n').filter(line => !line.trimStart().startsWith('--')).join('\n');
32
+ for (const statement of statements.split(';').map(item => item.trim()).filter(Boolean)) await db.query(statement);
33
+ await db.execute('INSERT INTO schema_migrations (version, sha256) VALUES (?,?)', [version, sha256]);
34
+ known.set(version, sha256);
35
+ console.log(JSON.stringify({ schema: version, sha256 }));
36
+ }
37
+ } else {
38
+ const [versions] = await db.query('SELECT version FROM schema_migrations');
39
+ if (!versions.some(row => row.version === '001_relay_core') || versions.some(row => !migrations.includes(row.version))) throw new Error('Unexpected schema version');
40
+ if (command === '--check') {
41
+ const names = ['computers', 'mobile_authorizations', 'official_computers', 'announcements', 'relay_settings', 'users', 'user_login_identities'];
42
+ if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
43
+ const counts = {};
44
+ for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
45
+ if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
46
+ const devices = deviceStore(process.env.RELAY_STATE_FILE).entries();
47
+ const admin = relayAdminState(process.env.RELAY_ADMIN_STATE_FILE, 8).read();
48
+ const expected = { computers: devices.length,
49
+ mobile_authorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
50
+ official_computers: devices.filter(item => item.officialComputer).length,
51
+ announcements: admin.announcements.length, relay_settings: 1 };
52
+ if (Object.keys(expected).some(name => counts[name] !== expected[name])) throw new Error('Imported row counts differ from JSON');
53
+ const [saved] = await db.query('SELECT id,host_hash,status,revision FROM computers');
54
+ const original = new Map(devices.map(item => [item.id, item]));
55
+ for (const row of saved) {
56
+ const item = original.get(row.id);
57
+ if (!item || item.hostHash !== row.host_hash || item.status !== row.status || item.revision !== Number(row.revision)) {
58
+ throw new Error('Imported computer identity differs from JSON');
59
+ }
60
+ }
61
+ const [authorized] = await db.query('SELECT computer_id,client_id,credential_hash FROM mobile_authorizations');
62
+ for (const row of authorized) {
63
+ const item = original.get(row.computer_id)?.clients.find(client => client.id === row.client_id);
64
+ if (!item || item.hash !== row.credential_hash) throw new Error('Imported mobile authorization differs from JSON');
65
+ }
66
+ const [settings] = await db.query('SELECT max_relay_sessions FROM relay_settings WHERE id=1');
67
+ if (settings[0]?.max_relay_sessions !== admin.maxRelaySessions) throw new Error('Imported relay setting differs from JSON');
68
+ console.log(JSON.stringify({ verified: true, counts }));
69
+ } else console.log(JSON.stringify(counts));
70
+ } else {
71
+ const stateFile = process.env.RELAY_STATE_FILE, adminFile = process.env.RELAY_ADMIN_STATE_FILE;
72
+ if (!stateFile || !adminFile) throw new Error('RELAY_STATE_FILE and RELAY_ADMIN_STATE_FILE required for import');
73
+ const devices = deviceStore(stateFile).entries();
74
+ const admin = relayAdminState(adminFile, 8).read();
75
+ const [existing] = await db.query('SELECT (SELECT COUNT(*) FROM computers) AS computers, (SELECT COUNT(*) FROM relay_settings) AS settings');
76
+ if (existing[0].computers || existing[0].settings) throw new Error('Import target is not empty');
77
+ await db.beginTransaction();
78
+ try {
79
+ for (const item of devices) {
80
+ await db.execute('INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at) VALUES (?,?,?,?,?,?)',
81
+ [item.id, item.hostHash, item.status, item.revision, sqlTime(item.createdAt), sqlTime(item.updatedAt)]);
82
+ for (const client of item.clients) await db.execute(
83
+ 'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
84
+ [item.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
85
+ if (item.officialComputer) {
86
+ const official = item.officialComputer;
87
+ await db.execute('INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at) VALUES (?,?,?,?,?)',
88
+ [item.id, official.environmentId, official.name, official.hostname, sqlTime(official.updatedAt)]);
89
+ }
90
+ }
91
+ await db.execute('INSERT INTO relay_settings (id,max_relay_sessions,updated_at) VALUES (1,?,UTC_TIMESTAMP(3))', [admin.maxRelaySessions]);
92
+ for (const item of admin.announcements) await db.execute(
93
+ 'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
94
+ [item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
95
+ item.publishedAt ? sqlTime(item.publishedAt) : null]);
96
+ await db.commit();
97
+ } catch (error) { await db.rollback(); throw error; }
98
+ console.log(JSON.stringify({ imported: true, computers: devices.length,
99
+ mobileAuthorizations: devices.reduce((sum, item) => sum + item.clients.length, 0),
100
+ officialComputers: devices.filter(item => item.officialComputer).length,
101
+ announcements: admin.announcements.length }));
102
+ }
103
+ }
104
+ } finally { await db.end(); }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
- import { deviceStore, validId } from '../src/self-relay/device-store.mjs';
3
+ import { deviceStore, validId } from '../src/self-relay/device-store.mjs';
4
+ import { openRelayMysql } from '../src/self-relay/mysql-state.mjs';
4
5
  import { startSelfRelay } from '../src/self-relay/server.mjs';
5
6
  import { updateDebugAllowlist } from '../src/self-relay/debug-allowlist.mjs';
6
7
 
@@ -9,15 +10,18 @@ if (!keyFile) throw new Error('RELAY_HOST_KEY_FILE is required');
9
10
  const hostKey = fs.readFileSync(keyFile, 'utf8').trim();
10
11
  const port = Number(process.env.RELAY_PORT || 45850);
11
12
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Invalid RELAY_PORT');
12
- const stateFile = process.env.RELAY_STATE_FILE;
13
- if (!stateFile) throw new Error('RELAY_STATE_FILE is required');
13
+ const stateFile = process.env.RELAY_STATE_FILE;
14
+ const mysqlConfigFile = process.env.RELAY_MYSQL_CONFIG_FILE;
15
+ const accountConfigFile = process.env.RELAY_ACCOUNT_CONFIG_FILE;
16
+ if (!stateFile && !mysqlConfigFile) throw new Error('RELAY_STATE_FILE or RELAY_MYSQL_CONFIG_FILE is required');
14
17
  const debugAllowlistFile = process.env.RELAY_DEBUG_ALLOWLIST_FILE || '/etc/codex-relay/debug-allowlist.json';
15
18
  const adminStateFile = process.env.RELAY_ADMIN_STATE_FILE || '/var/lib/codex-relay/admin.json';
16
19
  const maxRelaySessions = Number(process.env.RELAY_MAX_RELAY_SESSIONS || 8);
17
20
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid RELAY_MAX_RELAY_SESSIONS');
18
21
  const debugCommand = process.argv.find(value => ['--debug-list', '--debug-allow', '--debug-deny'].includes(value));
19
22
  if (debugCommand) {
20
- const records = deviceStore(stateFile).entries();
23
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
24
+ const records = (database?.devices || deviceStore(stateFile)).entries();
21
25
  if (debugCommand === '--debug-list') {
22
26
  console.log(JSON.stringify(records.map(record => ({
23
27
  deviceId: record.id, status: record.status, clientCount: record.clients.length,
@@ -30,21 +34,24 @@ if (debugCommand) {
30
34
  updateDebugAllowlist(debugAllowlistFile, deviceId, debugCommand === '--debug-allow');
31
35
  console.log('Debug allowlist updated; restart relay to apply.');
32
36
  }
37
+ if (database) await database.close();
33
38
  process.exit(0);
34
39
  }
35
- if (process.argv.includes('--init-state')) { deviceStore(stateFile, { initialize: true }); console.log('Device store initialized'); process.exit(0); }
36
- const revokeIndex = process.argv.indexOf('--revoke-host');
37
- if (revokeIndex >= 0) {
40
+ if (process.argv.includes('--init-state')) { if (mysqlConfigFile) throw new Error('Use migrations for MySQL mode'); deviceStore(stateFile, { initialize: true }); console.log('Device store initialized'); process.exit(0); }
41
+ const revokeIndex = process.argv.indexOf('--revoke-host');
42
+ if (revokeIndex >= 0) {
38
43
  // Run only with the service stopped: this is an offline administrator operation.
39
44
  if (!process.argv.includes('--offline')) throw new Error('Stop the relay service and supply --offline');
40
45
  const id = process.argv[revokeIndex + 1];
41
46
  if (!validId(id)) throw new Error('Invalid device ID');
42
- const store = deviceStore(stateFile), record = store.entries().find(item => item.id === id);
47
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
48
+ const store = database?.devices || deviceStore(stateFile), record = store.entries().find(item => item.id === id);
43
49
  if (!record) throw new Error('Device not found');
44
- store.save({ ...record, status: 'revoked', clientHash: undefined, clientId: undefined, revision: record.revision + 1, updatedAt: new Date().toISOString() });
50
+ await store.save({ ...record, status: 'revoked', clients: [], revision: record.revision + 1, updatedAt: new Date().toISOString() });
51
+ if (database) await database.close();
45
52
  console.log('Computer and phone authorization revoked'); process.exit(0);
46
53
  }
47
- const relay = await startSelfRelay({ port, hostKey, stateFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
54
+ const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
48
55
  publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
49
56
  log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
50
57
  console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
@@ -1,14 +1,14 @@
1
1
  (function (root, factory) {
2
2
  if (typeof module === 'object' && module.exports) module.exports = factory();
3
3
  else root.CodexAppServerEvents = factory();
4
- })(globalThis, function () {
4
+ })(globalThis, function () {
5
5
  function mergeItems(existing, incoming = []) {
6
6
  for (const source of incoming) {
7
7
  const item = compactItem(source);
8
8
  const index = existing.findIndex(current => current.id === item.id);
9
9
  if (index < 0) existing.push(item);
10
10
  else existing[index] = { ...existing[index], ...item };
11
- }
11
+ }
12
12
  return existing;
13
13
  }
14
14
 
@@ -137,6 +137,18 @@
137
137
  }
138
138
  return match;
139
139
  }
140
+ function catalogThreadProjectId(thread, projects) {
141
+ if (thread.projectId) return thread.projectId;
142
+ const local = thread.environments?.find(environment => environment.environmentId === 'local');
143
+ if (local && Array.isArray(local.runtimeWorkspaceRoots)) {
144
+ for (const root of local.runtimeWorkspaceRoots) {
145
+ const projectId = threadProjectId({ cwd: root }, projects);
146
+ if (projectId) return projectId;
147
+ }
148
+ return null;
149
+ }
150
+ return threadProjectId({ cwd: thread.cwd }, projects);
151
+ }
140
152
  function projectlessThreadTarget(cwd = '') {
141
153
  return { projectId: null, runtimeWorkspaceRoots: [], ...(cwd ? { cwd } : {}) };
142
154
  }
@@ -289,6 +301,6 @@
289
301
  }
290
302
  return readQueue(request, threadId);
291
303
  }
292
- return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, projectlessThreadTarget, updateSettings,
304
+ return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
293
305
  permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions };
294
306
  });
@@ -0,0 +1,35 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ else root.CodexFileMentions = api;
5
+ })(globalThis, function () {
6
+ const heading = '# Files mentioned by the user:';
7
+ const request = '## My request:';
8
+
9
+ function parse(value) {
10
+ const source = typeof value === 'string' ? value : '';
11
+ if (!source.startsWith(heading)) return { text: source, files: [] };
12
+ const marker = source.indexOf(`\n${request}`, heading.length);
13
+ if (marker < 0) return { text: source, files: [] };
14
+ const files = [];
15
+ for (const line of source.slice(heading.length, marker).split(/\r?\n/)) {
16
+ const match = /^## (.+): ((?:[A-Za-z]:[\\/]|\/)[^\r\n]+)$/.exec(line);
17
+ if (!match) continue;
18
+ let name = match[1];
19
+ if (name.startsWith('"')) {
20
+ try { name = JSON.parse(name); } catch { continue; }
21
+ }
22
+ if (typeof name !== 'string' || !name) continue;
23
+ files.push({ type: 'file', name, path: match[2] });
24
+ }
25
+ if (!files.length) return { text: source, files: [] };
26
+ return { text: source.slice(marker + request.length + 1).trim(), files };
27
+ }
28
+
29
+ function wrap(prompt, files) {
30
+ if (!files?.length) return prompt;
31
+ return `${heading}\n${files.map(file => `\n## ${JSON.stringify(file.name)}: ${file.path}\n`).join('')}\n${request}\n${prompt || '请查看上述文件。'}`;
32
+ }
33
+
34
+ return Object.freeze({ parse, wrap });
35
+ });