larkin 0.5.2 → 0.5.4

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.
@@ -1,22 +1,22 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
- /**
4
- * #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
5
- * Pre-v0.4.21 records have no unique ownership marker and can collide exactly
6
- * with user reminders, so existing indistinguishable loops are never migrated
7
- * or deleted automatically.
8
- */
4
+ import { acquireProcessLock } from "../platform/process-state.mjs";
5
+ /** Historical reminder loops are deliberately unrelated to this bounded audit registry. */
9
6
  export const INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
10
- // Audits return every retained target. Storage remains bounded so a heartbeat
11
- // cannot accumulate an unbounded work list.
12
7
  export const MAX_INBOX_AUDIT_TARGETS = 96;
13
8
  const MAX_STORED_TARGETS = MAX_INBOX_AUDIT_TARGETS;
9
+ export const MAX_INBOX_AUDIT_REGISTRY_BYTES = 64 * 1024;
10
+ export const MAX_INBOX_AUDIT_REGISTRY_ROWS = MAX_INBOX_AUDIT_TARGETS;
11
+ const LOCK_WAIT_MS = 5_000;
12
+ const LOCK_RETRY_MS = 25;
14
13
  const CHAT = /^oc_[A-Za-z0-9]+$/;
15
14
  const THREAD = /^omt_[A-Za-z0-9]+$/;
16
15
  const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
17
- export function inboxAuditRegistryFile(larkinHome) {
18
- return path.join(larkinHome, "inbox-audit.json");
19
- }
16
+ const GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ const OUTCOMES = new Set(["no-finding", "handled"]);
18
+ export function inboxAuditRegistryFile(larkinHome) { return path.join(larkinHome, "inbox-audit.json"); }
19
+ export function inboxAuditRetryFile(larkinHome) { return path.join(larkinHome, "inbox-audit-retry.json"); }
20
20
  function parseTarget(event) {
21
21
  const chatId = String(event.chat_id || "");
22
22
  const threadId = event.thread_id ? String(event.thread_id) : "";
@@ -25,70 +25,336 @@ function parseTarget(event) {
25
25
  return null;
26
26
  return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
27
27
  }
28
+ function emptyRegistry() { return { version: 4, targets: [] }; }
29
+ function assertSafeExistingFile(file, label) {
30
+ try {
31
+ const stat = fs.lstatSync(file);
32
+ if (!stat.isFile() || stat.isSymbolicLink())
33
+ throw new Error(`${label} is not a regular file`);
34
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
35
+ throw new Error(`${label} is not owned by the current user`);
36
+ }
37
+ catch (error) {
38
+ if (error.code !== "ENOENT")
39
+ throw error;
40
+ }
41
+ }
42
+ function prepareRegistryDirectory(file) {
43
+ const directory = path.dirname(file);
44
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
45
+ const stat = fs.lstatSync(directory);
46
+ if (!stat.isDirectory() || stat.isSymbolicLink())
47
+ throw new Error("inbox audit registry directory is unsafe");
48
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
49
+ throw new Error("inbox audit registry directory is not owned by the current user");
50
+ }
28
51
  function load(file) {
29
- let value;
52
+ let fd;
53
+ let bytes;
30
54
  try {
31
- value = JSON.parse(fs.readFileSync(file, "utf8"));
55
+ assertSafeExistingFile(file, "inbox audit registry");
56
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
57
+ const stat = fs.fstatSync(fd);
58
+ if (!stat.isFile())
59
+ throw new Error("inbox audit registry is not a regular file");
60
+ const buffer = Buffer.alloc(MAX_INBOX_AUDIT_REGISTRY_BYTES + 1);
61
+ let offset = 0;
62
+ while (offset < buffer.length) {
63
+ const read = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
64
+ if (read === 0)
65
+ break;
66
+ offset += read;
67
+ }
68
+ if (offset > MAX_INBOX_AUDIT_REGISTRY_BYTES)
69
+ throw new Error("inbox audit registry exceeds the bounded byte limit");
70
+ bytes = buffer.subarray(0, offset);
32
71
  }
33
72
  catch (error) {
34
73
  if (error.code === "ENOENT")
35
- return { version: 1, targets: [] };
74
+ return emptyRegistry();
36
75
  throw error;
37
76
  }
38
- if (value?.version !== 1 || !Array.isArray(value.targets))
39
- return { version: 1, targets: [] };
40
- return { version: 1, targets: value.targets.flatMap((row) => {
77
+ finally {
78
+ if (fd !== undefined)
79
+ fs.closeSync(fd);
80
+ }
81
+ const value = JSON.parse(bytes.toString("utf8"));
82
+ // v1 had no wake provenance; v2 had no durable observation generation.
83
+ // Neither can safely issue a completion receipt after this lifecycle upgrade.
84
+ if (value?.version !== 4 || !Array.isArray(value.targets))
85
+ return emptyRegistry();
86
+ if (value.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
87
+ throw new Error("inbox audit registry exceeds the bounded row limit");
88
+ return { version: 4, targets: value.targets.flatMap((row) => {
41
89
  if (!row || typeof row !== "object")
42
90
  return [];
43
91
  const candidate = row;
44
92
  if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
45
93
  return [];
94
+ const parts = candidate.target.split(":");
46
95
  const parsed = parseTarget({
47
- chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
48
- thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
96
+ chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : parts[1],
97
+ thread_id: candidate.target.startsWith("thread:") ? parts[2] : null,
49
98
  message_id: candidate.anchor,
50
99
  });
51
- return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
52
- && typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
53
- ? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
100
+ const status = candidate.status === "completed" ? "completed" : candidate.status === "pending" ? "pending" : null;
101
+ if (!status || typeof candidate.agent_id !== "string" || !candidate.agent_id || typeof candidate.generation !== "string" || !GENERATION.test(candidate.generation) || !parsed || candidate.target !== parsed.target
102
+ || !Number.isSafeInteger(candidate.source_seq) || Number(candidate.source_seq) < 1
103
+ || typeof candidate.observed_at !== "string" || !Number.isFinite(Date.parse(candidate.observed_at)))
104
+ return [];
105
+ const completed_at = typeof candidate.completed_at === "string" && Number.isFinite(Date.parse(candidate.completed_at)) ? candidate.completed_at : undefined;
106
+ const completed_anchor = typeof candidate.completed_anchor === "string" && ANCHOR.test(candidate.completed_anchor) ? candidate.completed_anchor : undefined;
107
+ const completed_outcome = OUTCOMES.has(String(candidate.completed_outcome)) ? candidate.completed_outcome : undefined;
108
+ return [{ agent_id: candidate.agent_id, generation: candidate.generation, ...parsed, source_seq: Number(candidate.source_seq), observed_at: candidate.observed_at, status,
109
+ ...(completed_at ? { completed_at } : {}), ...(completed_anchor ? { completed_anchor } : {}),
110
+ ...(completed_outcome ? { completed_outcome } : {}) }];
54
111
  }) };
55
112
  }
56
113
  function save(file, registry) {
57
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
58
- const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
59
- fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
60
- fs.renameSync(temporary, file);
61
- fs.chmodSync(file, 0o600);
114
+ if (registry.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
115
+ throw new Error("inbox audit registry exceeds the bounded row limit");
116
+ const serialized = `${JSON.stringify(registry)}\n`;
117
+ if (Buffer.byteLength(serialized) > MAX_INBOX_AUDIT_REGISTRY_BYTES)
118
+ throw new Error("inbox audit registry exceeds the bounded byte limit");
119
+ prepareRegistryDirectory(file);
120
+ assertSafeExistingFile(file, "inbox audit registry");
121
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
122
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
123
+ try {
124
+ fs.writeFileSync(fd, serialized);
125
+ fs.fsyncSync(fd);
126
+ }
127
+ finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ try {
131
+ fs.renameSync(temporary, file);
132
+ fs.chmodSync(file, 0o600);
133
+ }
134
+ catch (error) {
135
+ try {
136
+ fs.unlinkSync(temporary);
137
+ }
138
+ catch { /* best effort */ }
139
+ throw error;
140
+ }
141
+ }
142
+ function emptyRetryJournal() { return { version: 2, targets: [] }; }
143
+ function loadRetryJournal(file) {
144
+ let value;
145
+ try {
146
+ assertSafeExistingFile(file, "inbox audit retry journal");
147
+ const stat = fs.statSync(file);
148
+ if (stat.size > MAX_INBOX_AUDIT_REGISTRY_BYTES)
149
+ throw new Error("inbox audit retry journal exceeds the bounded byte limit");
150
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return emptyRetryJournal();
155
+ throw error;
156
+ }
157
+ const raw = value;
158
+ if (raw.version !== 2 || !Array.isArray(raw.targets) || raw.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
159
+ return emptyRetryJournal();
160
+ return { version: 2, targets: raw.targets.flatMap((candidate) => {
161
+ if (!candidate || typeof candidate !== "object")
162
+ return [];
163
+ const row = candidate;
164
+ const parsed = parseTarget({ chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor });
165
+ if (typeof row.agent_id !== "string" || !row.agent_id || typeof row.target !== "string" || !parsed || parsed.target !== row.target
166
+ || typeof row.chat_id !== "string" || (row.thread_id !== null && typeof row.thread_id !== "string")
167
+ || !Number.isSafeInteger(row.source_seq) || Number(row.source_seq) < 1
168
+ || typeof row.queued_at !== "string" || !Number.isFinite(Date.parse(row.queued_at)))
169
+ return [];
170
+ return [{ agent_id: row.agent_id, target: row.target, anchor: parsed.anchor, chat_id: row.chat_id, thread_id: row.thread_id ?? null, source_seq: Number(row.source_seq), queued_at: row.queued_at }];
171
+ }) };
172
+ }
173
+ function saveRetryJournal(file, journal) {
174
+ if (journal.targets.length > MAX_INBOX_AUDIT_REGISTRY_ROWS)
175
+ throw new Error("inbox audit retry journal exceeds the bounded row limit");
176
+ const serialized = `${JSON.stringify(journal)}\n`;
177
+ if (Buffer.byteLength(serialized) > MAX_INBOX_AUDIT_REGISTRY_BYTES)
178
+ throw new Error("inbox audit retry journal exceeds the bounded byte limit");
179
+ prepareRegistryDirectory(file);
180
+ assertSafeExistingFile(file, "inbox audit retry journal");
181
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
182
+ const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
183
+ try {
184
+ fs.writeFileSync(fd, serialized);
185
+ fs.fsyncSync(fd);
186
+ }
187
+ finally {
188
+ fs.closeSync(fd);
189
+ }
190
+ try {
191
+ fs.renameSync(temporary, file);
192
+ fs.chmodSync(file, 0o600);
193
+ }
194
+ catch (error) {
195
+ try {
196
+ fs.unlinkSync(temporary);
197
+ }
198
+ catch { /* best effort */ }
199
+ throw error;
200
+ }
201
+ }
202
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
203
+ /** Serialize Host observers and CLI completion without replacing a newer registry snapshot. */
204
+ function withAuditLock(file, operation) {
205
+ const deadline = Date.now() + LOCK_WAIT_MS;
206
+ const lockFile = `${file}.mutation-lock`;
207
+ prepareRegistryDirectory(file);
208
+ while (Date.now() <= deadline) {
209
+ let lock;
210
+ try {
211
+ assertSafeExistingFile(lockFile, "inbox audit mutation lock");
212
+ lock = acquireProcessLock(lockFile, path.basename(process.execPath), { malformedGraceMs: 1_000 });
213
+ return operation();
214
+ }
215
+ catch (error) {
216
+ if (lock || !/lock 已被|无法取得 lock|正在创建|暂不能接管|并发重试耗尽/.test(error instanceof Error ? error.message : String(error)))
217
+ throw error;
218
+ sleep(LOCK_RETRY_MS);
219
+ }
220
+ finally {
221
+ lock?.release();
222
+ }
223
+ }
224
+ throw new Error("inbox audit registry busy: bounded lock contention");
225
+ }
226
+ function mutate(file, operation) {
227
+ return withAuditLock(file, () => {
228
+ const registry = load(file);
229
+ const result = operation(registry);
230
+ save(file, registry);
231
+ return result;
232
+ });
233
+ }
234
+ function mutateRetryJournal(file, operation) {
235
+ return withAuditLock(file, () => {
236
+ const journal = loadRetryJournal(file);
237
+ const result = operation(journal);
238
+ saveRetryJournal(file, journal);
239
+ return result;
240
+ });
241
+ }
242
+ function revision(row) {
243
+ return `sha256:${crypto.createHash("sha256").update(JSON.stringify({
244
+ v: 2, agent_id: row.agent_id, target: row.target, anchor: row.anchor, generation: row.generation, status: row.status,
245
+ })).digest("hex")}`;
246
+ }
247
+ function receipt(row) {
248
+ const payload = { v: 1, agent_id: row.agent_id, target: row.target, anchor: row.anchor, revision: revision(row) };
249
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
250
+ }
251
+ function parseReceipt(value) {
252
+ try {
253
+ const raw = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
254
+ if (raw.v !== 1 || typeof raw.agent_id !== "string" || !raw.agent_id || typeof raw.target !== "string" || typeof raw.anchor !== "string"
255
+ || typeof raw.revision !== "string" || !raw.revision.startsWith("sha256:"))
256
+ return null;
257
+ const parts = raw.target.split(":");
258
+ const parsed = parseTarget({ chat_id: raw.target.startsWith("chat:") ? raw.target.slice(5) : parts[1], thread_id: raw.target.startsWith("thread:") ? parts[2] : null, message_id: raw.anchor });
259
+ return parsed?.target === raw.target ? raw : null;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
62
264
  }
63
- /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
64
265
  export function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
65
- if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
266
+ if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
66
267
  return false;
67
268
  const parsed = parseTarget(event);
68
- if (!parsed)
269
+ const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
270
+ if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
271
+ return false;
272
+ return mutate(file, (registry) => {
273
+ const existing = registry.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
274
+ if (existing && sourceSeq <= existing.source_seq)
275
+ return false;
276
+ registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
277
+ registry.targets.unshift({ agent_id: agentId, generation: crypto.randomUUID(), ...parsed, source_seq: sourceSeq, observed_at: now.toISOString(), status: "pending" });
278
+ registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
279
+ return true;
280
+ });
281
+ }
282
+ /** Durable recovery intent, recorded only after the same original wake gate. */
283
+ export function enqueueInboxAuditTargetRetry(file, agentId, event, now = new Date()) {
284
+ if (event.wake !== true || event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
285
+ return false;
286
+ const parsed = parseTarget(event);
287
+ const sourceSeq = typeof event.source_seq === "number" ? event.source_seq : Number.NaN;
288
+ if (!parsed || !Number.isSafeInteger(sourceSeq) || sourceSeq < 1)
69
289
  return false;
70
- const registry = load(file);
71
- const observed_at = now.toISOString();
72
- registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
73
- registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
74
- registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
75
- save(file, registry);
76
- return true;
77
- }
78
- function instruction(target) {
79
- const history = target.target.startsWith("thread:")
80
- ? "Use larkin im +threads-messages-list for this exact thread."
81
- : "Use larkin im +chat-messages-list for this exact chat.";
82
- return `${history} If a real missed outbound finding requires a response, use the existing guarded larkin im reply path anchored at ${target.anchor}; otherwise stay silent.`;
83
- }
84
- /** Content-free, bounded audit work list consumed by the Agent CLI. */
290
+ return mutateRetryJournal(file, (journal) => {
291
+ const existing = journal.targets.find((row) => row.agent_id === agentId && row.target === parsed.target);
292
+ if (existing && sourceSeq <= existing.source_seq)
293
+ return false;
294
+ journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
295
+ journal.targets.unshift({ agent_id: agentId, ...parsed, source_seq: sourceSeq, chat_id: String(event.chat_id), thread_id: event.thread_id ? String(event.thread_id) : null, queued_at: now.toISOString() });
296
+ journal.targets = journal.targets.slice(0, MAX_STORED_TARGETS);
297
+ return true;
298
+ });
299
+ }
300
+ /** Remove only the exact recovered/newer target so a late retry cannot erase a new anchor. */
301
+ export function discardInboxAuditTargetRetry(file, agentId, event) {
302
+ const parsed = parseTarget(event);
303
+ if (!parsed)
304
+ return;
305
+ mutateRetryJournal(file, (journal) => { journal.targets = journal.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target || row.source_seq > Number(event.source_seq ?? 0)); });
306
+ }
307
+ /** Replay persisted, already-authorized observer intents. Failures remain durable for a later bounded retry/startup. */
308
+ export function reconcileInboxAuditTargetRetries(registryFile, retryFile, agentId) {
309
+ const rows = loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId);
310
+ let recovered = 0;
311
+ for (const row of rows) {
312
+ try {
313
+ observeInboxAuditTarget(registryFile, row.agent_id, { chat_id: row.chat_id, chat_type: "group", thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq, wake: true, _sender_is_bot: false, _scan_authority: true });
314
+ discardInboxAuditTargetRetry(retryFile, row.agent_id, { chat_id: row.chat_id, thread_id: row.thread_id, message_id: row.anchor, source_seq: row.source_seq });
315
+ recovered += 1;
316
+ }
317
+ catch {
318
+ break;
319
+ }
320
+ }
321
+ return { recovered, remaining: loadRetryJournal(retryFile).targets.filter((row) => !agentId || row.agent_id === agentId).length };
322
+ }
323
+ function instruction(target, receiptValue) {
324
+ const history = target.target.startsWith("thread:") ? "Use larkin im +threads-messages-list for this exact thread." : "Use larkin im +chat-messages-list for this exact chat.";
325
+ return `${history} Inspect first. When finished, run larkin inbox audit complete --receipt ${receiptValue} --outcome <no-finding|handled> --json. If a real finding needs a response, use the guarded reply path anchored at ${target.anchor}; otherwise stay silent.`;
326
+ }
327
+ function pendingRows(file, agentId) {
328
+ return load(file).targets.filter((row) => row.agent_id === agentId && row.status === "pending").sort((left, right) => right.observed_at.localeCompare(left.observed_at));
329
+ }
330
+ export function hasPendingInboxAuditTargets(file, agentId) { return pendingRows(file, agentId).length > 0; }
331
+ /** Public read is intentionally completion-free: a caller crash leaves the target pending. */
85
332
  export function readInboxAuditTargets(file, agentId) {
86
- const rows = load(file).targets.filter((row) => row.agent_id === agentId)
87
- .sort((left, right) => right.observed_at.localeCompare(left.observed_at));
88
- return {
89
- version: 1,
90
- targets: rows.slice(0, MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
91
- has_more: rows.length > MAX_INBOX_AUDIT_TARGETS,
92
- no_finding: "stay_silent",
93
- };
333
+ const rows = pendingRows(file, agentId);
334
+ return { version: 4, targets: rows.slice(0, MAX_INBOX_AUDIT_TARGETS).map((row) => {
335
+ const target = { target: row.target, anchor: row.anchor, observed_at: row.observed_at, source_seq: row.source_seq };
336
+ const receiptValue = receipt(row);
337
+ return { ...target, revision: revision(row), receipt: receiptValue, instruction: instruction(target, receiptValue) };
338
+ }), has_more: rows.length > MAX_INBOX_AUDIT_TARGETS, no_finding: "stay_silent" };
339
+ }
340
+ export function completeInboxAuditTarget(file, agentId, receiptValue, outcome, now = new Date()) {
341
+ if (!OUTCOMES.has(outcome))
342
+ return { completed: false, reason: "invalid_outcome" };
343
+ const decoded = parseReceipt(receiptValue);
344
+ if (!decoded || decoded.agent_id !== agentId)
345
+ return { completed: false, reason: "invalid_receipt" };
346
+ return mutate(file, (registry) => {
347
+ const row = registry.targets.find((candidate) => candidate.agent_id === decoded.agent_id && candidate.target === decoded.target);
348
+ if (!row || row.anchor !== decoded.anchor)
349
+ return { completed: false, reason: "stale" };
350
+ if (row.status === "completed")
351
+ return { completed: false, reason: "already_completed" };
352
+ if (revision(row) !== decoded.revision)
353
+ return { completed: false, reason: "stale" };
354
+ row.status = "completed";
355
+ row.completed_at = now.toISOString();
356
+ row.completed_anchor = row.anchor;
357
+ row.completed_outcome = outcome;
358
+ return { completed: true, reason: "completed" };
359
+ });
94
360
  }
@@ -7,7 +7,7 @@ import * as path from "node:path";
7
7
  import { createAgentStateStore } from "../agent/agent-state-store.mjs";
8
8
  import * as larkinConfig from "../platform/config.mjs";
9
9
  import { projectInboxCheck, projectInboxEvents } from "../agent/inbox-projection.mjs";
10
- import { inboxAuditRegistryFile, readInboxAuditTargets } from "../agent/missed-outbound-scan.mjs";
10
+ import { completeInboxAuditTarget, inboxAuditRegistryFile, readInboxAuditTargets } from "../agent/missed-outbound-scan.mjs";
11
11
  import { createReminderRoutes } from "../agent/reminder-routes.mjs";
12
12
  import { InteractionStateMachine } from "../agent/interaction-state-machine.mjs";
13
13
  import { issueCallbackProbe, readCallbackCapability } from "../platform/callback-capability.mjs";
@@ -142,7 +142,7 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
142
142
  const [operation = "show", ...rest] = argv;
143
143
  const authority = { kind: "agent", agentId: agent.agentId };
144
144
  const options = parseOptions(rest, new Set(["--json"]));
145
- const unknownFlags = [...options.values.keys()].filter((flag) => !["--agent", "--chat", "--model"].includes(flag));
145
+ const unknownFlags = [...options.values.keys()].filter((flag) => !["--agent", "--chat", "--model", "--interval"].includes(flag));
146
146
  if (unknownFlags.length)
147
147
  throw new Error(`config 不支持参数:${unknownFlags.join(", ")};运行 larkin config --help`);
148
148
  const assertOnlyFlags = (valueFlags, booleanFlags = []) => {
@@ -238,6 +238,24 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
238
238
  else
239
239
  throw new Error("用法: larkin config mention global <require|free> | mention agent <inherit|require|free> | mention chat <oc_id> <inherit|require|free> [--agent <App ID>]");
240
240
  }
241
+ else if (operation === "inbox-audit") {
242
+ const [scope, first] = options.positionals;
243
+ if (scope === "global")
244
+ assertOnlyFlags(["--interval"]);
245
+ else if (scope === "agent")
246
+ assertOnlyFlags(["--agent", "--interval"]);
247
+ else
248
+ assertOnlyFlags([]);
249
+ if (options.positionals.length !== 2) {
250
+ throw new Error("用法: larkin config inbox-audit global <on|off> [--interval <15m|1h>] | inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]");
251
+ }
252
+ mutation = larkinConfig.inboxAuditMutationFromCli({
253
+ scope: scope || "",
254
+ enabled: first,
255
+ interval: options.values.get("--interval"),
256
+ agentId: targetId,
257
+ });
258
+ }
241
259
  else if (operation === "apply") {
242
260
  assertOnlyFlags(["--agent"]);
243
261
  if (options.positionals.length)
@@ -251,7 +269,7 @@ function agentConfigRequest(argv, env, config, agent, dependencies) {
251
269
  }).catch((error) => { throw new Error(`配置已保存但未应用:${error instanceof Error ? error.message : String(error)}`); });
252
270
  }
253
271
  else
254
- throw new Error("config 只支持 show/runtime/model/effort/mention/apply;运行 larkin config --help");
272
+ throw new Error("config 只支持 show/runtime/model/effort/mention/inbox-audit/apply;运行 larkin config --help");
255
273
  const result = larkinConfig.mutateConfig(env, mutation, authority);
256
274
  return { ok: true, revision: result.revision, persisted: true, applyState: result.applyState, changedScope: result.changedScope };
257
275
  }
@@ -519,10 +537,23 @@ export function runAgentCli(argv, env = process.env, dependencies = {}) {
519
537
  }
520
538
  const options = parseOptions(rest, new Set(["--json"]));
521
539
  if (subcommand === "audit") {
522
- if (options.positionals.length || options.values.size || options.booleans.size > 1) {
523
- throw new Error("inbox audit 只接受 --json");
540
+ const file = inboxAuditRegistryFile(config.larkinHome);
541
+ if (options.positionals[0] === "complete") {
542
+ if (options.positionals.length !== 1 || options.booleans.size > 1 || [...options.values.keys()].some((key) => !["--receipt", "--outcome"].includes(key))) {
543
+ throw new Error("用法: larkin inbox audit complete --receipt <receipt> --outcome <no-finding|handled> [--json]");
544
+ }
545
+ const receipt = options.values.get("--receipt");
546
+ const outcome = options.values.get("--outcome");
547
+ if (!receipt || !outcome)
548
+ throw new Error("用法: larkin inbox audit complete --receipt <receipt> --outcome <no-finding|handled> [--json]");
549
+ const completion = completeInboxAuditTarget(file, agent.agentId, receipt, outcome);
550
+ emitJson(io, completion);
551
+ return completion.reason === "invalid_receipt" || completion.reason === "invalid_outcome" ? 2 : 0;
524
552
  }
525
- emitJson(io, readInboxAuditTargets(inboxAuditRegistryFile(config.larkinHome), agent.agentId));
553
+ if (options.positionals.length || options.values.size || options.booleans.size > 1)
554
+ throw new Error("inbox audit 只接受 --json;完成检查用 inbox audit complete");
555
+ const audit = readInboxAuditTargets(file, agent.agentId);
556
+ emitJson(io, audit);
526
557
  return 0;
527
558
  }
528
559
  if (options.positionals.length || [...options.values.keys()].some((flag) => !["--target", "--limit"].includes(flag))) {
@@ -52,11 +52,11 @@ const die = (message) => {
52
52
  process.exit(1);
53
53
  };
54
54
  if (!kind || !["agents", "model", "runtime", "effort", "chats", "config"].includes(kind)) {
55
- die("用法: larkin agents | larkin config <show|mention|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
55
+ die("用法: larkin agents | larkin config <show|mention|inbox-audit|apply> | larkin model [<id>] | larkin runtime [<id>] [--model <id>] | larkin effort [<level>|clear] | larkin chats [free|strict <oc_id>]");
56
56
  }
57
57
  const allowedValueFlags = {
58
58
  agents: new Set(), model: new Set(["--agent"]), runtime: new Set(["--agent", "--model"]),
59
- effort: new Set(["--agent"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat"]),
59
+ effort: new Set(["--agent"]), chats: new Set(["--agent"]), config: new Set(["--agent", "--chat", "--interval"]),
60
60
  };
61
61
  const allowedBooleanFlags = {
62
62
  agents: new Set(["--json"]), model: new Set(), runtime: new Set(), effort: new Set(), chats: new Set(), config: new Set(["--json"]),
@@ -92,6 +92,7 @@ for (let index = 0; index < rest.length; index += 1) {
92
92
  const flagAgent = parsedValues.get("--agent");
93
93
  const flagModel = parsedValues.get("--model");
94
94
  const flagChat = parsedValues.get("--chat");
95
+ const flagInterval = parsedValues.get("--interval");
95
96
  const flagJson = parsedBooleans.has("--json");
96
97
  const value = positionals[0];
97
98
  function assertOnlyFlags(valueFlags, booleanFlags = []) {
@@ -151,13 +152,23 @@ else if (kind === "config") {
151
152
  if (positionals.length !== 4)
152
153
  die("用法: larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]");
153
154
  }
155
+ else if (operation === "inbox-audit" && scope === "global") {
156
+ assertOnlyFlags(["--interval"]);
157
+ if (positionals.length !== 3)
158
+ die("用法: larkin config inbox-audit global <on|off> [--interval <15m|1h>]");
159
+ }
160
+ else if (operation === "inbox-audit" && scope === "agent") {
161
+ assertOnlyFlags(["--agent", "--interval"]);
162
+ if (positionals.length !== 3)
163
+ die("用法: larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]");
164
+ }
154
165
  else if (operation === "apply") {
155
166
  assertOnlyFlags(["--agent"]);
156
167
  if (positionals.length !== 1)
157
168
  die("用法: larkin config apply [--agent <App ID>]");
158
169
  }
159
170
  else {
160
- die("config 只支持 show/mention/apply;运行 larkin config --help");
171
+ die("config 只支持 show/mention/inbox-audit/apply;运行 larkin config --help");
161
172
  }
162
173
  }
163
174
  const loaded = (() => {
@@ -339,12 +350,17 @@ if (kind === "config") {
339
350
  say(JSON.stringify(view, null, 2));
340
351
  else {
341
352
  say(`全局群消息策略=${String(view.mentionPolicy)}(free 会唤醒所有继承它的 Agent)`);
353
+ const inboxAudit = view.inboxAudit;
354
+ say(`全局 Inbox Audit=${inboxAudit?.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(inboxAudit?.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}(默认关闭;未 @ 的 require 群消息不会进入审计)`);
342
355
  for (const item of view.agents) {
343
356
  const mention = item.mention;
357
+ const agentAudit = item.inboxAudit;
344
358
  const apply = item.apply;
345
- say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} apply=${apply.applyState || "unknown"}`);
359
+ say(`agent=${item.agentId} runtime=${item.runtime} model=${item.model} effort=${item.effort || "default"} mention=${mention.override} inbox-audit=${agentAudit?.override?.enabled || "inherit"} apply=${apply.applyState || "unknown"}`);
346
360
  if (mention.chat)
347
361
  say(` chat=${mention.chat.chatId} override=${mention.chat.override} effective=${mention.chat.effective} source=${mention.chat.source}`);
362
+ if (agentAudit?.effective)
363
+ say(` inbox-audit effective=${agentAudit.effective.enabled ? "on" : "off"} interval=${larkinConfig.formatInboxAuditInterval(Number(agentAudit.effective.intervalMs || larkinConfig.DEFAULT_INBOX_AUDIT_INTERVAL_MS))}`);
348
364
  }
349
365
  }
350
366
  process.exit(0);
@@ -374,6 +390,21 @@ if (kind === "config") {
374
390
  say(JSON.stringify({ ok: true, revision: result.revision, persisted: result.persisted, applyState: result.applyState, changedScope: result.changedScope }, null, 2));
375
391
  process.exit(0);
376
392
  }
393
+ if (operation === "inbox-audit") {
394
+ try {
395
+ const result = larkinConfig.mutateConfig(process.env, larkinConfig.inboxAuditMutationFromCli({
396
+ scope: scope || "",
397
+ enabled: first,
398
+ interval: flagInterval,
399
+ agentId: scope === "agent" ? requireTarget() : (selected || keys[0] || ""),
400
+ }), authority);
401
+ say(JSON.stringify({ ok: true, revision: result.revision, persisted: result.persisted, applyState: result.applyState, changedScope: result.changedScope }, null, 2));
402
+ }
403
+ catch (error) {
404
+ die(error instanceof Error ? error.message : String(error));
405
+ }
406
+ process.exit(0);
407
+ }
377
408
  if (operation === "apply") {
378
409
  const agentId = requireTarget();
379
410
  const expectedSignature = larkinConfig.runtimeConfigSignature(config, agentId);
@@ -393,7 +424,7 @@ if (kind === "config") {
393
424
  }
394
425
  process.exit(0);
395
426
  }
396
- die("config 只支持 show/mention/apply");
427
+ die("config 只支持 show/mention/inbox-audit/apply");
397
428
  }
398
429
  let key = flagAgent;
399
430
  if (!key && runtimeAgentId)
package/dist/app/cli.mjs CHANGED
@@ -73,6 +73,7 @@ Examples:
73
73
  larkin config show --agent cli_x --chat oc_x --json
74
74
  larkin config runtime pi --agent cli_x --model default
75
75
  larkin config mention chat oc_x free --agent cli_x
76
+ larkin config inbox-audit global on --interval 15m
76
77
 
77
78
  Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
78
79
  session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
@@ -401,12 +401,27 @@ function dashboardMutation(value) {
401
401
  "set-global-mention": ["operation", "value"], "set-agent-mention": ["operation", "agentId", "value"],
402
402
  "set-chat-mention": ["operation", "agentId", "chatId", "value"], "set-agent-runtime": ["operation", "agentId", "runtime", "model"],
403
403
  "set-agent-model": ["operation", "agentId", "model"], "set-agent-effort": ["operation", "agentId", "effort"],
404
+ "set-global-inbox-audit": ["operation", "enabled", "intervalMs"], "set-agent-inbox-audit": ["operation", "agentId", "enabled", "intervalMs"],
404
405
  };
405
406
  if (!allowed[operation] || Object.keys(value).some((key) => !allowed[operation].includes(key)))
406
407
  throw new Error("unsupported operation");
407
408
  if (operation === "set-global-mention")
408
409
  return { kind: operation, value: String(value.value) };
410
+ if (operation === "set-global-inbox-audit") {
411
+ if (value.enabled !== undefined && typeof value.enabled !== "boolean")
412
+ throw new Error("invalid inbox-audit enabled");
413
+ if (value.intervalMs !== undefined && (typeof value.intervalMs !== "number" || !Number.isSafeInteger(value.intervalMs)))
414
+ throw new Error("invalid inbox-audit interval");
415
+ return { kind: operation, ...(value.enabled !== undefined ? { enabled: value.enabled } : {}), ...(value.intervalMs !== undefined ? { intervalMs: value.intervalMs } : {}) };
416
+ }
409
417
  const agentId = String(value.agentId || "");
418
+ if (operation === "set-agent-inbox-audit") {
419
+ if (value.enabled !== undefined && value.enabled !== "inherit" && typeof value.enabled !== "boolean")
420
+ throw new Error("invalid inbox-audit enabled");
421
+ if (value.intervalMs !== undefined && value.intervalMs !== "inherit" && (typeof value.intervalMs !== "number" || !Number.isSafeInteger(value.intervalMs)))
422
+ throw new Error("invalid inbox-audit interval");
423
+ return { kind: operation, agentId, ...(value.enabled !== undefined ? { enabled: value.enabled } : {}), ...(value.intervalMs !== undefined ? { intervalMs: value.intervalMs } : {}) };
424
+ }
410
425
  if (operation === "set-agent-mention")
411
426
  return { kind: operation, agentId, value: String(value.value) };
412
427
  if (operation === "set-chat-mention")