nexus-agentd 0.4.0 → 0.4.2
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/README.md +6 -1
- package/dist/config.js +1 -0
- package/dist/control-plane-validation.js +1 -1
- package/dist/control-plane.js +2 -1
- package/dist/drivers/stdio.d.ts +8 -0
- package/dist/drivers/stdio.js +13 -2
- package/dist/process-tree.js +76 -28
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -118,11 +118,16 @@ npm install -g \
|
|
|
118
118
|
pi-acp
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
`command`、`args`、`probeArgs`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI
|
|
121
|
+
`command`、`args`、`probeArgs`、`probeTimeoutMs`、`inheritEnv` 和 `env` 是仅可在本机配置文件修改的高级字段,WebUI
|
|
122
122
|
不接受这些字段。Workspace 在启动进程前经过 `realpath` 边界校验。通用 `stdio` 驱动用于自定义入口,
|
|
123
123
|
必须指定 `command`,用 `probeArgs` 指定可用性检查参数(例如 `["--version"]`);编辑其他字段时保留
|
|
124
124
|
这些本地配置。命令探测不等于 ACP 握手成功。
|
|
125
125
|
|
|
126
|
+
`probeTimeoutMs` 是可用性探测的期限,默认 60000 毫秒。探测会真正拉起一次 Agent 进程,而 Agent 在
|
|
127
|
+
应答探测前往往要加载大量插件(例如 Hermes 会注册 50 多个插件提供者),启动耗时远超普通 shell
|
|
128
|
+
命令;期限过短会把正在正常启动的 Agent 报成「不可用 / probe timed out」。启动较慢的 Agent 按实测
|
|
129
|
+
启动时间调高该值即可。
|
|
130
|
+
|
|
126
131
|
权限策略支持 `ask`(询问)、`allow`(自动允许单次)和 `deny`(拒绝)。`allow` 只选择 Agent 提供的
|
|
127
132
|
`allow_once`,缺少该选项则取消授权,不回退到永久授权。`ask` 模式下普通 `action: "accept"` 也只选择
|
|
128
133
|
单次授权,永久授权需要客户端明确传入对应 `optionId`。这不限制重复的单次自动授权,但只提供永久选项的
|
package/dist/config.js
CHANGED
|
@@ -181,6 +181,7 @@ function parseAgent(id, value) {
|
|
|
181
181
|
probeArgs: input.probeArgs === undefined
|
|
182
182
|
? undefined
|
|
183
183
|
: stringArray(input.probeArgs, `agents.${id}.probeArgs`),
|
|
184
|
+
probeTimeoutMs: integerValue(input.probeTimeoutMs, `agents.${id}.probeTimeoutMs`, 60 * 1000, 1000, 10 * 60 * 1000),
|
|
184
185
|
inheritEnv: input.inheritEnv === undefined
|
|
185
186
|
? undefined
|
|
186
187
|
: stringArray(input.inheritEnv, `agents.${id}.inheritEnv`),
|
|
@@ -117,7 +117,7 @@ export function clampTimeout(value, fallback, min, max) {
|
|
|
117
117
|
}
|
|
118
118
|
export function preservedAcpAdvanced(previous) {
|
|
119
119
|
const result = {};
|
|
120
|
-
for (const key of ['command', 'args', 'probeArgs', 'inheritEnv', 'env']) {
|
|
120
|
+
for (const key of ['command', 'args', 'probeArgs', 'probeTimeoutMs', 'inheritEnv', 'env']) {
|
|
121
121
|
if (previous[key] !== undefined)
|
|
122
122
|
result[key] = structuredClone(previous[key]);
|
|
123
123
|
}
|
package/dist/control-plane.js
CHANGED
|
@@ -513,7 +513,8 @@ export class AgentdControlPlane {
|
|
|
513
513
|
enabled: config.enabled !== false,
|
|
514
514
|
workspace: config.workspace || this.config.workspaceRoots[0] || '',
|
|
515
515
|
permissionPolicy: config.permissionPolicy || 'ask',
|
|
516
|
-
permissionTimeoutMs: config.permissionTimeoutMs || 15 * 60 * 1000
|
|
516
|
+
permissionTimeoutMs: config.permissionTimeoutMs || 15 * 60 * 1000,
|
|
517
|
+
probeTimeoutMs: config.probeTimeoutMs || 60 * 1000
|
|
517
518
|
};
|
|
518
519
|
}
|
|
519
520
|
exclusive(task) {
|
package/dist/drivers/stdio.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { AgentdDriverConfig } from '../types.js';
|
|
2
2
|
import type { AgentDriver } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* How long an availability probe may take before it is reported as a timeout.
|
|
5
|
+
* Probes spawn a real agent process, and agents that load plugins before
|
|
6
|
+
* answering (Hermes registers 50+ plugin providers) need far longer than the
|
|
7
|
+
* five seconds that used to be hard-coded here — they were reported as down
|
|
8
|
+
* while starting up normally. Override per agent with `probeTimeoutMs`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_PROBE_TIMEOUT_MS = 60000;
|
|
3
11
|
export interface StdioAcpDriverDefaults {
|
|
4
12
|
name: string;
|
|
5
13
|
description: string;
|
package/dist/drivers/stdio.js
CHANGED
|
@@ -27,6 +27,14 @@ const BASE_ENV_KEYS = [
|
|
|
27
27
|
'ProgramFiles(x86)',
|
|
28
28
|
'PATHEXT'
|
|
29
29
|
];
|
|
30
|
+
/**
|
|
31
|
+
* How long an availability probe may take before it is reported as a timeout.
|
|
32
|
+
* Probes spawn a real agent process, and agents that load plugins before
|
|
33
|
+
* answering (Hermes registers 50+ plugin providers) need far longer than the
|
|
34
|
+
* five seconds that used to be hard-coded here — they were reported as down
|
|
35
|
+
* while starting up normally. Override per agent with `probeTimeoutMs`.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 60_000;
|
|
30
38
|
export function createStdioAcpDriver(id, config, defaults) {
|
|
31
39
|
const command = config.command || defaults.command;
|
|
32
40
|
const args = config.args === undefined ? [...defaults.args] : [...config.args];
|
|
@@ -45,7 +53,10 @@ export function createStdioAcpDriver(id, config, defaults) {
|
|
|
45
53
|
ownsProcessGroup: process.platform !== 'win32',
|
|
46
54
|
async probe() {
|
|
47
55
|
try {
|
|
48
|
-
const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, {
|
|
56
|
+
const version = await probeCommand(command, config.probeArgs || defaults.probeArgs || ['--version'], env, name, {
|
|
57
|
+
ownsProcessGroup: process.platform !== 'win32',
|
|
58
|
+
timeoutMs: config.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
|
|
59
|
+
});
|
|
49
60
|
return {
|
|
50
61
|
id,
|
|
51
62
|
name,
|
|
@@ -155,7 +166,7 @@ export function probeCommand(command, args, env, label, options = {}) {
|
|
|
155
166
|
// process. Wait for tree termination before rejecting so a timed
|
|
156
167
|
// out health check cannot leave an agent running in the background.
|
|
157
168
|
void cleanup().then(() => finish(new Error(`${label} probe timed out`)), (error) => finish(new Error(`${label} probe timed out; cleanup failed: ${cleanupFailure(error)}`)));
|
|
158
|
-
}, options.timeoutMs ??
|
|
169
|
+
}, options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
|
|
159
170
|
timer.unref?.();
|
|
160
171
|
child.stdout.on('data', (chunk) => {
|
|
161
172
|
output = `${output}${String(chunk)}`.slice(-8192);
|
package/dist/process-tree.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const DEFAULT_GRACE_MS = 2_000;
|
|
3
3
|
const FORCE_KILL_WAIT_MS = 1_000;
|
|
4
|
-
// Enumerating costs about
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
const PROCESS_TABLE_TIMEOUT_MS =
|
|
4
|
+
// Enumerating the process table costs about 1.3s on an idle machine (roughly
|
|
5
|
+
// 0.4s of PowerShell startup plus the CIM query) and more when the gateway is
|
|
6
|
+
// busy. The previous 1.2s cap sat just below that, so the sweep timed out and
|
|
7
|
+
// silently found nothing; leave headroom for the query to actually finish.
|
|
8
|
+
const PROCESS_TABLE_TIMEOUT_MS = 2_500;
|
|
9
9
|
const PROCESS_TABLE_TTL_MS = 1_000;
|
|
10
|
+
// Ceiling for one orphan sweep. The sweep runs on every teardown — including a
|
|
11
|
+
// failed ACP startup, which callers bound at five seconds — so it must stay
|
|
12
|
+
// bounded: an incomplete sweep beats a teardown that never returns.
|
|
13
|
+
const ORPHAN_SWEEP_BUDGET_MS = 3_000;
|
|
10
14
|
// Windows reports a killed process as gone from `tasklist` slightly before the
|
|
11
15
|
// kernel closes the handles it held, so callers that delete the workspace or
|
|
12
16
|
// temp directory right after a teardown can still see EBUSY.
|
|
@@ -22,6 +26,11 @@ export async function terminateProcessTree(child, options = {}) {
|
|
|
22
26
|
// never handle: the graceful pass always fails and burns the whole
|
|
23
27
|
// grace period before the forced retry. Force the tree kill directly.
|
|
24
28
|
if (!hasExited(child)) {
|
|
29
|
+
// Enumerating the process table is the slowest step of a Windows
|
|
30
|
+
// teardown and does not depend on the leader being gone, so start
|
|
31
|
+
// it alongside the kill instead of after it. The snapshot then
|
|
32
|
+
// predates the tree kill, which the liveness filter below handles.
|
|
33
|
+
const table = windowsProcessTable();
|
|
25
34
|
await taskkill(pid, true);
|
|
26
35
|
if (!(await waitForExit(child, Math.max(graceMs, FORCE_KILL_WAIT_MS)))) {
|
|
27
36
|
// A forced kill can still lose the race on a loaded machine,
|
|
@@ -29,13 +38,17 @@ export async function terminateProcessTree(child, options = {}) {
|
|
|
29
38
|
await taskkill(pid, true);
|
|
30
39
|
await waitForExit(child, FORCE_KILL_WAIT_MS);
|
|
31
40
|
}
|
|
41
|
+
// A forced `taskkill /T` walks the tree as it exists at that
|
|
42
|
+
// instant, so a child spawned moments before the leader died
|
|
43
|
+
// survives as an orphan holding ports, file locks and inherited
|
|
44
|
+
// pipes. Sweep by parent id for whatever the tree kill missed.
|
|
45
|
+
await terminateOrphanedDescendants(pid, ORPHAN_SWEEP_BUDGET_MS, table);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// A leader that exited before we got here cannot be addressed by
|
|
49
|
+
// taskkill at all, so nothing has swept its descendants yet.
|
|
50
|
+
await terminateOrphanedDescendants(pid);
|
|
32
51
|
}
|
|
33
|
-
// A forced `taskkill /T` walks the tree as it exists at that instant,
|
|
34
|
-
// so a child spawned moments before the leader died survives as an
|
|
35
|
-
// orphan holding ports, file locks and inherited pipes — and a leader
|
|
36
|
-
// that exited before we got here cannot be addressed by taskkill at
|
|
37
|
-
// all. Sweep by parent id in both cases.
|
|
38
|
-
await terminateOrphanedDescendants(pid);
|
|
39
52
|
if (hasExited(child)) {
|
|
40
53
|
await delay(PROCESS_EXIT_SETTLE_MS);
|
|
41
54
|
return;
|
|
@@ -115,17 +128,31 @@ function taskkill(pid, force) {
|
|
|
115
128
|
const args = ['/PID', String(pid), '/T'];
|
|
116
129
|
if (force)
|
|
117
130
|
args.push('/F');
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
killer.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
let killer;
|
|
132
|
+
try {
|
|
133
|
+
killer = spawn('taskkill.exe', args, { stdio: 'ignore', windowsHide: true });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
resolve(false);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let settled = false;
|
|
140
|
+
const finish = (killed) => {
|
|
141
|
+
if (settled)
|
|
142
|
+
return;
|
|
143
|
+
settled = true;
|
|
124
144
|
clearTimeout(timer);
|
|
125
|
-
resolve();
|
|
145
|
+
resolve(killed);
|
|
126
146
|
};
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
try {
|
|
149
|
+
killer.kill();
|
|
150
|
+
}
|
|
151
|
+
catch { }
|
|
152
|
+
finish(false);
|
|
153
|
+
}, FORCE_KILL_WAIT_MS);
|
|
154
|
+
killer.once('error', () => finish(false));
|
|
155
|
+
killer.once('exit', (code) => finish(code === 0));
|
|
129
156
|
});
|
|
130
157
|
}
|
|
131
158
|
/**
|
|
@@ -136,16 +163,37 @@ function taskkill(pid, force) {
|
|
|
136
163
|
* after that, which is enough to find the survivors. Best-effort by design: an
|
|
137
164
|
* unreadable process table just leaves the sweep incomplete.
|
|
138
165
|
*/
|
|
139
|
-
async function terminateOrphanedDescendants(pid) {
|
|
140
|
-
const
|
|
166
|
+
async function terminateOrphanedDescendants(pid, budgetMs = ORPHAN_SWEEP_BUDGET_MS, table) {
|
|
167
|
+
const deadline = Date.now() + budgetMs;
|
|
168
|
+
const snapshot = await (table ?? windowsProcessTable());
|
|
169
|
+
// The snapshot may predate a caller's tree kill, and each taskkill costs
|
|
170
|
+
// the better part of a second, so only ever aim at processes still alive.
|
|
171
|
+
const doomed = collectDescendants(snapshot, pid).filter((target) => processAlive(target));
|
|
141
172
|
if (!doomed.length)
|
|
142
173
|
return;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
// `collectDescendants` already returns the whole descendant tree, so kill
|
|
175
|
+
// them together rather than paying the taskkill cost once per process.
|
|
176
|
+
await Promise.all(doomed.map((target) => taskkill(target, true)));
|
|
177
|
+
// Killing a parent can strand its own children, but the first pass covered
|
|
178
|
+
// the entire tree, so a rescan only pays off when one of the targets is
|
|
179
|
+
// still alive. Each rescan costs another full enumeration.
|
|
180
|
+
if (Date.now() >= deadline)
|
|
181
|
+
return;
|
|
182
|
+
if (!doomed.some((target) => processAlive(target)))
|
|
183
|
+
return;
|
|
184
|
+
const remaining = collectDescendants(await windowsProcessTable(true), pid)
|
|
185
|
+
.filter((survivor) => processAlive(survivor));
|
|
186
|
+
await Promise.all(remaining.map((survivor) => taskkill(survivor, true)));
|
|
187
|
+
}
|
|
188
|
+
function processAlive(pid) {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// EPERM means the process exists but belongs to someone else.
|
|
195
|
+
return error.code === 'EPERM';
|
|
196
|
+
}
|
|
149
197
|
}
|
|
150
198
|
function collectDescendants(table, root) {
|
|
151
199
|
if (!table)
|
package/dist/types.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface AgentdDriverConfig {
|
|
|
27
27
|
args?: string[];
|
|
28
28
|
/** Arguments used by a command availability probe. */
|
|
29
29
|
probeArgs?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Milliseconds the availability probe may take before it is reported as a
|
|
32
|
+
* timeout. Agent CLIs that load plugins before answering a probe need far
|
|
33
|
+
* more than a shell command; defaults to 60000.
|
|
34
|
+
*/
|
|
35
|
+
probeTimeoutMs?: number;
|
|
30
36
|
inheritEnv?: string[];
|
|
31
37
|
env?: Record<string, string>;
|
|
32
38
|
permissionPolicy?: PermissionPolicy;
|
|
@@ -141,6 +147,7 @@ export interface AgentdAgentConfigView {
|
|
|
141
147
|
workspace?: string;
|
|
142
148
|
permissionPolicy?: PermissionPolicy;
|
|
143
149
|
permissionTimeoutMs?: number;
|
|
150
|
+
probeTimeoutMs?: number;
|
|
144
151
|
agentCardUrl?: string;
|
|
145
152
|
agentUrl?: string;
|
|
146
153
|
preferredTransport?: A2ATransportPreference;
|