@steerable/agent-shell 0.6.21 → 0.6.24

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 (48) 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 -14
  7. package/dist/insights/flush.d.ts +5 -4
  8. package/dist/insights/flush.js +14 -15
  9. package/dist/insights/record.d.ts +6 -5
  10. package/dist/insights/record.js +9 -10
  11. package/dist/llm/index.d.ts +6 -2
  12. package/dist/llm/index.js +19 -9
  13. package/dist/local-backend/prompt-builder.d.ts +10 -4
  14. package/dist/local-backend/prompt-builder.js +13 -12
  15. package/dist/local-backend/router.d.ts +18 -4
  16. package/dist/local-backend/router.js +133 -98
  17. package/dist/local-backend/skills/00-identity/SKILL.md +1 -1
  18. package/dist/local-backend/task-service.d.ts +14 -13
  19. package/dist/local-backend/task-service.js +32 -29
  20. package/dist/local-backend/worktree-service.d.ts +1 -1
  21. package/dist/local-backend/worktree-service.js +6 -6
  22. package/dist/main.js +43 -26
  23. package/dist/server/http-server.d.ts +13 -1
  24. package/dist/server/http-server.js +37 -8
  25. package/dist/server/index.js +4 -90
  26. package/dist/server/start.d.ts +26 -0
  27. package/dist/server/start.js +111 -0
  28. package/dist/sidecar/boot.d.ts +3 -1
  29. package/dist/sidecar/boot.js +6 -7
  30. package/dist/sidecar/reverse-spawn.js +12 -4
  31. package/dist/sidecar/reverse-tools.d.ts +1 -1
  32. package/dist/sidecar/reverse-tools.js +1 -1
  33. package/dist/sidecar/storage-path.js +2 -3
  34. package/dist/storage/driver.d.ts +57 -0
  35. package/dist/storage/driver.js +104 -0
  36. package/dist/storage/index.d.ts +75 -68
  37. package/dist/storage/index.js +506 -301
  38. package/dist/storage/pack-migrations.d.ts +2 -4
  39. package/dist/storage/pack-migrations.js +2 -4
  40. package/dist/storage/scoped-store.d.ts +125 -0
  41. package/dist/storage/scoped-store.js +1 -0
  42. package/dist/storage/sqlite-driver.d.ts +16 -0
  43. package/dist/storage/sqlite-driver.js +135 -0
  44. package/dist/storage/write-lease-error.d.ts +11 -0
  45. package/dist/storage/write-lease-error.js +18 -0
  46. package/package.json +14 -5
  47. package/dist/storage/local-store-singleton.d.ts +0 -28
  48. package/dist/storage/local-store-singleton.js +0 -38
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: identity
3
- description: Defines the product agent's core role, working environment, and language convention. Always loaded as the foundational skill. Brand placeholders ({agentName}) are rendered from the product-injected brand.
3
+ description: Defines the core role, working environment, and language convention. Always loaded as the foundational skill. {agentName} is the current chat agent display name (product brand if none).
4
4
  priority: 1000
5
5
  tags: [identity, base]
6
6
  ---
@@ -29,7 +29,7 @@ import type { ToolRouter } from '../tool-router.js';
29
29
  import { streamCoreLoopTurn } from './coreloop-stream.js';
30
30
  import type { WorktreeService } from './worktree-service.js';
31
31
  import { type PersistedTurnBlock } from './turn-timeline.js';
32
- /** 任务表的最小读写面(生产 = localStore;测试注入内存实现)。 */
32
+ /** 任务表的最小异步读写面(生产注入 scoped store;测试注入内存实现)。 */
33
33
  export interface TaskStore {
34
34
  createTask(input: {
35
35
  chatId: string;
@@ -39,22 +39,22 @@ export interface TaskStore {
39
39
  recordId?: string | null;
40
40
  dependsOn?: string[] | null;
41
41
  initialStatus?: 'blocked' | 'running';
42
- }): TaskRecord;
43
- getTask(taskId: string): TaskRecord | null;
44
- listTasks(chatId?: string, limit?: number): TaskRecord[];
45
- updateTask(taskId: string, updates: Partial<Pick<TaskRecord, 'status' | 'answer' | 'error' | 'worktreeState' | 'traceId' | 'recordId'>>): TaskRecord | null;
42
+ }): Promise<TaskRecord>;
43
+ getTask(taskId: string): Promise<TaskRecord | null>;
44
+ listTasks(chatId?: string, limit?: number): Promise<TaskRecord[]>;
45
+ updateTask(taskId: string, updates: Partial<Pick<TaskRecord, 'status' | 'answer' | 'error' | 'worktreeState' | 'traceId' | 'recordId'>>): Promise<TaskRecord | null>;
46
46
  /** 回写推理时间线;缺省实现可为空(测试假 store 内存覆盖)。 */
47
- saveTaskProcess?(taskId: string, processJson: string): void;
47
+ saveTaskProcess?(taskId: string, processJson: string): Promise<void>;
48
48
  }
49
49
  export interface TaskServiceDeps {
50
- store: TaskStore;
50
+ store: TaskStore | (() => TaskStore);
51
51
  toolRouter: ToolRouter;
52
52
  worktreeService: WorktreeService;
53
53
  /** chatId → 绑定项目(无项目对话返回 null)。与 router.resolveChatProject 同源。 */
54
- resolveChatProject: (chatId: string) => {
54
+ resolveChatProject: (chatId: string) => Promise<{
55
55
  name: string;
56
56
  folderPath: string;
57
- } | null;
57
+ } | null>;
58
58
  /** 任务终态 / 推理过程广播(renderer 任务面板与右侧过程栏)。 */
59
59
  broadcast?: (eventName: 'task-updated' | 'task-process', payload: Record<string, unknown>) => void;
60
60
  /** 测试缝:替换真实流驱动(默认 streamCoreLoopTurn)。 */
@@ -68,6 +68,7 @@ export declare class TaskService {
68
68
  private readonly deps;
69
69
  private readonly running;
70
70
  constructor(deps: TaskServiceDeps);
71
+ private get store();
71
72
  /**
72
73
  * task_run 工具的实现:落任务表 + 点火独立流,立即返回。
73
74
  * 任何同步可知的失败(无 sidecar、worktree 建不起来)都抛给工具层,
@@ -113,21 +114,21 @@ export declare class TaskService {
113
114
  private cancelProcessFlush;
114
115
  private executeTask;
115
116
  /** task_status 工具:单任务或本 chat 全部任务的快照。 */
116
- status(chatId: string, taskId?: string): Record<string, unknown>;
117
+ status(chatId: string, taskId?: string): Promise<Record<string, unknown>>;
117
118
  /** task_result 工具:取终态任务的完整结果。 */
118
- result(chatId: string, taskId: string): Record<string, unknown>;
119
+ result(chatId: string, taskId: string): Promise<Record<string, unknown>>;
119
120
  /** 喂给模型的任务视图(完整字段,answer 截断防爆上下文)。 */
120
121
  private toModelView;
121
122
  /**
122
123
  * 右侧过程栏:当前推理时间线。running 且进程内有流 = live;
123
124
  * 表上 running 但本进程没流 = 上次崩溃残留(stale)。
124
125
  */
125
- getProcess(taskId: string): {
126
+ getProcess(taskId: string): Promise<{
126
127
  task: TaskRecord;
127
128
  timeline: PersistedTurnBlock[];
128
129
  live: boolean;
129
130
  stale: boolean;
130
- } | null;
131
+ } | null>;
131
132
  /** 合并任务 worktree 到主仓(UI「合并到主仓」按钮的路由实现)。 */
132
133
  mergeTaskWorktree(taskId: string): Promise<TaskRecord>;
133
134
  /** 丢弃任务 worktree(UI「丢弃」按钮的路由实现)。 */
@@ -55,6 +55,9 @@ export class TaskService {
55
55
  constructor(deps) {
56
56
  this.deps = deps;
57
57
  }
58
+ get store() {
59
+ return typeof this.deps.store === 'function' ? this.deps.store() : this.deps.store;
60
+ }
58
61
  /**
59
62
  * task_run 工具的实现:落任务表 + 点火独立流,立即返回。
60
63
  * 任何同步可知的失败(无 sidecar、worktree 建不起来)都抛给工具层,
@@ -76,13 +79,13 @@ export class TaskService {
76
79
  const dependsOn = input.dependsOn?.length ? input.dependsOn : null;
77
80
  let blockedBy = [];
78
81
  if (dependsOn) {
79
- const deps = dependsOn.map((id) => {
80
- const dep = this.deps.store.getTask(id);
82
+ const deps = await Promise.all(dependsOn.map(async (id) => {
83
+ const dep = await this.store.getTask(id);
81
84
  if (!dep || dep.chatId !== input.chatId) {
82
85
  throw new Error(`task_run: 依赖任务不存在或不属于本会话:${id}`);
83
86
  }
84
87
  return dep;
85
- });
88
+ }));
86
89
  const failed = deps.filter((d) => d.status === 'failed');
87
90
  if (failed.length) {
88
91
  throw new Error(`task_run: 依赖任务已失败(${failed.map((d) => d.id.slice(0, 8)).join('、')})——`
@@ -108,7 +111,7 @@ export class TaskService {
108
111
  }
109
112
  }
110
113
  const blocked = blockedBy.length > 0;
111
- const record = this.deps.store.createTask({
114
+ const record = await this.store.createTask({
112
115
  chatId: input.chatId,
113
116
  task,
114
117
  worktreePath,
@@ -117,7 +120,7 @@ export class TaskService {
117
120
  initialStatus: blocked ? 'blocked' : 'running',
118
121
  });
119
122
  const recordId = `task:${record.id}`;
120
- this.deps.store.updateTask(record.id, { recordId });
123
+ await this.store.updateTask(record.id, { recordId });
121
124
  if (!blocked) {
122
125
  this.ignite(record.id, input.chatId, task, recordId, worktreePath);
123
126
  }
@@ -137,7 +140,7 @@ export class TaskService {
137
140
  const text = message.trim();
138
141
  if (!text)
139
142
  return { success: false, error: 'task_send: message 不能为空' };
140
- const task = this.deps.store.getTask(taskId);
143
+ const task = await this.store.getTask(taskId);
141
144
  if (!task || task.chatId !== chatId) {
142
145
  return { success: false, error: `任务不存在:${taskId}` };
143
146
  }
@@ -178,15 +181,15 @@ export class TaskService {
178
181
  * completed 的点火;任一依赖 failed 的标 failed(fail fast,错误
179
182
  * 指明是哪个依赖挂了)。
180
183
  */
181
- maybeUnblock(chatId) {
182
- const blocked = this.deps.store
183
- .listTasks(chatId, 100)
184
+ async maybeUnblock(chatId) {
185
+ const blocked = (await this.store
186
+ .listTasks(chatId, 100))
184
187
  .filter((t) => t.status === 'blocked' && t.dependsOn?.length);
185
188
  for (const task of blocked) {
186
- const deps = task.dependsOn.map((id) => this.deps.store.getTask(id));
189
+ const deps = await Promise.all(task.dependsOn.map((id) => this.store.getTask(id)));
187
190
  const failedDep = deps.find((d) => d?.status === 'failed');
188
191
  if (failedDep) {
189
- this.deps.store.updateTask(task.id, {
192
+ await this.store.updateTask(task.id, {
190
193
  status: 'failed',
191
194
  error: `依赖任务失败:${failedDep.id.slice(0, 8)}(${failedDep.error ?? '无错误信息'})`,
192
195
  });
@@ -195,7 +198,7 @@ export class TaskService {
195
198
  }
196
199
  const allCompleted = deps.every((d) => d?.status === 'completed');
197
200
  if (allCompleted) {
198
- this.deps.store.updateTask(task.id, { status: 'running' });
201
+ await this.store.updateTask(task.id, { status: 'running' });
199
202
  this.ignite(task.id, task.chatId, task.task, task.recordId ?? `task:${task.id}`, task.worktreePath);
200
203
  this.deps.broadcast?.('task-updated', { chatId, taskId: task.id });
201
204
  }
@@ -217,7 +220,7 @@ export class TaskService {
217
220
  .finally(() => {
218
221
  this.running.delete(taskId);
219
222
  // 一个任务到终态,可能让同 chat 的 blocked 任务依赖就绪——调度点火。
220
- this.maybeUnblock(chatId);
223
+ void this.maybeUnblock(chatId);
221
224
  });
222
225
  this.running.set(taskId, entry);
223
226
  }
@@ -235,7 +238,7 @@ export class TaskService {
235
238
  timeline: entry.timeline,
236
239
  live: true,
237
240
  });
238
- this.deps.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
241
+ void this.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
239
242
  };
240
243
  if (when === 'immediate') {
241
244
  this.cancelProcessFlush(taskId);
@@ -259,7 +262,7 @@ export class TaskService {
259
262
  const getSupervisor = this.deps.getSupervisor ?? getSidecarSupervisor;
260
263
  const supervisor = getSupervisor();
261
264
  if (!supervisor) {
262
- this.deps.store.updateTask(taskId, {
265
+ await this.store.updateTask(taskId, {
263
266
  status: 'failed',
264
267
  error: 'sidecar 未运行',
265
268
  });
@@ -267,7 +270,7 @@ export class TaskService {
267
270
  return;
268
271
  }
269
272
  const settings = llmService.getSettings();
270
- const project = this.deps.resolveChatProject(chatId);
273
+ const project = await this.deps.resolveChatProject(chatId);
271
274
  // 4.6b 整合:worktree 任务的围栏根 = worktree 路径(更紧——任务摸不到
272
275
  // 主检出);否则沿用项目根。writableRoots 同理收窄。
273
276
  const fenceRoot = worktreePath ?? project?.folderPath ?? null;
@@ -397,14 +400,14 @@ export class TaskService {
397
400
  traceId = failureTraceId;
398
401
  }
399
402
  }
400
- this.deps.store.updateTask(taskId, {
403
+ await this.store.updateTask(taskId, {
401
404
  status,
402
405
  answer: answer.trim() || null,
403
406
  error,
404
407
  ...(traceId ? { traceId } : {}),
405
408
  });
406
409
  this.cancelProcessFlush(taskId);
407
- this.deps.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
410
+ await this.store.saveTaskProcess?.(taskId, JSON.stringify(entry.timeline));
408
411
  this.deps.broadcast?.('task-updated', { chatId, taskId });
409
412
  this.deps.broadcast?.('task-process', {
410
413
  chatId,
@@ -414,15 +417,15 @@ export class TaskService {
414
417
  });
415
418
  }
416
419
  /** task_status 工具:单任务或本 chat 全部任务的快照。 */
417
- status(chatId, taskId) {
420
+ async status(chatId, taskId) {
418
421
  if (taskId) {
419
- const task = this.deps.store.getTask(taskId);
422
+ const task = await this.store.getTask(taskId);
420
423
  if (!task || task.chatId !== chatId) {
421
424
  return { success: false, error: `任务不存在:${taskId}` };
422
425
  }
423
426
  return { success: true, task: this.toModelView(task) };
424
427
  }
425
- const tasks = this.deps.store.listTasks(chatId, 50);
428
+ const tasks = await this.store.listTasks(chatId, 50);
426
429
  return {
427
430
  success: true,
428
431
  total: tasks.length,
@@ -430,8 +433,8 @@ export class TaskService {
430
433
  };
431
434
  }
432
435
  /** task_result 工具:取终态任务的完整结果。 */
433
- result(chatId, taskId) {
434
- const task = this.deps.store.getTask(taskId);
436
+ async result(chatId, taskId) {
437
+ const task = await this.store.getTask(taskId);
435
438
  if (!task || task.chatId !== chatId) {
436
439
  return { success: false, error: `任务不存在:${taskId}` };
437
440
  }
@@ -473,8 +476,8 @@ export class TaskService {
473
476
  * 右侧过程栏:当前推理时间线。running 且进程内有流 = live;
474
477
  * 表上 running 但本进程没流 = 上次崩溃残留(stale)。
475
478
  */
476
- getProcess(taskId) {
477
- const task = this.deps.store.getTask(taskId);
479
+ async getProcess(taskId) {
480
+ const task = await this.store.getTask(taskId);
478
481
  if (!task)
479
482
  return null;
480
483
  const entry = this.running.get(taskId);
@@ -509,7 +512,7 @@ export class TaskService {
509
512
  }
510
513
  /** 合并任务 worktree 到主仓(UI「合并到主仓」按钮的路由实现)。 */
511
514
  async mergeTaskWorktree(taskId) {
512
- const task = this.deps.store.getTask(taskId);
515
+ const task = await this.store.getTask(taskId);
513
516
  if (!task)
514
517
  throw new Error(`任务不存在:${taskId}`);
515
518
  if (!task.worktreePath || !task.worktreeBranch) {
@@ -523,13 +526,13 @@ export class TaskService {
523
526
  }
524
527
  const name = path.basename(task.worktreePath);
525
528
  await this.deps.worktreeService.mergeWorktree(task.chatId, name, `task(${taskId.slice(0, 8)}): ${task.task.slice(0, 60)}`);
526
- const updated = this.deps.store.updateTask(taskId, { worktreeState: 'merged' });
529
+ const updated = await this.store.updateTask(taskId, { worktreeState: 'merged' });
527
530
  this.deps.broadcast?.('task-updated', { chatId: task.chatId, taskId });
528
531
  return updated;
529
532
  }
530
533
  /** 丢弃任务 worktree(UI「丢弃」按钮的路由实现)。 */
531
534
  async discardTaskWorktree(taskId) {
532
- const task = this.deps.store.getTask(taskId);
535
+ const task = await this.store.getTask(taskId);
533
536
  if (!task)
534
537
  throw new Error(`任务不存在:${taskId}`);
535
538
  if (!task.worktreePath) {
@@ -545,7 +548,7 @@ export class TaskService {
545
548
  await this.deps.worktreeService.removeWorktree(task.chatId, name, {
546
549
  deleteBranch: true,
547
550
  });
548
- const updated = this.deps.store.updateTask(taskId, { worktreeState: 'discarded' });
551
+ const updated = await this.store.updateTask(taskId, { worktreeState: 'discarded' });
549
552
  this.deps.broadcast?.('task-updated', { chatId: task.chatId, taskId });
550
553
  return updated;
551
554
  }
@@ -35,7 +35,7 @@ export declare function slugifyWorktreeName(raw: string): string;
35
35
  export declare class WorktreeService {
36
36
  private readonly deps;
37
37
  constructor(deps: {
38
- resolveProject: (chatId: string) => WorktreeProject | null;
38
+ resolveProject: (chatId: string) => Promise<WorktreeProject | null>;
39
39
  runGit?: GitRunner;
40
40
  });
41
41
  private git;
@@ -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 / suggested-replies)只推主窗口。
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';
@@ -748,7 +734,7 @@ function setupIpcHandlers() {
748
734
  // between the turn's user message and the assistant reply persisted at
749
735
  // turn end. Only on acceptance: a soft failure degrades into the
750
736
  // follow-up queue, which persists through the normal send path.
751
- localStore.addMessage(chatId, 'user', content);
737
+ await hostStore.addMessage(chatId, 'user', content);
752
738
  }
753
739
  return { ok, ...(ok ? {} : { reason: 'stream_not_active' }) };
754
740
  });
@@ -806,10 +792,41 @@ function setupIpcHandlers() {
806
792
  }
807
793
  app.whenReady().then(async () => {
808
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
+ });
809
826
  setupIpcHandlers();
810
827
  // 任务清扫 / mock 广播 / sidecar 启动 / MCP 刷新 / 终端预热都在
811
828
  // runtime.start() 里(与 BS 同一份)。
812
- runtime.start();
829
+ await runtime.start();
813
830
  createWindow();
814
831
  setSecondInstanceHandler(() => {
815
832
  if (mainWindow && !mainWindow.isDestroyed()) {
@@ -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
  }
@@ -365,19 +371,42 @@ export function createBsServer(deps) {
365
371
  }
366
372
  const url = new URL(req.url ?? '/', 'http://bs.local');
367
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
+ }
368
385
  // 门 2:API 与宿主能力要 Bearer token;静态资源不验(浏览器靠它
369
386
  // 拿 token),静态面不暴露任何能力。
370
387
  const needsAuth = pathname.startsWith('/api/v2/') || pathname.startsWith('/host/');
371
- if (needsAuth && !isAuthorized(req, url)) {
372
- sendJson(res, 401, { detail: 'unauthorized' });
373
- 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
+ }
374
403
  }
375
404
  if (pathname === '/api/v2/events' && req.method === 'GET') {
376
405
  bus.attach(res);
377
406
  return;
378
407
  }
379
408
  if (pathname.startsWith('/api/v2/')) {
380
- await handleApi(req, res, pathname);
409
+ await handleApi(req, res, pathname, principal);
381
410
  return;
382
411
  }
383
412
  if (pathname.startsWith('/host/')) {