@steerable/agent-shell 0.6.47 → 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.
Files changed (36) hide show
  1. package/dist/app-update.d.ts +10 -0
  2. package/dist/app-update.js +14 -0
  3. package/dist/host/runtime.js +3 -3
  4. package/dist/host-tools.d.ts +1 -1
  5. package/dist/host-tools.js +1 -0
  6. package/dist/llm/sidecar-provider.d.ts +0 -4
  7. package/dist/llm/sidecar-provider.js +0 -4
  8. package/dist/local-backend/coreloop-stream.d.ts +1 -3
  9. package/dist/local-backend/coreloop-stream.js +1 -3
  10. package/dist/local-backend/portable-bundle.d.ts +202 -0
  11. package/dist/local-backend/portable-bundle.js +318 -0
  12. package/dist/local-backend/portable-service.d.ts +23 -0
  13. package/dist/local-backend/portable-service.js +469 -0
  14. package/dist/local-backend/router.js +20 -0
  15. package/dist/local-backend/skills/85-local-exec/SKILL.md +2 -0
  16. package/dist/local-backend/skills/86-proactive-coding/SKILL.md +1 -1
  17. package/dist/local-backend/turn-files.d.ts +20 -0
  18. package/dist/local-backend/turn-files.js +162 -5
  19. package/dist/main.js +21 -1
  20. package/dist/preload.cjs +10 -0
  21. package/dist/preload.js +10 -0
  22. package/dist/present-files.d.ts +64 -0
  23. package/dist/present-files.js +136 -0
  24. package/dist/product-config.d.ts +6 -0
  25. package/dist/product-config.js +4 -0
  26. package/dist/server/start.js +7 -1
  27. package/dist/sidecar/boot.d.ts +19 -4
  28. package/dist/sidecar/boot.js +54 -20
  29. package/dist/sidecar/handle.d.ts +5 -8
  30. package/dist/sidecar/handle.js +6 -10
  31. package/dist/sidecar/supervisor.d.ts +1 -9
  32. package/dist/sidecar/supervisor.js +31 -81
  33. package/dist/sidecar/types.d.ts +0 -2
  34. package/dist/tool-router.d.ts +19 -2
  35. package/dist/tool-router.js +63 -2
  36. package/package.json +4 -4
@@ -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;
package/dist/main.js CHANGED
@@ -11,7 +11,7 @@ import log from 'electron-log';
11
11
  import { getBrand } from './brand.js';
12
12
  import { getPreloadPath } from './runtime.js';
13
13
  import { getProductConfig } from './product-config.js';
14
- import { AppUpdateController, appUpdateMenuAction, shouldNotifyUpdateReady, } from './app-update.js';
14
+ import { AppUpdateController, appUpdateMenuAction, shouldNotifyUpdateReady, toAppReleaseSnapshot, } from './app-update.js';
15
15
  import { createElectronAppUpdater } from './app-update-electron.js';
16
16
  import { assertHostIpcAllowed, getResolvedHostTools } from './host-tools-runtime.js';
17
17
  import { createJsonStore } from './json-store.js';
@@ -513,7 +513,26 @@ function createMenu() {
513
513
  ];
514
514
  Menu.setApplicationMenu(Menu.buildFromTemplate(template));
515
515
  }
516
+ function currentReleaseSnapshot(state) {
517
+ return toAppReleaseSnapshot(app.getVersion(), state ?? appUpdates?.state ?? { phase: 'disabled' });
518
+ }
519
+ function broadcastRelease(state) {
520
+ if (!mainWindow || mainWindow.isDestroyed())
521
+ return;
522
+ mainWindow.webContents.send('app-update-state', currentReleaseSnapshot(state));
523
+ }
516
524
  function setupIpcHandlers() {
525
+ ipcMain.handle('app:release-snapshot', () => currentReleaseSnapshot());
526
+ ipcMain.handle('app:release-check', async () => {
527
+ if (!appUpdates)
528
+ return currentReleaseSnapshot({ phase: 'disabled' });
529
+ return currentReleaseSnapshot(await appUpdates.checkNow());
530
+ });
531
+ ipcMain.handle('app:release-install', () => {
532
+ if (!appUpdates)
533
+ return currentReleaseSnapshot({ phase: 'disabled' });
534
+ return currentReleaseSnapshot(appUpdates.installNow());
535
+ });
517
536
  ipcMain.on('retry-connection', async () => {
518
537
  retryCount = 0;
519
538
  if (retryTimer) {
@@ -844,6 +863,7 @@ function startDesktopUpdates() {
844
863
  updater: createElectronAppUpdater(),
845
864
  onState: state => {
846
865
  log.info('[app-update]', state.phase, state.version ?? '', state.message ?? '');
866
+ broadcastRelease(state);
847
867
  if (shouldNotifyUpdateReady(previous, state) && state.version) {
848
868
  const notification = new ElectronNotification({
849
869
  title: getBrand().displayName,
package/dist/preload.cjs CHANGED
@@ -406,6 +406,16 @@ function buildPreloadApi(packContributions = {}) {
406
406
  import_electron.ipcRenderer.on("terminal:spawned", fn);
407
407
  return () => import_electron.ipcRenderer.removeListener("terminal:spawned", fn);
408
408
  }
409
+ },
410
+ app: {
411
+ snapshot: () => import_electron.ipcRenderer.invoke("app:release-snapshot"),
412
+ check: () => import_electron.ipcRenderer.invoke("app:release-check"),
413
+ install: () => import_electron.ipcRenderer.invoke("app:release-install"),
414
+ onState: (callback) => {
415
+ const fn = (_event, snapshot) => callback(snapshot);
416
+ import_electron.ipcRenderer.on("app-update-state", fn);
417
+ return () => import_electron.ipcRenderer.removeListener("app-update-state", fn);
418
+ }
409
419
  }
410
420
  };
411
421
  import_electron.contextBridge.exposeInMainWorld("electron", { ...electronAPI, ...packContributions });
package/dist/preload.js CHANGED
@@ -427,6 +427,16 @@ export function buildPreloadApi(packContributions = {}) {
427
427
  return () => ipcRenderer.removeListener('terminal:spawned', fn);
428
428
  },
429
429
  },
430
+ app: {
431
+ snapshot: () => ipcRenderer.invoke('app:release-snapshot'),
432
+ check: () => ipcRenderer.invoke('app:release-check'),
433
+ install: () => ipcRenderer.invoke('app:release-install'),
434
+ onState: (callback) => {
435
+ const fn = (_event, snapshot) => callback(snapshot);
436
+ ipcRenderer.on('app-update-state', fn);
437
+ return () => ipcRenderer.removeListener('app-update-state', fn);
438
+ },
439
+ },
430
440
  };
431
441
  contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...packContributions });
432
442
  }
@@ -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;
@@ -92,7 +92,7 @@ export async function startBsHost(options = {}) {
92
92
  shutdown() {
93
93
  shutdownPromise ??= (async () => {
94
94
  try {
95
- await new Promise((resolve, reject) => {
95
+ const closed = new Promise((resolve, reject) => {
96
96
  server.close((error) => {
97
97
  if (error)
98
98
  reject(error);
@@ -100,6 +100,12 @@ export async function startBsHost(options = {}) {
100
100
  resolve();
101
101
  });
102
102
  });
103
+ // The Tauri webview keeps HTTP/SSE connections alive while its
104
+ // ExitRequested handler waits for this promise. Force those
105
+ // connections closed so Cmd+Q does not deadlock until the outer
106
+ // five-second process timeout.
107
+ server.closeAllConnections();
108
+ await closed;
103
109
  }
104
110
  finally {
105
111
  await runtime.shutdown();
@@ -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。幂等。 */