@phi-code-admin/phi-code 0.76.15 → 0.77.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 (81) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +20 -3
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/api-key-store.d.ts +1 -1
  6. package/dist/core/api-key-store.d.ts.map +1 -1
  7. package/dist/core/api-key-store.js +5 -2
  8. package/dist/core/api-key-store.js.map +1 -1
  9. package/dist/core/auth-storage.d.ts.map +1 -1
  10. package/dist/core/auth-storage.js +3 -3
  11. package/dist/core/auth-storage.js.map +1 -1
  12. package/dist/core/bash-executor.d.ts.map +1 -1
  13. package/dist/core/bash-executor.js +4 -1
  14. package/dist/core/bash-executor.js.map +1 -1
  15. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  16. package/dist/core/compaction/branch-summarization.js +7 -2
  17. package/dist/core/compaction/branch-summarization.js.map +1 -1
  18. package/dist/core/compaction/compaction.d.ts.map +1 -1
  19. package/dist/core/compaction/compaction.js +4 -0
  20. package/dist/core/compaction/compaction.js.map +1 -1
  21. package/dist/core/keybindings.d.ts +5 -0
  22. package/dist/core/keybindings.d.ts.map +1 -1
  23. package/dist/core/keybindings.js +4 -0
  24. package/dist/core/keybindings.js.map +1 -1
  25. package/dist/core/model-registry.d.ts.map +1 -1
  26. package/dist/core/model-registry.js +6 -2
  27. package/dist/core/model-registry.js.map +1 -1
  28. package/dist/core/model-resolver.d.ts.map +1 -1
  29. package/dist/core/model-resolver.js +9 -3
  30. package/dist/core/model-resolver.js.map +1 -1
  31. package/dist/core/session-manager.d.ts.map +1 -1
  32. package/dist/core/session-manager.js +36 -5
  33. package/dist/core/session-manager.js.map +1 -1
  34. package/dist/core/settings-manager.d.ts.map +1 -1
  35. package/dist/core/settings-manager.js +15 -4
  36. package/dist/core/settings-manager.js.map +1 -1
  37. package/dist/core/tools/bash.d.ts.map +1 -1
  38. package/dist/core/tools/bash.js +2 -0
  39. package/dist/core/tools/bash.js.map +1 -1
  40. package/dist/core/tools/find.d.ts.map +1 -1
  41. package/dist/core/tools/find.js +3 -1
  42. package/dist/core/tools/find.js.map +1 -1
  43. package/dist/core/tools/output-accumulator.d.ts +1 -0
  44. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  45. package/dist/core/tools/output-accumulator.js +12 -1
  46. package/dist/core/tools/output-accumulator.js.map +1 -1
  47. package/dist/core/tools/read.d.ts.map +1 -1
  48. package/dist/core/tools/read.js +3 -1
  49. package/dist/core/tools/read.js.map +1 -1
  50. package/dist/migrations.d.ts.map +1 -1
  51. package/dist/migrations.js +27 -7
  52. package/dist/migrations.js.map +1 -1
  53. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  54. package/dist/modes/interactive/components/custom-editor.js +16 -1
  55. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  56. package/dist/modes/interactive/components/diff.d.ts.map +1 -1
  57. package/dist/modes/interactive/components/diff.js +4 -2
  58. package/dist/modes/interactive/components/diff.js.map +1 -1
  59. package/dist/modes/interactive/components/footer.d.ts +9 -0
  60. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  61. package/dist/modes/interactive/components/footer.js +29 -4
  62. package/dist/modes/interactive/components/footer.js.map +1 -1
  63. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  64. package/dist/modes/interactive/components/login-dialog.js +10 -4
  65. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  66. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  67. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  68. package/dist/modes/interactive/interactive-mode.js +80 -8
  69. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  70. package/dist/utils/sleep.d.ts.map +1 -1
  71. package/dist/utils/sleep.js +7 -3
  72. package/dist/utils/sleep.js.map +1 -1
  73. package/extensions/phi/benchmark.ts +9 -2
  74. package/extensions/phi/init.ts +4 -1
  75. package/extensions/phi/keys.ts +13 -0
  76. package/extensions/phi/memory.ts +4 -1
  77. package/extensions/phi/orchestrator.ts +115 -25
  78. package/extensions/phi/providers/opencode-go.ts +22 -12
  79. package/extensions/phi/setup.ts +8 -1
  80. package/extensions/phi/web-search.ts +15 -3
  81. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"sleep.d.ts","sourceRoot":"","sources":["../../src/utils/sleep.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAcrE","sourcesContent":["/**\n * Sleep helper that respects abort signal.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst timeout = setTimeout(resolve, ms);\n\n\t\tsignal?.addEventListener(\"abort\", () => {\n\t\t\tclearTimeout(timeout);\n\t\t\treject(new Error(\"Aborted\"));\n\t\t});\n\t});\n}\n"]}
1
+ {"version":3,"file":"sleep.d.ts","sourceRoot":"","sources":["../../src/utils/sleep.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAmBrE","sourcesContent":["/**\n * Sleep helper that respects abort signal.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timeout);\n\t\t\treject(new Error(\"Aborted\"));\n\t\t};\n\n\t\tconst timeout = setTimeout(() => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n"]}
@@ -7,11 +7,15 @@ export function sleep(ms, signal) {
7
7
  reject(new Error("Aborted"));
8
8
  return;
9
9
  }
10
- const timeout = setTimeout(resolve, ms);
11
- signal?.addEventListener("abort", () => {
10
+ const onAbort = () => {
12
11
  clearTimeout(timeout);
13
12
  reject(new Error("Aborted"));
14
- });
13
+ };
14
+ const timeout = setTimeout(() => {
15
+ signal?.removeEventListener("abort", onAbort);
16
+ resolve();
17
+ }, ms);
18
+ signal?.addEventListener("abort", onAbort, { once: true });
15
19
  });
16
20
  }
17
21
  //# sourceMappingURL=sleep.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sleep.js","sourceRoot":"","sources":["../../src/utils/sleep.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,UAAU,KAAK,CAAC,EAAU,EAAE,MAAoB,EAAiB;IACtE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC;QACvC,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACrB,MAAM,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;YAC7B,OAAO;QACR,CAAC;QAED,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAExC,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC;YACvC,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,MAAM,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;QAAA,CAC7B,CAAC,CAAC;IAAA,CACH,CAAC,CAAC;AAAA,CACH","sourcesContent":["/**\n * Sleep helper that respects abort signal.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst timeout = setTimeout(resolve, ms);\n\n\t\tsignal?.addEventListener(\"abort\", () => {\n\t\t\tclearTimeout(timeout);\n\t\t\treject(new Error(\"Aborted\"));\n\t\t});\n\t});\n}\n"]}
1
+ {"version":3,"file":"sleep.js","sourceRoot":"","sources":["../../src/utils/sleep.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,UAAU,KAAK,CAAC,EAAU,EAAE,MAAoB,EAAiB;IACtE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC;QACvC,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACrB,MAAM,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;YAC7B,OAAO;QACR,CAAC;QAED,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;YACrB,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,MAAM,CAAC,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC;QAAA,CAC7B,CAAC;QAEF,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC;YAChC,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC9C,OAAO,EAAE,CAAC;QAAA,CACV,EAAE,EAAE,CAAC,CAAC;QAEP,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAAA,CAC3D,CAAC,CAAC;AAAA,CACH","sourcesContent":["/**\n * Sleep helper that respects abort signal.\n */\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n\treturn new Promise((resolve, reject) => {\n\t\tif (signal?.aborted) {\n\t\t\treject(new Error(\"Aborted\"));\n\t\t\treturn;\n\t\t}\n\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timeout);\n\t\t\treject(new Error(\"Aborted\"));\n\t\t};\n\n\t\tconst timeout = setTimeout(() => {\n\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\n\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n"]}
@@ -711,9 +711,16 @@ Scoring: S (80+), A (65+), B (50+), C (35+), D (<35)`, "info");
711
711
  }
712
712
 
713
713
  if (arg) {
714
- // Benchmark specific model
715
- const model = available.find(m => m.id.toLowerCase() === arg || m.id.toLowerCase().includes(arg));
714
+ // Benchmark specific model: prefer exact id match, then require an
715
+ // unambiguous substring match to avoid benchmarking the wrong model.
716
+ const exact = available.find(m => m.id.toLowerCase() === arg);
717
+ const matches = available.filter(m => m.id.toLowerCase().includes(arg));
718
+ const model = exact ?? (matches.length === 1 ? matches[0] : undefined);
716
719
  if (!model) {
720
+ if (!exact && matches.length > 1) {
721
+ ctx.ui.notify(`Model "${arg}" is ambiguous. Did you mean:\n${matches.map(m => ` - ${m.id} (${m.provider})`).join("\n")}`, "warning");
722
+ return;
723
+ }
717
724
  ctx.ui.notify(`Model "${arg}" not found or no API key. Available:\n${available.map(m => ` - ${m.id} (${m.provider})`).join("\n")}`, "warning");
718
725
  return;
719
726
  }
@@ -502,7 +502,10 @@ _Edit this file to customize Phi Code's behavior for your project._
502
502
  // not cause the user to lose what they just typed.
503
503
  try {
504
504
  await persistProviderKey(provider, trimmed);
505
- process.env[provider.envVar] = trimmed;
505
+ // Do not mirror the key into process.env: getKey() reads models.json
506
+ // first, so the store is already the source of truth. Setting the env
507
+ // var would leak the plaintext key to every child process (bash tool,
508
+ // pi.exec, npm postinstall, etc.).
506
509
  } catch (err) {
507
510
  ctx.ui.notify(
508
511
  `Failed to write ${modelsJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
@@ -76,6 +76,19 @@ export default function keysExtension(pi: ExtensionAPI) {
76
76
  ctx.ui.notify("Usage: `/keys set <provider-id> <api-key>`", "warning");
77
77
  return;
78
78
  }
79
+ // A bare `set` only updates the key; it cannot supply baseUrl/api/models.
80
+ // For a never-configured id this would persist an unusable provider
81
+ // (apiKey but no endpoint), so require an existing entry with a baseUrl.
82
+ const existing = store.getProvider(id);
83
+ if (!existing?.baseUrl) {
84
+ ctx.ui.notify(
85
+ `\`${id}\` is not a configured provider (no baseUrl on file). ` +
86
+ `Run \`/setup\` to add it with an endpoint and models first; ` +
87
+ `\`/keys set\` only updates the key of an already-configured provider.`,
88
+ "warning",
89
+ );
90
+ return;
91
+ }
79
92
  watcher.muteForWrite("models_json_changed");
80
93
  store.setKey(id, key);
81
94
  ctx.ui.notify(
@@ -436,9 +436,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
436
436
  return {};
437
437
  }
438
438
  const truncated = userPrompt.length > 200 ? `${userPrompt.slice(0, 200)}...` : userPrompt;
439
+ // Neutralize angle brackets so user content cannot close the
440
+ // <system-reminder> block early and inject trusted instructions.
441
+ const safe = truncated.replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")).replace(/"/g, '\\"');
439
442
  const reminder = `<system-reminder>
440
443
  You are about to respond to a new user message:
441
- "${truncated.replace(/"/g, '\\"')}"
444
+ "${safe}"
442
445
 
443
446
  REMINDER (project rule, applies every turn):
444
447
  1. Call \`memory_search\` FIRST with keywords from the user's intent. Recent
@@ -229,6 +229,11 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
229
229
  const MAX_PHASE_DURATION_MS = 10 * 60 * 1000; // 10 minutes per phase
230
230
  const MAX_TOOL_CALLS_PER_PHASE = 60; // Safety limit
231
231
  let phaseStartTime: number | null = null;
232
+ // User's model before /plan started, restored once orchestration ends
233
+ let originalModel: any | null = null;
234
+ // Real phase counters for the final summary
235
+ let completedPhases = 0;
236
+ let skippedPhases = 0;
232
237
 
233
238
  /**
234
239
  * Parse agent .md file with YAML frontmatter
@@ -582,27 +587,48 @@ Tag the note with relevant keywords for vector search.
582
587
 
583
588
  /**
584
589
  * Switch model for the current phase.
590
+ * Returns the model id actually in use plus an optional warning when the
591
+ * requested routing.json model could not be applied (not in registry or no
592
+ * API key), so the caller can surface the degraded routing to the user.
585
593
  */
586
- async function switchModelForPhase(phase: OrchestratorPhase, ctx: any): Promise<string> {
594
+ async function switchModelForPhase(
595
+ phase: OrchestratorPhase,
596
+ ctx: any,
597
+ ): Promise<{ modelId: string; warning?: string }> {
587
598
  const available = ctx.modelRegistry?.getAvailable?.() || [];
588
599
  const target = resolveModelRef(available, phase.model) || resolveModelRef(available, phase.fallback);
600
+ const currentId = ctx.model?.id || phase.model;
589
601
 
590
- if (target && (target.id !== ctx.model?.id || target.provider !== ctx.model?.provider)) {
602
+ if (!target) {
603
+ // Neither the preferred nor the fallback ref resolved to a registered model.
604
+ return {
605
+ modelId: currentId,
606
+ warning: `Phase ${phase.label} model '${phase.model}' unavailable (not in registry); running on '${currentId}'`,
607
+ };
608
+ }
609
+
610
+ if (target.id !== ctx.model?.id || target.provider !== ctx.model?.provider) {
591
611
  const switched = await pi.setModel(target);
592
- if (switched) return target.id;
612
+ if (switched) return { modelId: target.id };
613
+ // setModel returns false when the target has no configured auth.
614
+ return {
615
+ modelId: currentId,
616
+ warning: `Phase ${phase.label} model '${phase.model}' unavailable (no API key); running on '${currentId}'`,
617
+ };
593
618
  }
594
- return ctx.model?.id || phase.model;
619
+ return { modelId: target.id };
595
620
  }
596
621
 
597
622
  /**
598
623
  * Activate agent for a phase: set system prompt + restrict tools.
599
624
  */
600
625
  function activateAgent(phase: OrchestratorPhase, ctx: any) {
626
+ // Fallback capture: savedTools is normally taken in the /plan handler at
627
+ // orchestration start, but capture it here too so restoration is always defined.
628
+ if (!savedTools) {
629
+ savedTools = pi.getActiveTools();
630
+ }
601
631
  if (phase.agent) {
602
- // Save current tools for restoration
603
- if (!savedTools) {
604
- savedTools = pi.getActiveTools();
605
- }
606
632
  // Set agent's system prompt (will be injected via before_agent_start)
607
633
  activeAgentPrompt = phase.agent.systemPrompt;
608
634
  // Restrict tools to agent's allowed tools
@@ -612,10 +638,20 @@ Tag the note with relevant keywords for vector search.
612
638
  const agentTools = [...phase.agent.tools, ...memoryTools.filter(t => !phase.agent.tools.includes(t))];
613
639
  activeAgentTools = agentTools;
614
640
  pi.setActiveTools(agentTools);
641
+ } else if (savedTools) {
642
+ // Agent with no tool restriction: reset to the full toolset so the
643
+ // previous phase's narrower restriction does not leak into this one.
644
+ activeAgentTools = null;
645
+ pi.setActiveTools(savedTools);
615
646
  }
616
647
  } else {
648
+ // No agent for this phase: restore the full toolset, otherwise the prior
649
+ // phase's restriction would persist.
617
650
  activeAgentPrompt = null;
618
651
  activeAgentTools = null;
652
+ if (savedTools) {
653
+ pi.setActiveTools(savedTools);
654
+ }
619
655
  }
620
656
  }
621
657
 
@@ -639,27 +675,54 @@ Tag the note with relevant keywords for vector search.
639
675
  (globalThis as any).__phiOrchestrationActive = active;
640
676
  }
641
677
 
678
+ /**
679
+ * Tear down orchestration state without chaining to the next phase.
680
+ * Used by the user-abort and auth-error paths so they restore the user's
681
+ * model and tools exactly like the normal completion branch.
682
+ */
683
+ function stopOrchestration() {
684
+ setOrchestrationActive(false);
685
+ phasePending = false;
686
+ deactivateAgent();
687
+ if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
688
+ if (originalModel) {
689
+ const toRestore = originalModel;
690
+ originalModel = null;
691
+ Promise.resolve(pi.setModel(toRestore)).catch(() => { /* best effort */ });
692
+ }
693
+ }
694
+
642
695
  function sendNextPhase(ctx: any) {
643
696
  if (phaseQueue.length === 0) {
644
697
  // All phases done — clean up and notify
645
698
  setOrchestrationActive(false);
646
699
  phasePending = false;
647
700
  deactivateAgent();
701
+ // Restore the user's model so /plan does not overwrite their /model choice.
702
+ if (originalModel) {
703
+ const toRestore = originalModel;
704
+ originalModel = null;
705
+ Promise.resolve(pi.setModel(toRestore)).catch(() => { /* best effort */ });
706
+ }
648
707
  if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
649
- // Generate global final summary
650
- const totalPhases = 5; // always 5
708
+ // Generate global final summary using the real phase counters
651
709
  const elapsed = phaseStartTime ? Math.round((Date.now() - phaseStartTime) / 1000) : 0;
652
710
  const minutes = Math.floor(elapsed / 60);
653
711
  const seconds = elapsed % 60;
712
+ const skippedNote = skippedPhases ? ` (${skippedPhases} skipped on timeout)` : "";
654
713
  ctx.ui.notify(`\n📊 **Orchestration Summary**\n` +
655
- ` Phases: ${totalPhases}/5 completed\n` +
714
+ ` Phases: ${completedPhases}/5 completed${skippedNote}\n` +
656
715
  ` Duration: ${minutes}m ${seconds}s\n` +
657
716
  ` Check \`.phi/plans/\` for all reports`, "info");
717
+ const allDone = completedPhases >= 5 && skippedPhases === 0;
718
+ const doneMsg = allDone
719
+ ? `\n✅ **All 5 phases complete!**`
720
+ : `\n⚠️ **Orchestration finished:** ${completedPhases}/5 phases completed${skippedNote}.`;
658
721
  try {
659
- ctx.ui.notify(`\n✅ **All 5 phases complete!**`, "info");
722
+ ctx.ui.notify(doneMsg, "info");
660
723
  } catch {
661
724
  // Fallback: inject completion message into conversation stream
662
- try { pi.sendUserMessage(`✅ All 5 phases complete! The project is ready for review.`); } catch { /* best effort */ }
725
+ try { pi.sendUserMessage(`Orchestration finished: ${completedPhases}/5 phases completed${skippedNote}.`); } catch { /* best effort */ }
663
726
  }
664
727
  return;
665
728
  }
@@ -667,18 +730,25 @@ Tag the note with relevant keywords for vector search.
667
730
  const phase = phaseQueue.shift()!;
668
731
  phasePending = true;
669
732
 
670
- switchModelForPhase(phase, ctx).then((modelId) => {
733
+ switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
671
734
  activateAgent(phase, ctx);
672
735
  const agentName = phase.agent?.name || phase.key;
673
736
  ctx.ui.notify(`\n${phase.label} → \`${modelId}\` (agent: ${agentName})`, "info");
674
- // Small delay to let the model switch settle, then send instruction
675
- setTimeout(() => pi.sendUserMessage(phase.instruction), 500);
737
+ if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
738
+ // Small delay to let the model switch settle, then send instruction.
739
+ // deliverAs:"followUp" queues the message instead of throwing when the
740
+ // agent is still streaming (e.g. on the timeout path).
741
+ setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
676
742
  // Set phase timeout — abort if phase takes too long
677
743
  if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
678
744
  phaseTimeoutId = setTimeout(() => {
679
745
  if (orchestrationActive && phasePending) {
680
746
  ctx.ui.notify(`\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min limit). Skipping to next phase.`, "warning");
681
747
  phasePending = false;
748
+ skippedPhases++;
749
+ // Abort the stuck phase first so the next instruction does not
750
+ // race a still-streaming phase.
751
+ try { ctx.abort(); } catch { /* best effort */ }
682
752
  sendNextPhase(ctx);
683
753
  }
684
754
  }, MAX_PHASE_DURATION_MS);
@@ -705,6 +775,19 @@ Tag the note with relevant keywords for vector search.
705
775
 
706
776
  pi.on("agent_end", async (event, ctx) => {
707
777
  if (!orchestrationActive) return;
778
+
779
+ // User abort (Ctrl+C / ESC): the aborted run still emits agent_end, but it
780
+ // must NOT be treated as phase completion. Detect the aborted assistant
781
+ // message and stop the whole workflow instead of chaining the next phase.
782
+ const userAborted = (event.messages || []).some(
783
+ (m: any) => m.role === 'assistant' && m.stopReason === 'aborted',
784
+ );
785
+ if (userAborted) {
786
+ ctx.ui.notify(`\n🛑 **Orchestration cancelled** by user. Remaining phases skipped.`, "warning");
787
+ stopOrchestration();
788
+ return;
789
+ }
790
+
708
791
  // Allow completion even if phasePending was cleared by a duplicate event
709
792
  if (!phasePending) {
710
793
  // If queue is empty and orchestration is active, force completion
@@ -740,9 +823,10 @@ Tag the note with relevant keywords for vector search.
740
823
  const match = content.match(/wrote \d+ bytes to (.+)/);
741
824
  if (match) filesWritten.push(match[1]);
742
825
  }
743
- // Track edits
744
- if (name === 'edit' && !content.includes('ERR')) {
745
- const match = content.match(/edited (.+)/) || content.match(/in (.+)/);
826
+ // Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
827
+ // Anchor the path capture so it does not over-capture unrelated text.
828
+ if (name === 'edit' && !content.includes('ERR') && !(msg as any).isError) {
829
+ const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
746
830
  if (match) filesEdited.push(match[1]);
747
831
  }
748
832
  // Track errors — but filter out edit retries (old_text mismatch = normal retry, not error)
@@ -770,10 +854,7 @@ Tag the note with relevant keywords for vector search.
770
854
  // Only a genuine auth failure (401) is fatal — abort the whole workflow.
771
855
  if (hasAuthError) {
772
856
  ctx.ui.notify(`\n❌ **Orchestrator aborted:** API authentication error (401)\nCheck your API key and model configuration.`, "error");
773
- setOrchestrationActive(false);
774
- phasePending = false;
775
- deactivateAgent();
776
- if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
857
+ stopOrchestration();
777
858
  return;
778
859
  }
779
860
 
@@ -816,6 +897,7 @@ Tag the note with relevant keywords for vector search.
816
897
  }
817
898
 
818
899
  // Phase complete — chain to next
900
+ completedPhases++;
819
901
  phasePending = false;
820
902
  sendNextPhase(ctx);
821
903
  });
@@ -851,6 +933,13 @@ Tag the note with relevant keywords for vector search.
851
933
  phaseQueue = phases.slice(1); // Queue phases 2-5
852
934
  setOrchestrationActive(true);
853
935
  phasePending = true;
936
+ // Capture the user's model + full toolset so they can be restored when
937
+ // orchestration ends (otherwise /plan permanently overwrites /model and
938
+ // per-phase tool restrictions can leak).
939
+ originalModel = ctx.model || null;
940
+ savedTools = pi.getActiveTools();
941
+ completedPhases = 0;
942
+ skippedPhases = 0;
854
943
  const firstPhase = phases[0];
855
944
 
856
945
  ctx.ui.notify(`📋 **Orchestrator started** — 5 phases with model routing + agent roles\n`, "info");
@@ -866,11 +955,12 @@ Tag the note with relevant keywords for vector search.
866
955
  // Record orchestration start time for final summary
867
956
  phaseStartTime = Date.now();
868
957
  // Switch model and activate agent for first phase
869
- const modelId = await switchModelForPhase(firstPhase, ctx);
958
+ const { modelId, warning } = await switchModelForPhase(firstPhase, ctx);
870
959
  activateAgent(firstPhase, ctx);
871
960
  const agentName = firstPhase.agent?.name || firstPhase.key;
872
961
  ctx.ui.notify(`${firstPhase.label} → \`${modelId}\` (agent: ${agentName})`, "info");
873
- setTimeout(() => pi.sendUserMessage(firstPhase.instruction), 200);
962
+ if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
963
+ setTimeout(() => pi.sendUserMessage(firstPhase.instruction, { deliverAs: "followUp" }), 200);
874
964
  },
875
965
  });
876
966
 
@@ -77,7 +77,9 @@ interface CacheEntry {
77
77
 
78
78
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
79
79
  let cache: CacheEntry | null = null;
80
- let inflightFetch: Promise<OpenCodeGoModel[]> | null = null;
80
+ // Inflight fetches keyed by apiKey so a keyless probe never leaks its
81
+ // (possibly 401/reduced) result to a caller that passed a real key.
82
+ const inflightFetch = new Map<string, Promise<OpenCodeGoModel[]>>();
81
83
 
82
84
  function isCacheValid(now: number): boolean {
83
85
  return cache !== null && now - cache.fetchedAt < CACHE_TTL_MS;
@@ -125,19 +127,25 @@ export async function getOpenCodeGoModels(options?: {
125
127
  return { models: cache!.models, source: "cache" };
126
128
  }
127
129
 
128
- if (inflightFetch) {
129
- try {
130
- const models = await inflightFetch;
131
- return { models, source: "live" };
132
- } catch {
133
- // fall through to fresh attempt
130
+ const inflightKey = options?.apiKey ?? "";
131
+
132
+ if (!force) {
133
+ const existing = inflightFetch.get(inflightKey);
134
+ if (existing) {
135
+ try {
136
+ const models = await existing;
137
+ return { models, source: "live" };
138
+ } catch {
139
+ // fall through to fresh attempt
140
+ }
134
141
  }
135
142
  }
136
143
 
137
- inflightFetch = fetchModelsRaw(options?.apiKey, timeoutMs);
144
+ const promise = fetchModelsRaw(options?.apiKey, timeoutMs);
145
+ inflightFetch.set(inflightKey, promise);
138
146
  try {
139
- const models = await inflightFetch;
140
- cache = { models, fetchedAt: now };
147
+ const models = await promise;
148
+ cache = { models, fetchedAt: Date.now() };
141
149
  return { models, source: "live" };
142
150
  } catch {
143
151
  if (cache) {
@@ -145,7 +153,9 @@ export async function getOpenCodeGoModels(options?: {
145
153
  }
146
154
  return { models: [...OPENCODE_GO_FALLBACK_MODELS], source: "fallback" };
147
155
  } finally {
148
- inflightFetch = null;
156
+ // Only clear the slot we created; a concurrent fetch for the same key
157
+ // may have replaced it (e.g. force=true overwriting a pending entry).
158
+ if (inflightFetch.get(inflightKey) === promise) inflightFetch.delete(inflightKey);
149
159
  }
150
160
  }
151
161
 
@@ -240,5 +250,5 @@ export function buildOpenCodeGoAnthropicProviderConfig(apiKey: string, models: O
240
250
  */
241
251
  export function _resetOpenCodeGoCache(): void {
242
252
  cache = null;
243
- inflightFetch = null;
253
+ inflightFetch.clear();
244
254
  }
@@ -19,7 +19,7 @@
19
19
  * - Storage chmod 0600 garantit la sécurité au repos
20
20
  */
21
21
 
22
- import { ApiKeyStore, type ExtensionAPI, type ExtensionUIContext, getApiKeyStore } from "phi-code";
22
+ import { ApiKeyStore, type ExtensionAPI, type ExtensionUIContext, getApiKeyStore, getConfigWatcher } from "phi-code";
23
23
  import { writeFile, mkdir } from "node:fs/promises";
24
24
  import { join } from "node:path";
25
25
  import { homedir } from "node:os";
@@ -443,7 +443,13 @@ async function configureGenericCloud(
443
443
  if (!proceed) return undefined;
444
444
  }
445
445
 
446
+ // Mute the config watcher so these programmatic writes are not echoed back as
447
+ // models_json_changed events. Mute per-write because the ping/fetch below can
448
+ // exceed the ignore window between the two setKey calls (cf. models.ts).
449
+ const watcher = getConfigWatcher();
450
+
446
451
  // Persist the key immediately so a downstream fetch failure cannot lose user input.
452
+ watcher.muteForWrite("models_json_changed");
447
453
  store.setKey(provider.id, trimmed, {
448
454
  baseUrl: provider.baseUrl,
449
455
  api: provider.api,
@@ -479,6 +485,7 @@ async function configureGenericCloud(
479
485
  : provider.staticModels.map((id) => ({ id, name: id, reasoning: true }))
480
486
  ).map(toPersistedModel);
481
487
 
488
+ watcher.muteForWrite("models_json_changed");
482
489
  store.setKey(provider.id, trimmed, {
483
490
  baseUrl: provider.baseUrl,
484
491
  api: provider.api,
@@ -50,8 +50,20 @@ function decodeEntities(text: string): string {
50
50
  return text
51
51
  .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
52
52
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
53
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
54
- .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
53
+ .replace(/&#(\d+);/g, (m, n) => {
54
+ try {
55
+ return String.fromCodePoint(parseInt(n, 10));
56
+ } catch {
57
+ return m;
58
+ }
59
+ })
60
+ .replace(/&#x([0-9a-fA-F]+);/g, (m, n) => {
61
+ try {
62
+ return String.fromCodePoint(parseInt(n, 16));
63
+ } catch {
64
+ return m;
65
+ }
66
+ });
55
67
  }
56
68
 
57
69
  function stripTags(html: string): string {
@@ -434,7 +446,7 @@ export default function webSearchExtension(pi: ExtensionAPI) {
434
446
  const hasReadability = await tryLoadReadability();
435
447
  if (hasReadability && _JSDOM && _Readability) {
436
448
  try {
437
- const dom = new _JSDOM(text, { url });
449
+ const dom = new _JSDOM(text, { url: currentUrl });
438
450
  const reader = new _Readability(dom.window.document);
439
451
  const article = reader.parse();
440
452
  if (article?.textContent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.76.15",
3
+ "version": "0.77.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {