@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.
@@ -0,0 +1,827 @@
1
+ import {
2
+ HarnessCapabilityUnsupportedError,
3
+ type HarnessV1BuiltinToolFiltering,
4
+ type HarnessV1ContinueTurnOptions,
5
+ type HarnessV1ContinueTurnState,
6
+ type HarnessV1NetworkSandboxSession,
7
+ type HarnessV1PermissionMode,
8
+ type HarnessV1PromptControl,
9
+ type HarnessV1PromptTurnOptions,
10
+ type HarnessV1ResumeSessionState,
11
+ type HarnessV1Session,
12
+ type HarnessV1Skill,
13
+ type HarnessV1StreamPart,
14
+ type HarnessV1ToolSpec,
15
+ } from '@ai-sdk/harness';
16
+ import { resolveSandboxHomeDir } from '@ai-sdk/harness/utils';
17
+ import {
18
+ Agent,
19
+ type AgentMessage,
20
+ type AgentModel,
21
+ type AgentRunResult,
22
+ } from '@cline/agents';
23
+ import { Llms } from '@cline/core';
24
+ import { createClineMcpRuntime } from './cline-mcp';
25
+ import { createClineRemoteOps } from './cline-remote-ops';
26
+ import {
27
+ CLINE_DEFAULT_HISTORY_FILE_NAME,
28
+ persistHistoryToSandbox,
29
+ pullHistoryFromSandbox,
30
+ resolveClinePrivateSessionDirectory,
31
+ safeClineHistoryFileName,
32
+ } from './cline-resume-state';
33
+ import { renderSkillsPromptSection, writeClineSkills } from './cline-skills';
34
+ import {
35
+ buildBuiltinAgentTools,
36
+ buildUserAgentTools,
37
+ CLINE_NATIVE_TOOL_KINDS,
38
+ createClineToolResult,
39
+ isClineBuiltinToolName,
40
+ resolveActiveClineBuiltinNames,
41
+ type PendingToolResult,
42
+ unwrapClineToolResult,
43
+ } from './cline-tools';
44
+ import {
45
+ createClineTranslatorState,
46
+ finishClineTranslation,
47
+ toolApprovalParts,
48
+ translateClineEvent,
49
+ type ClineTranslatorState,
50
+ } from './cline-translate';
51
+ import {
52
+ extractUserText,
53
+ mapRunFinishReason,
54
+ usageFromAgentUsage,
55
+ } from './cline-utils';
56
+
57
+ const HARNESS_ID = 'cline';
58
+
59
+ export type ClineReasoningEffort =
60
+ | 'none'
61
+ | 'minimal'
62
+ | 'low'
63
+ | 'medium'
64
+ | 'high'
65
+ | 'xhigh'
66
+ | 'max';
67
+
68
+ /*
69
+ * The Cline runtime lives in this Node process, not behind an attachable
70
+ * in-sandbox bridge. During a tool approval pause the turn is still alive and
71
+ * blocked on a parked promise, so detach must keep that live session for the
72
+ * next same-process resume instead of stopping it and failing the promise.
73
+ * Cross-process resume falls back to the history file persisted in the
74
+ * sandbox.
75
+ */
76
+ const parkedClineSessions = new Map<string, HarnessV1Session>();
77
+
78
+ export interface ClineSessionSettings {
79
+ readonly authEnv: Record<string, string>;
80
+ readonly mcpServers?: Record<string, unknown>;
81
+ readonly providerId?: string;
82
+ readonly modelId?: string;
83
+ readonly apiKey?: string;
84
+ readonly baseUrl?: string;
85
+ readonly headers?: Record<string, string>;
86
+ readonly reasoningEffort?: ClineReasoningEffort;
87
+ readonly maxIterations?: number;
88
+ }
89
+
90
+ export interface CreateClineSessionInput {
91
+ readonly sessionId: string;
92
+ readonly sandboxSession: HarnessV1NetworkSandboxSession;
93
+ readonly sessionWorkDir: string;
94
+ readonly skills: ReadonlyArray<HarnessV1Skill>;
95
+ readonly settings: ClineSessionSettings;
96
+ readonly clientApp: string;
97
+ readonly isResume: boolean;
98
+ readonly permissionMode?: HarnessV1PermissionMode;
99
+ readonly builtinToolFiltering?: HarnessV1BuiltinToolFiltering;
100
+ readonly resumeHistoryFileName?: string;
101
+ readonly abortSignal?: AbortSignal;
102
+ }
103
+
104
+ interface PendingToolApproval {
105
+ resolve: (value: { approved: boolean; reason?: string }) => void;
106
+ }
107
+
108
+ interface ActiveClineTurn {
109
+ readonly token: object;
110
+ readonly done: Promise<void>;
111
+ }
112
+
113
+ /**
114
+ * Whether a thrown error is an abort — the expected result of
115
+ * `doSuspendTurn` aborting the in-flight turn. Only these are safe to swallow
116
+ * while `suspending`; any other error must surface as an `error` chunk.
117
+ */
118
+ function isAbortError(value: unknown): boolean {
119
+ if (value == null) return false;
120
+ const name = (value as { name?: unknown }).name;
121
+ if (name === 'AbortError' || name === 'AgentRuntimeAbortError') {
122
+ return true;
123
+ }
124
+ const text =
125
+ typeof value === 'string'
126
+ ? value
127
+ : value instanceof Error
128
+ ? value.message
129
+ : String(value);
130
+ return /\baborted\b|AbortError/i.test(text);
131
+ }
132
+
133
+ function clineBuiltinToolRequiresApproval(input: {
134
+ permissionMode: HarnessV1PermissionMode;
135
+ kind: 'readonly' | 'edit' | 'bash';
136
+ }): boolean {
137
+ if (input.permissionMode === 'allow-all') return false;
138
+ if (input.permissionMode === 'allow-edits') return input.kind === 'bash';
139
+ return input.kind === 'edit' || input.kind === 'bash';
140
+ }
141
+
142
+ function buildSystemPrompt(input: {
143
+ sessionWorkDir: string;
144
+ sandboxDescription: string;
145
+ skillsSection?: string;
146
+ }): string {
147
+ const sections = [
148
+ 'You are Cline, an autonomous coding agent operating inside a sandboxed workspace.',
149
+ `## Workspace\n\nThe workspace root is \`${input.sessionWorkDir}\`. All relative file paths and shell commands resolve against it. Use the provided tools (read, write, edit, bash, grep, glob, ls) to inspect and modify the workspace.`,
150
+ `## Sandbox\n\n${input.sandboxDescription}`,
151
+ ];
152
+ if (input.skillsSection) {
153
+ sections.push(input.skillsSection);
154
+ }
155
+ return sections.join('\n\n');
156
+ }
157
+
158
+ function toClineBackendProviderBaseUrl(baseUrl: string): string {
159
+ return `${baseUrl.replace(/\/+$/, '')}/api/v1`;
160
+ }
161
+
162
+ function toAiGatewayProviderBaseUrl(baseUrl: string): string {
163
+ const trimmed = baseUrl.replace(/\/+$/, '');
164
+ return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
165
+ }
166
+
167
+ function createClineAgentModel({
168
+ settings,
169
+ clientApp,
170
+ }: {
171
+ settings: ClineSessionSettings;
172
+ clientApp: string;
173
+ }): { model: AgentModel; providerId: string } {
174
+ const gatewayBaseUrl = settings.authEnv.AI_GATEWAY_BASE_URL;
175
+ const isAiGateway = gatewayBaseUrl != null;
176
+ const providerId = isAiGateway ? 'cline' : (settings.providerId ?? 'cline');
177
+ const apiKey = isAiGateway
178
+ ? settings.authEnv.AI_GATEWAY_API_KEY
179
+ : (settings.apiKey ??
180
+ (providerId === 'cline' ? settings.authEnv.CLINE_API_KEY : undefined));
181
+ const baseUrl = isAiGateway
182
+ ? toAiGatewayProviderBaseUrl(gatewayBaseUrl)
183
+ : (settings.baseUrl ??
184
+ (providerId === 'cline' && settings.authEnv.CLINE_API_BASE_URL
185
+ ? toClineBackendProviderBaseUrl(settings.authEnv.CLINE_API_BASE_URL)
186
+ : undefined));
187
+ const headers = isAiGateway
188
+ ? {
189
+ ...settings.headers,
190
+ 'User-Agent': clientApp,
191
+ 'x-client-app': clientApp,
192
+ }
193
+ : settings.headers;
194
+ const gateway = Llms.createGateway({
195
+ providerConfigs: [
196
+ {
197
+ providerId,
198
+ ...(apiKey ? { apiKey } : {}),
199
+ ...(baseUrl ? { baseUrl } : {}),
200
+ ...(headers ? { headers } : {}),
201
+ ...(isAiGateway ? { apiKeyEnv: [] } : {}),
202
+ },
203
+ ],
204
+ });
205
+ const modelSelection = {
206
+ providerId,
207
+ ...(settings.modelId ? { modelId: settings.modelId } : {}),
208
+ };
209
+ const modelOptions =
210
+ settings.reasoningEffort === 'none'
211
+ ? { reasoning: { enabled: false } }
212
+ : {
213
+ reasoning: {
214
+ enabled: true,
215
+ effort: settings.reasoningEffort,
216
+ },
217
+ };
218
+ const model =
219
+ settings.reasoningEffort === undefined
220
+ ? gateway.createAgentModel(modelSelection)
221
+ : gateway.createAgentModel(modelSelection, modelOptions);
222
+
223
+ return {
224
+ model,
225
+ providerId,
226
+ };
227
+ }
228
+
229
+ export async function createClineSession(
230
+ input: CreateClineSessionInput,
231
+ ): Promise<HarnessV1Session> {
232
+ if (input.isResume) {
233
+ const parkedSession = parkedClineSessions.get(input.sessionId);
234
+ if (parkedSession) {
235
+ parkedClineSessions.delete(input.sessionId);
236
+ return {
237
+ ...parkedSession,
238
+ isResume: true,
239
+ };
240
+ }
241
+ }
242
+
243
+ const sandbox = input.sandboxSession.restricted();
244
+ const sandboxHomeDir = await resolveSandboxHomeDir({
245
+ sandbox,
246
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
247
+ });
248
+ const privateSessionDir = resolveClinePrivateSessionDirectory({
249
+ sandboxHomeDir,
250
+ sessionWorkDir: input.sessionWorkDir,
251
+ sessionId: input.sessionId,
252
+ });
253
+ const permissionMode = input.permissionMode ?? 'allow-all';
254
+ const activeBuiltinNames = resolveActiveClineBuiltinNames(
255
+ input.builtinToolFiltering,
256
+ );
257
+
258
+ const ops = createClineRemoteOps({
259
+ sandbox,
260
+ workDir: input.sessionWorkDir,
261
+ });
262
+
263
+ // Materialize harness-provided skills into sandbox HOME (not the workspace)
264
+ // and advertise them via a system prompt section.
265
+ let skillsSection: string | undefined;
266
+ if (input.skills.length > 0) {
267
+ const skillRootDir = await writeClineSkills({
268
+ sandbox,
269
+ sandboxHomeDir,
270
+ skills: input.skills,
271
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
272
+ });
273
+ if (skillRootDir) {
274
+ skillsSection = renderSkillsPromptSection(input.skills, skillRootDir);
275
+ }
276
+ }
277
+
278
+ const baseSystemPrompt = buildSystemPrompt({
279
+ sessionWorkDir: input.sessionWorkDir,
280
+ sandboxDescription: sandbox.description,
281
+ ...(skillsSection ? { skillsSection } : {}),
282
+ });
283
+
284
+ // On resume: pull the persisted conversation history from the sandbox so
285
+ // the fresh runtime instance starts from the prior transcript.
286
+ let currentMessages: readonly AgentMessage[] = [];
287
+ if (input.isResume && input.resumeHistoryFileName) {
288
+ const restored = await pullHistoryFromSandbox({
289
+ sandbox,
290
+ privateSessionDir,
291
+ historyFileName: safeClineHistoryFileName(input.resumeHistoryFileName),
292
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
293
+ });
294
+ if (restored) {
295
+ currentMessages = restored;
296
+ }
297
+ }
298
+ const mcpRuntime = await createClineMcpRuntime({
299
+ mcpServers: input.settings.mcpServers,
300
+ });
301
+ const { model, providerId } = createClineAgentModel({
302
+ settings: input.settings,
303
+ clientApp: input.clientApp,
304
+ });
305
+
306
+ // Per-session mutable state we hold across prompts.
307
+ let agent: Agent | undefined;
308
+ let unsubscribe: (() => void) | undefined;
309
+ let lastToolsSignature: string | undefined;
310
+ let agentHasRun = false;
311
+ let stopped = false;
312
+ /*
313
+ * Set by `doSuspendTurn` before it aborts the in-flight turn at a slice
314
+ * boundary. The turn settles silently when this is set, so the stream
315
+ * closes cleanly (no spurious `error` chunk) — the next slice
316
+ * rerun-continues from the persisted history.
317
+ */
318
+ let suspending = false;
319
+ const pendingToolResults = new Map<string, PendingToolResult>();
320
+ const pendingToolApprovals = new Map<string, PendingToolApproval>();
321
+ const pendingUserMessages: string[] = [];
322
+
323
+ // Emit channel set at the start of every turn and cleared on end.
324
+ let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
325
+ let translatorState: ClineTranslatorState | undefined;
326
+ let activeTurn: ActiveClineTurn | undefined;
327
+
328
+ function settlePendingToolResults(reason: string): void {
329
+ for (const pending of pendingToolResults.values()) {
330
+ pending.resolve(
331
+ createClineToolResult({
332
+ output: { error: reason },
333
+ isError: true,
334
+ }),
335
+ );
336
+ }
337
+ pendingToolResults.clear();
338
+ }
339
+
340
+ function settlePendingToolApprovals(reason: string): void {
341
+ for (const pending of pendingToolApprovals.values()) {
342
+ pending.resolve({ approved: false, reason });
343
+ }
344
+ pendingToolApprovals.clear();
345
+ }
346
+
347
+ async function persistHistory(): Promise<void> {
348
+ const messages = agent?.snapshot().messages ?? currentMessages;
349
+ await persistHistoryToSandbox({
350
+ sandbox,
351
+ privateSessionDir,
352
+ historyFileName: CLINE_DEFAULT_HISTORY_FILE_NAME,
353
+ messages,
354
+ });
355
+ }
356
+
357
+ function requestBuiltinToolApproval(toolCall: {
358
+ toolCallId: string;
359
+ toolName: string;
360
+ input: unknown;
361
+ }): Promise<{ approved: boolean; reason?: string }> {
362
+ if (translatorState && currentEmit) {
363
+ for (const part of toolApprovalParts(translatorState, toolCall)) {
364
+ currentEmit(part);
365
+ }
366
+ }
367
+ return new Promise(resolve => {
368
+ pendingToolApprovals.set(toolCall.toolCallId, { resolve });
369
+ });
370
+ }
371
+
372
+ function rebuildAgent(rebuildInput: {
373
+ userTools: ReadonlyArray<HarnessV1ToolSpec>;
374
+ instructions?: string;
375
+ }): void {
376
+ unsubscribe?.();
377
+ unsubscribe = undefined;
378
+ agent = new Agent({
379
+ model,
380
+ ...(input.settings.modelId
381
+ ? {
382
+ messageModelInfo: {
383
+ id: input.settings.modelId,
384
+ provider: providerId,
385
+ },
386
+ }
387
+ : {}),
388
+ sessionId: input.sessionId,
389
+ systemPrompt: rebuildInput.instructions
390
+ ? `${baseSystemPrompt}\n\n${rebuildInput.instructions}`
391
+ : baseSystemPrompt,
392
+ initialMessages: currentMessages,
393
+ toolExecution: 'parallel',
394
+ ...(input.settings.maxIterations !== undefined
395
+ ? { maxIterations: input.settings.maxIterations }
396
+ : {}),
397
+ tools: [
398
+ ...buildBuiltinAgentTools({ ops, activeNames: activeBuiltinNames }),
399
+ ...mcpRuntime.tools,
400
+ ...buildUserAgentTools({
401
+ specs: rebuildInput.userTools,
402
+ pendingToolResults,
403
+ }),
404
+ ],
405
+ hooks: {
406
+ afterTool: ({ result }) => {
407
+ const toolResult = unwrapClineToolResult(result.output);
408
+ return toolResult == null ? undefined : { result: toolResult };
409
+ },
410
+ },
411
+ // Built-in tools whose kind requires approval under the session's
412
+ // permission mode are marked `autoApprove: false`; the runtime then
413
+ // routes them through `requestToolApproval` below. User tools are
414
+ // approved by the harness framework, never by the adapter.
415
+ toolPolicies: Object.fromEntries(
416
+ activeBuiltinNames
417
+ .filter(name =>
418
+ clineBuiltinToolRequiresApproval({
419
+ permissionMode,
420
+ kind: CLINE_NATIVE_TOOL_KINDS[name],
421
+ }),
422
+ )
423
+ .map(name => [name, { autoApprove: false }]),
424
+ ),
425
+ requestToolApproval: request => {
426
+ if (!isClineBuiltinToolName(request.toolName)) {
427
+ // Custom host-executed tool approvals are handled by the framework
428
+ // before results are submitted back; the adapter never gates them.
429
+ return { approved: true };
430
+ }
431
+ return requestBuiltinToolApproval({
432
+ toolCallId: request.toolCallId,
433
+ toolName: request.toolName,
434
+ input: request.input,
435
+ });
436
+ },
437
+ // Mid-turn user messages injected via `submitUserMessage` are consumed
438
+ // by the runtime between loop iterations, before the next model call.
439
+ consumePendingUserMessage: () => pendingUserMessages.shift(),
440
+ });
441
+ agentHasRun = false;
442
+
443
+ unsubscribe = agent.subscribe(event => {
444
+ if (!translatorState || !currentEmit) return;
445
+ for (const part of translateClineEvent(event, translatorState)) {
446
+ currentEmit(part);
447
+ }
448
+ });
449
+ }
450
+
451
+ function createPromptControl(controlInput: {
452
+ done: Promise<void>;
453
+ abortSignal?: AbortSignal;
454
+ }): HarnessV1PromptControl {
455
+ const abortHandler = () => {
456
+ // Settle parked host-tool promises first so the runtime's tool
457
+ // executions do not dangle, then abort the loop itself.
458
+ settlePendingToolResults('Turn aborted');
459
+ settlePendingToolApprovals('Turn aborted');
460
+ agent?.abort('Turn aborted by caller');
461
+ };
462
+ if (controlInput.abortSignal) {
463
+ controlInput.abortSignal.addEventListener('abort', abortHandler, {
464
+ once: true,
465
+ });
466
+ void controlInput.done.then(
467
+ () => {
468
+ controlInput.abortSignal?.removeEventListener('abort', abortHandler);
469
+ },
470
+ () => {
471
+ controlInput.abortSignal?.removeEventListener('abort', abortHandler);
472
+ },
473
+ );
474
+ }
475
+
476
+ return {
477
+ async submitToolResult(args) {
478
+ const pending = pendingToolResults.get(args.toolCallId);
479
+ if (!pending) return;
480
+ pendingToolResults.delete(args.toolCallId);
481
+ pending.resolve(
482
+ createClineToolResult({
483
+ output: args.output,
484
+ isError: args.isError,
485
+ }),
486
+ );
487
+ },
488
+ async submitToolApproval(args) {
489
+ const pending = pendingToolApprovals.get(args.approvalId);
490
+ if (!pending) return;
491
+ pendingToolApprovals.delete(args.approvalId);
492
+ pending.resolve({
493
+ approved: args.approved,
494
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
495
+ });
496
+ },
497
+ async submitUserMessage(text) {
498
+ pendingUserMessages.push(text);
499
+ },
500
+ done: controlInput.done,
501
+ };
502
+ }
503
+
504
+ /*
505
+ * Drive one turn against the Cline runtime and return the control surface.
506
+ * Shared by `doPromptTurn` (a fresh user prompt) and `doContinueTurn`
507
+ * (no prompt — the runtime continues its own thread after a rerun resume).
508
+ */
509
+ function runTurn(turnOpts: {
510
+ text: string | undefined;
511
+ tools: ReadonlyArray<HarnessV1ToolSpec>;
512
+ instructions?: string;
513
+ emit: (part: HarnessV1StreamPart) => void;
514
+ abortSignal?: AbortSignal;
515
+ }): HarnessV1PromptControl {
516
+ if (stopped) {
517
+ throw new Error('Cline session has been stopped.');
518
+ }
519
+
520
+ const userTools = turnOpts.tools;
521
+ const signature = JSON.stringify(userTools.map(t => t.name).sort());
522
+ if (agent == null || signature !== lastToolsSignature) {
523
+ currentMessages = agent?.snapshot().messages ?? currentMessages;
524
+ rebuildAgent({
525
+ userTools,
526
+ ...(turnOpts.instructions
527
+ ? { instructions: turnOpts.instructions }
528
+ : {}),
529
+ });
530
+ lastToolsSignature = signature;
531
+ }
532
+
533
+ currentEmit = turnOpts.emit;
534
+ translatorState = createClineTranslatorState({
535
+ builtinToolNames: activeBuiltinNames,
536
+ hostToolNames: userTools.map(tool => tool.name),
537
+ mcpToolNames: mcpRuntime.toolNames,
538
+ });
539
+
540
+ turnOpts.emit({
541
+ type: 'stream-start',
542
+ ...(input.settings.modelId ? { modelId: input.settings.modelId } : {}),
543
+ });
544
+
545
+ const turnPromise = (async () => {
546
+ const runtime = agent!;
547
+ let result: AgentRunResult;
548
+ try {
549
+ // `text` is undefined only on rerun-continue: `continue()` without
550
+ // input re-drives the loop from the restored transcript instead of
551
+ // pushing an empty user message.
552
+ result =
553
+ turnOpts.text === undefined
554
+ ? await runtime.continue()
555
+ : agentHasRun
556
+ ? await runtime.continue(turnOpts.text)
557
+ : await runtime.run(turnOpts.text);
558
+ agentHasRun = true;
559
+ } catch (error) {
560
+ // `run` resolves for aborted/failed statuses; a throw here is
561
+ // unanticipated (e.g. config error). Swallow only the abort our own
562
+ // suspend caused; surface anything else.
563
+ if (suspending && isAbortError(error)) return;
564
+ currentEmit?.({ type: 'error', error });
565
+ return;
566
+ }
567
+
568
+ currentMessages = result.messages;
569
+
570
+ if (translatorState) {
571
+ for (const part of finishClineTranslation(translatorState)) {
572
+ currentEmit?.(part);
573
+ }
574
+ }
575
+
576
+ if (result.status === 'aborted') {
577
+ /*
578
+ * A `doSuspendTurn` aborts the in-flight turn on purpose — settle
579
+ * silently so the stream closes cleanly; the next slice
580
+ * rerun-continues from the persisted history. Caller-driven aborts
581
+ * finish the turn with an `other`/aborted reason.
582
+ */
583
+ if (suspending) return;
584
+ currentEmit?.({
585
+ type: 'finish',
586
+ finishReason: mapRunFinishReason(result.status),
587
+ totalUsage: usageFromAgentUsage(result.usage),
588
+ });
589
+ return;
590
+ }
591
+
592
+ if (result.status === 'failed') {
593
+ currentEmit?.({
594
+ type: 'error',
595
+ error: result.error ?? new Error('Cline agent run failed'),
596
+ });
597
+ return;
598
+ }
599
+
600
+ currentEmit?.({
601
+ type: 'finish',
602
+ finishReason: mapRunFinishReason(result.status),
603
+ totalUsage: usageFromAgentUsage(result.usage),
604
+ });
605
+ })();
606
+
607
+ const activeTurnToken = {};
608
+ const done = turnPromise.finally(() => {
609
+ if (activeTurn?.token === activeTurnToken) {
610
+ activeTurn = undefined;
611
+ }
612
+ currentEmit = undefined;
613
+ });
614
+ activeTurn = {
615
+ token: activeTurnToken,
616
+ done,
617
+ };
618
+
619
+ return createPromptControl({
620
+ done,
621
+ ...(turnOpts.abortSignal ? { abortSignal: turnOpts.abortSignal } : {}),
622
+ });
623
+ }
624
+
625
+ async function teardown(): Promise<void> {
626
+ unsubscribe?.();
627
+ unsubscribe = undefined;
628
+ agent = undefined;
629
+ await mcpRuntime.dispose();
630
+ }
631
+
632
+ const doStop = async (): Promise<HarnessV1ResumeSessionState> => {
633
+ if (stopped) {
634
+ throw new Error('Cline session has been stopped.');
635
+ }
636
+ stopped = true;
637
+ parkedClineSessions.delete(input.sessionId);
638
+ settlePendingToolResults('Cline session stopped');
639
+ settlePendingToolApprovals('Cline session stopped');
640
+
641
+ // Persist the conversation into the sandbox so a future process can pick
642
+ // it up after the sandbox provider reattaches.
643
+ try {
644
+ await persistHistory();
645
+ } catch {
646
+ // Best-effort: a missing history file means resume returns to a fresh
647
+ // conversation rather than failing stop.
648
+ }
649
+
650
+ agent?.abort('Cline session stopped');
651
+ await teardown();
652
+
653
+ return {
654
+ type: 'resume-session',
655
+ harnessId: HARNESS_ID,
656
+ specificationVersion: 'harness-v1',
657
+ data: { historyFileName: CLINE_DEFAULT_HISTORY_FILE_NAME },
658
+ };
659
+ };
660
+
661
+ const sessionImpl: HarnessV1Session = {
662
+ sessionId: input.sessionId,
663
+ isResume: input.isResume,
664
+ ...(input.settings.modelId ? { modelId: input.settings.modelId } : {}),
665
+
666
+ // The Cline runtime has no bridge to attach to and no in-sandbox event
667
+ // log to replay; its only cross-process resume path is restoring the
668
+ // persisted history on a fresh runtime, i.e. `rerun`.
669
+
670
+ doPromptTurn: async (
671
+ promptOpts: HarnessV1PromptTurnOptions,
672
+ ): Promise<HarnessV1PromptControl> => {
673
+ return runTurn({
674
+ text: extractUserText(promptOpts.prompt),
675
+ tools: promptOpts.tools ?? [],
676
+ ...(promptOpts.instructions
677
+ ? { instructions: promptOpts.instructions }
678
+ : {}),
679
+ emit: promptOpts.emit,
680
+ ...(promptOpts.abortSignal
681
+ ? { abortSignal: promptOpts.abortSignal }
682
+ : {}),
683
+ });
684
+ },
685
+
686
+ doContinueTurn: async (
687
+ continueOpts: HarnessV1ContinueTurnOptions,
688
+ ): Promise<HarnessV1PromptControl> => {
689
+ if (activeTurn != null) {
690
+ currentEmit = continueOpts.emit;
691
+ return createPromptControl({
692
+ done: activeTurn.done,
693
+ ...(continueOpts.abortSignal
694
+ ? { abortSignal: continueOpts.abortSignal }
695
+ : {}),
696
+ });
697
+ }
698
+
699
+ /*
700
+ * The model runs on the host, so there is no live turn to attach to —
701
+ * the previous slice's turn died with its process. Rerun-continue:
702
+ * re-drive the runtime from the history restored on resume. Lossy —
703
+ * work in flight at the slice boundary is recomputed, because a
704
+ * host-resident runtime cannot do a lossless attach.
705
+ */
706
+ return runTurn({
707
+ text: undefined,
708
+ tools: continueOpts.tools ?? [],
709
+ ...(continueOpts.instructions
710
+ ? { instructions: continueOpts.instructions }
711
+ : {}),
712
+ emit: continueOpts.emit,
713
+ ...(continueOpts.abortSignal
714
+ ? { abortSignal: continueOpts.abortSignal }
715
+ : {}),
716
+ });
717
+ },
718
+
719
+ doCompact: async () => {
720
+ throw new HarnessCapabilityUnsupportedError({
721
+ message:
722
+ 'cline: manual compaction is not supported by the standalone Cline runtime.',
723
+ harnessId: HARNESS_ID,
724
+ });
725
+ },
726
+
727
+ doDestroy: async () => {
728
+ if (stopped) return;
729
+ stopped = true;
730
+ parkedClineSessions.delete(input.sessionId);
731
+ settlePendingToolResults('Cline session stopped');
732
+ settlePendingToolApprovals('Cline session stopped');
733
+ agent?.abort('Cline session destroyed');
734
+ await teardown();
735
+ },
736
+
737
+ doStop,
738
+
739
+ doDetach: async (): Promise<HarnessV1ResumeSessionState> => {
740
+ if (activeTurn != null || pendingToolResults.size > 0) {
741
+ parkedClineSessions.set(input.sessionId, sessionImpl);
742
+ try {
743
+ await persistHistory();
744
+ } catch {
745
+ /*
746
+ * The parked in-process session is the authoritative continuation
747
+ * path while the live turn is waiting on host input. Persistence
748
+ * is only a fallback for later non-live resumes.
749
+ */
750
+ }
751
+ return {
752
+ type: 'resume-session',
753
+ harnessId: HARNESS_ID,
754
+ specificationVersion: 'harness-v1',
755
+ data: { historyFileName: CLINE_DEFAULT_HISTORY_FILE_NAME },
756
+ };
757
+ }
758
+ return doStop();
759
+ },
760
+
761
+ doSuspendTurn: async (): Promise<HarnessV1ContinueTurnState> => {
762
+ if (stopped) {
763
+ throw new Error('Cline session has been stopped.');
764
+ }
765
+ if (
766
+ activeTurn != null &&
767
+ (pendingToolResults.size > 0 || pendingToolApprovals.size > 0)
768
+ ) {
769
+ parkedClineSessions.set(input.sessionId, sessionImpl);
770
+ try {
771
+ await persistHistory();
772
+ } catch {
773
+ /*
774
+ * While waiting on host input, the live parked session is the
775
+ * authoritative same-process continuation path. The sandbox copy
776
+ * remains a best-effort fallback for a later cold resume.
777
+ */
778
+ }
779
+ return {
780
+ type: 'continue-turn',
781
+ harnessId: HARNESS_ID,
782
+ specificationVersion: 'harness-v1',
783
+ data: { historyFileName: CLINE_DEFAULT_HISTORY_FILE_NAME },
784
+ };
785
+ }
786
+ /*
787
+ * The model runs in this host process, which is about to be suspended
788
+ * at the slice boundary — the in-flight turn cannot survive it. Abort
789
+ * it (the turn settles silently via the `suspending` guard so the
790
+ * stream closes cleanly), persist the history into the sandbox, and
791
+ * tear down host-side resources. The sandbox itself is left running;
792
+ * the next slice pulls the history after the sandbox provider
793
+ * reattaches and rerun-continues. The tail in flight at the boundary
794
+ * is recomputed — a host-resident runtime cannot freeze a live turn
795
+ * the way a bridge adapter can.
796
+ */
797
+ suspending = true;
798
+ agent?.abort('Cline session suspended');
799
+ if (activeTurn) {
800
+ await activeTurn.done.catch(() => {});
801
+ }
802
+
803
+ try {
804
+ await persistHistory();
805
+ } catch {
806
+ // Best-effort: a missing/failed copy leaves the previously persisted
807
+ // history in place, so the next slice resumes from a slightly older
808
+ // (still valid) state.
809
+ }
810
+
811
+ stopped = true;
812
+ parkedClineSessions.delete(input.sessionId);
813
+ settlePendingToolResults('Cline session suspended');
814
+ settlePendingToolApprovals('Cline session suspended');
815
+ await teardown();
816
+
817
+ return {
818
+ type: 'continue-turn',
819
+ harnessId: HARNESS_ID,
820
+ specificationVersion: 'harness-v1',
821
+ data: { historyFileName: CLINE_DEFAULT_HISTORY_FILE_NAME },
822
+ };
823
+ },
824
+ };
825
+
826
+ return sessionImpl;
827
+ }