vanspod 0.0.0-stage → 0.1.2

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.
Files changed (79) hide show
  1. package/README.md +74 -2
  2. package/bin/register.cjs +11 -0
  3. package/bin/vanspod.cjs +2 -0
  4. package/dist/adapterSettings.js +37 -0
  5. package/dist/adapters/chat-output.js +69 -0
  6. package/dist/adapters/chat-stream.js +44 -0
  7. package/dist/adapters/chat.js +63 -0
  8. package/dist/adapters/claude-reasoning.js +118 -0
  9. package/dist/adapters/claude-request.js +190 -0
  10. package/dist/adapters/claude-response.js +79 -0
  11. package/dist/adapters/index.js +19 -0
  12. package/dist/adapters/messages-stream.js +126 -0
  13. package/dist/adapters/messages.js +21 -0
  14. package/dist/adapters/responses-stream.js +70 -0
  15. package/dist/adapters/responses.js +28 -0
  16. package/dist/adapters/stream.js +99 -0
  17. package/dist/adapters/transport.js +26 -0
  18. package/dist/adapters/types.js +35 -0
  19. package/dist/appearance.js +10 -0
  20. package/dist/classroomApp.js +1814 -0
  21. package/dist/classroomUpdate.js +155 -0
  22. package/dist/claudeWebSearch.js +248 -0
  23. package/dist/clientDetection.js +35 -0
  24. package/dist/clientIcons.js +33 -0
  25. package/dist/clientRouteRuntime.js +149 -0
  26. package/dist/clientRoutes.js +118 -0
  27. package/dist/clientSettingsBackup.js +110 -0
  28. package/dist/clientTraffic.js +26 -0
  29. package/dist/clipboard.js +32 -0
  30. package/dist/codexWebSearch.js +80 -0
  31. package/dist/codexWire/apply-patch-envelope.js +139 -0
  32. package/dist/codexWire/chat-options.js +51 -0
  33. package/dist/codexWire/chat-tools.js +80 -0
  34. package/dist/codexWire/freeform-wrapper-scan.js +326 -0
  35. package/dist/codexWire/progressive-freeform-input.js +133 -0
  36. package/dist/codexWire/request.js +73 -0
  37. package/dist/codexWire/responses-tools.js +88 -0
  38. package/dist/codexWire/tool-arguments.js +39 -0
  39. package/dist/codexWire/tool-guidance.js +77 -0
  40. package/dist/commandRunner.js +100 -0
  41. package/dist/connectionMascot.js +235 -0
  42. package/dist/courseCatalog.js +131 -0
  43. package/dist/desktop.js +25 -0
  44. package/dist/entry.js +17 -0
  45. package/dist/environmentIcons.js +11 -0
  46. package/dist/exitGuard.js +17 -0
  47. package/dist/folderPicker.js +35 -0
  48. package/dist/installProgress.js +171 -0
  49. package/dist/installations.js +72 -0
  50. package/dist/instance.js +160 -0
  51. package/dist/launcher.js +155 -0
  52. package/dist/legacyMigration.js +87 -0
  53. package/dist/lifecyclePage.js +41 -0
  54. package/dist/modelProxy.js +105 -0
  55. package/dist/modelStatus.js +42 -0
  56. package/dist/page.js +1016 -0
  57. package/dist/pagePrototype.js +528 -0
  58. package/dist/platformStorage.js +91 -0
  59. package/dist/removal.js +41 -0
  60. package/dist/responsesChat.js +835 -0
  61. package/dist/routeFiles.js +646 -0
  62. package/dist/routerSessionModels.js +32 -0
  63. package/dist/runtimeOptions.js +14 -0
  64. package/dist/secureStorage.js +63 -0
  65. package/dist/settingsActions.js +100 -0
  66. package/dist/start.js +822 -0
  67. package/dist/storageErrors.js +17 -0
  68. package/dist/storageLocation.js +58 -0
  69. package/dist/version.js +4 -0
  70. package/dist/vsCodeRouteSettings.js +208 -0
  71. package/dist/webSearch.js +56 -0
  72. package/dist/windowsSecureStorage.js +163 -0
  73. package/media/brand-icon.ico +0 -0
  74. package/media/brand-logo.png +0 -0
  75. package/media/fadou-superhero-blink.png +0 -0
  76. package/media/fadou-superhero.png +0 -0
  77. package/package.json +27 -4
  78. package/scripts/tray.ps1 +40 -0
  79. package/scripts/uninstall.ps1 +133 -0
@@ -0,0 +1,1814 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClassroomApp = exports.ENVIRONMENT_TOOLS = void 0;
4
+ const secureStorage_1 = require("./secureStorage");
5
+ const storageErrors_1 = require("./storageErrors");
6
+ const adapterSettings_1 = require("./adapterSettings");
7
+ const clientRoutes_1 = require("./clientRoutes");
8
+ const node_util_1 = require("node:util");
9
+ const courseCatalog_1 = require("./courseCatalog");
10
+ const modelProxy_1 = require("./modelProxy");
11
+ exports.ENVIRONMENT_TOOLS = ["uv", "git", "node", "pwsh"];
12
+ const ENVIRONMENT_LABEL = {
13
+ uv: "uv",
14
+ git: "git",
15
+ node: "Node.js",
16
+ pwsh: "PowerShell 7",
17
+ };
18
+ const LOCAL_LIST_NOTE = "這是本機清單。";
19
+ const INSTALL_UNAVAILABLE = "尚未指定專案資料夾,安裝不可用。";
20
+ const REOPEN_TERMINAL = "請重開終端機再重新檢查。";
21
+ const ENVIRONMENT_ABORTED = "已中止安裝。系統安裝程式可能仍在執行,請等待結束或稍後重新安裝。";
22
+ const PROXY_CLIENTS = ["codex", "claude", "copilot"];
23
+ class ClassroomApp {
24
+ deps;
25
+ adapters = (0, adapterSettings_1.defaultAdapters)();
26
+ clientRoutes = (0, clientRoutes_1.defaultClientRoutes)();
27
+ clientRouteOrigins = {};
28
+ adapterNotice;
29
+ hiddenKeys = new Set();
30
+ initialization;
31
+ connectionOperations = Promise.resolve();
32
+ connected = false;
33
+ classLabel;
34
+ nickname;
35
+ courseTitle;
36
+ sessionTitle;
37
+ detail = "";
38
+ connectionError;
39
+ connectionNotice;
40
+ connectionDraft;
41
+ notice;
42
+ projectFolder;
43
+ folderError;
44
+ catalog;
45
+ catalogError;
46
+ remoteCatalogHeld = false;
47
+ pendingCommand;
48
+ pendingActionId;
49
+ installedActions = new Map();
50
+ copiedSnippets = new Map();
51
+ commandOutput;
52
+ commandRunning = false;
53
+ commandAborted;
54
+ runningInstall;
55
+ stopping = false;
56
+ commandSucceeded;
57
+ clientModes = { codex: "native", claude: "native" };
58
+ detectedClients = { codex: false, claude: false, vsCodeChat: false };
59
+ // Legacy shared operations still reset both clients; aggregate mode keeps old connections readable.
60
+ get mode() {
61
+ return Object.values(this.clientModes).includes("classroom") ? "classroom" : "native";
62
+ }
63
+ set mode(mode) { this.clientModes = { codex: mode, claude: mode }; }
64
+ modelIds = [];
65
+ modelProviders = [];
66
+ environmentInstalled = {
67
+ uv: false,
68
+ git: false,
69
+ node: false,
70
+ pwsh: false,
71
+ };
72
+ environmentSelected = new Set();
73
+ environmentNotice;
74
+ proxyRunning = false;
75
+ mustRestart = [];
76
+ modelRestart = [];
77
+ allowlistError;
78
+ routeRestartNoted = false;
79
+ vsCodeOrigin;
80
+ sending = new Map();
81
+ constructor(deps) {
82
+ this.deps = deps;
83
+ }
84
+ serialConnectionOperation(operation) {
85
+ const next = this.connectionOperations.then(operation, operation);
86
+ this.connectionOperations = next.catch(() => { });
87
+ return next;
88
+ }
89
+ view() {
90
+ const view = {
91
+ connected: this.connected,
92
+ detail: this.detail,
93
+ canCopyKey: this.connected,
94
+ installAvailable: Boolean(this.projectFolder),
95
+ commandRunning: this.commandRunning,
96
+ mode: this.mode,
97
+ clientModes: { ...this.clientModes },
98
+ clientRoutes: this.clientRouteView(),
99
+ detectedClients: { codex: this.detectedClients.codex, claude: this.detectedClients.claude },
100
+ allowedModels: [...this.modelIds],
101
+ adapters: { ...this.adapters },
102
+ adapterNotice: this.adapterNotice,
103
+ tools: this.environmentTools(),
104
+ };
105
+ if (this.connectionError)
106
+ view.connectionError = this.connectionError;
107
+ if (this.notice && this.notice === this.connectionNotice)
108
+ view.connectionNotice = this.connectionNotice;
109
+ if (this.connectionDraft && !this.connected)
110
+ view.connectionDraft = { ...this.connectionDraft };
111
+ if (this.classLabel) {
112
+ view.classLabel = this.classLabel;
113
+ }
114
+ if (this.nickname) {
115
+ view.nickname = this.nickname;
116
+ }
117
+ if (this.courseTitle) {
118
+ view.courseTitle = this.courseTitle;
119
+ }
120
+ if (this.sessionTitle) {
121
+ view.sessionTitle = this.sessionTitle;
122
+ }
123
+ const notice = this.visibleNotice();
124
+ if (notice) {
125
+ view.notice = notice;
126
+ }
127
+ if (this.projectFolder) {
128
+ view.projectFolder = this.projectFolder;
129
+ }
130
+ else {
131
+ view.installNotice = INSTALL_UNAVAILABLE;
132
+ }
133
+ if (this.folderError) {
134
+ view.folderError = this.folderError;
135
+ }
136
+ if (this.catalog) {
137
+ view.catalog = this.catalog;
138
+ const installed = this.installedActions.get(this.projectFolder ?? "");
139
+ view.installedActionIds = this.catalog.actions
140
+ .filter((action) => installed?.get(action.id) === action.command)
141
+ .map((action) => action.id);
142
+ view.copiedSnippetIds = (this.catalog.snippets ?? [])
143
+ .filter((snippet) => this.copiedSnippets.get(snippet.id) === snippet.body)
144
+ .map((snippet) => snippet.id);
145
+ }
146
+ if (this.catalogError) {
147
+ view.catalogError = this.catalogError;
148
+ }
149
+ if (this.allowlistError)
150
+ view.allowlistError = this.allowlistError;
151
+ if (this.pendingCommand) {
152
+ view.pendingCommand = this.pendingCommand;
153
+ }
154
+ if (this.commandOutput !== undefined) {
155
+ view.commandOutput = this.commandOutput;
156
+ }
157
+ if (this.commandSucceeded !== undefined)
158
+ view.commandSucceeded = this.commandSucceeded;
159
+ if (this.commandAborted)
160
+ view.commandAborted = true;
161
+ if (this.mode === "classroom" && this.modelIds[0]) {
162
+ view.modelId = this.modelIds[0];
163
+ }
164
+ if (this.environmentNotice) {
165
+ view.environmentNotice = this.environmentNotice;
166
+ }
167
+ if (this.mustRestart.length > 0) {
168
+ view.mustRestart = [...this.mustRestart];
169
+ }
170
+ if (this.modelRestart.length > 0)
171
+ view.modelRestart = [...this.modelRestart];
172
+ let rendered = JSON.stringify(view);
173
+ for (const key of this.hiddenKeys) {
174
+ rendered = rendered.split(JSON.stringify(key).slice(1, -1)).join("[redacted]");
175
+ }
176
+ return JSON.parse(rendered);
177
+ }
178
+ redactSecrets(value) {
179
+ let redacted = value;
180
+ for (const key of this.hiddenKeys)
181
+ redacted = redacted.split(key).join("[redacted]");
182
+ return redacted;
183
+ }
184
+ clientRouteView() {
185
+ return {
186
+ codex: { ...this.clientRoutes.codex },
187
+ claudeCode: { ...this.clientRoutes.claudeCode },
188
+ vsCodeChat: { ...this.clientRoutes.vsCodeChat },
189
+ };
190
+ }
191
+ async checkEnvironment() {
192
+ if (this.commandRunning) {
193
+ return;
194
+ }
195
+ for (const tool of exports.ENVIRONMENT_TOOLS) {
196
+ let installed = false;
197
+ try {
198
+ installed = (await this.deps.environment.probe(tool)).installed;
199
+ }
200
+ catch {
201
+ installed = false;
202
+ }
203
+ this.environmentInstalled[tool] = installed;
204
+ }
205
+ }
206
+ selectEnvironment(tools) {
207
+ if (this.commandRunning) {
208
+ return;
209
+ }
210
+ this.environmentSelected = new Set(exports.ENVIRONMENT_TOOLS.filter((id) => tools.includes(id)));
211
+ }
212
+ async confirmEnvironment() {
213
+ if (this.stopping || this.commandRunning || !this.projectFolder) {
214
+ return;
215
+ }
216
+ const selected = exports.ENVIRONMENT_TOOLS.filter((id) => this.environmentSelected.has(id));
217
+ if (selected.length === 0) {
218
+ return;
219
+ }
220
+ const cwd = this.projectFolder;
221
+ this.commandRunning = true;
222
+ this.environmentNotice = undefined;
223
+ const abort = this.beginInstall();
224
+ let succeeded = false;
225
+ try {
226
+ for (const tool of selected) {
227
+ let result;
228
+ try {
229
+ result = await this.deps.environment.install(tool, cwd, abort.signal);
230
+ }
231
+ catch {
232
+ break;
233
+ }
234
+ if (abort.signal.aborted || !environmentInstallSucceeded(result)) {
235
+ break;
236
+ }
237
+ succeeded = true;
238
+ }
239
+ }
240
+ finally {
241
+ this.endInstall();
242
+ this.commandRunning = false;
243
+ }
244
+ if (abort.signal.aborted) {
245
+ await this.checkEnvironment();
246
+ this.environmentNotice = ENVIRONMENT_ABORTED;
247
+ }
248
+ else if (succeeded) {
249
+ this.environmentNotice = REOPEN_TERMINAL;
250
+ }
251
+ }
252
+ environmentTools() {
253
+ return exports.ENVIRONMENT_TOOLS.map((id) => ({
254
+ id,
255
+ label: ENVIRONMENT_LABEL[id],
256
+ installed: this.environmentInstalled[id],
257
+ selected: this.environmentSelected.has(id),
258
+ }));
259
+ }
260
+ async copilotRequest(selection) {
261
+ const upstream = await this.serialConnectionOperation(() => this.copilotUpstream(selection));
262
+ if (upstream)
263
+ await this.deps.send(upstream);
264
+ }
265
+ async copilotUpstream(selection) {
266
+ if (selection !== "VCRouter") {
267
+ return;
268
+ }
269
+ if (!this.proxyRunning) {
270
+ throw new Error("VCRouter 目前無法使用。請先啟動 VansPod。");
271
+ }
272
+ await this.deps.proxy.receive({ provider: "VCRouter" });
273
+ return this.classroomUpstream("copilot");
274
+ }
275
+ async forward(request) {
276
+ // Only select a coherent connection and permissions under the lock; streaming stays concurrent.
277
+ const upstream = await this.serialConnectionOperation(() => this.forwardUpstream(request));
278
+ const outcome = await this.deps.send(withHttp(upstream, request));
279
+ // Only a successful request proves the client loaded the new settings and the route works.
280
+ if (!outcome.ok)
281
+ return forwardedRecord(upstream);
282
+ if (this.deps.clientRouteRuntime && request.client !== "copilot" && this.clientModes[request.client] === "classroom") {
283
+ const route = request.client === "codex" ? this.clientRoutes.codex : this.clientRoutes.claudeCode;
284
+ route.health = "green";
285
+ route.restart = "ready";
286
+ this.modelRestart = this.modelRestart.filter(client => client !== request.client);
287
+ }
288
+ if (this.deps.clientRouteRuntime && request.client === "copilot" && this.clientRoutes.vsCodeChat.mode === "classroom") {
289
+ this.clientRoutes.vsCodeChat.health = "green";
290
+ this.clientRoutes.vsCodeChat.restart = "ready";
291
+ this.modelRestart = this.modelRestart.filter(client => client !== "vscode");
292
+ }
293
+ return forwardedRecord(upstream);
294
+ }
295
+ async forwardUpstream(request) {
296
+ if (this.deps.clientRouteRuntime && !this.proxyRunning) {
297
+ if (request.client === "codex" && this.clientRoutes.codex.health === "red") {
298
+ throw new Error("Codex Client Route 尚未恢復。請重開 Codex 後再試。");
299
+ }
300
+ if (request.client === "claude" && this.clientRoutes.claudeCode.health === "red") {
301
+ throw new Error("Claude Code Client Route 尚未恢復。請重開 Claude Code CLI 後再試。");
302
+ }
303
+ if (request.client === "copilot" && this.clientRoutes.vsCodeChat.health === "red") {
304
+ throw new Error("VS Code Client Route 尚未恢復。請重開 VS Code 後再試。");
305
+ }
306
+ }
307
+ if (!this.proxyRunning) {
308
+ throw new Error(request.client === "copilot"
309
+ ? "VCRouter 目前無法使用。請先啟動 VansPod。"
310
+ : "代理沒有在跑。");
311
+ }
312
+ if (request.client === "copilot") {
313
+ if (this.deps.clientRouteRuntime && this.clientRoutes.vsCodeChat.mode === "native") {
314
+ throw new Error("VS Code 已切回 Native。請重開 VS Code 後再試。");
315
+ }
316
+ await this.deps.proxy.receive({ provider: "VCRouter" });
317
+ this.assertAllowedModel(request.model);
318
+ const upstream = await this.classroomUpstream("copilot", request.model);
319
+ if (!upstream) {
320
+ throw new Error("沒有上課模型。");
321
+ }
322
+ return upstream;
323
+ }
324
+ if (request.client === "codex" && this.deps.clientRouteRuntime && this.clientModes.codex === "native") {
325
+ throw new Error("Codex 已切回 Native。請重開 Codex 後再試。");
326
+ }
327
+ if (request.client === "claude" && this.deps.clientRouteRuntime && this.clientModes.claude === "native") {
328
+ throw new Error("Claude Code 已切回 Native。請重開 Claude Code CLI 後再試。");
329
+ }
330
+ if (this.clientModes[request.client] === "classroom") {
331
+ // Web Search is a switch on the Class Session that Router enforces, not a model on the allowlist.
332
+ if (!(0, modelProxy_1.isCodexSearch)(request.client, request.path))
333
+ this.assertAllowedModel(request.model);
334
+ const upstream = await this.classroomUpstream(request.client, request.model);
335
+ if (!upstream) {
336
+ throw new Error("沒有上課模型。");
337
+ }
338
+ return upstream;
339
+ }
340
+ const target = request.client === "codex" ? "chatgpt" : "claude.ai";
341
+ const upstream = {
342
+ client: request.client,
343
+ target,
344
+ model: request.model,
345
+ };
346
+ return upstream;
347
+ }
348
+ assertAllowedModel(requested) {
349
+ if (this.modelIds.length === 0)
350
+ return;
351
+ const id = requested?.startsWith("VCRouter/")
352
+ ? requested.slice("VCRouter/".length)
353
+ : requested;
354
+ if (!id || !this.modelIds.includes(id)) {
355
+ throw new Error(`模型 ${id || "(missing)"} 不在課堂允許清單。`);
356
+ }
357
+ }
358
+ async classroomUpstream(client, requested) {
359
+ if (!this.connected)
360
+ return undefined;
361
+ let apiKey;
362
+ try {
363
+ apiKey = (await this.deps.storage.getConnection())?.apiKey;
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ const model = this.classroomModel(client, requested);
369
+ if (!apiKey || !model) {
370
+ return undefined;
371
+ }
372
+ return {
373
+ client,
374
+ target: "router",
375
+ model,
376
+ apiKey,
377
+ adapter: client === "copilot" ? this.clientRoutes.vsCodeChat.adapter : this.adapters[client],
378
+ };
379
+ }
380
+ async setAdapter(client, id) {
381
+ if (client === "vscode") {
382
+ if (id !== "responses" && id !== "chat")
383
+ throw new Error("無效的 VS Code adapter。");
384
+ await this.serialConnectionOperation(() => this.setVsCodeAdapter(id));
385
+ return;
386
+ }
387
+ if (!(0, adapterSettings_1.isAdapterChoice)(client, id))
388
+ throw new Error(`無效的 ${client === "codex" ? "Codex" : "Claude Code"} adapter。`);
389
+ await this.serialConnectionOperation(async () => {
390
+ if (client === "codex" && this.deps.clientRouteRuntime) {
391
+ if (this.clientRoutes.codex.health === "red" || this.clientRoutes.codex.health === "gray") {
392
+ this.adapterNotice = "Codex Client Route 目前無法變更 adapter。";
393
+ return;
394
+ }
395
+ const previous = this.clientRoutes.codex.adapter;
396
+ this.clientRoutes.codex.adapter = id;
397
+ try {
398
+ await this.saveClientRoutePreferences();
399
+ }
400
+ catch {
401
+ this.clientRoutes.codex.adapter = previous;
402
+ this.adapterNotice = "無法儲存 adapter 選擇,已保留原本設定。";
403
+ return;
404
+ }
405
+ this.adapters = { ...this.adapters, codex: id === "messages" ? "responses" : id };
406
+ if (this.clientRoutes.codex.mode === "classroom") {
407
+ this.clientRoutes.codex.health = "yellow";
408
+ this.clientRoutes.codex.restart = "restart-required";
409
+ }
410
+ this.adapterNotice = undefined;
411
+ return;
412
+ }
413
+ if (client === "claude" && this.deps.clientRouteRuntime) {
414
+ const route = this.clientRoutes.claudeCode;
415
+ if (route.health === "red" || route.health === "gray") {
416
+ this.adapterNotice = "Claude Code Client Route 目前無法變更 adapter。";
417
+ return;
418
+ }
419
+ const previous = route.adapter;
420
+ route.adapter = id;
421
+ try {
422
+ await this.saveClientRoutePreferences();
423
+ }
424
+ catch {
425
+ route.adapter = previous;
426
+ this.adapterNotice = "無法儲存 adapter 選擇,已保留原本設定。";
427
+ return;
428
+ }
429
+ this.adapters = { ...this.adapters, claude: id };
430
+ if (route.mode === "classroom") {
431
+ route.health = "yellow";
432
+ route.restart = "restart-required";
433
+ }
434
+ this.adapterNotice = undefined;
435
+ return;
436
+ }
437
+ if (this.clientModes[client] !== "classroom") {
438
+ this.adapterNotice = "請先將這個客戶端切換到課堂模式。";
439
+ return;
440
+ }
441
+ const next = { ...this.adapters, [client]: id };
442
+ try {
443
+ await this.deps.adapterSettings?.save(next);
444
+ }
445
+ catch {
446
+ this.adapterNotice = "無法儲存 adapter 選擇,已保留原本設定。";
447
+ return;
448
+ }
449
+ this.adapters = next;
450
+ this.adapterNotice = undefined;
451
+ });
452
+ }
453
+ classroomModel(client, requested) {
454
+ const first = this.modelIds[0];
455
+ if (!first) {
456
+ return undefined;
457
+ }
458
+ const id = requested?.startsWith("VCRouter/")
459
+ ? requested.slice("VCRouter/".length)
460
+ : requested;
461
+ if (id && this.modelIds.includes(id)) {
462
+ this.sending.set(client, id);
463
+ return id;
464
+ }
465
+ return this.sending.get(client) ?? first;
466
+ }
467
+ visibleNotice() {
468
+ const parts = [];
469
+ if (this.notice) {
470
+ parts.push(this.notice);
471
+ }
472
+ if (this.allowlistError)
473
+ parts.push(this.allowlistError);
474
+ return parts.length > 0 ? parts.join(" ") : undefined;
475
+ }
476
+ forgetModelChoices() {
477
+ this.sending.clear();
478
+ }
479
+ async publishModelOptions(updateClient) {
480
+ const first = this.modelIds[0];
481
+ if (!first || this.mode !== "classroom") {
482
+ await this.restoreModelOptions();
483
+ return;
484
+ }
485
+ const options = this.codexClassroomOptions(first);
486
+ if (updateClient !== "claude") {
487
+ const mode = this.deps.clientRouteRuntime ? this.clientRoutes.codex.mode : this.clientModes.codex;
488
+ await this.codexLifecycle().write(mode, options);
489
+ }
490
+ }
491
+ codexClassroomOptions(first = this.modelIds[0]) {
492
+ return {
493
+ mode: "classroom",
494
+ ids: [...this.modelIds],
495
+ selectedModel: this.sending.get("codex") ?? first,
496
+ proxyBaseUrl: this.clientRouteOrigins.codex ?? this.deps.proxyBaseUrl,
497
+ restart: true,
498
+ };
499
+ }
500
+ async restoreModelOptions() {
501
+ await this.deps.routes.setCodexRoute({ mode: "native", proxyBaseUrl: this.deps.proxyBaseUrl });
502
+ }
503
+ codexLifecycle() {
504
+ const route = this.clientRoutes.codex;
505
+ const origin = this.clientRouteOrigins.codex ?? this.deps.proxyBaseUrl;
506
+ return {
507
+ route,
508
+ write: (mode, classroom) => this.deps.routes.setCodexRoute(mode === "classroom" ? classroom ?? this.codexClassroomOptions() : { mode: "native", proxyBaseUrl: origin }),
509
+ setMode: (mode) => {
510
+ route.mode = mode;
511
+ this.clientModes.codex = mode;
512
+ },
513
+ setState: (mode, health, restart = "ready") => {
514
+ route.mode = mode;
515
+ route.health = health;
516
+ route.restart = restart;
517
+ this.clientModes.codex = mode;
518
+ },
519
+ stop: async () => {
520
+ await this.deps.routes.setCodexRoute({ mode: "native", proxyBaseUrl: origin });
521
+ await this.deps.clientRouteRuntime?.stop("codex");
522
+ },
523
+ };
524
+ }
525
+ prepare(actionId) {
526
+ if (this.commandRunning || !this.projectFolder) {
527
+ return;
528
+ }
529
+ const action = this.catalog?.actions.find((item) => item.id === actionId);
530
+ if (!action) {
531
+ return;
532
+ }
533
+ this.pendingCommand = action.command;
534
+ this.pendingActionId = action.id;
535
+ this.commandOutput = undefined;
536
+ this.commandAborted = undefined;
537
+ this.commandSucceeded = undefined;
538
+ }
539
+ async confirm() {
540
+ if (this.stopping || this.commandRunning || !this.projectFolder || !this.pendingCommand) {
541
+ return;
542
+ }
543
+ const cwd = this.projectFolder;
544
+ const command = this.pendingCommand;
545
+ const actionId = this.pendingActionId;
546
+ this.commandRunning = true;
547
+ this.commandOutput = undefined;
548
+ this.commandSucceeded = undefined;
549
+ this.commandAborted = undefined;
550
+ const abort = this.beginInstall();
551
+ try {
552
+ const result = await this.deps.commands.run(cwd, command, (output) => {
553
+ this.commandOutput = output;
554
+ }, abort.signal);
555
+ this.commandOutput = result.output;
556
+ if (result.aborted)
557
+ this.commandAborted = true;
558
+ this.commandSucceeded = !result.aborted && result.exitCode === 0;
559
+ if (this.commandSucceeded && actionId) {
560
+ let installed = this.installedActions.get(cwd);
561
+ if (!installed) {
562
+ installed = new Map();
563
+ this.installedActions.set(cwd, installed);
564
+ }
565
+ installed.set(actionId, command);
566
+ }
567
+ }
568
+ catch {
569
+ this.commandOutput = "指令執行失敗。";
570
+ this.commandSucceeded = false;
571
+ }
572
+ finally {
573
+ this.pendingCommand = undefined;
574
+ this.pendingActionId = undefined;
575
+ this.endInstall();
576
+ this.commandRunning = false;
577
+ }
578
+ }
579
+ beginInstall() {
580
+ const abort = new AbortController();
581
+ let settle;
582
+ const settled = new Promise((resolve) => { settle = resolve; });
583
+ this.runningInstall = { abort, settled, settle };
584
+ return abort;
585
+ }
586
+ endInstall() {
587
+ this.runningInstall?.settle();
588
+ this.runningInstall = undefined;
589
+ }
590
+ /** Abort Install:學生確認後終止執行中的安裝;沒有安裝時不做事。 */
591
+ async abortInstall() {
592
+ const install = this.runningInstall;
593
+ install?.abort.abort();
594
+ await install?.settled;
595
+ }
596
+ cancel() {
597
+ if (this.commandRunning) {
598
+ return;
599
+ }
600
+ this.pendingCommand = undefined;
601
+ this.pendingActionId = undefined;
602
+ }
603
+ async redeem(inviteCode, nickname) {
604
+ return this.serialConnectionOperation(() => this.redeemNow(inviteCode, nickname));
605
+ }
606
+ async redeemNow(inviteCode, nickname) {
607
+ const invite = inviteCode.trim();
608
+ const name = nickname.trim();
609
+ this.connectionError = undefined;
610
+ this.connectionDraft = { inviteCode, nickname };
611
+ if (!invite || !name) {
612
+ this.connectionError = "請先輸入邀請碼與課堂暱稱,再按「連線」。";
613
+ if (!this.connected)
614
+ this.detail = "";
615
+ this.notice = undefined;
616
+ return;
617
+ }
618
+ let redeemed;
619
+ try {
620
+ redeemed = await this.deps.router.redeemNickname({
621
+ invite_code: invite,
622
+ nickname: name,
623
+ });
624
+ }
625
+ catch {
626
+ if (!this.connected) {
627
+ this.connectionError = "連線失敗,請確認邀請碼與課堂暱稱後再試。";
628
+ this.detail = "";
629
+ this.notice = undefined;
630
+ }
631
+ return;
632
+ }
633
+ const connection = { version: 1, apiKey: redeemed?.api_key, session: redeemed?.session, nickname: name };
634
+ if (!(0, secureStorage_1.isClassroomConnection)(connection)) {
635
+ this.notice = this.connectionNotice = "兌換回應資料格式不正確,已保留原有課堂連線。請稍後再試。";
636
+ return;
637
+ }
638
+ this.hiddenKeys.add(connection.apiKey);
639
+ try {
640
+ await this.deps.storage.setConnection(connection, { allowTemporary: true });
641
+ }
642
+ catch {
643
+ this.notice = this.connectionNotice = "無法安全保存或清除原有課堂連線,換堂未完成。請稍後再試。";
644
+ return;
645
+ }
646
+ const desiredMode = this.mode;
647
+ const desiredClientModes = { ...this.clientModes };
648
+ const desiredVsCodeMode = this.clientRoutes.vsCodeChat.mode;
649
+ // A committed new session must never inherit permissions or resources from the old one.
650
+ this.mode = "native";
651
+ this.clientRoutes.vsCodeChat.mode = "native";
652
+ this.clientRoutes.vsCodeChat.restart = "ready";
653
+ this.modelIds = [];
654
+ this.modelProviders = [];
655
+ this.allowlistError = undefined;
656
+ this.forgetModelChoices();
657
+ this.catalog = undefined;
658
+ this.catalogError = undefined;
659
+ this.remoteCatalogHeld = false;
660
+ this.pendingCommand = undefined;
661
+ this.pendingActionId = undefined;
662
+ this.applyConnectionIdentity(connection);
663
+ this.connectionDraft = undefined;
664
+ this.notice = undefined;
665
+ // Storage is committed: publish its identity before any client-file operation can fail.
666
+ if (desiredVsCodeMode === "classroom") {
667
+ try {
668
+ await this.deps.vsCodeRouteSettings?.restore();
669
+ }
670
+ catch {
671
+ this.clientRoutes.vsCodeChat.health = "red";
672
+ try {
673
+ await this.saveClientRoutePreferences();
674
+ }
675
+ catch { /* Native/red remains authoritative in memory. */ }
676
+ this.notice = this.connectionNotice = "新課堂連線已建立,但無法恢復 VS Code 的 Native 設定。備份已保留,請稍後再試。";
677
+ return;
678
+ }
679
+ }
680
+ try {
681
+ await this.restoreModelOptions();
682
+ }
683
+ catch {
684
+ this.notice = this.connectionNotice = "新課堂連線已建立,但無法更新模型設定。目前使用 Native,請稍後再試。";
685
+ return;
686
+ }
687
+ await this.loadCatalog();
688
+ await this.loadModels(redeemed.api_key);
689
+ if (desiredMode === "classroom" && this.modelIds.length > 0) {
690
+ this.mode = "classroom";
691
+ this.clientModes = desiredClientModes;
692
+ try {
693
+ await this.publishModelOptions();
694
+ }
695
+ catch {
696
+ this.mode = "native";
697
+ this.modelIds = [];
698
+ this.forgetModelChoices();
699
+ try {
700
+ await this.restoreModelOptions();
701
+ }
702
+ catch { /* Keep proxy routing safely Native. */ }
703
+ this.notice = this.connectionNotice = "新課堂連線已建立,但無法更新模型設定。目前使用 Native,請稍後再試。";
704
+ }
705
+ }
706
+ if (desiredVsCodeMode === "classroom" && this.modelIds.length > 0 && this.vsCodeOrigin) {
707
+ this.clientRoutes.vsCodeChat.mode = "classroom";
708
+ try {
709
+ const result = await this.deps.vsCodeRouteSettings?.publish({
710
+ providers: this.modelProviders,
711
+ adapter: this.clientRoutes.vsCodeChat.adapter,
712
+ origin: this.vsCodeOrigin,
713
+ });
714
+ this.settleVsCodePublish(result?.changed);
715
+ }
716
+ catch (error) {
717
+ this.clientRoutes.vsCodeChat.mode = "native";
718
+ this.clientRoutes.vsCodeChat.health = "red";
719
+ this.clientRoutes.vsCodeChat.restart = "ready";
720
+ this.notice = this.connectionNotice = error instanceof Error ? error.message : "新課堂連線已建立,但無法更新 VS Code 設定。";
721
+ }
722
+ try {
723
+ await this.saveClientRoutePreferences();
724
+ }
725
+ catch {
726
+ this.clientRoutes.vsCodeChat.health = "red";
727
+ }
728
+ }
729
+ }
730
+ async setProjectFolder(folder) {
731
+ return this.serialConnectionOperation(() => this.setProjectFolderNow(folder));
732
+ }
733
+ async setProjectFolderNow(folder) {
734
+ if (this.commandRunning) {
735
+ return;
736
+ }
737
+ const next = folder.trim();
738
+ this.folderError = undefined;
739
+ this.projectFolder = next || undefined;
740
+ if (!this.remoteCatalogHeld) {
741
+ await this.loadCatalog();
742
+ }
743
+ }
744
+ rejectProjectFolder() {
745
+ this.folderError = "這不是資料夾。";
746
+ }
747
+ async setSwitch(mode, client) {
748
+ return this.serialConnectionOperation(() => client ? this.setClientSwitch(mode, client) : this.setSwitchNow(mode));
749
+ }
750
+ async setClientSwitch(mode, client) {
751
+ if (client === "vscode") {
752
+ await this.setVsCodeClientRouteSwitch(mode);
753
+ return;
754
+ }
755
+ if (client === "codex" && this.deps.clientRouteRuntime) {
756
+ await this.setCodexClientRouteSwitch(mode);
757
+ return;
758
+ }
759
+ if (client === "claude" && this.deps.clientRouteRuntime) {
760
+ await this.setClaudeCodeClientRouteSwitch(mode);
761
+ return;
762
+ }
763
+ if (mode === "classroom" && (!this.connected || this.modelIds.length === 0)) {
764
+ this.notice = "請先連線到有可用模型的課堂。";
765
+ return;
766
+ }
767
+ const previous = { ...this.clientModes };
768
+ if (previous[client] === mode && mode !== "native")
769
+ return;
770
+ const next = { ...previous, [client]: mode };
771
+ const aggregate = Object.values(next).includes("classroom") ? "classroom" : "native";
772
+ if (!await this.saveMode(aggregate, next))
773
+ return;
774
+ this.clientModes = next;
775
+ this.sending.delete(client);
776
+ try {
777
+ await this.publishModelOptions(client);
778
+ this.notice = undefined;
779
+ if (!this.modelRestart.includes(client))
780
+ this.modelRestart.push(client);
781
+ this.adapterNotice = undefined;
782
+ }
783
+ catch {
784
+ this.clientModes = previous;
785
+ let restored = true;
786
+ try {
787
+ await this.publishModelOptions(client);
788
+ }
789
+ catch {
790
+ restored = false;
791
+ this.mode = "native";
792
+ try {
793
+ await this.restoreModelOptions();
794
+ }
795
+ catch { /* Surface partial recovery below. */ }
796
+ }
797
+ const persisted = await this.saveMode(this.mode, this.clientModes);
798
+ this.notice = "無法更新客戶端模型設定,請稍後再試。" +
799
+ (restored ? " 已保留原本模式。" : " 目前使用個人模式,部分客戶端設定需要重新檢查。") +
800
+ (persisted ? "" : " 無法儲存恢復後的模式,請重試。");
801
+ }
802
+ }
803
+ async setCodexClientRouteSwitch(mode) {
804
+ const lifecycle = this.codexLifecycle();
805
+ const route = lifecycle.route;
806
+ if (route.health === "red" || route.health === "gray") {
807
+ this.notice = "Codex Client Route 目前無法切換 Classroom。";
808
+ return;
809
+ }
810
+ if (mode === "classroom" && (!this.connected || this.modelIds.length === 0)) {
811
+ this.notice = "請先連線到有可用模型的課堂。";
812
+ return;
813
+ }
814
+ if (route.mode === mode)
815
+ return;
816
+ const previousRoute = { ...route };
817
+ const previousModes = { ...this.clientModes };
818
+ lifecycle.setMode(mode);
819
+ try {
820
+ // Preference persistence and the route-file journal both precede the client write.
821
+ await this.saveClientRoutePreferences();
822
+ const aggregate = Object.values(this.clientModes).includes("classroom") ? "classroom" : "native";
823
+ if (!await this.saveMode(aggregate, this.clientModes))
824
+ throw new Error("mode persistence failed");
825
+ await lifecycle.write(mode);
826
+ lifecycle.setState(mode, mode === "classroom" ? "yellow" : "green", mode === "classroom" ? "restart-required" : "ready");
827
+ route.adapter = this.adapters.codex;
828
+ this.notice = undefined;
829
+ this.adapterNotice = undefined;
830
+ }
831
+ catch {
832
+ this.clientRoutes.codex = previousRoute;
833
+ this.clientModes = previousModes;
834
+ try {
835
+ await this.saveClientRoutePreferences();
836
+ }
837
+ catch { /* Keep the rollback visible in memory. */ }
838
+ try {
839
+ await this.codexLifecycle().write(previousRoute.mode);
840
+ }
841
+ catch { /* The route remains in its previous mode. */ }
842
+ this.notice = "無法更新 Codex Client Route,已保留原本模式。";
843
+ }
844
+ }
845
+ async setVsCodeClientRouteSwitch(mode) {
846
+ const route = this.clientRoutes.vsCodeChat;
847
+ if (route.health === "red" || route.health === "gray") {
848
+ this.notice = "VS Code Chat Client Route 目前無法切換 Classroom。";
849
+ return;
850
+ }
851
+ if (mode === "classroom" && (!this.connected || this.modelIds.length === 0 || !this.vsCodeOrigin)) {
852
+ this.notice = "請先連線到有可用模型的課堂。";
853
+ return;
854
+ }
855
+ if (route.mode === mode)
856
+ return;
857
+ const previous = { ...route };
858
+ route.mode = mode;
859
+ try {
860
+ await this.saveClientRoutePreferences();
861
+ const result = mode === "classroom"
862
+ ? await this.deps.vsCodeRouteSettings?.publish({ providers: this.modelProviders, adapter: route.adapter, origin: this.vsCodeOrigin })
863
+ : await this.deps.vsCodeRouteSettings?.restore();
864
+ if (mode === "classroom")
865
+ this.settleVsCodePublish(result?.changed);
866
+ else {
867
+ route.health = "green";
868
+ route.restart = "ready";
869
+ if (result?.changed)
870
+ this.noteModelRestart("vscode");
871
+ }
872
+ this.notice = undefined;
873
+ }
874
+ catch (error) {
875
+ this.clientRoutes.vsCodeChat = previous;
876
+ try {
877
+ await this.saveClientRoutePreferences();
878
+ }
879
+ catch { /* Keep visible state. */ }
880
+ if (String(error).includes("舊擴充") || String(error).includes("ownership") || String(error).includes("其他程式")) {
881
+ this.clientRoutes.vsCodeChat.health = "red";
882
+ this.notice = error instanceof Error ? error.message : "VS Code VCRouter provider 衝突。";
883
+ }
884
+ else {
885
+ this.notice = "無法更新 VS Code Chat Client Route,已保留原本模式。";
886
+ }
887
+ }
888
+ }
889
+ async setVsCodeAdapter(id) {
890
+ const route = this.clientRoutes.vsCodeChat;
891
+ if (route.health === "red" || route.health === "gray") {
892
+ this.adapterNotice = "VS Code Chat Client Route 目前無法變更 adapter。";
893
+ return;
894
+ }
895
+ const previous = route.adapter;
896
+ route.adapter = id;
897
+ try {
898
+ await this.saveClientRoutePreferences();
899
+ if (route.mode === "classroom") {
900
+ const result = await this.deps.vsCodeRouteSettings?.publish({ providers: this.modelProviders, adapter: id, origin: this.vsCodeOrigin });
901
+ if (result?.changed) {
902
+ route.health = "yellow";
903
+ route.restart = "restart-required";
904
+ this.noteModelRestart("vscode");
905
+ }
906
+ }
907
+ this.adapterNotice = undefined;
908
+ }
909
+ catch {
910
+ route.adapter = previous;
911
+ try {
912
+ await this.saveClientRoutePreferences();
913
+ }
914
+ catch { /* Keep rollback in memory. */ }
915
+ this.adapterNotice = "無法更新 VS Code adapter,已保留原本設定。";
916
+ }
917
+ }
918
+ async setClaudeCodeClientRouteSwitch(mode) {
919
+ const route = this.clientRoutes.claudeCode;
920
+ if (route.health === "red" || route.health === "gray") {
921
+ this.notice = "Claude Code Client Route 目前無法切換 Classroom。";
922
+ return;
923
+ }
924
+ if (mode === "classroom" && (!this.connected || this.modelIds.length === 0)) {
925
+ this.notice = "請先連線到有可用模型的課堂。";
926
+ return;
927
+ }
928
+ if (route.mode === mode)
929
+ return;
930
+ const previousRoute = { ...route };
931
+ const previousModes = { ...this.clientModes };
932
+ route.mode = mode;
933
+ this.clientModes.claude = mode;
934
+ try {
935
+ await this.saveClientRoutePreferences();
936
+ const aggregate = Object.values(this.clientModes).includes("classroom") ? "classroom" : "native";
937
+ if (!await this.saveMode(aggregate, this.clientModes))
938
+ throw new Error("mode persistence failed");
939
+ await this.deps.routes.setClaudeCodeRoute(mode === "classroom", this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl, this.modelIds[0]);
940
+ route.health = mode === "classroom" ? "yellow" : "green";
941
+ route.restart = mode === "classroom" ? "restart-required" : "ready";
942
+ this.notice = undefined;
943
+ this.adapterNotice = undefined;
944
+ }
945
+ catch {
946
+ this.clientRoutes.claudeCode = previousRoute;
947
+ this.clientModes = previousModes;
948
+ try {
949
+ await this.saveClientRoutePreferences();
950
+ }
951
+ catch { /* Keep the rollback visible in memory. */ }
952
+ try {
953
+ await this.deps.routes.setClaudeCodeRoute(previousRoute.mode === "classroom", this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl, this.modelIds[0]);
954
+ }
955
+ catch { /* The visible route state remains authoritative. */ }
956
+ this.notice = "無法更新 Claude Code Client Route,已保留原本模式。";
957
+ }
958
+ }
959
+ async setSwitchNow(mode) {
960
+ const requested = mode === "classroom" && this.connected && this.modelIds.length > 0 ? "classroom" : "native";
961
+ if (!await this.saveMode(requested))
962
+ return;
963
+ const entering = Object.values(this.clientModes).some(mode => mode !== requested);
964
+ this.mode = requested;
965
+ if (entering || requested === "native")
966
+ this.forgetModelChoices();
967
+ try {
968
+ if (requested === "classroom")
969
+ await this.publishModelOptions();
970
+ else
971
+ await this.restoreModelOptions();
972
+ if (entering)
973
+ this.modelRestart = ["codex", "claude"];
974
+ }
975
+ catch {
976
+ // Sequential client-file writes may have partially succeeded. Upstream must be safe immediately.
977
+ this.mode = "native";
978
+ this.forgetModelChoices();
979
+ let restored = true;
980
+ try {
981
+ await this.restoreModelOptions();
982
+ }
983
+ catch {
984
+ restored = false;
985
+ }
986
+ const persisted = requested === "native" || await this.saveMode("native");
987
+ this.notice = "無法更新模型設定。目前使用 Native,請稍後再試。" +
988
+ (restored ? "" : " 部分客戶端設定無法恢復,請重試或手動檢查。") +
989
+ (persisted ? "" : " 無法儲存 Native 模式,重新啟動仍可能恢復 Classroom;請重試。");
990
+ }
991
+ }
992
+ async saveMode(mode, clientModes = { codex: mode, claude: mode }) {
993
+ try {
994
+ const connection = await this.deps.storage.getConnection();
995
+ if (connection && !this.deps.clientRouteSettings) {
996
+ await this.deps.storage.setConnection({ ...connection, mode, clientModes }, { allowTemporary: false });
997
+ }
998
+ this.notice = undefined;
999
+ return true;
1000
+ }
1001
+ catch {
1002
+ this.notice = "無法儲存模型模式。已保留原本模式,請稍後再試。";
1003
+ return false;
1004
+ }
1005
+ }
1006
+ async reloadCatalog() {
1007
+ return this.serialConnectionOperation(() => this.loadCatalog());
1008
+ }
1009
+ async reloadAllowlist() {
1010
+ await this.detectClients();
1011
+ await this.deps.routes.migrateLegacyState(this.deps.proxyBaseUrl, this.detectedClients);
1012
+ if (this.deps.clientRouteRuntime) {
1013
+ await this.startCodexRoute();
1014
+ await this.startClaudeCodeRoute();
1015
+ await this.startVsCodeRoute();
1016
+ }
1017
+ return this.serialConnectionOperation(() => this.reloadAllowlistNow());
1018
+ }
1019
+ async saveClientRoutePreferences() {
1020
+ await this.deps.clientRouteSettings?.save({
1021
+ codex: { mode: this.clientRoutes.codex.mode, adapter: this.clientRoutes.codex.adapter },
1022
+ claudeCode: { mode: this.clientRoutes.claudeCode.mode, adapter: this.clientRoutes.claudeCode.adapter },
1023
+ vsCodeChat: { mode: this.clientRoutes.vsCodeChat.mode, adapter: this.clientRoutes.vsCodeChat.adapter },
1024
+ });
1025
+ }
1026
+ async startCodexRoute() {
1027
+ const runtime = this.deps.clientRouteRuntime;
1028
+ if (!runtime)
1029
+ return;
1030
+ let lifecycle = this.codexLifecycle();
1031
+ if (!this.detectedClients.codex) {
1032
+ lifecycle.setState("native", "gray");
1033
+ return;
1034
+ }
1035
+ const alreadyRunning = runtime.current?.("codex")?.health === "green";
1036
+ const restartPending = this.clientRoutes.codex.health === "yellow" &&
1037
+ this.clientRoutes.codex.restart === "restart-required";
1038
+ const retryingNativeRecovery = this.clientRoutes.codex.health === "red" && this.clientRoutes.codex.mode === "native";
1039
+ let health = "red";
1040
+ try {
1041
+ const started = await runtime.start("codex");
1042
+ this.clientRouteOrigins.codex = started.origin;
1043
+ health = started.health === "green" ? "green" : "red";
1044
+ }
1045
+ catch {
1046
+ health = "red";
1047
+ }
1048
+ if (health === "red") {
1049
+ lifecycle.setState("native", "red");
1050
+ try {
1051
+ await this.saveClientRoutePreferences();
1052
+ }
1053
+ catch { /* The visible red state remains authoritative. */ }
1054
+ try {
1055
+ await lifecycle.write("native");
1056
+ }
1057
+ catch { /* Retried on reload. */ }
1058
+ return;
1059
+ }
1060
+ lifecycle = this.codexLifecycle(); // start() supplied the route's authoritative origin.
1061
+ if (this.clientRoutes.codex.mode === "classroom" && (!this.connected || this.modelIds.length === 0)) {
1062
+ lifecycle.setMode("native");
1063
+ try {
1064
+ await this.saveClientRoutePreferences();
1065
+ }
1066
+ catch { /* Native remains the safe runtime state. */ }
1067
+ }
1068
+ if (retryingNativeRecovery) {
1069
+ try {
1070
+ await lifecycle.write("native");
1071
+ }
1072
+ catch {
1073
+ lifecycle.setState("native", "red");
1074
+ return;
1075
+ }
1076
+ }
1077
+ this.clientModes.codex = this.clientRoutes.codex.mode === "classroom" && this.connected && this.modelIds.length > 0
1078
+ ? "classroom"
1079
+ : "native";
1080
+ if (alreadyRunning && restartPending && this.clientModes.codex === "classroom")
1081
+ return;
1082
+ // Listener recovery never restores Classroom automatically after a red failure.
1083
+ lifecycle.setState(this.clientModes.codex, "green");
1084
+ if (!alreadyRunning && this.clientModes.codex === "classroom") {
1085
+ try {
1086
+ await lifecycle.write("classroom");
1087
+ lifecycle.setState("classroom", "yellow", "restart-required");
1088
+ }
1089
+ catch {
1090
+ lifecycle.setState("native", "red");
1091
+ try {
1092
+ await this.saveClientRoutePreferences();
1093
+ }
1094
+ catch { /* Visible red state wins. */ }
1095
+ }
1096
+ }
1097
+ }
1098
+ async startVsCodeRoute() {
1099
+ const runtime = this.deps.clientRouteRuntime;
1100
+ if (!runtime)
1101
+ return;
1102
+ const retryingNativeRecovery = this.clientRoutes.vsCodeChat.health === "red" && this.clientRoutes.vsCodeChat.mode === "native";
1103
+ if (!this.detectedClients.vsCodeChat) {
1104
+ this.clientRoutes.vsCodeChat = { ...this.clientRoutes.vsCodeChat, mode: "native", health: "gray", restart: "ready" };
1105
+ return;
1106
+ }
1107
+ const alreadyRunning = this.vsCodeOrigin !== undefined &&
1108
+ (this.clientRoutes.vsCodeChat.health === "green" || this.clientRoutes.vsCodeChat.health === "yellow") &&
1109
+ (runtime.current?.("vsCodeChat")?.health ?? "green") === "green";
1110
+ let started;
1111
+ try {
1112
+ started = await runtime.start("vsCodeChat");
1113
+ }
1114
+ catch {
1115
+ await this.failVsCodeRouteStart();
1116
+ return;
1117
+ }
1118
+ this.vsCodeOrigin = started.origin;
1119
+ if (started.health !== "green") {
1120
+ await this.failVsCodeRouteStart();
1121
+ return;
1122
+ }
1123
+ if (retryingNativeRecovery) {
1124
+ try {
1125
+ await this.deps.vsCodeRouteSettings?.restore();
1126
+ this.clientRoutes.vsCodeChat.health = "green";
1127
+ this.clientRoutes.vsCodeChat.restart = "ready";
1128
+ }
1129
+ catch {
1130
+ this.clientRoutes.vsCodeChat.health = "red";
1131
+ this.clientRoutes.vsCodeChat.restart = "ready";
1132
+ }
1133
+ return;
1134
+ }
1135
+ if (alreadyRunning)
1136
+ return;
1137
+ try {
1138
+ if (this.clientRoutes.vsCodeChat.mode === "classroom" && this.connected && this.modelIds.length > 0) {
1139
+ const result = await this.deps.vsCodeRouteSettings?.publish({
1140
+ providers: this.modelProviders,
1141
+ adapter: this.clientRoutes.vsCodeChat.adapter,
1142
+ origin: started.origin,
1143
+ });
1144
+ this.settleVsCodePublish(result?.changed);
1145
+ }
1146
+ else {
1147
+ this.clientRoutes.vsCodeChat.mode = "native";
1148
+ await this.deps.vsCodeRouteSettings?.initializeNative();
1149
+ this.clientRoutes.vsCodeChat.health = "green";
1150
+ this.clientRoutes.vsCodeChat.restart = "ready";
1151
+ }
1152
+ }
1153
+ catch (error) {
1154
+ this.clientRoutes.vsCodeChat.mode = "native";
1155
+ this.clientRoutes.vsCodeChat.health = "red";
1156
+ this.clientRoutes.vsCodeChat.restart = "ready";
1157
+ this.notice = error instanceof Error ? error.message : "VS Code Chat Client Route 設定失敗。";
1158
+ }
1159
+ try {
1160
+ await this.saveClientRoutePreferences();
1161
+ }
1162
+ catch { /* Route health remains visible. */ }
1163
+ }
1164
+ async failVsCodeRouteStart() {
1165
+ try {
1166
+ await this.deps.vsCodeRouteSettings?.restore();
1167
+ }
1168
+ catch { /* Journal remains available for retry. */ }
1169
+ this.clientRoutes.vsCodeChat = { ...this.clientRoutes.vsCodeChat, mode: "native", health: "red", restart: "ready" };
1170
+ try {
1171
+ await this.saveClientRoutePreferences();
1172
+ }
1173
+ catch { /* Visible red state wins. */ }
1174
+ }
1175
+ async startClaudeCodeRoute() {
1176
+ const runtime = this.deps.clientRouteRuntime;
1177
+ if (!runtime)
1178
+ return;
1179
+ const route = this.clientRoutes.claudeCode;
1180
+ const retryingNativeRecovery = route.health === "red" && route.mode === "native";
1181
+ if (!this.detectedClients.claude) {
1182
+ route.mode = "native";
1183
+ route.health = "gray";
1184
+ route.restart = "ready";
1185
+ this.clientModes.claude = "native";
1186
+ return;
1187
+ }
1188
+ const alreadyRunning = runtime.current?.("claudeCode")?.health === "green";
1189
+ const restartPending = route.health === "yellow" && route.restart === "restart-required";
1190
+ let health = "red";
1191
+ try {
1192
+ const started = await runtime.start("claudeCode");
1193
+ this.clientRouteOrigins.claudeCode = started.origin;
1194
+ health = started.health === "green" ? "green" : "red";
1195
+ }
1196
+ catch {
1197
+ health = "red";
1198
+ }
1199
+ if (health === "red") {
1200
+ route.mode = "native";
1201
+ route.health = "red";
1202
+ route.restart = "ready";
1203
+ this.clientModes.claude = "native";
1204
+ try {
1205
+ await this.saveClientRoutePreferences();
1206
+ }
1207
+ catch { /* The visible red state remains authoritative. */ }
1208
+ try {
1209
+ await this.deps.routes.setClaudeCodeRoute(false, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl);
1210
+ }
1211
+ catch { /* Retried on reload. */ }
1212
+ return;
1213
+ }
1214
+ if (route.mode === "classroom" && (!this.connected || this.modelIds.length === 0)) {
1215
+ route.mode = "native";
1216
+ try {
1217
+ await this.saveClientRoutePreferences();
1218
+ }
1219
+ catch { /* Native remains the safe runtime state. */ }
1220
+ }
1221
+ if (retryingNativeRecovery) {
1222
+ try {
1223
+ await this.deps.routes.setClaudeCodeRoute(false, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl);
1224
+ }
1225
+ catch {
1226
+ route.health = "red";
1227
+ route.restart = "ready";
1228
+ return;
1229
+ }
1230
+ }
1231
+ this.clientModes.claude = route.mode === "classroom" && this.connected && this.modelIds.length > 0
1232
+ ? "classroom"
1233
+ : "native";
1234
+ if (alreadyRunning && restartPending && this.clientModes.claude === "classroom")
1235
+ return;
1236
+ route.health = "green";
1237
+ route.restart = "ready";
1238
+ if (alreadyRunning)
1239
+ return;
1240
+ try {
1241
+ await this.deps.routes.setClaudeCodeRoute(this.clientModes.claude === "classroom", this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl, this.modelIds[0]);
1242
+ if (this.clientModes.claude === "classroom") {
1243
+ route.health = "yellow";
1244
+ route.restart = "restart-required";
1245
+ }
1246
+ }
1247
+ catch {
1248
+ route.mode = "native";
1249
+ route.health = "red";
1250
+ route.restart = "ready";
1251
+ this.clientModes.claude = "native";
1252
+ try {
1253
+ await this.saveClientRoutePreferences();
1254
+ }
1255
+ catch { /* The visible red state remains authoritative. */ }
1256
+ }
1257
+ }
1258
+ async detectClients() {
1259
+ await Promise.all(["codex", "claude", "vsCodeChat"].map(async (client) => {
1260
+ try {
1261
+ this.detectedClients[client] = await this.deps.detectClient?.(client) ?? false;
1262
+ }
1263
+ catch {
1264
+ this.detectedClients[client] = false;
1265
+ }
1266
+ }));
1267
+ }
1268
+ async reloadAllowlistNow() {
1269
+ if (!this.connected)
1270
+ return;
1271
+ const apiKey = (await this.deps.storage.getConnection())?.apiKey;
1272
+ if (!apiKey) {
1273
+ return;
1274
+ }
1275
+ const previous = this.mode === "classroom" ? this.modelIds.join("\0") : "";
1276
+ const refreshed = await this.loadModels(apiKey);
1277
+ this.allowlistError = refreshed
1278
+ ? undefined
1279
+ : "重新載入課堂模型失敗,已保留上次成功的模型清單。";
1280
+ const current = this.mode === "classroom" ? this.modelIds.join("\0") : "";
1281
+ if (previous !== current) {
1282
+ this.modelRestart = ["codex", "claude"].filter(client => this.clientModes[client] === "classroom");
1283
+ if (this.deps.clientRouteRuntime) {
1284
+ if (this.clientRoutes.codex.mode === "classroom") {
1285
+ this.clientRoutes.codex.health = "yellow";
1286
+ this.clientRoutes.codex.restart = "restart-required";
1287
+ }
1288
+ if (this.clientRoutes.claudeCode.mode === "classroom") {
1289
+ this.clientRoutes.claudeCode.health = "yellow";
1290
+ this.clientRoutes.claudeCode.restart = "restart-required";
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+ async loadModels(apiKey) {
1296
+ let payload;
1297
+ try {
1298
+ payload = await this.deps.router.fetchSessionModels(apiKey);
1299
+ }
1300
+ catch {
1301
+ return false;
1302
+ }
1303
+ if (!Array.isArray(payload))
1304
+ return false;
1305
+ const items = payload;
1306
+ const stringPayload = items.every(item => typeof item === "string");
1307
+ const providerPayload = items.every(item => !!item && typeof item === "object" && !Array.isArray(item));
1308
+ if (!stringPayload && !providerPayload)
1309
+ return false;
1310
+ const providers = stringPayload ? [] : items;
1311
+ const ids = [];
1312
+ if (stringPayload) {
1313
+ ids.push(...items);
1314
+ }
1315
+ else {
1316
+ for (const provider of providers) {
1317
+ if (!Array.isArray(provider.models))
1318
+ return false;
1319
+ for (const model of provider.models) {
1320
+ const id = model && typeof model === "object" && !Array.isArray(model)
1321
+ ? model.id
1322
+ : undefined;
1323
+ if (typeof id !== "string" || !id.trim())
1324
+ return false;
1325
+ ids.push(id);
1326
+ }
1327
+ }
1328
+ }
1329
+ if (ids.length === 0 || ids.some(id => !id.trim())) {
1330
+ this.modelIds = [];
1331
+ this.modelProviders = [];
1332
+ this.mode = "native";
1333
+ if (this.deps.clientRouteRuntime) {
1334
+ this.codexLifecycle().setMode("native");
1335
+ this.clientRoutes.claudeCode.mode = "native";
1336
+ this.clientRoutes.vsCodeChat.mode = "native";
1337
+ this.clientRoutes.codex.restart = "ready";
1338
+ this.clientRoutes.claudeCode.restart = "ready";
1339
+ this.clientRoutes.vsCodeChat.restart = "ready";
1340
+ try {
1341
+ await this.saveClientRoutePreferences();
1342
+ }
1343
+ catch {
1344
+ this.clientRoutes.codex.health = "red";
1345
+ this.clientRoutes.claudeCode.health = "red";
1346
+ this.clientRoutes.vsCodeChat.health = "red";
1347
+ }
1348
+ try {
1349
+ await this.deps.routes.setClaudeCodeRoute(false, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl);
1350
+ if (this.clientRoutes.claudeCode.health !== "red") {
1351
+ this.clientRoutes.claudeCode.health = this.detectedClients.claude ? "green" : "gray";
1352
+ }
1353
+ }
1354
+ catch {
1355
+ this.clientRoutes.claudeCode.health = "red";
1356
+ }
1357
+ try {
1358
+ await this.deps.vsCodeRouteSettings?.restore();
1359
+ if (this.clientRoutes.vsCodeChat.health !== "red") {
1360
+ this.clientRoutes.vsCodeChat.health = this.detectedClients.vsCodeChat ? "green" : "gray";
1361
+ }
1362
+ }
1363
+ catch {
1364
+ this.clientRoutes.vsCodeChat.health = "red";
1365
+ }
1366
+ }
1367
+ this.forgetModelChoices();
1368
+ if (this.deps.clientRouteRuntime) {
1369
+ try {
1370
+ await this.codexLifecycle().write("native");
1371
+ if (this.clientRoutes.codex.health !== "red") {
1372
+ this.clientRoutes.codex.health = this.detectedClients.codex ? "green" : "gray";
1373
+ }
1374
+ }
1375
+ catch {
1376
+ this.clientRoutes.codex.health = "red";
1377
+ }
1378
+ }
1379
+ else {
1380
+ await this.restoreModelOptions();
1381
+ }
1382
+ return true;
1383
+ }
1384
+ const previous = this.modelIds;
1385
+ const providersChanged = !(0, node_util_1.isDeepStrictEqual)(this.modelProviders, providers);
1386
+ const changed = previous.join("\0") !== ids.join("\0");
1387
+ this.modelIds = ids;
1388
+ this.modelProviders = structuredClone(providers);
1389
+ if (providersChanged && this.deps.clientRouteRuntime && this.clientRoutes.vsCodeChat.mode === "classroom" && this.vsCodeOrigin) {
1390
+ try {
1391
+ const result = await this.deps.vsCodeRouteSettings?.publish({
1392
+ providers: this.modelProviders,
1393
+ adapter: this.clientRoutes.vsCodeChat.adapter,
1394
+ origin: this.vsCodeOrigin,
1395
+ });
1396
+ if (result?.changed) {
1397
+ this.clientRoutes.vsCodeChat.health = "yellow";
1398
+ this.clientRoutes.vsCodeChat.restart = "restart-required";
1399
+ this.noteModelRestart("vscode");
1400
+ }
1401
+ }
1402
+ catch (error) {
1403
+ this.clientRoutes.vsCodeChat.health = "red";
1404
+ this.notice = error instanceof Error ? error.message : "VS Code Chat Client Route 設定失敗。";
1405
+ }
1406
+ }
1407
+ if (!changed || this.mode !== "classroom") {
1408
+ return true;
1409
+ }
1410
+ for (const client of PROXY_CLIENTS) {
1411
+ const current = this.sending.get(client);
1412
+ if (!current || ids.includes(current)) {
1413
+ continue;
1414
+ }
1415
+ this.sending.delete(client);
1416
+ }
1417
+ await this.publishModelOptions();
1418
+ if (this.clientModes.claude === "classroom" && previous[0] !== ids[0]) {
1419
+ try {
1420
+ await this.deps.routes.setClaudeCodeRoute(true, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl, ids[0]);
1421
+ }
1422
+ catch {
1423
+ this.notice = "無法更新 Claude Code 的課堂模型選項。";
1424
+ }
1425
+ }
1426
+ return true;
1427
+ }
1428
+ async copyKey() {
1429
+ return this.serialConnectionOperation(() => this.copyKeyNow());
1430
+ }
1431
+ async copyKeyNow() {
1432
+ this.connectionError = undefined;
1433
+ try {
1434
+ const apiKey = this.connected ? (await this.deps.storage.getConnection())?.apiKey : undefined;
1435
+ if (!apiKey) {
1436
+ this.notice = this.connectionNotice = "無法複製 Classroom API Key。請重新連線後再試。";
1437
+ return;
1438
+ }
1439
+ await this.deps.clipboard.write(apiKey);
1440
+ }
1441
+ catch {
1442
+ this.notice = this.connectionNotice = "無法複製 Classroom API Key。請重新連線後再試。";
1443
+ return;
1444
+ }
1445
+ this.notice = this.connectionNotice = "已複製 Classroom API Key。請勿分享給不信任的人。";
1446
+ }
1447
+ async copySnippet(snippetId) {
1448
+ const snippet = this.catalog?.snippets?.find((item) => item.id === snippetId);
1449
+ if (!snippet)
1450
+ return false;
1451
+ try {
1452
+ await this.deps.clipboard.write(snippet.body);
1453
+ this.copiedSnippets.set(snippet.id, snippet.body);
1454
+ return true;
1455
+ }
1456
+ catch {
1457
+ return false;
1458
+ }
1459
+ }
1460
+ async clearConnection() {
1461
+ return this.serialConnectionOperation(() => this.clearConnectionNow());
1462
+ }
1463
+ async clearConnectionNow() {
1464
+ try {
1465
+ await this.deps.storage.clearConnection();
1466
+ }
1467
+ catch {
1468
+ this.notice = this.connectionNotice = "無法清除課堂連線。已保留原本連線,重新啟動後仍可能恢復。請稍後再試。";
1469
+ return;
1470
+ }
1471
+ this.applyConnectionIdentity(undefined);
1472
+ this.connectionError = undefined;
1473
+ this.connectionDraft = undefined;
1474
+ this.notice = undefined;
1475
+ this.allowlistError = undefined;
1476
+ this.catalog = undefined;
1477
+ this.catalogError = undefined;
1478
+ this.remoteCatalogHeld = false;
1479
+ this.mode = "native";
1480
+ if (this.deps.clientRouteRuntime) {
1481
+ this.codexLifecycle().setMode("native");
1482
+ this.clientRoutes.codex.restart = "ready";
1483
+ this.clientRoutes.claudeCode.mode = "native";
1484
+ this.clientRoutes.claudeCode.restart = "ready";
1485
+ this.clientRoutes.vsCodeChat.mode = "native";
1486
+ this.clientRoutes.vsCodeChat.restart = "ready";
1487
+ try {
1488
+ await this.saveClientRoutePreferences();
1489
+ }
1490
+ catch {
1491
+ this.clientRoutes.codex.health = "red";
1492
+ this.clientRoutes.claudeCode.health = "red";
1493
+ this.clientRoutes.vsCodeChat.health = "red";
1494
+ }
1495
+ }
1496
+ this.modelIds = [];
1497
+ this.forgetModelChoices();
1498
+ let restoreFailed = false;
1499
+ try {
1500
+ if (this.deps.clientRouteRuntime)
1501
+ await this.codexLifecycle().write("native");
1502
+ else
1503
+ await this.restoreModelOptions();
1504
+ if (this.deps.clientRouteRuntime && this.clientRoutes.codex.health !== "red") {
1505
+ this.clientRoutes.codex.health = this.detectedClients.codex ? "green" : "gray";
1506
+ }
1507
+ }
1508
+ catch {
1509
+ restoreFailed = true;
1510
+ if (this.deps.clientRouteRuntime)
1511
+ this.clientRoutes.codex.health = "red";
1512
+ }
1513
+ if (this.deps.clientRouteRuntime) {
1514
+ try {
1515
+ await this.deps.routes.setClaudeCodeRoute(false, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl);
1516
+ if (this.clientRoutes.claudeCode.health !== "red") {
1517
+ this.clientRoutes.claudeCode.health = this.detectedClients.claude ? "green" : "gray";
1518
+ }
1519
+ }
1520
+ catch {
1521
+ restoreFailed = true;
1522
+ this.clientRoutes.claudeCode.health = "red";
1523
+ }
1524
+ try {
1525
+ await this.deps.vsCodeRouteSettings?.restore();
1526
+ if (this.clientRoutes.vsCodeChat.health !== "red") {
1527
+ this.clientRoutes.vsCodeChat.health = this.detectedClients.vsCodeChat ? "green" : "gray";
1528
+ }
1529
+ }
1530
+ catch {
1531
+ restoreFailed = true;
1532
+ this.clientRoutes.vsCodeChat.health = "red";
1533
+ }
1534
+ }
1535
+ if (restoreFailed) {
1536
+ this.notice = this.connectionNotice = "課堂連線已清除,但無法恢復客戶端的 Native 模型設定。請稍後重試或重新啟動。";
1537
+ }
1538
+ }
1539
+ async start() {
1540
+ return this.serialConnectionOperation(() => this.startNow());
1541
+ }
1542
+ async startNow() {
1543
+ this.stopping = false;
1544
+ if (!this.initialization)
1545
+ this.initialization = (async () => {
1546
+ if (this.deps.adapterSettings) {
1547
+ try {
1548
+ this.adapters = await this.deps.adapterSettings.load();
1549
+ }
1550
+ catch {
1551
+ this.adapterNotice = "無法讀取 adapter 設定,目前使用 Responses 預設值。";
1552
+ }
1553
+ }
1554
+ if (this.deps.clientRouteSettings) {
1555
+ try {
1556
+ const settings = await this.deps.clientRouteSettings.load();
1557
+ this.clientRoutes = {
1558
+ codex: { ...this.clientRoutes.codex, ...settings.codex },
1559
+ claudeCode: { ...this.clientRoutes.claudeCode, ...settings.claudeCode },
1560
+ vsCodeChat: { ...this.clientRoutes.vsCodeChat, ...settings.vsCodeChat },
1561
+ };
1562
+ this.adapters = { ...this.adapters, codex: settings.codex.adapter };
1563
+ this.adapters = { ...this.adapters, claude: settings.claudeCode.adapter };
1564
+ }
1565
+ catch {
1566
+ this.clientRoutes = (0, clientRoutes_1.defaultClientRoutes)();
1567
+ }
1568
+ }
1569
+ await this.detectClients();
1570
+ await this.deps.routes.migrateLegacyState(this.deps.proxyBaseUrl, this.detectedClients);
1571
+ await this.restoreConnection();
1572
+ await this.startCodexRoute();
1573
+ await this.startClaudeCodeRoute();
1574
+ await this.startVsCodeRoute();
1575
+ })();
1576
+ await this.initialization;
1577
+ this.deps.proxy.start();
1578
+ this.proxyRunning = true;
1579
+ if (!this.routeRestartNoted) {
1580
+ this.noteRestart("codex");
1581
+ this.noteRestart("claude");
1582
+ this.routeRestartNoted = true;
1583
+ }
1584
+ // Embedders created before the route runtime existed still receive the Codex
1585
+ // projection. The shipped app always supplies the independent runtime.
1586
+ if (!this.deps.clientRouteRuntime) {
1587
+ try {
1588
+ if (this.mode === "classroom")
1589
+ await this.publishModelOptions();
1590
+ else
1591
+ await this.restoreModelOptions();
1592
+ }
1593
+ catch {
1594
+ this.mode = "native";
1595
+ this.notice = "無法完成客戶端設定恢復。備份已保留,請重試切換個人模式。";
1596
+ }
1597
+ }
1598
+ }
1599
+ applyConnectionIdentity(connection) {
1600
+ this.connected = connection !== undefined;
1601
+ this.classLabel = connection && ([connection.session.class_name, connection.session.name].filter(Boolean).join(" · ") || undefined);
1602
+ this.courseTitle = connection?.session.class_name || undefined;
1603
+ this.sessionTitle = connection?.session.name || undefined;
1604
+ this.nickname = connection?.nickname;
1605
+ this.detail = connection ? "Classroom API Key 已設定。" : "";
1606
+ }
1607
+ async restoreConnection() {
1608
+ if (this.connected)
1609
+ return;
1610
+ let connection;
1611
+ try {
1612
+ connection = await this.deps.storage.getConnection();
1613
+ }
1614
+ catch (error) {
1615
+ this.detail = error instanceof storageErrors_1.StorageError ? error.message : "課堂連線無法讀取。請重新連線。";
1616
+ return;
1617
+ }
1618
+ if (!connection)
1619
+ return;
1620
+ this.hiddenKeys.add(connection.apiKey);
1621
+ this.applyConnectionIdentity(connection);
1622
+ this.mode = "native";
1623
+ await this.loadCatalog();
1624
+ await this.loadModels(connection.apiKey);
1625
+ if (connection.mode === "classroom" && this.modelIds.length > 0) {
1626
+ this.mode = "classroom";
1627
+ this.clientModes = connection.clientModes ?? { codex: "classroom", claude: "classroom" };
1628
+ }
1629
+ }
1630
+ async stop() {
1631
+ if (this.commandRunning)
1632
+ throw new Error("安裝中,請完成後再結束。");
1633
+ this.stopping = true;
1634
+ try {
1635
+ await this.serialConnectionOperation(() => this.stopNow());
1636
+ }
1637
+ catch (error) {
1638
+ this.stopping = false;
1639
+ throw error;
1640
+ }
1641
+ }
1642
+ async stopNow() {
1643
+ this.proxyRunning = false;
1644
+ this.deps.proxy.stop();
1645
+ let failure;
1646
+ try {
1647
+ if (this.deps.clientRouteRuntime)
1648
+ await this.codexLifecycle().stop();
1649
+ else
1650
+ await this.restoreModelOptions();
1651
+ }
1652
+ catch (error) {
1653
+ failure = error;
1654
+ if (this.deps.clientRouteRuntime)
1655
+ this.clientRoutes.codex.health = "red";
1656
+ }
1657
+ try {
1658
+ if (this.deps.clientRouteRuntime) {
1659
+ await this.deps.routes.setClaudeCodeRoute(false, this.clientRouteOrigins.claudeCode ?? this.deps.proxyBaseUrl);
1660
+ await this.deps.clientRouteRuntime.stop("claudeCode");
1661
+ }
1662
+ }
1663
+ catch (error) {
1664
+ failure ??= error;
1665
+ this.clientRoutes.claudeCode.health = "red";
1666
+ }
1667
+ try {
1668
+ if (this.deps.clientRouteRuntime) {
1669
+ await this.deps.vsCodeRouteSettings?.restore();
1670
+ await this.deps.clientRouteRuntime.stop("vsCodeChat");
1671
+ }
1672
+ }
1673
+ catch (error) {
1674
+ failure ??= error;
1675
+ this.clientRoutes.vsCodeChat.health = "red";
1676
+ }
1677
+ if (failure) {
1678
+ const message = failure instanceof Error ? failure.message : String(failure);
1679
+ throw new Error(this.redactSecrets(message || "無法停止 Client Route。"));
1680
+ }
1681
+ }
1682
+ noteRestart(client) {
1683
+ if (!this.mustRestart.includes(client)) {
1684
+ this.mustRestart.push(client);
1685
+ }
1686
+ }
1687
+ noteModelRestart(client) {
1688
+ if (!this.modelRestart.includes(client))
1689
+ this.modelRestart.push(client);
1690
+ }
1691
+ // An unchanged republish cannot clear a restart VS Code still owes; only its first request can.
1692
+ settleVsCodePublish(changed) {
1693
+ if (changed)
1694
+ this.noteModelRestart("vscode");
1695
+ const waiting = this.modelRestart.includes("vscode");
1696
+ this.clientRoutes.vsCodeChat.health = waiting ? "yellow" : "green";
1697
+ this.clientRoutes.vsCodeChat.restart = waiting ? "restart-required" : "ready";
1698
+ }
1699
+ closeWindow() {
1700
+ // 關分頁不停止代理,也不改路由。
1701
+ }
1702
+ async loadCatalog() {
1703
+ if (!this.connected) {
1704
+ return;
1705
+ }
1706
+ const apiKey = (await this.deps.storage.getConnection())?.apiKey;
1707
+ if (!apiKey) {
1708
+ this.catalog = undefined;
1709
+ this.catalogError = undefined;
1710
+ this.remoteCatalogHeld = false;
1711
+ return;
1712
+ }
1713
+ let body;
1714
+ try {
1715
+ body = await this.deps.catalog.fetchCourseCatalog(apiKey);
1716
+ }
1717
+ catch {
1718
+ await this.loadLocalCatalog();
1719
+ return;
1720
+ }
1721
+ const yaml = usableCatalogYaml(body?.course_catalog_yaml);
1722
+ if (yaml === undefined) {
1723
+ await this.loadLocalCatalog();
1724
+ return;
1725
+ }
1726
+ const parsed = (0, courseCatalog_1.parseCourseCatalog)(yaml);
1727
+ if (!parsed.ok) {
1728
+ await this.loadLocalCatalog();
1729
+ return;
1730
+ }
1731
+ this.catalog = catalogView(parsed.actions, parsed.snippets, "remote");
1732
+ this.catalogError = undefined;
1733
+ this.remoteCatalogHeld = true;
1734
+ }
1735
+ async loadLocalCatalog() {
1736
+ this.remoteCatalogHeld = false;
1737
+ if (!this.projectFolder) {
1738
+ this.catalog = undefined;
1739
+ this.catalogError = "遠端清單不可用。";
1740
+ return;
1741
+ }
1742
+ let text;
1743
+ try {
1744
+ text = await this.deps.projectFiles.readClassroomInstalls(this.projectFolder);
1745
+ }
1746
+ catch {
1747
+ text = undefined;
1748
+ }
1749
+ if (text === undefined) {
1750
+ this.catalog = undefined;
1751
+ this.catalogError = "找不到 classroom-installs.yaml";
1752
+ return;
1753
+ }
1754
+ const parsed = (0, courseCatalog_1.parseCourseCatalog)(text);
1755
+ if (!parsed.ok) {
1756
+ this.catalog = undefined;
1757
+ this.catalogError = parsed.error;
1758
+ return;
1759
+ }
1760
+ this.catalog = catalogView(parsed.actions, parsed.snippets, "local");
1761
+ this.catalogError = undefined;
1762
+ }
1763
+ }
1764
+ exports.ClassroomApp = ClassroomApp;
1765
+ function environmentInstallSucceeded(result) {
1766
+ if (result.exitCode === 0) {
1767
+ return true;
1768
+ }
1769
+ if (typeof result.exitCode !== "number") {
1770
+ return false;
1771
+ }
1772
+ return (/already installed/i.test(result.output) ||
1773
+ /no available upgrade found/i.test(result.output));
1774
+ }
1775
+ function usableCatalogYaml(value) {
1776
+ if (typeof value !== "string" || value.trim() === "") {
1777
+ return undefined;
1778
+ }
1779
+ return value;
1780
+ }
1781
+ function withHttp(upstream, request) {
1782
+ const next = { ...upstream };
1783
+ if (request.headers)
1784
+ next.headers = request.headers;
1785
+ if (request.signal)
1786
+ next.signal = request.signal;
1787
+ if (request.method !== undefined) {
1788
+ next.method = request.method;
1789
+ }
1790
+ if (request.path !== undefined) {
1791
+ next.path = request.path;
1792
+ }
1793
+ if (request.body !== undefined) {
1794
+ next.body = request.body;
1795
+ }
1796
+ return next;
1797
+ }
1798
+ function forwardedRecord(upstream) {
1799
+ return {
1800
+ client: upstream.client,
1801
+ target: upstream.target,
1802
+ model: upstream.model,
1803
+ };
1804
+ }
1805
+ function catalogView(actions, snippets, source) {
1806
+ const view = { source, actions };
1807
+ if (snippets.length > 0) {
1808
+ view.snippets = snippets;
1809
+ }
1810
+ if (source === "local") {
1811
+ view.localNote = LOCAL_LIST_NOTE;
1812
+ }
1813
+ return view;
1814
+ }