larkin 0.4.14 → 0.4.16
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/dist/agent/context-prompt.cjs +2 -1
- package/dist/agent/context-prompt.mjs +2 -1
- package/dist/app/setup.mjs +3 -2
- package/dist/runtime/pi-inline-extensions.mjs +31 -2
- package/dist/runtime/pi-profile-migration.cjs +109 -1
- package/dist/runtime/pi-profile-migration.mjs +105 -1
- package/dist/runtime/pi-subagent-injection.mjs +50 -13
- package/dist/runtime/pi-subagents.bundle.js +57 -10
- package/dist/setup/setup-bind.mjs +124 -41
- package/package.json +5 -1
- package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +249 -0
|
@@ -16,7 +16,7 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
|
|
|
16
16
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
17
17
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
18
18
|
*/
|
|
19
|
-
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
19
|
+
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v23";
|
|
20
20
|
/**
|
|
21
21
|
* Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
|
|
22
22
|
* 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
|
|
@@ -112,6 +112,7 @@ class ContextPromptBuilder {
|
|
|
112
112
|
"2. The tool returns an agent id immediately. Report it to the user and end the turn.",
|
|
113
113
|
"3. Do NOT poll or sleep; a completion notification arrives automatically.",
|
|
114
114
|
"4. On the notification, check the Inbox, then publish exactly one final summary.",
|
|
115
|
+
"If you explicitly use get_subagent_result with wait: true, make at most one bounded wait call per turn. If it returns timedOut: true, do not loop or call wait again in the same turn; yield and let the completion notification wake you.",
|
|
115
116
|
"Forbidden pattern (never acceptable): `nohup sh -c '...' > /tmp/x.out 2>&1 &`, `sleep N; cat ...`, disown, or any shell background substitute. These bypass subagent isolation.",
|
|
116
117
|
"Keep corrections, approvals, short commands, and Feishu writes in the foreground; do not delegate them.",
|
|
117
118
|
"Parallel independent tasks: when the user clearly asks for two or more independent tasks with no dependencies between them, delegate EACH task to its own background subagent in a single message with multiple Agent tool calls (one per task, all with run_in_background: true), report every job id, and end the turn. Do not run them one-by-one in the foreground and do not merge them into one subagent.",
|
|
@@ -13,7 +13,7 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
|
|
|
13
13
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
14
14
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
15
15
|
*/
|
|
16
|
-
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
16
|
+
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v23";
|
|
17
17
|
/**
|
|
18
18
|
* Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
|
|
19
19
|
* 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
|
|
@@ -109,6 +109,7 @@ export class ContextPromptBuilder {
|
|
|
109
109
|
"2. The tool returns an agent id immediately. Report it to the user and end the turn.",
|
|
110
110
|
"3. Do NOT poll or sleep; a completion notification arrives automatically.",
|
|
111
111
|
"4. On the notification, check the Inbox, then publish exactly one final summary.",
|
|
112
|
+
"If you explicitly use get_subagent_result with wait: true, make at most one bounded wait call per turn. If it returns timedOut: true, do not loop or call wait again in the same turn; yield and let the completion notification wake you.",
|
|
112
113
|
"Forbidden pattern (never acceptable): `nohup sh -c '...' > /tmp/x.out 2>&1 &`, `sleep N; cat ...`, disown, or any shell background substitute. These bypass subagent isolation.",
|
|
113
114
|
"Keep corrections, approvals, short commands, and Feishu writes in the foreground; do not delegate them.",
|
|
114
115
|
"Parallel independent tasks: when the user clearly asks for two or more independent tasks with no dependencies between them, delegate EACH task to its own background subagent in a single message with multiple Agent tool calls (one per task, all with run_in_background: true), report every job id, and end the turn. Do not run them one-by-one in the foreground and do not merge them into one subagent.",
|
package/dist/app/setup.mjs
CHANGED
|
@@ -190,8 +190,9 @@ export async function main() {
|
|
|
190
190
|
die(`setup 完成但 Agent ${selectedAgentId} 配置不可读`);
|
|
191
191
|
if (!["codex", "claude", "pi"].includes(configuredAgent.runtime))
|
|
192
192
|
die(`Runtime ${configuredAgent.runtime} 不受支持`);
|
|
193
|
-
// 非交互(Agent
|
|
194
|
-
//
|
|
193
|
+
// 非交互(Agent 驱动)时仍跳过 probeNativeRuntimeReadiness:该探测保持 interactive-only。
|
|
194
|
+
// external-pi 绑定已在 setup-bind 内做非交互 Pi RPC catalog discovery(RPC over pipes 不需要 TTY);
|
|
195
|
+
// daemon attach 探测仍是权威校验。其余真实阻塞(缺 runtime 可执行文件 / 缺 key / 缺 lark-cli)一律 fast-fail。
|
|
195
196
|
if (!OPT.nonInteractive) {
|
|
196
197
|
const runtimeReadiness = await probeNativeRuntimeReadiness({ runtime: configuredAgent.runtime,
|
|
197
198
|
agentId: selectedAgentId, cwd: configuredAgent.workspaceDir,
|
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
2
|
import bashTimeoutExtension from "./pi-bash-timeout-extension.mjs";
|
|
3
|
+
import { bundledPiSubagentExtensionPath } from "./pi-subagent-injection.mjs";
|
|
4
|
+
/**
|
|
5
|
+
* Load the prebuilt, patched subagent bundle shipped in dist/ rather than the
|
|
6
|
+
* package-manager copy. Standalone binaries additionally load the patched
|
|
7
|
+
* package through Bun's compiled module graph, while normal package installs
|
|
8
|
+
* continue to use the prebuilt patched bundle.
|
|
9
|
+
*/
|
|
10
|
+
async function loadBundledPiSubagentExtension() {
|
|
11
|
+
if (process.env.LARKIN_STANDALONE === "1") {
|
|
12
|
+
const loaded = await import("@tintinweb/pi-subagents/dist/index.js");
|
|
13
|
+
if (!loaded.default)
|
|
14
|
+
throw new Error("Larkin bounded pi-subagents package is invalid; refusing to start builtin Pi");
|
|
15
|
+
return loaded.default;
|
|
16
|
+
}
|
|
17
|
+
const bundle = bundledPiSubagentExtensionPath(process.env.LARKIN_CONFIG_DIR);
|
|
18
|
+
if (!bundle)
|
|
19
|
+
throw new Error("Larkin bounded pi-subagents bundle is unavailable; refusing to start builtin Pi");
|
|
20
|
+
const loaded = await import(pathToFileURL(bundle).href);
|
|
21
|
+
if (!loaded.default)
|
|
22
|
+
throw new Error("Larkin bounded pi-subagents bundle is invalid; refusing to start builtin Pi");
|
|
23
|
+
return loaded.default;
|
|
24
|
+
}
|
|
3
25
|
/** Builtin Pi extensions are loaded as code, never through Pi's path/data-URL loader. */
|
|
26
|
+
const bundledSubagentsExtension = async (pi) => {
|
|
27
|
+
const extension = await loadBundledPiSubagentExtension();
|
|
28
|
+
if (typeof extension === "function")
|
|
29
|
+
await extension(pi);
|
|
30
|
+
else
|
|
31
|
+
await extension.factory(pi);
|
|
32
|
+
};
|
|
4
33
|
export const BUILTIN_PI_EXTENSION_FACTORIES = Object.freeze([
|
|
5
|
-
|
|
34
|
+
bundledSubagentsExtension,
|
|
6
35
|
bashTimeoutExtension,
|
|
7
36
|
]);
|
|
8
37
|
/** Invoke Pi RPC with Larkin's builtin-only extension factories. */
|
|
@@ -32,8 +32,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
35
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
39
|
exports.releasePiProfileMigrationLock = releasePiProfileMigrationLock;
|
|
40
|
+
exports.clearStalePiProfileMigrationLock = clearStalePiProfileMigrationLock;
|
|
37
41
|
exports.preparePiProfileMigration = preparePiProfileMigration;
|
|
38
42
|
exports.validatePiProfileMigrationState = validatePiProfileMigrationState;
|
|
39
43
|
exports.assertPiProfileMigrationAfterState = assertPiProfileMigrationAfterState;
|
|
@@ -44,6 +48,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
44
48
|
const os = __importStar(require("node:os"));
|
|
45
49
|
const path = __importStar(require("node:path"));
|
|
46
50
|
const node_child_process_1 = require("node:child_process");
|
|
51
|
+
const process_inspect_cjs_1 = __importDefault(require("../platform/process-inspect.cjs"));
|
|
47
52
|
const runtime_readiness_js_1 = require("./runtime-readiness.cjs");
|
|
48
53
|
const pi_compaction_recovery_js_1 = require("./pi-compaction-recovery.cjs");
|
|
49
54
|
const pi_provider_config_js_1 = require("./pi-provider-config.cjs");
|
|
@@ -87,6 +92,109 @@ function releasePiProfileMigrationLock(state) {
|
|
|
87
92
|
throw error;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
const TARGET_BUSY = "Pi provider target is busy";
|
|
96
|
+
function migrationLockPath(configDir, agentId) {
|
|
97
|
+
if (!AGENT_ID.test(agentId))
|
|
98
|
+
throw new Error("Pi Agent ID 格式无效");
|
|
99
|
+
return path.join(path.resolve(configDir), "providers", "pi", `${agentId}${TARGET_LOCK_SUFFIX}`);
|
|
100
|
+
}
|
|
101
|
+
function lockPidFromBytes(bytes) {
|
|
102
|
+
const text = bytes.toString("utf8");
|
|
103
|
+
if (text.endsWith("\n") ? text.slice(0, -1).includes("\n") : text.includes("\n"))
|
|
104
|
+
return null;
|
|
105
|
+
const line = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
106
|
+
if (!/^[1-9][0-9]*$/.test(line))
|
|
107
|
+
return null;
|
|
108
|
+
const pid = Number(line);
|
|
109
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
110
|
+
}
|
|
111
|
+
function inspectOwnedLock(file) {
|
|
112
|
+
let fd;
|
|
113
|
+
try {
|
|
114
|
+
const initial = fs.lstatSync(file);
|
|
115
|
+
if (initial.isSymbolicLink() || !initial.isFile() || initial.nlink !== 1 || (initial.mode & 0o777) !== 0o600) {
|
|
116
|
+
throw new Error(TARGET_BUSY);
|
|
117
|
+
}
|
|
118
|
+
assertOwner(initial, "Pi provider lock");
|
|
119
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
120
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
121
|
+
const before = fs.fstatSync(fd);
|
|
122
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600)
|
|
123
|
+
throw new Error(TARGET_BUSY);
|
|
124
|
+
assertOwner(before, "Pi provider lock");
|
|
125
|
+
const bytes = Buffer.allocUnsafe(before.size);
|
|
126
|
+
let offset = 0;
|
|
127
|
+
while (offset < before.size) {
|
|
128
|
+
const read = fs.readSync(fd, bytes, offset, before.size - offset, null);
|
|
129
|
+
if (read === 0)
|
|
130
|
+
throw new Error(TARGET_BUSY);
|
|
131
|
+
offset += read;
|
|
132
|
+
}
|
|
133
|
+
const after = fs.fstatSync(fd);
|
|
134
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.nlink !== before.nlink
|
|
135
|
+
|| after.uid !== before.uid || (after.mode & 0o777) !== 0o600)
|
|
136
|
+
throw new Error(TARGET_BUSY);
|
|
137
|
+
const pid = lockPidFromBytes(bytes.subarray(0, after.size));
|
|
138
|
+
if (pid === null)
|
|
139
|
+
throw new Error(TARGET_BUSY);
|
|
140
|
+
return { pid, dev: after.dev, ino: after.ino };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.code === "ENOENT")
|
|
144
|
+
return null;
|
|
145
|
+
if (error.code === "ELOOP")
|
|
146
|
+
throw new Error(TARGET_BUSY);
|
|
147
|
+
if (error instanceof Error && error.message === TARGET_BUSY)
|
|
148
|
+
throw error;
|
|
149
|
+
if (error instanceof Error && /unsafe/.test(error.message))
|
|
150
|
+
throw new Error(TARGET_BUSY);
|
|
151
|
+
throw new Error(TARGET_BUSY);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (fd !== undefined)
|
|
155
|
+
fs.closeSync(fd);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Reclaim a credential-adjacent import lock only when it is a proven-dead owned regular file. */
|
|
159
|
+
function clearStalePiProfileMigrationLock(configDir, agentId, options = {}) {
|
|
160
|
+
const file = migrationLockPath(configDir, agentId);
|
|
161
|
+
assertNoSymlinkAncestors(path.dirname(file));
|
|
162
|
+
const inspected = inspectOwnedLock(file);
|
|
163
|
+
if (!inspected)
|
|
164
|
+
return;
|
|
165
|
+
const kill = options.kill ?? process.kill;
|
|
166
|
+
if (process_inspect_cjs_1.default.pidAlive(inspected.pid, kill))
|
|
167
|
+
throw new Error(TARGET_BUSY);
|
|
168
|
+
const quarantine = `${file}.stale-${process.pid}-${crypto.randomUUID()}`;
|
|
169
|
+
try {
|
|
170
|
+
fs.renameSync(file, quarantine);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code === "ENOENT")
|
|
174
|
+
return;
|
|
175
|
+
throw new Error(TARGET_BUSY);
|
|
176
|
+
}
|
|
177
|
+
fsyncDirectory(path.dirname(file));
|
|
178
|
+
try {
|
|
179
|
+
const quarantined = fs.lstatSync(quarantine);
|
|
180
|
+
if (quarantined.isSymbolicLink() || !quarantined.isFile() || quarantined.nlink !== 1
|
|
181
|
+
|| (quarantined.mode & 0o777) !== 0o600 || quarantined.dev !== inspected.dev || quarantined.ino !== inspected.ino) {
|
|
182
|
+
throw new Error(TARGET_BUSY);
|
|
183
|
+
}
|
|
184
|
+
assertOwner(quarantined, "Pi provider lock");
|
|
185
|
+
const bytes = fs.readFileSync(quarantine);
|
|
186
|
+
if (lockPidFromBytes(bytes) !== inspected.pid || process_inspect_cjs_1.default.pidAlive(inspected.pid, kill)) {
|
|
187
|
+
throw new Error(TARGET_BUSY);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
try {
|
|
192
|
+
fs.unlinkSync(quarantine);
|
|
193
|
+
}
|
|
194
|
+
catch { /* quarantine is not the live lock path */ }
|
|
195
|
+
fsyncDirectory(path.dirname(file));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
90
198
|
function isKnownSystemSymlink(value) {
|
|
91
199
|
return value === "/tmp" || value === "/private/tmp" || value === "/var" || value === "/private/var"
|
|
92
200
|
|| value === "/var/folders" || /^\/var\/folders\/[^/]+$/.test(value)
|
|
@@ -263,7 +371,7 @@ function preparePiProfileMigration(env, configDir, agentId, distribution = "buil
|
|
|
263
371
|
const sourceDirectoryState = assertDirectory(sourceDir, "external Pi profile");
|
|
264
372
|
const sourceExecutable = sourceVersion(env, sourceDir);
|
|
265
373
|
const sourceFiles = Object.fromEntries(FILES.map((name) => {
|
|
266
|
-
const file = readRegular(path.join(sourceDir, name), `external Pi ${name}
|
|
374
|
+
const file = readRegular(path.join(sourceDir, name), `external Pi ${name}`, false);
|
|
267
375
|
if (!file)
|
|
268
376
|
throw new Error(`external Pi ${name} is required`);
|
|
269
377
|
if (name === "auth.json" && file.mode !== 0o600)
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import processInspect from "../platform/process-inspect.cjs";
|
|
6
7
|
import { resolveRuntimeExecutable } from "./runtime-readiness.mjs";
|
|
7
8
|
import { mergeOwnedPiSettings, parsePiExecutableVersion } from "./pi-compaction-recovery.mjs";
|
|
8
9
|
import { BUNDLED_PI_VERSION } from "./pi-provider-config.mjs";
|
|
@@ -46,6 +47,109 @@ export function releasePiProfileMigrationLock(state) {
|
|
|
46
47
|
throw error;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
const TARGET_BUSY = "Pi provider target is busy";
|
|
51
|
+
function migrationLockPath(configDir, agentId) {
|
|
52
|
+
if (!AGENT_ID.test(agentId))
|
|
53
|
+
throw new Error("Pi Agent ID 格式无效");
|
|
54
|
+
return path.join(path.resolve(configDir), "providers", "pi", `${agentId}${TARGET_LOCK_SUFFIX}`);
|
|
55
|
+
}
|
|
56
|
+
function lockPidFromBytes(bytes) {
|
|
57
|
+
const text = bytes.toString("utf8");
|
|
58
|
+
if (text.endsWith("\n") ? text.slice(0, -1).includes("\n") : text.includes("\n"))
|
|
59
|
+
return null;
|
|
60
|
+
const line = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
61
|
+
if (!/^[1-9][0-9]*$/.test(line))
|
|
62
|
+
return null;
|
|
63
|
+
const pid = Number(line);
|
|
64
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
65
|
+
}
|
|
66
|
+
function inspectOwnedLock(file) {
|
|
67
|
+
let fd;
|
|
68
|
+
try {
|
|
69
|
+
const initial = fs.lstatSync(file);
|
|
70
|
+
if (initial.isSymbolicLink() || !initial.isFile() || initial.nlink !== 1 || (initial.mode & 0o777) !== 0o600) {
|
|
71
|
+
throw new Error(TARGET_BUSY);
|
|
72
|
+
}
|
|
73
|
+
assertOwner(initial, "Pi provider lock");
|
|
74
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
75
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
76
|
+
const before = fs.fstatSync(fd);
|
|
77
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600)
|
|
78
|
+
throw new Error(TARGET_BUSY);
|
|
79
|
+
assertOwner(before, "Pi provider lock");
|
|
80
|
+
const bytes = Buffer.allocUnsafe(before.size);
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset < before.size) {
|
|
83
|
+
const read = fs.readSync(fd, bytes, offset, before.size - offset, null);
|
|
84
|
+
if (read === 0)
|
|
85
|
+
throw new Error(TARGET_BUSY);
|
|
86
|
+
offset += read;
|
|
87
|
+
}
|
|
88
|
+
const after = fs.fstatSync(fd);
|
|
89
|
+
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.nlink !== before.nlink
|
|
90
|
+
|| after.uid !== before.uid || (after.mode & 0o777) !== 0o600)
|
|
91
|
+
throw new Error(TARGET_BUSY);
|
|
92
|
+
const pid = lockPidFromBytes(bytes.subarray(0, after.size));
|
|
93
|
+
if (pid === null)
|
|
94
|
+
throw new Error(TARGET_BUSY);
|
|
95
|
+
return { pid, dev: after.dev, ino: after.ino };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return null;
|
|
100
|
+
if (error.code === "ELOOP")
|
|
101
|
+
throw new Error(TARGET_BUSY);
|
|
102
|
+
if (error instanceof Error && error.message === TARGET_BUSY)
|
|
103
|
+
throw error;
|
|
104
|
+
if (error instanceof Error && /unsafe/.test(error.message))
|
|
105
|
+
throw new Error(TARGET_BUSY);
|
|
106
|
+
throw new Error(TARGET_BUSY);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
if (fd !== undefined)
|
|
110
|
+
fs.closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Reclaim a credential-adjacent import lock only when it is a proven-dead owned regular file. */
|
|
114
|
+
export function clearStalePiProfileMigrationLock(configDir, agentId, options = {}) {
|
|
115
|
+
const file = migrationLockPath(configDir, agentId);
|
|
116
|
+
assertNoSymlinkAncestors(path.dirname(file));
|
|
117
|
+
const inspected = inspectOwnedLock(file);
|
|
118
|
+
if (!inspected)
|
|
119
|
+
return;
|
|
120
|
+
const kill = options.kill ?? process.kill;
|
|
121
|
+
if (processInspect.pidAlive(inspected.pid, kill))
|
|
122
|
+
throw new Error(TARGET_BUSY);
|
|
123
|
+
const quarantine = `${file}.stale-${process.pid}-${crypto.randomUUID()}`;
|
|
124
|
+
try {
|
|
125
|
+
fs.renameSync(file, quarantine);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code === "ENOENT")
|
|
129
|
+
return;
|
|
130
|
+
throw new Error(TARGET_BUSY);
|
|
131
|
+
}
|
|
132
|
+
fsyncDirectory(path.dirname(file));
|
|
133
|
+
try {
|
|
134
|
+
const quarantined = fs.lstatSync(quarantine);
|
|
135
|
+
if (quarantined.isSymbolicLink() || !quarantined.isFile() || quarantined.nlink !== 1
|
|
136
|
+
|| (quarantined.mode & 0o777) !== 0o600 || quarantined.dev !== inspected.dev || quarantined.ino !== inspected.ino) {
|
|
137
|
+
throw new Error(TARGET_BUSY);
|
|
138
|
+
}
|
|
139
|
+
assertOwner(quarantined, "Pi provider lock");
|
|
140
|
+
const bytes = fs.readFileSync(quarantine);
|
|
141
|
+
if (lockPidFromBytes(bytes) !== inspected.pid || processInspect.pidAlive(inspected.pid, kill)) {
|
|
142
|
+
throw new Error(TARGET_BUSY);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
try {
|
|
147
|
+
fs.unlinkSync(quarantine);
|
|
148
|
+
}
|
|
149
|
+
catch { /* quarantine is not the live lock path */ }
|
|
150
|
+
fsyncDirectory(path.dirname(file));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
49
153
|
function isKnownSystemSymlink(value) {
|
|
50
154
|
return value === "/tmp" || value === "/private/tmp" || value === "/var" || value === "/private/var"
|
|
51
155
|
|| value === "/var/folders" || /^\/var\/folders\/[^/]+$/.test(value)
|
|
@@ -222,7 +326,7 @@ export function preparePiProfileMigration(env, configDir, agentId, distribution
|
|
|
222
326
|
const sourceDirectoryState = assertDirectory(sourceDir, "external Pi profile");
|
|
223
327
|
const sourceExecutable = sourceVersion(env, sourceDir);
|
|
224
328
|
const sourceFiles = Object.fromEntries(FILES.map((name) => {
|
|
225
|
-
const file = readRegular(path.join(sourceDir, name), `external Pi ${name}
|
|
329
|
+
const file = readRegular(path.join(sourceDir, name), `external Pi ${name}`, false);
|
|
226
330
|
if (!file)
|
|
227
331
|
throw new Error(`external Pi ${name} is required`);
|
|
228
332
|
if (name === "auth.json" && file.mode !== 0o600)
|
|
@@ -81,39 +81,76 @@ export function probeExternalPiVersion(piCommand, env) {
|
|
|
81
81
|
* 已装时 Larkin 不再 -e 注入,避免同名工具(Agent/get_subagent_result/steer_subagent)
|
|
82
82
|
* 重复注册导致 pi 扩展加载 FATAL(pi 对 duplicate tool registration 是硬失败)。
|
|
83
83
|
*/
|
|
84
|
-
|
|
84
|
+
const BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
85
|
+
function userPiSubagentsWaitCapability(env) {
|
|
85
86
|
const agentDir = env.PI_CODING_AGENT_DIR || path.join(env.HOME || process.env.HOME || "", ".pi", "agent");
|
|
86
87
|
try {
|
|
88
|
+
let configured = false;
|
|
87
89
|
const settingsFile = path.join(agentDir, "settings.json");
|
|
88
90
|
if (fs.existsSync(settingsFile)) {
|
|
89
91
|
const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8"));
|
|
90
92
|
const packages = settings?.packages;
|
|
91
93
|
if (Array.isArray(packages)) {
|
|
92
|
-
|
|
93
|
-
if (typeof entry === "string" && /pi-subagents/i.test(entry))
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
94
|
+
configured = packages.some((entry) => typeof entry === "string" && /pi-subagents/i.test(entry));
|
|
96
95
|
}
|
|
97
96
|
}
|
|
98
97
|
// settings.json 可能未登记但包目录已存在(残留/手动安装)。
|
|
99
98
|
// Split token: repo contract forbids the literal package-manager word in sources.
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
const packageRoots = [
|
|
100
|
+
path.join(agentDir, "n" + "pm", "node_modules", "@tintinweb", "pi-subagents"),
|
|
101
|
+
path.join(agentDir, "node_modules", "@tintinweb", "pi-subagents"),
|
|
102
|
+
];
|
|
103
|
+
const installedRoot = packageRoots.find((root) => fs.existsSync(root));
|
|
104
|
+
if (!configured && !installedRoot)
|
|
105
|
+
return "absent";
|
|
106
|
+
if (!installedRoot)
|
|
107
|
+
return "unbounded";
|
|
108
|
+
const packageManifest = JSON.parse(fs.readFileSync(path.join(installedRoot, "package.json"), "utf8"));
|
|
109
|
+
const extensions = packageManifest.pi?.extensions;
|
|
110
|
+
if (!Array.isArray(extensions) || extensions.length === 0
|
|
111
|
+
|| !extensions.every((entry) => typeof entry === "string" && entry.length > 0)) {
|
|
112
|
+
return "unbounded";
|
|
113
|
+
}
|
|
114
|
+
// Pi executes the entries declared by the package manifest. Do not trust a
|
|
115
|
+
// marker in a build artifact when the manifest points Pi at another file.
|
|
116
|
+
const capabilityFiles = extensions.map((entry) => path.resolve(installedRoot, entry));
|
|
117
|
+
const hasCapability = capabilityFiles.every((file) => {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(file, "utf8").includes(BOUNDED_WAIT_CAPABILITY);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return hasCapability ? "bounded" : "unbounded";
|
|
103
126
|
}
|
|
104
127
|
catch {
|
|
105
|
-
|
|
128
|
+
// Unreadable user extension state is not safe to treat as bounded.
|
|
129
|
+
return "unbounded";
|
|
106
130
|
}
|
|
107
|
-
|
|
131
|
+
}
|
|
132
|
+
export function userPiAlreadyHasSubagentsExtension(env) {
|
|
133
|
+
return userPiSubagentsWaitCapability(env) !== "absent";
|
|
134
|
+
}
|
|
135
|
+
export function userPiSubagentsHasBoundedWaitCapability(env) {
|
|
136
|
+
return userPiSubagentsWaitCapability(env) === "bounded";
|
|
108
137
|
}
|
|
109
138
|
export function resolvePiSubagentExtensionArg(input, probeVersion = () => probeExternalPiVersion(input.piCommand, input.env), resolveBundle = () => bundledPiSubagentExtensionPath(input.env.LARKIN_CONFIG_DIR)) {
|
|
139
|
+
// A user-installed extension wins only when it advertises the exact bounded
|
|
140
|
+
// wait capability. Check this before the bundle path so a missing Larkin
|
|
141
|
+
// asset cannot silently leave an unbounded user extension active.
|
|
142
|
+
if (userPiAlreadyHasSubagentsExtension(input.env)) {
|
|
143
|
+
if (!userPiSubagentsHasBoundedWaitCapability(input.env)) {
|
|
144
|
+
throw new Error("[larkin] WARNING: refusing external Pi because the user-installed " +
|
|
145
|
+
"pi-subagents extension is unbounded or unverifiable; remove it or install " +
|
|
146
|
+
"a version advertising larkin-pi-subagents-bounded-wait-v1.");
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
110
150
|
// resolveBundle is injectable so unit tests stay environment-independent
|
|
111
151
|
// (no dependency on build artifacts or the filesystem).
|
|
112
152
|
const bundle = resolveBundle();
|
|
113
153
|
if (!bundle)
|
|
114
154
|
return null;
|
|
115
|
-
// 用户已自行安装同款扩展时跳过注入,避免工具名重复注册冲突。
|
|
116
|
-
if (userPiAlreadyHasSubagentsExtension(input.env))
|
|
117
|
-
return null;
|
|
118
155
|
return piVersionSupportsSubagents(probeVersion()) ? bundle : null;
|
|
119
156
|
}
|
|
@@ -7104,6 +7104,43 @@ function abortable(promise, signal) {
|
|
|
7104
7104
|
});
|
|
7105
7105
|
});
|
|
7106
7106
|
}
|
|
7107
|
+
var RESULT_WAIT_QUEUE_POLL_MS = 50;
|
|
7108
|
+
var DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
|
|
7109
|
+
var MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
|
|
7110
|
+
function normalizeResultWaitTimeout(timeoutMs) {
|
|
7111
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
|
|
7112
|
+
return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
|
|
7113
|
+
return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
|
|
7114
|
+
}
|
|
7115
|
+
function waitWithDeadline(promise, timeoutMs, signal) {
|
|
7116
|
+
let timer;
|
|
7117
|
+
const timeout = new Promise((resolve2) => {
|
|
7118
|
+
timer = setTimeout(() => resolve2({ timedOut: true }), timeoutMs);
|
|
7119
|
+
});
|
|
7120
|
+
return abortable(Promise.race([
|
|
7121
|
+
promise.then((value) => ({ timedOut: false, value })),
|
|
7122
|
+
timeout
|
|
7123
|
+
]), signal).finally(() => {
|
|
7124
|
+
if (timer !== undefined)
|
|
7125
|
+
clearTimeout(timer);
|
|
7126
|
+
});
|
|
7127
|
+
}
|
|
7128
|
+
async function waitForSubagentResult(record, timeoutMs, signal) {
|
|
7129
|
+
const deadline = Date.now() + timeoutMs;
|
|
7130
|
+
while (record.status === "queued") {
|
|
7131
|
+
const remainingMs2 = deadline - Date.now();
|
|
7132
|
+
if (remainingMs2 <= 0)
|
|
7133
|
+
return true;
|
|
7134
|
+
await abortable(new Promise((resolve2) => setTimeout(resolve2, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs2))), signal);
|
|
7135
|
+
}
|
|
7136
|
+
if (!record.promise)
|
|
7137
|
+
return false;
|
|
7138
|
+
const remainingMs = deadline - Date.now();
|
|
7139
|
+
if (remainingMs <= 0)
|
|
7140
|
+
return true;
|
|
7141
|
+
return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
|
|
7142
|
+
}
|
|
7143
|
+
var LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
7107
7144
|
function renderRunningAgentStatus(frame, statsText, activity, theme) {
|
|
7108
7145
|
const container = new Container;
|
|
7109
7146
|
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
|
|
@@ -7287,7 +7324,10 @@ function src_default(pi) {
|
|
|
7287
7324
|
const agentActivity = new Map;
|
|
7288
7325
|
const pendingNudges = new Map;
|
|
7289
7326
|
const NUDGE_HOLD_MS = 200;
|
|
7290
|
-
|
|
7327
|
+
let resultWaitConsumedInParentTurn = false;
|
|
7328
|
+
pi.on("agent_start", () => {
|
|
7329
|
+
resultWaitConsumedInParentTurn = false;
|
|
7330
|
+
});
|
|
7291
7331
|
function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
|
|
7292
7332
|
cancelNudge(key);
|
|
7293
7333
|
pendingNudges.set(key, setTimeout(() => {
|
|
@@ -8147,6 +8187,9 @@ You will be notified when this agent completes.
|
|
|
8147
8187
|
wait: Type.Optional(Type.Boolean({
|
|
8148
8188
|
description: "If true, wait for the agent to complete before returning. Default: false."
|
|
8149
8189
|
})),
|
|
8190
|
+
timeout_ms: Type.Optional(Type.Number({
|
|
8191
|
+
description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000."
|
|
8192
|
+
})),
|
|
8150
8193
|
verbose: Type.Optional(Type.Boolean({
|
|
8151
8194
|
description: "If true, include the agent's full conversation (messages + tool calls). Default: false."
|
|
8152
8195
|
}))
|
|
@@ -8156,12 +8199,14 @@ You will be notified when this agent completes.
|
|
|
8156
8199
|
if (!record) {
|
|
8157
8200
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
8158
8201
|
}
|
|
8202
|
+
if (params.wait && resultWaitConsumedInParentTurn) {
|
|
8203
|
+
throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
|
|
8204
|
+
}
|
|
8205
|
+
let timedOut = false;
|
|
8206
|
+
const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
|
|
8159
8207
|
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
}
|
|
8163
|
-
if (record.promise)
|
|
8164
|
-
await abortable(record.promise, signal);
|
|
8208
|
+
resultWaitConsumedInParentTurn = true;
|
|
8209
|
+
timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
|
|
8165
8210
|
}
|
|
8166
8211
|
const displayName = getDisplayName(record.type);
|
|
8167
8212
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
@@ -8176,12 +8221,12 @@ You will be notified when this agent completes.
|
|
|
8176
8221
|
statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
8177
8222
|
statsParts.push(`Duration: ${duration}`);
|
|
8178
8223
|
let output = `Agent: ${record.id}
|
|
8179
|
-
` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}
|
|
8224
|
+
` + `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}
|
|
8180
8225
|
` + `Description: ${record.description}
|
|
8181
8226
|
|
|
8182
8227
|
`;
|
|
8183
|
-
if (record.status === "running") {
|
|
8184
|
-
output += "Agent is still running. Use wait: true or check back later.";
|
|
8228
|
+
if (record.status === "running" || record.status === "queued") {
|
|
8229
|
+
output += timedOut ? `Agent is still ${record.status}. Wait timed out after ${waitTimeoutMs}ms; the background agent was not cancelled. Yield/end this turn and await the completion notification; do not call wait: true again.` : "Agent is still running. Use wait: true or check back later.";
|
|
8185
8230
|
} else if (record.status === "error") {
|
|
8186
8231
|
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
8187
8232
|
} else {
|
|
@@ -8985,6 +9030,8 @@ ${systemPrompt}
|
|
|
8985
9030
|
});
|
|
8986
9031
|
}
|
|
8987
9032
|
export {
|
|
9033
|
+
waitForSubagentResult,
|
|
8988
9034
|
renderRunningAgentStatus,
|
|
8989
|
-
src_default as default
|
|
9035
|
+
src_default as default,
|
|
9036
|
+
LARKIN_BOUNDED_WAIT_CAPABILITY
|
|
8990
9037
|
};
|
|
@@ -9,7 +9,10 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { isWindows } from "../platform/secure-metadata.mjs";
|
|
10
10
|
import { TargetRootLayout } from "../platform/root-layout.mjs";
|
|
11
11
|
import { planSingleRootBinding } from "./setup-binding.mjs";
|
|
12
|
-
import { configureBuiltinPiProviderModel } from "../runtime/pi-provider-config.mjs";
|
|
12
|
+
import { configureBuiltinPiProviderModel, piAgentDirectory } from "../runtime/pi-provider-config.mjs";
|
|
13
|
+
import { applyPiProfileMigration, clearStalePiProfileMigrationLock, preparePiProfileMigration, releasePiProfileMigrationLock, rollbackPiProfileMigration, } from "../runtime/pi-profile-migration.mjs";
|
|
14
|
+
import { discoverPiModelCatalog } from "../runtime/pi-model-catalog.mjs";
|
|
15
|
+
import { calculatePiCompactionSettings } from "../runtime/pi-compaction-recovery.mjs";
|
|
13
16
|
import { terminalSetupQuestioner } from "./setup-agent-choice.mjs";
|
|
14
17
|
import { beginBuiltinPiCredentialTransaction, createOfficialPiAuthInteraction, createOfficialPiModelRuntime, runOfficialPiLogin, verifyOfficialPiProviderTurn, } from "../runtime/pi-official-auth.mjs";
|
|
15
18
|
import * as larkinConfigImport from "../platform/config.mjs";
|
|
@@ -195,6 +198,30 @@ function fabricateAttachment(root, serverId) {
|
|
|
195
198
|
};
|
|
196
199
|
fs.writeFileSync(file, JSON.stringify(attachment, null, 2), { mode: 0o600 });
|
|
197
200
|
}
|
|
201
|
+
function catalogModelsFromError(error) {
|
|
202
|
+
if (!error || typeof error !== "object" || !("catalogModels" in error))
|
|
203
|
+
return [];
|
|
204
|
+
const models = error.catalogModels;
|
|
205
|
+
return Array.isArray(models) ? models.filter((id) => typeof id === "string" && id.length > 0) : [];
|
|
206
|
+
}
|
|
207
|
+
function formatExternalPiSetupFailure(error, options) {
|
|
208
|
+
const original = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").trim();
|
|
209
|
+
const alternatives = catalogModelsFromError(error);
|
|
210
|
+
const parts = [];
|
|
211
|
+
if (/external Pi (?:auth\.json|models\.json|settings\.json) is required|external Pi profile|external Pi auth\.json has an unsafe mode|external Pi models\.json has an unsafe mode|external Pi settings\.json has an unsafe mode/.test(original)) {
|
|
212
|
+
parts.push("external-pi requires the official file-backed Pi profile containing auth.json (0600), models.json, and settings.json, normally after `pi` login and default-model selection. Environment-variable-only credentials are unsupported.");
|
|
213
|
+
}
|
|
214
|
+
if (original)
|
|
215
|
+
parts.push(original);
|
|
216
|
+
parts.push("Fix the official `pi` login and default model, then rerun `larkin setup --runtime external-pi`.");
|
|
217
|
+
if (options.agentExisted) {
|
|
218
|
+
parts.push("This Agent already exists; after a successful setup you can switch models with `larkin model <provider>/<model>`.");
|
|
219
|
+
}
|
|
220
|
+
if (alternatives.length) {
|
|
221
|
+
parts.push(`Alternative models that report a context window: ${alternatives.join(", ")}.`);
|
|
222
|
+
}
|
|
223
|
+
return parts.join(" ");
|
|
224
|
+
}
|
|
198
225
|
function safeProviderFailure(error) {
|
|
199
226
|
const message = error instanceof Error ? error.message : String(error);
|
|
200
227
|
if (/\b(?:401|403)\b|unauth|invalid.*(?:key|token)|credential/i.test(message)) {
|
|
@@ -267,49 +294,90 @@ export async function main() {
|
|
|
267
294
|
validateRuntime(runtime);
|
|
268
295
|
const defaultModel = larkinConfig.defaultModelFor(runtime);
|
|
269
296
|
const selectedModel = selection?.runtime === "pi" && selection.distribution === "builtin" ? selection.model : undefined;
|
|
270
|
-
|
|
271
|
-
let
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
? [prior.effort]
|
|
275
|
-
: [];
|
|
276
|
-
runtimeModels = [{ id: targetModelId, supportedReasoningEfforts: preservedEffort }];
|
|
277
|
-
}
|
|
278
|
-
const targetModel = runtimeModels.find((model) => model.id === targetModelId);
|
|
279
|
-
if (!targetModel)
|
|
280
|
-
die(`目标模型 ${runtime}/${targetModelId} 不在 runtime 模型目录中;未修改 Agent 配置`);
|
|
281
|
-
const stored = planSingleRootBinding({
|
|
282
|
-
config: larkinConfig.toStored(config),
|
|
283
|
-
profile,
|
|
284
|
-
requestedAgent,
|
|
285
|
-
runtime: selection?.runtime || options.runtime,
|
|
286
|
-
...(runtime === "pi" && piDistribution ? { piDistribution } : {}),
|
|
287
|
-
...(selectedModel ? { model: selectedModel } : {}),
|
|
288
|
-
defaultModel,
|
|
289
|
-
supportedReasoningEfforts: targetModel.supportedReasoningEfforts || [],
|
|
290
|
-
now: new Date().toISOString(),
|
|
291
|
-
});
|
|
292
|
-
const bound = stored.agents[profile.appId];
|
|
293
|
-
fs.mkdirSync(CFG_DIR, { recursive: true });
|
|
297
|
+
let targetModelId = selectedModel || (prior?.runtime === runtime ? prior.model : defaultModel);
|
|
298
|
+
let resolvedExternalModel;
|
|
299
|
+
let pendingMigration = null;
|
|
300
|
+
let setupConfigCommitted = false;
|
|
294
301
|
let providerTransaction = null;
|
|
295
302
|
let providerPublished = false;
|
|
296
|
-
const authAlreadyCompleted = selection?.runtime === "pi" && selection.distribution === "builtin"
|
|
297
|
-
&& selection.authCompleted === true;
|
|
298
|
-
const readinessAlreadyCompleted = authAlreadyCompleted
|
|
299
|
-
&& selection.readinessCompleted === true;
|
|
300
303
|
const authAbort = new AbortController();
|
|
301
|
-
const
|
|
304
|
+
const cancelOnSignal = (signal) => {
|
|
302
305
|
authAbort.abort();
|
|
306
|
+
if (!setupConfigCommitted && pendingMigration) {
|
|
307
|
+
try {
|
|
308
|
+
rollbackPiProfileMigration(pendingMigration.state);
|
|
309
|
+
}
|
|
310
|
+
catch { /* best effort */ }
|
|
311
|
+
}
|
|
303
312
|
if (!providerPublished)
|
|
304
313
|
providerTransaction?.rollback();
|
|
305
314
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
306
315
|
};
|
|
307
|
-
const onSigint = () =>
|
|
308
|
-
const onSigterm = () =>
|
|
316
|
+
const onSigint = () => cancelOnSignal("SIGINT");
|
|
317
|
+
const onSigterm = () => cancelOnSignal("SIGTERM");
|
|
309
318
|
process.once("SIGINT", onSigint);
|
|
310
319
|
process.once("SIGTERM", onSigterm);
|
|
320
|
+
let runtimeModels = larkinConfig.loadRuntimeModels()[runtime];
|
|
311
321
|
let authQuestioner = null;
|
|
312
322
|
try {
|
|
323
|
+
if (runtime === "pi" && piDistribution === "external" && targetModelId === "default") {
|
|
324
|
+
fs.mkdirSync(CFG_DIR, { recursive: true, mode: 0o700 });
|
|
325
|
+
try {
|
|
326
|
+
clearStalePiProfileMigrationLock(CFG_DIR, profile.appId);
|
|
327
|
+
const plan = preparePiProfileMigration(process.env, CFG_DIR, profile.appId, "external");
|
|
328
|
+
pendingMigration = plan;
|
|
329
|
+
applyPiProfileMigration(plan);
|
|
330
|
+
const catalog = await discoverPiModelCatalog({
|
|
331
|
+
cwd: CFG_DIR,
|
|
332
|
+
agentDir: piAgentDirectory(CFG_DIR, profile.appId),
|
|
333
|
+
env: process.env,
|
|
334
|
+
});
|
|
335
|
+
const effective = catalog.effectiveModel;
|
|
336
|
+
const entry = effective ? catalog.models.find((model) => model.id === effective) : undefined;
|
|
337
|
+
if (!effective || !entry) {
|
|
338
|
+
throw new Error("Pi official default resolution returned an unavailable model");
|
|
339
|
+
}
|
|
340
|
+
if (!entry.contextWindow) {
|
|
341
|
+
const alternatives = catalog.models
|
|
342
|
+
.filter((model) => Number.isFinite(model.contextWindow) && Number(model.contextWindow) > 0)
|
|
343
|
+
.map((model) => model.id);
|
|
344
|
+
throw Object.assign(new Error("Pi effective model is missing a context window"), { catalogModels: alternatives });
|
|
345
|
+
}
|
|
346
|
+
calculatePiCompactionSettings(entry.contextWindow);
|
|
347
|
+
targetModelId = effective;
|
|
348
|
+
resolvedExternalModel = effective;
|
|
349
|
+
runtimeModels = [{ id: effective, supportedReasoningEfforts: entry.supportedReasoningEfforts }];
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw new Error(formatExternalPiSetupFailure(error, { agentExisted: Boolean(prior) }));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (runtime === "pi") {
|
|
356
|
+
const preservedEffort = prior?.runtime === "pi" && typeof prior.effort === "string" && prior.effort.trim()
|
|
357
|
+
? [prior.effort]
|
|
358
|
+
: [];
|
|
359
|
+
runtimeModels = [{ id: targetModelId, supportedReasoningEfforts: preservedEffort }];
|
|
360
|
+
}
|
|
361
|
+
const targetModel = runtimeModels.find((model) => model.id === targetModelId);
|
|
362
|
+
if (!targetModel)
|
|
363
|
+
throw new Error(`目标模型 ${runtime}/${targetModelId} 不在 runtime 模型目录中;未修改 Agent 配置`);
|
|
364
|
+
const stored = planSingleRootBinding({
|
|
365
|
+
config: larkinConfig.toStored(config),
|
|
366
|
+
profile,
|
|
367
|
+
requestedAgent,
|
|
368
|
+
runtime: selection?.runtime || options.runtime,
|
|
369
|
+
...(runtime === "pi" && piDistribution ? { piDistribution } : {}),
|
|
370
|
+
...(selectedModel || resolvedExternalModel ? { model: selectedModel || resolvedExternalModel } : {}),
|
|
371
|
+
defaultModel,
|
|
372
|
+
supportedReasoningEfforts: targetModel.supportedReasoningEfforts || [],
|
|
373
|
+
now: new Date().toISOString(),
|
|
374
|
+
});
|
|
375
|
+
const bound = stored.agents[profile.appId];
|
|
376
|
+
fs.mkdirSync(CFG_DIR, { recursive: true });
|
|
377
|
+
const authAlreadyCompleted = selection?.runtime === "pi" && selection.distribution === "builtin"
|
|
378
|
+
&& selection.authCompleted === true;
|
|
379
|
+
const readinessAlreadyCompleted = authAlreadyCompleted
|
|
380
|
+
&& selection.readinessCompleted === true;
|
|
313
381
|
providerTransaction = selection?.runtime === "pi" && selection.distribution === "builtin"
|
|
314
382
|
&& !authAlreadyCompleted ? beginBuiltinPiCredentialTransaction(CFG_DIR, profile.appId)
|
|
315
383
|
: null;
|
|
@@ -349,10 +417,34 @@ export async function main() {
|
|
|
349
417
|
}
|
|
350
418
|
}
|
|
351
419
|
larkinConfig.commitSetupConfig(process.env, loaded.revision, stored);
|
|
420
|
+
setupConfigCommitted = true;
|
|
352
421
|
providerTransaction?.commit();
|
|
353
422
|
providerPublished = true;
|
|
423
|
+
if (pendingMigration) {
|
|
424
|
+
try {
|
|
425
|
+
releasePiProfileMigrationLock(pendingMigration.state);
|
|
426
|
+
}
|
|
427
|
+
catch { /* post-commit lock release is best-effort; do not roll back credentials */ }
|
|
428
|
+
pendingMigration = null;
|
|
429
|
+
}
|
|
430
|
+
fabricateAttachment(layout.root, stored.serverId);
|
|
431
|
+
say(`\n✓ 已写配置: ${CFG_FILE}`);
|
|
432
|
+
say(` agent=${profile.appId} runtime=${bound.runtime} model=${bound.model}`);
|
|
433
|
+
say(` 单服务 serverId=${stored.serverId} larkinHome=${layout.root}`);
|
|
434
|
+
say("\n启动这个 Agent:");
|
|
435
|
+
say(" larkin start # 单服务启动全部已配置 Agent");
|
|
436
|
+
say(` larkin start --agent ${profile.appId} # 仅调试这个 Agent`);
|
|
437
|
+
say("创建独立飞书(Lark)机器人 + Agent:");
|
|
438
|
+
say(" larkin setup");
|
|
354
439
|
}
|
|
355
440
|
catch (error) {
|
|
441
|
+
if (!setupConfigCommitted && pendingMigration) {
|
|
442
|
+
try {
|
|
443
|
+
rollbackPiProfileMigration(pendingMigration.state);
|
|
444
|
+
}
|
|
445
|
+
catch { /* best effort */ }
|
|
446
|
+
pendingMigration = null;
|
|
447
|
+
}
|
|
356
448
|
providerTransaction?.rollback();
|
|
357
449
|
throw error;
|
|
358
450
|
}
|
|
@@ -361,15 +453,6 @@ export async function main() {
|
|
|
361
453
|
process.off("SIGINT", onSigint);
|
|
362
454
|
process.off("SIGTERM", onSigterm);
|
|
363
455
|
}
|
|
364
|
-
fabricateAttachment(layout.root, stored.serverId);
|
|
365
|
-
say(`\n✓ 已写配置: ${CFG_FILE}`);
|
|
366
|
-
say(` agent=${profile.appId} runtime=${bound.runtime} model=${bound.model}`);
|
|
367
|
-
say(` 单服务 serverId=${stored.serverId} larkinHome=${layout.root}`);
|
|
368
|
-
say("\n启动这个 Agent:");
|
|
369
|
-
say(" larkin start # 单服务启动全部已配置 Agent");
|
|
370
|
-
say(` larkin start --agent ${profile.appId} # 仅调试这个 Agent`);
|
|
371
|
-
say("创建独立飞书(Lark)机器人 + Agent:");
|
|
372
|
-
say(" larkin setup");
|
|
373
456
|
}
|
|
374
457
|
if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) {
|
|
375
458
|
main().catch((error) => die(error instanceof Error ? error.message : String(error)));
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "larkin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"larkinPackageRole": "npm-published",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist/",
|
|
8
|
+
"patches/",
|
|
8
9
|
"assets/",
|
|
9
10
|
"scripts/npm/",
|
|
10
11
|
"README.md",
|
|
@@ -114,5 +115,8 @@
|
|
|
114
115
|
"typescript": "^7.0.2",
|
|
115
116
|
"vite": "^8.1.5",
|
|
116
117
|
"vitest": "^4.1.10"
|
|
118
|
+
},
|
|
119
|
+
"patchedDependencies": {
|
|
120
|
+
"@tintinweb/pi-subagents@0.14.3": "patches/@tintinweb%2Fpi-subagents@0.14.3.patch"
|
|
117
121
|
}
|
|
118
122
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-58e08ce8246a76a0 b/.bun-tag-58e08ce8246a76a0
|
|
2
|
+
new file mode 100644
|
|
3
|
+
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
4
|
+
diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-6ef69510e99ece5d b/.bun-tag-6ef69510e99ece5d
|
|
5
|
+
new file mode 100644
|
|
6
|
+
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
7
|
+
diff --git a/node_modules/@tintinweb/pi-subagents/.bun-tag-80c5d241789d23ea b/.bun-tag-80c5d241789d23ea
|
|
8
|
+
new file mode 100644
|
|
9
|
+
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
10
|
+
diff --git a/dist/index.js b/dist/index.js
|
|
11
|
+
index ab65ef15b5c1da803c0378b1d0ba383645198028..dc18e00d2ce4bddb6ee77608364d3c8b39fdfd60 100644
|
|
12
|
+
--- a/dist/index.js
|
|
13
|
+
+++ b/dist/index.js
|
|
14
|
+
@@ -69,6 +69,44 @@ function abortable(promise, signal) {
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
+const RESULT_WAIT_QUEUE_POLL_MS = 50;
|
|
19
|
+
+const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30000;
|
|
20
|
+
+const MAX_RESULT_WAIT_TIMEOUT_MS = 60000;
|
|
21
|
+
+function normalizeResultWaitTimeout(timeoutMs) {
|
|
22
|
+
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs))
|
|
23
|
+
+ return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
|
|
24
|
+
+ return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
|
|
25
|
+
+}
|
|
26
|
+
+function waitWithDeadline(promise, timeoutMs, signal) {
|
|
27
|
+
+ let timer;
|
|
28
|
+
+ const timeout = new Promise((resolve) => {
|
|
29
|
+
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
30
|
+
+ });
|
|
31
|
+
+ return abortable(Promise.race([
|
|
32
|
+
+ promise.then((value) => ({ timedOut: false, value })),
|
|
33
|
+
+ timeout,
|
|
34
|
+
+ ]), signal).finally(() => {
|
|
35
|
+
+ if (timer !== undefined)
|
|
36
|
+
+ clearTimeout(timer);
|
|
37
|
+
+ });
|
|
38
|
+
+}
|
|
39
|
+
+/** Wait for one background record without cancelling it when the deadline expires. */
|
|
40
|
+
+export async function waitForSubagentResult(record, timeoutMs, signal) {
|
|
41
|
+
+ const deadline = Date.now() + timeoutMs;
|
|
42
|
+
+ while (record.status === "queued") {
|
|
43
|
+
+ const remainingMs = deadline - Date.now();
|
|
44
|
+
+ if (remainingMs <= 0)
|
|
45
|
+
+ return true;
|
|
46
|
+
+ await abortable(new Promise((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))), signal);
|
|
47
|
+
+ }
|
|
48
|
+
+ if (!record.promise)
|
|
49
|
+
+ return false;
|
|
50
|
+
+ const remainingMs = deadline - Date.now();
|
|
51
|
+
+ if (remainingMs <= 0)
|
|
52
|
+
+ return true;
|
|
53
|
+
+ return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
|
|
54
|
+
+}
|
|
55
|
+
+const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
56
|
+
export function renderRunningAgentStatus(frame, statsText, activity, theme) {
|
|
57
|
+
const container = new Container();
|
|
58
|
+
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
|
|
59
|
+
@@ -280,9 +318,14 @@ export default function (pi) {
|
|
60
|
+
// before they reach pi.sendMessage (fire-and-forget).
|
|
61
|
+
const pendingNudges = new Map();
|
|
62
|
+
const NUDGE_HOLD_MS = 200;
|
|
63
|
+
+ // A parent agent run may span multiple model turns. Consume the one-wait
|
|
64
|
+
+ // budget when a queued/running wait begins, rather than only after timeout.
|
|
65
|
+
+ let resultWaitConsumedInParentTurn = false;
|
|
66
|
+
+ pi.on("agent_start", () => {
|
|
67
|
+
+ resultWaitConsumedInParentTurn = false;
|
|
68
|
+
+ });
|
|
69
|
+
// A queued result wait must observe completion before its held notification
|
|
70
|
+
// can fire, so successful waits can still suppress that redundant nudge.
|
|
71
|
+
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
|
|
72
|
+
function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
|
|
73
|
+
cancelNudge(key);
|
|
74
|
+
pendingNudges.set(key, setTimeout(() => {
|
|
75
|
+
@@ -1293,6 +1336,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
76
|
+
wait: Type.Optional(Type.Boolean({
|
|
77
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
78
|
+
})),
|
|
79
|
+
+ timeout_ms: Type.Optional(Type.Number({
|
|
80
|
+
+ description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
|
|
81
|
+
+ })),
|
|
82
|
+
verbose: Type.Optional(Type.Boolean({
|
|
83
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
84
|
+
})),
|
|
85
|
+
@@ -1307,12 +1353,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
86
|
+
// completion notification can still be delivered.
|
|
87
|
+
// Queued agents have no promise yet (it's created when the queue starts
|
|
88
|
+
// them), so poll until they leave the queue, then await like a running one.
|
|
89
|
+
+ // One deadline covers both phases so queue polling cannot reset the wait.
|
|
90
|
+
+ if (params.wait && resultWaitConsumedInParentTurn) {
|
|
91
|
+
+ throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
|
|
92
|
+
+ }
|
|
93
|
+
+ let timedOut = false;
|
|
94
|
+
+ const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
|
|
95
|
+
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
96
|
+
- while (record.status === "queued") {
|
|
97
|
+
- await abortable(new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
|
|
98
|
+
- }
|
|
99
|
+
- if (record.promise)
|
|
100
|
+
- await abortable(record.promise, signal);
|
|
101
|
+
+ resultWaitConsumedInParentTurn = true;
|
|
102
|
+
+ timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
|
|
103
|
+
}
|
|
104
|
+
const displayName = getDisplayName(record.type);
|
|
105
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
106
|
+
@@ -1327,10 +1376,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
107
|
+
statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
108
|
+
statsParts.push(`Duration: ${duration}`);
|
|
109
|
+
let output = `Agent: ${record.id}\n` +
|
|
110
|
+
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
111
|
+
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
|
|
112
|
+
`Description: ${record.description}\n\n`;
|
|
113
|
+
- if (record.status === "running") {
|
|
114
|
+
- output += "Agent is still running. Use wait: true or check back later.";
|
|
115
|
+
+ if (record.status === "running" || record.status === "queued") {
|
|
116
|
+
+ output += timedOut
|
|
117
|
+
+ ? `Agent is still ${record.status}. Wait timed out after ${waitTimeoutMs}ms; the background agent was not cancelled. Yield/end this turn and await the completion notification; do not call wait: true again.`
|
|
118
|
+
+ : "Agent is still running. Use wait: true or check back later.";
|
|
119
|
+
}
|
|
120
|
+
else if (record.status === "error") {
|
|
121
|
+
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
122
|
+
diff --git a/src/index.ts b/src/index.ts
|
|
123
|
+
index 828938e6d710dc5a8f99a6185e37f9592b12352d..fb2d3119a50c6aa9c8864037af65ff95bbe4bb1f 100644
|
|
124
|
+
--- a/src/index.ts
|
|
125
|
+
+++ b/src/index.ts
|
|
126
|
+
@@ -91,6 +91,54 @@ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
+const RESULT_WAIT_QUEUE_POLL_MS = 50;
|
|
131
|
+
+const DEFAULT_RESULT_WAIT_TIMEOUT_MS = 30_000;
|
|
132
|
+
+const MAX_RESULT_WAIT_TIMEOUT_MS = 60_000;
|
|
133
|
+
+
|
|
134
|
+
+function normalizeResultWaitTimeout(timeoutMs: number | undefined): number {
|
|
135
|
+
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_RESULT_WAIT_TIMEOUT_MS;
|
|
136
|
+
+ return Math.max(1, Math.min(MAX_RESULT_WAIT_TIMEOUT_MS, Math.floor(timeoutMs)));
|
|
137
|
+
+}
|
|
138
|
+
+
|
|
139
|
+
+function waitWithDeadline<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<{ timedOut: true } | { timedOut: false; value: T }> {
|
|
140
|
+
+ let timer: ReturnType<typeof setTimeout> | undefined;
|
|
141
|
+
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
|
|
142
|
+
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
143
|
+
+ });
|
|
144
|
+
+ return abortable(
|
|
145
|
+
+ Promise.race([
|
|
146
|
+
+ promise.then((value) => ({ timedOut: false as const, value })),
|
|
147
|
+
+ timeout,
|
|
148
|
+
+ ]),
|
|
149
|
+
+ signal,
|
|
150
|
+
+ ).finally(() => {
|
|
151
|
+
+ if (timer !== undefined) clearTimeout(timer);
|
|
152
|
+
+ });
|
|
153
|
+
+}
|
|
154
|
+
+
|
|
155
|
+
+/** Wait for one background record without cancelling it when the deadline expires. */
|
|
156
|
+
+export async function waitForSubagentResult(
|
|
157
|
+
+ record: Pick<AgentRecord, "status" | "promise">,
|
|
158
|
+
+ timeoutMs: number,
|
|
159
|
+
+ signal?: AbortSignal,
|
|
160
|
+
+): Promise<boolean> {
|
|
161
|
+
+ const deadline = Date.now() + timeoutMs;
|
|
162
|
+
+ while (record.status === "queued") {
|
|
163
|
+
+ const remainingMs = deadline - Date.now();
|
|
164
|
+
+ if (remainingMs <= 0) return true;
|
|
165
|
+
+ await abortable(
|
|
166
|
+
+ new Promise<void>((resolve) => setTimeout(resolve, Math.min(RESULT_WAIT_QUEUE_POLL_MS, remainingMs))),
|
|
167
|
+
+ signal,
|
|
168
|
+
+ );
|
|
169
|
+
+ }
|
|
170
|
+
+ if (!record.promise) return false;
|
|
171
|
+
+ const remainingMs = deadline - Date.now();
|
|
172
|
+
+ if (remainingMs <= 0) return true;
|
|
173
|
+
+ return (await waitWithDeadline(record.promise, remainingMs, signal)).timedOut;
|
|
174
|
+
+}
|
|
175
|
+
+
|
|
176
|
+
+export const LARKIN_BOUNDED_WAIT_CAPABILITY = "larkin-pi-subagents-bounded-wait-v1";
|
|
177
|
+
+
|
|
178
|
+
export function renderRunningAgentStatus(
|
|
179
|
+
frame: string,
|
|
180
|
+
statsText: string,
|
|
181
|
+
@@ -330,10 +378,14 @@ export default function (pi: ExtensionAPI) {
|
|
182
|
+
// before they reach pi.sendMessage (fire-and-forget).
|
|
183
|
+
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
184
|
+
const NUDGE_HOLD_MS = 200;
|
|
185
|
+
+ // A parent agent run may span multiple model turns. Consume the one-wait
|
|
186
|
+
+ // budget when a queued/running wait begins, rather than only after timeout.
|
|
187
|
+
+ let resultWaitConsumedInParentTurn = false;
|
|
188
|
+
+ pi.on("agent_start", () => {
|
|
189
|
+
+ resultWaitConsumedInParentTurn = false;
|
|
190
|
+
+ });
|
|
191
|
+
// A queued result wait must observe completion before its held notification
|
|
192
|
+
// can fire, so successful waits can still suppress that redundant nudge.
|
|
193
|
+
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
|
|
194
|
+
-
|
|
195
|
+
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
196
|
+
cancelNudge(key);
|
|
197
|
+
pendingNudges.set(key, setTimeout(() => {
|
|
198
|
+
@@ -1453,6 +1505,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
199
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
+ timeout_ms: Type.Optional(
|
|
203
|
+
+ Type.Number({
|
|
204
|
+
+ description: "Maximum wait in milliseconds when wait is true. Defaults to 30000 and is capped at 60000.",
|
|
205
|
+
+ }),
|
|
206
|
+
+ ),
|
|
207
|
+
verbose: Type.Optional(
|
|
208
|
+
Type.Boolean({
|
|
209
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
210
|
+
@@ -1470,14 +1527,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
211
|
+
// completion notification can still be delivered.
|
|
212
|
+
// Queued agents have no promise yet (it's created when the queue starts
|
|
213
|
+
// them), so poll until they leave the queue, then await like a running one.
|
|
214
|
+
+ // One deadline covers both phases so queue polling cannot reset the wait.
|
|
215
|
+
+ if (params.wait && resultWaitConsumedInParentTurn) {
|
|
216
|
+
+ throw new Error("Refusing wait: true after an earlier wait began in this parent turn; yield and await the completion notification instead.");
|
|
217
|
+
+ }
|
|
218
|
+
+ let timedOut = false;
|
|
219
|
+
+ const waitTimeoutMs = normalizeResultWaitTimeout(params.timeout_ms);
|
|
220
|
+
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
221
|
+
- while (record.status === "queued") {
|
|
222
|
+
- await abortable(
|
|
223
|
+
- new Promise<void>((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)),
|
|
224
|
+
- signal,
|
|
225
|
+
- );
|
|
226
|
+
- }
|
|
227
|
+
- if (record.promise) await abortable(record.promise, signal);
|
|
228
|
+
+ resultWaitConsumedInParentTurn = true;
|
|
229
|
+
+ timedOut = await waitForSubagentResult(record, waitTimeoutMs, signal);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const displayName = getDisplayName(record.type);
|
|
233
|
+
@@ -1492,11 +1550,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
234
|
+
|
|
235
|
+
let output =
|
|
236
|
+
`Agent: ${record.id}\n` +
|
|
237
|
+
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
238
|
+
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)}${timedOut ? " | timedOut: true" : ""} | ${statsParts.join(" | ")}\n` +
|
|
239
|
+
`Description: ${record.description}\n\n`;
|
|
240
|
+
|
|
241
|
+
- if (record.status === "running") {
|
|
242
|
+
- output += "Agent is still running. Use wait: true or check back later.";
|
|
243
|
+
+ if (record.status === "running" || record.status === "queued") {
|
|
244
|
+
+ output += timedOut
|
|
245
|
+
+ ? `Agent is still ${record.status}. Wait timed out after ${waitTimeoutMs}ms; the background agent was not cancelled. Yield/end this turn and await the completion notification; do not call wait: true again.`
|
|
246
|
+
+ : "Agent is still running. Use wait: true or check back later.";
|
|
247
|
+
} else if (record.status === "error") {
|
|
248
|
+
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
249
|
+
} else {
|