larkin 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,8 @@ Feishu clients do not reliably render Markdown links such as `[label](URL)` as c
87
87
 
88
88
  ## Setup and configuration
89
89
 
90
+ Feishu (https://open.feishu.cn) and Lark (https://open.larksuite.com) are different platforms; `larkin setup` must be told which brand with `--tenant feishu|lark` or the interactive prompt before the authorization QR, and must never emit a `feishu.cn` host for a Lark tenant.
91
+
90
92
  During setup, a new Agent is offered Pi first, followed by Codex and Claude Code. Provider keys are stored only in the selected Agent's private provider directory, not in the ordinary Agent config. Setup also discovers every API-key and OAuth/subscription login exposed by the pinned official Pi registry and delegates those flows to Pi.
91
93
 
92
94
  Use `larkin pi-auth status` or `larkin pi-auth logout <provider>` to manage Pi auth. The builtin Pi runtime loads Larkin's two supported extensions inline: background subagents and the 60-second foreground bash timeout guard are enabled without writing extension files or arguments. A compatible external Pi installation keeps the existing explicit `-e` extension path.
@@ -16,7 +16,7 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
16
16
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
17
17
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
18
18
  */
19
- exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v20";
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v21";
20
20
  /**
21
21
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
22
22
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -126,6 +126,7 @@ class ContextPromptBuilder {
126
126
  "## Feishu IM command map",
127
127
  "",
128
128
  `Use only the Larkin-owned \`${executable}\` command for Feishu. Bot identity, private configuration, and freshness are Runtime-bound; the wrapper delegates to the unmodified global official CLI. Use \`${executable} <command> --help\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary.`,
129
+ "If a platform URL must be shown, use the current Agent tenant host; never emit feishu.cn for a Lark tenant.",
129
130
  `Use the exact Inbox target for history reads. For \`thread:<chat_id>:<thread_id>\`, run \`${executable} im +threads-messages-list --thread <thread_id> --order desc --page-size 10 --no-reactions --json\`. For \`chat:<chat_id>\`, run \`${executable} im +chat-messages-list --chat-id <chat_id> --order desc --page-size 10 --no-reactions --json\`.`,
130
131
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
131
132
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
@@ -13,7 +13,7 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
13
13
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
14
14
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
15
15
  */
16
- export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v20";
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v21";
17
17
  /**
18
18
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
19
19
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -123,6 +123,7 @@ export class ContextPromptBuilder {
123
123
  "## Feishu IM command map",
124
124
  "",
125
125
  `Use only the Larkin-owned \`${executable}\` command for Feishu. Bot identity, private configuration, and freshness are Runtime-bound; the wrapper delegates to the unmodified global official CLI. Use \`${executable} <command> --help\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary.`,
126
+ "If a platform URL must be shown, use the current Agent tenant host; never emit feishu.cn for a Lark tenant.",
126
127
  `Use the exact Inbox target for history reads. For \`thread:<chat_id>:<thread_id>\`, run \`${executable} im +threads-messages-list --thread <thread_id> --order desc --page-size 10 --no-reactions --json\`. For \`chat:<chat_id>\`, run \`${executable} im +chat-messages-list --chat-id <chat_id> --order desc --page-size 10 --no-reactions --json\`.`,
127
128
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
128
129
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
package/dist/app/cli.mjs CHANGED
@@ -49,8 +49,8 @@ if (runtimeAgentAuthority && command === "comment")
49
49
  const commandHelp = {
50
50
  start: `Usage: larkin start [--agent <App ID> | --agents <App ID,...>]
51
51
  Start one foreground supervisor for the daemon and local dashboard, or reuse it.`,
52
- setup: `Usage: larkin setup [--runtime <builtin-pi|external-pi|codex|claude>] [--provider <id> --api-key <key>]
53
- Run setup to create or connect a bot, configure its Agent, and attach it. Interactive terminals keep the guided flow; Agent-driven (non-TTY) runs default to builtin-pi and need --provider/--api-key (or --runtime external-pi).`,
52
+ setup: `Usage: larkin setup [--tenant feishu|lark] [--runtime <builtin-pi|external-pi|codex|claude>] [--provider <id> --api-key <key>]
53
+ Run setup to create or connect a bot, configure its Agent, and attach it. Choose Feishu or Lark before the authorization QR (--tenant; default feishu). Interactive terminals keep the guided flow; Agent-driven (non-TTY) runs default to builtin-pi and need --provider/--api-key (or --runtime external-pi).`,
54
54
  status: `Usage: larkin status [--json]
55
55
  Show Agent configuration, bot identity, credentials, and connection status. Use --json for readiness automation.`,
56
56
  agents: `Usage: larkin agents [--json]
@@ -9,6 +9,7 @@ import { assertSecureBotsDirectory, readSecureBotCredential, validCredentialReco
9
9
  import { resolveOfficialLarkCli } from "./official-lark-cli.mjs";
10
10
  import { internalCommandSpec } from "./internal-command.mjs";
11
11
  import { assertAgentWorkspaceBound, larkChannelSourceConfigPath, larkChannelWorkspaceConfigPath, managedLarkCliEnv, } from "./agent-lark-cli-workspace.mjs";
12
+ import { accountTenantFromOpenHost, openPlatformHost, } from "../feishu/platform-hosts.mjs";
12
13
  function assertSecureProfileDirectory(directory) {
13
14
  const stat = fs.lstatSync(directory);
14
15
  if (!stat.isDirectory() || stat.isSymbolicLink()
@@ -140,7 +141,7 @@ export function hydrateRuntimeAgent(configDir, agent) {
140
141
  return {
141
142
  ...agent,
142
143
  feishuAppSecret: credential.appSecret,
143
- feishuDomain: credential.tenant === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn",
144
+ feishuDomain: openPlatformHost(credential.tenant),
144
145
  credentialRevision,
145
146
  };
146
147
  }
@@ -248,7 +249,7 @@ export function installRuntimeCommandShims(agent) {
248
249
  }
249
250
  return commandDir;
250
251
  }
251
- function sourceProjection(agent, env) {
252
+ export function sourceProjection(agent, env) {
252
253
  const configDir = path.resolve(env.LARKIN_CONFIG_DIR || "");
253
254
  if (!configDir)
254
255
  throw new Error("LARKIN_CONFIG_DIR required for lark-channel source projection");
@@ -261,7 +262,7 @@ function sourceProjection(agent, env) {
261
262
  accounts: { app: {
262
263
  id: agent.feishuAppId,
263
264
  secret: { source: "exec", provider: "larkin-bot-credential", id: agent.feishuAppId },
264
- tenant: agent.feishuDomain === "https://open.larksuite.com" ? "lark" : "feishu",
265
+ tenant: accountTenantFromOpenHost(agent.feishuDomain),
265
266
  } },
266
267
  secrets: { providers: { "larkin-bot-credential": {
267
268
  source: "exec", command: provider.command, args: provider.args,
@@ -294,6 +295,9 @@ function validateSourceProjection(file, agent) {
294
295
  throw new Error("lark-channel source projection 不得包含 plaintext secret 字段");
295
296
  if (root.credentialRevision !== agent.credentialRevision)
296
297
  throw new Error(`Agent ${agent.agentId} lark-channel credential revision mismatch`);
298
+ if (app?.tenant !== accountTenantFromOpenHost(agent.feishuDomain)) {
299
+ throw new Error(`Agent ${agent.agentId} lark-channel source projection tenant mismatch`);
300
+ }
297
301
  }
298
302
  export function syncAgentProfile(agent, env, dependencies = {}) {
299
303
  const expected = path.join(path.resolve(env.LARKIN_CONFIG_DIR || ""), "state", "agents", agent.agentId, "lark-cli-config");
@@ -82,6 +82,11 @@ Options:
82
82
  --base-url <url> custom 端点(provider=custom 时必填;也可覆盖 preset 默认端点)
83
83
  --model <id> 模型 ID;默认取 provider 预设。不同 runtime 的模型可用
84
84
  \`larkin model\` 查看 / 切换
85
+ --tenant feishu|lark 授权二维码之前选择品牌。默认 feishu(accounts.feishu.cn /
86
+ https://open.feishu.cn)。Lark 用 --tenant lark
87
+ (accounts.larksuite.com / https://open.larksuite.com)。
88
+ 交互式未指定时会先询问。飞书与 Lark 是不同平台,
89
+ Lark 租户不得使用 feishu.cn 主机。
85
90
 
86
91
  setup handles browser authorization, permission grants, credential storage, Agent configuration,
87
92
  and target-only hot attach. Each Agent is identified by its bot App ID: selecting the same bot reuses
@@ -152,6 +157,12 @@ export async function main() {
152
157
  registerArgs.push("--pi-distribution", normalizedRuntime.distribution);
153
158
  }
154
159
  registerArgs.push("--comment-subscription", OPT.commentSubscription);
160
+ if (has("--tenant")) {
161
+ const tenant = flag("--tenant");
162
+ if (tenant !== "feishu" && tenant !== "lark")
163
+ die("--tenant 只支持 feishu 或 lark");
164
+ registerArgs.push("--tenant", tenant === "lark" ? "lark" : "feishu");
165
+ }
155
166
  for (const name of ["--provider", "--api-key", "--base-url", "--model"]) {
156
167
  const value = flag(name);
157
168
  if (value)
@@ -61,6 +61,7 @@ const internal_command_js_1 = require("../app/internal-command.cjs");
61
61
  const agent_lark_cli_workspace_js_1 = require("../app/agent-lark-cli-workspace.cjs");
62
62
  const agent_readiness_js_1 = require("../app/agent-readiness.cjs");
63
63
  const document_comment_js_1 = require("./document-comment.cjs");
64
+ const platform_hosts_js_1 = require("./platform-hosts.cjs");
64
65
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
65
66
  function isRecord(value) {
66
67
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -152,7 +153,7 @@ function loadAgents(env, testEventSource, larkinHome, reconcileAgentWorkspaceImp
152
153
  }
153
154
  }
154
155
  if (!testEventSource && (typeof candidate.feishuAppSecret !== "string" || !candidate.feishuAppSecret.trim()
155
- || !["https://open.feishu.cn", "https://open.larksuite.com"].includes(String(candidate.feishuDomain)))) {
156
+ || !(0, platform_hosts_js_1.isOpenPlatformHost)(candidate.feishuDomain))) {
156
157
  throw new Error(`LARKIN_AGENTS_CONFIG Agent ${id} 缺少有效 channel 凭证/domain`);
157
158
  }
158
159
  const expectedWorkspace = node_path_1.default.join(larkinHome, "agents", id);
@@ -742,7 +743,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
742
743
  const channel = createLarkChannel({
743
744
  appId: agent.feishuAppId,
744
745
  appSecret: agent.feishuAppSecret,
745
- domain: agent.feishuDomain || "https://open.feishu.cn",
746
+ domain: (0, platform_hosts_js_1.requireOpenDomain)(agent.feishuDomain),
746
747
  source: "larkin",
747
748
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
748
749
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -857,7 +858,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
857
858
  const channel = createLarkChannel({
858
859
  appId: agent.feishuAppId,
859
860
  appSecret: agent.feishuAppSecret,
860
- domain: agent.feishuDomain || "https://open.feishu.cn",
861
+ domain: (0, platform_hosts_js_1.requireOpenDomain)(agent.feishuDomain),
861
862
  source: "larkin",
862
863
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
863
864
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -1165,6 +1166,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1165
1166
  }, 80);
1166
1167
  hostState.updateStatus(agent, { droughtReconnectAt, droughtReconnectAbandonedAt: null });
1167
1168
  reconnectFns.get(agent.agentId)?.();
1169
+ void runtimeHost.scanPendingInboxUpdates?.(agent.agentId)?.catch((error) => {
1170
+ log(`drought reconnect inbox scan failed agent=${agent.name}: ${errorMessage(error)}`);
1171
+ });
1168
1172
  }).catch((error) => {
1169
1173
  maintenance.inFlight = false;
1170
1174
  if (shuttingDown || fataling)
@@ -21,6 +21,7 @@ import { processCommandToken } from "../app/internal-command.mjs";
21
21
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
22
22
  import { isChannelReconnecting, isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
23
23
  import { documentCommentMessageId, documentCommentNoticeType, projectDocumentCommentEnvelope, resolveDocumentCommentContext, } from "./document-comment.mjs";
24
+ import { isOpenPlatformHost, requireOpenDomain } from "./platform-hosts.mjs";
24
25
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
25
26
  function isRecord(value) {
26
27
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -112,7 +113,7 @@ function loadAgents(env, testEventSource, larkinHome, reconcileAgentWorkspaceImp
112
113
  }
113
114
  }
114
115
  if (!testEventSource && (typeof candidate.feishuAppSecret !== "string" || !candidate.feishuAppSecret.trim()
115
- || !["https://open.feishu.cn", "https://open.larksuite.com"].includes(String(candidate.feishuDomain)))) {
116
+ || !isOpenPlatformHost(candidate.feishuDomain))) {
116
117
  throw new Error(`LARKIN_AGENTS_CONFIG Agent ${id} 缺少有效 channel 凭证/domain`);
117
118
  }
118
119
  const expectedWorkspace = path.join(larkinHome, "agents", id);
@@ -702,7 +703,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
702
703
  const channel = createLarkChannel({
703
704
  appId: agent.feishuAppId,
704
705
  appSecret: agent.feishuAppSecret,
705
- domain: agent.feishuDomain || "https://open.feishu.cn",
706
+ domain: requireOpenDomain(agent.feishuDomain),
706
707
  source: "larkin",
707
708
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
708
709
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -817,7 +818,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
817
818
  const channel = createLarkChannel({
818
819
  appId: agent.feishuAppId,
819
820
  appSecret: agent.feishuAppSecret,
820
- domain: agent.feishuDomain || "https://open.feishu.cn",
821
+ domain: requireOpenDomain(agent.feishuDomain),
821
822
  source: "larkin",
822
823
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
823
824
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -1125,6 +1126,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1125
1126
  }, 80);
1126
1127
  hostState.updateStatus(agent, { droughtReconnectAt, droughtReconnectAbandonedAt: null });
1127
1128
  reconnectFns.get(agent.agentId)?.();
1129
+ void runtimeHost.scanPendingInboxUpdates?.(agent.agentId)?.catch((error) => {
1130
+ log(`drought reconnect inbox scan failed agent=${agent.name}: ${errorMessage(error)}`);
1131
+ });
1128
1132
  }).catch((error) => {
1129
1133
  maintenance.inFlight = false;
1130
1134
  if (shuttingDown || fataling)
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ // Official Feishu vs Lark hosts from @larksuite/cli ResolveEndpoints
3
+ // (open / accounts / applink) plus Open-host docs and console paths.
4
+ // Do not add larkoffice.com or any unofficial host.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PLATFORM_HOSTS = void 0;
7
+ exports.parseLarkinTenant = parseLarkinTenant;
8
+ exports.openPlatformHost = openPlatformHost;
9
+ exports.registerAppAccountsHost = registerAppAccountsHost;
10
+ exports.isOpenPlatformHost = isOpenPlatformHost;
11
+ exports.requireOpenDomain = requireOpenDomain;
12
+ exports.accountTenantFromOpenHost = accountTenantFromOpenHost;
13
+ exports.PLATFORM_HOSTS = {
14
+ feishu: {
15
+ open: "https://open.feishu.cn",
16
+ accounts: "https://accounts.feishu.cn",
17
+ applink: "https://applink.feishu.cn",
18
+ docs: "https://open.feishu.cn/document",
19
+ console: "https://open.feishu.cn/app",
20
+ },
21
+ lark: {
22
+ open: "https://open.larksuite.com",
23
+ accounts: "https://accounts.larksuite.com",
24
+ applink: "https://applink.larksuite.com",
25
+ docs: "https://open.larksuite.com/document",
26
+ console: "https://open.larksuite.com/app",
27
+ },
28
+ };
29
+ function parseLarkinTenant(value) {
30
+ return value === "feishu" || value === "lark" ? value : null;
31
+ }
32
+ function openPlatformHost(tenant) {
33
+ return exports.PLATFORM_HOSTS[tenant].open;
34
+ }
35
+ function registerAppAccountsHost(tenant) {
36
+ // node-sdk registerApp.domain / larkDomain interpolate https://${host}
37
+ return new URL(exports.PLATFORM_HOSTS[tenant].accounts).host;
38
+ }
39
+ function isOpenPlatformHost(value) {
40
+ return value === exports.PLATFORM_HOSTS.feishu.open || value === exports.PLATFORM_HOSTS.lark.open;
41
+ }
42
+ function requireOpenDomain(value) {
43
+ if (isOpenPlatformHost(value))
44
+ return value;
45
+ throw new Error("missing or invalid Open Platform domain");
46
+ }
47
+ function accountTenantFromOpenHost(domain) {
48
+ return domain === exports.PLATFORM_HOSTS.lark.open ? "lark" : "feishu";
49
+ }
@@ -0,0 +1,40 @@
1
+ // Official Feishu vs Lark hosts from @larksuite/cli ResolveEndpoints
2
+ // (open / accounts / applink) plus Open-host docs and console paths.
3
+ // Do not add larkoffice.com or any unofficial host.
4
+ export const PLATFORM_HOSTS = {
5
+ feishu: {
6
+ open: "https://open.feishu.cn",
7
+ accounts: "https://accounts.feishu.cn",
8
+ applink: "https://applink.feishu.cn",
9
+ docs: "https://open.feishu.cn/document",
10
+ console: "https://open.feishu.cn/app",
11
+ },
12
+ lark: {
13
+ open: "https://open.larksuite.com",
14
+ accounts: "https://accounts.larksuite.com",
15
+ applink: "https://applink.larksuite.com",
16
+ docs: "https://open.larksuite.com/document",
17
+ console: "https://open.larksuite.com/app",
18
+ },
19
+ };
20
+ export function parseLarkinTenant(value) {
21
+ return value === "feishu" || value === "lark" ? value : null;
22
+ }
23
+ export function openPlatformHost(tenant) {
24
+ return PLATFORM_HOSTS[tenant].open;
25
+ }
26
+ export function registerAppAccountsHost(tenant) {
27
+ // node-sdk registerApp.domain / larkDomain interpolate https://${host}
28
+ return new URL(PLATFORM_HOSTS[tenant].accounts).host;
29
+ }
30
+ export function isOpenPlatformHost(value) {
31
+ return value === PLATFORM_HOSTS.feishu.open || value === PLATFORM_HOSTS.lark.open;
32
+ }
33
+ export function requireOpenDomain(value) {
34
+ if (isOpenPlatformHost(value))
35
+ return value;
36
+ throw new Error("missing or invalid Open Platform domain");
37
+ }
38
+ export function accountTenantFromOpenHost(domain) {
39
+ return domain === PLATFORM_HOSTS.lark.open ? "lark" : "feishu";
40
+ }
@@ -120,8 +120,20 @@ function boundedRecords(agent) {
120
120
  const bounded = [...active, ...terminal].slice(-MAX_DELIVERIES);
121
121
  agent.records = new Map(bounded.map((record) => [record.deliveryId, record]));
122
122
  agent.byMessage = new Map(bounded.map((record) => [record.messageId, record.deliveryId]));
123
+ prunePromotedInboxUpdateIds(agent);
123
124
  return bounded;
124
125
  }
126
+ function prunePromotedInboxUpdateIds(agent) {
127
+ if (!agent.promotedInboxUpdateIds?.size)
128
+ return;
129
+ for (const id of agent.promotedInboxUpdateIds) {
130
+ if (!agent.records.has(id))
131
+ agent.promotedInboxUpdateIds.delete(id);
132
+ }
133
+ if (agent.promotedInboxUpdateIds.size <= MAX_DELIVERIES)
134
+ return;
135
+ agent.promotedInboxUpdateIds = new Set([...agent.promotedInboxUpdateIds].slice(-MAX_DELIVERIES));
136
+ }
125
137
  function createRuntimeHost(options) {
126
138
  const managed = new Map();
127
139
  const listeners = new Set();
@@ -294,6 +306,20 @@ function createRuntimeHost(options) {
294
306
  }
295
307
  };
296
308
  const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
309
+ const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
310
+ const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
311
+ const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
312
+ const acceptedInboxStillPending = (agent, record) => {
313
+ if (!agent.stateStore?.readNdjson)
314
+ return true;
315
+ try {
316
+ return agent.stateStore.readNdjson("inbox")
317
+ .some((row) => row?.message_id === record.messageId);
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ };
297
323
  /**
298
324
  * Runtime acceptance acknowledges only the notification. At the safe turn
299
325
  * boundary, canonical Inbox presence decides whether that same delivery
@@ -494,6 +520,7 @@ function createRuntimeHost(options) {
494
520
  agent.retryAfterSubmit = false;
495
521
  queueMicrotask(() => { void retryPending(agent); });
496
522
  }
523
+ queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
497
524
  }
498
525
  };
499
526
  const retryPending = async (agent) => {
@@ -533,6 +560,45 @@ function createRuntimeHost(options) {
533
560
  agent.retryPendingInFlight = null;
534
561
  }
535
562
  };
563
+ /**
564
+ * After busy/submitting clears, accepted inbox_update notices have no later
565
+ * turn boundary. Promote each delivery id at most once to a wake.
566
+ */
567
+ const scanAndPromoteAcceptedInboxUpdates = async (agent) => {
568
+ if (!agentIsIdleForInboxScan(agent))
569
+ return;
570
+ if (agent.compactionMachines.size > 0 || agent.compactionRecoveryInFlight.size > 0)
571
+ return;
572
+ const candidates = [...agent.records.values()].filter((record) => record.status === "accepted"
573
+ && isInboxUpdateKind(record.input?.kind) && !agent.promotedInboxUpdateIds.has(record.deliveryId));
574
+ if (!candidates.length)
575
+ return;
576
+ reconcileExternalConsumption(agent);
577
+ let promoted = 0;
578
+ for (const record of candidates) {
579
+ const current = agent.records.get(record.deliveryId);
580
+ if (current?.status !== "accepted")
581
+ continue;
582
+ if (!acceptedInboxStillPending(agent, current)) {
583
+ reconcileAbsentCanonical(agent, current);
584
+ continue;
585
+ }
586
+ agent.promotedInboxUpdateIds.add(current.deliveryId);
587
+ current.reason = INBOX_UPDATE_PROMOTE_REASON;
588
+ current.retryable = true;
589
+ const finalRecord = setRecord(agent, current, "pending");
590
+ if (finalRecord.status === "consumed")
591
+ continue;
592
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: finalRecord.deliveryId,
593
+ messageId: finalRecord.messageId, status: "deferred", reason: INBOX_UPDATE_PROMOTE_REASON });
594
+ promoted += 1;
595
+ }
596
+ if (!promoted)
597
+ return;
598
+ if (agent.retryPendingInFlight)
599
+ agent.retryAfterSubmit = true;
600
+ await retryPending(agent);
601
+ };
536
602
  const scheduleRecreate = (agent, reason) => {
537
603
  if (agent.stopped || agent.retryTimer || agent.starting || agent.session)
538
604
  return;
@@ -870,6 +936,7 @@ function createRuntimeHost(options) {
870
936
  if (outcome !== "failed")
871
937
  return retryPending(agent);
872
938
  });
939
+ queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
873
940
  }
874
941
  else if (event.type === "activity") {
875
942
  if (agent.turnInProgress && event.activity !== "internal")
@@ -1230,6 +1297,14 @@ function createRuntimeHost(options) {
1230
1297
  };
1231
1298
  },
1232
1299
  isBusy(agentId) { const agent = managed.get(agentId); return Boolean(agent?.busy || agent?.submitting || agent?.starting); },
1300
+ async scanPendingInboxUpdates(agentId) {
1301
+ const agents = agentId ? [managed.get(agentId)] : [...managed.values()];
1302
+ for (const agent of agents) {
1303
+ if (!agent || agent.stopped)
1304
+ continue;
1305
+ await scanAndPromoteAcceptedInboxUpdates(agent);
1306
+ }
1307
+ },
1233
1308
  async resetSession(agentId) {
1234
1309
  const agent = managed.get(agentId);
1235
1310
  if (!agent || agent.stopped)
@@ -1546,7 +1621,8 @@ function createRuntimeHost(options) {
1546
1621
  compactionMachines: new Map(), compactionRecoveryInFlight: new Set(), piOverflowCompactionFailed: new Set(),
1547
1622
  piProactiveCompaction: null, piProactiveCompactionGeneration: null, piProactiveCompactionSession: null,
1548
1623
  piProactiveCompactionFailedGeneration: null, readiness: null,
1549
- turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
1624
+ turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false,
1625
+ promotedInboxUpdateIds: new Set() };
1550
1626
  const startupConsumed = [];
1551
1627
  const startupQuarantined = [];
1552
1628
  for (const record of agent.records.values()) {
@@ -1648,6 +1724,7 @@ function createRuntimeHost(options) {
1648
1724
  : { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
1649
1725
  delete existing.reason;
1650
1726
  delete existing.retryable;
1727
+ agent.promotedInboxUpdateIds.delete(existing.deliveryId);
1651
1728
  setRecord(agent, existing, "pending");
1652
1729
  const receipt = await (telemetry?.phase(messageId, "runtime.deliver", api_1.SpanKind.PRODUCER, () => submit(agent, existing, agent.busy || agent.submitting)) ?? submit(agent, existing, agent.busy || agent.submitting));
1653
1730
  telemetry?.delivery(agentId, messageId, receipt.status);
@@ -111,8 +111,20 @@ function boundedRecords(agent) {
111
111
  const bounded = [...active, ...terminal].slice(-MAX_DELIVERIES);
112
112
  agent.records = new Map(bounded.map((record) => [record.deliveryId, record]));
113
113
  agent.byMessage = new Map(bounded.map((record) => [record.messageId, record.deliveryId]));
114
+ prunePromotedInboxUpdateIds(agent);
114
115
  return bounded;
115
116
  }
117
+ function prunePromotedInboxUpdateIds(agent) {
118
+ if (!agent.promotedInboxUpdateIds?.size)
119
+ return;
120
+ for (const id of agent.promotedInboxUpdateIds) {
121
+ if (!agent.records.has(id))
122
+ agent.promotedInboxUpdateIds.delete(id);
123
+ }
124
+ if (agent.promotedInboxUpdateIds.size <= MAX_DELIVERIES)
125
+ return;
126
+ agent.promotedInboxUpdateIds = new Set([...agent.promotedInboxUpdateIds].slice(-MAX_DELIVERIES));
127
+ }
116
128
  export function createRuntimeHost(options) {
117
129
  const managed = new Map();
118
130
  const listeners = new Set();
@@ -285,6 +297,20 @@ export function createRuntimeHost(options) {
285
297
  }
286
298
  };
287
299
  const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
300
+ const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
301
+ const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
302
+ const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
303
+ const acceptedInboxStillPending = (agent, record) => {
304
+ if (!agent.stateStore?.readNdjson)
305
+ return true;
306
+ try {
307
+ return agent.stateStore.readNdjson("inbox")
308
+ .some((row) => row?.message_id === record.messageId);
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ };
288
314
  /**
289
315
  * Runtime acceptance acknowledges only the notification. At the safe turn
290
316
  * boundary, canonical Inbox presence decides whether that same delivery
@@ -485,6 +511,7 @@ export function createRuntimeHost(options) {
485
511
  agent.retryAfterSubmit = false;
486
512
  queueMicrotask(() => { void retryPending(agent); });
487
513
  }
514
+ queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
488
515
  }
489
516
  };
490
517
  const retryPending = async (agent) => {
@@ -524,6 +551,45 @@ export function createRuntimeHost(options) {
524
551
  agent.retryPendingInFlight = null;
525
552
  }
526
553
  };
554
+ /**
555
+ * After busy/submitting clears, accepted inbox_update notices have no later
556
+ * turn boundary. Promote each delivery id at most once to a wake.
557
+ */
558
+ const scanAndPromoteAcceptedInboxUpdates = async (agent) => {
559
+ if (!agentIsIdleForInboxScan(agent))
560
+ return;
561
+ if (agent.compactionMachines.size > 0 || agent.compactionRecoveryInFlight.size > 0)
562
+ return;
563
+ const candidates = [...agent.records.values()].filter((record) => record.status === "accepted"
564
+ && isInboxUpdateKind(record.input?.kind) && !agent.promotedInboxUpdateIds.has(record.deliveryId));
565
+ if (!candidates.length)
566
+ return;
567
+ reconcileExternalConsumption(agent);
568
+ let promoted = 0;
569
+ for (const record of candidates) {
570
+ const current = agent.records.get(record.deliveryId);
571
+ if (current?.status !== "accepted")
572
+ continue;
573
+ if (!acceptedInboxStillPending(agent, current)) {
574
+ reconcileAbsentCanonical(agent, current);
575
+ continue;
576
+ }
577
+ agent.promotedInboxUpdateIds.add(current.deliveryId);
578
+ current.reason = INBOX_UPDATE_PROMOTE_REASON;
579
+ current.retryable = true;
580
+ const finalRecord = setRecord(agent, current, "pending");
581
+ if (finalRecord.status === "consumed")
582
+ continue;
583
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: finalRecord.deliveryId,
584
+ messageId: finalRecord.messageId, status: "deferred", reason: INBOX_UPDATE_PROMOTE_REASON });
585
+ promoted += 1;
586
+ }
587
+ if (!promoted)
588
+ return;
589
+ if (agent.retryPendingInFlight)
590
+ agent.retryAfterSubmit = true;
591
+ await retryPending(agent);
592
+ };
527
593
  const scheduleRecreate = (agent, reason) => {
528
594
  if (agent.stopped || agent.retryTimer || agent.starting || agent.session)
529
595
  return;
@@ -861,6 +927,7 @@ export function createRuntimeHost(options) {
861
927
  if (outcome !== "failed")
862
928
  return retryPending(agent);
863
929
  });
930
+ queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
864
931
  }
865
932
  else if (event.type === "activity") {
866
933
  if (agent.turnInProgress && event.activity !== "internal")
@@ -1221,6 +1288,14 @@ export function createRuntimeHost(options) {
1221
1288
  };
1222
1289
  },
1223
1290
  isBusy(agentId) { const agent = managed.get(agentId); return Boolean(agent?.busy || agent?.submitting || agent?.starting); },
1291
+ async scanPendingInboxUpdates(agentId) {
1292
+ const agents = agentId ? [managed.get(agentId)] : [...managed.values()];
1293
+ for (const agent of agents) {
1294
+ if (!agent || agent.stopped)
1295
+ continue;
1296
+ await scanAndPromoteAcceptedInboxUpdates(agent);
1297
+ }
1298
+ },
1224
1299
  async resetSession(agentId) {
1225
1300
  const agent = managed.get(agentId);
1226
1301
  if (!agent || agent.stopped)
@@ -1537,7 +1612,8 @@ export function createRuntimeHost(options) {
1537
1612
  compactionMachines: new Map(), compactionRecoveryInFlight: new Set(), piOverflowCompactionFailed: new Set(),
1538
1613
  piProactiveCompaction: null, piProactiveCompactionGeneration: null, piProactiveCompactionSession: null,
1539
1614
  piProactiveCompactionFailedGeneration: null, readiness: null,
1540
- turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false };
1615
+ turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false,
1616
+ promotedInboxUpdateIds: new Set() };
1541
1617
  const startupConsumed = [];
1542
1618
  const startupQuarantined = [];
1543
1619
  for (const record of agent.records.values()) {
@@ -1639,6 +1715,7 @@ export function createRuntimeHost(options) {
1639
1715
  : { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
1640
1716
  delete existing.reason;
1641
1717
  delete existing.retryable;
1718
+ agent.promotedInboxUpdateIds.delete(existing.deliveryId);
1642
1719
  setRecord(agent, existing, "pending");
1643
1720
  const receipt = await (telemetry?.phase(messageId, "runtime.deliver", SpanKind.PRODUCER, () => submit(agent, existing, agent.busy || agent.submitting)) ?? submit(agent, existing, agent.busy || agent.submitting));
1644
1721
  telemetry?.delivery(agentId, messageId, receipt.status);
@@ -17,6 +17,7 @@ import { probeNativeRuntimeReadiness } from "../runtime/runtime-readiness.mjs";
17
17
  import { configureBuiltinPiProviderModel, stageBuiltinPiProvider, validatePiBaseUrl, validateBuiltinPiProviderSelection, listProviderModels, PI_PROVIDER_PRESETS } from "../runtime/pi-provider-config.mjs";
18
18
  import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime, createOfficialPiAuthInteraction, createOfficialPiModelRuntime, createOfficialPiLogoutRuntime, createOfficialPiRegistryRuntime, listOfficialPiAuthProviders, logoutOfficialPiProvider, officialPiAuthStatus, runOfficialPiLogin, verifyOfficialPiProviderTurn, } from "../runtime/pi-official-auth.mjs";
19
19
  import { documentCommentSubscriptionCapability, markDocumentCommentSubscriptionVerified, } from "../platform/callback-capability.mjs";
20
+ import { openPlatformHost, parseLarkinTenant, registerAppAccountsHost, } from "../feishu/platform-hosts.mjs";
20
21
  // qrcode-terminal does not publish TypeScript declarations.
21
22
  // @ts-expect-error bundled CommonJS dependency
22
23
  import qrcodePackage from "qrcode-terminal";
@@ -217,6 +218,45 @@ export async function applyDocumentCommentSubscription(input) {
217
218
  throw new Error("document comment local verified-state persistence failed; external subscription rollback was verified");
218
219
  }
219
220
  }
221
+ async function resolveSelectedTenant() {
222
+ const raw = flag("--tenant");
223
+ if (raw !== undefined) {
224
+ const parsed = parseLarkinTenant(raw);
225
+ if (parsed === "feishu" || parsed === "lark")
226
+ return parsed;
227
+ die("--tenant 只支持 feishu 或 lark");
228
+ }
229
+ if (process.stdin.isTTY && process.stdout.isTTY && !testFixture) {
230
+ const questioner = terminalSetupQuestioner();
231
+ try {
232
+ const answer = (await questioner.ask("选择平台(授权二维码必须对应正确品牌;lark-cli --domain 是业务域,品牌是 feishu|lark):\n"
233
+ + " 1. 中国飞书 Feishu(accounts.feishu.cn / https://open.feishu.cn)\n"
234
+ + " 2. International Lark(accounts.larksuite.com / https://open.larksuite.com)\n> ")).trim();
235
+ if (answer === "1" || /^feishu$/i.test(answer))
236
+ return "feishu";
237
+ if (answer === "2" || /^lark$/i.test(answer))
238
+ return "lark";
239
+ die("必须选择 1/feishu 或 2/lark;未开始授权");
240
+ }
241
+ finally {
242
+ questioner.close?.();
243
+ }
244
+ }
245
+ return "feishu";
246
+ }
247
+ function persistTenant(selected, userInfo) {
248
+ const brand = userInfo?.tenant_brand;
249
+ if (brand === undefined || brand === null || brand === "")
250
+ return selected;
251
+ const observed = parseLarkinTenant(brand);
252
+ if (observed !== "feishu" && observed !== "lark") {
253
+ die("授权返回的租户品牌非法;未执行凭证同步、文件写入或 Agent 绑定");
254
+ }
255
+ if (observed !== selected) {
256
+ die(`授权返回的租户 ${observed} 与预先选择的 ${selected} 不一致;未执行凭证同步、文件写入或 Agent 绑定`);
257
+ }
258
+ return selected;
259
+ }
220
260
  function botVerificationRetryable(result) {
221
261
  const text = `${typeof result.stdout === "string" ? result.stdout : ""}\n${typeof result.stderr === "string" ? result.stderr : ""}`;
222
262
  return /\binvalid_client\b|specified app does not exist|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed|temporary network|too many requests|\b(?:429|502|503|504)\b/i.test(text);
@@ -374,7 +414,7 @@ async function fetchBotInfoViaHttp(appId, tenant) {
374
414
  }
375
415
  if (typeof credential.appSecret !== "string" || !credential.appSecret)
376
416
  return null;
377
- const base = tenant === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
417
+ const base = openPlatformHost(tenant === "lark" ? "lark" : "feishu");
378
418
  try {
379
419
  const tokenResponse = await fetch(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
380
420
  method: "POST",
@@ -424,7 +464,10 @@ export async function main() {
424
464
  say(`setup 内部机器人注册阶段
425
465
 
426
466
  用法:
427
- bun bot-register.mjs --auto
467
+ bun bot-register.mjs --auto [--tenant feishu|lark]
468
+
469
+ --tenant 在授权二维码之前选择品牌。默认 feishu(accounts.feishu.cn);
470
+ Lark 必须用 --tenant lark 或交互选择,授权主机是 accounts.larksuite.com。
428
471
 
429
472
  完成后自动:按返回 appId 判定新旧 → 同步并校验 lark-cli bot 凭证 →
430
473
  原子写 bots/<appId>.json(0600)→ 绑定 Agent。`);
@@ -447,7 +490,8 @@ export async function main() {
447
490
  }
448
491
  if (!autoSelect)
449
492
  die("setup 注册阶段必须由交互式选择流程启动");
450
- say("[setup 1/5] 在网页选择已有机器人或创建新机器人");
493
+ const selectedTenant = await resolveSelectedTenant();
494
+ say(`[setup 1/5] 在${selectedTenant === "lark" ? " International Lark" : "中国飞书 Feishu"}网页选择已有机器人或创建新机器人`);
451
495
  if (commentSubscription === "application") {
452
496
  say(`! 已显式选择 ${commentSubscription} 维度评论订阅:一旦平台状态验证为已订阅,Bot 可见文档中实际送达的每条支持评论都会进入 Inbox 并唤醒 Agent,不要求 @Bot。`);
453
497
  say("! setup 将通过官方 lark-cli 的结构化 API 请求创建该订阅,并以只读 subscription_status 二次核验;不会从意图或事件配置推断订阅已生效。");
@@ -483,6 +527,8 @@ export async function main() {
483
527
  let pollingCount = 0;
484
528
  const result = await registerApp({
485
529
  source: "larkin",
530
+ domain: registerAppAccountsHost(selectedTenant),
531
+ larkDomain: registerAppAccountsHost("lark"),
486
532
  addons: {
487
533
  scopes: { tenant: TENANT_SCOPES },
488
534
  events: { items: { tenant: TENANT_EVENTS } },
@@ -509,9 +555,9 @@ export async function main() {
509
555
  die("授权完成但未取得有效凭证;未执行凭证同步、文件写入或 Agent 绑定");
510
556
  const id = rawId;
511
557
  const secret = rawSecret;
558
+ const tenant = persistTenant(selectedTenant, userInfo);
512
559
  const botsDir = ensureSecureBotsDir();
513
560
  say(`[setup 3/5] 正在按 App ID ${id} 刷新凭证并配置 Agent`);
514
- const tenant = userInfo?.tenant_brand === "lark" ? "lark" : "feishu";
515
561
  const botFile = path.join(botsDir, `${id}.json`);
516
562
  const prior = (() => {
517
563
  try {
@@ -8,6 +8,8 @@ import { registerApp as channelRegisterApp } from "@larksuite/channel";
8
8
  import { markSetupCapabilitiesRequested } from "../platform/callback-capability.mjs";
9
9
  import * as larkinConfig from "../platform/config.mjs";
10
10
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
11
+ import { loadValidatedBotCredential } from "./run-credential-preflight.mjs";
12
+ import { parseLarkinTenant, registerAppAccountsHost } from "../feishu/platform-hosts.mjs";
11
13
  // qrcode-terminal does not publish TypeScript declarations.
12
14
  // @ts-expect-error bundled CommonJS dependency
13
15
  import qrcodePackage from "qrcode-terminal";
@@ -41,7 +43,19 @@ if (explicitAppId && explicitAppId !== selected.feishuAppId)
41
43
  if (!/^cli_[A-Za-z0-9]+$/.test(APP_ID))
42
44
  throw new Error(`无效飞书(Lark) App ID:${APP_ID}`);
43
45
  const SEND_TO = flag("--send-to", "");
44
- const TENANT = flag("--tenant", "feishu");
46
+ const explicitTenant = argv.includes("--tenant") ? flag("--tenant", "") : "";
47
+ if (argv.includes("--tenant") && !parseLarkinTenant(explicitTenant)) {
48
+ throw new Error("--tenant 只支持 feishu 或 lark");
49
+ }
50
+ function storedCredentialTenant() {
51
+ try {
52
+ return loadValidatedBotCredential(path.join(config.larkinHome, "bots"), APP_ID).tenant;
53
+ }
54
+ catch {
55
+ return "feishu";
56
+ }
57
+ }
58
+ const TENANT = parseLarkinTenant(explicitTenant) ?? storedCredentialTenant();
45
59
  const WAIT_MIN = Number(flag("--wait-min", "9"));
46
60
  const URL_FILE = flag("--url-file", "");
47
61
  const TENANT_SCOPES = [
@@ -108,9 +122,8 @@ export async function main() {
108
122
  sendUrlToChat(info.url, minutes);
109
123
  },
110
124
  onStatusChange: (info) => log(`[grant] 状态: ${info.status}`),
125
+ domain: registerAppAccountsHost(TENANT),
111
126
  };
112
- if (TENANT === "lark")
113
- options.domain = "lark";
114
127
  let result;
115
128
  try {
116
129
  result = await registerApp(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [