larkin 0.4.12 → 0.4.14

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.
Files changed (33) hide show
  1. package/dist/agent/agent-state-store.cjs +237 -0
  2. package/dist/agent/agent-state-store.mjs +238 -1
  3. package/dist/agent/context-prompt.cjs +2 -1
  4. package/dist/agent/context-prompt.mjs +2 -1
  5. package/dist/agent/host-reminder-orchestrator.cjs +134 -7
  6. package/dist/agent/host-reminder-orchestrator.mjs +134 -7
  7. package/dist/agent/inbox-projection.cjs +5 -0
  8. package/dist/agent/inbox-projection.mjs +4 -0
  9. package/dist/agent/reminder-delivery-audit.cjs +86 -0
  10. package/dist/agent/reminder-delivery-audit.mjs +50 -0
  11. package/dist/agent/reminder-routes.cjs +83 -3
  12. package/dist/agent/reminder-routes.mjs +83 -3
  13. package/dist/agent/reminder-store.cjs +5 -2
  14. package/dist/agent/reminder-store.mjs +5 -2
  15. package/dist/agent/transport-business-context.cjs +26 -2
  16. package/dist/agent/transport-business-context.mjs +26 -2
  17. package/dist/app/agent-cli.mjs +33 -2
  18. package/dist/app/agent-lark-cli-workspace.cjs +3 -2
  19. package/dist/app/agent-lark-cli-workspace.mjs +3 -2
  20. package/dist/app/lark-cli.mjs +79 -11
  21. package/dist/app/local-control.mjs +64 -6
  22. package/dist/app/run.mjs +64 -24
  23. package/dist/app/runtime-agent-config.mjs +58 -5
  24. package/dist/app/runtime-process.mjs +6 -1
  25. package/dist/feishu/host-business-state.cjs +35 -14
  26. package/dist/feishu/host-business-state.mjs +35 -14
  27. package/dist/feishu/interaction-orchestrator.cjs +8 -1
  28. package/dist/feishu/interaction-orchestrator.mjs +8 -1
  29. package/dist/feishu/outbound-transport.cjs +37 -3
  30. package/dist/feishu/outbound-transport.mjs +37 -3
  31. package/dist/runtime/runtime-host.cjs +37 -0
  32. package/dist/runtime/runtime-host.mjs +37 -0
  33. package/package.json +1 -1
@@ -462,11 +462,15 @@ export function removeControlAuthority(larkinHome, expectedToken) {
462
462
  throw error;
463
463
  }
464
464
  }
465
- export function createSupervisorControlServer({ larkinHome, authorityToken, ensureDashboard, }) {
465
+ export function createSupervisorControlServer({ larkinHome, authorityToken, ensureDashboard, onAgentUpserted, }) {
466
466
  let socket = "";
467
467
  let socketRoot = "";
468
468
  let socketIdentity = null;
469
469
  const completed = new Map();
470
+ const operationConflict = (operationId, agentId) => ({
471
+ ok: false, operationId, ...(agentId ? { agentId } : {}), code: "operation_conflict",
472
+ error: "operationId 已绑定其他 Agent 或操作",
473
+ });
470
474
  let server = null;
471
475
  return {
472
476
  async start() {
@@ -490,9 +494,15 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
490
494
  let request;
491
495
  try {
492
496
  request = JSON.parse(line);
493
- if (!OPERATION_ID.test(String(request.operationId || "")) || request.operation !== "ensure-dashboard"
497
+ const isDashboardRecovery = request.operation === "ensure-dashboard";
498
+ const isAgentUpsert = request.operation === "agent-upserted";
499
+ const allowedKeys = isDashboardRecovery
500
+ ? ["operationId", "operation", "authorization"]
501
+ : isAgentUpsert ? ["operationId", "operation", "authorization", "agentId"] : [];
502
+ if (!OPERATION_ID.test(String(request.operationId || "")) || (!isDashboardRecovery && !isAgentUpsert)
494
503
  || !AUTHORIZATION.test(String(request.authorization || ""))
495
- || Object.keys(request).some((key) => !["operationId", "operation", "authorization"].includes(key))) {
504
+ || (isAgentUpsert && !AGENT_ID.test(String(request.agentId || "")))
505
+ || Object.keys(request).some((key) => !allowedKeys.includes(key))) {
496
506
  throw new Error("invalid supervisor control request");
497
507
  }
498
508
  const authority = assertSupervisorAuthority(larkinHome, authorityToken);
@@ -505,17 +515,28 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
505
515
  }
506
516
  const replay = completed.get(request.operationId);
507
517
  if (replay) {
508
- connection.end(`${JSON.stringify(replay)}\n`);
518
+ const sameRequest = replay.operation === request.operation
519
+ && replay.agentId === request.agentId;
520
+ const response = sameRequest ? replay.response : operationConflict(request.operationId, request.agentId);
521
+ connection.end(`${JSON.stringify(response)}\n`);
509
522
  return;
510
523
  }
511
524
  let response;
512
525
  try {
513
- response = { ok: true, operationId: request.operationId, state: await ensureDashboard() };
526
+ if (request.operation === "agent-upserted") {
527
+ await onAgentUpserted?.(request.agentId);
528
+ response = { ok: true, operationId: request.operationId, state: "agent-recorded" };
529
+ }
530
+ else {
531
+ response = { ok: true, operationId: request.operationId, state: await ensureDashboard() };
532
+ }
514
533
  }
515
534
  catch (error) {
516
535
  response = { ok: false, operationId: request.operationId, error: error instanceof Error ? error.message : String(error) };
517
536
  }
518
- completed.set(request.operationId, response);
537
+ completed.set(request.operationId, {
538
+ operation: request.operation, agentId: request.agentId, response,
539
+ });
519
540
  while (completed.size > 256)
520
541
  completed.delete(completed.keys().next().value);
521
542
  connection.end(`${JSON.stringify(response)}\n`);
@@ -812,6 +833,43 @@ async function sendSupervisorRecovery(larkinHome, operationId, timeoutMs) {
812
833
  });
813
834
  });
814
835
  }
836
+ async function sendSupervisorAgentUpsert(larkinHome, agentId, operationId, timeoutMs) {
837
+ const authority = assertSupervisorAuthority(larkinHome);
838
+ assertSecureSocketDirectory(authority.socketRoot);
839
+ const socket = authority.supervisorSocketPath;
840
+ const stat = fs.lstatSync(socket);
841
+ if (!stat.isSocket() || stat.isSymbolicLink()
842
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
843
+ || !notGroupOrWorldAccessible(stat))
844
+ throw new Error("supervisor control socket 不安全");
845
+ return await new Promise((resolve, reject) => {
846
+ const client = net.createConnection(socket);
847
+ const timer = setTimeout(() => { client.destroy(); reject(new Error("supervisor agent upsert control timeout")); }, timeoutMs);
848
+ let input = "";
849
+ client.setEncoding("utf8");
850
+ client.once("error", (error) => { clearTimeout(timer); reject(error); });
851
+ client.once("connect", () => client.write(`${JSON.stringify({
852
+ operationId, operation: "agent-upserted", agentId, authorization: authority.token,
853
+ })}\n`));
854
+ client.on("data", (chunk) => {
855
+ input += chunk;
856
+ const newline = input.indexOf("\n");
857
+ if (newline < 0)
858
+ return;
859
+ clearTimeout(timer);
860
+ client.end();
861
+ try {
862
+ resolve(JSON.parse(input.slice(0, newline)));
863
+ }
864
+ catch (error) {
865
+ reject(error);
866
+ }
867
+ });
868
+ });
869
+ }
870
+ export async function requestSupervisorAgentUpsert({ larkinHome, agentId, operationId = crypto.randomUUID(), timeoutMs = 30_000, }) {
871
+ return sendSupervisorAgentUpsert(larkinHome, agentId, operationId, timeoutMs);
872
+ }
815
873
  export async function requestDashboardRecovery({ larkinHome, operationId = crypto.randomUUID(), timeoutMs = 30_000, }) {
816
874
  const deadline = Date.now() + timeoutMs;
817
875
  let lastError;
package/dist/app/run.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import * as larkinConfig from "../platform/config.mjs";
9
9
  import { currentProcessMetadata } from "../platform/process-inspect.cjs";
10
10
  import { acquireProcessLock, readProcessState, terminateOwnedProcess, waitForProcessExit, } from "../platform/process-state.mjs";
11
- import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
11
+ import { hydrateRuntimeAgent, syncAgentProfile, validateAgentProfile } from "./runtime-agent-config.mjs";
12
12
  import { cleanupStaleAgentControlSocket, createSupervisorControlServer, initializeControlAuthority, removeControlAuthority, requestDashboardRecovery, } from "./local-control.mjs";
13
13
  import { internalCommandSpec, processCommandToken } from "./internal-command.mjs";
14
14
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
@@ -80,6 +80,9 @@ export async function main() {
80
80
  catch (error) {
81
81
  return die(error.message);
82
82
  }
83
+ // Capture this supervisor's startup membership before setup can persist a new
84
+ // Agent. Unqualified selection must not expand from later durable config.
85
+ const initialNames = new Set(names);
83
86
  const before = readProcessState(configDir);
84
87
  if (before.supervisor.state === "owned") {
85
88
  if (before.dashboard.state !== "owned") {
@@ -124,11 +127,16 @@ export async function main() {
124
127
  die("无法取得 supervisor start identity");
125
128
  }
126
129
  const controlToken = initializeControlAuthority(configDir, { pid: process.pid, processStartToken: supervisorStartToken });
130
+ // This supervisor's successful hot-attaches are the only membership additions
131
+ // that survive a daemon crash. Durable config apply state describes freshness,
132
+ // not which Agents this explicitly selected supervisor owns.
133
+ const hotAttachedNames = new Set();
127
134
  let ensureDashboardHandler = () => "starting";
128
135
  const supervisorControl = createSupervisorControlServer({
129
136
  larkinHome: configDir,
130
137
  authorityToken: controlToken,
131
138
  ensureDashboard: () => ensureDashboardHandler(),
139
+ onAgentUpserted: (agentId) => { hotAttachedNames.add(agentId); },
132
140
  });
133
141
  try {
134
142
  await supervisorControl.start();
@@ -141,16 +149,53 @@ export async function main() {
141
149
  catch { /* best effort */ }
142
150
  die(`supervisor control 启动失败:${error instanceof Error ? error.message : String(error)}`);
143
151
  }
144
- const agents = [];
145
- try {
146
- for (const name of names) {
147
- const stored = config.agents[name];
152
+ let agents = [];
153
+ const serverId = config.serverId;
154
+ if (!serverId)
155
+ die("配置缺少 serverId");
156
+ const runtimeEnv = {
157
+ ...process.env,
158
+ LARKIN_HOME: configDir,
159
+ LARKIN_CONFIG_DIR: configDir,
160
+ LARKIN_AGENT_TRANSPORT_MODULE: path.join(HERE, "..", "agent", "agent-transport.cjs"),
161
+ LARKIN_SERVER_ID: String(serverId),
162
+ LARKIN_AGENTS_CONFIG: "[]",
163
+ LARKIN_CONTROL_AUTHORIZATION: controlToken,
164
+ };
165
+ if (argv.includes("--dry-run"))
166
+ runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
167
+ // Re-read durable config for every daemon launch instead of reusing the startup
168
+ // snapshot: a hot attach updates the child and durable config, but cannot mutate
169
+ // this supervisor's old closure. Membership remains initialNames plus successful
170
+ // hot attaches recorded during this supervisor lifetime.
171
+ const refreshDaemonAgents = (reuseExistingProfiles) => {
172
+ const latest = larkinConfig.loadConfig(process.env);
173
+ if (latest.configDir !== configDir)
174
+ throw new Error("配置目录在 supervisor 运行期间发生变化");
175
+ if (!latest.config.serverId || !Object.keys(latest.config.agents).length)
176
+ throw new Error("配置缺少 serverId/agents");
177
+ const latestNames = [...new Set([...initialNames, ...hotAttachedNames])];
178
+ const refreshed = [];
179
+ for (const name of latestNames) {
180
+ const stored = latest.config.agents[name];
148
181
  if (!stored)
149
182
  throw new Error(`Agent ${name} 不存在`);
150
183
  const agent = hydrateRuntimeAgent(configDir, stored);
151
- syncAgentProfile(agent, { ...process.env, LARKIN_CONFIG_DIR: configDir });
152
- agents.push(agent);
184
+ if (reuseExistingProfiles)
185
+ validateAgentProfile(agent);
186
+ else
187
+ syncAgentProfile(agent, { ...process.env, LARKIN_CONFIG_DIR: configDir });
188
+ refreshed.push(agent);
153
189
  }
190
+ agents = refreshed;
191
+ names = latestNames;
192
+ runtimeEnv.LARKIN_SERVER_ID = String(latest.config.serverId);
193
+ runtimeEnv.LARKIN_AGENTS_CONFIG = JSON.stringify(agents);
194
+ for (const agent of agents)
195
+ traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
196
+ };
197
+ try {
198
+ refreshDaemonAgents(false);
154
199
  }
155
200
  catch (error) {
156
201
  await supervisorControl.close().catch(() => { });
@@ -183,22 +228,6 @@ export async function main() {
183
228
  launchLock.release();
184
229
  die(`dashboard PID ${before.dashboard.pid} 身份无法确认;拒绝替换`);
185
230
  }
186
- const serverId = config.serverId;
187
- if (!serverId)
188
- die("配置缺少 serverId");
189
- const runtimeEnv = {
190
- ...process.env,
191
- LARKIN_HOME: configDir,
192
- LARKIN_CONFIG_DIR: configDir,
193
- LARKIN_AGENT_TRANSPORT_MODULE: path.join(HERE, "..", "agent", "agent-transport.cjs"),
194
- LARKIN_SERVER_ID: String(serverId),
195
- LARKIN_AGENTS_CONFIG: JSON.stringify(agents),
196
- LARKIN_CONTROL_AUTHORIZATION: controlToken,
197
- };
198
- if (argv.includes("--dry-run"))
199
- runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
200
- for (const agent of agents)
201
- traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
202
231
  let stopping = false;
203
232
  let dashboard = null;
204
233
  // Set when the daemon-restart path stops the dashboard on purpose (port
@@ -222,7 +251,19 @@ export async function main() {
222
251
  // exiting on transient failures (e.g. network/proxy ECONNRESET during Feishu
223
252
  // auth). Only when the budget is exhausted does the supervisor exit so the
224
253
  // external process manager (launchd) takes over as the final respawn layer.
254
+ let initialRefreshPending = true;
225
255
  const launchDaemon = () => {
256
+ if (initialRefreshPending)
257
+ initialRefreshPending = false;
258
+ else
259
+ try {
260
+ refreshDaemonAgents(true);
261
+ }
262
+ catch (error) {
263
+ console.error(`✗ daemon 启动前重新加载配置失败:${error instanceof Error ? error.message : String(error)}`);
264
+ resolveDaemonFinal?.({ code: null, signal: null });
265
+ return;
266
+ }
226
267
  daemon = spawn(daemonSpec.command, daemonSpec.args, { env: runtimeEnv, stdio: "inherit" });
227
268
  for (const agent of agents)
228
269
  traceProcessBoundary(runtimeEnv, "supervisor:daemon-spawned", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId), childPid: daemon.pid ?? null });
@@ -257,7 +298,6 @@ export async function main() {
257
298
  const oldDashboard = dashboard;
258
299
  if (oldDashboard && oldDashboard.exitCode === null && oldDashboard.signalCode === null) {
259
300
  intentionalDashboardExit = oldDashboard;
260
- dashboard = null;
261
301
  oldDashboard.kill("SIGTERM");
262
302
  // If the old dashboard ignores SIGTERM (observed in production holding
263
303
  // the dashboard port for minutes), escalate so the port is released
@@ -17,6 +17,13 @@ function assertSecureProfileDirectory(directory) {
17
17
  || !exactMode(stat, 0o700))
18
18
  throw new Error("lark-cli profile 目录不安全");
19
19
  }
20
+ function assertValidProfileDirectory(directory) {
21
+ const stat = fs.lstatSync(directory);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink()
23
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
24
+ || (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
25
+ throw new Error("lark-cli profile 目录不安全");
26
+ }
20
27
  function captureProfileSnapshot(file) {
21
28
  let fd = null;
22
29
  try {
@@ -233,22 +240,44 @@ function runOfficialLarkCliAsync(command, args, env, dependencies) {
233
240
  });
234
241
  });
235
242
  }
243
+ function expectedRuntimeCommandShim() {
244
+ const standalone = process.env.LARKIN_STANDALONE === "1";
245
+ const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
246
+ const argumentsPrefix = standalone ? [] : [binaryEntry];
247
+ const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
248
+ return `#!/bin/sh\nexec ${command} "$@"\n`;
249
+ }
236
250
  export function installRuntimeCommandShims(agent) {
237
251
  const stateDir = path.resolve(agent.stateDir);
238
252
  const commandDir = path.join(stateDir, "runtime-bin");
239
253
  assertSecureRuntimeCommandDirectory(commandDir);
240
- const standalone = process.env.LARKIN_STANDALONE === "1";
241
- const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
242
- for (const [name, argumentsPrefix] of [["larkin", standalone ? [] : [binaryEntry]]]) {
254
+ const fileContents = expectedRuntimeCommandShim();
255
+ for (const name of ["larkin"]) {
243
256
  const file = path.join(commandDir, name);
244
257
  const temporary = path.join(commandDir, `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`);
245
- const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
246
- fs.writeFileSync(temporary, `#!/bin/sh\nexec ${command} "$@"\n`, { mode: 0o700, flag: "wx" });
258
+ fs.writeFileSync(temporary, fileContents, { mode: 0o700, flag: "wx" });
247
259
  fs.renameSync(temporary, file);
248
260
  fs.chmodSync(file, 0o700);
249
261
  }
250
262
  return commandDir;
251
263
  }
264
+ function validateRuntimeCommandShims(agent) {
265
+ const commandDir = path.join(path.resolve(agent.stateDir), "runtime-bin");
266
+ const directory = fs.lstatSync(commandDir);
267
+ if (!directory.isDirectory() || directory.isSymbolicLink()
268
+ || (typeof process.getuid === "function" && directory.uid !== process.getuid())
269
+ || (!exactMode(directory, 0o700) && !exactMode(directory, 0o500)))
270
+ throw new Error("Runtime command shim 目录不安全");
271
+ const file = path.join(commandDir, "larkin");
272
+ const stat = fs.lstatSync(file);
273
+ if (!stat.isFile() || stat.isSymbolicLink()
274
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())
275
+ || (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
276
+ throw new Error("Runtime command shim 不安全");
277
+ if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim()) {
278
+ throw new Error("Runtime command shim 内容无效");
279
+ }
280
+ }
252
281
  export function sourceProjection(agent, env) {
253
282
  const configDir = path.resolve(env.LARKIN_CONFIG_DIR || "");
254
283
  if (!configDir)
@@ -299,7 +328,31 @@ function validateSourceProjection(file, agent) {
299
328
  throw new Error(`Agent ${agent.agentId} lark-channel source projection tenant mismatch`);
300
329
  }
301
330
  }
331
+ export function validateAgentProfile(agent) {
332
+ // Validate the profile root before resolving any leaf paths. The supervisor's
333
+ // restart fast path must not follow a replaced symlink or unsafe root.
334
+ assertValidProfileDirectory(agent.larkConfigDir);
335
+ const sourceFile = larkChannelSourceConfigPath(agent);
336
+ const workspaceFile = larkChannelWorkspaceConfigPath(agent);
337
+ const source = captureProfileSnapshot(sourceFile);
338
+ if (!source)
339
+ throw new Error(`Agent ${agent.agentId} lark-channel source projection missing`);
340
+ validateSourceProjection(sourceFile, agent);
341
+ const workspace = captureProfileSnapshot(workspaceFile);
342
+ if (!workspace)
343
+ throw new Error(`Agent ${agent.agentId} lark-channel workspace config missing`);
344
+ validateExclusiveBotProfile(workspace, agent);
345
+ validateRuntimeCommandShims(agent);
346
+ assertAgentWorkspaceBound(agent);
347
+ }
302
348
  export function syncAgentProfile(agent, env, dependencies = {}) {
349
+ if (!dependencies.forceRebind) {
350
+ try {
351
+ validateAgentProfile(agent);
352
+ return;
353
+ }
354
+ catch { /* absent or stale state requires exactly one bind */ }
355
+ }
303
356
  const expected = path.join(path.resolve(env.LARKIN_CONFIG_DIR || ""), "state", "agents", agent.agentId, "lark-cli-config");
304
357
  if (path.resolve(agent.larkConfigDir) !== expected)
305
358
  throw new Error("lark-cli profile 路径不是 canonical contained 路径");
@@ -8,7 +8,7 @@ import { createRuntimeHost } from "../runtime/runtime-host.mjs";
8
8
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
9
9
  import { loadConfig, markConfigApplied, runtimeConfigSignature } from "../platform/config.mjs";
10
10
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
11
- import { createAgentControlServer } from "./local-control.mjs";
11
+ import { createAgentControlServer, requestSupervisorAgentUpsert } from "./local-control.mjs";
12
12
  import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
13
13
  import { loadTelemetryConfig } from "../platform/telemetry-config.mjs";
14
14
  import { telemetrySingleton } from "../platform/telemetry-tracing.mjs";
@@ -104,6 +104,11 @@ export async function main(env = process.env, overrides = {}) {
104
104
  // Only the selected profile is synchronized; active profiles and their directory
105
105
  // are never quarantined or rebuilt during hot attach.
106
106
  await hostShell.upsertAgent(agent);
107
+ const tracked = await requestSupervisorAgentUpsert({
108
+ larkinHome: env.LARKIN_HOME, agentId: agent.agentId, operationId: request.operationId,
109
+ });
110
+ if (!tracked.ok)
111
+ throw new Error(tracked.error || "supervisor 未记录 Agent 热挂载");
107
112
  },
108
113
  async resetSession(request) {
109
114
  const result = await hostShell.resetSession(request.agentId, request.waitReadyMs);
@@ -316,31 +316,50 @@ class HostEnvelopeProjector {
316
316
  }
317
317
  createReminderEnvelope(agentId, reminder, overdueMs, repeatDescription) {
318
318
  const seq = this.nextSequence(agentId);
319
- const anchorMessageId = typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
320
- ? reminder.msgRef
321
- : null;
319
+ const legacyChannel = typeof reminder.channel === "string" ? reminder.channel.trim() : "";
320
+ const legacyChatTarget = /^oc_[A-Za-z0-9_-]+$/.test(legacyChannel) ? `chat:${legacyChannel}` : null;
321
+ const deliveryTarget = typeof reminder.deliveryTarget === "string" && reminder.deliveryTarget
322
+ ? reminder.deliveryTarget : legacyChatTarget;
323
+ const anchorMessageId = typeof reminder.deliveryAnchor === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor)
324
+ ? reminder.deliveryAnchor
325
+ : typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
326
+ ? reminder.msgRef
327
+ : null;
328
+ const commentAnchorId = deliveryTarget?.startsWith("document-comment:") && typeof reminder.deliveryAnchor === "string"
329
+ && /^doc_comment_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor) ? reminder.deliveryAnchor : null;
330
+ const anchorId = anchorMessageId || commentAnchorId;
331
+ const chatId = deliveryTarget?.match(/^chat:(oc_[A-Za-z0-9_-]+)$/)?.[1] || null;
322
332
  const lines = [
323
333
  `[定时提醒触发] ${reminder.title}`,
324
334
  `提醒ID: #${reminder.reminderId.slice(0, 8)}` + (reminder.repeat && repeatDescription
325
335
  ? ` 重复: ${repeatDescription}(下次已自动排在 ${reminder.fireAt})`
326
336
  : " 类型: 一次性"),
327
337
  overdueMs > 120_000 ? `注意: 原定时间已过 ${Math.round(overdueMs / 60_000)} 分钟(Runtime Host 离线期间错过,现补触发)` : null,
328
- anchorMessageId ? `锚定消息: ${anchorMessageId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是飞书 om_ message_id,不能用于回复` : null,
338
+ deliveryTarget ? `原始 deliveryTarget: ${deliveryTarget}`
339
+ : anchorId ? "这是升级前存量 user-facing reminder,优先回复其安全锚点;不得向标题中的任何人或第三方发送消息"
340
+ : "本条为 internal/no-delivery reminder,不得向标题中的任何人或第三方发送消息",
341
+ anchorId ? `锚定消息: ${anchorId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是可用的 delivery anchor,不能用于回复` : null,
329
342
  anchorMessageId
330
- ? `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId} ...`)}`
331
- : null,
332
- reminder.channel && /^oc_[A-Za-z0-9_-]+$/.test(reminder.channel)
333
- ? `发送到原群: ${this.larkCommand(`im +messages-send --chat-id ${reminder.channel} ...`)}`
334
- : reminder.channel
335
- ? `历史目标 ${reminder.channel} 不是 chat_id;${anchorMessageId ? "若不回复锚定消息," : ""}先用 ${this.larkCommand("im +chat-search")} 查询并确认 oc_ chat_id,禁止按名称猜测发送目标`
336
- : anchorMessageId
337
- ? null
338
- : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
343
+ ? [
344
+ `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId}${deliveryTarget?.startsWith("thread:") ? " --reply-in-thread" : ""} ...`)}`,
345
+ chatId ? `若 ${anchorMessageId} 无法由当前 Inbox 解析(例如 interaction_* 卡片锚点或已淘汰的历史消息),改用聊天兜底发送: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)};仅使用这个持久化 chat_id,不得猜测收件人` : null,
346
+ ].filter((line) => Boolean(line)).join("\n")
347
+ : commentAnchorId
348
+ ? `回复原文档评论: ${this.agentCommand(`comment reply --message-id ${commentAnchorId} --text ...`)}`
349
+ : deliveryTarget
350
+ ? chatId
351
+ ? `发送到原始 target: ${deliveryTarget}: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)}(不得从提醒标题推断收件人)`
352
+ : `发送到原始 target: ${deliveryTarget}(不得从提醒标题推断收件人)`
353
+ : null,
339
354
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
340
355
  ].filter((line) => Boolean(line));
341
356
  const envelope = {
342
357
  kind: "reminder",
343
- message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
358
+ // The random suffix keeps each firing's occurrence identity unique across
359
+ // Host restarts: the in-memory seq restarts at 1 and would otherwise let a
360
+ // recurring reminder reuse a prior occurrenceId in the delivery audit.
361
+ message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}_${this.randomHex(6)}`,
362
+ reminderId: reminder.reminderId,
344
363
  seq,
345
364
  sender_name: "定时提醒",
346
365
  sender_type: "system",
@@ -350,6 +369,8 @@ class HostEnvelopeProjector {
350
369
  timestamp: this.now().toISOString(),
351
370
  thread_id: null,
352
371
  wake: true,
372
+ deliveryTarget,
373
+ deliveryAnchor: anchorId,
353
374
  };
354
375
  return { ...envelope, target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET };
355
376
  }
@@ -302,31 +302,50 @@ export class HostEnvelopeProjector {
302
302
  }
303
303
  createReminderEnvelope(agentId, reminder, overdueMs, repeatDescription) {
304
304
  const seq = this.nextSequence(agentId);
305
- const anchorMessageId = typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
306
- ? reminder.msgRef
307
- : null;
305
+ const legacyChannel = typeof reminder.channel === "string" ? reminder.channel.trim() : "";
306
+ const legacyChatTarget = /^oc_[A-Za-z0-9_-]+$/.test(legacyChannel) ? `chat:${legacyChannel}` : null;
307
+ const deliveryTarget = typeof reminder.deliveryTarget === "string" && reminder.deliveryTarget
308
+ ? reminder.deliveryTarget : legacyChatTarget;
309
+ const anchorMessageId = typeof reminder.deliveryAnchor === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor)
310
+ ? reminder.deliveryAnchor
311
+ : typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
312
+ ? reminder.msgRef
313
+ : null;
314
+ const commentAnchorId = deliveryTarget?.startsWith("document-comment:") && typeof reminder.deliveryAnchor === "string"
315
+ && /^doc_comment_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor) ? reminder.deliveryAnchor : null;
316
+ const anchorId = anchorMessageId || commentAnchorId;
317
+ const chatId = deliveryTarget?.match(/^chat:(oc_[A-Za-z0-9_-]+)$/)?.[1] || null;
308
318
  const lines = [
309
319
  `[定时提醒触发] ${reminder.title}`,
310
320
  `提醒ID: #${reminder.reminderId.slice(0, 8)}` + (reminder.repeat && repeatDescription
311
321
  ? ` 重复: ${repeatDescription}(下次已自动排在 ${reminder.fireAt})`
312
322
  : " 类型: 一次性"),
313
323
  overdueMs > 120_000 ? `注意: 原定时间已过 ${Math.round(overdueMs / 60_000)} 分钟(Runtime Host 离线期间错过,现补触发)` : null,
314
- anchorMessageId ? `锚定消息: ${anchorMessageId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是飞书 om_ message_id,不能用于回复` : null,
324
+ deliveryTarget ? `原始 deliveryTarget: ${deliveryTarget}`
325
+ : anchorId ? "这是升级前存量 user-facing reminder,优先回复其安全锚点;不得向标题中的任何人或第三方发送消息"
326
+ : "本条为 internal/no-delivery reminder,不得向标题中的任何人或第三方发送消息",
327
+ anchorId ? `锚定消息: ${anchorId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是可用的 delivery anchor,不能用于回复` : null,
315
328
  anchorMessageId
316
- ? `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId} ...`)}`
317
- : null,
318
- reminder.channel && /^oc_[A-Za-z0-9_-]+$/.test(reminder.channel)
319
- ? `发送到原群: ${this.larkCommand(`im +messages-send --chat-id ${reminder.channel} ...`)}`
320
- : reminder.channel
321
- ? `历史目标 ${reminder.channel} 不是 chat_id;${anchorMessageId ? "若不回复锚定消息," : ""}先用 ${this.larkCommand("im +chat-search")} 查询并确认 oc_ chat_id,禁止按名称猜测发送目标`
322
- : anchorMessageId
323
- ? null
324
- : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
329
+ ? [
330
+ `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId}${deliveryTarget?.startsWith("thread:") ? " --reply-in-thread" : ""} ...`)}`,
331
+ chatId ? `若 ${anchorMessageId} 无法由当前 Inbox 解析(例如 interaction_* 卡片锚点或已淘汰的历史消息),改用聊天兜底发送: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)};仅使用这个持久化 chat_id,不得猜测收件人` : null,
332
+ ].filter((line) => Boolean(line)).join("\n")
333
+ : commentAnchorId
334
+ ? `回复原文档评论: ${this.agentCommand(`comment reply --message-id ${commentAnchorId} --text ...`)}`
335
+ : deliveryTarget
336
+ ? chatId
337
+ ? `发送到原始 target: ${deliveryTarget}: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)}(不得从提醒标题推断收件人)`
338
+ : `发送到原始 target: ${deliveryTarget}(不得从提醒标题推断收件人)`
339
+ : null,
325
340
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
326
341
  ].filter((line) => Boolean(line));
327
342
  const envelope = {
328
343
  kind: "reminder",
329
- message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
344
+ // The random suffix keeps each firing's occurrence identity unique across
345
+ // Host restarts: the in-memory seq restarts at 1 and would otherwise let a
346
+ // recurring reminder reuse a prior occurrenceId in the delivery audit.
347
+ message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}_${this.randomHex(6)}`,
348
+ reminderId: reminder.reminderId,
330
349
  seq,
331
350
  sender_name: "定时提醒",
332
351
  sender_type: "system",
@@ -336,6 +355,8 @@ export class HostEnvelopeProjector {
336
355
  timestamp: this.now().toISOString(),
337
356
  thread_id: null,
338
357
  wake: true,
358
+ deliveryTarget,
359
+ deliveryAnchor: anchorId,
339
360
  };
340
361
  return { ...envelope, target: RUNTIME_REMINDER_TARGET };
341
362
  }
@@ -93,6 +93,8 @@ class HostInteractionOrchestrator {
93
93
  const reminderId = `int_${(0, node_crypto_1.createHash)("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
94
94
  const current = this.now();
95
95
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
96
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
97
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
96
98
  const reminder = ReminderStore.mutate(file, (store) => {
97
99
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
98
100
  if (existing)
@@ -107,6 +109,9 @@ class HostInteractionOrchestrator {
107
109
  status: "scheduled",
108
110
  msgRef: run.message_id,
109
111
  msgPermalink: null,
112
+ deliveryTarget,
113
+ deliveryAnchor,
114
+ deliveryMode: "user",
110
115
  repeat: null,
111
116
  tz: null,
112
117
  channel: run.chat_id,
@@ -114,7 +119,9 @@ class HostInteractionOrchestrator {
114
119
  version: 1,
115
120
  events: [],
116
121
  };
117
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
122
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
123
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
124
+ });
118
125
  store.reminders.push(created);
119
126
  return created;
120
127
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));
@@ -57,6 +57,8 @@ export class HostInteractionOrchestrator {
57
57
  const reminderId = `int_${createHash("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
58
58
  const current = this.now();
59
59
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
60
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
61
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
60
62
  const reminder = ReminderStore.mutate(file, (store) => {
61
63
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
62
64
  if (existing)
@@ -71,6 +73,9 @@ export class HostInteractionOrchestrator {
71
73
  status: "scheduled",
72
74
  msgRef: run.message_id,
73
75
  msgPermalink: null,
76
+ deliveryTarget,
77
+ deliveryAnchor,
78
+ deliveryMode: "user",
74
79
  repeat: null,
75
80
  tz: null,
76
81
  channel: run.chat_id,
@@ -78,7 +83,9 @@ export class HostInteractionOrchestrator {
78
83
  version: 1,
79
84
  events: [],
80
85
  };
81
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
86
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
87
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
88
+ });
82
89
  store.reminders.push(created);
83
90
  return created;
84
91
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));