@steerable/agent-shell 0.6.65 → 0.6.67
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/host/runtime.js +6 -3
- package/dist/host-tools.d.ts +1 -1
- package/dist/host-tools.js +1 -0
- package/dist/local-backend/router.js +26 -0
- package/dist/local-backend/skills/85-local-exec/SKILL.md +1 -0
- package/dist/local-executor.d.ts +8 -0
- package/dist/local-executor.js +34 -0
- package/dist/project-registry.d.ts +10 -0
- package/dist/project-registry.js +45 -2
- package/dist/shell-session.d.ts +37 -0
- package/dist/shell-session.js +169 -0
- package/dist/tool-router.js +33 -0
- package/package.json +4 -4
package/dist/host/runtime.js
CHANGED
|
@@ -32,6 +32,7 @@ import { startHostSidecar, shutdownHostSidecar } from '../sidecar/boot.js';
|
|
|
32
32
|
import { createVisibleTerminalExec } from './visible-terminal-exec.js';
|
|
33
33
|
import { createJsonStore } from '../json-store.js';
|
|
34
34
|
import { bindWorkspaceSkillRoots } from '../local-backend/skill-loader.js';
|
|
35
|
+
import { collectPackExecWritableRoots } from '../local-backend/pack-turn-hooks.js';
|
|
35
36
|
import { getChatAttachmentsDir } from '../attachments.js';
|
|
36
37
|
import { ensureChatWorkspace } from '../project-home.js';
|
|
37
38
|
import { recordInsightTurn } from '../insights/record.js';
|
|
@@ -236,8 +237,7 @@ export async function createHostRuntime(options) {
|
|
|
236
237
|
store: defaultStore,
|
|
237
238
|
toolRouter,
|
|
238
239
|
resolveProjectRoot: async (chatId) => localBackendRouter.resolveChatWorkspaceRoot(chatId),
|
|
239
|
-
//
|
|
240
|
-
// 写:源文件夹及其子目录与家目录同一档;附件目录保持只读。
|
|
240
|
+
// 写:源文件夹、包声明的可写根,以及各自的子目录。附件目录保持只读。
|
|
241
241
|
resolveAdditionalReadRoots: async (chatId) => {
|
|
242
242
|
const project = await localBackendRouter.resolveChatProject(chatId);
|
|
243
243
|
return [
|
|
@@ -247,7 +247,10 @@ export async function createHostRuntime(options) {
|
|
|
247
247
|
},
|
|
248
248
|
resolveAdditionalWriteRoots: async (chatId) => {
|
|
249
249
|
const project = await localBackendRouter.resolveChatProject(chatId);
|
|
250
|
-
return
|
|
250
|
+
return [
|
|
251
|
+
...(project?.sourceFolders ?? []),
|
|
252
|
+
...collectPackExecWritableRoots(chatId),
|
|
253
|
+
];
|
|
251
254
|
},
|
|
252
255
|
approvalHandler: approvalBridge.handler,
|
|
253
256
|
askUserHandler: askUserBridge.handler,
|
package/dist/host-tools.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type ResolvedSettingsChrome = Record<SettingsItemId, boolean>;
|
|
|
34
34
|
*/
|
|
35
35
|
export declare function resolveSettingsChrome(value?: unknown, tools?: ResolvedHostTools): ResolvedSettingsChrome;
|
|
36
36
|
export declare function hasGeneralSettingsChrome(chrome?: ResolvedSettingsChrome): boolean;
|
|
37
|
-
export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "view_image", "local_write_file", "local_edit_file", "present_files", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script", "grep", "glob", "pwsh"];
|
|
37
|
+
export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "write_stdin", "local_read_file", "view_image", "local_write_file", "local_edit_file", "present_files", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script", "grep", "glob", "pwsh"];
|
|
38
38
|
export declare function resolveHostToolSurface(value: boolean | {
|
|
39
39
|
capability?: boolean;
|
|
40
40
|
chrome?: boolean;
|
package/dist/host-tools.js
CHANGED
|
@@ -72,6 +72,18 @@ function parseSourceFolders(value) {
|
|
|
72
72
|
.filter((item) => typeof item === 'string' && item.trim() !== '')
|
|
73
73
|
.map(expandUserPath);
|
|
74
74
|
}
|
|
75
|
+
/** 拖拽排序的 id 列表。不是字符串数组时返回 null,由路由回 400。 */
|
|
76
|
+
function readProjectOrderIds(value) {
|
|
77
|
+
if (!Array.isArray(value))
|
|
78
|
+
return null;
|
|
79
|
+
const ids = [];
|
|
80
|
+
for (const item of value) {
|
|
81
|
+
if (typeof item !== 'string' || item.trim() === '')
|
|
82
|
+
return null;
|
|
83
|
+
ids.push(item.trim());
|
|
84
|
+
}
|
|
85
|
+
return ids;
|
|
86
|
+
}
|
|
75
87
|
/**
|
|
76
88
|
* 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
|
|
77
89
|
* 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
|
|
@@ -582,6 +594,20 @@ export class LocalBackendRouter {
|
|
|
582
594
|
}
|
|
583
595
|
}
|
|
584
596
|
}
|
|
597
|
+
if (pathname === '/api/v2/projects/order' && method === 'PUT') {
|
|
598
|
+
const registry = this.toolRouter.projectRegistry;
|
|
599
|
+
if (!registry) {
|
|
600
|
+
return { status: 503, data: { error: '项目注册表不可用' } };
|
|
601
|
+
}
|
|
602
|
+
const orderedIds = readProjectOrderIds(this.toRecord(request.body).orderedIds);
|
|
603
|
+
if (!orderedIds) {
|
|
604
|
+
return { status: 400, data: { error: 'orderedIds 必须是项目 id 列表' } };
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
status: 200,
|
|
608
|
+
data: { success: true, projects: registry.reorder(orderedIds) },
|
|
609
|
+
};
|
|
610
|
+
}
|
|
585
611
|
const projectMatch = pathname.match(/^\/api\/v2\/projects\/([^/]+)$/);
|
|
586
612
|
if (projectMatch) {
|
|
587
613
|
const projectId = decodeURIComponent(projectMatch[1]);
|
|
@@ -32,6 +32,7 @@ match: any
|
|
|
32
32
|
|
|
33
33
|
- **超时 ≠ 失败,严禁原样重跑启动命令**:返回里出现 `timedOut: true` 时,只是 shell 等待被掐断——如果命令是启动 GUI / 长时间运行的程序(如 Qt 界面软件),该程序**很可能仍在运行**,用户可能正在里面操作。此时**不要**再次执行同一条启动命令(会把程序重复启动多次)。正确做法:先确认进程是否在运行(如 `Get-Process`);确实需要长时间等待时传更大的 `timeout` 参数;启动 GUI 程序时优先用不阻塞的方式(PowerShell `Start-Process "app.exe"`),启动成功即视为完成,不要等它退出。
|
|
34
34
|
- **GUI 启动命令的特殊约定**:命令行里带独立的 `gui` 字样(如 `xxx.exe gui replay ...`、`--gui`)会被识别为"启动 GUI 程序"。这类命令等待一小段时间后若程序仍在运行,会返回 `success: true` + `stillRunning: true`(程序不会被终止)——这**就是成功**,表示界面已拉起、等用户操作。看到 `stillRunning: true` 后直接向用户汇报"程序已启动,请在界面中操作",**绝对不要**再次执行同一条命令。
|
|
35
|
+
- **交互命令续同一会话**:需要读提示再输入(安装向导、REPL、`ssh` 口令之外的交互程序)时,给 `local_exec_shell` 传 `yieldMs`(毫秒)。进程还在跑时返回 `stillRunning: true` 和 `sessionId`,**不要**把这次返回当成超时失败,也**不要**再开一条新命令重跑。下一步用 `write_stdin`:`sessionId` 原样带回,`chars` 是要写入的输入(含回车),`yieldMs` 等新输出。只想看新输出时 `chars` 传空字符串。`write_stdin` 只返回上次之后的新输出。会话不存在时停止重试。不传 `yieldMs` 的命令仍等到退出或超时,超时行为不变。
|
|
35
36
|
|
|
36
37
|
## 真实执行纪律
|
|
37
38
|
|
package/dist/local-executor.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface LocalExecRequest {
|
|
|
11
11
|
shell?: ShellType;
|
|
12
12
|
/** 在伪终端里跑。缺省走管道,调用方要终端语义时显式打开。 */
|
|
13
13
|
pty?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* 设了就在这么多毫秒后返回,进程还在就留下 sessionId。
|
|
16
|
+
* 不设则跟原来一样,等到退出或超时。
|
|
17
|
+
*/
|
|
18
|
+
yieldMs?: number;
|
|
14
19
|
/** 用户已经批准一次 prompt 决定。不能放过 forbidden。 */
|
|
15
20
|
execApproval?: 'allow';
|
|
16
21
|
}
|
|
@@ -38,6 +43,8 @@ export interface LocalExecResult {
|
|
|
38
43
|
transport?: 'local' | 'ssh';
|
|
39
44
|
/** 这次启动是否分配了伪终端。 */
|
|
40
45
|
pty?: boolean;
|
|
46
|
+
/** yieldMs 留下的还在运行的伪终端。下一步用 write_stdin 写入或只取新输出。 */
|
|
47
|
+
sessionId?: string;
|
|
41
48
|
}
|
|
42
49
|
export interface LocalFileReadRequest {
|
|
43
50
|
path: string;
|
|
@@ -249,6 +256,7 @@ export declare class LocalExecutor {
|
|
|
249
256
|
executeShell(request: LocalExecRequest): Promise<LocalExecResult>;
|
|
250
257
|
private holdForExecPolicy;
|
|
251
258
|
private runPlannedShell;
|
|
259
|
+
private runYieldedPty;
|
|
252
260
|
readLocalFile(request: LocalFileReadRequest, projectRoot?: string | null, additionalReadRoots?: string[] | null): Promise<LocalFileReadResult>;
|
|
253
261
|
writeLocalFile(request: LocalFileWriteRequest, projectRoot?: string | null, additionalWriteRoots?: string[] | null): Promise<LocalFileWriteResult>;
|
|
254
262
|
/**
|
package/dist/local-executor.js
CHANGED
|
@@ -9,6 +9,7 @@ import { constants as fsConstants } from 'fs';
|
|
|
9
9
|
import { adaptCommandForPowerShell } from './shell-adapt.js';
|
|
10
10
|
import { evaluateExecPolicy, planSshArgv, } from './shell-exec.js';
|
|
11
11
|
import { nodeShellBackend } from './shell-process.js';
|
|
12
|
+
import { closeShellSession, readShellSession, shellSessionStatus, startPtySession, } from './shell-session.js';
|
|
12
13
|
import { applyEdits, EditError } from './local-edit.js';
|
|
13
14
|
// `he` ships as CommonJS and does not expose ESM named exports, so we have to
|
|
14
15
|
// take the default import and destructure at runtime in this ESM module.
|
|
@@ -572,6 +573,14 @@ export class LocalExecutor {
|
|
|
572
573
|
spec.cwd = os.homedir();
|
|
573
574
|
spec.env = stringEnv();
|
|
574
575
|
}
|
|
576
|
+
const yieldMs = input.request.yieldMs;
|
|
577
|
+
if (pty &&
|
|
578
|
+
!input.useSsh &&
|
|
579
|
+
!this.shellBackend &&
|
|
580
|
+
typeof yieldMs === 'number' &&
|
|
581
|
+
Number.isFinite(yieldMs)) {
|
|
582
|
+
return await this.runYieldedPty(spec, input.resolvedShell.type, yieldMs);
|
|
583
|
+
}
|
|
575
584
|
const backend = this.shellBackend ?? nodeShellBackend;
|
|
576
585
|
const outcome = await backend.run(spec, {
|
|
577
586
|
timeoutMs: input.timeout,
|
|
@@ -603,6 +612,31 @@ export class LocalExecutor {
|
|
|
603
612
|
pty: spec.pty,
|
|
604
613
|
};
|
|
605
614
|
}
|
|
615
|
+
async runYieldedPty(spec, shell, yieldMs) {
|
|
616
|
+
const sessionId = startPtySession({
|
|
617
|
+
file: spec.file,
|
|
618
|
+
args: spec.args,
|
|
619
|
+
cwd: spec.cwd,
|
|
620
|
+
env: spec.env,
|
|
621
|
+
maxOutputBytes: this.maxOutputBytes,
|
|
622
|
+
});
|
|
623
|
+
const stdout = (await readShellSession(sessionId, yieldMs)) ?? '';
|
|
624
|
+
const status = shellSessionStatus(sessionId);
|
|
625
|
+
const base = {
|
|
626
|
+
stdout,
|
|
627
|
+
stderr: '',
|
|
628
|
+
shell,
|
|
629
|
+
platform: process.platform,
|
|
630
|
+
transport: 'local',
|
|
631
|
+
pty: true,
|
|
632
|
+
};
|
|
633
|
+
if (!status?.running) {
|
|
634
|
+
const exitCode = status?.exitCode ?? -1;
|
|
635
|
+
closeShellSession(sessionId);
|
|
636
|
+
return { ...base, success: exitCode === 0, exitCode };
|
|
637
|
+
}
|
|
638
|
+
return { ...base, success: true, stillRunning: true, sessionId };
|
|
639
|
+
}
|
|
606
640
|
async readLocalFile(request, projectRoot, additionalReadRoots) {
|
|
607
641
|
try {
|
|
608
642
|
const filePath = this.resolvePath(request.path);
|
|
@@ -30,6 +30,11 @@ export interface ProjectRecord {
|
|
|
30
30
|
* 旧记录没有此字段,读取时按 `false` 处理(见 {@link ProjectRegistry.isTrusted})。
|
|
31
31
|
*/
|
|
32
32
|
trusted?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* 侧边栏显示顺序,越小越靠前。旧记录没有此字段时按 createdAt 升序。
|
|
35
|
+
* 用户拖拽排序后每个项目都会写上序号;之后新建的项目没有序号,排在已编号项目之后。
|
|
36
|
+
*/
|
|
37
|
+
sortOrder?: number;
|
|
33
38
|
createdAt: string;
|
|
34
39
|
updatedAt: string;
|
|
35
40
|
}
|
|
@@ -51,6 +56,11 @@ export declare class ProjectRegistry {
|
|
|
51
56
|
create(input: CreateProjectInput): ProjectRecord;
|
|
52
57
|
update(id: string, updates: Partial<CreateProjectInput>): ProjectRecord;
|
|
53
58
|
delete(id: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* 按 orderedIds 重排侧边栏。未出现的 id 忽略;名单里没提到的项目
|
|
61
|
+
* 保持相对顺序接在后面。写入连续的 sortOrder,list() 随后按该序号返回。
|
|
62
|
+
*/
|
|
63
|
+
reorder(orderedIds: readonly string[]): ProjectRecord[];
|
|
54
64
|
/**
|
|
55
65
|
* W6-5: read the trust flag, defaulting absent (legacy) records to
|
|
56
66
|
* `false` — fail-closed, so a project is never trusted unless the user
|
package/dist/project-registry.js
CHANGED
|
@@ -19,8 +19,9 @@ export class ProjectRegistry {
|
|
|
19
19
|
this.store = store;
|
|
20
20
|
}
|
|
21
21
|
list() {
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
// 拖过序的记录按 sortOrder;没有序号的旧记录仍按创建时间升序,
|
|
23
|
+
// 并且排在已编号项目之后(新建项目因此出现在列表末尾)。
|
|
24
|
+
return [...(this.store.get(STORE_KEY) ?? [])].sort(compareProjects);
|
|
24
25
|
}
|
|
25
26
|
get(idOrName) {
|
|
26
27
|
const needle = idOrName.trim().toLowerCase();
|
|
@@ -87,6 +88,33 @@ export class ProjectRegistry {
|
|
|
87
88
|
this.store.set(STORE_KEY, next);
|
|
88
89
|
return true;
|
|
89
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* 按 orderedIds 重排侧边栏。未出现的 id 忽略;名单里没提到的项目
|
|
93
|
+
* 保持相对顺序接在后面。写入连续的 sortOrder,list() 随后按该序号返回。
|
|
94
|
+
*/
|
|
95
|
+
reorder(orderedIds) {
|
|
96
|
+
const current = this.list();
|
|
97
|
+
const byId = new Map(current.map((project) => [project.id, project]));
|
|
98
|
+
const next = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const id of orderedIds) {
|
|
101
|
+
const project = byId.get(id);
|
|
102
|
+
if (!project || seen.has(id))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(id);
|
|
105
|
+
next.push(project);
|
|
106
|
+
}
|
|
107
|
+
for (const project of current) {
|
|
108
|
+
if (!seen.has(project.id))
|
|
109
|
+
next.push(project);
|
|
110
|
+
}
|
|
111
|
+
const stamped = next.map((project, index) => ({
|
|
112
|
+
...project,
|
|
113
|
+
sortOrder: index,
|
|
114
|
+
}));
|
|
115
|
+
this.store.set(STORE_KEY, stamped);
|
|
116
|
+
return stamped;
|
|
117
|
+
}
|
|
90
118
|
/**
|
|
91
119
|
* W6-5: read the trust flag, defaulting absent (legacy) records to
|
|
92
120
|
* `false` — fail-closed, so a project is never trusted unless the user
|
|
@@ -111,6 +139,21 @@ export class ProjectRegistry {
|
|
|
111
139
|
return next;
|
|
112
140
|
}
|
|
113
141
|
}
|
|
142
|
+
function compareProjects(a, b) {
|
|
143
|
+
const aOrder = finiteSortOrder(a.sortOrder);
|
|
144
|
+
const bOrder = finiteSortOrder(b.sortOrder);
|
|
145
|
+
if (aOrder !== null && bOrder !== null && aOrder !== bOrder)
|
|
146
|
+
return aOrder - bOrder;
|
|
147
|
+
if ((aOrder !== null) !== (bOrder !== null))
|
|
148
|
+
return aOrder !== null ? -1 : 1;
|
|
149
|
+
const byCreated = a.createdAt.localeCompare(b.createdAt);
|
|
150
|
+
if (byCreated !== 0)
|
|
151
|
+
return byCreated;
|
|
152
|
+
return a.id.localeCompare(b.id);
|
|
153
|
+
}
|
|
154
|
+
function finiteSortOrder(value) {
|
|
155
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
156
|
+
}
|
|
114
157
|
function normalizeSourceFolders(folders, homePath) {
|
|
115
158
|
const home = homePath.trim();
|
|
116
159
|
const seen = new Set();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface ShellSessionStatus {
|
|
2
|
+
running: boolean;
|
|
3
|
+
exitCode: number | null;
|
|
4
|
+
}
|
|
5
|
+
export interface ShellSessionUpdate {
|
|
6
|
+
success: boolean;
|
|
7
|
+
stdout: string;
|
|
8
|
+
running: boolean;
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
error?: string;
|
|
11
|
+
truncated?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** 打开一个伪终端并记下会话号。调用方负责之后用 read / write 取输出。 */
|
|
14
|
+
export declare function startPtySession(spec: {
|
|
15
|
+
file: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
cwd?: string;
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
maxOutputBytes: number;
|
|
20
|
+
}): string;
|
|
21
|
+
export declare function shellSessionStatus(sessionId: string): ShellSessionStatus | null;
|
|
22
|
+
/** 等到有一段新输出安静下来、进程退出,或到达 yieldMs。返回这段新输出。 */
|
|
23
|
+
export declare function readShellSession(sessionId: string, waitMs: number): Promise<string | null>;
|
|
24
|
+
export declare function writeShellSessionChars(sessionId: string, chars: string): {
|
|
25
|
+
ok: true;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
error: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function writeShellStdin(input: {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
chars: string;
|
|
33
|
+
yieldMs: number;
|
|
34
|
+
}): Promise<ShellSessionUpdate>;
|
|
35
|
+
export declare function closeShellSession(sessionId: string): void;
|
|
36
|
+
/** 测试收尾:关掉还留着的伪终端。 */
|
|
37
|
+
export declare function closeAllShellSessions(): void;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 还在跑的伪终端会话。
|
|
3
|
+
* local_exec_shell 带 yieldMs 时把进程留在这里;write_stdin 按会话号写入,或只取新输出。
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import * as pty from 'node-pty';
|
|
7
|
+
import { stripAnsi } from './shell-process.js';
|
|
8
|
+
const MAX_SESSION_MS = 10 * 60 * 1000;
|
|
9
|
+
const QUIET_MS = 200;
|
|
10
|
+
const MAX_YIELD_MS = 120_000;
|
|
11
|
+
const sessions = new Map();
|
|
12
|
+
function stringEnv(env) {
|
|
13
|
+
const next = {};
|
|
14
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
15
|
+
if (typeof value === 'string')
|
|
16
|
+
next[key] = value;
|
|
17
|
+
}
|
|
18
|
+
if (env)
|
|
19
|
+
Object.assign(next, env);
|
|
20
|
+
next.TERM = 'xterm-256color';
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
function takeNew(live) {
|
|
24
|
+
const fresh = stripAnsi(live.raw.slice(live.cursor));
|
|
25
|
+
live.cursor = live.raw.length;
|
|
26
|
+
return fresh;
|
|
27
|
+
}
|
|
28
|
+
function delay(ms) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
setTimeout(resolve, ms);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** 打开一个伪终端并记下会话号。调用方负责之后用 read / write 取输出。 */
|
|
34
|
+
export function startPtySession(spec) {
|
|
35
|
+
const id = randomUUID();
|
|
36
|
+
const proc = pty.spawn(spec.file, spec.args, {
|
|
37
|
+
name: 'xterm-256color',
|
|
38
|
+
cols: 120,
|
|
39
|
+
rows: 30,
|
|
40
|
+
cwd: spec.cwd,
|
|
41
|
+
env: stringEnv(spec.env),
|
|
42
|
+
});
|
|
43
|
+
const live = {
|
|
44
|
+
id,
|
|
45
|
+
proc,
|
|
46
|
+
raw: '',
|
|
47
|
+
cursor: 0,
|
|
48
|
+
exitCode: null,
|
|
49
|
+
truncated: false,
|
|
50
|
+
timer: setTimeout(() => {
|
|
51
|
+
try {
|
|
52
|
+
proc.kill();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (!(err instanceof Error))
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}, MAX_SESSION_MS),
|
|
59
|
+
};
|
|
60
|
+
live.timer.unref?.();
|
|
61
|
+
proc.onData((chunk) => {
|
|
62
|
+
const next = live.raw + chunk;
|
|
63
|
+
if (Buffer.byteLength(next) > spec.maxOutputBytes) {
|
|
64
|
+
live.truncated = true;
|
|
65
|
+
live.raw = Buffer.from(next).subarray(0, spec.maxOutputBytes).toString('utf-8');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
live.raw = next;
|
|
69
|
+
});
|
|
70
|
+
proc.onExit(({ exitCode }) => {
|
|
71
|
+
live.exitCode = exitCode ?? -1;
|
|
72
|
+
clearTimeout(live.timer);
|
|
73
|
+
});
|
|
74
|
+
sessions.set(id, live);
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
export function shellSessionStatus(sessionId) {
|
|
78
|
+
const live = sessions.get(sessionId);
|
|
79
|
+
if (!live)
|
|
80
|
+
return null;
|
|
81
|
+
return { running: live.exitCode === null, exitCode: live.exitCode };
|
|
82
|
+
}
|
|
83
|
+
/** 等到有一段新输出安静下来、进程退出,或到达 yieldMs。返回这段新输出。 */
|
|
84
|
+
export async function readShellSession(sessionId, waitMs) {
|
|
85
|
+
const live = sessions.get(sessionId);
|
|
86
|
+
if (!live)
|
|
87
|
+
return null;
|
|
88
|
+
const cap = Math.max(0, Math.min(waitMs, MAX_YIELD_MS));
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
let lastLen = live.raw.length;
|
|
91
|
+
let lastChange = started;
|
|
92
|
+
while (Date.now() - started < cap) {
|
|
93
|
+
if (live.exitCode !== null)
|
|
94
|
+
break;
|
|
95
|
+
if (live.raw.length !== lastLen) {
|
|
96
|
+
lastLen = live.raw.length;
|
|
97
|
+
lastChange = Date.now();
|
|
98
|
+
}
|
|
99
|
+
else if (lastLen > live.cursor && Date.now() - lastChange >= QUIET_MS) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
await delay(20);
|
|
103
|
+
}
|
|
104
|
+
return takeNew(live);
|
|
105
|
+
}
|
|
106
|
+
export function writeShellSessionChars(sessionId, chars) {
|
|
107
|
+
const live = sessions.get(sessionId);
|
|
108
|
+
if (!live)
|
|
109
|
+
return { ok: false, error: 'session not found' };
|
|
110
|
+
if (live.exitCode !== null)
|
|
111
|
+
return { ok: true };
|
|
112
|
+
try {
|
|
113
|
+
live.proc.write(chars);
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
return { ok: false, error: message || 'failed to write stdin' };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function writeShellStdin(input) {
|
|
122
|
+
const sessionId = input.sessionId.trim();
|
|
123
|
+
if (!sessionId)
|
|
124
|
+
return { success: false, stdout: '', running: false, exitCode: null, error: 'sessionId is required' };
|
|
125
|
+
const existing = shellSessionStatus(sessionId);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
return { success: false, stdout: '', running: false, exitCode: null, error: 'session not found' };
|
|
128
|
+
}
|
|
129
|
+
if (input.chars && existing.running) {
|
|
130
|
+
const wrote = writeShellSessionChars(sessionId, input.chars);
|
|
131
|
+
if (!wrote.ok) {
|
|
132
|
+
return { success: false, stdout: '', running: existing.running, exitCode: existing.exitCode, error: wrote.error };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const stdout = await readShellSession(sessionId, input.yieldMs);
|
|
136
|
+
const after = shellSessionStatus(sessionId);
|
|
137
|
+
if (stdout === null || !after) {
|
|
138
|
+
return { success: false, stdout: '', running: false, exitCode: null, error: 'session not found' };
|
|
139
|
+
}
|
|
140
|
+
const live = sessions.get(sessionId);
|
|
141
|
+
return {
|
|
142
|
+
success: true,
|
|
143
|
+
stdout,
|
|
144
|
+
running: after.running,
|
|
145
|
+
exitCode: after.exitCode,
|
|
146
|
+
...(live?.truncated ? { truncated: true } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function closeShellSession(sessionId) {
|
|
150
|
+
const live = sessions.get(sessionId);
|
|
151
|
+
if (!live)
|
|
152
|
+
return;
|
|
153
|
+
sessions.delete(sessionId);
|
|
154
|
+
clearTimeout(live.timer);
|
|
155
|
+
if (live.exitCode === null) {
|
|
156
|
+
try {
|
|
157
|
+
live.proc.kill();
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (!(err instanceof Error))
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** 测试收尾:关掉还留着的伪终端。 */
|
|
166
|
+
export function closeAllShellSessions() {
|
|
167
|
+
for (const id of [...sessions.keys()])
|
|
168
|
+
closeShellSession(id);
|
|
169
|
+
}
|
package/dist/tool-router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { buildProjectRootViolation, expandFencePath, isPathWithinRoot, writableFenceRoots, writableFenceViolation, } from './local-executor.js';
|
|
4
|
+
import { writeShellStdin } from './shell-session.js';
|
|
4
5
|
import { mcpExecutor } from './mcp-executor.js';
|
|
5
6
|
import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
|
|
6
7
|
import { maybeAutoInstallWrittenSkill } from './local-backend/skill-install.js';
|
|
@@ -186,6 +187,8 @@ export class ToolRouter {
|
|
|
186
187
|
{
|
|
187
188
|
name: 'local_exec_shell',
|
|
188
189
|
description: 'Execute a shell command in a pseudo-terminal. Set pty to false for a plain pipe. ' +
|
|
190
|
+
'Set yieldMs to return while the command is still running; the result includes sessionId. ' +
|
|
191
|
+
'Continue that same session with write_stdin (write input, or pass empty chars to poll new output). ' +
|
|
189
192
|
'When this host is configured with a remote endpoint, the command runs on that host instead of this machine.',
|
|
190
193
|
mode: 'destructive',
|
|
191
194
|
inputSchema: {
|
|
@@ -195,10 +198,33 @@ export class ToolRouter {
|
|
|
195
198
|
cwd: { type: 'string' },
|
|
196
199
|
timeout: { type: 'number', description: 'Timeout in milliseconds (e.g. 30000). If a small number like 15 or 30 is provided, it is assumed to be in seconds and automatically multiplied by 1000.' },
|
|
197
200
|
pty: { type: 'boolean', description: 'Run in a pseudo-terminal. Defaults to true.' },
|
|
201
|
+
yieldMs: {
|
|
202
|
+
type: 'number',
|
|
203
|
+
description: 'Return after this many milliseconds if the command is still running, and include sessionId for write_stdin. Omit to wait until exit or timeout.',
|
|
204
|
+
},
|
|
198
205
|
},
|
|
199
206
|
required: ['command'],
|
|
200
207
|
},
|
|
201
208
|
},
|
|
209
|
+
{
|
|
210
|
+
name: 'write_stdin',
|
|
211
|
+
description: 'Write more input to a running local_exec_shell session, or poll its new output. ' +
|
|
212
|
+
'Pass the sessionId from that command. chars is sent to the terminal; omit it or pass an empty string to only collect output that arrived since the last call. ' +
|
|
213
|
+
'Does not start a new command and does not ask for approval again.',
|
|
214
|
+
mode: 'local',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
sessionId: { type: 'string', description: 'sessionId returned by local_exec_shell.' },
|
|
219
|
+
chars: { type: 'string', description: 'Input to write. Empty string polls new output only.' },
|
|
220
|
+
yieldMs: {
|
|
221
|
+
type: 'number',
|
|
222
|
+
description: 'How long to wait for new output, in milliseconds. Defaults to 1000.',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: ['sessionId'],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
202
228
|
{
|
|
203
229
|
name: 'local_read_file',
|
|
204
230
|
description: 'Read a local text file. Large files can be paged with offset/limit (1-based line numbers); ' +
|
|
@@ -956,7 +982,14 @@ export class ToolRouter {
|
|
|
956
982
|
cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
|
|
957
983
|
timeout: typeof args.timeout === 'number' ? args.timeout : undefined,
|
|
958
984
|
pty: args.pty !== false,
|
|
985
|
+
yieldMs: typeof args.yieldMs === 'number' ? args.yieldMs : undefined,
|
|
959
986
|
}, projectRoot, context?.additionalWriteRoots ?? null);
|
|
987
|
+
case 'write_stdin':
|
|
988
|
+
return await writeShellStdin({
|
|
989
|
+
sessionId: typeof args.sessionId === 'string' ? args.sessionId : '',
|
|
990
|
+
chars: typeof args.chars === 'string' ? args.chars : '',
|
|
991
|
+
yieldMs: typeof args.yieldMs === 'number' ? args.yieldMs : 1000,
|
|
992
|
+
});
|
|
960
993
|
case 'local_read_file':
|
|
961
994
|
return await this.localExecutor.readLocalFile({
|
|
962
995
|
path: String(args.path || ''),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.67",
|
|
4
4
|
"description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"jpeg-js": "^0.4.4",
|
|
54
54
|
"node-pty": "^1.1.0",
|
|
55
55
|
"pngjs": "^7.0.0",
|
|
56
|
-
"@steerable/agent-
|
|
57
|
-
"@steerable/agent-
|
|
58
|
-
"@steerable/pack-sdk": "0.6.
|
|
56
|
+
"@steerable/agent-harness": "0.6.67",
|
|
57
|
+
"@steerable/agent-protocol": "0.6.67",
|
|
58
|
+
"@steerable/pack-sdk": "0.6.67"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/better-sqlite3": "^7.6.13",
|