larkin 0.5.13 → 0.5.15

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.
@@ -16,7 +16,7 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
16
16
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
17
17
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
18
18
  */
19
- exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v32";
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
20
20
  /**
21
21
  * Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
22
22
  * 不声明 tmux 一定可用,也不复述扩展的工具细节。
@@ -43,6 +43,8 @@ const FEISHU_IM_COMMAND_GROUPS = [
43
43
  ["Messages", ["im +messages-send", "im +messages-reply", "im messages delete", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
44
44
  ["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
45
45
  ];
46
+ // 云文档 shortcut 与 IM 一样是官方 lark-cli 直通,不属于 Agent CLI 自有命令清单。
47
+ const FEISHU_DOCS_COMMANDS = ["docs +create", "docs +update", "docs +fetch"];
46
48
  function clean(value) {
47
49
  return value.replaceAll("\r", "").trim();
48
50
  }
@@ -187,6 +189,15 @@ class ContextPromptBuilder {
187
189
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
188
190
  `- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
189
191
  "",
192
+ "## Feishu docs command map",
193
+ "",
194
+ `Create, read, or update a Feishu cloud document only through these Larkin-owned shortcuts. Identity stays Runtime-locked: use \`${executable}\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a docs command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary or invent a scope grant.`,
195
+ "A missing document URL or token is fail-closed: stop and report the missing locator. Do not guess a token, and do not call any docs shortcut that is absent from this map.",
196
+ `To create a document from supplied Markdown or plain text, run \`${executable} docs +create --doc-format markdown --content '<exact_markdown_body>' --json\`. Pass \`--content\` as one literal argument, using the same ANSI-C quoting rule as message bodies when the body is multiline. For a local file, use \`--content @<file>\` instead of inlining it. Create an empty document only with \`${executable} docs +create --title '<exact_title>' --json\` and no \`--content\`. \`--parent-token\` and \`--parent-position\` are optional and mutually exclusive. Official default \`--doc-format\` is \`xml\`; use \`markdown\` for this ordinary supplied-text recipe.`,
197
+ `To read an existing document, run \`${executable} docs +fetch --doc <doc_url_or_token> --json\`. Add \`--scope outline|range|keyword|section\` only for a partial read. Add \`--detail with-ids\` before an update that needs block ids. Omit \`--scope\` only when the whole document is required.`,
198
+ `To change an existing document, fetch its current content first, then run \`${executable} docs +update --doc <doc_url_or_token> --command <command> --json\`. For a simple inline replacement use \`--command str_replace --pattern '<exact_old>' --content '<exact_new>'\`. To append, use \`--command append --content '<exact_body>'\`. Use \`--command overwrite\` only when the user explicitly asks to replace the whole document. If the fetch fails or the scope is missing, fail closed and do not write from memory.`,
199
+ `- Documents: ${FEISHU_DOCS_COMMANDS.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`,
200
+ "",
190
201
  "Do not invent Larkin commands that are absent from this list. Project instructions and memory remain owned by the runtime's native workspace discovery.",
191
202
  ].filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""));
192
203
  const content = `${sections.join("\n").trim()}\n`;
@@ -13,7 +13,7 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
13
13
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
14
14
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
15
15
  */
16
- export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v32";
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
17
17
  /**
18
18
  * Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
19
19
  * 不声明 tmux 一定可用,也不复述扩展的工具细节。
@@ -40,6 +40,8 @@ const FEISHU_IM_COMMAND_GROUPS = [
40
40
  ["Messages", ["im +messages-send", "im +messages-reply", "im messages delete", "im +chat-messages-list", "im +threads-messages-list", "im +messages-mget"]],
41
41
  ["Chats", ["im +chat-list", "im +chat-search", "im chats get"]],
42
42
  ];
43
+ // 云文档 shortcut 与 IM 一样是官方 lark-cli 直通,不属于 Agent CLI 自有命令清单。
44
+ const FEISHU_DOCS_COMMANDS = ["docs +create", "docs +update", "docs +fetch"];
43
45
  function clean(value) {
44
46
  return value.replaceAll("\r", "").trim();
45
47
  }
@@ -184,6 +186,15 @@ export class ContextPromptBuilder {
184
186
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
185
187
  `- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
186
188
  "",
189
+ "## Feishu docs command map",
190
+ "",
191
+ `Create, read, or update a Feishu cloud document only through these Larkin-owned shortcuts. Identity stays Runtime-locked: use \`${executable}\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a docs command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary or invent a scope grant.`,
192
+ "A missing document URL or token is fail-closed: stop and report the missing locator. Do not guess a token, and do not call any docs shortcut that is absent from this map.",
193
+ `To create a document from supplied Markdown or plain text, run \`${executable} docs +create --doc-format markdown --content '<exact_markdown_body>' --json\`. Pass \`--content\` as one literal argument, using the same ANSI-C quoting rule as message bodies when the body is multiline. For a local file, use \`--content @<file>\` instead of inlining it. Create an empty document only with \`${executable} docs +create --title '<exact_title>' --json\` and no \`--content\`. \`--parent-token\` and \`--parent-position\` are optional and mutually exclusive. Official default \`--doc-format\` is \`xml\`; use \`markdown\` for this ordinary supplied-text recipe.`,
194
+ `To read an existing document, run \`${executable} docs +fetch --doc <doc_url_or_token> --json\`. Add \`--scope outline|range|keyword|section\` only for a partial read. Add \`--detail with-ids\` before an update that needs block ids. Omit \`--scope\` only when the whole document is required.`,
195
+ `To change an existing document, fetch its current content first, then run \`${executable} docs +update --doc <doc_url_or_token> --command <command> --json\`. For a simple inline replacement use \`--command str_replace --pattern '<exact_old>' --content '<exact_new>'\`. To append, use \`--command append --content '<exact_body>'\`. Use \`--command overwrite\` only when the user explicitly asks to replace the whole document. If the fetch fails or the scope is missing, fail closed and do not write from memory.`,
196
+ `- Documents: ${FEISHU_DOCS_COMMANDS.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`,
197
+ "",
187
198
  "Do not invent Larkin commands that are absent from this list. Project instructions and memory remain owned by the runtime's native workspace discovery.",
188
199
  ].filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""));
189
200
  const content = `${sections.join("\n").trim()}\n`;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.evaluateFreshness = evaluateFreshness;
4
+ /** Provider-neutral optimistic gate. Provider fields and cursor ordering stay adapter-owned. */
5
+ function evaluateFreshness(input) {
6
+ let snapshot;
7
+ try {
8
+ snapshot = input.probe();
9
+ }
10
+ catch (error) {
11
+ return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
12
+ }
13
+ try {
14
+ const current = input.adapter.cursor(snapshot);
15
+ const comparison = input.adapter.compare(input.seen, current);
16
+ if (comparison === "gap")
17
+ return { status: "unavailable", reason: "authoritative history cannot establish a comparable head" };
18
+ if (comparison === "fresh")
19
+ return { status: "fresh", current, snapshot };
20
+ if (!current)
21
+ return { status: "unavailable", reason: "authoritative history returned an invalid empty cursor" };
22
+ return { status: "conflict", current, snapshot, context: input.adapter.unseen(input.seen, snapshot) };
23
+ }
24
+ catch (error) {
25
+ return { status: "unavailable", reason: error instanceof Error ? error.message : String(error) };
26
+ }
27
+ }
@@ -284,6 +284,45 @@ function parseCommentReply(argv) {
284
284
  return { messageId, text };
285
285
  }
286
286
  const IM_WRITE_MEMO_LIMIT = 512;
287
+ function normalizeImBodyEscapes(argv) {
288
+ const next = [...argv];
289
+ const boundary = next.indexOf("--");
290
+ const limit = boundary < 0 ? next.length : boundary;
291
+ for (let index = 0; index < limit; index += 1) {
292
+ const argument = next[index];
293
+ const flag = ["--text", "--markdown"].find((candidate) => argument === candidate || argument.startsWith(`${candidate}=`));
294
+ if (!flag)
295
+ continue;
296
+ const valueIndex = argument === flag ? index + 1 : index;
297
+ if (valueIndex >= limit)
298
+ continue;
299
+ const value = argument === flag ? next[valueIndex] : argument.slice(flag.length + 1);
300
+ const hasEscapedWhitespace = /\\[nt]/.test(value);
301
+ // 已含真实换行、Windows 路径和 fenced code 的反斜杠均可能是用户的原文;
302
+ // 仅修复无歧义的单行普通文本,避免破坏代码、正则或路径。
303
+ const preserveVerbatim = /[\r\n]/.test(value)
304
+ || value.includes("```")
305
+ || /(?:^|[\s("'`])[A-Za-z]:\\/.test(value);
306
+ const normalized = hasEscapedWhitespace && !preserveVerbatim
307
+ ? value.replace(/\\([nt])/g, (_match, escape) => escape === "n" ? "\n" : "\t")
308
+ : value;
309
+ if (argument === flag)
310
+ next[valueIndex] = normalized;
311
+ else
312
+ next[index] = `${flag}=${normalized}`;
313
+ if (argument === flag)
314
+ index += 1;
315
+ }
316
+ return next;
317
+ }
318
+ function recordOutboundAt(store, target) {
319
+ store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
320
+ const at = new Date().toISOString();
321
+ state.last_outbound_at = at;
322
+ state.last_outbound_by_target ??= {};
323
+ state.last_outbound_by_target[target] = at;
324
+ });
325
+ }
287
326
  // 只标注、不拦截:每次成功写把「实际生效的幂等 key → 服务端返回的 message_id」记进备忘。
288
327
  // 同 key 再次成功且服务端返回同一个 message_id,说明服务端走了幂等去重(没有产生新消息),
289
328
  // 返回 true 供输出标注 duplicate。拦截权始终在服务端,备忘不会吞掉任何发送。
@@ -1117,7 +1156,7 @@ function emitCommittedUnverified(result, io, input) {
1117
1156
  }
1118
1157
  export function runLarkCli(argv, env = process.env, dependencies = {}) {
1119
1158
  const io = dependencies.io ?? defaultIo();
1120
- const effectiveArgv = argv;
1159
+ let effectiveArgv = argv;
1121
1160
  const runtimeAgentId = larkinConfig.resolveRuntimeAuthority(env);
1122
1161
  if (!runtimeAgentId) {
1123
1162
  try {
@@ -1175,6 +1214,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1175
1214
  }
1176
1215
  if (decision.kind === "passthrough")
1177
1216
  return passthroughWithObservation(effectiveArgv, privateEnv, io, nativeDependencies, store);
1217
+ if (decision.kind === "guarded" && (decision.operation === "send" || decision.operation === "reply")) {
1218
+ effectiveArgv = normalizeImBodyEscapes(effectiveArgv);
1219
+ }
1178
1220
  try {
1179
1221
  const recallMessageId = decision.operation === "recall" ? assertRecallSyntax(effectiveArgv) : null;
1180
1222
  const priorRecall = recallMessageId ? recallLedgerEntry(store, recallMessageId) : null;
@@ -1233,6 +1275,9 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1233
1275
  }
1234
1276
  const intentKey = policyFlagValue(effectiveArgv, "--idempotency-key") ?? intentId(targetKey, effectiveArgv);
1235
1277
  const write = callNative(botArgv(effectiveArgv, intentKey, decision), privateEnv, io, nativeDependencies);
1278
+ if (!write.error && write.status === 0 && (decision.operation === "send" || decision.operation === "reply")) {
1279
+ recordOutboundAt(store, targetKey);
1280
+ }
1236
1281
  if (decision.operation === "urgent-app") {
1237
1282
  try {
1238
1283
  assertUrgentAppNativeAccepted(write);
@@ -15,6 +15,7 @@ class ProcessingEyeOrchestrator {
15
15
  recordError;
16
16
  setTimer;
17
17
  clearTimer;
18
+ now;
18
19
  constructor(options = {}) {
19
20
  this.options = options;
20
21
  this.execFile = options.execFile ?? node_child_process_1.execFile;
@@ -22,6 +23,7 @@ class ProcessingEyeOrchestrator {
22
23
  this.recordError = options.recordStatusError ?? (() => { });
23
24
  this.setTimer = options.setTimer ?? setTimeout;
24
25
  this.clearTimer = options.clearTimer ?? clearTimeout;
26
+ this.now = options.now ?? Date.now;
25
27
  }
26
28
  larkApi(agent, method, apiPath, data, callback) {
27
29
  const args = ["api", method, apiPath];
@@ -64,7 +66,7 @@ class ProcessingEyeOrchestrator {
64
66
  const existing = this.state.get(agent.agentId);
65
67
  if (existing)
66
68
  return existing;
67
- const created = { sawActive: false, fallbackTimer: null, completionTimer: null, gen: 0 };
69
+ const created = { sawActive: false, fallbackTimer: null, completionTimer: null, progressTimer: null, turnStartedAt: 0, gen: 0 };
68
70
  this.state.set(agent.agentId, created);
69
71
  return created;
70
72
  }
@@ -74,6 +76,12 @@ class ProcessingEyeOrchestrator {
74
76
  this.clearTimer(state.completionTimer);
75
77
  state.completionTimer = null;
76
78
  }
79
+ cancelProgress(state) {
80
+ if (!state.progressTimer)
81
+ return;
82
+ this.clearTimer(state.progressTimer);
83
+ state.progressTimer = null;
84
+ }
77
85
  restoreAndClear(agent) {
78
86
  let leftovers = [];
79
87
  try {
@@ -87,7 +95,7 @@ class ProcessingEyeOrchestrator {
87
95
  this.pending.set(agent.agentId, leftovers);
88
96
  this.clear(agent, "启动清扫遗留");
89
97
  }
90
- add(agent, msgId) {
98
+ add(agent, msgId, options = {}) {
91
99
  if (!agent.feishuProfile || !msgId || !msgId.startsWith("om_")) {
92
100
  this.log(`👀 跳过 agent=${agent.name} msg=${msgId || "?"} profile=${agent.feishuProfile || "缺"}(不满足点表情条件)`);
93
101
  return;
@@ -100,7 +108,9 @@ class ProcessingEyeOrchestrator {
100
108
  // add itself therefore establishes an active generation instead of
101
109
  // erasing activity already observed for that turn.
102
110
  state.sawActive = true;
111
+ state.turnStartedAt = this.now();
103
112
  this.cancelCompletion(state);
113
+ this.cancelProgress(state);
104
114
  if (state.fallbackTimer)
105
115
  this.clearTimer(state.fallbackTimer);
106
116
  const generation = state.gen;
@@ -112,6 +122,22 @@ class ProcessingEyeOrchestrator {
112
122
  this.clear(agent, "15分钟兜底");
113
123
  }, 15 * 60 * 1000);
114
124
  state.fallbackTimer = fallbackTimer;
125
+ let progressTimer;
126
+ progressTimer = this.setTimer(() => {
127
+ const current = this.state.get(agent.agentId);
128
+ if (current?.gen !== generation || current.progressTimer !== progressTimer)
129
+ return;
130
+ current.progressTimer = null;
131
+ const pending = this.pending.get(agent.agentId) || [];
132
+ const anchor = pending.at(-1);
133
+ if (!anchor)
134
+ return;
135
+ const lastOutboundAt = this.options.lastOutboundAt?.(agent, anchor.target) ?? null;
136
+ if (lastOutboundAt !== null && lastOutboundAt >= current.turnStartedAt)
137
+ return;
138
+ this.sendProgress(agent, anchor);
139
+ }, 2 * 60 * 1000);
140
+ state.progressTimer = progressTimer;
115
141
  this.larkApi(agent, "POST", `/open-apis/im/v1/messages/${msgId}/reactions`, { reaction_type: { emoji_type: "OnIt" } }, (error, result) => {
116
142
  const reactionId = result?.data?.reaction_id;
117
143
  if (!reactionId) {
@@ -124,7 +150,8 @@ class ProcessingEyeOrchestrator {
124
150
  return;
125
151
  }
126
152
  const list = this.pending.get(agent.agentId) || [];
127
- list.push({ msgId, reactionId });
153
+ list.push({ msgId, reactionId, ...(options.replyInThread ? { replyInThread: true } : {}),
154
+ ...(options.target ? { target: options.target } : {}) });
128
155
  this.pending.set(agent.agentId, list);
129
156
  this.save(agent);
130
157
  });
@@ -139,6 +166,7 @@ class ProcessingEyeOrchestrator {
139
166
  state.fallbackTimer = null;
140
167
  }
141
168
  this.cancelCompletion(state);
169
+ this.cancelProgress(state);
142
170
  }
143
171
  const list = this.pending.get(agent.agentId) || [];
144
172
  if (!list.length) {
@@ -157,6 +185,25 @@ class ProcessingEyeOrchestrator {
157
185
  // 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
158
186
  this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
159
187
  }
188
+ sendProgress(agent, anchor) {
189
+ const selected = this.options.cliForAgent?.(agent) ?? (() => {
190
+ const managed = (0, agent_lark_cli_workspace_js_1.managedOfficialLarkCli)(agent, this.options.envForAgent?.(agent) ?? process.env);
191
+ return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
192
+ })();
193
+ const args = ["im", "+messages-reply", "--message-id", anchor.msgId,
194
+ ...(anchor.replyInThread ? ["--reply-in-thread"] : []),
195
+ "--text", "我还在处理中,完成后会汇总更新。", "--json", "--as", "bot"];
196
+ this.execFile(selected.command, [...selected.argsPrefix, ...args], { encoding: "utf8", timeout: 10_000, env: selected.env }, (error, stdout, stderr) => {
197
+ if (error) {
198
+ const code = error.code;
199
+ const detail = String(stderr || "").trim().slice(0, 160) || String(stdout || "").trim().slice(0, 120);
200
+ this.log(`长任务进度发送失败 agent=${agent.name}: exit=${code ?? "?"}${detail ? ` | ${detail}` : ""}`);
201
+ return;
202
+ }
203
+ this.options.recordOutboundAt?.(agent, anchor.target);
204
+ this.log(`长任务进度已发送 agent=${agent.name} msg=${anchor.msgId}`);
205
+ });
206
+ }
160
207
  observeActivity(agent, activity) {
161
208
  const state = this.getState(agent);
162
209
  if (activity === "error" || activity === "offline") {
@@ -12,6 +12,7 @@ export class ProcessingEyeOrchestrator {
12
12
  recordError;
13
13
  setTimer;
14
14
  clearTimer;
15
+ now;
15
16
  constructor(options = {}) {
16
17
  this.options = options;
17
18
  this.execFile = options.execFile ?? nodeExecFile;
@@ -19,6 +20,7 @@ export class ProcessingEyeOrchestrator {
19
20
  this.recordError = options.recordStatusError ?? (() => { });
20
21
  this.setTimer = options.setTimer ?? setTimeout;
21
22
  this.clearTimer = options.clearTimer ?? clearTimeout;
23
+ this.now = options.now ?? Date.now;
22
24
  }
23
25
  larkApi(agent, method, apiPath, data, callback) {
24
26
  const args = ["api", method, apiPath];
@@ -61,7 +63,7 @@ export class ProcessingEyeOrchestrator {
61
63
  const existing = this.state.get(agent.agentId);
62
64
  if (existing)
63
65
  return existing;
64
- const created = { sawActive: false, fallbackTimer: null, completionTimer: null, gen: 0 };
66
+ const created = { sawActive: false, fallbackTimer: null, completionTimer: null, progressTimer: null, turnStartedAt: 0, gen: 0 };
65
67
  this.state.set(agent.agentId, created);
66
68
  return created;
67
69
  }
@@ -71,6 +73,12 @@ export class ProcessingEyeOrchestrator {
71
73
  this.clearTimer(state.completionTimer);
72
74
  state.completionTimer = null;
73
75
  }
76
+ cancelProgress(state) {
77
+ if (!state.progressTimer)
78
+ return;
79
+ this.clearTimer(state.progressTimer);
80
+ state.progressTimer = null;
81
+ }
74
82
  restoreAndClear(agent) {
75
83
  let leftovers = [];
76
84
  try {
@@ -84,7 +92,7 @@ export class ProcessingEyeOrchestrator {
84
92
  this.pending.set(agent.agentId, leftovers);
85
93
  this.clear(agent, "启动清扫遗留");
86
94
  }
87
- add(agent, msgId) {
95
+ add(agent, msgId, options = {}) {
88
96
  if (!agent.feishuProfile || !msgId || !msgId.startsWith("om_")) {
89
97
  this.log(`👀 跳过 agent=${agent.name} msg=${msgId || "?"} profile=${agent.feishuProfile || "缺"}(不满足点表情条件)`);
90
98
  return;
@@ -97,7 +105,9 @@ export class ProcessingEyeOrchestrator {
97
105
  // add itself therefore establishes an active generation instead of
98
106
  // erasing activity already observed for that turn.
99
107
  state.sawActive = true;
108
+ state.turnStartedAt = this.now();
100
109
  this.cancelCompletion(state);
110
+ this.cancelProgress(state);
101
111
  if (state.fallbackTimer)
102
112
  this.clearTimer(state.fallbackTimer);
103
113
  const generation = state.gen;
@@ -109,6 +119,22 @@ export class ProcessingEyeOrchestrator {
109
119
  this.clear(agent, "15分钟兜底");
110
120
  }, 15 * 60 * 1000);
111
121
  state.fallbackTimer = fallbackTimer;
122
+ let progressTimer;
123
+ progressTimer = this.setTimer(() => {
124
+ const current = this.state.get(agent.agentId);
125
+ if (current?.gen !== generation || current.progressTimer !== progressTimer)
126
+ return;
127
+ current.progressTimer = null;
128
+ const pending = this.pending.get(agent.agentId) || [];
129
+ const anchor = pending.at(-1);
130
+ if (!anchor)
131
+ return;
132
+ const lastOutboundAt = this.options.lastOutboundAt?.(agent, anchor.target) ?? null;
133
+ if (lastOutboundAt !== null && lastOutboundAt >= current.turnStartedAt)
134
+ return;
135
+ this.sendProgress(agent, anchor);
136
+ }, 2 * 60 * 1000);
137
+ state.progressTimer = progressTimer;
112
138
  this.larkApi(agent, "POST", `/open-apis/im/v1/messages/${msgId}/reactions`, { reaction_type: { emoji_type: "OnIt" } }, (error, result) => {
113
139
  const reactionId = result?.data?.reaction_id;
114
140
  if (!reactionId) {
@@ -121,7 +147,8 @@ export class ProcessingEyeOrchestrator {
121
147
  return;
122
148
  }
123
149
  const list = this.pending.get(agent.agentId) || [];
124
- list.push({ msgId, reactionId });
150
+ list.push({ msgId, reactionId, ...(options.replyInThread ? { replyInThread: true } : {}),
151
+ ...(options.target ? { target: options.target } : {}) });
125
152
  this.pending.set(agent.agentId, list);
126
153
  this.save(agent);
127
154
  });
@@ -136,6 +163,7 @@ export class ProcessingEyeOrchestrator {
136
163
  state.fallbackTimer = null;
137
164
  }
138
165
  this.cancelCompletion(state);
166
+ this.cancelProgress(state);
139
167
  }
140
168
  const list = this.pending.get(agent.agentId) || [];
141
169
  if (!list.length) {
@@ -154,6 +182,25 @@ export class ProcessingEyeOrchestrator {
154
182
  // 执行结束(含正常完成):只摘除 👀,不再追加任何完成 reaction(用户 2026-08-12 要求)。
155
183
  this.log(`👀 已摘 agent=${agent.name} n=${list.length} 原因=${reason || "?"}`);
156
184
  }
185
+ sendProgress(agent, anchor) {
186
+ const selected = this.options.cliForAgent?.(agent) ?? (() => {
187
+ const managed = managedOfficialLarkCli(agent, this.options.envForAgent?.(agent) ?? process.env);
188
+ return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
189
+ })();
190
+ const args = ["im", "+messages-reply", "--message-id", anchor.msgId,
191
+ ...(anchor.replyInThread ? ["--reply-in-thread"] : []),
192
+ "--text", "我还在处理中,完成后会汇总更新。", "--json", "--as", "bot"];
193
+ this.execFile(selected.command, [...selected.argsPrefix, ...args], { encoding: "utf8", timeout: 10_000, env: selected.env }, (error, stdout, stderr) => {
194
+ if (error) {
195
+ const code = error.code;
196
+ const detail = String(stderr || "").trim().slice(0, 160) || String(stdout || "").trim().slice(0, 120);
197
+ this.log(`长任务进度发送失败 agent=${agent.name}: exit=${code ?? "?"}${detail ? ` | ${detail}` : ""}`);
198
+ return;
199
+ }
200
+ this.options.recordOutboundAt?.(agent, anchor.target);
201
+ this.log(`长任务进度已发送 agent=${agent.name} msg=${anchor.msgId}`);
202
+ });
203
+ }
157
204
  observeActivity(agent, activity) {
158
205
  const state = this.getState(agent);
159
206
  if (activity === "error" || activity === "offline") {
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.memberNamesFromPayloads = memberNamesFromPayloads;
40
+ exports.lastOutboundAtForTarget = lastOutboundAtForTarget;
40
41
  exports.createHostShell = createHostShell;
41
42
  const node_child_process_1 = require("node:child_process");
42
43
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -49,6 +50,7 @@ const workspace_service_js_1 = require("../platform/workspace-service.cjs");
49
50
  const agent_state_store_js_1 = require("../agent/agent-state-store.cjs");
50
51
  const host_business_state_js_1 = require("./host-business-state.cjs");
51
52
  const host_processing_eye_js_1 = require("./host-processing-eye.cjs");
53
+ const im_freshness_adapter_js_1 = require("./im-freshness-adapter.cjs");
52
54
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
53
55
  const host_reminder_orchestrator_js_1 = require("../agent/host-reminder-orchestrator.cjs");
54
56
  const inbox_audit_heartbeat_js_1 = require("../agent/inbox-audit-heartbeat.cjs");
@@ -120,6 +122,34 @@ function memberNamesFromPayloads(payloads) {
120
122
  return names;
121
123
  }
122
124
  function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
125
+ function canonicalImTarget(target) {
126
+ if (!target)
127
+ return null;
128
+ try {
129
+ return (0, im_freshness_adapter_js_1.serializeFeishuImTarget)((0, im_freshness_adapter_js_1.feishuImTarget)(target));
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ function lastOutboundAtForTarget(store, target) {
136
+ const state = store.readJson("freshnessState", {});
137
+ const canonicalTarget = canonicalImTarget(target);
138
+ const value = canonicalTarget ? state.last_outbound_by_target?.[canonicalTarget] : state.last_outbound_at;
139
+ const timestamp = typeof value === "string" ? Date.parse(value) : Number.NaN;
140
+ return Number.isFinite(timestamp) ? timestamp : null;
141
+ }
142
+ function recordOutboundAtForTarget(store, target) {
143
+ const canonicalTarget = canonicalImTarget(target);
144
+ store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
145
+ const at = new Date().toISOString();
146
+ state.last_outbound_at = at;
147
+ if (!canonicalTarget)
148
+ return;
149
+ state.last_outbound_by_target ??= {};
150
+ state.last_outbound_by_target[canonicalTarget] = at;
151
+ });
152
+ }
123
153
  function safeRuntimeStatusDiagnostic(error) {
124
154
  return (0, provider_error_classifier_js_1.safeProviderDiagnostic)(error, "Runtime entered an error state", 500);
125
155
  }
@@ -338,6 +368,8 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
338
368
  recordStatusError: (agent, text) => hostState.recordStatusError(agent, text),
339
369
  readPending: (agent) => stateStore(agent).readJson("pendingReact", {}).items || [],
340
370
  writePending: (agent, items) => stateStore(agent).writeJson("pendingReact", { items }),
371
+ lastOutboundAt: (agent, target) => lastOutboundAtForTarget(stateStore(agent), target),
372
+ recordOutboundAt: (agent, target) => recordOutboundAtForTarget(stateStore(agent), target),
341
373
  cliForAgent: (agent) => {
342
374
  const managed = managedCliForAgent(agent);
343
375
  return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
@@ -567,8 +599,12 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
567
599
  excerpt: (0, host_business_state_js_1.safeConversationExcerpt)(event.content, 180),
568
600
  at: new Date().toISOString(),
569
601
  }, 30);
570
- if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
571
- processingEyes.add(agent, String(inboxEnvelope.message_id || ""));
602
+ if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent") {
603
+ processingEyes.add(agent, String(inboxEnvelope.message_id || ""), {
604
+ replyInThread: String(inboxEnvelope.target || "").startsWith("thread:"),
605
+ target: String(inboxEnvelope.target || ""),
606
+ });
607
+ }
572
608
  if (receipt.status === "deferred")
573
609
  log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
574
610
  }
@@ -9,6 +9,7 @@ import { reconcileAgentWorkspace } from "../platform/workspace-service.mjs";
9
9
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
10
10
  import { HostEnvelopeProjector, HostStateProjection, SenderIdentityCache, projectActivityStatus, projectSessionStatus, safeConversationExcerpt, shouldPreventiveReconnect, } from "./host-business-state.mjs";
11
11
  import { ProcessingEyeOrchestrator } from "./host-processing-eye.mjs";
12
+ import { feishuImTarget, serializeFeishuImTarget } from "./im-freshness-adapter.mjs";
12
13
  import { projectInboxEnvelope, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
13
14
  import { HostReminderOrchestrator } from "../agent/host-reminder-orchestrator.mjs";
14
15
  import { boundedInboxAuditDiagnostic, InboxAuditHeartbeat, INBOX_AUDIT_CADENCE_MS } from "../agent/inbox-audit-heartbeat.mjs";
@@ -80,6 +81,34 @@ export function memberNamesFromPayloads(payloads) {
80
81
  return names;
81
82
  }
82
83
  function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
84
+ function canonicalImTarget(target) {
85
+ if (!target)
86
+ return null;
87
+ try {
88
+ return serializeFeishuImTarget(feishuImTarget(target));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ export function lastOutboundAtForTarget(store, target) {
95
+ const state = store.readJson("freshnessState", {});
96
+ const canonicalTarget = canonicalImTarget(target);
97
+ const value = canonicalTarget ? state.last_outbound_by_target?.[canonicalTarget] : state.last_outbound_at;
98
+ const timestamp = typeof value === "string" ? Date.parse(value) : Number.NaN;
99
+ return Number.isFinite(timestamp) ? timestamp : null;
100
+ }
101
+ function recordOutboundAtForTarget(store, target) {
102
+ const canonicalTarget = canonicalImTarget(target);
103
+ store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
104
+ const at = new Date().toISOString();
105
+ state.last_outbound_at = at;
106
+ if (!canonicalTarget)
107
+ return;
108
+ state.last_outbound_by_target ??= {};
109
+ state.last_outbound_by_target[canonicalTarget] = at;
110
+ });
111
+ }
83
112
  function safeRuntimeStatusDiagnostic(error) {
84
113
  return safeProviderDiagnostic(error, "Runtime entered an error state", 500);
85
114
  }
@@ -298,6 +327,8 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
298
327
  recordStatusError: (agent, text) => hostState.recordStatusError(agent, text),
299
328
  readPending: (agent) => stateStore(agent).readJson("pendingReact", {}).items || [],
300
329
  writePending: (agent, items) => stateStore(agent).writeJson("pendingReact", { items }),
330
+ lastOutboundAt: (agent, target) => lastOutboundAtForTarget(stateStore(agent), target),
331
+ recordOutboundAt: (agent, target) => recordOutboundAtForTarget(stateStore(agent), target),
301
332
  cliForAgent: (agent) => {
302
333
  const managed = managedCliForAgent(agent);
303
334
  return { command: managed.command.command, argsPrefix: managed.command.argsPrefix, env: managed.env };
@@ -527,8 +558,12 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
527
558
  excerpt: safeConversationExcerpt(event.content, 180),
528
559
  at: new Date().toISOString(),
529
560
  }, 30);
530
- if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent")
531
- processingEyes.add(agent, String(inboxEnvelope.message_id || ""));
561
+ if (inboxEnvelope.sender_type === "human" || inboxEnvelope.sender_type === "agent") {
562
+ processingEyes.add(agent, String(inboxEnvelope.message_id || ""), {
563
+ replyInThread: String(inboxEnvelope.target || "").startsWith("thread:"),
564
+ target: String(inboxEnvelope.target || ""),
565
+ });
566
+ }
532
567
  if (receipt.status === "deferred")
533
568
  log(`Runtime 暂缓投递,消息保留在 inbox seq=${inboxEnvelope.seq}: ${receipt.reason}`);
534
569
  }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.feishuImFreshnessAdapter = void 0;
4
+ exports.messageRevisionTime = messageRevisionTime;
5
+ exports.feishuImTarget = feishuImTarget;
6
+ exports.serializeFeishuImTarget = serializeFeishuImTarget;
7
+ exports.mergeFeishuImCursor = mergeFeishuImCursor;
8
+ function integerMillis(value) {
9
+ if (typeof value !== "string" || !/^[0-9]+$/.test(value))
10
+ return null;
11
+ try {
12
+ return BigInt(value);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function messageRevisionTime(message) {
19
+ const created = integerMillis(message.create_time);
20
+ const updated = message.update_time === undefined || message.update_time === "" ? created : integerMillis(message.update_time);
21
+ if (created === null || updated === null)
22
+ throw new Error(`malformed Feishu IM revision for ${message.message_id || "unknown message"}`);
23
+ return created > updated ? created : updated;
24
+ }
25
+ function feishuImTarget(value) {
26
+ const thread = /^thread:([^:]+):(.+)$/.exec(value);
27
+ if (thread)
28
+ return { provider: "feishu.im", resourceKind: "thread", resourceId: `${thread[1]}/${thread[2]}` };
29
+ const chat = /^chat:(.+)$/.exec(value);
30
+ if (chat)
31
+ return { provider: "feishu.im", resourceKind: "chat", resourceId: chat[1] };
32
+ throw new Error(`unsupported Feishu IM freshness target: ${value}`);
33
+ }
34
+ function serializeFeishuImTarget(target) {
35
+ return `${target.provider}/${target.resourceKind}/${target.resourceId}`;
36
+ }
37
+ function normalizedMessages(snapshot) {
38
+ if (!snapshot || !Array.isArray(snapshot.messages))
39
+ throw new Error("malformed Feishu IM history payload: messages must be an array");
40
+ return snapshot.messages.map((message) => {
41
+ if (!message || typeof message !== "object" || typeof message.message_id !== "string" || !message.message_id) {
42
+ throw new Error("malformed Feishu IM history payload: message_id is required");
43
+ }
44
+ return { ...message, revision: messageRevisionTime(message) };
45
+ });
46
+ }
47
+ exports.feishuImFreshnessAdapter = {
48
+ cursor(snapshot) {
49
+ const messages = normalizedMessages(snapshot);
50
+ if (!messages.length)
51
+ return null;
52
+ const revision = messages.reduce((maximum, message) => message.revision > maximum ? message.revision : maximum, 0n);
53
+ return {
54
+ schema: 1,
55
+ revisionTime: revision.toString(),
56
+ messageIds: [...new Set(messages.filter((message) => message.revision === revision).map((message) => message.message_id))].sort(),
57
+ };
58
+ },
59
+ compare(seen, current) {
60
+ if (!current)
61
+ return seen ? "gap" : "fresh";
62
+ if (!seen)
63
+ return "conflict";
64
+ const seenTime = integerMillis(seen.revisionTime);
65
+ const currentTime = integerMillis(current.revisionTime);
66
+ if (seen.schema !== 1 || seenTime === null || currentTime === null)
67
+ return "gap";
68
+ if (currentTime < seenTime)
69
+ return "gap";
70
+ if (currentTime > seenTime)
71
+ return "conflict";
72
+ return current.messageIds.every((id) => seen.messageIds.includes(id)) ? "fresh" : "conflict";
73
+ },
74
+ unseen(seen, snapshot) {
75
+ const messages = normalizedMessages(snapshot);
76
+ if (!seen)
77
+ return messages.map(({ revision: _revision, ...message }) => message);
78
+ const seenTime = integerMillis(seen.revisionTime);
79
+ if (seenTime === null)
80
+ return messages.map(({ revision: _revision, ...message }) => message);
81
+ return messages.filter((message) => message.revision > seenTime
82
+ || (message.revision === seenTime && !seen.messageIds.includes(message.message_id)))
83
+ .map(({ revision: _revision, ...message }) => message);
84
+ },
85
+ };
86
+ function mergeFeishuImCursor(left, right) {
87
+ if (!left)
88
+ return { ...right, messageIds: [...right.messageIds] };
89
+ const leftTime = BigInt(left.revisionTime);
90
+ const rightTime = BigInt(right.revisionTime);
91
+ if (rightTime < leftTime)
92
+ return left;
93
+ if (rightTime > leftTime)
94
+ return { ...right, messageIds: [...right.messageIds] };
95
+ return { schema: 1, revisionTime: left.revisionTime, messageIds: [...new Set([...left.messageIds, ...right.messageIds])].sort() };
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [