@keyou007/agent-bot 0.1.1 → 0.1.3

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 (73) hide show
  1. package/.env.example +3 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +23 -6
  4. package/README.zh.md +23 -6
  5. package/config.example.yaml +1 -2
  6. package/dist/cli/FeishuAppRegistration.js +12 -2
  7. package/dist/cli/FeishuAppRegistration.js.map +1 -1
  8. package/dist/cli/Initializer.js +164 -14
  9. package/dist/cli/Initializer.js.map +1 -1
  10. package/dist/cli/LocalControlClient.js +14 -1
  11. package/dist/cli/LocalControlClient.js.map +1 -1
  12. package/dist/cli/ServerStarter.js +69 -0
  13. package/dist/cli/ServerStarter.js.map +1 -0
  14. package/dist/cli/help.js +6 -2
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/profile.js +40 -0
  17. package/dist/cli/profile.js.map +1 -0
  18. package/dist/cli.js +93 -89
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config/loadConfig.js +7 -4
  21. package/dist/config/loadConfig.js.map +1 -1
  22. package/dist/config/paths.js +1 -0
  23. package/dist/config/paths.js.map +1 -1
  24. package/dist/config/schema.js +2 -1
  25. package/dist/config/schema.js.map +1 -1
  26. package/dist/feishu/CardRenderer.js +21 -3
  27. package/dist/feishu/CardRenderer.js.map +1 -1
  28. package/dist/feishu/FeishuConnector.js +8 -11
  29. package/dist/feishu/FeishuConnector.js.map +1 -1
  30. package/dist/feishu/transport.js +4 -11
  31. package/dist/feishu/transport.js.map +1 -1
  32. package/dist/index.js +20 -8
  33. package/dist/index.js.map +1 -1
  34. package/dist/proxy/ProxySessionController.js +18 -3
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/startup/StartupNotifier.js +13 -2
  37. package/dist/startup/StartupNotifier.js.map +1 -1
  38. package/dist/startup/startFeishu.js +2 -1
  39. package/dist/startup/startFeishu.js.map +1 -1
  40. package/dist/supervision/SafeRestartNotifier.js +23 -7
  41. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  42. package/dist/supervision/SafeRestartScheduler.js +20 -2
  43. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  44. package/dist/supervision/SupervisorDiagnostics.js +209 -0
  45. package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
  46. package/dist/supervisor.js +54 -16
  47. package/dist/supervisor.js.map +1 -1
  48. package/docs/technical-reference.md +28 -14
  49. package/docs/technical-reference.zh.md +28 -14
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/skills/agent-bot/SKILL.md +24 -4
  53. package/src/cli/FeishuAppRegistration.ts +14 -1
  54. package/src/cli/Initializer.ts +174 -14
  55. package/src/cli/LocalControlClient.ts +12 -1
  56. package/src/cli/ServerStarter.ts +106 -0
  57. package/src/cli/help.ts +6 -2
  58. package/src/cli/profile.ts +59 -0
  59. package/src/cli.ts +114 -84
  60. package/src/config/loadConfig.ts +12 -4
  61. package/src/config/paths.ts +1 -0
  62. package/src/config/schema.ts +2 -1
  63. package/src/feishu/CardRenderer.ts +25 -3
  64. package/src/feishu/FeishuConnector.ts +8 -12
  65. package/src/feishu/transport.ts +7 -16
  66. package/src/index.ts +31 -8
  67. package/src/proxy/ProxySessionController.ts +18 -3
  68. package/src/startup/StartupNotifier.ts +21 -4
  69. package/src/startup/startFeishu.ts +2 -0
  70. package/src/supervision/SafeRestartNotifier.ts +22 -7
  71. package/src/supervision/SafeRestartScheduler.ts +27 -3
  72. package/src/supervision/SupervisorDiagnostics.ts +268 -0
  73. package/src/supervisor.ts +53 -15
@@ -33,6 +33,8 @@ Relevant source areas:
33
33
 
34
34
  The default user-data root is `~/.agent-bot`. `AGENT_BOT_HOME` replaces that root.
35
35
 
36
+ The CLI also supports explicit directory-based profiles. Without `--profile`, commands use the main profile and the normal environment-based path rules. `--profile <directory>` pins both `AGENT_BOT_HOME` and `AGENT_BOT_CONFIG` for the command and every spawned supervisor or worker, with the configuration fixed at `<directory>/config.yaml`. It also clears inherited Feishu credential variables before loading the selected profile's `.env`, which prevents a secondary service launched from inside the primary Agent Bot process tree from accidentally reusing the primary bot. It cannot be combined with `--config`. Alternative profiles must be selected explicitly on every command; Agent Bot does not maintain a named-profile registry.
37
+
36
38
  | Default path | Contents |
37
39
  | ------------------------------------ | ---------------------------------- |
38
40
  | `~/.agent-bot/config.yaml` | Main YAML configuration |
@@ -43,9 +45,10 @@ The default user-data root is `~/.agent-bot`. `AGENT_BOT_HOME` replaces that roo
43
45
 
44
46
  Configuration path precedence:
45
47
 
46
- 1. CLI `--config <path>`
47
- 2. `AGENT_BOT_CONFIG`
48
- 3. `~/.agent-bot/config.yaml`
48
+ 1. CLI `--profile <directory>` selects `<directory>/config.yaml`
49
+ 2. CLI `--config <path>`
50
+ 3. `AGENT_BOT_CONFIG`
51
+ 4. `<AGENT_BOT_HOME>/config.yaml`, defaulting to `~/.agent-bot/config.yaml`
49
52
 
50
53
  The default `.env` is always loaded from the Agent Bot home. YAML values in the form `${NAME}` are expanded from the process environment after `.env` loading.
51
54
 
@@ -59,11 +62,14 @@ Credential behavior:
59
62
 
60
63
  - Environment values take precedence over values in `~/.agent-bot/.env`.
61
64
  - Both `FEISHU_APP_ID` and `FEISHU_APP_SECRET` are required.
65
+ - One-click registration also stores the authorizing user's `open_id` as `FEISHU_USER_OPEN_ID`.
62
66
  - Complete credentials are preserved unless `--reconfigure-feishu` is used.
63
- - An incomplete credential pair is rejected instead of being silently replaced.
64
- - Newly registered credentials are written atomically to `.env`.
67
+ - Missing or incomplete credentials cause a new app registration.
68
+ - Newly registered credentials are fsynced, atomically replaced in `.env`, and read back before configuration begins.
69
+
70
+ Initialization holds `~/.agent-bot/init.lock` so concurrent commands cannot create multiple apps, including commands that use different config paths. A lock left by a dead process is recovered on the next run, and credential temporary files left by an interrupted write are removed.
65
71
 
66
- When credentials are absent, initialization uses Feishu one-click registration and reports its verification URL as text and a QR code. After registration, or when complete credentials already exist, it audits the currently published app version.
72
+ When complete credentials are absent, initialization uses Feishu one-click registration and reports its verification URL as text and a QR code. In-progress registration codes are not resumed: if the process exits before the complete credential pair is persisted, the next run starts a new app registration. After credentials are persisted, initialization audits the currently published app version and can safely resume that audit after interruption.
67
73
 
68
74
  Missing app configuration is handled in two stages:
69
75
 
@@ -72,6 +78,8 @@ Missing app configuration is handled in two stages:
72
78
 
73
79
  The generated authorization links do not contain the app secret.
74
80
 
81
+ After Feishu initialization succeeds, the CLI releases the initialization lock and starts the detached supervisor through the same readiness path as `agent-bot server start`. It waits up to 45 seconds for the worker to connect to Feishu and become ready. If the selected profile is already running, no second supervisor is created. `--skip-feishu` skips automatic server startup, and `--json` includes the resulting `server.status` without adding non-JSON output.
82
+
75
83
  ## Feishu App Requirements
76
84
 
77
85
  Core configuration required for basic messaging:
@@ -103,10 +111,9 @@ The complete example is [config.example.yaml](../config.example.yaml). The main
103
111
 
104
112
  ```yaml
105
113
  feishu:
106
- transport: "auto"
107
114
  appId: "${FEISHU_APP_ID}"
108
115
  appSecret: "${FEISHU_APP_SECRET}"
109
- useConsoleWhenMissingCredentials: true
116
+ userOpenId: "${FEISHU_USER_OPEN_ID}"
110
117
 
111
118
  console:
112
119
  enabled: true
@@ -131,11 +138,7 @@ logging:
131
138
  path: "./logs/agent-bot.log"
132
139
  ```
133
140
 
134
- `feishu.transport` accepts:
135
-
136
- - `auto`: use the SDK when credentials exist; otherwise follow `useConsoleWhenMissingCredentials`
137
- - `sdk`: require Feishu credentials and start the WebSocket client
138
- - `console`: disable Feishu transport
141
+ `agent-bot server start` requires both Feishu credentials. The worker starts the SDK's persistent connection without inspecting SDK logs or private connection state, then sends startup status cards as an outbound readiness check. It normally targets known private chats and recently active groups. If the database has no known chat yet, it sends the card to `feishu.userOpenId` using an `open_id` private message. When notification targets exist, at least one card must be delivered before the server reports ready; individual target failures remain isolated. If neither a known chat nor `feishu.userOpenId` is available, startup continues without the outbound check. Missing credentials still fail startup with an initialization hint. `agent-bot console` is the explicit local-only path and does not require Feishu credentials.
139
142
 
140
143
  At least one agent must be configured. `defaults.agent` must name a configured agent.
141
144
 
@@ -179,7 +182,7 @@ Starting a Feishu thread from a mapped user message, progress card, or final res
179
182
 
180
183
  Each turn owns one progress card. Normal updates are throttled to one every two seconds; critical updates have a 500 ms minimum gap. On completion, Agent Bot updates the progress card to a terminal state before sending a separate final Markdown message.
181
184
 
182
- After message deduplication, Agent Bot attempts an `OnIt` reaction. It replaces that reaction with `DONE`, `ERROR`, or `CrossMark` when the turn succeeds, fails, or is canceled. Reaction failures do not block task execution.
185
+ After the durable message-deduplication claim, Agent Bot awaits the `OnIt` reaction before chat persistence, image downloads, queue waits, command execution, or runtime calls. It replaces that reaction with `DONE`, `ERROR`, or `CrossMark` when the turn succeeds, fails, or is canceled. Reaction failures are logged but do not block task execution.
183
186
 
184
187
  Incoming rich-text images are downloaded into the inbound image cache and passed to Codex as `localImage` inputs. An image-only message uses the default Prompt `请分析这张图片。`. An ACP runtime that cannot accept image input returns an explicit error.
185
188
 
@@ -218,6 +221,15 @@ The final-delivery ledger prevents duplicate successful replies. App Server requ
218
221
 
219
222
  `agent-bot server start` starts a detached supervisor. The supervisor restarts the worker after unexpected exits and applies exponential backoff from one to 30 seconds after repeated crashes.
220
223
 
224
+ Supervisor crash diagnostics are isolated to the selected profile:
225
+
226
+ - `logs/supervisor.log` permanently records worker PIDs, exit codes, uptime, restart delays, and diagnostic artifact paths.
227
+ - `logs/worker.stderr.log` captures Node/V8 fatal output that would otherwise be lost by the detached process. Both diagnostic logs rotate at 10 MiB with three backups.
228
+ - `data/last-crash.json` points to the latest unexpected worker exit, while timestamped `data/crash-reports/crash-*.json` files preserve the history.
229
+ - `data/crash-reports/report.*.json` contains Node diagnostic reports when Node can generate one.
230
+
231
+ The supervisor, worker, replacement supervisor, and Console worker enable Node reports for fatal runtime errors and uncaught exceptions by default. Supported Node versions also exclude environment variables and network interfaces so credentials are not copied into reports. Intentional restart exit code `75` and stop exit code `76` do not create crash manifests.
232
+
221
233
  A safe restart waits for:
222
234
 
223
235
  1. Active tasks to finish
@@ -228,6 +240,8 @@ New messages reset the quiet timer. `--immediate` and `--force` skip these check
228
240
 
229
241
  The first safe-restart status card is delayed by three seconds so a task's final response can usually arrive first. Status changes during that window are coalesced into the initial card. The delay does not block scheduler polling, and shutdown flushes any pending card immediately.
230
242
 
243
+ Pending safe-restart cards include a bottom `Cancel` action. The action carries the scheduler's monotonic schedule ID, so an old card cannot cancel a newer restart. A successful cancellation updates every existing status card in place and removes the action; the action is also absent once the scheduler enters the irreversible restarting phase.
244
+
231
245
  The local control server implements task mutations and service restart requests. Read-only task queries access SQLite directly.
232
246
 
233
247
  ## Permission Modes
@@ -33,6 +33,8 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
33
33
 
34
34
  默认用户数据根目录为 `~/.agent-bot`,可通过 `AGENT_BOT_HOME` 整体替换。
35
35
 
36
+ CLI 还支持显式指定目录的 Profile。不使用 `--profile` 时,命令使用主 Profile 以及原有的环境变量路径规则。`--profile <目录>` 会为当前命令及其启动的 Supervisor、Worker 固定 `AGENT_BOT_HOME` 和 `AGENT_BOT_CONFIG`,配置文件固定为 `<目录>/config.yaml`。它还会先清除继承的飞书凭据环境变量,再加载所选 Profile 的 `.env`,避免从主 Agent Bot 进程树中启动辅助实例时误用主机器人的凭据。`--profile` 不能和 `--config` 同时使用。其他 Profile 必须在每次命令中显式选择;Agent Bot 不维护按名称注册的 Profile。
37
+
36
38
  | 默认路径 | 内容 |
37
39
  | ------------------------------------ | -------------------- |
38
40
  | `~/.agent-bot/config.yaml` | YAML 主配置 |
@@ -43,9 +45,10 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
43
45
 
44
46
  配置文件优先级:
45
47
 
46
- 1. CLI `--config <路径>`
47
- 2. `AGENT_BOT_CONFIG`
48
- 3. `~/.agent-bot/config.yaml`
48
+ 1. CLI `--profile <目录>` 选择 `<目录>/config.yaml`
49
+ 2. CLI `--config <路径>`
50
+ 3. `AGENT_BOT_CONFIG`
51
+ 4. `<AGENT_BOT_HOME>/config.yaml`,默认为 `~/.agent-bot/config.yaml`
49
52
 
50
53
  默认 `.env` 始终从 Agent Bot 用户目录加载。加载 `.env` 后,YAML 中的 `${NAME}` 会使用进程环境变量展开。
51
54
 
@@ -59,11 +62,14 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
59
62
 
60
63
  - 进程环境变量优先于 `~/.agent-bot/.env`
61
64
  - `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 必须同时存在
65
+ - 一键注册还会把授权用户的 `open_id` 保存为 `FEISHU_USER_OPEN_ID`
62
66
  - 除非使用 `--reconfigure-feishu`,完整凭据不会被替换
63
- - 凭据只有一项时会直接报错,不会静默覆盖
64
- - 新创建的凭据以原子方式写入 `.env`
67
+ - 缺少凭据或只有一项时会重新创建应用
68
+ - 新创建的凭据经过 fsync、原子替换写入 `.env`,并在配置权限前读回校验
69
+
70
+ 初始化期间会持有 `~/.agent-bot/init.lock`,避免使用不同配置路径的并发命令重复创建应用。进程异常退出遗留的锁会在下次运行时恢复,凭据写入中断留下的临时文件也会被清理。
65
71
 
66
- 缺少凭据时,初始化会启动飞书一键创建流程,并以文本和二维码显示验证链接。无论凭据是新创建还是已存在,之后都会检查应用当前已发布版本。
72
+ 没有完整凭据时,初始化会启动飞书一键创建流程,并以文本和二维码显示验证链接。注册中的设备码不会恢复:如果进程在完整凭据持久化前退出,下次会重新创建应用。凭据持久化后会检查应用当前已发布版本;这一权限审计阶段中断后可以安全继续。
67
73
 
68
74
  缺失配置分两阶段处理:
69
75
 
@@ -72,6 +78,8 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
72
78
 
73
79
  生成的授权链接不包含 App Secret。
74
80
 
81
+ 飞书初始化成功后,CLI 会先释放初始化锁,再通过与 `agent-bot server start` 相同的就绪检查流程启动后台 Supervisor,并等待最多 45 秒,直到 Worker 连接飞书并进入就绪状态。如果当前 Profile 的服务已经运行,不会创建第二个 Supervisor。`--skip-feishu` 会跳过自动启动;`--json` 会把结果写入 `server.status`,不会混入非 JSON 文本。
82
+
75
83
  ## 飞书应用要求
76
84
 
77
85
  基础消息能力依赖以下核心配置:
@@ -103,10 +111,9 @@ Agent Bot 是基于 Node.js 22+、ESM 和 TypeScript 的应用,主要组件如
103
111
 
104
112
  ```yaml
105
113
  feishu:
106
- transport: "auto"
107
114
  appId: "${FEISHU_APP_ID}"
108
115
  appSecret: "${FEISHU_APP_SECRET}"
109
- useConsoleWhenMissingCredentials: true
116
+ userOpenId: "${FEISHU_USER_OPEN_ID}"
110
117
 
111
118
  console:
112
119
  enabled: true
@@ -131,11 +138,7 @@ logging:
131
138
  path: "./logs/agent-bot.log"
132
139
  ```
133
140
 
134
- `feishu.transport` 支持:
135
-
136
- - `auto`:存在凭据时使用 SDK,否则按 `useConsoleWhenMissingCredentials` 决定是否回退 Console
137
- - `sdk`:要求飞书凭据并启动长连接客户端
138
- - `console`:禁用飞书传输
141
+ `agent-bot server start` 要求同时配置飞书 `appId` 和 `appSecret`。Worker 启动 SDK 长连接时不读取 SDK 日志或私有连接状态,随后通过发送启动状态卡片检查出站能力。通知通常发往已知私聊和最近活跃的群聊;数据库里尚无已知会话时,改用 `feishu.userOpenId`,按 `open_id` 私聊发送。存在通知目标时,至少一张卡片发送成功后 Server 才报告就绪,单个目标发送失败仍相互隔离。如果既没有已知会话,也没有 `feishu.userOpenId`,启动会跳过出站检查并继续。缺少凭据时仍会启动失败并提示先初始化。`agent-bot console` 是明确的纯本地入口,不需要飞书凭据。
139
142
 
140
143
  必须至少配置一个 Agent,`defaults.agent` 必须指向已配置的 Agent。
141
144
 
@@ -179,7 +182,7 @@ AGENT_BOT=1
179
182
 
180
183
  每个 turn 只有一张进度卡。普通更新最多每两秒一次,关键更新最短间隔 500 毫秒。完成时先把进度卡更新为终态,再单独发送 Markdown 最终回答。
181
184
 
182
- 消息去重后,Agent Bot 会尝试添加 `OnIt` 表情。Turn 成功、失败或取消时分别替换为 `DONE`、`ERROR` 或 `CrossMark`。表情操作失败不会阻塞任务。
185
+ 完成持久化消息去重占位后,Agent Bot 会等待 `OnIt` 表情添加成功,再进行聊天信息持久化、图片下载、队列等待、命令执行或 Runtime 调用。Turn 成功、失败或取消时分别替换为 `DONE`、`ERROR` 或 `CrossMark`。表情操作失败会记录日志,但不会阻塞任务。
183
186
 
184
187
  富文本图片会下载到输入图片缓存,并以 `localImage` 传给 Codex。纯图片消息使用默认 Prompt `请分析这张图片。`。ACP Runtime 不支持图片输入时会明确报错。
185
188
 
@@ -218,6 +221,15 @@ SQLite 保存:
218
221
 
219
222
  `agent-bot server start` 启动后台 supervisor。Worker 意外退出后会自动拉起;连续崩溃时使用 1 到 30 秒的指数退避。
220
223
 
224
+ Supervisor 的崩溃诊断文件按当前 Profile 隔离:
225
+
226
+ - `logs/supervisor.log` 持久记录 Worker PID、退出码、运行时长、重启延迟和诊断文件路径
227
+ - `logs/worker.stderr.log` 保存后台进程原本会丢失的 Node/V8 fatal 输出;两类诊断日志达到 10 MiB 时轮转,保留三个备份
228
+ - `data/last-crash.json` 指向最近一次 Worker 异常退出,带时间戳的 `data/crash-reports/crash-*.json` 保留历史记录
229
+ - Node 能生成报告时,写入 `data/crash-reports/report.*.json`
230
+
231
+ Supervisor、Worker、替换 Supervisor 和 Console Worker 默认启用 Node fatal error 与未捕获异常报告。Node 版本支持时还会排除环境变量和网络接口,避免凭据进入报告。主动重启退出码 `75` 和停止退出码 `76` 不会创建崩溃清单。
232
+
221
233
  安全重启会等待:
222
234
 
223
235
  1. 活动任务完成
@@ -228,6 +240,8 @@ SQLite 保存:
228
240
 
229
241
  首次安全重启状态卡会延迟 3 秒发送,让任务最终回答尽可能先到达;延迟期间的状态变化会合并到首张卡片。该延迟不阻塞调度器轮询,真正关闭前会立即 flush 尚未发送的卡片。
230
242
 
243
+ 等待中的安全重启状态卡底部带有 `Cancel` 操作。回调会携带调度器单调递增的计划 ID,因此旧卡片不会取消较新的重启。取消成功后,所有已发送的状态卡都会原地更新并移除按钮;调度器进入不可逆的正在重启阶段后也不再显示该按钮。
244
+
231
245
  本地控制服务负责修改任务和请求服务重启;只读任务查询直接访问 SQLite。
232
246
 
233
247
  ## 权限模式
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@keyou007/agent-bot",
9
- "version": "0.1.1",
9
+ "version": "0.1.3",
10
10
  "license": "GPL-3.0-only",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.2.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Using Codex in Lark/Feishu feels just like using native Codex.",
6
6
  "license": "GPL-3.0-only",
@@ -48,9 +48,20 @@ agent-bot init
48
48
 
49
49
  Initialization creates the Agent Bot home, `config.yaml`, `.env`, `data/`, and `logs/` from the bundled examples. If Feishu credentials are absent, it starts Feishu one-click app registration, prints an authorization URL and QR code, waits for the user to approve the request, and stores the returned app ID and secret in `.env`. It then audits the app's currently published tenant permissions, event subscriptions, and callbacks. Core and optional missing items produce separate incremental-configuration URLs so optional approval cannot block core setup.
50
50
 
51
+ After Feishu initialization succeeds, `init` releases its initialization lock and automatically starts the selected profile's server, waiting until it is ready. It does not create another supervisor when that profile is already running. `init --skip-feishu` is the exception: it prepares Console-only files and does not start the server. With `--json`, inspect `server.status` for `started`, `already-running`, or `skipped`.
52
+
51
53
  When running `init` on the user's behalf, relay every exact authorization or incremental-configuration URL and let the user complete it in their own browser. The command waits only for core message receipt and delivery capabilities. Optional capabilities such as group creation, group-title synchronization, reactions, images, and card actions do not block initialization; relay the final affected-feature warnings when they remain unavailable. Neither URL contains the app secret, and the secret itself is never printed.
52
54
 
53
- Existing complete Feishu credentials are preserved but still audited and repaired when needed. Use `agent-bot init --skip-feishu` only for console-only initialization because it skips both registration and configuration auditing. Use `agent-bot init --reconfigure-feishu` only when the user explicitly wants to replace existing credentials. The command respects `AGENT_BOT_HOME`, `AGENT_BOT_CONFIG`, and `--config <path>`. Use `agent-bot init --json` when structured final output helps.
55
+ Existing complete Feishu credentials are preserved but still audited and repaired when needed. Missing or incomplete credentials mean app creation did not complete, so a later `init` starts a new registration instead of resuming the previous device code. Once both credentials are durably saved, an interrupted permission audit resumes against the same remote app. `init` also holds a local lock to prevent concurrent registrations. Use `agent-bot init --skip-feishu` only for console-only initialization because it skips both registration and configuration auditing. Use `agent-bot init --reconfigure-feishu` only when the user explicitly wants to replace existing complete credentials. The command respects `AGENT_BOT_HOME`, `AGENT_BOT_CONFIG`, and `--config <path>`. Use `agent-bot init --json` when structured final output helps.
56
+
57
+ For an isolated secondary bot, pass its profile directory explicitly on every command:
58
+
59
+ ```powershell
60
+ agent-bot --profile ~/.agent-bot-rescue init
61
+ agent-bot --profile ~/.agent-bot-rescue server status
62
+ ```
63
+
64
+ No `--profile` selects the main profile at `~/.agent-bot` by default. `--profile <directory>` selects that directory's `config.yaml`, `.env`, data, logs, and local control endpoint and propagates the selection to the supervisor and worker. Alternative profiles are not registered by name. Do not combine `--profile` with `--config`.
54
65
 
55
66
  ## Start with inspection
56
67
 
@@ -67,7 +78,7 @@ agent-bot task list
67
78
 
68
79
  Add `--json` to status and list commands when machine-readable output helps. If `agent-bot` is unavailable, report that the CLI is not installed or linked instead of guessing process state.
69
80
 
70
- By default Agent Bot stores user-owned configuration and runtime state in `~/.agent-bot`: config at `~/.agent-bot/config.yaml`, environment variables at `~/.agent-bot/.env`, SQLite state at `~/.agent-bot/data/agent-bot.sqlite`, logs at `~/.agent-bot/logs/agent-bot.log`, and inbound image cache next to the SQLite database. `AGENT_BOT_HOME` changes this root. Use `--config <path>` only when controlling a non-default configuration.
81
+ By default Agent Bot stores user-owned configuration and runtime state in `~/.agent-bot`: config at `~/.agent-bot/config.yaml`, environment variables at `~/.agent-bot/.env`, SQLite state at `~/.agent-bot/data/agent-bot.sqlite`, logs at `~/.agent-bot/logs/agent-bot.log`, and inbound image cache next to the SQLite database. `AGENT_BOT_HOME` changes this root. Prefer `--profile <directory>` when controlling a complete isolated instance; use `--config <path>` only when selecting a non-default configuration without changing the whole profile.
71
82
 
72
83
  ## Manage the service
73
84
 
@@ -80,12 +91,21 @@ agent-bot server restart --reason "reason for restart"
80
91
  agent-bot server stop
81
92
  ```
82
93
 
83
- Treat `server restart` as the normal restart path. It schedules a safe restart and waits for active tasks, pending final-message delivery, and a quiet inbound-message window. The first safe-restart status card is delayed by three seconds so the active task's final response can usually be delivered first; this presentation delay does not postpone scheduler polling.
94
+ Treat `server restart` as the normal restart path. It schedules a safe restart and waits for active tasks, pending final-message delivery, and a quiet inbound-message window. The first safe-restart status card is delayed by three seconds so the active task's final response can usually be delivered first; this presentation delay does not postpone scheduler polling. While the restart is pending, its status card has a bottom `Cancel` button that conditionally cancels that exact schedule and updates the card in place.
84
95
 
85
96
  Use `agent-bot server restart --immediate` only when the user explicitly requests an immediate restart or accepts interruption. Never use `taskkill`, `Stop-Process`, or equivalent commands for routine Agent Bot restart management.
86
97
 
87
98
  `agent-bot console` opens the console UI only when the service is stopped. Do not use `--force` while the service is live unless the user explicitly accepts concurrent state access.
88
99
 
100
+ When a startup card says the Worker exited and the supervisor restarted it, inspect the selected profile's persisted crash evidence before guessing at a cause:
101
+
102
+ - `data/last-crash.json` for PID, exit code, uptime, restart delay, and linked report paths
103
+ - `logs/supervisor.log` for the restart timeline
104
+ - `logs/worker.stderr.log` for Node/V8 fatal output
105
+ - `data/crash-reports/report.*.json` for Node diagnostic reports
106
+
107
+ Normal safe restarts and explicit stops do not overwrite the latest crash record. Every explicit profile keeps its own diagnostic files; use the same `--profile <directory>` selection when correlating service status with those paths.
108
+
89
109
  ## Manage tasks
90
110
 
91
111
  List tasks before resolving a numeric reference because task numbers follow the current list order:
@@ -131,7 +151,7 @@ Messages whose trimmed text starts with `/` are always parsed as Agent Bot comma
131
151
 
132
152
  When a group body has a current task, renaming the Feishu group to `[agent-name] new title` also renames that current task if the prefix matches the task's configured agent. Malformed names, agent mismatches, empty groups, and thread-specific tasks are ignored.
133
153
 
134
- Use `--context <key>` or `--status <status>` to narrow `task list`. Use `--config <path>` before the command when controlling a non-default Agent Bot configuration.
154
+ Use `--context <key>` or `--status <status>` to narrow `task list`. Use `--profile <directory>` before the command when controlling an isolated Agent Bot instance, or `--config <path>` for only a non-default configuration.
135
155
 
136
156
  ## Apply code changes safely
137
157
 
@@ -16,6 +16,7 @@ export interface FeishuAppRegistrationChallenge {
16
16
  export interface FeishuAppCredentials {
17
17
  appId: string;
18
18
  appSecret: string;
19
+ userOpenId?: string;
19
20
  }
20
21
 
21
22
  export interface FeishuAppRegistrationOptions {
@@ -94,7 +95,14 @@ export async function registerFeishuApp(options: FeishuAppRegistrationOptions =
94
95
  if (!errorCode) {
95
96
  const appId = readString(payload, "client_id");
96
97
  const appSecret = readString(payload, "client_secret");
97
- if (appId && appSecret) return { appId, appSecret };
98
+ if (appId && appSecret) {
99
+ const userOpenId = readString(readObject(payload, "user_info"), "open_id");
100
+ return {
101
+ appId,
102
+ appSecret,
103
+ ...(userOpenId ? { userOpenId } : {}),
104
+ };
105
+ }
98
106
  continue;
99
107
  }
100
108
 
@@ -165,6 +173,11 @@ function readString(value: RegistrationPayload, key: string): string {
165
173
  return typeof field === "string" ? field.trim() : "";
166
174
  }
167
175
 
176
+ function readObject(value: RegistrationPayload, key: string): RegistrationPayload {
177
+ const field = value[key];
178
+ return isRecord(field) ? field : {};
179
+ }
180
+
168
181
  function readPositiveInteger(value: RegistrationPayload, key: string): number | undefined {
169
182
  const field = value[key];
170
183
  const parsed = typeof field === "number" ? field : Number(field);
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
3
+ import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { parse as parseDotEnv } from "dotenv";
@@ -37,15 +39,24 @@ export interface FeishuCredentialState {
37
39
  status: FeishuCredentialStatus;
38
40
  appId?: string;
39
41
  appSecret?: string;
42
+ userOpenId?: string;
40
43
  }
41
44
 
42
45
  export interface FeishuCredentials {
43
46
  appId: string;
44
47
  appSecret: string;
48
+ userOpenId?: string;
49
+ }
50
+
51
+ export interface InitializationLock {
52
+ path: string;
53
+ release(): void;
45
54
  }
46
55
 
47
56
  const DEFAULT_CONFIG_TEMPLATE = fileURLToPath(new URL("../../config.example.yaml", import.meta.url));
48
57
  const DEFAULT_ENV_TEMPLATE = fileURLToPath(new URL("../../.env.example", import.meta.url));
58
+ const INITIALIZATION_LOCK_NAME = "init.lock";
59
+ const MAX_INITIALIZATION_LOCK_AGE_MS = 30 * 60_000;
49
60
 
50
61
  export function initializeAgentBot(options: InitializationOptions = {}): InitializationResult {
51
62
  const env = options.env ?? process.env;
@@ -63,14 +74,7 @@ export function initializeAgentBot(options: InitializationOptions = {}): Initial
63
74
  const home = ensureDirectory(homePath);
64
75
  fs.mkdirSync(configDirectory, { recursive: true });
65
76
 
66
- let config: InitializedPath;
67
- if (fs.existsSync(configPath)) {
68
- config = { path: configPath, status: "existing" };
69
- } else {
70
- fs.copyFileSync(configTemplatePath, configPath, fs.constants.COPYFILE_EXCL);
71
- config = { path: configPath, status: "created" };
72
- }
73
-
77
+ const config = copyIfMissing(configTemplatePath, configPath);
74
78
  const envFile = copyIfMissing(envTemplatePath, envPath);
75
79
  restrictFilePermissions(envFile.path);
76
80
  const data = ensureDirectory(path.join(configDirectory, "data"));
@@ -83,29 +87,112 @@ export function readFeishuCredentials(envPath: string, env: NodeJS.ProcessEnv =
83
87
  const fileValues = fs.existsSync(envPath) ? parseDotEnv(fs.readFileSync(envPath, "utf8")) : {};
84
88
  const appId = firstNonBlank(env.FEISHU_APP_ID, fileValues.FEISHU_APP_ID);
85
89
  const appSecret = firstNonBlank(env.FEISHU_APP_SECRET, fileValues.FEISHU_APP_SECRET);
86
- if (appId && appSecret) return { status: "configured", appId, appSecret };
87
- if (appId || appSecret) return { status: "incomplete", appId, appSecret };
88
- return { status: "missing" };
90
+ const userOpenId = firstNonBlank(env.FEISHU_USER_OPEN_ID, fileValues.FEISHU_USER_OPEN_ID);
91
+ const user = userOpenId ? { userOpenId } : {};
92
+ if (appId && appSecret) return { status: "configured", appId, appSecret, ...user };
93
+ if (appId || appSecret) return { status: "incomplete", appId, appSecret, ...user };
94
+ return { status: "missing", ...user };
89
95
  }
90
96
 
91
97
  export function writeFeishuCredentials(envPath: string, credentials: FeishuCredentials): void {
92
98
  assertDotEnvValue("FEISHU_APP_ID", credentials.appId);
93
99
  assertDotEnvValue("FEISHU_APP_SECRET", credentials.appSecret);
100
+ if (credentials.userOpenId) assertDotEnvValue("FEISHU_USER_OPEN_ID", credentials.userOpenId);
94
101
  const original = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
95
102
  let updated = upsertDotEnvValue(original, "FEISHU_APP_ID", credentials.appId);
96
103
  updated = upsertDotEnvValue(updated, "FEISHU_APP_SECRET", credentials.appSecret);
104
+ updated = upsertDotEnvValue(updated, "FEISHU_USER_OPEN_ID", credentials.userOpenId ?? "");
97
105
 
98
106
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
99
107
  const temporaryPath = `${envPath}.${process.pid}.${Date.now()}.tmp`;
108
+ let temporaryFile: number | undefined;
100
109
  try {
101
- fs.writeFileSync(temporaryPath, updated, { encoding: "utf8", mode: 0o600 });
110
+ temporaryFile = fs.openSync(temporaryPath, "wx", 0o600);
111
+ fs.writeFileSync(temporaryFile, updated, "utf8");
112
+ fs.fsyncSync(temporaryFile);
113
+ fs.closeSync(temporaryFile);
114
+ temporaryFile = undefined;
102
115
  fs.renameSync(temporaryPath, envPath);
116
+ syncDirectory(path.dirname(envPath));
103
117
  restrictFilePermissions(envPath);
118
+ const persisted = readFeishuCredentials(envPath, {});
119
+ if (
120
+ persisted.status !== "configured"
121
+ || persisted.appId !== credentials.appId
122
+ || persisted.appSecret !== credentials.appSecret
123
+ || persisted.userOpenId !== credentials.userOpenId
124
+ ) {
125
+ throw new Error("飞书凭据写入后校验失败。");
126
+ }
104
127
  } finally {
128
+ if (temporaryFile !== undefined) fs.closeSync(temporaryFile);
105
129
  fs.rmSync(temporaryPath, { force: true });
106
130
  }
107
131
  }
108
132
 
133
+ export function shouldCreateFeishuApp(
134
+ credentials: FeishuCredentialState,
135
+ reconfigureFeishu: boolean,
136
+ ): boolean {
137
+ return reconfigureFeishu || credentials.status !== "configured";
138
+ }
139
+
140
+ export function acquireInitializationLock(lockDirectory: string): InitializationLock {
141
+ fs.mkdirSync(lockDirectory, { recursive: true });
142
+ const lockPath = path.join(lockDirectory, INITIALIZATION_LOCK_NAME);
143
+ const owner = {
144
+ token: randomUUID(),
145
+ pid: process.pid,
146
+ hostname: os.hostname(),
147
+ startedAt: new Date().toISOString(),
148
+ };
149
+
150
+ for (let attempt = 0; attempt < 3; attempt += 1) {
151
+ let lockFile: number | undefined;
152
+ let created = false;
153
+ try {
154
+ lockFile = fs.openSync(lockPath, "wx", 0o600);
155
+ created = true;
156
+ fs.writeFileSync(lockFile, `${JSON.stringify(owner)}\n`, "utf8");
157
+ fs.fsyncSync(lockFile);
158
+ fs.closeSync(lockFile);
159
+ lockFile = undefined;
160
+ return {
161
+ path: lockPath,
162
+ release: () => {
163
+ const current = readInitializationLock(lockPath);
164
+ if (current?.token === owner.token) fs.rmSync(lockPath, { force: true });
165
+ },
166
+ };
167
+ } catch (error) {
168
+ if (lockFile !== undefined) fs.closeSync(lockFile);
169
+ if (created) fs.rmSync(lockPath, { force: true });
170
+ if (!isFileExistsError(error)) throw error;
171
+ if (!isStaleInitializationLock(lockPath)) {
172
+ throw new Error(`另一个 agent-bot init 正在运行(锁文件:${lockPath})。`);
173
+ }
174
+ fs.rmSync(lockPath, { force: true });
175
+ }
176
+ }
177
+
178
+ throw new Error(`无法获取初始化锁:${lockPath}`);
179
+ }
180
+
181
+ export function cleanupFeishuCredentialTemporaryFiles(envPath: string): number {
182
+ const directory = path.dirname(envPath);
183
+ if (!fs.existsSync(directory)) return 0;
184
+ const prefix = `${path.basename(envPath)}.`;
185
+ let removed = 0;
186
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
187
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(".tmp")) continue;
188
+ const middle = entry.name.slice(prefix.length, -".tmp".length);
189
+ if (!/^\d+\.\d+$/.test(middle)) continue;
190
+ fs.rmSync(path.join(directory, entry.name), { force: true });
191
+ removed += 1;
192
+ }
193
+ return removed;
194
+ }
195
+
109
196
  function assertTemplate(templatePath: string): void {
110
197
  if (!fs.statSync(templatePath, { throwIfNoEntry: false })?.isFile()) {
111
198
  throw new Error(`Initialization template does not exist: ${templatePath}`);
@@ -121,8 +208,13 @@ function ensureDirectory(directoryPath: string): InitializedPath {
121
208
  function copyIfMissing(sourcePath: string, targetPath: string): InitializedPath {
122
209
  if (fs.existsSync(targetPath)) return { path: targetPath, status: "existing" };
123
210
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
124
- fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
125
- return { path: targetPath, status: "created" };
211
+ try {
212
+ fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
213
+ return { path: targetPath, status: "created" };
214
+ } catch (error) {
215
+ if (isFileExistsError(error)) return { path: targetPath, status: "existing" };
216
+ throw error;
217
+ }
126
218
  }
127
219
 
128
220
  function upsertDotEnvValue(contents: string, key: string, value: string): string {
@@ -154,6 +246,74 @@ function restrictFilePermissions(filePath: string): void {
154
246
  }
155
247
  }
156
248
 
249
+ function syncDirectory(directoryPath: string): void {
250
+ let directory: number | undefined;
251
+ try {
252
+ directory = fs.openSync(directoryPath, "r");
253
+ fs.fsyncSync(directory);
254
+ } catch {
255
+ // Directory fsync is not supported on every Windows or mounted filesystem.
256
+ } finally {
257
+ if (directory !== undefined) fs.closeSync(directory);
258
+ }
259
+ }
260
+
261
+ interface InitializationLockOwner {
262
+ token: string;
263
+ pid: number;
264
+ hostname: string;
265
+ startedAt: string;
266
+ }
267
+
268
+ function readInitializationLock(lockPath: string): InitializationLockOwner | undefined {
269
+ try {
270
+ const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as unknown;
271
+ if (!parsed || typeof parsed !== "object") return undefined;
272
+ const value = parsed as Record<string, unknown>;
273
+ if (
274
+ typeof value.token !== "string"
275
+ || typeof value.pid !== "number"
276
+ || typeof value.hostname !== "string"
277
+ || typeof value.startedAt !== "string"
278
+ ) {
279
+ return undefined;
280
+ }
281
+ return {
282
+ token: value.token,
283
+ pid: value.pid,
284
+ hostname: value.hostname,
285
+ startedAt: value.startedAt,
286
+ };
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ }
291
+
292
+ function isStaleInitializationLock(lockPath: string): boolean {
293
+ const owner = readInitializationLock(lockPath);
294
+ const modifiedAt = fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? Date.now();
295
+ const ageMs = Date.now() - modifiedAt;
296
+ if (!owner) return ageMs >= 5_000;
297
+ if (owner.hostname === os.hostname()) return !isProcessRunning(owner.pid);
298
+ const startedAt = Date.parse(owner.startedAt);
299
+ const ownerAgeMs = Number.isFinite(startedAt) ? Date.now() - startedAt : ageMs;
300
+ return ownerAgeMs >= MAX_INITIALIZATION_LOCK_AGE_MS;
301
+ }
302
+
303
+ function isProcessRunning(pid: number): boolean {
304
+ if (!Number.isInteger(pid) || pid <= 0) return false;
305
+ try {
306
+ process.kill(pid, 0);
307
+ return true;
308
+ } catch (error) {
309
+ return (error as NodeJS.ErrnoException).code === "EPERM";
310
+ }
311
+ }
312
+
313
+ function isFileExistsError(error: unknown): boolean {
314
+ return (error as NodeJS.ErrnoException)?.code === "EEXIST";
315
+ }
316
+
157
317
  function firstNonBlank(...values: Array<string | undefined>): string | undefined {
158
318
  return values.find((value) => value !== undefined && value.trim().length > 0);
159
319
  }
@@ -41,7 +41,18 @@ export async function sendControlRequest(
41
41
  export async function isServerRunning(endpoint: string): Promise<boolean> {
42
42
  try {
43
43
  const response = await sendControlRequest(endpoint, { action: "health" }, 1_000);
44
- return response.ok;
44
+ if (!response.ok) return false;
45
+ if (!response.data || typeof response.data !== "object") return true;
46
+ return (response.data as Record<string, unknown>).ready !== false;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ export async function isServerReachable(endpoint: string): Promise<boolean> {
53
+ try {
54
+ await sendControlRequest(endpoint, { action: "health" }, 1_000);
55
+ return true;
45
56
  } catch {
46
57
  return false;
47
58
  }