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.
- package/dist/agent/context-prompt.cjs +16 -3
- package/dist/agent/context-prompt.mjs +16 -3
- package/dist/app/cli.mjs +2 -2
- package/dist/app/lark-cli.mjs +80 -10
- package/dist/app/local-control.mjs +32 -4
- package/dist/app/run.mjs +47 -8
- package/dist/app/setup.mjs +74 -21
- package/dist/feishu/host-processing-eye.cjs +1 -0
- package/dist/feishu/host-processing-eye.mjs +1 -0
- package/dist/feishu/host-shell.cjs +1 -1
- package/dist/feishu/host-shell.mjs +1 -1
- package/dist/platform/process-inspect.cjs +14 -1
- package/dist/runtime/pi-provider-config.cjs +13 -1
- package/dist/runtime/pi-provider-config.mjs +13 -1
- package/dist/setup/bot-register.mjs +126 -1
- package/dist/setup/grant-scopes.mjs +14 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
`
|
|
115
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
112
|
-
`
|
|
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 <
|
|
52
|
-
Run
|
|
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]
|
package/dist/app/lark-cli.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
861
|
+
return passthroughWithObservation(effectiveArgv, privateEnv, io, nativeDependencies, store);
|
|
800
862
|
try {
|
|
801
|
-
const target = guardedTarget(decision,
|
|
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(
|
|
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(
|
|
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(
|
|
871
|
-
if (decision.kind === "passthrough" && !requiresCapturedPassthrough(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 (
|
|
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"));
|
package/dist/app/setup.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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>
|
|
45
|
-
--
|
|
46
|
-
|
|
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 (
|
|
105
|
-
registerArgs.push("--runtime",
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 启动。`);
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
|
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}
|
|
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,
|