@steerable/agent-shell 0.6.73 → 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.
- package/dist/fence-path.d.ts +16 -0
- package/dist/fence-path.js +54 -0
- package/dist/host/runtime.d.ts +0 -13
- package/dist/host/runtime.js +26 -0
- package/dist/host-tools.d.ts +1 -0
- package/dist/host-tools.js +1 -0
- package/dist/i18n.d.ts +2 -0
- package/dist/i18n.js +6 -1
- package/dist/json-store.d.ts +1 -0
- package/dist/json-store.js +50 -4
- package/dist/local-backend/coreloop-stream.d.ts +29 -5
- package/dist/local-backend/coreloop-stream.js +122 -1
- package/dist/local-backend/fork-copy.d.ts +9 -0
- package/dist/local-backend/fork-copy.js +75 -0
- package/dist/local-backend/history-edit.d.ts +12 -2
- package/dist/local-backend/history-edit.js +98 -4
- package/dist/local-backend/router.d.ts +1 -0
- package/dist/local-backend/router.js +189 -53
- package/dist/local-backend/task-process.js +5 -0
- package/dist/local-backend/task-service.js +6 -7
- package/dist/local-backend/turn-files.js +3 -1
- package/dist/local-executor.d.ts +1 -3
- package/dist/local-executor.js +10 -13
- package/dist/product-config.d.ts +7 -1
- package/dist/project-registry.d.ts +33 -0
- package/dist/project-registry.js +206 -78
- package/dist/project-rules.d.ts +2 -0
- package/dist/project-rules.js +17 -13
- package/dist/scenario/pack.d.ts +1 -1
- package/dist/sidecar/approval-label.d.ts +55 -0
- package/dist/sidecar/approval-label.js +102 -0
- package/dist/sidecar/reverse-approval.d.ts +26 -0
- package/dist/sidecar/reverse-approval.js +15 -4
- package/dist/sidecar/types.d.ts +14 -0
- package/dist/storage/index.d.ts +8 -1
- package/dist/storage/index.js +7 -2
- package/dist/storage/process-locks.d.ts +6 -0
- package/dist/storage/process-locks.js +6 -0
- package/dist/storage/scoped-store.d.ts +1 -1
- package/dist/storage/sqlite-driver.js +6 -5
- package/dist/tool-router.d.ts +22 -9
- package/dist/tool-router.js +43 -29
- 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
|
-
/**
|
|
40
|
-
|
|
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
|
|
137
|
+
content: rewrite(message.content),
|
|
138
|
+
messageMetadata: rewriteMetadata(message.messageMetadata, rewrite),
|
|
45
139
|
}));
|
|
46
140
|
}
|
|
@@ -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 {
|
|
51
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
837
|
-
rulesActive: trusted &&
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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([
|
|
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
|
|
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
|
-
|
|
2977
|
-
|
|
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
|
-
|
|
3005
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 === '' || (
|
|
585
|
+
return rel === '' || (rel !== '..' &&
|
|
586
|
+
!rel.startsWith(`..${path.sep}`) &&
|
|
587
|
+
!path.isAbsolute(rel));
|
|
586
588
|
});
|
|
587
589
|
}
|
|
588
590
|
/**
|
package/dist/local-executor.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|