@steerable/agent-shell 0.6.74 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~` 展开后收成真实路径。
|
|
3
|
+
* 绝对路径按自身解析。相对路径在给了 `baseDir` 时按该目录解析
|
|
4
|
+
* (项目家目录或对话工作区),否则按进程当前目录解析。
|
|
5
|
+
* 围栏比较前路径与根都要过这一步,这样 `/tmp` 与 `/private/tmp` 是同一目录。
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveFencePath(inputPath: string, baseDir?: string | null): string;
|
|
8
|
+
/** `~` 展开后 resolve,再解析符号链接。相对路径按进程当前目录解析。 */
|
|
9
|
+
export declare function expandFencePath(inputPath: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* `target` 是否就是 `root`,或落在它里面。两边都先解析符号链接,
|
|
12
|
+
* 所以 `/tmp` 与 `/private/tmp` 是同一目录,指向外面的链接不算在里面。
|
|
13
|
+
*/
|
|
14
|
+
export declare function canonicalPathInside(root: string, target: string): boolean;
|
|
15
|
+
/** 把已 resolve 的绝对路径收成真实路径。解析失败时保留原路径。 */
|
|
16
|
+
export declare function canonicalFencePath(resolved: string): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* `~` 展开后收成真实路径。
|
|
6
|
+
* 绝对路径按自身解析。相对路径在给了 `baseDir` 时按该目录解析
|
|
7
|
+
* (项目家目录或对话工作区),否则按进程当前目录解析。
|
|
8
|
+
* 围栏比较前路径与根都要过这一步,这样 `/tmp` 与 `/private/tmp` 是同一目录。
|
|
9
|
+
*/
|
|
10
|
+
export function resolveFencePath(inputPath, baseDir) {
|
|
11
|
+
const expanded = inputPath.startsWith('~')
|
|
12
|
+
? path.join(os.homedir(), inputPath.slice(1))
|
|
13
|
+
: inputPath;
|
|
14
|
+
if (path.isAbsolute(expanded) || !baseDir?.trim()) {
|
|
15
|
+
return canonicalFencePath(path.resolve(expanded));
|
|
16
|
+
}
|
|
17
|
+
const base = canonicalFencePath(path.resolve(baseDir));
|
|
18
|
+
return canonicalFencePath(path.resolve(base, expanded));
|
|
19
|
+
}
|
|
20
|
+
/** `~` 展开后 resolve,再解析符号链接。相对路径按进程当前目录解析。 */
|
|
21
|
+
export function expandFencePath(inputPath) {
|
|
22
|
+
return resolveFencePath(inputPath);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `target` 是否就是 `root`,或落在它里面。两边都先解析符号链接,
|
|
26
|
+
* 所以 `/tmp` 与 `/private/tmp` 是同一目录,指向外面的链接不算在里面。
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalPathInside(root, target) {
|
|
29
|
+
const relative = path.relative(canonicalFencePath(path.resolve(root)), canonicalFencePath(path.resolve(target)));
|
|
30
|
+
return relative === '' || (relative !== '..' &&
|
|
31
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
32
|
+
!path.isAbsolute(relative));
|
|
33
|
+
}
|
|
34
|
+
/** 把已 resolve 的绝对路径收成真实路径。解析失败时保留原路径。 */
|
|
35
|
+
export function canonicalFencePath(resolved) {
|
|
36
|
+
const missing = [];
|
|
37
|
+
let current = resolved;
|
|
38
|
+
for (;;) {
|
|
39
|
+
try {
|
|
40
|
+
const real = realpathSync(current);
|
|
41
|
+
return missing.reduceRight((dir, name) => path.join(dir, name), real);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const code = error.code;
|
|
45
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
46
|
+
return resolved;
|
|
47
|
+
const parent = path.dirname(current);
|
|
48
|
+
if (parent === current)
|
|
49
|
+
return resolved;
|
|
50
|
+
missing.push(path.basename(current));
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/host/runtime.d.ts
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HostRuntime —— CS(Electron 主进程)与 BS(headless HTTP server)共享的
|
|
3
|
-
* 服务装配与生命周期。
|
|
4
|
-
*
|
|
5
|
-
* 历史上 main.ts 与 server/index.ts 各自装配同一份服务(LocalExecutor /
|
|
6
|
-
* ToolRouter / TaskService / sidecar …),两处逐行重复;BS 曾漏接
|
|
7
|
-
* TaskService 导致模型退化成 local_exec_shell + nohup 的"假后台"。本模块
|
|
8
|
-
* 把装配收敛为一处,两个入口只保留各自的广播机制(IPC webContents.send
|
|
9
|
-
* vs SSE 总线)与生命周期(Electron app 事件 vs SIGINT/SIGTERM)。
|
|
10
|
-
*
|
|
11
|
-
* 这也是阶段 3 上提框架后 `@steerable/agent-shell` 的核心装配件:场景包
|
|
12
|
-
* (ScenarioPack)的服务/工具贡献在 0.3 逐槽接入本模块。
|
|
13
|
-
*/
|
|
14
1
|
import { LocalExecutor, type LocalExecRequest, type LocalExecResult } from '../local-executor.js';
|
|
15
2
|
import { LocalScriptRegistry } from '../local-script-registry.js';
|
|
16
3
|
import { TerminalManager } from '../terminal-manager.js';
|
package/dist/host/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* 这也是阶段 3 上提框架后 `@steerable/agent-shell` 的核心装配件:场景包
|
|
12
12
|
* (ScenarioPack)的服务/工具贡献在 0.3 逐槽接入本模块。
|
|
13
13
|
*/
|
|
14
|
+
import { homedir } from 'node:os';
|
|
14
15
|
import { LocalExecutor, setDefaultExecTimeoutMs, } from '../local-executor.js';
|
|
15
16
|
import { LocalScriptRegistry } from '../local-script-registry.js';
|
|
16
17
|
import { TerminalManager } from '../terminal-manager.js';
|
|
@@ -27,6 +28,7 @@ import { LOCAL_SCOPE, closeStorage, getPackDbAccess, getScopedStore, initializeS
|
|
|
27
28
|
import { subscribeChatActivity } from '../storage/chat-activity.js';
|
|
28
29
|
import { getUserDataDir } from '../runtime.js';
|
|
29
30
|
import { acquireInstanceLease, bindStorageInstance, } from '../storage/process-locks.js';
|
|
31
|
+
import { approvalCwdFence, hostApprovalCategoryLabel } from '../sidecar/approval-label.js';
|
|
30
32
|
import { createApprovalBridge } from '../sidecar/reverse-approval.js';
|
|
31
33
|
import { createAskUserBridge } from '../sidecar/reverse-ask-user.js';
|
|
32
34
|
import { startHostSidecar, shutdownHostSidecar } from '../sidecar/boot.js';
|
|
@@ -214,6 +216,30 @@ export async function createHostRuntime(options) {
|
|
|
214
216
|
broadcast,
|
|
215
217
|
hasWindow,
|
|
216
218
|
onLog: (line) => onLog(line),
|
|
219
|
+
categoryLabel: (toolName, category) => {
|
|
220
|
+
const declaredLabel = toolRouter.getSchemaByName(toolName)?.approval?.label;
|
|
221
|
+
return hostApprovalCategoryLabel({
|
|
222
|
+
category,
|
|
223
|
+
toolName,
|
|
224
|
+
...(declaredLabel ? { declaredLabel } : {}),
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
outsideWorkspace: async ({ toolName, arguments: args, chatId }) => {
|
|
228
|
+
if (!chatId || !localBackendRouter)
|
|
229
|
+
return false;
|
|
230
|
+
const project = await localBackendRouter.resolveChatProject(chatId);
|
|
231
|
+
const workspaceRoot = project
|
|
232
|
+
? null
|
|
233
|
+
: await localBackendRouter.resolveChatWorkspaceRoot(chatId);
|
|
234
|
+
return approvalCwdFence({
|
|
235
|
+
toolName,
|
|
236
|
+
cwd: args.cwd,
|
|
237
|
+
projectRoot: project?.folderPath ?? null,
|
|
238
|
+
workspaceRoot,
|
|
239
|
+
sourceFolders: project?.sourceFolders ?? [],
|
|
240
|
+
home: homedir(),
|
|
241
|
+
});
|
|
242
|
+
},
|
|
217
243
|
});
|
|
218
244
|
const askUserBridge = createAskUserBridge({
|
|
219
245
|
broadcast,
|
package/dist/host-tools.d.ts
CHANGED
|
@@ -47,6 +47,7 @@ export declare function resolveHostTools(config?: HostToolsConfig | null): Resol
|
|
|
47
47
|
export declare function resolveApprovalMode(value: unknown): ApprovalMode;
|
|
48
48
|
/**
|
|
49
49
|
* 产品 `approval: "off"` 或 STEERABLE_APPROVAL=0 时本轮不挂 host 审批。
|
|
50
|
+
* 环境变量关掉审批层后,破坏性工具由工具路由拒绝。命令行放行用 `--approve allow-all`。
|
|
50
51
|
* 环境变量是调试逃生口;产品字段是装配真源。
|
|
51
52
|
*/
|
|
52
53
|
export declare function isApprovalEnabled(input?: {
|
package/dist/host-tools.js
CHANGED
|
@@ -139,6 +139,7 @@ export function resolveApprovalMode(value) {
|
|
|
139
139
|
}
|
|
140
140
|
/**
|
|
141
141
|
* 产品 `approval: "off"` 或 STEERABLE_APPROVAL=0 时本轮不挂 host 审批。
|
|
142
|
+
* 环境变量关掉审批层后,破坏性工具由工具路由拒绝。命令行放行用 `--approve allow-all`。
|
|
142
143
|
* 环境变量是调试逃生口;产品字段是装配真源。
|
|
143
144
|
*/
|
|
144
145
|
export function isApprovalEnabled(input = {}) {
|
package/dist/i18n.d.ts
CHANGED
|
@@ -20,4 +20,6 @@ export declare function configureI18n(options: {
|
|
|
20
20
|
export declare function getLocale(): string;
|
|
21
21
|
export declare function setLocale(locale: string): boolean;
|
|
22
22
|
export declare function t(message: string, params?: Record<string, string | number>): string;
|
|
23
|
+
/** Translate `message` for one request without changing the process locale. */
|
|
24
|
+
export declare function tFor(locale: string, message: string, params?: Record<string, string | number>): string;
|
|
23
25
|
export declare function resetI18nForTests(): void;
|
package/dist/i18n.js
CHANGED
|
@@ -74,7 +74,12 @@ export function setLocale(locale) {
|
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
76
|
export function t(message, params) {
|
|
77
|
-
|
|
77
|
+
return tFor(current, message, params);
|
|
78
|
+
}
|
|
79
|
+
/** Translate `message` for one request without changing the process locale. */
|
|
80
|
+
export function tFor(locale, message, params) {
|
|
81
|
+
const normalized = normalizeLocale(locale) ?? SOURCE_LOCALE;
|
|
82
|
+
const table = normalized === SOURCE_LOCALE ? undefined : catalogs[normalized];
|
|
78
83
|
const template = table?.[message] ?? message;
|
|
79
84
|
return formatMessage(template, params);
|
|
80
85
|
}
|
package/dist/json-store.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface JsonStore<T extends Record<string, unknown>> {
|
|
|
2
2
|
get<Key extends keyof T>(key: Key): T[Key];
|
|
3
3
|
get<Key extends keyof T>(key: Key, defaultValue: Required<T>[Key]): Required<T>[Key];
|
|
4
4
|
set<Key extends keyof T>(key: Key, value?: T[Key]): void;
|
|
5
|
+
mutate<Key extends keyof T>(key: Key, update: (current: T[Key]) => T[Key]): T[Key];
|
|
5
6
|
}
|
|
6
7
|
export declare function createJsonStore<T extends Record<string, unknown>>(options: {
|
|
7
8
|
name: string;
|
package/dist/json-store.js
CHANGED
|
@@ -7,21 +7,23 @@
|
|
|
7
7
|
* 解析会塌成 namespace——所以这里声明用到的最小接口并做一次强转,不依赖
|
|
8
8
|
* 它的声明文件。
|
|
9
9
|
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
10
12
|
import ConfImport from 'conf';
|
|
11
13
|
import { getUserDataDir } from './runtime.js';
|
|
12
14
|
import { fileLockPath } from './storage/process-locks.js';
|
|
13
15
|
import { acquireWriteLease } from './storage/write-lease.js';
|
|
14
16
|
const Conf = ConfImport;
|
|
15
17
|
export function createJsonStore(options) {
|
|
18
|
+
const cwd = getUserDataDir();
|
|
16
19
|
const store = new Conf({
|
|
17
|
-
cwd
|
|
18
|
-
|
|
19
|
-
defaults: options.defaults,
|
|
20
|
+
cwd,
|
|
21
|
+
configName: options.name,
|
|
22
|
+
defaults: defaultsFromLegacy(cwd, options.name, options.defaults),
|
|
20
23
|
});
|
|
21
24
|
const lockPath = fileLockPath(getUserDataDir(), options.name);
|
|
22
25
|
return {
|
|
23
26
|
get: store.get.bind(store),
|
|
24
|
-
// set is wrapped below so two processes cannot clobber a read-modify-write.
|
|
25
27
|
set(key, value) {
|
|
26
28
|
const lease = acquireWriteLease(lockPath, 5_000);
|
|
27
29
|
try {
|
|
@@ -31,5 +33,49 @@ export function createJsonStore(options) {
|
|
|
31
33
|
lease.release();
|
|
32
34
|
}
|
|
33
35
|
},
|
|
36
|
+
mutate(key, update) {
|
|
37
|
+
const lease = acquireWriteLease(lockPath, 5_000);
|
|
38
|
+
try {
|
|
39
|
+
const next = update(store.get(key));
|
|
40
|
+
store.set(key, next);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
lease.release();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
34
47
|
};
|
|
35
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Older builds passed `name` to conf, which only reads `configName` and
|
|
51
|
+
* therefore stored every registry in `config.json`. Copy this registry's
|
|
52
|
+
* keys once, and leave the old file in place.
|
|
53
|
+
*/
|
|
54
|
+
function defaultsFromLegacy(cwd, name, defaults) {
|
|
55
|
+
if (defaults === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const target = path.join(cwd, `${name}.json`);
|
|
58
|
+
const legacy = path.join(cwd, 'config.json');
|
|
59
|
+
if (fs.existsSync(target) || !fs.existsSync(legacy))
|
|
60
|
+
return defaults;
|
|
61
|
+
const record = readRecord(legacy);
|
|
62
|
+
if (!record)
|
|
63
|
+
return defaults;
|
|
64
|
+
const seeded = { ...defaults };
|
|
65
|
+
for (const key of Object.keys(defaults)) {
|
|
66
|
+
if (key in record)
|
|
67
|
+
seeded[key] = record[key];
|
|
68
|
+
}
|
|
69
|
+
return seeded;
|
|
70
|
+
}
|
|
71
|
+
function readRecord(file) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
74
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
75
|
+
return null;
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -31,6 +31,13 @@ export interface CoreLoopToolAction {
|
|
|
31
31
|
backend?: string;
|
|
32
32
|
enforcement: string;
|
|
33
33
|
};
|
|
34
|
+
/** Session-allow reuse lifted onto the stream chunk. */
|
|
35
|
+
approval?: {
|
|
36
|
+
kind?: string;
|
|
37
|
+
category?: string;
|
|
38
|
+
cached?: boolean;
|
|
39
|
+
waitMs?: number;
|
|
40
|
+
};
|
|
34
41
|
}
|
|
35
42
|
export interface CoreLoopTurnOutcome {
|
|
36
43
|
status: string;
|
|
@@ -71,6 +78,27 @@ export interface SkillTurnContext {
|
|
|
71
78
|
/** 智能体的「加载全部技能」:catalog 无视条件全量列出。 */
|
|
72
79
|
ignoreConditions: boolean;
|
|
73
80
|
}
|
|
81
|
+
/** 一轮发给 sidecar 的工具。`mode` 与 `approvalCategory` 是可选线协议字段。 */
|
|
82
|
+
export interface TurnToolWire {
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
inputSchema: unknown;
|
|
86
|
+
mode?: string;
|
|
87
|
+
approvalCategory?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 把宿主工具 schema 收成线协议上的一项。未声明审批类别时不带 `approvalCategory`,
|
|
91
|
+
* sidecar 按工具名做类别。
|
|
92
|
+
*/
|
|
93
|
+
export declare function toTurnTool(schema: {
|
|
94
|
+
name: string;
|
|
95
|
+
description: string;
|
|
96
|
+
inputSchema: unknown;
|
|
97
|
+
mode?: string;
|
|
98
|
+
approval?: {
|
|
99
|
+
category?: string;
|
|
100
|
+
} | null;
|
|
101
|
+
}): TurnToolWire;
|
|
74
102
|
export interface StreamCoreLoopTurnOptions {
|
|
75
103
|
supervisor: SidecarSupervisor;
|
|
76
104
|
chatId: string;
|
|
@@ -86,11 +114,7 @@ export interface StreamCoreLoopTurnOptions {
|
|
|
86
114
|
*/
|
|
87
115
|
resume?: boolean;
|
|
88
116
|
messages: LlmMessage[];
|
|
89
|
-
tools:
|
|
90
|
-
name: string;
|
|
91
|
-
description: string;
|
|
92
|
-
inputSchema: unknown;
|
|
93
|
-
}>;
|
|
117
|
+
tools: TurnToolWire[];
|
|
94
118
|
provider: string;
|
|
95
119
|
model: string;
|
|
96
120
|
baseUrl?: string;
|
|
@@ -11,6 +11,123 @@
|
|
|
11
11
|
* sidecar failed to start, the router fails loud with a 503 — there is no
|
|
12
12
|
* in-process fallback loop or sidecar opt-out.
|
|
13
13
|
*/
|
|
14
|
+
/** Markers an older engine left inside the tool-result preview JSON. */
|
|
15
|
+
function markersFromToolPreview(preview) {
|
|
16
|
+
if (!preview || preview[0] !== '{')
|
|
17
|
+
return {};
|
|
18
|
+
const record = previewData(preview);
|
|
19
|
+
if (!record)
|
|
20
|
+
return {};
|
|
21
|
+
const sandbox = record._sandbox;
|
|
22
|
+
const approval = record._approval;
|
|
23
|
+
return {
|
|
24
|
+
...(sandbox && typeof sandbox === 'object' && !Array.isArray(sandbox)
|
|
25
|
+
&& typeof sandbox.enforcement === 'string'
|
|
26
|
+
? { sandbox: sandbox }
|
|
27
|
+
: {}),
|
|
28
|
+
...(approval && typeof approval === 'object' && !Array.isArray(approval)
|
|
29
|
+
? { approval: approval }
|
|
30
|
+
: {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function previewData(preview) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(preview);
|
|
36
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
37
|
+
return undefined;
|
|
38
|
+
const data = parsed.data;
|
|
39
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
40
|
+
return undefined;
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// The published loop cuts the preview at 300 characters and appends an
|
|
45
|
+
// ellipsis. The marker objects are placed first so they are still intact.
|
|
46
|
+
return {
|
|
47
|
+
_sandbox: objectAfterKey(preview, '_sandbox'),
|
|
48
|
+
_approval: objectAfterKey(preview, '_approval'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function objectAfterKey(text, key) {
|
|
53
|
+
const needle = `"${key}"`;
|
|
54
|
+
let from = 0;
|
|
55
|
+
while (from < text.length) {
|
|
56
|
+
const found = text.indexOf(needle, from);
|
|
57
|
+
if (found < 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const colon = text.indexOf(':', found + needle.length);
|
|
60
|
+
if (colon < 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
let index = colon + 1;
|
|
63
|
+
while (text[index] === ' ' || text[index] === '\n' || text[index] === '\t')
|
|
64
|
+
index += 1;
|
|
65
|
+
if (text[index] !== '{') {
|
|
66
|
+
from = found + needle.length;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const end = matchingBrace(text, index);
|
|
70
|
+
if (end < 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(text.slice(index, end + 1));
|
|
74
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// The cut landed inside this object. A later copy of the key will not
|
|
80
|
+
// be more complete.
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
from = found + needle.length;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function matchingBrace(text, open) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let inString = false;
|
|
90
|
+
let escape = false;
|
|
91
|
+
for (let index = open; index < text.length; index += 1) {
|
|
92
|
+
const char = text[index] ?? '';
|
|
93
|
+
if (inString) {
|
|
94
|
+
if (escape)
|
|
95
|
+
escape = false;
|
|
96
|
+
else if (char === '\\')
|
|
97
|
+
escape = true;
|
|
98
|
+
else if (char === '"')
|
|
99
|
+
inString = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === '"')
|
|
103
|
+
inString = true;
|
|
104
|
+
else if (char === '{')
|
|
105
|
+
depth += 1;
|
|
106
|
+
else if (char === '}') {
|
|
107
|
+
depth -= 1;
|
|
108
|
+
if (depth === 0)
|
|
109
|
+
return index;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 把宿主工具 schema 收成线协议上的一项。未声明审批类别时不带 `approvalCategory`,
|
|
116
|
+
* sidecar 按工具名做类别。
|
|
117
|
+
*/
|
|
118
|
+
export function toTurnTool(schema) {
|
|
119
|
+
const tool = {
|
|
120
|
+
name: schema.name,
|
|
121
|
+
description: schema.description,
|
|
122
|
+
inputSchema: schema.inputSchema,
|
|
123
|
+
};
|
|
124
|
+
if (schema.mode)
|
|
125
|
+
tool.mode = schema.mode;
|
|
126
|
+
const category = schema.approval?.category;
|
|
127
|
+
if (category)
|
|
128
|
+
tool.approvalCategory = category;
|
|
129
|
+
return tool;
|
|
130
|
+
}
|
|
14
131
|
/**
|
|
15
132
|
* Active CoreLoop turns by chatId — lets the main process route a mid-turn
|
|
16
133
|
* steer (user typed while the turn runs) to the right sidecar stream without
|
|
@@ -145,6 +262,8 @@ export async function streamCoreLoopTurn(options) {
|
|
|
145
262
|
description: t.description,
|
|
146
263
|
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
147
264
|
},
|
|
265
|
+
...(t.mode ? { mode: t.mode } : {}),
|
|
266
|
+
...(t.approvalCategory ? { approvalCategory: t.approvalCategory } : {}),
|
|
148
267
|
})),
|
|
149
268
|
};
|
|
150
269
|
// Track in-flight tool calls so results can be paired with their arguments
|
|
@@ -208,6 +327,7 @@ export async function streamCoreLoopTurn(options) {
|
|
|
208
327
|
if (chunk.toolResult) {
|
|
209
328
|
const started = pendingCalls.get(chunk.toolResult.id);
|
|
210
329
|
pendingCalls.delete(chunk.toolResult.id);
|
|
330
|
+
const lifted = markersFromToolPreview(chunk.toolResult.resultPreview);
|
|
211
331
|
options.onToolAction?.({
|
|
212
332
|
id: chunk.toolResult.id,
|
|
213
333
|
tool: chunk.toolResult.name,
|
|
@@ -216,7 +336,8 @@ export async function streamCoreLoopTurn(options) {
|
|
|
216
336
|
result: chunk.toolResult.resultPreview,
|
|
217
337
|
error: chunk.toolResult.error,
|
|
218
338
|
durationMs: chunk.toolResult.durationMs,
|
|
219
|
-
sandbox: chunk.toolResult.sandbox,
|
|
339
|
+
sandbox: chunk.toolResult.sandbox ?? lifted.sandbox,
|
|
340
|
+
approval: chunk.toolResult.approval ?? lifted.approval,
|
|
220
341
|
});
|
|
221
342
|
}
|
|
222
343
|
if (chunk.notice) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 无项目会话工作区复制上限。产品配置 `forkWorkspaceMaxBytes` 可以改。 */
|
|
2
|
+
export declare const DEFAULT_FORK_WORKSPACE_MAX_BYTES: number;
|
|
3
|
+
/** 目录里普通文件的字节数。不跟随符号链接。目录不存在时为 0。 */
|
|
4
|
+
export declare function directorySizeBytes(root: string): number;
|
|
5
|
+
/** 复制目录。源不存在时什么也不做。目标已存在则失败,避免盖住别的会话。 */
|
|
6
|
+
export declare function copyForkDirectory(src: string, dst: string): Promise<void>;
|
|
7
|
+
export declare function removeForkDirectory(dir: string): Promise<void>;
|
|
8
|
+
/** 把旧会话的附件目录和工作区前缀换成新会话的。只换完整路径前缀。 */
|
|
9
|
+
export declare function forkPathRewriter(oldChatId: string, newChatId: string): (text: string) => string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { chatAttachmentsDirPath } from '../attachments.js';
|
|
4
|
+
import { canonicalFencePath } from '../fence-path.js';
|
|
5
|
+
import { replacePathPrefix } from './history-edit.js';
|
|
6
|
+
import { chatWorkspacePath } from '../project-home.js';
|
|
7
|
+
/** 无项目会话工作区复制上限。产品配置 `forkWorkspaceMaxBytes` 可以改。 */
|
|
8
|
+
export const DEFAULT_FORK_WORKSPACE_MAX_BYTES = 500 * 1024 * 1024;
|
|
9
|
+
/** 目录里普通文件的字节数。不跟随符号链接。目录不存在时为 0。 */
|
|
10
|
+
export function directorySizeBytes(root) {
|
|
11
|
+
if (!fs.existsSync(root))
|
|
12
|
+
return 0;
|
|
13
|
+
let total = 0;
|
|
14
|
+
const stack = [root];
|
|
15
|
+
while (stack.length > 0) {
|
|
16
|
+
const current = stack.pop();
|
|
17
|
+
if (!current)
|
|
18
|
+
break;
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const full = path.join(current, entry.name);
|
|
28
|
+
if (entry.isSymbolicLink())
|
|
29
|
+
continue;
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
stack.push(full);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!entry.isFile())
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
total += fs.statSync(full).size;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// 统计期间文件消失则跳过。
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
}
|
|
46
|
+
/** 复制目录。源不存在时什么也不做。目标已存在则失败,避免盖住别的会话。 */
|
|
47
|
+
export async function copyForkDirectory(src, dst) {
|
|
48
|
+
if (!fs.existsSync(src))
|
|
49
|
+
return;
|
|
50
|
+
await fs.promises.cp(src, dst, {
|
|
51
|
+
recursive: true,
|
|
52
|
+
errorOnExist: true,
|
|
53
|
+
verbatimSymlinks: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function removeForkDirectory(dir) {
|
|
57
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
/** 登记路径和它的真实路径都要换,`/tmp` 与 `/private/tmp` 指向同一目录。 */
|
|
60
|
+
function forkPrefixPairs(from, to) {
|
|
61
|
+
const pairs = [[from, to]];
|
|
62
|
+
const realFrom = canonicalFencePath(path.resolve(from));
|
|
63
|
+
const realTo = canonicalFencePath(path.resolve(to));
|
|
64
|
+
if (realFrom !== from)
|
|
65
|
+
pairs.push([realFrom, realTo]);
|
|
66
|
+
return pairs;
|
|
67
|
+
}
|
|
68
|
+
/** 把旧会话的附件目录和工作区前缀换成新会话的。只换完整路径前缀。 */
|
|
69
|
+
export function forkPathRewriter(oldChatId, newChatId) {
|
|
70
|
+
const pairs = [
|
|
71
|
+
...forkPrefixPairs(chatAttachmentsDirPath(oldChatId), chatAttachmentsDirPath(newChatId)),
|
|
72
|
+
...forkPrefixPairs(chatWorkspacePath(oldChatId), chatWorkspacePath(newChatId)),
|
|
73
|
+
].sort((left, right) => right[0].length - left[0].length);
|
|
74
|
+
return (text) => pairs.reduce((current, [from, to]) => replacePathPrefix(current, from, to), text);
|
|
75
|
+
}
|
|
@@ -23,5 +23,15 @@ export declare function rewindFrom(messages: readonly StoredTurn[]): {
|
|
|
23
23
|
deleteFromId: string;
|
|
24
24
|
removed: number;
|
|
25
25
|
} | null;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Replace `from` only when it is a complete path prefix: the next character
|
|
28
|
+
* is a separator or the end of the string, and the previous character does
|
|
29
|
+
* not continue a path. Letters and digits count in any script, so a Han
|
|
30
|
+
* character glues the same way an ASCII letter does. A longer name that
|
|
31
|
+
* merely starts with the same characters is left alone.
|
|
32
|
+
* On Windows the match ignores letter case and treats `/` and `\` as the
|
|
33
|
+
* same separator.
|
|
34
|
+
*/
|
|
35
|
+
export declare function replacePathPrefix(text: string, from: string, to: string, platform?: NodeJS.Platform): string;
|
|
36
|
+
/** Newest-first history copied in time order. `rewrite` updates copied paths. */
|
|
37
|
+
export declare function forkHistory(messages: readonly StoredTurn[], rewrite?: (text: string) => string): HistoryWrite[];
|