@steerable/agent-shell 0.6.51 → 0.6.53

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/brand.d.ts +5 -0
  2. package/dist/goal-store.d.ts +43 -0
  3. package/dist/goal-store.js +155 -0
  4. package/dist/host/runtime.js +15 -3
  5. package/dist/host-tools.d.ts +1 -1
  6. package/dist/host-tools.js +6 -0
  7. package/dist/local-backend/portable-bundle.d.ts +16 -2
  8. package/dist/local-backend/portable-bundle.js +21 -9
  9. package/dist/local-backend/portable-service.js +25 -11
  10. package/dist/local-backend/router.js +71 -27
  11. package/dist/local-backend/subagent-profiles.js +4 -0
  12. package/dist/local-backend/task-service.d.ts +6 -0
  13. package/dist/local-backend/task-service.js +47 -2
  14. package/dist/local-executor.d.ts +36 -2
  15. package/dist/local-executor.js +171 -19
  16. package/dist/mcp-executor.d.ts +42 -10
  17. package/dist/mcp-executor.js +248 -41
  18. package/dist/mcp-server-registry.d.ts +56 -8
  19. package/dist/mcp-server-registry.js +191 -26
  20. package/dist/product-config.d.ts +14 -0
  21. package/dist/project-home.d.ts +1 -1
  22. package/dist/project-home.js +1 -1
  23. package/dist/project-registry.d.ts +4 -4
  24. package/dist/project-registry.js +2 -2
  25. package/dist/shell-exec.d.ts +62 -0
  26. package/dist/shell-exec.js +147 -0
  27. package/dist/shell-process.d.ts +3 -0
  28. package/dist/shell-process.js +137 -0
  29. package/dist/sidecar/boot.d.ts +5 -2
  30. package/dist/sidecar/boot.js +5 -0
  31. package/dist/sidecar/reverse-approval.d.ts +2 -0
  32. package/dist/sidecar/reverse-approval.js +2 -0
  33. package/dist/sidecar/reverse-tools.d.ts +6 -2
  34. package/dist/sidecar/reverse-tools.js +9 -1
  35. package/dist/sidecar/types.d.ts +10 -1
  36. package/dist/storage/write-lease-error.js +4 -1
  37. package/dist/tool-presentation.d.ts +23 -0
  38. package/dist/tool-presentation.js +266 -0
  39. package/dist/tool-router.d.ts +26 -8
  40. package/dist/tool-router.js +275 -24
  41. package/dist/workspace-search.d.ts +35 -0
  42. package/dist/workspace-search.js +186 -0
  43. package/package.json +5 -5
package/dist/brand.d.ts CHANGED
@@ -18,6 +18,11 @@ export interface Brand {
18
18
  agentName: string;
19
19
  /** 一句话定位(fallback prompt 用) */
20
20
  tagline: string;
21
+ /**
22
+ * 新对话落地页 / 空会话首屏副文案。省略用 shell 默认句;
23
+ * 空字符串表示不显示。只进渲染层,不进系统提示词。
24
+ */
25
+ homeHint?: string;
21
26
  /** 新对话 / 首页未选手动专家时绑定的内置智能体 id */
22
27
  defaultAgentId: string;
23
28
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 会话目标。每个 chat 一份,写在用户数据目录的 goals.json。
3
+ *
4
+ * 修订号做乐观并发:update_goal 必须带上一次读到的 revision。
5
+ */
6
+ export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete';
7
+ export type GoalAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked';
8
+ export declare const GOAL_ACTIONS: readonly GoalAction[];
9
+ export interface StoredGoal {
10
+ id: string;
11
+ chatId: string;
12
+ revision: number;
13
+ objective: string;
14
+ phase: GoalPhase;
15
+ blockedReason?: string;
16
+ updatedAt: number;
17
+ }
18
+ export interface GoalToolResult {
19
+ success: boolean;
20
+ goal?: StoredGoal | null;
21
+ error?: string;
22
+ needsFollowup?: boolean;
23
+ }
24
+ export declare class GoalStore {
25
+ private readonly filePath;
26
+ private pending;
27
+ constructor(filePath: string);
28
+ static default(): GoalStore;
29
+ get(chatId: string): Promise<GoalToolResult>;
30
+ create(chatId: string, objective: string): Promise<GoalToolResult>;
31
+ update(input: {
32
+ chatId: string;
33
+ id: string;
34
+ revision: number;
35
+ action: string;
36
+ objective?: string;
37
+ reason?: string;
38
+ }): Promise<GoalToolResult>;
39
+ private queue;
40
+ private load;
41
+ private read;
42
+ private write;
43
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 会话目标。每个 chat 一份,写在用户数据目录的 goals.json。
3
+ *
4
+ * 修订号做乐观并发:update_goal 必须带上一次读到的 revision。
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { getUserDataDir } from './runtime.js';
10
+ export const GOAL_ACTIONS = [
11
+ 'edit',
12
+ 'pause',
13
+ 'resume',
14
+ 'complete',
15
+ 'blocked',
16
+ ];
17
+ export class GoalStore {
18
+ filePath;
19
+ pending = Promise.resolve();
20
+ constructor(filePath) {
21
+ this.filePath = filePath;
22
+ }
23
+ static default() {
24
+ return new GoalStore(path.join(getUserDataDir(), 'goals.json'));
25
+ }
26
+ async get(chatId) {
27
+ const data = await this.load();
28
+ return { success: true, goal: data.goals[chatId] ?? null };
29
+ }
30
+ async create(chatId, objective) {
31
+ const text = objective.trim();
32
+ if (!text)
33
+ return { success: false, error: 'objective 不能为空', needsFollowup: true };
34
+ return this.queue(async () => {
35
+ const data = await this.read();
36
+ const existing = data.goals[chatId];
37
+ if (existing && existing.phase !== 'complete') {
38
+ return {
39
+ success: false,
40
+ error: `本会话已有未完成目标(${existing.id},revision ${existing.revision})。先 update_goal 完成或改写它。`,
41
+ goal: existing,
42
+ needsFollowup: true,
43
+ };
44
+ }
45
+ const goal = {
46
+ id: randomUUID(),
47
+ chatId,
48
+ revision: 1,
49
+ objective: text,
50
+ phase: 'active',
51
+ updatedAt: Date.now(),
52
+ };
53
+ data.goals[chatId] = goal;
54
+ await this.write(data);
55
+ return { success: true, goal };
56
+ });
57
+ }
58
+ async update(input) {
59
+ if (!GOAL_ACTIONS.includes(input.action)) {
60
+ return {
61
+ success: false,
62
+ error: `action 必须是 ${GOAL_ACTIONS.join(' | ')}`,
63
+ needsFollowup: true,
64
+ };
65
+ }
66
+ const action = input.action;
67
+ return this.queue(async () => {
68
+ const data = await this.read();
69
+ const current = data.goals[input.chatId];
70
+ if (!current || current.id !== input.id) {
71
+ return { success: false, error: '目标不存在或 id 不匹配。先 get_goal。', needsFollowup: true };
72
+ }
73
+ if (current.revision !== input.revision) {
74
+ return {
75
+ success: false,
76
+ error: `revision 已变(当前 ${current.revision})。重新 get_goal 后再更新。`,
77
+ goal: current,
78
+ needsFollowup: true,
79
+ };
80
+ }
81
+ const next = applyAction(current, action, input.objective, input.reason);
82
+ if ('error' in next)
83
+ return { success: false, error: next.error, goal: current, needsFollowup: true };
84
+ data.goals[input.chatId] = next;
85
+ await this.write(data);
86
+ return { success: true, goal: next };
87
+ });
88
+ }
89
+ queue(fn) {
90
+ const run = this.pending.then(fn, fn);
91
+ this.pending = run.then(() => undefined, () => undefined);
92
+ return run;
93
+ }
94
+ async load() {
95
+ return this.queue(() => this.read());
96
+ }
97
+ async read() {
98
+ try {
99
+ const raw = await readFile(this.filePath, 'utf8');
100
+ const parsed = JSON.parse(raw);
101
+ if (!parsed || typeof parsed !== 'object' || !parsed.goals)
102
+ return { goals: {} };
103
+ return parsed;
104
+ }
105
+ catch (err) {
106
+ const code = err.code;
107
+ if (code === 'ENOENT')
108
+ return { goals: {} };
109
+ throw err;
110
+ }
111
+ }
112
+ async write(data) {
113
+ await mkdir(path.dirname(this.filePath), { recursive: true });
114
+ await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
115
+ }
116
+ }
117
+ function applyAction(current, action, objective, reason) {
118
+ const next = { ...current, revision: current.revision + 1, updatedAt: Date.now() };
119
+ if (action === 'edit') {
120
+ const text = objective?.trim() ?? '';
121
+ if (!text)
122
+ return { error: 'edit 需要 objective' };
123
+ next.objective = text;
124
+ return next;
125
+ }
126
+ if (action === 'pause') {
127
+ if (current.phase !== 'active')
128
+ return { error: `只有 active 目标可以暂停(当前 ${current.phase})` };
129
+ next.phase = 'paused';
130
+ return next;
131
+ }
132
+ if (action === 'resume') {
133
+ if (current.phase !== 'paused' && current.phase !== 'blocked') {
134
+ return { error: `只有 paused 或 blocked 目标可以恢复(当前 ${current.phase})` };
135
+ }
136
+ next.phase = 'active';
137
+ delete next.blockedReason;
138
+ return next;
139
+ }
140
+ if (action === 'complete') {
141
+ if (current.phase === 'complete')
142
+ return { error: '目标已经完成' };
143
+ next.phase = 'complete';
144
+ return next;
145
+ }
146
+ const text = reason?.trim() ?? '';
147
+ if (!text)
148
+ return { error: 'blocked 需要 reason' };
149
+ if (current.phase !== 'active' && current.phase !== 'paused') {
150
+ return { error: `当前阶段 ${current.phase} 不能标为 blocked` };
151
+ }
152
+ next.phase = 'blocked';
153
+ next.blockedReason = text;
154
+ return next;
155
+ }
@@ -16,6 +16,7 @@ import { LocalScriptRegistry } from '../local-script-registry.js';
16
16
  import { TerminalManager } from '../terminal-manager.js';
17
17
  import { ToolRouter } from '../tool-router.js';
18
18
  import { McpServerRegistry, } from '../mcp-server-registry.js';
19
+ import { mcpExecutor } from '../mcp-executor.js';
19
20
  import { ProjectRegistry } from '../project-registry.js';
20
21
  import { getProductConfig } from '../product-config.js';
21
22
  import { LocalBackendRouter } from '../local-backend/router.js';
@@ -120,7 +121,13 @@ export async function createHostRuntime(options) {
120
121
  if (!projectId)
121
122
  return null;
122
123
  const project = projectRegistry.get(projectId);
123
- return project ? { name: project.name, folderPath: project.folderPath } : null;
124
+ return project
125
+ ? {
126
+ name: project.name,
127
+ folderPath: project.folderPath,
128
+ sourceFolders: project.sourceFolders ?? [],
129
+ }
130
+ : null;
124
131
  };
125
132
  // 场景包装配(2.3):产品组装根(products/<id>/active.ts)在 import 期
126
133
  // 把包装配函数注册进 pack-assembly 注册表;这里统一装配。宿主不 import
@@ -214,8 +221,8 @@ export async function createHostRuntime(options) {
214
221
  store: defaultStore,
215
222
  toolRouter,
216
223
  resolveProjectRoot: async (chatId) => localBackendRouter.resolveChatWorkspaceRoot(chatId),
217
- // 项目模式下文件读写被围栏在项目目录内;会话附件目录是额外放行的
218
- // 只读根,保证用户上传的文件即使在项目会话里也能被 agent 读回。
224
+ // 读:项目家目录之外,附件目录和源文件夹(含各自子目录)也可读。
225
+ // 写:源文件夹及其子目录与家目录同一档;附件目录保持只读。
219
226
  resolveAdditionalReadRoots: async (chatId) => {
220
227
  const project = await localBackendRouter.resolveChatProject(chatId);
221
228
  return [
@@ -223,6 +230,10 @@ export async function createHostRuntime(options) {
223
230
  ...(project?.sourceFolders ?? []),
224
231
  ];
225
232
  },
233
+ resolveAdditionalWriteRoots: async (chatId) => {
234
+ const project = await localBackendRouter.resolveChatProject(chatId);
235
+ return project?.sourceFolders ?? [];
236
+ },
226
237
  approvalHandler: approvalBridge.handler,
227
238
  askUserHandler: askUserBridge.handler,
228
239
  // P2b: resume 时 sidecar 把记录里的读证据推给 LocalExecutor 的
@@ -254,6 +265,7 @@ export async function createHostRuntime(options) {
254
265
  for (const handle of [...packHandles.values()].reverse()) {
255
266
  await handle.dispose?.();
256
267
  }
268
+ await mcpExecutor.shutdownAll();
257
269
  await shutdownHostSidecar();
258
270
  await closeStorage();
259
271
  },
@@ -34,7 +34,7 @@ export type ResolvedSettingsChrome = Record<SettingsItemId, boolean>;
34
34
  */
35
35
  export declare function resolveSettingsChrome(value?: unknown, tools?: ResolvedHostTools): ResolvedSettingsChrome;
36
36
  export declare function hasGeneralSettingsChrome(chrome?: ResolvedSettingsChrome): boolean;
37
- export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "view_image", "local_write_file", "local_edit_file", "present_files", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script"];
37
+ export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "view_image", "local_write_file", "local_edit_file", "present_files", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script", "grep", "glob", "pwsh"];
38
38
  export declare function resolveHostToolSurface(value: boolean | {
39
39
  capability?: boolean;
40
40
  chrome?: boolean;
@@ -84,6 +84,9 @@ export const LOCAL_FS_TOOL_NAMES = [
84
84
  'local_run_snippet',
85
85
  'local_list_scripts',
86
86
  'local_run_script',
87
+ 'grep',
88
+ 'glob',
89
+ 'pwsh',
87
90
  ];
88
91
  const FAMILY_TOOL_PREFIX = {
89
92
  terminal: [],
@@ -97,6 +100,9 @@ const FAMILY_TOOL_PREFIX = {
97
100
  'worktree_create',
98
101
  'worktree_list',
99
102
  'worktree_remove',
103
+ 'job_list',
104
+ 'job_output',
105
+ 'job_kill',
100
106
  ],
101
107
  mcp: ['mcp_list_tools', 'mcp_tool_exec'],
102
108
  web: ['web_search', 'web_fetch'],
@@ -69,9 +69,14 @@ export interface PortableWebSearchSection {
69
69
  }
70
70
  export interface PortableMcpSection {
71
71
  name: string;
72
- command: string;
73
- args: string[];
72
+ transport: 'stdio' | 'streamable-http';
73
+ command?: string;
74
+ args?: string[];
74
75
  cwd?: string;
76
+ url?: string;
77
+ headers?: Record<string, string>;
78
+ headersFromEnv?: Record<string, string>;
79
+ bearerTokenEnvVar?: string;
75
80
  enabled: boolean;
76
81
  envIncluded: boolean;
77
82
  env?: Record<string, string>;
@@ -178,11 +183,20 @@ export declare function webSearchSectionFromSettings(settings: {
178
183
  }, includeSecrets: boolean): PortableWebSearchSection;
179
184
  export declare function mcpSectionFromServer(server: {
180
185
  name: string;
186
+ transport: 'stdio';
181
187
  command: string;
182
188
  args: string[];
183
189
  cwd?: string;
184
190
  enabled: boolean;
185
191
  env: Record<string, string>;
192
+ } | {
193
+ name: string;
194
+ transport: 'streamable-http';
195
+ url: string;
196
+ headers: Record<string, string>;
197
+ headersFromEnv: Record<string, string>;
198
+ bearerTokenEnvVar?: string;
199
+ enabled: boolean;
186
200
  }, includeSecrets: boolean): PortableMcpSection;
187
201
  /**
188
202
  * 把消息正文和 metadata 里的旧附件绝对路径换成新会话里的路径。
@@ -106,15 +106,27 @@ export function webSearchSectionFromSettings(settings, includeSecrets) {
106
106
  };
107
107
  }
108
108
  export function mcpSectionFromServer(server, includeSecrets) {
109
- return {
110
- name: server.name,
111
- command: server.command,
112
- args: [...server.args],
113
- cwd: server.cwd,
114
- enabled: server.enabled,
115
- envIncluded: includeSecrets,
116
- ...(includeSecrets ? { env: { ...server.env } } : {}),
117
- };
109
+ return server.transport === 'stdio'
110
+ ? {
111
+ name: server.name,
112
+ transport: 'stdio',
113
+ command: server.command,
114
+ args: [...server.args],
115
+ cwd: server.cwd,
116
+ enabled: server.enabled,
117
+ envIncluded: includeSecrets,
118
+ ...(includeSecrets ? { env: { ...server.env } } : {}),
119
+ }
120
+ : {
121
+ name: server.name,
122
+ transport: 'streamable-http',
123
+ url: server.url,
124
+ headers: { ...server.headers },
125
+ headersFromEnv: { ...server.headersFromEnv },
126
+ bearerTokenEnvVar: server.bearerTokenEnvVar,
127
+ enabled: server.enabled,
128
+ envIncluded: false,
129
+ };
118
130
  }
119
131
  /**
120
132
  * 把消息正文和 metadata 里的旧附件绝对路径换成新会话里的路径。
@@ -180,22 +180,36 @@ async function applyMcp(mcp, servers) {
180
180
  return 'MCP 注册表不可用';
181
181
  for (const server of servers) {
182
182
  const name = server.name?.trim();
183
- const command = server.command?.trim();
184
- if (!name || !command)
183
+ if (!name)
185
184
  continue;
186
- const input = {
187
- name,
188
- command,
189
- args: Array.isArray(server.args) ? server.args.map((arg) => String(arg)) : [],
190
- cwd: server.cwd,
191
- enabled: server.enabled !== false,
192
- ...(server.envIncluded && server.env ? { env: server.env } : {}),
193
- };
185
+ const input = server.transport === 'streamable-http'
186
+ ? {
187
+ name,
188
+ transport: 'streamable-http',
189
+ url: server.url?.trim() ?? '',
190
+ headers: server.headers ?? {},
191
+ headersFromEnv: server.headersFromEnv ?? {},
192
+ bearerTokenEnvVar: server.bearerTokenEnvVar,
193
+ enabled: server.enabled !== false,
194
+ }
195
+ : {
196
+ name,
197
+ transport: 'stdio',
198
+ command: server.command?.trim() ?? '',
199
+ args: Array.isArray(server.args) ? server.args.map((arg) => String(arg)) : [],
200
+ cwd: server.cwd,
201
+ enabled: server.enabled !== false,
202
+ ...(server.envIncluded && server.env ? { env: server.env } : {}),
203
+ };
204
+ if ((input.transport === 'stdio' && !input.command) ||
205
+ (input.transport === 'streamable-http' && !input.url)) {
206
+ continue;
207
+ }
194
208
  const existing = mcp.list().find((item) => item.name.toLowerCase() === name.toLowerCase());
195
209
  if (existing)
196
210
  mcp.update(existing.id, input);
197
211
  else
198
- mcp.create({ ...input, env: input.env ?? {} });
212
+ mcp.create(input);
199
213
  }
200
214
  return null;
201
215
  }
@@ -10,6 +10,7 @@ import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from
10
10
  import { driveWithAutoContinue, resolveAutoContinueMax, } from './auto-continue-helper.js';
11
11
  import { buildAmbientDelegateRoster, buildDelegateDispatchInstruction, buildDelegateRosterHint, buildMentionDelegateRoster, BUILTIN_SUBAGENT_PROFILES, mergeTurnSubagentParam, } from './subagent-profiles.js';
12
12
  import { resolveMentionedAgentIds } from './mention-targets.js';
13
+ import { presentToolCall } from '../tool-presentation.js';
13
14
  import { readSidecarHistoryEntries, timelineFromHistoryEntries, } from './task-process.js';
14
15
  import { appendTimelineDelta, freezeTimelineReasoning, sealLastTimelineBlock, syncTimelineTools, } from './turn-timeline.js';
15
16
  import { turnDurationMs } from './turn-duration.js';
@@ -50,6 +51,14 @@ import { matchPackBackendRoute } from './pack-backend-routes.js';
50
51
  import { collectTurnFiles } from './turn-files.js';
51
52
  import { resolveMentionedPaths } from './mentioned-paths.js';
52
53
  import { getAuthProvider } from '../auth/index.js';
54
+ function stringRecord(value) {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value))
56
+ return {};
57
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
58
+ key,
59
+ String(item),
60
+ ]));
61
+ }
53
62
  function parseSourceFolders(value) {
54
63
  if (!Array.isArray(value))
55
64
  return [];
@@ -1139,7 +1148,7 @@ export class LocalBackendRouter {
1139
1148
  const servers = registry.list().map((s) => {
1140
1149
  const cached = registry.getCachedTools(s.id);
1141
1150
  return {
1142
- ...s,
1151
+ ...registry.toPublicEntry(s),
1143
1152
  serverKey: registry.serverKey(s),
1144
1153
  toolCount: cached?.tools.length ?? 0,
1145
1154
  toolsPreview: cached?.tools.slice(0, 8).map((t) => t.name) ?? [],
@@ -1155,19 +1164,35 @@ export class LocalBackendRouter {
1155
1164
  return { status: 503, data: { error: 'MCP 注册表不可用' } };
1156
1165
  try {
1157
1166
  const payload = this.toRecord(request.body);
1158
- const entry = registry.create({
1159
- name: String(payload.name ?? ''),
1160
- command: String(payload.command ?? ''),
1161
- args: Array.isArray(payload.args) ? payload.args.map((a) => String(a)) : [],
1162
- env: payload.env && typeof payload.env === 'object' && !Array.isArray(payload.env)
1163
- ? Object.fromEntries(Object.entries(payload.env).map(([k, v]) => [k, String(v)]))
1164
- : {},
1165
- cwd: typeof payload.cwd === 'string' ? payload.cwd : undefined,
1166
- enabled: payload.enabled !== false,
1167
- });
1167
+ const entry = payload.transport === 'streamable-http'
1168
+ ? registry.create({
1169
+ name: String(payload.name ?? ''),
1170
+ transport: 'streamable-http',
1171
+ url: String(payload.url ?? ''),
1172
+ headers: stringRecord(payload.headers),
1173
+ headersFromEnv: stringRecord(payload.headersFromEnv),
1174
+ bearerTokenEnvVar: typeof payload.bearerTokenEnvVar === 'string'
1175
+ ? payload.bearerTokenEnvVar
1176
+ : undefined,
1177
+ reconnect: payload.reconnect &&
1178
+ typeof payload.reconnect === 'object' &&
1179
+ !Array.isArray(payload.reconnect)
1180
+ ? payload.reconnect
1181
+ : undefined,
1182
+ enabled: payload.enabled !== false,
1183
+ })
1184
+ : registry.create({
1185
+ name: String(payload.name ?? ''),
1186
+ transport: 'stdio',
1187
+ command: String(payload.command ?? ''),
1188
+ args: Array.isArray(payload.args) ? payload.args.map((a) => String(a)) : [],
1189
+ env: stringRecord(payload.env),
1190
+ cwd: typeof payload.cwd === 'string' ? payload.cwd : undefined,
1191
+ enabled: payload.enabled !== false,
1192
+ });
1168
1193
  // 后台拉一次工具列表,下一轮对话即可用;失败不阻塞创建
1169
1194
  void registry.refreshTools(entry.id).catch(() => { });
1170
- return { status: 200, data: { server: entry } };
1195
+ return { status: 200, data: { server: registry.toPublicEntry(entry) } };
1171
1196
  }
1172
1197
  catch (err) {
1173
1198
  return { status: 400, data: { error: err instanceof Error ? err.message : String(err) } };
@@ -1187,7 +1212,7 @@ export class LocalBackendRouter {
1187
1212
  return {
1188
1213
  status: 200,
1189
1214
  data: {
1190
- added: result.added,
1215
+ added: result.added.map((entry) => registry.toPublicEntry(entry)),
1191
1216
  skipped: result.skipped,
1192
1217
  },
1193
1218
  };
@@ -1210,17 +1235,32 @@ export class LocalBackendRouter {
1210
1235
  const payload = this.toRecord(request.body);
1211
1236
  const updated = registry.update(serverId, {
1212
1237
  name: typeof payload.name === 'string' ? payload.name : undefined,
1238
+ transport: payload.transport === 'stdio' || payload.transport === 'streamable-http'
1239
+ ? payload.transport
1240
+ : undefined,
1213
1241
  command: typeof payload.command === 'string' ? payload.command : undefined,
1214
1242
  args: Array.isArray(payload.args) ? payload.args.map((a) => String(a)) : undefined,
1215
1243
  env: payload.env && typeof payload.env === 'object' && !Array.isArray(payload.env)
1216
- ? Object.fromEntries(Object.entries(payload.env).map(([k, v]) => [k, String(v)]))
1244
+ ? stringRecord(payload.env)
1217
1245
  : undefined,
1218
1246
  cwd: typeof payload.cwd === 'string' ? payload.cwd : undefined,
1247
+ url: typeof payload.url === 'string' ? payload.url : undefined,
1248
+ headers: payload.headers && typeof payload.headers === 'object' && !Array.isArray(payload.headers)
1249
+ ? stringRecord(payload.headers)
1250
+ : undefined,
1251
+ headersFromEnv: payload.headersFromEnv &&
1252
+ typeof payload.headersFromEnv === 'object' &&
1253
+ !Array.isArray(payload.headersFromEnv)
1254
+ ? stringRecord(payload.headersFromEnv)
1255
+ : undefined,
1256
+ bearerTokenEnvVar: typeof payload.bearerTokenEnvVar === 'string'
1257
+ ? payload.bearerTokenEnvVar
1258
+ : undefined,
1219
1259
  enabled: typeof payload.enabled === 'boolean' ? payload.enabled : undefined,
1220
1260
  });
1221
1261
  if (updated.enabled)
1222
1262
  void registry.refreshTools(updated.id).catch(() => { });
1223
- return { status: 200, data: { server: updated } };
1263
+ return { status: 200, data: { server: registry.toPublicEntry(updated) } };
1224
1264
  }
1225
1265
  catch (err) {
1226
1266
  return { status: 400, data: { error: err instanceof Error ? err.message : String(err) } };
@@ -2448,17 +2488,17 @@ export class LocalBackendRouter {
2448
2488
  ? chatProject.folderPath
2449
2489
  : await this.resolveChatWorkspaceRoot(chatId);
2450
2490
  if (chatProject) {
2491
+ const sourceFolderLine = chatProject.sourceFolders.length > 0
2492
+ ? `源文件夹及其子目录也可读写:${chatProject.sourceFolders.join('、')}。`
2493
+ : '如确需读写家目录外的目录,可请用户把该目录附加为源文件夹。';
2451
2494
  systemPrompt +=
2452
2495
  `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,家目录:${chatProject.folderPath}\n` +
2453
- `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该家目录内:` +
2454
- `写入路径越界会被拒绝;命令默认在家目录下运行,显式指定的 cwd 越界也会被拒绝。` +
2455
- (chatProject.sourceFolders.length > 0
2456
- ? `另有源文件夹(只读):${chatProject.sourceFolders.join('、')}。`
2457
- : '') +
2458
- `请一律使用项目目录内的路径(相对路径按家目录解析)。` +
2459
- `如确需读取项目外的文件,可请用户在项目里附加为源文件夹。\n` +
2460
- `例外:本会话的用户上传附件目录 ${chatAttachmentsDirPath(chatId)} 也在项目家目录外,` +
2461
- `但它已作为**只读放行根**开放给 local_read_file(用绝对路径可直接读取,不会被项目围栏拒绝)。` +
2496
+ `文件写入(local_write_file / local_edit_file)和命令写入可以落在家目录及其全部子目录内。` +
2497
+ `${sourceFolderLine}` +
2498
+ `这些范围之外的写入会被拒绝。命令未指定 cwd 时在家目录运行;显式 cwd 必须落在上述可写目录(含子目录)内。` +
2499
+ `相对路径按家目录解析。\n` +
2500
+ `例外:本会话的用户上传附件目录 ${chatAttachmentsDirPath(chatId)} 只读,不在可写范围内,` +
2501
+ `但 local_read_file 可以用绝对路径读取。` +
2462
2502
  /* shell-neutral:allow generic document extensions, not product identity */ `用户附到本条消息的文件都放在那里;需要转换(docx / pdf / xlsx / pptx 等)时,` +
2463
2503
  `把该绝对路径交给 local_exec_shell 的转换脚本处理即可,**不要**因为它在项目家目录外就拒绝读取或要求用户重新拷贝。`;
2464
2504
  // W6-5 + W6-7a:项目级规则文件(AGENTS.md / CLAUDE.md)是不可信输入,
@@ -2481,7 +2521,7 @@ export class LocalBackendRouter {
2481
2521
  else {
2482
2522
  systemPrompt +=
2483
2523
  `\n\n【对话工作区】当前对话没有绑定项目。工作区:${workspaceRoot}\n` +
2484
- `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该目录内:` +
2524
+ `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该目录及其子目录内:` +
2485
2525
  `写入路径越界会被拒绝;命令默认在工作区下运行,显式指定的 cwd 越界也会被拒绝。` +
2486
2526
  `请一律使用工作区内的路径(相对路径按工作区解析)。` +
2487
2527
  `用户若要把对话绑到已有项目,可在输入框上方选择项目。`;
@@ -2940,7 +2980,7 @@ export class LocalBackendRouter {
2940
2980
  // spec (default.harness.yaml) is the single source of truth for
2941
2981
  // maxRounds / maxToolErrors, and an explicit request param overrides it.
2942
2982
  // W4-2: per-exec sandbox for shell tool calls, default-on. Writable
2943
- // roots = 项目家目录,或无项目时 Documents/<应用>/conversations/<chatId>。
2983
+ // roots = 项目家目录(或无项目时的对话工作区)+ 源文件夹。每个根含其子目录。
2944
2984
  // `execPolicy: 'full'`(输入框「完整权限」)关闭这一层,让 mkdir
2945
2985
  // Downloads 这类工作区外写入不再被 Seatbelt 拦成 Operation not permitted。
2946
2986
  const chatProject = await this.resolveChatProject(chatId);
@@ -2952,6 +2992,7 @@ export class LocalBackendRouter {
2952
2992
  // 工作区)。
2953
2993
  ...collectPackExecWritableRoots(chatId),
2954
2994
  workspaceRoot,
2995
+ ...(chatProject?.sourceFolders ?? []),
2955
2996
  ], { policy: clampExecPolicy(parseExecPolicy(payload.execPolicy), getResolvedHostTools()) });
2956
2997
  // W4-1: approval algebra, host mode — every tool call is gated by the
2957
2998
  // Electron approval UI over the reverse channel. The durable scope
@@ -2989,6 +3030,7 @@ export class LocalBackendRouter {
2989
3030
  const turnFileRoots = [
2990
3031
  ...collectPackExecWritableRoots(chatId),
2991
3032
  workspaceRoot,
3033
+ ...(chatProject?.sourceFolders ?? []),
2992
3034
  ];
2993
3035
  try {
2994
3036
  const coreLoopOptions = {
@@ -3077,7 +3119,7 @@ export class LocalBackendRouter {
3077
3119
  ...(toolPolicy.mode === 'all' ? {} : { toolPolicy }),
3078
3120
  },
3079
3121
  execSandbox,
3080
- approval,
3122
+ approval: approval ? { ...approval, writableRoots: turnFileRoots } : undefined,
3081
3123
  // delegate-on-pool 统一:模型的多代理面收敛为单工具
3082
3124
  // delegate_subagent(默认开)。内置画像集(explore/research/coder)
3083
3125
  // 带工具域/轮次/并发/系统提示,模型按画像 description 选委派
@@ -3110,6 +3152,7 @@ export class LocalBackendRouter {
3110
3152
  tool: call.tool,
3111
3153
  mode: chatMode,
3112
3154
  arguments: call.arguments,
3155
+ view: presentToolCall(call.tool, call.arguments),
3113
3156
  threw: false,
3114
3157
  });
3115
3158
  syncTimelineTools(timeline, executedActions);
@@ -3124,6 +3167,7 @@ export class LocalBackendRouter {
3124
3167
  tool: action.tool,
3125
3168
  mode: chatMode,
3126
3169
  arguments: action.arguments,
3170
+ view: presentToolCall(action.tool, action.arguments, action.result),
3127
3171
  result: action.result,
3128
3172
  success: action.success,
3129
3173
  error: action.error,
@@ -20,8 +20,12 @@ import { loadSkills } from './skill-loader.js';
20
20
  /** 只读工具域:探索与调研画像共享的「摸不到写操作」基线。 */
21
21
  const READ_ONLY_TOOLS = [
22
22
  'local_read_file',
23
+ 'view_image',
23
24
  'local_list_scripts',
24
25
  'local_open_path',
26
+ 'grep',
27
+ 'glob',
28
+ 'get_goal',
25
29
  'web_search',
26
30
  'web_fetch',
27
31
  'mcp_list_tools',
@@ -54,6 +54,7 @@ export interface TaskServiceDeps {
54
54
  resolveChatProject: (chatId: string) => Promise<{
55
55
  name: string;
56
56
  folderPath: string;
57
+ sourceFolders?: string[];
57
58
  } | null>;
58
59
  /**
59
60
  * 无项目对话的可写根(Documents/<应用>/conversations/<chatId>)。
@@ -120,6 +121,11 @@ export declare class TaskService {
120
121
  private executeTask;
121
122
  /** task_status 工具:单任务或本 chat 全部任务的快照。 */
122
123
  status(chatId: string, taskId?: string): Promise<Record<string, unknown>>;
124
+ /**
125
+ * job_kill:结束 running 或 blocked 任务。running 先打标记再取消流,
126
+ * 终态回写看到标记后保持 failed,避免流的正常完成把杀死盖掉。
127
+ */
128
+ kill(chatId: string, taskId: string, reason?: string): Promise<Record<string, unknown>>;
123
129
  /** task_result 工具:取终态任务的完整结果。 */
124
130
  result(chatId: string, taskId: string): Promise<Record<string, unknown>>;
125
131
  /** 喂给模型的任务视图(完整字段,answer 截断防爆上下文)。 */