@steerable/agent-shell 0.6.30 → 0.6.32

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.
@@ -28,6 +28,7 @@ import { createVisibleTerminalExec } from './visible-terminal-exec.js';
28
28
  import { createJsonStore } from '../json-store.js';
29
29
  import { bindWorkspaceSkillRoots } from '../local-backend/skill-loader.js';
30
30
  import { getChatAttachmentsDir } from '../attachments.js';
31
+ import { ensureChatWorkspace } from '../project-home.js';
31
32
  import { recordInsightTurn } from '../insights/record.js';
32
33
  import { llmService } from '../llm/index.js';
33
34
  import { getPackAssemblies, } from './pack-assembly.js';
@@ -150,6 +151,12 @@ export async function createHostRuntime(options) {
150
151
  toolRouter,
151
152
  worktreeService,
152
153
  resolveChatProject: resolveChatProjectRoot,
154
+ resolveChatWorkspaceRoot: async (chatId) => {
155
+ const project = await resolveChatProjectRoot(chatId);
156
+ if (project)
157
+ return project.folderPath;
158
+ return ensureChatWorkspace(chatId);
159
+ },
153
160
  broadcast,
154
161
  });
155
162
  toolRouter.setTaskServices({ taskService, worktreeService });
@@ -204,10 +211,16 @@ export async function createHostRuntime(options) {
204
211
  void startHostSidecar({
205
212
  store: defaultStore,
206
213
  toolRouter,
207
- resolveProjectRoot: async (chatId) => (await localBackendRouter.resolveChatProject(chatId))?.folderPath ?? null,
214
+ resolveProjectRoot: async (chatId) => localBackendRouter.resolveChatWorkspaceRoot(chatId),
208
215
  // 项目模式下文件读写被围栏在项目目录内;会话附件目录是额外放行的
209
216
  // 只读根,保证用户上传的文件即使在项目会话里也能被 agent 读回。
210
- resolveAdditionalReadRoots: (chatId) => [getChatAttachmentsDir(chatId)],
217
+ resolveAdditionalReadRoots: async (chatId) => {
218
+ const project = await localBackendRouter.resolveChatProject(chatId);
219
+ return [
220
+ getChatAttachmentsDir(chatId),
221
+ ...(project?.sourceFolders ?? []),
222
+ ];
223
+ },
211
224
  approvalHandler: approvalBridge.handler,
212
225
  askUserHandler: askUserBridge.handler,
213
226
  // P2b: resume 时 sidecar 把记录里的读证据推给 LocalExecutor 的
@@ -98,15 +98,19 @@ export declare class LocalBackendRouter {
98
98
  private runTitleGenInBackground;
99
99
  /**
100
100
  * 解析当前对话绑定的项目(项目模式)。chat 没绑项目、项目已被删除、或
101
- * 注册表未注入时都返回 null(= 不沙箱,行为同旧版本)。
102
- *
103
- * Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
104
- * 工具调用补上 projectRoot 围栏。
101
+ * 注册表未注入时都返回 null。无项目对话的可写根见
102
+ * {@link LocalBackendRouter.resolveChatWorkspaceRoot}。
105
103
  */
106
104
  resolveChatProject(chatId: string): Promise<{
107
105
  name: string;
108
106
  folderPath: string;
107
+ sourceFolders: string[];
109
108
  } | null>;
109
+ /**
110
+ * 本对话的可写根:绑了项目用项目家目录,否则在
111
+ * `Documents/<应用名>/conversations/<chatId>/` 建对话工作区。
112
+ */
113
+ resolveChatWorkspaceRoot(chatId: string): Promise<string>;
110
114
  /**
111
115
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
112
116
  *
@@ -38,12 +38,18 @@ import { detectInterruptedTurn } from './interrupted-helper.js';
38
38
  import { dropCurrentUserMessage } from './history-helper.js';
39
39
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
40
40
  import { loadProjectRuleFiles } from '../project-rules.js';
41
+ import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, } from '../project-home.js';
41
42
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
42
43
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
43
44
  import { matchPackBackendRoute } from './pack-backend-routes.js';
44
45
  import { collectTurnFiles } from './turn-files.js';
45
46
  import { resolveMentionedPaths } from './mentioned-paths.js';
46
47
  import { getAuthProvider } from '../auth/index.js';
48
+ function parseSourceFolders(value) {
49
+ if (!Array.isArray(value))
50
+ return [];
51
+ return value.filter((item) => typeof item === 'string' && item.trim() !== '');
52
+ }
47
53
  /**
48
54
  * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
49
55
  * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
@@ -431,9 +437,17 @@ export class LocalBackendRouter {
431
437
  if (method === 'POST') {
432
438
  const payload = this.toRecord(request.body);
433
439
  try {
440
+ const name = String(payload.name || '');
441
+ const sourceFolders = parseSourceFolders(payload.sourceFolders);
442
+ let folderPath = typeof payload.folderPath === 'string' ? payload.folderPath.trim() : '';
443
+ if (!folderPath) {
444
+ folderPath = allocateProjectHome(name);
445
+ ensureProjectHome(folderPath);
446
+ }
434
447
  const project = registry.create({
435
- name: String(payload.name || ''),
436
- folderPath: String(payload.folderPath || ''),
448
+ name,
449
+ folderPath,
450
+ sourceFolders,
437
451
  });
438
452
  return { status: 200, data: { success: true, project } };
439
453
  }
@@ -458,6 +472,9 @@ export class LocalBackendRouter {
458
472
  const project = registry.update(projectId, {
459
473
  name: typeof payload.name === 'string' ? payload.name : undefined,
460
474
  folderPath: typeof payload.folderPath === 'string' ? payload.folderPath : undefined,
475
+ sourceFolders: payload.sourceFolders === undefined
476
+ ? undefined
477
+ : parseSourceFolders(payload.sourceFolders),
461
478
  });
462
479
  return { status: 200, data: { success: true, project } };
463
480
  }
@@ -1732,7 +1749,7 @@ export class LocalBackendRouter {
1732
1749
  }
1733
1750
  // 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
1734
1751
  // 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
1735
- // 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
1752
+ // 路径按对话绑定的项目根解析,无项目时按对话工作区。
1736
1753
  if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
1737
1754
  const payload = this.toRecord(request.body);
1738
1755
  const candidates = Array.isArray(payload.candidates)
@@ -1742,7 +1759,9 @@ export class LocalBackendRouter {
1742
1759
  return { status: 200, data: { resolved: [] } };
1743
1760
  }
1744
1761
  const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
1745
- const baseDir = (chatId ? (await this.resolveChatProject(chatId))?.folderPath : null) ?? os.homedir();
1762
+ const baseDir = chatId
1763
+ ? await this.resolveChatWorkspaceRoot(chatId)
1764
+ : os.homedir();
1746
1765
  const resolved = await resolveMentionedPaths({ candidates, baseDir });
1747
1766
  return { status: 200, data: { resolved } };
1748
1767
  }
@@ -2173,10 +2192,8 @@ export class LocalBackendRouter {
2173
2192
  }
2174
2193
  /**
2175
2194
  * 解析当前对话绑定的项目(项目模式)。chat 没绑项目、项目已被删除、或
2176
- * 注册表未注入时都返回 null(= 不沙箱,行为同旧版本)。
2177
- *
2178
- * Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
2179
- * 工具调用补上 projectRoot 围栏。
2195
+ * 注册表未注入时都返回 null。无项目对话的可写根见
2196
+ * {@link LocalBackendRouter.resolveChatWorkspaceRoot}。
2180
2197
  */
2181
2198
  async resolveChatProject(chatId) {
2182
2199
  const projectId = (await this.store.getChat(chatId))?.projectId;
@@ -2185,7 +2202,21 @@ export class LocalBackendRouter {
2185
2202
  const project = this.toolRouter.projectRegistry?.get(projectId);
2186
2203
  if (!project)
2187
2204
  return null;
2188
- return { name: project.name, folderPath: project.folderPath };
2205
+ return {
2206
+ name: project.name,
2207
+ folderPath: project.folderPath,
2208
+ sourceFolders: project.sourceFolders ?? [],
2209
+ };
2210
+ }
2211
+ /**
2212
+ * 本对话的可写根:绑了项目用项目家目录,否则在
2213
+ * `Documents/<应用名>/conversations/<chatId>/` 建对话工作区。
2214
+ */
2215
+ async resolveChatWorkspaceRoot(chatId) {
2216
+ const project = await this.resolveChatProject(chatId);
2217
+ if (project)
2218
+ return project.folderPath;
2219
+ return ensureChatWorkspace(chatId);
2189
2220
  }
2190
2221
  /**
2191
2222
  * 解析本轮生效的智能体:人设前言、技能勾选、工具策略同源于这一次解析。
@@ -2375,13 +2406,19 @@ export class LocalBackendRouter {
2375
2406
  // 都追加)。硬围栏在 ToolRouter/LocalExecutor,这里让模型事先知道边界,
2376
2407
  // 减少越界尝试。
2377
2408
  const chatProject = await this.resolveChatProject(chatId);
2409
+ const workspaceRoot = chatProject
2410
+ ? chatProject.folderPath
2411
+ : await this.resolveChatWorkspaceRoot(chatId);
2378
2412
  if (chatProject) {
2379
2413
  systemPrompt +=
2380
- `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,根目录:${chatProject.folderPath}\n` +
2381
- `你的文件读写(local_read_file / local_write_file)和命令执行(local_exec_shell)都被限制在该目录内:` +
2382
- `文件路径越界会被拒绝;命令默认在项目根目录下运行,显式指定的 cwd 越界也会被拒绝。` +
2383
- `请一律使用项目目录内的路径(相对路径按项目根目录解析)。` +
2384
- `如确需访问项目外的文件,向用户说明该限制,并请其把文件放入项目目录后再操作。`;
2414
+ `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,家目录:${chatProject.folderPath}\n` +
2415
+ `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该家目录内:` +
2416
+ `写入路径越界会被拒绝;命令默认在家目录下运行,显式指定的 cwd 越界也会被拒绝。` +
2417
+ (chatProject.sourceFolders.length > 0
2418
+ ? `另有源文件夹(只读):${chatProject.sourceFolders.join('、')}。`
2419
+ : '') +
2420
+ `请一律使用项目目录内的路径(相对路径按家目录解析)。` +
2421
+ `如确需访问项目外的文件,向用户说明该限制,或请其在项目里附加为源文件夹。`;
2385
2422
  // W6-5 + W6-7a:项目级规则文件(AGENTS.md / CLAUDE.md)是不可信输入,
2386
2423
  // 仅在用户显式信任该项目后才注入模型上下文——未信任一律不读取、不注入
2387
2424
  // (打开恶意仓库时,一段构造的规则文件不能劫持 agent)。信任状态持久化、
@@ -2399,6 +2436,14 @@ export class LocalBackendRouter {
2399
2436
  }
2400
2437
  }
2401
2438
  }
2439
+ else {
2440
+ systemPrompt +=
2441
+ `\n\n【对话工作区】当前对话没有绑定项目。工作区:${workspaceRoot}\n` +
2442
+ `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该目录内:` +
2443
+ `写入路径越界会被拒绝;命令默认在工作区下运行,显式指定的 cwd 越界也会被拒绝。` +
2444
+ `请一律使用工作区内的路径(相对路径按工作区解析)。` +
2445
+ `用户若要把对话绑到已有项目,可在输入框上方选择项目。`;
2446
+ }
2402
2447
  // @引用的历史对话(payload.referencedChatIds):把被引用对话的最近消息
2403
2448
  // 摘录注入为 user 消息,放在本对话历史之前。
2404
2449
  const referencedChatMessages = await this.buildReferencedChatContextMessages(payload, chatId);
@@ -2853,16 +2898,18 @@ export class LocalBackendRouter {
2853
2898
  // spec (default.harness.yaml) is the single source of truth for
2854
2899
  // maxRounds / maxToolErrors, and an explicit request param overrides it.
2855
2900
  // W4-2: per-exec sandbox for shell tool calls, default-on. Writable
2856
- // roots = the chat's project root (project mode) — unbound chats get an
2857
- // empty list, so writes confine to system scratch dirs only.
2901
+ // roots = 项目家目录,或无项目时 Documents/<应用>/conversations/<chatId>。
2858
2902
  // `execPolicy: 'full'`(输入框「完整权限」)关闭这一层,让 mkdir
2859
- // Downloads 这类项目外写入不再被 Seatbelt 拦成 Operation not permitted。
2903
+ // Downloads 这类工作区外写入不再被 Seatbelt 拦成 Operation not permitted。
2860
2904
  const chatProject = await this.resolveChatProject(chatId);
2905
+ const workspaceRoot = chatProject
2906
+ ? chatProject.folderPath
2907
+ : await this.resolveChatWorkspaceRoot(chatId);
2861
2908
  const execSandbox = buildExecSandbox([
2862
2909
  // 场景包声明的每会话可写根(如文档包在项目根之外落盘产物的
2863
2910
  // 工作区)。
2864
2911
  ...collectPackExecWritableRoots(chatId),
2865
- ...(chatProject ? [chatProject.folderPath] : []),
2912
+ workspaceRoot,
2866
2913
  ], { policy: parseExecPolicy(payload.execPolicy) });
2867
2914
  // W4-1: approval algebra, host mode — every tool call is gated by the
2868
2915
  // Electron approval UI over the reverse channel. The durable scope
@@ -2905,7 +2952,7 @@ export class LocalBackendRouter {
2905
2952
  const turnStartedAtMs = Date.now();
2906
2953
  const turnFileRoots = [
2907
2954
  ...collectPackExecWritableRoots(chatId),
2908
- ...(chatProject ? [chatProject.folderPath] : []),
2955
+ workspaceRoot,
2909
2956
  ];
2910
2957
  try {
2911
2958
  const coreLoopOptions = {
@@ -3195,7 +3242,7 @@ export class LocalBackendRouter {
3195
3242
  roots: turnFileRoots,
3196
3243
  sinceMs: turnStartedAtMs,
3197
3244
  actions: executedActions,
3198
- projectRoot: chatProject?.folderPath ?? null,
3245
+ projectRoot: workspaceRoot,
3199
3246
  });
3200
3247
  }
3201
3248
  catch (turnFilesErr) {
@@ -55,6 +55,11 @@ export interface TaskServiceDeps {
55
55
  name: string;
56
56
  folderPath: string;
57
57
  } | null>;
58
+ /**
59
+ * 无项目对话的可写根(Documents/<应用>/conversations/<chatId>)。
60
+ * 测试可不传,围栏回落为仅 scratch。
61
+ */
62
+ resolveChatWorkspaceRoot?: (chatId: string) => Promise<string>;
58
63
  /** 任务终态 / 推理过程广播(renderer 任务面板与右侧过程栏)。 */
59
64
  broadcast?: (eventName: 'task-updated' | 'task-process', payload: Record<string, unknown>) => void;
60
65
  /** 测试缝:替换真实流驱动(默认 streamCoreLoopTurn)。 */
@@ -272,8 +272,12 @@ export class TaskService {
272
272
  const settings = llmService.getSettings();
273
273
  const project = await this.deps.resolveChatProject(chatId);
274
274
  // 4.6b 整合:worktree 任务的围栏根 = worktree 路径(更紧——任务摸不到
275
- // 主检出);否则沿用项目根。writableRoots 同理收窄。
276
- const fenceRoot = worktreePath ?? project?.folderPath ?? null;
275
+ // 主检出);否则项目家目录,再否则对话工作区。
276
+ const fenceRoot = worktreePath ??
277
+ project?.folderPath ??
278
+ (this.deps.resolveChatWorkspaceRoot
279
+ ? await this.deps.resolveChatWorkspaceRoot(chatId)
280
+ : null);
277
281
  const execSandbox = buildExecSandbox(fenceRoot ? [fenceRoot] : []);
278
282
  const approval = process.env.STEERABLE_APPROVAL === '0'
279
283
  ? undefined
@@ -0,0 +1,14 @@
1
+ export interface NativeFolderDialogResult {
2
+ canceled: boolean;
3
+ filePaths: string[];
4
+ }
5
+ export interface NativeFolderDialogOptions {
6
+ title?: string;
7
+ }
8
+ export interface NativeFolderDialogDeps {
9
+ platform?: NodeJS.Platform;
10
+ run?: (cmd: string, args: readonly string[]) => Promise<{
11
+ stdout: string;
12
+ }>;
13
+ }
14
+ export declare function selectNativeDirectory(options?: NativeFolderDialogOptions, deps?: NativeFolderDialogDeps): Promise<NativeFolderDialogResult>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * BS 宿主没有 Electron `dialog.showOpenDialog`(`ELECTRON_RUN_AS_NODE=1`)。
3
+ * 本模块用各平台系统选择器弹出「选文件夹」,返回与 preload
4
+ * `local.selectDirectory` 相同的 `{ canceled, filePaths }`。
5
+ *
6
+ * 取消、无 DISPLAY、命令不存在都记为 canceled,让 UI 走手动输入路径。
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ const execFileAsync = promisify(execFile);
11
+ function sanitizePrompt(title) {
12
+ const raw = (title ?? '选择文件夹').replace(/[\r\n]+/g, ' ').slice(0, 80);
13
+ return raw.replace(/["\\]/g, '');
14
+ }
15
+ function picked(stdout) {
16
+ const folder = stdout.trim().replace(/[/\\]+$/, '');
17
+ return folder ? { canceled: false, filePaths: [folder] } : { canceled: true, filePaths: [] };
18
+ }
19
+ export async function selectNativeDirectory(options = {}, deps = {}) {
20
+ const platform = deps.platform ?? process.platform;
21
+ const run = deps.run ?? ((cmd, args) => execFileAsync(cmd, [...args]));
22
+ const title = sanitizePrompt(options.title);
23
+ try {
24
+ if (platform === 'darwin') {
25
+ const { stdout } = await run('osascript', [
26
+ '-e',
27
+ `POSIX path of (choose folder with prompt "${title}")`,
28
+ ]);
29
+ return picked(stdout);
30
+ }
31
+ if (platform === 'win32') {
32
+ const escaped = title.replace(/'/g, "''");
33
+ const script = [
34
+ 'Add-Type -AssemblyName System.Windows.Forms',
35
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
36
+ `$d.Description = '${escaped}'`,
37
+ 'if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }',
38
+ ].join('; ');
39
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-Command', script]);
40
+ return picked(stdout);
41
+ }
42
+ try {
43
+ const { stdout } = await run('zenity', [
44
+ '--file-selection',
45
+ '--directory',
46
+ `--title=${title}`,
47
+ ]);
48
+ return picked(stdout);
49
+ }
50
+ catch {
51
+ const { stdout } = await run('kdialog', [
52
+ '--getexistingdirectory',
53
+ process.env.HOME || '/',
54
+ title,
55
+ ]);
56
+ return picked(stdout);
57
+ }
58
+ }
59
+ catch {
60
+ return { canceled: true, filePaths: [] };
61
+ }
62
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 项目默认家目录:`Documents/<应用名>/<项目名>/`。
3
+ *
4
+ * 新建项目不再等于「选一个已有文件夹」——项目是带名字的容器,
5
+ * 家目录由本模块分配并创建。用户另加的源文件夹是附加只读根,
6
+ * 不替代这个家目录。
7
+ */
8
+ /** 把 `~` / `~/…` 展开成绝对路径;其它输入原样(已 trim)。 */
9
+ export declare function expandUserPath(folder: string): string;
10
+ export declare function sanitizeProjectDirName(name: string): string;
11
+ /** `Documents/<应用显示名>`:该应用下所有托管项目的父目录。 */
12
+ export declare function appProjectsRoot(options?: {
13
+ documentsDir?: string;
14
+ appFolderName?: string;
15
+ }): string;
16
+ /**
17
+ * 为项目名分配尚未占用的家目录路径(不落盘)。
18
+ * 已存在同名目录时追加 `-2`、`-3`…
19
+ */
20
+ export declare function allocateProjectHome(projectName: string, options?: {
21
+ documentsDir?: string;
22
+ appFolderName?: string;
23
+ exists?: (folderPath: string) => boolean;
24
+ }): string;
25
+ export declare function ensureProjectHome(folderPath: string): void;
26
+ /** 无项目对话的工作区子目录名(与托管项目家目录并列,避免撞名)。 */
27
+ export declare const CHAT_WORKSPACES_DIR = "conversations";
28
+ /**
29
+ * 无项目对话的工作区路径:`Documents/<应用名>/conversations/<chatId>/`。
30
+ * 不落盘;调用方用 {@link ensureChatWorkspace} 创建。
31
+ */
32
+ export declare function chatWorkspacePath(chatId: string, options?: {
33
+ documentsDir?: string;
34
+ appFolderName?: string;
35
+ }): string;
36
+ /** 确保无项目对话工作区存在,返回绝对路径。 */
37
+ export declare function ensureChatWorkspace(chatId: string, options?: {
38
+ documentsDir?: string;
39
+ appFolderName?: string;
40
+ }): string;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 项目默认家目录:`Documents/<应用名>/<项目名>/`。
3
+ *
4
+ * 新建项目不再等于「选一个已有文件夹」——项目是带名字的容器,
5
+ * 家目录由本模块分配并创建。用户另加的源文件夹是附加只读根,
6
+ * 不替代这个家目录。
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { getBrand } from './brand.js';
12
+ import { getDocumentsDir } from './runtime.js';
13
+ const UNSAFE_DIR_CHARS = /[\\/:*?"<>|]/g;
14
+ /** 把 `~` / `~/…` 展开成绝对路径;其它输入原样(已 trim)。 */
15
+ export function expandUserPath(folder) {
16
+ const trimmed = folder.trim();
17
+ if (trimmed === '~')
18
+ return os.homedir();
19
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
20
+ return path.join(os.homedir(), trimmed.slice(2));
21
+ }
22
+ return trimmed;
23
+ }
24
+ export function sanitizeProjectDirName(name) {
25
+ const trimmed = name
26
+ .trim()
27
+ .replace(UNSAFE_DIR_CHARS, '-')
28
+ .replace(/\s+/g, ' ')
29
+ .replace(/\.+$/g, '');
30
+ return trimmed || '未命名项目';
31
+ }
32
+ /** `Documents/<应用显示名>`:该应用下所有托管项目的父目录。 */
33
+ export function appProjectsRoot(options) {
34
+ const documents = options?.documentsDir ?? getDocumentsDir();
35
+ const appName = options?.appFolderName ?? getBrand().displayName;
36
+ return path.join(documents, sanitizeProjectDirName(appName));
37
+ }
38
+ /**
39
+ * 为项目名分配尚未占用的家目录路径(不落盘)。
40
+ * 已存在同名目录时追加 `-2`、`-3`…
41
+ */
42
+ export function allocateProjectHome(projectName, options) {
43
+ const root = appProjectsRoot(options);
44
+ const base = sanitizeProjectDirName(projectName);
45
+ const exists = options?.exists ?? ((folderPath) => fs.existsSync(folderPath));
46
+ let candidate = path.join(root, base);
47
+ if (!exists(candidate))
48
+ return candidate;
49
+ for (let i = 2; i < 1000; i += 1) {
50
+ candidate = path.join(root, `${base}-${i}`);
51
+ if (!exists(candidate))
52
+ return candidate;
53
+ }
54
+ throw new Error('无法分配项目目录:重名过多');
55
+ }
56
+ export function ensureProjectHome(folderPath) {
57
+ fs.mkdirSync(folderPath, { recursive: true });
58
+ }
59
+ /** 无项目对话的工作区子目录名(与托管项目家目录并列,避免撞名)。 */
60
+ export const CHAT_WORKSPACES_DIR = 'conversations';
61
+ /**
62
+ * 无项目对话的工作区路径:`Documents/<应用名>/conversations/<chatId>/`。
63
+ * 不落盘;调用方用 {@link ensureChatWorkspace} 创建。
64
+ */
65
+ export function chatWorkspacePath(chatId, options) {
66
+ const id = sanitizeProjectDirName(chatId);
67
+ return path.join(appProjectsRoot(options), CHAT_WORKSPACES_DIR, id);
68
+ }
69
+ /** 确保无项目对话工作区存在,返回绝对路径。 */
70
+ export function ensureChatWorkspace(chatId, options) {
71
+ const folderPath = chatWorkspacePath(chatId, options);
72
+ ensureProjectHome(folderPath);
73
+ return folderPath;
74
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * 项目注册表(项目模式)。
3
3
  *
4
- * 项目 = 名字 + 绑定的本地文件夹。chat 通过 `chat_sessions.project_id` 绑定
5
- * 项目;绑定后该对话的文件读写与命令执行被硬限制在项目文件夹内(见
6
- * tool-router.ts 的 ToolExecContext.projectRoot 与 local-executor.ts 的
7
- * 路径围栏)。
4
+ * 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
5
+ * `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
6
+ * `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
7
+ * 在家目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);源文件夹
8
+ * 只放宽 local_read_file。
8
9
  *
9
10
  * 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
10
11
  * 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
@@ -14,8 +15,13 @@ export interface ProjectRecord {
14
15
  id: string;
15
16
  /** 用户可见名称(侧边栏分组标题)。 */
16
17
  name: string;
17
- /** 绑定的项目文件夹(绝对路径)。 */
18
+ /** 托管家目录(绝对路径)。默认 `Documents/<应用名>/<项目名>/`。 */
18
19
  folderPath: string;
20
+ /**
21
+ * 附加源文件夹(已有代码目录)。只放宽读取,不替代家目录,也不放宽写入。
22
+ * 旧记录没有此字段,读取时按空列表处理。
23
+ */
24
+ sourceFolders?: string[];
19
25
  /**
20
26
  * W6-5 项目信任门控:项目目录里的 `AGENTS.md` / `CLAUDE.md` 等规则文件
21
27
  * 是「项目作者写给 agent 的指令」——打开一个恶意仓库时,一段精心构造的
@@ -30,6 +36,7 @@ export interface ProjectRecord {
30
36
  export interface CreateProjectInput {
31
37
  name: string;
32
38
  folderPath: string;
39
+ sourceFolders?: string[];
33
40
  }
34
41
  /** 最小 KV 存储接口,避免本模块直接依赖 electron-store。 */
35
42
  export interface ProjectKvStore {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * 项目注册表(项目模式)。
3
3
  *
4
- * 项目 = 名字 + 绑定的本地文件夹。chat 通过 `chat_sessions.project_id` 绑定
5
- * 项目;绑定后该对话的文件读写与命令执行被硬限制在项目文件夹内(见
6
- * tool-router.ts 的 ToolExecContext.projectRoot 与 local-executor.ts 的
7
- * 路径围栏)。
4
+ * 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
5
+ * `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
6
+ * `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
7
+ * 在家目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);源文件夹
8
+ * 只放宽 local_read_file。
8
9
  *
9
10
  * 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
10
11
  * 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
@@ -34,11 +35,13 @@ export class ProjectRegistry {
34
35
  throw new Error('项目文件夹不能为空');
35
36
  if (this.get(name))
36
37
  throw new Error(`已存在同名项目「${name}」`);
38
+ const sourceFolders = normalizeSourceFolders(input.sourceFolders, folderPath);
37
39
  const now = new Date().toISOString();
38
40
  const entry = {
39
41
  id: randomUUID(),
40
42
  name,
41
43
  folderPath,
44
+ ...(sourceFolders.length > 0 ? { sourceFolders } : {}),
42
45
  createdAt: now,
43
46
  updatedAt: now,
44
47
  };
@@ -62,10 +65,14 @@ export class ProjectRegistry {
62
65
  : current.folderPath;
63
66
  if (!nextFolder)
64
67
  throw new Error('项目文件夹不能为空');
68
+ const sourceFolders = updates.sourceFolders !== undefined
69
+ ? normalizeSourceFolders(updates.sourceFolders, nextFolder)
70
+ : normalizeSourceFolders(current.sourceFolders, nextFolder);
65
71
  const next = {
66
72
  ...current,
67
73
  name: nextName,
68
74
  folderPath: nextFolder,
75
+ sourceFolders: sourceFolders.length > 0 ? sourceFolders : undefined,
69
76
  updatedAt: new Date().toISOString(),
70
77
  };
71
78
  projects[idx] = next;
@@ -104,3 +111,16 @@ export class ProjectRegistry {
104
111
  return next;
105
112
  }
106
113
  }
114
+ function normalizeSourceFolders(folders, homePath) {
115
+ const home = homePath.trim();
116
+ const seen = new Set();
117
+ const out = [];
118
+ for (const raw of folders ?? []) {
119
+ const folder = raw.trim();
120
+ if (!folder || folder === home || seen.has(folder))
121
+ continue;
122
+ seen.add(folder);
123
+ out.push(folder);
124
+ }
125
+ return out;
126
+ }
package/dist/runtime.d.ts CHANGED
@@ -2,6 +2,12 @@
2
2
  export declare function isElectronRuntime(): boolean;
3
3
  /** 应用数据目录(SQLite、JSON store、用户技能目录的根)。 */
4
4
  export declare function getUserDataDir(): string;
5
+ /**
6
+ * 用户文档目录。项目默认家目录建在这里的「应用名」文件夹下
7
+ * (见 project-home.ts)。测试可用 STEERABLE_DOCUMENTS_DIR 改锚点,
8
+ * 避免写进真实 Documents。
9
+ */
10
+ export declare function getDocumentsDir(): string;
5
11
  /**
6
12
  * 注入应用根目录(产品组装根调用)。重复注入不同值抛错(组装期笔误,
7
13
  * fail fast——与 setProductBrand 同语义)。
package/dist/runtime.js CHANGED
@@ -54,6 +54,25 @@ export function getUserDataDir() {
54
54
  const dirName = getProductConfig().dataDirName ?? '.agent-shell';
55
55
  return path.join(os.homedir(), dirName);
56
56
  }
57
+ /**
58
+ * 用户文档目录。项目默认家目录建在这里的「应用名」文件夹下
59
+ * (见 project-home.ts)。测试可用 STEERABLE_DOCUMENTS_DIR 改锚点,
60
+ * 避免写进真实 Documents。
61
+ */
62
+ export function getDocumentsDir() {
63
+ if (process.env.STEERABLE_DOCUMENTS_DIR)
64
+ return process.env.STEERABLE_DOCUMENTS_DIR;
65
+ const app = tryElectronApp();
66
+ if (app) {
67
+ try {
68
+ return app.getPath('documents');
69
+ }
70
+ catch {
71
+ // 个别环境 getPath('documents') 不可用,回落到 ~/Documents。
72
+ }
73
+ }
74
+ return path.join(os.homedir(), 'Documents');
75
+ }
57
76
  /**
58
77
  * 应用根目录(消费产品的仓库/打包根:含 products/manifest.json、assets、
59
78
  * scripts 的那层)。3.2 起 shell 是被消费的框架包,本模块自己的位置
@@ -34,6 +34,7 @@ import { getSidecarSupervisor } from '../llm/index.js';
34
34
  import { matchPackHttpRoute } from '../host/http-routes.js';
35
35
  import { getBrand } from '../brand.js';
36
36
  import { saveAttachmentFiles } from '../attachments.js';
37
+ import { selectNativeDirectory } from '../native-folder-dialog.js';
37
38
  import { getAuthProvider } from '../auth/index.js';
38
39
  /** 与 router.handleStream 内部的路由正则保持一致——只有这些路径是流式。 */
39
40
  const STREAM_PATH_PATTERNS = [
@@ -250,6 +251,11 @@ export function createBsServer(deps) {
250
251
  return;
251
252
  }
252
253
  // ─── local(对齐 local:* IPC) ───
254
+ if (method === 'POST' && pathname === '/host/local/select-directory') {
255
+ const title = typeof body.title === 'string' ? body.title : undefined;
256
+ sendJson(res, 200, await selectNativeDirectory({ title }));
257
+ return;
258
+ }
253
259
  if (method === 'POST' && pathname === '/host/local/exec-shell') {
254
260
  const request = body;
255
261
  const viaTerminal = await maybeExecInTerminal(request);
@@ -5,13 +5,13 @@ import type { SidecarReverseHandler } from './types.js';
5
5
  export interface HostSidecarDeps {
6
6
  store: ScopedStore;
7
7
  toolRouter: ToolRouter;
8
- /** W4-2 项目模式围栏:chatId → 项目根(无项目对话返回 null)。 */
8
+ /** W4-2 围栏:chatId → 项目家目录或无项目对话工作区。 */
9
9
  resolveProjectRoot: (chatId: string) => Promise<string | null>;
10
10
  /**
11
11
  * 项目模式之外额外放行的只读根(会话附件目录等)。文件写入仍只受
12
12
  * projectRoot 围栏约束;这些根只放宽 local_read_file 的读取范围。
13
13
  */
14
- resolveAdditionalReadRoots?: (chatId: string) => string[];
14
+ resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
15
15
  /** W4-1 审批反向通道处理器(宿主审批弹窗的应答入口)。 */
16
16
  approvalHandler: ReturnType<typeof createApprovalBridge>['handler'];
17
17
  /**
@@ -16,7 +16,7 @@ export interface ReverseToolDeps {
16
16
  }) => void;
17
17
  /**
18
18
  * 项目模式:按 chatId 解析项目根(绝对路径),无绑定/已删除返回 null。
19
- * main.ts 注入 LocalBackendRouter.resolveChatProject 的适配;未注入时
19
+ * main.ts 注入对话可写根(项目家目录或无项目对话工作区);未注入时
20
20
  * 项目围栏不生效(CLI/test 场景)。
21
21
  */
22
22
  resolveProjectRoot?: (chatId: string) => Promise<string | null>;
@@ -24,6 +24,6 @@ export interface ReverseToolDeps {
24
24
  * 额外放行的只读根(会话附件目录等)。写入仍只受 projectRoot 围栏约束,
25
25
  * 这里只放宽 local_read_file 的读取范围。
26
26
  */
27
- resolveAdditionalReadRoots?: (chatId: string) => string[];
27
+ resolveAdditionalReadRoots?: (chatId: string) => string[] | Promise<string[]>;
28
28
  }
29
29
  export declare function createToolInvokeHandler(deps: ReverseToolDeps): SidecarReverseHandler;
@@ -56,7 +56,7 @@ export function createToolInvokeHandler(deps) {
56
56
  ? (await deps.resolveProjectRoot?.(p.context.chatId) ?? null)
57
57
  : null;
58
58
  const additionalReadRoots = p.context?.chatId
59
- ? (deps.resolveAdditionalReadRoots?.(p.context.chatId) ?? [])
59
+ ? await Promise.resolve(deps.resolveAdditionalReadRoots?.(p.context.chatId) ?? [])
60
60
  : [];
61
61
  if (name === 'local_exec_shell') {
62
62
  const classification = classifyShellCommand(String(toolArgs.command ?? ''));
@@ -12,7 +12,7 @@ export interface ChatSessionRecord {
12
12
  title: string;
13
13
  userId: string;
14
14
  agentId: string | null;
15
- /** 项目模式:绑定的项目 id(ProjectRegistry)。null = 无项目对话(不沙箱)。 */
15
+ /** 项目模式:绑定的项目 id(ProjectRegistry)。null = 无项目对话(用对话工作区)。 */
16
16
  projectId: string | null;
17
17
  createdAt: string;
18
18
  updatedAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.30",
3
+ "version": "0.6.32",
4
4
  "description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -49,9 +49,9 @@
49
49
  "electron-store": "^8.1.0",
50
50
  "he": "^1.2.0",
51
51
  "node-pty": "^1.1.0",
52
- "@steerable/agent-harness": "0.6.30",
53
- "@steerable/pack-sdk": "0.6.30",
54
- "@steerable/agent-protocol": "0.6.30"
52
+ "@steerable/agent-protocol": "0.6.32",
53
+ "@steerable/pack-sdk": "0.6.32",
54
+ "@steerable/agent-harness": "0.6.32"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/better-sqlite3": "^7.6.13",