@nmzpy/pi-ember-stack 0.2.1 → 0.2.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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -38,8 +38,17 @@ interface AgentCache {
38
38
  projectAgentsDir: string | null;
39
39
  /** File-level signature per directory (name:mtime:size for each .md file) */
40
40
  dirSignatures: Map<string, string>;
41
+ /** Monotonic timestamp (ms) of the last fs-based signature validation.
42
+ * Within the TTL, cache hits skip dirSignature() entirely so no
43
+ * synchronous fs (readdirSync + statSync) hits the UI thread. */
44
+ validatedAt: number;
41
45
  }
42
46
 
47
+ /** Cache TTL: skip fs-based signature validation for this long after a
48
+ * successful validation. Agent .md edits within the window are not
49
+ * detected until the TTL expires or /subagent reload is invoked. */
50
+ const CACHE_VALIDATION_TTL_MS = 2000;
51
+
43
52
  let _cache: AgentCache | null = null;
44
53
 
45
54
  /** Clear the agent cache (call on /reload). */
@@ -153,7 +162,9 @@ export function discoverAgents(
153
162
  const userDir = path.join(getAgentDir(), "agents");
154
163
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
155
164
 
156
- // Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
165
+ // Check cache (with file-signature invalidation so editing agent .md files auto-detects changes).
166
+ // Within the TTL, skip the fs-based dirSignature() check entirely so
167
+ // cache hits do zero synchronous fs (readdirSync + statSync per file).
157
168
  if (
158
169
  _cache &&
159
170
  _cache.userDir === userDir &&
@@ -161,6 +172,10 @@ export function discoverAgents(
161
172
  _cache.bundledDir === bundledAgentsDir &&
162
173
  _cache.scope === scope
163
174
  ) {
175
+ const withinTtl = Date.now() - _cache.validatedAt < CACHE_VALIDATION_TTL_MS;
176
+ if (withinTtl) {
177
+ return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
178
+ }
164
179
  let stale = false;
165
180
  for (const [dir, cachedSig] of _cache.dirSignatures) {
166
181
  if (dirSignature(dir) !== cachedSig) {
@@ -169,6 +184,7 @@ export function discoverAgents(
169
184
  }
170
185
  }
171
186
  if (!stale) {
187
+ _cache.validatedAt = Date.now();
172
188
  return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
173
189
  }
174
190
  // Cache is stale — rebuild below
@@ -207,6 +223,7 @@ export function discoverAgents(
207
223
  agents,
208
224
  projectAgentsDir,
209
225
  dirSignatures,
226
+ validatedAt: Date.now(),
210
227
  };
211
228
 
212
229
  return { agents, projectAgentsDir };
@@ -23,10 +23,9 @@ import {
23
23
  type ExtensionAPI,
24
24
  type ExtensionContext,
25
25
  getAgentDir,
26
- getMarkdownTheme,
27
26
  ModelRegistry,
28
27
  } from "@earendil-works/pi-coding-agent";
29
- import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
28
+ import { Box, Container, SelectList, Text } from "@earendil-works/pi-tui";
30
29
  import { Type } from "typebox";
31
30
 
32
31
  import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
@@ -39,10 +38,13 @@ import {
39
38
  runSubAgent,
40
39
  } from "./runner.ts";
41
40
  import {
42
- aggregateUsage,
43
- formatUsageStats,
44
- renderSingleResult,
41
+ anySubagentRunning,
42
+ renderSubagentExpanded,
43
+ renderSubagentLayout,
44
+ SubagentCapLine,
45
45
  } from "./render.ts";
46
+ import { PulseManager } from "../../../pi-compact-tools/renderer.ts";
47
+ import { isLatestSubagentRunning } from "../../../pi-ember-ui/mode-colors.ts";
46
48
  import { type SubagentThread, threadStore } from "./threads.ts";
47
49
  import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
48
50
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
@@ -139,11 +141,34 @@ interface SubagentDetails {
139
141
  export default function (pi: ExtensionAPI) {
140
142
  let currentCtx: ExtensionContext | undefined;
141
143
 
144
+ // Shared pulse timer for subagent flashing bullets. Reset on session
145
+ // replacement so stale invalidate callbacks from the previous session
146
+ // do not fire into a dead TUI.
147
+ const subagentPulses = new PulseManager();
148
+
149
+ function update_subagent_pulse(context: any, running: boolean): void {
150
+ if (!context.invalidate) return;
151
+ // ToolExecutionComponent creates a fresh invalidate closure for every
152
+ // render. Keep one callback per row so completed rows can actually be
153
+ // removed from the shared timer instead of leaking stale TUI callbacks.
154
+ const invalidate = context.state.subagentPulseInvalidate ?? context.invalidate;
155
+ context.state.subagentPulseInvalidate = invalidate;
156
+ if (running) subagentPulses.add(invalidate);
157
+ else subagentPulses.remove(invalidate);
158
+ }
159
+
142
160
  // Invalidate agent cache + clear thread store on session replacement.
143
161
  pi.on("session_start", (event, ctx) => {
144
162
  currentCtx = ctx;
145
163
  if (event.reason === "reload") invalidateAgentCache();
146
164
  threadStore.clear();
165
+ subagentPulses.clear();
166
+ });
167
+
168
+ pi.on("session_shutdown", () => {
169
+ currentCtx = undefined;
170
+ threadStore.clear();
171
+ subagentPulses.clear();
147
172
  });
148
173
 
149
174
  // Proactively steer agents toward sub-agent delegation when users mention it
@@ -165,7 +190,11 @@ export default function (pi: ExtensionAPI) {
165
190
  pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
166
191
  const request = raw as SubagentRunRequest;
167
192
  const ctx = currentCtx;
168
- if (!ctx || !request?.id || typeof request.respond !== "function") return;
193
+ if (!request?.id || typeof request.respond !== "function") return;
194
+ if (!ctx) {
195
+ request.respond({ id: request.id, ok: false, error: "Subagent session is not active." });
196
+ return;
197
+ }
169
198
  if (request.accept && !request.accept()) return;
170
199
  const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
171
200
  if (!agent) {
@@ -279,6 +308,7 @@ export default function (pi: ExtensionAPI) {
279
308
  `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
280
309
  ].join(" "),
281
310
  parameters: SubagentParams,
311
+ renderShell: "self",
282
312
  promptSnippet: "Delegate tasks to specialized sub-agents (scout, reviewer, worker, general-purpose)",
283
313
  promptGuidelines: [
284
314
  "Use subagent to delegate work that would flood the main context with search results or file contents.",
@@ -481,6 +511,22 @@ export default function (pi: ExtensionAPI) {
481
511
  mode: "chain-step",
482
512
  toolCallId: _toolCallId,
483
513
  });
514
+ // Publish the active step before awaiting it so chain mode shows
515
+ // the running agent's gradient instead of an empty group header.
516
+ results.push({
517
+ agent: step.agent,
518
+ task: taskWithContext,
519
+ exitCode: -1,
520
+ messages: [],
521
+ stderr: "",
522
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
523
+ });
524
+ if (onUpdate) {
525
+ onUpdate({
526
+ content: [{ type: "text", text: "Running..." }],
527
+ details: makeDetails("chain")(results),
528
+ });
529
+ }
484
530
  const result = await runOne(
485
531
  step.agent, taskWithContext, step.cwd,
486
532
  signal, step.timeout ?? params.timeout,
@@ -490,7 +536,7 @@ export default function (pi: ExtensionAPI) {
490
536
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
491
537
  result,
492
538
  });
493
- results.push(result);
539
+ results[i] = result;
494
540
 
495
541
  const isError = isFailedResult(result);
496
542
  if (isError) {
@@ -746,239 +792,85 @@ export default function (pi: ExtensionAPI) {
746
792
  },
747
793
 
748
794
  // ------------------------------------------------------------------
749
- // TUI rendering
795
+ // TUI rendering — compact grouped layout (Exploring-style)
750
796
  // ------------------------------------------------------------------
751
797
 
752
- renderCall(args, theme, _context) {
753
- const scope: AgentScope = args.agentScope ?? "user";
754
- const fg = theme.fg.bind(theme);
755
-
756
- // Chain
757
- if (args.chain && args.chain.length > 0) {
758
- let text =
759
- fg("muted", "• ") +
760
- fg("toolTitle", theme.bold("subagent ")) +
761
- fg("accent", `chain (${args.chain.length} steps)`) +
762
- fg("muted", ` [${scope}]`);
763
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
764
- const step = args.chain[i];
765
- const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
766
- const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
767
- text +=
768
- "\n " +
769
- fg("muted", `${i + 1}.`) +
770
- " " +
771
- fg("accent", step.agent) +
772
- fg("dim", ` ${preview}`);
773
- }
774
- if (args.chain.length > 3)
775
- text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
776
- return new Text(text, 0, 0);
798
+ renderCall(args, theme, context) {
799
+ // The cap is a full-width sibling above the padded subagent box.
800
+ // Its render(width) reads the live viewport width and visibility state.
801
+ let shell = context.state.shell;
802
+ if (!(shell instanceof Container)) {
803
+ shell = new Container();
804
+ context.state.shell = shell;
777
805
  }
806
+ shell.clear();
778
807
 
779
- // Parallel
780
- if (args.tasks && args.tasks.length > 0) {
781
- let text =
782
- fg("muted", "• ") +
783
- fg("toolTitle", theme.bold("subagent ")) +
784
- fg("accent", `parallel (${args.tasks.length} tasks)`) +
785
- fg("muted", ` [${scope}]`);
786
- for (const t of args.tasks.slice(0, 3)) {
787
- const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
788
- text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
789
- }
790
- if (args.tasks.length > 3)
791
- text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
792
- return new Text(text, 0, 0);
808
+ let capLine = context.state.capLine;
809
+ if (!(capLine instanceof SubagentCapLine)) {
810
+ capLine = new SubagentCapLine(
811
+ () => isLatestSubagentRunning(),
812
+ (theme.fg as any).bind(theme),
813
+ );
814
+ context.state.capLine = capLine;
815
+ } else {
816
+ capLine.setForeground((theme.fg as any).bind(theme));
793
817
  }
818
+ shell.addChild(capLine);
794
819
 
795
- // Single
796
- const agentName = args.agent || "...";
797
- const preview = args.task
798
- ? args.task.length > 60
799
- ? `${args.task.slice(0, 60)}...`
800
- : args.task
801
- : "...";
802
- let text =
803
- fg("muted", "• ") +
804
- fg("toolTitle", theme.bold("subagent ")) +
805
- fg("accent", agentName) +
806
- fg("muted", ` [${scope}]`);
807
- text += `\n ${fg("dim", preview)}`;
808
- return new Text(text, 0, 0);
820
+ // Reuse or create the self-rendered Box with the subagentBg token.
821
+ let box = context.state.box;
822
+ if (!(box instanceof Box)) {
823
+ box = new Box(1, 0);
824
+ context.state.box = box;
825
+ }
826
+ box.setBgFn((s: string) => (theme.bg as any)("subagentBg", s));
827
+ box.clear();
828
+
829
+ // Reuse or create the single Text child that carries the layout.
830
+ let callText = context.state.callText;
831
+ if (!(callText instanceof Text)) {
832
+ callText = new Text("", 0, 0);
833
+ context.state.callText = callText;
834
+ }
835
+ const results = context.state.results ?? [];
836
+ callText.setText(renderSubagentLayout(args, results, theme));
837
+ box.addChild(callText);
838
+ shell.addChild(box);
839
+
840
+ // Flash while any agent is running.
841
+ update_subagent_pulse(context, anySubagentRunning(args, results));
842
+ return shell;
809
843
  },
810
844
 
811
- renderResult(result, { expanded }, theme, _context) {
845
+ renderResult(result, { expanded }, theme, context) {
812
846
  const details = result.details as SubagentDetails | undefined;
813
- if (!details || details.results.length === 0) {
814
- const text = result.content[0];
815
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
847
+ const results = details?.results ?? [];
848
+ context.state.results = results;
849
+ update_subagent_pulse(context, details ? anySubagentRunning(context.args, results) : false);
850
+ if (!details) {
851
+ const outputBlock = result.content.find((item: any) => item.type === "text");
852
+ const output = outputBlock?.type === "text" ? outputBlock.text : "(no output)";
853
+ return new Text(output, 0, 0);
816
854
  }
817
855
 
818
- const fg = theme.fg.bind(theme);
819
- const mdTheme = getMarkdownTheme();
820
-
821
- // --- Single ---
822
- if (details.mode === "single" && details.results.length === 1) {
823
- return renderSingleResult(details.results[0], expanded, theme);
824
- }
825
-
826
- // --- Chain ---
827
- if (details.mode === "chain") {
828
- const successCount = details.results.filter((r) => !isFailedResult(r)).length;
829
- const icon =
830
- successCount === details.results.length
831
- ? fg("success", "✓")
832
- : fg("error", "✗");
833
-
834
- if (expanded) {
835
- const container = new Container();
836
- container.addChild(
837
- new Text(
838
- icon +
839
- " " +
840
- fg("toolTitle", theme.bold("chain ")) +
841
- fg("accent", `${successCount}/${details.results.length} steps`),
842
- 0,
843
- 0,
844
- ),
845
- );
846
- for (const r of details.results) {
847
- container.addChild(new Spacer(1));
848
- const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
849
- container.addChild(
850
- new Text(
851
- fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
852
- fg("accent", r.agent) +
853
- ` ${stepIcon}`,
854
- 0,
855
- 0,
856
- ),
857
- );
858
- if (r.errorMessage) {
859
- container.addChild(
860
- new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
861
- );
862
- }
863
- const finalOutput = getResultOutput(r);
864
- if (finalOutput) {
865
- container.addChild(new Spacer(1));
866
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
867
- }
868
- const usageStr = formatUsageStats(r.usage, r.model);
869
- if (usageStr)
870
- container.addChild(new Text(fg("dim", usageStr), 0, 0));
871
- }
872
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
873
- if (totalUsage) {
874
- container.addChild(new Spacer(1));
875
- container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
876
- }
877
- return container;
878
- }
879
-
880
- let text =
881
- icon +
882
- " " +
883
- fg("toolTitle", theme.bold("chain ")) +
884
- fg("accent", `${successCount}/${details.results.length} steps`);
885
- for (const r of details.results) {
886
- const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
887
- text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
888
- }
889
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
890
- if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
891
- text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
892
- return new Text(text, 0, 0);
856
+ // Update the call-slot Text with the latest statuses. renderResult
857
+ // runs after renderCall in updateDisplay, so this wins the paint.
858
+ const callText = context.state.callText;
859
+ if (callText instanceof Text) {
860
+ callText.setText(renderSubagentLayout(context.args, results, theme));
893
861
  }
894
862
 
895
- // --- Parallel ---
896
- if (details.mode === "parallel") {
897
- const running = details.results.filter((r) => r.exitCode === -1).length;
898
- const successCount = details.results.filter(
899
- (r) => r.exitCode !== -1 && !isFailedResult(r),
900
- ).length;
901
- const failCount = details.results.filter(
902
- (r) => r.exitCode !== -1 && isFailedResult(r),
903
- ).length;
904
- const isRunning = running > 0;
905
- const icon = isRunning
906
- ? fg("warning", "⏳")
907
- : failCount > 0
908
- ? fg("warning", "◐")
909
- : fg("success", "✓");
910
- const status = isRunning
911
- ? `${successCount + failCount}/${details.results.length} done, ${running} running`
912
- : `${successCount}/${details.results.length} tasks`;
913
-
914
- if (expanded && !isRunning) {
915
- const container = new Container();
916
- container.addChild(
917
- new Text(
918
- `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
919
- 0,
920
- 0,
921
- ),
922
- );
923
- for (const r of details.results) {
924
- container.addChild(new Spacer(1));
925
- const taskIcon = isFailedResult(r)
926
- ? fg("error", "✗")
927
- : fg("success", "✓");
928
- container.addChild(
929
- new Text(
930
- fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
931
- 0,
932
- 0,
933
- ),
934
- );
935
- container.addChild(
936
- new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
937
- );
938
- if (r.errorMessage) {
939
- container.addChild(
940
- new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
941
- );
942
- }
943
- const finalOutput = getResultOutput(r);
944
- if (finalOutput) {
945
- container.addChild(new Spacer(1));
946
- container.addChild(
947
- new Markdown(finalOutput.trim(), 0, 0, mdTheme),
948
- );
949
- }
950
- const taskUsage = formatUsageStats(r.usage, r.model);
951
- if (taskUsage)
952
- container.addChild(new Text(fg("dim", taskUsage), 0, 0));
953
- }
954
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
955
- if (totalUsage) {
956
- container.addChild(new Spacer(1));
957
- container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
958
- }
959
- return container;
960
- }
863
+ const isRunning = anySubagentRunning(context.args, results);
961
864
 
962
- let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
963
- for (const r of details.results) {
964
- const taskIcon =
965
- r.exitCode === -1
966
- ? fg("warning", "⏳")
967
- : isFailedResult(r)
968
- ? fg("error", "✗")
969
- : fg("success", "✓");
970
- text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
971
- }
972
- if (!isRunning) {
973
- const totalUsage = formatUsageStats(aggregateUsage(details.results));
974
- if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
975
- }
976
- if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
977
- return new Text(text, 0, 0);
865
+ // Expanded view (Ctrl+O): detailed per-agent output, wrapped in
866
+ // the subagentBg Box so it stays visually integrated.
867
+ if (expanded && !isRunning && details && details.results.length > 0) {
868
+ const expandedContent = renderSubagentExpanded(details, theme);
869
+ if (expandedContent) return expandedContent;
978
870
  }
979
871
 
980
- const fallback = result.content[0];
981
- return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
872
+ // Collapsed: the call-slot Box is the single visible component.
873
+ return new Text("", 0, 0);
982
874
  },
983
875
  });
984
876
  // /agent command — switch between subagent threads.
@@ -1,96 +1,96 @@
1
- /**
2
- * Shared model resolution for pi-subagent.
3
- *
4
- * Provides a single canonical resolveModel() used by both the tool handler
5
- * (index.ts) and the event-driven service path (service.ts), ensuring
6
- * consistent error reporting across all sub-agent invocation paths.
7
- *
8
- * Queries the parent ModelRegistry first (catches custom-configured models
9
- * with overridden base URLs, headers, compatibility settings). Falls back
10
- * to the built-in registry for unconfigured models.
11
- * For unqualified names (no provider prefix), known naming conventions
12
- * are tried before assuming Anthropic.
13
- */
14
-
15
- import type { Model } from "@earendil-works/pi-ai";
16
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
17
-
18
- type BuiltInModelResolver = (provider: string, id: string) => Model<any> | undefined;
19
-
20
- interface ModelModule {
21
- getModel: BuiltInModelResolver;
22
- }
23
-
24
- // Pi 0.80 exposes getModel from /compat; 0.79 exported it from the main entry.
25
- const { getModel } = (await import("@earendil-works/pi-ai/compat").catch(
26
- () => import("@earendil-works/pi-ai"),
27
- )) as ModelModule;
28
-
29
- export interface ResolvedModel {
30
- model: Model<any> | null;
31
- attempted: string[];
32
- }
33
-
34
- /** Known provider prefixes for unqualified model names. */
35
- const KNOWN_PROVIDERS: [string, RegExp][] = [
36
- ["openai", /^gpt-/i],
37
- ["anthropic", /^claude-/i],
38
- ["google", /^gemini-/i],
39
- ["cohere", /^command-/i],
40
- ["deepseek", /^(deepseek-|ds-)/i],
41
- ["mistral", /^mistral-/i],
42
- ["groq", /^(groq-|llama-)/i],
43
- ];
44
-
45
- function tryGetModel(
46
- provider: string,
47
- id: string,
48
- modelRegistry?: ModelRegistry,
49
- ): Model<any> | null {
50
- // Query parent ModelRegistry first — it includes custom-configured models
51
- // (overridden base URLs, headers, compatibility settings, per-model overrides).
52
- // Fall back to built-in registry for unconfigured models.
53
- if (modelRegistry) {
54
- const found = modelRegistry.find(provider as any, id as any) ?? null;
55
- if (found) return found;
56
- }
57
- const builtIn = getModel(provider as any, id as any) ?? null;
58
- if (builtIn) return builtIn;
59
- return null;
60
- }
61
-
62
- export function resolveModel(
63
- modelName: string | undefined,
64
- parentModel: Model<any> | undefined,
65
- modelRegistry?: ModelRegistry,
66
- ): ResolvedModel {
67
- const attempted: string[] = [];
68
- if (modelName) {
69
- const idx = modelName.indexOf("/");
70
- if (idx > 0) {
71
- // Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
72
- const provider = modelName.slice(0, idx);
73
- const id = modelName.slice(idx + 1);
74
- attempted.push(modelName);
75
- const found = tryGetModel(provider, id, modelRegistry);
76
- if (found) return { model: found, attempted };
77
- } else {
78
- // Unqualified: try known providers by naming convention
79
- for (const [provider, pattern] of KNOWN_PROVIDERS) {
80
- if (pattern.test(modelName)) {
81
- attempted.push(`${provider}/${modelName}`);
82
- const found = tryGetModel(provider, modelName, modelRegistry);
83
- if (found) return { model: found, attempted };
84
- }
85
- }
86
- // Fall back to Anthropic shorthand (backward compat)
87
- attempted.push(`anthropic/${modelName}`);
88
- const found = tryGetModel("anthropic", modelName, modelRegistry);
89
- if (found) return { model: found, attempted };
90
- }
91
- } else if (parentModel) {
92
- attempted.push(`${parentModel.provider}/${parentModel.id}`);
93
- return { model: parentModel, attempted };
94
- }
95
- return { model: null, attempted };
96
- }
1
+ /**
2
+ * Shared model resolution for pi-subagent.
3
+ *
4
+ * Provides a single canonical resolveModel() used by both the tool handler
5
+ * (index.ts) and the event-driven service path (service.ts), ensuring
6
+ * consistent error reporting across all sub-agent invocation paths.
7
+ *
8
+ * Queries the parent ModelRegistry first (catches custom-configured models
9
+ * with overridden base URLs, headers, compatibility settings). Falls back
10
+ * to the built-in registry for unconfigured models.
11
+ * For unqualified names (no provider prefix), known naming conventions
12
+ * are tried before assuming Anthropic.
13
+ */
14
+
15
+ import type { Model } from "@earendil-works/pi-ai";
16
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
17
+
18
+ type BuiltInModelResolver = (provider: string, id: string) => Model<any> | undefined;
19
+
20
+ interface ModelModule {
21
+ getModel: BuiltInModelResolver;
22
+ }
23
+
24
+ // Pi 0.80 exposes getModel from /compat; 0.79 exported it from the main entry.
25
+ const { getModel } = (await import("@earendil-works/pi-ai/compat").catch(
26
+ () => import("@earendil-works/pi-ai"),
27
+ )) as ModelModule;
28
+
29
+ export interface ResolvedModel {
30
+ model: Model<any> | null;
31
+ attempted: string[];
32
+ }
33
+
34
+ /** Known provider prefixes for unqualified model names. */
35
+ const KNOWN_PROVIDERS: [string, RegExp][] = [
36
+ ["openai", /^gpt-/i],
37
+ ["anthropic", /^claude-/i],
38
+ ["google", /^gemini-/i],
39
+ ["cohere", /^command-/i],
40
+ ["deepseek", /^(deepseek-|ds-)/i],
41
+ ["mistral", /^mistral-/i],
42
+ ["groq", /^(groq-|llama-)/i],
43
+ ];
44
+
45
+ function tryGetModel(
46
+ provider: string,
47
+ id: string,
48
+ modelRegistry?: ModelRegistry,
49
+ ): Model<any> | null {
50
+ // Query parent ModelRegistry first — it includes custom-configured models
51
+ // (overridden base URLs, headers, compatibility settings, per-model overrides).
52
+ // Fall back to built-in registry for unconfigured models.
53
+ if (modelRegistry) {
54
+ const found = modelRegistry.find(provider as any, id as any) ?? null;
55
+ if (found) return found;
56
+ }
57
+ const builtIn = getModel(provider as any, id as any) ?? null;
58
+ if (builtIn) return builtIn;
59
+ return null;
60
+ }
61
+
62
+ export function resolveModel(
63
+ modelName: string | undefined,
64
+ parentModel: Model<any> | undefined,
65
+ modelRegistry?: ModelRegistry,
66
+ ): ResolvedModel {
67
+ const attempted: string[] = [];
68
+ if (modelName) {
69
+ const idx = modelName.indexOf("/");
70
+ if (idx > 0) {
71
+ // Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
72
+ const provider = modelName.slice(0, idx);
73
+ const id = modelName.slice(idx + 1);
74
+ attempted.push(modelName);
75
+ const found = tryGetModel(provider, id, modelRegistry);
76
+ if (found) return { model: found, attempted };
77
+ } else {
78
+ // Unqualified: try known providers by naming convention
79
+ for (const [provider, pattern] of KNOWN_PROVIDERS) {
80
+ if (pattern.test(modelName)) {
81
+ attempted.push(`${provider}/${modelName}`);
82
+ const found = tryGetModel(provider, modelName, modelRegistry);
83
+ if (found) return { model: found, attempted };
84
+ }
85
+ }
86
+ // Fall back to Anthropic shorthand (backward compat)
87
+ attempted.push(`anthropic/${modelName}`);
88
+ const found = tryGetModel("anthropic", modelName, modelRegistry);
89
+ if (found) return { model: found, attempted };
90
+ }
91
+ } else if (parentModel) {
92
+ attempted.push(`${parentModel.provider}/${parentModel.id}`);
93
+ return { model: parentModel, attempted };
94
+ }
95
+ return { model: null, attempted };
96
+ }