@keyou007/agent-bot 0.1.6 → 0.1.8

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 (51) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +5 -1
  3. package/README.zh.md +5 -1
  4. package/config.example.yaml +2 -0
  5. package/dist/cli/FeishuAppConfiguration.js +71 -21
  6. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  7. package/dist/cli/OptionalAuthorizationSkip.js +30 -0
  8. package/dist/cli/OptionalAuthorizationSkip.js.map +1 -0
  9. package/dist/cli/VerificationOutput.js +9 -0
  10. package/dist/cli/VerificationOutput.js.map +1 -0
  11. package/dist/cli.js +23 -18
  12. package/dist/cli.js.map +1 -1
  13. package/dist/codex/CodexEventMapper.js +13 -0
  14. package/dist/codex/CodexEventMapper.js.map +1 -1
  15. package/dist/codex/CodexRuntime.js +33 -4
  16. package/dist/codex/CodexRuntime.js.map +1 -1
  17. package/dist/config/loadConfig.js +1 -0
  18. package/dist/config/loadConfig.js.map +1 -1
  19. package/dist/config/schema.js +1 -0
  20. package/dist/config/schema.js.map +1 -1
  21. package/dist/feishu/CardRenderer.js +2 -2
  22. package/dist/feishu/CardRenderer.js.map +1 -1
  23. package/dist/feishu/FeishuBotIdentity.js +35 -0
  24. package/dist/feishu/FeishuBotIdentity.js.map +1 -0
  25. package/dist/feishu/FeishuConnector.js +20 -5
  26. package/dist/feishu/FeishuConnector.js.map +1 -1
  27. package/dist/presentation/TurnStateReducer.js +2 -1
  28. package/dist/presentation/TurnStateReducer.js.map +1 -1
  29. package/dist/proxy/ProxySessionController.js +5 -1
  30. package/dist/proxy/ProxySessionController.js.map +1 -1
  31. package/dist/utils/generatedImageMarkdown.js +11 -0
  32. package/dist/utils/generatedImageMarkdown.js.map +1 -0
  33. package/docs/technical-reference.md +8 -2
  34. package/docs/technical-reference.zh.md +8 -2
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/skills/agent-bot/SKILL.md +7 -3
  38. package/src/cli/FeishuAppConfiguration.ts +131 -19
  39. package/src/cli/OptionalAuthorizationSkip.ts +38 -0
  40. package/src/cli/VerificationOutput.ts +22 -0
  41. package/src/cli.ts +30 -18
  42. package/src/codex/CodexEventMapper.ts +13 -0
  43. package/src/codex/CodexRuntime.ts +48 -5
  44. package/src/config/loadConfig.ts +1 -0
  45. package/src/config/schema.ts +1 -0
  46. package/src/feishu/CardRenderer.ts +2 -2
  47. package/src/feishu/FeishuBotIdentity.ts +55 -0
  48. package/src/feishu/FeishuConnector.ts +24 -4
  49. package/src/presentation/TurnStateReducer.ts +6 -1
  50. package/src/proxy/ProxySessionController.ts +4 -1
  51. package/src/utils/generatedImageMarkdown.ts +10 -0
@@ -76,7 +76,9 @@ When complete credentials are absent, initialization uses Feishu one-click regis
76
76
  Missing app configuration is handled in two stages:
77
77
 
78
78
  1. Core configuration is requested and polled until it becomes available.
79
- 2. Remaining optional configuration is requested without blocking initialization.
79
+ 2. Remaining optional configuration is requested. The CLI prints its QR code followed by the authorization URL, then immediately polls for up to five minutes. An interactive terminal offers only `Y` to skip optional authorization and continue; otherwise the user completes authorization in the browser while polling continues.
80
+
81
+ Optional polling failures and timeouts return a partial configuration result instead of failing initialization. When stdin is not an interactive terminal, no skip input is available and polling continues until configuration becomes active or times out. Verification URLs and prompts use stderr, so `--json` keeps its final stdout machine-readable.
80
82
 
81
83
  The generated authorization links do not contain the app secret.
82
84
 
@@ -91,7 +93,8 @@ Core configuration required for basic messaging:
91
93
  - Bot capability
92
94
  - Persistent-connection event delivery
93
95
  - `im.message.receive_v1`
94
- - Tenant permissions for group @ messages and private messages
96
+ - `im:message.group_msg:readonly` for all user messages in groups containing the bot
97
+ - Tenant permission for private messages
95
98
  - `im:message:send_as_bot` or a broader equivalent
96
99
  - `application:application:self_manage` so initialization can inspect the published version
97
100
 
@@ -118,6 +121,7 @@ feishu:
118
121
  appId: "${FEISHU_APP_ID}"
119
122
  appSecret: "${FEISHU_APP_SECRET}"
120
123
  userOpenId: "${FEISHU_USER_OPEN_ID}"
124
+ respondToAllGroupMessages: true
121
125
 
122
126
  console:
123
127
  enabled: true
@@ -142,6 +146,8 @@ logging:
142
146
  path: "./logs/agent-bot.log"
143
147
  ```
144
148
 
149
+ `feishu.respondToAllGroupMessages` defaults to `true`. Set it to `false` to ignore group messages unless they mention the current bot. The worker resolves the bot's Open ID at startup so mentioning another member does not trigger it. Private messages are always accepted. Initialization requests all-user group-message delivery regardless of this runtime option, allowing it to be changed later without another authorization.
150
+
145
151
  `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. Each startup card includes the Agent Bot version read from the installed package metadata. 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.
146
152
 
147
153
  At least one agent must be configured. `defaults.agent` must name a configured agent.
@@ -76,7 +76,9 @@ CLI 界面文本不受系统语言影响,统一使用英文。系统生成的
76
76
  缺失配置分两阶段处理:
77
77
 
78
78
  1. 请求核心配置并轮询,直到基础能力生效
79
- 2. 请求其余可选配置,但不阻塞初始化
79
+ 2. 请求其余可选配置。CLI 会先显示二维码,随后显示授权链接,然后立即开始最多 5 分钟的轮询。交互式终端只提供 `Y` 选项,用于跳过可选授权并继续;否则用户直接在浏览器中完成授权,轮询会持续进行
80
+
81
+ 可选配置轮询失败或超时会返回部分配置结果,不会导致初始化失败。stdin 不是交互式终端时无法输入跳过指令,轮询会持续到配置生效或超时。验证链接和提示写入 stderr,因此 `--json` 的最终 stdout 仍可直接供程序读取。
80
82
 
81
83
  生成的授权链接不包含 App Secret。
82
84
 
@@ -91,7 +93,8 @@ CLI 界面文本不受系统语言影响,统一使用英文。系统生成的
91
93
  - 机器人能力
92
94
  - 长连接事件接收
93
95
  - `im.message.receive_v1`
94
- - 接收群聊 @ 消息和私聊消息所需的租户权限
96
+ - `im:message.group_msg:readonly`,用于接收机器人所在群内的全部用户消息
97
+ - 接收私聊消息所需的租户权限
95
98
  - `im:message:send_as_bot` 或覆盖它的更大权限
96
99
  - `application:application:self_manage`,供初始化检查已发布版本
97
100
 
@@ -118,6 +121,7 @@ feishu:
118
121
  appId: "${FEISHU_APP_ID}"
119
122
  appSecret: "${FEISHU_APP_SECRET}"
120
123
  userOpenId: "${FEISHU_USER_OPEN_ID}"
124
+ respondToAllGroupMessages: true
121
125
 
122
126
  console:
123
127
  enabled: true
@@ -142,6 +146,8 @@ logging:
142
146
  path: "./logs/agent-bot.log"
143
147
  ```
144
148
 
149
+ `feishu.respondToAllGroupMessages` 默认为 `true`。设为 `false` 后,未 @ 当前机器人的群消息会被忽略;Worker 启动时会解析机器人的 Open ID,因此 @ 其他成员不会误触发。私聊始终正常处理。无论该运行时配置如何,初始化都会申请接收全部群消息的权限,之后切换配置无需再次授权。
150
+
145
151
  `agent-bot server start` 要求同时配置飞书 `appId` 和 `appSecret`。Worker 启动 SDK 长连接时不读取 SDK 日志或私有连接状态,随后通过发送启动状态卡片检查出站能力。每张启动卡片都会显示从已安装包元数据读取的 Agent Bot 版本。通知通常发往已知私聊和最近活跃的群聊;数据库里尚无已知会话时,改用 `feishu.userOpenId`,按 `open_id` 私聊发送。存在通知目标时,至少一张卡片发送成功后 Server 才报告就绪,单个目标发送失败仍相互隔离。如果既没有已知会话,也没有 `feishu.userOpenId`,启动会跳过出站检查并继续。缺少凭据时仍会启动失败并提示先初始化。`agent-bot console` 是明确的纯本地入口,不需要飞书凭据。
146
152
 
147
153
  必须至少配置一个 Agent,`defaults.agent` 必须指向已配置的 Agent。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@keyou007/agent-bot",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@keyou007/agent-bot",
9
- "version": "0.1.6",
9
+ "version": "0.1.8",
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.6",
3
+ "version": "0.1.8",
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",
@@ -46,11 +46,15 @@ Run initialization once after installing or linking the CLI:
46
46
  agent-bot init
47
47
  ```
48
48
 
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.
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 a QR code followed by its authorization URL, 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. Core configuration is always polled until ready. For optional configuration, the CLI prints the QR code followed by the authorization URL and immediately waits up to five minutes for activation. The only terminal choice is `Y`, which skips optional authorization and continues initialization.
50
+
51
+ Core messaging configuration includes `im:message.group_msg:readonly`, not merely the narrower group-mention scope. Agent Bot groups accept ordinary messages without requiring users to @ the bot, so initialization must keep waiting until all-user group-message delivery is active.
52
+
53
+ Initialization always requests all-user group-message delivery. The runtime option `feishu.respondToAllGroupMessages` controls whether Agent Bot uses it: the default `true` responds to ordinary group messages, while `false` requires a mention of the current bot. Private messages are unaffected.
50
54
 
51
55
  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
56
 
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.
57
+ 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. Optional polling starts automatically after the link is shown. Do not enter `Y` unless the user explicitly asks to skip optional authorization; there is no separate accept action. Optional capabilities such as group creation, group-title synchronization, reactions, images, and card actions never block initialization when skipped, timed out, or still unavailable; relay the final affected-feature warnings. In a non-interactive terminal, optional polling continues until activation or timeout because skip input is unavailable. Neither URL contains the app secret, and the secret itself is never printed.
54
58
 
55
59
  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
60
 
@@ -151,7 +155,7 @@ Keep action labels placed to the right of card content or at the bottom of a car
151
155
 
152
156
  Messages whose trimmed text starts with `/` are always parsed as Agent Bot commands. Unknown slash commands are reported to the user with a `/help` hint and must never fall through to the model, including slash-prefixed messages that also contain images.
153
157
 
154
- 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.
158
+ When a group body has a current task, renaming the Feishu group to `[agent-name] [project directory] new title` also renames that current task if the Agent prefix matches the task's configured agent. Only `new title` is written to the task; the Agent and project-directory prefixes remain group metadata. The older `[agent-name] new title` shape remains supported. Malformed names, agent mismatches, empty groups, and thread-specific tasks are ignored.
155
159
 
156
160
  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.
157
161
 
@@ -6,6 +6,7 @@ const TENANT_TOKEN_PATH = "/open-apis/auth/v3/tenant_access_token/internal";
6
6
  const APPLICATION_PATH_PREFIX = "/open-apis/application/v6/applications";
7
7
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
8
8
  const DEFAULT_COMPLETION_TIMEOUT_MS = 10 * 60_000;
9
+ const DEFAULT_OPTIONAL_COMPLETION_TIMEOUT_MS = 5 * 60_000;
9
10
  const REQUEST_TIMEOUT_MS = 30_000;
10
11
 
11
12
  interface ScopeRequirement {
@@ -27,11 +28,8 @@ const REQUIRED_SCOPE_REQUIREMENTS: ScopeRequirement[] = [
27
28
  accepted: ["im:chat:read", "im:chat:readonly", "im:chat"],
28
29
  },
29
30
  {
30
- requested: "im:message.group_at_msg:readonly",
31
+ requested: "im:message.group_msg:readonly",
31
32
  accepted: [
32
- "im:message.group_at_msg:readonly",
33
- "im:message.group_at_msg.include_bot:readonly",
34
- "im:message.group_at_msg",
35
33
  "im:message.group_msg:readonly",
36
34
  "im:message.group_msg",
37
35
  ],
@@ -68,7 +66,7 @@ export const REQUIRED_FEISHU_CALLBACKS = ["card.action.trigger"] as const;
68
66
 
69
67
  const CORE_FEISHU_SCOPES = new Set([
70
68
  "application:application:self_manage",
71
- "im:message.group_at_msg:readonly",
69
+ "im:message.group_msg:readonly",
72
70
  "im:message.p2p_msg:readonly",
73
71
  "im:message:send_as_bot",
74
72
  ]);
@@ -96,8 +94,10 @@ export interface EnsureFeishuAppConfigurationOptions {
96
94
  fetch?: typeof globalThis.fetch;
97
95
  sleep?: (milliseconds: number) => Promise<void>;
98
96
  signal?: AbortSignal;
97
+ optionalSkipSignal?: AbortSignal;
99
98
  pollIntervalMs?: number;
100
99
  timeoutMs?: number;
100
+ optionalTimeoutMs?: number;
101
101
  onVerification?: (challenge: FeishuConfigurationChallenge) => void | Promise<void>;
102
102
  }
103
103
 
@@ -118,6 +118,8 @@ export async function ensureFeishuAppConfiguration(
118
118
  const sleep = options.sleep ?? delay;
119
119
  const pollIntervalMs = positiveInteger(options.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS;
120
120
  const timeoutMs = positiveInteger(options.timeoutMs) ?? DEFAULT_COMPLETION_TIMEOUT_MS;
121
+ const optionalTimeoutMs = positiveInteger(options.optionalTimeoutMs)
122
+ ?? DEFAULT_OPTIONAL_COMPLETION_TIMEOUT_MS;
121
123
 
122
124
  let configuration: FeishuAppConfiguration;
123
125
  try {
@@ -139,13 +141,16 @@ export async function ensureFeishuAppConfiguration(
139
141
 
140
142
  const coreMissing = coreMissingFeishuAppConfiguration(missing);
141
143
  if (!hasMissingConfiguration(coreMissing)) {
142
- await requestMissingFeishuConfiguration(credentials.appId, missing, false, options.onVerification);
143
- return {
144
- status: "partial",
144
+ return completeOptionalFeishuConfiguration(
145
+ credentials,
145
146
  configuration,
146
- added: emptyConfiguration(),
147
- remaining: missing,
148
- };
147
+ missing,
148
+ fetchImpl,
149
+ sleep,
150
+ pollIntervalMs,
151
+ optionalTimeoutMs,
152
+ options,
153
+ );
149
154
  }
150
155
 
151
156
  await requestMissingFeishuConfiguration(credentials.appId, coreMissing, true, options.onVerification);
@@ -161,15 +166,16 @@ export async function ensureFeishuAppConfiguration(
161
166
  configuration = await readFeishuAppConfiguration(credentials, fetchImpl, options.signal);
162
167
  const remaining = missingFeishuAppConfiguration(configuration);
163
168
  if (!hasMissingConfiguration(coreMissingFeishuAppConfiguration(remaining))) {
164
- if (hasMissingConfiguration(remaining)) {
165
- await requestMissingFeishuConfiguration(credentials.appId, remaining, false, options.onVerification);
166
- }
167
- return {
168
- status: hasMissingConfiguration(remaining) ? "partial" : "updated",
169
+ return completeOptionalFeishuConfiguration(
170
+ credentials,
169
171
  configuration,
170
- added: resolvedMissingConfiguration(missing, remaining),
171
- remaining,
172
- };
172
+ missing,
173
+ fetchImpl,
174
+ sleep,
175
+ pollIntervalMs,
176
+ optionalTimeoutMs,
177
+ options,
178
+ );
173
179
  }
174
180
  lastError = undefined;
175
181
  } catch (error) {
@@ -192,6 +198,112 @@ async function requestMissingFeishuConfiguration(
192
198
  await onVerification?.({ verificationUrl, missing, blocking });
193
199
  }
194
200
 
201
+ async function completeOptionalFeishuConfiguration(
202
+ credentials: FeishuAppCredentials,
203
+ initialConfiguration: FeishuAppConfiguration,
204
+ initialMissing: MissingFeishuAppConfiguration,
205
+ fetchImpl: typeof globalThis.fetch,
206
+ sleep: (milliseconds: number) => Promise<void>,
207
+ pollIntervalMs: number,
208
+ timeoutMs: number,
209
+ options: EnsureFeishuAppConfigurationOptions,
210
+ ): Promise<EnsureFeishuAppConfigurationResult> {
211
+ let configuration = initialConfiguration;
212
+ let remaining = missingFeishuAppConfiguration(configuration);
213
+ if (hasMissingConfiguration(remaining)) {
214
+ await requestMissingFeishuConfiguration(
215
+ credentials.appId,
216
+ remaining,
217
+ false,
218
+ options.onVerification,
219
+ );
220
+ if (!options.optionalSkipSignal?.aborted) {
221
+ const completed = await waitForOptionalFeishuConfiguration(
222
+ credentials,
223
+ configuration,
224
+ fetchImpl,
225
+ sleep,
226
+ pollIntervalMs,
227
+ timeoutMs,
228
+ options.signal,
229
+ options.optionalSkipSignal,
230
+ );
231
+ configuration = completed.configuration;
232
+ remaining = completed.remaining;
233
+ }
234
+ }
235
+ return {
236
+ status: hasMissingConfiguration(remaining) ? "partial" : "updated",
237
+ configuration,
238
+ added: resolvedMissingConfiguration(initialMissing, remaining),
239
+ remaining,
240
+ };
241
+ }
242
+
243
+ async function waitForOptionalFeishuConfiguration(
244
+ credentials: FeishuAppCredentials,
245
+ initialConfiguration: FeishuAppConfiguration,
246
+ fetchImpl: typeof globalThis.fetch,
247
+ sleep: (milliseconds: number) => Promise<void>,
248
+ pollIntervalMs: number,
249
+ timeoutMs: number,
250
+ signal?: AbortSignal,
251
+ optionalSkipSignal?: AbortSignal,
252
+ ): Promise<{
253
+ configuration: FeishuAppConfiguration;
254
+ remaining: MissingFeishuAppConfiguration;
255
+ }> {
256
+ let configuration = initialConfiguration;
257
+ let remaining = missingFeishuAppConfiguration(configuration);
258
+ let remainingMs = timeoutMs;
259
+ while (remainingMs > 0 && hasMissingConfiguration(remaining)) {
260
+ if (optionalSkipSignal?.aborted) break;
261
+ const waitMs = Math.min(pollIntervalMs, remainingMs);
262
+ const waitResult = await sleepWithOptionalSkip(
263
+ sleep,
264
+ waitMs,
265
+ signal,
266
+ optionalSkipSignal,
267
+ );
268
+ if (waitResult === "skipped") break;
269
+ remainingMs -= waitMs;
270
+ try {
271
+ configuration = await readFeishuAppConfiguration(credentials, fetchImpl, signal);
272
+ remaining = missingFeishuAppConfiguration(configuration);
273
+ } catch (error) {
274
+ if (signal?.aborted) throw abortError(signal);
275
+ }
276
+ }
277
+ return { configuration, remaining };
278
+ }
279
+
280
+ async function sleepWithOptionalSkip(
281
+ sleep: (milliseconds: number) => Promise<void>,
282
+ milliseconds: number,
283
+ signal?: AbortSignal,
284
+ optionalSkipSignal?: AbortSignal,
285
+ ): Promise<"elapsed" | "skipped"> {
286
+ if (!optionalSkipSignal) {
287
+ await sleepWithAbort(sleep, milliseconds, signal);
288
+ return "elapsed";
289
+ }
290
+ if (optionalSkipSignal.aborted) return "skipped";
291
+
292
+ let onSkip: (() => void) | undefined;
293
+ const skipped = new Promise<"skipped">((resolve) => {
294
+ onSkip = () => resolve("skipped");
295
+ optionalSkipSignal.addEventListener("abort", onSkip, { once: true });
296
+ });
297
+ try {
298
+ return await Promise.race([
299
+ sleepWithAbort(sleep, milliseconds, signal).then(() => "elapsed" as const),
300
+ skipped,
301
+ ]);
302
+ } finally {
303
+ if (onSkip) optionalSkipSignal.removeEventListener("abort", onSkip);
304
+ }
305
+ }
306
+
195
307
  export function missingFeishuAppConfiguration(configuration: FeishuAppConfiguration): MissingFeishuAppConfiguration {
196
308
  const scopes = new Set(configuration.scopes);
197
309
  const events = new Set(configuration.events);
@@ -0,0 +1,38 @@
1
+ import { createInterface, type Interface } from "node:readline";
2
+
3
+ export interface OptionalAuthorizationSkipListener {
4
+ close(): void;
5
+ }
6
+
7
+ export function listenForOptionalAuthorizationSkip(
8
+ onSkip: () => void,
9
+ ): OptionalAuthorizationSkipListener {
10
+ if (!process.stdin.isTTY) return { close: () => undefined };
11
+
12
+ const readline = createInterface({
13
+ input: process.stdin,
14
+ output: process.stderr,
15
+ });
16
+ const onLine = (value: string): void => {
17
+ if (!isOptionalAuthorizationSkip(value)) {
18
+ process.stderr.write("Enter Y to skip optional authorization, or keep waiting.\n");
19
+ return;
20
+ }
21
+ process.stderr.write("Optional authorization skipped. Initialization will continue.\n");
22
+ onSkip();
23
+ };
24
+ readline.on("line", onLine);
25
+ process.stderr.write("Skip optional authorization? Enter Y and press Enter to skip.\n");
26
+ return {
27
+ close: () => closeReadline(readline, onLine),
28
+ };
29
+ }
30
+
31
+ export function isOptionalAuthorizationSkip(value: string): boolean {
32
+ return value.trim().toLowerCase() === "y";
33
+ }
34
+
35
+ function closeReadline(readline: Interface, onLine: (value: string) => void): void {
36
+ readline.removeListener("line", onLine);
37
+ readline.close();
38
+ }
@@ -0,0 +1,22 @@
1
+ import qrcode from "qrcode-terminal";
2
+
3
+ export interface VerificationOutputOptions {
4
+ verificationUrl: string;
5
+ json: boolean;
6
+ qrInstruction: string;
7
+ }
8
+
9
+ export function printVerificationQrAndLink(
10
+ options: VerificationOutputOptions,
11
+ write: (value: string) => void = (value) => process.stderr.write(value),
12
+ renderQr: (
13
+ value: string,
14
+ callback: (output: string) => void,
15
+ ) => void = (value, callback) => qrcode.generate(value, { small: true }, callback),
16
+ ): void {
17
+ if (!options.json) {
18
+ write(`\n${options.qrInstruction}\n\n`);
19
+ renderQr(options.verificationUrl, (output) => write(`${output}\n`));
20
+ }
21
+ write(`Authorization link:\n${options.verificationUrl}\n\n`);
22
+ }
package/src/cli.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import fs from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
- import qrcode from "qrcode-terminal";
6
5
  import { loadConfig, loadConfigWithoutEnvironmentMutation } from "./config/loadConfig.js";
7
6
  import { sendControlRequest, isServerReachable } from "./cli/LocalControlClient.js";
8
7
  import {
@@ -21,6 +20,10 @@ import {
21
20
  type InitializationStatus,
22
21
  } from "./cli/Initializer.js";
23
22
  import { parseInitCommandOptions, type InitCommandOptions } from "./cli/initOptions.js";
23
+ import {
24
+ listenForOptionalAuthorizationSkip,
25
+ type OptionalAuthorizationSkipListener,
26
+ } from "./cli/OptionalAuthorizationSkip.js";
24
27
  import {
25
28
  registerFeishuApp,
26
29
  type FeishuAppCredentials,
@@ -36,6 +39,7 @@ import { formatServerStatus, withConfiguredFeishuAppId } from "./cli/serverStatu
36
39
  import { resolveSystemSkillsRoot, SkillRegistry, type SkillRegistrationStatus } from "./cli/SkillRegistry.js";
37
40
  import { readPackageVersion } from "./cli/packageVersion.js";
38
41
  import { applyExplicitProfile, parseGlobalOptions } from "./cli/profile.js";
42
+ import { printVerificationQrAndLink } from "./cli/VerificationOutput.js";
39
43
  import {
40
44
  startInitializedServer,
41
45
  startServer,
@@ -178,10 +182,18 @@ async function initializeFeishu(
178
182
  }
179
183
 
180
184
  if (!options.json) process.stdout.write("\nChecking Lark app permissions, events, and callbacks...\n");
185
+ const optionalSkip = new AbortController();
186
+ let skipListener: OptionalAuthorizationSkipListener | undefined;
181
187
  const configuration = await ensureFeishuAppConfiguration(credentials, {
182
188
  signal: controller.signal,
183
- onVerification: (challenge) => printFeishuConfigurationVerification(challenge, options.json),
184
- });
189
+ optionalSkipSignal: optionalSkip.signal,
190
+ onVerification: (challenge) => {
191
+ printFeishuConfigurationVerification(challenge, options.json);
192
+ if (!challenge.blocking) {
193
+ skipListener = listenForOptionalAuthorizationSkip(() => optionalSkip.abort());
194
+ }
195
+ },
196
+ }).finally(() => skipListener?.close());
185
197
  return {
186
198
  status,
187
199
  appId: credentials.appId,
@@ -544,13 +556,13 @@ function printServerStartResult(result: ServerStartResult): void {
544
556
  }
545
557
 
546
558
  function printFeishuVerification(challenge: FeishuAppRegistrationChallenge, json: boolean): void {
547
- process.stderr.write("\nScan with Lark or open the link below in a browser to create the bot app:\n\n");
548
- process.stderr.write(`${challenge.verificationUrl}\n\n`);
549
- if (!json) {
550
- qrcode.generate(challenge.verificationUrl, { small: true }, (output) => {
551
- process.stderr.write(`${output}\n`);
552
- });
553
- }
559
+ printVerificationQrAndLink(
560
+ {
561
+ verificationUrl: challenge.verificationUrl,
562
+ json,
563
+ qrInstruction: "Scan this QR code with Lark to create the bot app:",
564
+ },
565
+ );
554
566
  process.stderr.write(`The link expires in about ${Math.ceil(challenge.expiresIn / 60)} minutes. Waiting for confirmation...\n`);
555
567
  }
556
568
 
@@ -568,17 +580,17 @@ function printFeishuConfigurationVerification(
568
580
  if (challenge.missing.callbacks.length > 0) {
569
581
  process.stderr.write(`Callbacks: ${challenge.missing.callbacks.join(", ")}\n`);
570
582
  }
571
- process.stderr.write("\nScan with Lark or open the link below in a browser to complete the configuration:\n\n");
572
- process.stderr.write(`${challenge.verificationUrl}\n\n`);
573
- if (!json) {
574
- qrcode.generate(challenge.verificationUrl, { small: true }, (output) => {
575
- process.stderr.write(`${output}\n`);
576
- });
577
- }
583
+ printVerificationQrAndLink(
584
+ {
585
+ verificationUrl: challenge.verificationUrl,
586
+ json,
587
+ qrInstruction: "Scan this QR code with Lark to complete the configuration:",
588
+ },
589
+ );
578
590
  if (challenge.blocking) {
579
591
  process.stderr.write("Waiting for the core scopes and message event to become active...\n");
580
592
  } else {
581
- process.stderr.write("These optional items do not block basic messaging. Initialization will continue; grant them later using the link.\n");
593
+ process.stderr.write("Waiting up to 5 minutes for these optional items to become active...\n");
582
594
  }
583
595
  }
584
596
 
@@ -201,6 +201,19 @@ function mapTool(
201
201
  completedAt,
202
202
  };
203
203
  }
204
+ if (type === "imageGeneration") {
205
+ const imagePath = nonEmptyString(item.savedPath);
206
+ return {
207
+ id,
208
+ title: "生成图片",
209
+ kind: "image_generation",
210
+ status,
211
+ command: nonEmptyString(item.revisedPrompt),
212
+ imagePath,
213
+ startedAt,
214
+ completedAt,
215
+ };
216
+ }
204
217
  return undefined;
205
218
  }
206
219
 
@@ -17,6 +17,7 @@ import type {
17
17
  RuntimeSession,
18
18
  RuntimeSessionMetadata,
19
19
  } from "../runtime/types.js";
20
+ import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
20
21
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
21
22
  import { mapCodexNotification } from "./CodexEventMapper.js";
22
23
  import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
@@ -59,6 +60,7 @@ interface CodexSession extends RuntimeSession {
59
60
  activeTurnStartedAt?: number;
60
61
  terminalTurnIds: Set<string>;
61
62
  finalText: string;
63
+ generatedImagePaths: string[];
62
64
  messagePhases: Map<string, "commentary" | "final_answer">;
63
65
  needsResume: boolean;
64
66
  }
@@ -548,6 +550,14 @@ export class CodexRuntime implements AgentRuntime {
548
550
  } else if (mapped.kind === "plan") {
549
551
  this.emit({ type: "plan_updated", sessionId, turnId: mapped.turnId, steps: mapped.steps });
550
552
  } else if (mapped.kind === "tool") {
553
+ if (
554
+ mapped.phase === "updated"
555
+ && mapped.tool.kind === "image_generation"
556
+ && mapped.tool.status === "completed"
557
+ && mapped.tool.imagePath
558
+ ) {
559
+ session.generatedImagePaths = uniqueStrings([...session.generatedImagePaths, mapped.tool.imagePath]);
560
+ }
551
561
  this.emit({
552
562
  type: mapped.phase === "started" ? "tool_started" : "tool_updated",
553
563
  sessionId,
@@ -572,14 +582,16 @@ export class CodexRuntime implements AgentRuntime {
572
582
  } else if (mapped.status === "failed") {
573
583
  this.emit({ type: "turn_failed", sessionId, turnId: mapped.turnId, message: mapped.error ?? "Codex turn failed." });
574
584
  } else {
585
+ const finalResponse = appendGeneratedImageMarkdown(session.finalText, session.generatedImagePaths);
575
586
  this.emit({
576
587
  type: "turn_completed",
577
588
  sessionId,
578
589
  turnId: mapped.turnId,
579
- finalResponse: session.finalText,
590
+ finalResponse,
580
591
  durationMs: mapped.durationMs,
581
592
  });
582
593
  }
594
+ session.generatedImagePaths = [];
583
595
  }
584
596
  }
585
597
 
@@ -693,6 +705,7 @@ export class CodexRuntime implements AgentRuntime {
693
705
  session.activeTurnId = turnId;
694
706
  session.activeTurnStartedAt = startedAt;
695
707
  session.finalText = "";
708
+ session.generatedImagePaths = [];
696
709
  session.messagePhases.clear();
697
710
  this.emit({ type: "turn_started", sessionId: session.localSessionId, turnId, startedAt });
698
711
  }
@@ -726,8 +739,16 @@ export class CodexRuntime implements AgentRuntime {
726
739
  });
727
740
  return;
728
741
  }
729
- const finalResponse = extractFinalResponse(turn) || session.finalText;
742
+ const generatedImagePaths = uniqueStrings([
743
+ ...session.generatedImagePaths,
744
+ ...extractGeneratedImagePaths(turn),
745
+ ]);
746
+ const finalResponse = appendGeneratedImageMarkdown(
747
+ extractFinalResponse(turn) || session.finalText,
748
+ generatedImagePaths,
749
+ );
730
750
  session.finalText = finalResponse;
751
+ session.generatedImagePaths = [];
731
752
  this.emit({
732
753
  type: "turn_completed",
733
754
  sessionId: session.localSessionId,
@@ -765,6 +786,7 @@ export class CodexRuntime implements AgentRuntime {
765
786
  : [],
766
787
  ),
767
788
  finalText: "",
789
+ generatedImagePaths: [],
768
790
  messagePhases: new Map(),
769
791
  needsResume: false,
770
792
  };
@@ -796,6 +818,7 @@ export class CodexRuntime implements AgentRuntime {
796
818
  if (!turnId) continue;
797
819
  session.activeTurnId = undefined;
798
820
  session.activeTurnStartedAt = undefined;
821
+ session.generatedImagePaths = [];
799
822
  this.emit({
800
823
  type: "turn_failed",
801
824
  sessionId: session.localSessionId,
@@ -841,7 +864,13 @@ interface CodexThreadSnapshot {
841
864
  interface CodexTurnSnapshot {
842
865
  id: string;
843
866
  status: "completed" | "interrupted" | "failed" | "inProgress";
844
- items?: Array<{ type?: string; text?: string; phase?: string | null; status?: string }>;
867
+ items?: Array<{
868
+ type?: string;
869
+ text?: string;
870
+ phase?: string | null;
871
+ status?: string;
872
+ savedPath?: string;
873
+ }>;
845
874
  error?: { message?: string } | null;
846
875
  startedAt?: number | null;
847
876
  durationMs?: number | null;
@@ -893,6 +922,17 @@ function extractFinalResponse(turn: CodexTurnSnapshot): string {
893
922
  .join("\n\n");
894
923
  }
895
924
 
925
+ function extractGeneratedImagePaths(turn: CodexTurnSnapshot): string[] {
926
+ return uniqueStrings((turn.items ?? []).flatMap((item) => {
927
+ const savedPath = stringValue(item.savedPath)?.trim();
928
+ return item.type === "imageGeneration" && item.status !== "failed" && savedPath ? [savedPath] : [];
929
+ }));
930
+ }
931
+
932
+ function uniqueStrings(values: string[]): string[] {
933
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
934
+ }
935
+
896
936
  function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary {
897
937
  const lastTurn = thread.turns?.at(-1);
898
938
  const lastCompletedTurn = [...(thread.turns ?? [])]
@@ -923,7 +963,9 @@ function remoteSessionSummary(thread: CodexThreadSnapshot): RemoteSessionSummary
923
963
  lastCompletedTurnId: lastCompletedTurn?.id,
924
964
  lastTurnStatus,
925
965
  lastActivity: lastText,
926
- finalResponse: lastTurn && lastTurn.status !== "inProgress" ? extractFinalResponse(lastTurn) || undefined : undefined,
966
+ finalResponse: lastTurn && lastTurn.status !== "inProgress"
967
+ ? appendGeneratedImageMarkdown(extractFinalResponse(lastTurn), extractGeneratedImagePaths(lastTurn)) || undefined
968
+ : undefined,
927
969
  lastError: lastTurn?.error?.message,
928
970
  lastTurnToolCount: toolCounts?.total,
929
971
  lastTurnCompletedToolCount: toolCounts?.completed,
@@ -956,7 +998,8 @@ function isToolItemType(type: string | undefined): boolean {
956
998
  || type === "mcpToolCall"
957
999
  || type === "dynamicToolCall"
958
1000
  || type === "webSearch"
959
- || type === "imageView";
1001
+ || type === "imageView"
1002
+ || type === "imageGeneration";
960
1003
  }
961
1004
 
962
1005
  function mergeRemoteSessionSummary(