larkin 0.4.13 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/agent/agent-state-store.cjs +237 -0
  2. package/dist/agent/agent-state-store.mjs +238 -1
  3. package/dist/agent/context-prompt.cjs +3 -1
  4. package/dist/agent/context-prompt.mjs +3 -1
  5. package/dist/agent/host-reminder-orchestrator.cjs +134 -7
  6. package/dist/agent/host-reminder-orchestrator.mjs +134 -7
  7. package/dist/agent/inbox-projection.cjs +5 -0
  8. package/dist/agent/inbox-projection.mjs +4 -0
  9. package/dist/agent/reminder-delivery-audit.cjs +86 -0
  10. package/dist/agent/reminder-delivery-audit.mjs +50 -0
  11. package/dist/agent/reminder-routes.cjs +83 -3
  12. package/dist/agent/reminder-routes.mjs +83 -3
  13. package/dist/agent/reminder-store.cjs +5 -2
  14. package/dist/agent/reminder-store.mjs +5 -2
  15. package/dist/agent/transport-business-context.cjs +26 -2
  16. package/dist/agent/transport-business-context.mjs +26 -2
  17. package/dist/app/agent-cli.mjs +33 -2
  18. package/dist/app/lark-cli.mjs +79 -11
  19. package/dist/feishu/host-business-state.cjs +35 -14
  20. package/dist/feishu/host-business-state.mjs +35 -14
  21. package/dist/feishu/interaction-orchestrator.cjs +8 -1
  22. package/dist/feishu/interaction-orchestrator.mjs +8 -1
  23. package/dist/feishu/outbound-transport.cjs +37 -3
  24. package/dist/feishu/outbound-transport.mjs +37 -3
  25. package/dist/runtime/pi-inline-extensions.mjs +31 -2
  26. package/dist/runtime/pi-subagent-injection.mjs +50 -13
  27. package/dist/runtime/pi-subagents.bundle.js +57 -10
  28. package/dist/runtime/runtime-host.cjs +37 -0
  29. package/dist/runtime/runtime-host.mjs +37 -0
  30. package/package.json +5 -1
  31. package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +249 -0
@@ -302,31 +302,50 @@ export class HostEnvelopeProjector {
302
302
  }
303
303
  createReminderEnvelope(agentId, reminder, overdueMs, repeatDescription) {
304
304
  const seq = this.nextSequence(agentId);
305
- const anchorMessageId = typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
306
- ? reminder.msgRef
307
- : null;
305
+ const legacyChannel = typeof reminder.channel === "string" ? reminder.channel.trim() : "";
306
+ const legacyChatTarget = /^oc_[A-Za-z0-9_-]+$/.test(legacyChannel) ? `chat:${legacyChannel}` : null;
307
+ const deliveryTarget = typeof reminder.deliveryTarget === "string" && reminder.deliveryTarget
308
+ ? reminder.deliveryTarget : legacyChatTarget;
309
+ const anchorMessageId = typeof reminder.deliveryAnchor === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor)
310
+ ? reminder.deliveryAnchor
311
+ : typeof reminder.msgRef === "string" && /^om_[A-Za-z0-9_-]+$/.test(reminder.msgRef)
312
+ ? reminder.msgRef
313
+ : null;
314
+ const commentAnchorId = deliveryTarget?.startsWith("document-comment:") && typeof reminder.deliveryAnchor === "string"
315
+ && /^doc_comment_[A-Za-z0-9_-]+$/.test(reminder.deliveryAnchor) ? reminder.deliveryAnchor : null;
316
+ const anchorId = anchorMessageId || commentAnchorId;
317
+ const chatId = deliveryTarget?.match(/^chat:(oc_[A-Za-z0-9_-]+)$/)?.[1] || null;
308
318
  const lines = [
309
319
  `[定时提醒触发] ${reminder.title}`,
310
320
  `提醒ID: #${reminder.reminderId.slice(0, 8)}` + (reminder.repeat && repeatDescription
311
321
  ? ` 重复: ${repeatDescription}(下次已自动排在 ${reminder.fireAt})`
312
322
  : " 类型: 一次性"),
313
323
  overdueMs > 120_000 ? `注意: 原定时间已过 ${Math.round(overdueMs / 60_000)} 分钟(Runtime Host 离线期间错过,现补触发)` : null,
314
- anchorMessageId ? `锚定消息: ${anchorMessageId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是飞书 om_ message_id,不能用于回复` : null,
324
+ deliveryTarget ? `原始 deliveryTarget: ${deliveryTarget}`
325
+ : anchorId ? "这是升级前存量 user-facing reminder,优先回复其安全锚点;不得向标题中的任何人或第三方发送消息"
326
+ : "本条为 internal/no-delivery reminder,不得向标题中的任何人或第三方发送消息",
327
+ anchorId ? `锚定消息: ${anchorId}` : reminder.msgRef ? `历史锚点 ${String(reminder.msgRef)} 不是可用的 delivery anchor,不能用于回复` : null,
315
328
  anchorMessageId
316
- ? `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId} ...`)}`
317
- : null,
318
- reminder.channel && /^oc_[A-Za-z0-9_-]+$/.test(reminder.channel)
319
- ? `发送到原群: ${this.larkCommand(`im +messages-send --chat-id ${reminder.channel} ...`)}`
320
- : reminder.channel
321
- ? `历史目标 ${reminder.channel} 不是 chat_id;${anchorMessageId ? "若不回复锚定消息," : ""}先用 ${this.larkCommand("im +chat-search")} 查询并确认 oc_ chat_id,禁止按名称猜测发送目标`
322
- : anchorMessageId
323
- ? null
324
- : `本条存量提醒缺少可用的飞书 message_id/chat_id,无法安全推断原会话;请先用 ${this.larkCommand("im +chat-search")} 确认目标,禁止猜测发送`,
329
+ ? [
330
+ `回复原会话: ${this.larkCommand(`im +messages-reply --message-id ${anchorMessageId}${deliveryTarget?.startsWith("thread:") ? " --reply-in-thread" : ""} ...`)}`,
331
+ chatId ? `若 ${anchorMessageId} 无法由当前 Inbox 解析(例如 interaction_* 卡片锚点或已淘汰的历史消息),改用聊天兜底发送: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)};仅使用这个持久化 chat_id,不得猜测收件人` : null,
332
+ ].filter((line) => Boolean(line)).join("\n")
333
+ : commentAnchorId
334
+ ? `回复原文档评论: ${this.agentCommand(`comment reply --message-id ${commentAnchorId} --text ...`)}`
335
+ : deliveryTarget
336
+ ? chatId
337
+ ? `发送到原始 target: ${deliveryTarget}: ${this.larkCommand(`im +messages-send --chat-id ${chatId} ...`)}(不得从提醒标题推断收件人)`
338
+ : `发送到原始 target: ${deliveryTarget}(不得从提醒标题推断收件人)`
339
+ : null,
325
340
  `这是你之前用 ${this.agentCommand("reminder schedule")} 设置的提醒,请按标题执行相应动作。管理: ${this.agentCommand("reminder list")} / ${this.agentCommand("reminder snooze")} / ${this.agentCommand("reminder cancel")}`,
326
341
  ].filter((line) => Boolean(line));
327
342
  const envelope = {
328
343
  kind: "reminder",
329
- message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}`,
344
+ // The random suffix keeps each firing's occurrence identity unique across
345
+ // Host restarts: the in-memory seq restarts at 1 and would otherwise let a
346
+ // recurring reminder reuse a prior occurrenceId in the delivery audit.
347
+ message_id: `rem_${reminder.reminderId.slice(0, 16)}_${seq}_${this.randomHex(6)}`,
348
+ reminderId: reminder.reminderId,
330
349
  seq,
331
350
  sender_name: "定时提醒",
332
351
  sender_type: "system",
@@ -336,6 +355,8 @@ export class HostEnvelopeProjector {
336
355
  timestamp: this.now().toISOString(),
337
356
  thread_id: null,
338
357
  wake: true,
358
+ deliveryTarget,
359
+ deliveryAnchor: anchorId,
339
360
  };
340
361
  return { ...envelope, target: RUNTIME_REMINDER_TARGET };
341
362
  }
@@ -93,6 +93,8 @@ class HostInteractionOrchestrator {
93
93
  const reminderId = `int_${(0, node_crypto_1.createHash)("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
94
94
  const current = this.now();
95
95
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
96
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
97
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
96
98
  const reminder = ReminderStore.mutate(file, (store) => {
97
99
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
98
100
  if (existing)
@@ -107,6 +109,9 @@ class HostInteractionOrchestrator {
107
109
  status: "scheduled",
108
110
  msgRef: run.message_id,
109
111
  msgPermalink: null,
112
+ deliveryTarget,
113
+ deliveryAnchor,
114
+ deliveryMode: "user",
110
115
  repeat: null,
111
116
  tz: null,
112
117
  channel: run.chat_id,
@@ -114,7 +119,9 @@ class HostInteractionOrchestrator {
114
119
  version: 1,
115
120
  events: [],
116
121
  };
117
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
122
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
123
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
124
+ });
118
125
  store.reminders.push(created);
119
126
  return created;
120
127
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));
@@ -57,6 +57,8 @@ export class HostInteractionOrchestrator {
57
57
  const reminderId = `int_${createHash("sha256").update(run.run_id).digest("hex").slice(0, 24)}`;
58
58
  const current = this.now();
59
59
  const fireAt = effect.args.fire_at ? Date.parse(effect.args.fire_at) : current + Number(effect.args.delay_seconds) * 1_000;
60
+ const deliveryTarget = typeof run.chat_id === "string" && run.chat_id ? `chat:${run.chat_id}` : null;
61
+ const deliveryAnchor = typeof run.message_id === "string" && /^om_[A-Za-z0-9_-]+$/.test(run.message_id) ? run.message_id : null;
60
62
  const reminder = ReminderStore.mutate(file, (store) => {
61
63
  const existing = store.reminders.find((candidate) => candidate.reminderId === reminderId);
62
64
  if (existing)
@@ -71,6 +73,9 @@ export class HostInteractionOrchestrator {
71
73
  status: "scheduled",
72
74
  msgRef: run.message_id,
73
75
  msgPermalink: null,
76
+ deliveryTarget,
77
+ deliveryAnchor,
78
+ deliveryMode: "user",
74
79
  repeat: null,
75
80
  tz: null,
76
81
  channel: run.chat_id,
@@ -78,7 +83,9 @@ export class HostInteractionOrchestrator {
78
83
  version: 1,
79
84
  events: [],
80
85
  };
81
- ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current);
86
+ ReminderStore.appendEvent(created, "scheduled", "interaction", run.run_id, created.fireAt, current, {
87
+ deliveryTarget, deliveryAnchor, deliveryMode: "user",
88
+ });
82
89
  store.reminders.push(created);
83
90
  return created;
84
91
  }, Math.max(1, Math.min(350, deadline - this.now() - 100)));
@@ -155,16 +155,30 @@ function createOutboundTransport(options) {
155
155
  };
156
156
  const handleSend = (body) => {
157
157
  const target = body.target || "";
158
+ const targetKey = String(target);
159
+ const report = (succeeded, details = {}) => {
160
+ if (options.dryRun)
161
+ return;
162
+ try {
163
+ options.onDeliveryOutcome?.({ target: targetKey, succeeded, ...details });
164
+ }
165
+ catch (error) {
166
+ options.log("delivery outcome hook 失败:", error.message);
167
+ }
168
+ };
158
169
  const content = String(body.content ?? "").replace(/\n+$/, "");
159
170
  const chatId = options.resolveChatId(target);
160
171
  if (!chatId) {
161
- return { ok: false, status: 400, error: `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。` };
172
+ const error = `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。`;
173
+ report(false, { reason: error });
174
+ return { ok: false, status: 400, error };
162
175
  }
163
176
  const idempotencyKey = body.idempotencyKey
164
177
  || node_crypto_1.default.createHash("sha256").update(`${String(target)}\0${content}`).digest("hex").slice(0, 32);
165
178
  const replyContext = options.replyContextFor(String(target)) || options.replyContextFor(chatId);
166
179
  const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds.filter(Boolean) : [];
167
180
  let messageId = "sent";
181
+ let committedProviderCalls = 0;
168
182
  if (content !== "" || attachmentIds.length === 0) {
169
183
  const replyInThread = Boolean(replyContext?.in_topic && replyContext.reply_to);
170
184
  const result = replyInThread
@@ -172,7 +186,9 @@ function createOutboundTransport(options) {
172
186
  : options.sendText(chatId, content, idempotencyKey);
173
187
  if (result.code !== 0) {
174
188
  options.log("send 失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
175
- return { ok: false, status: 502, error: `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
189
+ const error = `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
190
+ report(false, { reason: error });
191
+ return { ok: false, status: 502, error };
176
192
  }
177
193
  try {
178
194
  const parsed = JSON.parse(result.stdout);
@@ -181,8 +197,10 @@ function createOutboundTransport(options) {
181
197
  catch {
182
198
  messageId = options.dryRun ? "dryrun" : "sent";
183
199
  }
200
+ committedProviderCalls += 1;
184
201
  options.log(`${replyInThread ? "reply-in-thread" : "send"} → chat=${chatId} len=${content.length} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
185
202
  }
203
+ const missingAttachmentIds = [];
186
204
  if (attachmentIds.length) {
187
205
  const index = loadAttachmentIndex();
188
206
  for (let position = 0; position < attachmentIds.length; position += 1) {
@@ -190,21 +208,34 @@ function createOutboundTransport(options) {
190
208
  const attachment = index[attachmentId];
191
209
  if (!attachment) {
192
210
  options.log(`attachment 未找到 id=${attachmentId}(跳过)`);
211
+ missingAttachmentIds.push(attachmentId);
193
212
  continue;
194
213
  }
195
214
  const result = options.sendMedia(chatId, replyContext, attachment, `${idempotencyKey}:a${position}`);
196
215
  if (result.code !== 0) {
197
216
  options.log("attachment 发送失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
198
- return { ok: false, status: 502, error: `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
217
+ const error = `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
218
+ report(false, { reason: error });
219
+ return { ok: false, status: 502, error };
199
220
  }
200
221
  try {
201
222
  const parsed = JSON.parse(result.stdout);
202
223
  messageId = parsed?.data?.message_id || messageId;
203
224
  }
204
225
  catch { /* retain prior message id */ }
226
+ committedProviderCalls += 1;
205
227
  options.log(`attach → chat=${chatId} name=${attachment.filename} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
206
228
  }
207
229
  }
230
+ // Empty content plus attachment IDs that are all absent from the index
231
+ // reaches this point without any provider call; claiming success here would
232
+ // record delivery_succeeded for a reminder turn in which the user received
233
+ // nothing.
234
+ if (committedProviderCalls === 0) {
235
+ const error = `没有可发送的内容:附件不存在(${missingAttachmentIds.join(", ")}),且消息正文为空。`;
236
+ report(false, { reason: error });
237
+ return { ok: false, status: 400, error };
238
+ }
208
239
  options.appendConversation({
209
240
  direction: "out",
210
241
  from: options.botDisplayName() || options.agentName,
@@ -215,6 +246,9 @@ function createOutboundTransport(options) {
215
246
  messageId,
216
247
  at: new Date().toISOString(),
217
248
  });
249
+ // This is deliberately after every guarded provider call succeeds and after
250
+ // the local outbound projection, not at Runtime acceptance.
251
+ report(true, { messageId });
218
252
  return { ok: true, status: 200, data: { ok: true, state: "sent", messageId, messageSeq: 1 } };
219
253
  };
220
254
  return { handleSend, stageUpload };
@@ -149,16 +149,30 @@ export function createOutboundTransport(options) {
149
149
  };
150
150
  const handleSend = (body) => {
151
151
  const target = body.target || "";
152
+ const targetKey = String(target);
153
+ const report = (succeeded, details = {}) => {
154
+ if (options.dryRun)
155
+ return;
156
+ try {
157
+ options.onDeliveryOutcome?.({ target: targetKey, succeeded, ...details });
158
+ }
159
+ catch (error) {
160
+ options.log("delivery outcome hook 失败:", error.message);
161
+ }
162
+ };
152
163
  const content = String(body.content ?? "").replace(/\n+$/, "");
153
164
  const chatId = options.resolveChatId(target);
154
165
  if (!chatId) {
155
- return { ok: false, status: 400, error: `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。` };
166
+ const error = `该 target 没有已知 chat_id:${JSON.stringify(target)}。只能回复 canonical Inbox 给出的确切会话;不会兜底发往默认群。`;
167
+ report(false, { reason: error });
168
+ return { ok: false, status: 400, error };
156
169
  }
157
170
  const idempotencyKey = body.idempotencyKey
158
171
  || crypto.createHash("sha256").update(`${String(target)}\0${content}`).digest("hex").slice(0, 32);
159
172
  const replyContext = options.replyContextFor(String(target)) || options.replyContextFor(chatId);
160
173
  const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds.filter(Boolean) : [];
161
174
  let messageId = "sent";
175
+ let committedProviderCalls = 0;
162
176
  if (content !== "" || attachmentIds.length === 0) {
163
177
  const replyInThread = Boolean(replyContext?.in_topic && replyContext.reply_to);
164
178
  const result = replyInThread
@@ -166,7 +180,9 @@ export function createOutboundTransport(options) {
166
180
  : options.sendText(chatId, content, idempotencyKey);
167
181
  if (result.code !== 0) {
168
182
  options.log("send 失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
169
- return { ok: false, status: 502, error: `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
183
+ const error = `lark-cli send failed: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
184
+ report(false, { reason: error });
185
+ return { ok: false, status: 502, error };
170
186
  }
171
187
  try {
172
188
  const parsed = JSON.parse(result.stdout);
@@ -175,8 +191,10 @@ export function createOutboundTransport(options) {
175
191
  catch {
176
192
  messageId = options.dryRun ? "dryrun" : "sent";
177
193
  }
194
+ committedProviderCalls += 1;
178
195
  options.log(`${replyInThread ? "reply-in-thread" : "send"} → chat=${chatId} len=${content.length} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
179
196
  }
197
+ const missingAttachmentIds = [];
180
198
  if (attachmentIds.length) {
181
199
  const index = loadAttachmentIndex();
182
200
  for (let position = 0; position < attachmentIds.length; position += 1) {
@@ -184,21 +202,34 @@ export function createOutboundTransport(options) {
184
202
  const attachment = index[attachmentId];
185
203
  if (!attachment) {
186
204
  options.log(`attachment 未找到 id=${attachmentId}(跳过)`);
205
+ missingAttachmentIds.push(attachmentId);
187
206
  continue;
188
207
  }
189
208
  const result = options.sendMedia(chatId, replyContext, attachment, `${idempotencyKey}:a${position}`);
190
209
  if (result.code !== 0) {
191
210
  options.log("attachment 发送失败 FULL:", `${result.stdout || ""} || ${result.stderr || ""}`);
192
- return { ok: false, status: 502, error: `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}` };
211
+ const error = `lark-cli 发送附件失败: ${(result.stdout || result.stderr || "").trim().split("\n")[0]}`;
212
+ report(false, { reason: error });
213
+ return { ok: false, status: 502, error };
193
214
  }
194
215
  try {
195
216
  const parsed = JSON.parse(result.stdout);
196
217
  messageId = parsed?.data?.message_id || messageId;
197
218
  }
198
219
  catch { /* retain prior message id */ }
220
+ committedProviderCalls += 1;
199
221
  options.log(`attach → chat=${chatId} name=${attachment.filename} id=${messageId}${options.dryRun ? " (dry-run)" : ""}`);
200
222
  }
201
223
  }
224
+ // Empty content plus attachment IDs that are all absent from the index
225
+ // reaches this point without any provider call; claiming success here would
226
+ // record delivery_succeeded for a reminder turn in which the user received
227
+ // nothing.
228
+ if (committedProviderCalls === 0) {
229
+ const error = `没有可发送的内容:附件不存在(${missingAttachmentIds.join(", ")}),且消息正文为空。`;
230
+ report(false, { reason: error });
231
+ return { ok: false, status: 400, error };
232
+ }
202
233
  options.appendConversation({
203
234
  direction: "out",
204
235
  from: options.botDisplayName() || options.agentName,
@@ -209,6 +240,9 @@ export function createOutboundTransport(options) {
209
240
  messageId,
210
241
  at: new Date().toISOString(),
211
242
  });
243
+ // This is deliberately after every guarded provider call succeeds and after
244
+ // the local outbound projection, not at Runtime acceptance.
245
+ report(true, { messageId });
212
246
  return { ok: true, status: 200, data: { ok: true, state: "sent", messageId, messageSeq: 1 } };
213
247
  };
214
248
  return { handleSend, stageUpload };
@@ -1,8 +1,37 @@
1
- import subagentsExtension from "@tintinweb/pi-subagents/dist/index.js";
1
+ import { pathToFileURL } from "node:url";
2
2
  import bashTimeoutExtension from "./pi-bash-timeout-extension.mjs";
3
+ import { bundledPiSubagentExtensionPath } from "./pi-subagent-injection.mjs";
4
+ /**
5
+ * Load the prebuilt, patched subagent bundle shipped in dist/ rather than the
6
+ * package-manager copy. Standalone binaries additionally load the patched
7
+ * package through Bun's compiled module graph, while normal package installs
8
+ * continue to use the prebuilt patched bundle.
9
+ */
10
+ async function loadBundledPiSubagentExtension() {
11
+ if (process.env.LARKIN_STANDALONE === "1") {
12
+ const loaded = await import("@tintinweb/pi-subagents/dist/index.js");
13
+ if (!loaded.default)
14
+ throw new Error("Larkin bounded pi-subagents package is invalid; refusing to start builtin Pi");
15
+ return loaded.default;
16
+ }
17
+ const bundle = bundledPiSubagentExtensionPath(process.env.LARKIN_CONFIG_DIR);
18
+ if (!bundle)
19
+ throw new Error("Larkin bounded pi-subagents bundle is unavailable; refusing to start builtin Pi");
20
+ const loaded = await import(pathToFileURL(bundle).href);
21
+ if (!loaded.default)
22
+ throw new Error("Larkin bounded pi-subagents bundle is invalid; refusing to start builtin Pi");
23
+ return loaded.default;
24
+ }
3
25
  /** Builtin Pi extensions are loaded as code, never through Pi's path/data-URL loader. */
26
+ const bundledSubagentsExtension = async (pi) => {
27
+ const extension = await loadBundledPiSubagentExtension();
28
+ if (typeof extension === "function")
29
+ await extension(pi);
30
+ else
31
+ await extension.factory(pi);
32
+ };
4
33
  export const BUILTIN_PI_EXTENSION_FACTORIES = Object.freeze([
5
- subagentsExtension,
34
+ bundledSubagentsExtension,
6
35
  bashTimeoutExtension,
7
36
  ]);
8
37
  /** Invoke Pi RPC with Larkin's builtin-only extension factories. */
@@ -81,39 +81,76 @@ export function probeExternalPiVersion(piCommand, env) {
81
81
  * 已装时 Larkin 不再 -e 注入,避免同名工具(Agent/get_subagent_result/steer_subagent)
82
82
  * 重复注册导致 pi 扩展加载 FATAL(pi 对 duplicate tool registration 是硬失败)。
83
83
  */
84
- export function userPiAlreadyHasSubagentsExtension(env) {
84
+ const BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
85
+ function userPiSubagentsWaitCapability(env) {
85
86
  const agentDir = env.PI_CODING_AGENT_DIR || path.join(env.HOME || process.env.HOME || "", ".pi", "agent");
86
87
  try {
88
+ let configured = false;
87
89
  const settingsFile = path.join(agentDir, "settings.json");
88
90
  if (fs.existsSync(settingsFile)) {
89
91
  const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8"));
90
92
  const packages = settings?.packages;
91
93
  if (Array.isArray(packages)) {
92
- for (const entry of packages) {
93
- if (typeof entry === "string" && /pi-subagents/i.test(entry))
94
- return true;
95
- }
94
+ configured = packages.some((entry) => typeof entry === "string" && /pi-subagents/i.test(entry));
96
95
  }
97
96
  }
98
97
  // settings.json 可能未登记但包目录已存在(残留/手动安装)。
99
98
  // Split token: repo contract forbids the literal package-manager word in sources.
100
- const npmDir = path.join(agentDir, "n" + "pm", "node_modules", "@tintinweb");
101
- if (fs.existsSync(npmDir) && fs.readdirSync(npmDir).some((name) => /pi-subagents/i.test(name)))
102
- return true;
99
+ const packageRoots = [
100
+ path.join(agentDir, "n" + "pm", "node_modules", "@tintinweb", "pi-subagents"),
101
+ path.join(agentDir, "node_modules", "@tintinweb", "pi-subagents"),
102
+ ];
103
+ const installedRoot = packageRoots.find((root) => fs.existsSync(root));
104
+ if (!configured && !installedRoot)
105
+ return "absent";
106
+ if (!installedRoot)
107
+ return "unbounded";
108
+ const packageManifest = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
109
+ const extensions = packageManifest.pi?.extensions;
110
+ if (!Array.isArray(extensions) || extensions.length === 0
111
+ || !extensions.every((entry) => typeof entry === "string" && entry.length > 0)) {
112
+ return "unbounded";
113
+ }
114
+ // Pi executes the entries declared by the package manifest. Do not trust a
115
+ // marker in a build artifact when the manifest points Pi at another file.
116
+ const capabilityFiles = extensions.map((entry) => path.resolve(installedRoot, entry));
117
+ const hasCapability = capabilityFiles.every((file) => {
118
+ try {
119
+ return fs.readFileSync(file, "utf8").includes(BOUNDED_WAIT_CAPABILITY);
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ });
125
+ return hasCapability ? "bounded" : "unbounded";
103
126
  }
104
127
  catch {
105
- /* unreadable config: assume not installed, injection stays safe */
128
+ // Unreadable user extension state is not safe to treat as bounded.
129
+ return "unbounded";
106
130
  }
107
- return false;
131
+ }
132
+ export function userPiAlreadyHasSubagentsExtension(env) {
133
+ return userPiSubagentsWaitCapability(env) !== "absent";
134
+ }
135
+ export function userPiSubagentsHasBoundedWaitCapability(env) {
136
+ return userPiSubagentsWaitCapability(env) === "bounded";
108
137
  }
109
138
  export function resolvePiSubagentExtensionArg(input, probeVersion = () => probeExternalPiVersion(input.piCommand, input.env), resolveBundle = () => bundledPiSubagentExtensionPath(input.env.LARKIN_CONFIG_DIR)) {
139
+ // A user-installed extension wins only when it advertises the exact bounded
140
+ // wait capability. Check this before the bundle path so a missing Larkin
141
+ // asset cannot silently leave an unbounded user extension active.
142
+ if (userPiAlreadyHasSubagentsExtension(input.env)) {
143
+ if (!userPiSubagentsHasBoundedWaitCapability(input.env)) {
144
+ throw new Error("[larkin] WARNING: refusing external Pi because the user-installed " +
145
+ "pi-subagents extension is unbounded or unverifiable; remove it or install " +
146
+ "a version advertising larkin-pi-subagents-bounded-wait-v1.");
147
+ }
148
+ return null;
149
+ }
110
150
  // resolveBundle is injectable so unit tests stay environment-independent
111
151
  // (no dependency on build artifacts or the filesystem).
112
152
  const bundle = resolveBundle();
113
153
  if (!bundle)
114
154
  return null;
115
- // 用户已自行安装同款扩展时跳过注入,避免工具名重复注册冲突。
116
- if (userPiAlreadyHasSubagentsExtension(input.env))
117
- return null;
118
155
  return piVersionSupportsSubagents(probeVersion()) ? bundle : null;
119
156
  }
@@ -7104,6 +7104,43 @@ function abortable(promise, signal) {
7104
7104
  });
7105
7105
  });
7106
7106
  }
7107
+ var RESULT_WAIT_QUEUE_POLL_MS = 50;
7108
+ var DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
7109
+ var MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
7110
+ function normalizeResultWaitTimeout(timeoutMs) {
7111
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
7112
+ return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
7113
+ return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
7114
+ }
7115
+ function waitWithDeadline(promise, timeoutMs, signal) {
7116
+ let timer;
7117
+ const timeout = new Promise((resolve2) => {
7118
+ timer = setTimeout(() => resolve2({ timedOut: true }), timeoutMs);
7119
+ });
7120
+ return abortable(Promise.race([
7121
+ promise.then((value) => ({ timedOut: false, value })),
7122
+ timeout
7123
+ ]), signal).finally(() => {
7124
+ if (timer !== undefined)
7125
+ clearTimeout(timer);
7126
+ });
7127
+ }
7128
+ async function waitForSubagentResult(record, timeoutMs, signal) {
7129
+ const deadline = Date.now() + timeoutMs;
7130
+ while (record.status === "queued") {
7131
+ const remainingMs2 = deadline - Date.now();
7132
+ if (remainingMs2 <= 0)
7133
+ return true;
7134
+ await abortable(new Promise((resolve2) => setTimeout(resolve2, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs2))), signal);
7135
+ }
7136
+ if (!record.promise)
7137
+ return false;
7138
+ const remainingMs = deadline - Date.now();
7139
+ if (remainingMs <= 0)
7140
+ return true;
7141
+ return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
7142
+ }
7143
+ var LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
7107
7144
  function renderRunningAgentStatus(frame, statsText, activity, theme) {
7108
7145
  const container = new Container;
7109
7146
  container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
@@ -7287,7 +7324,10 @@ function src_default(pi) {
7287
7324
  const agentActivity = new Map;
7288
7325
  const pendingNudges = new Map;
7289
7326
  const NUDGE_HOLD_MS = 200;
7290
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
7327
+ let resultWaitConsumedInParentTurn = false;
7328
+ pi.on("agent_start", () => {
7329
+ resultWaitConsumedInParentTurn = false;
7330
+ });
7291
7331
  function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
7292
7332
  cancelNudge(key);
7293
7333
  pendingNudges.set(key, setTimeout(() => {
@@ -8147,6 +8187,9 @@ You will be notified when this agent completes.
8147
8187
  wait: Type.Optional(Type.Boolean({
8148
8188
  description: "If true, wait for the agent to complete before returning. Default: false."
8149
8189
  })),
8190
+ timeout_ms: Type.Optional(Type.Number({
8191
+ description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000."
8192
+ })),
8150
8193
  verbose: Type.Optional(Type.Boolean({
8151
8194
  description: "If true, include the agent's full conversation (messages + tool calls). Default: false."
8152
8195
  }))
@@ -8156,12 +8199,14 @@ You will be notified when this agent completes.
8156
8199
  if (!record) {
8157
8200
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
8158
8201
  }
8202
+ if (params.wait && resultWaitConsumedInParentTurn) {
8203
+ throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
8204
+ }
8205
+ let timedOut = false;
8206
+ const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
8159
8207
  if (params.wait && (record.status === "running" || record.status === "queued")) {
8160
- while (record.status === "queued") {
8161
- await abortable(new Promise((resolve2) => setTimeout(resolve2, QUEUE_WAIT_POLL_MS)), signal);
8162
- }
8163
- if (record.promise)
8164
- await abortable(record.promise, signal);
8208
+ resultWaitConsumedInParentTurn = true;
8209
+ timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
8165
8210
  }
8166
8211
  const displayName = getDisplayName(record.type);
8167
8212
  const duration = formatDuration(record.startedAt, record.completedAt);
@@ -8176,12 +8221,12 @@ You will be notified when this agent completes.
8176
8221
  statsParts.push(`Compactions: ${record.compactionCount}`);
8177
8222
  statsParts.push(`Duration: ${duration}`);
8178
8223
  let output = `Agent: ${record.id}
8179
- ` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}
8224
+ ` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}
8180
8225
  ` + `Description: ${record.description}
8181
8226
 
8182
8227
  `;
8183
- if (record.status === "running") {
8184
- output += "Agent is still running. Use wait: true or check back later.";
8228
+ if (record.status === "running" || record.status === "queued") {
8229
+ output += timedOut ? `Agent is still ${record.status}. Wait timed out after ${waitTimeoutMs}ms; the background agent was not cancelled. Yield/end this turn and await the completion notification; do not call wait: true again.` : "Agent is still running. Use wait: true or check back later.";
8185
8230
  } else if (record.status === "error") {
8186
8231
  output += `Error: ${record.error}${partialOutputSuffix(record)}`;
8187
8232
  } else {
@@ -8985,6 +9030,8 @@ ${systemPrompt}
8985
9030
  });
8986
9031
  }
8987
9032
  export {
9033
+ waitForSubagentResult,
8988
9034
  renderRunningAgentStatus,
8989
- src_default as default
9035
+ src_default as default,
9036
+ LARKIN_BOUNDED_WAIT_CAPABILITY
8990
9037
  };
@@ -9,6 +9,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
9
9
  const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const agent_cli_capabilities_js_1 = require("../agent/agent-cli-capabilities.cjs");
12
+ const reminder_delivery_audit_js_1 = require("../agent/reminder-delivery-audit.cjs");
12
13
  const inbox_projection_js_1 = require("../agent/inbox-projection.cjs");
13
14
  const api_1 = require("@opentelemetry/api");
14
15
  const provider_error_classifier_js_1 = require("./provider-error-classifier.cjs");
@@ -1071,6 +1072,33 @@ function createRuntimeHost(options) {
1071
1072
  agent.piProactiveCompaction = tracked;
1072
1073
  return tracked;
1073
1074
  };
1075
+ // Reminder audit contexts are in-turn capabilities. Finalize any survivors
1076
+ // at both turn boundaries: turn end reconciles the turn that consumed them,
1077
+ // and turn start expires contexts left by a crashed/aborted prior turn so a
1078
+ // direct outbound cannot be falsely attributed to a stale reminder.
1079
+ const finalizeReminderContexts = (agent, reason) => {
1080
+ const legacyReminder = agent.stateStore?.resolveCurrentReminders ? null : agent.stateStore?.resolveCurrentReminder?.() ?? null;
1081
+ const reminderContexts = agent.stateStore?.resolveCurrentReminders?.() ?? (legacyReminder ? [legacyReminder] : []);
1082
+ let reminderAuditComplete = true;
1083
+ if (agent.stateStore?.paths?.reminders && agent.stateStore.clearCurrentReminder) {
1084
+ for (const reminder of reminderContexts) {
1085
+ try {
1086
+ (0, reminder_delivery_audit_js_1.auditReminderDelivery)({ stateStore: agent.stateStore, agentId: agent.config.agentId,
1087
+ reminderId: reminder.reminderId, deliveryAnchor: reminder.deliveryAnchor, target: reminder.deliveryTarget,
1088
+ succeeded: reminder.deliveryCommitted === true, finalize: true,
1089
+ ...(reminder.deliveryMessageId ? { messageId: reminder.deliveryMessageId } : {}),
1090
+ ...(!reminder.deliveryCommitted ? { reason } : {}) });
1091
+ }
1092
+ catch {
1093
+ // Retain the context if the failure could not be durably recorded;
1094
+ // a later matching outbound or turn can reconcile it.
1095
+ reminderAuditComplete = false;
1096
+ }
1097
+ }
1098
+ }
1099
+ if (reminderAuditComplete)
1100
+ agent.stateStore?.clearCurrentReminders?.();
1101
+ };
1074
1102
  const observe = (agent, session, event) => {
1075
1103
  if (agent.session !== session)
1076
1104
  return; // Ignore late output from a replaced child.
@@ -1113,6 +1141,10 @@ function createRuntimeHost(options) {
1113
1141
  ...(event.model ? { model: event.model } : {}), ...(event.reasoningEffort ? { reasoningEffort: event.reasoningEffort } : {}) });
1114
1142
  }
1115
1143
  else if (event.type === "turn-start") {
1144
+ // Clear a source left by a crashed/aborted prior turn before a new turn
1145
+ // can execute a direct task without an Inbox poll.
1146
+ agent.stateStore?.clearCurrentInboxSource?.();
1147
+ finalizeReminderContexts(agent, "Runtime turn started before a prior turn reconciled this reminder delivery");
1116
1148
  agent.busy = true;
1117
1149
  agent.turnInProgress = true;
1118
1150
  agent.turnHadFailure = false;
@@ -1134,6 +1166,11 @@ function createRuntimeHost(options) {
1134
1166
  agent.busy = false;
1135
1167
  emit({ type: "activity", agentId: agent.config.agentId, activity: "idle", activityKind: "idle", detailKind: "turn_ended" });
1136
1168
  reconcileAcceptedAtTurnEnd(agent);
1169
+ // last_source and reminder audit contexts are in-turn capabilities,
1170
+ // never cross-turn defaults. A later direct Runtime task must fail
1171
+ // closed instead of inheriting a previous turn's chat or reminder.
1172
+ agent.stateStore?.clearCurrentInboxSource?.();
1173
+ finalizeReminderContexts(agent, "Runtime turn ended without a matching Feishu write");
1137
1174
  if (agent.backgroundCompletionInFlight) {
1138
1175
  if (agent.turnHadFailure)
1139
1176
  failBackgroundCompletionWake(agent);