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