@ai-sdk/harness-cline 0.0.0 → 1.0.0
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/CHANGELOG.md +14 -0
- package/LICENSE +13 -0
- package/README.md +56 -3
- package/dist/index.d.ts +127 -0
- package/dist/index.js +1587 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -11
- package/src/cline-auth.ts +40 -0
- package/src/cline-harness.ts +197 -0
- package/src/cline-mcp.ts +153 -0
- package/src/cline-remote-ops.ts +243 -0
- package/src/cline-resume-state.ts +139 -0
- package/src/cline-session.ts +827 -0
- package/src/cline-skills.ts +59 -0
- package/src/cline-tools.ts +381 -0
- package/src/cline-translate.ts +255 -0
- package/src/cline-utils.ts +151 -0
- package/src/index.ts +13 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
package/src/cline-mcp.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AgentTool } from '@cline/agents';
|
|
2
|
+
import type {
|
|
3
|
+
CreateMcpToolsOptions,
|
|
4
|
+
McpManager,
|
|
5
|
+
McpManagerOptions,
|
|
6
|
+
McpServerClientFactory,
|
|
7
|
+
McpServerRegistration,
|
|
8
|
+
McpServerTransportConfig,
|
|
9
|
+
} from '@cline/core';
|
|
10
|
+
|
|
11
|
+
const CLINE_CORE_PACKAGE: string = '@cline/core';
|
|
12
|
+
|
|
13
|
+
type ClineCoreMcpModule = {
|
|
14
|
+
createDefaultMcpServerClientFactory(): McpServerClientFactory;
|
|
15
|
+
createMcpTools(options: CreateMcpToolsOptions): Promise<AgentTool[]>;
|
|
16
|
+
InMemoryMcpManager: new (options: McpManagerOptions) => McpManager;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface ClineMcpRuntime {
|
|
20
|
+
readonly tools: ReadonlyArray<AgentTool>;
|
|
21
|
+
readonly toolNames: ReadonlySet<string>;
|
|
22
|
+
dispose(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionalStringArray(value: unknown): string[] | undefined {
|
|
30
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
|
31
|
+
? value
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function optionalStringRecord(
|
|
36
|
+
value: unknown,
|
|
37
|
+
): Record<string, string> | undefined {
|
|
38
|
+
return isRecord(value) &&
|
|
39
|
+
Object.values(value).every(item => typeof item === 'string')
|
|
40
|
+
? (value as Record<string, string>)
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveTransport(input: {
|
|
45
|
+
serverName: string;
|
|
46
|
+
config: Record<string, unknown>;
|
|
47
|
+
}): McpServerTransportConfig {
|
|
48
|
+
if (isRecord(input.config.transport)) {
|
|
49
|
+
return input.config.transport as unknown as McpServerTransportConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof input.config.command === 'string') {
|
|
53
|
+
const args = optionalStringArray(input.config.args);
|
|
54
|
+
const env = optionalStringRecord(input.config.env);
|
|
55
|
+
return {
|
|
56
|
+
type: 'stdio',
|
|
57
|
+
command: input.config.command,
|
|
58
|
+
...(args ? { args } : {}),
|
|
59
|
+
...(typeof input.config.cwd === 'string'
|
|
60
|
+
? { cwd: input.config.cwd }
|
|
61
|
+
: {}),
|
|
62
|
+
...(env ? { env } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof input.config.url === 'string') {
|
|
67
|
+
const configuredType = input.config.type ?? input.config.transportType;
|
|
68
|
+
const headers = optionalStringRecord(input.config.headers);
|
|
69
|
+
return {
|
|
70
|
+
type:
|
|
71
|
+
configuredType === 'http' || configuredType === 'streamableHttp'
|
|
72
|
+
? 'streamableHttp'
|
|
73
|
+
: 'sse',
|
|
74
|
+
url: input.config.url,
|
|
75
|
+
...(headers ? { headers } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Cline MCP server ${JSON.stringify(input.serverName)} must define a transport, command, or URL.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveRegistration(input: {
|
|
85
|
+
serverName: string;
|
|
86
|
+
config: unknown;
|
|
87
|
+
}): McpServerRegistration {
|
|
88
|
+
if (!isRecord(input.config)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Cline MCP server ${JSON.stringify(input.serverName)} must be configured with an object value.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
name: input.serverName,
|
|
96
|
+
transport: resolveTransport({
|
|
97
|
+
serverName: input.serverName,
|
|
98
|
+
config: input.config,
|
|
99
|
+
}),
|
|
100
|
+
...(typeof input.config.disabled === 'boolean'
|
|
101
|
+
? { disabled: input.config.disabled }
|
|
102
|
+
: {}),
|
|
103
|
+
...(isRecord(input.config.metadata)
|
|
104
|
+
? { metadata: input.config.metadata }
|
|
105
|
+
: {}),
|
|
106
|
+
...(isRecord(input.config.oauth) ? { oauth: input.config.oauth } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function createClineMcpRuntime(input: {
|
|
111
|
+
mcpServers: Record<string, unknown> | undefined;
|
|
112
|
+
}): Promise<ClineMcpRuntime> {
|
|
113
|
+
if (input.mcpServers == null || Object.keys(input.mcpServers).length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
tools: [],
|
|
116
|
+
toolNames: new Set(),
|
|
117
|
+
dispose: async () => {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const {
|
|
122
|
+
createDefaultMcpServerClientFactory,
|
|
123
|
+
createMcpTools,
|
|
124
|
+
InMemoryMcpManager,
|
|
125
|
+
} = (await import(CLINE_CORE_PACKAGE)) as ClineCoreMcpModule;
|
|
126
|
+
const manager = new InMemoryMcpManager({
|
|
127
|
+
clientFactory: createDefaultMcpServerClientFactory(),
|
|
128
|
+
});
|
|
129
|
+
const tools: AgentTool[] = [];
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
for (const [serverName, config] of Object.entries(input.mcpServers)) {
|
|
133
|
+
const registration = resolveRegistration({ serverName, config });
|
|
134
|
+
await manager.registerServer(registration);
|
|
135
|
+
if (registration.disabled) continue;
|
|
136
|
+
tools.push(
|
|
137
|
+
...(await createMcpTools({
|
|
138
|
+
serverName,
|
|
139
|
+
provider: manager,
|
|
140
|
+
})),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await manager.dispose().catch(() => {});
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
tools,
|
|
150
|
+
toolNames: new Set(tools.map(tool => tool.name)),
|
|
151
|
+
dispose: async () => manager.dispose(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { shellQuote } from '@ai-sdk/harness/utils';
|
|
3
|
+
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sandbox-backed implementations of the Cline harness's built-in tools.
|
|
7
|
+
*
|
|
8
|
+
* The Cline runtime executes on the host, but the workspace the model reasons
|
|
9
|
+
* about lives in the sandbox — every file/shell operation goes through the
|
|
10
|
+
* restricted `SandboxSession` surface. Relative paths resolve against the
|
|
11
|
+
* per-session working directory the framework created.
|
|
12
|
+
*/
|
|
13
|
+
export interface ClineRemoteOpsOptions {
|
|
14
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
15
|
+
/** Absolute sandbox path of the session's working directory. */
|
|
16
|
+
readonly workDir: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClineRemoteOps {
|
|
20
|
+
resolvePath(inputPath: string): string;
|
|
21
|
+
readFile(inputPath: string): Promise<string>;
|
|
22
|
+
writeFile(inputPath: string, content: string): Promise<void>;
|
|
23
|
+
editFile(inputPath: string, oldText: string, newText: string): Promise<void>;
|
|
24
|
+
bash(
|
|
25
|
+
command: string,
|
|
26
|
+
input?: {
|
|
27
|
+
/** Timeout in seconds (the `bash` tool contract). */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
},
|
|
31
|
+
): Promise<{ output: string; exitCode: number }>;
|
|
32
|
+
grep(
|
|
33
|
+
pattern: string,
|
|
34
|
+
input?: {
|
|
35
|
+
path?: string;
|
|
36
|
+
glob?: string;
|
|
37
|
+
ignoreCase?: boolean;
|
|
38
|
+
literal?: boolean;
|
|
39
|
+
context?: number;
|
|
40
|
+
limit?: number;
|
|
41
|
+
},
|
|
42
|
+
): Promise<string>;
|
|
43
|
+
glob(pattern: string, inputPath?: string, limit?: number): Promise<string[]>;
|
|
44
|
+
ls(inputPath?: string, limit?: number): Promise<string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createClineRemoteOps(
|
|
48
|
+
options: ClineRemoteOpsOptions,
|
|
49
|
+
): ClineRemoteOps {
|
|
50
|
+
const { sandbox, workDir } = options;
|
|
51
|
+
|
|
52
|
+
const resolvePath = (inputPath: string): string => {
|
|
53
|
+
const normalized = path.posix.isAbsolute(inputPath)
|
|
54
|
+
? path.posix.normalize(inputPath)
|
|
55
|
+
: path.posix.normalize(path.posix.join(workDir, inputPath));
|
|
56
|
+
return normalized;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const runShell = async (
|
|
60
|
+
command: string,
|
|
61
|
+
input?: { signal?: AbortSignal },
|
|
62
|
+
): Promise<{ exitCode: number; output: string }> => {
|
|
63
|
+
// `sandbox.run({ command })` already wraps in a shell; interpolated
|
|
64
|
+
// paths/values inside `command` are quoted with `shellQuote` by callers.
|
|
65
|
+
const result = await sandbox.run({
|
|
66
|
+
command,
|
|
67
|
+
workingDirectory: workDir,
|
|
68
|
+
...(input?.signal ? { abortSignal: input.signal } : {}),
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
exitCode: result.exitCode,
|
|
72
|
+
output: `${result.stdout}${result.stderr}`,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const readFile = async (inputPath: string): Promise<string> => {
|
|
77
|
+
const resolved = resolvePath(inputPath);
|
|
78
|
+
const content = await sandbox.readTextFile({ path: resolved });
|
|
79
|
+
if (content == null) {
|
|
80
|
+
throw new Error(`File not found: ${inputPath}`);
|
|
81
|
+
}
|
|
82
|
+
return content;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const writeFile = async (
|
|
86
|
+
inputPath: string,
|
|
87
|
+
content: string,
|
|
88
|
+
): Promise<void> => {
|
|
89
|
+
const resolved = resolvePath(inputPath);
|
|
90
|
+
// `writeTextFile` creates parent directories recursively per the
|
|
91
|
+
// SandboxSession contract, so no explicit mkdir is needed.
|
|
92
|
+
await sandbox.writeTextFile({ path: resolved, content });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const editFile = async (
|
|
96
|
+
inputPath: string,
|
|
97
|
+
oldText: string,
|
|
98
|
+
newText: string,
|
|
99
|
+
): Promise<void> => {
|
|
100
|
+
const current = await readFile(inputPath);
|
|
101
|
+
const index = current.indexOf(oldText);
|
|
102
|
+
if (index === -1) {
|
|
103
|
+
throw new Error(`Text to replace was not found in ${inputPath}`);
|
|
104
|
+
}
|
|
105
|
+
const updated = `${current.slice(0, index)}${newText}${current.slice(
|
|
106
|
+
index + oldText.length,
|
|
107
|
+
)}`;
|
|
108
|
+
await writeFile(inputPath, updated);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
resolvePath,
|
|
113
|
+
readFile,
|
|
114
|
+
writeFile,
|
|
115
|
+
editFile,
|
|
116
|
+
|
|
117
|
+
async bash(command, input) {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
// `input.timeout` is expressed in seconds (the `bash` tool contract),
|
|
120
|
+
// so convert to milliseconds for `setTimeout`.
|
|
121
|
+
const timeoutId =
|
|
122
|
+
typeof input?.timeout === 'number' && input.timeout > 0
|
|
123
|
+
? setTimeout(() => controller.abort(), input.timeout * 1000)
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const forwardedSignal = input?.signal;
|
|
127
|
+
const onAbort = () => controller.abort();
|
|
128
|
+
forwardedSignal?.addEventListener('abort', onAbort, { once: true });
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const result = await runShell(command, { signal: controller.signal });
|
|
132
|
+
return { output: result.output, exitCode: result.exitCode };
|
|
133
|
+
} finally {
|
|
134
|
+
forwardedSignal?.removeEventListener('abort', onAbort);
|
|
135
|
+
if (timeoutId) {
|
|
136
|
+
clearTimeout(timeoutId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async grep(pattern, input = {}) {
|
|
142
|
+
const target = resolvePath(input.path ?? '.');
|
|
143
|
+
const flags = [
|
|
144
|
+
'-R',
|
|
145
|
+
'-n',
|
|
146
|
+
'--binary-files=without-match',
|
|
147
|
+
...(input.ignoreCase ? ['-i'] : []),
|
|
148
|
+
...(input.literal ? ['-F'] : []),
|
|
149
|
+
...(typeof input.context === 'number' && input.context > 0
|
|
150
|
+
? ['-C', String(input.context)]
|
|
151
|
+
: []),
|
|
152
|
+
...(input.glob ? ['--include', input.glob] : []),
|
|
153
|
+
];
|
|
154
|
+
const limit = Math.max(1, input.limit ?? 100);
|
|
155
|
+
const result = await runShell(
|
|
156
|
+
[
|
|
157
|
+
`if [ ! -e ${shellQuote(
|
|
158
|
+
target,
|
|
159
|
+
)} ]; then echo "__CLINE_GREP_NOT_FOUND__"; exit 2; fi`,
|
|
160
|
+
`grep ${flags.map(shellQuote).join(' ')} -- ${shellQuote(
|
|
161
|
+
pattern,
|
|
162
|
+
)} ${shellQuote(target)} 2>/dev/null | head -n ${limit}`,
|
|
163
|
+
].join('; '),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const output = result.output.trim();
|
|
167
|
+
if (output.includes('__CLINE_GREP_NOT_FOUND__')) {
|
|
168
|
+
throw new Error(`Path not found: ${input.path ?? '.'}`);
|
|
169
|
+
}
|
|
170
|
+
return output || 'No matches found';
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async glob(pattern, inputPath = '.', limit = 1_000) {
|
|
174
|
+
const target = resolvePath(inputPath);
|
|
175
|
+
const result = await runShell(
|
|
176
|
+
[
|
|
177
|
+
`if [ ! -e ${shellQuote(
|
|
178
|
+
target,
|
|
179
|
+
)} ]; then echo "__CLINE_FIND_NOT_FOUND__"; exit 2; fi`,
|
|
180
|
+
`if [ -d ${shellQuote(target)} ]; then find ${shellQuote(
|
|
181
|
+
target,
|
|
182
|
+
)} -type f -print; else printf '%s\\n' ${shellQuote(target)}; fi`,
|
|
183
|
+
].join('; '),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const output = result.output.trim();
|
|
187
|
+
if (output.includes('__CLINE_FIND_NOT_FOUND__')) {
|
|
188
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return output
|
|
192
|
+
.split('\n')
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.map(absolutePath => {
|
|
195
|
+
if (absolutePath === target) {
|
|
196
|
+
return path.posix.basename(absolutePath);
|
|
197
|
+
}
|
|
198
|
+
return path.posix.relative(target, absolutePath);
|
|
199
|
+
})
|
|
200
|
+
.filter(
|
|
201
|
+
candidate =>
|
|
202
|
+
candidate.length > 0 && path.matchesGlob(candidate, pattern),
|
|
203
|
+
)
|
|
204
|
+
.sort((left, right) =>
|
|
205
|
+
left.toLowerCase().localeCompare(right.toLowerCase()),
|
|
206
|
+
)
|
|
207
|
+
.slice(0, limit);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async ls(inputPath = '.', limit = 500) {
|
|
211
|
+
const target = resolvePath(inputPath);
|
|
212
|
+
const result = await runShell(
|
|
213
|
+
[
|
|
214
|
+
`if [ ! -e ${shellQuote(
|
|
215
|
+
target,
|
|
216
|
+
)} ]; then echo "__CLINE_LS_NOT_FOUND__"; exit 2; fi`,
|
|
217
|
+
`if [ ! -d ${shellQuote(
|
|
218
|
+
target,
|
|
219
|
+
)} ]; then echo "__CLINE_LS_NOT_DIR__"; exit 3; fi`,
|
|
220
|
+
`cd ${shellQuote(target)}`,
|
|
221
|
+
'ls -1Ap',
|
|
222
|
+
].join('; '),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const output = result.output.trim();
|
|
226
|
+
if (output.includes('__CLINE_LS_NOT_FOUND__')) {
|
|
227
|
+
throw new Error(`Path not found: ${inputPath}`);
|
|
228
|
+
}
|
|
229
|
+
if (output.includes('__CLINE_LS_NOT_DIR__')) {
|
|
230
|
+
throw new Error(`Not a directory: ${inputPath}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return output
|
|
234
|
+
.split('\n')
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.map(line => line.replace(/[*=@|]$/, ''))
|
|
237
|
+
.sort((left, right) =>
|
|
238
|
+
left.toLowerCase().localeCompare(right.toLowerCase()),
|
|
239
|
+
)
|
|
240
|
+
.slice(0, limit);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
safeParseJSON,
|
|
5
|
+
type Experimental_SandboxSession,
|
|
6
|
+
} from '@ai-sdk/provider-utils';
|
|
7
|
+
import type { AgentMessage } from '@cline/agents';
|
|
8
|
+
import { z } from 'zod/v4';
|
|
9
|
+
|
|
10
|
+
const CLINE_HISTORY_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
|
11
|
+
|
|
12
|
+
export function safeClineHistoryFileName(historyFileName: string): string {
|
|
13
|
+
if (!CLINE_HISTORY_FILE_NAME_PATTERN.test(historyFileName)) {
|
|
14
|
+
throw new Error(`Invalid Cline history file name: ${historyFileName}`);
|
|
15
|
+
}
|
|
16
|
+
return historyFileName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const clineHistoryFileNameSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.refine(
|
|
22
|
+
historyFileName => CLINE_HISTORY_FILE_NAME_PATTERN.test(historyFileName),
|
|
23
|
+
'Cline historyFileName must be a safe .json basename.',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Schema for the adapter-specific portion of lifecycle state `data` produced
|
|
28
|
+
* by the Cline harness's resumable lifecycle methods. Carries the basename of
|
|
29
|
+
* the serialized conversation history. The actual history bytes live in a
|
|
30
|
+
* private, session-scoped directory under sandbox HOME so they survive
|
|
31
|
+
* cross-process resume without appearing in the agent workspace.
|
|
32
|
+
*/
|
|
33
|
+
export const clineResumeStateSchema = z.looseObject({
|
|
34
|
+
historyFileName: clineHistoryFileNameSchema.optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type ClineResumeStateData = z.infer<typeof clineResumeStateSchema>;
|
|
38
|
+
|
|
39
|
+
export const CLINE_DEFAULT_HISTORY_FILE_NAME = 'history.json';
|
|
40
|
+
|
|
41
|
+
export function resolveClinePrivateSessionDirectory(input: {
|
|
42
|
+
readonly sandboxHomeDir: string;
|
|
43
|
+
readonly sessionWorkDir: string;
|
|
44
|
+
readonly sessionId: string;
|
|
45
|
+
}): string {
|
|
46
|
+
const sessionKey = createHash('sha256').update(input.sessionId).digest('hex');
|
|
47
|
+
const privateSessionDir = path.posix.join(
|
|
48
|
+
input.sandboxHomeDir,
|
|
49
|
+
'.ai-sdk',
|
|
50
|
+
'harness-cline',
|
|
51
|
+
sessionKey,
|
|
52
|
+
);
|
|
53
|
+
const relativePath = path.posix.relative(
|
|
54
|
+
input.sessionWorkDir,
|
|
55
|
+
privateSessionDir,
|
|
56
|
+
);
|
|
57
|
+
if (
|
|
58
|
+
relativePath === '' ||
|
|
59
|
+
(!relativePath.startsWith('../') && !path.posix.isAbsolute(relativePath))
|
|
60
|
+
) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Cline private session directory ${JSON.stringify(privateSessionDir)} must be outside sessionWorkDir ${JSON.stringify(input.sessionWorkDir)}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return privateSessionDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveContainedSandboxPath(input: {
|
|
69
|
+
readonly privateSessionDir: string;
|
|
70
|
+
readonly historyFileName: string;
|
|
71
|
+
}): string {
|
|
72
|
+
const historyDir = path.posix.resolve(input.privateSessionDir);
|
|
73
|
+
const filePath = path.posix.resolve(
|
|
74
|
+
historyDir,
|
|
75
|
+
safeClineHistoryFileName(input.historyFileName),
|
|
76
|
+
);
|
|
77
|
+
const relativePath = path.posix.relative(historyDir, filePath);
|
|
78
|
+
if (
|
|
79
|
+
relativePath === '' ||
|
|
80
|
+
relativePath.startsWith('..') ||
|
|
81
|
+
path.posix.isAbsolute(relativePath)
|
|
82
|
+
) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Invalid Cline history file name: ${input.historyFileName}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return filePath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Persist the runtime's conversation history into private sandbox state so a
|
|
92
|
+
* future process can resume the session after
|
|
93
|
+
* `HarnessV1SandboxProvider.resume?.({ sessionId })` reattaches the sandbox.
|
|
94
|
+
*/
|
|
95
|
+
export async function persistHistoryToSandbox(args: {
|
|
96
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
97
|
+
readonly privateSessionDir: string;
|
|
98
|
+
readonly historyFileName: string;
|
|
99
|
+
readonly messages: readonly AgentMessage[];
|
|
100
|
+
readonly abortSignal?: AbortSignal;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
const remotePath = resolveContainedSandboxPath({
|
|
103
|
+
privateSessionDir: args.privateSessionDir,
|
|
104
|
+
historyFileName: args.historyFileName,
|
|
105
|
+
});
|
|
106
|
+
// `writeTextFile` creates parent directories recursively per the
|
|
107
|
+
// SandboxSession contract.
|
|
108
|
+
await args.sandbox.writeTextFile({
|
|
109
|
+
path: remotePath,
|
|
110
|
+
content: JSON.stringify(args.messages),
|
|
111
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pull a previously persisted conversation history from the sandbox. Returns
|
|
117
|
+
* `undefined` when the file is missing or unparsable — resume then falls back
|
|
118
|
+
* to a fresh conversation rather than failing `doStart`.
|
|
119
|
+
*/
|
|
120
|
+
export async function pullHistoryFromSandbox(args: {
|
|
121
|
+
readonly sandbox: Experimental_SandboxSession;
|
|
122
|
+
readonly privateSessionDir: string;
|
|
123
|
+
readonly historyFileName: string;
|
|
124
|
+
readonly abortSignal?: AbortSignal;
|
|
125
|
+
}): Promise<AgentMessage[] | undefined> {
|
|
126
|
+
const remotePath = resolveContainedSandboxPath({
|
|
127
|
+
privateSessionDir: args.privateSessionDir,
|
|
128
|
+
historyFileName: args.historyFileName,
|
|
129
|
+
});
|
|
130
|
+
const content = await args.sandbox.readTextFile({
|
|
131
|
+
path: remotePath,
|
|
132
|
+
...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
|
|
133
|
+
});
|
|
134
|
+
if (content == null) return undefined;
|
|
135
|
+
const parsed = await safeParseJSON({ text: content });
|
|
136
|
+
return parsed.success && Array.isArray(parsed.value)
|
|
137
|
+
? (parsed.value as unknown as AgentMessage[])
|
|
138
|
+
: undefined;
|
|
139
|
+
}
|