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.
- package/CHANGELOG.md +12 -0
- package/README.md +22 -1
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +1 -100
- package/dist/agent/agent-cli-capabilities.cjs +1 -1
- package/dist/agent/agent-cli-capabilities.mjs +1 -1
- package/dist/agent/config-cli-contract.cjs +3 -1
- package/dist/agent/config-cli-contract.mjs +3 -1
- package/dist/agent/context-prompt.cjs +16 -17
- package/dist/agent/context-prompt.mjs +15 -16
- package/dist/agent/inbox-audit-heartbeat.cjs +157 -37
- package/dist/agent/inbox-audit-heartbeat.mjs +122 -36
- package/dist/agent/missed-outbound-scan.cjs +326 -54
- package/dist/agent/missed-outbound-scan.mjs +319 -53
- package/dist/app/agent-cli.mjs +37 -6
- package/dist/app/agent-config.mjs +36 -5
- package/dist/app/cli.mjs +1 -0
- package/dist/dashboard/dashboard-config-controller.mjs +15 -0
- package/dist/dashboard/web/assets/dashboard.css +1 -1
- package/dist/dashboard/web/assets/dashboard.js +15 -15
- package/dist/feishu/host-shell.cjs +63 -5
- package/dist/feishu/host-shell.mjs +66 -8
- package/dist/platform/config.cjs +173 -5
- package/dist/platform/config.mjs +167 -4
- package/dist/runtime/pi-state-dir.cjs +105 -0
- package/dist/runtime/pi-state-dir.mjs +94 -0
- package/dist/runtime/pi-tmux-extension.mjs +137 -0
- package/dist/runtime/pi-tmux-injection.mjs +49 -0
- package/dist/runtime/pi-tmux.bundle.js +600 -0
- package/dist/runtime/pi-tmux.mjs +408 -0
- package/dist/runtime/runtime-adapters.mjs +26 -113
- package/dist/runtime/runtime-host.cjs +3 -413
- package/dist/runtime/runtime-host.mjs +2 -412
- package/package.json +2 -8
- package/dist/runtime/pi-bash-timeout-extension.mjs +0 -60
- package/dist/runtime/pi-bash-timeout-injection.mjs +0 -54
- package/dist/runtime/pi-bash-timeout.bundle.js +0 -43
- package/dist/runtime/pi-extension-api.mjs +0 -1
- package/dist/runtime/pi-subagent-injection.mjs +0 -290
- package/dist/runtime/pi-subagent-ledger.cjs +0 -632
- package/dist/runtime/pi-subagent-ledger.mjs +0 -594
- package/dist/runtime/pi-subagent-record-watchdog-injection.mjs +0 -43
- package/dist/runtime/pi-subagent-record-watchdog.bundle.js +0 -183
- package/dist/runtime/pi-subagent-record-watchdog.mjs +0 -46
- package/dist/runtime/pi-subagents-notification.mjs +0 -175
- package/dist/runtime/pi-subagents.bundle.js +0 -9110
- package/dist/runtime/pi-supervised-command-extension.mjs +0 -72
- package/dist/runtime/pi-supervised-command.bundle.js +0 -4735
- package/dist/runtime/pi-supervised-command.mjs +0 -420
- 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 {
|