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
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { agentCliPromptCapabilities } from "../agent/agent-cli-capabilities.mjs";
5
+ import { auditReminderDelivery } from "../agent/reminder-delivery-audit.mjs";
5
6
  import { isCanonicalInboxTarget, targetKeyOfInboxEnvelope } from "../agent/inbox-projection.mjs";
6
7
  import { SpanKind } from "@opentelemetry/api";
7
8
  import { buildStrictProviderErrorInput, classifyStrictProviderError } from "./provider-error-classifier.mjs";
@@ -1062,6 +1063,33 @@ export function createRuntimeHost(options) {
1062
1063
  agent.piProactiveCompaction = tracked;
1063
1064
  return tracked;
1064
1065
  };
1066
+ // Reminder audit contexts are in-turn capabilities. Finalize any survivors
1067
+ // at both turn boundaries: turn end reconciles the turn that consumed them,
1068
+ // and turn start expires contexts left by a crashed/aborted prior turn so a
1069
+ // direct outbound cannot be falsely attributed to a stale reminder.
1070
+ const finalizeReminderContexts = (agent, reason) => {
1071
+ const legacyReminder = agent.stateStore?.resolveCurrentReminders ? null : agent.stateStore?.resolveCurrentReminder?.() ?? null;
1072
+ const reminderContexts = agent.stateStore?.resolveCurrentReminders?.() ?? (legacyReminder ? [legacyReminder] : []);
1073
+ let reminderAuditComplete = true;
1074
+ if (agent.stateStore?.paths?.reminders && agent.stateStore.clearCurrentReminder) {
1075
+ for (const reminder of reminderContexts) {
1076
+ try {
1077
+ auditReminderDelivery({ stateStore: agent.stateStore, agentId: agent.config.agentId,
1078
+ reminderId: reminder.reminderId, deliveryAnchor: reminder.deliveryAnchor, target: reminder.deliveryTarget,
1079
+ succeeded: reminder.deliveryCommitted === true, finalize: true,
1080
+ ...(reminder.deliveryMessageId ? { messageId: reminder.deliveryMessageId } : {}),
1081
+ ...(!reminder.deliveryCommitted ? { reason } : {}) });
1082
+ }
1083
+ catch {
1084
+ // Retain the context if the failure could not be durably recorded;
1085
+ // a later matching outbound or turn can reconcile it.
1086
+ reminderAuditComplete = false;
1087
+ }
1088
+ }
1089
+ }
1090
+ if (reminderAuditComplete)
1091
+ agent.stateStore?.clearCurrentReminders?.();
1092
+ };
1065
1093
  const observe = (agent, session, event) => {
1066
1094
  if (agent.session !== session)
1067
1095
  return; // Ignore late output from a replaced child.
@@ -1104,6 +1132,10 @@ export function createRuntimeHost(options) {
1104
1132
  ...(event.model ? { model: event.model } : {}), ...(event.reasoningEffort ? { reasoningEffort: event.reasoningEffort } : {}) });
1105
1133
  }
1106
1134
  else if (event.type === "turn-start") {
1135
+ // Clear a source left by a crashed/aborted prior turn before a new turn
1136
+ // can execute a direct task without an Inbox poll.
1137
+ agent.stateStore?.clearCurrentInboxSource?.();
1138
+ finalizeReminderContexts(agent, "Runtime turn started before a prior turn reconciled this reminder delivery");
1107
1139
  agent.busy = true;
1108
1140
  agent.turnInProgress = true;
1109
1141
  agent.turnHadFailure = false;
@@ -1125,6 +1157,11 @@ export function createRuntimeHost(options) {
1125
1157
  agent.busy = false;
1126
1158
  emit({ type: "activity", agentId: agent.config.agentId, activity: "idle", activityKind: "idle", detailKind: "turn_ended" });
1127
1159
  reconcileAcceptedAtTurnEnd(agent);
1160
+ // last_source and reminder audit contexts are in-turn capabilities,
1161
+ // never cross-turn defaults. A later direct Runtime task must fail
1162
+ // closed instead of inheriting a previous turn's chat or reminder.
1163
+ agent.stateStore?.clearCurrentInboxSource?.();
1164
+ finalizeReminderContexts(agent, "Runtime turn ended without a matching Feishu write");
1128
1165
  if (agent.backgroundCompletionInFlight) {
1129
1166
  if (agent.turnHadFailure)
1130
1167
  failBackgroundCompletionWake(agent);
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [
7
7
  "dist/",
8
+ "patches/",
8
9
  "assets/",
9
10
  "scripts/npm/",
10
11
  "README.md",
@@ -114,5 +115,8 @@
114
115
  "typescript": "^7.0.2",
115
116
  "vite": "^8.1.5",
116
117
  "vitest": "^4.1.10"
118
+ },
119
+ "patchedDependencies": {
120
+ "@tintinweb/pi-subagents@0.14.3": "patches/@tintinweb%2Fpi-subagents@0.14.3.patch"
117
121
  }
118
122
  }
@@ -0,0 +1,249 @@
1
+ diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-58e08ce8246a76a0 b/.bun-tag-58e08ce8246a76a0
2
+ new file mode 100644
3
+ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
4
+ diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-6ef69510e99ece5d b/.bun-tag-6ef69510e99ece5d
5
+ new file mode 100644
6
+ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7
+ diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-80c5d241789d23ea b/.bun-tag-80c5d241789d23ea
8
+ new file mode 100644
9
+ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
10
+ diff --git a/dist/index.js b/dist/index.js
11
+ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b39fdfd60 100644
12
+ --- a/dist/index.js
13
+ +++ b/dist/index.js
14
+ @@ -69,6 +69,44 @@ function abortable(promise, signal) {
15
+ });
16
+ });
17
+ }
18
+ +const RESULT_WAIT_QUEUE_POLL_MS = 50;
19
+ +const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
20
+ +const MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
21
+ +function normalizeResultWaitTimeout(timeoutMs) {
22
+ + if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
23
+ + return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
24
+ + return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
25
+ +}
26
+ +function waitWithDeadline(promise, timeoutMs, signal) {
27
+ + let timer;
28
+ + const timeout = new Promise((resolve) => {
29
+ + timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
30
+ + });
31
+ + return abortable(Promise.race([
32
+ + promise.then((value) => ({ timedOut: false, value })),
33
+ + timeout,
34
+ + ]), signal).finally(() => {
35
+ + if (timer !== undefined)
36
+ + clearTimeout(timer);
37
+ + });
38
+ +}
39
+ +/** Wait for one background record without cancelling it when the deadline expires. */
40
+ +export async function waitForSubagentResult(record, timeoutMs, signal) {
41
+ + const deadline = Date.now() + timeoutMs;
42
+ + while (record.status === "queued") {
43
+ + const remainingMs = deadline - Date.now();
44
+ + if (remainingMs <= 0)
45
+ + return true;
46
+ + await abortable(new Promise((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))), signal);
47
+ + }
48
+ + if (!record.promise)
49
+ + return false;
50
+ + const remainingMs = deadline - Date.now();
51
+ + if (remainingMs <= 0)
52
+ + return true;
53
+ + return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
54
+ +}
55
+ +const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
56
+ export function renderRunningAgentStatus(frame, statsText, activity, theme) {
57
+ const container = new Container();
58
+ container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
59
+ @@ -280,9 +318,14 @@ export default function (pi) {
60
+ // before they reach pi.sendMessage (fire-and-forget).
61
+ const pendingNudges = new Map();
62
+ const NUDGE_HOLD_MS = 200;
63
+ + // A parent agent run may span multiple model turns. Consume the one-wait
64
+ + // budget when a queued/running wait begins, rather than only after timeout.
65
+ + let resultWaitConsumedInParentTurn = false;
66
+ + pi.on("agent_start", () => {
67
+ + resultWaitConsumedInParentTurn = false;
68
+ + });
69
+ // A queued result wait must observe completion before its held notification
70
+ // can fire, so successful waits can still suppress that redundant nudge.
71
+ - const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
72
+ function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
73
+ cancelNudge(key);
74
+ pendingNudges.set(key, setTimeout(() => {
75
+ @@ -1293,6 +1336,9 @@ Terse command-style prompts produce shallow, generic work.
76
+ wait: Type.Optional(Type.Boolean({
77
+ description: "If true, wait for the agent to complete before returning. Default: false.",
78
+ })),
79
+ + timeout_ms: Type.Optional(Type.Number({
80
+ + description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
81
+ + })),
82
+ verbose: Type.Optional(Type.Boolean({
83
+ description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
84
+ })),
85
+ @@ -1307,12 +1353,15 @@ Terse command-style prompts produce shallow, generic work.
86
+ // completion notification can still be delivered.
87
+ // Queued agents have no promise yet (it's created when the queue starts
88
+ // them), so poll until they leave the queue, then await like a running one.
89
+ + // One deadline covers both phases so queue polling cannot reset the wait.
90
+ + if (params.wait && resultWaitConsumedInParentTurn) {
91
+ + throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
92
+ + }
93
+ + let timedOut = false;
94
+ + const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
95
+ if (params.wait && (record.status === "running" || record.status === "queued")) {
96
+ - while (record.status === "queued") {
97
+ - await abortable(new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
98
+ - }
99
+ - if (record.promise)
100
+ - await abortable(record.promise, signal);
101
+ + resultWaitConsumedInParentTurn = true;
102
+ + timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
103
+ }
104
+ const displayName = getDisplayName(record.type);
105
+ const duration = formatDuration(record.startedAt, record.completedAt);
106
+ @@ -1327,10 +1376,12 @@ Terse command-style prompts produce shallow, generic work.
107
+ statsParts.push(`Compactions: ${record.compactionCount}`);
108
+ statsParts.push(`Duration: ${duration}`);
109
+ let output = `Agent: ${record.id}\n` +
110
+ - `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
111
+ + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
112
+ `Description: ${record.description}\n\n`;
113
+ - if (record.status === "running") {
114
+ - output += "Agent is still running. Use wait: true or check back later.";
115
+ + if (record.status === "running" || record.status === "queued") {
116
+ + output += timedOut
117
+ + ? `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.`
118
+ + : "Agent is still running. Use wait: true or check back later.";
119
+ }
120
+ else if (record.status === "error") {
121
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
122
+ diff --git a/src/index.ts b/src/index.ts
123
+ index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95bbe4bb1f 100644
124
+ --- a/src/index.ts
125
+ +++ b/src/index.ts
126
+ @@ -91,6 +91,54 @@ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
127
+ });
128
+ }
129
+
130
+ +const RESULT_WAIT_QUEUE_POLL_MS = 50;
131
+ +const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30_000;
132
+ +const MAX_RESULT_WAIT_TIMEOUT_MS = 60_000;
133
+ +
134
+ +function normalizeResultWaitTimeout(timeoutMs: number | undefined): number {
135
+ + if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
136
+ + return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
137
+ +}
138
+ +
139
+ +function waitWithDeadline<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<{ timedOut: true } | { timedOut: false; value: T }> {
140
+ + let timer: ReturnType<typeof setTimeout> | undefined;
141
+ + const timeout = new Promise<{ timedOut: true }>((resolve) => {
142
+ + timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
143
+ + });
144
+ + return abortable(
145
+ + Promise.race([
146
+ + promise.then((value) => ({ timedOut: false as const, value })),
147
+ + timeout,
148
+ + ]),
149
+ + signal,
150
+ + ).finally(() => {
151
+ + if (timer !== undefined) clearTimeout(timer);
152
+ + });
153
+ +}
154
+ +
155
+ +/** Wait for one background record without cancelling it when the deadline expires. */
156
+ +export async function waitForSubagentResult(
157
+ + record: Pick<AgentRecord, "status" | "promise">,
158
+ + timeoutMs: number,
159
+ + signal?: AbortSignal,
160
+ +): Promise<boolean> {
161
+ + const deadline = Date.now() + timeoutMs;
162
+ + while (record.status === "queued") {
163
+ + const remainingMs = deadline - Date.now();
164
+ + if (remainingMs <= 0) return true;
165
+ + await abortable(
166
+ + new Promise<void>((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))),
167
+ + signal,
168
+ + );
169
+ + }
170
+ + if (!record.promise) return false;
171
+ + const remainingMs = deadline - Date.now();
172
+ + if (remainingMs <= 0) return true;
173
+ + return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
174
+ +}
175
+ +
176
+ +export const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
177
+ +
178
+ export function renderRunningAgentStatus(
179
+ frame: string,
180
+ statsText: string,
181
+ @@ -330,10 +378,14 @@ export default function (pi: ExtensionAPI) {
182
+ // before they reach pi.sendMessage (fire-and-forget).
183
+ const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
184
+ const NUDGE_HOLD_MS = 200;
185
+ + // A parent agent run may span multiple model turns. Consume the one-wait
186
+ + // budget when a queued/running wait begins, rather than only after timeout.
187
+ + let resultWaitConsumedInParentTurn = false;
188
+ + pi.on("agent_start", () => {
189
+ + resultWaitConsumedInParentTurn = false;
190
+ + });
191
+ // A queued result wait must observe completion before its held notification
192
+ // can fire, so successful waits can still suppress that redundant nudge.
193
+ - const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
194
+ -
195
+ function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
196
+ cancelNudge(key);
197
+ pendingNudges.set(key, setTimeout(() => {
198
+ @@ -1453,6 +1505,11 @@ Terse command-style prompts produce shallow, generic work.
199
+ description: "If true, wait for the agent to complete before returning. Default: false.",
200
+ }),
201
+ ),
202
+ + timeout_ms: Type.Optional(
203
+ + Type.Number({
204
+ + description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
205
+ + }),
206
+ + ),
207
+ verbose: Type.Optional(
208
+ Type.Boolean({
209
+ description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
210
+ @@ -1470,14 +1527,15 @@ Terse command-style prompts produce shallow, generic work.
211
+ // completion notification can still be delivered.
212
+ // Queued agents have no promise yet (it's created when the queue starts
213
+ // them), so poll until they leave the queue, then await like a running one.
214
+ + // One deadline covers both phases so queue polling cannot reset the wait.
215
+ + if (params.wait && resultWaitConsumedInParentTurn) {
216
+ + throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
217
+ + }
218
+ + let timedOut = false;
219
+ + const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
220
+ if (params.wait && (record.status === "running" || record.status === "queued")) {
221
+ - while (record.status === "queued") {
222
+ - await abortable(
223
+ - new Promise<void>((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)),
224
+ - signal,
225
+ - );
226
+ - }
227
+ - if (record.promise) await abortable(record.promise, signal);
228
+ + resultWaitConsumedInParentTurn = true;
229
+ + timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
230
+ }
231
+
232
+ const displayName = getDisplayName(record.type);
233
+ @@ -1492,11 +1550,13 @@ Terse command-style prompts produce shallow, generic work.
234
+
235
+ let output =
236
+ `Agent: ${record.id}\n` +
237
+ - `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
238
+ + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
239
+ `Description: ${record.description}\n\n`;
240
+
241
+ - if (record.status === "running") {
242
+ - output += "Agent is still running. Use wait: true or check back later.";
243
+ + if (record.status === "running" || record.status === "queued") {
244
+ + output += timedOut
245
+ + ? `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.`
246
+ + : "Agent is still running. Use wait: true or check back later.";
247
+ } else if (record.status === "error") {
248
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
249
+ } else {