larkin 0.5.14 → 0.5.16

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.
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isRuntimeReadinessCurrent = isRuntimeReadinessCurrent;
4
+ exports.isSessionCurrent = isSessionCurrent;
4
5
  exports.isCurrentOwnedDaemon = isCurrentOwnedDaemon;
5
6
  exports.isChannelReconnecting = isChannelReconnecting;
6
7
  exports.projectAgentReadiness = projectAgentReadiness;
@@ -16,6 +17,14 @@ function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
16
17
  const epoch = timestamp(daemonStartedAt);
17
18
  return observedAt !== null && epoch !== null && observedAt >= epoch;
18
19
  }
20
+ function isSessionCurrent(session, daemonStartedAt) {
21
+ // A resumed session deliberately retains its original creation time. Its
22
+ // current daemon-epoch proof is the fresh session observation, not that
23
+ // historic creation timestamp.
24
+ const epoch = timestamp(daemonStartedAt);
25
+ const sessionObservedAt = timestamp(session?.lastSeenAt) ?? timestamp(session?.startedAt);
26
+ return epoch !== null && sessionObservedAt !== null && sessionObservedAt >= epoch;
27
+ }
19
28
  function isCurrentOwnedDaemon(daemon) {
20
29
  return daemon?.state === "owned"
21
30
  && Number(daemon.pid) > 0
@@ -34,11 +43,7 @@ function projectAgentReadiness(input) {
34
43
  const daemonOwned = isCurrentOwnedDaemon(input.daemon) && (0, process_state_js_1.daemonHasAgent)(input.daemon, input.agentId);
35
44
  const daemonStartedAt = timestamp(input.daemon.startedAt);
36
45
  const connectedAt = timestamp(status?.connectedAt);
37
- // A resumed session deliberately retains its original creation time. Its
38
- // current daemon-epoch proof is the fresh session observation, not that
39
- // historic creation timestamp.
40
- const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
41
- const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
46
+ const sessionCurrent = isSessionCurrent(status?.session, input.daemon.startedAt);
42
47
  const channelConnected = daemonOwned
43
48
  && status?.connectedVia === "channel"
44
49
  && daemonStartedAt !== null
@@ -10,6 +10,14 @@ export function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
10
10
  const epoch = timestamp(daemonStartedAt);
11
11
  return observedAt !== null && epoch !== null && observedAt >= epoch;
12
12
  }
13
+ export function isSessionCurrent(session, daemonStartedAt) {
14
+ // A resumed session deliberately retains its original creation time. Its
15
+ // current daemon-epoch proof is the fresh session observation, not that
16
+ // historic creation timestamp.
17
+ const epoch = timestamp(daemonStartedAt);
18
+ const sessionObservedAt = timestamp(session?.lastSeenAt) ?? timestamp(session?.startedAt);
19
+ return epoch !== null && sessionObservedAt !== null && sessionObservedAt >= epoch;
20
+ }
13
21
  export function isCurrentOwnedDaemon(daemon) {
14
22
  return daemon?.state === "owned"
15
23
  && Number(daemon.pid) > 0
@@ -28,11 +36,7 @@ export function projectAgentReadiness(input) {
28
36
  const daemonOwned = isCurrentOwnedDaemon(input.daemon) && daemonHasAgent(input.daemon, input.agentId);
29
37
  const daemonStartedAt = timestamp(input.daemon.startedAt);
30
38
  const connectedAt = timestamp(status?.connectedAt);
31
- // A resumed session deliberately retains its original creation time. Its
32
- // current daemon-epoch proof is the fresh session observation, not that
33
- // historic creation timestamp.
34
- const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
35
- const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
39
+ const sessionCurrent = isSessionCurrent(status?.session, input.daemon.startedAt);
36
40
  const channelConnected = daemonOwned
37
41
  && status?.connectedVia === "channel"
38
42
  && daemonStartedAt !== null
@@ -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);
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readJson, readProcessState } from "../platform/process-state.mjs";
10
- import { isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
10
+ import { isRuntimeReadinessCurrent, isSessionCurrent } from "../app/agent-readiness.mjs";
11
11
  import { isAllowedDashboardAvatarUrl } from "./dashboard-avatar.mjs";
12
12
  import { collectWorkspaceEntry as collectTypedWorkspaceEntry } from "./dashboard-workspace.mjs";
13
13
  import { buildFingerprint, packageVersion } from "../platform/build-info.mjs";
@@ -480,9 +480,7 @@ async function collectAgentStatus(a, configDir, daemonStartedAt, piModelResolver
480
480
  // 本 agent 专属时间线:三路历史合并按时间倒序,不是全局一条汇总——每个 agent 只看自己的故事。
481
481
  const { recentErrors, lastActivity, lastDeliver, feed } = projectStatusTimeline(status);
482
482
  const statusReadiness = status.runtimeReadiness;
483
- const sessionStartedAt = finiteTime(status.session?.startedAt);
484
- const daemonEpoch = finiteTime(daemonStartedAt);
485
- const sessionCurrent = sessionStartedAt !== null && daemonEpoch !== null && sessionStartedAt >= daemonEpoch;
483
+ const sessionCurrent = isSessionCurrent(status.session, daemonStartedAt);
486
484
  const runtimeReadiness = sessionCurrent && isRuntimeReadinessCurrent(statusReadiness, daemonStartedAt)
487
485
  || statusReadiness?.state !== "ready"
488
486
  ? statusReadiness || null
@@ -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
  }
@@ -1563,9 +1599,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1563
1599
  const status = hostState.readStatus(agent);
1564
1600
  const turns = status.session && typeof status.session === "object"
1565
1601
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1566
- const sessionStartedAt = status.session && typeof status.session === "object"
1567
- ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1568
- const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1602
+ const sessionCurrent = (0, agent_readiness_js_1.isSessionCurrent)(status.session && typeof status.session === "object"
1603
+ ? status.session
1604
+ : null, daemonStartedAt);
1569
1605
  const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
1570
1606
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1571
1607
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
@@ -1650,9 +1686,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1650
1686
  const status = hostState.readStatus(agent);
1651
1687
  const turns = status.session && typeof status.session === "object"
1652
1688
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1653
- const sessionStartedAt = status.session && typeof status.session === "object"
1654
- ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1655
- const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1689
+ const sessionCurrent = (0, agent_readiness_js_1.isSessionCurrent)(status.session && typeof status.session === "object"
1690
+ ? status.session
1691
+ : null, daemonStartedAt);
1656
1692
  const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
1657
1693
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1658
1694
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
@@ -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";
@@ -22,7 +23,7 @@ import { readDocumentCommentSubscription, verifyCallbackProbe } from "../platfor
22
23
  import { loadConfig, resolveInboxAuditSchedule, resolveMentionPolicy } from "../platform/config.mjs";
23
24
  import { processCommandToken } from "../app/internal-command.mjs";
24
25
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
25
- import { isChannelReconnecting, isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
26
+ import { isChannelReconnecting, isRuntimeReadinessCurrent, isSessionCurrent } from "../app/agent-readiness.mjs";
26
27
  import { documentCommentMessageId, documentCommentNoticeType, projectDocumentCommentEnvelope, resolveDocumentCommentContext, } from "./document-comment.mjs";
27
28
  import { isOpenPlatformHost, requireOpenDomain } from "./platform-hosts.mjs";
28
29
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
@@ -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
  }
@@ -1523,9 +1558,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1523
1558
  const status = hostState.readStatus(agent);
1524
1559
  const turns = status.session && typeof status.session === "object"
1525
1560
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1526
- const sessionStartedAt = status.session && typeof status.session === "object"
1527
- ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1528
- const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1561
+ const sessionCurrent = isSessionCurrent(status.session && typeof status.session === "object"
1562
+ ? status.session
1563
+ : null, daemonStartedAt);
1529
1564
  const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
1530
1565
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1531
1566
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
@@ -1610,9 +1645,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1610
1645
  const status = hostState.readStatus(agent);
1611
1646
  const turns = status.session && typeof status.session === "object"
1612
1647
  ? Math.max(0, Number(status.session.turns) || 0) : 0;
1613
- const sessionStartedAt = status.session && typeof status.session === "object"
1614
- ? Date.parse(String(status.session.startedAt || "")) : Number.NaN;
1615
- const sessionCurrent = Number.isFinite(sessionStartedAt) && sessionStartedAt >= Date.parse(daemonStartedAt);
1648
+ const sessionCurrent = isSessionCurrent(status.session && typeof status.session === "object"
1649
+ ? status.session
1650
+ : null, daemonStartedAt);
1616
1651
  const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
1617
1652
  const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
1618
1653
  return { turns, runtimeReady, ...connectionState(status), pendingCount };
@@ -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.14",
3
+ "version": "0.5.16",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [