@steerable/agent-shell 0.6.47 → 0.6.48

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.
@@ -11,6 +11,16 @@ export interface AppUpdateState {
11
11
  percent?: number;
12
12
  message?: string;
13
13
  }
14
+ /** 渲染层看到的版本与更新状态。`version` 是当前安装版本。 */
15
+ export interface AppReleaseSnapshot {
16
+ version: string;
17
+ enabled: boolean;
18
+ phase: AppUpdatePhase;
19
+ availableVersion?: string;
20
+ percent?: number;
21
+ message?: string;
22
+ }
23
+ export declare function toAppReleaseSnapshot(currentVersion: string, state: AppUpdateState): AppReleaseSnapshot;
14
24
  export interface AppUpdateCheckResult {
15
25
  updateInfo: {
16
26
  version: string;
@@ -6,6 +6,20 @@
6
6
  import { appendFileSync } from 'node:fs';
7
7
  import { gt, valid } from 'semver';
8
8
  export const APP_UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
9
+ export function toAppReleaseSnapshot(currentVersion, state) {
10
+ const snapshot = {
11
+ version: currentVersion,
12
+ enabled: state.phase !== 'disabled',
13
+ phase: state.phase,
14
+ };
15
+ if (state.version)
16
+ snapshot.availableVersion = state.version;
17
+ if (state.percent !== undefined)
18
+ snapshot.percent = state.percent;
19
+ if (state.message)
20
+ snapshot.message = state.message;
21
+ return snapshot;
22
+ }
9
23
  export function appUpdateMenuAction(state) {
10
24
  if (state.phase === 'disabled')
11
25
  return 'none';
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
  }
@@ -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();
@@ -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 { rustSidecarEnabled, 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(',')
@@ -166,9 +167,16 @@ async function startEgressProxyIfEnabled(store) {
166
167
  export async function startHostSidecar(deps) {
167
168
  if (process.env.STEERABLE_USE_SIDECAR === '0')
168
169
  return;
170
+ const generation = ++sidecarGeneration;
171
+ const pythonRunner = process.env.STEERABLE_PYTHON?.trim();
172
+ const runCodeEnabled = !rustSidecarEnabled() || Boolean(pythonRunner);
169
173
  // ready 后的完整接线:注册全局 handle + reverse channels + web 工具握手。
170
174
  // 正常 boot 路径与「boot 失败后后台 restart 迟到就绪」路径共用。
171
175
  const wireSupervisor = async (supervisor) => {
176
+ if (generation !== sidecarGeneration) {
177
+ await supervisor.shutdown();
178
+ return;
179
+ }
172
180
  supervisorRef = supervisor;
173
181
  setSidecarSupervisor(supervisor);
174
182
  // A4 reverse channel: the sidecar-hosted CoreLoop asks this process to
@@ -302,7 +310,8 @@ export async function startHostSidecar(deps) {
302
310
  // P1: offer the sidecar's run_code (programmatic tool calls) to the
303
311
  // model. The sidecar registers + advertises it; the desktop's
304
312
  // tool-router forwards the call back over the reverse channel.
305
- STEERABLE_RUN_CODE: '1',
313
+ STEERABLE_RUN_CODE: runCodeEnabled ? '1' : '0',
314
+ ...(pythonRunner ? { STEERABLE_PYTHON: pythonRunner } : {}),
306
315
  // P3: conversational JS PTC (run_js/wait_js). The sidecar spawns a
307
316
  // long-lived Node worker. Browser and Tauri desktop hosts both run
308
317
  // under the pinned Node runtime, so process.execPath is directly
@@ -311,6 +320,13 @@ export async function startHostSidecar(deps) {
311
320
  STEERABLE_PTC_NODE: process.execPath,
312
321
  },
313
322
  onLogLine: (line) => (deps.onLogLine ?? ((l) => log.info('[sidecar]', l)))(line),
323
+ }, (created) => {
324
+ if (generation === sidecarGeneration) {
325
+ supervisorRef = created;
326
+ }
327
+ else {
328
+ void created.shutdown();
329
+ }
314
330
  });
315
331
  await wireSupervisor(supervisor);
316
332
  return supervisor;
@@ -336,6 +352,7 @@ export async function startHostSidecar(deps) {
336
352
  }
337
353
  /** 宿主退出钩子:停 supervisor + egress proxy。幂等。 */
338
354
  export async function shutdownHostSidecar() {
355
+ sidecarGeneration += 1;
339
356
  if (supervisorRef) {
340
357
  const supervisor = supervisorRef;
341
358
  supervisorRef = null;
@@ -48,7 +48,7 @@ export declare class SidecarSupervisor extends EventEmitter {
48
48
  private reverseHandlers;
49
49
  private constructor();
50
50
  /** Spawn the sidecar and wait for the ready marker. */
51
- static start(options?: SidecarStartOptions): Promise<SidecarSupervisor>;
51
+ static start(options?: SidecarStartOptions, onCreate?: (supervisor: SidecarSupervisor) => void): Promise<SidecarSupervisor>;
52
52
  /** Round-trip a JSON-RPC method call. */
53
53
  call<T = unknown>(method: string, params?: unknown, options?: SidecarMethodOptions): Promise<T>;
54
54
  /** Convenience: list tools registered on the sidecar. */
@@ -220,7 +220,8 @@ export declare class SidecarSupervisor extends EventEmitter {
220
220
  export declare function resolveSidecarPython(pythonExecutable?: string): string;
221
221
  export declare function rustSidecarEnabled(): boolean;
222
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`).
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.
225
226
  */
226
227
  export declare function resolveRustSidecarBin(explicit?: string): string | undefined;
@@ -77,8 +77,9 @@ export class SidecarSupervisor extends EventEmitter {
77
77
  this.options = options;
78
78
  }
79
79
  /** Spawn the sidecar and wait for the ready marker. */
80
- static async start(options = {}) {
80
+ static async start(options = {}, onCreate) {
81
81
  const supervisor = new SidecarSupervisor(options);
82
+ onCreate?.(supervisor);
82
83
  try {
83
84
  await supervisor.boot();
84
85
  SidecarSupervisor.lastSpawnRefusal = null;
@@ -383,19 +384,28 @@ export class SidecarSupervisor extends EventEmitter {
383
384
  // Internal: boot
384
385
  // ------------------------------------------------------------------
385
386
  async boot() {
386
- const rustBin = rustSidecarEnabled()
387
+ const useRustSidecar = rustSidecarEnabled();
388
+ const rustBin = useRustSidecar
387
389
  ? resolveRustSidecarBin(this.options.rustSidecarBin)
388
390
  : undefined;
389
391
  let spawnPlan;
390
392
  if (rustBin) {
391
393
  spawnPlan = await this.resolveSandboxedSpawn(rustBin, this.options.args ?? []);
392
394
  }
395
+ else if (useRustSidecar) {
396
+ throw new Error('STEERABLE_RUST_SIDECAR is enabled but the Rust sidecar binary is missing');
397
+ }
393
398
  else {
394
399
  const py = this.resolvePythonBinary();
395
400
  const entry = this.options.entryModule ?? 'steerable_sidecar';
396
401
  const args = ['-m', entry, ...(this.options.args ?? [])];
397
402
  spawnPlan = await this.resolveSandboxedSpawn(py, args);
398
403
  }
404
+ // Profile generation is asynchronous. A quit can begin while it runs;
405
+ // never spawn a new child after shutdown has claimed the supervisor.
406
+ if (this.shuttingDown) {
407
+ throw new SidecarShutdownError('sidecar startup cancelled by shutdown');
408
+ }
399
409
  const child = spawn(spawnPlan.command, spawnPlan.args, {
400
410
  cwd: this.options.cwd,
401
411
  env: { ...process.env, ...this.options.env, ...spawnPlan.env },
@@ -508,9 +518,6 @@ export class SidecarSupervisor extends EventEmitter {
508
518
  (allowResolver ? ' + resolver only (web tools egress via the per-host proxy)' : '') +
509
519
  ')');
510
520
  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
521
  const confinedTmp = join(writableRoots[0], 'tmp');
515
522
  mkdirSync(confinedTmp, { recursive: true });
516
523
  return {
@@ -646,6 +653,15 @@ export class SidecarSupervisor extends EventEmitter {
646
653
  child.stderr.setEncoding('utf-8');
647
654
  child.stdout.on('data', (chunk) => this.handleStdoutChunk(chunk));
648
655
  child.stderr.on('data', (chunk) => this.handleStderrChunk(chunk));
656
+ child.stdin.on('error', (error) => {
657
+ // A child may close stdin between shutdown's requireChild() and write().
658
+ // Keep the stream error handled; the write callback/pending failure
659
+ // carries the transport error to callers.
660
+ this.failPending(new SidecarShutdownError(`sidecar stdin closed: ${error.message}`));
661
+ if (!this.shuttingDown) {
662
+ this.options.onLogLine?.(`sidecar stdin error: ${error.message}`);
663
+ }
664
+ });
649
665
  child.on('exit', (code, signal) => {
650
666
  this.emit('exit', { code, signal });
651
667
  this.failPending(new SidecarShutdownError(`sidecar exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
@@ -968,11 +984,15 @@ function envFlag(name) {
968
984
  return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
969
985
  }
970
986
  export function rustSidecarEnabled() {
971
- return envFlag(RUST_SIDECAR_ENV);
987
+ if ((process.env[RUST_SIDECAR_ENV] ?? '').trim()) {
988
+ return envFlag(RUST_SIDECAR_ENV);
989
+ }
990
+ return resolveRustSidecarBin() !== undefined;
972
991
  }
973
992
  /**
974
- * Resolve the optional Rust sidecar binary. Missing binary with the flag
975
- * on is a silent fallback to Python (`python -m steerable_sidecar`).
993
+ * Resolve the Rust sidecar binary from an explicit path, a packaged engine,
994
+ * or a framework source checkout. Boot fails when Rust was explicitly
995
+ * enabled but no binary resolves.
976
996
  */
977
997
  export function resolveRustSidecarBin(explicit) {
978
998
  if (explicit) {
@@ -983,6 +1003,12 @@ export function resolveRustSidecarBin(explicit) {
983
1003
  return existsSync(fromEnv) ? fromEnv : undefined;
984
1004
  }
985
1005
  const exe = process.platform === 'win32' ? 'steerable-sidecar.exe' : 'steerable-sidecar';
1006
+ const resourcesPath = process.resourcesPath;
1007
+ if (resourcesPath) {
1008
+ const packaged = join(resourcesPath, 'engine', exe);
1009
+ if (existsSync(packaged))
1010
+ return packaged;
1011
+ }
986
1012
  const relatives = [
987
1013
  join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
988
1014
  join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
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.48",
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/agent-harness": "0.6.48",
59
+ "@steerable/pack-sdk": "0.6.48",
60
+ "@steerable/agent-protocol": "0.6.48"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/better-sqlite3": "^7.6.13",