larkin 0.5.16 → 0.5.17

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,11 +12,11 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
12
12
  * 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
13
13
  * 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
14
14
  * 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
15
- * 4. 幂等与安全:idempotency、freshness 前置检查、不可重复提交、脱敏、失败可见(fail visibly)。
15
+ * 4. 幂等与安全:idempotency、非阻塞 freshness 提示、不可重复提交、脱敏、失败可见(fail visibly)。
16
16
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
17
17
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
18
18
  */
19
- exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v34";
20
20
  /**
21
21
  * Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
22
22
  * 不声明 tmux 一定可用,也不复述扩展的工具细节。
@@ -156,7 +156,7 @@ class ContextPromptBuilder {
156
156
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
157
157
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
158
158
  "Only a real Feishu `message_id` beginning with `om_` may be passed to `+messages-reply`; `rem_`, `redeliver_`, and every other synthetic ID must never be replied to. Send with a confirmed `chat_id` and never guess a chat id from a display name.",
159
- "Before every send/reply/card/recall write, Larkin probes the exact chat or thread history with the current Bot identity. A nonzero `freshness_conflict` includes bounded unseen context and direct-acks that cursor; reconsider it, then retry the ordinary command. Larkin never saves the blocked operation as a draft.",
159
+ "Freshness is a non-blocking reminder: a send, reply, card, recall, or urgent write is never rejected for freshness. After a successful write, stderr may contain a `larkin_notice` with `freshness`; the write already happened. Re-read the exact chat or thread with the Larkin command, and send a short follow-up only when newer context changes the answer. Never resend the same content merely because of this notice.",
160
160
  "For ordinary plain-text message bodies, use `--text`, including brief one-line replies, multiline status lines, and paragraphs. Use `--markdown` only when you intentionally need Markdown rendering, such as headings, lists, emphasis, blockquotes, fenced code, or Markdown links.",
161
161
  "When a URL must be visible, clickable, or openable by the recipient, include the complete bare `https://...` URL as visible text. Do not rely solely on `[label](URL)`, because Feishu client rendering is unreliable. A label may also be included, but the bare URL must remain present.",
162
162
  "Use native `--text` for ordinary plain text and for verbatim preservation, such as logs, literal code, or exact whitespace. Both `--markdown` and `--text` remain supported in the Larkin Runtime.",
@@ -166,7 +166,7 @@ class ContextPromptBuilder {
166
166
  `For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
167
167
  `A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
168
168
  `Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
169
- "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. Without a `freshness_conflict` it uses two total model tool calls including the poll. If the first write instead returns a nonzero pre-commit, provider-not-reached `freshness_conflict`, reconsider its bounded context and, only if the exact operation remains unchanged, retry the identical command once; this is the only allowed extra call and produces exactly three total model tool calls including the poll.",
169
+ "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. It uses two total model tool calls including the poll. A later freshness notice is advisory and must not cause a duplicate send.",
170
170
  "For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
171
171
  "Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
172
172
  "For a task that starts from a known thread and asks for its latest human content, after the required Inbox poll invoke the thread composite directly as the first and only post-poll model tool call. For this thread source, never preview or read the thread separately, extract a message id, switch to `+messages-mget`, or add a second source read.",
@@ -181,10 +181,10 @@ class ContextPromptBuilder {
181
181
  `The complete canonical exact send and reply paths above must not be preceded by \`${executable} im +messages-send --help\` or \`${executable} im +messages-reply --help\`; run these two known recipes directly.`,
182
182
  "For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
183
183
  `For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
184
- "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, freshness, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
185
- `To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, probes freshness before provider commit, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
184
+ "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
185
+ `To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
186
186
  "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
187
- "Both `freshness_unavailable` and `freshness_conflict` are pre-commit, provider-not-reached results. If a retry is warranted for an unchanged exact operation, rerun the identical safe ordinary command: `--text` for a direct literal or the same deterministic `--content` dataflow for a tool source. The wrapper reuses its derived key. If the target or body changes after reconsideration, run the revised ordinary command and let the wrapper derive a new key. A result with `committed=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
187
+ "A `larkin_notice` freshness reminder is written to stderr only after the provider write has completed. Keep the successful write; re-read the target before any follow-up, and only send a revised follow-up if the new context materially changes the answer. A result with `committed=true` or `duplicate=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
188
188
  "For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
189
189
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
190
190
  `- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
@@ -9,11 +9,11 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
9
9
  * 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
10
10
  * 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
11
11
  * 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
12
- * 4. 幂等与安全:idempotency、freshness 前置检查、不可重复提交、脱敏、失败可见(fail visibly)。
12
+ * 4. 幂等与安全:idempotency、非阻塞 freshness 提示、不可重复提交、脱敏、失败可见(fail visibly)。
13
13
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
14
14
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
15
15
  */
16
- export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v33";
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v34";
17
17
  /**
18
18
  * Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
19
19
  * 不声明 tmux 一定可用,也不复述扩展的工具细节。
@@ -153,7 +153,7 @@ export class ContextPromptBuilder {
153
153
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
154
154
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
155
155
  "Only a real Feishu `message_id` beginning with `om_` may be passed to `+messages-reply`; `rem_`, `redeliver_`, and every other synthetic ID must never be replied to. Send with a confirmed `chat_id` and never guess a chat id from a display name.",
156
- "Before every send/reply/card/recall write, Larkin probes the exact chat or thread history with the current Bot identity. A nonzero `freshness_conflict` includes bounded unseen context and direct-acks that cursor; reconsider it, then retry the ordinary command. Larkin never saves the blocked operation as a draft.",
156
+ "Freshness is a non-blocking reminder: a send, reply, card, recall, or urgent write is never rejected for freshness. After a successful write, stderr may contain a `larkin_notice` with `freshness`; the write already happened. Re-read the exact chat or thread with the Larkin command, and send a short follow-up only when newer context changes the answer. Never resend the same content merely because of this notice.",
157
157
  "For ordinary plain-text message bodies, use `--text`, including brief one-line replies, multiline status lines, and paragraphs. Use `--markdown` only when you intentionally need Markdown rendering, such as headings, lists, emphasis, blockquotes, fenced code, or Markdown links.",
158
158
  "When a URL must be visible, clickable, or openable by the recipient, include the complete bare `https://...` URL as visible text. Do not rely solely on `[label](URL)`, because Feishu client rendering is unreliable. A label may also be included, but the bare URL must remain present.",
159
159
  "Use native `--text` for ordinary plain text and for verbatim preservation, such as logs, literal code, or exact whitespace. Both `--markdown` and `--text` remain supported in the Larkin Runtime.",
@@ -163,7 +163,7 @@ export class ContextPromptBuilder {
163
163
  `For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
164
164
  `A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
165
165
  `Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
166
- "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. Without a `freshness_conflict` it uses two total model tool calls including the poll. If the first write instead returns a nonzero pre-commit, provider-not-reached `freshness_conflict`, reconsider its bounded context and, only if the exact operation remains unchanged, retry the identical command once; this is the only allowed extra call and produces exactly three total model tool calls including the poll.",
166
+ "This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. It uses two total model tool calls including the poll. A later freshness notice is advisory and must not cause a duplicate send.",
167
167
  "For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
168
168
  "Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
169
169
  "For a task that starts from a known thread and asks for its latest human content, after the required Inbox poll invoke the thread composite directly as the first and only post-poll model tool call. For this thread source, never preview or read the thread separately, extract a message id, switch to `+messages-mget`, or add a second source read.",
@@ -178,10 +178,10 @@ export class ContextPromptBuilder {
178
178
  `The complete canonical exact send and reply paths above must not be preceded by \`${executable} im +messages-send --help\` or \`${executable} im +messages-reply --help\`; run these two known recipes directly.`,
179
179
  "For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
180
180
  `For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
181
- "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, freshness, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
182
- `To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, probes freshness before provider commit, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
181
+ "This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
182
+ `To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
183
183
  "The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
184
- "Both `freshness_unavailable` and `freshness_conflict` are pre-commit, provider-not-reached results. If a retry is warranted for an unchanged exact operation, rerun the identical safe ordinary command: `--text` for a direct literal or the same deterministic `--content` dataflow for a tool source. The wrapper reuses its derived key. If the target or body changes after reconsideration, run the revised ordinary command and let the wrapper derive a new key. A result with `committed=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
184
+ "A `larkin_notice` freshness reminder is written to stderr only after the provider write has completed. Keep the successful write; re-read the target before any follow-up, and only send a revised follow-up if the new context materially changes the answer. A result with `committed=true` or `duplicate=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
185
185
  "For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
186
186
  ...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
187
187
  `- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
@@ -119,7 +119,7 @@ function migrationError(group, subcommand) {
119
119
  if (group === "server")
120
120
  return "server 已移除;飞书(Lark)群与消息信息请通过 `larkin im ...` 查询。";
121
121
  if (group === "im")
122
- return "请通过 `larkin im ...` 使用飞书(Lark)命令;Runtime 会自动绑定当前 Bot identity,并在写入前执行 freshness gate。可运行 `larkin im --help` 查看帮助。";
122
+ return "请通过 `larkin im ...` 使用飞书(Lark)命令;Runtime 会自动绑定当前 Bot identity,并在成功写入后给出非阻塞 freshness 提示。可运行 `larkin im --help` 查看帮助。";
123
123
  if (group === "profile" && subcommand !== "show") {
124
124
  return "profile 只保留只读的 `larkin profile show`;身份和凭证由 `larkin setup` 管理,不支持 update。";
125
125
  }
package/dist/app/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { CONFIG_CLI_USAGE } from "../agent/config-cli-contract.mjs";
9
9
  import { internalCommandSpec } from "./internal-command.mjs";
10
10
  import { packageVersion } from "../platform/build-info.mjs";
11
+ import { isLarkinRuntimeContext } from "./runtime-context.mjs";
11
12
  const command = process.argv[2] || "help";
12
13
  let rest = process.argv.slice(3);
13
14
  const PACKAGE_VERSION = packageVersion(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."));
@@ -36,6 +37,18 @@ const routes = {
36
37
  };
37
38
  const runtimeAgentAuthority = typeof process.env.LARKIN_AGENT_ID === "string"
38
39
  && process.env.LARKIN_AGENT_ID.trim().length > 0;
40
+ const runtimeContext = isLarkinRuntimeContext(process.env);
41
+ // `config` deliberately remains a public operator surface: it supports global
42
+ // and cross-Agent configuration, and it never falls through to `activeAgent`.
43
+ const runtimeScopedCommands = new Set(["inbox", "reminder", "interaction", "profile", "comment"]);
44
+ const missingRuntimeAuthority = (name) => {
45
+ console.error(`larkin: Runtime Agent authority is missing for "${command}" (LARKIN_AGENT_ID is required). `
46
+ + `Runtime context was detected (${name}); it will not fall back to the active Agent.`);
47
+ process.exit(2);
48
+ };
49
+ if (!runtimeAgentAuthority && runtimeScopedCommands.has(command)) {
50
+ missingRuntimeAuthority("agent-scoped command");
51
+ }
39
52
  const runtimeAgentCommand = runtimeAgentAuthority
40
53
  && ["inbox", "reminder", "interaction", "profile", "config"].includes(command);
41
54
  if (runtimeAgentCommand)
@@ -107,6 +120,8 @@ const retiredPublicCommands = new Set(["pi-auth", "pi-distribution"]);
107
120
  const wantsHelp = command === "help" || command === "--help" || command === "-h"
108
121
  || (!runtimeAgentAuthority && command.startsWith("-"));
109
122
  if (!routes[command] && !wantsHelp && !retiredPublicCommands.has(command)) {
123
+ if (runtimeContext && !runtimeAgentAuthority)
124
+ missingRuntimeAuthority("Runtime marker");
110
125
  routes[command] = runtimeAgentAuthority ? ["lark-cli", command] : ["lark", command];
111
126
  }
112
127
  if (!routes[command]) {
@@ -190,7 +190,7 @@ export function classifyLarkCliCommand(argv) {
190
190
  return { kind: "passthrough" };
191
191
  const protectedPaths = protectedOperations(argv);
192
192
  if (command === "larkin-draft")
193
- return { kind: "denied", reason: "larkin-draft 已移除;freshness conflict 后请重新判断并执行普通写命令" };
193
+ return { kind: "denied", reason: "larkin-draft 已移除;请使用受 Runtime 身份与目标绑定保护的普通写命令" };
194
194
  if (exactPath(parsed.commandArgv, ["im", "+messages-send"]))
195
195
  return uniqueProtectedOperation(protectedPaths, "send")
196
196
  ? (parsed.flags.has("--thread-id")
@@ -223,21 +223,21 @@ export function classifyLarkCliCommand(argv) {
223
223
  if (exactPath(parsed.commandArgv, ["im", "messages", "create"]) || exactPath(parsed.commandArgv, ["im", "messages", "reply"])) {
224
224
  const expected = parsed.commandArgv[2] === "create" ? "raw-create" : "raw-reply";
225
225
  return uniqueProtectedOperation(protectedPaths, expected)
226
- ? { kind: "denied", reason: "该原始 IM 写入口会旁路 target freshness;请使用 +messages-send/+messages-reply" }
226
+ ? { kind: "denied", reason: "该原始 IM 写入口会旁路 Runtime target binding;请使用 +messages-send/+messages-reply" }
227
227
  : noncanonicalProtectedDecision();
228
228
  }
229
229
  if (["forward", "merge_forward", "urgent_phone", "urgent_sms"]
230
230
  .some((operation) => exactPath(parsed.commandArgv, ["im", "messages", operation]))) {
231
231
  const expected = `raw-${parsed.commandArgv[2]}`;
232
232
  return uniqueProtectedOperation(protectedPaths, expected)
233
- ? { kind: "denied", reason: "该 IM 写入口无法建立 target freshness;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
233
+ ? { kind: "denied", reason: "该 IM 写入口无法建立 Runtime target binding;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
234
234
  : noncanonicalProtectedDecision();
235
235
  }
236
236
  if (["forward", "merge_forward"]
237
237
  .some((operation) => exactPath(parsed.commandArgv, ["im", "threads", operation]))) {
238
238
  const expected = `thread-${parsed.commandArgv[2]}`;
239
239
  return uniqueProtectedOperation(protectedPaths, expected)
240
- ? { kind: "denied", reason: "该 IM forwarding 入口无法建立 target freshness;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
240
+ ? { kind: "denied", reason: "该 IM forwarding 入口无法建立 Runtime target binding;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
241
241
  : noncanonicalProtectedDecision();
242
242
  }
243
243
  if (command === "api")
@@ -347,8 +347,7 @@ function runCommentReply(argv, privateEnv, io, dependencies, store, agentId) {
347
347
  const target = targetKey ? parseDocumentCommentTarget(targetKey) : null;
348
348
  if (!target)
349
349
  throw new Error("comment reply 无法从当前 Agent Inbox 绑定文档评论 locator;先 poll 该消息且不得跨 Agent/评论回复");
350
- if (!store.inboxTargetIsFresh(targetKey))
351
- throw new Error("comment reply 需要先 poll 当前 document-comment target 的最新 Inbox 消息");
350
+ const staleCommentTarget = !store.inboxTargetIsFresh(targetKey);
352
351
  const digest = createHash("sha256").update(input.text).digest("hex");
353
352
  const ledgerKey = `${input.messageId}::${digest}`;
354
353
  const claim = store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
@@ -421,6 +420,13 @@ function runCommentReply(argv, privateEnv, io, dependencies, store, agentId) {
421
420
  });
422
421
  }
423
422
  const committedWrite = !result.error && result.status === 0;
423
+ if (committedWrite && staleCommentTarget) {
424
+ io.stderr(`${JSON.stringify({
425
+ larkin_notice: "freshness",
426
+ target: targetKey,
427
+ hint: "Newer Inbox messages exist. Re-read the document comment context with larkin before sending a follow-up. Do not resend the same content.",
428
+ })}\n`);
429
+ }
424
430
  const currentReminder = committedWrite
425
431
  ? store.resolveCurrentReminders().filter((reminder) => reminder.deliveryTarget === targetKey).at(-1) ?? null
426
432
  : null;
@@ -464,9 +470,9 @@ function definitiveProviderRejection(result) {
464
470
  }
465
471
  return false;
466
472
  }
467
- function callNative(argv, env, io, dependencies) {
473
+ function callNative(argv, env, io, dependencies, timeoutMs) {
468
474
  const native = dependencies.nativeCommand;
469
- const result = (dependencies.spawn ?? spawnSync)(native?.command ?? resolveOfficialLarkCli({ spawn: dependencies.spawn, env }).command, [...(native?.argsPrefix ?? []), ...argv], { encoding: "utf8", env: { ...process.env, ...env } });
475
+ const result = (dependencies.spawn ?? spawnSync)(native?.command ?? resolveOfficialLarkCli({ spawn: dependencies.spawn, env }).command, [...(native?.argsPrefix ?? []), ...argv], { encoding: "utf8", env: { ...process.env, ...env }, ...(timeoutMs ? { timeout: timeoutMs } : {}) });
470
476
  return result;
471
477
  }
472
478
  function spawnNative(argv, env, io, dependencies) {
@@ -524,7 +530,7 @@ function guardedTarget(decision, argv, store) {
524
530
  const chatId = policyFlagValue(argv, "--chat-id");
525
531
  const userId = policyFlagValue(argv, "--user-id");
526
532
  if (!chatId || userId) {
527
- throw new Error("Runtime +messages-send 必须只使用 Inbox 已确认的 --chat-id;--user-id 无法建立 freshness target");
533
+ throw new Error("Runtime +messages-send 必须使用已确认的 --chat-id;--user-id 无法建立受限 target binding");
528
534
  }
529
535
  return feishuImTarget(`chat:${chatId}`);
530
536
  }
@@ -533,7 +539,7 @@ function guardedTarget(decision, argv, store) {
533
539
  throw new Error(`${decision.operation} 写入缺少 --message-id`);
534
540
  const target = store.resolveInboxMessageTarget(messageId);
535
541
  if (!target)
536
- throw new Error(`无法从 Inbox 状态确定 ${messageId} 的 target;先 poll 对应消息,禁止旁路 freshness`);
542
+ throw new Error(`无法从 Inbox 状态确定 ${messageId} 的 target;先 poll 对应消息,禁止旁路 target binding`);
537
543
  return feishuImTarget(target);
538
544
  }
539
545
  function recallLedgerEntry(store, messageId) {
@@ -889,22 +895,62 @@ function intentId(target, argv) {
889
895
  .update(JSON.stringify([target, argv])).digest("hex");
890
896
  return `larkin-${fingerprint.slice(0, 32)}`;
891
897
  }
892
- function emitFreshnessError(io, input) {
898
+ function emitFreshnessNotice(io, input) {
893
899
  io.stderr(`${JSON.stringify({
894
- ok: false,
895
- identity: "bot",
896
- error: { type: input.subtype === "freshness_conflict" ? "conflict" : "unavailable", subtype: input.subtype,
897
- ...(input.reason ? { message: input.reason } : {}) },
900
+ larkin_notice: "freshness",
898
901
  target: input.target,
899
- ...(input.current ? { current_cursor: input.current } : {}),
900
- ...(input.messages ? { unseen_messages: input.messages } : {}),
901
- next: "Reconsider the returned context, then retry the ordinary send/reply/card/recall command; history is probed again before every write.",
902
+ newer_messages: input.messages.length,
903
+ latest_message_ids: input.messages.map((message) => message.message_id),
904
+ hint: "Newer messages exist. Re-read this target with larkin and send a short follow-up only if they change your answer. Do not resend the same content.",
902
905
  })}\n`);
903
906
  }
904
907
  function freshnessGeneration(env) {
905
908
  return typeof env.LARKIN_RUNTIME_OBSERVATION_GENERATION === "string" && env.LARKIN_RUNTIME_OBSERVATION_GENERATION
906
909
  ? env.LARKIN_RUNTIME_OBSERVATION_GENERATION : "external";
907
910
  }
911
+ // The post-write observation is advisory: cap it tightly so success is not
912
+ // held hostage by a slow provider history response.
913
+ const FRESHNESS_PROBE_TIMEOUT_MS = 500;
914
+ function emitSoftFreshnessNotice(target, targetKey, seen, ownMessageId, env, io, dependencies, store) {
915
+ let timedOut = false;
916
+ try {
917
+ const gated = evaluateFreshness({
918
+ seen,
919
+ adapter: feishuImFreshnessAdapter,
920
+ probe: () => {
921
+ const result = callNative(probeArgv(target), env, io, dependencies, FRESHNESS_PROBE_TIMEOUT_MS);
922
+ const error = result.error;
923
+ timedOut = error?.code === "ETIMEDOUT"
924
+ || error?.message.includes("ETIMEDOUT") === true
925
+ || (result.status === null && result.signal === "SIGTERM");
926
+ return parseHistory(result, target, true);
927
+ },
928
+ });
929
+ if (gated.status === "unavailable") {
930
+ if (timedOut)
931
+ io.stderr(`lark-cli: freshness observation timed out after ${FRESHNESS_PROBE_TIMEOUT_MS}ms; no read position was recorded\n`);
932
+ return;
933
+ }
934
+ if (!seen && gated.status === "fresh" && gated.current) {
935
+ // A first authoritative observation establishes a quiet baseline from
936
+ // the provider's latest 20-message window, including messages that
937
+ // arrived while this write was in flight. This does not claim the Agent
938
+ // read those messages; it only enables change detection on later writes.
939
+ store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, freshnessGeneration(env));
940
+ }
941
+ if (gated.status !== "conflict")
942
+ return;
943
+ store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, freshnessGeneration(env));
944
+ const newer = gated.context.filter((message) => message.message_id !== ownMessageId);
945
+ if (newer.length)
946
+ emitFreshnessNotice(io, { target: targetKey, messages: newer });
947
+ }
948
+ catch {
949
+ // freshness 仅作提示:观察失败绝不能改变已完成写入的结果。
950
+ if (timedOut)
951
+ io.stderr(`lark-cli: freshness observation timed out after ${FRESHNESS_PROBE_TIMEOUT_MS}ms; no read position was recorded\n`);
952
+ }
953
+ }
908
954
  function rawFlagValue(argv, flag) {
909
955
  const nativeArgv = nativeArgvBeforeBoundary(argv);
910
956
  const direct = nativeArgv.indexOf(flag);
@@ -1066,23 +1112,6 @@ function writeResponseMessage(result) {
1066
1112
  return null;
1067
1113
  }
1068
1114
  }
1069
- function observeSuccessfulWrite(result, target, targetKey, store, generation) {
1070
- if (result.error || result.status !== 0)
1071
- return false;
1072
- try {
1073
- const candidate = writeResponseMessage(result);
1074
- if (!candidate)
1075
- return false;
1076
- const snapshot = parseHistory({ ...result, stdout: JSON.stringify({ ok: true, data: { messages: [candidate] } }) }, target);
1077
- const cursor = feishuImFreshnessAdapter.cursor(snapshot);
1078
- if (cursor)
1079
- store.mergeFreshnessCursor(targetKey, cursor, mergeFeishuImCursor, generation);
1080
- return cursor !== null;
1081
- }
1082
- catch {
1083
- return false;
1084
- }
1085
- }
1086
1115
  function emitNativeResult(result, io) {
1087
1116
  if (result.stdout)
1088
1117
  io.stdout(result.stdout);
@@ -1125,35 +1154,6 @@ function emitDuplicatedRecall(io, messageId, target) {
1125
1154
  })}\n`);
1126
1155
  return 0;
1127
1156
  }
1128
- function emitCommittedUnverified(result, io, input) {
1129
- let providerResponse;
1130
- try {
1131
- providerResponse = JSON.parse(result.stdout || "");
1132
- }
1133
- catch {
1134
- providerResponse = { raw_stdout: result.stdout || "" };
1135
- }
1136
- const providerDocument = providerResponse && typeof providerResponse === "object" && !Array.isArray(providerResponse)
1137
- ? providerResponse
1138
- : { provider_response: providerResponse };
1139
- io.stdout(`${JSON.stringify({
1140
- ...providerDocument,
1141
- ok: true,
1142
- committed: true,
1143
- verified: false,
1144
- cursor_advanced: false,
1145
- target: input.target,
1146
- verification: {
1147
- status: "unverified",
1148
- subtype: "post_write_unverified",
1149
- message: input.reason,
1150
- ...(input.current ? { current_cursor: input.current } : {}),
1151
- ...(input.messages ? { unseen_messages: input.messages } : {}),
1152
- },
1153
- ...(result.stderr ? { provider_stderr_present: true } : {}),
1154
- })}\n`);
1155
- return 0;
1156
- }
1157
1157
  export function runLarkCli(argv, env = process.env, dependencies = {}) {
1158
1158
  const io = dependencies.io ?? defaultIo();
1159
1159
  let effectiveArgv = argv;
@@ -1235,20 +1235,6 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1235
1235
  const targetKey = serializeFeishuImTarget(target);
1236
1236
  const generation = freshnessGeneration(privateEnv);
1237
1237
  const seen = store.readFreshnessCursor(targetKey, generation);
1238
- const gated = evaluateFreshness({
1239
- seen,
1240
- adapter: feishuImFreshnessAdapter,
1241
- probe: () => parseHistory(callNative(probeArgv(target), privateEnv, io, nativeDependencies), target, true),
1242
- });
1243
- if (gated.status === "unavailable") {
1244
- emitFreshnessError(io, { subtype: "freshness_unavailable", target: targetKey, reason: gated.reason });
1245
- return 3;
1246
- }
1247
- if (gated.status === "conflict") {
1248
- emitFreshnessError(io, { subtype: "freshness_conflict", target: targetKey, current: gated.current, messages: gated.context });
1249
- store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, generation);
1250
- return 3;
1251
- }
1252
1238
  if (decision.operation === "recall") {
1253
1239
  const claim = claimRecall(store, recallMessageId, targetKey);
1254
1240
  if (claim === "deleted")
@@ -1258,16 +1244,15 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1258
1244
  }
1259
1245
  const recallWrite = callNative(botArgv(effectiveArgv, "", decision), privateEnv, io, nativeDependencies);
1260
1246
  if (!recallWrite.error && recallWrite.status === 0) {
1261
- const predictedSnapshot = {
1262
- messages: gated.snapshot.messages.filter((message) => message.message_id !== recallMessageId),
1263
- };
1264
- const predictedCursor = feishuImFreshnessAdapter.cursor(predictedSnapshot);
1265
- finalizeSuccessfulRecall(store, recallMessageId, targetKey, predictedCursor, generation);
1247
+ finalizeSuccessfulRecall(store, recallMessageId, targetKey, null, generation);
1266
1248
  }
1267
1249
  else if (definitiveProviderRejection(recallWrite)) {
1268
1250
  finalizeFailedRecall(store, recallMessageId, targetKey);
1269
1251
  }
1270
- return emitNativeResult(recallWrite, io);
1252
+ const code = emitNativeResult(recallWrite, io);
1253
+ if (code === 0)
1254
+ emitSoftFreshnessNotice(target, targetKey, seen, undefined, privateEnv, io, nativeDependencies, store);
1255
+ return code;
1271
1256
  }
1272
1257
  if (decision.operation === "urgent-app") {
1273
1258
  const userIds = assertUrgentAppPreconditions(effectiveArgv, urgent.message, target, agent.feishuAppId);
@@ -1291,7 +1276,10 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1291
1276
  })}\n`);
1292
1277
  return 2;
1293
1278
  }
1294
- return emitNativeResult(write, io);
1279
+ const code = emitNativeResult(write, io);
1280
+ if (code === 0)
1281
+ emitSoftFreshnessNotice(target, targetKey, seen, writeResponseMessage(write)?.message_id, privateEnv, io, nativeDependencies, store);
1282
+ return code;
1295
1283
  }
1296
1284
  const writeMessage = writeResponseMessage(write);
1297
1285
  const deliveryTarget = decision.operation === "send"
@@ -1331,40 +1319,10 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
1331
1319
  io.stderr(`lark-cli: reminder delivery audit failed: ${error instanceof Error ? error.message : String(error)}\n`);
1332
1320
  }
1333
1321
  const duplicate = memo.duplicate;
1334
- if (duplicate) {
1335
- observeSuccessfulWrite(write, target, targetKey, store, generation);
1336
- emitDuplicatedWrite(write, io, { target: targetKey });
1337
- return 0;
1338
- }
1339
- if (!write.error && write.status === 0 && !observeSuccessfulWrite(write, target, targetKey, store, generation)) {
1340
- const responseMessage = writeResponseMessage(write);
1341
- try {
1342
- const postSnapshot = parseHistory(callNative(probeArgv(target), privateEnv, io, nativeDependencies), target, true);
1343
- const postCursor = feishuImFreshnessAdapter.cursor(postSnapshot);
1344
- const unseenAfterWrite = feishuImFreshnessAdapter.unseen(gated.current, postSnapshot);
1345
- const confirmedOwnWrite = responseMessage && postCursor
1346
- && unseenAfterWrite.some((message) => message.message_id === responseMessage.message_id)
1347
- && unseenAfterWrite.every((message) => message.message_id === responseMessage.message_id);
1348
- if (!confirmedOwnWrite) {
1349
- return emitCommittedUnverified(write, io, {
1350
- target: targetKey,
1351
- ...(postCursor ? { current: postCursor } : {}),
1352
- messages: unseenAfterWrite,
1353
- reason: responseMessage
1354
- ? "provider write succeeded but bounded post-write probe found an additional concurrent update; cursor was not advanced"
1355
- : "provider write succeeded without a message id/revision and bounded post-write probe could not identify the write; cursor was not advanced",
1356
- });
1357
- }
1358
- store.mergeFreshnessCursor(targetKey, postCursor, mergeFeishuImCursor, generation);
1359
- }
1360
- catch {
1361
- return emitCommittedUnverified(write, io, {
1362
- target: targetKey,
1363
- reason: "provider write succeeded but bounded post-write confirmation was unavailable; cursor was not advanced",
1364
- });
1365
- }
1366
- }
1367
- return emitNativeResult(write, io);
1322
+ const code = duplicate ? emitDuplicatedWrite(write, io, { target: targetKey }) : emitNativeResult(write, io);
1323
+ if (code === 0)
1324
+ emitSoftFreshnessNotice(target, targetKey, seen, writeMessage?.message_id, privateEnv, io, nativeDependencies, store);
1325
+ return code;
1368
1326
  }
1369
1327
  catch (error) {
1370
1328
  io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
package/dist/app/lark.mjs CHANGED
@@ -4,6 +4,20 @@ import { spawn } from "node:child_process";
4
4
  import * as larkinConfig from "../platform/config.mjs";
5
5
  import { assessPassthrough, PASSTHROUGH_USAGE } from "../feishu/lark-passthrough.mjs";
6
6
  import { managedOfficialLarkCli } from "./agent-lark-cli-workspace.mjs";
7
+ import { isLarkinRuntimeContext } from "./runtime-context.mjs";
8
+ const runtimeScopedCommands = new Set(["inbox", "reminder", "interaction", "profile", "comment"]);
9
+ const requestedCommand = process.argv.slice(2).find((argument) => !argument.startsWith("-"));
10
+ const runtimeContext = isLarkinRuntimeContext(process.env);
11
+ if (!process.env.LARKIN_AGENT_ID && runtimeContext) {
12
+ console.error(`larkin: Runtime Agent authority is missing for "${requestedCommand ?? "lark-cli"}" (LARKIN_AGENT_ID is required). `
13
+ + "Runtime commands cannot fall back to the active Agent.");
14
+ process.exit(2);
15
+ }
16
+ if (!process.env.LARKIN_AGENT_ID && requestedCommand && runtimeScopedCommands.has(requestedCommand)) {
17
+ console.error(`larkin: Runtime Agent authority is missing for "${requestedCommand}" (LARKIN_AGENT_ID is required). `
18
+ + "Agent-scoped commands cannot fall back to the active Agent.");
19
+ process.exit(2);
20
+ }
7
21
  const decision = assessPassthrough(process.argv.slice(2), process.env);
8
22
  if (!decision.ok) {
9
23
  console.error(`larkin: ${decision.reason}`);
@@ -240,18 +240,29 @@ function runOfficialLarkCliAsync(command, args, env, dependencies) {
240
240
  });
241
241
  });
242
242
  }
243
- function expectedRuntimeCommandShim() {
243
+ function expectedRuntimeCommandShim(agent) {
244
244
  const standalone = process.env.LARKIN_STANDALONE === "1";
245
245
  const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
246
246
  const argumentsPrefix = standalone ? [] : [binaryEntry];
247
247
  const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
248
- return `#!/bin/sh\nexec ${command} "$@"\n`;
248
+ const stateDir = path.resolve(agent.stateDir);
249
+ const configDir = path.resolve(stateDir, "../../..");
250
+ return [
251
+ "#!/bin/sh",
252
+ "export LARKIN_RUNTIME=1",
253
+ `export LARKIN_AGENT_ID=${shellQuote(agent.agentId)}`,
254
+ `export LARKIN_STATE_DIR=${shellQuote(stateDir)}`,
255
+ `export LARKIN_CONFIG_DIR=${shellQuote(configDir)}`,
256
+ `export LARKIN_HOME=${shellQuote(configDir)}`,
257
+ `exec ${command} "$@"`,
258
+ "",
259
+ ].join("\n");
249
260
  }
250
261
  export function installRuntimeCommandShims(agent) {
251
262
  const stateDir = path.resolve(agent.stateDir);
252
263
  const commandDir = path.join(stateDir, "runtime-bin");
253
264
  assertSecureRuntimeCommandDirectory(commandDir);
254
- const fileContents = expectedRuntimeCommandShim();
265
+ const fileContents = expectedRuntimeCommandShim(agent);
255
266
  for (const name of ["larkin"]) {
256
267
  const file = path.join(commandDir, name);
257
268
  const temporary = path.join(commandDir, `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`);
@@ -274,7 +285,7 @@ function validateRuntimeCommandShims(agent) {
274
285
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
275
286
  || (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
276
287
  throw new Error("Runtime command shim 不安全");
277
- if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim()) {
288
+ if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim(agent)) {
278
289
  throw new Error("Runtime command shim 内容无效");
279
290
  }
280
291
  }
@@ -0,0 +1,17 @@
1
+ function hasRuntimeBin(pathValue) {
2
+ if (!pathValue)
3
+ return false;
4
+ return pathValue.split(process.platform === "win32" ? ";" : ":")
5
+ .some((entry) => /(?:^|[\\/])runtime-bin$/.test(entry));
6
+ }
7
+ /**
8
+ * Runtime markers are deliberately independent of LARKIN_AGENT_ID. They keep a
9
+ * damaged Runtime environment from falling through to a terminal's activeAgent
10
+ * selection when its per-Agent identity has been stripped.
11
+ */
12
+ export function isLarkinRuntimeContext(env = process.env) {
13
+ return env.LARKIN_RUNTIME === "1"
14
+ || Boolean(env.LARKIN_RUNTIME_OBSERVATION_GENERATION)
15
+ || Boolean(env.LARKIN_STATE_DIR)
16
+ || hasRuntimeBin(env.PATH);
17
+ }
@@ -60,7 +60,7 @@ exports.feishuImFreshnessAdapter = {
60
60
  if (!current)
61
61
  return seen ? "gap" : "fresh";
62
62
  if (!seen)
63
- return "conflict";
63
+ return "fresh";
64
64
  const seenTime = integerMillis(seen.revisionTime);
65
65
  const currentTime = integerMillis(current.revisionTime);
66
66
  if (seen.schema !== 1 || seenTime === null || currentTime === null)
@@ -74,7 +74,7 @@ exports.feishuImFreshnessAdapter = {
74
74
  unseen(seen, snapshot) {
75
75
  const messages = normalizedMessages(snapshot);
76
76
  if (!seen)
77
- return messages.map(({ revision: _revision, ...message }) => message);
77
+ return [];
78
78
  const seenTime = integerMillis(seen.revisionTime);
79
79
  if (seenTime === null)
80
80
  return messages.map(({ revision: _revision, ...message }) => message);
@@ -53,7 +53,7 @@ export const feishuImFreshnessAdapter = {
53
53
  if (!current)
54
54
  return seen ? "gap" : "fresh";
55
55
  if (!seen)
56
- return "conflict";
56
+ return "fresh";
57
57
  const seenTime = integerMillis(seen.revisionTime);
58
58
  const currentTime = integerMillis(current.revisionTime);
59
59
  if (seen.schema !== 1 || seenTime === null || currentTime === null)
@@ -67,7 +67,7 @@ export const feishuImFreshnessAdapter = {
67
67
  unseen(seen, snapshot) {
68
68
  const messages = normalizedMessages(snapshot);
69
69
  if (!seen)
70
- return messages.map(({ revision: _revision, ...message }) => message);
70
+ return [];
71
71
  const seenTime = integerMillis(seen.revisionTime);
72
72
  if (seenTime === null)
73
73
  return messages.map(({ revision: _revision, ...message }) => message);
@@ -308,6 +308,16 @@ function createRuntimeHost(options) {
308
308
  maxAttempts: options.retryPolicy?.maxAttempts ?? 6,
309
309
  stableWindowMs: options.retryPolicy?.stableWindowMs ?? 30_000,
310
310
  };
311
+ const turnEndRetryPolicy = {
312
+ baseDelayMs: options.turnEndRetryPolicy?.baseDelayMs ?? 1_000,
313
+ maxDelayMs: options.turnEndRetryPolicy?.maxDelayMs ?? 30_000,
314
+ maxAttempts: options.turnEndRetryPolicy?.maxAttempts ?? 3,
315
+ };
316
+ const turnEndRetryScheduler = options.turnEndRetryScheduler ?? {
317
+ now: Date.now,
318
+ setTimeout,
319
+ clearTimeout,
320
+ };
311
321
  const emit = (event) => {
312
322
  if (event.type === "delivery")
313
323
  telemetry?.delivery(event.agentId, event.messageId, event.status);
@@ -316,6 +326,7 @@ function createRuntimeHost(options) {
316
326
  };
317
327
  const runtimeEnv = (config, generation) => {
318
328
  const base = {
329
+ LARKIN_RUNTIME: "1",
319
330
  LARKIN_AGENT_ID: config.agentId,
320
331
  ...(generation ? {
321
332
  LARKIN_RUNTIME_OBSERVATION_GENERATION: generation,
@@ -361,6 +372,8 @@ function createRuntimeHost(options) {
361
372
  record.updatedAt = now();
362
373
  agent.records.set(record.deliveryId, record);
363
374
  agent.byMessage.set(record.messageId, record.deliveryId);
375
+ if (status === "consumed" || status === "error")
376
+ clearPendingRetryTimerWhenIdle(agent);
364
377
  emitConsumed(agent, persist(agent));
365
378
  return agent.records.get(record.deliveryId) ?? record;
366
379
  };
@@ -466,8 +479,42 @@ function createRuntimeHost(options) {
466
479
  emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: next.deliveryId, messageId: next.messageId, status: "consumed" });
467
480
  }
468
481
  }
482
+ clearPendingRetryTimerWhenIdle(agent);
469
483
  };
470
484
  const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
485
+ const turnEndRetryDelayMs = (attempt) => Math.min(turnEndRetryPolicy.maxDelayMs, turnEndRetryPolicy.baseDelayMs * 2 ** (attempt - 1));
486
+ const isRetryDue = (record) => !record.retryNotBefore || Number.isNaN(Date.parse(record.retryNotBefore))
487
+ || Date.parse(record.retryNotBefore) <= turnEndRetryScheduler.now();
488
+ const clearDeliveryRetryTimer = (agent) => {
489
+ if (!agent.deliveryRetryTimer)
490
+ return;
491
+ turnEndRetryScheduler.clearTimeout(agent.deliveryRetryTimer);
492
+ agent.deliveryRetryTimer = null;
493
+ };
494
+ function clearPendingRetryTimerWhenIdle(agent) {
495
+ if (!agent.deliveryRetryTimer)
496
+ return;
497
+ if ([...agent.records.values()].some((record) => record.status === "pending" && record.retryNotBefore))
498
+ return;
499
+ clearDeliveryRetryTimer(agent);
500
+ }
501
+ const schedulePendingRetry = (agent) => {
502
+ if (agent.stopped || agent.deliveryRetryTimer)
503
+ return;
504
+ const next = [...agent.records.values()]
505
+ .filter((record) => record.status === "pending" && record.retryNotBefore)
506
+ .map((record) => Date.parse(record.retryNotBefore))
507
+ .filter((at) => Number.isFinite(at))
508
+ .sort((left, right) => left - right)[0];
509
+ if (next === undefined)
510
+ return;
511
+ const delay = Math.max(0, next - turnEndRetryScheduler.now());
512
+ agent.deliveryRetryTimer = turnEndRetryScheduler.setTimeout(() => {
513
+ agent.deliveryRetryTimer = null;
514
+ void retryPending(agent);
515
+ }, delay);
516
+ agent.deliveryRetryTimer.unref?.();
517
+ };
471
518
  const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
472
519
  const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
473
520
  const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
@@ -495,13 +542,32 @@ function createRuntimeHost(options) {
495
542
  for (const record of agent.records.values()) {
496
543
  if (record.status !== "accepted")
497
544
  continue;
498
- emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
499
- messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
500
- }
545
+ const attempts = record.turnEndRetryAttempts ?? 0;
546
+ if (attempts >= turnEndRetryPolicy.maxAttempts) {
547
+ record.reason = `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`;
548
+ record.retryable = false;
549
+ const terminal = setRecord(agent, record, "error");
550
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: terminal.deliveryId,
551
+ messageId: terminal.messageId, status: "error", reason: terminal.reason });
552
+ continue;
553
+ }
554
+ const attempt = attempts + 1;
555
+ const delay = turnEndRetryDelayMs(attempt);
556
+ record.turnEndRetryAttempts = attempt;
557
+ record.retryNotBefore = new Date(turnEndRetryScheduler.now() + delay).toISOString();
558
+ record.reason = TURN_END_RETRY_REASON;
559
+ record.retryable = true;
560
+ record.input = { ...record.input, attempt: (record.input.attempt ?? 0) + 1 };
561
+ const deferred = setRecord(agent, record, "pending");
562
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: deferred.deliveryId,
563
+ messageId: deferred.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
564
+ }
565
+ schedulePendingRetry(agent);
501
566
  return;
502
567
  }
503
568
  const consumed = [];
504
569
  const deferred = [];
570
+ const exhausted = [];
505
571
  try {
506
572
  store.withInboxTransaction(() => {
507
573
  const inboxIds = new Set(store.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
@@ -522,8 +588,31 @@ function createRuntimeHost(options) {
522
588
  if (current.status !== "accepted" || candidate.status !== "accepted" || !inboxIds.has(candidate.messageId)) {
523
589
  return candidate;
524
590
  }
525
- const next = { ...candidate, status: "pending", updatedAt: now(),
526
- reason: TURN_END_RETRY_REASON, retryable: true };
591
+ const attempts = candidate.turnEndRetryAttempts ?? 0;
592
+ if (attempts >= turnEndRetryPolicy.maxAttempts) {
593
+ const terminal = { ...candidate, status: "error", updatedAt: now(),
594
+ reason: `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`,
595
+ retryable: false };
596
+ changed = true;
597
+ pendingUpdates.push(terminal);
598
+ return terminal;
599
+ }
600
+ const attempt = attempts + 1;
601
+ const delay = turnEndRetryDelayMs(attempt);
602
+ const staleInput = candidate.input && typeof candidate.input === "object" ? candidate.input : null;
603
+ const next = {
604
+ ...candidate,
605
+ status: "pending",
606
+ updatedAt: now(),
607
+ reason: TURN_END_RETRY_REASON,
608
+ retryable: true,
609
+ turnEndRetryAttempts: attempt,
610
+ retryNotBefore: new Date(turnEndRetryScheduler.now() + delay).toISOString(),
611
+ input: {
612
+ ...(staleInput ?? { inputId: candidate.deliveryId, deliveryId: candidate.deliveryId, kind: "wake", text: "" }),
613
+ attempt: (Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0) + 1,
614
+ },
615
+ };
527
616
  changed = true;
528
617
  pendingUpdates.push(next);
529
618
  return next;
@@ -532,7 +621,10 @@ function createRuntimeHost(options) {
532
621
  store.writeJson("runtimeDeliveries", { ...disk, records });
533
622
  for (const next of pendingUpdates) {
534
623
  agent.records.set(next.deliveryId, next);
535
- deferred.push(next);
624
+ if (next.status === "error")
625
+ exhausted.push(next);
626
+ else
627
+ deferred.push(next);
536
628
  }
537
629
  }
538
630
  });
@@ -545,6 +637,11 @@ function createRuntimeHost(options) {
545
637
  for (const record of deferred)
546
638
  emit({ type: "delivery", agentId: agent.config.agentId,
547
639
  deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
640
+ for (const record of exhausted)
641
+ emit({ type: "delivery", agentId: agent.config.agentId,
642
+ deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: record.reason });
643
+ if (deferred.length)
644
+ schedulePendingRetry(agent);
548
645
  };
549
646
  // Production callers persist the canonical Inbox before calling deliver(). If a
550
647
  // concurrent drain wins between that append and ledger creation, close only the
@@ -743,9 +840,11 @@ function createRuntimeHost(options) {
743
840
  if (idleGate)
744
841
  await idleGate;
745
842
  }
746
- const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
843
+ const record = [...agent.records.values()].find((candidate) => (candidate.status === "pending" && isRetryDue(candidate)) || candidate.status === "submitting");
747
844
  if (record)
748
845
  await submit(agent, record, false);
846
+ else
847
+ schedulePendingRetry(agent);
749
848
  })();
750
849
  agent.retryPendingInFlight = run;
751
850
  try {
@@ -1519,6 +1618,8 @@ function createRuntimeHost(options) {
1519
1618
  clearInterval(previous.poller);
1520
1619
  if (previous.retryTimer)
1521
1620
  clearTimeout(previous.retryTimer);
1621
+ if (previous.deliveryRetryTimer)
1622
+ turnEndRetryScheduler.clearTimeout(previous.deliveryRetryTimer);
1522
1623
  if (previous.stabilityTimer)
1523
1624
  clearTimeout(previous.stabilityTimer);
1524
1625
  const previousFailure = currentAuthFailure(previous);
@@ -1530,7 +1631,7 @@ function createRuntimeHost(options) {
1530
1631
  }, previousFailure));
1531
1632
  const candidate = {
1532
1633
  ...previous, config, adapter, session, launchId: node_crypto_1.default.randomUUID(), busy: false, submitting: false,
1533
- starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null,
1634
+ starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null,
1534
1635
  recreateAttempts: 0, stabilityTimer: null, recreateReason: null, stopped: false,
1535
1636
  disabledReason: null, configurationRecovery: null,
1536
1637
  authFailureActive: scoped && previous.authFailureActive,
@@ -1623,6 +1724,7 @@ function createRuntimeHost(options) {
1623
1724
  if (pendingAfterCreate > 0) {
1624
1725
  throw new RuntimeSessionResetError("inbox_backlog", `Agent ${agentId} received Inbox backlog during reset`, pendingAfterCreate);
1625
1726
  }
1727
+ clearDeliveryRetryTimer(agent);
1626
1728
  agent.generation += 1;
1627
1729
  agent.launchId = node_crypto_1.default.randomUUID();
1628
1730
  agent.config.sessionId = null;
@@ -1893,7 +1995,7 @@ function createRuntimeHost(options) {
1893
1995
  retryPendingInFlight: null,
1894
1996
  records: new Map(persisted.records.map((record) => [record.deliveryId, record])),
1895
1997
  byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
1896
- generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
1998
+ generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null, recreateAttempts: 0,
1897
1999
  stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
1898
2000
  configurationRecovery: null, stateStore,
1899
2001
  compactionBreaker: config.runtime === "pi" && stateStore
@@ -2065,6 +2167,7 @@ function createRuntimeHost(options) {
2065
2167
  clearInterval(agent.poller);
2066
2168
  if (agent.retryTimer)
2067
2169
  clearTimeout(agent.retryTimer);
2170
+ clearDeliveryRetryTimer(agent);
2068
2171
  if (agent.stabilityTimer)
2069
2172
  clearTimeout(agent.stabilityTimer);
2070
2173
  if (agent.busy)
@@ -299,6 +299,16 @@ export function createRuntimeHost(options) {
299
299
  maxAttempts: options.retryPolicy?.maxAttempts ?? 6,
300
300
  stableWindowMs: options.retryPolicy?.stableWindowMs ?? 30_000,
301
301
  };
302
+ const turnEndRetryPolicy = {
303
+ baseDelayMs: options.turnEndRetryPolicy?.baseDelayMs ?? 1_000,
304
+ maxDelayMs: options.turnEndRetryPolicy?.maxDelayMs ?? 30_000,
305
+ maxAttempts: options.turnEndRetryPolicy?.maxAttempts ?? 3,
306
+ };
307
+ const turnEndRetryScheduler = options.turnEndRetryScheduler ?? {
308
+ now: Date.now,
309
+ setTimeout,
310
+ clearTimeout,
311
+ };
302
312
  const emit = (event) => {
303
313
  if (event.type === "delivery")
304
314
  telemetry?.delivery(event.agentId, event.messageId, event.status);
@@ -307,6 +317,7 @@ export function createRuntimeHost(options) {
307
317
  };
308
318
  const runtimeEnv = (config, generation) => {
309
319
  const base = {
320
+ LARKIN_RUNTIME: "1",
310
321
  LARKIN_AGENT_ID: config.agentId,
311
322
  ...(generation ? {
312
323
  LARKIN_RUNTIME_OBSERVATION_GENERATION: generation,
@@ -352,6 +363,8 @@ export function createRuntimeHost(options) {
352
363
  record.updatedAt = now();
353
364
  agent.records.set(record.deliveryId, record);
354
365
  agent.byMessage.set(record.messageId, record.deliveryId);
366
+ if (status === "consumed" || status === "error")
367
+ clearPendingRetryTimerWhenIdle(agent);
355
368
  emitConsumed(agent, persist(agent));
356
369
  return agent.records.get(record.deliveryId) ?? record;
357
370
  };
@@ -457,8 +470,42 @@ export function createRuntimeHost(options) {
457
470
  emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: next.deliveryId, messageId: next.messageId, status: "consumed" });
458
471
  }
459
472
  }
473
+ clearPendingRetryTimerWhenIdle(agent);
460
474
  };
461
475
  const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
476
+ const turnEndRetryDelayMs = (attempt) => Math.min(turnEndRetryPolicy.maxDelayMs, turnEndRetryPolicy.baseDelayMs * 2 ** (attempt - 1));
477
+ const isRetryDue = (record) => !record.retryNotBefore || Number.isNaN(Date.parse(record.retryNotBefore))
478
+ || Date.parse(record.retryNotBefore) <= turnEndRetryScheduler.now();
479
+ const clearDeliveryRetryTimer = (agent) => {
480
+ if (!agent.deliveryRetryTimer)
481
+ return;
482
+ turnEndRetryScheduler.clearTimeout(agent.deliveryRetryTimer);
483
+ agent.deliveryRetryTimer = null;
484
+ };
485
+ function clearPendingRetryTimerWhenIdle(agent) {
486
+ if (!agent.deliveryRetryTimer)
487
+ return;
488
+ if ([...agent.records.values()].some((record) => record.status === "pending" && record.retryNotBefore))
489
+ return;
490
+ clearDeliveryRetryTimer(agent);
491
+ }
492
+ const schedulePendingRetry = (agent) => {
493
+ if (agent.stopped || agent.deliveryRetryTimer)
494
+ return;
495
+ const next = [...agent.records.values()]
496
+ .filter((record) => record.status === "pending" && record.retryNotBefore)
497
+ .map((record) => Date.parse(record.retryNotBefore))
498
+ .filter((at) => Number.isFinite(at))
499
+ .sort((left, right) => left - right)[0];
500
+ if (next === undefined)
501
+ return;
502
+ const delay = Math.max(0, next - turnEndRetryScheduler.now());
503
+ agent.deliveryRetryTimer = turnEndRetryScheduler.setTimeout(() => {
504
+ agent.deliveryRetryTimer = null;
505
+ void retryPending(agent);
506
+ }, delay);
507
+ agent.deliveryRetryTimer.unref?.();
508
+ };
462
509
  const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
463
510
  const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
464
511
  const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
@@ -486,13 +533,32 @@ export function createRuntimeHost(options) {
486
533
  for (const record of agent.records.values()) {
487
534
  if (record.status !== "accepted")
488
535
  continue;
489
- emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: record.deliveryId,
490
- messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
491
- }
536
+ const attempts = record.turnEndRetryAttempts ?? 0;
537
+ if (attempts >= turnEndRetryPolicy.maxAttempts) {
538
+ record.reason = `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`;
539
+ record.retryable = false;
540
+ const terminal = setRecord(agent, record, "error");
541
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: terminal.deliveryId,
542
+ messageId: terminal.messageId, status: "error", reason: terminal.reason });
543
+ continue;
544
+ }
545
+ const attempt = attempts + 1;
546
+ const delay = turnEndRetryDelayMs(attempt);
547
+ record.turnEndRetryAttempts = attempt;
548
+ record.retryNotBefore = new Date(turnEndRetryScheduler.now() + delay).toISOString();
549
+ record.reason = TURN_END_RETRY_REASON;
550
+ record.retryable = true;
551
+ record.input = { ...record.input, attempt: (record.input.attempt ?? 0) + 1 };
552
+ const deferred = setRecord(agent, record, "pending");
553
+ emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: deferred.deliveryId,
554
+ messageId: deferred.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
555
+ }
556
+ schedulePendingRetry(agent);
492
557
  return;
493
558
  }
494
559
  const consumed = [];
495
560
  const deferred = [];
561
+ const exhausted = [];
496
562
  try {
497
563
  store.withInboxTransaction(() => {
498
564
  const inboxIds = new Set(store.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
@@ -513,8 +579,31 @@ export function createRuntimeHost(options) {
513
579
  if (current.status !== "accepted" || candidate.status !== "accepted" || !inboxIds.has(candidate.messageId)) {
514
580
  return candidate;
515
581
  }
516
- const next = { ...candidate, status: "pending", updatedAt: now(),
517
- reason: TURN_END_RETRY_REASON, retryable: true };
582
+ const attempts = candidate.turnEndRetryAttempts ?? 0;
583
+ if (attempts >= turnEndRetryPolicy.maxAttempts) {
584
+ const terminal = { ...candidate, status: "error", updatedAt: now(),
585
+ reason: `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`,
586
+ retryable: false };
587
+ changed = true;
588
+ pendingUpdates.push(terminal);
589
+ return terminal;
590
+ }
591
+ const attempt = attempts + 1;
592
+ const delay = turnEndRetryDelayMs(attempt);
593
+ const staleInput = candidate.input && typeof candidate.input === "object" ? candidate.input : null;
594
+ const next = {
595
+ ...candidate,
596
+ status: "pending",
597
+ updatedAt: now(),
598
+ reason: TURN_END_RETRY_REASON,
599
+ retryable: true,
600
+ turnEndRetryAttempts: attempt,
601
+ retryNotBefore: new Date(turnEndRetryScheduler.now() + delay).toISOString(),
602
+ input: {
603
+ ...(staleInput ?? { inputId: candidate.deliveryId, deliveryId: candidate.deliveryId, kind: "wake", text: "" }),
604
+ attempt: (Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0) + 1,
605
+ },
606
+ };
518
607
  changed = true;
519
608
  pendingUpdates.push(next);
520
609
  return next;
@@ -523,7 +612,10 @@ export function createRuntimeHost(options) {
523
612
  store.writeJson("runtimeDeliveries", { ...disk, records });
524
613
  for (const next of pendingUpdates) {
525
614
  agent.records.set(next.deliveryId, next);
526
- deferred.push(next);
615
+ if (next.status === "error")
616
+ exhausted.push(next);
617
+ else
618
+ deferred.push(next);
527
619
  }
528
620
  }
529
621
  });
@@ -536,6 +628,11 @@ export function createRuntimeHost(options) {
536
628
  for (const record of deferred)
537
629
  emit({ type: "delivery", agentId: agent.config.agentId,
538
630
  deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
631
+ for (const record of exhausted)
632
+ emit({ type: "delivery", agentId: agent.config.agentId,
633
+ deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: record.reason });
634
+ if (deferred.length)
635
+ schedulePendingRetry(agent);
539
636
  };
540
637
  // Production callers persist the canonical Inbox before calling deliver(). If a
541
638
  // concurrent drain wins between that append and ledger creation, close only the
@@ -734,9 +831,11 @@ export function createRuntimeHost(options) {
734
831
  if (idleGate)
735
832
  await idleGate;
736
833
  }
737
- const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
834
+ const record = [...agent.records.values()].find((candidate) => (candidate.status === "pending" && isRetryDue(candidate)) || candidate.status === "submitting");
738
835
  if (record)
739
836
  await submit(agent, record, false);
837
+ else
838
+ schedulePendingRetry(agent);
740
839
  })();
741
840
  agent.retryPendingInFlight = run;
742
841
  try {
@@ -1510,6 +1609,8 @@ export function createRuntimeHost(options) {
1510
1609
  clearInterval(previous.poller);
1511
1610
  if (previous.retryTimer)
1512
1611
  clearTimeout(previous.retryTimer);
1612
+ if (previous.deliveryRetryTimer)
1613
+ turnEndRetryScheduler.clearTimeout(previous.deliveryRetryTimer);
1513
1614
  if (previous.stabilityTimer)
1514
1615
  clearTimeout(previous.stabilityTimer);
1515
1616
  const previousFailure = currentAuthFailure(previous);
@@ -1521,7 +1622,7 @@ export function createRuntimeHost(options) {
1521
1622
  }, previousFailure));
1522
1623
  const candidate = {
1523
1624
  ...previous, config, adapter, session, launchId: crypto.randomUUID(), busy: false, submitting: false,
1524
- starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null,
1625
+ starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null,
1525
1626
  recreateAttempts: 0, stabilityTimer: null, recreateReason: null, stopped: false,
1526
1627
  disabledReason: null, configurationRecovery: null,
1527
1628
  authFailureActive: scoped && previous.authFailureActive,
@@ -1614,6 +1715,7 @@ export function createRuntimeHost(options) {
1614
1715
  if (pendingAfterCreate > 0) {
1615
1716
  throw new RuntimeSessionResetError("inbox_backlog", `Agent ${agentId} received Inbox backlog during reset`, pendingAfterCreate);
1616
1717
  }
1718
+ clearDeliveryRetryTimer(agent);
1617
1719
  agent.generation += 1;
1618
1720
  agent.launchId = crypto.randomUUID();
1619
1721
  agent.config.sessionId = null;
@@ -1884,7 +1986,7 @@ export function createRuntimeHost(options) {
1884
1986
  retryPendingInFlight: null,
1885
1987
  records: new Map(persisted.records.map((record) => [record.deliveryId, record])),
1886
1988
  byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
1887
- generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
1989
+ generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null, recreateAttempts: 0,
1888
1990
  stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
1889
1991
  configurationRecovery: null, stateStore,
1890
1992
  compactionBreaker: config.runtime === "pi" && stateStore
@@ -2056,6 +2158,7 @@ export function createRuntimeHost(options) {
2056
2158
  clearInterval(agent.poller);
2057
2159
  if (agent.retryTimer)
2058
2160
  clearTimeout(agent.retryTimer);
2161
+ clearDeliveryRetryTimer(agent);
2059
2162
  if (agent.stabilityTimer)
2060
2163
  clearTimeout(agent.stabilityTimer);
2061
2164
  if (agent.busy)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [