larkin 0.5.3 → 0.5.5

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -1
  3. package/artifacts/release/THIRD_PARTY_NOTICES.txt +1 -100
  4. package/dist/agent/agent-cli-capabilities.cjs +1 -1
  5. package/dist/agent/agent-cli-capabilities.mjs +1 -1
  6. package/dist/agent/config-cli-contract.cjs +3 -1
  7. package/dist/agent/config-cli-contract.mjs +3 -1
  8. package/dist/agent/context-prompt.cjs +16 -17
  9. package/dist/agent/context-prompt.mjs +15 -16
  10. package/dist/agent/inbox-audit-heartbeat.cjs +157 -37
  11. package/dist/agent/inbox-audit-heartbeat.mjs +122 -36
  12. package/dist/agent/missed-outbound-scan.cjs +326 -54
  13. package/dist/agent/missed-outbound-scan.mjs +319 -53
  14. package/dist/app/agent-cli.mjs +37 -6
  15. package/dist/app/agent-config.mjs +36 -5
  16. package/dist/app/cli.mjs +1 -0
  17. package/dist/dashboard/dashboard-config-controller.mjs +15 -0
  18. package/dist/dashboard/web/assets/dashboard.css +1 -1
  19. package/dist/dashboard/web/assets/dashboard.js +15 -15
  20. package/dist/feishu/host-shell.cjs +63 -5
  21. package/dist/feishu/host-shell.mjs +66 -8
  22. package/dist/platform/config.cjs +173 -5
  23. package/dist/platform/config.mjs +167 -4
  24. package/dist/runtime/pi-state-dir.cjs +105 -0
  25. package/dist/runtime/pi-state-dir.mjs +94 -0
  26. package/dist/runtime/pi-tmux-extension.mjs +137 -0
  27. package/dist/runtime/pi-tmux-injection.mjs +49 -0
  28. package/dist/runtime/pi-tmux.bundle.js +600 -0
  29. package/dist/runtime/pi-tmux.mjs +408 -0
  30. package/dist/runtime/runtime-adapters.mjs +26 -113
  31. package/dist/runtime/runtime-host.cjs +3 -413
  32. package/dist/runtime/runtime-host.mjs +2 -412
  33. package/package.json +2 -8
  34. package/dist/runtime/pi-bash-timeout-extension.mjs +0 -60
  35. package/dist/runtime/pi-bash-timeout-injection.mjs +0 -54
  36. package/dist/runtime/pi-bash-timeout.bundle.js +0 -43
  37. package/dist/runtime/pi-extension-api.mjs +0 -1
  38. package/dist/runtime/pi-subagent-injection.mjs +0 -290
  39. package/dist/runtime/pi-subagent-ledger.cjs +0 -632
  40. package/dist/runtime/pi-subagent-ledger.mjs +0 -594
  41. package/dist/runtime/pi-subagent-record-watchdog-injection.mjs +0 -43
  42. package/dist/runtime/pi-subagent-record-watchdog.bundle.js +0 -183
  43. package/dist/runtime/pi-subagent-record-watchdog.mjs +0 -46
  44. package/dist/runtime/pi-subagents-notification.mjs +0 -175
  45. package/dist/runtime/pi-subagents.bundle.js +0 -9110
  46. package/dist/runtime/pi-supervised-command-extension.mjs +0 -72
  47. package/dist/runtime/pi-supervised-command.bundle.js +0 -4735
  48. package/dist/runtime/pi-supervised-command.mjs +0 -420
  49. package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +0 -945
@@ -1,945 +0,0 @@
1
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-1cce67bbcb821bd9 b/.bun-tag-1cce67bbcb821bd9
2
- new file mode 100644
3
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
4
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-2a826c9b186cb67c b/.bun-tag-2a826c9b186cb67c
5
- new file mode 100644
6
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-3fadcd1707a055f8 b/.bun-tag-3fadcd1707a055f8
8
- new file mode 100644
9
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
10
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-521d9b2ad9a104fa b/.bun-tag-521d9b2ad9a104fa
11
- new file mode 100644
12
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
13
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-58e08ce8246a76a0 b/.bun-tag-58e08ce8246a76a0
14
- new file mode 100644
15
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
16
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-60f990c0070fb268 b/.bun-tag-60f990c0070fb268
17
- new file mode 100644
18
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
19
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-6ef69510e99ece5d b/.bun-tag-6ef69510e99ece5d
20
- new file mode 100644
21
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
22
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-80c5d241789d23ea b/.bun-tag-80c5d241789d23ea
23
- new file mode 100644
24
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
25
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-8c139481f6770606 b/.bun-tag-8c139481f6770606
26
- new file mode 100644
27
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
28
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-93fb9cd92e52c135 b/.bun-tag-93fb9cd92e52c135
29
- new file mode 100644
30
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
31
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-a192c07cdd151919 b/.bun-tag-a192c07cdd151919
32
- new file mode 100644
33
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
34
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-a6be946c74d62c48 b/.bun-tag-a6be946c74d62c48
35
- new file mode 100644
36
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
37
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-bd9424a2f5d10a5e b/.bun-tag-bd9424a2f5d10a5e
38
- new file mode 100644
39
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
40
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-f064cf221732e6fa b/.bun-tag-f064cf221732e6fa
41
- new file mode 100644
42
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
43
- diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-fcccdb30b0e20493 b/.bun-tag-fcccdb30b0e20493
44
- new file mode 100644
45
- index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
46
- diff --git a/dist/agent-manager.js b/dist/agent-manager.js
47
- index 5619e266cb828b197965c4953ee9fff8a9d3a9a7..23de2b5bc0a27acbcbb1f133625d3c95bd9a5f7f 100644
48
- --- a/dist/agent-manager.js
49
- +++ b/dist/agent-manager.js
50
- @@ -50,11 +50,14 @@ export class AgentManager {
51
- queue = [];
52
- /** Number of currently running background agents. */
53
- runningBackground = 0;
54
- - constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact) {
55
- + constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact, supervisedKey) {
56
- + this.supervisedById = new Map();
57
- + this.reaps = new Map();
58
- this.onComplete = onComplete;
59
- this.onStart = onStart;
60
- this.onCompact = onCompact;
61
- this.maxConcurrent = maxConcurrent;
62
- + this.supervisedKey = supervisedKey;
63
- // Cleanup completed agents after 10 minutes (but keep sessions for resume)
64
- this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
65
- this.cleanupInterval.unref();
66
- @@ -77,7 +80,18 @@ export class AgentManager {
67
- // call, not minutes later at drain. Throw (not warn): programmatic callers
68
- // can fix and retry; the RPC layer converts throws into error envelopes.
69
- assertValidSpawnCwd(options.cwd);
70
- + if (Object.prototype.hasOwnProperty.call(options, "allowSupervisedCommands")) {
71
- + throw new Error("allowSupervisedCommands is not a public spawn option");
72
- + }
73
- + const supervisedPath = this.supervisedKey ? options[this.supervisedKey] : undefined;
74
- + if (typeof supervisedPath === "string" && supervisedPath.length === 0) {
75
- + throw new Error("supervised command bundle is missing");
76
- + }
77
- const id = randomUUID().slice(0, 17);
78
- + if (typeof supervisedPath === "string")
79
- + this.supervisedById.set(id, supervisedPath);
80
- + else
81
- + this.supervisedById.delete(id);
82
- const abortController = new AbortController();
83
- const record = {
84
- id,
85
- @@ -166,6 +180,7 @@ export class AgentManager {
86
- isolated: options.isolated,
87
- inheritContext: options.inheritContext,
88
- thinkingLevel: options.thinkingLevel,
89
- + allowSupervisedCommands: this.supervisedById.get(id),
90
- // Worktree wins for the working dir (the agent must run in the copy —
91
- // which, with a custom cwd, was created from that target). Config stays
92
- // with the parent project when a caller-supplied cwd is in play; it must
93
- @@ -202,25 +217,29 @@ export class AgentManager {
94
- options.onSessionCreated?.(session);
95
- },
96
- })
97
- - .then(({ responseText, session, aborted, steered, failure }) => {
98
- + .then(async ({ responseText, session, aborted, steered, failure }) => {
99
- + let nextStatus = record.status;
100
- // Don't overwrite status if externally stopped via abort()
101
- if (record.status !== "stopped") {
102
- // Precedence: a hard abort keeps "aborted"; then a failed final turn
103
- // (provider error that pi resolved instead of rejecting, #144) is an
104
- // honest "error" — not a completion with an empty or stale result.
105
- if (aborted) {
106
- - record.status = "aborted";
107
- + nextStatus = "aborted";
108
- }
109
- else if (failure) {
110
- - record.status = "error";
111
- + nextStatus = "error";
112
- record.error = failure;
113
- }
114
- else {
115
- - record.status = steered ? "steered" : "completed";
116
- + nextStatus = steered ? "steered" : "completed";
117
- }
118
- }
119
- record.result = responseText;
120
- record.session = session;
121
- + await this.finalize(id, record, nextStatus === "error" ? "error" : "complete");
122
- + if (record.status !== "stopped")
123
- + record.status = nextStatus;
124
- record.completedAt ??= Date.now();
125
- detach();
126
- // Final flush of streaming output file
127
- @@ -262,12 +281,11 @@ export class AgentManager {
128
- }
129
- return responseText;
130
- })
131
- - .catch((err) => {
132
- - // Don't overwrite status if externally stopped via abort()
133
- - if (record.status !== "stopped") {
134
- - record.status = "error";
135
- - }
136
- + .catch(async (err) => {
137
- record.error = err instanceof Error ? err.message : String(err);
138
- + await this.finalize(id, record, "error");
139
- + if (record.status !== "stopped")
140
- + record.status = "error";
141
- record.completedAt ??= Date.now();
142
- detach();
143
- // Final flush of streaming output file on error
144
- @@ -439,10 +457,27 @@ export class AgentManager {
145
- if (record.status !== "running")
146
- return false;
147
- record.abortController?.abort();
148
- - record.status = "stopped";
149
- - record.completedAt = Date.now();
150
- + const done = this.finalize(id, record, "abort").then(() => {
151
- + record.status = "stopped";
152
- + record.completedAt ??= Date.now();
153
- + });
154
- + this.reaps.set(id, done);
155
- return true;
156
- }
157
- + finalize(id, record, reason) {
158
- + const existing = this.reaps.get(id);
159
- + if (existing)
160
- + return existing;
161
- + const work = (async () => {
162
- + const session = record.session;
163
- + const runner = session?._extensionRunner;
164
- + if (runner?.emit)
165
- + await runner.emit({ type: "session_shutdown", reason });
166
- + this.supervisedById.delete(id);
167
- + })();
168
- + this.reaps.set(id, work);
169
- + return work;
170
- + }
171
- /** Dispose a record's session and remove it from the map. */
172
- removeRecord(id, record) {
173
- record.session?.dispose?.();
174
- @@ -478,28 +513,14 @@ export class AgentManager {
175
- hasRunning() {
176
- return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
177
- }
178
- - /** Abort all running and queued agents immediately. */
179
- - abortAll() {
180
- + async abortAll() {
181
- let count = 0;
182
- - // Clear queued agents first
183
- - for (const queued of this.queue) {
184
- - const record = this.agents.get(queued.id);
185
- - if (record) {
186
- - record.status = "stopped";
187
- - record.completedAt = Date.now();
188
- + const ids = [...this.agents.keys()];
189
- + for (const id of ids) {
190
- + if (this.abort(id))
191
- count++;
192
- - }
193
- - }
194
- - this.queue = [];
195
- - // Abort running agents
196
- - for (const record of this.agents.values()) {
197
- - if (record.status === "running") {
198
- - record.abortController?.abort();
199
- - record.status = "stopped";
200
- - record.completedAt = Date.now();
201
- - count++;
202
- - }
203
- }
204
- + await Promise.all([...this.reaps.values()]);
205
- return count;
206
- }
207
- /** Wait for all running and queued agents to complete (including queued ones). */
208
- @@ -517,14 +538,16 @@ export class AgentManager {
209
- await Promise.allSettled(pending);
210
- }
211
- }
212
- - dispose() {
213
- + async dispose() {
214
- clearInterval(this.cleanupInterval);
215
- - // Clear queue
216
- + await this.abortAll();
217
- this.queue = [];
218
- - for (const record of this.agents.values()) {
219
- - record.session?.dispose();
220
- + for (const [id, record] of this.agents) {
221
- + this.supervisedById.delete(id);
222
- + record.session?.dispose?.();
223
- }
224
- this.agents.clear();
225
- + this.supervisedById.clear();
226
- // Prune any orphaned git worktrees (crash recovery)
227
- try {
228
- pruneWorktrees(process.cwd());
229
- diff --git a/dist/agent-runner.js b/dist/agent-runner.js
230
- index 40df116e6afdd84b6b9c74c65ddec25a14ce66bb..a38552e22b5cd0683ef438885c0c97e336e017fc 100644
231
- --- a/dist/agent-runner.js
232
- +++ b/dist/agent-runner.js
233
- @@ -1,9 +1,10 @@
234
- /**
235
- * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
236
- */
237
- -import { readFileSync } from "node:fs";
238
- +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
239
- import { homedir } from "node:os";
240
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
241
- +import { fileURLToPath } from "node:url";
242
- import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
243
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
244
- import { buildParentContext, extractText } from "./context.js";
245
- @@ -23,6 +24,26 @@ export const SUBAGENT_TOOL_NAMES = {
246
- GET_RESULT: "get_subagent_result",
247
- STEER: "steer_subagent",
248
- };
249
- +export function supervisedSiblingPath() {
250
- + const fromEnv = process.env.LARKIN_PI_SUPERVISED_BUNDLE;
251
- + if (typeof fromEnv === "string" && fromEnv.length > 0)
252
- + return fromEnv;
253
- + return join(dirname(fileURLToPath(import.meta.url)), "pi-supervised-command.bundle.js");
254
- +}
255
- +function resolveTrustedSupervisedBundle(candidate) {
256
- + if (candidate === undefined)
257
- + return undefined;
258
- + if (typeof candidate !== "string" || candidate.length === 0)
259
- + throw new Error("supervised command bundle is missing");
260
- + const sibling = supervisedSiblingPath();
261
- + for (const file of [sibling, candidate]) {
262
- + if (!existsSync(file) || lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile())
263
- + throw new Error("supervised command bundle is missing");
264
- + }
265
- + if (realpathSync(candidate) !== realpathSync(sibling))
266
- + throw new Error("supervised command bundle is missing");
267
- + return realpathSync(sibling);
268
- +}
269
- /** Names of tools registered by this extension that subagents must NOT inherit. */
270
- const EXCLUDED_TOOL_NAMES = Object.values(SUBAGENT_TOOL_NAMES);
271
- /**
272
- @@ -483,7 +504,11 @@ export async function runAgent(ctx, type, prompt, options) {
273
- // It's only needed when we're neither loading everything without excludes
274
- // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
275
- const loadAll = extensions === true || extensionsSpec?.wildcard === true;
276
- - const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
277
- + const supervisedPath = resolveTrustedSupervisedBundle(options.allowSupervisedCommands);
278
- + const additionalExtensionPaths = [
279
- + ...(extensionsSpec?.paths ?? []),
280
- + ...(supervisedPath ? [supervisedPath] : []),
281
- + ];
282
- // Pre-filter discovered set, captured by the override — the exclude-typo warning
283
- // must compare against this, not the surviving set (absence from survivors is
284
- // an exclude *succeeding*).
285
- @@ -506,7 +531,7 @@ export async function runAgent(ctx, type, prompt, options) {
286
- cwd: configCwd,
287
- agentDir,
288
- noExtensions,
289
- - additionalExtensionPaths,
290
- + additionalExtensionPaths: additionalExtensionPaths.length ? additionalExtensionPaths : undefined,
291
- extensionsOverride,
292
- noSkills,
293
- noPromptTemplates: true,
294
- diff --git a/dist/index.js b/dist/index.js
295
- index ab65ef15b5c1da803c0378b1d0ba383645198028..c6e7ccd02f016c36c6f098323e88c07063862805 100644
296
- --- a/dist/index.js
297
- +++ b/dist/index.js
298
- @@ -10,12 +10,13 @@
299
- * /agents — Interactive agent management menu
300
- */
301
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
302
- -import { join } from "node:path";
303
- +import { dirname, join } from "node:path";
304
- +import { fileURLToPath } from "node:url";
305
- import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
306
- import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
307
- import { Type } from "@sinclair/typebox";
308
- import { AgentManager } from "./agent-manager.js";
309
- -import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
310
- +import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent, supervisedSiblingPath } from "./agent-runner.js";
311
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
312
- import { registerRpcHandlers } from "./cross-extension-rpc.js";
313
- import { loadCustomAgents } from "./custom-agents.js";
314
- @@ -69,6 +70,45 @@ function abortable(promise, signal) {
315
- });
316
- });
317
- }
318
- +const RESULT_WAIT_QUEUE_POLL_MS = 50;
319
- +const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
320
- +const MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
321
- +function normalizeResultWaitTimeout(timeoutMs) {
322
- + if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
323
- + return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
324
- + return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
325
- +}
326
- +function waitWithDeadline(promise, timeoutMs, signal) {
327
- + let timer;
328
- + const timeout = new Promise((resolve) => {
329
- + timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
330
- + });
331
- + return abortable(Promise.race([
332
- + promise.then((value) => ({ timedOut: false, value })),
333
- + timeout,
334
- + ]), signal).finally(() => {
335
- + if (timer !== undefined)
336
- + clearTimeout(timer);
337
- + });
338
- +}
339
- +/** Wait for one background record without cancelling it when the deadline expires. */
340
- +export async function waitForSubagentResult(record, timeoutMs, signal) {
341
- + const deadline = Date.now() + timeoutMs;
342
- + while (record.status === "queued") {
343
- + const remainingMs = deadline - Date.now();
344
- + if (remainingMs <= 0)
345
- + return true;
346
- + await abortable(new Promise((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))), signal);
347
- + }
348
- + if (!record.promise)
349
- + return false;
350
- + const remainingMs = deadline - Date.now();
351
- + if (remainingMs <= 0)
352
- + return true;
353
- + return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
354
- +}
355
- +const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
356
- +const LARKIN_SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
357
- export function renderRunningAgentStatus(frame, statsText, activity, theme) {
358
- const container = new Container();
359
- container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
360
- @@ -280,9 +320,14 @@ export default function (pi) {
361
- // before they reach pi.sendMessage (fire-and-forget).
362
- const pendingNudges = new Map();
363
- const NUDGE_HOLD_MS = 200;
364
- + // A parent agent run may span multiple model turns. Consume the one-wait
365
- + // budget when a queued/running wait begins, rather than only after timeout.
366
- + let resultWaitConsumedInParentTurn = false;
367
- + pi.on("agent_start", () => {
368
- + resultWaitConsumedInParentTurn = false;
369
- + });
370
- // A queued result wait must observe completion before its held notification
371
- // can fire, so successful waits can still suppress that redundant nudge.
372
- - const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
373
- function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
374
- cancelNudge(key);
375
- pendingNudges.set(key, setTimeout(() => {
376
- @@ -378,6 +423,13 @@ export default function (pi) {
377
- };
378
- }
379
- // Background completion: route through group join or send individual nudge
380
- + function supervisedCommandBundlePath() {
381
- + const sibling = supervisedSiblingPath();
382
- + if (!existsSync(sibling))
383
- + throw new Error("supervised command bundle is missing");
384
- + return sibling;
385
- + }
386
- + const supervisedKey = Symbol("larkin-supervised-commands");
387
- const manager = new AgentManager((record) => {
388
- // Emit lifecycle event based on terminal status
389
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
390
- @@ -432,7 +484,7 @@ export default function (pi) {
391
- tokensBefore: info.tokensBefore,
392
- compactionCount: record.compactionCount,
393
- });
394
- - });
395
- + }, supervisedKey);
396
- // Expose manager via Symbol.for() global registry for cross-package access.
397
- // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
398
- //
399
- @@ -525,12 +577,12 @@ export default function (pi) {
400
- delete globalThis[MANAGER_KEY];
401
- }
402
- scheduler.stop();
403
- - manager.abortAll();
404
- + await manager.abortAll();
405
- for (const timer of pendingNudges.values())
406
- clearTimeout(timer);
407
- pendingNudges.clear();
408
- fleet.dispose();
409
- - manager.dispose();
410
- + await manager.dispose();
411
- });
412
- // Live widget: show running agents above editor.
413
- // widgetMode (default "background") selects what the widget shows: "all" =
414
- @@ -1124,6 +1176,7 @@ Terse command-style prompts produce shallow, generic work.
415
- inheritContext,
416
- thinkingLevel: thinking,
417
- isBackground: true,
418
- + ...(params.run_in_background === true ? { [supervisedKey]: supervisedCommandBundlePath() } : {}),
419
- isolation,
420
- invocation: agentInvocation,
421
- ...bgCallbacks,
422
- @@ -1293,6 +1346,9 @@ Terse command-style prompts produce shallow, generic work.
423
- wait: Type.Optional(Type.Boolean({
424
- description: "If true, wait for the agent to complete before returning. Default: false.",
425
- })),
426
- + timeout_ms: Type.Optional(Type.Number({
427
- + description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
428
- + })),
429
- verbose: Type.Optional(Type.Boolean({
430
- description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
431
- })),
432
- @@ -1307,12 +1363,15 @@ Terse command-style prompts produce shallow, generic work.
433
- // completion notification can still be delivered.
434
- // Queued agents have no promise yet (it's created when the queue starts
435
- // them), so poll until they leave the queue, then await like a running one.
436
- + // One deadline covers both phases so queue polling cannot reset the wait.
437
- + if (params.wait && resultWaitConsumedInParentTurn) {
438
- + throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
439
- + }
440
- + let timedOut = false;
441
- + const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
442
- if (params.wait && (record.status === "running" || record.status === "queued")) {
443
- - while (record.status === "queued") {
444
- - await abortable(new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
445
- - }
446
- - if (record.promise)
447
- - await abortable(record.promise, signal);
448
- + resultWaitConsumedInParentTurn = true;
449
- + timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
450
- }
451
- const displayName = getDisplayName(record.type);
452
- const duration = formatDuration(record.startedAt, record.completedAt);
453
- @@ -1327,10 +1386,12 @@ Terse command-style prompts produce shallow, generic work.
454
- statsParts.push(`Compactions: ${record.compactionCount}`);
455
- statsParts.push(`Duration: ${duration}`);
456
- let output = `Agent: ${record.id}\n` +
457
- - `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
458
- + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
459
- `Description: ${record.description}\n\n`;
460
- - if (record.status === "running") {
461
- - output += "Agent is still running. Use wait: true or check back later.";
462
- + if (record.status === "running" || record.status === "queued") {
463
- + output += timedOut
464
- + ? `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.`
465
- + : "Agent is still running. Use wait: true or check back later.";
466
- }
467
- else if (record.status === "error") {
468
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
469
- diff --git a/src/agent-manager.ts b/src/agent-manager.ts
470
- index 318f88588a05aa1459e7c86288aa886ea85a28b1..8e552a73b9ef1e269a2a8f712e50ae1382c54ad6 100644
471
- --- a/src/agent-manager.ts
472
- +++ b/src/agent-manager.ts
473
- @@ -113,16 +113,21 @@ export class AgentManager {
474
- /** Number of currently running background agents. */
475
- private runningBackground = 0;
476
-
477
- + private supervisedKey?: symbol;
478
- + private supervisedById = new Map<string, string>();
479
- +
480
- constructor(
481
- onComplete?: OnAgentComplete,
482
- maxConcurrent = DEFAULT_MAX_CONCURRENT,
483
- onStart?: OnAgentStart,
484
- onCompact?: OnAgentCompact,
485
- + supervisedKey?: symbol,
486
- ) {
487
- this.onComplete = onComplete;
488
- this.onStart = onStart;
489
- this.onCompact = onCompact;
490
- this.maxConcurrent = maxConcurrent;
491
- + this.supervisedKey = supervisedKey;
492
- // Cleanup completed agents after 10 minutes (but keep sessions for resume)
493
- this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
494
- this.cleanupInterval.unref();
495
- @@ -154,8 +159,19 @@ export class AgentManager {
496
- // call, not minutes later at drain. Throw (not warn): programmatic callers
497
- // can fix and retry; the RPC layer converts throws into error envelopes.
498
- assertValidSpawnCwd(options.cwd);
499
- + if (Object.prototype.hasOwnProperty.call(options, "allowSupervisedCommands")) {
500
- + throw new Error("allowSupervisedCommands is not a public spawn option");
501
- + }
502
- + const supervisedPath = this.supervisedKey
503
- + ? (options as Record<symbol, unknown>)[this.supervisedKey]
504
- + : undefined;
505
- + if (typeof supervisedPath === "string" && supervisedPath.length === 0) {
506
- + throw new Error("supervised command bundle is missing");
507
- + }
508
-
509
- const id = randomUUID().slice(0, 17);
510
- + if (typeof supervisedPath === "string") this.supervisedById.set(id, supervisedPath);
511
- + else this.supervisedById.delete(id);
512
- const abortController = new AbortController();
513
- const record: AgentRecord = {
514
- id,
515
- @@ -190,6 +206,7 @@ export class AgentManager {
516
- try {
517
- this.startAgent(id, record, args);
518
- } catch (err) {
519
- + this.supervisedById.delete(id);
520
- this.agents.delete(id);
521
- throw err;
522
- }
523
- @@ -252,6 +269,7 @@ export class AgentManager {
524
- isolated: options.isolated,
525
- inheritContext: options.inheritContext,
526
- thinkingLevel: options.thinkingLevel,
527
- + allowSupervisedCommands: this.supervisedById.get(id),
528
- // Worktree wins for the working dir (the agent must run in the copy —
529
- // which, with a custom cwd, was created from that target). Config stays
530
- // with the parent project when a caller-supplied cwd is in play; it must
531
- @@ -287,23 +305,26 @@ export class AgentManager {
532
- options.onSessionCreated?.(session);
533
- },
534
- })
535
- - .then(({ responseText, session, aborted, steered, failure }) => {
536
- + .then(async ({ responseText, session, aborted, steered, failure }) => {
537
- // Don't overwrite status if externally stopped via abort()
538
- + let nextStatus = record.status;
539
- if (record.status !== "stopped") {
540
- // Precedence: a hard abort keeps "aborted"; then a failed final turn
541
- // (provider error that pi resolved instead of rejecting, #144) is an
542
- // honest "error" — not a completion with an empty or stale result.
543
- if (aborted) {
544
- - record.status = "aborted";
545
- + nextStatus = "aborted";
546
- } else if (failure) {
547
- - record.status = "error";
548
- + nextStatus = "error";
549
- record.error = failure;
550
- } else {
551
- - record.status = steered ? "steered" : "completed";
552
- + nextStatus = steered ? "steered" : "completed";
553
- }
554
- }
555
- record.result = responseText;
556
- record.session = session;
557
- + await this.finalize(id, record, nextStatus === "error" ? "error" : "complete");
558
- + if (record.status !== "stopped") record.status = nextStatus;
559
- record.completedAt ??= Date.now();
560
-
561
- detach();
562
- @@ -339,12 +360,10 @@ export class AgentManager {
563
- }
564
- return responseText;
565
- })
566
- - .catch((err) => {
567
- - // Don't overwrite status if externally stopped via abort()
568
- - if (record.status !== "stopped") {
569
- - record.status = "error";
570
- - }
571
- + .catch(async (err) => {
572
- record.error = err instanceof Error ? err.message : String(err);
573
- + await this.finalize(id, record, "error");
574
- + if (record.status !== "stopped") record.status = "error";
575
- record.completedAt ??= Date.now();
576
-
577
- detach();
578
- @@ -525,18 +544,41 @@ export class AgentManager {
579
- this.queue = this.queue.filter(q => q.id !== id);
580
- record.status = "stopped";
581
- record.completedAt = Date.now();
582
- + this.supervisedById.delete(id);
583
- return true;
584
- }
585
-
586
- - if (record.status !== "running") return false;
587
- + if (record.status !== "running") {
588
- + this.supervisedById.delete(id);
589
- + return false;
590
- + }
591
- record.abortController?.abort();
592
- - record.status = "stopped";
593
- - record.completedAt = Date.now();
594
- + const done = this.finalize(id, record, "abort").then(() => {
595
- + record.status = "stopped";
596
- + record.completedAt ??= Date.now();
597
- + });
598
- + this.reaps.set(id, done);
599
- return true;
600
- }
601
-
602
- + private reaps = new Map<string, Promise<void>>();
603
- +
604
- + private finalize(id: string, record: AgentRecord, reason: string): Promise<void> {
605
- + const existing = this.reaps.get(id);
606
- + if (existing) return existing;
607
- + const work = (async () => {
608
- + const session = record.session as { _extensionRunner?: { emit?: (event: unknown) => Promise<unknown> } } | undefined;
609
- + const runner = session?._extensionRunner;
610
- + if (runner?.emit) await runner.emit({ type: "session_shutdown", reason });
611
- + this.supervisedById.delete(id);
612
- + })();
613
- + this.reaps.set(id, work);
614
- + return work;
615
- + }
616
- +
617
- /** Dispose a record's session and remove it from the map. */
618
- private removeRecord(id: string, record: AgentRecord): void {
619
- + this.supervisedById.delete(id);
620
- record.session?.dispose?.();
621
- record.session = undefined;
622
- this.agents.delete(id);
623
- @@ -572,28 +614,14 @@ export class AgentManager {
624
- );
625
- }
626
-
627
- - /** Abort all running and queued agents immediately. */
628
- - abortAll(): number {
629
- + /** Abort all running and queued agents and wait until child session_shutdown/reap finishes. */
630
- + async abortAll(): Promise<number> {
631
- let count = 0;
632
- - // Clear queued agents first
633
- - for (const queued of this.queue) {
634
- - const record = this.agents.get(queued.id);
635
- - if (record) {
636
- - record.status = "stopped";
637
- - record.completedAt = Date.now();
638
- - count++;
639
- - }
640
- - }
641
- - this.queue = [];
642
- - // Abort running agents
643
- - for (const record of this.agents.values()) {
644
- - if (record.status === "running") {
645
- - record.abortController?.abort();
646
- - record.status = "stopped";
647
- - record.completedAt = Date.now();
648
- - count++;
649
- - }
650
- + const ids = [...this.agents.keys()];
651
- + for (const id of ids) {
652
- + if (this.abort(id)) count++;
653
- }
654
- + await Promise.all([...this.reaps.values()]);
655
- return count;
656
- }
657
-
658
- @@ -612,14 +640,17 @@ export class AgentManager {
659
- }
660
- }
661
-
662
- - dispose() {
663
- + async dispose() {
664
- clearInterval(this.cleanupInterval);
665
- - // Clear queue
666
- + await this.abortAll();
667
- this.queue = [];
668
- - for (const record of this.agents.values()) {
669
- - record.session?.dispose();
670
- + for (const [id, record] of this.agents) {
671
- + this.supervisedById.delete(id);
672
- + const session = record.session as { dispose?: () => void } | undefined;
673
- + session?.dispose?.();
674
- }
675
- this.agents.clear();
676
- + this.supervisedById.clear();
677
- // Prune any orphaned git worktrees (crash recovery)
678
- try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
679
- // Also prune repos that caller-supplied cwds created worktrees in — a clean
680
- diff --git a/src/agent-runner.ts b/src/agent-runner.ts
681
- index 444eb84ab3769c6dba8d1e764b88ac99d968d392..f297775376233e0c66c586d9fe186df378b068f5 100644
682
- --- a/src/agent-runner.ts
683
- +++ b/src/agent-runner.ts
684
- @@ -2,9 +2,10 @@
685
- * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
686
- */
687
-
688
- -import { readFileSync } from "node:fs";
689
- +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
690
- import { homedir } from "node:os";
691
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
692
- +import { fileURLToPath } from "node:url";
693
- import type { Model } from "@earendil-works/pi-ai";
694
- import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
695
- import {
696
- @@ -38,6 +39,29 @@ export const SUBAGENT_TOOL_NAMES = {
697
- STEER: "steer_subagent",
698
- } as const;
699
-
700
- +export function supervisedSiblingPath(): string {
701
- + const fromEnv = process.env.LARKIN_PI_SUPERVISED_BUNDLE;
702
- + if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
703
- + return join(dirname(fileURLToPath(import.meta.url)), "pi-supervised-command.bundle.js");
704
- +}
705
- +
706
- +function resolveTrustedSupervisedBundle(candidate: string | undefined): string | undefined {
707
- + if (candidate === undefined) return undefined;
708
- + if (typeof candidate !== "string" || candidate.length === 0) {
709
- + throw new Error("supervised command bundle is missing");
710
- + }
711
- + const sibling = supervisedSiblingPath();
712
- + for (const file of [sibling, candidate]) {
713
- + if (!existsSync(file) || lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile()) {
714
- + throw new Error("supervised command bundle is missing");
715
- + }
716
- + }
717
- + if (realpathSync(candidate) !== realpathSync(sibling)) {
718
- + throw new Error("supervised command bundle is missing");
719
- + }
720
- + return realpathSync(sibling);
721
- +}
722
- +
723
- /** Names of tools registered by this extension that subagents must NOT inherit. */
724
- const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
725
-
726
- @@ -358,6 +382,8 @@ export interface RunOptions {
727
- isolated?: boolean;
728
- inheritContext?: boolean;
729
- thinkingLevel?: ThinkingLevel;
730
- + /** Absolute path to the supervised-command extension. Set only by Agent(run_in_background: true). */
731
- + allowSupervisedCommands?: string;
732
- /** Override working directory (e.g. for worktree isolation). */
733
- cwd?: string;
734
- /**
735
- @@ -612,7 +638,11 @@ export async function runAgent(
736
- // It's only needed when we're neither loading everything without excludes
737
- // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
738
- const loadAll = extensions === true || extensionsSpec?.wildcard === true;
739
- - const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
740
- + const supervisedPath = resolveTrustedSupervisedBundle(options.allowSupervisedCommands);
741
- + const additionalExtensionPaths = [
742
- + ...(extensionsSpec?.paths ?? []),
743
- + ...(supervisedPath ? [supervisedPath] : []),
744
- + ];
745
- // Pre-filter discovered set, captured by the override — the exclude-typo warning
746
- // must compare against this, not the surviving set (absence from survivors is
747
- // an exclude *succeeding*).
748
- @@ -636,7 +666,7 @@ export async function runAgent(
749
- cwd: configCwd,
750
- agentDir,
751
- noExtensions,
752
- - additionalExtensionPaths,
753
- + additionalExtensionPaths: additionalExtensionPaths.length ? additionalExtensionPaths : undefined,
754
- extensionsOverride,
755
- noSkills,
756
- noPromptTemplates: true,
757
- diff --git a/src/index.ts b/src/index.ts
758
- index 828938e6d710dc5a8f99a6185e37f9592b12352d..379fb9567b671d0349699212eabc8e20151f5e95 100644
759
- --- a/src/index.ts
760
- +++ b/src/index.ts
761
- @@ -11,12 +11,13 @@
762
- */
763
-
764
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
765
- -import { join } from "node:path";
766
- +import { dirname, join } from "node:path";
767
- +import { fileURLToPath } from "node:url";
768
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
769
- import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
770
- import { Type } from "@sinclair/typebox";
771
- import { AgentManager } from "./agent-manager.js";
772
- -import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
773
- +import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent, supervisedSiblingPath } from "./agent-runner.js";
774
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
775
- import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
776
- import { loadCustomAgents } from "./custom-agents.js";
777
- @@ -91,6 +92,55 @@ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
778
- });
779
- }
780
-
781
- +const RESULT_WAIT_QUEUE_POLL_MS = 50;
782
- +const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30_000;
783
- +const MAX_RESULT_WAIT_TIMEOUT_MS = 60_000;
784
- +
785
- +function normalizeResultWaitTimeout(timeoutMs: number | undefined): number {
786
- + if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
787
- + return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
788
- +}
789
- +
790
- +function waitWithDeadline<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<{ timedOut: true } | { timedOut: false; value: T }> {
791
- + let timer: ReturnType<typeof setTimeout> | undefined;
792
- + const timeout = new Promise<{ timedOut: true }>((resolve) => {
793
- + timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
794
- + });
795
- + return abortable(
796
- + Promise.race([
797
- + promise.then((value) => ({ timedOut: false as const, value })),
798
- + timeout,
799
- + ]),
800
- + signal,
801
- + ).finally(() => {
802
- + if (timer !== undefined) clearTimeout(timer);
803
- + });
804
- +}
805
- +
806
- +/** Wait for one background record without cancelling it when the deadline expires. */
807
- +export async function waitForSubagentResult(
808
- + record: Pick<AgentRecord, "status" | "promise">,
809
- + timeoutMs: number,
810
- + signal?: AbortSignal,
811
- +): Promise<boolean> {
812
- + const deadline = Date.now() + timeoutMs;
813
- + while (record.status === "queued") {
814
- + const remainingMs = deadline - Date.now();
815
- + if (remainingMs <= 0) return true;
816
- + await abortable(
817
- + new Promise<void>((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))),
818
- + signal,
819
- + );
820
- + }
821
- + if (!record.promise) return false;
822
- + const remainingMs = deadline - Date.now();
823
- + if (remainingMs <= 0) return true;
824
- + return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
825
- +}
826
- +
827
- +export const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
828
- +export const LARKIN_SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
829
- +
830
- export function renderRunningAgentStatus(
831
- frame: string,
832
- statsText: string,
833
- @@ -330,10 +380,14 @@ export default function (pi: ExtensionAPI) {
834
- // before they reach pi.sendMessage (fire-and-forget).
835
- const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
836
- const NUDGE_HOLD_MS = 200;
837
- + // A parent agent run may span multiple model turns. Consume the one-wait
838
- + // budget when a queued/running wait begins, rather than only after timeout.
839
- + let resultWaitConsumedInParentTurn = false;
840
- + pi.on("agent_start", () => {
841
- + resultWaitConsumedInParentTurn = false;
842
- + });
843
- // A queued result wait must observe completion before its held notification
844
- // can fire, so successful waits can still suppress that redundant nudge.
845
- - const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
846
- -
847
- function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
848
- cancelNudge(key);
849
- pendingNudges.set(key, setTimeout(() => {
850
- @@ -433,6 +487,12 @@ export default function (pi: ExtensionAPI) {
851
- }
852
-
853
- // Background completion: route through group join or send individual nudge
854
- + function supervisedCommandBundlePath(): string {
855
- + const sibling = supervisedSiblingPath();
856
- + if (!existsSync(sibling)) throw new Error("supervised command bundle is missing");
857
- + return sibling;
858
- + }
859
- + const supervisedKey = Symbol("larkin-supervised-commands");
860
- const manager = new AgentManager((record) => {
861
- // Emit lifecycle event based on terminal status
862
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
863
- @@ -490,7 +550,7 @@ export default function (pi: ExtensionAPI) {
864
- tokensBefore: info.tokensBefore,
865
- compactionCount: record.compactionCount,
866
- });
867
- - });
868
- + }, supervisedKey);
869
-
870
- // Expose manager via Symbol.for() global registry for cross-package access.
871
- // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
872
- @@ -588,11 +648,11 @@ export default function (pi: ExtensionAPI) {
873
- delete (globalThis as any)[MANAGER_KEY];
874
- }
875
- scheduler.stop();
876
- - manager.abortAll();
877
- + await manager.abortAll();
878
- for (const timer of pendingNudges.values()) clearTimeout(timer);
879
- pendingNudges.clear();
880
- fleet.dispose();
881
- - manager.dispose();
882
- + await manager.dispose();
883
- });
884
-
885
- // Live widget: show running agents above editor.
886
- @@ -1259,6 +1319,7 @@ Terse command-style prompts produce shallow, generic work.
887
- inheritContext,
888
- thinkingLevel: thinking,
889
- isBackground: true,
890
- + ...(params.run_in_background === true ? { [supervisedKey]: supervisedCommandBundlePath() } : {}),
891
- isolation,
892
- invocation: agentInvocation,
893
- ...bgCallbacks,
894
- @@ -1453,6 +1514,11 @@ Terse command-style prompts produce shallow, generic work.
895
- description: "If true, wait for the agent to complete before returning. Default: false.",
896
- }),
897
- ),
898
- + timeout_ms: Type.Optional(
899
- + Type.Number({
900
- + description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
901
- + }),
902
- + ),
903
- verbose: Type.Optional(
904
- Type.Boolean({
905
- description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
906
- @@ -1470,14 +1536,15 @@ Terse command-style prompts produce shallow, generic work.
907
- // completion notification can still be delivered.
908
- // Queued agents have no promise yet (it's created when the queue starts
909
- // them), so poll until they leave the queue, then await like a running one.
910
- + // One deadline covers both phases so queue polling cannot reset the wait.
911
- + if (params.wait && resultWaitConsumedInParentTurn) {
912
- + throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
913
- + }
914
- + let timedOut = false;
915
- + const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
916
- if (params.wait && (record.status === "running" || record.status === "queued")) {
917
- - while (record.status === "queued") {
918
- - await abortable(
919
- - new Promise<void>((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)),
920
- - signal,
921
- - );
922
- - }
923
- - if (record.promise) await abortable(record.promise, signal);
924
- + resultWaitConsumedInParentTurn = true;
925
- + timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
926
- }
927
-
928
- const displayName = getDisplayName(record.type);
929
- @@ -1492,11 +1559,13 @@ Terse command-style prompts produce shallow, generic work.
930
-
931
- let output =
932
- `Agent: ${record.id}\n` +
933
- - `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
934
- + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
935
- `Description: ${record.description}\n\n`;
936
-
937
- - if (record.status === "running") {
938
- - output += "Agent is still running. Use wait: true or check back later.";
939
- + if (record.status === "running" || record.status === "queued") {
940
- + output += timedOut
941
- + ? `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.`
942
- + : "Agent is still running. Use wait: true or check back later.";
943
- } else if (record.status === "error") {
944
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
945
- } else {