@oh-my-pi/pi-coding-agent 16.0.0 → 16.0.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 (70) hide show
  1. package/CHANGELOG.md +140 -133
  2. package/dist/cli.js +250 -218
  3. package/dist/types/config/model-resolver.d.ts +14 -0
  4. package/dist/types/config/settings-schema.d.ts +22 -0
  5. package/dist/types/discovery/helpers.d.ts +7 -0
  6. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  7. package/dist/types/exec/non-interactive-env.d.ts +2 -0
  8. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  9. package/dist/types/modes/types.d.ts +5 -0
  10. package/dist/types/session/agent-session.d.ts +11 -1
  11. package/dist/types/session/messages.d.ts +3 -0
  12. package/dist/types/session/session-manager.d.ts +4 -1
  13. package/dist/types/task/index.d.ts +21 -0
  14. package/dist/types/tools/github-cache.d.ts +5 -4
  15. package/dist/types/tools/job.d.ts +1 -0
  16. package/dist/types/utils/markit.d.ts +8 -0
  17. package/dist/types/web/search/index.d.ts +2 -2
  18. package/dist/types/web/search/provider.d.ts +2 -0
  19. package/package.json +12 -12
  20. package/src/advisor/__tests__/advisor.test.ts +44 -0
  21. package/src/cli/args.ts +2 -0
  22. package/src/collab/host.ts +1 -1
  23. package/src/config/model-resolver.ts +35 -1
  24. package/src/config/settings-schema.ts +23 -1
  25. package/src/discovery/claude-plugins.ts +3 -42
  26. package/src/discovery/github.ts +189 -6
  27. package/src/discovery/helpers.ts +11 -0
  28. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  29. package/src/eval/js/shared/prelude.txt +12 -3
  30. package/src/eval/py/prelude.py +26 -2
  31. package/src/exec/bash-executor.ts +2 -2
  32. package/src/exec/non-interactive-env.ts +71 -0
  33. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  34. package/src/extensibility/extensions/runner.ts +17 -1
  35. package/src/extensibility/plugins/loader.ts +157 -23
  36. package/src/extensibility/plugins/manager.ts +44 -36
  37. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  38. package/src/extensibility/plugins/runtime-config.ts +9 -0
  39. package/src/internal-urls/docs-index.generated.ts +9 -9
  40. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  41. package/src/main.ts +5 -1
  42. package/src/modes/acp/acp-agent.ts +3 -3
  43. package/src/modes/components/settings-defs.ts +7 -0
  44. package/src/modes/components/tips.txt +1 -1
  45. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  46. package/src/modes/controllers/input-controller.ts +1 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +47 -0
  49. package/src/modes/rpc/rpc-mode.ts +3 -3
  50. package/src/modes/runtime-init.ts +2 -1
  51. package/src/modes/types.ts +5 -0
  52. package/src/prompts/agents/designer.md +8 -0
  53. package/src/prompts/review-request.md +1 -1
  54. package/src/prompts/system/subagent-system-prompt.md +4 -1
  55. package/src/prompts/tools/eval.md +13 -3
  56. package/src/prompts/tools/irc.md +1 -1
  57. package/src/sdk.ts +9 -1
  58. package/src/session/agent-session.ts +260 -50
  59. package/src/session/messages.ts +1 -1
  60. package/src/session/session-manager.ts +3 -1
  61. package/src/slash-commands/builtin-registry.ts +5 -2
  62. package/src/system-prompt.ts +7 -1
  63. package/src/task/executor.ts +105 -8
  64. package/src/task/index.ts +70 -9
  65. package/src/tools/github-cache.ts +32 -7
  66. package/src/tools/job.ts +14 -1
  67. package/src/utils/lang-from-path.ts +5 -0
  68. package/src/utils/markit.ts +24 -1
  69. package/src/web/search/index.ts +2 -2
  70. package/src/web/search/provider.ts +14 -2
@@ -435,7 +435,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
435
435
  const { verb, rest } = parseSubcommand(command.args);
436
436
  if (!verb || verb === "toggle") {
437
437
  const active = runtime.session.toggleAdvisorEnabled();
438
- const configured = runtime.session.settings.get("advisor.enabled") as boolean;
438
+ const configured = runtime.session.isAdvisorEnabled();
439
439
  if (active) {
440
440
  await runtime.output("Advisor enabled.");
441
441
  } else if (configured) {
@@ -473,7 +473,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
473
473
  const { verb, rest } = parseSubcommand(command.args);
474
474
  if (!verb || verb === "toggle") {
475
475
  const active = runtime.ctx.session.toggleAdvisorEnabled();
476
- const configured = runtime.ctx.session.settings.get("advisor.enabled") as boolean;
476
+ const configured = runtime.ctx.session.isAdvisorEnabled();
477
477
  if (active) {
478
478
  runtime.ctx.showStatus("Advisor enabled.");
479
479
  } else if (configured) {
@@ -481,6 +481,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
481
481
  } else {
482
482
  runtime.ctx.showStatus("Advisor disabled.");
483
483
  }
484
+ refreshStatusLine(runtime.ctx);
484
485
  runtime.ctx.editor.setText("");
485
486
  return;
486
487
  }
@@ -489,12 +490,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
489
490
  runtime.ctx.showStatus(
490
491
  active ? "Advisor enabled." : "Advisor setting enabled, but no model is assigned to the 'advisor' role.",
491
492
  );
493
+ refreshStatusLine(runtime.ctx);
492
494
  runtime.ctx.editor.setText("");
493
495
  return;
494
496
  }
495
497
  if (verb === "off") {
496
498
  runtime.ctx.session.setAdvisorEnabled(false);
497
499
  runtime.ctx.showStatus("Advisor disabled.");
500
+ refreshStatusLine(runtime.ctx);
498
501
  runtime.ctx.editor.setText("");
499
502
  return;
500
503
  }
@@ -615,7 +615,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
615
615
  resolvedCustomPrompt,
616
616
  resolvedAppendPrompt,
617
617
  ]);
618
- const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
618
+ const contextPromptSources = contextFiles.map(file => file.content);
619
+ const promptSources = [
620
+ effectiveSystemPromptCustomization,
621
+ resolvedCustomPrompt,
622
+ resolvedAppendPrompt,
623
+ ...contextPromptSources,
624
+ ];
619
625
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
620
626
 
621
627
  const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
@@ -7,11 +7,16 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import type { Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import type { Rule } from "../capability/rule";
13
13
  import { ModelRegistry } from "../config/model-registry";
14
- import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
14
+ import {
15
+ formatModelSelectorValue,
16
+ formatModelStringWithRouting,
17
+ resolveModelOverride,
18
+ resolveModelOverrideWithAuthFallback,
19
+ } from "../config/model-resolver";
15
20
  import type { PromptTemplate } from "../config/prompt-templates";
16
21
  import { Settings } from "../config/settings";
17
22
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
@@ -33,7 +38,7 @@ import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage
33
38
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
34
39
  import type { ArtifactManager } from "../session/artifacts";
35
40
  import type { AuthStorage } from "../session/auth-storage";
36
- import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
41
+ import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages";
37
42
  import { SessionManager } from "../session/session-manager";
38
43
  import { truncateTail } from "../session/streaming-output";
39
44
  import type { ContextFileEntry } from "../tools";
@@ -120,6 +125,74 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
120
125
  .filter(Boolean);
121
126
  }
122
127
 
128
+ const SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX = "subagent:";
129
+
130
+ interface SubagentRetryFallbackCandidate {
131
+ model: Model<Api>;
132
+ selector: string;
133
+ }
134
+
135
+ function resolveSubagentRetryFallbackCandidates(
136
+ modelPatterns: string[],
137
+ modelRegistry: ModelRegistry,
138
+ settings: Settings,
139
+ ): SubagentRetryFallbackCandidate[] {
140
+ const candidates: SubagentRetryFallbackCandidate[] = [];
141
+ const seen = new Set<string>();
142
+ for (const pattern of modelPatterns) {
143
+ const resolved = resolveModelOverride([pattern], modelRegistry, settings);
144
+ if (!resolved.model) continue;
145
+ const selector = resolved.explicitThinkingLevel
146
+ ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel)
147
+ : formatModelStringWithRouting(resolved.model);
148
+ if (seen.has(selector)) continue;
149
+ seen.add(selector);
150
+ candidates.push({ model: resolved.model, selector });
151
+ }
152
+ return candidates;
153
+ }
154
+
155
+ function installSubagentRetryFallbackChain(args: {
156
+ settings: Settings;
157
+ id: string;
158
+ candidates: SubagentRetryFallbackCandidate[];
159
+ model: Model<Api> | undefined;
160
+ authFallbackUsed: boolean;
161
+ }): string | undefined {
162
+ const { settings, id, candidates, model, authFallbackUsed } = args;
163
+ if (!model || authFallbackUsed || candidates.length <= 1) return undefined;
164
+
165
+ const selectedIndex = candidates.findIndex(
166
+ candidate => candidate.model.provider === model.provider && candidate.model.id === model.id,
167
+ );
168
+ if (selectedIndex < 0) return undefined;
169
+ const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector);
170
+ if (fallbackSelectors.length === 0) return undefined;
171
+
172
+ const role = `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`;
173
+ const modelRoles: Record<string, string> = {};
174
+ const existingRoles = settings.getModelRoles();
175
+ for (const existingRole in existingRoles) {
176
+ const selector = existingRoles[existingRole];
177
+ if (selector) {
178
+ modelRoles[existingRole] = selector;
179
+ }
180
+ }
181
+ modelRoles[role] = candidates[selectedIndex].selector;
182
+ settings.override("modelRoles", modelRoles);
183
+ const fallbackChains: Record<string, string[]> = {
184
+ [role]: fallbackSelectors,
185
+ };
186
+ const existingFallbackChains = settings.get("retry.fallbackChains");
187
+ for (const existingRole in existingFallbackChains) {
188
+ if (existingRole !== role) {
189
+ fallbackChains[existingRole] = existingFallbackChains[existingRole];
190
+ }
191
+ }
192
+ settings.override("retry.fallbackChains", fallbackChains);
193
+ return role;
194
+ }
195
+
123
196
  function renderIrcPeerRoster(selfId: string): string {
124
197
  const peers = AgentRegistry.global()
125
198
  .list()
@@ -1222,6 +1295,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1222
1295
  popLoopPhase();
1223
1296
  }
1224
1297
  }
1298
+ if (event.type === "retry_fallback_applied") {
1299
+ progress.resolvedModel = event.to;
1300
+ scheduleProgress(true);
1301
+ return;
1302
+ }
1303
+ if (event.type === "retry_fallback_succeeded") {
1304
+ progress.resolvedModel = event.model;
1305
+ scheduleProgress(true);
1306
+ return;
1307
+ }
1225
1308
  });
1226
1309
 
1227
1310
  const captureSalvage = (session: AgentSession): void => {
@@ -1817,21 +1900,35 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1817
1900
  resolvedModel: model.id,
1818
1901
  });
1819
1902
  }
1903
+ const retryFallbackRole = installSubagentRetryFallbackChain({
1904
+ settings: subagentSettings,
1905
+ id,
1906
+ candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, settings),
1907
+ model,
1908
+ authFallbackUsed,
1909
+ });
1910
+ if (retryFallbackRole) {
1911
+ logger.debug("Configured subagent runtime model fallback chain", {
1912
+ role: retryFallbackRole,
1913
+ requested: modelPatterns,
1914
+ });
1915
+ }
1820
1916
  if (model?.contextWindow && model.contextWindow > 0) {
1821
1917
  progress.contextWindow = model.contextWindow;
1822
1918
  }
1823
1919
  if (model) {
1824
1920
  progress.resolvedModel = explicitThinkingLevel
1825
- ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1826
- : `${model.provider}/${model.id}`;
1921
+ ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
1922
+ : formatModelStringWithRouting(model);
1827
1923
  }
1828
1924
  const effectiveThinkingLevel = explicitThinkingLevel
1829
1925
  ? resolvedThinkingLevel
1830
1926
  : (thinkingLevel ?? resolvedThinkingLevel);
1831
1927
 
1928
+ const effectiveCwd = worktree ?? cwd;
1832
1929
  const sessionManager = sessionFile
1833
- ? await awaitAbortable(SessionManager.open(sessionFile))
1834
- : SessionManager.inMemory(worktree ?? cwd);
1930
+ ? await awaitAbortable(SessionManager.open(sessionFile, undefined, undefined, { initialCwd: effectiveCwd }))
1931
+ : SessionManager.inMemory(effectiveCwd);
1835
1932
  if (options.parentArtifactManager) {
1836
1933
  sessionManager.adoptArtifactManager(options.parentArtifactManager);
1837
1934
  }
@@ -2047,7 +2144,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2047
2144
  {
2048
2145
  getModel: () => session.model,
2049
2146
  isIdle: () => !session.isStreaming,
2050
- abort: () => session.abort(),
2147
+ abort: () => session.abort({ reason: USER_INTERRUPT_LABEL }),
2051
2148
  hasPendingMessages: () => session.queuedMessageCount > 0,
2052
2149
  shutdown: () => {},
2053
2150
  getContextUsage: () => session.getContextUsage(),
package/src/task/index.ts CHANGED
@@ -366,6 +366,49 @@ export function buildSpecializationAdvisory(
366
366
  );
367
367
  }
368
368
 
369
+ /**
370
+ * Suggestion — never a rejection — nudging the spawner to coordinate via `irc`
371
+ * when one call creates ≥2 live siblings and it still holds spawn capacity.
372
+ * Returns undefined when there is nothing to coordinate or IRC is unavailable.
373
+ */
374
+ export function buildCoordinationAdvisory(
375
+ items: TaskItem[],
376
+ depthCapacity: boolean,
377
+ ircEnabled: boolean,
378
+ ): string | undefined {
379
+ if (!depthCapacity || !ircEnabled || items.length < 2) return undefined;
380
+ return (
381
+ `Coordinate: ${items.length} siblings are running together. If their work overlaps, have them ` +
382
+ `message each other via \`irc\` (by id, or "all" to broadcast) before editing shared files — ` +
383
+ `live coordination beats a serial handoff. Check \`irc\` op:"list" to see who is doing what.`
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Compose the non-blocking advisory appended to a `task` result: the
389
+ * specialization nudge, plus — only when the siblings keep running after this
390
+ * call (`willRunAsync`) — the coordination suggestion. Coordination is gated on
391
+ * async because a sync fanout's siblings have already finished, so a
392
+ * "coordinate while they run" hint would misfire. Returns undefined when
393
+ * neither applies.
394
+ */
395
+ export function composeSpawnAdvisory(args: {
396
+ agentName: string | undefined;
397
+ items: TaskItem[];
398
+ depthCapacity: boolean;
399
+ ircEnabled: boolean;
400
+ willRunAsync: boolean;
401
+ }): string | undefined {
402
+ return (
403
+ [
404
+ buildSpecializationAdvisory(args.agentName, args.items, args.depthCapacity),
405
+ args.willRunAsync ? buildCoordinationAdvisory(args.items, args.depthCapacity, args.ircEnabled) : undefined,
406
+ ]
407
+ .filter(Boolean)
408
+ .join("\n\n") || undefined
409
+ );
410
+ }
411
+
369
412
  /** Sentinel for async jobs whose subagent finished with a failing result; progress is already updated. */
370
413
  class TaskJobError extends Error {}
371
414
 
@@ -539,16 +582,35 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
539
582
  this.session.settings.get("task.maxRecursionDepth") ?? 2,
540
583
  this.session.taskDepth ?? 0,
541
584
  );
542
- const advisory = buildSpecializationAdvisory(params.agent, spawnItems, depthCapacity);
585
+ const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
586
+ // Coordination only makes sense when the siblings keep running after this
587
+ // call returns (async). In the sync fallback they have already completed,
588
+ // so a "coordinate while they run" hint would misfire.
589
+ const willRunAsync = !!manager && selectedAgent?.blocking !== true;
590
+ const advisory = this.session.suppressSpawnAdvisory
591
+ ? undefined
592
+ : composeSpawnAdvisory({
593
+ agentName: params.agent,
594
+ items: spawnItems,
595
+ depthCapacity,
596
+ ircEnabled,
597
+ willRunAsync,
598
+ });
599
+ // Returns a fresh result (copied content array, copied text part) rather
600
+ // than mutating the caller's — task results are short-lived here, but an
601
+ // in-place edit on a shared/cached AgentToolResult would be a hidden trap.
543
602
  const withAdvisory = (result: AgentToolResult<TaskToolDetails>): AgentToolResult<TaskToolDetails> => {
544
603
  if (!advisory) return result;
545
- const textPart = result.content.find(part => part.type === "text");
546
- if (textPart && typeof textPart.text === "string") {
547
- textPart.text = `${textPart.text}\n\n${advisory}`;
548
- } else {
549
- result.content.push({ type: "text", text: advisory });
550
- }
551
- return result;
604
+ let appended = false;
605
+ const content = result.content.map(part => {
606
+ if (!appended && part.type === "text" && typeof part.text === "string") {
607
+ appended = true;
608
+ return { ...part, text: `${part.text}\n\n${advisory}` };
609
+ }
610
+ return part;
611
+ });
612
+ if (!appended) content.push({ type: "text", text: advisory });
613
+ return { ...result, content };
552
614
  };
553
615
  if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
554
616
  // Sync fallback: async execution disabled, orphaned host that never
@@ -614,7 +676,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
614
676
  },
615
677
  });
616
678
 
617
- const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
618
679
  const started: Array<{ agentId: string; jobId: string; description?: string }> = [];
619
680
  const failedSchedules: string[] = [];
620
681
  for (const spawn of spawns) {
@@ -8,10 +8,11 @@
8
8
  * helpers swallow open/IO failures and degrade to "no cache" so a corrupt or
9
9
  * unreadable DB never blocks a `gh` call.
10
10
  *
11
- * TTL:
12
11
  * Soft TTL → return cached row directly.
13
- * Past soft TTL but within hard TTL → return cached row AND schedule a
14
- * background refresh (errors logged, never thrown).
12
+ * Stateful issue/PR rows past soft TTL but within hard TTL → refresh
13
+ * synchronously, falling back to the cached row if the live fetch fails.
14
+ * Expensive PR diff rows past soft TTL but within hard TTL → return cached
15
+ * row AND schedule a background refresh (errors logged, never thrown).
15
16
  * Past hard TTL → treat as miss and fetch fresh.
16
17
  */
17
18
 
@@ -21,6 +22,7 @@ import * as os from "node:os";
21
22
  import * as path from "node:path";
22
23
  import { getGithubCacheDbPath, logger } from "@oh-my-pi/pi-utils";
23
24
  import type { Settings } from "../config/settings";
25
+ import { ToolAbortError } from "./tool-errors";
24
26
 
25
27
  // ────────────────────────────────────────────────────────────────────────────
26
28
  // Storage layer
@@ -449,7 +451,7 @@ export interface CacheLookupOptions<T> {
449
451
  now?: number;
450
452
  }
451
453
 
452
- export type CacheStatus = "miss" | "fresh" | "stale" | "disabled";
454
+ export type CacheStatus = "miss" | "fresh" | "refreshed" | "stale" | "disabled";
453
455
 
454
456
  export interface CacheLookupResult<T> {
455
457
  rendered: string;
@@ -595,7 +597,7 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
595
597
  status: "fresh",
596
598
  fetchedAt: cached.fetchedAt,
597
599
  };
598
- } else {
600
+ } else if (options.kind === "pr-diff") {
599
601
  scheduleBackgroundRefresh(
600
602
  authKey,
601
603
  options.repo,
@@ -611,6 +613,28 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
611
613
  status: "stale",
612
614
  fetchedAt: cached.fetchedAt,
613
615
  };
616
+ } else {
617
+ try {
618
+ const fresh = await options.fetchFresh();
619
+ const fetchedAt = Date.now();
620
+ storeResult(authKey, options.repo, options.kind, options.number, options.includeComments, fresh, fetchedAt);
621
+ return { ...fresh, status: "refreshed", fetchedAt };
622
+ } catch (err) {
623
+ if (err instanceof ToolAbortError) throw err;
624
+ logger.debug("github cache: synchronous refresh failed; returning stale view", {
625
+ err: String(err),
626
+ repo: options.repo,
627
+ kind: options.kind,
628
+ number: options.number,
629
+ });
630
+ return {
631
+ rendered: cached.rendered,
632
+ sourceUrl: cached.sourceUrl,
633
+ payload: cached.payload,
634
+ status: "stale",
635
+ fetchedAt: cached.fetchedAt,
636
+ };
637
+ }
614
638
  }
615
639
  }
616
640
 
@@ -624,7 +648,7 @@ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise
624
648
  * Human-friendly freshness note for protocol-handler `notes[]` rendering.
625
649
  */
626
650
  export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, now: number = Date.now()): string {
627
- if (status === "miss") return "Fetched live";
651
+ if (status === "miss" || status === "refreshed") return "Fetched live";
628
652
  if (status === "disabled") return "Cache disabled; fetched live";
629
653
  const ageSec = Math.max(0, Math.round((now - fetchedAtMs) / 1000));
630
654
  const human =
@@ -633,6 +657,7 @@ export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, no
633
657
  : ageSec < 3600
634
658
  ? `${Math.round(ageSec / 60)}m ago`
635
659
  : `${Math.round(ageSec / 3600)}h ago`;
636
- if (status === "stale") return `Cached: ${human} (refreshing in background)`;
660
+ if (status === "stale")
661
+ return `WARNING: showing cached content from ${human}; live refresh failed or is still running`;
637
662
  return `Cached: ${human}`;
638
663
  }
package/src/tools/job.ts CHANGED
@@ -372,6 +372,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
372
372
  interface JobRenderArgs {
373
373
  poll?: string[];
374
374
  cancel?: string[];
375
+ list?: boolean;
375
376
  }
376
377
 
377
378
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
@@ -433,6 +434,7 @@ function flattenStructuredPreview(text: string): string {
433
434
  }
434
435
 
435
436
  function describeTarget(args: JobRenderArgs | undefined): string {
437
+ if (args?.list) return "background jobs";
436
438
  const poll = args?.poll ?? [];
437
439
  const cancel = args?.cancel ?? [];
438
440
  const parts: string[] = [];
@@ -460,7 +462,7 @@ export const jobToolRenderer = {
460
462
  uiTheme: Theme,
461
463
  args?: JobRenderArgs,
462
464
  ): Component {
463
- const jobs = result.details?.jobs ?? [];
465
+ let jobs = result.details?.jobs ?? [];
464
466
 
465
467
  if (jobs.length === 0) {
466
468
  const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
@@ -468,6 +470,17 @@ export const jobToolRenderer = {
468
470
  return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
469
471
  }
470
472
 
473
+ const isPollCall = args
474
+ ? !args.list && (!args.cancel || args.cancel.length === 0 || args.poll !== undefined)
475
+ : true;
476
+
477
+ if (!options.isPartial && isPollCall) {
478
+ jobs = jobs.filter(job => job.status !== "running");
479
+ if (jobs.length === 0) {
480
+ return new Text("", 0, 0);
481
+ }
482
+ }
483
+
471
484
  const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
472
485
  for (const job of jobs) counts[job.status]++;
473
486
 
@@ -66,6 +66,7 @@ const EXTENSION_LANG: Record<string, readonly [string, string]> = {
66
66
  cljc: ["clojure", "clojure"],
67
67
  cljs: ["clojure", "clojure"],
68
68
  edn: ["clojure", "clojure"],
69
+ el: ["emacs-lisp", "emacs-lisp"],
69
70
 
70
71
  // .NET
71
72
  cs: ["csharp", "csharp"],
@@ -209,6 +210,7 @@ export function getLanguageFromPath(filePath: string): string | undefined {
209
210
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
210
211
  return "dockerfile";
211
212
  }
213
+ if (baseName === ".emacs") return "emacs-lisp";
212
214
  if (baseName === "justfile") return "just";
213
215
  if (baseName === "cmakelists.txt") return "cmake";
214
216
 
@@ -223,6 +225,9 @@ export function detectLanguageId(filePath: string): string {
223
225
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
224
226
  return "dockerfile";
225
227
  }
228
+ if (baseName === ".emacs") {
229
+ return "emacs-lisp";
230
+ }
226
231
  if (baseName === "makefile" || baseName === "gnumakefile") {
227
232
  return "makefile";
228
233
  }
@@ -1,4 +1,4 @@
1
- import { untilAborted } from "@oh-my-pi/pi-utils";
1
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
2
2
  import type { Markit, StreamInfo } from "markit-ai";
3
3
  import { ToolAbortError } from "../tools/tool-errors";
4
4
 
@@ -8,6 +8,29 @@ export interface MarkitConversionResult {
8
8
  error?: string;
9
9
  }
10
10
 
11
+ interface MuPdfWasmModuleConfig {
12
+ print?: (...values: unknown[]) => void;
13
+ printErr?: (...values: unknown[]) => void;
14
+ }
15
+
16
+ declare global {
17
+ var $libmupdf_wasm_Module: MuPdfWasmModuleConfig | undefined;
18
+ }
19
+
20
+ function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void {
21
+ const message = values.length === 1 && typeof values[0] === "string" ? values[0] : values.map(String).join(" ");
22
+ logger.debug("mupdf wasm output", { stream, message });
23
+ }
24
+
25
+ function installMuPdfWasmLogger(): void {
26
+ const moduleConfig = globalThis.$libmupdf_wasm_Module ?? {};
27
+ moduleConfig.print = (...values) => logMuPdfWasmOutput("stdout", values);
28
+ moduleConfig.printErr = (...values) => logMuPdfWasmOutput("stderr", values);
29
+ globalThis.$libmupdf_wasm_Module = moduleConfig;
30
+ }
31
+
32
+ installMuPdfWasmLogger();
33
+
11
34
  let markit: () => Markit | Promise<Markit> = async () => {
12
35
  const promise = import("markit-ai").then(({ Markit }) => {
13
36
  const instance = new Markit();
@@ -300,6 +300,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
300
300
  return [webSearchCustomTool];
301
301
  }
302
302
 
303
- export { getSearchProvider, setPreferredSearchProvider } from "./provider";
303
+ export { getSearchProvider, setExcludedSearchProviders, setPreferredSearchProvider } from "./provider";
304
304
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
305
- export { isSearchProviderPreference } from "./types";
305
+ export { isSearchProviderId, isSearchProviderPreference } from "./types";
@@ -127,6 +127,18 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
127
127
  preferredProvId = provider;
128
128
  }
129
129
 
130
+ /** Providers excluded from web search resolution via settings. */
131
+ let excludedProvIds = new Set<SearchProviderId>();
132
+
133
+ /** Set providers that web search should never use, including fallbacks. */
134
+ export function setExcludedSearchProviders(providers: readonly SearchProviderId[]): void {
135
+ excludedProvIds = new Set(providers);
136
+ }
137
+
138
+ function isSearchProviderExcluded(id: SearchProviderId): boolean {
139
+ return excludedProvIds.has(id);
140
+ }
141
+
130
142
  /**
131
143
  * Determine which providers are configured and currently available.
132
144
  * Each candidate is loaded (and its `isAvailable()` called) only as the chain
@@ -138,7 +150,7 @@ export async function resolveProviderChain(
138
150
  ): Promise<SearchProvider[]> {
139
151
  const providers: SearchProvider[] = [];
140
152
 
141
- if (preferredProvider !== "auto") {
153
+ if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
142
154
  const provider = await getSearchProvider(preferredProvider);
143
155
  if (await provider.isExplicitlyAvailable(authStorage)) {
144
156
  providers.push(provider);
@@ -146,7 +158,7 @@ export async function resolveProviderChain(
146
158
  }
147
159
 
148
160
  for (const id of SEARCH_PROVIDER_ORDER) {
149
- if (id === preferredProvider) continue;
161
+ if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
150
162
  const provider = await getSearchProvider(id);
151
163
  if (await provider.isAvailable(authStorage)) {
152
164
  providers.push(provider);