larkin 0.4.19 → 0.4.21

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.
@@ -1,17 +1,317 @@
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
1
13
  diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-58e08ce8246a76a0 b/.bun-tag-58e08ce8246a76a0
2
14
  new file mode 100644
3
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
4
19
  diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-6ef69510e99ece5d b/.bun-tag-6ef69510e99ece5d
5
20
  new file mode 100644
6
21
  index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7
22
  diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-80c5d241789d23ea b/.bun-tag-80c5d241789d23ea
8
23
  new file mode 100644
9
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,
10
294
  diff --git a/dist/index.js b/dist/index.js
11
- index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b39fdfd60 100644
295
+ index ab65ef15b5c1da803c0378b1d0ba383645198028..c6e7ccd02f016c36c6f098323e88c07063862805 100644
12
296
  --- a/dist/index.js
13
297
  +++ b/dist/index.js
14
- @@ -69,6 +69,44 @@ function abortable(promise, signal) {
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) {
15
315
  });
16
316
  });
17
317
  }
@@ -53,10 +353,11 @@ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b
53
353
  + return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
54
354
  +}
55
355
  +const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
356
+ +const LARKIN_SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
56
357
  export function renderRunningAgentStatus(frame, statsText, activity, theme) {
57
358
  const container = new Container();
58
359
  container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
59
- @@ -280,9 +318,14 @@ export default function (pi) {
360
+ @@ -280,9 +320,14 @@ export default function (pi) {
60
361
  // before they reach pi.sendMessage (fire-and-forget).
61
362
  const pendingNudges = new Map();
62
363
  const NUDGE_HOLD_MS = 200;
@@ -72,7 +373,53 @@ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b
72
373
  function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
73
374
  cancelNudge(key);
74
375
  pendingNudges.set(key, setTimeout(() => {
75
- @@ -1293,6 +1336,9 @@ Terse command-style prompts produce shallow, generic work.
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.
76
423
  wait: Type.Optional(Type.Boolean({
77
424
  description: "If true, wait for the agent to complete before returning. Default: false.",
78
425
  })),
@@ -82,7 +429,7 @@ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b
82
429
  verbose: Type.Optional(Type.Boolean({
83
430
  description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
84
431
  })),
85
- @@ -1307,12 +1353,15 @@ Terse command-style prompts produce shallow, generic work.
432
+ @@ -1307,12 +1363,15 @@ Terse command-style prompts produce shallow, generic work.
86
433
  // completion notification can still be delivered.
87
434
  // Queued agents have no promise yet (it's created when the queue starts
88
435
  // them), so poll until they leave the queue, then await like a running one.
@@ -103,7 +450,7 @@ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b
103
450
  }
104
451
  const displayName = getDisplayName(record.type);
105
452
  const duration = formatDuration(record.startedAt, record.completedAt);
106
- @@ -1327,10 +1376,12 @@ Terse command-style prompts produce shallow, generic work.
453
+ @@ -1327,10 +1386,12 @@ Terse command-style prompts produce shallow, generic work.
107
454
  statsParts.push(`Compactions: ${record.compactionCount}`);
108
455
  statsParts.push(`Duration: ${duration}`);
109
456
  let output = `Agent: ${record.id}\n` +
@@ -119,14 +466,318 @@ index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b
119
466
  }
120
467
  else if (record.status === "error") {
121
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,
122
757
  diff --git a/src/index.ts b/src/index.ts
123
- index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95bbe4bb1f 100644
758
+ index 828938e6d710dc5a8f99a6185e37f9592b12352d..379fb9567b671d0349699212eabc8e20151f5e95 100644
124
759
  --- a/src/index.ts
125
760
  +++ b/src/index.ts
126
- @@ -91,6 +91,54 @@ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
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> {
127
778
  });
128
779
  }
129
-
780
+
130
781
  +const RESULT_WAIT_QUEUE_POLL_MS = 50;
131
782
  +const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30_000;
132
783
  +const MAX_RESULT_WAIT_TIMEOUT_MS = 60_000;
@@ -174,11 +825,12 @@ index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95
174
825
  +}
175
826
  +
176
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";
177
829
  +
178
830
  export function renderRunningAgentStatus(
179
831
  frame: string,
180
832
  statsText: string,
181
- @@ -330,10 +378,14 @@ export default function (pi: ExtensionAPI) {
833
+ @@ -330,10 +380,14 @@ export default function (pi: ExtensionAPI) {
182
834
  // before they reach pi.sendMessage (fire-and-forget).
183
835
  const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
184
836
  const NUDGE_HOLD_MS = 200;
@@ -195,7 +847,51 @@ index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95
195
847
  function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
196
848
  cancelNudge(key);
197
849
  pendingNudges.set(key, setTimeout(() => {
198
- @@ -1453,6 +1505,11 @@ Terse command-style prompts produce shallow, generic work.
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.
199
895
  description: "If true, wait for the agent to complete before returning. Default: false.",
200
896
  }),
201
897
  ),
@@ -207,7 +903,7 @@ index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95
207
903
  verbose: Type.Optional(
208
904
  Type.Boolean({
209
905
  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.
906
+ @@ -1470,14 +1536,15 @@ Terse command-style prompts produce shallow, generic work.
211
907
  // completion notification can still be delivered.
212
908
  // Queued agents have no promise yet (it's created when the queue starts
213
909
  // them), so poll until they leave the queue, then await like a running one.
@@ -228,16 +924,16 @@ index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95
228
924
  + resultWaitConsumedInParentTurn = true;
229
925
  + timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
230
926
  }
231
-
927
+
232
928
  const displayName = getDisplayName(record.type);
233
- @@ -1492,11 +1550,13 @@ Terse command-style prompts produce shallow, generic work.
234
-
929
+ @@ -1492,11 +1559,13 @@ Terse command-style prompts produce shallow, generic work.
930
+
235
931
  let output =
236
932
  `Agent: ${record.id}\n` +
237
933
  - `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
238
934
  + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
239
935
  `Description: ${record.description}\n\n`;
240
-
936
+
241
937
  - if (record.status === "running") {
242
938
  - output += "Agent is still running. Use wait: true or check back later.";
243
939
  + if (record.status === "running" || record.status === "queued") {