@oh-my-pi/pi-coding-agent 15.1.9 → 15.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 (64) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/types/config/settings-schema.d.ts +10 -0
  3. package/dist/types/eval/py/kernel.d.ts +6 -0
  4. package/dist/types/goals/state.d.ts +1 -1
  5. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  6. package/dist/types/hashline/parser.d.ts +6 -2
  7. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  8. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  9. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  10. package/dist/types/tools/ast-edit.d.ts +3 -0
  11. package/dist/types/tools/ast-grep.d.ts +3 -0
  12. package/dist/types/tools/browser/launch.d.ts +2 -0
  13. package/dist/types/tools/find.d.ts +3 -0
  14. package/dist/types/tools/search.d.ts +3 -0
  15. package/dist/types/tui/file-list.d.ts +6 -0
  16. package/dist/types/tui/hyperlink.d.ts +42 -0
  17. package/dist/types/tui/index.d.ts +1 -0
  18. package/dist/types/web/search/providers/utils.d.ts +2 -1
  19. package/package.json +7 -7
  20. package/src/config/settings-schema.ts +12 -0
  21. package/src/config/settings.ts +28 -5
  22. package/src/discovery/builtin.ts +30 -0
  23. package/src/edit/renderer.ts +5 -3
  24. package/src/eval/py/executor.ts +12 -1
  25. package/src/eval/py/kernel.ts +24 -8
  26. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  27. package/src/goals/runtime.ts +9 -3
  28. package/src/goals/state.ts +1 -1
  29. package/src/goals/tools/goal-tool.ts +12 -2
  30. package/src/hashline/diff.ts +1 -1
  31. package/src/hashline/execute.ts +2 -2
  32. package/src/hashline/parser.ts +87 -12
  33. package/src/internal-urls/memory-protocol.ts +1 -1
  34. package/src/modes/interactive-mode.ts +29 -1
  35. package/src/modes/theme/shimmer.ts +79 -0
  36. package/src/prompts/tools/goal.md +7 -2
  37. package/src/session/agent-session.ts +18 -75
  38. package/src/slash-commands/helpers/format.ts +23 -3
  39. package/src/task/executor.ts +115 -19
  40. package/src/tools/ast-edit.ts +39 -6
  41. package/src/tools/ast-grep.ts +38 -6
  42. package/src/tools/browser/launch.ts +63 -51
  43. package/src/tools/find.ts +13 -2
  44. package/src/tools/read.ts +46 -6
  45. package/src/tools/search.ts +447 -265
  46. package/src/tui/file-list.ts +10 -2
  47. package/src/tui/hyperlink.ts +126 -0
  48. package/src/tui/index.ts +1 -0
  49. package/src/web/search/index.ts +13 -9
  50. package/src/web/search/providers/anthropic.ts +3 -1
  51. package/src/web/search/providers/brave.ts +3 -1
  52. package/src/web/search/providers/codex.ts +3 -1
  53. package/src/web/search/providers/exa.ts +3 -1
  54. package/src/web/search/providers/gemini.ts +3 -1
  55. package/src/web/search/providers/jina.ts +3 -1
  56. package/src/web/search/providers/kagi.ts +5 -1
  57. package/src/web/search/providers/kimi.ts +3 -1
  58. package/src/web/search/providers/parallel.ts +5 -1
  59. package/src/web/search/providers/perplexity.ts +5 -1
  60. package/src/web/search/providers/searxng.ts +3 -1
  61. package/src/web/search/providers/synthetic.ts +3 -1
  62. package/src/web/search/providers/tavily.ts +3 -1
  63. package/src/web/search/providers/utils.ts +33 -1
  64. package/src/web/search/providers/zai.ts +3 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.2.2] - 2026-05-22
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `RULES.md` not being injected. The documented sticky-rules file at `~/.omp/agent/RULES.md` and `<repo>/.omp/RULES.md` was never read by any discovery provider; only `.omp/rules/*.md` was scanned. The native provider now loads both as always-apply rules so they re-attach every turn as documented ([#1266](https://github.com/can1357/oh-my-pi/issues/1266)).
10
+
11
+ ## [15.2.1] - 2026-05-21
12
+
13
+ ### Fixed
14
+
15
+ - Fixed compaction routing to the wrong provider when `modelRoles.default` is set to a different model than the active chat. Auto- and manual compaction now prefer the active session's model and only fall back to role-based candidates when the current model has no usable credentials. Previously, an Anthropic chat with `modelRoles.default = openai/gpt-5` would compact through OpenAI (including the remote-compaction endpoint), even though the live conversation never used OpenAI.
16
+
17
+ ### Fixed
18
+
19
+ - Fixed context overflow not being detected when the session's model (e.g. `hf:zai-org/GLM-5.1` via `synthetic` provider) returns a 400 with the upstream "400 status code (no body)" message wrapped inside a JSON envelope. The `isContextOverflow` check now matches the no-body status phrase anywhere in the error string rather than requiring it at the very start, so auto-compaction fires correctly instead of leaving the session silently stuck ([#1251](https://github.com/can1357/oh-my-pi/issues/1251)).
20
+ - Fixed `formatCapturedHttpError` not extracting the error text from `{"error":"string"}` response bodies (only object-valued `error` fields were previously handled), resulting in raw JSON in error messages instead of the human-readable string.
21
+
22
+ ## [15.2.0] - 2026-05-21
23
+
24
+ ### Changed
25
+
26
+ - Changed the interactive working loader and slash-command progress bars to use the active theme's accent shimmer instead of static muted text.
27
+
28
+ ### Fixed
29
+
30
+ - Fixed false-positive hashline `~ TEXT` separator-padding warning firing on YAML, JSON, Python, Markdown, TOML, and other indent-sensitive file edits. The padding check is now skipped entirely for indentation-sensitive extensions (`.py`, `.yml`/`.yaml`, `.md`/`.mdx`, `.json`/`.jsonc`/`.json5`, `.toml`, `.rst`, `.tf`, `.nix`, `.coffee`, `.haml`/`.slim`/`.pug`, `.sass`/`.styl`, `.nim`, `.cr`, `.elm`, `.fs`, …), and tightened on every other extension to flag only the `~ beta` typo shape (exactly one leading space before non-space content) rather than any leading-space payload.
31
+
32
+ ### Fixed
33
+
34
+ - Fixed goal state machine: `get` now returns paused goals (was returning null for `enabled=false`), `complete` now works on paused goals after interrupts, `create` is allowed after a previous goal reaches `complete` status, and the goal tool is re-added to the active tool set on session reload when a paused goal is persisted. Added `resume` and `drop` ops to the goal tool so the agent can re-engage or discard a paused goal without requiring user slash commands. ([#1249](https://github.com/can1357/oh-my-pi/issues/1249))
35
+
5
36
  ## [15.1.9] - 2026-05-21
6
37
 
7
38
  ### Fixed
@@ -24,6 +55,10 @@
24
55
 
25
56
  - Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221))
26
57
 
58
+ ### Added
59
+
60
+ - Added OSC 8 terminal hyperlink support for file paths in tool output. When the terminal supports hyperlinks (kitty, Ghostty, WezTerm, iTerm2, Alacritty, VS Code) and the new `tui.hyperlinks` setting is `auto` (default) or `always`, OMP wraps file paths emitted by `read`, `find`, `search`, `edit`, `ast_grep`, and `ast_edit` renderers in `file:///abs/path` hyperlinks. `local://` and other fs-backed internal URLs resolve to their backing path. Set `tui.hyperlinks: off` to disable. ([#1244](https://github.com/can1357/oh-my-pi/issues/1244))
61
+
27
62
  ## [15.1.8] - 2026-05-20
28
63
 
29
64
  ### Fixed
@@ -8476,4 +8511,4 @@ Initial public release.
8476
8511
  - Git branch display in footer
8477
8512
  - Message queueing during streaming responses
8478
8513
  - OAuth integration for Gmail and Google Calendar access
8479
- - HTML export with syntax highlighting and collapsible sections
8514
+ - HTML export with syntax highlighting and collapsible sections
@@ -634,6 +634,16 @@ export declare const SETTINGS_SCHEMA: {
634
634
  readonly default: 20;
635
635
  readonly description: "Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).";
636
636
  };
637
+ readonly "tui.hyperlinks": {
638
+ readonly type: "enum";
639
+ readonly values: readonly ["off", "auto", "always"];
640
+ readonly default: "auto";
641
+ readonly ui: {
642
+ readonly tab: "appearance";
643
+ readonly label: "Terminal Hyperlinks";
644
+ readonly description: "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)";
645
+ };
646
+ };
637
647
  readonly "display.tabWidth": {
638
648
  readonly type: "number";
639
649
  readonly default: 3;
@@ -21,6 +21,12 @@ export interface KernelExecuteResult {
21
21
  cancelled: boolean;
22
22
  timedOut: boolean;
23
23
  stdinRequested: boolean;
24
+ /**
25
+ * True when the kernel subprocess was killed as part of settling this
26
+ * execution (e.g. SIGINT was ignored and we escalated to shutdown, or the
27
+ * kernel died unexpectedly). When false, the kernel remains reusable.
28
+ */
29
+ kernelKilled?: boolean;
24
30
  }
25
31
  export interface KernelShutdownResult {
26
32
  confirmed: boolean;
@@ -17,7 +17,7 @@ export interface GoalModeState {
17
17
  goal: Goal;
18
18
  }
19
19
  export interface GoalToolDetails {
20
- op: "create" | "get" | "complete";
20
+ op: "create" | "get" | "complete" | "resume" | "drop";
21
21
  goal?: Goal | null;
22
22
  remainingTokens?: number | null;
23
23
  completionBudgetReport?: string | null;
@@ -9,7 +9,9 @@ declare const goalSchema: z.ZodObject<{
9
9
  op: z.ZodEnum<{
10
10
  complete: "complete";
11
11
  create: "create";
12
+ drop: "drop";
12
13
  get: "get";
14
+ resume: "resume";
13
15
  }>;
14
16
  objective: z.ZodOptional<z.ZodString>;
15
17
  token_budget: z.ZodOptional<z.ZodNumber>;
@@ -32,7 +34,9 @@ export declare class GoalTool implements AgentTool<typeof goalSchema, GoalToolDe
32
34
  op: z.ZodEnum<{
33
35
  complete: "complete";
34
36
  create: "create";
37
+ drop: "drop";
35
38
  get: "get";
39
+ resume: "resume";
36
40
  }>;
37
41
  objective: z.ZodOptional<z.ZodString>;
38
42
  token_budget: z.ZodOptional<z.ZodNumber>;
@@ -1,7 +1,11 @@
1
1
  import type { HashlineCursor, HashlineEdit } from "./types";
2
2
  export declare function cloneCursor(cursor: HashlineCursor): HashlineCursor;
3
- export declare function parseHashline(diff: string): HashlineEdit[];
4
- export declare function parseHashlineWithWarnings(diff: string): {
3
+ export declare function parseHashline(diff: string, opts?: ParseHashlineOptions): HashlineEdit[];
4
+ export interface ParseHashlineOptions {
5
+ /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
6
+ path?: string;
7
+ }
8
+ export declare function parseHashlineWithWarnings(diff: string, opts?: ParseHashlineOptions): {
5
9
  edits: HashlineEdit[];
6
10
  warnings: string[];
7
11
  };
@@ -1,4 +1,10 @@
1
1
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
2
+ /**
3
+ * Snapshot of memory roots for every registered session, deduped.
4
+ * Each session has its own cwd (possibly a worktree), so subagents and main
5
+ * may see different roots.
6
+ */
7
+ export declare function memoryRootsFromRegistry(): string[];
2
8
  /**
3
9
  * Resolve a memory:// URL to an absolute filesystem path under memory root.
4
10
  */
@@ -0,0 +1,27 @@
1
+ import type { Theme, ThemeColor } from "./theme";
2
+ type ShimmerTheme = Pick<Theme, "bold" | "fg">;
3
+ /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
4
+ export interface ShimmerPalette {
5
+ /** Color for chars outside / at the edge of the band (intensity < 0.2). */
6
+ low: ThemeColor;
7
+ /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
8
+ mid: ThemeColor;
9
+ /** Color at the band's crest (intensity >= 0.6). */
10
+ high: ThemeColor;
11
+ /** Whether to bold the crest tier. Default `false`. */
12
+ bold?: boolean;
13
+ }
14
+ /** One run of text that shares a palette inside a larger shimmer sweep. */
15
+ export interface ShimmerSegment {
16
+ text: string;
17
+ palette?: ShimmerPalette;
18
+ }
19
+ export declare const DEFAULT_SHIMMER_PALETTE: ShimmerPalette;
20
+ /**
21
+ * Apply a shimmer sweep across one or more segments, treating them as a single
22
+ * continuous string for band positioning. Each segment can supply its own
23
+ * palette so the gradient stays in lockstep while the colors differ.
24
+ */
25
+ export declare function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string;
26
+ export declare function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string;
27
+ export {};
@@ -1,7 +1,10 @@
1
+ import { type Theme } from "../../modes/theme/theme";
1
2
  /** Format a millisecond duration as a coarse-grained human label. */
2
3
  export declare function formatDuration(ms: number): string;
4
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
3
5
  /**
4
6
  * Render an ASCII progress bar with a trailing percent label.
5
7
  * `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
6
8
  */
7
- export declare function renderAsciiBar(fraction: number | undefined, width?: number): string;
9
+ export declare function renderAsciiBar(fraction: number | undefined, width?: number, uiTheme?: ProgressBarTheme): string;
10
+ export {};
@@ -31,6 +31,9 @@ export interface AstEditToolDetails {
31
31
  /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
32
32
  * a `│` gutter (no model-only hashline anchors). The TUI uses this directly so it never parses model-facing text. */
33
33
  displayContent?: string;
34
+ /** Absolute base directory used during the edit. Used by the renderer to resolve
35
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
36
+ searchPath?: string;
34
37
  }
35
38
  export declare class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
36
39
  private readonly session;
@@ -28,6 +28,9 @@ export interface AstGrepToolDetails {
28
28
  /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
29
29
  * a `│` gutter and `*` to mark match lines. The TUI uses this directly so it never parses model-facing text. */
30
30
  displayContent?: string;
31
+ /** Absolute base directory used during search. Used by the renderer to resolve
32
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
33
+ searchPath?: string;
31
34
  }
32
35
  export declare class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
33
36
  private readonly session;
@@ -48,6 +48,8 @@ export interface UserAgentSession {
48
48
  override: UserAgentOverride;
49
49
  browserSession: CDPSession | null;
50
50
  }
51
+ /** Builds the browser-page stealth bootstrap source for regression tests. */
52
+ export declare function buildStealthInjectionScriptForTest(scripts?: readonly string[]): string;
51
53
  /** Apply stealth patches + UA override to a headless page. Idempotent within a tab. */
52
54
  export declare function applyStealthPatches(browser: Browser, page: Page, state: {
53
55
  browserSession: CDPSession | null;
@@ -23,6 +23,9 @@ export interface FindToolDetails {
23
23
  files?: string[];
24
24
  truncated?: boolean;
25
25
  error?: string;
26
+ /** Working directory at search time. Used by the renderer to resolve relative
27
+ * file paths to absolute paths for OSC 8 hyperlinks. */
28
+ cwd?: string;
26
29
  /** User-supplied paths whose base directory was missing on disk. The tool
27
30
  * skipped these and continued with the surviving entries; surfaced as a
28
31
  * non-fatal warning in the renderer and in the model-facing text. */
@@ -43,6 +43,9 @@ export interface SearchToolDetails {
43
43
  * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
44
44
  * context). The TUI uses this directly so it never parses model-facing hashline anchors. */
45
45
  displayContent?: string;
46
+ /** Absolute base directory used during search. Used by the renderer to resolve
47
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
48
+ searchPath?: string;
46
49
  /** User-supplied paths whose base directory was missing on disk. The tool
47
50
  * skipped these and continued with the surviving entries; surfaced as a
48
51
  * non-fatal warning in the renderer and in the model-facing text. */
@@ -4,6 +4,9 @@
4
4
  import type { Theme } from "../modes/theme/theme";
5
5
  export interface FileEntry {
6
6
  path: string;
7
+ /** Absolute filesystem path. When provided together with {@link FileListOptions.hyperlinkFn}, the
8
+ * rendered path text is wrapped in an OSC 8 hyperlink. */
9
+ absPath?: string;
7
10
  isDirectory?: boolean;
8
11
  meta?: string;
9
12
  }
@@ -12,5 +15,8 @@ export interface FileListOptions {
12
15
  expanded?: boolean;
13
16
  maxCollapsed?: number;
14
17
  showIcons?: boolean;
18
+ /** When provided, called with the entry's absolute path and the ANSI-styled display string to
19
+ * optionally wrap the path in an OSC 8 hyperlink. Only invoked when {@link FileEntry.absPath} is set. */
20
+ hyperlinkFn?: (absPath: string, displayText: string) => string;
15
21
  }
16
22
  export declare function renderFileList(options: FileListOptions, theme: Theme): string[];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Returns true when OSC 8 hyperlinks should be emitted.
3
+ *
4
+ * Respects `tui.hyperlinks` setting:
5
+ * - `"off"`: never
6
+ * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
7
+ * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
8
+ */
9
+ export declare function isHyperlinkEnabled(): boolean;
10
+ /**
11
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
12
+ *
13
+ * Returns `displayText` unchanged when hyperlinks are disabled or when
14
+ * the text already contains an OSC 8 sequence (prevents double-wrapping).
15
+ *
16
+ * The caller is responsible for passing an absolute path. Relative paths
17
+ * produce invalid `file://` URIs and are accepted silently to avoid runtime
18
+ * errors in renderer hot paths.
19
+ *
20
+ * @param absPath - Absolute filesystem path
21
+ * @param displayText - Text to render as the hyperlink anchor (may contain ANSI codes)
22
+ * @param opts - Optional line/col position appended as `?line=N&col=M` query params
23
+ */
24
+ export declare function fileHyperlink(absPath: string, displayText: string, opts?: {
25
+ line?: number;
26
+ col?: number;
27
+ }): string;
28
+ /**
29
+ * Synchronously resolve a filesystem-backed internal URL (e.g. `local://foo.md`,
30
+ * `memory://root/notes.md`) to its absolute filesystem path. Returns `undefined`
31
+ * for inputs that aren't fs-backed, aren't resolvable in the current session
32
+ * registry, or fail to parse.
33
+ *
34
+ * Used by renderers to wrap fs-backed internal URLs in OSC 8 hyperlinks even
35
+ * when the resolved path isn't yet available from tool result details (e.g.
36
+ * during the call/streaming phase before a result lands).
37
+ *
38
+ * Async-resolved schemes (`artifact://`, `agent://`, `skill://`, `rule://`,
39
+ * `omp://`) are not handled here — those rely on `details.resolvedPath` set
40
+ * by the read tool's router resolution.
41
+ */
42
+ export declare function tryResolveInternalUrlSync(input: string): string | undefined;
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export * from "./code-cell";
5
5
  export * from "./file-list";
6
+ export * from "./hyperlink";
6
7
  export * from "./output-block";
7
8
  export * from "./status-line";
8
9
  export * from "./tree-list";
@@ -1,4 +1,4 @@
1
- import type { SearchSource } from "../../../web/search/types";
1
+ import { SearchProviderError, type SearchProviderId, type SearchSource } from "../../../web/search/types";
2
2
  /**
3
3
  * Search for an API credential by checking an env-derived key first,
4
4
  * then falling back to agent.db stored credentials for the given providers.
@@ -47,3 +47,4 @@ export declare function toSearchSources(sources: ReadonlyArray<{
47
47
  snippet?: string;
48
48
  publishedDate?: string;
49
49
  }>, numResults: number): SearchSource[];
50
+ export declare function classifyProviderHttpError(provider: SearchProviderId, status: number, body: string): SearchProviderError | null;
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": "15.1.9",
4
+ "version": "15.2.2",
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",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.9",
51
- "@oh-my-pi/pi-agent-core": "15.1.9",
52
- "@oh-my-pi/pi-ai": "15.1.9",
53
- "@oh-my-pi/pi-natives": "15.1.9",
54
- "@oh-my-pi/pi-tui": "15.1.9",
55
- "@oh-my-pi/pi-utils": "15.1.9",
50
+ "@oh-my-pi/omp-stats": "15.2.2",
51
+ "@oh-my-pi/pi-agent-core": "15.2.2",
52
+ "@oh-my-pi/pi-ai": "15.2.2",
53
+ "@oh-my-pi/pi-natives": "15.2.2",
54
+ "@oh-my-pi/pi-tui": "15.2.2",
55
+ "@oh-my-pi/pi-utils": "15.2.2",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -583,6 +583,18 @@ export const SETTINGS_SCHEMA = {
583
583
  description:
584
584
  "Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).",
585
585
  },
586
+
587
+ "tui.hyperlinks": {
588
+ type: "enum",
589
+ values: ["off", "auto", "always"] as const,
590
+ default: "auto",
591
+ ui: {
592
+ tab: "appearance",
593
+ label: "Terminal Hyperlinks",
594
+ description:
595
+ "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
596
+ },
597
+ },
586
598
  // Display rendering
587
599
  "display.tabWidth": {
588
600
  type: "number",
@@ -129,6 +129,18 @@ function stringArrayFromUnknown(value: unknown): string[] {
129
129
  return [];
130
130
  }
131
131
 
132
+ function shallowStringRecord(value: unknown): Record<string, string> {
133
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
134
+
135
+ const result: Record<string, string> = {};
136
+ for (const [key, item] of Object.entries(value)) {
137
+ if (typeof item === "string") {
138
+ result[key] = item;
139
+ }
140
+ }
141
+ return result;
142
+ }
143
+
132
144
  function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
133
145
  if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
134
146
 
@@ -424,8 +436,19 @@ export class Settings {
424
436
  * Set a model role (helper for modelRoles record).
425
437
  */
426
438
  setModelRole(role: ModelRole | string, modelId: string): void {
427
- const current = this.get("modelRoles");
439
+ const current = shallowStringRecord(getByPath(this.#global, ["modelRoles"]));
440
+ const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
441
+ const updateRuntimeOverride =
442
+ !!runtimeOverrides &&
443
+ typeof runtimeOverrides === "object" &&
444
+ !Array.isArray(runtimeOverrides) &&
445
+ Object.hasOwn(runtimeOverrides, role);
446
+
428
447
  this.set("modelRoles", { ...current, [role]: modelId });
448
+
449
+ if (updateRuntimeOverride) {
450
+ this.override("modelRoles", { ...shallowStringRecord(runtimeOverrides), [role]: modelId });
451
+ }
429
452
  }
430
453
 
431
454
  /**
@@ -440,20 +463,20 @@ export class Settings {
440
463
  * Get all model roles (helper for modelRoles record).
441
464
  */
442
465
  getModelRoles(): ReadOnlyDict<string> {
443
- return this.get("modelRoles");
466
+ return { ...this.get("modelRoles") };
444
467
  }
445
468
 
446
469
  /*
447
470
  * Override model roles (helper for modelRoles record).
448
471
  */
449
472
  overrideModelRoles(roles: ReadOnlyDict<string>): void {
450
- const prev = this.get("modelRoles");
473
+ const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"]));
451
474
  for (const [role, modelId] of Object.entries(roles)) {
452
475
  if (modelId) {
453
- prev[role] = modelId;
476
+ next[role] = modelId;
454
477
  }
455
478
  }
456
- this.override("modelRoles", prev);
479
+ this.override("modelRoles", next);
457
480
  }
458
481
 
459
482
  /**
@@ -346,9 +346,39 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
346
346
  if (result.warnings) warnings.push(...result.warnings);
347
347
  }
348
348
 
349
+ // Top-level RULES.md is a sticky always-apply rule. Documented in
350
+ // https://omp.sh/docs/context-files as the file that gets "re-injected near
351
+ // the current turn so they keep hold across long conversations".
352
+ // User scope: ~/.omp/agent/RULES.md
353
+ // Project scope: nearest .omp/RULES.md walking up from cwd to repoRoot
354
+ const userRulesFile = path.join(ctx.home, PATHS.userAgent, "RULES.md");
355
+ const userRule = await loadStickyRulesFile(userRulesFile, "user");
356
+ if (userRule) items.push(userRule);
357
+
358
+ const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
359
+ if (nearestProjectConfigDir) {
360
+ const projectRulesFile = path.join(nearestProjectConfigDir.dir, "RULES.md");
361
+ const projectRule = await loadStickyRulesFile(projectRulesFile, "project");
362
+ if (projectRule) items.push(projectRule);
363
+ }
364
+
349
365
  return { items, warnings };
350
366
  }
351
367
 
368
+ /**
369
+ * Read a top-level `RULES.md` and synthesize an always-apply rule.
370
+ * Returns null when the file is absent or empty so callers can short-circuit.
371
+ */
372
+ async function loadStickyRulesFile(filePath: string, level: "user" | "project"): Promise<Rule | null> {
373
+ const content = await readFile(filePath);
374
+ if (!content) return null;
375
+ const source = createSourceMeta(PROVIDER_ID, filePath, level);
376
+ const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName: "RULES" });
377
+ // Force alwaysApply regardless of frontmatter — the whole point of RULES.md
378
+ // is to be reattached every turn.
379
+ return { ...rule, alwaysApply: true };
380
+ }
381
+
352
382
  registerProvider<Rule>(ruleCapability.id, {
353
383
  id: PROVIDER_ID,
354
384
  displayName: DISPLAY_NAME,
@@ -25,7 +25,7 @@ import {
25
25
  truncateDiffByHunk,
26
26
  } from "../tools/render-utils";
27
27
  import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
28
- import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
28
+ import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
29
29
  import type { EditMode } from "../utils/edit-mode";
30
30
  import type { VimToolDetails } from "../vim/types";
31
31
  import type { DiffError, DiffResult } from "./diff";
@@ -219,14 +219,16 @@ function formatEditPathDisplay(
219
219
  uiTheme: Theme,
220
220
  options?: { rename?: string; firstChangedLine?: number },
221
221
  ): string {
222
- let pathDisplay = rawPath ? uiTheme.fg("accent", shortenPath(rawPath)) : uiTheme.fg("toolOutput", "…");
222
+ let pathDisplay = rawPath
223
+ ? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
224
+ : uiTheme.fg("toolOutput", "…");
223
225
 
224
226
  if (options?.firstChangedLine) {
225
227
  pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
226
228
  }
227
229
 
228
230
  if (options?.rename) {
229
- pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(options.rename))}`;
231
+ pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
230
232
  }
231
233
 
232
234
  return pathDisplay;
@@ -209,6 +209,15 @@ function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
209
209
  return `Command timed out after ${secs} seconds`;
210
210
  }
211
211
 
212
+ function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string {
213
+ const secs = timeoutMs === undefined ? undefined : Math.max(1, Math.round(timeoutMs / 1000));
214
+ if (kernelKilled) {
215
+ return "eval cell timed out and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call.";
216
+ }
217
+ const duration = secs === undefined ? "the configured timeout" : `${secs}s`;
218
+ return `eval cell timed out after ${duration}; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted.`;
219
+ }
220
+
212
221
  function createCancelledPythonResult(timedOut: boolean, timeoutMs?: number): PythonResult {
213
222
  const output = timedOut ? (formatTimeoutAnnotation(timeoutMs) ?? "Command timed out") : "";
214
223
  const outputBytes = Buffer.byteLength(output, "utf-8");
@@ -434,7 +443,9 @@ async function executeWithKernel(
434
443
  });
435
444
 
436
445
  if (result.cancelled) {
437
- const annotation = result.timedOut ? formatTimeoutAnnotation(executionTimeoutMs) : undefined;
446
+ const annotation = result.timedOut
447
+ ? formatKernelTimeoutAnnotation(executionTimeoutMs, result.kernelKilled ?? false)
448
+ : undefined;
438
449
  return {
439
450
  exitCode: undefined,
440
451
  cancelled: true,
@@ -46,8 +46,10 @@ const STARTUP_TIMEOUT_MS = 10_000;
46
46
  // How long to wait after SIGINT for the runner to emit `done`. If the cell is
47
47
  // stuck in code that ignores Python signals (e.g. a C extension holding the
48
48
  // GIL), we escalate to a full subprocess shutdown so the host queue unblocks
49
- // instead of hanging the session forever.
50
- const INTERRUPT_ESCALATION_MS = 2_000;
49
+ // instead of hanging the session forever. The grace window is intentionally
50
+ // generous: a clean interrupt is far preferable to losing the persistent
51
+ // kernel's state, so we only kill as a last-resort recovery path.
52
+ const INTERRUPT_ESCALATION_MS = 5_000;
51
53
 
52
54
  export interface KernelExecuteOptions {
53
55
  signal?: AbortSignal;
@@ -66,6 +68,12 @@ export interface KernelExecuteResult {
66
68
  cancelled: boolean;
67
69
  timedOut: boolean;
68
70
  stdinRequested: boolean;
71
+ /**
72
+ * True when the kernel subprocess was killed as part of settling this
73
+ * execution (e.g. SIGINT was ignored and we escalated to shutdown, or the
74
+ * kernel died unexpectedly). When false, the kernel remains reusable.
75
+ */
76
+ kernelKilled?: boolean;
69
77
  }
70
78
 
71
79
  export interface KernelShutdownResult {
@@ -162,6 +170,7 @@ interface PendingExecution {
162
170
  cancelled: boolean;
163
171
  timedOut: boolean;
164
172
  stdinRequested: boolean;
173
+ kernelKilled: boolean;
165
174
  settled: boolean;
166
175
  escalationTimer?: NodeJS.Timeout;
167
176
  }
@@ -222,7 +231,7 @@ export class PythonKernel {
222
231
  kernel.#exitedPromise = proc.exited;
223
232
  void kernel.#exitedPromise.then(code => {
224
233
  kernel.#alive = false;
225
- kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`);
234
+ kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
226
235
  });
227
236
 
228
237
  kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
@@ -261,6 +270,7 @@ export class PythonKernel {
261
270
  timedOut: false,
262
271
  stdinRequested: false,
263
272
  settled: false,
273
+ kernelKilled: false,
264
274
  };
265
275
  this.#pending.set(msgId, pending);
266
276
 
@@ -276,6 +286,7 @@ export class PythonKernel {
276
286
  cancelled: pending.cancelled,
277
287
  timedOut: pending.timedOut,
278
288
  stdinRequested: pending.stdinRequested,
289
+ kernelKilled: pending.kernelKilled,
279
290
  });
280
291
  };
281
292
 
@@ -287,9 +298,12 @@ export class PythonKernel {
287
298
  logger.warn("Python runner did not respond to SIGINT; terminating subprocess", {
288
299
  kernelId: this.id,
289
300
  });
290
- // `shutdown()` aborts pending executions immediately and escalates to
291
- // SIGTERM/SIGKILL, so the host queue unblocks even if the runner is
292
- // stuck in a non-interruptible state.
301
+ // SIGINT was ignored; mark the cell as kernel-killed so callers can
302
+ // surface the harsher recovery message. `shutdown()` aborts pending
303
+ // executions immediately and escalates to SIGTERM/SIGKILL, so the
304
+ // host queue unblocks even if the runner is stuck in a
305
+ // non-interruptible state.
306
+ pending.kernelKilled = true;
293
307
  void this.shutdown();
294
308
  }, INTERRUPT_ESCALATION_MS);
295
309
  escalation.unref?.();
@@ -363,7 +377,7 @@ export class PythonKernel {
363
377
  if (this.#shutdownConfirmed) return { confirmed: true };
364
378
 
365
379
  this.#alive = false;
366
- this.#abortPendingExecutions("Python kernel shutdown");
380
+ this.#abortPendingExecutions("Python kernel shutdown", { kernelKilled: true });
367
381
 
368
382
  const timeoutMs = options?.timeoutMs ?? SHUTDOWN_GRACE_MS;
369
383
  const proc = this.#proc;
@@ -410,10 +424,11 @@ export class PythonKernel {
410
424
  return { confirmed };
411
425
  }
412
426
 
413
- #abortPendingExecutions(reason: string): void {
427
+ #abortPendingExecutions(reason: string, options?: { kernelKilled?: boolean }): void {
414
428
  if (this.#pending.size === 0) return;
415
429
  const pending = Array.from(this.#pending.values());
416
430
  this.#pending.clear();
431
+ const kernelKilledDefault = options?.kernelKilled ?? false;
417
432
  for (const entry of pending) {
418
433
  if (entry.settled) continue;
419
434
  entry.settled = true;
@@ -425,6 +440,7 @@ export class PythonKernel {
425
440
  stdinRequested: entry.stdinRequested,
426
441
  executionCount: entry.executionCount,
427
442
  error: entry.error,
443
+ kernelKilled: entry.kernelKilled || kernelKilledDefault,
428
444
  });
429
445
  }
430
446
  }