larkin 0.4.20 → 0.4.22

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.
@@ -12,7 +12,7 @@ const internal_command_js_1 = require("../app/internal-command.cjs");
12
12
  exports.AGENT_CLI_CAPABILITIES = Object.freeze({
13
13
  version: 1,
14
14
  commands: Object.freeze({
15
- inbox: Object.freeze(["check", "poll"]),
15
+ inbox: Object.freeze(["check", "poll", "audit"]),
16
16
  comment: Object.freeze(["reply"]),
17
17
  reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
18
18
  interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
@@ -31,7 +31,9 @@ function resolveAgentCliExecutable(cliPath, runtimeExecutable = node_process_1.d
31
31
  function agentCliPromptCapabilities(executable = "larkin") {
32
32
  const commands = Object.entries(exports.AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
33
33
  command: `${group} ${operation}`,
34
- purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages." : "Poll full messages and direct-ack the returned batch.") :
34
+ purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
35
+ : operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
36
+ : "Poll full messages and direct-ack the returned batch.") :
35
37
  group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
36
38
  group === "reminder" ? "Manage this Agent's durable reminders." :
37
39
  group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
@@ -4,7 +4,7 @@ import { INTERNAL_AGENT_CLI, internalCommandShell } from "../app/internal-comman
4
4
  export const AGENT_CLI_CAPABILITIES = Object.freeze({
5
5
  version: 1,
6
6
  commands: Object.freeze({
7
- inbox: Object.freeze(["check", "poll"]),
7
+ inbox: Object.freeze(["check", "poll", "audit"]),
8
8
  comment: Object.freeze(["reply"]),
9
9
  reminder: Object.freeze(["schedule", "list", "snooze", "update", "cancel", "log"]),
10
10
  interaction: Object.freeze(["callback-status", "callback-probe", "create", "get", "resolve"]),
@@ -23,7 +23,9 @@ export function resolveAgentCliExecutable(cliPath, runtimeExecutable = process.e
23
23
  export function agentCliPromptCapabilities(executable = "larkin") {
24
24
  const commands = Object.entries(AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
25
25
  command: `${group} ${operation}`,
26
- purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages." : "Poll full messages and direct-ack the returned batch.") :
26
+ purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
27
+ : operation === "audit" ? "Read bounded observed group/topic audit targets and instructions."
28
+ : "Poll full messages and direct-ack the returned batch.") :
27
29
  group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
28
30
  group === "reminder" ? "Manage this Agent's durable reminders." :
29
31
  group === "interaction" ? "Create, inspect, or resolve a durable interactive card run." :
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InboxAuditHeartbeat = exports.INBOX_AUDIT_CADENCE_MS = void 0;
4
+ const inbox_projection_js_1 = require("./inbox-projection.cjs");
5
+ exports.INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
6
+ /** One Host-owned timer fans a targetless internal wake to each active Agent. */
7
+ class InboxAuditHeartbeat {
8
+ options;
9
+ timer = null;
10
+ running = false;
11
+ sequence = 0;
12
+ cadenceMs;
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.cadenceMs = options.cadenceMs ?? exports.INBOX_AUDIT_CADENCE_MS;
16
+ if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
17
+ throw new Error("inbox audit cadence must be a positive integer");
18
+ }
19
+ start() {
20
+ if (this.running)
21
+ return;
22
+ this.running = true;
23
+ this.schedule();
24
+ }
25
+ stop() {
26
+ this.running = false;
27
+ if (this.timer)
28
+ (this.options.clearTimer ?? clearTimeout)(this.timer);
29
+ this.timer = null;
30
+ }
31
+ schedule() {
32
+ if (!this.running || this.timer)
33
+ return;
34
+ this.timer = (this.options.setTimer ?? setTimeout)(() => {
35
+ this.timer = null;
36
+ return this.fire().finally(() => { if (this.running)
37
+ this.schedule(); });
38
+ }, this.cadenceMs);
39
+ this.timer.unref?.();
40
+ }
41
+ async fire() {
42
+ const now = this.options.now ?? Date.now;
43
+ for (const agent of this.options.agents) {
44
+ const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
45
+ const envelope = {
46
+ kind: "reminder",
47
+ message_id,
48
+ target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET,
49
+ wake: true,
50
+ content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
51
+ };
52
+ try {
53
+ const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
54
+ if (appended.status === "appended")
55
+ await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
56
+ }
57
+ catch (error) {
58
+ this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ exports.InboxAuditHeartbeat = InboxAuditHeartbeat;
@@ -0,0 +1,59 @@
1
+ import { RUNTIME_REMINDER_TARGET } from "./inbox-projection.mjs";
2
+ export const INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
3
+ /** One Host-owned timer fans a targetless internal wake to each active Agent. */
4
+ export class InboxAuditHeartbeat {
5
+ options;
6
+ timer = null;
7
+ running = false;
8
+ sequence = 0;
9
+ cadenceMs;
10
+ constructor(options) {
11
+ this.options = options;
12
+ this.cadenceMs = options.cadenceMs ?? INBOX_AUDIT_CADENCE_MS;
13
+ if (!Number.isSafeInteger(this.cadenceMs) || this.cadenceMs < 1)
14
+ throw new Error("inbox audit cadence must be a positive integer");
15
+ }
16
+ start() {
17
+ if (this.running)
18
+ return;
19
+ this.running = true;
20
+ this.schedule();
21
+ }
22
+ stop() {
23
+ this.running = false;
24
+ if (this.timer)
25
+ (this.options.clearTimer ?? clearTimeout)(this.timer);
26
+ this.timer = null;
27
+ }
28
+ schedule() {
29
+ if (!this.running || this.timer)
30
+ return;
31
+ this.timer = (this.options.setTimer ?? setTimeout)(() => {
32
+ this.timer = null;
33
+ return this.fire().finally(() => { if (this.running)
34
+ this.schedule(); });
35
+ }, this.cadenceMs);
36
+ this.timer.unref?.();
37
+ }
38
+ async fire() {
39
+ const now = this.options.now ?? Date.now;
40
+ for (const agent of this.options.agents) {
41
+ const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
42
+ const envelope = {
43
+ kind: "reminder",
44
+ message_id,
45
+ target: RUNTIME_REMINDER_TARGET,
46
+ wake: true,
47
+ content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. No finding stays silent.",
48
+ };
49
+ try {
50
+ const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
51
+ if (appended.status === "appended")
52
+ await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
53
+ }
54
+ catch (error) {
55
+ this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAX_INBOX_AUDIT_TARGETS = exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = void 0;
37
+ exports.inboxAuditRegistryFile = inboxAuditRegistryFile;
38
+ exports.observeInboxAuditTarget = observeInboxAuditTarget;
39
+ exports.readInboxAuditTargets = readInboxAuditTargets;
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ /**
43
+ * #169's host-owned heartbeat no longer creates per-target missed-outbound loops.
44
+ * Pre-v0.4.21 records have no unique ownership marker and can collide exactly
45
+ * with user reminders, so existing indistinguishable loops are never migrated
46
+ * or deleted automatically.
47
+ */
48
+ exports.INBOX_AUDIT_LEGACY_MIGRATION_NON_GOAL = "New versions no longer create missed-outbound loops; existing indistinguishable historical loops are not migrated or deleted automatically.";
49
+ // Audits return every retained target. Storage remains bounded so a heartbeat
50
+ // cannot accumulate an unbounded work list.
51
+ exports.MAX_INBOX_AUDIT_TARGETS = 96;
52
+ const MAX_STORED_TARGETS = exports.MAX_INBOX_AUDIT_TARGETS;
53
+ const CHAT = /^oc_[A-Za-z0-9]+$/;
54
+ const THREAD = /^omt_[A-Za-z0-9]+$/;
55
+ const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
56
+ function inboxAuditRegistryFile(larkinHome) {
57
+ return path.join(larkinHome, "inbox-audit.json");
58
+ }
59
+ function parseTarget(event) {
60
+ const chatId = String(event.chat_id || "");
61
+ const threadId = event.thread_id ? String(event.thread_id) : "";
62
+ const anchor = String(event.message_id || "");
63
+ if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
64
+ return null;
65
+ return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
66
+ }
67
+ function load(file) {
68
+ let value;
69
+ try {
70
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
71
+ }
72
+ catch (error) {
73
+ if (error.code === "ENOENT")
74
+ return { version: 1, targets: [] };
75
+ throw error;
76
+ }
77
+ if (value?.version !== 1 || !Array.isArray(value.targets))
78
+ return { version: 1, targets: [] };
79
+ return { version: 1, targets: value.targets.flatMap((row) => {
80
+ if (!row || typeof row !== "object")
81
+ return [];
82
+ const candidate = row;
83
+ if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
84
+ return [];
85
+ const parsed = parseTarget({
86
+ chat_id: candidate.target.startsWith("chat:") ? candidate.target.slice(5) : candidate.target.split(":")[1],
87
+ thread_id: candidate.target.startsWith("thread:") ? candidate.target.split(":")[2] : null,
88
+ message_id: candidate.anchor,
89
+ });
90
+ return typeof candidate.agent_id === "string" && candidate.agent_id && parsed && candidate.target === parsed.target
91
+ && typeof candidate.observed_at === "string" && Number.isFinite(Date.parse(candidate.observed_at))
92
+ ? [{ agent_id: candidate.agent_id, ...parsed, observed_at: candidate.observed_at }] : [];
93
+ }) };
94
+ }
95
+ function save(file, registry) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
98
+ fs.writeFileSync(temporary, `${JSON.stringify(registry)}\n`, { mode: 0o600 });
99
+ fs.renameSync(temporary, file);
100
+ fs.chmodSync(file, 0o600);
101
+ }
102
+ /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
103
+ function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
104
+ if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
105
+ return false;
106
+ const parsed = parseTarget(event);
107
+ if (!parsed)
108
+ return false;
109
+ const registry = load(file);
110
+ const observed_at = now.toISOString();
111
+ registry.targets = registry.targets.filter((row) => row.agent_id !== agentId || row.target !== parsed.target);
112
+ registry.targets.unshift({ agent_id: agentId, ...parsed, observed_at });
113
+ registry.targets = registry.targets.slice(0, MAX_STORED_TARGETS);
114
+ save(file, registry);
115
+ return true;
116
+ }
117
+ function instruction(target) {
118
+ const history = target.target.startsWith("thread:")
119
+ ? "Use larkin im +threads-messages-list for this exact thread."
120
+ : "Use larkin im +chat-messages-list for this exact chat.";
121
+ 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.`;
122
+ }
123
+ /** Content-free, bounded audit work list consumed by the Agent CLI. */
124
+ function readInboxAuditTargets(file, agentId) {
125
+ const rows = load(file).targets.filter((row) => row.agent_id === agentId)
126
+ .sort((left, right) => right.observed_at.localeCompare(left.observed_at));
127
+ return {
128
+ version: 1,
129
+ targets: rows.slice(0, exports.MAX_INBOX_AUDIT_TARGETS).map(({ agent_id: _agentId, ...target }) => ({ ...target, instruction: instruction(target) })),
130
+ has_more: rows.length > exports.MAX_INBOX_AUDIT_TARGETS,
131
+ no_finding: "stay_silent",
132
+ };
133
+ }
@@ -0,0 +1,94 @@
1
+ import * as fs from "node:fs";
2
+ 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
+ */
9
+ 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
+ export const MAX_INBOX_AUDIT_TARGETS = 96;
13
+ const MAX_STORED_TARGETS = MAX_INBOX_AUDIT_TARGETS;
14
+ const CHAT = /^oc_[A-Za-z0-9]+$/;
15
+ const THREAD = /^omt_[A-Za-z0-9]+$/;
16
+ const ANCHOR = /^om_[A-Za-z0-9_-]+$/;
17
+ export function inboxAuditRegistryFile(larkinHome) {
18
+ return path.join(larkinHome, "inbox-audit.json");
19
+ }
20
+ function parseTarget(event) {
21
+ const chatId = String(event.chat_id || "");
22
+ const threadId = event.thread_id ? String(event.thread_id) : "";
23
+ const anchor = String(event.message_id || "");
24
+ if (!CHAT.test(chatId) || !ANCHOR.test(anchor) || (threadId && !THREAD.test(threadId)))
25
+ return null;
26
+ return { target: threadId ? `thread:${chatId}:${threadId}` : `chat:${chatId}`, anchor };
27
+ }
28
+ function load(file) {
29
+ let value;
30
+ try {
31
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ENOENT")
35
+ return { version: 1, targets: [] };
36
+ throw error;
37
+ }
38
+ if (value?.version !== 1 || !Array.isArray(value.targets))
39
+ return { version: 1, targets: [] };
40
+ return { version: 1, targets: value.targets.flatMap((row) => {
41
+ if (!row || typeof row !== "object")
42
+ return [];
43
+ const candidate = row;
44
+ if (typeof candidate.target !== "string" || typeof candidate.anchor !== "string")
45
+ return [];
46
+ 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,
49
+ message_id: candidate.anchor,
50
+ });
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 }] : [];
54
+ }) };
55
+ }
56
+ 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);
62
+ }
63
+ /** Record only an authoritative human group/topic source; DMs and bots are ignored. */
64
+ export function observeInboxAuditTarget(file, agentId, event, now = new Date()) {
65
+ if (event._scan_authority !== true || event._sender_is_bot !== false || event.chat_type !== "group")
66
+ return false;
67
+ const parsed = parseTarget(event);
68
+ if (!parsed)
69
+ 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. */
85
+ 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
+ };
94
+ }
@@ -7,6 +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
11
  import { createReminderRoutes } from "../agent/reminder-routes.mjs";
11
12
  import { InteractionStateMachine } from "../agent/interaction-state-machine.mjs";
12
13
  import { issueCallbackProbe, readCallbackCapability } from "../platform/callback-capability.mjs";
@@ -501,10 +502,17 @@ export function runAgentCli(argv, env = process.env, dependencies = {}) {
501
502
  const { config, agent } = requireAgent(env);
502
503
  const stateStore = dependencies.stateStore ?? createAgentStateStore(config.larkinHome, agent.agentId);
503
504
  if (group === "inbox") {
504
- if (!["check", "poll"].includes(subcommand || "")) {
505
- throw new Error("inbox 只支持 `larkin inbox check` 与 `larkin inbox poll`");
505
+ if (!["check", "poll", "audit"].includes(subcommand || "")) {
506
+ throw new Error("inbox 只支持 `larkin inbox check`、`larkin inbox poll` 与 `larkin inbox audit`");
506
507
  }
507
508
  const options = parseOptions(rest, new Set(["--json"]));
509
+ if (subcommand === "audit") {
510
+ if (options.positionals.length || options.values.size || options.booleans.size > 1) {
511
+ throw new Error("inbox audit 只接受 --json");
512
+ }
513
+ emitJson(io, readInboxAuditTargets(inboxAuditRegistryFile(config.larkinHome), agent.agentId));
514
+ return 0;
515
+ }
508
516
  if (options.positionals.length || [...options.values.keys()].some((flag) => !["--target", "--limit"].includes(flag))) {
509
517
  throw new Error(`inbox ${subcommand} 只接受 --target、${subcommand === "poll" ? "--limit、" : ""}--json`);
510
518
  }
@@ -51,6 +51,8 @@ const host_business_state_js_1 = require("./host-business-state.cjs");
51
51
  const host_processing_eye_js_1 = require("./host-processing-eye.cjs");
52
52
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
53
53
  const host_reminder_orchestrator_js_1 = require("../agent/host-reminder-orchestrator.cjs");
54
+ const inbox_audit_heartbeat_js_1 = require("../agent/inbox-audit-heartbeat.cjs");
55
+ const missed_outbound_scan_js_1 = require("../agent/missed-outbound-scan.cjs");
54
56
  const host_channel_business_js_1 = require("./host-channel-business.cjs");
55
57
  const interaction_orchestrator_js_1 = require("./interaction-orchestrator.cjs");
56
58
  const message_policy_js_1 = require("./message-policy.cjs");
@@ -339,6 +341,13 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
339
341
  for (const agent of agents)
340
342
  prepareAgentState(agent);
341
343
  const reminder = new host_reminder_orchestrator_js_1.HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
344
+ const inboxAudit = new inbox_audit_heartbeat_js_1.InboxAuditHeartbeat({
345
+ agents,
346
+ stateStore,
347
+ runtimeHost,
348
+ log,
349
+ });
350
+ const auditRegistry = (0, missed_outbound_scan_js_1.inboxAuditRegistryFile)(larkinHome);
342
351
  const seenEventIds = new Set();
343
352
  const inFlightEventIds = new Set();
344
353
  const onFeishuMessage = async (agent, event, options) => {
@@ -385,6 +394,12 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
385
394
  // append/dedupe decision is durable. Agent model-seen state is untouched.
386
395
  if (event.event_id)
387
396
  seenEventIds.add(eventKey);
397
+ try {
398
+ (0, missed_outbound_scan_js_1.observeInboxAuditTarget)(auditRegistry, agent.agentId, event);
399
+ }
400
+ catch (error) {
401
+ log(`inbox audit target 未持久化: ${error.message}`);
402
+ }
388
403
  if (append.status === "duplicate_consumed")
389
404
  return null;
390
405
  const inboxEnvelope = append.envelope;
@@ -1336,6 +1351,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1336
1351
  eventSourceStartTimer = null;
1337
1352
  await Promise.resolve(eventSourceStop());
1338
1353
  reminder.stopSync();
1354
+ inboxAudit.stop();
1339
1355
  interaction.stopSync();
1340
1356
  await runtimeHost.shutdown(reason);
1341
1357
  })();
@@ -1550,6 +1566,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
1550
1566
  sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
1551
1567
  })));
1552
1568
  reminder.startSync();
1569
+ inboxAudit.start();
1553
1570
  interaction.startSync();
1554
1571
  eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
1555
1572
  }
@@ -11,6 +11,8 @@ import { HostEnvelopeProjector, HostStateProjection, SenderIdentityCache, projec
11
11
  import { ProcessingEyeOrchestrator } from "./host-processing-eye.mjs";
12
12
  import { projectInboxEnvelope, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
13
13
  import { HostReminderOrchestrator } from "../agent/host-reminder-orchestrator.mjs";
14
+ import { InboxAuditHeartbeat } from "../agent/inbox-audit-heartbeat.mjs";
15
+ import { inboxAuditRegistryFile, observeInboxAuditTarget } from "../agent/missed-outbound-scan.mjs";
14
16
  import { HostChannelBusiness } from "./host-channel-business.mjs";
15
17
  import { HostInteractionOrchestrator } from "./interaction-orchestrator.mjs";
16
18
  import { targetFor } from "./message-policy.mjs";
@@ -299,6 +301,13 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
299
301
  for (const agent of agents)
300
302
  prepareAgentState(agent);
301
303
  const reminder = new HostReminderOrchestrator({ agents, stateStore, envelopeProjector, deliveryTarget: runtimeHost, log });
304
+ const inboxAudit = new InboxAuditHeartbeat({
305
+ agents,
306
+ stateStore,
307
+ runtimeHost,
308
+ log,
309
+ });
310
+ const auditRegistry = inboxAuditRegistryFile(larkinHome);
302
311
  const seenEventIds = new Set();
303
312
  const inFlightEventIds = new Set();
304
313
  const onFeishuMessage = async (agent, event, options) => {
@@ -345,6 +354,12 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
345
354
  // append/dedupe decision is durable. Agent model-seen state is untouched.
346
355
  if (event.event_id)
347
356
  seenEventIds.add(eventKey);
357
+ try {
358
+ observeInboxAuditTarget(auditRegistry, agent.agentId, event);
359
+ }
360
+ catch (error) {
361
+ log(`inbox audit target 未持久化: ${error.message}`);
362
+ }
348
363
  if (append.status === "duplicate_consumed")
349
364
  return null;
350
365
  const inboxEnvelope = append.envelope;
@@ -1296,6 +1311,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1296
1311
  eventSourceStartTimer = null;
1297
1312
  await Promise.resolve(eventSourceStop());
1298
1313
  reminder.stopSync();
1314
+ inboxAudit.stop();
1299
1315
  interaction.stopSync();
1300
1316
  await runtimeHost.shutdown(reason);
1301
1317
  })();
@@ -1510,6 +1526,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
1510
1526
  sessionId: agentStates.get(agent.agentId)?.state.sessions[agent.runtime] || null,
1511
1527
  })));
1512
1528
  reminder.startSync();
1529
+ inboxAudit.start();
1513
1530
  interaction.startSync();
1514
1531
  eventSourceStartTimer = setTimeout(() => { eventSourceStartTimer = null; startEventSource(); }, eventSourceStartDelayMs);
1515
1532
  }
@@ -39,6 +39,8 @@ function normalizeChannelMessage(message) {
39
39
  _mentioned_bot: Boolean(message.mentionedBot || message.mentionAll),
40
40
  _mention_all: Boolean(message.mentionAll),
41
41
  _sender_is_bot: message.senderIsBot === true,
42
+ _scan_authority: typeof message.senderIsBot === "boolean"
43
+ && (message.chatType === "group" || message.chatMode === "topic"),
42
44
  };
43
45
  }
44
46
  function slug10(value) {
@@ -26,6 +26,8 @@ export function normalizeChannelMessage(message) {
26
26
  _mentioned_bot: Boolean(message.mentionedBot || message.mentionAll),
27
27
  _mention_all: Boolean(message.mentionAll),
28
28
  _sender_is_bot: message.senderIsBot === true,
29
+ _scan_authority: typeof message.senderIsBot === "boolean"
30
+ && (message.chatType === "group" || message.chatMode === "topic"),
29
31
  };
30
32
  }
31
33
  export function slug10(value) {
@@ -19,6 +19,7 @@ import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime,
19
19
  import { documentCommentSubscriptionCapability, markDocumentCommentSubscriptionVerified, } from "../platform/callback-capability.mjs";
20
20
  import { openPlatformHost, parseLarkinTenant, registerAppAccountsHost, } from "../feishu/platform-hosts.mjs";
21
21
  import { authorizationUrlFailureMessage, presentAuthorizationUrl } from "./setup-authorization-url.mjs";
22
+ import { missingGrantedTenantScopes } from "./tenant-scope-grant.mjs";
22
23
  // qrcode-terminal does not publish TypeScript declarations.
23
24
  // @ts-expect-error bundled CommonJS dependency
24
25
  import qrcodePackage from "qrcode-terminal";
@@ -28,6 +29,7 @@ const testFixture = process.env.LARKIN_TEST_BOT_REGISTER_MODULE
28
29
  const registerApp = testFixture?.registerApp ?? channelRegisterApp;
29
30
  const qrcode = testFixture?.qrcode ?? qrcodePackage;
30
31
  const spawnSync = testFixture?.spawnSync ?? systemSpawnSync;
32
+ const spawnChild = testFixture?.spawn ?? systemSpawn;
31
33
  const synchronizeAgentProfile = testFixture?.syncAgentProfile ?? syncAgentProfile;
32
34
  const resolveOfficialCli = testFixture?.resolveOfficialLarkCli ?? resolveOfficialLarkCli;
33
35
  const wait = testFixture?.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
@@ -306,7 +308,7 @@ async function runBindProcess(command, args) {
306
308
  return spawnSync(command, [...args], { env: process.env, stdio: "inherit" }).status;
307
309
  }
308
310
  return await new Promise((resolve, reject) => {
309
- const child = systemSpawn(command, [...args], { env: process.env, stdio: "inherit" });
311
+ const child = spawnChild(command, [...args], { env: process.env, stdio: "inherit" });
310
312
  trackPendingChild(child);
311
313
  let killTimer = null;
312
314
  const abort = () => {
@@ -339,7 +341,7 @@ async function runBoundedCliProcess(command, args, env, label) {
339
341
  return { status: result.status, stdout: result.stdout || "", stderr: result.stderr || "" };
340
342
  }
341
343
  return await new Promise((resolve, reject) => {
342
- const child = systemSpawn(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
344
+ const child = spawnChild(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
343
345
  trackPendingChild(child);
344
346
  let stdout = "";
345
347
  let stderr = "";
@@ -641,12 +643,13 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
641
643
  : commentSubscription === "none" || !priorSubscription
642
644
  ? { mode: "none", status: "safe-default", source: "setup-default", updatedAt: requestedAt }
643
645
  : priorSubscription;
644
- if (!flag("--runtime") && (!testFixture || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
646
+ const injectedAgentChoice = testFixture?.collectSetupAgentChoice;
647
+ if (!flag("--runtime") && (!testFixture || injectedAgentChoice || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
645
648
  const existing = larkinConfig.loadConfig(process.env).config.agents[id];
646
649
  // The official resolver performs a synchronous login-shell probe. Resolve it
647
650
  // exactly once before the credential heartbeat lock becomes active.
648
651
  resolvedSetupOfficialCli = resolveOfficialCli({ env: process.env });
649
- const questioner = terminalSetupQuestioner();
652
+ const questioner = injectedAgentChoice ? { ask: async () => "", secret: async () => "" } : terminalSetupQuestioner();
650
653
  // One setup-owned transaction starts before status/logout and remains active
651
654
  // through selection, login, bind, readiness, and the final setup commit.
652
655
  pendingPiAuthTransaction = beginBuiltinPiCredentialTransaction(CFG_DIR, id);
@@ -657,37 +660,43 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
657
660
  report: (message) => say(message),
658
661
  };
659
662
  try {
660
- const requested = await collectSetupAgentChoice(questioner, existing, authServices);
661
- const choice = await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
662
- runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
663
- }), (message) => say(`! ${message}`), authServices);
663
+ const requested = injectedAgentChoice
664
+ ? await injectedAgentChoice()
665
+ : await collectSetupAgentChoice(questioner, existing, authServices);
666
+ const choice = injectedAgentChoice
667
+ ? requested
668
+ : await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
669
+ runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
670
+ }), (message) => say(`! ${message}`), authServices);
664
671
  if (choice) {
665
672
  let serializedChoice = choice;
666
673
  if (choice.runtime === "pi" && choice.distribution === "builtin") {
667
- const official = choice.preset === "official" ? choice : null;
668
- const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
669
- const providerId = official?.providerId || configured.provider;
670
- const authType = official?.authType || "api_key";
671
- let piRuntime;
672
- try {
673
- say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
674
- piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
675
- await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
676
- }
677
- catch {
678
- pendingPiAuthTransaction.rollback();
679
- pendingPiAuthTransaction = null;
680
- throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
681
- }
682
- if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
683
- say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
674
+ if (!injectedAgentChoice) {
675
+ const official = choice.preset === "official" ? choice : null;
676
+ const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
677
+ const providerId = official?.providerId || configured.provider;
678
+ const authType = official?.authType || "api_key";
679
+ let piRuntime;
684
680
  try {
685
- await verifyOfficialPiProviderTurn(piRuntime, choice.model);
681
+ say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
682
+ piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
683
+ await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
686
684
  }
687
685
  catch {
688
686
  pendingPiAuthTransaction.rollback();
689
687
  pendingPiAuthTransaction = null;
690
- throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
688
+ throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
689
+ }
690
+ if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
691
+ say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
692
+ try {
693
+ await verifyOfficialPiProviderTurn(piRuntime, choice.model);
694
+ }
695
+ catch {
696
+ pendingPiAuthTransaction.rollback();
697
+ pendingPiAuthTransaction = null;
698
+ throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
699
+ }
691
700
  }
692
701
  }
693
702
  serializedChoice = { ...choice, authCompleted: true, readinessCompleted: true };
@@ -850,6 +859,21 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
850
859
  }
851
860
  if (!verified)
852
861
  throw new Error("Bot identity verification failed");
862
+ const scopeResult = await runIdentityProcess(official.command, [...official.argsPrefix, "api", "GET", "/open-apis/application/v6/scopes"], cliEnv);
863
+ let scopePayload;
864
+ try {
865
+ scopePayload = JSON.parse(scopeResult.stdout || "");
866
+ }
867
+ catch {
868
+ scopePayload = null;
869
+ }
870
+ const missing = missingGrantedTenantScopes(scopePayload);
871
+ if (scopeResult.status !== 0 || missing.length > 0) {
872
+ const reason = scopeResult.status !== 0
873
+ ? `scopes API 失败 status=${scopeResult.status}`
874
+ : `缺 ${missing.join(", ")}`;
875
+ throw new Error(`Bot tenant scope 未就绪,${reason};+chat-list 成功不能当作可发群消息。请在开发者后台开通后重跑 setup,不要重建 Agent。`);
876
+ }
853
877
  if (commentSubscription !== "preserve") {
854
878
  const reconciled = await applyDocumentCommentSubscription({
855
879
  mode: commentSubscription,
@@ -901,6 +925,8 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
901
925
  }
902
926
  catch (error) {
903
927
  const diagnostic = errorMessage(error);
928
+ if (diagnostic.startsWith("Bot tenant scope 未就绪"))
929
+ die(diagnostic);
904
930
  if (diagnostic.startsWith("document comment application subscription")
905
931
  || diagnostic.startsWith("document comment local verified-state persistence"))
906
932
  say(`! ${diagnostic}`);
@@ -10,6 +10,7 @@ import * as larkinConfig from "../platform/config.mjs";
10
10
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
11
11
  import { loadValidatedBotCredential } from "./run-credential-preflight.mjs";
12
12
  import { parseLarkinTenant, registerAppAccountsHost } from "../feishu/platform-hosts.mjs";
13
+ import { presentAuthorizationUrl } from "./setup-authorization-url.mjs";
13
14
  // qrcode-terminal does not publish TypeScript declarations.
14
15
  // @ts-expect-error bundled CommonJS dependency
15
16
  import qrcodePackage from "qrcode-terminal";
@@ -108,18 +109,26 @@ export async function main() {
108
109
  },
109
110
  onQRCodeReady: (info) => {
110
111
  const minutes = Math.max(1, Math.round(info.expireIn / 60));
112
+ const official = resolveManagedOfficialCli(selected, process.env);
113
+ const presented = presentAuthorizationUrl(info.url, {
114
+ tenant: TENANT,
115
+ larkCliVersion: official.command.version,
116
+ });
117
+ if (TENANT === "lark" && /\/page\/launcher(?:\?|$)/.test(presented)) {
118
+ throw new Error("Lark 租户拒绝把 /page/launcher 交给浏览器");
119
+ }
111
120
  log("\n请用飞书(Lark)扫码或打开链接,确认给应用增补权限:\n");
112
- qrcode.generate(info.url, { small: true });
113
- log(`\n链接: ${info.url}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
121
+ qrcode.generate(presented, { small: true });
122
+ log(`\n链接: ${presented}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
114
123
  if (URL_FILE) {
115
124
  try {
116
- fs.writeFileSync(URL_FILE, info.url);
125
+ fs.writeFileSync(URL_FILE, presented);
117
126
  }
118
127
  catch (error) {
119
128
  log(`[grant] 写 url-file 失败: ${error instanceof Error ? error.message : String(error)}`);
120
129
  }
121
130
  }
122
- sendUrlToChat(info.url, minutes);
131
+ sendUrlToChat(presented, minutes);
123
132
  },
124
133
  onStatusChange: (info) => log(`[grant] 状态: ${info.status}`),
125
134
  domain: registerAppAccountsHost(TENANT),
@@ -0,0 +1,16 @@
1
+ /** Tenant scopes the freshness gate needs on the Bot application. */
2
+ export const FRESHNESS_REQUIRED_TENANT_SCOPES = ["im:message.group_msg"];
3
+ function asRows(payload) {
4
+ if (!payload || typeof payload !== "object")
5
+ return [];
6
+ const root = payload;
7
+ const raw = Array.isArray(root.data?.scopes) ? root.data.scopes
8
+ : Array.isArray(root.scopes) ? root.scopes
9
+ : [];
10
+ return raw.filter((row) => !!row && typeof row === "object");
11
+ }
12
+ /** grant_status 1 is granted on GET /open-apis/application/v6/scopes. */
13
+ export function missingGrantedTenantScopes(payload, required = FRESHNESS_REQUIRED_TENANT_SCOPES) {
14
+ const rows = asRows(payload);
15
+ return required.filter((name) => !rows.some((row) => row.scope_name === name && row.grant_status === 1));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.20",
3
+ "version": "0.4.22",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [