@phi-code-admin/phi-code 0.84.2 → 0.86.0

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 (75) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +95 -126
  3. package/agents/code.md +1 -0
  4. package/agents/explore.md +11 -5
  5. package/agents/plan.md +18 -3
  6. package/agents/review.md +18 -7
  7. package/agents/test.md +1 -0
  8. package/config/routing.example.json +129 -0
  9. package/config/routing.schema.json +58 -0
  10. package/dist/cli/args.d.ts.map +1 -1
  11. package/dist/cli/args.js +1 -1
  12. package/dist/cli/args.js.map +1 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/core/sdk.d.ts.map +1 -1
  17. package/dist/core/sdk.js +3 -3
  18. package/dist/core/sdk.js.map +1 -1
  19. package/dist/core/system-prompt.d.ts.map +1 -1
  20. package/dist/core/system-prompt.js +6 -6
  21. package/dist/core/system-prompt.js.map +1 -1
  22. package/dist/modes/interactive/components/config-selector.d.ts.map +1 -1
  23. package/dist/modes/interactive/components/config-selector.js +1 -1
  24. package/dist/modes/interactive/components/config-selector.js.map +1 -1
  25. package/dist/modes/interactive/interactive-mode.d.ts +2 -2
  26. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  27. package/dist/modes/interactive/interactive-mode.js +32 -30
  28. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  29. package/dist/package-manager-cli.d.ts.map +1 -1
  30. package/dist/package-manager-cli.js +9 -8
  31. package/dist/package-manager-cli.js.map +1 -1
  32. package/dist/utils/pi-user-agent.d.ts.map +1 -1
  33. package/dist/utils/pi-user-agent.js +4 -1
  34. package/dist/utils/pi-user-agent.js.map +1 -1
  35. package/dist/utils/version-check.d.ts +5 -5
  36. package/dist/utils/version-check.d.ts.map +1 -1
  37. package/dist/utils/version-check.js +13 -8
  38. package/dist/utils/version-check.js.map +1 -1
  39. package/docs/compaction.md +11 -11
  40. package/docs/custom-provider.md +4 -4
  41. package/docs/development.md +2 -2
  42. package/docs/extensions.md +47 -47
  43. package/docs/fork-policy.md +63 -0
  44. package/docs/index.md +7 -7
  45. package/docs/json.md +3 -3
  46. package/docs/keybindings.md +5 -5
  47. package/docs/models.md +6 -6
  48. package/docs/packages.md +37 -37
  49. package/docs/prompt-templates.md +4 -4
  50. package/docs/providers.md +9 -9
  51. package/docs/quickstart.md +25 -25
  52. package/docs/rpc.md +3 -3
  53. package/docs/sdk.md +34 -34
  54. package/docs/session-format.md +4 -4
  55. package/docs/sessions.md +11 -11
  56. package/docs/settings.md +11 -11
  57. package/docs/shell-aliases.md +2 -2
  58. package/docs/skills.md +9 -9
  59. package/docs/terminal-setup.md +7 -7
  60. package/docs/termux.md +6 -6
  61. package/docs/themes.md +9 -9
  62. package/docs/tmux.md +3 -3
  63. package/docs/tui.md +8 -8
  64. package/docs/usage.md +40 -40
  65. package/docs/windows.md +2 -2
  66. package/examples/sdk/12-full-control.ts +1 -1
  67. package/extensions/phi/agents.ts +5 -113
  68. package/extensions/phi/models.ts +170 -49
  69. package/extensions/phi/orchestrator.ts +44 -37
  70. package/extensions/phi/providers/agent-def.ts +128 -0
  71. package/extensions/phi/providers/live-models.ts +31 -1
  72. package/extensions/phi/providers/opencode-go.ts +7 -3
  73. package/extensions/phi/setup.ts +9 -0
  74. package/extensions/phi/skill-loader.ts +46 -25
  75. package/package.json +2 -1
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { ApiKeyStore, type ConfigWatcher, type ExtensionAPI, getApiKeyStore, getConfigWatcher } from "phi-code";
19
+ import { getModels } from "phi-code-ai";
19
20
  import {
20
21
  buildOpenCodeGoAnthropicProviderConfig,
21
22
  buildOpenCodeGoProviderConfig,
@@ -25,7 +26,9 @@ import { formatWindow, inferContextWindow, parseContextWindow } from "./provider
25
26
  import { fetchLiveModels, peekCache, resetLiveModelsCache, toPersistedModel } from "./providers/live-models.js";
26
27
 
27
28
  const PROVIDER_DISPLAY: Record<string, string> = {
29
+ opencode: "OpenCode Zen",
28
30
  "opencode-go": "OpenCode Go",
31
+ "opencode-go-anthropic": "OpenCode Go (Anthropic-compat)",
29
32
  "alibaba-codingplan": "Alibaba Coding Plan (OpenAI-compat)",
30
33
  "alibaba-codingplan-anthropic": "Alibaba Coding Plan (Anthropic-compat)",
31
34
  openai: "OpenAI",
@@ -37,10 +40,62 @@ const PROVIDER_DISPLAY: Record<string, string> = {
37
40
  "lm-studio": "LM Studio (local)",
38
41
  };
39
42
 
43
+ /**
44
+ * Providers the live-models dispatcher can actually re-fetch (see
45
+ * live-models.ts dispatchFetch + refreshOpenCodeGo). Used to extend the
46
+ * startup/manual refresh to providers that are authenticated via auth.json or
47
+ * env vars but have no models.json entry yet — without it, a user who set a
48
+ * key with /auth (and never ran /setup) would never see new upstream models.
49
+ */
50
+ const REFRESHABLE_PROVIDERS: ReadonlySet<string> = new Set([
51
+ "opencode",
52
+ "opencode-go",
53
+ "opencode-go-anthropic",
54
+ "alibaba-codingplan",
55
+ "alibaba-codingplan-anthropic",
56
+ "openai",
57
+ "anthropic",
58
+ "google",
59
+ "openrouter",
60
+ "groq",
61
+ "ollama",
62
+ "lm-studio",
63
+ ]);
64
+
40
65
  function displayName(id: string): string {
41
66
  return PROVIDER_DISPLAY[id] ?? id;
42
67
  }
43
68
 
69
+ /** Default discovery base URLs for providers whose models.json entry is created by a refresh. */
70
+ const DEFAULT_BASE_URLS: Record<string, string> = {
71
+ opencode: "https://opencode.ai/zen/v1",
72
+ "opencode-go": "https://opencode.ai/zen/go/v1",
73
+ "alibaba-codingplan": "https://coding-intl.dashscope.aliyuncs.com/v1",
74
+ "alibaba-codingplan-anthropic": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic",
75
+ openai: "https://api.openai.com/v1",
76
+ anthropic: "https://api.anthropic.com/v1",
77
+ google: "https://generativelanguage.googleapis.com/v1beta",
78
+ openrouter: "https://openrouter.ai/api/v1",
79
+ groq: "https://api.groq.com/openai/v1",
80
+ ollama: "http://localhost:11434/v1",
81
+ "lm-studio": "http://localhost:1234/v1",
82
+ };
83
+
84
+ /**
85
+ * Built-in (models.generated.ts) model ids for a provider. Persisting only the
86
+ * models NOT in this set keeps the rich built-in definitions (costs, image
87
+ * input, thinking-level maps) authoritative — models.json carries just the
88
+ * delta the static catalog does not know about yet.
89
+ */
90
+ function builtinModelIds(providerId: string): Set<string> {
91
+ try {
92
+ const models = getModels(providerId as Parameters<typeof getModels>[0]) as Array<{ id: string }>;
93
+ return new Set(models.map((m) => m.id));
94
+ } catch {
95
+ return new Set();
96
+ }
97
+ }
98
+
44
99
  interface RefreshOutcome {
45
100
  provider: string;
46
101
  source: "live" | "cache" | "fallback" | "unsupported" | "skipped";
@@ -68,7 +123,21 @@ async function refreshOpenCodeGo(
68
123
  ? buildOpenCodeGoAnthropicProviderConfig(keyForBuild, models)
69
124
  : buildOpenCodeGoProviderConfig(keyForBuild, models);
70
125
 
71
- if (config.models.length === 0) {
126
+ // Persist only models the built-in catalog does not know yet; built-ins stay
127
+ // authoritative (costs, image input) and models.json carries the delta.
128
+ const builtin = builtinModelIds(providerId);
129
+ const newModels = config.models.filter((m) => !builtin.has(m.id));
130
+
131
+ if (newModels.length === 0) {
132
+ if (stored && Array.isArray(stored.models) && stored.models.length > 0) {
133
+ // Clean up previously persisted models that have since become built-in.
134
+ watcher.muteForWrite("models_json_changed");
135
+ store.setKey(providerId, stored.apiKey ?? apiKey ?? "local", {
136
+ baseUrl: stored.baseUrl ?? config.baseUrl,
137
+ api: stored.api ?? config.api,
138
+ models: [],
139
+ });
140
+ }
72
141
  return { provider: providerId, source: source === "fallback" ? "fallback" : "skipped", count: 0 };
73
142
  }
74
143
 
@@ -76,22 +145,26 @@ async function refreshOpenCodeGo(
76
145
  store.setKey(providerId, stored?.apiKey ?? apiKey ?? "local", {
77
146
  baseUrl: stored?.baseUrl ?? config.baseUrl,
78
147
  api: stored?.api ?? config.api,
79
- models: config.models,
148
+ models: newModels,
80
149
  });
81
150
 
82
151
  const outcomeSource = source === "live" ? "live" : source === "cache" ? "cache" : "fallback";
83
- return { provider: providerId, source: outcomeSource, count: config.models.length };
152
+ return { provider: providerId, source: outcomeSource, count: newModels.length };
84
153
  }
85
154
 
86
155
  async function refreshOne(
87
156
  store: ApiKeyStore,
88
157
  watcher: ConfigWatcher,
89
158
  providerId: string,
159
+ resolvedApiKey?: string,
90
160
  ): Promise<RefreshOutcome> {
91
161
  const stored = store.getProvider(providerId);
92
- const apiKey = stored?.apiKey && !stored.apiKey.startsWith("$") && stored.apiKey !== "local"
162
+ const storedKey = stored?.apiKey && !stored.apiKey.startsWith("$") && stored.apiKey !== "local"
93
163
  ? stored.apiKey
94
164
  : undefined;
165
+ // Prefer the key stored in models.json, else the one resolved from
166
+ // auth.json/env by the model registry (providers set up via /auth only).
167
+ const apiKey = storedKey ?? resolvedApiKey;
95
168
 
96
169
  // OpenCode Go is a provider pair the generic fetchLiveModels path can't express
97
170
  // (and never handled the Anthropic side), so refresh it from the shared catalog.
@@ -110,40 +183,30 @@ async function refreshOne(
110
183
  return { provider: providerId, source: "skipped", count: 0, error: result.error };
111
184
  }
112
185
 
113
- const persisted = result.models.map(toPersistedModel);
114
- if (persisted.length === 0) {
115
- return { provider: providerId, source: result.source, count: 0, error: result.error };
116
- }
186
+ // Persist only the delta the built-in catalog does not know yet (see
187
+ // builtinModelIds). Built-in definitions keep their costs/capabilities.
188
+ const builtin = builtinModelIds(providerId);
189
+ const persisted = result.models.map(toPersistedModel).filter((m) => !builtin.has(m.id));
117
190
 
118
191
  // Preserve baseUrl/api/apiKey/headers from existing config; only models change.
119
- const baseUrl =
120
- stored?.baseUrl ??
121
- (providerId === "opencode-go"
122
- ? "https://opencode.ai/zen/go/v1"
123
- : providerId === "alibaba-codingplan"
124
- ? "https://coding-intl.dashscope.aliyuncs.com/v1"
125
- : providerId === "alibaba-codingplan-anthropic"
126
- ? "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
127
- : providerId === "openai"
128
- ? "https://api.openai.com/v1"
129
- : providerId === "anthropic"
130
- ? "https://api.anthropic.com/v1"
131
- : providerId === "google"
132
- ? "https://generativelanguage.googleapis.com/v1beta"
133
- : providerId === "openrouter"
134
- ? "https://openrouter.ai/api/v1"
135
- : providerId === "groq"
136
- ? "https://api.groq.com/openai/v1"
137
- : providerId === "ollama"
138
- ? "http://localhost:11434/v1"
139
- : providerId === "lm-studio"
140
- ? "http://localhost:1234/v1"
141
- : undefined);
142
-
192
+ const baseUrl = stored?.baseUrl ?? DEFAULT_BASE_URLS[providerId];
143
193
  if (!baseUrl) {
144
194
  return { provider: providerId, source: "skipped", count: 0, error: "unknown baseUrl" };
145
195
  }
146
196
 
197
+ if (persisted.length === 0) {
198
+ if (stored && Array.isArray(stored.models) && stored.models.length > 0) {
199
+ // Clean up previously persisted models that have since become built-in.
200
+ watcher.muteForWrite("models_json_changed");
201
+ store.setKey(providerId, stored.apiKey ?? "local", {
202
+ baseUrl,
203
+ api: stored.api,
204
+ models: [],
205
+ });
206
+ }
207
+ return { provider: providerId, source: result.source, count: 0, error: result.error };
208
+ }
209
+
147
210
  // Mute the config watcher so it does not echo this programmatic write back
148
211
  // as a models_json_changed event (which would trigger a spurious reload +
149
212
  // "Keys reloaded" notification). Mute per-write because refresh loops can
@@ -311,29 +374,73 @@ export default function modelsExtension(pi: ExtensionAPI) {
311
374
  ctx.ui.notify(out, "info");
312
375
  }
313
376
 
377
+ interface RefreshTarget {
378
+ id: string;
379
+ resolvedApiKey?: string;
380
+ }
381
+
382
+ /**
383
+ * Providers to refresh: every provider persisted in models.json, plus every
384
+ * refreshable provider that is authenticated (auth.json / env vars) but has
385
+ * no models.json entry yet. API keys are resolved through the registry so
386
+ * providers configured via /auth alone still get authenticated listings.
387
+ */
388
+ async function resolveRefreshTargets(registry: {
389
+ getAvailable(): Array<{ provider: string }>;
390
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
391
+ }): Promise<RefreshTarget[]> {
392
+ const targets = new Map<string, RefreshTarget>();
393
+ for (const id of store.listProviders()) {
394
+ targets.set(id, { id });
395
+ }
396
+ try {
397
+ for (const model of registry.getAvailable()) {
398
+ const id = model.provider;
399
+ if (!targets.has(id) && REFRESHABLE_PROVIDERS.has(id)) {
400
+ targets.set(id, { id });
401
+ }
402
+ }
403
+ } catch {
404
+ // registry unavailable — fall back to models.json providers only
405
+ }
406
+ for (const target of targets.values()) {
407
+ try {
408
+ target.resolvedApiKey = await registry.getApiKeyForProvider(target.id);
409
+ } catch {
410
+ // no resolvable key — refreshOne will try the stored/keyless path
411
+ }
412
+ }
413
+ return [...targets.values()];
414
+ }
415
+
314
416
  // Background refresh on session_start so every new Phi Code session reflects
315
417
  // the latest provider catalogs without the user typing `/models refresh`.
316
418
  // Failures are silent — startup must never be blocked by upstream API hiccups.
317
419
  pi.on("session_start", async (_event, ctx) => {
420
+ if (process.env.PI_OFFLINE) return;
318
421
  try {
319
422
  store.load();
320
423
  } catch {
321
424
  // no models.json yet
322
425
  }
323
- const providers = store.listProviders();
324
- if (providers.length === 0) return;
426
+ const targets = await resolveRefreshTargets(ctx.modelRegistry);
427
+ if (targets.length === 0) return;
325
428
 
326
429
  // Fire-and-forget. Hot-reload via models_json_changed event surfaces results.
327
430
  void (async () => {
328
- let changed = 0;
329
- for (const id of providers) {
330
- const outcome = await refreshOne(store, watcher, id).catch(() => undefined);
331
- if (outcome && outcome.source === "live" && outcome.count > 0) changed++;
431
+ let discovered = 0;
432
+ let changedProviders = 0;
433
+ for (const target of targets) {
434
+ const outcome = await refreshOne(store, watcher, target.id, target.resolvedApiKey).catch(() => undefined);
435
+ if (outcome && outcome.source === "live" && outcome.count > 0) {
436
+ changedProviders++;
437
+ discovered += outcome.count;
438
+ }
332
439
  }
333
- if (changed > 0) {
440
+ if (changedProviders > 0) {
334
441
  try {
335
442
  ctx.ui.notify(
336
- `Refreshed ${changed}/${providers.length} provider catalog(s) in the background.`,
443
+ `Discovered ${discovered} new model(s) across ${changedProviders} provider(s). See /model.`,
337
444
  "info",
338
445
  );
339
446
  } catch {
@@ -346,20 +453,33 @@ export default function modelsExtension(pi: ExtensionAPI) {
346
453
 
347
454
  async function refreshCommand(
348
455
  target: string | undefined,
349
- ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void; setStatus?: (k: string, v?: string) => void } },
456
+ ctx: {
457
+ ui: { notify: (m: string, t?: "info" | "warning" | "error") => void; setStatus?: (k: string, v?: string) => void };
458
+ modelRegistry: {
459
+ getAvailable(): Array<{ provider: string }>;
460
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
461
+ };
462
+ },
350
463
  ): Promise<void> {
351
- const providers = target ? [target] : store.listProviders();
352
- if (providers.length === 0) {
464
+ const targets = target ? [{ id: target } as RefreshTarget] : await resolveRefreshTargets(ctx.modelRegistry);
465
+ if (target) {
466
+ try {
467
+ targets[0].resolvedApiKey = await ctx.modelRegistry.getApiKeyForProvider(target);
468
+ } catch {
469
+ // keep undefined
470
+ }
471
+ }
472
+ if (targets.length === 0) {
353
473
  ctx.ui.notify("No providers configured.", "warning");
354
474
  return;
355
475
  }
356
- ctx.ui.notify(`Refreshing ${providers.length} provider(s)...`, "info");
476
+ ctx.ui.notify(`Refreshing ${targets.length} provider(s)...`, "info");
357
477
  ctx.ui.setStatus?.("models-refresh", "Fetching live model catalogs...");
358
478
 
359
479
  const outcomes: RefreshOutcome[] = [];
360
- for (const id of providers) {
361
- const outcome = await refreshOne(store, watcher, id).catch((err) => ({
362
- provider: id,
480
+ for (const t of targets) {
481
+ const outcome = await refreshOne(store, watcher, t.id, t.resolvedApiKey).catch((err) => ({
482
+ provider: t.id,
363
483
  source: "skipped" as const,
364
484
  count: 0,
365
485
  error: err instanceof Error ? err.message : String(err),
@@ -371,9 +491,10 @@ export default function modelsExtension(pi: ExtensionAPI) {
371
491
  let out = "**Refresh report:**\n";
372
492
  for (const o of outcomes) {
373
493
  const icon = o.source === "live" ? "[ok]" : o.source === "fallback" ? "[fb]" : o.source === "cache" ? "[c]" : "[--]";
374
- out += ` ${icon} ${displayName(o.provider)} \`${o.provider}\` — ${o.count} model(s) (${o.source}${o.error ? `, ${o.error}` : ""})\n`;
494
+ out += ` ${icon} ${displayName(o.provider)} \`${o.provider}\` — ${o.count} new model(s) (${o.source}${o.error ? `, ${o.error}` : ""})\n`;
375
495
  }
376
- out += `\nModels persisted to \`${store.configPath}\`. \`/model\` picker now reflects this catalog.`;
496
+ out += `\nOnly models missing from the built-in catalog are persisted to \`${store.configPath}\`;\n`;
497
+ out += `built-in models stay available either way. \`/model\` picker reflects the merged catalog.`;
377
498
  ctx.ui.notify(out, "info");
378
499
  pi.events.emit("models_json_changed", { source: "models-refresh" });
379
500
  }
@@ -29,6 +29,7 @@ import {
29
29
  parsePhaseVerdict,
30
30
  } from "./providers/orchestrator-helpers.js";
31
31
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
32
+ import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
32
33
 
33
34
  // ─── Types ───────────────────────────────────────────────────────────────
34
35
 
@@ -212,12 +213,6 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
212
213
 
213
214
  // ─── Orchestration State ─────────────────────────────────────────
214
215
 
215
- interface AgentDef {
216
- name: string;
217
- tools: string[];
218
- systemPrompt: string;
219
- }
220
-
221
216
  interface OrchestratorPhase {
222
217
  key: string;
223
218
  label: string;
@@ -331,30 +326,25 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
331
326
  - **Internal orchestration data:** notes injected as "Previous phase summary" or budget/handoff reminders are for YOUR use only. Do not repeat them back to the user.`;
332
327
 
333
328
  /**
334
- * Parse agent .md file with YAML frontmatter
329
+ * Load a skill body (SKILL.md) so a phase instruction can embed it verbatim.
330
+ * Search order mirrors agent-def.ts: project .phi/skills, then the global
331
+ * ~/.phi/agent/skills (postinstall copies bundled skills there), then the
332
+ * bundled <package>/skills for the repo layout. YAML frontmatter is
333
+ * stripped. Returns null when the skill is not installed.
335
334
  */
336
- function loadAgentDef(name: string): AgentDef | null {
335
+ function loadSkillContent(name: string): string | null {
337
336
  const dirs = [
338
- join(process.cwd(), ".phi", "agents"),
339
- join(homedir(), ".phi", "agent", "agents"),
337
+ join(process.cwd(), ".phi", "skills"),
338
+ join(homedir(), ".phi", "agent", "skills"),
339
+ join(__dirname, "..", "..", "skills"),
340
340
  ];
341
341
  for (const dir of dirs) {
342
- const filePath = join(dir, `${name}.md`);
343
342
  try {
344
- const content = readFileSync(filePath, "utf-8");
345
- const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
346
- if (!fmMatch) continue;
347
- const fields: Record<string, string> = {};
348
- for (const line of fmMatch[1].split("\n")) {
349
- const m = line.match(/^(\w+):\s*(.*)$/);
350
- if (m) fields[m[1]] = m[2].trim();
351
- }
352
- return {
353
- name: fields.name || name,
354
- tools: (fields.tools || "").split(",").map(t => t.trim()).filter(Boolean),
355
- systemPrompt: fmMatch[2].trim(),
356
- };
357
- } catch { continue; }
343
+ const content = readFileSync(join(dir, name, "SKILL.md"), "utf-8");
344
+ const fmMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
345
+ const body = (fmMatch ? fmMatch[1] : content).trim();
346
+ if (body) return body;
347
+ } catch { /* try next dir */ }
358
348
  }
359
349
  return null;
360
350
  }
@@ -392,6 +382,15 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
392
382
  : '';
393
383
  const runtimeInfo = `\n\nRuntime: ${process.platform} (${process.arch})${shellNote}`;
394
384
 
385
+ // Embed the prompt-architect skill so the PLAN phase applies it to every
386
+ // task it writes. The skill ships bundled and is copied to
387
+ // ~/.phi/agent/skills by postinstall; when missing, the inline task format
388
+ // still enforces the [CONTEXT]/[TASK]/[FORMAT]/[CONSTRAINTS] structure.
389
+ const promptArchitectSkill = loadSkillContent("prompt-architect");
390
+ const promptArchitectSection = promptArchitectSkill
391
+ ? `\n**Prompt-architect skill (apply it to every task you write):**\n\n${promptArchitectSkill}\n`
392
+ : "";
393
+
395
394
  const phases: OrchestratorPhase[] = [
396
395
  {
397
396
  key: "explore", label: "🔍 Phase 1 — EXPLORE", model: explore.preferred, fallback: explore.fallback,
@@ -466,29 +465,37 @@ After your analysis, use \`ontology_add\` to save key project entities AND their
466
465
  **Step 1:** Read \`.phi/plans/brief-*.md\` (created by the explore phase)
467
466
  **Step 2:** Read \`.phi/plans/explore-*.md\` to understand the codebase analysis
468
467
  **Step 3:** Design the architecture based on findings
469
- **Step 4:** Create a DETAILED TODO LIST in \`.phi/plans/todo-${ts}.md\`:
470
- For each task:
471
- - Task number and title
472
- - Agent assignment (code/test)
473
- - Files to create/modify
474
- - Specific implementation details
475
- - Dependencies on other tasks
468
+ **Step 4:** Create a DETAILED TODO LIST in \`.phi/plans/todo-${ts}.md\`.
469
+ Every task is a PROMPT for the CODE agent, which starts with ZERO memory of
470
+ this conversation. Write each task with the prompt-architect structure:
471
+ - **[CONTEXT]** what exists and why this task (real paths from the explore phase)
472
+ - **[TASK]** exactly what to do — files to create/modify with full paths
473
+ - **[FORMAT]** expected deliverable — exports, signatures, style, tests
474
+ - **[CONSTRAINTS]** what NOT to break, patterns to respect
475
+ Plus: task number + title, agent assignment (code/test), dependencies.
476
476
 
477
477
  **Format for the todo list:**
478
478
  \`\`\`markdown
479
479
  # TODO: Project Tasks
480
480
 
481
481
  ## Task 1: [Task Title] [agent-type]
482
- - [ ] Specific implementation details
483
- - [ ] Files to create: path/to/file.ext
484
- - [ ] Expected behavior
482
+ **[CONTEXT]** [What exists now, why this task, real paths (src/x.ts:42)]
483
+ **[TASK]**
484
+ - [ ] Create path/to/file.ext with [specific behavior]
485
+ - [ ] Modify path/to/other.ext: [exact change]
486
+ **[FORMAT]** [Expected exports/signatures/tests]
487
+ **[CONSTRAINTS]** [What not to break, patterns to follow]
485
488
  - Dependencies: None
486
489
 
487
490
  ## Task 2: [Task Title] [agent-type]
488
- - [ ] Implementation details
491
+ **[CONTEXT]** ...
492
+ **[TASK]**
493
+ - [ ] ...
494
+ **[FORMAT]** ...
495
+ **[CONSTRAINTS]** ...
489
496
  - Dependencies: Task 1
490
497
  \`\`\`
491
-
498
+ ${promptArchitectSection}
492
499
  Before finishing, use \`memory_write\` to save your plan summary with relevant tags for future reference.` + runtimeInfo + COMMON_PHASE_RULES,
493
500
  },
494
501
  {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Agent definitions (.md with YAML frontmatter) — single source of truth for
3
+ * parsing and discovery, shared by the /plan orchestrator (loadAgentDef) and
4
+ * the /agents command (discoverAgents). Before this module existed the two
5
+ * call sites each had their own parser and they had already drifted.
6
+ *
7
+ * Search order (first match wins):
8
+ * 1. <cwd>/.phi/agents/ (project)
9
+ * 2. ~/.phi/agent/agents/ (global — postinstall copies bundled here)
10
+ * 3. <package>/agents/ (bundled, repo layout)
11
+ *
12
+ * Layout note: this file lives in extensions/phi/providers/. Three hops up is
13
+ * the package root in the repo layout (packages/coding-agent) AND the agent
14
+ * dir in the installed layout (~/.phi/agent), so the "bundled" candidate
15
+ * resolves to a real agents/ directory in both.
16
+ */
17
+
18
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { basename, join } from "node:path";
21
+
22
+ export type AgentSource = "project" | "global" | "bundled";
23
+
24
+ export interface AgentDef {
25
+ name: string;
26
+ description: string;
27
+ tools: string[];
28
+ model: string;
29
+ systemPrompt: string;
30
+ filePath: string;
31
+ source: AgentSource;
32
+ }
33
+
34
+ /**
35
+ * Parse an agent .md file: YAML frontmatter (name, description, tools, model)
36
+ * followed by the system prompt body. `tools` is a comma-separated list.
37
+ * Returns null when the file has no frontmatter block.
38
+ */
39
+ export function parseAgentMarkdown(content: string, filePath: string, source: AgentSource): AgentDef | null {
40
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
41
+ if (!fmMatch) return null;
42
+
43
+ const fields: Record<string, string> = {};
44
+ for (const line of fmMatch[1].split("\n")) {
45
+ const m = line.match(/^(\w+):\s*(.*)$/);
46
+ if (m) fields[m[1]] = m[2].trim();
47
+ }
48
+
49
+ const name = fields.name || basename(filePath).replace(/\.md$/, "");
50
+ if (!name) return null;
51
+
52
+ return {
53
+ name,
54
+ description: fields.description || "No description",
55
+ tools: (fields.tools || "")
56
+ .split(",")
57
+ .map((t) => t.trim())
58
+ .filter(Boolean),
59
+ model: fields.model || "default",
60
+ systemPrompt: fmMatch[2].trim(),
61
+ filePath,
62
+ source,
63
+ };
64
+ }
65
+
66
+ function readAgentFile(filePath: string, source: AgentSource): AgentDef | null {
67
+ try {
68
+ return parseAgentMarkdown(readFileSync(filePath, "utf-8"), filePath, source);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Agent directories in precedence order (project > global > bundled). */
75
+ export function agentSearchDirs(cwd: string = process.cwd()): Array<{ dir: string; source: AgentSource }> {
76
+ return [
77
+ { dir: join(cwd, ".phi", "agents"), source: "project" },
78
+ { dir: join(homedir(), ".phi", "agent", "agents"), source: "global" },
79
+ { dir: join(__dirname, "..", "..", "..", "agents"), source: "bundled" },
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * Load a single agent definition by name. Used by the /plan orchestrator to
85
+ * activate a phase persona.
86
+ */
87
+ export function loadAgentDef(name: string, cwd: string = process.cwd()): AgentDef | null {
88
+ for (const { dir, source } of agentSearchDirs(cwd)) {
89
+ const def = readAgentFile(join(dir, `${name}.md`), source);
90
+ if (def) return def;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Discover every agent definition across all sources, first match per name
97
+ * wins. Used by the /agents command.
98
+ */
99
+ export function discoverAgents(cwd: string = process.cwd()): AgentDef[] {
100
+ const seen = new Set<string>();
101
+ const agents: AgentDef[] = [];
102
+
103
+ for (const { dir, source } of agentSearchDirs(cwd)) {
104
+ if (!existsSync(dir)) continue;
105
+ let entries: string[];
106
+ try {
107
+ entries = readdirSync(dir);
108
+ } catch {
109
+ continue;
110
+ }
111
+ for (const entry of entries) {
112
+ if (!entry.endsWith(".md")) continue;
113
+ const filePath = join(dir, entry);
114
+ try {
115
+ if (!statSync(filePath).isFile()) continue;
116
+ } catch {
117
+ continue;
118
+ }
119
+ const def = readAgentFile(filePath, source);
120
+ if (def && !seen.has(def.name)) {
121
+ seen.add(def.name);
122
+ agents.push(def);
123
+ }
124
+ }
125
+ }
126
+
127
+ return agents;
128
+ }
@@ -27,7 +27,7 @@ import {
27
27
  import { ALIBABA_MODELS, ALIBABA_PROVIDERS, pingAlibaba } from "./alibaba.js";
28
28
  import { inferContextWindow } from "./context-window.js";
29
29
 
30
- export const LAST_VERIFIED = "2026-05-15";
30
+ export const LAST_VERIFIED = "2026-07-10";
31
31
 
32
32
  export interface LiveModel {
33
33
  id: string;
@@ -203,8 +203,27 @@ const STATIC_OPENROUTER: LiveModel[] = [
203
203
  { id: "minimax/MiniMax-M2.7", name: "MiniMax M2.7", reasoning: true },
204
204
  ];
205
205
 
206
+ // OpenCode Zen (https://opencode.ai/zen) — the non-Go catalog. The models
207
+ // endpoint is reachable without a key (like OpenRouter); inference needs one.
208
+ const STATIC_OPENCODE_ZEN: LiveModel[] = [
209
+ { id: "claude-fable-5", name: "Claude Fable 5", contextWindow: 200_000, reasoning: true },
210
+ { id: "claude-opus-4-8", name: "Claude Opus 4.8", contextWindow: 200_000, reasoning: true },
211
+ { id: "claude-sonnet-5", name: "Claude Sonnet 5", contextWindow: 200_000, reasoning: true },
212
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextWindow: 200_000, reasoning: true },
213
+ { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", contextWindow: 1_000_000, reasoning: true },
214
+ { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", contextWindow: 1_000_000, reasoning: true },
215
+ { id: "gpt-5.5", name: "GPT-5.5", contextWindow: 400_000, reasoning: true },
216
+ { id: "gpt-5.4", name: "GPT-5.4", contextWindow: 400_000, reasoning: true },
217
+ { id: "glm-5.2", name: "GLM 5.2", contextWindow: 200_000, reasoning: true },
218
+ { id: "glm-5.1", name: "GLM 5.1", contextWindow: 200_000, reasoning: true },
219
+ { id: "kimi-k2.6", name: "Kimi K2.6", contextWindow: 256_000, reasoning: true },
220
+ { id: "minimax-m2.7", name: "MiniMax M2.7", contextWindow: 1_000_000, reasoning: true },
221
+ ];
222
+
206
223
  function staticFallbackFor(providerId: string): LiveModel[] {
207
224
  switch (providerId) {
225
+ case "opencode":
226
+ return STATIC_OPENCODE_ZEN;
208
227
  case "opencode-go":
209
228
  return OPENCODE_GO_FALLBACK_MODELS.map((m) => ({
210
229
  id: m.id,
@@ -274,6 +293,15 @@ async function fetchOpenRouter(apiKey: string | undefined, timeoutMs: number): P
274
293
  return mapOpenAIModels(raw);
275
294
  }
276
295
 
296
+ async function fetchOpenCodeZen(apiKey: string | undefined, timeoutMs: number): Promise<LiveModel[]> {
297
+ const headers: Record<string, string> = { Accept: "application/json" };
298
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
299
+ const raw = (await fetchJson("https://opencode.ai/zen/v1/models", headers, timeoutMs)) as OpenAIModelsResponse;
300
+ // The Zen endpoint reports neither context windows nor reasoning support;
301
+ // frontier catalog models all expose reasoning, so default to true.
302
+ return mapOpenAIModels(raw).map((m) => ({ ...m, reasoning: true }));
303
+ }
304
+
277
305
  async function fetchGroq(apiKey: string, timeoutMs: number): Promise<LiveModel[]> {
278
306
  const raw = (await fetchJson(
279
307
  "https://api.groq.com/openai/v1/models",
@@ -351,6 +379,8 @@ async function dispatchFetch(providerId: string, options: FetchOptions): Promise
351
379
  return await fetchGoogle(apiKey, timeoutMs);
352
380
  case "openrouter":
353
381
  return await fetchOpenRouter(apiKey, timeoutMs);
382
+ case "opencode":
383
+ return await fetchOpenCodeZen(apiKey, timeoutMs);
354
384
  case "groq":
355
385
  if (!apiKey) throw new Error("Groq requires an API key for live listing");
356
386
  return await fetchGroq(apiKey, timeoutMs);