@agent-link/agent 0.1.333 → 0.1.334

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/copilot.js CHANGED
@@ -14,12 +14,14 @@ import { readdirSync, readFileSync, writeFileSync, existsSync, rmSync, mkdirSync
14
14
  import { basename, dirname, extname, join, resolve } from 'path';
15
15
  import { homedir, platform } from 'os';
16
16
  import { randomUUID } from 'crypto';
17
+ import * as acp from '@agentclientprotocol/sdk';
17
18
  import { processUserMessage } from './history-message-transform.js';
18
19
  import { copilotCapabilities } from './backends/types.js';
19
20
  import { CONFIG_DIR } from './config.js';
20
21
  import { getCleanEnv, resolveExecutablePath } from './sdk.js';
21
22
  import { getProvider, resolveProviderCommand } from './providers.js';
22
23
  import { FileCache } from './file-cache.js';
24
+ import { CopilotCommandRuntime, } from './copilot-command-bridge.js';
23
25
  // ── Module state ───────────────────────────────────────────────────────────
24
26
  const conversations = new Map();
25
27
  const sendFns = new Set();
@@ -27,6 +29,24 @@ let globalModel = null;
27
29
  const MODEL_PATTERN = /^[a-zA-Z0-9._-]+$/;
28
30
  const CONTEXT_TIERS = new Set(['default', 'long_context']);
29
31
  const REASONING_EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
32
+ const COPILOT_PLAN_INPUT_TIMEOUT_MS = 10 * 60_000;
33
+ const COPILOT_CONTINUE_PLAN_ANSWER = 'Continue developing the plan. In your final response, ask me to confirm the plan or describe any changes.';
34
+ const pendingCopilotDecisions = new Map();
35
+ class CopilotDecisionCancelledError extends Error {
36
+ constructor() {
37
+ super('Copilot decision request was cancelled');
38
+ }
39
+ }
40
+ function setCopilotModeState(state, mode) {
41
+ state.copilotMode = mode;
42
+ state.planMode = mode === 'plan';
43
+ sendFn({
44
+ type: 'plan_mode_changed',
45
+ enabled: state.planMode,
46
+ mode,
47
+ conversationId: state.conversationId,
48
+ });
49
+ }
30
50
  const COPILOT_NATIVE_ATTACHMENT_MIME_TYPES = new Set([
31
51
  'application/pdf',
32
52
  'image/gif',
@@ -60,6 +80,107 @@ export function addCopilotSendFn(fn) {
60
80
  sendFns.add(fn);
61
81
  return () => { sendFns.delete(fn); };
62
82
  }
83
+ function emitCopilotDecisionRequest(pending) {
84
+ sendFn({
85
+ type: 'backend_event',
86
+ conversationId: pending.conversationId,
87
+ providerId: pending.providerId,
88
+ claudeSessionId: pending.sessionId,
89
+ event: {
90
+ type: 'user_input_requested',
91
+ session: sessionRef(pending.sessionId),
92
+ requestId: pending.requestId,
93
+ request: pending.request,
94
+ },
95
+ });
96
+ }
97
+ function settleCopilotDecision(pending, answer) {
98
+ if (pendingCopilotDecisions.get(pending.requestId) !== pending)
99
+ return false;
100
+ pendingCopilotDecisions.delete(pending.requestId);
101
+ if (pending.timer)
102
+ clearTimeout(pending.timer);
103
+ pending.timer = null;
104
+ sendFn({
105
+ type: 'backend_event',
106
+ conversationId: pending.conversationId,
107
+ providerId: pending.providerId,
108
+ claudeSessionId: pending.sessionId,
109
+ event: {
110
+ type: 'user_input_resolved',
111
+ session: sessionRef(pending.sessionId),
112
+ requestId: pending.requestId,
113
+ resolution: answer.action === 'cancel' ? 'cancelled' : 'answered',
114
+ },
115
+ });
116
+ pending.resolve(answer);
117
+ return true;
118
+ }
119
+ function waitForCopilotDecision(state, sessionId, request, choices = []) {
120
+ const requestId = randomUUID();
121
+ return new Promise(resolvePromise => {
122
+ const pending = {
123
+ requestId,
124
+ conversationId: state.conversationId,
125
+ providerId: state.providerId,
126
+ sessionId,
127
+ request,
128
+ choices,
129
+ resolve: resolvePromise,
130
+ timer: null,
131
+ };
132
+ pendingCopilotDecisions.set(requestId, pending);
133
+ if (request.expiresAt) {
134
+ pending.timer = setTimeout(() => {
135
+ if (choices.length > 0) {
136
+ settleCopilotDecision(pending, { action: 'answer', answer: choices[0] });
137
+ }
138
+ else {
139
+ settleCopilotDecision(pending, {
140
+ action: 'answer',
141
+ answer: COPILOT_CONTINUE_PLAN_ANSWER,
142
+ });
143
+ }
144
+ }, Math.max(0, request.expiresAt - Date.now()));
145
+ }
146
+ emitCopilotDecisionRequest(pending);
147
+ });
148
+ }
149
+ function cancelPendingCopilotDecisions(conversationId) {
150
+ for (const pending of [...pendingCopilotDecisions.values()]) {
151
+ if (pending.conversationId === conversationId) {
152
+ settleCopilotDecision(pending, { action: 'cancel' });
153
+ }
154
+ }
155
+ }
156
+ export function getPendingCopilotDecisions(conversationId) {
157
+ return [...pendingCopilotDecisions.values()]
158
+ .filter(pending => pending.conversationId === conversationId)
159
+ .map(pending => ({
160
+ requestId: pending.requestId,
161
+ conversationId: pending.conversationId,
162
+ providerId: pending.providerId,
163
+ sessionId: pending.sessionId,
164
+ request: pending.request,
165
+ }));
166
+ }
167
+ export function handleCopilotUserAnswer(requestId, answers, scope = {}) {
168
+ const pending = pendingCopilotDecisions.get(requestId);
169
+ if (!pending)
170
+ return false;
171
+ if (scope.conversationId && pending.conversationId !== scope.conversationId)
172
+ return false;
173
+ if (scope.providerId && pending.providerId !== scope.providerId)
174
+ return false;
175
+ const action = typeof answers.__action === 'string' ? answers.__action : undefined;
176
+ const feedback = typeof answers.__feedback === 'string' ? answers.__feedback.trim() : undefined;
177
+ const firstAnswer = Object.entries(answers)
178
+ .find(([key, value]) => !key.startsWith('__') && typeof value === 'string')?.[1];
179
+ const answer = action || (typeof firstAnswer === 'string' ? firstAnswer : '');
180
+ if (!answer)
181
+ return false;
182
+ return settleCopilotDecision(pending, { action: 'answer', answer, feedback });
183
+ }
63
184
  function resolveDirectExecution(cmdPath) {
64
185
  if (platform() !== 'win32')
65
186
  return null;
@@ -133,10 +254,18 @@ function finishCopilotTurn(state) {
133
254
  state.heartbeatTimer = null;
134
255
  }
135
256
  }
257
+ function ensureCopilotCommandTurnActive(state, turnId) {
258
+ if (!state.turnActive || state.turnId !== turnId) {
259
+ throw new CopilotDecisionCancelledError();
260
+ }
261
+ }
136
262
  function emitCopilotCancelled(conversationId, state) {
137
263
  sendFn({
138
264
  type: 'backend_event',
139
265
  conversationId,
266
+ claudeSessionId: state?.copilotSessionId || null,
267
+ turnId: state?.turnId || null,
268
+ stateVersion: state?.stateVersion || 0,
140
269
  event: { type: 'turn_cancelled', session: sessionRef(state?.copilotSessionId || '') },
141
270
  });
142
271
  sendFn({
@@ -205,6 +334,10 @@ export function rebindCopilotConversation(copilotSessionId, newConvId) {
205
334
  conversations.delete(oldConvId);
206
335
  state.conversationId = newConvId;
207
336
  conversations.set(newConvId, state);
337
+ for (const pending of pendingCopilotDecisions.values()) {
338
+ if (pending.conversationId === oldConvId)
339
+ pending.conversationId = newConvId;
340
+ }
208
341
  return true;
209
342
  }
210
343
  }
@@ -229,6 +362,13 @@ export function createCopilotPlaceholder(conversationId, opts) {
229
362
  stateVersion: 0,
230
363
  heartbeatTimer: null,
231
364
  sessionNotified: false,
365
+ copilotMode: 'interactive',
366
+ planMode: false,
367
+ planModePending: false,
368
+ isCompacting: false,
369
+ commandRuntime: null,
370
+ commandSessionId: null,
371
+ deferredCommandUsage: null,
232
372
  });
233
373
  }
234
374
  export function restartCopilotConversation(conversationId, opts) {
@@ -243,7 +383,7 @@ export function restartCopilotConversation(conversationId, opts) {
243
383
  child: null,
244
384
  abortController: null,
245
385
  copilotSessionId: null,
246
- lastCopilotSessionId: copilotSessionId,
386
+ lastCopilotSessionId: opts?.preserveSession === false ? null : copilotSessionId,
247
387
  workDir: state?.workDir || process.cwd(),
248
388
  turnActive: false,
249
389
  providerId: opts?.providerId || state?.providerId || 'copilot',
@@ -254,6 +394,13 @@ export function restartCopilotConversation(conversationId, opts) {
254
394
  stateVersion: (state?.stateVersion || 0) + 1,
255
395
  heartbeatTimer: null,
256
396
  sessionNotified: false,
397
+ copilotMode: state?.copilotMode || (state?.planMode ? 'plan' : 'interactive'),
398
+ planMode: state?.planMode || false,
399
+ planModePending: state?.planModePending || false,
400
+ isCompacting: false,
401
+ commandRuntime: null,
402
+ commandSessionId: null,
403
+ deferredCommandUsage: null,
257
404
  });
258
405
  return { wasTurnActive, copilotSessionId };
259
406
  }
@@ -267,6 +414,459 @@ export function setModel(conversationId, model) {
267
414
  globalModel = model;
268
415
  }
269
416
  }
417
+ function detachCopilotProviderSession(state, providerId) {
418
+ if (state.providerId === providerId)
419
+ return;
420
+ state.providerId = providerId;
421
+ state.copilotSessionId = null;
422
+ state.lastCopilotSessionId = null;
423
+ state.sessionNotified = false;
424
+ }
425
+ function isMissingCopilotSessionError(error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ return /(?:session|resource).*not found|not found.*(?:session|resource)|no (?:session|task|name).*matched/i.test(message);
428
+ }
429
+ function emitCopilotCompactionStarted(state) {
430
+ if (state.isCompacting)
431
+ return false;
432
+ state.isCompacting = true;
433
+ const session = sessionRef(state.copilotSessionId || state.commandSessionId || 'pending');
434
+ sendFn({
435
+ type: 'context_compaction',
436
+ conversationId: state.conversationId,
437
+ turnId: state.turnId,
438
+ status: 'started',
439
+ });
440
+ sendFn({
441
+ type: 'backend_event',
442
+ conversationId: state.conversationId,
443
+ turnId: state.turnId,
444
+ event: { type: 'compact_started', session },
445
+ });
446
+ return true;
447
+ }
448
+ function emitCopilotCompactionCompleted(state) {
449
+ if (!state.isCompacting)
450
+ return false;
451
+ state.isCompacting = false;
452
+ const session = sessionRef(state.copilotSessionId || state.commandSessionId || '');
453
+ sendFn({
454
+ type: 'context_compaction',
455
+ conversationId: state.conversationId,
456
+ turnId: state.turnId,
457
+ status: 'completed',
458
+ });
459
+ sendFn({
460
+ type: 'backend_event',
461
+ conversationId: state.conversationId,
462
+ turnId: state.turnId,
463
+ event: { type: 'compact_completed', session },
464
+ });
465
+ return true;
466
+ }
467
+ function emitCopilotCommandOutput(state, content) {
468
+ if (!content.trim())
469
+ return;
470
+ sendFn({
471
+ type: 'command_output',
472
+ conversationId: state.conversationId,
473
+ content,
474
+ });
475
+ }
476
+ function emitCopilotCommandCompleted(state) {
477
+ if (!state.turnActive)
478
+ return;
479
+ finishCopilotTurn(state);
480
+ const session = sessionRef(state.copilotSessionId || '');
481
+ const usage = state.deferredCommandUsage || undefined;
482
+ state.deferredCommandUsage = null;
483
+ sendFn({
484
+ type: 'backend_event',
485
+ conversationId: state.conversationId,
486
+ event: { type: 'turn_completed', session, ...(usage ? { usage } : {}) },
487
+ });
488
+ sendFn({
489
+ type: 'turn_completed',
490
+ conversationId: state.conversationId,
491
+ workDir: state.workDir,
492
+ claudeSessionId: state.copilotSessionId,
493
+ turnId: state.turnId,
494
+ stateVersion: state.stateVersion,
495
+ ...(usage ? { usage } : {}),
496
+ });
497
+ }
498
+ function commandInputForDisplay(command, input) {
499
+ return input.trim() ? `/${command} ${input}` : `/${command}`;
500
+ }
501
+ async function applyCopilotCommandResult(state, runtime, sessionId, command, input, result) {
502
+ switch (result.kind) {
503
+ case 'text':
504
+ emitCopilotCommandOutput(state, result.text);
505
+ return;
506
+ case 'completed':
507
+ if (result.message)
508
+ emitCopilotCommandOutput(state, result.message);
509
+ return;
510
+ case 'select-subcommand':
511
+ throw new Error(`${result.title}: ${result.options.map(option => option.name).join(', ')}`);
512
+ case 'agent-prompt':
513
+ if (result.notice)
514
+ emitCopilotCommandOutput(state, result.notice);
515
+ if (result.mode) {
516
+ await runtime.setMode(sessionId, result.mode);
517
+ setCopilotModeState(state, result.mode);
518
+ }
519
+ await runtime.send(sessionId, result.prompt, result.displayPrompt || commandInputForDisplay(command, input));
520
+ }
521
+ }
522
+ async function requestCopilotPlanInput(state, sessionId, request) {
523
+ const choices = request.choices || [];
524
+ const expiresAt = Date.now() + COPILOT_PLAN_INPUT_TIMEOUT_MS;
525
+ const decision = await waitForCopilotDecision(state, sessionId, {
526
+ questions: [{
527
+ question: request.question,
528
+ header: 'Plan question',
529
+ options: choices.map(choice => ({ label: choice, value: choice })),
530
+ }],
531
+ source: 'tool_request_user_input',
532
+ presentation: 'inline',
533
+ kind: 'plan_input',
534
+ allowFreeform: request.allowFreeform !== false,
535
+ expiresAt,
536
+ timeoutAction: choices.length > 0 ? 'first_option' : 'continue_plan',
537
+ }, choices);
538
+ if (decision.action === 'cancel')
539
+ throw new CopilotDecisionCancelledError();
540
+ const answer = decision.answer || '';
541
+ return {
542
+ answer,
543
+ wasFreeform: !choices.includes(answer),
544
+ };
545
+ }
546
+ async function requestCopilotAcpElicitation(state, request) {
547
+ if (!acp.CreateElicitationRequest.isForm(request) || !('sessionId' in request)) {
548
+ return { action: 'cancel' };
549
+ }
550
+ const properties = Object.entries(request.requestedSchema.properties || {});
551
+ const [fieldName, field] = properties[0] || [];
552
+ if (!fieldName || !field)
553
+ return { action: 'decline' };
554
+ let choices = [];
555
+ let multiSelect = false;
556
+ if (acp.ElicitationPropertySchema.isString(field)) {
557
+ choices = field.oneOf?.map(option => option.const) || field.enum || [];
558
+ }
559
+ else if (acp.ElicitationPropertySchema.isArray(field)) {
560
+ multiSelect = true;
561
+ choices = acp.MultiSelectItems.isTitled(field.items)
562
+ ? field.items.anyOf.map(option => option.const)
563
+ : (acp.MultiSelectItems.isString(field.items) ? field.items.enum : []);
564
+ }
565
+ else if (acp.ElicitationPropertySchema.isBoolean(field)) {
566
+ choices = ['true', 'false'];
567
+ }
568
+ const fieldRecord = field;
569
+ const fieldDescription = typeof fieldRecord.description === 'string'
570
+ ? fieldRecord.description
571
+ : request.message;
572
+ const fieldTitle = typeof fieldRecord.title === 'string'
573
+ ? fieldRecord.title
574
+ : 'Plan question';
575
+ const expiresAt = Date.now() + COPILOT_PLAN_INPUT_TIMEOUT_MS;
576
+ const decision = await waitForCopilotDecision(state, request.sessionId, {
577
+ questions: [{
578
+ question: fieldDescription,
579
+ header: fieldTitle,
580
+ options: choices.map(choice => ({ label: choice, value: choice })),
581
+ multiSelect,
582
+ }],
583
+ source: 'tool_request_user_input',
584
+ presentation: 'inline',
585
+ kind: 'plan_input',
586
+ allowFreeform: choices.length === 0,
587
+ expiresAt,
588
+ timeoutAction: choices.length > 0 ? 'first_option' : 'continue_plan',
589
+ }, choices);
590
+ if (decision.action === 'cancel')
591
+ return { action: 'cancel' };
592
+ const raw = decision.answer || '';
593
+ let value = raw;
594
+ if (acp.ElicitationPropertySchema.isArray(field))
595
+ value = raw ? raw.split(/,\s*/) : [];
596
+ else if (acp.ElicitationPropertySchema.isBoolean(field))
597
+ value = raw === 'true';
598
+ else if (acp.ElicitationPropertySchema.isNumber(field)
599
+ || acp.ElicitationPropertySchema.isInteger(field))
600
+ value = Number(raw);
601
+ return { action: 'accept', content: { [fieldName]: value } };
602
+ }
603
+ function copilotPlanConfirmationPrompt(content) {
604
+ return /[\u3400-\u9fff]/.test(content)
605
+ ? '请确认以上计划,或直接回复需要修改的内容。'
606
+ : 'Please confirm the Plan above, or reply with any changes you want.';
607
+ }
608
+ function emitCopilotPlanReview(state, sessionId, content, languageHint = content || '') {
609
+ const plan = content?.trim() || '';
610
+ const prompt = copilotPlanConfirmationPrompt(languageHint);
611
+ sendFn({
612
+ type: 'backend_event',
613
+ conversationId: state.conversationId,
614
+ providerId: state.providerId,
615
+ claudeSessionId: sessionId,
616
+ event: {
617
+ type: 'text_delta',
618
+ session: sessionRef(sessionId),
619
+ text: `${plan ? `\n\n${plan}\n\n` : '\n\n'}> ${prompt}`,
620
+ },
621
+ });
622
+ }
623
+ async function acceptCopilotPlanExitWithoutBlocking(state, request) {
624
+ const selectedAction = request.actions.includes('exit_only')
625
+ ? 'exit_only'
626
+ : request.actions.includes('interactive')
627
+ ? 'interactive'
628
+ : null;
629
+ if (!selectedAction) {
630
+ return {
631
+ approved: false,
632
+ feedback: COPILOT_CONTINUE_PLAN_ANSWER,
633
+ };
634
+ }
635
+ state.planModePending = false;
636
+ return { approved: true, selectedAction };
637
+ }
638
+ export async function executeCopilotCommand(conversationId, command, input, workDir, options) {
639
+ let state = conversations.get(conversationId);
640
+ if (!state) {
641
+ createCopilotPlaceholder(conversationId, { providerId: options?.providerId });
642
+ state = conversations.get(conversationId);
643
+ }
644
+ if (state.turnActive || state.commandRuntime) {
645
+ sendFn({
646
+ type: 'backend_event',
647
+ conversationId,
648
+ event: { type: 'error', message: 'Copilot is already processing this conversation' },
649
+ });
650
+ return;
651
+ }
652
+ state.workDir = workDir;
653
+ const providerChanged = !!options?.providerId && state.providerId !== options.providerId;
654
+ if (options?.providerId)
655
+ detachCopilotProviderSession(state, options.providerId);
656
+ const resumeSessionId = providerChanged
657
+ ? undefined
658
+ : options?.resumeSessionId
659
+ || state.copilotSessionId
660
+ || state.lastCopilotSessionId
661
+ || undefined;
662
+ const normalized = normalizeCopilotRunOptions(options?.copilotOptions);
663
+ if (normalized.error) {
664
+ sendCopilotSetupError(state, normalized.error);
665
+ return;
666
+ }
667
+ if (normalized.options.model)
668
+ state.model = normalized.options.model;
669
+ if (normalized.options.context)
670
+ state.contextTier = normalized.options.context;
671
+ if (normalized.options.effort)
672
+ state.reasoningEffort = normalized.options.effort;
673
+ state.deferredCommandUsage = null;
674
+ let pendingPlanReview = null;
675
+ const runtime = new CopilotCommandRuntime({
676
+ providerId: state.providerId,
677
+ workDir,
678
+ onSessionEvent: (eventSessionId, event) => {
679
+ if (eventSessionId === state.commandSessionId)
680
+ handleCopilotEvent(state, event);
681
+ },
682
+ onUserInputRequest: command === 'plan'
683
+ ? (eventSessionId, request) => requestCopilotPlanInput(state, eventSessionId, request)
684
+ : undefined,
685
+ onExitPlanModeRequest: command === 'plan'
686
+ ? (eventSessionId, request) => {
687
+ pendingPlanReview = {
688
+ sessionId: eventSessionId,
689
+ content: request.planContent || request.summary,
690
+ languageHint: `${request.summary}\n${request.planContent || ''}`,
691
+ };
692
+ return acceptCopilotPlanExitWithoutBlocking(state, request);
693
+ }
694
+ : undefined,
695
+ onAcpElicitationRequest: command === 'plan'
696
+ ? request => requestCopilotAcpElicitation(state, request)
697
+ : undefined,
698
+ });
699
+ state.commandRuntime = runtime;
700
+ const previousMode = state.copilotMode;
701
+ startCopilotTurn(state);
702
+ const commandTurnId = state.turnId;
703
+ sendFn({
704
+ type: 'backend_event',
705
+ conversationId,
706
+ event: {
707
+ type: 'turn_started',
708
+ session: sessionRef(resumeSessionId || 'pending'),
709
+ },
710
+ });
711
+ if (command === 'compact')
712
+ emitCopilotCompactionStarted(state);
713
+ let sessionId = null;
714
+ try {
715
+ await runtime.start();
716
+ ensureCopilotCommandTurnActive(state, commandTurnId);
717
+ const sessionOptions = {
718
+ model: state.model || globalModel || undefined,
719
+ contextTier: state.contextTier || undefined,
720
+ reasoningEffort: state.reasoningEffort || undefined,
721
+ };
722
+ try {
723
+ sessionId = await runtime.openSession(resumeSessionId, sessionOptions);
724
+ ensureCopilotCommandTurnActive(state, commandTurnId);
725
+ }
726
+ catch (error) {
727
+ if (!resumeSessionId || !isMissingCopilotSessionError(error))
728
+ throw error;
729
+ state.copilotSessionId = null;
730
+ state.lastCopilotSessionId = null;
731
+ state.sessionNotified = false;
732
+ sessionId = await runtime.openSession(undefined, sessionOptions);
733
+ ensureCopilotCommandTurnActive(state, commandTurnId);
734
+ }
735
+ state.commandSessionId = sessionId;
736
+ state.copilotSessionId = sessionId;
737
+ state.lastCopilotSessionId = sessionId;
738
+ await runtime.enableAllowAll(sessionId);
739
+ ensureCopilotCommandTurnActive(state, commandTurnId);
740
+ if (!state.sessionNotified) {
741
+ state.sessionNotified = true;
742
+ sendFn({
743
+ type: 'backend_event',
744
+ conversationId,
745
+ event: { type: 'session_started', session: sessionRef(sessionId) },
746
+ });
747
+ }
748
+ const commands = await runtime.listSessionCommands(sessionId);
749
+ ensureCopilotCommandTurnActive(state, commandTurnId);
750
+ if (!commands.some(value => value.name === command)) {
751
+ throw new Error(`Copilot provider ${state.providerId} does not expose /${command}`);
752
+ }
753
+ if (command === 'plan')
754
+ state.planModePending = true;
755
+ if (command === 'goal')
756
+ state.planModePending = false;
757
+ const result = await runtime.invokeCommand(sessionId, command, input);
758
+ ensureCopilotCommandTurnActive(state, commandTurnId);
759
+ await applyCopilotCommandResult(state, runtime, sessionId, command, input, result);
760
+ ensureCopilotCommandTurnActive(state, commandTurnId);
761
+ if (command === 'compact')
762
+ emitCopilotCompactionCompleted(state);
763
+ const reportedMode = await runtime.getMode(sessionId);
764
+ ensureCopilotCommandTurnActive(state, commandTurnId);
765
+ const mode = command === 'plan'
766
+ ? (state.planModePending ? 'plan' : reportedMode)
767
+ : command === 'goal'
768
+ ? 'autopilot'
769
+ : reportedMode;
770
+ state.planModePending = mode === 'plan';
771
+ setCopilotModeState(state, mode);
772
+ if (command === 'plan') {
773
+ const review = pendingPlanReview || {
774
+ sessionId,
775
+ languageHint: input,
776
+ };
777
+ emitCopilotPlanReview(state, review.sessionId, review.content, review.languageHint);
778
+ pendingPlanReview = null;
779
+ }
780
+ }
781
+ catch (error) {
782
+ pendingPlanReview = null;
783
+ if (command === 'compact')
784
+ state.isCompacting = false;
785
+ if (!resumeSessionId && sessionId && state.copilotSessionId === sessionId) {
786
+ state.copilotSessionId = null;
787
+ state.lastCopilotSessionId = null;
788
+ state.sessionNotified = false;
789
+ }
790
+ if (command === 'plan') {
791
+ state.planModePending = true;
792
+ setCopilotModeState(state, 'plan');
793
+ }
794
+ else if (command === 'goal') {
795
+ setCopilotModeState(state, previousMode);
796
+ }
797
+ if (!(error instanceof CopilotDecisionCancelledError) && state.turnActive) {
798
+ sendFn({
799
+ type: 'backend_event',
800
+ conversationId,
801
+ event: {
802
+ type: 'error',
803
+ message: error instanceof Error ? error.message : String(error),
804
+ },
805
+ });
806
+ }
807
+ }
808
+ finally {
809
+ if (sessionId)
810
+ await runtime.destroySession(sessionId);
811
+ await runtime.stop();
812
+ state.commandRuntime = null;
813
+ state.commandSessionId = null;
814
+ state.isCompacting = false;
815
+ emitCopilotCommandCompleted(state);
816
+ }
817
+ }
818
+ export async function setCopilotPlanMode(conversationId, enabled, workDir, providerId = 'copilot') {
819
+ let state = conversations.get(conversationId);
820
+ if (!state) {
821
+ createCopilotPlaceholder(conversationId, { providerId });
822
+ state = conversations.get(conversationId);
823
+ }
824
+ if (state.turnActive || state.commandRuntime) {
825
+ throw new Error('Copilot is already processing this conversation');
826
+ }
827
+ state.workDir = workDir;
828
+ detachCopilotProviderSession(state, providerId);
829
+ if (enabled) {
830
+ state.planModePending = true;
831
+ setCopilotModeState(state, 'plan');
832
+ return;
833
+ }
834
+ state.planModePending = false;
835
+ const sessionId = state.copilotSessionId || state.lastCopilotSessionId;
836
+ if (!sessionId) {
837
+ setCopilotModeState(state, 'interactive');
838
+ return;
839
+ }
840
+ const runtime = new CopilotCommandRuntime({ providerId, workDir });
841
+ state.commandRuntime = runtime;
842
+ state.commandSessionId = sessionId;
843
+ let activeSessionId = sessionId;
844
+ try {
845
+ await runtime.start();
846
+ try {
847
+ await runtime.openSession(sessionId);
848
+ }
849
+ catch (error) {
850
+ if (!isMissingCopilotSessionError(error))
851
+ throw error;
852
+ state.copilotSessionId = null;
853
+ state.lastCopilotSessionId = null;
854
+ state.sessionNotified = false;
855
+ activeSessionId = await runtime.openSession();
856
+ state.copilotSessionId = activeSessionId;
857
+ state.lastCopilotSessionId = activeSessionId;
858
+ state.commandSessionId = activeSessionId;
859
+ }
860
+ await runtime.setMode(activeSessionId, 'interactive');
861
+ setCopilotModeState(state, await runtime.getMode(activeSessionId));
862
+ }
863
+ finally {
864
+ await runtime.destroySession(activeSessionId);
865
+ await runtime.stop();
866
+ state.commandRuntime = null;
867
+ state.commandSessionId = null;
868
+ }
869
+ }
270
870
  export function normalizeCopilotRunOptions(input) {
271
871
  const options = {};
272
872
  if (!input)
@@ -343,26 +943,30 @@ export function handleCopilotChat(conversationId, text, workDir, options, files)
343
943
  stateVersion: 0,
344
944
  heartbeatTimer: null,
345
945
  sessionNotified: false,
946
+ copilotMode: 'interactive',
947
+ planMode: false,
948
+ planModePending: false,
949
+ isCompacting: false,
950
+ commandRuntime: null,
951
+ commandSessionId: null,
952
+ deferredCommandUsage: null,
346
953
  };
347
954
  conversations.set(conversationId, state);
348
955
  }
349
956
  state.workDir = workDir;
350
- if (options?.providerId) {
351
- state.providerId = options.providerId;
352
- }
957
+ const providerChanged = !!options?.providerId && state.providerId !== options.providerId;
958
+ if (options?.providerId)
959
+ detachCopilotProviderSession(state, options.providerId);
353
960
  // Determine session to resume
354
- const resumeId = options?.resumeSessionId
355
- || state.copilotSessionId
356
- || state.lastCopilotSessionId;
961
+ const resumeId = providerChanged
962
+ ? null
963
+ : options?.resumeSessionId
964
+ || state.copilotSessionId
965
+ || state.lastCopilotSessionId;
357
966
  if (resumeId) {
358
967
  state.copilotSessionId = resumeId;
359
968
  state.lastCopilotSessionId = resumeId;
360
969
  }
361
- // For new sessions, pre-assign a sessionId via --session-id so we know it
362
- // immediately (don't have to wait for the `result` event).
363
- if (!resumeId) {
364
- state.copilotSessionId = randomUUID();
365
- }
366
970
  const normalized = normalizeCopilotRunOptions(options?.copilotOptions);
367
971
  if (normalized.error) {
368
972
  sendCopilotSetupError(state, normalized.error);
@@ -374,6 +978,19 @@ export function handleCopilotChat(conversationId, text, workDir, options, files)
374
978
  state.contextTier = normalized.options.context;
375
979
  if (normalized.options.effort)
376
980
  state.reasoningEffort = normalized.options.effort;
981
+ if (state.planModePending) {
982
+ void executeCopilotCommand(conversationId, 'plan', text, workDir, {
983
+ resumeSessionId: resumeId || undefined,
984
+ providerId: state.providerId,
985
+ copilotOptions: options?.copilotOptions,
986
+ });
987
+ return;
988
+ }
989
+ // For new one-shot sessions, pre-assign a sessionId via --session-id so we
990
+ // know it immediately (the SDK command path creates its own persisted ID).
991
+ if (!resumeId) {
992
+ state.copilotSessionId = randomUUID();
993
+ }
377
994
  let processedFiles;
378
995
  try {
379
996
  processedFiles = processFilesForCopilot(files);
@@ -494,7 +1111,20 @@ export function handleCopilotChat(conversationId, text, workDir, options, files)
494
1111
  state.child = null;
495
1112
  state.abortController = null;
496
1113
  if (state.turnActive) {
1114
+ const missingResumeSession = !!resumeId
1115
+ && code !== 0
1116
+ && isMissingCopilotSessionError(stderrBuf);
497
1117
  finishCopilotTurn(state);
1118
+ if (missingResumeSession) {
1119
+ state.copilotSessionId = null;
1120
+ state.lastCopilotSessionId = null;
1121
+ state.sessionNotified = false;
1122
+ handleCopilotChat(conversationId, text, workDir, {
1123
+ providerId: state.providerId,
1124
+ copilotOptions: options?.copilotOptions,
1125
+ }, files);
1126
+ return;
1127
+ }
498
1128
  // If turn wasn't completed by a result event, emit error or cancellation
499
1129
  if (code !== 0 && code !== null) {
500
1130
  const errMsg = stderrBuf.trim() || `Copilot process exited with code ${code}`;
@@ -572,6 +1202,44 @@ function handleCopilotEvent(state, event) {
572
1202
  }
573
1203
  const session = sessionRef(state.copilotSessionId || 'pending');
574
1204
  switch (event.type) {
1205
+ case 'session.mode_changed': {
1206
+ const data = event.data;
1207
+ if (data?.newMode) {
1208
+ const mode = data.newMode;
1209
+ if (state.planModePending && mode === 'interactive')
1210
+ break;
1211
+ setCopilotModeState(state, mode);
1212
+ }
1213
+ break;
1214
+ }
1215
+ case 'session.compaction_start': {
1216
+ emitCopilotCompactionStarted(state);
1217
+ break;
1218
+ }
1219
+ case 'session.compaction_complete': {
1220
+ // Native providers can report a compaction boundary before the command
1221
+ // request itself settles. Keep the synthesized row pending until
1222
+ // invokeCommand succeeds so a later rejection cannot leave a false
1223
+ // completed checkmark behind.
1224
+ if (state.commandRuntime && state.isCompacting)
1225
+ break;
1226
+ emitCopilotCompactionCompleted(state);
1227
+ break;
1228
+ }
1229
+ case 'session.compaction_failed': {
1230
+ state.isCompacting = false;
1231
+ const data = event.data;
1232
+ sendFn({
1233
+ type: 'backend_event',
1234
+ conversationId: convId,
1235
+ turnId: state.turnId,
1236
+ event: {
1237
+ type: 'error',
1238
+ message: data?.error || 'Context compaction failed',
1239
+ },
1240
+ });
1241
+ break;
1242
+ }
575
1243
  case 'assistant.message_delta': {
576
1244
  const data = event.data;
577
1245
  if (data?.deltaContent) {
@@ -662,8 +1330,15 @@ function handleCopilotEvent(state, event) {
662
1330
  state.copilotSessionId = sessionId;
663
1331
  state.lastCopilotSessionId = sessionId;
664
1332
  }
665
- finishCopilotTurn(state);
666
1333
  const usage = buildCopilotUsage(state, event.usage);
1334
+ // Native command turns can remain open after Copilot finishes generating
1335
+ // while AgentLink waits for a required final Plan decision. Their outer
1336
+ // command lifecycle emits the authoritative terminal event.
1337
+ if (state.commandRuntime) {
1338
+ state.deferredCommandUsage = usage;
1339
+ break;
1340
+ }
1341
+ finishCopilotTurn(state);
667
1342
  // Emit backend_event turn_completed
668
1343
  sendFn({
669
1344
  type: 'backend_event',
@@ -691,10 +1366,23 @@ function handleCopilotEvent(state, event) {
691
1366
  // ── Cancel ──────────────────────────────────────────────────────────────────
692
1367
  export function cancelCopilotExecution(conversationId) {
693
1368
  const state = conversations.get(conversationId);
1369
+ cancelPendingCopilotDecisions(conversationId);
694
1370
  if (!state) {
695
1371
  emitCopilotCancelled(conversationId);
696
1372
  return;
697
1373
  }
1374
+ state.deferredCommandUsage = null;
1375
+ if (state.commandRuntime && state.commandSessionId) {
1376
+ const cancellation = state.isCompacting
1377
+ ? state.commandRuntime.abortManualCompaction(state.commandSessionId)
1378
+ : state.commandRuntime.abort(state.commandSessionId);
1379
+ state.isCompacting = false;
1380
+ void cancellation.catch(() => { });
1381
+ if (state.turnActive)
1382
+ finishCopilotTurn(state);
1383
+ emitCopilotCancelled(conversationId, state);
1384
+ return;
1385
+ }
698
1386
  if (!state.child) {
699
1387
  if (state.turnActive) {
700
1388
  finishCopilotTurn(state);
@@ -723,7 +1411,7 @@ function cleanupCopilotConversation(conversationId) {
723
1411
  const state = conversations.get(conversationId);
724
1412
  if (!state)
725
1413
  return;
726
- if (state.child) {
1414
+ if (state.child || state.commandRuntime) {
727
1415
  cancelCopilotExecution(conversationId);
728
1416
  }
729
1417
  if (state.heartbeatTimer) {
@@ -1034,6 +1722,37 @@ export function readCopilotSessionMessages(_workDir, sessionId) {
1034
1722
  }
1035
1723
  return messages;
1036
1724
  }
1725
+ export function readCopilotSessionMode(sessionId) {
1726
+ const sessionRoot = resolve(COPILOT_SESSION_DIR);
1727
+ const sessionDir = resolve(COPILOT_SESSION_DIR, sessionId);
1728
+ if (sessionDir === sessionRoot || !sessionDir.startsWith(`${sessionRoot}${process.platform === 'win32' ? '\\' : '/'}`)) {
1729
+ return 'interactive';
1730
+ }
1731
+ const eventsPath = join(sessionDir, 'events.jsonl');
1732
+ if (!existsSync(eventsPath))
1733
+ return 'interactive';
1734
+ let mode = 'interactive';
1735
+ try {
1736
+ for (const line of readFileSync(eventsPath, 'utf-8').split('\n')) {
1737
+ if (!line.trim())
1738
+ continue;
1739
+ try {
1740
+ const event = JSON.parse(line);
1741
+ const nextMode = event.type === 'session.mode_changed' ? event.data?.newMode : undefined;
1742
+ if (nextMode === 'interactive' || nextMode === 'plan' || nextMode === 'autopilot') {
1743
+ mode = nextMode;
1744
+ }
1745
+ }
1746
+ catch {
1747
+ // Ignore partial or malformed history lines.
1748
+ }
1749
+ }
1750
+ }
1751
+ catch {
1752
+ return 'interactive';
1753
+ }
1754
+ return mode;
1755
+ }
1037
1756
  export function getCopilotSessionWorkDir(sessionId) {
1038
1757
  const sessionRoot = resolve(COPILOT_SESSION_DIR);
1039
1758
  const sessionDir = resolve(COPILOT_SESSION_DIR, sessionId);