@steerable/agent-shell 0.6.74 → 0.7.1

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 (43) hide show
  1. package/dist/fence-path.d.ts +16 -0
  2. package/dist/fence-path.js +54 -0
  3. package/dist/host/runtime.d.ts +0 -13
  4. package/dist/host/runtime.js +26 -0
  5. package/dist/host-tools.d.ts +1 -0
  6. package/dist/host-tools.js +1 -0
  7. package/dist/i18n.d.ts +2 -0
  8. package/dist/i18n.js +6 -1
  9. package/dist/json-store.d.ts +1 -0
  10. package/dist/json-store.js +50 -4
  11. package/dist/local-backend/coreloop-stream.d.ts +29 -5
  12. package/dist/local-backend/coreloop-stream.js +122 -1
  13. package/dist/local-backend/fork-copy.d.ts +9 -0
  14. package/dist/local-backend/fork-copy.js +75 -0
  15. package/dist/local-backend/history-edit.d.ts +12 -2
  16. package/dist/local-backend/history-edit.js +98 -4
  17. package/dist/local-backend/router.d.ts +1 -0
  18. package/dist/local-backend/router.js +189 -53
  19. package/dist/local-backend/task-process.js +5 -0
  20. package/dist/local-backend/task-service.js +6 -7
  21. package/dist/local-backend/turn-files.js +3 -1
  22. package/dist/local-executor.d.ts +1 -3
  23. package/dist/local-executor.js +10 -13
  24. package/dist/product-config.d.ts +7 -1
  25. package/dist/project-registry.d.ts +33 -0
  26. package/dist/project-registry.js +206 -78
  27. package/dist/project-rules.d.ts +2 -0
  28. package/dist/project-rules.js +17 -13
  29. package/dist/scenario/pack.d.ts +1 -1
  30. package/dist/sidecar/approval-label.d.ts +55 -0
  31. package/dist/sidecar/approval-label.js +102 -0
  32. package/dist/sidecar/reverse-approval.d.ts +26 -0
  33. package/dist/sidecar/reverse-approval.js +15 -4
  34. package/dist/sidecar/types.d.ts +14 -0
  35. package/dist/storage/index.d.ts +8 -1
  36. package/dist/storage/index.js +7 -2
  37. package/dist/storage/process-locks.d.ts +6 -0
  38. package/dist/storage/process-locks.js +6 -0
  39. package/dist/storage/scoped-store.d.ts +1 -1
  40. package/dist/storage/sqlite-driver.js +6 -5
  41. package/dist/tool-router.d.ts +22 -9
  42. package/dist/tool-router.js +43 -29
  43. package/package.json +4 -4
@@ -6,6 +6,7 @@ import { createHash } from 'crypto';
6
6
  import { spawn, execSync } from 'child_process';
7
7
  import { log } from './log.js';
8
8
  import { constants as fsConstants } from 'fs';
9
+ import { expandFencePath, resolveFencePath } from './fence-path.js';
9
10
  import { adaptCommandForPowerShell } from './shell-adapt.js';
10
11
  import { evaluateExecPolicy, planSshArgv, } from './shell-exec.js';
11
12
  import { nodeShellBackend } from './shell-process.js';
@@ -181,15 +182,11 @@ export function isGuiLaunchCommand(command) {
181
182
  */
182
183
  export function isPathWithinRoot(resolvedPath, resolvedRoot) {
183
184
  const rel = path.relative(resolvedRoot, resolvedPath);
184
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
185
- }
186
- /** `~` 展开后 resolve。围栏比较前路径与根都要过这一步。 */
187
- export function expandFencePath(inputPath) {
188
- const expanded = inputPath.startsWith('~')
189
- ? path.join(os.homedir(), inputPath.slice(1))
190
- : inputPath;
191
- return path.resolve(expanded);
185
+ return rel === '' || (rel !== '..' &&
186
+ !rel.startsWith(`..${path.sep}`) &&
187
+ !path.isAbsolute(rel));
192
188
  }
189
+ export { canonicalFencePath, expandFencePath, resolveFencePath } from './fence-path.js';
193
190
  /**
194
191
  * 可写根:项目家目录(或对话工作区)加上额外可写根(源文件夹)。
195
192
  * 每个根都包含它自己和全部子目录。空列表表示不设围栏。
@@ -639,7 +636,7 @@ export class LocalExecutor {
639
636
  }
640
637
  async readLocalFile(request, projectRoot, additionalReadRoots) {
641
638
  try {
642
- const filePath = this.resolvePath(request.path);
639
+ const filePath = this.resolvePath(request.path, projectRoot);
643
640
  if (projectRoot) {
644
641
  const root = this.resolvePath(projectRoot);
645
642
  const inAdditionalRoot = (additionalReadRoots ?? []).some((r) => isPathWithinRoot(filePath, this.resolvePath(r)));
@@ -674,7 +671,7 @@ export class LocalExecutor {
674
671
  }
675
672
  }
676
673
  async writeLocalFile(request, projectRoot, additionalWriteRoots) {
677
- const filePath = this.resolvePath(request.path);
674
+ const filePath = this.resolvePath(request.path, projectRoot);
678
675
  const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
679
676
  if (violation)
680
677
  return { success: false, error: violation };
@@ -735,7 +732,7 @@ export class LocalExecutor {
735
732
  * 原子落盘。返回新版本令牌与统一 diff。
736
733
  */
737
734
  async editLocalFile(request, projectRoot, additionalWriteRoots) {
738
- const filePath = this.resolvePath(request.path);
735
+ const filePath = this.resolvePath(request.path, projectRoot);
739
736
  const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
740
737
  if (violation)
741
738
  return { success: false, error: violation };
@@ -1096,8 +1093,8 @@ export class LocalExecutor {
1096
1093
  return new RegExp(d.pattern, flags);
1097
1094
  });
1098
1095
  }
1099
- resolvePath(inputPath) {
1100
- return expandFencePath(inputPath);
1096
+ resolvePath(inputPath, baseDir) {
1097
+ return resolveFencePath(inputPath, baseDir);
1101
1098
  }
1102
1099
  resolveCwd(cwd, shellType) {
1103
1100
  const resolved = this.resolvePath(cwd || os.homedir());
@@ -43,7 +43,8 @@ export interface ProductConfig {
43
43
  }>;
44
44
  /**
45
45
  * 命令安全询问:host = 弹宿主审批;off = 本轮不挂审批。缺省 host。
46
- * STEERABLE_APPROVAL=0 仍是调试逃生口。
46
+ * STEERABLE_APPROVAL=0 关掉本轮审批层,破坏性工具由工具路由拒绝。
47
+ * 需要放行时用命令行 `--approve allow-all`,不要依赖这个环境变量。
47
48
  */
48
49
  approval?: 'host' | 'off';
49
50
  /**
@@ -126,6 +127,11 @@ export interface ProductConfig {
126
127
  locales: string[];
127
128
  defaultLocale: string;
128
129
  };
130
+ /**
131
+ * 无项目会话分叉时,工作区复制的字节上限。缺省 500 MiB。
132
+ * 超过则接口返回 413,不创建新会话。
133
+ */
134
+ forkWorkspaceMaxBytes?: number;
129
135
  }
130
136
  /** shell 内置智能体:只有产品显式 `true` 才开。 */
131
137
  export declare function isShellBuiltinAgentEnabled(id: 'local-assistant' | 'all-round-assistant', config?: ProductConfig): boolean;
@@ -30,6 +30,11 @@ export interface ProjectRecord {
30
30
  * 旧记录没有此字段,读取时按 `false` 处理(见 {@link ProjectRegistry.isTrusted})。
31
31
  */
32
32
  trusted?: boolean;
33
+ /**
34
+ * 命令行创建的项目。桌面创建的记录不写该字段。
35
+ * 旧记录没有此字段,读取时忽略。
36
+ */
37
+ origin?: 'cli';
33
38
  /**
34
39
  * 侧边栏显示顺序,越小越靠前。旧记录没有此字段时按 createdAt 升序。
35
40
  * 用户拖拽排序后每个项目都会写上序号;之后新建的项目没有序号,排在已编号项目之后。
@@ -42,11 +47,17 @@ export interface CreateProjectInput {
42
47
  name: string;
43
48
  folderPath: string;
44
49
  sourceFolders?: string[];
50
+ origin?: 'cli';
45
51
  }
46
52
  /** 最小 KV 存储接口,避免本模块直接依赖具体存储实现。 */
47
53
  export interface ProjectKvStore {
48
54
  get(key: 'projects'): ProjectRecord[] | undefined;
49
55
  set(key: 'projects', value: ProjectRecord[]): void;
56
+ /**
57
+ * Read-modify-write under the projects file lock. Absent in memory stores;
58
+ * the registry then updates in this process only.
59
+ */
60
+ mutate?(key: 'projects', update: (current: ProjectRecord[]) => ProjectRecord[]): ProjectRecord[];
50
61
  }
51
62
  export declare class ProjectRegistry {
52
63
  private readonly store;
@@ -54,6 +65,27 @@ export declare class ProjectRegistry {
54
65
  list(): ProjectRecord[];
55
66
  get(idOrName: string): ProjectRecord | null;
56
67
  create(input: CreateProjectInput): ProjectRecord;
68
+ /**
69
+ * 同名时在名称后加 `-2`、`-3`,与家目录重名的处理一致。
70
+ */
71
+ createUnique(input: CreateProjectInput): ProjectRecord;
72
+ /**
73
+ * 按真实路径查找。目录已被删除时 `realpath` 失败,改用记录里的原始路径。
74
+ * Windows 上只忽略大小写。
75
+ */
76
+ findByFolder(realDir: string, realpath?: (target: string) => string, platform?: NodeJS.Platform): ProjectRecord | null;
77
+ /**
78
+ * 在同一次读改写里查找或创建。`D` 等于已有项目,或落在某个项目目录之内时复用
79
+ * (取路径最长的那个);否则以目录名创建,重名加后缀。
80
+ */
81
+ resolveByFolder(folderPath: string, options?: {
82
+ origin?: 'cli';
83
+ realpath?: (target: string) => string;
84
+ platform?: NodeJS.Platform;
85
+ }): {
86
+ project: ProjectRecord;
87
+ created: boolean;
88
+ };
57
89
  update(id: string, updates: Partial<CreateProjectInput>): ProjectRecord;
58
90
  delete(id: string): boolean;
59
91
  /**
@@ -69,4 +101,5 @@ export declare class ProjectRegistry {
69
101
  isTrusted(id: string): boolean;
70
102
  /** W6-5: grant or revoke trust. Returns the updated record. */
71
103
  setTrusted(id: string, trusted: boolean): ProjectRecord;
104
+ private rewrite;
72
105
  }
@@ -12,6 +12,8 @@
12
12
  * mcp-server-registry.ts 同一模式)。
13
13
  */
14
14
  import { randomUUID } from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
15
17
  const STORE_KEY = 'projects';
16
18
  export class ProjectRegistry {
17
19
  store;
@@ -34,85 +36,141 @@ export class ProjectRegistry {
34
36
  throw new Error('项目名称不能为空');
35
37
  if (!folderPath)
36
38
  throw new Error('项目文件夹不能为空');
37
- if (this.get(name))
38
- throw new Error(`已存在同名项目「${name}」`);
39
- const sourceFolders = normalizeSourceFolders(input.sourceFolders, folderPath);
40
- const now = new Date().toISOString();
41
- const entry = {
42
- id: randomUUID(),
43
- name,
44
- folderPath,
45
- ...(sourceFolders.length > 0 ? { sourceFolders } : {}),
46
- createdAt: now,
47
- updatedAt: now,
48
- };
49
- this.store.set(STORE_KEY, [...this.list(), entry]);
50
- return entry;
39
+ let created = null;
40
+ this.rewrite((projects) => {
41
+ if (projects.some((project) => project.name.toLowerCase() === name.toLowerCase())) {
42
+ throw new Error(`已存在同名项目「${name}」`);
43
+ }
44
+ created = buildProject(name, folderPath, input);
45
+ return [...projects, created];
46
+ });
47
+ return created;
51
48
  }
52
- update(id, updates) {
53
- const projects = this.list();
54
- const idx = projects.findIndex((p) => p.id === id);
55
- if (idx === -1)
56
- throw new Error('项目不存在');
57
- const current = projects[idx];
58
- const nextName = updates.name !== undefined ? updates.name.trim() : current.name;
59
- if (!nextName)
49
+ /**
50
+ * 同名时在名称后加 `-2`、`-3`,与家目录重名的处理一致。
51
+ */
52
+ createUnique(input) {
53
+ const base = input.name.trim();
54
+ const folderPath = input.folderPath.trim();
55
+ if (!base)
60
56
  throw new Error('项目名称不能为空');
61
- const nameClash = projects.some((p) => p.id !== id && p.name.toLowerCase() === nextName.toLowerCase());
62
- if (nameClash)
63
- throw new Error(`已存在同名项目「${nextName}」`);
64
- const nextFolder = updates.folderPath !== undefined
65
- ? updates.folderPath.trim()
66
- : current.folderPath;
67
- if (!nextFolder)
57
+ if (!folderPath)
68
58
  throw new Error('项目文件夹不能为空');
69
- const sourceFolders = updates.sourceFolders !== undefined
70
- ? normalizeSourceFolders(updates.sourceFolders, nextFolder)
71
- : normalizeSourceFolders(current.sourceFolders, nextFolder);
72
- const next = {
73
- ...current,
74
- name: nextName,
75
- folderPath: nextFolder,
76
- sourceFolders: sourceFolders.length > 0 ? sourceFolders : undefined,
77
- updatedAt: new Date().toISOString(),
78
- };
79
- projects[idx] = next;
80
- this.store.set(STORE_KEY, projects);
81
- return next;
59
+ let created = null;
60
+ this.rewrite((projects) => {
61
+ const name = uniqueProjectName(projects, base);
62
+ created = buildProject(name, folderPath, input);
63
+ return [...projects, created];
64
+ });
65
+ return created;
66
+ }
67
+ /**
68
+ * 按真实路径查找。目录已被删除时 `realpath` 失败,改用记录里的原始路径。
69
+ * Windows 上只忽略大小写。
70
+ */
71
+ findByFolder(realDir, realpath = fs.realpathSync, platform = process.platform) {
72
+ const target = canonicalFolder(realDir, realpath, platform);
73
+ if (!target)
74
+ return null;
75
+ return locateProject(this.list(), target, realpath, platform);
76
+ }
77
+ /**
78
+ * 在同一次读改写里查找或创建。`D` 等于已有项目,或落在某个项目目录之内时复用
79
+ * (取路径最长的那个);否则以目录名创建,重名加后缀。
80
+ */
81
+ resolveByFolder(folderPath, options) {
82
+ const realpath = options?.realpath ?? fs.realpathSync;
83
+ const platform = options?.platform ?? process.platform;
84
+ const target = canonicalFolder(folderPath, realpath, platform);
85
+ if (!target)
86
+ throw new Error('项目文件夹不能为空');
87
+ let created = false;
88
+ let chosen = null;
89
+ this.rewrite((projects) => {
90
+ const existing = locateProject(projects, target, realpath, platform);
91
+ if (existing) {
92
+ chosen = existing;
93
+ return projects;
94
+ }
95
+ const name = uniqueProjectName(projects, pathFor(platform).basename(target) || 'project');
96
+ chosen = buildProject(name, target, { name, folderPath: target, origin: options?.origin });
97
+ created = true;
98
+ return [...projects, chosen];
99
+ });
100
+ return { project: chosen, created };
101
+ }
102
+ update(id, updates) {
103
+ let updated = null;
104
+ this.rewrite((projects) => {
105
+ const idx = projects.findIndex((p) => p.id === id);
106
+ if (idx === -1)
107
+ throw new Error('项目不存在');
108
+ const current = projects[idx];
109
+ const nextName = updates.name !== undefined ? updates.name.trim() : current.name;
110
+ if (!nextName)
111
+ throw new Error('项目名称不能为空');
112
+ const nameClash = projects.some((p) => p.id !== id && p.name.toLowerCase() === nextName.toLowerCase());
113
+ if (nameClash)
114
+ throw new Error(`已存在同名项目「${nextName}」`);
115
+ const nextFolder = updates.folderPath !== undefined
116
+ ? updates.folderPath.trim()
117
+ : current.folderPath;
118
+ if (!nextFolder)
119
+ throw new Error('项目文件夹不能为空');
120
+ const sourceFolders = updates.sourceFolders !== undefined
121
+ ? normalizeSourceFolders(updates.sourceFolders, nextFolder)
122
+ : normalizeSourceFolders(current.sourceFolders, nextFolder);
123
+ const next = {
124
+ ...current,
125
+ name: nextName,
126
+ folderPath: nextFolder,
127
+ sourceFolders: sourceFolders.length > 0 ? sourceFolders : undefined,
128
+ updatedAt: new Date().toISOString(),
129
+ };
130
+ updated = next;
131
+ const copy = projects.slice();
132
+ copy[idx] = next;
133
+ return copy;
134
+ });
135
+ return updated;
82
136
  }
83
137
  delete(id) {
84
- const projects = this.list();
85
- const next = projects.filter((p) => p.id !== id);
86
- if (next.length === projects.length)
87
- return false;
88
- this.store.set(STORE_KEY, next);
89
- return true;
138
+ let removed = false;
139
+ this.rewrite((projects) => {
140
+ const next = projects.filter((p) => p.id !== id);
141
+ removed = next.length !== projects.length;
142
+ return removed ? next : projects;
143
+ });
144
+ return removed;
90
145
  }
91
146
  /**
92
147
  * 按 orderedIds 重排侧边栏。未出现的 id 忽略;名单里没提到的项目
93
148
  * 保持相对顺序接在后面。写入连续的 sortOrder,list() 随后按该序号返回。
94
149
  */
95
150
  reorder(orderedIds) {
96
- const current = this.list();
97
- const byId = new Map(current.map((project) => [project.id, project]));
98
- const next = [];
99
- const seen = new Set();
100
- for (const id of orderedIds) {
101
- const project = byId.get(id);
102
- if (!project || seen.has(id))
103
- continue;
104
- seen.add(id);
105
- next.push(project);
106
- }
107
- for (const project of current) {
108
- if (!seen.has(project.id))
151
+ let stamped = [];
152
+ this.rewrite((projects) => {
153
+ const current = [...projects].sort(compareProjects);
154
+ const byId = new Map(current.map((project) => [project.id, project]));
155
+ const next = [];
156
+ const seen = new Set();
157
+ for (const id of orderedIds) {
158
+ const project = byId.get(id);
159
+ if (!project || seen.has(id))
160
+ continue;
161
+ seen.add(id);
109
162
  next.push(project);
110
- }
111
- const stamped = next.map((project, index) => ({
112
- ...project,
113
- sortOrder: index,
114
- }));
115
- this.store.set(STORE_KEY, stamped);
163
+ }
164
+ for (const project of current) {
165
+ if (!seen.has(project.id))
166
+ next.push(project);
167
+ }
168
+ stamped = next.map((project, index) => ({
169
+ ...project,
170
+ sortOrder: index,
171
+ }));
172
+ return stamped;
173
+ });
116
174
  return stamped;
117
175
  }
118
176
  /**
@@ -125,17 +183,28 @@ export class ProjectRegistry {
125
183
  }
126
184
  /** W6-5: grant or revoke trust. Returns the updated record. */
127
185
  setTrusted(id, trusted) {
128
- const projects = this.list();
129
- const idx = projects.findIndex((p) => p.id === id);
130
- if (idx === -1)
131
- throw new Error('项目不存在');
132
- const next = {
133
- ...projects[idx],
134
- trusted,
135
- updatedAt: new Date().toISOString(),
136
- };
137
- projects[idx] = next;
138
- this.store.set(STORE_KEY, projects);
186
+ let updated = null;
187
+ this.rewrite((projects) => {
188
+ const idx = projects.findIndex((p) => p.id === id);
189
+ if (idx === -1)
190
+ throw new Error('项目不存在');
191
+ const next = {
192
+ ...projects[idx],
193
+ trusted,
194
+ updatedAt: new Date().toISOString(),
195
+ };
196
+ updated = next;
197
+ const copy = projects.slice();
198
+ copy[idx] = next;
199
+ return copy;
200
+ });
201
+ return updated;
202
+ }
203
+ rewrite(update) {
204
+ if (this.store.mutate)
205
+ return this.store.mutate(STORE_KEY, update);
206
+ const next = update([...(this.store.get(STORE_KEY) ?? [])]);
207
+ this.store.set(STORE_KEY, next);
139
208
  return next;
140
209
  }
141
210
  }
@@ -167,3 +236,62 @@ function normalizeSourceFolders(folders, homePath) {
167
236
  }
168
237
  return out;
169
238
  }
239
+ function buildProject(name, folderPath, input) {
240
+ const sourceFolders = normalizeSourceFolders(input.sourceFolders, folderPath);
241
+ const now = new Date().toISOString();
242
+ return {
243
+ id: randomUUID(),
244
+ name,
245
+ folderPath,
246
+ ...(sourceFolders.length > 0 ? { sourceFolders } : {}),
247
+ ...(input.origin === 'cli' ? { origin: 'cli' } : {}),
248
+ createdAt: now,
249
+ updatedAt: now,
250
+ };
251
+ }
252
+ function uniqueProjectName(projects, base) {
253
+ const taken = new Set(projects.map((project) => project.name.toLowerCase()));
254
+ if (!taken.has(base.toLowerCase()))
255
+ return base;
256
+ for (let index = 2; index < 1000; index += 1) {
257
+ const candidate = `${base}-${index}`;
258
+ if (!taken.has(candidate.toLowerCase()))
259
+ return candidate;
260
+ }
261
+ throw new Error('无法分配项目名称:重名过多');
262
+ }
263
+ function pathFor(platform) {
264
+ return platform === 'win32' ? path.win32 : path.posix;
265
+ }
266
+ function canonicalFolder(folderPath, realpath, platform) {
267
+ const absolute = pathFor(platform).resolve(folderPath.trim());
268
+ if (!absolute)
269
+ return '';
270
+ try {
271
+ return realpath(absolute);
272
+ }
273
+ catch {
274
+ return absolute;
275
+ }
276
+ }
277
+ function locateProject(projects, target, realpath, platform) {
278
+ const platformPath = pathFor(platform);
279
+ const keyed = projects.map((project) => ({
280
+ project,
281
+ key: canonicalFolder(project.folderPath, realpath, platform),
282
+ }));
283
+ const exact = keyed.find((item) => platformPath.relative(item.key, target) === '');
284
+ if (exact)
285
+ return exact.project;
286
+ const parents = keyed
287
+ .filter((item) => isInsideFolder(item.key, target, platformPath))
288
+ .sort((left, right) => right.key.length - left.key.length);
289
+ return parents[0]?.project ?? null;
290
+ }
291
+ function isInsideFolder(parent, child, platformPath) {
292
+ const relative = platformPath.relative(parent, child);
293
+ return relative !== ''
294
+ && relative !== '..'
295
+ && !relative.startsWith(`..${platformPath.sep}`)
296
+ && !platformPath.isAbsolute(relative);
297
+ }
@@ -12,4 +12,6 @@ export interface ProjectRules {
12
12
  * 向上遍历在「用户 home 目录」处封顶(若项目不在 home 下则按 MAX_LEVELS
13
13
  * 封顶)——不扫 `/`、`/etc` 这类与项目无关的祖先目录。
14
14
  */
15
+ /** Rule-file paths only. Does not read file bodies. */
16
+ export declare function listProjectRuleFiles(folderPath: string): string[];
15
17
  export declare function loadProjectRuleFiles(folderPath: string): ProjectRules;
@@ -7,8 +7,9 @@
7
7
  * WorldState 的一节),我们此前完全没有。
8
8
  *
9
9
  * 安全前提(W6-5):这些文件是「项目作者写给 agent 的指令」,属于不可信输入。
10
- * 本模块只做发现并返回内容;**是否注入由调用方按项目信任状态决定**——未信任
11
- * 的项目一律不调用本模块。遍历与注入都有硬上限,单个文件与总量都截断。
10
+ * 本模块只做发现并返回内容。未信任的项目只调用 `listProjectRuleFiles`
11
+ * (stat,不读正文);`loadProjectRuleFiles` 读取正文,仅在已信任时调用。
12
+ * 遍历与注入都有硬上限,单个文件与总量都截断。
12
13
  *
13
14
  * 「热重载」按回合粒度天然成立:每个回合重新读取磁盘,规则文件保存即生效,
14
15
  * 不需要文件 watcher。
@@ -33,13 +34,13 @@ const EMPTY = { files: [], content: '', truncated: false };
33
34
  * 向上遍历在「用户 home 目录」处封顶(若项目不在 home 下则按 MAX_LEVELS
34
35
  * 封顶)——不扫 `/`、`/etc` 这类与项目无关的祖先目录。
35
36
  */
36
- export function loadProjectRuleFiles(folderPath) {
37
+ /** Rule-file paths only. Does not read file bodies. */
38
+ export function listProjectRuleFiles(folderPath) {
37
39
  if (!folderPath || !folderPath.trim())
38
- return EMPTY;
40
+ return [];
39
41
  const start = path.resolve(folderPath);
40
42
  if (!fs.existsSync(start))
41
- return EMPTY;
42
- // 构造 根→…→起始目录 的目录链(根在前,越往后越贴近项目)。
43
+ return [];
43
44
  const home = os.homedir();
44
45
  const chain = [];
45
46
  let dir = start;
@@ -48,23 +49,22 @@ export function loadProjectRuleFiles(folderPath) {
48
49
  seen.add(dir);
49
50
  chain.unshift(dir);
50
51
  if (dir === home)
51
- break; // 不越过 home 向上
52
+ break;
52
53
  const parent = path.dirname(dir);
53
54
  if (parent === dir)
54
- break; // 已到文件系统根
55
+ break;
55
56
  dir = parent;
56
57
  }
57
- // 根→叶收集规则文件;每层内 override 优先。
58
58
  const files = [];
59
59
  for (const d of chain) {
60
60
  for (const name of RULE_FILE_NAMES) {
61
- const p = path.join(d, name);
61
+ const file = path.join(d, name);
62
62
  try {
63
- if (fs.statSync(p).isFile())
64
- files.push(p);
63
+ if (fs.statSync(file).isFile())
64
+ files.push(file);
65
65
  }
66
66
  catch {
67
- // 不存在或不可读——跳过该文件。
67
+ // missing or unreadable
68
68
  }
69
69
  if (files.length >= MAX_RULE_FILES)
70
70
  break;
@@ -72,6 +72,10 @@ export function loadProjectRuleFiles(folderPath) {
72
72
  if (files.length >= MAX_RULE_FILES)
73
73
  break;
74
74
  }
75
+ return files;
76
+ }
77
+ export function loadProjectRuleFiles(folderPath) {
78
+ const files = listProjectRuleFiles(folderPath);
75
79
  if (files.length === 0)
76
80
  return EMPTY;
77
81
  // 拼接并施加总量上限。
@@ -5,4 +5,4 @@
5
5
  *
6
6
  * 契约全文与设计约束见 packages/pack-sdk/types/index.d.ts 头注释。
7
7
  */
8
- export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, HttpRouteContribution, HttpRouteRequest, MainContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, CliCommandIO, CliCommandSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
8
+ export type { ScenarioId, ToolMode, ToolExposure, PackToolContext, PackToolHandler, ToolApprovalSpec, ToolContribution, ServiceFactory, ServiceCreateContext, MigrationContribution, SkillContribution, AgentSeedIdentity, AgentSeed, HttpRouteContribution, HttpRouteRequest, MainContribution, ComponentRef, SettingsPanelContribution, RouteContribution, ChatSlotContribution, CardRendererContribution, RendererContribution, BrandSpec, PackagingSpec, CliCommandIO, CliCommandSpec, ScenarioPack, PackBackendRouteResponse, PackBackendRouteRequest, PackBackendRoute, PackTurnObserver, PackTurnHooks, } from '@steerable/pack-sdk';
@@ -0,0 +1,55 @@
1
+ export interface ApprovalCategoryLabelInput {
2
+ locale: string;
3
+ category: string;
4
+ toolName: string;
5
+ declaredLabel?: Readonly<Record<string, string>>;
6
+ translate: (message: string) => string;
7
+ }
8
+ /**
9
+ * Display name for an approval category.
10
+ * Pack declarations win. Built-in categories and shell tools use the shell dictionary.
11
+ *
12
+ * @param input Locale, category, and the tool's declared labels.
13
+ * @returns A label for the card, or undefined when the category is just the tool name.
14
+ */
15
+ export declare function resolveApprovalCategoryLabel(input: ApprovalCategoryLabelInput): string | undefined;
16
+ /** English source string for the approval card. The web view translates it. */
17
+ export declare function hostApprovalCategoryLabel(input: {
18
+ category: string;
19
+ toolName: string;
20
+ declaredLabel?: Readonly<Record<string, string>>;
21
+ }): string | undefined;
22
+ export type CwdFence = 'project' | 'workspace';
23
+ /**
24
+ * Which approval sentence to show when `local_exec_shell` names a cwd
25
+ * outside the writable root. A bound project uses its folder and source
26
+ * folders. A chat without a project uses the conversation workspace.
27
+ *
28
+ * @param input Tool name, cwd, and the chat's project or workspace root.
29
+ * @returns `false` when the card should not mention an outside directory.
30
+ */
31
+ export declare function approvalCwdFence(input: {
32
+ toolName: string;
33
+ cwd: unknown;
34
+ projectRoot: string | null;
35
+ workspaceRoot: string | null;
36
+ sourceFolders: readonly string[];
37
+ home?: string;
38
+ }): CwdFence | false;
39
+ /**
40
+ * True when `local_exec_shell` names a cwd outside the given root and its source folders.
41
+ *
42
+ * The root is the project folder, or the conversation workspace when the chat
43
+ * has no project. `home` defaults to the process home directory, the same
44
+ * directory the shell fence uses when it expands `~`.
45
+ *
46
+ * @param input Tool name, cwd argument, and the chat's writable folders.
47
+ * @returns Whether that cwd is outside those folders.
48
+ */
49
+ export declare function shellCwdOutsideProject(input: {
50
+ toolName: string;
51
+ cwd: unknown;
52
+ projectRoot: string | null;
53
+ sourceFolders: readonly string[];
54
+ home?: string;
55
+ }): boolean;