@steerable/agent-shell 0.6.51 → 0.6.53
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/brand.d.ts +5 -0
- package/dist/goal-store.d.ts +43 -0
- package/dist/goal-store.js +155 -0
- package/dist/host/runtime.js +15 -3
- package/dist/host-tools.d.ts +1 -1
- package/dist/host-tools.js +6 -0
- package/dist/local-backend/portable-bundle.d.ts +16 -2
- package/dist/local-backend/portable-bundle.js +21 -9
- package/dist/local-backend/portable-service.js +25 -11
- package/dist/local-backend/router.js +71 -27
- package/dist/local-backend/subagent-profiles.js +4 -0
- package/dist/local-backend/task-service.d.ts +6 -0
- package/dist/local-backend/task-service.js +47 -2
- package/dist/local-executor.d.ts +36 -2
- package/dist/local-executor.js +171 -19
- package/dist/mcp-executor.d.ts +42 -10
- package/dist/mcp-executor.js +248 -41
- package/dist/mcp-server-registry.d.ts +56 -8
- package/dist/mcp-server-registry.js +191 -26
- package/dist/product-config.d.ts +14 -0
- package/dist/project-home.d.ts +1 -1
- package/dist/project-home.js +1 -1
- package/dist/project-registry.d.ts +4 -4
- package/dist/project-registry.js +2 -2
- package/dist/shell-exec.d.ts +62 -0
- package/dist/shell-exec.js +147 -0
- package/dist/shell-process.d.ts +3 -0
- package/dist/shell-process.js +137 -0
- package/dist/sidecar/boot.d.ts +5 -2
- package/dist/sidecar/boot.js +5 -0
- package/dist/sidecar/reverse-approval.d.ts +2 -0
- package/dist/sidecar/reverse-approval.js +2 -0
- package/dist/sidecar/reverse-tools.d.ts +6 -2
- package/dist/sidecar/reverse-tools.js +9 -1
- package/dist/sidecar/types.d.ts +10 -1
- package/dist/storage/write-lease-error.js +4 -1
- package/dist/tool-presentation.d.ts +23 -0
- package/dist/tool-presentation.js +266 -0
- package/dist/tool-router.d.ts +26 -8
- package/dist/tool-router.js +275 -24
- package/dist/workspace-search.d.ts +35 -0
- package/dist/workspace-search.js +186 -0
- package/package.json +5 -5
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地进程启动:管道用 child_process,PTY 用 node-pty。
|
|
3
|
+
* SSH 也走这里,argv 由 shell-exec 规划好,本文件不再解释命令文本。
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import * as pty from 'node-pty';
|
|
7
|
+
// eslint-disable-next-line no-control-regex
|
|
8
|
+
const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
9
|
+
export function stripAnsi(input) {
|
|
10
|
+
return input.replace(ANSI_RE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
11
|
+
}
|
|
12
|
+
export const nodeShellBackend = {
|
|
13
|
+
run(spec, limits) {
|
|
14
|
+
return spec.pty ? runPty(spec, limits) : runPipe(spec, limits);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
function runPipe(spec, limits) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const stdoutChunks = [];
|
|
20
|
+
const stderrChunks = [];
|
|
21
|
+
let stdoutBytes = 0;
|
|
22
|
+
let stderrBytes = 0;
|
|
23
|
+
let truncated = false;
|
|
24
|
+
let settled = false;
|
|
25
|
+
const child = spawn(spec.file, spec.args, {
|
|
26
|
+
cwd: spec.cwd,
|
|
27
|
+
env: spec.env,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
const finish = (outcome) => {
|
|
31
|
+
if (settled)
|
|
32
|
+
return;
|
|
33
|
+
settled = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve(outcome);
|
|
36
|
+
};
|
|
37
|
+
const take = (chunks, bytes, chunk) => {
|
|
38
|
+
if (bytes >= limits.maxOutputBytes) {
|
|
39
|
+
truncated = true;
|
|
40
|
+
return bytes;
|
|
41
|
+
}
|
|
42
|
+
const remain = limits.maxOutputBytes - bytes;
|
|
43
|
+
const safe = chunk.length > remain ? chunk.subarray(0, remain) : chunk;
|
|
44
|
+
chunks.push(safe);
|
|
45
|
+
if (safe.length < chunk.length)
|
|
46
|
+
truncated = true;
|
|
47
|
+
return bytes + safe.length;
|
|
48
|
+
};
|
|
49
|
+
child.stdout?.on('data', (chunk) => {
|
|
50
|
+
stdoutBytes = take(stdoutChunks, stdoutBytes, chunk);
|
|
51
|
+
});
|
|
52
|
+
child.stderr?.on('data', (chunk) => {
|
|
53
|
+
stderrBytes = take(stderrChunks, stderrBytes, chunk);
|
|
54
|
+
});
|
|
55
|
+
const stdout = () => Buffer.concat(stdoutChunks).toString('utf-8');
|
|
56
|
+
const stderr = () => Buffer.concat(stderrChunks).toString('utf-8');
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
if (!limits.gui)
|
|
59
|
+
child.kill();
|
|
60
|
+
finish({
|
|
61
|
+
exitCode: -1,
|
|
62
|
+
stdout: stdout(),
|
|
63
|
+
stderr: stderr(),
|
|
64
|
+
truncated,
|
|
65
|
+
timedOut: true,
|
|
66
|
+
stillRunning: limits.gui,
|
|
67
|
+
error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
|
|
68
|
+
});
|
|
69
|
+
}, limits.timeoutMs);
|
|
70
|
+
child.on('error', (err) => {
|
|
71
|
+
finish({ exitCode: -1, stdout: stdout(), stderr: stderr(), truncated, error: err.message });
|
|
72
|
+
});
|
|
73
|
+
child.on('close', (exitCode) => {
|
|
74
|
+
finish({
|
|
75
|
+
exitCode: exitCode ?? -1,
|
|
76
|
+
stdout: stdout(),
|
|
77
|
+
stderr: stderr(),
|
|
78
|
+
truncated,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function runPty(spec, limits) {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
let output = '';
|
|
86
|
+
let truncated = false;
|
|
87
|
+
let settled = false;
|
|
88
|
+
const proc = pty.spawn(spec.file, spec.args, {
|
|
89
|
+
name: 'xterm-256color',
|
|
90
|
+
cols: 120,
|
|
91
|
+
rows: 30,
|
|
92
|
+
cwd: spec.cwd,
|
|
93
|
+
env: { ...spec.env, TERM: 'xterm-256color' },
|
|
94
|
+
});
|
|
95
|
+
const finish = (outcome) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
resolve(outcome);
|
|
101
|
+
};
|
|
102
|
+
proc.onData((chunk) => {
|
|
103
|
+
const next = output + chunk;
|
|
104
|
+
if (Buffer.byteLength(next) > limits.maxOutputBytes) {
|
|
105
|
+
truncated = true;
|
|
106
|
+
output = trimToBytes(next, limits.maxOutputBytes);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
output = next;
|
|
110
|
+
});
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
if (!limits.gui)
|
|
113
|
+
proc.kill();
|
|
114
|
+
finish({
|
|
115
|
+
exitCode: -1,
|
|
116
|
+
stdout: stripAnsi(output),
|
|
117
|
+
stderr: '',
|
|
118
|
+
truncated,
|
|
119
|
+
timedOut: true,
|
|
120
|
+
stillRunning: limits.gui,
|
|
121
|
+
error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
|
|
122
|
+
});
|
|
123
|
+
}, limits.timeoutMs);
|
|
124
|
+
proc.onExit(({ exitCode }) => {
|
|
125
|
+
finish({
|
|
126
|
+
exitCode: exitCode ?? -1,
|
|
127
|
+
stdout: stripAnsi(output),
|
|
128
|
+
stderr: '',
|
|
129
|
+
truncated,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function trimToBytes(value, maxBytes) {
|
|
135
|
+
const buffer = Buffer.from(value);
|
|
136
|
+
return buffer.subarray(0, maxBytes).toString('utf-8');
|
|
137
|
+
}
|
package/dist/sidecar/boot.d.ts
CHANGED
|
@@ -9,10 +9,13 @@ export interface HostSidecarDeps {
|
|
|
9
9
|
/** W4-2 围栏:chatId → 项目家目录或无项目对话工作区。 */
|
|
10
10
|
resolveProjectRoot: (chatId: string) => Promise<string | null>;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
* projectRoot 围栏约束;这些根只放宽 local_read_file 的读取范围。
|
|
12
|
+
* 额外只读根(会话附件目录、源文件夹)。只放宽 local_read_file,含子目录。
|
|
14
13
|
*/
|
|
15
14
|
resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
|
|
15
|
+
/**
|
|
16
|
+
* 额外可写根(项目源文件夹)。放宽写入、编辑和命令 cwd,含各自子目录。
|
|
17
|
+
*/
|
|
18
|
+
resolveAdditionalWriteRoots?: (chatId: string) => string[] | Promise<string[]>;
|
|
16
19
|
/** W4-1 审批反向通道处理器(宿主审批弹窗的应答入口)。 */
|
|
17
20
|
approvalHandler: ReturnType<typeof createApprovalBridge>['handler'];
|
|
18
21
|
/**
|
package/dist/sidecar/boot.js
CHANGED
|
@@ -20,6 +20,7 @@ import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, d
|
|
|
20
20
|
import { WEB_TOOL_RPC_TIMEOUT_MS, webToolsEnabled } from '../tool-router.js';
|
|
21
21
|
import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
|
|
22
22
|
import { executeHostedWebSearch } from '../hosted-web-search.js';
|
|
23
|
+
import { getProductConfig } from '../product-config.js';
|
|
23
24
|
let egressProxyRef = null;
|
|
24
25
|
let supervisorRef = null;
|
|
25
26
|
let sidecarGeneration = 0;
|
|
@@ -121,6 +122,9 @@ async function startEgressProxyIfEnabled(store) {
|
|
|
121
122
|
? {
|
|
122
123
|
STEERABLE_EGRESS_CONTROL_PORT: String(egressProxyRef.controlPort),
|
|
123
124
|
[plan.control.tokenEnv]: plan.control.tokenValue,
|
|
125
|
+
...(getProductConfig().egressApproval === 'off'
|
|
126
|
+
? { STEERABLE_EGRESS_APPROVAL: 'off' }
|
|
127
|
+
: {}),
|
|
124
128
|
}
|
|
125
129
|
: {};
|
|
126
130
|
return {
|
|
@@ -214,6 +218,7 @@ export async function startHostSidecar(deps) {
|
|
|
214
218
|
// root so ToolRouter's cwd/path confinement applies.
|
|
215
219
|
resolveProjectRoot: deps.resolveProjectRoot,
|
|
216
220
|
resolveAdditionalReadRoots: deps.resolveAdditionalReadRoots,
|
|
221
|
+
resolveAdditionalWriteRoots: deps.resolveAdditionalWriteRoots,
|
|
217
222
|
}));
|
|
218
223
|
// W4-1: approval algebra reverse channel — the sidecar's
|
|
219
224
|
// ApprovalExecutor(HostApprover) asks here; the renderer modal answers
|
|
@@ -43,6 +43,7 @@ export function createApprovalBridge(deps) {
|
|
|
43
43
|
return deny('no renderer window available to approve the call');
|
|
44
44
|
}
|
|
45
45
|
const requestId = randomUUID();
|
|
46
|
+
const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
|
|
46
47
|
const prompt = {
|
|
47
48
|
requestId,
|
|
48
49
|
toolName,
|
|
@@ -52,6 +53,7 @@ export function createApprovalBridge(deps) {
|
|
|
52
53
|
mode: typeof p.mode === 'string' ? p.mode : 'other',
|
|
53
54
|
category: typeof p.category === 'string' ? p.category : toolName,
|
|
54
55
|
round: typeof p.round === 'number' ? p.round : 0,
|
|
56
|
+
...(chatId ? { chatId } : {}),
|
|
55
57
|
};
|
|
56
58
|
deps.onLog?.(`approval: prompting for ${toolName} (${prompt.mode}/${prompt.category})`);
|
|
57
59
|
return await new Promise((resolve) => {
|
|
@@ -21,9 +21,13 @@ export interface ReverseToolDeps {
|
|
|
21
21
|
*/
|
|
22
22
|
resolveProjectRoot?: (chatId: string) => Promise<string | null>;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
* 这里只放宽 local_read_file 的读取范围。
|
|
24
|
+
* 额外只读根(会话附件目录、源文件夹)。只放宽读取,含各自子目录。
|
|
26
25
|
*/
|
|
27
26
|
resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* 额外可写根(项目源文件夹)。放宽写入、编辑和命令 cwd,含各自子目录。
|
|
29
|
+
* 附件目录不进这里。
|
|
30
|
+
*/
|
|
31
|
+
resolveAdditionalWriteRoots?: (chatId: string) => string[] | Promise<string[]>;
|
|
28
32
|
}
|
|
29
33
|
export declare function createToolInvokeHandler(deps: ReverseToolDeps): SidecarReverseHandler;
|
|
@@ -74,6 +74,9 @@ export function createToolInvokeHandler(deps) {
|
|
|
74
74
|
const additionalReadRoots = p.context?.chatId
|
|
75
75
|
? await Promise.resolve(deps.resolveAdditionalReadRoots?.(p.context.chatId) ?? [])
|
|
76
76
|
: [];
|
|
77
|
+
const additionalWriteRoots = p.context?.chatId
|
|
78
|
+
? await Promise.resolve(deps.resolveAdditionalWriteRoots?.(p.context.chatId) ?? [])
|
|
79
|
+
: [];
|
|
77
80
|
if (name === 'local_exec_shell') {
|
|
78
81
|
const classification = classifyShellCommand(String(toolArgs.command ?? ''));
|
|
79
82
|
if (classification.severity === 'critical') {
|
|
@@ -94,7 +97,11 @@ export function createToolInvokeHandler(deps) {
|
|
|
94
97
|
? normalizeToolPolicy(p.context.toolPolicy)
|
|
95
98
|
: null;
|
|
96
99
|
const toolPolicy = normalizedPolicy && normalizedPolicy.mode !== 'all' ? normalizedPolicy : null;
|
|
97
|
-
const execContext = projectRoot ||
|
|
100
|
+
const execContext = projectRoot ||
|
|
101
|
+
additionalReadRoots.length > 0 ||
|
|
102
|
+
additionalWriteRoots.length > 0 ||
|
|
103
|
+
p.context?.chatId ||
|
|
104
|
+
toolPolicy
|
|
98
105
|
? {
|
|
99
106
|
projectRoot,
|
|
100
107
|
chatId: p.context?.chatId,
|
|
@@ -102,6 +109,7 @@ export function createToolInvokeHandler(deps) {
|
|
|
102
109
|
// worktree_* 工具按它把任务绑定到来源对话。
|
|
103
110
|
...(toolPolicy ? { toolPolicy } : {}),
|
|
104
111
|
...(additionalReadRoots.length > 0 ? { additionalReadRoots } : {}),
|
|
112
|
+
...(additionalWriteRoots.length > 0 ? { additionalWriteRoots } : {}),
|
|
105
113
|
}
|
|
106
114
|
: undefined;
|
|
107
115
|
try {
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -333,12 +333,15 @@ export interface SidecarChatStreamRequest {
|
|
|
333
333
|
* executor with the six-tool orchestration family (agent_spawn /
|
|
334
334
|
* agent_send / agent_wait / agent_close / agent_list / agent_interrupt).
|
|
335
335
|
* When delegation is also on (it is by default), both surfaces share one
|
|
336
|
-
* agent pool — one maxParallel budget, one lineage space.
|
|
336
|
+
* agent pool — one maxParallel budget, one lineage space.
|
|
337
|
+
* `childMaxRounds` / `childMaxToolErrors` default to the parent loop's
|
|
338
|
+
* limits. */
|
|
337
339
|
orchestration?: {
|
|
338
340
|
enabled?: boolean;
|
|
339
341
|
maxDepth?: number;
|
|
340
342
|
maxParallel?: number;
|
|
341
343
|
childMaxRounds?: number;
|
|
344
|
+
childMaxToolErrors?: number;
|
|
342
345
|
};
|
|
343
346
|
/**
|
|
344
347
|
* Sub-agent delegation (delegate-on-pool). ON BY DEFAULT on the sidecar —
|
|
@@ -451,6 +454,12 @@ export interface SidecarChatStreamRequest {
|
|
|
451
454
|
mode: 'host' | 'auto';
|
|
452
455
|
timeoutMs?: number;
|
|
453
456
|
storePath?: string;
|
|
457
|
+
/**
|
|
458
|
+
* Project home, source folders, and other configured write roots.
|
|
459
|
+
* File tools whose targets all sit inside these directories skip the
|
|
460
|
+
* host prompt. Independent of whether the OS exec sandbox is on.
|
|
461
|
+
*/
|
|
462
|
+
writableRoots?: string[];
|
|
454
463
|
};
|
|
455
464
|
}
|
|
456
465
|
/**
|
|
@@ -6,9 +6,12 @@ export function acquireWriteLeaseOrExit(acquire, loadElectron = () => import('el
|
|
|
6
6
|
}
|
|
7
7
|
catch (error) {
|
|
8
8
|
if (error instanceof StoreAlreadyOwnedError) {
|
|
9
|
+
console.error(`[bs] 本地数据库正在被另一个进程使用(${error.lockPath})。\n` +
|
|
10
|
+
'[bs] 再打开一次桌面应用会回到已经打开的窗口。\n' +
|
|
11
|
+
'[bs] 如果终端里的 pnpm dev:bs 占着数据库,请先在那个终端按 Ctrl+C 停掉,再重新打开。');
|
|
9
12
|
void loadElectron()
|
|
10
13
|
.then(({ app, dialog }) => {
|
|
11
|
-
dialog.showErrorBox('
|
|
14
|
+
dialog.showErrorBox('无法再开一个', '再打开一次应用时,会回到已经打开的窗口。\n\n这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。如果终端里还在跑 pnpm dev:bs,先停掉它,再重新打开。');
|
|
12
15
|
app.exit(1);
|
|
13
16
|
})
|
|
14
17
|
.catch(() => exitProcess(1));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一等工具的展示声明。
|
|
3
|
+
*
|
|
4
|
+
* 卡片种类和标题从这里按工具名取,不从名字前缀猜测。
|
|
5
|
+
* 未声明的名字(MCP、插件、场景包)落到 generic / other,`declared: false`。
|
|
6
|
+
* 宿主在 executed_actions 上盖 `view`,卡片读这份声明。
|
|
7
|
+
*/
|
|
8
|
+
export declare const TOOL_CARDS: readonly ["generic", "terminal", "diff", "search", "read", "web"];
|
|
9
|
+
export type ToolCard = (typeof TOOL_CARDS)[number];
|
|
10
|
+
export declare const TOOL_KINDS: readonly ["read", "edit", "delete", "move", "search", "execute", "fetch", "other"];
|
|
11
|
+
export type ToolKind = (typeof TOOL_KINDS)[number];
|
|
12
|
+
export interface ToolCallView {
|
|
13
|
+
card: ToolCard;
|
|
14
|
+
kind: ToolKind;
|
|
15
|
+
title: string;
|
|
16
|
+
/** 名字在声明表里。false 表示通用回退,不是从名字前缀推断出来的。 */
|
|
17
|
+
declared: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 取一次调用的展示。`result` 缺省时只根据参数出标题;结果到达后用同一声明补计数和状态。
|
|
21
|
+
*/
|
|
22
|
+
export declare function presentToolCall(name: string, args: unknown, result?: unknown): ToolCallView;
|
|
23
|
+
export declare function declaredToolNames(): readonly string[];
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一等工具的展示声明。
|
|
3
|
+
*
|
|
4
|
+
* 卡片种类和标题从这里按工具名取,不从名字前缀猜测。
|
|
5
|
+
* 未声明的名字(MCP、插件、场景包)落到 generic / other,`declared: false`。
|
|
6
|
+
* 宿主在 executed_actions 上盖 `view`,卡片读这份声明。
|
|
7
|
+
*/
|
|
8
|
+
export const TOOL_CARDS = ['generic', 'terminal', 'diff', 'search', 'read', 'web'];
|
|
9
|
+
export const TOOL_KINDS = [
|
|
10
|
+
'read',
|
|
11
|
+
'edit',
|
|
12
|
+
'delete',
|
|
13
|
+
'move',
|
|
14
|
+
'search',
|
|
15
|
+
'execute',
|
|
16
|
+
'fetch',
|
|
17
|
+
'other',
|
|
18
|
+
];
|
|
19
|
+
function clip(text, max = 60) {
|
|
20
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return '';
|
|
23
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
24
|
+
}
|
|
25
|
+
function str(args, key) {
|
|
26
|
+
const value = args[key];
|
|
27
|
+
return typeof value === 'string' ? value : '';
|
|
28
|
+
}
|
|
29
|
+
function dataOf(result) {
|
|
30
|
+
if (!result || typeof result !== 'object')
|
|
31
|
+
return null;
|
|
32
|
+
const data = result.data;
|
|
33
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function failed(result) {
|
|
39
|
+
return Boolean(result && typeof result === 'object' && result.success === false);
|
|
40
|
+
}
|
|
41
|
+
function commandTitle(args) {
|
|
42
|
+
return clip(str(args, 'command')) || 'shell';
|
|
43
|
+
}
|
|
44
|
+
function pathTitle(args, fallback) {
|
|
45
|
+
return clip(str(args, 'path') || str(args, 'file') || str(args, 'target'), 72) || fallback;
|
|
46
|
+
}
|
|
47
|
+
function webSearchTitle(args, result) {
|
|
48
|
+
const query = str(args, 'query');
|
|
49
|
+
const head = `搜索“${clip(query, 40) || '…'}”`;
|
|
50
|
+
if (result === undefined || failed(result))
|
|
51
|
+
return head;
|
|
52
|
+
const data = dataOf(result);
|
|
53
|
+
const count = data && typeof data.result_count === 'number' ? data.result_count : null;
|
|
54
|
+
return count === null ? head : `${head} → ${count} 条结果`;
|
|
55
|
+
}
|
|
56
|
+
function shortUrl(raw) {
|
|
57
|
+
if (!raw)
|
|
58
|
+
return '未知地址';
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(raw);
|
|
61
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
62
|
+
return clip(`${url.host}${path}`, 48);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return clip(raw, 48);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function webFetchTitle(args, result) {
|
|
69
|
+
const head = `抓取 ${shortUrl(str(args, 'url'))}`;
|
|
70
|
+
if (result === undefined || failed(result))
|
|
71
|
+
return head;
|
|
72
|
+
const data = dataOf(result);
|
|
73
|
+
if (!data)
|
|
74
|
+
return head;
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (typeof data.status === 'number')
|
|
77
|
+
parts.push(String(data.status));
|
|
78
|
+
if (typeof data.bytes === 'number' && Number.isFinite(data.bytes)) {
|
|
79
|
+
parts.push(data.bytes < 1024 ? `${data.bytes} B` : `${(data.bytes / 1024).toFixed(1)} KB`);
|
|
80
|
+
}
|
|
81
|
+
if (data.truncated === true)
|
|
82
|
+
parts.push('已截断');
|
|
83
|
+
return parts.length > 0 ? `${head} → ${parts.join(' · ')}` : head;
|
|
84
|
+
}
|
|
85
|
+
function taskRunTitle(args, result) {
|
|
86
|
+
const task = clip(str(args, 'task'));
|
|
87
|
+
if (!task)
|
|
88
|
+
return '后台任务';
|
|
89
|
+
const status = dataOf(result)?.status;
|
|
90
|
+
if (status === 'blocked')
|
|
91
|
+
return `${task} · 等待依赖`;
|
|
92
|
+
if (args.worktree === true)
|
|
93
|
+
return `${task} · 隔离工作区`;
|
|
94
|
+
return task;
|
|
95
|
+
}
|
|
96
|
+
function jobIdTitle(args, fallback) {
|
|
97
|
+
const id = str(args, 'job_id') || str(args, 'taskId');
|
|
98
|
+
return id ? id.slice(0, 8) : fallback;
|
|
99
|
+
}
|
|
100
|
+
function runCodeTitle(args, result) {
|
|
101
|
+
const description = clip(str(args, 'description'), 40) || '程序';
|
|
102
|
+
if (failed(result))
|
|
103
|
+
return `程序「${description}」失败`;
|
|
104
|
+
const calls = dataOf(result)?.calls;
|
|
105
|
+
const count = Array.isArray(calls) ? calls.length : 0;
|
|
106
|
+
return count > 0 ? `程序「${description}」· ${count} 个内层工具` : `程序「${description}」`;
|
|
107
|
+
}
|
|
108
|
+
function goalTitle(args, fallback) {
|
|
109
|
+
return clip(str(args, 'objective'), 72) || fallback;
|
|
110
|
+
}
|
|
111
|
+
const PRESENTERS = {
|
|
112
|
+
local_exec_shell: { card: 'terminal', kind: 'execute', title: commandTitle },
|
|
113
|
+
pwsh: { card: 'terminal', kind: 'execute', title: commandTitle },
|
|
114
|
+
bash: { card: 'terminal', kind: 'execute', title: commandTitle },
|
|
115
|
+
bash_session: { card: 'terminal', kind: 'execute', title: commandTitle },
|
|
116
|
+
write_stdin: {
|
|
117
|
+
card: 'terminal',
|
|
118
|
+
kind: 'execute',
|
|
119
|
+
title: (args) => clip(str(args, 'chars'), 40) || str(args, 'sessionId').slice(0, 8) || 'stdin',
|
|
120
|
+
},
|
|
121
|
+
local_run_snippet: {
|
|
122
|
+
card: 'terminal',
|
|
123
|
+
kind: 'execute',
|
|
124
|
+
title: (args) => clip(str(args, 'code'), 48) || str(args, 'language') || 'snippet',
|
|
125
|
+
},
|
|
126
|
+
local_run_script: {
|
|
127
|
+
card: 'terminal',
|
|
128
|
+
kind: 'execute',
|
|
129
|
+
title: (args) => str(args, 'scriptId') || 'script',
|
|
130
|
+
},
|
|
131
|
+
run_code: { card: 'generic', kind: 'execute', title: runCodeTitle },
|
|
132
|
+
run_js: { card: 'generic', kind: 'execute', title: runCodeTitle },
|
|
133
|
+
wait_js: {
|
|
134
|
+
card: 'generic',
|
|
135
|
+
kind: 'execute',
|
|
136
|
+
title: (args) => str(args, 'id').slice(0, 8) || 'wait_js',
|
|
137
|
+
},
|
|
138
|
+
local_read_file: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'read') },
|
|
139
|
+
read_file: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'read') },
|
|
140
|
+
view_image: { card: 'read', kind: 'read', title: (args) => pathTitle(args, 'image') },
|
|
141
|
+
capture_display: {
|
|
142
|
+
card: 'read',
|
|
143
|
+
kind: 'read',
|
|
144
|
+
title: (args) => clip(str(args, 'target'), 48) || 'display',
|
|
145
|
+
},
|
|
146
|
+
local_list_scripts: { card: 'read', kind: 'read', title: () => '已保存的脚本' },
|
|
147
|
+
local_write_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'write') },
|
|
148
|
+
write_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'write') },
|
|
149
|
+
local_edit_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'edit') },
|
|
150
|
+
edit_file: { card: 'diff', kind: 'edit', title: (args) => pathTitle(args, 'edit') },
|
|
151
|
+
apply_patch: { card: 'diff', kind: 'edit', title: () => 'apply_patch' },
|
|
152
|
+
grep: {
|
|
153
|
+
card: 'search',
|
|
154
|
+
kind: 'search',
|
|
155
|
+
title: (args) => clip(str(args, 'pattern')) || 'grep',
|
|
156
|
+
},
|
|
157
|
+
glob: {
|
|
158
|
+
card: 'search',
|
|
159
|
+
kind: 'search',
|
|
160
|
+
title: (args) => clip(str(args, 'pattern')) || 'glob',
|
|
161
|
+
},
|
|
162
|
+
tool_search: {
|
|
163
|
+
card: 'search',
|
|
164
|
+
kind: 'search',
|
|
165
|
+
title: (args) => clip(str(args, 'query')) || 'tool_search',
|
|
166
|
+
},
|
|
167
|
+
web_search: { card: 'web', kind: 'search', title: webSearchTitle },
|
|
168
|
+
web_fetch: { card: 'web', kind: 'fetch', title: webFetchTitle },
|
|
169
|
+
task_run: { card: 'generic', kind: 'other', title: taskRunTitle },
|
|
170
|
+
task_send: {
|
|
171
|
+
card: 'generic',
|
|
172
|
+
kind: 'other',
|
|
173
|
+
title: (args) => clip(str(args, 'message')) || '转达任务',
|
|
174
|
+
},
|
|
175
|
+
task_status: {
|
|
176
|
+
card: 'generic',
|
|
177
|
+
kind: 'read',
|
|
178
|
+
title: (args, result) => {
|
|
179
|
+
const id = str(args, 'taskId');
|
|
180
|
+
if (id)
|
|
181
|
+
return id.slice(0, 8);
|
|
182
|
+
const total = dataOf(result)?.total;
|
|
183
|
+
return typeof total === 'number' ? `${total} 个任务` : '本会话';
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
task_result: {
|
|
187
|
+
card: 'generic',
|
|
188
|
+
kind: 'read',
|
|
189
|
+
title: (args) => jobIdTitle(args, '收取结果'),
|
|
190
|
+
},
|
|
191
|
+
job_list: {
|
|
192
|
+
card: 'generic',
|
|
193
|
+
kind: 'read',
|
|
194
|
+
title: (_args, result) => {
|
|
195
|
+
const total = dataOf(result)?.total;
|
|
196
|
+
return typeof total === 'number' ? `${total} 个任务` : '后台任务';
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
job_output: { card: 'generic', kind: 'read', title: (args) => jobIdTitle(args, '任务输出') },
|
|
200
|
+
job_kill: { card: 'generic', kind: 'delete', title: (args) => jobIdTitle(args, '结束任务') },
|
|
201
|
+
worktree_create: {
|
|
202
|
+
card: 'generic',
|
|
203
|
+
kind: 'other',
|
|
204
|
+
title: (args) => str(args, 'name') || '新建工作区',
|
|
205
|
+
},
|
|
206
|
+
worktree_list: { card: 'read', kind: 'read', title: () => '工作区列表' },
|
|
207
|
+
worktree_remove: {
|
|
208
|
+
card: 'generic',
|
|
209
|
+
kind: 'delete',
|
|
210
|
+
title: (args) => str(args, 'name') || '移除工作区',
|
|
211
|
+
},
|
|
212
|
+
get_goal: { card: 'generic', kind: 'read', title: () => '当前目标' },
|
|
213
|
+
create_goal: { card: 'generic', kind: 'other', title: (args) => goalTitle(args, '新建目标') },
|
|
214
|
+
update_goal: {
|
|
215
|
+
card: 'generic',
|
|
216
|
+
kind: 'other',
|
|
217
|
+
title: (args) => {
|
|
218
|
+
const action = str(args, 'action');
|
|
219
|
+
const objective = clip(str(args, 'objective'), 48);
|
|
220
|
+
return objective ? `${action || 'update'} ${objective}` : action || '更新目标';
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
present_files: { card: 'generic', kind: 'other', title: () => '交付文件' },
|
|
224
|
+
local_open_path: {
|
|
225
|
+
card: 'generic',
|
|
226
|
+
kind: 'other',
|
|
227
|
+
title: (args) => pathTitle(args, 'open'),
|
|
228
|
+
},
|
|
229
|
+
todo_write: { card: 'generic', kind: 'other', title: () => '待办' },
|
|
230
|
+
ask_user: {
|
|
231
|
+
card: 'generic',
|
|
232
|
+
kind: 'other',
|
|
233
|
+
title: (args) => clip(str(args, 'intro') || str(args, 'question')) || '提问',
|
|
234
|
+
},
|
|
235
|
+
skill: { card: 'generic', kind: 'read', title: (args) => str(args, 'name') || 'skill' },
|
|
236
|
+
delegate_subagent: {
|
|
237
|
+
card: 'generic',
|
|
238
|
+
kind: 'other',
|
|
239
|
+
title: (args) => clip(str(args, 'task')) || str(args, 'subagent_type') || '子代理',
|
|
240
|
+
},
|
|
241
|
+
mcp_list_tools: { card: 'generic', kind: 'fetch', title: () => 'MCP 工具' },
|
|
242
|
+
mcp_tool_exec: {
|
|
243
|
+
card: 'generic',
|
|
244
|
+
kind: 'fetch',
|
|
245
|
+
title: (args) => str(args, 'toolName') || 'MCP',
|
|
246
|
+
},
|
|
247
|
+
plugin_list: { card: 'generic', kind: 'read', title: () => '插件' },
|
|
248
|
+
plugin_enable: { card: 'generic', kind: 'other', title: (args) => str(args, 'name') || '启用插件' },
|
|
249
|
+
plugin_disable: { card: 'generic', kind: 'delete', title: (args) => str(args, 'name') || '停用插件' },
|
|
250
|
+
plugin_reload: { card: 'generic', kind: 'other', title: (args) => str(args, 'name') || '重载插件' },
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* 取一次调用的展示。`result` 缺省时只根据参数出标题;结果到达后用同一声明补计数和状态。
|
|
254
|
+
*/
|
|
255
|
+
export function presentToolCall(name, args, result) {
|
|
256
|
+
const record = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
257
|
+
const presenter = PRESENTERS[name];
|
|
258
|
+
if (!presenter) {
|
|
259
|
+
return { card: 'generic', kind: 'other', title: name, declared: false };
|
|
260
|
+
}
|
|
261
|
+
const title = presenter.title(record, result).trim() || name;
|
|
262
|
+
return { card: presenter.card, kind: presenter.kind, title, declared: true };
|
|
263
|
+
}
|
|
264
|
+
export function declaredToolNames() {
|
|
265
|
+
return Object.keys(PRESENTERS);
|
|
266
|
+
}
|