@phi-code-admin/phi-code 0.75.4 → 0.75.6

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.
@@ -40,6 +40,7 @@ import {
40
40
  pingOpenCodeGo,
41
41
  validateOpenCodeGoApiKey,
42
42
  } from "./providers/opencode-go.js";
43
+ import { fetchLiveModels, pingProvider, toPersistedModel } from "./providers/live-models.js";
43
44
 
44
45
  // ─── Types ───────────────────────────────────────────────────────────────
45
46
 
@@ -176,22 +177,9 @@ function getProviderCatalog(): ProviderEntry[] {
176
177
  // ─── Helpers ─────────────────────────────────────────────────────────────
177
178
 
178
179
  async function probeLocalProvider(provider: ProviderEntry): Promise<string[]> {
179
- if (!provider.probeUrl) return [];
180
- const controller = new AbortController();
181
- const timeout = setTimeout(() => controller.abort(), 2_500);
182
- try {
183
- const res = await fetch(provider.probeUrl, {
184
- signal: controller.signal,
185
- headers: { Authorization: `Bearer ${provider.id === "ollama" ? "ollama" : "lm-studio"}` },
186
- });
187
- clearTimeout(timeout);
188
- if (!res.ok) return [];
189
- const data = (await res.json()) as { data?: Array<{ id?: string; name?: string }> };
190
- return (data.data ?? []).map((m) => m.id ?? m.name ?? "").filter(Boolean);
191
- } catch {
192
- clearTimeout(timeout);
193
- return [];
194
- }
180
+ if (!provider.local) return [];
181
+ const result = await fetchLiveModels(provider.id, { forceRefresh: true, timeoutMs: 2_500 });
182
+ return result.source === "live" ? result.models.map((m) => m.id) : [];
195
183
  }
196
184
 
197
185
  function buildStatusWidget(
@@ -211,8 +199,7 @@ function buildStatusWidget(
211
199
  lines.push(` ${icon} ${p.displayName}${note}`);
212
200
  }
213
201
  lines.push("");
214
- lines.push("Assignments:");
215
- lines.push(` Default chat : ${assignments.default ?? "(not set)"}`);
202
+ lines.push("Orchestration roles (used by /plan — NOT chat):");
216
203
  for (const role of ORCHESTRATION_ROLES) {
217
204
  const a = assignments.orchestration[role.key];
218
205
  const preferred = a?.preferred ?? "(not set)";
@@ -220,6 +207,7 @@ function buildStatusWidget(
220
207
  lines.push(` ${role.label.padEnd(8)} : ${preferred} / ${fallback}`);
221
208
  }
222
209
  lines.push("");
210
+ lines.push("Chat default model : controlled via `/model` (this wizard never overrides it)");
223
211
  lines.push(`Keys file : ${store.configPath} (chmod 0600 on Unix)`);
224
212
  return lines;
225
213
  }
@@ -442,21 +430,53 @@ async function configureGenericCloud(
442
430
  if (!proceed) return undefined;
443
431
  }
444
432
 
433
+ // Persist the key immediately so a downstream fetch failure cannot lose user input.
445
434
  store.setKey(provider.id, trimmed, {
446
435
  baseUrl: provider.baseUrl,
447
436
  api: provider.api,
448
- models: provider.staticModels.map((id) => ({
449
- id,
450
- name: id,
451
- reasoning: true,
452
- input: ["text"] as const,
453
- })),
454
437
  });
438
+
439
+ // Optional ping for early auth diagnostics.
440
+ ui.setStatus("setup-ping", `Pinging ${provider.displayName}...`);
441
+ const ping = await pingProvider(provider.id, trimmed, 5_000).catch((err) => ({
442
+ ok: false,
443
+ error: err instanceof Error ? err.message : String(err),
444
+ }));
445
+ ui.setStatus("setup-ping", undefined);
446
+ if (ping.ok) {
447
+ ui.notify(`${provider.displayName} ping OK (200).`, "info");
448
+ } else {
449
+ ui.notify(
450
+ `${provider.displayName} ping failed: ${ping.error ?? "unknown"}. Key saved; you can retry with \`/keys test ${provider.id}\`.`,
451
+ "warning",
452
+ );
453
+ }
454
+
455
+ // Live-fetch the model catalog (falls back to the static list when offline).
456
+ ui.setStatus("setup-fetch", `Fetching ${provider.displayName} model list...`);
457
+ const live = await fetchLiveModels(provider.id, {
458
+ apiKey: trimmed,
459
+ forceRefresh: true,
460
+ timeoutMs: 6_000,
461
+ });
462
+ ui.setStatus("setup-fetch", undefined);
463
+
464
+ const models = (live.models.length > 0
465
+ ? live.models
466
+ : provider.staticModels.map((id) => ({ id, name: id, reasoning: true }))
467
+ ).map(toPersistedModel);
468
+
469
+ store.setKey(provider.id, trimmed, {
470
+ baseUrl: provider.baseUrl,
471
+ api: provider.api,
472
+ models,
473
+ });
474
+
455
475
  ui.notify(
456
- `${provider.displayName} configured: \`${maskKeyForDisplay(trimmed)}\` (${provider.staticModels.length} models)`,
476
+ `${provider.displayName} configured: \`${maskKeyForDisplay(trimmed)}\` (${models.length} models, source: ${live.source}${live.error ? `, ${live.error}` : ""})`,
457
477
  "info",
458
478
  );
459
- return { providerId: provider.id, modelCount: provider.staticModels.length };
479
+ return { providerId: provider.id, modelCount: models.length };
460
480
  }
461
481
 
462
482
  async function configureLocal(
@@ -517,15 +537,22 @@ async function configureAssignments(
517
537
  return { defaultModel: "default", orchestration: {} };
518
538
  }
519
539
 
520
- const defaultModel =
521
- (await pickModelFromCatalog(ui, "Default chat model (used when no orchestration is active)", allModelIds)) ??
522
- allModelIds[0];
540
+ ui.notify(
541
+ "Assigning orchestration role models. The chat default is controlled via `/model` " +
542
+ "this wizard does NOT change it.",
543
+ "info",
544
+ );
545
+
546
+ // Sentinel: the orchestrator falls back to the current chat model when a
547
+ // route doesn't pin a specific one. We never ask the user for a "default"
548
+ // chat model here — `/model` owns that.
549
+ const defaultModel = "default";
523
550
 
524
551
  const orchestration: Record<string, RouteAssignment> = {};
525
552
  for (const role of ORCHESTRATION_ROLES) {
526
553
  const preferred =
527
554
  (await pickModelFromCatalog(ui, `${role.label} - preferred model (${role.desc})`, allModelIds)) ??
528
- defaultModel;
555
+ allModelIds[0];
529
556
  const fallbackOptions = allModelIds.filter((m) => m !== preferred);
530
557
  const fallback = fallbackOptions.length > 0
531
558
  ? (await pickModelFromCatalog(ui, `${role.label} - fallback model`, fallbackOptions)) ?? preferred
@@ -539,7 +566,23 @@ async function configureAssignments(
539
566
 
540
567
  // ─── Extension ───────────────────────────────────────────────────────────
541
568
 
569
+ // One-time global guard so a stray async rejection inside the wizard never kills the TUI.
570
+ let setupUnhandledGuard = false;
571
+ function installSetupUnhandledRejectionGuard(): void {
572
+ if (setupUnhandledGuard) return;
573
+ setupUnhandledGuard = true;
574
+ process.on("unhandledRejection", (reason) => {
575
+ const message = reason instanceof Error ? reason.message : String(reason);
576
+ try {
577
+ process.stderr.write(`[phi-setup] swallowed unhandledRejection: ${message}\n`);
578
+ } catch {
579
+ // no-op
580
+ }
581
+ });
582
+ }
583
+
542
584
  export default function setupExtension(pi: ExtensionAPI) {
585
+ installSetupUnhandledRejectionGuard();
543
586
  pi.registerCommand("setup", {
544
587
  description: "Phi Code setup wizard (refonte UX, replaces /phi-init)",
545
588
  handler: async (_args, ctx) => {
@@ -551,10 +594,14 @@ export default function setupExtension(pi: ExtensionAPI) {
551
594
  // empty file or missing, fine
552
595
  }
553
596
 
597
+ try {
554
598
  ui.notify(
555
599
  "**φ Phi Code Setup Wizard**\n\n" +
556
- "This wizard configures providers and assigns models to agent roles.\n" +
557
- "Keys are stored in `~/.phi/agent/models.json` (chmod 0600 on Unix).\n" +
600
+ "This wizard configures providers and assigns models to **orchestration roles** " +
601
+ "(used by `/plan`).\n" +
602
+ "The **chat default model is controlled via `/model`** and stays sticky across " +
603
+ "prompts — this wizard will never change it.\n\n" +
604
+ "Keys are stored in `~/.phi/agent/models.json` (chmod 0600 on Unix). " +
558
605
  "Edit that file directly later to hot-reload (no restart needed).",
559
606
  "info",
560
607
  );
@@ -652,14 +699,21 @@ export default function setupExtension(pi: ExtensionAPI) {
652
699
  const provider = catalog[providerIndex];
653
700
 
654
701
  let result: { providerId: string; modelCount: number } | undefined;
655
- if (provider.id === "alibaba-codingplan") {
656
- result = await configureAlibaba(ui, store);
657
- } else if (provider.id === "opencode-go") {
658
- result = await configureOpenCodeGo(ui, store);
659
- } else if (provider.local) {
660
- result = await configureLocal(ui, store, provider);
661
- } else {
662
- result = await configureGenericCloud(ui, store, provider);
702
+ try {
703
+ if (provider.id === "alibaba-codingplan") {
704
+ result = await configureAlibaba(ui, store);
705
+ } else if (provider.id === "opencode-go") {
706
+ result = await configureOpenCodeGo(ui, store);
707
+ } else if (provider.local) {
708
+ result = await configureLocal(ui, store, provider);
709
+ } else {
710
+ result = await configureGenericCloud(ui, store, provider);
711
+ }
712
+ } catch (err) {
713
+ ui.notify(
714
+ `Provider configuration failed: ${err instanceof Error ? err.message : String(err)}`,
715
+ "error",
716
+ );
663
717
  }
664
718
 
665
719
  if (result) refreshAvailable();
@@ -681,12 +735,20 @@ export default function setupExtension(pi: ExtensionAPI) {
681
735
  "**Setup complete.**\n\n" +
682
736
  "Next steps:\n" +
683
737
  " - `/keys` to list/manage saved keys\n" +
738
+ " - `/models refresh` to re-fetch the catalog from each provider's API\n" +
684
739
  " - `/routing` to inspect routing\n" +
685
740
  " - `/agents` to list sub-agents\n" +
686
741
  " - `/skills` to list skills\n" +
687
742
  " - Edit `~/.phi/agent/models.json` or `routing.json` directly: hot-reload kicks in",
688
743
  "info",
689
744
  );
745
+ } catch (err) {
746
+ ui.setWidget("setup-status", undefined);
747
+ ui.notify(
748
+ `Setup wizard error: ${err instanceof Error ? err.message : String(err)}`,
749
+ "error",
750
+ );
751
+ }
690
752
  },
691
753
  });
692
754
  }
@@ -19,6 +19,17 @@ import { homedir } from "node:os";
19
19
 
20
20
  interface ExtensionConfig {
21
21
  enabled: boolean;
22
+ /**
23
+ * When false (default), the smart router never changes the chat model — it
24
+ * only emits an informational notification per prompt. Use `/model` to
25
+ * choose the chat model; that choice is now the single source of truth
26
+ * and is persisted across sessions via the settings manager.
27
+ *
28
+ * When true (opt-in via `/routing autoswitch on`), the router switches
29
+ * the chat model to the preferred route per prompt, matching the
30
+ * pre-0.75.6 behavior.
31
+ */
32
+ autoSwitch: boolean;
22
33
  notifyOnRecommendation: boolean;
23
34
  }
24
35
 
@@ -31,7 +42,8 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
31
42
  let router = new SmartRouter(SmartRouter.defaultConfig());
32
43
  let extConfig: ExtensionConfig = {
33
44
  enabled: true,
34
- notifyOnRecommendation: true,
45
+ autoSwitch: false,
46
+ notifyOnRecommendation: false,
35
47
  };
36
48
 
37
49
  /**
@@ -95,20 +107,30 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
95
107
 
96
108
  const modelToUse = targetModel || fallbackModel;
97
109
 
98
- if (modelToUse && modelToUse.id !== ctx.model?.id) {
99
- // Actually switch the model
110
+ if (!modelToUse) {
111
+ if (extConfig.notifyOnRecommendation) {
112
+ ctx.ui.notify(
113
+ `Routing suggestion: ${recommendation.category} → \`${recommendation.model}\` (not in registry).`,
114
+ "info",
115
+ );
116
+ }
117
+ } else if (modelToUse.id === ctx.model?.id) {
118
+ // Already on the recommended model — nothing to do.
119
+ } else if (extConfig.autoSwitch) {
120
+ // Legacy opt-in behavior: actually swap the chat model.
100
121
  const switched = await pi.setModel(modelToUse);
101
122
  if (switched && extConfig.notifyOnRecommendation) {
102
123
  ctx.ui.notify(
103
- `🔀 ${recommendation.category} → \`${modelToUse.id}\`${modelToUse.id !== recommendation.model ? ` (fallback)` : ""}`,
104
- "info"
124
+ `Auto-switched (${recommendation.category}) → \`${modelToUse.id}\`${modelToUse.id !== recommendation.model ? " (fallback)" : ""}`,
125
+ "info",
105
126
  );
106
127
  }
107
- } else if (extConfig.notifyOnRecommendation && !modelToUse) {
108
- // Model not available just notify
128
+ } else if (extConfig.notifyOnRecommendation) {
129
+ // Default behavior: never override the user's `/model` choice. Just
130
+ // advertise the suggestion — the chat model stays sticky.
109
131
  ctx.ui.notify(
110
- `🔀 ${recommendation.category} → \`${recommendation.model}\` (not available, keeping current)`,
111
- "info"
132
+ `Routing suggestion: ${recommendation.category} → \`${modelToUse.id}\`. Stay on \`${ctx.model?.id}\` (use \`/routing autoswitch on\` to auto-apply, or \`/model\` to change manually).`,
133
+ "info",
112
134
  );
113
135
  }
114
136
  }
@@ -125,44 +147,66 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
125
147
 
126
148
  if (!arg) {
127
149
  const routingConfig = (router as any).config as RoutingConfig;
128
- let output = `**🔀 Smart Router** (powered by sigma-agents)\n\n`;
129
- output += `Status: ${extConfig.enabled ? "✅ Enabled" : "❌ Disabled"}\n`;
130
- output += `Notifications: ${extConfig.notifyOnRecommendation ? "On" : "Off"}\n\n`;
150
+ let output = `**Smart Router** (powered by sigma-agents)\n\n`;
151
+ output += `Status: ${extConfig.enabled ? "enabled" : "disabled"}\n`;
152
+ output += `Auto-switch: ${extConfig.autoSwitch ? "on (overrides /model per prompt)" : "off (suggestions only — /model is sticky)"}\n`;
153
+ output += `Notifications: ${extConfig.notifyOnRecommendation ? "on" : "off"}\n\n`;
131
154
 
132
155
  output += `**Routes:**\n`;
133
156
  for (const [cat, route] of Object.entries(routingConfig.routes)) {
134
157
  output += ` **${cat}** → \`${route.preferredModel}\` (fallback: \`${route.fallback}\`) [agent: ${route.agent || "none"}]\n`;
135
158
  output += ` Keywords: ${route.keywords.slice(0, 6).join(", ")}${route.keywords.length > 6 ? "..." : ""}\n`;
136
159
  }
137
- output += `\n **default** → \`${routingConfig.default.model}\`\n`;
160
+ output += `\n **default** → \`${routingConfig.default.model}\` (orchestrator fallback only; chat default comes from \`/model\`)\n`;
138
161
 
139
162
  output += `\nConfig: \`${configPath}\``;
140
- output += `\nCommands: \`/routing enable|disable|notify-on|notify-off|reload|test\``;
163
+ output += `\nCommands: \`/routing [enable|disable|autoswitch on|autoswitch off|notify-on|notify-off|reload|test]\``;
141
164
 
142
165
  ctx.ui.notify(output, "info");
143
166
  return;
144
167
  }
145
168
 
169
+ const tokens = arg.split(/\s+/);
170
+ const head = tokens[0];
171
+
172
+ if (head === "autoswitch") {
173
+ const flag = tokens[1];
174
+ if (flag === "on") {
175
+ extConfig.autoSwitch = true;
176
+ ctx.ui.notify(
177
+ "Auto-switch enabled: the smart router will override the chat model per prompt. " +
178
+ "Disable with `/routing autoswitch off` if it gets in your way.",
179
+ "info",
180
+ );
181
+ } else if (flag === "off") {
182
+ extConfig.autoSwitch = false;
183
+ ctx.ui.notify("Auto-switch disabled. The model selected via `/model` is now sticky.", "info");
184
+ } else {
185
+ ctx.ui.notify("Usage: `/routing autoswitch on|off`", "warning");
186
+ }
187
+ return;
188
+ }
189
+
146
190
  switch (arg) {
147
191
  case "enable":
148
192
  extConfig.enabled = true;
149
- ctx.ui.notify("Smart routing enabled.", "info");
193
+ ctx.ui.notify("Smart routing enabled.", "info");
150
194
  break;
151
195
  case "disable":
152
196
  extConfig.enabled = false;
153
- ctx.ui.notify("Smart routing disabled.", "info");
197
+ ctx.ui.notify("Smart routing disabled.", "info");
154
198
  break;
155
199
  case "notify-on":
156
200
  extConfig.notifyOnRecommendation = true;
157
- ctx.ui.notify("🔔 Routing notifications enabled.", "info");
201
+ ctx.ui.notify("Routing notifications enabled.", "info");
158
202
  break;
159
203
  case "notify-off":
160
204
  extConfig.notifyOnRecommendation = false;
161
- ctx.ui.notify("🔕 Routing notifications disabled.", "info");
205
+ ctx.ui.notify("Routing notifications disabled.", "info");
162
206
  break;
163
207
  case "reload":
164
208
  await loadConfig();
165
- ctx.ui.notify("🔄 Routing config reloaded from disk.", "info");
209
+ ctx.ui.notify("Routing config reloaded from disk.", "info");
166
210
  break;
167
211
  case "test": {
168
212
  const tests = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.75.4",
3
+ "version": "0.75.6",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {