@oh-my-pi/pi-coding-agent 16.3.10 → 16.3.12

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 (85) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/cli.js +3368 -3277
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-discovery.ts +18 -2
  32. package/src/config/model-registry.ts +9 -0
  33. package/src/config/settings-schema.ts +11 -1
  34. package/src/config/settings.ts +11 -0
  35. package/src/discovery/builtin.ts +2 -1
  36. package/src/discovery/claude-plugins.ts +167 -46
  37. package/src/discovery/helpers.ts +16 -1
  38. package/src/edit/renderer.ts +20 -6
  39. package/src/eval/js/worker-core.ts +163 -6
  40. package/src/exec/bash-executor.ts +14 -9
  41. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  42. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  43. package/src/extensibility/shared-events.ts +2 -2
  44. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  45. package/src/internal-urls/docs-index.generated.txt +1 -1
  46. package/src/internal-urls/registry-helpers.ts +9 -6
  47. package/src/modes/components/model-selector.ts +30 -6
  48. package/src/modes/components/settings-defs.ts +1 -1
  49. package/src/modes/components/status-line/component.ts +14 -2
  50. package/src/modes/controllers/command-controller.ts +13 -23
  51. package/src/modes/controllers/event-controller.ts +12 -12
  52. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  53. package/src/modes/controllers/input-controller.ts +18 -2
  54. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  55. package/src/modes/controllers/selector-controller.ts +16 -5
  56. package/src/modes/github-ref-autocomplete.ts +75 -0
  57. package/src/modes/interactive-mode.ts +54 -10
  58. package/src/modes/prompt-action-autocomplete.ts +35 -0
  59. package/src/modes/utils/context-usage.ts +58 -5
  60. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  61. package/src/modes/utils/ui-helpers.ts +2 -2
  62. package/src/modes/workflow.ts +14 -8
  63. package/src/prompts/system/plan-mode-active.md +5 -2
  64. package/src/prompts/system/system-prompt.md +1 -2
  65. package/src/prompts/system/title-system.md +10 -10
  66. package/src/prompts/system/workflow-notice.md +69 -50
  67. package/src/prompts/tools/bash.md +18 -7
  68. package/src/prompts/tools/grep.md +1 -1
  69. package/src/prompts/tools/read.md +4 -3
  70. package/src/sdk.ts +11 -0
  71. package/src/session/agent-session.ts +128 -14
  72. package/src/system-prompt.test.ts +34 -1
  73. package/src/system-prompt.ts +27 -10
  74. package/src/tools/bash-interactive.ts +1 -1
  75. package/src/tools/bash-skill-urls.ts +39 -7
  76. package/src/tools/bash.ts +69 -39
  77. package/src/tools/browser/launch.ts +31 -4
  78. package/src/tools/grep.ts +53 -14
  79. package/src/tools/index.ts +11 -0
  80. package/src/tools/path-utils.ts +46 -1
  81. package/src/tools/read.ts +108 -50
  82. package/src/utils/local-date.ts +7 -0
  83. package/src/utils/open.ts +36 -10
  84. package/src/utils/title-generator.ts +40 -65
  85. package/src/prompts/system/title-system-marker.md +0 -17
@@ -45,6 +45,17 @@ export interface AdvisorRuntimeHost {
45
45
  * one that routes `advise()` results back to the primary.
46
46
  */
47
47
  beginAdvisorUpdate?(): void;
48
+ /**
49
+ * Called with the error of every failed advisor turn, before the retry sleep
50
+ * or the dropped-after-3 path. Lets the host apply credential-level remedies
51
+ * the advisor loop lacks: the in-stream a/b/c auth retry rotates through
52
+ * sibling credentials within one request but never blocks the LAST failing
53
+ * one — the primary agent's retry pipeline does that via
54
+ * `markUsageLimitReached`, so without this hook the advisor re-picks the
55
+ * same usage-limited account on every retry. Errors thrown here are logged
56
+ * and swallowed.
57
+ */
58
+ onTurnError?(error: unknown): Promise<void> | void;
48
59
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
49
60
  notifyFailure?(error: unknown): void;
50
61
  }
@@ -189,6 +189,10 @@ export declare class ModelRegistry {
189
189
  * Check if a model selector is currently suppressed due to rate limits.
190
190
  */
191
191
  isSelectorSuppressed(selector: string): boolean;
192
+ /**
193
+ * Clear the cooldown suppression for one selector after an explicit user selection.
194
+ */
195
+ clearSuppressedSelector(selector: string): void;
192
196
  /**
193
197
  * Clear all cooldown suppressions recorded via {@link suppressSelector}.
194
198
  * Used to reset retry-fallback cooldown state without a full {@link refresh}.
@@ -1380,6 +1380,12 @@ export declare const SETTINGS_SCHEMA: {
1380
1380
  readonly "retry.fallbackChains": {
1381
1381
  readonly type: "record";
1382
1382
  readonly default: Record<string, string[]>;
1383
+ readonly ui: {
1384
+ readonly tab: "model";
1385
+ readonly group: "Retry & Fallback";
1386
+ readonly label: "Retry Fallback Chains";
1387
+ readonly description: 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.';
1388
+ };
1383
1389
  };
1384
1390
  readonly "retry.fallbackRevertPolicy": {
1385
1391
  readonly type: "enum";
@@ -152,6 +152,8 @@ export declare class Settings {
152
152
  * can register independently without overwriting each other.
153
153
  */
154
154
  export declare const onAppendOnlyModeChanged: (cb: (value: string) => void) => () => void;
155
+ /** Subscribe to model role changes. Returns an unsubscribe function. */
156
+ export declare const onModelRolesChanged: (cb: () => void) => () => void;
155
157
  /**
156
158
  * Subscribe to session-accent setting changes.
157
159
  * Returns an unsubscribe function. Callers should re-read settings in the callback.
@@ -122,6 +122,15 @@ export interface ScanSkillsFromDirOptions {
122
122
  providerId: string;
123
123
  level: "user" | "project";
124
124
  requireDescription?: boolean;
125
+ /**
126
+ * When true, treat a `SKILL.md` sitting directly under `dir` as a single skill in addition to
127
+ * scanning `<dir>/<name>/SKILL.md` children. Matches the Claude plugin manifest convention
128
+ * that lets a skill path point at a directory containing `SKILL.md` directly (e.g.
129
+ * `"skills": ["./"]`), where the frontmatter `name` determines the invocation name and the
130
+ * directory basename is the fallback. Default `false` preserves the strict child-scan
131
+ * semantic every non-Claude provider relies on.
132
+ */
133
+ includeSelf?: boolean;
125
134
  }
126
135
  export declare function compareSkillOrder(aName: string, aPath: string, bName: string, bPath: string): number;
127
136
  export declare function scanSkillsFromDir(_ctx: LoadContext, options: ScanSkillsFromDirOptions): Promise<LoadResult<Skill>>;
@@ -2,6 +2,7 @@ import { type MinimizerOptions } from "@oh-my-pi/pi-natives";
2
2
  import { type ShellMinimizerSettings } from "../config/settings.js";
3
3
  export interface BashExecutorOptions {
4
4
  cwd?: string;
5
+ /** Milliseconds before aborting the command; 0 disables the executor deadline. */
5
6
  timeout?: number;
6
7
  onChunk?: (chunk: string) => void;
7
8
  chunkThrottleMs?: number;
@@ -27,7 +27,7 @@ export interface SessionStartEvent {
27
27
  export interface SessionBeforeSwitchEvent {
28
28
  type: "session_before_switch";
29
29
  /** Reason for the switch */
30
- reason: "new" | "resume" | "fork";
30
+ reason: "new" | "resume" | "fork" | "handoff";
31
31
  /** Session file we're switching to (only for "resume") */
32
32
  targetSessionFile?: string;
33
33
  }
@@ -35,7 +35,7 @@ export interface SessionBeforeSwitchEvent {
35
35
  export interface SessionSwitchEvent {
36
36
  type: "session_switch";
37
37
  /** Reason for the switch */
38
- reason: "new" | "resume" | "fork";
38
+ reason: "new" | "resume" | "fork" | "handoff";
39
39
  /** Session file we came from */
40
40
  previousSessionFile: string | undefined;
41
41
  }
@@ -3,10 +3,12 @@ export declare function resetRegisteredArtifactDirsForTests(): void;
3
3
  /**
4
4
  * Snapshot of artifacts dirs for every registered session, deduped.
5
5
  *
6
- * Prefers `sessionManager.getArtifactsDir()` because subagents adopt their
7
- * parent's `ArtifactManager` and report the parent's dir there; dedup then
8
- * collapses parent + N subagents (the whole agent tree) to one entry. Falls
9
- * back to the raw session file (with the `.jsonl` suffix stripped) when no
10
- * live session reference is attached.
6
+ * Collects TWO candidate dirs per ref, because a subagent reads from its
7
+ * adopted (root-wide) `ArtifactManager.dir` but its own children are written
8
+ * one level deeper, under `sessionFile.slice(0, -6)` (`task/index.ts`). A
9
+ * depth-2+ subagent's output therefore lives in the write-time dir, not the
10
+ * adopted one, so `agent://` must scan both or it 404s a live nested peer.
11
+ * `addDir` dedup collapses the depth-0 case (both formulas agree) back to a
12
+ * single entry.
11
13
  */
12
14
  export declare function artifactsDirsFromRegistry(): string[];
@@ -7,7 +7,8 @@ interface ScopedModelItem {
7
7
  model: Model;
8
8
  thinkingLevel?: string;
9
9
  }
10
- type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ConfiguredThinkingLevel, selector?: string) => void;
10
+ type ModelSelectorAction = "modelRole" | "retryFallback";
11
+ type RoleSelectCallback = (model: Model, role: string | null, thinkingLevel?: ConfiguredThinkingLevel, selector?: string, action?: ModelSelectorAction) => void;
11
12
  /**
12
13
  * Component that renders a model selector with provider tabs and context menu.
13
14
  * - Tab/Arrow Left/Right: Switch between provider tabs
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
3
+ *
4
+ * Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
5
+ * token is rewritten to an internal URL (`pr://3164` or `issue://3164`) plus a
6
+ * trailing space, and the existing tool-mediated pipeline (the `read` tool →
7
+ * InternalUrlRouter → `gh`) resolves it from the session cwd's git remote.
8
+ *
9
+ * No network at suggestion time — candidates are generated locally. GitHub
10
+ * shares the issue/PR number space and there is no cheap way to tell which a
11
+ * given number is while typing, so both a PR and an Issue candidate are offered
12
+ * by default. Naming the type first (`pr #3164` / `issue #3164`) constrains the
13
+ * candidates to that kind. Anything that is not a standalone `#<number>` token
14
+ * keeps falling through to the existing prompt-action menu.
15
+ */
16
+ import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
17
+ export interface GithubRefContext {
18
+ /** Text to replace on accept: `#3164`, or `pr #3164` when a qualifier precedes it. */
19
+ prefix: string;
20
+ /** Type the user named (`pr`/`pull` → `pr`, `issue` → `issue`), or null to offer both. */
21
+ qualifier: "pr" | "issue" | null;
22
+ /** The numeric reference, e.g. `3164`. */
23
+ number: string;
24
+ }
25
+ export declare function getGithubRefContext(textBeforeCursor: string): GithubRefContext | null;
26
+ /**
27
+ * Suggestions for a `#<number>` token. Both kinds are offered unless the user
28
+ * named a type (`pr #3164` / `issue #3164`), in which case only that kind is
29
+ * offered. Returns `null` when the text before the cursor is not a standalone
30
+ * `#<number>` token.
31
+ */
32
+ export declare function getGithubRefSuggestions(textBeforeCursor: string): {
33
+ items: AutocompleteItem[];
34
+ prefix: string;
35
+ } | null;
@@ -24,18 +24,6 @@ export interface ContextBreakdown {
24
24
  }
25
25
  export declare function estimateSkillsTokens(skills: readonly Skill[]): number;
26
26
  export declare function estimateToolSchemaTokens(tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>): number;
27
- /**
28
- * Compute just the NON-MESSAGE token total: system prompt (with its skills
29
- * section subtracted, since skills are tokenized separately) + system context
30
- * (the rest of the system-prompt array) + tools + skills.
31
- *
32
- * Exposed so callers like `StatusLineComponent` can cache the non-message
33
- * total separately from the message total. Non-message inputs (skills,
34
- * tools, system prompt) change rarely; the message list grows on every
35
- * streaming turn. Splitting the two lets the caller refresh each on its own
36
- * cadence — non-message recomputed only when the inputs identity changes,
37
- * messages walked incrementally as new entries append.
38
- */
39
27
  export declare function computeNonMessageTokens(session: AgentSession): number;
40
28
  /**
41
29
  * Shared helper for the four non-message token totals used by
@@ -1,6 +1,10 @@
1
1
  import { type KeywordHighlighter } from "./gradient-highlight.js";
2
- /** Hidden system notice appended after a user message that mentions "workflowz". */
2
+ /** WORKFLOW_NOTICE is the default hidden notice for sessions with batched task calls enabled. */
3
3
  export declare const WORKFLOW_NOTICE: string;
4
+ /** renderWorkflowNotice renders the workflow notice for the active task schema. */
5
+ export declare function renderWorkflowNotice({ taskBatch }: {
6
+ taskBatch: boolean;
7
+ }): string;
4
8
  /**
5
9
  * Whether `text` contains the standalone keyword "workflowz"
6
10
  * (lowercase, whitespace-delimited) in prose — never inside a code block, inline
@@ -184,6 +184,8 @@ export interface AgentSessionConfig {
184
184
  toolRegistry?: Map<string, AgentTool>;
185
185
  /** Tool names whose current registry entry is still the built-in implementation. */
186
186
  builtInToolNames?: Iterable<string>;
187
+ /** Update tool-session predicates that render guidance from the live active tool set. */
188
+ setActiveToolNames?: (names: Iterable<string>) => void;
187
189
  /** Current session pre-LLM message transform pipeline */
188
190
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
189
191
  /**
@@ -224,6 +226,8 @@ export interface AgentSessionConfig {
224
226
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{
225
227
  systemPrompt: string[];
226
228
  }>;
229
+ /** Local calendar date provider used by prompt-cache invalidation. Defaults to the host local date. */
230
+ getLocalCalendarDate?: () => string;
227
231
  /** Rebuild the SSH tool from current capability discovery results. */
228
232
  reloadSshTool?: () => Promise<AgentTool | null>;
229
233
  requestedToolNames?: ReadonlySet<string>;
@@ -366,6 +370,7 @@ export interface HandoffResult {
366
370
  export interface SessionHandoffOptions {
367
371
  autoTriggered?: boolean;
368
372
  signal?: AbortSignal;
373
+ onSwitchCancelled?: () => void;
369
374
  }
370
375
  /** Result from cycleModel() */
371
376
  export interface ModelCycleResult {
@@ -35,7 +35,7 @@ export declare function loadProjectContextFiles(options?: LoadContextFilesOption
35
35
  * Project-level SYSTEM.md overrides user-level SYSTEM.md.
36
36
  */
37
37
  export declare function loadSystemPromptFiles(options?: LoadContextFilesOptions): Promise<string | null>;
38
- export declare const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES: readonly ["read", "bash", "eval", "edit", "write"];
38
+ export declare const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES: readonly ["read", "bash", "edit", "write"];
39
39
  export interface SystemPromptToolMetadata {
40
40
  label: string;
41
41
  description: string;
@@ -8,7 +8,7 @@ export interface BashInteractiveResult extends OutputSummary {
8
8
  export declare function runInteractiveBashPty(ui: NonNullable<AgentToolContext["ui"]>, options: {
9
9
  command: string;
10
10
  cwd: string;
11
- timeoutMs: number;
11
+ timeoutMs?: number;
12
12
  signal?: AbortSignal;
13
13
  env?: Record<string, string>;
14
14
  artifactPath?: string;
@@ -25,6 +25,7 @@ export declare function resolveSkillUrlToPath(url: string, skills: readonly Skil
25
25
  export declare function expandSkillUrls(command: string, skills: readonly Skill[]): string;
26
26
  /**
27
27
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
28
+ * Unresolvable URLs and literal mentions inside larger quoted text are left unchanged.
28
29
  * Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
29
30
  */
30
31
  export declare function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string>;
@@ -72,6 +72,7 @@ export interface BashToolDetails {
72
72
  meta?: OutputMeta;
73
73
  timeoutSeconds?: number;
74
74
  requestedTimeoutSeconds?: number;
75
+ timeoutDisabled?: boolean;
75
76
  wallTimeMs?: number;
76
77
  /** Exit code of a command that ran to completion but failed (non-zero). */
77
78
  exitCode?: number;
@@ -97,7 +98,7 @@ export declare class BashTool implements AgentTool<typeof bashSchemaBase | typeo
97
98
  readonly formatApprovalDetails: (args: unknown) => string[];
98
99
  readonly label = "Bash";
99
100
  readonly loadMode = "essential";
100
- readonly description: string;
101
+ get description(): string;
101
102
  readonly parameters: BashToolSchema;
102
103
  readonly concurrency: (args: Partial<BashToolInput>) => "shared" | "exclusive";
103
104
  readonly strict = true;
@@ -60,6 +60,7 @@ export declare function applyStealthPatches(browser: Browser, page: Page, state:
60
60
  browserSession: CDPSession | null;
61
61
  override: UserAgentOverride | null;
62
62
  }): Promise<void>;
63
+ export declare function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[];
63
64
  export declare function targetSupportsUserAgentOverrideForTest(target: Target): boolean;
64
65
  export declare function configureUserAgentTargetsForTest(browser: Browser, state: {
65
66
  browserSession: CDPSession | null;
@@ -8,6 +8,7 @@ import type { OutputMeta } from "./output-meta.js";
8
8
  declare const searchSchema: import("arktype/internal/variants/object.ts").ObjectType<{
9
9
  pattern: string;
10
10
  path?: string | undefined;
11
+ selector?: string | undefined;
11
12
  case?: boolean | undefined;
12
13
  gitignore?: boolean | undefined;
13
14
  skip?: number | null | undefined;
@@ -66,6 +67,7 @@ export declare class GrepTool implements AgentTool<typeof searchSchema, GrepTool
66
67
  readonly parameters: import("arktype/internal/variants/object.ts").ObjectType<{
67
68
  pattern: string;
68
69
  path?: string | undefined;
70
+ selector?: string | undefined;
69
71
  case?: boolean | undefined;
70
72
  gitignore?: boolean | undefined;
71
73
  skip?: number | null | undefined;
@@ -172,6 +172,10 @@ export interface ToolSession {
172
172
  getAgentId?: () => string | null;
173
173
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
174
174
  getToolByName?: (name: string) => AgentTool | undefined;
175
+ /** Return whether a built-in tool is active in this turn's tool set. */
176
+ isToolActive?: (name: string) => boolean;
177
+ /** Update the active built-in tool predicate when a session changes tools mid-run. */
178
+ setActiveToolNames?: (names: Iterable<string>) => void;
175
179
  /** Agent registry for IRC routing across live sessions. */
176
180
  agentRegistry?: AgentRegistry;
177
181
  /** Get artifacts directory for artifact:// URLs */
@@ -36,6 +36,30 @@ export declare function splitPathAndSel(rawPath: string): {
36
36
  path: string;
37
37
  sel?: string;
38
38
  };
39
+ /**
40
+ * Three-way probe for whether the exact filesystem entry named by `filePath`
41
+ * exists. `stat` (used earlier) failed for reasons other than "no such file"
42
+ * (dangling symlink, `EACCES` on a parent, transient I/O), and each of those
43
+ * silently reinterpreted a real literal path such as `test:1-2` as `test`
44
+ * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a
45
+ * dangling symlink is still detected as present; ambiguous errors resolve to
46
+ * `"unknown"` so callers keep the raw path instead of guessing.
47
+ */
48
+ export declare function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown">;
49
+ /**
50
+ * Async sibling of {@link splitPathAndSel} that prefers a literal filesystem
51
+ * path over selector interpretation. Filenames whose tail matches the selector
52
+ * grammar (e.g. `test:1-2`, `log:raw`) are legal on POSIX; without this the
53
+ * strict splitter peels the tail and both `read` and `grep` refuse to open the
54
+ * real file (issue #4618). The literal wins on a confirmed `lstat`, and also
55
+ * on `"unknown"` (`EACCES` on a parent, transient I/O), so an unreachable
56
+ * literal is never silently reinterpreted as `path + selector`. Only a
57
+ * definitive `ENOENT`/`ENOTDIR` falls back to the strict split.
58
+ */
59
+ export declare function splitPathAndSelPreferringLiteral(rawPath: string, cwd: string): Promise<{
60
+ path: string;
61
+ sel?: string;
62
+ }>;
39
63
  /**
40
64
  * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
41
65
  *
@@ -7,6 +7,7 @@ import { type TruncationResult } from "../session/streaming-output.js";
7
7
  import { type OutputMeta } from "./output-meta.js";
8
8
  declare const readSchema: import("arktype/internal/variants/object.ts").ObjectType<{
9
9
  path: string;
10
+ selector?: string | undefined;
10
11
  }, {}>;
11
12
  export type ReadToolInput = typeof readSchema.infer;
12
13
  export interface ReadToolDetails {
@@ -59,6 +60,7 @@ export declare class ReadTool implements AgentTool<typeof readSchema, ReadToolDe
59
60
  readonly description: string;
60
61
  readonly parameters: import("arktype/internal/variants/object.ts").ObjectType<{
61
62
  path: string;
63
+ selector?: string | undefined;
62
64
  }, {}>;
63
65
  readonly strict = true;
64
66
  constructor(session: ToolSession);
@@ -0,0 +1,2 @@
1
+ /** formatLocalCalendarDate formats a Date as YYYY-MM-DD in the host local timezone. */
2
+ export declare function formatLocalCalendarDate(date?: Date): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.3.10",
4
+ "version": "16.3.12",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.10",
60
- "@oh-my-pi/omp-stats": "16.3.10",
61
- "@oh-my-pi/pi-agent-core": "16.3.10",
62
- "@oh-my-pi/pi-ai": "16.3.10",
63
- "@oh-my-pi/pi-catalog": "16.3.10",
64
- "@oh-my-pi/pi-mnemopi": "16.3.10",
65
- "@oh-my-pi/pi-natives": "16.3.10",
66
- "@oh-my-pi/pi-tui": "16.3.10",
67
- "@oh-my-pi/pi-utils": "16.3.10",
68
- "@oh-my-pi/pi-wire": "16.3.10",
69
- "@oh-my-pi/snapcompact": "16.3.10",
59
+ "@oh-my-pi/hashline": "16.3.12",
60
+ "@oh-my-pi/omp-stats": "16.3.12",
61
+ "@oh-my-pi/pi-agent-core": "16.3.12",
62
+ "@oh-my-pi/pi-ai": "16.3.12",
63
+ "@oh-my-pi/pi-catalog": "16.3.12",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.12",
65
+ "@oh-my-pi/pi-natives": "16.3.12",
66
+ "@oh-my-pi/pi-tui": "16.3.12",
67
+ "@oh-my-pi/pi-utils": "16.3.12",
68
+ "@oh-my-pi/pi-wire": "16.3.12",
69
+ "@oh-my-pi/snapcompact": "16.3.12",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -1294,6 +1294,151 @@ describe("advisor", () => {
1294
1294
  expect(failures).toHaveLength(2);
1295
1295
  });
1296
1296
 
1297
+ it("calls onTurnError with state.error before retrying the batch", async () => {
1298
+ const promptInputs: string[] = [];
1299
+ const turnErrors: unknown[] = [];
1300
+ const events: string[] = [];
1301
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1302
+ let promptCalls = 0;
1303
+ const agent: AdvisorAgent = {
1304
+ prompt: async input => {
1305
+ promptCalls++;
1306
+ promptInputs.push(input);
1307
+ events.push(`prompt:${promptCalls}`);
1308
+ state.error = promptCalls === 1 ? "provider failed" : undefined;
1309
+ },
1310
+ abort: () => {},
1311
+ reset: () => {
1312
+ state.error = undefined;
1313
+ },
1314
+ state,
1315
+ };
1316
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1317
+ const host: AdvisorRuntimeHost = {
1318
+ snapshotMessages: () => messages,
1319
+ enqueueAdvice: () => {},
1320
+ onTurnError: error => {
1321
+ turnErrors.push(error);
1322
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1323
+ },
1324
+ };
1325
+ const runtime = new AdvisorRuntime(agent, host, 1);
1326
+
1327
+ runtime.onTurnEnd(messages);
1328
+ await runtime.waitForCatchup(1000, 1);
1329
+
1330
+ expect(promptInputs).toHaveLength(2);
1331
+ expect(turnErrors).toHaveLength(1);
1332
+ const error = turnErrors[0];
1333
+ if (!(error instanceof Error)) throw new Error("expected advisor turn error");
1334
+ expect(error.message).toBe("provider failed");
1335
+ expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
1336
+ expect(runtime.backlog).toBe(0);
1337
+ });
1338
+
1339
+ it("calls onTurnError for each consecutive failure including the dropped third turn", async () => {
1340
+ const promptInputs: string[] = [];
1341
+ const turnErrors: unknown[] = [];
1342
+ const failures: unknown[] = [];
1343
+ const events: string[] = [];
1344
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1345
+ let promptCalls = 0;
1346
+ const agent: AdvisorAgent = {
1347
+ prompt: async input => {
1348
+ promptCalls++;
1349
+ promptInputs.push(input);
1350
+ events.push(`prompt:${promptCalls}`);
1351
+ state.error = `provider failed ${promptCalls}`;
1352
+ },
1353
+ abort: () => {},
1354
+ reset: () => {
1355
+ state.error = undefined;
1356
+ },
1357
+ state,
1358
+ };
1359
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1360
+ const host: AdvisorRuntimeHost = {
1361
+ snapshotMessages: () => messages,
1362
+ enqueueAdvice: () => {},
1363
+ onTurnError: error => {
1364
+ turnErrors.push(error);
1365
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1366
+ },
1367
+ notifyFailure: error => {
1368
+ failures.push(error);
1369
+ events.push(`notify:${error instanceof Error ? error.message : String(error)}`);
1370
+ },
1371
+ };
1372
+ const runtime = new AdvisorRuntime(agent, host, 1);
1373
+
1374
+ runtime.onTurnEnd(messages);
1375
+ await runtime.waitForCatchup(1000, 1);
1376
+
1377
+ expect(promptInputs).toHaveLength(3);
1378
+ expect(turnErrors.map(error => (error instanceof Error ? error.message : String(error)))).toEqual([
1379
+ "provider failed 1",
1380
+ "provider failed 2",
1381
+ "provider failed 3",
1382
+ ]);
1383
+ expect(failures).toHaveLength(1);
1384
+ const failure = failures[0];
1385
+ if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1386
+ expect(failure.message).toBe("provider failed 3");
1387
+ expect(events).toEqual([
1388
+ "prompt:1",
1389
+ "hook:provider failed 1",
1390
+ "prompt:2",
1391
+ "hook:provider failed 2",
1392
+ "prompt:3",
1393
+ "hook:provider failed 3",
1394
+ "notify:provider failed 3",
1395
+ ]);
1396
+ expect(runtime.backlog).toBe(0);
1397
+ });
1398
+
1399
+ it("continues retrying when onTurnError rejects", async () => {
1400
+ const promptInputs: string[] = [];
1401
+ const turnErrors: unknown[] = [];
1402
+ const events: string[] = [];
1403
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1404
+ let promptCalls = 0;
1405
+ const agent: AdvisorAgent = {
1406
+ prompt: async input => {
1407
+ promptCalls++;
1408
+ promptInputs.push(input);
1409
+ events.push(`prompt:${promptCalls}`);
1410
+ state.error = promptCalls === 1 ? "provider failed" : undefined;
1411
+ },
1412
+ abort: () => {},
1413
+ reset: () => {
1414
+ state.error = undefined;
1415
+ },
1416
+ state,
1417
+ };
1418
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1419
+ const host: AdvisorRuntimeHost = {
1420
+ snapshotMessages: () => messages,
1421
+ enqueueAdvice: () => {},
1422
+ onTurnError: async error => {
1423
+ turnErrors.push(error);
1424
+ events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1425
+ throw new Error("hook failed");
1426
+ },
1427
+ };
1428
+ const runtime = new AdvisorRuntime(agent, host, 1);
1429
+
1430
+ runtime.onTurnEnd(messages);
1431
+ await runtime.waitForCatchup(1000, 1);
1432
+
1433
+ expect(promptInputs).toHaveLength(2);
1434
+ expect(turnErrors).toHaveLength(1);
1435
+ const error = turnErrors[0];
1436
+ if (!(error instanceof Error)) throw new Error("expected advisor turn error");
1437
+ expect(error.message).toBe("provider failed");
1438
+ expect(events).toEqual(["prompt:1", "hook:provider failed", "prompt:2"]);
1439
+ expect(runtime.backlog).toBe(0);
1440
+ });
1441
+
1297
1442
  it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
1298
1443
  // The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
1299
1444
  // assistant turn before `state.error` is read. Without rollback, the runtime's
@@ -48,6 +48,17 @@ export interface AdvisorRuntimeHost {
48
48
  * one that routes `advise()` results back to the primary.
49
49
  */
50
50
  beginAdvisorUpdate?(): void;
51
+ /**
52
+ * Called with the error of every failed advisor turn, before the retry sleep
53
+ * or the dropped-after-3 path. Lets the host apply credential-level remedies
54
+ * the advisor loop lacks: the in-stream a/b/c auth retry rotates through
55
+ * sibling credentials within one request but never blocks the LAST failing
56
+ * one — the primary agent's retry pipeline does that via
57
+ * `markUsageLimitReached`, so without this hook the advisor re-picks the
58
+ * same usage-limited account on every retry. Errors thrown here are logged
59
+ * and swallowed.
60
+ */
61
+ onTurnError?(error: unknown): Promise<void> | void;
51
62
  /** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
52
63
  notifyFailure?(error: unknown): void;
53
64
  }
@@ -352,6 +363,14 @@ export class AdvisorRuntime {
352
363
  if (this.#epoch !== epoch) continue;
353
364
  this.#rollbackFailedTurn(messageSnapshot);
354
365
  logger.debug("advisor turn failed", { err: String(err) });
366
+ try {
367
+ await this.host.onTurnError?.(err);
368
+ } catch (hookErr) {
369
+ logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
370
+ }
371
+ // The hook awaits; a reset during it invalidates this batch like the
372
+ // prompt await above — drop it instead of requeueing stale content.
373
+ if (this.#epoch !== epoch) continue;
355
374
  this.#consecutiveFailures++;
356
375
  if (this.#consecutiveFailures >= 3) {
357
376
  logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
@@ -49,7 +49,7 @@ export function createApiKeyResolver(
49
49
  options: ApiKeyResolverOptions = {},
50
50
  ): ApiKeyResolver {
51
51
  const { sessionId, baseUrl, modelId } = options;
52
- return async ({ lastChance, error, signal }) => {
52
+ return async ({ lastChance, error, signal, previousKey }) => {
53
53
  if (error === undefined) {
54
54
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
55
55
  }
@@ -59,7 +59,12 @@ export function createApiKeyResolver(
59
59
  // sibling exists we switch immediately; the precise no-sibling backoff
60
60
  // is owned by `markUsageLimitReached` (default + server usage-report
61
61
  // reset) and the outer whole-turn retry layer.
62
- await registry.authStorage.rotateSessionCredential(provider, sessionId, { error, modelId, signal });
62
+ await registry.authStorage.rotateSessionCredential(provider, sessionId, {
63
+ error,
64
+ modelId,
65
+ signal,
66
+ apiKey: previousKey,
67
+ });
63
68
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId });
64
69
  }
65
70
  return registry.getApiKeyForProvider(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });