@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
@@ -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 { 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';
@@ -22,6 +22,7 @@ import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
22
22
  import { executeHostedWebSearch } from '../hosted-web-search.js';
23
23
  let egressProxyRef = null;
24
24
  let supervisorRef = null;
25
+ let sidecarGeneration = 0;
25
26
  async function deriveSidecarEgressAllowList() {
26
27
  const envList = (process.env.STEERABLE_SIDECAR_SANDBOX_ALLOWED_HOSTS ?? '')
27
28
  .split(',')
@@ -158,17 +159,49 @@ async function startEgressProxyIfEnabled(store) {
158
159
  }
159
160
  }
160
161
  /**
161
- * Default-on (2026-08-26): the sidecar hosts the CoreLoop, which is the
162
- * default chat path. Explicit STEERABLE_USE_SIDECAR=0 opts out (in-process
163
- * TS loop). Boot is registered synchronously so early RPC thin clients
164
- * (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().
165
193
  */
166
194
  export async function startHostSidecar(deps) {
167
- if (process.env.STEERABLE_USE_SIDECAR === '0')
168
- return;
195
+ const generation = ++sidecarGeneration;
196
+ const pythonRunner = process.env.STEERABLE_PYTHON?.trim();
197
+ const runCodeEnabled = true;
169
198
  // ready 后的完整接线:注册全局 handle + reverse channels + web 工具握手。
170
199
  // 正常 boot 路径与「boot 失败后后台 restart 迟到就绪」路径共用。
171
200
  const wireSupervisor = async (supervisor) => {
201
+ if (generation !== sidecarGeneration) {
202
+ await supervisor.shutdown();
203
+ return;
204
+ }
172
205
  supervisorRef = supervisor;
173
206
  setSidecarSupervisor(supervisor);
174
207
  // A4 reverse channel: the sidecar-hosted CoreLoop asks this process to
@@ -251,12 +284,10 @@ export async function startHostSidecar(deps) {
251
284
  // 缺省接线)。模型面是 tool-router 的 plugin_* 工具(deferred 层,经
252
285
  // tool_search 发现);这里注入直调缝。握手失败降级为工具缺席,不阻塞 boot。
253
286
  try {
254
- await supervisor.call('plugin.list');
255
- deps.toolRouter.setPluginRpc((method, params) => supervisor.call(method, params));
287
+ await wirePluginRegistry(supervisor, deps.toolRouter);
256
288
  log.info('[sidecar] plugin registry wired; plugin_* tools available');
257
289
  }
258
290
  catch (err) {
259
- deps.toolRouter.setPluginRpc(null);
260
291
  log.warn('[sidecar] plugin registry unavailable; plugin_* tools hidden this session', err);
261
292
  }
262
293
  log.info('[sidecar] ready', supervisor.getBootSnapshot());
@@ -302,7 +333,8 @@ export async function startHostSidecar(deps) {
302
333
  // P1: offer the sidecar's run_code (programmatic tool calls) to the
303
334
  // model. The sidecar registers + advertises it; the desktop's
304
335
  // tool-router forwards the call back over the reverse channel.
305
- STEERABLE_RUN_CODE: '1',
336
+ STEERABLE_RUN_CODE: runCodeEnabled ? '1' : '0',
337
+ ...(pythonRunner ? { STEERABLE_PYTHON: pythonRunner } : {}),
306
338
  // P3: conversational JS PTC (run_js/wait_js). The sidecar spawns a
307
339
  // long-lived Node worker. Browser and Tauri desktop hosts both run
308
340
  // under the pinned Node runtime, so process.execPath is directly
@@ -311,24 +343,25 @@ export async function startHostSidecar(deps) {
311
343
  STEERABLE_PTC_NODE: process.execPath,
312
344
  },
313
345
  onLogLine: (line) => (deps.onLogLine ?? ((l) => log.info('[sidecar]', l)))(line),
346
+ }, (created) => {
347
+ if (generation === sidecarGeneration) {
348
+ supervisorRef = created;
349
+ }
350
+ else {
351
+ void created.shutdown();
352
+ }
314
353
  });
315
354
  await wireSupervisor(supervisor);
316
355
  return supervisor;
317
356
  }
318
357
  catch (err) {
319
- 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);
320
359
  setSidecarSupervisor(null);
321
- // boot 失败后 supervisor 的 exit→restart 循环仍在后台重试(另一个
322
- // 宿主暂时持有 sessions.lock 这类瞬态冲突会自愈)。迟到就绪时补
323
- // 接线,否则 sidecar 进程活着但 router 永远 503。
324
360
  const late = err.supervisor;
325
361
  if (late) {
326
- late.once('ready', () => {
327
- log.info('[sidecar] late ready after initial boot failure');
328
- void wireSupervisor(late);
329
- });
362
+ await late.shutdown();
330
363
  }
331
- return null;
364
+ throw err;
332
365
  }
333
366
  })();
334
367
  setSidecarSupervisorPending(boot);
@@ -336,6 +369,7 @@ export async function startHostSidecar(deps) {
336
369
  }
337
370
  /** 宿主退出钩子:停 supervisor + egress proxy。幂等。 */
338
371
  export async function shutdownHostSidecar() {
372
+ sidecarGeneration += 1;
339
373
  if (supervisorRef) {
340
374
  const supervisor = supervisorRef;
341
375
  supervisorRef = null;
@@ -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
  *
@@ -48,7 +46,7 @@ export declare class SidecarSupervisor extends EventEmitter {
48
46
  private reverseHandlers;
49
47
  private constructor();
50
48
  /** Spawn the sidecar and wait for the ready marker. */
51
- static start(options?: SidecarStartOptions): Promise<SidecarSupervisor>;
49
+ static start(options?: SidecarStartOptions, onCreate?: (supervisor: SidecarSupervisor) => void): Promise<SidecarSupervisor>;
52
50
  /** Round-trip a JSON-RPC method call. */
53
51
  call<T = unknown>(method: string, params?: unknown, options?: SidecarMethodOptions): Promise<T>;
54
52
  /** Convenience: list tools registered on the sidecar. */
@@ -218,9 +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 optional Rust sidecar binary. Missing binary with the flag
224
- * on is a silent fallback to Python (`python -m steerable_sidecar`).
225
- */
226
- export declare function resolveRustSidecarBin(explicit?: string): string | undefined;
@@ -25,8 +25,6 @@ const READY_PREFIX = '__SIDECAR_READY__:';
25
25
  const DEFAULT_BOOT_TIMEOUT_MS = 15_000;
26
26
  const DEFAULT_HEALTH_INTERVAL_MS = 5_000;
27
27
  const DEFAULT_RESTART_AFTER_FAILED_PINGS = 3;
28
- export const RUST_SIDECAR_ENV = 'STEERABLE_RUST_SIDECAR';
29
- export const RUST_SIDECAR_BIN_ENV = 'STEERABLE_RUST_SIDECAR_BIN';
30
28
  // Only /usr/bin/sandbox-exec is trusted — a PATH-relative lookup could
31
29
  // resolve to an attacker-planted binary (codex's rule).
32
30
  const SEATBELT_EXECUTABLE = '/usr/bin/sandbox-exec';
@@ -77,8 +75,9 @@ export class SidecarSupervisor extends EventEmitter {
77
75
  this.options = options;
78
76
  }
79
77
  /** Spawn the sidecar and wait for the ready marker. */
80
- static async start(options = {}) {
78
+ static async start(options = {}, onCreate) {
81
79
  const supervisor = new SidecarSupervisor(options);
80
+ onCreate?.(supervisor);
82
81
  try {
83
82
  await supervisor.boot();
84
83
  SidecarSupervisor.lastSpawnRefusal = null;
@@ -383,18 +382,14 @@ export class SidecarSupervisor extends EventEmitter {
383
382
  // Internal: boot
384
383
  // ------------------------------------------------------------------
385
384
  async boot() {
386
- const rustBin = rustSidecarEnabled()
387
- ? resolveRustSidecarBin(this.options.rustSidecarBin)
388
- : undefined;
389
- let spawnPlan;
390
- if (rustBin) {
391
- spawnPlan = await this.resolveSandboxedSpawn(rustBin, this.options.args ?? []);
392
- }
393
- else {
394
- const py = this.resolvePythonBinary();
395
- const entry = this.options.entryModule ?? 'steerable_sidecar';
396
- const args = ['-m', entry, ...(this.options.args ?? [])];
397
- spawnPlan = await this.resolveSandboxedSpawn(py, args);
385
+ const py = this.resolvePythonBinary();
386
+ const entry = this.options.entryModule ?? 'steerable_sidecar';
387
+ const args = ['-m', entry, ...(this.options.args ?? [])];
388
+ const spawnPlan = await this.resolveSandboxedSpawn(py, args);
389
+ // Profile generation is asynchronous. A quit can begin while it runs;
390
+ // never spawn a new child after shutdown has claimed the supervisor.
391
+ if (this.shuttingDown) {
392
+ throw new SidecarShutdownError('sidecar startup cancelled by shutdown');
398
393
  }
399
394
  const child = spawn(spawnPlan.command, spawnPlan.args, {
400
395
  cwd: this.options.cwd,
@@ -492,12 +487,7 @@ export class SidecarSupervisor extends EventEmitter {
492
487
  ...(webEgress ? ['--allow-web-egress'] : []),
493
488
  ...(allowResolver ? ['--allow-resolver'] : []),
494
489
  ];
495
- const rustBin = rustSidecarEnabled()
496
- ? resolveRustSidecarBin(this.options.rustSidecarBin)
497
- : undefined;
498
- const { stdout } = rustBin
499
- ? await execFileAsync(rustBin, ['sandbox', 'profile', ...flags], { timeout: 10_000 })
500
- : await execFileAsync(command, ['-m', 'steerable_sidecar.sandbox', 'profile', ...flags], { timeout: 10_000 });
490
+ const { stdout } = await execFileAsync(command, ['-m', 'steerable_sidecar.sandbox', 'profile', ...flags], { timeout: 10_000 });
501
491
  const profile = stdout.trim();
502
492
  if (!profile.includes('(deny default)')) {
503
493
  throw new Error('generated profile is not a Seatbelt policy');
@@ -508,9 +498,6 @@ export class SidecarSupervisor extends EventEmitter {
508
498
  (allowResolver ? ' + resolver only (web tools egress via the per-host proxy)' : '') +
509
499
  ')');
510
500
  this.sandboxPosture = { backend: 'seatbelt', enforcement: 'partial', reason: 'active' };
511
- // macOS TMPDIR is /var/folders, which the Seatbelt profile does not
512
- // allow. run_code / run_js materialize their workers there and fail
513
- // with EPERM, so the model cannot list or read local folders.
514
501
  const confinedTmp = join(writableRoots[0], 'tmp');
515
502
  mkdirSync(confinedTmp, { recursive: true });
516
503
  return {
@@ -545,29 +532,17 @@ export class SidecarSupervisor extends EventEmitter {
545
532
  // 都透传环境)+ sidecar 应用层域名名单强制,网络命名空间保持共享。
546
533
  // 这里如实记录,不假装接上了实际不强制的参数。
547
534
  const egressViaProxy = this.options.env?.STEERABLE_EGRESS_CONFINED === '1';
548
- const rustBin = rustSidecarEnabled()
549
- ? resolveRustSidecarBin(this.options.rustSidecarBin)
550
- : undefined;
551
- const wrapArgs = rustBin
552
- ? [
553
- 'sandbox',
554
- 'linux-wrap',
555
- ...writableRoots.flatMap((root) => ['--writable-root', root]),
556
- '--',
557
- command,
558
- ...args,
559
- ]
560
- : [
561
- '-m',
562
- 'steerable_sidecar.sandbox',
563
- 'linux-wrap',
564
- ...writableRoots.flatMap((root) => ['--writable-root', root]),
565
- '--',
566
- command,
567
- ...args,
568
- ];
535
+ const wrapArgs = [
536
+ '-m',
537
+ 'steerable_sidecar.sandbox',
538
+ 'linux-wrap',
539
+ ...writableRoots.flatMap((root) => ['--writable-root', root]),
540
+ '--',
541
+ command,
542
+ ...args,
543
+ ];
569
544
  try {
570
- const { stdout } = await execFileAsync(rustBin ?? command, wrapArgs, { timeout: 15_000 });
545
+ const { stdout } = await execFileAsync(command, wrapArgs, { timeout: 15_000 });
571
546
  const plan = JSON.parse(stdout.trim());
572
547
  if (!Array.isArray(plan.argv) || plan.argv.length < 1 || typeof plan.argv[0] !== 'string') {
573
548
  throw new Error('linux-wrap did not return an argv');
@@ -646,6 +621,15 @@ export class SidecarSupervisor extends EventEmitter {
646
621
  child.stderr.setEncoding('utf-8');
647
622
  child.stdout.on('data', (chunk) => this.handleStdoutChunk(chunk));
648
623
  child.stderr.on('data', (chunk) => this.handleStderrChunk(chunk));
624
+ child.stdin.on('error', (error) => {
625
+ // A child may close stdin between shutdown's requireChild() and write().
626
+ // Keep the stream error handled; the write callback/pending failure
627
+ // carries the transport error to callers.
628
+ this.failPending(new SidecarShutdownError(`sidecar stdin closed: ${error.message}`));
629
+ if (!this.shuttingDown) {
630
+ this.options.onLogLine?.(`sidecar stdin error: ${error.message}`);
631
+ }
632
+ });
649
633
  child.on('exit', (code, signal) => {
650
634
  this.emit('exit', { code, signal });
651
635
  this.failPending(new SidecarShutdownError(`sidecar exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
@@ -960,39 +944,5 @@ export function resolveSidecarPython(pythonExecutable) {
960
944
  if (existsSync(candidate))
961
945
  return candidate;
962
946
  }
963
- // Fallback to system python (developer machines).
964
- return process.platform === 'win32' ? 'python' : 'python3';
965
- }
966
- function envFlag(name) {
967
- const raw = (process.env[name] ?? '').trim().toLowerCase();
968
- return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
969
- }
970
- export function rustSidecarEnabled() {
971
- return envFlag(RUST_SIDECAR_ENV);
972
- }
973
- /**
974
- * Resolve the optional Rust sidecar binary. Missing binary with the flag
975
- * on is a silent fallback to Python (`python -m steerable_sidecar`).
976
- */
977
- export function resolveRustSidecarBin(explicit) {
978
- if (explicit) {
979
- return existsSync(explicit) ? explicit : undefined;
980
- }
981
- const fromEnv = process.env[RUST_SIDECAR_BIN_ENV];
982
- if (fromEnv) {
983
- return existsSync(fromEnv) ? fromEnv : undefined;
984
- }
985
- const exe = process.platform === 'win32' ? 'steerable-sidecar.exe' : 'steerable-sidecar';
986
- const relatives = [
987
- join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
988
- join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
989
- join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
990
- join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
991
- ];
992
- for (const rel of relatives) {
993
- const candidate = join(__dirname, rel);
994
- if (existsSync(candidate))
995
- return candidate;
996
- }
997
- return undefined;
947
+ throw new Error('portable Python sidecar runtime not found; prepare python-runtime or set STEERABLE_SIDECAR_PYTHON');
998
948
  }
@@ -21,8 +21,6 @@ export interface SidecarSandboxPosture {
21
21
  export interface SidecarStartOptions {
22
22
  /** Override the python binary; defaults to the bundled portable runtime. */
23
23
  pythonExecutable?: string;
24
- /** Optional Rust sidecar binary; used when STEERABLE_RUST_SIDECAR is on. */
25
- rustSidecarBin?: string;
26
24
  /** Override the entrypoint module; defaults to ``steerable_sidecar``. */
27
25
  entryModule?: string;
28
26
  /** Extra arguments appended after ``-m <entryModule>``. */
@@ -76,7 +76,13 @@ export declare const WEB_TOOL_RPC_TIMEOUT_MS = 130000;
76
76
  * 由 PluginRegistry 背书。宿主只持 schema 并经此 delegate 直调——与 web 工具
77
77
  * 不同,这些是普通 RPC 而非 tool.invoke(不经过模型工具分发层)。
78
78
  */
79
- export type PluginRpcDelegate = (method: 'plugin.list' | 'plugin.enable' | 'plugin.disable' | 'plugin.reload', params?: Record<string, unknown>) => Promise<unknown>;
79
+ export type PluginRpcDelegate = (method: 'plugin.list' | 'plugin.enable' | 'plugin.disable' | 'plugin.reload' | 'plugin.tools.describe', params?: Record<string, unknown>) => Promise<unknown>;
80
+ /**
81
+ * 插件自带工具的执行缝:唯一实现在 sidecar 的插件路由上,宿主只持
82
+ * `plugin.tools.describe` 给出的 schema,调用经 `tool.invoke` 前转(与 web
83
+ * 工具同一模式)。审批已在 sidecar CoreLoop 的 ApprovalExecutor 完成。
84
+ */
85
+ export type PluginToolDelegate = (name: string, args: Record<string, unknown>) => Promise<unknown>;
80
86
  /**
81
87
  * 本机是否提供网络读取工具(`STEERABLE_WEB_TOOLS=0` 关闭)。
82
88
  *
@@ -142,7 +148,18 @@ export declare class ToolRouter {
142
148
  * plugin_* 工具一个 schema 都不出——模型看不到不可用的工具。
143
149
  */
144
150
  private pluginRpc;
145
- setPluginRpc(delegate: PluginRpcDelegate | null): void;
151
+ private pluginToolInvoke;
152
+ /** 已启用插件的工具(名字 → schema),由 refreshPluginTools 从 sidecar 同步。 */
153
+ private pluginToolSchemas;
154
+ setPluginRpc(delegate: PluginRpcDelegate | null, invokeTool?: PluginToolDelegate | null): void;
155
+ private pluginToolsEnabled;
156
+ /**
157
+ * 从 sidecar 拉取已启用插件的工具描述,替换宿主侧的插件工具集。
158
+ * 启动握手后和每次 plugin_enable / plugin_disable / plugin_reload 成功后
159
+ * 调用,保证模型看到的集合与 sidecar 路由上可分发的集合一致。
160
+ * 与内置 / 场景包工具重名的插件工具被跳过并告警——内置工具优先。
161
+ */
162
+ refreshPluginTools(): Promise<void>;
146
163
  listSchemas(): ToolSchema[];
147
164
  /**
148
165
  * 插件生命周期工具(框架 plugin.* RPC 的模型面)。全在 deferred 层:
@@ -6,6 +6,7 @@ import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from '
6
6
  import { maybeAutoInstallWrittenSkill } from './local-backend/skill-install.js';
7
7
  import { filterToolsByPolicy, isToolAllowed, } from './local-backend/agent-capability.js';
8
8
  import { processViewImage } from './image-attachment.js';
9
+ import { PRESENT_FILES_SCHEMA, PRESENT_FILES_TOOL_NAME, executePresentFiles, } from './present-files.js';
9
10
  import { isHostToolCapabilityEnabled } from './host-tools.js';
10
11
  import { getResolvedHostTools } from './host-tools-runtime.js';
11
12
  /** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
@@ -22,6 +23,7 @@ export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
22
23
  export const TOOL_SEARCH_TOOL_NAME = 'tool_search';
23
24
  /** 前转 RPC 超时:sidecar 侧单次请求上限默认 30s(可配到 600s),这里给足余量。 */
24
25
  export const WEB_TOOL_RPC_TIMEOUT_MS = 130_000;
26
+ const PLUGIN_TOOL_MODES = new Set(['read', 'safe_write', 'destructive']);
25
27
  /**
26
28
  * 本机是否提供网络读取工具(`STEERABLE_WEB_TOOLS=0` 关闭)。
27
29
  *
@@ -107,8 +109,52 @@ export class ToolRouter {
107
109
  * plugin_* 工具一个 schema 都不出——模型看不到不可用的工具。
108
110
  */
109
111
  pluginRpc = null;
110
- setPluginRpc(delegate) {
112
+ pluginToolInvoke = null;
113
+ /** 已启用插件的工具(名字 → schema),由 refreshPluginTools 从 sidecar 同步。 */
114
+ pluginToolSchemas = new Map();
115
+ setPluginRpc(delegate, invokeTool = null) {
111
116
  this.pluginRpc = delegate;
117
+ this.pluginToolInvoke = delegate ? invokeTool : null;
118
+ if (!delegate)
119
+ this.pluginToolSchemas = new Map();
120
+ }
121
+ pluginToolsEnabled() {
122
+ return getResolvedHostTools().plugins.capability;
123
+ }
124
+ /**
125
+ * 从 sidecar 拉取已启用插件的工具描述,替换宿主侧的插件工具集。
126
+ * 启动握手后和每次 plugin_enable / plugin_disable / plugin_reload 成功后
127
+ * 调用,保证模型看到的集合与 sidecar 路由上可分发的集合一致。
128
+ * 与内置 / 场景包工具重名的插件工具被跳过并告警——内置工具优先。
129
+ */
130
+ async refreshPluginTools() {
131
+ if (!this.pluginRpc || !this.pluginToolInvoke) {
132
+ this.pluginToolSchemas = new Map();
133
+ return;
134
+ }
135
+ const result = (await this.pluginRpc('plugin.tools.describe'));
136
+ this.pluginToolSchemas = new Map();
137
+ const taken = new Set(this.listSchemas().map((s) => s.name));
138
+ const next = new Map();
139
+ for (const tool of result?.tools ?? []) {
140
+ if (!tool || typeof tool.name !== 'string' || !tool.name)
141
+ continue;
142
+ if (taken.has(tool.name)) {
143
+ console.warn(`[tool-router] plugin tool ${tool.name} (${tool.plugin ?? 'unknown plugin'}) shadows a host tool; skipped`);
144
+ continue;
145
+ }
146
+ const owner = tool.plugin ? `[plugin:${tool.plugin}] ` : '';
147
+ next.set(tool.name, {
148
+ name: tool.name,
149
+ description: `${owner}${tool.description || tool.name}`,
150
+ inputSchema: tool.schema && typeof tool.schema === 'object'
151
+ ? tool.schema
152
+ : { type: 'object', properties: {} },
153
+ mode: (tool.mode && PLUGIN_TOOL_MODES.has(tool.mode) ? tool.mode : 'external'),
154
+ exposure: 'deferred',
155
+ });
156
+ }
157
+ this.pluginToolSchemas = next;
112
158
  }
113
159
  listSchemas() {
114
160
  return [
@@ -240,6 +286,7 @@ export class ToolRouter {
240
286
  required: ['path', 'edits'],
241
287
  },
242
288
  },
289
+ PRESENT_FILES_SCHEMA,
243
290
  {
244
291
  name: 'local_open_path',
245
292
  description: 'Open local path or URL',
@@ -374,6 +421,9 @@ export class ToolRouter {
374
421
  ...this.listWebToolSchemas(),
375
422
  ...this.listRegisteredMcpToolSchemas(),
376
423
  ...this.listPluginToolSchemas(),
424
+ // 插件自带的工具:与 MCP 动态工具同在 deferred 层,经 tool_search 发现。
425
+ // 名字不带族前缀,按名归族会漏过 plugins 能力开关,这里显式判定。
426
+ ...(this.pluginToolsEnabled() ? this.pluginToolSchemas.values() : []),
377
427
  // ─── 场景包贡献的工具(0.3a 开槽) ─────────────────
378
428
  ...[...this.contributedTools.values()].map((c) => ({
379
429
  name: c.name,
@@ -747,6 +797,8 @@ export class ToolRouter {
747
797
  this.autoInstallSkillOnSuccess(edited, args.path);
748
798
  return edited;
749
799
  }
800
+ case PRESENT_FILES_TOOL_NAME:
801
+ return await executePresentFiles(args, projectRoot);
750
802
  case 'local_open_path':
751
803
  return await this.localExecutor.openLocalTarget({
752
804
  target: String(args.target || ''),
@@ -802,7 +854,10 @@ export class ToolRouter {
802
854
  }
803
855
  const method = call.name.replace('_', '.');
804
856
  const params = call.name === 'plugin_list' ? undefined : { name: String(args.name || '') };
805
- return await this.pluginRpc(method, params);
857
+ const result = await this.pluginRpc(method, params);
858
+ if (call.name !== 'plugin_list')
859
+ await this.refreshPluginTools();
860
+ return result;
806
861
  }
807
862
  case 'task_run': {
808
863
  const chatId = this.requireTaskContext(context);
@@ -857,6 +912,12 @@ export class ToolRouter {
857
912
  if (contributed) {
858
913
  return await contributed.handler(args, context ?? {});
859
914
  }
915
+ if (this.pluginToolSchemas.has(call.name) && this.pluginToolInvoke) {
916
+ if (!this.pluginToolsEnabled()) {
917
+ throw new Error(`工具 ${call.name} 未在本产品引入`);
918
+ }
919
+ return await this.pluginToolInvoke(call.name, args);
920
+ }
860
921
  return { success: false, error: `Unknown tool: ${call.name}` };
861
922
  }
862
923
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.47",
3
+ "version": "0.6.49",
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/",
@@ -55,9 +55,9 @@
55
55
  "he": "^1.2.0",
56
56
  "node-pty": "^1.1.0",
57
57
  "semver": "^7.8.0",
58
- "@steerable/pack-sdk": "0.6.47",
59
- "@steerable/agent-harness": "0.6.47",
60
- "@steerable/agent-protocol": "0.6.47"
58
+ "@steerable/pack-sdk": "0.6.49",
59
+ "@steerable/agent-protocol": "0.6.49",
60
+ "@steerable/agent-harness": "0.6.49"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/better-sqlite3": "^7.6.13",