@steerable/agent-shell 0.6.48 → 0.6.49

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 @@
32
32
  import fs from 'node:fs/promises';
33
33
  import os from 'node:os';
34
34
  import path from 'node:path';
35
+ import { PRESENT_FILES_TOOL_NAME, readPresentedArgs, } from '../present-files.js';
35
36
  /** 这些目录要么体量巨大(node_modules/target),要么是框架内部状态(.steerable),扫了只有噪音。 */
36
37
  const IGNORED_DIR_NAMES = new Set([
37
38
  'node_modules',
@@ -48,7 +49,65 @@ const IGNORED_DIR_NAMES = new Set([
48
49
  'target',
49
50
  '.steerable',
50
51
  ]);
51
- const IGNORED_FILE_NAMES = new Set(['.DS_Store']);
52
+ const IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db']);
53
+ /** 交付物扩展名(最终产物,如电子表格、幻灯片、文档、图片、音视频、独立页面、压缩包)。 */
54
+ export const DELIVERABLE_EXTENSIONS = new Set([
55
+ // 表格 / Spreadsheets
56
+ '.xlsx', '.xls', '.csv', '.tsv', '.numbers',
57
+ // 幻灯片 / Presentations
58
+ '.pptx', '.ppt', '.key', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
59
+ // 文档 / Documents
60
+ '.docx', '.doc', '.pdf', '.pages', '.epub', '.rtf',
61
+ // 图像与富媒体 / Images & Media
62
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.mp4', '.mov', '.mp3',
63
+ // 独立文档输出与压缩包 / Standalone HTML & Archives
64
+ '.html', '.htm', '.zip', '.tar.gz', '.tar', '.7z',
65
+ ]);
66
+ const INTERMEDIATE_DIR_PATTERNS = [
67
+ /(?:^|[\\/])review_work(?:[\\/]|$)/i,
68
+ /(?:^|[\\/])(?:work|temp|tmp|\.temp|\.tmp|scratch)(?:[\\/]|$)/i,
69
+ /(?:^|[\\/])scripts(?:[\\/]|$)/i,
70
+ /(?:^|[\\/])(?:build|dist|\.cache|__pycache__)(?:[\\/]|$)/i,
71
+ ];
72
+ /** 是否为临时文件 / 办公软件锁定文件(如 ~$ 开头的文件名),产物列表中一律排除。 */
73
+ export function isIgnoredFileName(name) {
74
+ if (IGNORED_FILE_NAMES.has(name))
75
+ return true;
76
+ // Office 临时锁定文件(如 ~$ 开头)
77
+ if (name.startsWith('~$'))
78
+ return true;
79
+ // 临时文件与编辑器交换文件
80
+ if (name.endsWith('.tmp') || name.endsWith('.swp') || name.endsWith('~'))
81
+ return true;
82
+ return false;
83
+ }
84
+ /** 判定文件是最终交付文件还是中间修改文件。 */
85
+ export function classifyFileCategory(filePath) {
86
+ const normalized = filePath.replace(/\\/g, '/');
87
+ const ext = path.extname(normalized).toLowerCase();
88
+ // 1. 若具有最终交付产物扩展名(电子表格、PPT、PDF、图片等),属于交付物
89
+ if (DELIVERABLE_EXTENSIONS.has(ext))
90
+ return 'deliverable';
91
+ // 2. 位于中间工作目录(如 review_work/、scripts/)属于中间文件
92
+ for (const pattern of INTERMEDIATE_DIR_PATTERNS) {
93
+ if (pattern.test(normalized))
94
+ return 'intermediate';
95
+ }
96
+ // 3. 其余脚本、代码、配置文件均归为中间文件
97
+ return 'intermediate';
98
+ }
99
+ /** 从统一 diff 文本中统计增删行数。 */
100
+ export function parseDiffStats(diffText) {
101
+ let additions = 0;
102
+ let deletions = 0;
103
+ for (const line of diffText.split('\n')) {
104
+ if (line.startsWith('+') && !line.startsWith('+++'))
105
+ additions++;
106
+ else if (line.startsWith('-') && !line.startsWith('---'))
107
+ deletions++;
108
+ }
109
+ return { additions, deletions };
110
+ }
52
111
  /** 单次扫描的遍历上限:深度 10、条目 10 万、结果 100 条。 */
53
112
  const MAX_DEPTH = 10;
54
113
  const MAX_VISITED = 100_000;
@@ -101,7 +160,78 @@ export async function collectTurnFiles(options) {
101
160
  for (const dir of shallowRoots) {
102
161
  await scanShallow(dir, sinceMs, byPath);
103
162
  }
104
- return [...byPath.values()]
163
+ // present_files 声明的文件:本轮可能没动过(交付已有文件),不受时间水位线
164
+ // 约束,只要求仍是普通文件。
165
+ const presented = new Map();
166
+ for (const action of options.actions ?? []) {
167
+ if (action.tool !== PRESENT_FILES_TOOL_NAME || action.success === false)
168
+ continue;
169
+ for (const file of readPresentedArgs(action.arguments, projectRoot, homeDir)) {
170
+ presented.set(file.path, file);
171
+ }
172
+ }
173
+ for (const file of presented.values()) {
174
+ if (byPath.has(file.path))
175
+ continue;
176
+ const stat = await statPresentedFile(file.path);
177
+ if (stat)
178
+ byPath.set(file.path, stat);
179
+ }
180
+ // 从 actions 中提取增删行数统计(local_edit_file 的 diff 或 local_write_file 的 content)
181
+ const statsByPath = new Map();
182
+ for (const action of options.actions ?? []) {
183
+ if (typeof action.tool === 'string') {
184
+ const args = action.arguments && typeof action.arguments === 'object' ? action.arguments : null;
185
+ const rawPath = typeof args?.path === 'string' ? args.path : null;
186
+ if (rawPath) {
187
+ const full = path.isAbsolute(rawPath)
188
+ ? path.normalize(rawPath)
189
+ : projectRoot
190
+ ? path.resolve(projectRoot, rawPath)
191
+ : null;
192
+ if (full) {
193
+ if (action.tool === 'local_edit_file') {
194
+ const diff = editDiffOf(action.result);
195
+ if (diff !== null) {
196
+ const diffStats = parseDiffStats(diff);
197
+ const prev = statsByPath.get(full) ?? { additions: 0, deletions: 0 };
198
+ statsByPath.set(full, {
199
+ additions: prev.additions + diffStats.additions,
200
+ deletions: prev.deletions + diffStats.deletions,
201
+ });
202
+ }
203
+ }
204
+ else if (action.tool === 'local_write_file') {
205
+ if (typeof args?.content === 'string') {
206
+ const lineCount = args.content ? args.content.split('\n').length : 0;
207
+ statsByPath.set(full, { additions: lineCount, deletions: 0 });
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+ const enriched = [];
215
+ for (const file of byPath.values()) {
216
+ const base = path.basename(file.path);
217
+ if (isIgnoredFileName(base))
218
+ continue;
219
+ const stats = statsByPath.get(file.path);
220
+ const declared = presented.get(file.path);
221
+ // 本轮有声明时以声明为准;没有声明(旧模型、未调用)才退回扩展名规则。
222
+ const category = presented.size > 0
223
+ ? declared
224
+ ? 'deliverable'
225
+ : 'intermediate'
226
+ : classifyFileCategory(file.path);
227
+ enriched.push({
228
+ ...file,
229
+ category,
230
+ ...(declared?.description ? { description: declared.description } : {}),
231
+ ...(stats ? { additions: stats.additions, deletions: stats.deletions } : {}),
232
+ });
233
+ }
234
+ return enriched
105
235
  .sort((a, b) => a.path.localeCompare(b.path))
106
236
  .slice(0, MAX_FILES);
107
237
  }
@@ -134,7 +264,7 @@ async function walk(dir, sinceMs, depth, state, out) {
134
264
  await walk(full, sinceMs, depth + 1, state, out);
135
265
  continue;
136
266
  }
137
- if (!entry.isFile() || IGNORED_FILE_NAMES.has(entry.name))
267
+ if (!entry.isFile() || isIgnoredFileName(entry.name))
138
268
  continue;
139
269
  const file = await statTurnFile(full, sinceMs);
140
270
  if (file)
@@ -152,7 +282,7 @@ async function statTurnFile(full, sinceMs) {
152
282
  return null;
153
283
  }
154
284
  // 路径字面量可能指到目录(如 xxx.app 包);产物列表只收文件。
155
- if (!stat.isFile())
285
+ if (!stat.isFile() || isIgnoredFileName(path.basename(full)))
156
286
  return null;
157
287
  const touched = stat.mtimeMs + TOUCHED_SLACK_MS >= sinceMs ||
158
288
  stat.birthtimeMs + TOUCHED_SLACK_MS >= sinceMs;
@@ -160,6 +290,33 @@ async function statTurnFile(full, sinceMs) {
160
290
  return null;
161
291
  return { path: full, kind: kindOf(stat, sinceMs), size: stat.size };
162
292
  }
293
+ /** 声明的交付文件只要求仍是普通文件;本轮没动过的已有文件标 modified。 */
294
+ async function statPresentedFile(full) {
295
+ let stat;
296
+ try {
297
+ stat = await fs.stat(full);
298
+ }
299
+ catch {
300
+ // 声明后到回合收尾之间被删掉:不再列出。
301
+ return null;
302
+ }
303
+ if (!stat.isFile())
304
+ return null;
305
+ return { path: full, kind: 'modified', size: stat.size };
306
+ }
307
+ /** local_edit_file 的 diff:直连结果在顶层,经 sidecar 回流的结果折进 `data`。 */
308
+ function editDiffOf(result) {
309
+ if (!result || typeof result !== 'object')
310
+ return null;
311
+ const record = result;
312
+ if (typeof record.diff === 'string')
313
+ return record.diff;
314
+ const data = record.data;
315
+ if (data && typeof data === 'object' && typeof data.diff === 'string') {
316
+ return data.diff;
317
+ }
318
+ return null;
319
+ }
163
320
  function kindOf(stat, sinceMs) {
164
321
  // birthtime 不可用的文件系统返回 0/负值——此时无法区分新建与修改,
165
322
  // 统一标 modified(标签降级,不漏文件)。
@@ -232,7 +389,7 @@ async function scanShallow(dir, sinceMs, out) {
232
389
  return;
233
390
  }
234
391
  for (const entry of entries) {
235
- if (entry.name.startsWith('.') || IGNORED_FILE_NAMES.has(entry.name))
392
+ if (entry.name.startsWith('.') || isIgnoredFileName(entry.name))
236
393
  continue;
237
394
  if (entry.isSymbolicLink() || !entry.isFile())
238
395
  continue;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `present_files`:模型声明本轮的最终交付文件。
3
+ *
4
+ * 回合产物列表(turn-files)把声明过的文件渲染成交付卡片,其余本轮写过的
5
+ * 文件归入「Edited N files」。只看扩展名分不清交付物与检查用的预览图、
6
+ * 导出副本,所以由模型显式声明。工具只 stat 元数据、不读内容,也不复制
7
+ * 文件——用户打开的是源文件当前版本。
8
+ */
9
+ export declare const PRESENT_FILES_TOOL_NAME = "present_files";
10
+ export declare const PRESENT_FILES_SCHEMA: {
11
+ name: string;
12
+ description: string;
13
+ mode: "read";
14
+ inputSchema: {
15
+ type: string;
16
+ properties: {
17
+ files: {
18
+ type: string;
19
+ minItems: number;
20
+ maxItems: number;
21
+ description: string;
22
+ items: {
23
+ type: string;
24
+ properties: {
25
+ path: {
26
+ type: string;
27
+ description: string;
28
+ };
29
+ description: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ };
34
+ required: string[];
35
+ additionalProperties: boolean;
36
+ };
37
+ };
38
+ };
39
+ required: string[];
40
+ additionalProperties: boolean;
41
+ };
42
+ };
43
+ export interface PresentedFile {
44
+ path: string;
45
+ description?: string;
46
+ }
47
+ /**
48
+ * 把参数里的路径解析成绝对路径:展开 `~/`,相对路径按项目根解析;
49
+ * 无项目根的相对路径无法定位,返回 null。
50
+ */
51
+ export declare function resolvePresentedPath(raw: string, projectRoot: string | null, homeDir?: string): string | null;
52
+ /** 从工具参数里取出声明的文件(不校验存在性);形状不符的条目跳过。 */
53
+ export declare function readPresentedArgs(args: unknown, projectRoot: string | null, homeDir?: string): PresentedFile[];
54
+ /**
55
+ * 执行 `present_files`:每个路径必须是已存在的普通文件。任一路径不合格时
56
+ * 整次调用失败并逐个说明原因,模型可以修正后重试。
57
+ */
58
+ export declare function executePresentFiles(args: Record<string, unknown>, projectRoot: string | null): Promise<{
59
+ success: true;
60
+ presented: PresentedFile[];
61
+ } | {
62
+ success: false;
63
+ error: string;
64
+ }>;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `present_files`:模型声明本轮的最终交付文件。
3
+ *
4
+ * 回合产物列表(turn-files)把声明过的文件渲染成交付卡片,其余本轮写过的
5
+ * 文件归入「Edited N files」。只看扩展名分不清交付物与检查用的预览图、
6
+ * 导出副本,所以由模型显式声明。工具只 stat 元数据、不读内容,也不复制
7
+ * 文件——用户打开的是源文件当前版本。
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ export const PRESENT_FILES_TOOL_NAME = 'present_files';
13
+ /** 单次调用最多声明的文件数;交付卡片过多就失去「最终结果」的意义。 */
14
+ const MAX_PRESENTED_FILES = 4;
15
+ export const PRESENT_FILES_SCHEMA = {
16
+ name: PRESENT_FILES_TOOL_NAME,
17
+ description: 'Declare existing local files as the final deliverables of this turn. ' +
18
+ 'When a file you created or updated is an output the user asked to receive ' +
19
+ '(spreadsheet, slide deck, document, report, image, exported archive), call this after writing it ' +
20
+ 'and before your final reply, including files produced by scripts or commands. ' +
21
+ 'Do not present helper scripts, scratch files, render previews made only to check your work, ' +
22
+ 'or intermediate exports. Files you write but do not present are still listed to the user as edited files.',
23
+ mode: 'read',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ files: {
28
+ type: 'array',
29
+ minItems: 1,
30
+ maxItems: MAX_PRESENTED_FILES,
31
+ description: `Usually the 1-2 most important deliverables; at most ${MAX_PRESENTED_FILES}.`,
32
+ items: {
33
+ type: 'object',
34
+ properties: {
35
+ path: {
36
+ type: 'string',
37
+ description: 'Absolute path, or a path relative to the project root.',
38
+ },
39
+ description: {
40
+ type: 'string',
41
+ description: 'Optional one-line summary shown on the file card.',
42
+ },
43
+ },
44
+ required: ['path'],
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ },
49
+ required: ['files'],
50
+ additionalProperties: false,
51
+ },
52
+ };
53
+ /**
54
+ * 把参数里的路径解析成绝对路径:展开 `~/`,相对路径按项目根解析;
55
+ * 无项目根的相对路径无法定位,返回 null。
56
+ */
57
+ export function resolvePresentedPath(raw, projectRoot, homeDir = os.homedir()) {
58
+ const trimmed = raw.trim();
59
+ if (!trimmed)
60
+ return null;
61
+ const expanded = trimmed === '~' || trimmed.startsWith('~/')
62
+ ? path.join(homeDir, trimmed.slice(1))
63
+ : trimmed;
64
+ if (path.isAbsolute(expanded))
65
+ return path.normalize(expanded);
66
+ return projectRoot ? path.resolve(projectRoot, expanded) : null;
67
+ }
68
+ /** 从工具参数里取出声明的文件(不校验存在性);形状不符的条目跳过。 */
69
+ export function readPresentedArgs(args, projectRoot, homeDir) {
70
+ if (!args || typeof args !== 'object')
71
+ return [];
72
+ const files = args.files;
73
+ if (!Array.isArray(files))
74
+ return [];
75
+ const out = [];
76
+ for (const item of files) {
77
+ if (!item || typeof item !== 'object')
78
+ continue;
79
+ const record = item;
80
+ if (typeof record.path !== 'string')
81
+ continue;
82
+ const resolved = resolvePresentedPath(record.path, projectRoot, homeDir);
83
+ if (!resolved)
84
+ continue;
85
+ const description = typeof record.description === 'string' ? record.description.trim() : '';
86
+ out.push({ path: resolved, ...(description ? { description } : {}) });
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * 执行 `present_files`:每个路径必须是已存在的普通文件。任一路径不合格时
92
+ * 整次调用失败并逐个说明原因,模型可以修正后重试。
93
+ */
94
+ export async function executePresentFiles(args, projectRoot) {
95
+ const rawFiles = Array.isArray(args.files) ? args.files : [];
96
+ if (rawFiles.length === 0) {
97
+ return { success: false, error: 'files must list at least one file to present.' };
98
+ }
99
+ if (rawFiles.length > MAX_PRESENTED_FILES) {
100
+ return {
101
+ success: false,
102
+ error: `Present at most ${MAX_PRESENTED_FILES} files per call; keep only the final deliverables.`,
103
+ };
104
+ }
105
+ const problems = [];
106
+ const presented = [];
107
+ for (const item of rawFiles) {
108
+ const raw = item && typeof item === 'object' && typeof item.path === 'string'
109
+ ? item.path
110
+ : '';
111
+ const [file] = readPresentedArgs({ files: [item] }, projectRoot);
112
+ if (!file) {
113
+ problems.push(raw
114
+ ? `${raw}: use an absolute path (no project root to resolve a relative path).`
115
+ : 'each entry needs a non-empty path.');
116
+ continue;
117
+ }
118
+ try {
119
+ const stat = await fs.stat(file.path);
120
+ if (!stat.isFile()) {
121
+ problems.push(`${file.path}: not a regular file.`);
122
+ continue;
123
+ }
124
+ }
125
+ catch {
126
+ // stat 失败(多为 ENOENT):报给模型让它先写文件或修正路径。
127
+ problems.push(`${file.path}: file not found. Create it first or fix the path.`);
128
+ continue;
129
+ }
130
+ presented.push(file);
131
+ }
132
+ if (problems.length > 0) {
133
+ return { success: false, error: `Cannot present: ${problems.join(' ')}` };
134
+ }
135
+ return { success: true, presented };
136
+ }
@@ -66,6 +66,10 @@ export interface ProductConfig {
66
66
  * 设置入口。缺省全开。`false` 藏对应侧栏页或综合设置分段。
67
67
  */
68
68
  settings?: Record<string, boolean>;
69
+ /**
70
+ * 对话与配置的导出/导入。缺省关。只有显式 `true` 的产品才有入口和接口。
71
+ */
72
+ portable?: boolean;
69
73
  /**
70
74
  * 产品钉死的大模型。`settings.llm === false` 时必填,运行时用这份,
71
75
  * 不再读设置页。密钥用 `apiKeyEnv` 指向环境变量,不要把 key 写进仓库。
@@ -105,6 +109,8 @@ export interface ProductConfig {
105
109
  }
106
110
  /** shell 内置智能体:只有产品显式 `true` 才开。 */
107
111
  export declare function isShellBuiltinAgentEnabled(id: 'local-assistant' | 'all-round-assistant', config?: ProductConfig): boolean;
112
+ /** 对话与配置能否导出/导入。缺省关,产品必须显式打开。 */
113
+ export declare function isPortableProduct(config?: ProductConfig): boolean;
108
114
  /** shell 内置技能:`true` 全开;否则只有对象里显式 `true` 的目录开。 */
109
115
  export declare function isShellBuiltinSkillEnabled(id: string, config?: ProductConfig): boolean;
110
116
  /**
@@ -13,6 +13,10 @@
13
13
  export function isShellBuiltinAgentEnabled(id, config = getProductConfig()) {
14
14
  return config.builtinAgents?.[id] === true;
15
15
  }
16
+ /** 对话与配置能否导出/导入。缺省关,产品必须显式打开。 */
17
+ export function isPortableProduct(config = getProductConfig()) {
18
+ return config.portable === true;
19
+ }
16
20
  /** shell 内置技能:`true` 全开;否则只有对象里显式 `true` 的目录开。 */
17
21
  export function isShellBuiltinSkillEnabled(id, config = getProductConfig()) {
18
22
  const value = config.builtinSkills;
@@ -1,3 +1,4 @@
1
+ import { SidecarSupervisor } from './supervisor.js';
1
2
  import type { ScopedStore } from '../storage/scoped-store.js';
2
3
  import { type ToolRouter } from '../tool-router.js';
3
4
  import type { createApprovalBridge } from './reverse-approval.js';
@@ -28,10 +29,24 @@ export interface HostSidecarDeps {
28
29
  onLogLine?: (line: string) => void;
29
30
  }
30
31
  /**
31
- * Default-on (2026-08-26): the sidecar hosts the CoreLoop, which is the
32
- * default chat path. Explicit STEERABLE_USE_SIDECAR=0 opts out (in-process
33
- * TS loop). Boot is registered synchronously so early RPC thin clients
34
- * (skill-loader et al.) can await it via whenSidecarSupervisor().
32
+ * Wire the sidecar plugin registry into the host tool router.
33
+ *
34
+ * The `plugin.list` probe doubles as the availability check: on failure the
35
+ * router is cleared (no plugin_* tools, no plugin-provided tools) and the
36
+ * error is rethrown for the caller to log. On success the router gets the
37
+ * plugin.* RPC seam plus a `tool.invoke` forwarder, then syncs the enabled
38
+ * plugins' tool descriptors from `plugin.tools.describe`. Plugin tools run
39
+ * only in the sidecar; approval already happened in its CoreLoop before a
40
+ * call is forwarded here, hence `consentGranted`.
41
+ *
42
+ * @param supervisor Running sidecar supervisor.
43
+ * @param toolRouter Host router that advertises and dispatches the tools.
44
+ */
45
+ export declare function wirePluginRegistry(supervisor: Pick<SidecarSupervisor, 'call' | 'invokeTool'>, toolRouter: ToolRouter): Promise<void>;
46
+ /**
47
+ * The complete Python sidecar hosts the Rust CoreLoop and is mandatory.
48
+ * Boot is registered synchronously so early RPC thin clients (skill-loader
49
+ * et al.) can await it via whenSidecarSupervisor().
35
50
  */
36
51
  export declare function startHostSidecar(deps: HostSidecarDeps): Promise<void>;
37
52
  /** 宿主退出钩子:停 supervisor + egress proxy。幂等。 */
@@ -9,7 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import log from 'electron-log';
12
- import { rustSidecarEnabled, SidecarSupervisor, } from './supervisor.js';
12
+ import { SidecarSupervisor, } from './supervisor.js';
13
13
  import { setSidecarSupervisor, setSidecarSupervisorPending, llmService } from '../llm/index.js';
14
14
  import { resolveSidecarStoragePath } from './storage-path.js';
15
15
  import { deriveEgressAllowListFromBaseUrl, probeExecSandboxCapability, } from './exec-sandbox.js';
@@ -159,17 +159,42 @@ async function startEgressProxyIfEnabled(store) {
159
159
  }
160
160
  }
161
161
  /**
162
- * Default-on (2026-08-26): the sidecar hosts the CoreLoop, which is the
163
- * default chat path. Explicit STEERABLE_USE_SIDECAR=0 opts out (in-process
164
- * TS loop). Boot is registered synchronously so early RPC thin clients
165
- * (skill-loader et al.) can await it via whenSidecarSupervisor().
162
+ * Wire the sidecar plugin registry into the host tool router.
163
+ *
164
+ * The `plugin.list` probe doubles as the availability check: on failure the
165
+ * router is cleared (no plugin_* tools, no plugin-provided tools) and the
166
+ * error is rethrown for the caller to log. On success the router gets the
167
+ * plugin.* RPC seam plus a `tool.invoke` forwarder, then syncs the enabled
168
+ * plugins' tool descriptors from `plugin.tools.describe`. Plugin tools run
169
+ * only in the sidecar; approval already happened in its CoreLoop before a
170
+ * call is forwarded here, hence `consentGranted`.
171
+ *
172
+ * @param supervisor Running sidecar supervisor.
173
+ * @param toolRouter Host router that advertises and dispatches the tools.
174
+ */
175
+ export async function wirePluginRegistry(supervisor, toolRouter) {
176
+ try {
177
+ await supervisor.call('plugin.list');
178
+ }
179
+ catch (err) {
180
+ toolRouter.setPluginRpc(null);
181
+ throw err;
182
+ }
183
+ toolRouter.setPluginRpc((method, params) => supervisor.call(method, params), (name, args) => supervisor.invokeTool(name, args, {
184
+ consentGranted: true,
185
+ timeoutMs: WEB_TOOL_RPC_TIMEOUT_MS,
186
+ }));
187
+ await toolRouter.refreshPluginTools();
188
+ }
189
+ /**
190
+ * The complete Python sidecar hosts the Rust CoreLoop and is mandatory.
191
+ * Boot is registered synchronously so early RPC thin clients (skill-loader
192
+ * et al.) can await it via whenSidecarSupervisor().
166
193
  */
167
194
  export async function startHostSidecar(deps) {
168
- if (process.env.STEERABLE_USE_SIDECAR === '0')
169
- return;
170
195
  const generation = ++sidecarGeneration;
171
196
  const pythonRunner = process.env.STEERABLE_PYTHON?.trim();
172
- const runCodeEnabled = !rustSidecarEnabled() || Boolean(pythonRunner);
197
+ const runCodeEnabled = true;
173
198
  // ready 后的完整接线:注册全局 handle + reverse channels + web 工具握手。
174
199
  // 正常 boot 路径与「boot 失败后后台 restart 迟到就绪」路径共用。
175
200
  const wireSupervisor = async (supervisor) => {
@@ -259,12 +284,10 @@ export async function startHostSidecar(deps) {
259
284
  // 缺省接线)。模型面是 tool-router 的 plugin_* 工具(deferred 层,经
260
285
  // tool_search 发现);这里注入直调缝。握手失败降级为工具缺席,不阻塞 boot。
261
286
  try {
262
- await supervisor.call('plugin.list');
263
- deps.toolRouter.setPluginRpc((method, params) => supervisor.call(method, params));
287
+ await wirePluginRegistry(supervisor, deps.toolRouter);
264
288
  log.info('[sidecar] plugin registry wired; plugin_* tools available');
265
289
  }
266
290
  catch (err) {
267
- deps.toolRouter.setPluginRpc(null);
268
291
  log.warn('[sidecar] plugin registry unavailable; plugin_* tools hidden this session', err);
269
292
  }
270
293
  log.info('[sidecar] ready', supervisor.getBootSnapshot());
@@ -332,19 +355,13 @@ export async function startHostSidecar(deps) {
332
355
  return supervisor;
333
356
  }
334
357
  catch (err) {
335
- log.error('[sidecar] failed to start, falling back to in-process providers', err);
358
+ log.error('[sidecar] failed to start; Python sidecar is required', err);
336
359
  setSidecarSupervisor(null);
337
- // boot 失败后 supervisor 的 exit→restart 循环仍在后台重试(另一个
338
- // 宿主暂时持有 sessions.lock 这类瞬态冲突会自愈)。迟到就绪时补
339
- // 接线,否则 sidecar 进程活着但 router 永远 503。
340
360
  const late = err.supervisor;
341
361
  if (late) {
342
- late.once('ready', () => {
343
- log.info('[sidecar] late ready after initial boot failure');
344
- void wireSupervisor(late);
345
- });
362
+ await late.shutdown();
346
363
  }
347
- return null;
364
+ throw err;
348
365
  }
349
366
  })();
350
367
  setSidecarSupervisorPending(boot);
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Optional sidecar handle. `main.ts` calls `setSidecarSupervisor()` after the
3
- * sidecar has booted. The sidecar path is default-on (2026-08-26): the
4
- * CoreLoop chat path and the LLM provider both route through it. Set
5
- * `STEERABLE_USE_SIDECAR=0` to fall back to the in-process providers — also
6
- * the automatic behavior when the sidecar failed to start.
2
+ * Mandatory sidecar handle. The host calls `setSidecarSupervisor()` after the
3
+ * complete Python sidecar has booted; CoreLoop and provider traffic route
4
+ * through it.
7
5
  *
8
6
  * Kept in a dependency-light module (type-only import of the supervisor) so
9
7
  * RPC thin clients (`local-edit`, `skill-loader`) can read the handle without
@@ -25,8 +23,7 @@ export declare function isSidecarEnabled(): boolean;
25
23
  /**
26
24
  * Await the sidecar handle through boot: returns the live handle immediately
27
25
  * when already up, otherwise waits on the registered boot promise. Returns
28
- * null when the sidecar is disabled, no boot is in flight, boot failed, or
29
- * the wait exceeds `timeoutMs` (bounded so a hung python env degrades the
30
- * caller to its no-sidecar fallback instead of hanging the request).
26
+ * null when no boot is in flight, boot failed, or the wait exceeds
27
+ * `timeoutMs`; callers surface that as sidecar unavailable.
31
28
  */
32
29
  export declare function whenSidecarSupervisor(timeoutMs?: number): Promise<SidecarSupervisor | null>;
@@ -14,21 +14,18 @@ export function setSidecarSupervisorPending(pending) {
14
14
  }
15
15
  /** The supervised sidecar handle, when the sidecar path is active. */
16
16
  export function getSidecarSupervisor() {
17
- return isSidecarEnabled() ? sidecarSupervisor : null;
17
+ return sidecarSupervisor;
18
18
  }
19
19
  export function isSidecarEnabled() {
20
- return process.env.STEERABLE_USE_SIDECAR !== '0' && sidecarSupervisor != null;
20
+ return sidecarSupervisor != null;
21
21
  }
22
22
  /**
23
23
  * Await the sidecar handle through boot: returns the live handle immediately
24
24
  * when already up, otherwise waits on the registered boot promise. Returns
25
- * null when the sidecar is disabled, no boot is in flight, boot failed, or
26
- * the wait exceeds `timeoutMs` (bounded so a hung python env degrades the
27
- * caller to its no-sidecar fallback instead of hanging the request).
25
+ * null when no boot is in flight, boot failed, or the wait exceeds
26
+ * `timeoutMs`; callers surface that as sidecar unavailable.
28
27
  */
29
28
  export async function whenSidecarSupervisor(timeoutMs = 20_000) {
30
- if (process.env.STEERABLE_USE_SIDECAR === '0')
31
- return null;
32
29
  const live = getSidecarSupervisor();
33
30
  if (live)
34
31
  return live;
@@ -39,9 +36,8 @@ export async function whenSidecarSupervisor(timeoutMs = 20_000) {
39
36
  const timeout = new Promise((resolve) => {
40
37
  timer = setTimeout(() => resolve(null), timeoutMs);
41
38
  });
42
- // A rejected boot promise (python env broken etc.) is already logged by the
43
- // boot path; waiters degrade to their no-sidecar fallback instead of
44
- // crashing on someone else's boot failure.
39
+ // A rejected boot promise is surfaced by host startup; concurrent waiters
40
+ // receive null and return their explicit sidecar-unavailable error.
45
41
  const settled = pending.catch(() => null);
46
42
  try {
47
43
  return await Promise.race([settled, timeout]);
@@ -11,8 +11,6 @@
11
11
  */
12
12
  import { EventEmitter } from 'node:events';
13
13
  import type { SidecarApplyEditsResult, SidecarChatStreamHandlers, SidecarChatStreamRequest, SidecarHealthSnapshot, SidecarMethodOptions, SidecarModelCatalog, SidecarReverseHandler, SidecarSandboxPosture, SidecarSessionBranches, SidecarSessionForkOutcome, SidecarSessionMessages, SidecarSessionTree, SidecarSkillModule, SidecarStartOptions, SidecarToolResult } from './types.js';
14
- export declare const RUST_SIDECAR_ENV = "STEERABLE_RUST_SIDECAR";
15
- export declare const RUST_SIDECAR_BIN_ENV = "STEERABLE_RUST_SIDECAR_BIN";
16
14
  /**
17
15
  * Tool names carried by a `tool.list` reply.
18
16
  *
@@ -218,10 +216,3 @@ export declare class SidecarSupervisor extends EventEmitter {
218
216
  * launcher (W1.3.3) spawns the same interpreter the sidecar uses.
219
217
  */
220
218
  export declare function resolveSidecarPython(pythonExecutable?: string): string;
221
- export declare function rustSidecarEnabled(): boolean;
222
- /**
223
- * Resolve the Rust sidecar binary from an explicit path, a packaged engine,
224
- * or a framework source checkout. Boot fails when Rust was explicitly
225
- * enabled but no binary resolves.
226
- */
227
- export declare function resolveRustSidecarBin(explicit?: string): string | undefined;