larkin 0.2.70 → 0.2.73

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.
@@ -3,7 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContextPromptBuilder = exports.LARKIN_STANDING_PROMPT_VERSION = void 0;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
6
- exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v16";
6
+ /**
7
+ * Standing prompt 工程(调 prompt 时先读):
8
+ * - OpenAI Prompt Engineering Guide: https://platform.openai.com/docs/guides/prompt-engineering
9
+ * - Anthropic Prompt Engineering overview: https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview
10
+ *
11
+ * 本文件维护的核心调优原则:
12
+ * 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
13
+ * 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
14
+ * 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
15
+ * 4. 幂等与安全:idempotency、freshness 前置检查、不可重复提交、脱敏、失败可见(fail visibly)。
16
+ * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
17
+ * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
18
+ */
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v17";
7
20
  const FEISHU_IM_COMMAND_GROUPS = [
8
21
  ["Messages", ["im +messages-send", "im +messages-reply", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
9
22
  ["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
@@ -111,8 +124,8 @@ class ContextPromptBuilder {
111
124
  "For exact text supplied directly in the current instruction or Inbox event, pass the body unchanged as one literal `--text` argument. A direct literal must not use command substitution, backticks, `eval`, `echo`, or an unquoted variable; if it cannot be represented safely, stop and report the limitation instead of normalizing it.",
112
125
  "An explicit exact or verbatim direct literal uses `--text` and overrides the regular markdown default.",
113
126
  `For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
114
- `For an ordinary current Inbox exact reply when the user or event does not explicitly request a topic, in-thread, or thread reply, after the required poll use this complete canonical recipe: \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --json\`. Replace the placeholders with the polled id and unchanged exact literal, and omit \`--reply-in-thread\` so the reply stays on the original chat main timeline.`,
115
- `Only when the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply may you use this known canonical recipe after the required poll: \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. You must not infer a topic request from available thread metadata, the source message id, or ordinary reply wording; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
127
+ `A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
128
+ `Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
116
129
  "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. Without a `freshness_conflict` it uses two total model tool calls including the poll. If the first write instead returns a nonzero pre-commit, provider-not-reached `freshness_conflict`, reconsider its bounded context and, only if the exact operation remains unchanged, retry the identical command once; this is the only allowed extra call and produces exactly three total model tool calls including the poll.",
117
130
  "For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
118
131
  "Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
@@ -1,6 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
3
- export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v16";
3
+ /**
4
+ * Standing prompt 工程(调 prompt 时先读):
5
+ * - OpenAI Prompt Engineering Guide: https://platform.openai.com/docs/guides/prompt-engineering
6
+ * - Anthropic Prompt Engineering overview: https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview
7
+ *
8
+ * 本文件维护的核心调优原则:
9
+ * 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
10
+ * 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
11
+ * 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
12
+ * 4. 幂等与安全:idempotency、freshness 前置检查、不可重复提交、脱敏、失败可见(fail visibly)。
13
+ * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
14
+ * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
15
+ */
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v17";
4
17
  const FEISHU_IM_COMMAND_GROUPS = [
5
18
  ["Messages", ["im +messages-send", "im +messages-reply", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
6
19
  ["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
@@ -108,8 +121,8 @@ export class ContextPromptBuilder {
108
121
  "For exact text supplied directly in the current instruction or Inbox event, pass the body unchanged as one literal `--text` argument. A direct literal must not use command substitution, backticks, `eval`, `echo`, or an unquoted variable; if it cannot be represented safely, stop and report the limitation instead of normalizing it.",
109
122
  "An explicit exact or verbatim direct literal uses `--text` and overrides the regular markdown default.",
110
123
  `For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
111
- `For an ordinary current Inbox exact reply when the user or event does not explicitly request a topic, in-thread, or thread reply, after the required poll use this complete canonical recipe: \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --json\`. Replace the placeholders with the polled id and unchanged exact literal, and omit \`--reply-in-thread\` so the reply stays on the original chat main timeline.`,
112
- `Only when the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply may you use this known canonical recipe after the required poll: \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. You must not infer a topic request from available thread metadata, the source message id, or ordinary reply wording; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
124
+ `A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
125
+ `Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
113
126
  "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. Without a `freshness_conflict` it uses two total model tool calls including the poll. If the first write instead returns a nonzero pre-commit, provider-not-reached `freshness_conflict`, reconsider its bounded context and, only if the exact operation remains unchanged, retry the identical command once; this is the only allowed extra call and produces exactly three total model tool calls including the poll.",
114
127
  "For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
115
128
  "Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
package/dist/app/cli.mjs CHANGED
@@ -48,8 +48,8 @@ if (runtimeAgentAuthority && command === "comment")
48
48
  const commandHelp = {
49
49
  start: `Usage: larkin start [--agent <App ID> | --agents <App ID,...>]
50
50
  Start one foreground supervisor for the daemon and local dashboard, or reuse it.`,
51
- setup: `Usage: larkin setup [--runtime <runtime>] [--comment-subscription <none|application>] [--no-start]
52
- Run the interactive setup to create or connect a bot and configure its Agent.`,
51
+ setup: `Usage: larkin setup [--runtime <builtin-pi|external-pi|codex|claude>] [--provider <id> --api-key <key>]
52
+ 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).`,
53
53
  status: `Usage: larkin status [--json]
54
54
  Show Agent configuration, bot identity, credentials, and connection status. Use --json for readiness automation.`,
55
55
  agents: `Usage: larkin agents [--json]
@@ -28,6 +28,7 @@ const MANAGEMENT_COMMANDS = new Set(["auth", "config", "profile", "update", "ins
28
28
  const USER_ONLY_COMMANDS = new Set(["attendance", "mail", "okr"]);
29
29
  const POLICY_VALUE_FLAGS = new Set([
30
30
  "--as", "--profile", "--config-dir", "--agent", "--chat-id", "--user-id", "--message-id", "--idempotency-key", "--thread-id",
31
+ "--mention",
31
32
  ]);
32
33
  const PROTECTED_VALUE_FLAGS = new Set([
33
34
  ...POLICY_VALUE_FLAGS,
@@ -94,6 +95,59 @@ function protectedSyntaxTokens(argv) {
94
95
  function hasNativeHelpFlag(argv) {
95
96
  return protectedSyntaxTokens(argv).some((argument) => HELP_FLAGS.has(argument));
96
97
  }
98
+ /**
99
+ * Agent 间协作唤醒(#69):把 `--mention <open_id>` 翻译成真实的飞书 mention 元素。
100
+ * 纯文本 @ 不会产生 mention 事件,目标 agent 不会被唤醒;
101
+ * `--text` 正文 → text 消息 `<at user_id="..."></at>` 前缀;
102
+ * `--markdown` 正文 → post 消息首个元素为 at 标签(正文按纯文本元素发送)。
103
+ * 仅对 im +messages-send / +messages-reply 生效,其余命令原样透传。
104
+ */
105
+ function mentionTranslation(argv) {
106
+ const nativeArgv = nativeArgvBeforeBoundary(argv);
107
+ if (nativeArgv[0] !== "im"
108
+ || (nativeArgv[1] !== "+messages-send" && nativeArgv[1] !== "+messages-reply")
109
+ || hasNativeHelpFlag(argv))
110
+ return [...argv];
111
+ const parsed = parsePolicyArgv(argv);
112
+ if (parsed.error || !parsed.flags.has("--mention"))
113
+ return [...argv];
114
+ const mention = parsed.flags.get("--mention");
115
+ if (!/^ou_[A-Za-z0-9]+$/.test(mention)) {
116
+ throw new Error("--mention 需要有效的 open_id(ou_ 开头)");
117
+ }
118
+ const rawText = rawFlagValue(argv, "--text");
119
+ const rawMarkdown = rawFlagValue(argv, "--markdown");
120
+ const text = rawText !== null && !rawText.startsWith("-") ? rawText : null;
121
+ const markdown = rawMarkdown !== null && !rawMarkdown.startsWith("-") ? rawMarkdown : null;
122
+ if (nativeArgv.some((argument) => argument === "--content" || argument.startsWith("--content="))) {
123
+ throw new Error("--mention 不能与 --content 同时使用(会重写消息正文)");
124
+ }
125
+ if (text !== null && markdown !== null)
126
+ throw new Error("--mention 与 --text/--markdown 只能二选一");
127
+ if (text === null && markdown === null)
128
+ throw new Error("--mention 需要 --text 或 --markdown 正文");
129
+ const body = (text ?? markdown ?? "").trim();
130
+ if (!body)
131
+ throw new Error("--mention 的正文不能为空");
132
+ const content = text !== null
133
+ ? JSON.stringify({ text: `<at user_id="${mention}"></at> ${body}` })
134
+ : JSON.stringify({ zh_cn: { title: "", content: [[{ tag: "at", user_id: mention }], [{ tag: "text", text: body }]] } });
135
+ const next = [];
136
+ for (let index = 0; index < argv.length; index += 1) {
137
+ const argument = argv[index];
138
+ const consumed = ["--mention", "--text", "--markdown"].find((name) => argument === name || argument.startsWith(`${name}=`));
139
+ if (consumed) {
140
+ if (argument === consumed)
141
+ index += 1;
142
+ continue;
143
+ }
144
+ next.push(argument);
145
+ }
146
+ const boundary = next.indexOf("--");
147
+ const insertion = boundary < 0 ? next.length : boundary;
148
+ next.splice(insertion, 0, "--content", content, "--msg-type", text !== null ? "text" : "post");
149
+ return next;
150
+ }
97
151
  function protectedOperations(argv) {
98
152
  const tokens = protectedSyntaxTokens(argv);
99
153
  const operations = [];
@@ -740,12 +794,20 @@ function emitCommittedUnverified(result, io, input) {
740
794
  }
741
795
  export function runLarkCli(argv, env = process.env, dependencies = {}) {
742
796
  const io = dependencies.io ?? defaultIo();
797
+ let effectiveArgv;
798
+ try {
799
+ effectiveArgv = mentionTranslation(argv);
800
+ }
801
+ catch (error) {
802
+ io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
803
+ return 2;
804
+ }
743
805
  const runtimeAgentId = larkinConfig.resolveRuntimeAuthority(env);
744
806
  if (!runtimeAgentId) {
745
807
  try {
746
808
  const nativeDependencies = dependencies.nativeCommand ? dependencies
747
809
  : { ...dependencies, nativeCommand: resolveOfficialLarkCli({ spawn: dependencies.spawn, env }) };
748
- return spawnNative(argv, env, io, nativeDependencies);
810
+ return spawnNative(effectiveArgv, env, io, nativeDependencies);
749
811
  }
750
812
  catch (error) {
751
813
  io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -762,7 +824,7 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
762
824
  io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
763
825
  return 2;
764
826
  }
765
- const decision = classifyLarkCliCommand(argv);
827
+ const decision = classifyLarkCliCommand(effectiveArgv);
766
828
  if (decision.kind === "denied") {
767
829
  io.stderr(`lark-cli: ${decision.reason}\n`);
768
830
  return 2;
@@ -796,9 +858,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
796
858
  }
797
859
  }
798
860
  if (decision.kind === "passthrough")
799
- return passthroughWithObservation(argv, privateEnv, io, nativeDependencies, store);
861
+ return passthroughWithObservation(effectiveArgv, privateEnv, io, nativeDependencies, store);
800
862
  try {
801
- const target = guardedTarget(decision, argv, store);
863
+ const target = guardedTarget(decision, effectiveArgv, store);
802
864
  const targetKey = serializeFeishuImTarget(target);
803
865
  const generation = freshnessGeneration(privateEnv);
804
866
  const seen = store.readFreshnessCursor(targetKey, generation);
@@ -816,7 +878,7 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
816
878
  store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, generation);
817
879
  return 3;
818
880
  }
819
- const write = callNative(botArgv(argv, intentId(targetKey, gated.current, argv)), privateEnv, io, nativeDependencies);
881
+ const write = callNative(botArgv(effectiveArgv, intentId(targetKey, gated.current, effectiveArgv)), privateEnv, io, nativeDependencies);
820
882
  if (!write.error && write.status === 0 && !observeSuccessfulWrite(write, target, targetKey, store, generation)) {
821
883
  const responseMessage = writeResponseMessage(write);
822
884
  try {
@@ -853,10 +915,18 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
853
915
  }
854
916
  }
855
917
  export async function runLarkCliProcess(argv, env = process.env) {
918
+ let effectiveArgv;
919
+ try {
920
+ effectiveArgv = mentionTranslation(argv);
921
+ }
922
+ catch (error) {
923
+ process.stderr.write(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
924
+ return 2;
925
+ }
856
926
  const runtimeAgentId = larkinConfig.resolveRuntimeAuthority(env);
857
927
  if (!runtimeAgentId) {
858
928
  try {
859
- return await spawnNativeTransparent(argv, env, resolveOfficialLarkCli({ env }));
929
+ return await spawnNativeTransparent(effectiveArgv, env, resolveOfficialLarkCli({ env }));
860
930
  }
861
931
  catch (error) {
862
932
  process.stderr.write(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -867,17 +937,17 @@ export async function runLarkCliProcess(argv, env = process.env) {
867
937
  const { config } = larkinConfig.loadConfig(env);
868
938
  const agent = larkinConfig.selectAgent(config, { ...env, LARKIN_AGENT_ID: runtimeAgentId });
869
939
  assertAgentWorkspaceBound(agent);
870
- const decision = classifyLarkCliCommand(argv);
871
- if (decision.kind === "passthrough" && !requiresCapturedPassthrough(argv)) {
940
+ const decision = classifyLarkCliCommand(effectiveArgv);
941
+ if (decision.kind === "passthrough" && !requiresCapturedPassthrough(effectiveArgv)) {
872
942
  const privateEnv = managedLarkCliEnv(agent, { ...env, LARKIN_AGENT_ID: agent.agentId });
873
- return await spawnNativeTransparent(argv, privateEnv, resolveOfficialLarkCli({ env: privateEnv }));
943
+ return await spawnNativeTransparent(effectiveArgv, privateEnv, resolveOfficialLarkCli({ env: privateEnv }));
874
944
  }
875
945
  }
876
946
  catch (error) {
877
947
  process.stderr.write(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
878
948
  return 2;
879
949
  }
880
- return runLarkCli(argv, env);
950
+ return runLarkCli(effectiveArgv, env);
881
951
  }
882
952
  export async function main(argv = process.argv.slice(2), env = process.env) {
883
953
  process.exit(await runLarkCliProcess(argv, env));
@@ -138,6 +138,31 @@ function secureAuthority(larkinHome) {
138
138
  }
139
139
  return value;
140
140
  }
141
+ // The authority file is the single trust anchor between supervisor and daemon.
142
+ // If it goes missing while the supervisor stays up (e.g. a crashed restart
143
+ // cycle deleted it), the daemon re-establishes it from the supervisor's process
144
+ // state instead of dying at startup and crash-looping. Only a missing file is
145
+ // healed; a token mismatch stays fail-closed.
146
+ function secureAuthorityOrRecover(larkinHome, authorityToken) {
147
+ try {
148
+ return secureAuthority(larkinHome);
149
+ }
150
+ catch (error) {
151
+ if (error.code !== "ENOENT")
152
+ throw error;
153
+ // The real trust anchor is the supervisor identity: recovery only proceeds
154
+ // when supervisor-status.json describes a live, token-carrying supervisor
155
+ // process, and the token written here is the one that supervisor injected
156
+ // into this daemon. The sameSecret check after recovery is a tautology for
157
+ // the missing-file case; the supervisor's own control-server start re-verifies
158
+ // the file against its token, so a forged file still fails closed there.
159
+ const supervisor = readProcessState(larkinHome).supervisor;
160
+ if (supervisor.state !== "owned" || !supervisor.pid || !supervisor.processStartToken)
161
+ throw error;
162
+ initializeControlAuthority(larkinHome, { pid: Number(supervisor.pid), processStartToken: supervisor.processStartToken }, authorityToken);
163
+ return secureAuthority(larkinHome);
164
+ }
165
+ }
141
166
  function sameSecret(left, right) {
142
167
  const a = Buffer.from(left);
143
168
  const b = Buffer.from(right);
@@ -302,12 +327,15 @@ async function listenPrivate(server, socket) {
302
327
  throw error;
303
328
  }
304
329
  }
305
- export function initializeControlAuthority(larkinHome, supervisor) {
330
+ function writeControlAuthority(larkinHome, authority) {
331
+ atomicWritePrivateJson(controlAuthorityPath(larkinHome), authority);
332
+ }
333
+ export function initializeControlAuthority(larkinHome, supervisor, explicitToken) {
306
334
  fs.mkdirSync(larkinHome, { recursive: true, mode: 0o700 });
307
335
  assertSecureRoot(larkinHome);
308
336
  const socketRoot = ensureSecureSocketRoot(larkinHome);
309
- const token = crypto.randomBytes(32).toString("base64url");
310
- atomicWritePrivateJson(controlAuthorityPath(larkinHome), {
337
+ const token = explicitToken ?? crypto.randomBytes(32).toString("base64url");
338
+ writeControlAuthority(larkinHome, {
311
339
  version: 2,
312
340
  token,
313
341
  socketRoot,
@@ -436,7 +464,7 @@ export function createAgentControlServer({ larkinHome, authorityToken, upsert, r
436
464
  fs.mkdirSync(larkinHome, { recursive: true, mode: 0o700 });
437
465
  assertSecureRoot(larkinHome);
438
466
  removeLegacyResetLedger(larkinHome);
439
- const authority = secureAuthority(larkinHome);
467
+ const authority = secureAuthorityOrRecover(larkinHome, authorityToken);
440
468
  if (!sameSecret(authority.token, authorityToken))
441
469
  throw new Error("daemon control authorization 不匹配");
442
470
  const supervisor = readProcessState(larkinHome).supervisor;
package/dist/app/run.mjs CHANGED
@@ -195,6 +195,10 @@ export async function main() {
195
195
  runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
196
196
  let stopping = false;
197
197
  let dashboard = null;
198
+ // Set when the daemon-restart path stops the dashboard on purpose (port
199
+ // handover). Its exit is then not a crash and must not count against the
200
+ // dashboard recovery budget nor spawn a duplicate dashboard.
201
+ let intentionalDashboardExit = null;
198
202
  let dashboardRestartTimer = null;
199
203
  const dashboardCrashes = [];
200
204
  const dashboardScript = process.env.LARKIN_FEISHU_DRYRUN === "1" && process.env.LARKIN_TEST_DASHBOARD_SCRIPT
@@ -234,15 +238,32 @@ export async function main() {
234
238
  daemonRestartTimer = null;
235
239
  if (stopping)
236
240
  return;
237
- // The fresh daemon re-registers its control authority; drop the stale one.
238
- try {
239
- removeControlAuthority(configDir, controlToken);
240
- }
241
- catch { /* best effort */ }
242
- // Dashboard state is coupled to the daemon; restart it alongside.
241
+ // Keep the control authority intact: the fresh daemon extends it with its
242
+ // own binding and replaces the stale control socket via prepareSocket.
243
+ // Deleting it here makes the daemon's startup fail closed (secureAuthority
244
+ // ENOENT), so every relaunch would crash and the supervisor would loop
245
+ // forever (observed in production as daemon 异常退出 2/3 repeats).
246
+ // Dashboard state is coupled to the daemon; restart it alongside, but let
247
+ // the old dashboard release the dashboard port first: its intentional
248
+ // stop is not a crash and the replacement starts from the exit handler.
243
249
  const oldDashboard = dashboard;
244
250
  if (oldDashboard && oldDashboard.exitCode === null && oldDashboard.signalCode === null) {
251
+ intentionalDashboardExit = oldDashboard;
252
+ dashboard = null;
245
253
  oldDashboard.kill("SIGTERM");
254
+ // If the old dashboard ignores SIGTERM (observed in production holding
255
+ // the dashboard port for minutes), escalate so the port is released
256
+ // and the replacement can start. unref keeps an ignored dashboard from
257
+ // delaying supervisor shutdown.
258
+ const escalate = setTimeout(() => {
259
+ if (oldDashboard.exitCode === null && oldDashboard.signalCode === null)
260
+ oldDashboard.kill("SIGKILL");
261
+ }, 5_000);
262
+ escalate.unref?.();
263
+ oldDashboard.once("exit", () => clearTimeout(escalate));
264
+ }
265
+ else {
266
+ launchDashboard();
246
267
  }
247
268
  if (dashboardRestartTimer) {
248
269
  clearTimeout(dashboardRestartTimer);
@@ -251,7 +272,6 @@ export async function main() {
251
272
  dashboardCrashes.length = 0;
252
273
  launchDaemon();
253
274
  writeSupervisorStatus(statusFile, supervisor, { daemonPid: daemon.pid });
254
- launchDashboard();
255
275
  }, delay);
256
276
  });
257
277
  };
@@ -272,6 +292,14 @@ export async function main() {
272
292
  dashboard = null;
273
293
  if (stopping)
274
294
  return;
295
+ if (intentionalDashboardExit === ownedChild) {
296
+ // Stopped on purpose by the daemon-restart path; bring the replacement
297
+ // up now that the dashboard port is free.
298
+ intentionalDashboardExit = null;
299
+ if (!stopping && daemon.exitCode === null && dashboard === null)
300
+ launchDashboard();
301
+ return;
302
+ }
275
303
  const now = Date.now();
276
304
  dashboardCrashes.push(now);
277
305
  while (dashboardCrashes[0] < now - 60_000)
@@ -313,8 +341,19 @@ export async function main() {
313
341
  if (stopping)
314
342
  return;
315
343
  stopping = true;
316
- if (daemon.exitCode === null && daemon.signalCode === null)
344
+ if (daemonRestartTimer) {
345
+ clearTimeout(daemonRestartTimer);
346
+ daemonRestartTimer = null;
347
+ }
348
+ if (daemon.exitCode === null && daemon.signalCode === null) {
317
349
  daemon.kill(signal);
350
+ }
351
+ else {
352
+ // The daemon already exited and the supervisor is inside the restart
353
+ // backoff window; the pending timer would never fire again (stopping is
354
+ // set), so resolve the final result now instead of awaiting forever.
355
+ resolveDaemonFinal?.({ code: daemon.exitCode, signal: daemon.signalCode });
356
+ }
318
357
  };
319
358
  process.once("SIGINT", () => requestStop("SIGINT"));
320
359
  process.once("SIGTERM", () => requestStop("SIGTERM"));
@@ -7,6 +7,8 @@ import path from "node:path";
7
7
  import readline from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import * as larkinConfig from "../platform/config.mjs";
10
+ import { PI_PROVIDER_PRESETS } from "../runtime/pi-provider-config.mjs";
11
+ const PI_PROVIDER_IDS = PI_PROVIDER_PRESETS.map((p) => p.id).join(" | ") + " | custom";
10
12
  import { acquireProcessLock, readProcessState } from "../platform/process-state.mjs";
11
13
  import { requestAgentUpsert } from "./local-control.mjs";
12
14
  import { openOwnedDashboardWhenReady } from "./setup-dashboard.mjs";
@@ -27,30 +29,70 @@ if (argv.some((arg) => arg === "--no-dashboard" || arg.startsWith("--no-dashboar
27
29
  }
28
30
  if (has("--start") && has("--no-start"))
29
31
  die("--start 与 --no-start 不能同时使用");
30
- const commentSubscription = flag("--comment-subscription") || null;
31
- if (has("--comment-subscription") && !commentSubscription)
32
- die("--comment-subscription 缺少参数值");
33
- if (commentSubscription && !["none", "application"].includes(commentSubscription)) {
34
- die("--comment-subscription 只支持 none 或 application");
32
+ if (has("--comment-subscription")) {
33
+ die("--comment-subscription 已移除:评论订阅默认 application(平台验证的应用级订阅),不需要该 flag");
34
+ }
35
+ // 默认应用级评论订阅:平台验证的应用订阅让该应用可见文档的全部评论进入 Agent Inbox。
36
+ const commentSubscription = "application";
37
+ const OPT = { runtime: flag("--runtime") || null, commentSubscription, start: true, help: has("--help") || has("-h"), nonInteractive: !Boolean(process.stdin.isTTY) };
38
+ // runtime 枚举归一:builtin-pi(默认)/ external-pi / codex / claude → 内部 (runtime, distribution)
39
+ const RUNTIME_ENUM = ["builtin-pi", "external-pi", "codex", "claude"];
40
+ function normalizeRuntime(value) {
41
+ const chosen = (value || "builtin-pi");
42
+ if (!RUNTIME_ENUM.includes(chosen)) {
43
+ die(`未知 runtime \`${value}\`;可选:${RUNTIME_ENUM.join(" | ")}`);
44
+ }
45
+ if (chosen === "builtin-pi")
46
+ return { runtime: "pi", distribution: "builtin" };
47
+ if (chosen === "external-pi")
48
+ return { runtime: "pi", distribution: "external" };
49
+ return { runtime: chosen };
50
+ }
51
+ const normalizedRuntime = normalizeRuntime(OPT.runtime);
52
+ // 参数化路径:显式 --runtime、无 TTY(Agent 驱动)、或任何 provider 参数。
53
+ // 交互终端且未指定任何参数时,不传 --runtime 给 bot-register,由其交互选择流程接管。
54
+ const parameterized = Boolean(OPT.runtime) || OPT.nonInteractive
55
+ || ["--provider", "--api-key", "--base-url"].some((name) => has(name));
56
+ // builtin-pi 前置校验:授权前就 fast-fail(避免用户白授权后才发现缺 key);--help 永远跳过
57
+ if (!OPT.help && parameterized && normalizedRuntime.distribution === "builtin") {
58
+ const p = flag("--provider");
59
+ const b = flag("--base-url");
60
+ const k = flag("--api-key");
61
+ if (!p && !b)
62
+ die(`builtin-pi 需要 --provider <id>(${PI_PROVIDER_IDS})或 --base-url;或 --runtime external-pi 用已有 pi`);
63
+ if (!k)
64
+ die("builtin-pi 需要 --api-key;或 --runtime external-pi 用已有登录");
65
+ }
66
+ if (!OPT.help && normalizedRuntime.distribution === "external" && (flag("--provider") || flag("--api-key") || flag("--base-url"))) {
67
+ die("external-pi 使用已有 pi 环境,不接受 --provider/--api-key/--base-url");
35
68
  }
36
- const OPT = { runtime: flag("--runtime") || null, commentSubscription, start: !has("--no-start"), help: has("--help") || has("-h") };
37
69
  if (OPT.help) {
38
70
  say(`larkin setup — Create or connect a Feishu bot, then configure and attach its Agent
39
71
 
40
72
  Usage:
41
- larkin setup Select a bot in the browser and run setup
73
+ larkin setup Select a bot in the browser and run setup(默认:内置 Pi + 配置完成即热挂载)
74
+ larkin setup --provider <id> --api-key <key> 指定内置 Pi provider(无需交互)
42
75
 
43
76
  Options:
44
- --runtime <runtime> Select the Agent runtime
45
- --comment-subscription <mode> none (safe default) or application
46
- --no-start Configure the Agent without starting or attaching it
77
+ --runtime <runtime> Agent runtime 枚举:builtin-pi(默认,内置 Pi)| external-pi | codex | claude
78
+ --provider <id> 内置 Pi provider:deepseek | kimi | minimax | zhipu | openai |
79
+ anthropic | gemini | groq | cerebras | xai | fireworks |
80
+ together | mistral | openrouter | kimi-coding | qwen-cn | custom
81
+ --api-key <key> API key(内置 Pi 必填)
82
+ --base-url <url> custom 端点(provider=custom 时必填;也可覆盖 preset 默认端点)
83
+ --model <id> 模型 ID;默认取 provider 预设。不同 runtime 的模型可用
84
+ \`larkin model\` 查看 / 切换
47
85
 
48
86
  setup handles browser authorization, permission grants, credential storage, Agent configuration,
49
87
  and target-only hot attach. Each Agent is identified by its bot App ID: selecting the same bot reuses
50
88
  its existing Agent, memory, and state; creating a new bot creates a new Agent.
51
89
 
52
90
  If larkin is running, only the selected Agent is added or updated. Otherwise setup starts the same
53
- daemon + dashboard supervisor used by larkin start.`);
91
+ daemon + dashboard supervisor used by larkin start.
92
+
93
+ Interactive terminals without flags keep the guided flow (browser bot selection + runtime choice).
94
+ Non-interactive (Agent-driven) runs default to builtin-pi and require --provider/--api-key (or
95
+ --runtime external-pi to reuse an existing pi login).`);
54
96
  process.exit(0);
55
97
  }
56
98
  function runForeground(mode, args) {
@@ -101,10 +143,17 @@ export async function main() {
101
143
  fs.mkdirSync(CFG_DIR, { recursive: true, mode: 0o700 });
102
144
  const resultFile = path.join(CFG_DIR, `.setup-result-${process.pid}.json`);
103
145
  const registerArgs = ["--auto", "--result-file", resultFile];
104
- if (OPT.runtime)
105
- registerArgs.push("--runtime", OPT.runtime);
106
- if (OPT.commentSubscription)
107
- registerArgs.push("--comment-subscription", OPT.commentSubscription);
146
+ if (parameterized) {
147
+ registerArgs.push("--runtime", normalizedRuntime.runtime);
148
+ if (normalizedRuntime.distribution)
149
+ registerArgs.push("--pi-distribution", normalizedRuntime.distribution);
150
+ }
151
+ registerArgs.push("--comment-subscription", OPT.commentSubscription);
152
+ for (const name of ["--provider", "--api-key", "--base-url", "--model"]) {
153
+ const value = flag(name);
154
+ if (value)
155
+ registerArgs.push(name, value);
156
+ }
108
157
  const result = await runForeground("bot-register", registerArgs);
109
158
  if (result.code !== 0)
110
159
  die("机器人授权或 Agent 配置未完成");
@@ -127,12 +176,16 @@ export async function main() {
127
176
  die(`setup 完成但 Agent ${selectedAgentId} 配置不可读`);
128
177
  if (!["codex", "claude", "pi"].includes(configuredAgent.runtime))
129
178
  die(`Runtime ${configuredAgent.runtime} 不受支持`);
130
- const runtimeReadiness = await probeNativeRuntimeReadiness({ runtime: configuredAgent.runtime,
131
- agentId: selectedAgentId, cwd: configuredAgent.workspaceDir,
132
- env: { ...process.env, LARKIN_CONFIG_DIR: CFG_DIR,
133
- ...(configuredAgent.piDistribution ? { LARKIN_PI_DISTRIBUTION: configuredAgent.piDistribution } : {}) } });
134
- if (runtimeReadiness.state !== "ready") {
135
- die(`Runtime ${configuredAgent.runtime} ${runtimeReadiness.state}:${runtimeReadiness.reason || "prerequisite unavailable"};${runtimeReadiness.nextAction || "修复后重试"}`);
179
+ // 非交互(Agent 驱动)时跳过 RPC probe:无 TTY 环境无法进行 pi RPC,探测留到 daemon 启动;
180
+ // 其余真实阻塞(缺 runtime 可执行文件 / 缺 key / 缺 lark-cli)一律 fast-fail 并给出 agent 可读的修复提示。
181
+ if (!OPT.nonInteractive) {
182
+ const runtimeReadiness = await probeNativeRuntimeReadiness({ runtime: configuredAgent.runtime,
183
+ agentId: selectedAgentId, cwd: configuredAgent.workspaceDir,
184
+ env: { ...process.env, LARKIN_CONFIG_DIR: CFG_DIR,
185
+ ...(configuredAgent.piDistribution ? { LARKIN_PI_DISTRIBUTION: configuredAgent.piDistribution } : {}) } });
186
+ if (runtimeReadiness.state !== "ready") {
187
+ die(`Runtime ${configuredAgent.runtime} ${runtimeReadiness.state}:${runtimeReadiness.reason || "prerequisite unavailable"};${runtimeReadiness.nextAction || "修复后重试"}`);
188
+ }
136
189
  }
137
190
  if (!OPT.start) {
138
191
  say(`\n[setup 5/5] ✓ 配置完成。运行 larkin start 启动。`);
@@ -154,6 +154,7 @@ class ProcessingEyeOrchestrator {
154
154
  }
155
155
  });
156
156
  }
157
+ // 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
157
158
  this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
158
159
  }
159
160
  observeActivity(agent, activity) {
@@ -151,6 +151,7 @@ export class ProcessingEyeOrchestrator {
151
151
  }
152
152
  });
153
153
  }
154
+ // 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
154
155
  this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
155
156
  }
156
157
  observeActivity(agent, activity) {
@@ -386,7 +386,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
386
386
  excerpt: (0, host_business_state_js_1.safeConversationExcerpt)(event.content, 180),
387
387
  at: new Date().toISOString(),
388
388
  }, 30);
389
- if (envelope.sender_type === "human")
389
+ if (envelope.sender_type === "human" || envelope.sender_type === "agent")
390
390
  processingEyes.add(agent, String(envelope.message_id || ""));
391
391
  if (receipt.status === "deferred")
392
392
  log(`Runtime 暂缓投递,消息保留在 inbox seq=${envelope.seq}: ${receipt.reason}`);
@@ -346,7 +346,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
346
346
  excerpt: safeConversationExcerpt(event.content, 180),
347
347
  at: new Date().toISOString(),
348
348
  }, 30);
349
- if (envelope.sender_type === "human")
349
+ if (envelope.sender_type === "human" || envelope.sender_type === "agent")
350
350
  processingEyes.add(agent, String(envelope.message_id || ""));
351
351
  if (receipt.status === "deferred")
352
352
  log(`Runtime 暂缓投递,消息保留在 inbox seq=${envelope.seq}: ${receipt.reason}`);
@@ -22,7 +22,7 @@ function pidAlive(pid, kill = process.kill) {
22
22
  return error.code !== "ESRCH";
23
23
  }
24
24
  }
25
- function inspectUnix(pid) {
25
+ function inspectUnixOnce(pid) {
26
26
  const env = { ...process.env, LC_ALL: "C", LANG: "C" };
27
27
  const state = (0, node_child_process_1.spawnSync)("ps", ["-ww", "-p", String(pid), "-o", "stat="], { encoding: "utf8", env });
28
28
  const started = (0, node_child_process_1.spawnSync)("ps", ["-ww", "-p", String(pid), "-o", "lstart="], { encoding: "utf8", env });
@@ -46,6 +46,19 @@ function inspectUnix(pid) {
46
46
  // opaque identity token, so preserve the stable kernel-provided text.
47
47
  return { ok: true, command: commandText, startToken: startedText };
48
48
  }
49
+ function inspectUnix(pid) {
50
+ // ps(1) can transiently return empty lstart/command output for a live process
51
+ // (observed in production as "ps metadata incomplete" taking down the daemon).
52
+ // Retry briefly so a one-off glitch cannot kill the runtime; liveness is
53
+ // re-checked on every attempt, so a genuine exit still resolves immediately.
54
+ for (let attempt = 0;; attempt += 1) {
55
+ const result = inspectUnixOnce(pid);
56
+ if (result.ok || result.dead || attempt >= 2)
57
+ return result;
58
+ const deadline = Date.now() + 50 * (attempt + 1);
59
+ while (Date.now() < deadline) { /* bounded busy wait; ps glitches are sub-second */ }
60
+ }
61
+ }
49
62
  function inspectWindows(pid) {
50
63
  const script = `$p=Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\"; if($null -eq $p){exit 3}; [pscustomobject]@{CommandLine=$p.CommandLine;CreationDate=$p.CreationDate.ToUniversalTime().ToString('o')} | ConvertTo-Json -Compress`;
51
64
  const result = (0, node_child_process_1.spawnSync)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8" });
@@ -19,9 +19,21 @@ exports.BUNDLED_PI_VERSION = "0.83.0";
19
19
  // Keeping the user-facing defaults here makes endpoint changes reviewable in Larkin.
20
20
  exports.PI_PROVIDER_PRESETS = Object.freeze([
21
21
  { id: "deepseek", name: "DeepSeek(推荐)", provider: "deepseek", baseUrl: "https://api.deepseek.com", defaultModel: "deepseek-v4-pro", api: "openai-completions" },
22
- { id: "kimi", name: "Kimi / Moonshot(中国)", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", defaultModel: "kimi-k2.7-code", api: "openai-completions" },
22
+ { id: "kimi", name: "Kimi / Moonshot(中国)", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", defaultModel: "kimi-k2.6", api: "openai-completions" },
23
23
  { id: "minimax", name: "MiniMax(中国)", provider: "minimax-cn", baseUrl: "https://api.minimaxi.com/anthropic", defaultModel: "MiniMax-M2.7", api: "anthropic-messages" },
24
24
  { id: "zhipu", name: "智谱 / BigModel", provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", defaultModel: "glm-5.2", api: "openai-completions" },
25
+ { id: "openai", name: "OpenAI", provider: "openai", baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-5.5", api: "openai-completions" },
26
+ { id: "anthropic", name: "Anthropic Claude", provider: "anthropic", baseUrl: "https://api.anthropic.com", defaultModel: "claude-opus-4-8", api: "anthropic-messages" },
27
+ { id: "gemini", name: "Google Gemini", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", defaultModel: "gemini-3.1-pro-preview", api: "openai-completions" },
28
+ { id: "groq", name: "Groq", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", defaultModel: "openai/gpt-oss-120b", api: "openai-completions" },
29
+ { id: "cerebras", name: "Cerebras", provider: "cerebras", baseUrl: "https://api.cerebras.ai/v1", defaultModel: "zai-glm-4.7", api: "openai-completions" },
30
+ { id: "xai", name: "xAI Grok", provider: "xai", baseUrl: "https://api.x.ai/v1", defaultModel: "grok-4.5", api: "openai-completions" },
31
+ { id: "fireworks", name: "Fireworks AI", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", defaultModel: "accounts/fireworks/models/kimi-k2p6", api: "openai-completions" },
32
+ { id: "together", name: "Together AI", provider: "together", baseUrl: "https://api.together.ai/v1", defaultModel: "moonshotai/Kimi-K2.6", api: "openai-completions" },
33
+ { id: "mistral", name: "Mistral", provider: "mistral", baseUrl: "https://api.mistral.ai", defaultModel: "devstral-medium-latest", api: "openai-completions" },
34
+ { id: "openrouter", name: "OpenRouter", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", defaultModel: "moonshotai/kimi-k2.6", api: "openai-completions" },
35
+ { id: "kimi-coding", name: "Kimi For Coding", provider: "kimi-coding", baseUrl: "https://api.kimi.com/coding", defaultModel: "kimi-for-coding", api: "openai-completions" },
36
+ { id: "qwen-cn", name: "通义千问 Token Plan(中国)", provider: "qwen-token-plan-cn", baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen3.7-max", api: "openai-completions" },
25
37
  ]);
26
38
  const APP_ID = /^cli_[A-Za-z0-9]+$/;
27
39
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:@+\/-]{0,255}$/;
@@ -6,9 +6,21 @@ export const BUNDLED_PI_VERSION = "0.83.0";
6
6
  // Keeping the user-facing defaults here makes endpoint changes reviewable in Larkin.
7
7
  export const PI_PROVIDER_PRESETS = Object.freeze([
8
8
  { id: "deepseek", name: "DeepSeek(推荐)", provider: "deepseek", baseUrl: "https://api.deepseek.com", defaultModel: "deepseek-v4-pro", api: "openai-completions" },
9
- { id: "kimi", name: "Kimi / Moonshot(中国)", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", defaultModel: "kimi-k2.7-code", api: "openai-completions" },
9
+ { id: "kimi", name: "Kimi / Moonshot(中国)", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", defaultModel: "kimi-k2.6", api: "openai-completions" },
10
10
  { id: "minimax", name: "MiniMax(中国)", provider: "minimax-cn", baseUrl: "https://api.minimaxi.com/anthropic", defaultModel: "MiniMax-M2.7", api: "anthropic-messages" },
11
11
  { id: "zhipu", name: "智谱 / BigModel", provider: "zai-coding-cn", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", defaultModel: "glm-5.2", api: "openai-completions" },
12
+ { id: "openai", name: "OpenAI", provider: "openai", baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-5.5", api: "openai-completions" },
13
+ { id: "anthropic", name: "Anthropic Claude", provider: "anthropic", baseUrl: "https://api.anthropic.com", defaultModel: "claude-opus-4-8", api: "anthropic-messages" },
14
+ { id: "gemini", name: "Google Gemini", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", defaultModel: "gemini-3.1-pro-preview", api: "openai-completions" },
15
+ { id: "groq", name: "Groq", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", defaultModel: "openai/gpt-oss-120b", api: "openai-completions" },
16
+ { id: "cerebras", name: "Cerebras", provider: "cerebras", baseUrl: "https://api.cerebras.ai/v1", defaultModel: "zai-glm-4.7", api: "openai-completions" },
17
+ { id: "xai", name: "xAI Grok", provider: "xai", baseUrl: "https://api.x.ai/v1", defaultModel: "grok-4.5", api: "openai-completions" },
18
+ { id: "fireworks", name: "Fireworks AI", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", defaultModel: "accounts/fireworks/models/kimi-k2p6", api: "openai-completions" },
19
+ { id: "together", name: "Together AI", provider: "together", baseUrl: "https://api.together.ai/v1", defaultModel: "moonshotai/Kimi-K2.6", api: "openai-completions" },
20
+ { id: "mistral", name: "Mistral", provider: "mistral", baseUrl: "https://api.mistral.ai", defaultModel: "devstral-medium-latest", api: "openai-completions" },
21
+ { id: "openrouter", name: "OpenRouter", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", defaultModel: "moonshotai/kimi-k2.6", api: "openai-completions" },
22
+ { id: "kimi-coding", name: "Kimi For Coding", provider: "kimi-coding", baseUrl: "https://api.kimi.com/coding", defaultModel: "kimi-for-coding", api: "openai-completions" },
23
+ { id: "qwen-cn", name: "通义千问 Token Plan(中国)", provider: "qwen-token-plan-cn", baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen3.7-max", api: "openai-completions" },
12
24
  ]);
13
25
  const APP_ID = /^cli_[A-Za-z0-9]+$/;
14
26
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:@+\/-]{0,255}$/;
@@ -7,12 +7,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { registerApp as channelRegisterApp } from "@larksuite/channel";
8
8
  import { internalCommandSpec } from "../app/internal-command.mjs";
9
9
  import * as larkinConfig from "../platform/config.mjs";
10
+ import { createAgentStateStore } from "../agent/agent-state-store.mjs";
10
11
  import { hydrateRuntimeAgent, syncAgentProfile, syncAgentProfileAsync } from "../app/runtime-agent-config.mjs";
11
12
  import { managedLarkCliEnv } from "../app/agent-lark-cli-workspace.mjs";
12
13
  import { resolveOfficialLarkCli } from "../app/official-lark-cli.mjs";
13
14
  import { collectSetupAgentChoice, recoverUnavailableExternalPi, terminalSetupQuestioner } from "./setup-agent-choice.mjs";
14
15
  import { probeNativeRuntimeReadiness } from "../runtime/runtime-readiness.mjs";
15
- import { configureBuiltinPiProviderModel } from "../runtime/pi-provider-config.mjs";
16
+ import { configureBuiltinPiProviderModel, stageBuiltinPiProvider, validatePiBaseUrl, PI_PROVIDER_PRESETS } from "../runtime/pi-provider-config.mjs";
16
17
  import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime, createOfficialPiAuthInteraction, createOfficialPiModelRuntime, createOfficialPiLogoutRuntime, createOfficialPiRegistryRuntime, listOfficialPiAuthProviders, logoutOfficialPiProvider, officialPiAuthStatus, runOfficialPiLogin, verifyOfficialPiProviderTurn, } from "../runtime/pi-official-auth.mjs";
17
18
  import { documentCommentSubscriptionCapability, markDocumentCommentSubscriptionVerified, } from "../platform/callback-capability.mjs";
18
19
  // qrcode-terminal does not publish TypeScript declarations.
@@ -355,6 +356,61 @@ async function runBoundedCliProcess(command, args, env, label) {
355
356
  async function runIdentityProcess(command, args, env) {
356
357
  return runBoundedCliProcess(command, args, env, "Bot identity verification");
357
358
  }
359
+ async function fetchBotInfoViaHttp(appId, tenant) {
360
+ let credential;
361
+ try {
362
+ credential = JSON.parse(fs.readFileSync(path.join(ensureSecureBotsDir(), `${appId}.json`), "utf8"));
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ if (typeof credential.appSecret !== "string" || !credential.appSecret)
368
+ return null;
369
+ const base = tenant === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
370
+ try {
371
+ const tokenResponse = await fetch(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
372
+ method: "POST",
373
+ headers: { "content-type": "application/json" },
374
+ body: JSON.stringify({ app_id: appId, app_secret: credential.appSecret }),
375
+ signal: AbortSignal.timeout(15_000),
376
+ });
377
+ const tokenPayload = await tokenResponse.json();
378
+ if (tokenPayload.code !== 0 || typeof tokenPayload.tenant_access_token !== "string")
379
+ return null;
380
+ const infoResponse = await fetch(`${base}/open-apis/bot/v3/info`, {
381
+ headers: { authorization: `Bearer ${tokenPayload.tenant_access_token}` },
382
+ signal: AbortSignal.timeout(15_000),
383
+ });
384
+ const infoPayload = await infoResponse.json();
385
+ if (infoPayload.code !== 0 || !infoPayload.bot || typeof infoPayload.bot !== "object")
386
+ return null;
387
+ return {
388
+ open_id: typeof infoPayload.bot.open_id === "string" ? infoPayload.bot.open_id : undefined,
389
+ app_name: typeof infoPayload.bot.app_name === "string" ? infoPayload.bot.app_name : undefined,
390
+ avatar_url: typeof infoPayload.bot.avatar_url === "string" ? infoPayload.bot.avatar_url : undefined,
391
+ };
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ }
397
+ /**
398
+ * 允许被添加进群:把应用可用范围设为全员可见(更新后线上立即生效)。
399
+ * 前提是授权确认页勾选了 admin:app.visibility;未勾选时给出可执行提示,不阻断 setup。
400
+ */
401
+ async function ensureAppVisibleToAll(id, official, cliEnv) {
402
+ const result = await runBoundedCliProcess(official.command, [...official.argsPrefix, "api", "PATCH", `/open-apis/application/v6/applications/${id}/visibility`,
403
+ "--as", "bot", "--data", JSON.stringify({ is_visible_to_all: true }), "--json"], cliEnv, "App visibility grant");
404
+ if (result.status !== 0)
405
+ return false;
406
+ try {
407
+ const envelope = JSON.parse(result.stdout || "");
408
+ return envelope?.ok === true;
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
358
414
  export async function main() {
359
415
  if (has("--help") || has("-h")) {
360
416
  say(`setup 内部机器人注册阶段
@@ -396,14 +452,19 @@ export async function main() {
396
452
  "im:message.p2p_msg:readonly",
397
453
  "im:message.group_at_msg:readonly",
398
454
  "im:message.group_msg",
455
+ "im:message:send_as_bot",
399
456
  "im:chat:readonly",
400
457
  "im:chat:create",
401
458
  "im:chat:update",
459
+ "im:chat.group_info:readonly",
402
460
  "im:chat.members:read",
403
461
  "im:chat.members:write_only",
462
+ "im:chat:operate_as_owner",
404
463
  "im:resource",
405
464
  "application:application:self_manage",
406
465
  "contact:user.employee_id:readonly",
466
+ // 允许 setup 把应用可用范围设为全员可见:任何成员都能把 bot 加进自己的群(修复 230003 类场景)
467
+ "admin:app.visibility",
407
468
  // drive.notice.comment_add_v1 and the stable comment read/reply APIs are
408
469
  // currently delivered by Feishu under the aggregate drive tenant scope.
409
470
  "drive:drive",
@@ -518,6 +579,37 @@ export async function main() {
518
579
  questioner.close?.();
519
580
  }
520
581
  }
582
+ // ── 非交互参数化内置 Pi(builtin-pi 是默认 runtime 类型)──
583
+ // --pi-distribution builtin:需 --provider <id> + --api-key <key>(或 --base-url 自定义端点)。
584
+ const piDistributionFlag = flag("--pi-distribution");
585
+ const setupProvider = flag("--provider");
586
+ const setupApiKey = flag("--api-key");
587
+ const setupBaseUrl = flag("--base-url");
588
+ const setupModel = flag("--model");
589
+ if (piDistributionFlag === "builtin") {
590
+ if (!setupProvider && !setupBaseUrl) {
591
+ throw new Error("builtin-pi 需要 --provider <id>(deepseek|kimi|minimax|zhipu|openai|anthropic|gemini|groq|cerebras|xai|fireworks|together|mistral|openrouter|kimi-coding|qwen-cn|custom)或 --base-url;或改用 --runtime external-pi 使用已有 pi 环境");
592
+ }
593
+ if (!setupApiKey)
594
+ throw new Error("builtin-pi 需要 --api-key;或改用 --runtime external-pi 使用已有 pi 登录");
595
+ const presetId = setupProvider ?? "custom";
596
+ const presetDef = PI_PROVIDER_PRESETS.find((p) => p.id === presetId);
597
+ if (!presetDef && !setupBaseUrl)
598
+ throw new Error(`未知 provider \`${presetId}\`;可选:${PI_PROVIDER_PRESETS.map((p) => p.id).join(" | ")} | custom`);
599
+ const raw = {
600
+ distribution: "builtin",
601
+ preset: presetId,
602
+ model: setupModel ?? presetDef?.defaultModel ?? "",
603
+ ...(presetDef ? { baseUrl: presetDef.baseUrl } : setupBaseUrl ? { baseUrl: validatePiBaseUrl(setupBaseUrl) } : {}),
604
+ };
605
+ stageBuiltinPiProvider(CFG_DIR, id, { ...raw, apiKey: setupApiKey });
606
+ temporaryAgentChoiceFile = path.join(CFG_DIR, `.setup-agent-choice-${process.pid}-${Date.now()}.json`);
607
+ fs.writeFileSync(temporaryAgentChoiceFile, `${JSON.stringify({ ...raw, authCompleted: true, readinessCompleted: true })}\n`, { mode: 0o600, flag: "wx" });
608
+ say(`[setup 2/5] ✓ 内置 Pi provider 已配置(${presetId}${setupBaseUrl ? " / custom" : ""})`);
609
+ }
610
+ else if (piDistributionFlag === "external" && (setupProvider || setupApiKey || setupBaseUrl)) {
611
+ throw new Error("external-pi 使用已有 pi 环境与登录,不接受 --provider/--api-key/--base-url");
612
+ }
521
613
  const stagedBotFile = path.join(botsDir, `.${id}.${process.pid}.tmp`);
522
614
  try {
523
615
  fs.writeFileSync(stagedBotFile, `${JSON.stringify({
@@ -628,6 +720,39 @@ export async function main() {
628
720
  say(`✓ ${reconciled.dimension} 维度文档评论订阅已取消并由 subscription_status 验证;仅 @Bot 评论进入 Inbox。`);
629
721
  }
630
722
  }
723
+ // ── setup 完成即主动写入 bot 身份(不再等首次连接)──
724
+ // 新 bot 在首次连接前,dashboard/agents 就能显示名字与头像(原:未连接过、无身份缓存)。
725
+ try {
726
+ const botInfo = await fetchBotInfoViaHttp(id, tenant);
727
+ if (botInfo?.open_id) {
728
+ const store = createAgentStateStore(CFG_DIR, id);
729
+ store.writeJson("botIdentity", {
730
+ open_id: botInfo.open_id,
731
+ name: botInfo.app_name || null,
732
+ avatar_url: botInfo.avatar_url || null,
733
+ updated_at: new Date().toISOString(),
734
+ });
735
+ say(`✓ bot 身份已写入 state:${botInfo.app_name || "?"}(${botInfo.open_id})`);
736
+ }
737
+ else {
738
+ say("! bot 身份接口暂未返回完整信息;首次连接后会自动补写");
739
+ }
740
+ }
741
+ catch (error) {
742
+ say(`! bot 身份主动写入失败(首次连接后会自动补写):${errorMessage(error)}`);
743
+ }
744
+ // ── 允许被添加进群:应用可用范围设为全员可见(更新后线上立即生效,best-effort)──
745
+ try {
746
+ if (await ensureAppVisibleToAll(id, official, cliEnv)) {
747
+ say("✓ 应用可用范围已设为全员可见:任何成员都可以把该 bot 添加进自己的群");
748
+ }
749
+ else {
750
+ say("! 未能自动设为全员可见(可能未确认 admin:app.visibility 权限);如成员无法把 bot 加进群,请在开发者后台「应用发布 → 版本管理与发布」把可用范围设为全部成员,或确认权限后重跑 setup");
751
+ }
752
+ }
753
+ catch (error) {
754
+ say(`! 自动设置全员可见未完成(${errorMessage(error)});可在开发者后台配置可用范围,或确认 admin:app.visibility 后重跑 setup`);
755
+ }
631
756
  }
632
757
  catch (error) {
633
758
  const diagnostic = errorMessage(error);
@@ -45,11 +45,23 @@ const TENANT = flag("--tenant", "feishu");
45
45
  const WAIT_MIN = Number(flag("--wait-min", "9"));
46
46
  const URL_FILE = flag("--url-file", "");
47
47
  const TENANT_SCOPES = [
48
- "im:message:readonly",
48
+ "im:message",
49
+ "im:message.p2p_msg:readonly",
50
+ "im:message.group_at_msg:readonly",
51
+ "im:message.group_msg",
52
+ "im:message:send_as_bot",
49
53
  "im:chat:readonly",
50
54
  "im:chat",
55
+ "im:chat:create",
56
+ "im:chat:update",
51
57
  "im:chat.group_info:readonly",
52
58
  "im:chat.members:read",
59
+ "im:chat.members:write_only",
60
+ "im:chat:operate_as_owner",
61
+ "im:resource",
62
+ "application:application:self_manage",
63
+ "contact:user.employee_id:readonly",
64
+ "admin:app.visibility",
53
65
  "drive:drive",
54
66
  "docs:document.comment:read",
55
67
  "docs:document.comment:create",
@@ -59,7 +71,7 @@ const log = (...args) => { process.stderr.write(`${args.join(" ")}\n`); };
59
71
  function sendUrlToChat(url, minutes) {
60
72
  if (!SEND_TO)
61
73
  return;
62
- const text = `🔐 larkin 给机器人【${APP_ID}】增补读取权限。\n请【应用 owner 本人】打开下面链接确认(约 ${minutes} 分钟内有效),勾选并确认这些权限:\n${TENANT_SCOPES.join("、")}\n\n${url}\n\n确认后我会自动接上并重试读取。`;
74
+ const text = `🔐 larkin 给机器人【${APP_ID}】增补权限。\n请【应用 owner 本人】打开下面链接确认(约 ${minutes} 分钟内有效),勾选并确认这些权限:\n${TENANT_SCOPES.join("、")}\n\n${url}\n\n确认后我会自动接上并重试读取。`;
63
75
  const managed = resolveManagedOfficialCli(selected, process.env);
64
76
  const result = spawnSync(managed.command.command, [...managed.command.argsPrefix, "im", "+messages-send", "--chat-id", SEND_TO, "--text", text, "--json"], {
65
77
  encoding: "utf8", env: managed.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.2.70",
3
+ "version": "0.2.73",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [