@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.
@@ -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 project?.sourceFolders ?? [];
250
+ return [
251
+ ...(project?.sourceFolders ?? []),
252
+ ...collectPackExecWritableRoots(chatId),
253
+ ];
251
254
  },
252
255
  approvalHandler: approvalBridge.handler,
253
256
  askUserHandler: askUserBridge.handler,
@@ -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;
@@ -75,6 +75,7 @@ export function hasGeneralSettingsChrome(chrome = resolveSettingsChrome()) {
75
75
  }
76
76
  export const LOCAL_FS_TOOL_NAMES = [
77
77
  'local_exec_shell',
78
+ 'write_stdin',
78
79
  'local_read_file',
79
80
  'view_image',
80
81
  'local_write_file',
@@ -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
 
@@ -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
  /**
@@ -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
@@ -19,8 +19,9 @@ export class ProjectRegistry {
19
19
  this.store = store;
20
20
  }
21
21
  list() {
22
- // 按创建时间升序:侧边栏项目组顺序稳定,早建的在前。
23
- return [...(this.store.get(STORE_KEY) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
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
+ }
@@ -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.65",
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-protocol": "0.6.65",
57
- "@steerable/agent-harness": "0.6.65",
58
- "@steerable/pack-sdk": "0.6.65"
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",