@steerable/agent-shell 0.6.74 → 0.7.2

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
@@ -36,11 +36,105 @@ export function rewindFrom(messages) {
36
36
  return null;
37
37
  return { deleteFromId: target.id, removed: index + 1 };
38
38
  }
39
- /** Newest-first history copied in time order. */
40
- export function forkHistory(messages) {
39
+ /**
40
+ * Replace `from` only when it is a complete path prefix: the next character
41
+ * is a separator or the end of the string, and the previous character does
42
+ * not continue a path. Letters and digits count in any script, so a Han
43
+ * character glues the same way an ASCII letter does. A longer name that
44
+ * merely starts with the same characters is left alone.
45
+ * On Windows the match ignores letter case and treats `/` and `\` as the
46
+ * same separator.
47
+ */
48
+ export function replacePathPrefix(text, from, to, platform = process.platform) {
49
+ const source = from.replace(/[\\/]+$/, '');
50
+ const target = to.replace(/[\\/]+$/, '');
51
+ if (!source || prefixesEqual(source, target, platform))
52
+ return text;
53
+ const win32 = platform === 'win32';
54
+ let out = '';
55
+ let cursor = 0;
56
+ while (cursor < text.length) {
57
+ const at = findPrefix(text, source, cursor, win32);
58
+ if (at < 0) {
59
+ out += text.slice(cursor);
60
+ break;
61
+ }
62
+ const next = text[at + source.length];
63
+ if (next === undefined || next === '/' || next === '\\') {
64
+ out += text.slice(cursor, at) + target;
65
+ }
66
+ else {
67
+ out += text.slice(cursor, at + source.length);
68
+ }
69
+ cursor = at + source.length;
70
+ }
71
+ return out;
72
+ }
73
+ function prefixesEqual(left, right, platform) {
74
+ return left.length === right.length && regionEquals(left, 0, right, platform === 'win32');
75
+ }
76
+ function findPrefix(text, source, fromIndex, win32) {
77
+ const limit = text.length - source.length;
78
+ for (let index = fromIndex; index <= limit; index += 1) {
79
+ if (index > 0 && continuesPath(text[index - 1] ?? ''))
80
+ continue;
81
+ if (regionEquals(text, index, source, win32))
82
+ return index;
83
+ }
84
+ return -1;
85
+ }
86
+ function continuesPath(char) {
87
+ if (char === '/' || char === '\\' || char === ':' || char === '.' || char === '_'
88
+ || char === '-' || char === '~') {
89
+ return true;
90
+ }
91
+ return /[\p{L}\p{N}\p{M}]/u.test(char);
92
+ }
93
+ function regionEquals(text, at, source, win32) {
94
+ for (let index = 0; index < source.length; index += 1) {
95
+ if (!charsEqual(text[at + index] ?? '', source[index] ?? '', win32))
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+ function charsEqual(left, right, win32) {
101
+ if (left === right)
102
+ return true;
103
+ if (!win32)
104
+ return false;
105
+ if ((left === '/' || left === '\\') && (right === '/' || right === '\\'))
106
+ return true;
107
+ return left.toLowerCase() === right.toLowerCase();
108
+ }
109
+ function rewriteJsonValue(value, rewrite) {
110
+ if (typeof value === 'string')
111
+ return rewrite(value);
112
+ if (Array.isArray(value))
113
+ return value.map((item) => rewriteJsonValue(item, rewrite));
114
+ if (value && typeof value === 'object') {
115
+ const next = {};
116
+ for (const [key, item] of Object.entries(value)) {
117
+ next[key] = rewriteJsonValue(item, rewrite);
118
+ }
119
+ return next;
120
+ }
121
+ return value;
122
+ }
123
+ function rewriteMetadata(raw, rewrite) {
124
+ if (!raw)
125
+ return raw ?? null;
126
+ try {
127
+ return JSON.stringify(rewriteJsonValue(JSON.parse(raw), rewrite));
128
+ }
129
+ catch {
130
+ return rewrite(raw);
131
+ }
132
+ }
133
+ /** Newest-first history copied in time order. `rewrite` updates copied paths. */
134
+ export function forkHistory(messages, rewrite = (text) => text) {
41
135
  return messages.slice().reverse().map((message) => ({
42
136
  role: message.role,
43
- content: message.content,
44
- messageMetadata: message.messageMetadata ?? null,
137
+ content: rewrite(message.content),
138
+ messageMetadata: rewriteMetadata(message.messageMetadata, rewrite),
45
139
  }));
46
140
  }
@@ -197,6 +197,7 @@ export declare class LocalBackendRouter {
197
197
  private briefResult;
198
198
  private briefScalar;
199
199
  private chatBusy;
200
+ private forkChat;
200
201
  private tryChatLock;
201
202
  private withChatWriteLock;
202
203
  private sse;
@@ -4,11 +4,12 @@ import path from 'node:path';
4
4
  import { readFile, realpath, stat } from 'node:fs/promises';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import os from 'node:os';
7
+ import { getLocale, tFor } from '../i18n.js';
7
8
  import { getAppRootDir, getUserDataDir, shellOpenPath } from '../runtime.js';
8
9
  import { acquireChatWriteLock, ChatBusyError, } from '../storage/process-locks.js';
9
10
  import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
12
+ import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, toTurnTool, } from './coreloop-stream.js';
12
13
  import { driveWithAutoContinue, resolveAutoContinueMax, } from './auto-continue-helper.js';
13
14
  import { buildActiveGoalContext, buildGoalContinuationPrompt, shouldContinueGoal, } from './goal-continuation-helper.js';
14
15
  import { buildAmbientDelegateRoster, buildDelegateDispatchInstruction, buildDelegateRosterHint, buildMentionDelegateRoster, BUILTIN_SUBAGENT_PROFILES, mergeTurnSubagentParam, } from './subagent-profiles.js';
@@ -43,14 +44,16 @@ import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateFork
43
44
  import { resolveBranchActivation } from './branch-helper.js';
44
45
  import { detectInterruptedTurn } from './interrupted-helper.js';
45
46
  import { handlePortableRequest } from './portable-service.js';
46
- import { isPortableProduct } from '../product-config.js';
47
+ import { getProductConfig, isPortableProduct } from '../product-config.js';
47
48
  import { dropCurrentUserMessage } from './history-helper.js';
48
49
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
49
50
  import { chatAttachmentsDirPath, isValidChatAttachmentsKey } from '../attachments.js';
50
- import { loadProjectRuleFiles } from '../project-rules.js';
51
- import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
51
+ import { canonicalFencePath, canonicalPathInside } from '../fence-path.js';
52
+ import { listProjectRuleFiles, loadProjectRuleFiles } from '../project-rules.js';
53
+ import { allocateProjectHome, chatWorkspacePath, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
52
54
  import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
53
55
  import { compactHistory, forkHistory, rewindFrom } from './history-edit.js';
56
+ import { copyForkDirectory, DEFAULT_FORK_WORKSPACE_MAX_BYTES, directorySizeBytes, forkPathRewriter, removeForkDirectory, } from './fork-copy.js';
54
57
  import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
55
58
  import { matchPackBackendRoute } from './pack-backend-routes.js';
56
59
  import { invokePackHttpRoute } from '../host/http-routes.js';
@@ -87,12 +90,9 @@ function readProjectOrderIds(value) {
87
90
  }
88
91
  /** 回合产物下载上限:base64 走 localBackend JSON 通道,过大文件先拒绝。 */
89
92
  const MAX_LOCAL_DOWNLOAD_BYTES = 128 * 1024 * 1024;
90
- /** `target` 是否位于 `root` 内部(含 root 本身)。 */
93
+ /** `target` 是否位于 `root` 内部(含 root 本身)。比较前解析符号链接。 */
91
94
  function isPathInsideRoot(root, target) {
92
- const rel = path.relative(path.resolve(root), path.resolve(target));
93
- return rel === '' || (rel !== '..' &&
94
- !rel.startsWith(`..${path.sep}`) &&
95
- !path.isAbsolute(rel));
95
+ return canonicalPathInside(root, target);
96
96
  }
97
97
  /** 从一条消息的 `messageMetadata` 里取出本回合产物的绝对路径。 */
98
98
  function turnFilePathsFromMetadata(raw) {
@@ -292,6 +292,26 @@ function persistedAgentIdFromMetadata(raw) {
292
292
  const agentId = parseMessageMetadata(raw)?.agentId;
293
293
  return typeof agentId === 'string' && agentId ? agentId : undefined;
294
294
  }
295
+ /** Rule path relative to the project root. A path outside that root keeps its base name. */
296
+ function relativeRulePath(folderPath, file) {
297
+ if (!path.isAbsolute(file))
298
+ return file;
299
+ const relative = path.relative(folderPath, file);
300
+ if (!relative
301
+ || relative === '..'
302
+ || relative.startsWith(`..${path.sep}`)
303
+ || path.isAbsolute(relative))
304
+ return path.basename(file);
305
+ return relative;
306
+ }
307
+ /** Fork title in the caller's locale. The stored default title stays `新对话`. */
308
+ function forkTitle(source, requestedLocale) {
309
+ const title = (source || '新对话').trim() || '新对话';
310
+ const locale = typeof requestedLocale === 'string' && requestedLocale.trim()
311
+ ? requestedLocale
312
+ : getLocale();
313
+ return tFor(locale, '{title} (fork)', { title });
314
+ }
295
315
  export class LocalBackendRouter {
296
316
  toolRouter;
297
317
  defaultStore;
@@ -687,6 +707,32 @@ export class LocalBackendRouter {
687
707
  data: { success: true, projects: registry.reorder(orderedIds) },
688
708
  };
689
709
  }
710
+ if (pathname === '/api/v2/projects/resolve' && method === 'POST') {
711
+ const registry = this.toolRouter.projectRegistry;
712
+ if (!registry) {
713
+ return { status: 503, data: { error: '项目注册表不可用' } };
714
+ }
715
+ const payload = this.toRecord(request.body);
716
+ const folderPath = typeof payload.folderPath === 'string' ? expandUserPath(payload.folderPath) : '';
717
+ if (!folderPath.trim()) {
718
+ return { status: 400, data: { error: '项目文件夹不能为空' } };
719
+ }
720
+ try {
721
+ const resolved = registry.resolveByFolder(folderPath, {
722
+ ...(payload.origin === 'cli' ? { origin: 'cli' } : {}),
723
+ });
724
+ return {
725
+ status: 200,
726
+ data: { project: resolved.project, created: resolved.created },
727
+ };
728
+ }
729
+ catch (err) {
730
+ return {
731
+ status: 400,
732
+ data: { error: err instanceof Error ? err.message : String(err) },
733
+ };
734
+ }
735
+ }
690
736
  const projectMatch = pathname.match(/^\/api\/v2\/projects\/([^/]+)$/);
691
737
  if (projectMatch) {
692
738
  const projectId = decodeURIComponent(projectMatch[1]);
@@ -826,15 +872,16 @@ export class LocalBackendRouter {
826
872
  return { status: 200, data: { project: null } };
827
873
  }
828
874
  const trusted = registry.isTrusted(project.id);
829
- // 仅在已信任时才真正读取规则文件内容;未信任时只探测「有没有」,
830
- // 不读内容(发现本身不解析、不注入)。
831
- const rules = loadProjectRuleFiles(project.folderPath);
875
+ const files = trusted
876
+ ? loadProjectRuleFiles(project.folderPath).files
877
+ : listProjectRuleFiles(project.folderPath);
832
878
  return {
833
879
  status: 200,
834
880
  data: {
835
881
  project: { id: project.id, name: project.name, folderPath: project.folderPath, trusted },
836
- ruleFileCount: rules.files.length,
837
- rulesActive: trusted && rules.files.length > 0,
882
+ ruleFileCount: files.length,
883
+ rulesActive: trusted && files.length > 0,
884
+ files: files.map((file) => relativeRulePath(project.folderPath, file)),
838
885
  },
839
886
  };
840
887
  }
@@ -845,6 +892,7 @@ export class LocalBackendRouter {
845
892
  const chat = await this.store.getChat(chatId);
846
893
  if (!chat)
847
894
  return this.notFound('Chat not found');
895
+ const source = chat.forkedFrom ? await this.store.getChat(chat.forkedFrom) : null;
848
896
  return {
849
897
  status: 200,
850
898
  data: {
@@ -858,6 +906,7 @@ export class LocalBackendRouter {
858
906
  isPinned: chat.isPinned,
859
907
  systemPrompt: chat.systemPrompt,
860
908
  pinnedRefs: chat.pinnedRefs,
909
+ forkedFrom: source ? { chatId: source.id, title: source.title } : null,
861
910
  },
862
911
  };
863
912
  }
@@ -941,13 +990,21 @@ export class LocalBackendRouter {
941
990
  return this.badRequest('projectId 必须是项目 id 字符串或 null');
942
991
  }
943
992
  }
993
+ let titleUpdate;
994
+ if (typeof payload.title === 'string') {
995
+ const trimmed = payload.title.trim();
996
+ if (!trimmed)
997
+ return this.badRequest('标题不能为空');
998
+ titleUpdate = trimmed;
999
+ }
944
1000
  const lease = this.tryChatLock(chatId);
945
1001
  if (!lease)
946
1002
  return this.chatBusy();
947
1003
  let updated;
948
1004
  try {
949
1005
  updated = await this.store.updateChat(chatId, {
950
- title: typeof payload.title === 'string' ? payload.title : undefined,
1006
+ title: titleUpdate,
1007
+ titleSource: titleUpdate !== undefined ? 'user' : undefined,
951
1008
  systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
952
1009
  pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
953
1010
  projectId: projectIdUpdate,
@@ -958,6 +1015,12 @@ export class LocalBackendRouter {
958
1015
  }
959
1016
  if (!updated)
960
1017
  return this.notFound('Chat not found');
1018
+ if (titleUpdate !== undefined) {
1019
+ this.broadcast?.('chat-title-updated', {
1020
+ chatId,
1021
+ title: updated.title,
1022
+ });
1023
+ }
961
1024
  return {
962
1025
  status: 200,
963
1026
  data: {
@@ -1070,15 +1133,7 @@ export class LocalBackendRouter {
1070
1133
  }
1071
1134
  const forkMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/fork$/);
1072
1135
  if (forkMatch && method === 'POST') {
1073
- const chatId = forkMatch[1];
1074
- const chat = await this.store.getChat(chatId);
1075
- if (!chat)
1076
- return { status: 404, data: { detail: 'chat not found' } };
1077
- const created = await this.store.createChat(chat.title || '分叉', chat.agentId, chat.projectId);
1078
- for (const write of forkHistory(await this.historyTurns(chatId))) {
1079
- await this.store.addMessage(created.id, write.role, write.content, write.messageMetadata);
1080
- }
1081
- return { status: 200, data: { chatId: created.id } };
1136
+ return await this.forkChat(forkMatch[1], request.body);
1082
1137
  }
1083
1138
  const rewindMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/rewind$/);
1084
1139
  if (rewindMatch && method === 'POST') {
@@ -2488,11 +2543,7 @@ export class LocalBackendRouter {
2488
2543
  const turnTools = this.toolRouter
2489
2544
  .listModelSchemas(turnAgents.capability.toolPolicy)
2490
2545
  .filter(schema => chatMode !== 'plan' || schema.mode === 'read')
2491
- .map(schema => ({
2492
- name: schema.name,
2493
- description: schema.description,
2494
- inputSchema: schema.inputSchema,
2495
- }));
2546
+ .map(schema => toTurnTool(schema));
2496
2547
  // 用户通过 "/" 显式指定的 MCP 工具:从注册表补齐元信息,available 取决于
2497
2548
  // 该工具本轮是否真的在 turnTools 里(未启用/未连接/被 plan 模式过滤都算
2498
2549
  // 不可用——指令模块会据此渲染"强制调用"或"如实告知不可用"两种版本)。
@@ -2510,11 +2561,7 @@ export class LocalBackendRouter {
2510
2561
  isToolAllowed(turnAgents.capability.toolPolicy, token)) {
2511
2562
  const schema = this.toolRouter.getSchemaByName(token);
2512
2563
  if (schema && (chatMode !== 'plan' || schema.mode === 'read')) {
2513
- turnTools.push({
2514
- name: schema.name,
2515
- description: schema.description,
2516
- inputSchema: schema.inputSchema,
2517
- });
2564
+ turnTools.push(toTurnTool(schema));
2518
2565
  }
2519
2566
  }
2520
2567
  forcedMcpTool = {
@@ -2619,15 +2666,18 @@ export class LocalBackendRouter {
2619
2666
  });
2620
2667
  return;
2621
2668
  }
2622
- if (fresh.title && fresh.title !== '新对话') {
2669
+ if (fresh.titleSource === 'user' || (fresh.title && fresh.title !== '新对话')) {
2623
2670
  console.log('[local-backend] title-gen skip', {
2624
2671
  chatId,
2625
- reason: 'user-changed-title',
2672
+ reason: fresh.titleSource === 'user' ? 'user-title' : 'user-changed-title',
2626
2673
  currentTitle: fresh.title,
2627
2674
  });
2628
2675
  return;
2629
2676
  }
2630
- const updated = await this.store.updateChat(chatId, { title: result.title });
2677
+ const updated = await this.store.updateChat(chatId, {
2678
+ title: result.title,
2679
+ titleSource: 'auto',
2680
+ });
2631
2681
  if (!updated) {
2632
2682
  console.warn('[local-backend] title-gen write failed', { chatId });
2633
2683
  return;
@@ -2717,7 +2767,10 @@ export class LocalBackendRouter {
2717
2767
  },
2718
2768
  };
2719
2769
  }
2720
- const wanted = new Set([path.resolve(target), path.resolve(realPath)]);
2770
+ const wanted = new Set([
2771
+ canonicalFencePath(path.resolve(target)),
2772
+ canonicalFencePath(realPath),
2773
+ ]);
2721
2774
  let allowed = false;
2722
2775
  const teamUserRoot = process.env.DEEPPATH_TEAM_USER_DATA_ROOT?.trim();
2723
2776
  if (teamUserRoot) {
@@ -2739,7 +2792,7 @@ export class LocalBackendRouter {
2739
2792
  const messages = await this.store.listMessages(chatId, 1000);
2740
2793
  for (const message of messages) {
2741
2794
  for (const listed of turnFilePathsFromMetadata(message.messageMetadata)) {
2742
- if (wanted.has(path.resolve(listed))) {
2795
+ if (wanted.has(canonicalFencePath(path.resolve(listed)))) {
2743
2796
  allowed = true;
2744
2797
  break;
2745
2798
  }
@@ -2874,7 +2927,15 @@ export class LocalBackendRouter {
2874
2927
  .filter((part) => part.trim())
2875
2928
  .join('\n\n');
2876
2929
  const polluted = this.detectToolDenialInHistory(historyAssistantTexts);
2877
- const runtimeEnvironment = this.buildRuntimeEnvironmentContext(chatMode);
2930
+ const chatProject = await this.resolveChatProject(chatId);
2931
+ const workspaceRoot = chatProject
2932
+ ? chatProject.folderPath
2933
+ : await this.resolveChatWorkspaceRoot(chatId);
2934
+ // 与本回合发出的 execSandbox.enabled 同一条件。中途改成完整权限
2935
+ // 只更新审批卡,这段提示已经发给模型。
2936
+ const commandSandboxOn = clampExecPolicy(parseExecPolicy(payload.execPolicy), getResolvedHostTools()) !== 'full' &&
2937
+ process.env.STEERABLE_EXEC_SANDBOX !== '0';
2938
+ const runtimeEnvironment = this.buildRuntimeEnvironmentContext(chatMode, workspaceRoot);
2878
2939
  // 名录进 realityCheck:两条系统提示拼装路径(技能拼装 / 用户整段覆盖)
2879
2940
  // 都会带上它,且位置在末尾——不动技能正文那段 prompt cache 前缀。
2880
2941
  const activeGoal = (await this.toolRouter.goals().get(chatId)).goal;
@@ -2963,19 +3024,23 @@ export class LocalBackendRouter {
2963
3024
  // 项目模式:绑定项目的对话在系统提示末尾追加沙箱约定(两条拼装路径
2964
3025
  // 都追加)。硬围栏在 ToolRouter/LocalExecutor,这里让模型事先知道边界,
2965
3026
  // 减少越界尝试。
2966
- const chatProject = await this.resolveChatProject(chatId);
2967
- const workspaceRoot = chatProject
2968
- ? chatProject.folderPath
2969
- : await this.resolveChatWorkspaceRoot(chatId);
2970
3027
  if (chatProject) {
2971
3028
  const sourceFolderLine = chatProject.sourceFolders.length > 0
2972
3029
  ? `源文件夹及其子目录也可读写:${chatProject.sourceFolders.join('、')}。`
2973
3030
  : '如确需读写家目录外的目录,可请用户把该目录附加为源文件夹。';
3031
+ const shellFence = commandSandboxOn
3032
+ ? `文件写入(local_write_file / local_edit_file)和命令写入可以落在家目录及其全部子目录内。` +
3033
+ `${sourceFolderLine}` +
3034
+ `这些范围之外的写入会被拒绝。命令未指定 cwd 时在家目录运行。` +
3035
+ `只有 local_exec_shell 的显式 cwd 可以落在这些目录之外:命令仍在沙箱中运行,写入该目录会被拒绝;需要写入时,把它加为源文件夹。`
3036
+ : `文件写入(local_write_file / local_edit_file)可以落在家目录及其全部子目录内。` +
3037
+ `${sourceFolderLine}` +
3038
+ `这些范围之外的文件写入会被拒绝。命令未指定 cwd 时在家目录运行。` +
3039
+ `本回合命令沙箱已关闭:local_exec_shell 的显式 cwd 可以落在这些目录之外,写入该目录不会被沙箱拒绝。`;
2974
3040
  systemPrompt +=
2975
3041
  `\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,家目录:${chatProject.folderPath}\n` +
2976
- `文件写入(local_write_file / local_edit_file)和命令写入可以落在家目录及其全部子目录内。` +
2977
- `${sourceFolderLine}` +
2978
- `这些范围之外的写入会被拒绝。命令未指定 cwd 时在家目录运行;显式 cwd 必须落在上述可写目录(含子目录)内。` +
3042
+ shellFence +
3043
+ `pwsh 与代码片段的 cwd 必须留在这些目录内。` +
2979
3044
  `相对路径按家目录解析。\n` +
2980
3045
  `例外:本会话的用户上传附件目录 ${chatAttachmentsDirPath(chatId)} 只读,不在可写范围内,` +
2981
3046
  `但 local_read_file 可以用绝对路径读取。` +
@@ -2999,11 +3064,15 @@ export class LocalBackendRouter {
2999
3064
  }
3000
3065
  }
3001
3066
  else {
3067
+ const shellFence = commandSandboxOn
3068
+ ? '只有 local_exec_shell 的显式 cwd 可以在工作区外:命令仍在沙箱中运行,写入该目录会被拒绝。'
3069
+ : '本回合命令沙箱已关闭:local_exec_shell 的显式 cwd 可以在工作区外,写入该目录不会被沙箱拒绝。';
3002
3070
  systemPrompt +=
3003
3071
  `\n\n【对话工作区】当前对话没有绑定项目。工作区:${workspaceRoot}\n` +
3004
- `你的文件写入(local_write_file)和命令执行(local_exec_shell)都被限制在该目录及其子目录内:` +
3005
- `写入路径越界会被拒绝;命令默认在工作区下运行,显式指定的 cwd 越界也会被拒绝。` +
3006
- `请一律使用工作区内的路径(相对路径按工作区解析)。` +
3072
+ `文件写入(local_write_file)被限制在该目录及其子目录内,写入路径越界会被拒绝。` +
3073
+ `命令默认在工作区下运行。${shellFence}` +
3074
+ `pwsh 与代码片段的 cwd 必须留在工作区内。` +
3075
+ `需要保存的文件请放在工作区内(相对路径按工作区解析)。` +
3007
3076
  `用户若要把对话绑到已有项目,可在输入框上方选择项目。`;
3008
3077
  }
3009
3078
  // @引用的历史对话(payload.referencedChatIds):把被引用对话的最近消息
@@ -3269,10 +3338,10 @@ export class LocalBackendRouter {
3269
3338
  return (localHints.some((hint) => normalized.includes(hint)) &&
3270
3339
  actionHints.some((hint) => normalized.includes(hint)));
3271
3340
  }
3272
- buildRuntimeEnvironmentContext(chatMode) {
3341
+ buildRuntimeEnvironmentContext(chatMode, workingDirectory) {
3273
3342
  const platform = process.platform;
3274
3343
  const arch = process.arch;
3275
- const cwd = process.cwd();
3344
+ const cwd = workingDirectory;
3276
3345
  const shell = platform === 'win32'
3277
3346
  ? 'Windows PowerShell(默认;不要使用 bash/Linux 专属语法,除非工具返回证明实际 shell 是 WSL/bash)'
3278
3347
  : (process.env.SHELL || 'unknown');
@@ -3283,6 +3352,7 @@ export class LocalBackendRouter {
3283
3352
  : platform === 'linux'
3284
3353
  ? 'Linux'
3285
3354
  : platform;
3355
+ // 命令缺省 cwd 和相对路径都按这个目录解析,不按宿主进程的当前目录。
3286
3356
  const lines = [
3287
3357
  '',
3288
3358
  '',
@@ -3438,6 +3508,69 @@ export class LocalBackendRouter {
3438
3508
  data: { code: 'chat_busy', message: '该会话正在另一个进程中运行' },
3439
3509
  };
3440
3510
  }
3511
+ async forkChat(chatId, body) {
3512
+ const chat = await this.store.getChat(chatId);
3513
+ if (!chat)
3514
+ return { status: 404, data: { detail: 'chat not found' } };
3515
+ const lease = this.tryChatLock(chatId);
3516
+ if (!lease)
3517
+ return this.chatBusy();
3518
+ let createdId = null;
3519
+ let attachmentsDst = null;
3520
+ let workspaceDst = null;
3521
+ try {
3522
+ const workspaceSrc = chat.projectId ? null : chatWorkspacePath(chatId);
3523
+ const maxBytes = getProductConfig().forkWorkspaceMaxBytes ?? DEFAULT_FORK_WORKSPACE_MAX_BYTES;
3524
+ if (workspaceSrc) {
3525
+ const bytes = directorySizeBytes(workspaceSrc);
3526
+ if (bytes > maxBytes) {
3527
+ return {
3528
+ status: 413,
3529
+ data: {
3530
+ code: 'workspace_too_large',
3531
+ bytes,
3532
+ maxBytes,
3533
+ message: '工作区太大,无法分叉',
3534
+ },
3535
+ };
3536
+ }
3537
+ }
3538
+ const title = forkTitle(chat.title, this.toRecord(body).locale);
3539
+ const created = await this.store.createChat(title, chat.agentId, chat.projectId);
3540
+ createdId = created.id;
3541
+ await this.store.updateChat(created.id, { forkedFrom: chat.id });
3542
+ attachmentsDst = chatAttachmentsDirPath(created.id);
3543
+ await copyForkDirectory(chatAttachmentsDirPath(chatId), attachmentsDst);
3544
+ if (workspaceSrc) {
3545
+ workspaceDst = chatWorkspacePath(created.id);
3546
+ await copyForkDirectory(workspaceSrc, workspaceDst);
3547
+ }
3548
+ const rewrite = forkPathRewriter(chatId, created.id);
3549
+ for (const write of forkHistory(await this.historyTurns(chatId), rewrite)) {
3550
+ await this.store.addMessage(created.id, write.role, write.content, write.messageMetadata);
3551
+ }
3552
+ return { status: 200, data: { chatId: created.id } };
3553
+ }
3554
+ catch (err) {
3555
+ console.warn('[local-backend] fork failed', { chatId, err });
3556
+ if (createdId) {
3557
+ try {
3558
+ await this.store.deleteChat(createdId);
3559
+ }
3560
+ catch (deleteErr) {
3561
+ console.warn('[local-backend] fork rollback failed', { chatId: createdId, deleteErr });
3562
+ }
3563
+ }
3564
+ if (attachmentsDst)
3565
+ await removeForkDirectory(attachmentsDst);
3566
+ if (workspaceDst)
3567
+ await removeForkDirectory(workspaceDst);
3568
+ return { status: 500, data: { detail: 'Fork failed' } };
3569
+ }
3570
+ finally {
3571
+ lease.release();
3572
+ }
3573
+ }
3441
3574
  tryChatLock(chatId) {
3442
3575
  try {
3443
3576
  return acquireChatWriteLock(getUserDataDir(), chatId);
@@ -3639,7 +3772,9 @@ export class LocalBackendRouter {
3639
3772
  ...(toolPolicy.mode === 'all' ? {} : { toolPolicy }),
3640
3773
  },
3641
3774
  execSandbox,
3642
- approval: approval ? { ...approval, writableRoots: turnFileRoots } : undefined,
3775
+ approval: approval
3776
+ ? { ...approval, writableRoots: turnFileRoots, pathBase: workspaceRoot }
3777
+ : undefined,
3643
3778
  // delegate-on-pool 统一:模型的多代理面收敛为单工具
3644
3779
  // delegate_subagent(默认开)。内置画像集(explore/research/coder)
3645
3780
  // 带工具域/轮次/并发/系统提示,模型按画像 description 选委派
@@ -3695,6 +3830,7 @@ export class LocalBackendRouter {
3695
3830
  // W4-2: surface the per-exec sandbox marker on the tool card —
3696
3831
  // enforcement is a value the user can see, not a log line.
3697
3832
  ...(action.sandbox ? { sandbox: action.sandbox } : {}),
3833
+ ...(action.approval ? { approval: action.approval } : {}),
3698
3834
  threw: false,
3699
3835
  };
3700
3836
  if (idx >= 0)
@@ -58,12 +58,17 @@ export function timelineFromHistoryEntries(entries) {
58
58
  if (idx < 0) {
59
59
  idx = actions.findIndex((row) => row.tool === name && row.result === undefined);
60
60
  }
61
+ const data = asRecord(parsed.data);
62
+ const approval = asRecord(data?._approval);
63
+ const sandbox = asRecord(data?._sandbox);
61
64
  const row = {
62
65
  ...(idx >= 0 ? actions[idx] : { tool: name }),
63
66
  tool: name,
64
67
  result: parsed.data ?? parsed,
65
68
  success: parsed.success !== false && parsed.error == null,
66
69
  error: typeof parsed.error === 'string' ? parsed.error : undefined,
70
+ ...(sandbox ? { sandbox } : {}),
71
+ ...(approval ? { approval } : {}),
67
72
  threw: false,
68
73
  };
69
74
  if (idx >= 0)
@@ -26,7 +26,7 @@
26
26
  import path from 'node:path';
27
27
  import { llmService, getSidecarSupervisor } from '../llm/index.js';
28
28
  import { sidecarWireProvider } from '../storage/llm-settings.js';
29
- import { buildWorldState, streamCoreLoopTurn, } from './coreloop-stream.js';
29
+ import { buildWorldState, streamCoreLoopTurn, toTurnTool, } from './coreloop-stream.js';
30
30
  import { builtinSubagentParam } from './subagent-profiles.js';
31
31
  import { buildExecSandbox } from '../sidecar/exec-sandbox.js';
32
32
  import { resolveTurnApproval } from '../host-tools-runtime.js';
@@ -290,11 +290,7 @@ export class TaskService {
290
290
  const tools = this.deps.toolRouter
291
291
  .listModelSchemas()
292
292
  .filter((schema) => schema.name !== 'task_run')
293
- .map((schema) => ({
294
- name: schema.name,
295
- description: schema.description,
296
- inputSchema: schema.inputSchema,
297
- }));
293
+ .map((schema) => toTurnTool(schema));
298
294
  let answer = '';
299
295
  let status = 'completed';
300
296
  let error = null;
@@ -337,7 +333,9 @@ export class TaskService {
337
333
  },
338
334
  }),
339
335
  execSandbox,
340
- approval: approval ? { ...approval, writableRoots } : undefined,
336
+ approval: approval
337
+ ? { ...approval, writableRoots, ...(fenceRoot ? { pathBase: fenceRoot } : {}) }
338
+ : undefined,
341
339
  // 与主对话同一套内置画像(explore/research/coder)——任务回合的
342
340
  // 子代理编排语义不分叉。
343
341
  subagent: builtinSubagentParam(),
@@ -383,6 +381,7 @@ export class TaskService {
383
381
  error: action.error,
384
382
  durationMs: action.durationMs,
385
383
  ...(action.sandbox ? { sandbox: action.sandbox } : {}),
384
+ ...(action.approval ? { approval: action.approval } : {}),
386
385
  threw: false,
387
386
  };
388
387
  if (idx >= 0)
@@ -582,7 +582,9 @@ function execCwdOf(action, projectRoot, homeDir) {
582
582
  function isCoveredByRoots(cwd, roots) {
583
583
  return roots.some((root) => {
584
584
  const rel = path.relative(path.resolve(root), cwd);
585
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
585
+ return rel === '' || (rel !== '..' &&
586
+ !rel.startsWith(`..${path.sep}`) &&
587
+ !path.isAbsolute(rel));
586
588
  });
587
589
  }
588
590
  /**
@@ -179,8 +179,7 @@ export declare function isGuiLaunchCommand(command: string): boolean;
179
179
  * 字符串/相对路径判断,不碰 fs,方便 ToolRouter 与 LocalExecutor 共用。
180
180
  */
181
181
  export declare function isPathWithinRoot(resolvedPath: string, resolvedRoot: string): boolean;
182
- /** `~` 展开后 resolve。围栏比较前路径与根都要过这一步。 */
183
- export declare function expandFencePath(inputPath: string): string;
182
+ export { canonicalFencePath, expandFencePath, resolveFencePath } from './fence-path.js';
184
183
  /**
185
184
  * 可写根:项目家目录(或对话工作区)加上额外可写根(源文件夹)。
186
185
  * 每个根都包含它自己和全部子目录。空列表表示不设围栏。
@@ -323,4 +322,3 @@ export declare class LocalExecutor {
323
322
  private computeDefaultShell;
324
323
  private detectWslAvailable;
325
324
  }
326
- export {};