cli-copilot-worker 0.1.1 → 0.1.3

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,136 @@
1
+ export interface ModelCatalogInput {
2
+ id: string;
3
+ name?: string | undefined;
4
+ policyState?: 'enabled' | 'disabled' | 'unconfigured' | undefined;
5
+ }
6
+
7
+ export interface ModelAliasMapping {
8
+ alias: string;
9
+ canonical: string;
10
+ }
11
+
12
+ export interface ModelCatalog {
13
+ latestModelIds: string[];
14
+ aliasMappings: ModelAliasMapping[];
15
+ }
16
+
17
+ const MANUALLY_EXCLUDED_MODEL_IDS = new Set(['gpt-4.1', 'claude-opus-4.6-fast']);
18
+
19
+ interface ParsedModelId {
20
+ family: string;
21
+ version: number[];
22
+ }
23
+
24
+ function isSelectableModel(model: ModelCatalogInput): boolean {
25
+ return (
26
+ model.policyState !== 'disabled' &&
27
+ model.policyState !== 'unconfigured' &&
28
+ !MANUALLY_EXCLUDED_MODEL_IDS.has(model.id)
29
+ );
30
+ }
31
+
32
+ function parseModelId(id: string): ParsedModelId | undefined {
33
+ const match = /^(.*)-(\d+(?:\.\d+)?)(?:-(.+))?$/.exec(id);
34
+ if (!match) {
35
+ return undefined;
36
+ }
37
+
38
+ const prefix = match[1];
39
+ const version = match[2];
40
+ const suffix = match[3];
41
+ if (!prefix || !version) {
42
+ return undefined;
43
+ }
44
+
45
+ const major = version.split('.')[0];
46
+ const familyBase = prefix.includes('-') ? prefix : `${prefix}-${major}`;
47
+ return {
48
+ family: suffix ? `${familyBase}-${suffix}` : familyBase,
49
+ version: version.split('.').map((part) => Number.parseInt(part, 10)),
50
+ };
51
+ }
52
+
53
+ function compareVersions(left: number[], right: number[]): number {
54
+ const length = Math.max(left.length, right.length);
55
+ for (let index = 0; index < length; index += 1) {
56
+ const leftPart = left[index] ?? 0;
57
+ const rightPart = right[index] ?? 0;
58
+ if (leftPart !== rightPart) {
59
+ return leftPart - rightPart;
60
+ }
61
+ }
62
+
63
+ return 0;
64
+ }
65
+
66
+ export function buildModelCatalog(models: ModelCatalogInput[]): ModelCatalog {
67
+ const selectable = models.filter(isSelectableModel);
68
+ const latestByFamily = new Map<string, { id: string; version: number[] }>();
69
+ const passthrough = new Set<string>();
70
+
71
+ for (const model of selectable) {
72
+ const parsed = parseModelId(model.id);
73
+ if (!parsed) {
74
+ passthrough.add(model.id);
75
+ continue;
76
+ }
77
+
78
+ const existing = latestByFamily.get(parsed.family);
79
+ if (!existing || compareVersions(parsed.version, existing.version) > 0) {
80
+ latestByFamily.set(parsed.family, { id: model.id, version: parsed.version });
81
+ }
82
+ }
83
+
84
+ const aliasMappings = selectable
85
+ .map((model) => {
86
+ const parsed = parseModelId(model.id);
87
+ if (!parsed) {
88
+ return undefined;
89
+ }
90
+
91
+ const canonical = latestByFamily.get(parsed.family)?.id;
92
+ if (!canonical || canonical === model.id) {
93
+ return undefined;
94
+ }
95
+
96
+ return {
97
+ alias: model.id,
98
+ canonical,
99
+ };
100
+ })
101
+ .filter((mapping): mapping is ModelAliasMapping => mapping !== undefined)
102
+ .sort((left, right) => left.alias.localeCompare(right.alias));
103
+
104
+ const latestModelIds = [
105
+ ...new Set([
106
+ ...passthrough,
107
+ ...[...latestByFamily.values()].map((entry) => entry.id),
108
+ ]),
109
+ ].sort((left, right) => left.localeCompare(right));
110
+
111
+ return {
112
+ latestModelIds,
113
+ aliasMappings,
114
+ };
115
+ }
116
+
117
+ export function resolveModelAlias(requestedModel: string, catalog: ModelCatalog): string | undefined {
118
+ if (catalog.latestModelIds.includes(requestedModel)) {
119
+ return requestedModel;
120
+ }
121
+
122
+ return catalog.aliasMappings.find((mapping) => mapping.alias === requestedModel)?.canonical;
123
+ }
124
+
125
+ export function describeAllowedModels(catalog: ModelCatalog): string {
126
+ const allowed = catalog.latestModelIds.join(', ');
127
+ if (catalog.aliasMappings.length === 0) {
128
+ return `Allowed models: ${allowed}`;
129
+ }
130
+
131
+ const aliases = catalog.aliasMappings
132
+ .map((mapping) => `${mapping.alias} -> ${mapping.canonical}`)
133
+ .join(', ');
134
+
135
+ return `Allowed models: ${allowed}. Accepted legacy aliases: ${aliases}`;
136
+ }
@@ -103,7 +103,7 @@ export class ProfileManager {
103
103
  static fromEnvironment(persistedProfiles?: PersistedProfileState[]): ProfileManager {
104
104
  const raw = process.env.COPILOT_CONFIG_DIRS;
105
105
  const dirs = raw
106
- ? raw.split(',').map((entry) => entry.trim()).filter(Boolean)
106
+ ? raw.split(':').map((entry) => entry.trim()).filter(Boolean)
107
107
  : [defaultProfileDir()];
108
108
 
109
109
  return new ProfileManager({ profileDirs: dirs, persistedProfiles });
@@ -1,10 +1,17 @@
1
1
  import { unlink, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
 
4
- import { approveAll, type CopilotClient, type CopilotSession } from '@github/copilot-sdk';
4
+ import { approveAll, type CopilotClient, type CopilotSession, type ModelInfo } from '@github/copilot-sdk';
5
5
 
6
6
  import { buildAuthenticatedClient } from '../core/copilot.js';
7
7
  import { classifyCopilotStartupFailure, classifySessionErrorEvent } from '../core/failure-classifier.js';
8
+ import { startFleetModeIfEnabled } from '../core/fleet-mode.js';
9
+ import {
10
+ buildModelCatalog,
11
+ describeAllowedModels,
12
+ resolveModelAlias,
13
+ type ModelCatalogInput,
14
+ } from '../core/model-catalog.js';
8
15
  import { ensureStateRoot } from '../core/paths.js';
9
16
  import { ProfileFaultPlanner } from '../core/profile-faults.js';
10
17
  import { ProfileManager } from '../core/profile-manager.js';
@@ -97,6 +104,7 @@ export class CliCopilotWorkerService {
97
104
  await this.store.load();
98
105
  this.profileManager = ProfileManager.fromEnvironment(this.store.getProfiles());
99
106
  this.faultPlanner = ProfileFaultPlanner.fromEnvironment();
107
+ this.reconcilePendingQuestions();
100
108
  this.store.setProfiles(this.profileManager.toPersistedState());
101
109
  await this.store.persist();
102
110
  }
@@ -123,6 +131,7 @@ export class CliCopilotWorkerService {
123
131
 
124
132
  async shutdown(): Promise<Record<string, unknown>> {
125
133
  for (const execution of this.activeExecutions.values()) {
134
+ this.clearPendingQuestion(execution.conversationId, execution.jobId, 'failed');
126
135
  await execution.session.abort().catch(() => {});
127
136
  await execution.session.disconnect().catch(() => {});
128
137
  await execution.client.stop().catch(async () => {
@@ -150,9 +159,10 @@ export class CliCopilotWorkerService {
150
159
  }
151
160
 
152
161
  async run(args: RunRequest, writer?: EventWriter): Promise<Record<string, unknown>> {
162
+ const resolvedModel = await this.resolveRequestedModel(args.cwd, args.model);
153
163
  const conversation = this.store.createConversation({
154
164
  cwd: args.cwd,
155
- model: args.model ?? DEFAULT_MODEL,
165
+ model: resolvedModel,
156
166
  });
157
167
  const job = this.store.createJob({
158
168
  conversationId: conversation.id,
@@ -386,6 +396,7 @@ export class CliCopilotWorkerService {
386
396
  failureMessage: 'Job cancelled.',
387
397
  });
388
398
  }
399
+ this.clearPendingQuestion(job.conversationId, job.id, 'cancelled');
389
400
  await execution.session.abort().catch(() => {});
390
401
  await execution.session.disconnect().catch(() => {});
391
402
  await execution.client.stop().catch(async () => {
@@ -397,6 +408,7 @@ export class CliCopilotWorkerService {
397
408
  this.store.finalizeJob(job.id, 'cancelled');
398
409
  this.store.updateConversation(job.conversationId, {
399
410
  status: 'cancelled',
411
+ pendingQuestion: undefined,
400
412
  });
401
413
  await this.store.appendTranscript({
402
414
  conversationId: job.conversationId,
@@ -488,7 +500,16 @@ export class CliCopilotWorkerService {
488
500
  message: `Running ${job.kind} on ${conversation.id}`,
489
501
  });
490
502
 
491
- const profiles = this.planProfiles(conversation);
503
+ let profiles: CopilotProfile[];
504
+ try {
505
+ profiles = await this.planExecutionProfiles(conversation);
506
+ } catch (error) {
507
+ const failure = classifyCopilotStartupFailure(error);
508
+ await this.finalizeJobFailure(job.id, conversation.id, failure.message);
509
+ rejectDone(new Error(failure.message));
510
+ return;
511
+ }
512
+
492
513
  if (profiles.length === 0) {
493
514
  const failure = classifyCopilotStartupFailure(
494
515
  new Error('No eligible Copilot profiles are available. All configured profiles are cooling down or unavailable.'),
@@ -670,6 +691,7 @@ export class CliCopilotWorkerService {
670
691
  clearTimeout(timeout);
671
692
  }
672
693
  input.writer?.event('error', { message: failure.message });
694
+ this.clearPendingQuestion(input.conversation.id, input.job.id, 'failed');
673
695
  await this.recordAttemptFailure({
674
696
  conversationId: input.conversation.id,
675
697
  jobId: input.job.id,
@@ -707,6 +729,7 @@ export class CliCopilotWorkerService {
707
729
  lastError: undefined,
708
730
  profileId: input.profile.id,
709
731
  profileConfigDir: input.profile.configDir,
732
+ pendingQuestion: undefined,
710
733
  });
711
734
  await this.store.appendTranscript({
712
735
  conversationId: input.conversation.id,
@@ -798,6 +821,16 @@ export class CliCopilotWorkerService {
798
821
 
799
822
  void this.persistState()
800
823
  .then(async () => {
824
+ const fleetStarted = await startFleetModeIfEnabled(session, input.content);
825
+ if (fleetStarted) {
826
+ await this.store.appendTranscript({
827
+ conversationId: input.conversation.id,
828
+ jobId: input.job.id,
829
+ role: 'status',
830
+ content: 'Fleet mode started.',
831
+ });
832
+ input.writer?.event('status', { message: 'Fleet mode started.' });
833
+ }
801
834
  await session.send({ prompt: input.content });
802
835
  })
803
836
  .catch((error) => {
@@ -846,6 +879,28 @@ export class CliCopilotWorkerService {
846
879
  return prioritized;
847
880
  }
848
881
 
882
+ private async planExecutionProfiles(conversation: ConversationRecord): Promise<CopilotProfile[]> {
883
+ const prioritized = this.planProfiles(conversation);
884
+ if (conversation.hasStarted) {
885
+ return prioritized;
886
+ }
887
+
888
+ const supported = await this.filterProfilesByModelSupport(
889
+ conversation.cwd,
890
+ prioritized,
891
+ conversation.model,
892
+ );
893
+
894
+ if (supported.length > 0) {
895
+ return supported;
896
+ }
897
+
898
+ const catalog = await this.loadModelCatalogForProfiles(conversation.cwd, prioritized);
899
+ throw new Error(
900
+ `Unsupported model "${conversation.model}" for the configured Copilot profiles. ${describeAllowedModels(catalog)}`,
901
+ );
902
+ }
903
+
849
904
  private async getOrCreateClient(cwd: string, profile: CopilotProfile): Promise<{ client: CopilotClient; clientKey: string }> {
850
905
  const clientKey = `${cwd}:${profile.configDir}`;
851
906
  let client = this.clients.get(clientKey);
@@ -867,6 +922,75 @@ export class CliCopilotWorkerService {
867
922
  this.clients.delete(clientKey);
868
923
  }
869
924
 
925
+ private async resolveRequestedModel(cwd: string, requestedModel?: string): Promise<string> {
926
+ const profiles = this.profileManager.getCandidateProfiles();
927
+ if (profiles.length === 0) {
928
+ throw new Error('No eligible Copilot profiles are available. All configured profiles are cooling down or unavailable.');
929
+ }
930
+
931
+ const catalog = await this.loadModelCatalogForProfiles(cwd, profiles);
932
+ const rawModel = requestedModel ?? DEFAULT_MODEL;
933
+ const resolved = resolveModelAlias(rawModel, catalog);
934
+ if (!resolved) {
935
+ throw new Error(`Unsupported model "${rawModel}". ${describeAllowedModels(catalog)}`);
936
+ }
937
+
938
+ return resolved;
939
+ }
940
+
941
+ private async filterProfilesByModelSupport(
942
+ cwd: string,
943
+ profiles: CopilotProfile[],
944
+ model: string,
945
+ ): Promise<CopilotProfile[]> {
946
+ const supportChecks = await Promise.all(
947
+ profiles.map(async (profile) => ({
948
+ profile,
949
+ supports: await this.profileSupportsModel(cwd, profile, model),
950
+ })),
951
+ );
952
+
953
+ return supportChecks
954
+ .filter((entry) => entry.supports)
955
+ .map((entry) => entry.profile);
956
+ }
957
+
958
+ private async profileSupportsModel(cwd: string, profile: CopilotProfile, model: string): Promise<boolean> {
959
+ try {
960
+ const catalog = await this.loadModelCatalogForProfiles(cwd, [profile]);
961
+ return catalog.latestModelIds.includes(model);
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+
967
+ private async loadModelCatalogForProfiles(cwd: string, profiles: CopilotProfile[]) {
968
+ const settled = await Promise.allSettled(
969
+ profiles.map(async (profile) => {
970
+ const { client } = await this.getOrCreateClient(cwd, profile);
971
+ return await client.listModels();
972
+ }),
973
+ );
974
+
975
+ const models = settled
976
+ .flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
977
+ .map((model) => this.toModelCatalogInput(model));
978
+
979
+ if (models.length === 0) {
980
+ throw new Error('No selectable Copilot models are available from the configured profiles.');
981
+ }
982
+
983
+ return buildModelCatalog(models);
984
+ }
985
+
986
+ private toModelCatalogInput(model: ModelInfo): ModelCatalogInput {
987
+ return {
988
+ id: model.id,
989
+ name: model.name,
990
+ policyState: model.policy?.state,
991
+ };
992
+ }
993
+
870
994
  private async recordAttemptFailure(input: {
871
995
  conversationId: string;
872
996
  jobId: string;
@@ -900,6 +1024,7 @@ export class CliCopilotWorkerService {
900
1024
  this.store.updateConversation(conversationId, {
901
1025
  status: 'failed',
902
1026
  lastError: message,
1027
+ pendingQuestion: undefined,
903
1028
  });
904
1029
  await this.store.appendTranscript({
905
1030
  conversationId,
@@ -947,6 +1072,37 @@ export class CliCopilotWorkerService {
947
1072
  await this.store.persist();
948
1073
  }
949
1074
 
1075
+ private clearPendingQuestion(
1076
+ conversationId: string,
1077
+ jobId: string,
1078
+ status: ConversationRecord['status'],
1079
+ ): void {
1080
+ this.questionRegistry.clear(conversationId, 'Conversation is no longer waiting for an answer.');
1081
+ this.store.updateConversation(conversationId, {
1082
+ pendingQuestion: undefined,
1083
+ status,
1084
+ });
1085
+ this.store.updateJob(jobId, {
1086
+ status: status === 'cancelled' ? 'cancelled' : 'failed',
1087
+ });
1088
+ }
1089
+
1090
+ private reconcilePendingQuestions(): void {
1091
+ for (const conversation of this.store.listConversations()) {
1092
+ if (!conversation.pendingQuestion) {
1093
+ continue;
1094
+ }
1095
+
1096
+ if (conversation.status === 'waiting_answer') {
1097
+ continue;
1098
+ }
1099
+
1100
+ this.store.updateConversation(conversation.id, {
1101
+ pendingQuestion: undefined,
1102
+ });
1103
+ }
1104
+ }
1105
+
950
1106
  private resolveProfile(conversation: ConversationRecord) {
951
1107
  if (conversation.profileConfigDir && conversation.profileId) {
952
1108
  const match = this.profileManager
package/src/output.ts CHANGED
@@ -22,6 +22,18 @@ export function resolveOutputFormat(explicit?: string | undefined): OutputFormat
22
22
  throw new Error(`Invalid output format: ${explicit}. Use "text" or "json".`);
23
23
  }
24
24
 
25
+ export function resolveFollowOutputFormat(explicit?: string | undefined): OutputFormat {
26
+ if (explicit === 'json') {
27
+ throw new Error('--follow only supports text output');
28
+ }
29
+
30
+ if (!explicit) {
31
+ return 'text';
32
+ }
33
+
34
+ return resolveOutputFormat(explicit);
35
+ }
36
+
25
37
  export function printJson(data: unknown): void {
26
38
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
27
39
  }