@steerable/agent-shell 0.6.20 → 0.6.22

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 (64) hide show
  1. package/README.md +34 -0
  2. package/dist/auth/index.d.ts +38 -0
  3. package/dist/auth/index.js +20 -0
  4. package/dist/host/pack-assembly.d.ts +7 -7
  5. package/dist/host/runtime.d.ts +6 -2
  6. package/dist/host/runtime.js +54 -15
  7. package/dist/host/visible-terminal-exec.d.ts +0 -2
  8. package/dist/host/visible-terminal-exec.js +4 -8
  9. package/dist/insights/flush.d.ts +5 -4
  10. package/dist/insights/flush.js +14 -15
  11. package/dist/insights/record.d.ts +6 -5
  12. package/dist/insights/record.js +9 -10
  13. package/dist/llm/index.d.ts +6 -2
  14. package/dist/llm/index.js +19 -9
  15. package/dist/local-backend/ai-suggestions.d.ts +32 -0
  16. package/dist/local-backend/ai-suggestions.js +160 -0
  17. package/dist/local-backend/coreloop-stream.js +1 -1
  18. package/dist/local-backend/mentioned-paths.d.ts +28 -0
  19. package/dist/local-backend/mentioned-paths.js +67 -0
  20. package/dist/local-backend/prompt-builder.d.ts +10 -4
  21. package/dist/local-backend/prompt-builder.js +13 -12
  22. package/dist/local-backend/router.d.ts +26 -4
  23. package/dist/local-backend/router.js +243 -96
  24. package/dist/local-backend/skills/00-identity/SKILL.md +1 -1
  25. package/dist/local-backend/task-service.d.ts +14 -13
  26. package/dist/local-backend/task-service.js +32 -29
  27. package/dist/local-backend/turn-files.d.ts +57 -0
  28. package/dist/local-backend/turn-files.js +285 -0
  29. package/dist/local-backend/worktree-service.d.ts +1 -1
  30. package/dist/local-backend/worktree-service.js +6 -6
  31. package/dist/main.js +45 -26
  32. package/dist/preload.cjs +30 -10
  33. package/dist/preload.js +33 -10
  34. package/dist/server/http-server.d.ts +13 -1
  35. package/dist/server/http-server.js +45 -8
  36. package/dist/server/index.js +4 -90
  37. package/dist/server/sse-bus.d.ts +1 -1
  38. package/dist/server/start.d.ts +26 -0
  39. package/dist/server/start.js +111 -0
  40. package/dist/sidecar/boot.d.ts +3 -1
  41. package/dist/sidecar/boot.js +6 -7
  42. package/dist/sidecar/reverse-approval.d.ts +2 -0
  43. package/dist/sidecar/reverse-approval.js +2 -1
  44. package/dist/sidecar/reverse-ask-user.d.ts +2 -0
  45. package/dist/sidecar/reverse-ask-user.js +2 -1
  46. package/dist/sidecar/reverse-spawn.js +12 -4
  47. package/dist/sidecar/reverse-tools.d.ts +1 -1
  48. package/dist/sidecar/reverse-tools.js +1 -1
  49. package/dist/sidecar/storage-path.js +2 -3
  50. package/dist/storage/driver.d.ts +57 -0
  51. package/dist/storage/driver.js +104 -0
  52. package/dist/storage/index.d.ts +80 -67
  53. package/dist/storage/index.js +528 -296
  54. package/dist/storage/pack-migrations.d.ts +2 -4
  55. package/dist/storage/pack-migrations.js +2 -4
  56. package/dist/storage/scoped-store.d.ts +125 -0
  57. package/dist/storage/scoped-store.js +1 -0
  58. package/dist/storage/sqlite-driver.d.ts +16 -0
  59. package/dist/storage/sqlite-driver.js +135 -0
  60. package/dist/storage/write-lease-error.d.ts +11 -0
  61. package/dist/storage/write-lease-error.js +18 -0
  62. package/package.json +14 -5
  63. package/dist/storage/local-store-singleton.d.ts +0 -28
  64. package/dist/storage/local-store-singleton.js +0 -38
@@ -49,8 +49,8 @@ export class WorktreeService {
49
49
  managedRoot(projectRoot) {
50
50
  return path.join(projectRoot, '.steerable', 'worktrees');
51
51
  }
52
- requireProject(chatId) {
53
- const project = this.deps.resolveProject(chatId);
52
+ async requireProject(chatId) {
53
+ const project = await this.deps.resolveProject(chatId);
54
54
  if (!project) {
55
55
  throw new Error('worktree 需要对话绑定项目(git 仓库)——请先把本对话关联到一个项目。');
56
56
  }
@@ -91,7 +91,7 @@ export class WorktreeService {
91
91
  * 同名已存在时直接复用(幂等——模型重试不该炸掉已建好的工作区)。
92
92
  */
93
93
  async createWorktree(chatId, rawName) {
94
- const project = this.requireProject(chatId);
94
+ const project = await this.requireProject(chatId);
95
95
  await this.requireGitRepo(project.folderPath);
96
96
  const name = slugifyWorktreeName(rawName ?? '');
97
97
  const suffix = name === 'wt' ? `-${Date.now().toString(36)}` : '';
@@ -117,7 +117,7 @@ export class WorktreeService {
117
117
  }
118
118
  /** 列出本项目的托管 worktree(只认 .steerable/worktrees 下的)。 */
119
119
  async listWorktrees(chatId) {
120
- const project = this.requireProject(chatId);
120
+ const project = await this.requireProject(chatId);
121
121
  await this.requireGitRepo(project.folderPath);
122
122
  const { stdout } = await this.git(project.folderPath, [
123
123
  'worktree',
@@ -168,7 +168,7 @@ export class WorktreeService {
168
168
  * 但默认删掉保持分支列表干净——已合并的分支删除是安全的)。
169
169
  */
170
170
  async removeWorktree(chatId, name, options = {}) {
171
- const project = this.requireProject(chatId);
171
+ const project = await this.requireProject(chatId);
172
172
  const worktrees = await this.listWorktrees(chatId);
173
173
  const target = worktrees.find((w) => w.name === name || w.path === name);
174
174
  if (!target) {
@@ -203,7 +203,7 @@ export class WorktreeService {
203
203
  * 可稍后重试或手工处理。
204
204
  */
205
205
  async mergeWorktree(chatId, name, commitMessage) {
206
- const project = this.requireProject(chatId);
206
+ const project = await this.requireProject(chatId);
207
207
  const worktrees = await this.listWorktrees(chatId);
208
208
  const target = worktrees.find((w) => w.name === name || w.path === name);
209
209
  if (!target) {
package/dist/main.js CHANGED
@@ -39,7 +39,6 @@ const __dirname = path.dirname(__filename);
39
39
  app.setPath('userData', getUserDataDir());
40
40
  import { getActiveCoreLoopStreamId } from './local-backend/coreloop-stream.js';
41
41
  import { getSidecarSupervisor } from './llm/index.js';
42
- import { localStore } from './storage/index.js';
43
42
  import { createHostRuntime } from './host/runtime.js';
44
43
  import { registerPackIpc } from './host/ipc.js';
45
44
  const store = createJsonStore({ name: 'config' });
@@ -62,20 +61,16 @@ const RETRY_DELAYS = [2000, 5000, 10000, 30000];
62
61
  // 全部宿主服务由共享 HostRuntime 装配(与 BS server 同一份,见
63
62
  // src/host/runtime.ts 模块头——历史上两处逐行重复,BS 曾漏接 TaskService
64
63
  // 导致模型退化成"假后台")。CS 的差异只剩广播(IPC)与生命周期。
65
- const runtime = createHostRuntime({
66
- broadcast: (channel, payload) => broadcastTerminalEvent(channel, payload),
67
- // LocalBackendRouter 的后台事件(目前只有 chat-title-updated)只推主窗口。
68
- // 没拿到 mainWindow 时静默丢——title 是 nice-to-have,不该让启动顺序影响功能。
69
- broadcastMain: (eventName, payload) => {
70
- if (!mainWindow || mainWindow.isDestroyed())
71
- return;
72
- mainWindow.webContents.send(eventName, payload);
73
- },
74
- hasWindow: () => BrowserWindow.getAllWindows().some((win) => !win.isDestroyed()),
75
- onLog: (line) => log.info('[sidecar]', line),
76
- taskSweepReason: '应用重启,任务流已中断',
77
- });
78
- const { localExecutor, localScriptRegistry, terminalManager, packHandles, localBackendRouter, approvalBridge, askUserBridge, maybeExecInTerminal, } = runtime;
64
+ let runtime;
65
+ let hostStore;
66
+ let localExecutor;
67
+ let localScriptRegistry;
68
+ let terminalManager;
69
+ let packHandles;
70
+ let localBackendRouter;
71
+ let approvalBridge;
72
+ let askUserBridge;
73
+ let maybeExecInTerminal;
79
74
  let routeWindow = null;
80
75
  // 进行中的 agent 流(streamId → AbortController)。cancelStream IPC、
81
76
  // 窗口销毁、应用退出时统一 abort,保证不会有"关了还在跑"的幽灵循环。
@@ -90,15 +85,6 @@ function broadcastTerminalEvent(channel, payload) {
90
85
  win.webContents.send(channel, payload);
91
86
  }
92
87
  }
93
- terminalManager.on('data', (sessionId, chunk) => {
94
- broadcastTerminalEvent('terminal:data', { sessionId, chunk });
95
- });
96
- terminalManager.on('exit', (sessionId, code, signal) => {
97
- broadcastTerminalEvent('terminal:exit', { sessionId, code, signal });
98
- });
99
- terminalManager.on('spawned', session => {
100
- broadcastTerminalEvent('terminal:spawned', session);
101
- });
102
88
  function getStartPath() {
103
89
  if (!START_PATH)
104
90
  return '/agent';
@@ -544,9 +530,11 @@ function setupIpcHandlers() {
544
530
  // validated and routed to the pending sidecar `approval.request` call;
545
531
  // unknown requestIds (late/stale answers) are dropped.
546
532
  ipcMain.handle('approval:decide', (_event, payload) => approvalBridge.decide(payload));
533
+ ipcMain.handle('approval:pending', () => approvalBridge.pending());
547
534
  // W8: the renderer's ask-user card answers here; routed to the pending
548
535
  // sidecar `ask_user.request` call (fail-open empty answers on mismatch).
549
536
  ipcMain.handle('ask-user:answer', (_event, payload) => askUserBridge.answer(payload));
537
+ ipcMain.handle('ask-user:pending', () => askUserBridge.pending());
550
538
  ipcMain.handle('local:update-safety-config', async (_event, config) => {
551
539
  localExecutor.updateSafetyConfig(config);
552
540
  return { success: true };
@@ -746,7 +734,7 @@ function setupIpcHandlers() {
746
734
  // between the turn's user message and the assistant reply persisted at
747
735
  // turn end. Only on acceptance: a soft failure degrades into the
748
736
  // follow-up queue, which persists through the normal send path.
749
- localStore.addMessage(chatId, 'user', content);
737
+ await hostStore.addMessage(chatId, 'user', content);
750
738
  }
751
739
  return { ok, ...(ok ? {} : { reason: 'stream_not_active' }) };
752
740
  });
@@ -804,10 +792,41 @@ function setupIpcHandlers() {
804
792
  }
805
793
  app.whenReady().then(async () => {
806
794
  applyStrictCsp();
795
+ runtime = await createHostRuntime({
796
+ broadcast: (channel, payload) => broadcastTerminalEvent(channel, payload),
797
+ broadcastMain: (eventName, payload) => {
798
+ if (!mainWindow || mainWindow.isDestroyed())
799
+ return;
800
+ mainWindow.webContents.send(eventName, payload);
801
+ },
802
+ hasWindow: () => BrowserWindow.getAllWindows().some((win) => !win.isDestroyed()),
803
+ onLog: (line) => log.info('[sidecar]', line),
804
+ taskSweepReason: '应用重启,任务流已中断',
805
+ });
806
+ ({
807
+ store: hostStore,
808
+ localExecutor,
809
+ localScriptRegistry,
810
+ terminalManager,
811
+ packHandles,
812
+ localBackendRouter,
813
+ approvalBridge,
814
+ askUserBridge,
815
+ maybeExecInTerminal,
816
+ } = runtime);
817
+ terminalManager.on('data', (sessionId, chunk) => {
818
+ broadcastTerminalEvent('terminal:data', { sessionId, chunk });
819
+ });
820
+ terminalManager.on('exit', (sessionId, code, signal) => {
821
+ broadcastTerminalEvent('terminal:exit', { sessionId, code, signal });
822
+ });
823
+ terminalManager.on('spawned', session => {
824
+ broadcastTerminalEvent('terminal:spawned', session);
825
+ });
807
826
  setupIpcHandlers();
808
827
  // 任务清扫 / mock 广播 / sidecar 启动 / MCP 刷新 / 终端预热都在
809
828
  // runtime.start() 里(与 BS 同一份)。
810
- runtime.start();
829
+ await runtime.start();
811
830
  createWindow();
812
831
  setSecondInstanceHandler(() => {
813
832
  if (mainWindow && !mainWindow.isDestroyed()) {
package/dist/preload.cjs CHANGED
@@ -87,6 +87,25 @@ function buildPreloadApi(packContributions = {}) {
87
87
  import_electron.ipcRenderer.removeListener("chat-title-updated", handler);
88
88
  };
89
89
  },
90
+ /**
91
+ * 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
92
+ * 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
93
+ */
94
+ onSuggestedReplies: (callback) => {
95
+ const handler = (_event, payload) => {
96
+ if (typeof payload?.chatId === "string" && typeof payload?.messageId === "string" && Array.isArray(payload.suggestions) && payload.suggestions.every((item) => typeof item === "string")) {
97
+ callback({
98
+ chatId: payload.chatId,
99
+ messageId: payload.messageId,
100
+ suggestions: payload.suggestions
101
+ });
102
+ }
103
+ };
104
+ import_electron.ipcRenderer.on("suggested-replies", handler);
105
+ return () => {
106
+ import_electron.ipcRenderer.removeListener("suggested-replies", handler);
107
+ };
108
+ },
90
109
  /**
91
110
  * 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
92
111
  * 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
@@ -154,9 +173,9 @@ function buildPreloadApi(packContributions = {}) {
154
173
  },
155
174
  /**
156
175
  * W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
157
- * 请求广播到 renderer(approval:request),审批弹窗应答后走
158
- * approval:decide 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
159
- * 弹窗监听者(AgentPage 挂载的 ApprovalModalHost)。
176
+ * 请求广播到 renderer(approval:request),输入区审批菜单应答后走
177
+ * approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
178
+ * 的请求。
160
179
  */
161
180
  approval: {
162
181
  onRequest: (callback) => {
@@ -173,13 +192,16 @@ function buildPreloadApi(packContributions = {}) {
173
192
  },
174
193
  decide: async (decision) => {
175
194
  await import_electron.ipcRenderer.invoke("approval:decide", decision);
195
+ },
196
+ pending: async () => {
197
+ return await import_electron.ipcRenderer.invoke("approval:pending");
176
198
  }
177
199
  },
178
200
  /**
179
201
  * W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
180
202
  * 请求广播到 renderer(ask-user:request),问题卡片应答后走
181
- * ask-user:answer 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
182
- * 卡片监听者(AgentLayout 挂载的 AskUserModalHost)。
203
+ * ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
204
+ * 的请求。
183
205
  */
184
206
  askUser: {
185
207
  onRequest: (callback) => {
@@ -196,6 +218,9 @@ function buildPreloadApi(packContributions = {}) {
196
218
  },
197
219
  answer: async (reply) => {
198
220
  await import_electron.ipcRenderer.invoke("ask-user:answer", reply);
221
+ },
222
+ pending: async () => {
223
+ return await import_electron.ipcRenderer.invoke("ask-user:pending");
199
224
  }
200
225
  },
201
226
  local: {
@@ -380,11 +405,6 @@ function buildPreloadApi(packContributions = {}) {
380
405
  const fn = (_event, session) => callback(session);
381
406
  import_electron.ipcRenderer.on("terminal:spawned", fn);
382
407
  return () => import_electron.ipcRenderer.removeListener("terminal:spawned", fn);
383
- },
384
- onReveal: (callback) => {
385
- const fn = (_event, payload) => callback(payload);
386
- import_electron.ipcRenderer.on("terminal:reveal", fn);
387
- return () => import_electron.ipcRenderer.removeListener("terminal:reveal", fn);
388
408
  }
389
409
  }
390
410
  };
package/dist/preload.js CHANGED
@@ -103,6 +103,28 @@ export function buildPreloadApi(packContributions = {}) {
103
103
  ipcRenderer.removeListener('chat-title-updated', handler);
104
104
  };
105
105
  },
106
+ /**
107
+ * 回合追问建议就绪。Backend 在助手回复完成后 fire-and-forget 生成 3 条
108
+ * 下一轮用户输入;启发式兜底会立刻推一次,LLM 成功后再替换。返回 unsubscribe。
109
+ */
110
+ onSuggestedReplies: (callback) => {
111
+ const handler = (_event, payload) => {
112
+ if (typeof payload?.chatId === 'string' &&
113
+ typeof payload?.messageId === 'string' &&
114
+ Array.isArray(payload.suggestions) &&
115
+ payload.suggestions.every((item) => typeof item === 'string')) {
116
+ callback({
117
+ chatId: payload.chatId,
118
+ messageId: payload.messageId,
119
+ suggestions: payload.suggestions,
120
+ });
121
+ }
122
+ };
123
+ ipcRenderer.on('suggested-replies', handler);
124
+ return () => {
125
+ ipcRenderer.removeListener('suggested-replies', handler);
126
+ };
127
+ },
106
128
  /**
107
129
  * 会话补建通知:Backend 在向本地不存在的 chatId 首次发送时按 URL 里的 id
108
130
  * 现场补建会话,并广播 `chat-created`({chatId, agentId})。返回 unsubscribe
@@ -171,9 +193,9 @@ export function buildPreloadApi(packContributions = {}) {
171
193
  },
172
194
  /**
173
195
  * W4-1 审批代数:sidecar 的 ApprovalExecutor 经反向通道请示,主进程把
174
- * 请求广播到 renderer(approval:request),审批弹窗应答后走
175
- * approval:decide 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
176
- * 弹窗监听者(AgentPage 挂载的 ApprovalModalHost)。
196
+ * 请求广播到 renderer(approval:request),输入区审批菜单应答后走
197
+ * approval:decide 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
198
+ * 的请求。
177
199
  */
178
200
  approval: {
179
201
  onRequest: (callback) => {
@@ -191,12 +213,15 @@ export function buildPreloadApi(packContributions = {}) {
191
213
  decide: async (decision) => {
192
214
  await ipcRenderer.invoke('approval:decide', decision);
193
215
  },
216
+ pending: async () => {
217
+ return await ipcRenderer.invoke('approval:pending');
218
+ },
194
219
  },
195
220
  /**
196
221
  * W8 结构化提问:sidecar 的 ask_user 工具经反向通道请示,主进程把
197
222
  * 请求广播到 renderer(ask-user:request),问题卡片应答后走
198
- * ask-user:answer 回到主进程。订阅返回 unsubscribe;同一时刻只有一个
199
- * 卡片监听者(AgentLayout 挂载的 AskUserModalHost)。
223
+ * ask-user:answer 回到主进程。pending 供刷新后的 renderer 恢复仍在等待
224
+ * 的请求。
200
225
  */
201
226
  askUser: {
202
227
  onRequest: (callback) => {
@@ -214,6 +239,9 @@ export function buildPreloadApi(packContributions = {}) {
214
239
  answer: async (reply) => {
215
240
  await ipcRenderer.invoke('ask-user:answer', reply);
216
241
  },
242
+ pending: async () => {
243
+ return await ipcRenderer.invoke('ask-user:pending');
244
+ },
217
245
  },
218
246
  local: {
219
247
  selectDirectory: async (options) => {
@@ -398,11 +426,6 @@ export function buildPreloadApi(packContributions = {}) {
398
426
  ipcRenderer.on('terminal:spawned', fn);
399
427
  return () => ipcRenderer.removeListener('terminal:spawned', fn);
400
428
  },
401
- onReveal: (callback) => {
402
- const fn = (_event, payload) => callback(payload);
403
- ipcRenderer.on('terminal:reveal', fn);
404
- return () => ipcRenderer.removeListener('terminal:reveal', fn);
405
- },
406
429
  },
407
430
  };
408
431
  contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...packContributions });
@@ -23,15 +23,21 @@
23
23
  * 不要绑到非回环地址,那会把 shell / 文件读写 / 场景包工具控制暴露给整个
24
24
  * 网络(token 只能挡没有 token 的人)。
25
25
  */
26
- import { type Server } from 'node:http';
26
+ import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
27
27
  import type { LocalBackendRouter } from '../local-backend/router.js';
28
+ import type { ScopedStore } from '../storage/scoped-store.js';
28
29
  import type { LocalExecutor, LocalExecRequest, LocalExecResult } from '../local-executor.js';
29
30
  import type { LocalScriptRegistry } from '../local-script-registry.js';
30
31
  import type { TerminalManager } from '../terminal-manager.js';
31
32
  import type { createApprovalBridge } from '../sidecar/reverse-approval.js';
32
33
  import type { createAskUserBridge } from '../sidecar/reverse-ask-user.js';
33
34
  import type { SseBus } from './sse-bus.js';
35
+ /** Runs before built-in routing; `handled` short-circuits the request. */
36
+ export interface BsMiddleware {
37
+ (req: IncomingMessage, res: ServerResponse): Promise<'handled' | 'pass'>;
38
+ }
34
39
  export interface BsServerDeps {
40
+ store: ScopedStore;
35
41
  localBackendRouter: LocalBackendRouter;
36
42
  localExecutor: LocalExecutor;
37
43
  localScriptRegistry: LocalScriptRegistry;
@@ -44,5 +50,11 @@ export interface BsServerDeps {
44
50
  webDistDir: string;
45
51
  /** Bearer token;缺省每次启动随机生成。测试注入固定值。 */
46
52
  authToken?: string;
53
+ /**
54
+ * Trusted product middleware, invoked in order after Host validation and
55
+ * before authentication. This position lets a product own public sign-in
56
+ * and identity-provider callback routes without weakening built-in routes.
57
+ */
58
+ middleware?: readonly BsMiddleware[];
47
59
  }
48
60
  export declare function createBsServer(deps: BsServerDeps): Server;
@@ -31,10 +31,10 @@ import path from 'node:path';
31
31
  import log from 'electron-log';
32
32
  import { getActiveCoreLoopStreamId } from '../local-backend/coreloop-stream.js';
33
33
  import { getSidecarSupervisor } from '../llm/index.js';
34
- import { localStore } from '../storage/index.js';
35
34
  import { matchPackHttpRoute } from '../host/http-routes.js';
36
35
  import { getBrand } from '../brand.js';
37
36
  import { saveAttachmentFiles } from '../attachments.js';
37
+ import { getAuthProvider } from '../auth/index.js';
38
38
  /** 与 router.handleStream 内部的路由正则保持一致——只有这些路径是流式。 */
39
39
  const STREAM_PATH_PATTERNS = [
40
40
  /^\/api\/v2\/chats\/[^/]+\/(send|run|agent)$/,
@@ -91,6 +91,7 @@ function asRecord(body) {
91
91
  }
92
92
  export function createBsServer(deps) {
93
93
  const { localBackendRouter, localExecutor, localScriptRegistry, terminalManager, approvalBridge, askUserBridge, maybeExecInTerminal, bus, webDistDir, } = deps;
94
+ const middleware = deps.middleware ?? [];
94
95
  const authToken = deps.authToken ?? randomBytes(24).toString('base64url');
95
96
  /** `/api/v2/*` 与 `/host/*` 的 Bearer 门;EventSource 不能设头,收 query token。 */
96
97
  function isAuthorized(req, url) {
@@ -98,13 +99,18 @@ export function createBsServer(deps) {
98
99
  return true;
99
100
  return url.searchParams.get('token') === authToken;
100
101
  }
101
- async function handleApi(req, res, pathname) {
102
+ async function handleApi(req, res, pathname, principal) {
102
103
  const isStream = req.method === 'POST' && STREAM_PATH_PATTERNS.some((p) => p.test(pathname));
103
104
  const body = req.method === 'POST' || req.method === 'PATCH' || req.method === 'PUT'
104
105
  ? await readJsonBody(req)
105
106
  : undefined;
106
107
  const search = new URL(req.url ?? '/', 'http://bs.local').search;
107
- const request = { method: req.method ?? 'GET', path: `${pathname}${search}`, body };
108
+ const request = {
109
+ method: req.method ?? 'GET',
110
+ path: `${pathname}${search}`,
111
+ body,
112
+ ...(principal ? { principal } : {}),
113
+ };
108
114
  if (isStream) {
109
115
  const controller = new AbortController();
110
116
  // 浏览器关页/取消 fetch → 中止 agent 循环(对齐 Electron 的 cancelStream)。
@@ -221,7 +227,7 @@ export function createBsServer(deps) {
221
227
  const ok = await supervisor.steerChat(streamId, content);
222
228
  // 与 main.ts 同理:接受即落库,重开对话能看到这条注入的用户消息。
223
229
  if (ok)
224
- localStore.addMessage(chatId, 'user', content);
230
+ await deps.store.addMessage(chatId, 'user', content);
225
231
  sendJson(res, 200, { ok, ...(ok ? {} : { reason: 'stream_not_active' }) });
226
232
  return;
227
233
  }
@@ -230,11 +236,19 @@ export function createBsServer(deps) {
230
236
  sendJson(res, 200, await approvalBridge.decide(body));
231
237
  return;
232
238
  }
239
+ if (method === 'GET' && pathname === '/host/approval/pending') {
240
+ sendJson(res, 200, approvalBridge.pending());
241
+ return;
242
+ }
233
243
  // ─── ask_user(对齐 ask-user:answer IPC) ───
234
244
  if (method === 'POST' && pathname === '/host/ask-user/answer') {
235
245
  sendJson(res, 200, askUserBridge.answer(body));
236
246
  return;
237
247
  }
248
+ if (method === 'GET' && pathname === '/host/ask-user/pending') {
249
+ sendJson(res, 200, askUserBridge.pending());
250
+ return;
251
+ }
238
252
  // ─── local(对齐 local:* IPC) ───
239
253
  if (method === 'POST' && pathname === '/host/local/exec-shell') {
240
254
  const request = body;
@@ -357,19 +371,42 @@ export function createBsServer(deps) {
357
371
  }
358
372
  const url = new URL(req.url ?? '/', 'http://bs.local');
359
373
  const pathname = url.pathname;
374
+ for (const handle of middleware) {
375
+ const result = await handle(req, res);
376
+ if (result !== 'handled' && result !== 'pass') {
377
+ throw new Error(`BS middleware returned invalid result: ${String(result)}`);
378
+ }
379
+ if (result === 'handled' || res.writableEnded)
380
+ return;
381
+ if (res.headersSent) {
382
+ throw new Error('BS middleware returned pass after starting a response');
383
+ }
384
+ }
360
385
  // 门 2:API 与宿主能力要 Bearer token;静态资源不验(浏览器靠它
361
386
  // 拿 token),静态面不暴露任何能力。
362
387
  const needsAuth = pathname.startsWith('/api/v2/') || pathname.startsWith('/host/');
363
- if (needsAuth && !isAuthorized(req, url)) {
364
- sendJson(res, 401, { detail: 'unauthorized' });
365
- return;
388
+ let principal;
389
+ if (needsAuth) {
390
+ const authProvider = getAuthProvider();
391
+ if (authProvider) {
392
+ const decision = await authProvider.authenticate(req.headers);
393
+ if (!decision.ok) {
394
+ sendJson(res, decision.status, decision.body ?? { detail: decision.status === 403 ? 'forbidden' : 'unauthorized' });
395
+ return;
396
+ }
397
+ principal = decision.principal;
398
+ }
399
+ else if (!isAuthorized(req, url)) {
400
+ sendJson(res, 401, { detail: 'unauthorized' });
401
+ return;
402
+ }
366
403
  }
367
404
  if (pathname === '/api/v2/events' && req.method === 'GET') {
368
405
  bus.attach(res);
369
406
  return;
370
407
  }
371
408
  if (pathname.startsWith('/api/v2/')) {
372
- await handleApi(req, res, pathname);
409
+ await handleApi(req, res, pathname, principal);
373
410
  return;
374
411
  }
375
412
  if (pathname.startsWith('/host/')) {
@@ -1,100 +1,14 @@
1
- /**
2
- * BS 模式入口:`node dist/server/index.js`。
3
- *
4
- * 装配与 Electron 主进程(main.ts)相同的一组服务——同一份装配住在
5
- * src/host/runtime.ts(HostRuntime),这里只保留 BS 特有的部分:SSE
6
- * 总线广播、HTTP 出口(http-server.ts)、SIGINT/SIGTERM 生命周期。
7
- * 数据目录、sidecar、工具链、审批流全部共用;两种模式因此操作同一份
8
- * SQLite / JSON 配置(不要同时启动两个宿主——write-lease 会拒绝第二个写者)。
9
- *
10
- * 默认绑定 127.0.0.1:4787(DEEPPATH_BS_HOST / DEEPPATH_BS_PORT 可改)。
11
- * 无认证:与桌面版同一信任模型,不要暴露到非回环地址。
12
- */
13
- // 产品组装根必须是第一个 import:包迁移注册要先于 storage 单例构造(0.3b)。
14
- // 产品组装根(products/<id>/server.ts 或 devtools/dev-server.ts)必须先于
15
- // 本模块完成 import:包迁移注册要先于 storage 单例构造(0.3b / 2.3)。
16
- import { existsSync } from 'node:fs';
17
- import path from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
- import dotenv from 'dotenv';
20
- import log from 'electron-log';
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
- // 与 main.ts 相同的 .env 加载顺序(.env.local 优先;已有环境变量胜出)。
23
- (function loadDotenvFiles() {
24
- const repoRoot = path.resolve(__dirname, '..', '..');
25
- for (const file of ['.env.local', '.env']) {
26
- const fullPath = path.join(repoRoot, file);
27
- if (existsSync(fullPath))
28
- dotenv.config({ path: fullPath, override: false });
29
- }
30
- })();
31
- import { getBrand } from '../brand.js';
32
- import { getUserDataDir } from '../runtime.js';
33
- import { createHostRuntime } from '../host/runtime.js';
34
- import { registerPackHttpRoutes } from '../host/http-routes.js';
35
- import { SseBus } from './sse-bus.js';
36
- import { createBsServer } from './http-server.js';
37
- // BS 模式没有 Electron 的 userData 概念,electron-log 默认会写
38
- // ~/.config/<name>/logs;在 HOME 只读(容器/只读环境)或用户显式指定
39
- // DEEPPATH_USER_DATA_DIR 时,日志应跟随应用数据目录走。
40
- log.transports.file.resolvePathFn = () => path.join(getUserDataDir(), 'logs', 'main.log');
1
+ /** Executable BS entry; reusable assembly lives in `start.ts`. */
2
+ import { startBsHost } from './start.js';
41
3
  async function main() {
42
- const brand = getBrand();
43
- const bus = new SseBus();
44
- const broadcast = (channel, payload) => bus.broadcast(channel, payload);
45
- // 与 CS 同一份服务装配;BS 的差异只有广播(SSE 总线)与"有浏览器连着
46
- // 事件总线 = 有人能应答审批/提问"的 hasWindow 判定。
47
- const runtime = createHostRuntime({
48
- broadcast,
49
- hasWindow: () => bus.size > 0,
50
- onLog: (line) => log.info('[sidecar]', line),
51
- taskSweepReason: '服务重启,任务流已中断',
52
- });
53
- const { localExecutor, localScriptRegistry, terminalManager, packHandles, localBackendRouter, approvalBridge, askUserBridge, maybeExecInTerminal, } = runtime;
54
- // 2.3 起 web 产物按产品构建:产品入口注入 DEEPPATH_WEB_DIST
55
- //(products/<id>/web/dist);缺省回退到 shell 包自带的 web/dist
56
- //(packages/agent-shell/web/dist),仅用于给出可读的报错。
57
- const webDistDir = process.env.DEEPPATH_WEB_DIST ??
58
- path.join(__dirname, '..', '..', 'web', 'dist');
59
- if (!existsSync(path.join(webDistDir, 'index.html'))) {
60
- console.error(`[bs] web build not found at ${webDistDir} — run \`pnpm build\` first.`);
61
- process.exit(1);
62
- }
63
- // 场景包 HTTP 路由(/host/<packId>/*)由装配产物给出,宿主循环注册
64
- //(2.3;路由路径命名空间由 registerPackHttpRoutes 校验)。
65
- for (const [packId, handle] of packHandles) {
66
- const routes = handle.httpRoutes?.();
67
- if (routes)
68
- registerPackHttpRoutes(packId, routes);
69
- }
70
- const server = createBsServer({
71
- localBackendRouter,
72
- localExecutor,
73
- localScriptRegistry,
74
- terminalManager,
75
- approvalBridge,
76
- askUserBridge,
77
- maybeExecInTerminal,
78
- bus,
79
- webDistDir,
80
- });
81
- const host = process.env.DEEPPATH_BS_HOST || '127.0.0.1';
82
- const port = Number(process.env.DEEPPATH_BS_PORT || 4787);
83
- await new Promise((resolve, reject) => {
84
- server.once('error', reject);
85
- server.listen(port, host, () => resolve());
86
- });
87
- console.log(`[bs] ${brand.displayName} server listening at http://${host}:${port} (flavor=${brand.flavor})`);
88
- // 任务清扫 / mock 广播 / sidecar 启动 / MCP 刷新 / 终端预热。不阻塞
89
- // HTTP 就绪;竞速的回合由 router 回退处理(同 main.ts)。
90
- runtime.start();
4
+ const handle = await startBsHost();
91
5
  let shuttingDown = false;
92
6
  const shutdown = () => {
93
7
  if (shuttingDown)
94
8
  return;
95
9
  shuttingDown = true;
96
10
  console.log('[bs] shutting down…');
97
- void runtime.shutdown().finally(() => process.exit(0));
11
+ void handle.shutdown().finally(() => process.exit(0));
98
12
  // 兜底:sidecar 卡住也不拖住退出。
99
13
  setTimeout(() => process.exit(0), 5_000).unref();
100
14
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * BS 模式的 SSE 事件总线:替代 Electron 的 `webContents.send(channel, payload)`
3
3
  * 广播。所有宿主事件(terminal:data、approval:request、chat-title-updated、
4
- * 场景包事件……)经这一条 `GET /api/v2/events` 长连接推给浏览器,
4
+ * suggested-replies、场景包事件……)经这一条 `GET /api/v2/events` 长连接推给浏览器,
5
5
  * channel 名与 IPC 通道保持一致,前端 http-bridge 按名分发。
6
6
  */
7
7
  import type { ServerResponse } from 'node:http';
@@ -0,0 +1,26 @@
1
+ import type { Server } from 'node:http';
2
+ import type { TenantScope } from '../storage/driver.js';
3
+ import { type BsMiddleware } from './http-server.js';
4
+ export interface BsHostOptions {
5
+ host?: string;
6
+ port?: number;
7
+ authToken?: string;
8
+ middleware?: readonly BsMiddleware[];
9
+ /** Fixed storage owner for a per-user host process. */
10
+ scope?: TenantScope;
11
+ }
12
+ export interface BsHostHandle {
13
+ readonly server: Server;
14
+ readonly host: string;
15
+ readonly port: number;
16
+ /** Stops accepting requests and shuts down all host runtime services. */
17
+ shutdown(): Promise<void>;
18
+ }
19
+ /**
20
+ * Assembles and starts the BS HTTP host.
21
+ *
22
+ * Product composition roots may register providers, package contributions,
23
+ * and middleware before calling this function.
24
+ */
25
+ export declare function startBsHost(options?: BsHostOptions): Promise<BsHostHandle>;
26
+ export type { BsMiddleware };