@oh-my-pi/pi-coding-agent 13.3.6 → 13.3.7

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.3.7] - 2026-02-27
6
+ ### Breaking Changes
7
+
8
+ - Removed `preloadedSkills` option from `CreateAgentSessionOptions`; skills are no longer inlined into system prompts
9
+ - Removed `skills` field from Task schema; subagents now always inherit the session skill set instead of per-task skill selection
10
+ - Removed Task tool per-task `tasks[].skills` support; subagents now always inherit the session skill set
11
+ - Removed `preloadedSkills` system prompt plumbing and template sections; skills are no longer inlined as a separate preloaded block
12
+
13
+ ### Changed
14
+
15
+ - Refactored schema reference resolution to inline all `$ref` definitions instead of preserving them at the root level, eliminating unresolved references in tool parameters
16
+ - Added `lenientArgValidation` flag to SubmitResultTool to allow the agent loop to bypass strict argument validation errors
17
+ - Modified schema validation to allow non-conforming output on second validation failure, enabling recovery from strict schema constraints after initial rejection
18
+ - Updated JTD-to-TypeScript conversion to gracefully fall back to 'unknown' type when conversion fails, preventing template rendering errors
19
+ - Changed JTD-to-JSON Schema conversion to normalize nested JTD fragments within JSON Schema nodes, enabling mixed schema definitions
20
+ - Changed output schema validation to gracefully fall back to unconstrained object when schema is invalid, instead of rejecting submissions
21
+ - Changed schema sanitization to remove strict-mode incompatible constraints (minLength, pattern, etc.) from tool parameters while preserving them for runtime validation
22
+ - Simplified task execution to always pass available session skills to subagents instead of resolving per-task skill lists
23
+ - Added `KILO_API_KEY` to CLI environment variable help text for Kilo Gateway provider setup ([#193](https://github.com/can1357/oh-my-pi/issues/193))
24
+
25
+ ### Removed
26
+
27
+ - Removed preloaded skills section from system prompt templates; skills are now referenced only as available resources
28
+
29
+ ### Fixed
30
+
31
+ - Fixed schema compilation validation by adding explicit AJV compilation check to catch unresolved `$ref` references and other schema errors before tool execution
32
+ - Fixed handling of circular and deeply nested output schemas to prevent stack overflow and enable successful result submission with fallback unconstrained schema
33
+ - Fixed processing of non-object output schemas (arrays, primitives, booleans) to accept valid result submissions without blocking
34
+ - Fixed handling of mixed JTD and JSON Schema output definitions to properly convert all nested JTD elements (e.g., `elements` → `items`, `int32` → `integer`)
35
+ - Fixed strict schema generation for output schemas with only required fields, enabling proper Claude API compatibility
36
+ - Fixed handling of union type schemas (e.g., object|null) to normalize them into strict-mode compatible variants
37
+
5
38
  ## [13.3.6] - 2026-02-26
6
39
  ### Breaking Changes
7
40
 
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": "13.3.6",
4
+ "version": "13.3.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.3.6",
45
- "@oh-my-pi/pi-agent-core": "13.3.6",
46
- "@oh-my-pi/pi-ai": "13.3.6",
47
- "@oh-my-pi/pi-natives": "13.3.6",
48
- "@oh-my-pi/pi-tui": "13.3.6",
49
- "@oh-my-pi/pi-utils": "13.3.6",
44
+ "@oh-my-pi/omp-stats": "13.3.7",
45
+ "@oh-my-pi/pi-agent-core": "13.3.7",
46
+ "@oh-my-pi/pi-ai": "13.3.7",
47
+ "@oh-my-pi/pi-natives": "13.3.7",
48
+ "@oh-my-pi/pi-tui": "13.3.7",
49
+ "@oh-my-pi/pi-utils": "13.3.7",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -32,6 +32,11 @@ export interface MCPServer {
32
32
  type: "oauth" | "apikey";
33
33
  credentialId?: string;
34
34
  };
35
+ /** OAuth configuration (clientId, callbackPort) for servers requiring explicit client credentials */
36
+ oauth?: {
37
+ clientId?: string;
38
+ callbackPort?: number;
39
+ };
35
40
  /** Transport type */
36
41
  transport?: "stdio" | "sse" | "http";
37
42
  /** Source metadata (added by loader) */
package/src/cli/args.ts CHANGED
@@ -201,6 +201,7 @@ export function getExtraHelpText(): string {
201
201
  CEREBRAS_API_KEY - Cerebras models
202
202
  XAI_API_KEY - xAI Grok models
203
203
  OPENROUTER_API_KEY - OpenRouter aggregated models
204
+ KILO_API_KEY - Kilo Gateway models
204
205
  MISTRAL_API_KEY - Mistral models
205
206
  ZAI_API_KEY - z.ai models (ZhipuAI/GLM)
206
207
  MINIMAX_API_KEY - MiniMax models
@@ -225,7 +225,13 @@ handlebars.registerHelper("includes", (collection: unknown, item: unknown): bool
225
225
  */
226
226
  handlebars.registerHelper("not", (value: unknown): boolean => !value);
227
227
 
228
- handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => jtdToTypeScript(schema));
228
+ handlebars.registerHelper("jtdToTypeScript", (schema: unknown): string => {
229
+ try {
230
+ return jtdToTypeScript(schema);
231
+ } catch {
232
+ return "unknown";
233
+ }
234
+ });
229
235
 
230
236
  handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
231
237
 
@@ -157,6 +157,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
157
157
  url: serverConfig.url as string | undefined,
158
158
  headers: serverConfig.headers as Record<string, string> | undefined,
159
159
  auth: serverConfig.auth as { type: "oauth" | "apikey"; credentialId?: string } | undefined,
160
+ oauth: serverConfig.oauth as { clientId?: string; callbackPort?: number } | undefined,
160
161
  transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
161
162
  _source: createSourceMeta(PROVIDER_ID, path, level),
162
163
  });
@@ -36,6 +36,7 @@ interface MCPConfigFile {
36
36
  credentialId?: string;
37
37
  };
38
38
  type?: "stdio" | "sse" | "http";
39
+ oauth?: { clientId?: string; callbackPort?: number };
39
40
  }
40
41
  >;
41
42
  }
@@ -81,6 +82,7 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
81
82
  url: serverConfig.url,
82
83
  headers: serverConfig.headers,
83
84
  auth: serverConfig.auth,
85
+ oauth: serverConfig.oauth,
84
86
  transport: serverConfig.type,
85
87
  _source: source,
86
88
  };
@@ -43,7 +43,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
43
43
  "session-switching-and-recent-listing.md": "# Session switching and recent session listing\n\nThis document describes how coding-agent discovers recent sessions, resolves `--resume` targets, presents session pickers, and switches the active runtime session.\n\nIt focuses on current implementation behavior, including fallback paths and caveats.\n\n## Implementation files\n\n- [`../src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/cli/session-picker.ts`](../packages/coding-agent/src/cli/session-picker.ts)\n- [`../src/modes/components/session-selector.ts`](../packages/coding-agent/src/modes/components/session-selector.ts)\n- [`../src/modes/controllers/selector-controller.ts`](../packages/coding-agent/src/modes/controllers/selector-controller.ts)\n- [`../src/main.ts`](../packages/coding-agent/src/main.ts)\n- [`../src/sdk.ts`](../packages/coding-agent/src/sdk.ts)\n- [`../src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`../src/modes/utils/ui-helpers.ts`](../packages/coding-agent/src/modes/utils/ui-helpers.ts)\n\n## Recent-session discovery\n\n### Directory scope\n\n`SessionManager` stores sessions under a cwd-scoped directory by default:\n\n- `~/.omp/agent/sessions/--<cwd-encoded>--/*.jsonl`\n\n`SessionManager.list(cwd, sessionDir?)` reads only that directory unless an explicit `sessionDir` is provided.\n\n### Two listing paths with different payloads\n\nThere are two different listing pipelines:\n\n1. `getRecentSessions(sessionDir, limit)` (welcome/summary view)\n - Reads only a 4KB prefix (`readTextPrefix(..., 4096)`) from each file.\n - Parses header + earliest user text preview.\n - Returns lightweight `RecentSessionInfo` with lazy `name` and `timeAgo` getters.\n - Sorts by file `mtime` descending.\n\n2. `SessionManager.list(...)` / `SessionManager.listAll()` (resume pickers and ID matching)\n - Reads full session files.\n - Builds `SessionInfo` objects (`id`, `cwd`, `title`, `messageCount`, `firstMessage`, `allMessagesText`, timestamps).\n - Drops sessions with zero `message` entries.\n - Sorts by `modified` descending.\n\n### Metadata fallback behavior\n\nFor recent summaries (`RecentSessionInfo`):\n\n- display name preference: `header.title` -> first user prompt -> `header.id` -> filename\n- name is truncated to 40 chars for compact displays\n- control characters/newlines are stripped/sanitized from title-derived names\n\nFor `SessionInfo` list entries:\n\n- `title` is `header.title` or latest compaction `shortSummary`\n- `firstMessage` is first user message text or `\"(no messages)\"`\n\n## `--continue` resolution and terminal breadcrumb preference\n\n`SessionManager.continueRecent(cwd, sessionDir?)` resolves the target in this order:\n\n1. Read terminal-scoped breadcrumb (`~/.omp/agent/terminal-sessions/<terminal-id>`)\n2. Validate breadcrumb:\n - current terminal can be identified\n - breadcrumb cwd matches current cwd (resolved path compare)\n - referenced file still exists\n3. If breadcrumb is invalid/missing, fall back to newest file by mtime in the session dir (`findMostRecentSession`)\n4. If none found, create a new session\n\nTerminal ID derivation prefers TTY path and falls back to env-based identifiers (`KITTY_WINDOW_ID`, `TMUX_PANE`, `TERM_SESSION_ID`, `WT_SESSION`).\n\nBreadcrumb writes are best-effort and non-fatal.\n\n## Startup-time resume target resolution (`main.ts`)\n\n### `--resume <value>`\n\n`createSessionManager(...)` handles string-valued `--resume` in two modes:\n\n1. Path-like value (contains `/`, `\\\\`, or ends with `.jsonl`)\n - direct `SessionManager.open(sessionArg, parsed.sessionDir)`\n\n2. ID prefix value\n - find match in `SessionManager.list(cwd, sessionDir)` by `id.startsWith(sessionArg)`\n - if no local match and `sessionDir` is not forced, try `SessionManager.listAll()`\n - first match is used (no ambiguity prompt)\n\nCross-project match behavior:\n\n- if matched session cwd differs from current cwd, CLI prompts whether to fork into current project\n- yes -> `SessionManager.forkFrom(...)`\n- no -> throws error (`Session \"...\" is in another project (...)`)\n\nNo match -> throws error (`Session \"...\" not found.`).\n\n### `--resume` (no value)\n\nHandled after initial session-manager construction:\n\n1. list local sessions with `SessionManager.list(cwd, parsed.sessionDir)`\n2. if empty: print `No sessions found` and exit early\n3. open TUI picker (`selectSession`)\n4. if canceled: print `No session selected` and exit early\n5. if selected: `SessionManager.open(selectedPath)`\n\n### `--continue`\n\nUses `SessionManager.continueRecent(...)` directly (breadcrumb-first behavior above).\n\n## Picker-based selection internals\n\n## CLI picker (`src/cli/session-picker.ts`)\n\n`selectSession(sessions)` creates a standalone TUI with `SessionSelectorComponent` and resolves exactly once:\n\n- selection -> resolves selected path\n- cancel (Esc) -> resolves `null`\n- hard exit (Ctrl+C path) -> stops TUI and `process.exit(0)`\n\n## Interactive in-session picker (`SelectorController.showSessionSelector`)\n\nFlow:\n\n1. fetch sessions from current session dir via `SessionManager.list(currentCwd, currentSessionDir)`\n2. mount `SessionSelectorComponent` in editor area using `showSelector(...)`\n3. callbacks:\n - select -> close selector and call `handleResumeSession(sessionPath)`\n - cancel -> restore editor and rerender\n - exit -> `ctx.shutdown()`\n\n## Session selector component behavior\n\n`SessionList` supports:\n\n- arrow/page navigation\n- Enter to select\n- Esc to cancel\n- Ctrl+C to exit\n- fuzzy search across session id/title/cwd/first message/all messages/path\n\nEmpty-list render behavior:\n\n- renders a message instead of crashing\n- Enter on empty does nothing (no callback)\n- Esc/Ctrl+C still work\n\nCaveat: UI text says `Press Tab to view all`, but this component currently has no Tab handler and current wiring only lists current-scope sessions.\n\n## Runtime switch execution (`AgentSession.switchSession`)\n\n`switchSession(sessionPath)` is the core in-process switch path.\n\nLifecycle/state transition:\n\n1. capture `previousSessionFile`\n2. emit `session_before_switch` hook event (`reason: \"resume\"`, cancellable)\n3. if canceled -> return `false` with no switch\n4. disconnect from current agent event stream\n5. abort active generation/tool flow\n6. clear queued steering/follow-up/next-turn message buffers\n7. flush session writer (`sessionManager.flush()`) to persist pending writes\n8. `sessionManager.setSessionFile(sessionPath)`\n - updates session file pointer\n - writes terminal breadcrumb\n - loads entries / migrates / blob-resolves / reindexes\n - if missing/invalid file data: initializes a new session at that path and rewrites header\n9. update `agent.sessionId`\n10. rebuild context via `buildSessionContext()`\n11. emit `session_switch` hook event (`reason: \"resume\"`, `previousSessionFile`)\n12. replace agent messages with rebuilt context\n13. restore default model from `sessionContext.models.default` if available and present in model registry\n14. restore thinking level:\n - if branch already has `thinking_level_change`, apply saved session level\n - else derive default thinking level from settings, clamp to model capability, set it, and append a new `thinking_level_change` entry\n15. reconnect agent listeners and return `true`\n\n## UI state rebuild after interactive switch\n\n`SelectorController.handleResumeSession` performs UI reset around `switchSession`:\n\n- stop loading animation\n- clear status container\n- clear pending-message UI and pending tool map\n- reset streaming component/message references\n- call `session.switchSession(...)`\n- clear chat container and rerender from session context (`renderInitialMessages`)\n- reload todos from new session artifacts\n- show `Resumed session`\n\nSo visible conversation/todo state is rebuilt from the new session file.\n\n## Startup resume vs in-session switch\n\n### Startup resume (`--continue`, `--resume`, direct open)\n\n- Session file is chosen before `createAgentSession(...)`.\n- `sdk.ts` builds `existingSession = sessionManager.buildSessionContext()`.\n- Agent messages are restored once during session creation.\n- Model/thinking are selected during creation (including restore/fallback logic).\n- Interactive mode then runs `#restoreModeFromSession()` to re-enter persisted mode state (currently plan/plan_paused).\n\n### In-session switch (`/resume`-style selector path)\n\n- Uses `AgentSession.switchSession(...)` on an already-running `AgentSession`.\n- Messages/model/thinking are rebuilt immediately in place.\n- Hook `session_before_switch`/`session_switch` events are emitted.\n- UI chat/todos are refreshed.\n- No dedicated post-switch mode restore call is made in selector flow; mode re-entry behavior is not symmetric with startup `#restoreModeFromSession()`.\n\n## Failure and edge-case behavior\n\n### Cancellation paths\n\n- CLI picker cancel -> returns `null`, caller prints `No session selected`, process exits early.\n- Interactive picker cancel -> editor restored, no session change.\n- Hook cancellation (`session_before_switch`) -> `switchSession()` returns `false`.\n\n### Empty list paths\n\n- CLI `--resume` (no value): empty list prints `No sessions found` and exits.\n- Interactive selector: empty list renders message and remains cancellable.\n\n### Missing/invalid target session file\n\nWhen opening/switching to a specific path (`setSessionFile`):\n\n- ENOENT -> treated as empty -> new session initialized at that exact path and persisted.\n- malformed/invalid header (or effectively unreadable parsed entries) -> treated as empty -> new session initialized and persisted.\n\nThis is recovery behavior, not hard failure.\n\n### Hard failures\n\nSwitch/open can still throw on true I/O failures (permission errors, rewrite failures, etc.), which propagate to callers.\n\n### ID prefix matching caveats\n\n- ID matching uses `startsWith` and takes first match in sorted list.\n- No ambiguity UI if multiple sessions share prefix.\n- `SessionManager.list(...)` excludes sessions with zero messages, so those sessions are not resumable via ID match/list picker.\n",
44
44
  "session-tree-plan.md": "# Session tree architecture (current)\n\nReference: [session.md](../docs/session.md)\n\nThis document describes how session tree navigation works today: in-memory tree model, leaf movement rules, branching behavior, and extension/event integration.\n\n## What this subsystem is\n\nThe session is stored as an append-only entry log, but runtime behavior is tree-based:\n\n- Every non-header entry has `id` and `parentId`.\n- The active position is `leafId` in `SessionManager`.\n- Appending an entry always creates a child of the current leaf.\n- Branching does **not** rewrite history; it only changes where the leaf points before the next append.\n\nKey files:\n\n- `src/session/session-manager.ts` — tree data model, traversal, leaf movement, branch/session extraction\n- `src/session/agent-session.ts` — `/tree` navigation flow, summarization, hook/event emission\n- `src/modes/components/tree-selector.ts` — interactive tree UI behavior and filtering\n- `src/modes/controllers/selector-controller.ts` — selector orchestration for `/tree` and `/branch`\n- `src/modes/controllers/input-controller.ts` — command routing (`/tree`, `/branch`, double-escape behavior)\n- `src/session/messages.ts` — conversion of `branch_summary`, `compaction`, and `custom_message` entries into LLM context messages\n\n## Tree data model in `SessionManager`\n\nRuntime indices:\n\n- `#byId: Map<string, SessionEntry>` — fast lookup for any entry\n- `#leafId: string | null` — current position in the tree\n- `#labelsById: Map<string, string>` — resolved labels by target entry id\n\nTree APIs:\n\n- `getBranch(fromId?)` walks parent links to root and returns root→node path\n- `getTree()` returns `SessionTreeNode[]` (`entry`, `children`, `label`)\n - parent links become children arrays\n - entries with missing parents are treated as roots\n - children are sorted oldest→newest by timestamp\n- `getChildren(parentId)` returns direct children\n- `getLabel(id)` resolves current label from `labelsById`\n\n`getTree()` is a runtime projection; persistence remains append-only JSONL entries.\n\n## Leaf movement semantics\n\nThere are three leaf movement primitives:\n\n1. `branch(entryId)`\n - Validates entry exists\n - Sets `leafId = entryId`\n - No new entry is written\n\n2. `resetLeaf()`\n - Sets `leafId = null`\n - Next append creates a new root entry (`parentId = null`)\n\n3. `branchWithSummary(branchFromId, summary, details?, fromExtension?)`\n - Accepts `branchFromId: string | null`\n - Sets `leafId = branchFromId`\n - Appends a `branch_summary` entry as child of that leaf\n - When `branchFromId` is `null`, `fromId` is persisted as `\"root\"`\n\n## `/tree` navigation behavior (same session file)\n\n`AgentSession.navigateTree()` is navigation, not file forking.\n\nFlow:\n\n1. Validate target and compute abandoned path (`collectEntriesForBranchSummary`)\n2. Emit `session_before_tree` with `TreePreparation`\n3. Optionally summarize abandoned entries (hook-provided summary or built-in summarizer)\n4. Compute new leaf target:\n - selecting a **user** message: leaf moves to its parent, and message text is returned for editor prefill\n - selecting a **custom_message**: same rule as user message (leaf = parent, text prefills editor)\n - selecting any other entry: leaf = selected entry id\n5. Apply leaf move:\n - with summary: `branchWithSummary(newLeafId, ...)`\n - without summary and `newLeafId === null`: `resetLeaf()`\n - otherwise: `branch(newLeafId)`\n6. Rebuild agent context from new leaf and emit `session_tree`\n\nImportant: summary entries are attached at the **new navigation position**, not on the abandoned branch tail.\n\n## `/branch` behavior (new session file)\n\n`/branch` and `/tree` are intentionally different:\n\n- `/tree` navigates within the current session file.\n- `/branch` creates a new session branch file (or in-memory replacement for non-persistent mode).\n\nUser-facing `/branch` flow (`SelectorController.showUserMessageSelector` → `AgentSession.branch`):\n\n- Branch source must be a **user message**.\n- Selected user text is extracted for editor prefill.\n- If selected user message is root (`parentId === null`): start a new session via `newSession({ parentSession: previousSessionFile })`.\n- Otherwise: `createBranchedSession(selectedEntry.parentId)` to fork history up to the selected prompt boundary.\n\n`SessionManager.createBranchedSession(leafId)` specifics:\n\n- Builds root→leaf path via `getBranch(leafId)`; throws if missing.\n- Excludes existing `label` entries from copied path.\n- Rebuilds fresh label entries from resolved `labelsById` for entries that remain in path.\n- Persistent mode: writes new JSONL file and switches manager to it; returns new file path.\n- In-memory mode: replaces in-memory entries; returns `undefined`.\n\n## Context reconstruction and summary/custom integration\n\n`buildSessionContext()` (in `session-manager.ts`) resolves the active root→leaf path and builds effective LLM context state:\n\n- Tracks latest thinking/model/mode/ttsr state on path.\n- Handles latest compaction on path:\n - emits compaction summary first\n - replays kept messages from `firstKeptEntryId` to compaction point\n - then replays post-compaction messages\n- Includes `branch_summary` and `custom_message` entries as `AgentMessage` objects.\n\n`session/messages.ts` then maps these message types for model input:\n\n- `branchSummary` and `compactionSummary` become user-role templated context messages\n- `custom`/`hookMessage` become user-role content messages\n\nSo tree movement changes context by changing the active leaf path, not by mutating old entries.\n\n## Labels and tree UI behavior\n\nLabel persistence:\n\n- `appendLabelChange(targetId, label?)` writes `label` entries on the current leaf chain.\n- `labelsById` is updated immediately (set or delete).\n- `getTree()` resolves current label onto each returned node.\n\nTree selector behavior (`tree-selector.ts`):\n\n- Flattens tree for navigation, keeps active-path highlighting, and prioritizes displaying the active branch first.\n- Supports filter modes: `default`, `no-tools`, `user-only`, `labeled-only`, `all`.\n- Supports free-text search over rendered semantic content.\n- `Shift+L` opens inline label editing and writes via `appendLabelChange`.\n\nCommand routing:\n\n- `/tree` always opens tree selector.\n- `/branch` opens user-message selector unless `doubleEscapeAction=tree`, in which case it also uses tree selector UX.\n\n## Extension and hook touchpoints for tree operations\n\nCommand-time extension API (`ExtensionCommandContext`):\n\n- `branch(entryId)` — create branched session file\n- `navigateTree(targetId, { summarize? })` — move within current tree/file\n\nEvents around tree navigation:\n\n- `session_before_tree`\n - receives `TreePreparation`:\n - `targetId`\n - `oldLeafId`\n - `commonAncestorId`\n - `entriesToSummarize`\n - `userWantsSummary`\n - may cancel navigation\n - may provide summary payload used instead of built-in summarizer\n - receives abort `signal` (Escape cancellation path)\n- `session_tree`\n - emits `newLeafId`, `oldLeafId`\n - includes `summaryEntry` when a summary was created\n - `fromExtension` indicates summary origin\n\nAdjacent but related lifecycle hooks:\n\n- `session_before_branch` / `session_branch` for `/branch` flow\n- `session_before_compact`, `session.compacting`, `session_compact` for compaction entries that later affect tree-context reconstruction\n\n## Real constraints and edge conditions\n\n- `branch()` cannot target `null`; use `resetLeaf()` for root-before-first-entry state.\n- `branchWithSummary()` supports `null` target and records `fromId: \"root\"`.\n- Selecting current leaf in tree selector is a no-op.\n- Summarization requires an active model; if absent, summarize navigation fails fast.\n- If summarization is aborted, navigation is cancelled and leaf is unchanged.\n- In-memory sessions never return a branch file path from `createBranchedSession`.\n\n## Legacy compatibility still present\n\nSession migrations still run on load:\n\n- v1→v2 adds `id`/`parentId` and converts compaction index anchor to id anchor\n- v2→v3 migrates legacy `hookMessage` role to `custom`\n\nCurrent runtime behavior is version-3 tree semantics after migration.\n",
45
45
  "session.md": "# Session Storage and Entry Model\n\nThis document is the source of truth for how coding-agent sessions are represented, persisted, migrated, and reconstructed at runtime.\n\n## Scope\n\nCovers:\n\n- Session JSONL format and versioning\n- Entry taxonomy and tree semantics (`id`/`parentId` + leaf pointer)\n- Migration/compatibility behavior when loading old or malformed files\n- Context reconstruction (`buildSessionContext`)\n- Persistence guarantees, failure behavior, truncation/blob externalization\n- Storage abstractions (`FileSessionStorage`, `MemorySessionStorage`) and related utilities\n\nDoes not cover `/tree` UI rendering behavior beyond semantics that affect session data.\n\n## Implementation Files\n\n- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`src/session/messages.ts`](../packages/coding-agent/src/session/messages.ts)\n- [`src/session/session-storage.ts`](../packages/coding-agent/src/session/session-storage.ts)\n- [`src/session/history-storage.ts`](../packages/coding-agent/src/session/history-storage.ts)\n- [`src/session/blob-store.ts`](../packages/coding-agent/src/session/blob-store.ts)\n\n## On-Disk Layout\n\nDefault session file location:\n\n```text\n~/.omp/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl\n```\n\n`<cwd-encoded>` is derived from the working directory by stripping leading slash and replacing `/`, `\\\\`, and `:` with `-`.\n\nBlob store location:\n\n```text\n~/.omp/agent/blobs/<sha256>\n```\n\nTerminal breadcrumb files are written under:\n\n```text\n~/.omp/agent/terminal-sessions/<terminal-id>\n```\n\nBreadcrumb content is two lines: original cwd, then session file path. `continueRecent()` prefers this terminal-scoped pointer before scanning most-recent mtime.\n\n## File Format\n\nSession files are JSONL: one JSON object per line.\n\n- Line 1 is always the session header (`type: \"session\"`).\n- Remaining lines are `SessionEntry` values.\n- Entries are append-only at runtime; branch navigation moves a pointer (`leafId`) rather than mutating existing entries.\n\n### Header (`SessionHeader`)\n\n```json\n{\n \"type\": \"session\",\n \"version\": 3,\n \"id\": \"1f9d2a6b9c0d1234\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\",\n \"cwd\": \"/work/pi\",\n \"title\": \"optional session title\",\n \"parentSession\": \"optional lineage marker\"\n}\n```\n\nNotes:\n\n- `version` is optional in v1 files; absence means v1.\n- `parentSession` is an opaque lineage string. Current code writes either a session id or a session path depending on flow (`fork`, `forkFrom`, `createBranchedSession`, or explicit `newSession({ parentSession })`). Treat as metadata, not a typed foreign key.\n\n### Entry Base (`SessionEntryBase`)\n\nAll non-header entries include:\n\n```json\n{\n \"type\": \"...\",\n \"id\": \"8-char-id\",\n \"parentId\": \"previous-or-branch-parent\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\"\n}\n```\n\n`parentId` can be `null` for a root entry (first append, or after `resetLeaf()`).\n\n## Entry Taxonomy\n\n`SessionEntry` is the union of:\n\n- `message`\n- `thinking_level_change`\n- `model_change`\n- `compaction`\n- `branch_summary`\n- `custom`\n- `custom_message`\n- `label`\n- `ttsr_injection`\n- `session_init`\n- `mode_change`\n\n### `message`\n\nStores an `AgentMessage` directly.\n\n```json\n{\n \"type\": \"message\",\n \"id\": \"a1b2c3d4\",\n \"parentId\": null,\n \"timestamp\": \"2026-02-16T10:21:00.000Z\",\n \"message\": {\n \"role\": \"assistant\",\n \"provider\": \"anthropic\",\n \"model\": \"claude-sonnet-4-5\",\n \"content\": [{ \"type\": \"text\", \"text\": \"Done.\" }],\n \"usage\": { \"input\": 100, \"output\": 20, \"cacheRead\": 0, \"cacheWrite\": 0, \"cost\": { \"input\": 0, \"output\": 0, \"cacheRead\": 0, \"cacheWrite\": 0, \"total\": 0 } },\n \"timestamp\": 1760000000000\n }\n}\n```\n\n### `model_change`\n\n```json\n{\n \"type\": \"model_change\",\n \"id\": \"b1c2d3e4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:21:30.000Z\",\n \"model\": \"openai/gpt-4o\",\n \"role\": \"default\"\n}\n```\n\n`role` is optional; missing is treated as `default` in context reconstruction.\n\n### `thinking_level_change`\n\n```json\n{\n \"type\": \"thinking_level_change\",\n \"id\": \"c1d2e3f4\",\n \"parentId\": \"b1c2d3e4\",\n \"timestamp\": \"2026-02-16T10:22:00.000Z\",\n \"thinkingLevel\": \"high\"\n}\n```\n\n### `compaction`\n\n```json\n{\n \"type\": \"compaction\",\n \"id\": \"d1e2f3a4\",\n \"parentId\": \"c1d2e3f4\",\n \"timestamp\": \"2026-02-16T10:23:00.000Z\",\n \"summary\": \"Conversation summary\",\n \"shortSummary\": \"Short recap\",\n \"firstKeptEntryId\": \"a1b2c3d4\",\n \"tokensBefore\": 42000,\n \"details\": { \"readFiles\": [\"src/a.ts\"] },\n \"preserveData\": { \"hookState\": true },\n \"fromExtension\": false\n}\n```\n\n### `branch_summary`\n\n```json\n{\n \"type\": \"branch_summary\",\n \"id\": \"e1f2a3b4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:24:00.000Z\",\n \"fromId\": \"a1b2c3d4\",\n \"summary\": \"Summary of abandoned path\",\n \"details\": { \"note\": \"optional\" },\n \"fromExtension\": true\n}\n```\n\nIf branching from root (`branchFromId === null`), `fromId` is the literal string `\"root\"`.\n\n### `custom`\n\nExtension state persistence; ignored by `buildSessionContext`.\n\n```json\n{\n \"type\": \"custom\",\n \"id\": \"f1a2b3c4\",\n \"parentId\": \"e1f2a3b4\",\n \"timestamp\": \"2026-02-16T10:25:00.000Z\",\n \"customType\": \"my-extension\",\n \"data\": { \"state\": 1 }\n}\n```\n\n### `custom_message`\n\nExtension-provided message that does participate in LLM context.\n\n```json\n{\n \"type\": \"custom_message\",\n \"id\": \"a2b3c4d5\",\n \"parentId\": \"f1a2b3c4\",\n \"timestamp\": \"2026-02-16T10:26:00.000Z\",\n \"customType\": \"my-extension\",\n \"content\": \"Injected context\",\n \"display\": true,\n \"details\": { \"debug\": false }\n}\n```\n\n### `label`\n\n```json\n{\n \"type\": \"label\",\n \"id\": \"b2c3d4e5\",\n \"parentId\": \"a2b3c4d5\",\n \"timestamp\": \"2026-02-16T10:27:00.000Z\",\n \"targetId\": \"a1b2c3d4\",\n \"label\": \"checkpoint\"\n}\n```\n\n`label: undefined` clears a label for `targetId`.\n\n### `ttsr_injection`\n\n```json\n{\n \"type\": \"ttsr_injection\",\n \"id\": \"c2d3e4f5\",\n \"parentId\": \"b2c3d4e5\",\n \"timestamp\": \"2026-02-16T10:28:00.000Z\",\n \"injectedRules\": [\"ruleA\", \"ruleB\"]\n}\n```\n\n### `session_init`\n\n```json\n{\n \"type\": \"session_init\",\n \"id\": \"d2e3f4a5\",\n \"parentId\": \"c2d3e4f5\",\n \"timestamp\": \"2026-02-16T10:29:00.000Z\",\n \"systemPrompt\": \"...\",\n \"task\": \"...\",\n \"tools\": [\"read\", \"edit\"],\n \"outputSchema\": { \"type\": \"object\" }\n}\n```\n\n### `mode_change`\n\n```json\n{\n \"type\": \"mode_change\",\n \"id\": \"e2f3a4b5\",\n \"parentId\": \"d2e3f4a5\",\n \"timestamp\": \"2026-02-16T10:30:00.000Z\",\n \"mode\": \"plan\",\n \"data\": { \"planFile\": \"/tmp/plan.md\" }\n}\n```\n\n## Versioning and Migration\n\nCurrent session version: `3`.\n\n### v1 -> v2\n\nApplied when header `version` is missing or `< 2`:\n\n- Adds `id` and `parentId` to each non-header entry.\n- Reconstructs a linear parent chain using file order.\n- Migrates compaction field `firstKeptEntryIndex` -> `firstKeptEntryId` when present.\n- Sets header `version = 2`.\n\n### v2 -> v3\n\nApplied when header `version < 3`:\n\n- For `message` entries: rewrites legacy `message.role === \"hookMessage\"` to `\"custom\"`.\n- Sets header `version = 3`.\n\n### Migration Trigger and Persistence\n\n- Migrations run during session load (`setSessionFile`).\n- If any migration ran, the entire file is rewritten to disk immediately.\n- Migration mutates in-memory entries first, then persists rewritten JSONL.\n\n## Load and Compatibility Behavior\n\n`loadEntriesFromFile(path)` behavior:\n\n- Missing file (`ENOENT`) -> returns `[]`.\n- Non-parseable lines are handled by lenient JSONL parser (`parseJsonlLenient`).\n- If first parsed entry is not a valid session header (`type !== \"session\"` or missing string `id`) -> returns `[]`.\n\n`SessionManager.setSessionFile()` behavior:\n\n- `[]` from loader is treated as empty/nonexistent session and replaced with a new initialized session file at that path.\n- Valid files are loaded, migrated if needed, blob refs resolved, then indexed.\n\n## Tree and Leaf Semantics\n\nThe underlying model is append-only tree + mutable leaf pointer:\n\n- Every append method creates exactly one new entry whose `parentId` is current `leafId`.\n- The new entry becomes the new `leafId`.\n- `branch(entryId)` moves only `leafId`; existing entries remain unchanged.\n- `resetLeaf()` sets `leafId = null`; next append creates a new root entry (`parentId: null`).\n- `branchWithSummary()` sets leaf to branch target and appends a `branch_summary` entry.\n\n`getEntries()` returns all non-header entries in insertion order. Existing entries are not deleted in normal operation; rewrites preserve logical history while updating representation (migrations, move, targeted rewrite helpers).\n\n## Context Reconstruction (`buildSessionContext`)\n\n`buildSessionContext(entries, leafId, byId?)` resolves what is sent to the model.\n\nAlgorithm:\n\n1. Determine leaf:\n - `leafId === null` -> return empty context.\n - explicit `leafId` -> use that entry if found.\n - otherwise fallback to last entry.\n2. Walk `parentId` chain from leaf to root and reverse to root->leaf path.\n3. Derive runtime state across path:\n - `thinkingLevel` from latest `thinking_level_change` (default `\"off\"`)\n - model map from `model_change` entries (`role ?? \"default\"`)\n - fallback `models.default` from assistant message provider/model if no explicit model change\n - deduplicated `injectedTtsrRules` from all `ttsr_injection` entries\n - mode/modeData from latest `mode_change` (default mode `\"none\"`)\n4. Build message list:\n - `message` entries pass through\n - `custom_message` entries become `custom` AgentMessages via `createCustomMessage`\n - `branch_summary` entries become `branchSummary` AgentMessages via `createBranchSummaryMessage`\n - if a `compaction` exists on path:\n - emit compaction summary first (`createCompactionSummaryMessage`)\n - emit path entries starting at `firstKeptEntryId` up to the compaction boundary\n - emit entries after the compaction boundary\n\n`custom` and `session_init` entries do not inject model context directly.\n\n## Persistence Guarantees and Failure Model\n\n### Persist vs in-memory\n\n- `SessionManager.create/open/continueRecent/forkFrom` -> persistent mode (`persist = true`).\n- `SessionManager.inMemory` -> non-persistent mode (`persist = false`) with `MemorySessionStorage`.\n\n### Write pipeline\n\nWrites are serialized through an internal promise chain (`#persistChain`) and `NdjsonFileWriter`.\n\n- `append*` updates in-memory state immediately.\n- Persistence is deferred until at least one assistant message exists.\n - Before first assistant: entries are retained in memory; no file append occurs.\n - When first assistant exists: full in-memory session is flushed to file.\n - Afterwards: new entries append incrementally.\n\nRationale in code: avoid persisting sessions that never produced an assistant response.\n\n### Durability operations\n\n- `flush()` flushes writer and calls `fsync()`.\n- Atomic full rewrites (`#rewriteFile`) write to temp file, flush+fsync, close, then rename over target.\n- Used for migrations, `setSessionName`, `rewriteEntries`, move operations, and tool-call arg rewrites.\n\n### Error behavior\n\n- Persistence errors are latched (`#persistError`) and rethrown on subsequent operations.\n- First error is logged once with session file context.\n- Writer close is best-effort but propagates the first meaningful error.\n\n## Data Size Controls and Blob Externalization\n\nBefore persisting entries:\n\n- Large strings are truncated to `MAX_PERSIST_CHARS` (500,000 chars) with notice:\n - `\"[Session persistence truncated large content]\"`\n- Transient fields `partialJson` and `jsonlEvents` are removed.\n- If object has both `content` and `lineCount`, line count is recomputed after truncation.\n- Image blocks in `content` arrays with base64 length >= 1024 are externalized to blob refs:\n - stored as `blob:sha256:<hash>`\n - raw bytes written to blob store (`BlobStore.put`)\n\nOn load, blob refs are resolved back to base64 for message/custom_message image blocks.\n\n## Storage Abstractions\n\n`SessionStorage` interface provides all filesystem operations used by `SessionManager`:\n\n- sync: `ensureDirSync`, `existsSync`, `writeTextSync`, `statSync`, `listFilesSync`\n- async: `exists`, `readText`, `readTextPrefix`, `writeText`, `rename`, `unlink`, `openWriter`\n\nImplementations:\n\n- `FileSessionStorage`: real filesystem (Bun + node fs)\n- `MemorySessionStorage`: map-backed in-memory implementation for tests/non-persistent sessions\n\n`SessionStorageWriter` exposes `writeLine`, `flush`, `fsync`, `close`, `getError`.\n\n## Session Discovery Utilities\n\nDefined in `session-manager.ts`:\n\n- `getRecentSessions(sessionDir, limit)` -> lightweight metadata for UI/session picker\n- `findMostRecentSession(sessionDir)` -> newest by mtime\n- `list(cwd, sessionDir?)` -> sessions in one project scope\n- `listAll()` -> sessions across all project scopes under `~/.omp/agent/sessions`\n\nMetadata extraction reads only a prefix (`readTextPrefix(..., 4096)`) where possible.\n\n## Related but Distinct: Prompt History Storage\n\n`HistoryStorage` (`history-storage.ts`) is a separate SQLite subsystem for prompt recall/search, not session replay.\n\n- DB: `~/.omp/agent/history.db`\n- Table: `history(id, prompt, created_at, cwd)`\n- FTS5 index: `history_fts` with trigger-maintained sync\n- Deduplicates consecutive identical prompts using in-memory last-prompt cache\n- Async insertion (`setImmediate`) so prompt capture does not block turn execution\n\nUse session files for conversation graph/state replay; use `HistoryStorage` for prompt history UX.",
46
- "skills.md": "# Skills\n\nSkills are file-backed capability packs discovered at startup and exposed to the model as:\n\n- lightweight metadata in the system prompt (name + description)\n- on-demand content via `read skill://...`\n- optional interactive `/skill:<name>` commands\n\nThis document covers current runtime behavior in `src/extensibility/skills.ts`, `src/discovery/builtin.ts`, `src/internal-urls/skill-protocol.ts`, and `src/discovery/agents-md.ts`.\n\n## What a skill is in this codebase\n\nA discovered skill is represented as:\n\n- `name`\n- `description`\n- `filePath` (the `SKILL.md` path)\n- `baseDir` (skill directory)\n- source metadata (`provider`, `level`, path)\n\nThe runtime only requires `name` and `path` for validity. In practice, matching quality depends on `description` being meaningful.\n\n## Required layout and SKILL.md expectations\n\n### Directory layout\n\nFor provider-based discovery (native/Claude/Codex/Agents/plugin providers), skills are discovered as **one level under `skills/`**:\n\n- `<skills-root>/<skill-name>/SKILL.md`\n\nNested patterns like `<skills-root>/group/<skill>/SKILL.md` are not discovered by provider loaders.\n\nFor `skills.customDirectories`, scanning uses the same non-recursive layout (`*/SKILL.md`).\n\n```text\nProvider-discovered layout (non-recursive under skills/):\n\n<root>/skills/\n ├─ postgres/\n │ └─ SKILL.md ✅ discovered\n ├─ pdf/\n │ └─ SKILL.md ✅ discovered\n └─ team/\n └─ internal/\n └─ SKILL.md ❌ not discovered by provider loaders\n\nCustom-directory scanning is also non-recursive, so nested paths are ignored unless you point `customDirectories` at that nested parent.\n```\n\n\n### `SKILL.md` frontmatter\n\nSupported frontmatter fields on the skill type:\n\n- `name?: string`\n- `description?: string`\n- `globs?: string[]`\n- `alwaysApply?: boolean`\n- additional keys are preserved as unknown metadata\n\nCurrent runtime behavior:\n\n- `name` defaults to the skill directory name\n- `description` is required for:\n - native `.omp` provider skill discovery (`requireDescription: true`)\n - `skills.customDirectories` scans via `scanSkillsFromDir` in `src/discovery/helpers.ts` (non-recursive)\n- non-native providers can load skills without description\n\n## Discovery pipeline\n\n`discoverSkills()` in `src/extensibility/skills.ts` does two passes:\n\n1. **Capability providers** via `loadCapability(\"skills\")`\n2. **Custom directories** via `scanSkillsFromDir(..., { requireDescription: true })` (one-level directory enumeration)\n\nIf `skills.enabled` is `false`, discovery returns no skills.\n\n### Built-in skill providers and precedence\n\nProvider ordering is priority-first (higher wins), then registration order for ties.\n\nCurrent registered skill providers:\n\n1. `native` (priority 100) — `.omp` user/project skills via `src/discovery/builtin.ts`\n2. `claude` (priority 80)\n3. priority 70 group (in registration order):\n - `claude-plugins`\n - `agents`\n - `codex`\n\nDedup key is skill name. First item with a given name wins.\n\n### Source toggles and filtering\n\n`discoverSkills()` applies these controls:\n\n- source toggles: `enableCodexUser`, `enableClaudeUser`, `enableClaudeProject`, `enablePiUser`, `enablePiProject`\n- glob filters on skill name:\n - `ignoredSkills` (exclude)\n - `includeSkills` (include allowlist; empty means include all)\n\nFilter order is:\n\n1. source enabled\n2. not ignored\n3. included (if include list present)\n\nFor providers other than codex/claude/native (for example `agents`, `claude-plugins`), enablement currently falls back to: enabled if **any** built-in source toggle is enabled.\n\n### Collision and duplicate handling\n\n- Capability dedup already keeps first skill per name (highest-precedence provider)\n- `extensibility/skills.ts` additionally:\n - de-duplicates identical files by `realpath` (symlink-safe)\n - emits collision warnings when a later skill name conflicts\n - keeps the convenience `discoverSkillsFromDir({ dir, source })` API as a thin adapter over `scanSkillsFromDir`\n- Custom-directory skills are merged after provider skills and follow the same collision behavior\n\n## Runtime usage behavior\n\n### System prompt exposure\n\nSystem prompt construction (`src/system-prompt.ts`) uses discovered skills as follows:\n\n- if `read` tool is available **and** no explicit preloaded skills are supplied:\n - include discovered skills list in prompt\n- otherwise:\n - omit discovered list\n- if preloaded skills are provided (for example from Task tool skill pinning):\n - inline full preloaded skill contents in `<preloaded_skills>`\n\n### Task tool skill pinning\n\nWhen a Task call specifies `skills`, runtime resolves names against session skills:\n\n- unknown names cause an immediate error with available skill names\n- resolved skills are passed as preloaded skills to subagents\n\n### Interactive `/skill:<name>` commands\n\nIf `skills.enableSkillCommands` is true, interactive mode registers one slash command per discovered skill.\n\n`/skill:<name> [args]` behavior:\n\n- reads the skill file directly from `filePath`\n- strips frontmatter\n- injects skill body as a follow-up custom message\n- appends metadata (`Skill: <path>`, optional `User: <args>`)\n\n## `skill://` URL behavior\n\n`src/internal-urls/skill-protocol.ts` supports:\n\n- `skill://<name>` → resolves to that skill's `SKILL.md`\n- `skill://<name>/<relative-path>` → resolves inside that skill directory\n\n```text\nskill:// URL resolution\n\nskill://pdf\n -> <pdf-base>/SKILL.md\n\nskill://pdf/references/tables.md\n -> <pdf-base>/references/tables.md\n\nGuards:\n- reject absolute paths\n- reject `..` traversal\n- reject any resolved path escaping <pdf-base>\n```\n\nResolution details:\n\n- skill name must match exactly\n- relative paths are URL-decoded\n- absolute paths are rejected\n- path traversal (`..`) is rejected\n- resolved path must remain within `baseDir`\n- missing files return an explicit `File not found` error\n\nContent type:\n\n- `.md` => `text/markdown`\n- everything else => `text/plain`\n\nNo fallback search is performed for missing assets.\n\n## Skills vs AGENTS.md, commands, tools, hooks\n\n### Skills vs AGENTS.md\n\n- **Skills**: named, optional capability packs selected by task context or explicitly requested\n- **AGENTS.md/context files**: persistent instruction files loaded as context-file capability and merged by level/depth rules\n\n`src/discovery/agents-md.ts` specifically walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files (up to depth 20), excluding hidden-directory segments.\n\n### Skills vs slash commands\n\n- **Skills**: model-readable knowledge/workflow content\n- **Slash commands**: user-invoked command entry points\n- `/skill:<name>` is a convenience wrapper that injects skill text; it does not change skill discovery semantics\n\n### Skills vs custom tools\n\n- **Skills**: documentation/workflow content loaded through prompt context and `read`\n- **Custom tools**: executable tool APIs callable by the model with schemas and runtime side effects\n\n### Skills vs hooks\n\n- **Skills**: passive content\n- **Hooks**: event-driven runtime interceptors that can block/modify behavior during execution\n\n## Practical authoring guidance tied to discovery logic\n\n- Put each skill in its own directory: `<skills-root>/<skill-name>/SKILL.md`\n- Always include explicit `name` and `description` frontmatter\n- Keep referenced assets under the same skill directory and access with `skill://<name>/...`\n- For nested taxonomy (`team/domain/skill`), point `skills.customDirectories` to the nested parent directory; scanning itself remains non-recursive\n- Avoid duplicate skill names across sources; first match wins by provider precedence\n",
46
+ "skills.md": "# Skills\n\nSkills are file-backed capability packs discovered at startup and exposed to the model as:\n\n- lightweight metadata in the system prompt (name + description)\n- on-demand content via `read skill://...`\n- optional interactive `/skill:<name>` commands\n\nThis document covers current runtime behavior in `src/extensibility/skills.ts`, `src/discovery/builtin.ts`, `src/internal-urls/skill-protocol.ts`, and `src/discovery/agents-md.ts`.\n\n## What a skill is in this codebase\n\nA discovered skill is represented as:\n\n- `name`\n- `description`\n- `filePath` (the `SKILL.md` path)\n- `baseDir` (skill directory)\n- source metadata (`provider`, `level`, path)\n\nThe runtime only requires `name` and `path` for validity. In practice, matching quality depends on `description` being meaningful.\n\n## Required layout and SKILL.md expectations\n\n### Directory layout\n\nFor provider-based discovery (native/Claude/Codex/Agents/plugin providers), skills are discovered as **one level under `skills/`**:\n\n- `<skills-root>/<skill-name>/SKILL.md`\n\nNested patterns like `<skills-root>/group/<skill>/SKILL.md` are not discovered by provider loaders.\n\nFor `skills.customDirectories`, scanning uses the same non-recursive layout (`*/SKILL.md`).\n\n```text\nProvider-discovered layout (non-recursive under skills/):\n\n<root>/skills/\n ├─ postgres/\n │ └─ SKILL.md ✅ discovered\n ├─ pdf/\n │ └─ SKILL.md ✅ discovered\n └─ team/\n └─ internal/\n └─ SKILL.md ❌ not discovered by provider loaders\n\nCustom-directory scanning is also non-recursive, so nested paths are ignored unless you point `customDirectories` at that nested parent.\n```\n\n\n### `SKILL.md` frontmatter\n\nSupported frontmatter fields on the skill type:\n\n- `name?: string`\n- `description?: string`\n- `globs?: string[]`\n- `alwaysApply?: boolean`\n- additional keys are preserved as unknown metadata\n\nCurrent runtime behavior:\n\n- `name` defaults to the skill directory name\n- `description` is required for:\n - native `.omp` provider skill discovery (`requireDescription: true`)\n - `skills.customDirectories` scans via `scanSkillsFromDir` in `src/discovery/helpers.ts` (non-recursive)\n- non-native providers can load skills without description\n\n## Discovery pipeline\n\n`discoverSkills()` in `src/extensibility/skills.ts` does two passes:\n\n1. **Capability providers** via `loadCapability(\"skills\")`\n2. **Custom directories** via `scanSkillsFromDir(..., { requireDescription: true })` (one-level directory enumeration)\n\nIf `skills.enabled` is `false`, discovery returns no skills.\n\n### Built-in skill providers and precedence\n\nProvider ordering is priority-first (higher wins), then registration order for ties.\n\nCurrent registered skill providers:\n\n1. `native` (priority 100) — `.omp` user/project skills via `src/discovery/builtin.ts`\n2. `claude` (priority 80)\n3. priority 70 group (in registration order):\n - `claude-plugins`\n - `agents`\n - `codex`\n\nDedup key is skill name. First item with a given name wins.\n\n### Source toggles and filtering\n\n`discoverSkills()` applies these controls:\n\n- source toggles: `enableCodexUser`, `enableClaudeUser`, `enableClaudeProject`, `enablePiUser`, `enablePiProject`\n- glob filters on skill name:\n - `ignoredSkills` (exclude)\n - `includeSkills` (include allowlist; empty means include all)\n\nFilter order is:\n\n1. source enabled\n2. not ignored\n3. included (if include list present)\n\nFor providers other than codex/claude/native (for example `agents`, `claude-plugins`), enablement currently falls back to: enabled if **any** built-in source toggle is enabled.\n\n### Collision and duplicate handling\n\n- Capability dedup already keeps first skill per name (highest-precedence provider)\n- `extensibility/skills.ts` additionally:\n - de-duplicates identical files by `realpath` (symlink-safe)\n - emits collision warnings when a later skill name conflicts\n - keeps the convenience `discoverSkillsFromDir({ dir, source })` API as a thin adapter over `scanSkillsFromDir`\n- Custom-directory skills are merged after provider skills and follow the same collision behavior\n\n## Runtime usage behavior\n\n### System prompt exposure\n\nSystem prompt construction (`src/system-prompt.ts`) uses discovered skills as follows:\n\n- if `read` tool is available:\n - include discovered skills list in prompt\n- otherwise:\n - omit discovered list\n\nTask tool subagents receive the session's discovered/provided skills list via normal session creation; there is no per-task skill pinning override.\n\n### Interactive `/skill:<name>` commands\n\nIf `skills.enableSkillCommands` is true, interactive mode registers one slash command per discovered skill.\n\n`/skill:<name> [args]` behavior:\n\n- reads the skill file directly from `filePath`\n- strips frontmatter\n- injects skill body as a follow-up custom message\n- appends metadata (`Skill: <path>`, optional `User: <args>`)\n\n## `skill://` URL behavior\n\n`src/internal-urls/skill-protocol.ts` supports:\n\n- `skill://<name>` → resolves to that skill's `SKILL.md`\n- `skill://<name>/<relative-path>` → resolves inside that skill directory\n\n```text\nskill:// URL resolution\n\nskill://pdf\n -> <pdf-base>/SKILL.md\n\nskill://pdf/references/tables.md\n -> <pdf-base>/references/tables.md\n\nGuards:\n- reject absolute paths\n- reject `..` traversal\n- reject any resolved path escaping <pdf-base>\n```\n\nResolution details:\n\n- skill name must match exactly\n- relative paths are URL-decoded\n- absolute paths are rejected\n- path traversal (`..`) is rejected\n- resolved path must remain within `baseDir`\n- missing files return an explicit `File not found` error\n\nContent type:\n\n- `.md` => `text/markdown`\n- everything else => `text/plain`\n\nNo fallback search is performed for missing assets.\n\n## Skills vs AGENTS.md, commands, tools, hooks\n\n### Skills vs AGENTS.md\n\n- **Skills**: named, optional capability packs selected by task context or explicitly requested\n- **AGENTS.md/context files**: persistent instruction files loaded as context-file capability and merged by level/depth rules\n\n`src/discovery/agents-md.ts` specifically walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files (up to depth 20), excluding hidden-directory segments.\n\n### Skills vs slash commands\n\n- **Skills**: model-readable knowledge/workflow content\n- **Slash commands**: user-invoked command entry points\n- `/skill:<name>` is a convenience wrapper that injects skill text; it does not change skill discovery semantics\n\n### Skills vs custom tools\n\n- **Skills**: documentation/workflow content loaded through prompt context and `read`\n- **Custom tools**: executable tool APIs callable by the model with schemas and runtime side effects\n\n### Skills vs hooks\n\n- **Skills**: passive content\n- **Hooks**: event-driven runtime interceptors that can block/modify behavior during execution\n\n## Practical authoring guidance tied to discovery logic\n\n- Put each skill in its own directory: `<skills-root>/<skill-name>/SKILL.md`\n- Always include explicit `name` and `description` frontmatter\n- Keep referenced assets under the same skill directory and access with `skill://<name>/...`\n- For nested taxonomy (`team/domain/skill`), point `skills.customDirectories` to the nested parent directory; scanning itself remains non-recursive\n- Avoid duplicate skill names across sources; first match wins by provider precedence\n",
47
47
  "slash-command-internals.md": "# Slash command internals\n\nThis document describes how slash commands are discovered, deduplicated, surfaced in interactive mode, and expanded at prompt time in `coding-agent`.\n\n## Implementation files\n\n- [`src/extensibility/slash-commands.ts`](../packages/coding-agent/src/extensibility/slash-commands.ts)\n- [`src/capability/slash-command.ts`](../packages/coding-agent/src/capability/slash-command.ts)\n- [`src/discovery/builtin.ts`](../packages/coding-agent/src/discovery/builtin.ts)\n- [`src/discovery/claude.ts`](../packages/coding-agent/src/discovery/claude.ts)\n- [`src/discovery/codex.ts`](../packages/coding-agent/src/discovery/codex.ts)\n- [`src/discovery/claude-plugins.ts`](../packages/coding-agent/src/discovery/claude-plugins.ts)\n- [`src/capability/index.ts`](../packages/coding-agent/src/capability/index.ts)\n- [`src/discovery/helpers.ts`](../packages/coding-agent/src/discovery/helpers.ts)\n- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`src/modes/interactive-mode.ts`](../packages/coding-agent/src/modes/interactive-mode.ts)\n- [`src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)\n- [`src/modes/utils/ui-helpers.ts`](../packages/coding-agent/src/modes/utils/ui-helpers.ts)\n- [`src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts)\n\n## 1) Discovery model\n\nSlash commands are a capability (`id: \"slash-commands\"`) keyed by command name (`key: cmd => cmd.name`).\n\nThe capability registry loads all registered providers, sorted by provider priority descending, and deduplicates by key with **first wins** semantics.\n\n### Provider precedence\n\nCurrent slash-command providers and priorities:\n\n1. `native` (OMP) — priority `100`\n2. `claude` — priority `80`\n3. `claude-plugins` — priority `70`\n4. `codex` — priority `70`\n\nTie behavior: equal-priority providers keep registration order. Current import order registers `claude-plugins` before `codex`, so plugin commands win over codex commands on name collisions.\n\n### Name-collision behavior\n\nFor `slash-commands`, collisions are resolved strictly by capability dedup:\n\n- highest-precedence item is kept in `result.items`\n- lower-precedence duplicates remain only in `result.all` and are marked `_shadowed = true`\n\nThis applies across providers and also within a provider if it returns duplicate names.\n\n### File scanning behavior\n\nProviders mostly use `loadFilesFromDir(...)`, which currently:\n\n- defaults to non-recursive matching (`*.md`)\n- uses native glob with `gitignore: true`, `hidden: false`\n- reads each matched file and transforms it into a `SlashCommand`\n\nSo hidden files/directories are not loaded, and ignored paths are skipped.\n\n## 2) Provider-specific source paths and local precedence\n\n## `native` provider (`builtin.ts`)\n\nSearch roots come from `.omp` directories:\n\n- project: `<cwd>/.omp/commands/*.md`\n- user: `~/.omp/agent/commands/*.md`\n\n`getConfigDirs()` returns project first, then user, so **project native commands beat user native commands** when names collide.\n\n## `claude` provider (`claude.ts`)\n\nLoads:\n\n- user: `~/.claude/commands/*.md`\n- project: `<cwd>/.claude/commands/*.md`\n\nThe provider pushes user items before project items, so **user Claude commands beat project Claude commands** on same-name collisions inside this provider.\n\n## `codex` provider (`codex.ts`)\n\nLoads:\n\n- user: `~/.codex/commands/*.md`\n- project: `<cwd>/.codex/commands/*.md`\n\nBoth sides are loaded then flattened in user-first order, so **user Codex commands beat project Codex commands** on collisions.\n\nCodex command content is parsed with frontmatter stripping (`parseFrontmatter`), and command name can be overridden by frontmatter `name`; otherwise filename is used.\n\n## `claude-plugins` provider (`claude-plugins.ts`)\n\nLoads plugin command roots from `~/.claude/plugins/installed_plugins.json`, then scans `<pluginRoot>/commands/*.md`.\n\nOrdering follows registry iteration order and per-plugin entry order from that JSON data. There is no additional sort step.\n\n## 3) Materialization to runtime `FileSlashCommand`\n\n`loadSlashCommands()` in `src/extensibility/slash-commands.ts` converts capability items into `FileSlashCommand` objects used at prompt time.\n\nFor each command:\n\n1. parse frontmatter/body (`parseFrontmatter`)\n2. description source:\n - `frontmatter.description` if present\n - else first non-empty body line (trimmed, max 60 chars with `...`)\n3. keep parsed body as executable template content\n4. compute a display source string like `via Claude Code Project`\n\nFrontmatter parse severity is source-dependent:\n\n- `native` level -> parse errors are `fatal`\n- `user`/`project` levels -> parse errors are `warn` with fallback parsing\n\n### Bundled fallback commands\n\nAfter filesystem/provider commands, embedded command templates are appended (`EMBEDDED_COMMAND_TEMPLATES`) if their names are not already present.\n\nCurrent embedded set comes from `src/task/commands.ts` and is used as a fallback (`source: \"bundled\"`).\n\n## 4) Interactive mode: where command lists come from\n\nInteractive mode combines multiple command sources for autocomplete and command routing.\n\nAt construction time it builds a pending command list from:\n\n- built-ins (`BUILTIN_SLASH_COMMANDS`, includes argument completion and inline hints for selected commands)\n- extension-registered slash commands (`extensionRunner.getRegisteredCommands(...)`)\n- TypeScript custom commands (`session.customCommands`), mapped to slash command labels\n- optional skill commands (`/skill:<name>`) when `skills.enableSkillCommands` is enabled\n\nThen `init()` calls `refreshSlashCommandState(...)` to load file-based commands and install one `CombinedAutocompleteProvider` containing:\n\n- pending commands above\n- discovered file-based commands\n\n`refreshSlashCommandState(...)` also updates `session.setSlashCommands(...)` so prompt expansion uses the same discovered file command set.\n\n### Refresh lifecycle\n\nSlash command state is refreshed:\n\n- during interactive init\n- after `/move` changes working directory (`handleMoveCommand` calls `resetCapabilities()` then `refreshSlashCommandState(newCwd)`)\n\nThere is no continuous file watcher for command directories.\n\n### Other surfacing\n\nThe Extensions dashboard also loads `slash-commands` capability and displays active/shadowed command entries, including `_shadowed` duplicates.\n\n## 5) Prompt pipeline placement\n\n`AgentSession.prompt(...)` slash handling order (when `expandPromptTemplates !== false`):\n\n1. **Extension commands** (`#tryExecuteExtensionCommand`) \n If `/name` matches extension-registered command, handler executes immediately and prompt returns.\n2. **TypeScript custom commands** (`#tryExecuteCustomCommand`) \n Boundary only: if matched, it executes and may return:\n - `string` -> replace prompt text with that string\n - `void/undefined` -> treated as handled; no LLM prompt\n3. **File-based slash commands** (`expandSlashCommand`) \n If text still starts with `/`, attempt markdown command expansion.\n4. **Prompt templates** (`expandPromptTemplate`) \n Applied after slash/custom processing.\n5. **Delivery** \n - idle: prompt is sent immediately to agent\n - streaming: prompt is queued as steer/follow-up depending on `streamingBehavior`\n\nThis is why slash command expansion sits before prompt-template expansion, and why custom commands can transform away the leading slash before file-command matching.\n\n## 6) Expansion semantics for file-based slash commands\n\n`expandSlashCommand(text, fileCommands)` behavior:\n\n- only runs when text begins with `/`\n- parses command name from first token after `/`\n- parses args from remaining text via `parseCommandArgs`\n- finds exact name match in loaded `fileCommands`\n- if matched, applies:\n - positional replacement: `$1`, `$2`, ...\n - aggregate replacement: `$ARGUMENTS` and `$@`\n - then template rendering via `renderPromptTemplate` with `{ args, ARGUMENTS, arguments }`\n- if no match, returns original text unchanged\n\n### `parseCommandArgs` caveats\n\nThe parser is simple quote-aware splitting:\n\n- supports `'single'` and `\"double\"` quoting to keep spaces\n- strips quote delimiters\n- does not implement backslash escaping rules\n- unmatched quote is not an error; parser consumes until end\n\n## 7) Unknown `/...` behavior\n\nUnknown slash input is **not rejected** by core slash logic.\n\nIf command is not handled by extension/custom/file layers, `expandSlashCommand` returns original text, and the literal `/...` prompt proceeds through normal prompt-template expansion and LLM delivery.\n\nInteractive mode separately hard-handles many built-ins in `InputController` (for example `/settings`, `/model`, `/mcp`, `/move`, `/exit`). Those are consumed before `session.prompt(...)` and therefore never reach file-command expansion in that path.\n\n## 8) Streaming-time differences vs idle\n\n## Idle path\n\n- `session.prompt(\"/x ...\")` runs command pipeline and either executes command immediately or sends expanded text directly.\n\n## Streaming path (`session.isStreaming === true`)\n\n- `prompt(...)` still runs extension/custom/file/template transforms first\n- then requires `streamingBehavior`:\n - `\"steer\"` -> queue interrupt message (`agent.steer`)\n - `\"followUp\"` -> queue post-turn message (`agent.followUp`)\n- if `streamingBehavior` is omitted, prompt throws an error\n\n### Important command-specific streaming behavior\n\n- Extension commands are executed immediately even during streaming (not queued as text).\n- `steer(...)`/`followUp(...)` helper methods reject extension commands (`#throwIfExtensionCommand`) to avoid queuing command text for handlers that must run synchronously.\n- Compaction queue replay uses `isKnownSlashCommand(...)` to decide whether queued entries should be replayed via `session.prompt(...)` (for known slash commands) vs raw steer/follow-up methods.\n\n## 9) Error handling and failure surfaces\n\n- Provider load failures are isolated; registry collects warnings and continues with other providers.\n- Invalid slash command items (missing name/path/content or invalid level) are dropped by capability validation.\n- Frontmatter parse failures:\n - native commands: fatal parse error bubbles\n - non-native commands: warning + fallback key/value parse\n- Extension/custom command handler exceptions are caught and reported via extension error channel (or logger fallback for custom commands without extension runner), and treated as handled (no unintended fallback execution).\n",
48
48
  "task-agent-discovery.md": "# Task Agent Discovery and Selection\n\nThis document describes how the task subsystem discovers agent definitions, merges multiple sources, and resolves a requested agent at execution time.\n\nIt covers runtime behavior as implemented today, including precedence, invalid-definition handling, and spawn/depth constraints that can make an agent effectively unavailable.\n\n## Implementation files\n\n- [`src/task/discovery.ts`](../packages/coding-agent/src/task/discovery.ts)\n- [`src/task/agents.ts`](../packages/coding-agent/src/task/agents.ts)\n- [`src/task/types.ts`](../packages/coding-agent/src/task/types.ts)\n- [`src/task/index.ts`](../packages/coding-agent/src/task/index.ts)\n- [`src/task/commands.ts`](../packages/coding-agent/src/task/commands.ts)\n- [`src/prompts/agents/task.md`](../packages/coding-agent/src/prompts/agents/task.md)\n- [`src/prompts/tools/task.md`](../packages/coding-agent/src/prompts/tools/task.md)\n- [`src/discovery/helpers.ts`](../packages/coding-agent/src/discovery/helpers.ts)\n- [`src/config.ts`](../packages/coding-agent/src/config.ts)\n- [`src/task/executor.ts`](../packages/coding-agent/src/task/executor.ts)\n\n---\n\n## Agent definition shape\n\nTask agents normalize into `AgentDefinition` (`src/task/types.ts`):\n\n- `name`, `description`, `systemPrompt` (required for a valid loaded agent)\n- optional `tools`, `spawns`, `model`, `thinkingLevel`, `output`\n- `source`: `\"bundled\" | \"user\" | \"project\"`\n- optional `filePath`\n\nParsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers.ts`):\n\n- missing `name` or `description` => invalid (`null`), caller treats as parse failure\n- `tools` accepts CSV or array; if provided, `submit_result` is auto-added\n- `spawns` accepts `*`, CSV, or array\n- backward-compat behavior: if `spawns` missing but `tools` includes `task`, `spawns` becomes `*`\n- `output` is passed through as opaque schema data\n\n## Bundled agents\n\nBundled agents are embedded at build time (`src/task/agents.ts`) using text imports.\n\n`EMBEDDED_AGENT_DEFS` defines:\n\n- `explore`, `plan`, `designer`, `reviewer` from prompt files\n- `task` and `quick_task` from shared `task.md` body plus injected frontmatter\n\nLoading path:\n\n1. `loadBundledAgents()` parses embedded markdown with `parseAgent(..., \"bundled\", \"fatal\")`\n2. results are cached in-memory (`bundledAgentsCache`)\n3. `clearBundledAgentsCache()` is test-only cache reset\n\nBecause bundled parsing uses `level: \"fatal\"`, malformed bundled frontmatter throws and can fail discovery entirely.\n\n## Filesystem and plugin discovery\n\n`discoverAgents(cwd, home)` (`src/task/discovery.ts`) merges agents from multiple places before appending bundled definitions.\n\n### Discovery inputs\n\n1. User config agent dirs from `getConfigDirs(\"agents\", { project: false })`\n2. Nearest project agent dirs from `findAllNearestProjectConfigDirs(\"agents\", cwd)`\n3. Claude plugin roots (`listClaudePluginRoots(home)`) with `agents/` subdirs\n4. Bundled agents (`loadBundledAgents()`)\n\n### Actual source order\n\nSource-family order comes from `getConfigDirs(\"\", { project: false })`, which is derived from `priorityList` in `src/config.ts`:\n\n1. `.omp`\n2. `.claude`\n3. `.codex`\n4. `.gemini`\n\nFor each source family, discovery order is:\n\n1. nearest project dir for that source (if found)\n2. user dir for that source\n\nAfter all source-family dirs, plugin `agents/` dirs are appended (project-scope plugins first, then user-scope).\n\nBundled agents are appended last.\n\n### Important caveat: stale comments vs current code\n\n`discovery.ts` header comments still mention `.pi` and do not mention `.codex`/`.gemini`. Actual runtime order is driven by `src/config.ts` and currently uses `.omp`, `.claude`, `.codex`, `.gemini`.\n\n## Merge and collision rules\n\nDiscovery uses first-wins dedup by exact `agent.name`:\n\n- A `Set<string>` tracks seen names.\n- Loaded agents are flattened in directory order and kept only if name unseen.\n- Bundled agents are filtered against the same set and only added if still unseen.\n\nImplications:\n\n- Project overrides user for same source family.\n- Higher-priority source family overrides lower (`.omp` before `.claude`, etc.).\n- Non-bundled agents override bundled agents with the same name.\n- Name matching is case-sensitive (`Task` and `task` are distinct).\n- Within one directory, markdown files are read in lexicographic filename order before dedup.\n\n## Invalid/missing agent file behavior\n\nPer directory (`loadAgentsFromDir`):\n\n- unreadable/missing directory: treated as empty (`readdir(...).catch(() => [])`)\n- file read or parse failure: warning logged, file skipped\n- parse path uses `parseAgent(..., level: \"warn\")`\n\nFrontmatter failure behavior comes from `parseFrontmatter`:\n\n- parse error at `warn` level logs warning\n- parser falls back to a simple `key: value` line parser\n- if required fields are still missing, `parseAgentFields` fails, then `AgentParsingError` is thrown and caught by caller (file skipped)\n\nNet effect: one bad custom agent file does not abort discovery of other files.\n\n## Agent lookup and selection\n\nLookup is exact-name linear search:\n\n- `getAgent(agents, name)` => `agents.find(a => a.name === name)`\n\nIn task execution (`TaskTool.execute`):\n\n1. agents are rediscovered at call time (`discoverAgents(this.session.cwd)`)\n2. requested `params.agent` is resolved through `getAgent`\n3. missing agent returns immediate tool response:\n - `Unknown agent \"...\". Available: ...`\n - no subprocess runs\n\n### Description vs execution-time discovery\n\n`TaskTool.create()` builds the tool description from discovery results at initialization time (`buildDescription`).\n\n`execute()` rediscoveres agents again. So the runtime set can differ from what was listed in the earlier tool description if agent files changed mid-session.\n\n## Structured-output guardrails and schema precedence\n\nRuntime output schema precedence in `TaskTool.execute`:\n\n1. agent frontmatter `output`\n2. task call `params.schema`\n3. parent session `outputSchema`\n\n(`effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema`)\n\nPrompt-time guardrail text in `src/prompts/tools/task.md` warns about mismatch behavior for structured-output agents (`explore`, `reviewer`): output-format instructions in prose can conflict with built-in schema and produce `null` outputs.\n\nThis is guidance, not hard runtime validation logic in `discoverAgents`.\n\n## Command discovery interaction\n\n`src/task/commands.ts` is parallel infrastructure for workflow commands (not agent definitions), but it follows the same overall pattern:\n\n- discover from capability providers first\n- deduplicate by name with first-wins\n- append bundled commands if still unseen\n- exact-name lookup via `getCommand`\n\nIn `src/task/index.ts`, command helpers are re-exported with agent discovery helpers. Agent discovery itself does not depend on command discovery at runtime.\n\n## Availability constraints beyond discovery\n\nAn agent can be discoverable but still unavailable to run because of execution guardrails.\n\n### Parent spawn policy\n\n`TaskTool.execute` checks `session.getSessionSpawns()`:\n\n- `\"*\"` => allow any\n- `\"\"` => deny all\n- CSV list => allow only listed names\n\nIf denied: immediate `Cannot spawn '...'. Allowed: ...` response.\n\n### Blocked self-recursion env guard\n\n`PI_BLOCKED_AGENT` is read at tool construction. If request matches, execution is rejected with recursion-prevention message.\n\n### Recursion-depth gating (task tool availability inside child sessions)\n\nIn `runSubprocess` (`src/task/executor.ts`):\n\n- depth computed from `taskDepth`\n- `task.maxRecursionDepth` controls cutoff\n- when at max depth:\n - `task` tool is removed from child tool list\n - child `spawns` env is set to empty\n\nSo deeper levels cannot spawn further tasks even if the agent definition includes `spawns`.\n\n## Plan mode caveat (current implementation)\n\n`TaskTool.execute` computes an `effectiveAgent` for plan mode (prepends plan-mode prompt, forces read-only tool subset, clears spawns), but `runSubprocess` is called with `agent` rather than `effectiveAgent`.\n\nCurrent effect:\n\n- model override / thinking level / output schema are derived from `effectiveAgent`\n- system prompt and tool/spawn restrictions from `effectiveAgent` are not passed through in this call path\n\nThis is an implementation caveat worth knowing when reading plan-mode behavior expectations.\n",
49
49
  "theme.md": "# Theming Reference\n\nThis document describes how theming works in the coding-agent today: schema, loading, runtime behavior, and failure modes.\n\n## What the theme system controls\n\nThe theme system drives:\n\n- foreground/background color tokens used across the TUI\n- markdown styling adapters (`getMarkdownTheme()`)\n- selector/editor/settings list adapters (`getSelectListTheme()`, `getEditorTheme()`, `getSettingsListTheme()`)\n- symbol preset + symbol overrides (`unicode`, `nerd`, `ascii`)\n- syntax highlighting colors used by native highlighter (`@oh-my-pi/pi-natives`)\n- status line segment colors\n\nPrimary implementation: `src/modes/theme/theme.ts`.\n\n## Theme JSON shape\n\nTheme files are JSON objects validated against the runtime schema in `theme.ts` (`ThemeJsonSchema`) and mirrored by `src/modes/theme/theme-schema.json`.\n\nTop-level fields:\n\n- `name` (required)\n- `colors` (required; all color tokens required)\n- `vars` (optional; reusable color variables)\n- `export` (optional; HTML export colors)\n- `symbols` (optional)\n - `preset` (optional: `unicode | nerd | ascii`)\n - `overrides` (optional: key/value overrides for `SymbolKey`)\n\nColor values accept:\n\n- hex string (`\"#RRGGBB\"`)\n- 256-color index (`0..255`)\n- variable reference string (resolved through `vars`)\n- empty string (`\"\"`) meaning terminal default (`\\x1b[39m` fg, `\\x1b[49m` bg)\n\n## Required color tokens (current)\n\nAll tokens below are required in `colors`.\n\n### Core text and borders (11)\n\n`accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`\n\n### Background blocks (7)\n\n`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`, `statusLineBg`\n\n### Message/tool text (5)\n\n`userMessageText`, `customMessageText`, `customMessageLabel`, `toolTitle`, `toolOutput`\n\n### Markdown (10)\n\n`mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet`\n\n### Tool diff + syntax highlighting (12)\n\n`toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`,\n`syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`\n\n### Mode/thinking borders (8)\n\n`thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `bashMode`, `pythonMode`\n\n### Status line segment colors (14)\n\n`statusLineSep`, `statusLineModel`, `statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`, `statusLineContext`, `statusLineSpend`, `statusLineStaged`, `statusLineDirty`, `statusLineUntracked`, `statusLineOutput`, `statusLineCost`, `statusLineSubagents`\n\n## Optional tokens\n\n### `export` section (optional)\n\nUsed for HTML export theming helpers:\n\n- `export.pageBg`\n- `export.cardBg`\n- `export.infoBg`\n\nIf omitted, export code derives defaults from resolved theme colors.\n\n### `symbols` section (optional)\n\n- `symbols.preset` sets a theme-level default symbol set.\n- `symbols.overrides` can override individual `SymbolKey` values.\n\nRuntime precedence:\n\n1. settings `symbolPreset` override (if set)\n2. theme JSON `symbols.preset`\n3. fallback `\"unicode\"`\n\nInvalid override keys are ignored and logged (`logger.debug`).\n\n## Built-in vs custom theme sources\n\nTheme lookup order (`loadThemeJson`):\n\n1. built-in embedded themes (`dark.json`, `light.json`, and all `defaults/*.json` compiled into `defaultThemes`)\n2. custom theme file: `<customThemesDir>/<name>.json`\n\nCustom themes directory comes from `getCustomThemesDir()`:\n\n- default: `~/.omp/agent/themes`\n- overridden by `PI_CODING_AGENT_DIR` (`$PI_CODING_AGENT_DIR/themes`)\n\n`getAvailableThemes()` returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.\n\n## Loading, validation, and resolution\n\nFor custom theme files:\n\n1. read JSON\n2. parse JSON\n3. validate against `ThemeJsonSchema`\n4. resolve `vars` references recursively\n5. convert resolved values to ANSI by terminal capability mode\n\nValidation behavior:\n\n- missing required color tokens: explicit grouped error message\n- bad token types/values: validation errors with JSON path\n- unknown theme file: `Theme not found: <name>`\n\nVar reference behavior:\n\n- supports nested references\n- throws on missing variable reference\n- throws on circular references\n\n## Terminal color mode behavior\n\nColor mode detection (`detectColorMode`):\n\n- `COLORTERM=truecolor|24bit` => truecolor\n- `WT_SESSION` => truecolor\n- `TERM` in `dumb`, `linux`, or empty => 256color\n- otherwise => truecolor\n\nConversion behavior:\n\n- hex -> `Bun.color(..., \"ansi-16m\" | \"ansi-256\")`\n- numeric -> `38;5` / `48;5` ANSI\n- `\"\"` -> default fg/bg reset\n\n## Runtime switching behavior\n\n### Initial theme (`initTheme`)\n\n`main.ts` initializes theme with settings:\n\n- `symbolPreset`\n- `colorBlindMode`\n- `theme.dark`\n- `theme.light`\n\nAuto theme slot selection uses `COLORFGBG` background detection:\n\n- parse background index from `COLORFGBG`\n- `< 8` => dark slot (`theme.dark`)\n- `>= 8` => light slot (`theme.light`)\n- parse failure => dark slot\n\nCurrent defaults from settings schema:\n\n- `theme.dark = \"titanium\"`\n- `theme.light = \"light\"`\n- `symbolPreset = \"unicode\"`\n- `colorBlindMode = false`\n\n### Explicit switching (`setTheme`)\n\n- loads selected theme\n- updates global `theme` singleton\n- optionally starts watcher\n- triggers `onThemeChange` callback\n\nOn failure:\n\n- falls back to built-in `dark`\n- returns `{ success: false, error }`\n\n### Preview switching (`previewTheme`)\n\n- applies temporary preview theme to global `theme`\n- does **not** change persisted settings by itself\n- returns success/error without fallback replacement\n\nSettings UI uses this for live preview and restores prior theme on cancel.\n\n## Watchers and live reload\n\nWhen watcher is enabled (`setTheme(..., true)` / interactive init):\n\n- only watches custom file path `<customThemesDir>/<currentTheme>.json`\n- built-ins are effectively not watched\n- file `change`: attempts reload (debounced)\n- file `rename`/delete: falls back to `dark`, closes watcher\n\nAuto mode also installs a `SIGWINCH` listener and can re-evaluate dark/light slot mapping when terminal state changes.\n\n## Color-blind mode behavior\n\n`colorBlindMode` changes only one token at runtime:\n\n- `toolDiffAdded` is HSV-adjusted (green shifted toward blue)\n- adjustment is applied only when resolved value is a hex string\n\nOther tokens are unchanged.\n\n## Where theme settings are persisted\n\nTheme-related settings are persisted by `Settings` to global config YAML:\n\n- path: `<agentDir>/config.yml`\n- default agent dir: `~/.omp/agent`\n- effective default file: `~/.omp/agent/config.yml`\n\nPersisted keys:\n\n- `theme.dark`\n- `theme.light`\n- `symbolPreset`\n- `colorBlindMode`\n\nLegacy migration exists: old flat `theme: \"name\"` is migrated to nested `theme.dark` or `theme.light` based on luminance detection.\n\n## Creating a custom theme (practical)\n\n1. Create file in custom themes dir, e.g. `~/.omp/agent/themes/my-theme.json`.\n2. Include `name`, optional `vars`, and **all required** `colors` tokens.\n3. Optionally include `symbols` and `export`.\n4. Select the theme in Settings (`Display -> Dark theme` or `Display -> Light theme`) depending on which auto slot you want.\n\nMinimal skeleton:\n\n```json\n{\n \"name\": \"my-theme\",\n \"vars\": {\n \"accent\": \"#7aa2f7\",\n \"muted\": 244\n },\n \"colors\": {\n \"accent\": \"accent\",\n \"border\": \"#4c566a\",\n \"borderAccent\": \"accent\",\n \"borderMuted\": \"muted\",\n \"success\": \"#9ece6a\",\n \"error\": \"#f7768e\",\n \"warning\": \"#e0af68\",\n \"muted\": \"muted\",\n \"dim\": 240,\n \"text\": \"\",\n \"thinkingText\": \"muted\",\n\n \"selectedBg\": \"#2a2f45\",\n \"userMessageBg\": \"#1f2335\",\n \"userMessageText\": \"\",\n \"customMessageBg\": \"#24283b\",\n \"customMessageText\": \"\",\n \"customMessageLabel\": \"accent\",\n \"toolPendingBg\": \"#1f2335\",\n \"toolSuccessBg\": \"#1f2d2a\",\n \"toolErrorBg\": \"#2d1f2a\",\n \"toolTitle\": \"\",\n \"toolOutput\": \"muted\",\n\n \"mdHeading\": \"accent\",\n \"mdLink\": \"accent\",\n \"mdLinkUrl\": \"muted\",\n \"mdCode\": \"#c0caf5\",\n \"mdCodeBlock\": \"#c0caf5\",\n \"mdCodeBlockBorder\": \"muted\",\n \"mdQuote\": \"muted\",\n \"mdQuoteBorder\": \"muted\",\n \"mdHr\": \"muted\",\n \"mdListBullet\": \"accent\",\n\n \"toolDiffAdded\": \"#9ece6a\",\n \"toolDiffRemoved\": \"#f7768e\",\n \"toolDiffContext\": \"muted\",\n\n \"syntaxComment\": \"#565f89\",\n \"syntaxKeyword\": \"#bb9af7\",\n \"syntaxFunction\": \"#7aa2f7\",\n \"syntaxVariable\": \"#c0caf5\",\n \"syntaxString\": \"#9ece6a\",\n \"syntaxNumber\": \"#ff9e64\",\n \"syntaxType\": \"#2ac3de\",\n \"syntaxOperator\": \"#89ddff\",\n \"syntaxPunctuation\": \"#9aa5ce\",\n\n \"thinkingOff\": 240,\n \"thinkingMinimal\": 244,\n \"thinkingLow\": \"#7aa2f7\",\n \"thinkingMedium\": \"#2ac3de\",\n \"thinkingHigh\": \"#bb9af7\",\n \"thinkingXhigh\": \"#f7768e\",\n\n \"bashMode\": \"#2ac3de\",\n \"pythonMode\": \"#bb9af7\",\n\n \"statusLineBg\": \"#16161e\",\n \"statusLineSep\": 240,\n \"statusLineModel\": \"#bb9af7\",\n \"statusLinePath\": \"#7aa2f7\",\n \"statusLineGitClean\": \"#9ece6a\",\n \"statusLineGitDirty\": \"#e0af68\",\n \"statusLineContext\": \"#2ac3de\",\n \"statusLineSpend\": \"#7dcfff\",\n \"statusLineStaged\": \"#9ece6a\",\n \"statusLineDirty\": \"#e0af68\",\n \"statusLineUntracked\": \"#f7768e\",\n \"statusLineOutput\": \"#c0caf5\",\n \"statusLineCost\": \"#ff9e64\",\n \"statusLineSubagents\": \"#bb9af7\"\n }\n}\n```\n\n## Testing custom themes\n\nUse this workflow:\n\n1. Start interactive mode (watcher enabled from startup).\n2. Open settings and preview theme values (live `previewTheme`).\n3. For custom theme files, edit the JSON while running and confirm auto-reload on save.\n4. Exercise critical surfaces:\n - markdown rendering\n - tool blocks (pending/success/error)\n - diff rendering (added/removed/context)\n - status line readability\n - thinking level border changes\n - bash/python mode border colors\n5. Validate both symbol presets if your theme depends on glyph width/appearance.\n\n## Real constraints and caveats\n\n- All `colors` tokens are required for custom themes.\n- `export` and `symbols` are optional.\n- `$schema` in theme JSON is informational; runtime validation is enforced by compiled TypeBox schema in code.\n- `setTheme` failure falls back to `dark`; `previewTheme` failure does not replace current theme.\n- File watcher reload errors keep the current loaded theme until a successful reload or fallback path is triggered.\n",
package/src/mcp/config.ts CHANGED
@@ -42,6 +42,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig {
42
42
  enabled: server.enabled,
43
43
  timeout: server.timeout,
44
44
  auth: server.auth,
45
+ oauth: server.oauth,
45
46
  };
46
47
 
47
48
  if (transport === "stdio") {
@@ -22,6 +22,8 @@ export interface MCPOAuthConfig {
22
22
  clientSecret?: string;
23
23
  /** OAuth scopes (space-separated) */
24
24
  scopes?: string;
25
+ /** Custom callback port (default: 3000) */
26
+ callbackPort?: number;
25
27
  }
26
28
 
27
29
  /**
@@ -37,7 +39,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
37
39
  private config: MCPOAuthConfig,
38
40
  ctrl: OAuthController,
39
41
  ) {
40
- super(ctrl, DEFAULT_PORT, CALLBACK_PATH);
42
+ super(ctrl, config.callbackPort ?? DEFAULT_PORT, CALLBACK_PATH);
41
43
  this.#resolvedClientId = this.#resolveClientId(config);
42
44
  }
43
45
 
package/src/mcp/types.ts CHANGED
@@ -59,6 +59,11 @@ interface MCPServerConfigBase {
59
59
  timeout?: number;
60
60
  /** Authentication configuration (optional) */
61
61
  auth?: MCPAuthConfig;
62
+ /** OAuth configuration for servers requiring explicit client credentials */
63
+ oauth?: {
64
+ clientId?: string;
65
+ callbackPort?: number;
66
+ };
62
67
  }
63
68
 
64
69
  /** Stdio server configuration */
@@ -163,7 +163,7 @@ export class StatusLineComponent implements Component {
163
163
  // Fire async fetch, return cached value
164
164
  (async () => {
165
165
  try {
166
- const result = await $`git status --porcelain`.quiet().nothrow();
166
+ const result = await $`git --no-optional-locks status --porcelain`.quiet().nothrow();
167
167
 
168
168
  if (result.exitCode !== 0) {
169
169
  this.#cachedGitStatus = null;
@@ -282,9 +282,10 @@ export class MCPCommandController {
282
282
  const credentialId = await this.#handleOAuthFlow(
283
283
  oauth.authorizationUrl,
284
284
  oauth.tokenUrl,
285
- oauth.clientId ?? "",
285
+ oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
286
286
  "",
287
287
  oauth.scopes ?? "",
288
+ finalConfig.oauth?.callbackPort,
288
289
  );
289
290
  finalConfig = {
290
291
  ...finalConfig,
@@ -352,6 +353,7 @@ export class MCPCommandController {
352
353
  clientId: string,
353
354
  clientSecret: string,
354
355
  scopes: string,
356
+ callbackPort?: number,
355
357
  ): Promise<string> {
356
358
  const authStorage = this.ctx.session.modelRegistry.authStorage;
357
359
  let parsedAuthUrl: URL;
@@ -377,6 +379,7 @@ export class MCPCommandController {
377
379
  clientId: resolvedClientId,
378
380
  clientSecret: clientSecret || undefined,
379
381
  scopes: scopes || undefined,
382
+ callbackPort,
380
383
  },
381
384
  {
382
385
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -1198,9 +1201,10 @@ export class MCPCommandController {
1198
1201
  const credentialId = await this.#handleOAuthFlow(
1199
1202
  oauth.authorizationUrl,
1200
1203
  oauth.tokenUrl,
1201
- oauth.clientId ?? "",
1204
+ oauth.clientId ?? found.config.oauth?.clientId ?? "",
1202
1205
  "",
1203
1206
  oauth.scopes ?? "",
1207
+ found.config.oauth?.callbackPort,
1204
1208
  );
1205
1209
 
1206
1210
  const updated: MCPServerConfig = {
@@ -40,16 +40,6 @@ If a skill covers your output, you **MUST** read `skill://<name>` before proceed
40
40
  {{/list}}
41
41
  </skills>
42
42
  {{/if}}
43
- {{#if preloadedSkills.length}}
44
- Following skills are preloaded in full; you **MUST** apply instructions directly.
45
- <preloaded-skills>
46
- {{#list preloadedSkills join="\n"}}
47
- <skill name="{{name}}">
48
- {{content}}
49
- </skill>
50
- {{/list}}
51
- </preloaded-skills>
52
- {{/if}}
53
43
  {{#if rules.length}}
54
44
  Rules are local constraints.
55
45
  You **MUST** read `rule://<name>` when working in that domain.
@@ -93,14 +93,6 @@ You **MUST** use the following skills, to save you time, when working in their d
93
93
  {{/each}}
94
94
  {{/if}}
95
95
 
96
- {{#if preloadedSkills.length}}
97
- Preloaded skills:
98
- {{#each preloadedSkills}}
99
- ## {{name}}
100
- {{content}}
101
- {{/each}}
102
- {{/if}}
103
-
104
96
  {{#if rules.length}}
105
97
  # Rules
106
98
  Domain-specific rules from past experience. **MUST** read `rule://<name>` when working in their territory.
@@ -12,7 +12,6 @@ Subagents lack your conversation history. Every decision, file content, and user
12
12
  - `.id`: CamelCase, max 32 chars
13
13
  - `.description`: UI display only — subagent never sees it
14
14
  - `.assignment`: Complete self-contained instructions. One-liners PROHIBITED; missing acceptance criteria = too vague.
15
- - `.skills`: Skill names to preload
16
15
  - `context`: Shared background prepended to every assignment. Session-specific info only.
17
16
  - `schema`: JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
18
17
  - `tasks`: Tasks to execute in parallel.
package/src/sdk.ts CHANGED
@@ -144,8 +144,6 @@ export interface CreateAgentSessionOptions {
144
144
 
145
145
  /** Skills. Default: discovered from multiple locations */
146
146
  skills?: Skill[];
147
- /** Skills to inline into the system prompt instead of listing available skills. */
148
- preloadedSkills?: Skill[];
149
147
  /** Rules. Default: discovered from multiple locations */
150
148
  rules?: Rule[];
151
149
  /** Context files (AGENTS.md content). Default: discovered walking up from cwd */
@@ -1128,7 +1126,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1128
1126
  const defaultPrompt = await buildSystemPromptInternal({
1129
1127
  cwd,
1130
1128
  skills,
1131
- preloadedSkills: options.preloadedSkills,
1132
1129
  contextFiles,
1133
1130
  tools,
1134
1131
  toolNames,
@@ -1147,7 +1144,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1147
1144
  return await buildSystemPromptInternal({
1148
1145
  cwd,
1149
1146
  skills,
1150
- preloadedSkills: options.preloadedSkills,
1151
1147
  contextFiles,
1152
1148
  tools,
1153
1149
  toolNames,
@@ -3844,6 +3844,22 @@ Be thorough - include exact file paths, function names, error messages, and tech
3844
3844
  onChunk?: (chunk: string) => void,
3845
3845
  options?: { excludeFromContext?: boolean },
3846
3846
  ): Promise<BashResult> {
3847
+ const excludeFromContext = options?.excludeFromContext === true;
3848
+ const cwd = this.sessionManager.getCwd();
3849
+
3850
+ if (this.#extensionRunner?.hasHandlers("user_bash")) {
3851
+ const hookResult = await this.#extensionRunner.emitUserBash({
3852
+ type: "user_bash",
3853
+ command,
3854
+ excludeFromContext,
3855
+ cwd,
3856
+ });
3857
+ if (hookResult?.result) {
3858
+ this.recordBashResult(command, hookResult.result, options);
3859
+ return hookResult.result;
3860
+ }
3861
+ }
3862
+
3847
3863
  this.#bashAbortController = new AbortController();
3848
3864
 
3849
3865
  try {
@@ -3942,12 +3958,27 @@ Be thorough - include exact file paths, function names, error messages, and tech
3942
3958
  onChunk?: (chunk: string) => void,
3943
3959
  options?: { excludeFromContext?: boolean },
3944
3960
  ): Promise<PythonResult> {
3961
+ const excludeFromContext = options?.excludeFromContext === true;
3962
+ const cwd = this.sessionManager.getCwd();
3963
+
3964
+ if (this.#extensionRunner?.hasHandlers("user_python")) {
3965
+ const hookResult = await this.#extensionRunner.emitUserPython({
3966
+ type: "user_python",
3967
+ code,
3968
+ excludeFromContext,
3969
+ cwd,
3970
+ });
3971
+ if (hookResult?.result) {
3972
+ this.recordPythonResult(code, hookResult.result, options);
3973
+ return hookResult.result;
3974
+ }
3975
+ }
3976
+
3945
3977
  this.#pythonAbortController = new AbortController();
3946
3978
 
3947
3979
  try {
3948
3980
  // Use the same session ID as the Python tool for kernel sharing
3949
3981
  const sessionFile = this.sessionManager.getSessionFile();
3950
- const cwd = this.sessionManager.getCwd();
3951
3982
  const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
3952
3983
 
3953
3984
  const result = await executePythonCommand(code, {
@@ -16,24 +16,6 @@ import { loadSkills, type Skill } from "./extensibility/skills";
16
16
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
17
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
18
18
 
19
- type PreloadedSkill = { name: string; content: string };
20
-
21
- async function loadPreloadedSkillContents(preloadedSkills: Skill[]): Promise<PreloadedSkill[]> {
22
- const contents = await Promise.all(
23
- preloadedSkills.map(async skill => {
24
- try {
25
- const content = await Bun.file(skill.filePath).text();
26
- return { name: skill.name, content };
27
- } catch (err) {
28
- const message = err instanceof Error ? err.message : String(err);
29
- throw new Error(`Failed to load skill "${skill.name}" from ${skill.filePath}: ${message}`);
30
- }
31
- }),
32
- );
33
-
34
- return contents;
35
- }
36
-
37
19
  function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
38
20
  for (const value of values) {
39
21
  const trimmed = value?.trim();
@@ -350,11 +332,9 @@ export interface BuildSystemPromptOptions {
350
332
  cwd?: string;
351
333
  /** Pre-loaded context files (skips discovery if provided). */
352
334
  contextFiles?: Array<{ path: string; content: string; depth?: number }>;
353
- /** Pre-loaded skills (skips discovery if provided). */
335
+ /** Skills provided directly to system prompt construction. */
354
336
  skills?: Skill[];
355
- /** Skills to inline into the system prompt instead of listing available skills. */
356
- preloadedSkills?: Skill[];
357
- /** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
337
+ /** Pre-loaded rulebook rules (descriptions, excluding TTSR and always-apply). */
358
338
  rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
359
339
  /** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
360
340
  intentField?: string;
@@ -378,13 +358,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
378
358
  cwd,
379
359
  contextFiles: providedContextFiles,
380
360
  skills: providedSkills,
381
- preloadedSkills: providedPreloadedSkills,
382
361
  rules,
383
362
  intentField,
384
363
  eagerTasks = false,
385
364
  } = options;
386
365
  const resolvedCwd = cwd ?? getProjectDir();
387
- const preloadedSkills = providedPreloadedSkills;
388
366
 
389
367
  const prepPromise = (() => {
390
368
  const systemPromptCustomizationPromise = logger.timeAsync("loadSystemPromptFiles", loadSystemPromptFiles, {
@@ -400,9 +378,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
400
378
  : skillsSettings?.enabled !== false
401
379
  ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
402
380
  : Promise.resolve([]);
403
- const preloadedSkillContentsPromise = preloadedSkills
404
- ? logger.timeAsync("loadPreloadedSkills", loadPreloadedSkillContents, preloadedSkills)
405
- : [];
406
381
 
407
382
  return Promise.all([
408
383
  resolvePromptInput(customPrompt, "system prompt"),
@@ -411,7 +386,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
411
386
  contextFilesPromise,
412
387
  agentsMdSearchPromise,
413
388
  skillsPromise,
414
- preloadedSkillContentsPromise,
415
389
  ]).then(
416
390
  ([
417
391
  resolvedCustomPrompt,
@@ -420,7 +394,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
420
394
  contextFiles,
421
395
  agentsMdSearch,
422
396
  skills,
423
- preloadedSkillContents,
424
397
  ]) => ({
425
398
  resolvedCustomPrompt,
426
399
  resolvedAppendPrompt,
@@ -428,7 +401,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
428
401
  contextFiles,
429
402
  agentsMdSearch,
430
403
  skills,
431
- preloadedSkillContents,
432
404
  }),
433
405
  );
434
406
  })();
@@ -451,7 +423,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
451
423
  files: [],
452
424
  };
453
425
  let skills: Skill[] = providedSkills ?? [];
454
- let preloadedSkillContents: PreloadedSkill[] = [];
455
426
 
456
427
  if (prepResult.type === "timeout") {
457
428
  logger.warn("System prompt preparation timed out; using minimal startup context", {
@@ -474,7 +445,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
474
445
  contextFiles = prepResult.value.contextFiles;
475
446
  agentsMdSearch = prepResult.value.agentsMdSearch;
476
447
  skills = prepResult.value.skills;
477
- preloadedSkillContents = prepResult.value.preloadedSkillContents;
478
448
  }
479
449
 
480
450
  const now = new Date();
@@ -517,7 +487,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
517
487
 
518
488
  // Filter skills to only include those with read tool
519
489
  const hasRead = tools?.has("read");
520
- const filteredSkills = preloadedSkills === undefined && hasRead ? skills : [];
490
+ const filteredSkills = hasRead ? skills : [];
521
491
 
522
492
  const environment = await logger.timeAsync("getEnvironmentInfo", getEnvironmentInfo);
523
493
  const data = {
@@ -531,7 +501,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
531
501
  contextFiles,
532
502
  agentsMdSearch,
533
503
  skills: filteredSkills,
534
- preloadedSkills: preloadedSkillContents,
535
504
  rules: rules ?? [],
536
505
  date,
537
506
  dateTime,
@@ -158,7 +158,6 @@ export interface ExecutorOptions {
158
158
  eventBus?: EventBus;
159
159
  contextFiles?: ContextFileEntry[];
160
160
  skills?: Skill[];
161
- preloadedSkills?: Skill[];
162
161
  promptTemplates?: PromptTemplate[];
163
162
  mcpManager?: MCPManager;
164
163
  authStorage?: AuthStorage;
@@ -950,7 +949,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
950
949
  requireSubmitResultTool: true,
951
950
  contextFiles: options.contextFiles,
952
951
  skills: options.skills,
953
- preloadedSkills: options.preloadedSkills,
954
952
  promptTemplates: options.promptTemplates,
955
953
  systemPrompt: defaultPrompt =>
956
954
  renderPromptTemplate(subagentSystemPromptTemplate, {
package/src/task/index.ts CHANGED
@@ -690,58 +690,13 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
690
690
 
691
691
  // Build full prompts with context prepended
692
692
  const tasksWithContext = tasksWithUniqueIds.map(t => renderTemplate(context, t));
693
+ const availableSkills = [...(this.session.skills ?? [])];
693
694
  const contextFiles = this.session.contextFiles;
694
- const availableSkills = this.session.skills;
695
- const availableSkillList = availableSkills ?? [];
696
695
  const promptTemplates = this.session.promptTemplates;
697
- const skillLookup = new Map(availableSkillList.map(skill => [skill.name, skill]));
698
- const missingSkillsByTask: Array<{ id: string; missing: string[] }> = [];
699
- const tasksWithSkills = tasksWithContext.map(task => {
700
- if (task.skills === undefined) {
701
- return { ...task, resolvedSkills: availableSkills, preloadedSkills: undefined };
702
- }
703
- const requested = task.skills;
704
- const resolved = [] as typeof availableSkillList;
705
- const missing: string[] = [];
706
- const seen = new Set<string>();
707
- for (const name of requested) {
708
- const trimmed = name.trim();
709
- if (!trimmed || seen.has(trimmed)) continue;
710
- seen.add(trimmed);
711
- const skill = skillLookup.get(trimmed);
712
- if (skill) {
713
- resolved.push(skill);
714
- } else {
715
- missing.push(trimmed);
716
- }
717
- }
718
- if (missing.length > 0) {
719
- missingSkillsByTask.push({ id: task.id, missing });
720
- }
721
- return { ...task, resolvedSkills: resolved, preloadedSkills: resolved };
722
- });
723
-
724
- if (missingSkillsByTask.length > 0) {
725
- const available = availableSkillList.map(skill => skill.name).join(", ") || "none";
726
- const details = missingSkillsByTask.map(entry => `${entry.id}: ${entry.missing.join(", ")}`).join("; ");
727
- return {
728
- content: [
729
- {
730
- type: "text",
731
- text: `Unknown skills requested: ${details}. Available skills: ${available}`,
732
- },
733
- ],
734
- details: {
735
- projectAgentsDir,
736
- results: [],
737
- totalDurationMs: Date.now() - startTime,
738
- },
739
- };
740
- }
741
696
 
742
697
  // Initialize progress for all tasks
743
- for (let i = 0; i < tasksWithSkills.length; i++) {
744
- const t = tasksWithSkills[i];
698
+ for (let i = 0; i < tasksWithContext.length; i++) {
699
+ const t = tasksWithContext[i];
745
700
  progressMap.set(i, {
746
701
  index: i,
747
702
  id: t.id,
@@ -760,7 +715,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
760
715
  }
761
716
  emitProgress();
762
717
 
763
- const runTask = async (task: (typeof tasksWithSkills)[number], index: number) => {
718
+ const runTask = async (task: (typeof tasksWithContext)[number], index: number) => {
764
719
  if (!isIsolated) {
765
720
  return runSubprocess({
766
721
  cwd: this.session.cwd,
@@ -791,8 +746,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
791
746
  settings: this.session.settings,
792
747
  mcpManager: this.session.mcpManager,
793
748
  contextFiles,
794
- skills: task.resolvedSkills,
795
- preloadedSkills: task.preloadedSkills,
749
+ skills: availableSkills,
796
750
  promptTemplates,
797
751
  });
798
752
  }
@@ -842,8 +796,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
842
796
  settings: this.session.settings,
843
797
  mcpManager: this.session.mcpManager,
844
798
  contextFiles,
845
- skills: task.resolvedSkills,
846
- preloadedSkills: task.preloadedSkills,
799
+ skills: availableSkills,
847
800
  promptTemplates,
848
801
  });
849
802
  if (mergeMode === "branch" && result.exitCode === 0) {
@@ -927,7 +880,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
927
880
 
928
881
  // Execute in parallel with concurrency limit
929
882
  const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
930
- tasksWithSkills,
883
+ tasksWithContext,
931
884
  maxConcurrency,
932
885
  runTask,
933
886
  signal,
@@ -938,7 +891,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
938
891
  if (result !== undefined) {
939
892
  return result;
940
893
  }
941
- const task = tasksWithSkills[index];
894
+ const task = tasksWithContext[index];
942
895
  return {
943
896
  index,
944
897
  id: task.id,
@@ -7,7 +7,6 @@ interface RenderResult {
7
7
  task: string;
8
8
  id: string;
9
9
  description: string;
10
- skills?: string[];
11
10
  }
12
11
 
13
12
  /**
@@ -16,17 +15,16 @@ interface RenderResult {
16
15
  * If context is provided, it is prepended with a separator.
17
16
  */
18
17
  export function renderTemplate(context: string | undefined, task: TaskItem): RenderResult {
19
- let { id, description, assignment, skills } = task;
18
+ let { id, description, assignment } = task;
20
19
  assignment = assignment.trim();
21
20
  context = context?.trim();
22
21
 
23
22
  if (!context || !assignment) {
24
- return { task: assignment || context!, id, description, skills };
23
+ return { task: assignment || context!, id, description };
25
24
  }
26
25
  return {
27
26
  task: renderPromptTemplate(subagentUserPromptTemplate, { context, assignment }),
28
27
  id,
29
28
  description,
30
- skills,
31
29
  };
32
30
  }
package/src/task/types.ts CHANGED
@@ -44,11 +44,6 @@ export const taskItemSchema = Type.Object({
44
44
  description:
45
45
  "Complete per-task instructions the subagent executes. Must follow the Target/Change/Edge Cases/Acceptance structure. Only include per-task deltas — shared background belongs in `context`.",
46
46
  }),
47
- skills: Type.Optional(
48
- Type.Array(Type.String(), {
49
- description: "Skill names to preload into the subagent. Use only where it changes correctness.",
50
- }),
51
- ),
52
47
  });
53
48
  export type TaskItem = Static<typeof taskItemSchema>;
54
49
 
@@ -181,7 +181,9 @@ export async function applyBaseline(worktreeDir: string, baseline: WorktreeBasel
181
181
  // Commit baseline state so captureRepoDeltaPatch can cleanly subtract it.
182
182
  // Without this, `git add -A && git commit` by the task would include
183
183
  // baseline untracked files in the diff-tree output.
184
- const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
184
+ const hasChanges = (
185
+ await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
186
+ ).trim();
185
187
  if (hasChanges) {
186
188
  await $`git add -A`.cwd(nestedDir).quiet();
187
189
  await $`git commit -m omp-baseline --allow-empty`.cwd(nestedDir).quiet();
@@ -343,7 +345,9 @@ export async function applyNestedPatches(
343
345
  }
344
346
 
345
347
  // Commit so nested repo history reflects the task changes
346
- const hasChanges = (await $`git status --porcelain`.cwd(nestedDir).quiet().nothrow().text()).trim();
348
+ const hasChanges = (
349
+ await $`git --no-optional-locks status --porcelain`.cwd(nestedDir).quiet().nothrow().text()
350
+ ).trim();
347
351
  if (hasChanges) {
348
352
  const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
349
353
  await $`git add -A`.cwd(nestedDir).quiet();
@@ -38,15 +38,6 @@ function convertSchema(schema: unknown): unknown {
38
38
  return {};
39
39
  }
40
40
 
41
- // Type form: { type: "string" } → { type: "string" }
42
- if (isJTDType(schema)) {
43
- const jsonType = primitiveMap[schema.type as JTDPrimitive];
44
- if (!jsonType) {
45
- return { type: schema.type };
46
- }
47
- return { type: jsonType };
48
- }
49
-
50
41
  // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
51
42
  if (isJTDEnum(schema)) {
52
43
  return { enum: schema.enum };
@@ -60,6 +51,14 @@ function convertSchema(schema: unknown): unknown {
60
51
  };
61
52
  }
62
53
 
54
+ // Type form: { type: "string" } → { type: "string" }
55
+ if (isJTDType(schema)) {
56
+ const jsonType = primitiveMap[schema.type as JTDPrimitive];
57
+ if (!jsonType) {
58
+ return { type: schema.type };
59
+ }
60
+ return { type: jsonType };
61
+ }
63
62
  // Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... }
64
63
  if (isJTDValues(schema)) {
65
64
  return {
@@ -171,13 +170,30 @@ export function isJTDSchema(schema: unknown): boolean {
171
170
  return false;
172
171
  }
173
172
 
173
+ function normalizeMixedSchemaNode(schema: unknown): unknown {
174
+ if (schema === null || typeof schema !== "object") {
175
+ return schema;
176
+ }
177
+
178
+ if (Array.isArray(schema)) {
179
+ return schema.map(item => normalizeMixedSchemaNode(item));
180
+ }
181
+
182
+ if (isJTDSchema(schema)) {
183
+ return normalizeMixedSchemaNode(convertSchema(schema));
184
+ }
185
+
186
+ const normalized: Record<string, unknown> = {};
187
+ for (const [key, value] of Object.entries(schema)) {
188
+ normalized[key] = normalizeMixedSchemaNode(value);
189
+ }
190
+
191
+ return normalized;
192
+ }
174
193
  /**
175
194
  * Convert JTD schema to JSON Schema.
176
195
  * If already JSON Schema, returns as-is.
177
196
  */
178
197
  export function jtdToJsonSchema(schema: unknown): unknown {
179
- if (!isJTDSchema(schema)) {
180
- return schema;
181
- }
182
- return convertSchema(schema);
198
+ return normalizeMixedSchemaNode(schema);
183
199
  }
@@ -4,6 +4,7 @@
4
4
  * Subagents must call this tool to finish and return structured JSON output.
5
5
  */
6
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
+ import { enforceStrictSchema, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/typebox-helpers";
7
8
  import type { Static, TSchema } from "@sinclair/typebox";
8
9
  import { Type } from "@sinclair/typebox";
9
10
  import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
@@ -51,6 +52,53 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
51
52
  .join("; ");
52
53
  }
53
54
 
55
+ /**
56
+ * Resolve all $ref references in a JSON Schema by inlining definitions.
57
+ * Handles $defs and definitions at any nesting level.
58
+ * Removes $defs/definitions from the output since all refs are inlined.
59
+ */
60
+ function resolveSchemaRefs(schema: Record<string, unknown>): Record<string, unknown> {
61
+ const defs: Record<string, Record<string, unknown>> = {};
62
+ const defsObj = schema.$defs ?? schema.definitions;
63
+ if (defsObj && typeof defsObj === "object" && !Array.isArray(defsObj)) {
64
+ for (const [name, def] of Object.entries(defsObj as Record<string, unknown>)) {
65
+ if (def && typeof def === "object" && !Array.isArray(def)) {
66
+ defs[name] = def as Record<string, unknown>;
67
+ }
68
+ }
69
+ }
70
+ if (Object.keys(defs).length === 0) return schema;
71
+
72
+ const inlining = new Set<string>();
73
+ function inline(node: unknown): unknown {
74
+ if (node === null || typeof node !== "object") return node;
75
+ if (Array.isArray(node)) return node.map(inline);
76
+ const obj = node as Record<string, unknown>;
77
+ const ref = obj.$ref;
78
+ if (typeof ref === "string") {
79
+ const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
80
+ if (match) {
81
+ const name = match[1];
82
+ const def = defs[name];
83
+ if (def) {
84
+ if (inlining.has(name)) return {};
85
+ inlining.add(name);
86
+ const resolved = inline(def);
87
+ inlining.delete(name);
88
+ return resolved;
89
+ }
90
+ }
91
+ }
92
+ const result: Record<string, unknown> = {};
93
+ for (const [key, value] of Object.entries(obj)) {
94
+ if (key === "$defs" || key === "definitions") continue;
95
+ result[key] = inline(value);
96
+ }
97
+ return result;
98
+ }
99
+ return inline(schema) as Record<string, unknown>;
100
+ }
101
+
54
102
  export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails> {
55
103
  readonly name = "submit_result";
56
104
  readonly label = "Submit Result";
@@ -58,51 +106,96 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
58
106
  "Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" +
59
107
  "If you cannot complete the task, call with an error message payload.";
60
108
  readonly parameters: TSchema;
61
- readonly strict = true;
109
+ strict = true;
110
+ lenientArgValidation = true;
62
111
 
63
112
  readonly #validate?: ValidateFunction;
64
- readonly #schemaError?: string;
113
+ #schemaValidationFailures = 0;
65
114
 
66
115
  constructor(session: ToolSession) {
67
- const schemaResult = normalizeSchema(session.outputSchema);
68
- // Convert JTD to JSON Schema if needed (auto-detected)
69
- const normalizedSchema =
70
- schemaResult.normalized !== undefined ? jtdToJsonSchema(schemaResult.normalized) : undefined;
71
- let schemaError = schemaResult.error;
72
-
73
- if (normalizedSchema !== undefined && !schemaError) {
74
- try {
75
- this.#validate = ajv.compile(normalizedSchema as any);
76
- } catch (err) {
77
- schemaError = err instanceof Error ? err.message : String(err);
116
+ const createParameters = (dataSchema: TSchema): TSchema =>
117
+ Type.Object(
118
+ {
119
+ result: Type.Union([
120
+ Type.Object({ data: dataSchema }, { description: "Successfully completed the task" }),
121
+ Type.Object({
122
+ error: Type.String({ description: "Error message when the task cannot be completed" }),
123
+ }),
124
+ ]),
125
+ },
126
+ {
127
+ additionalProperties: false,
128
+ description: "Submit either `data` for success or `error` for failure",
129
+ },
130
+ ) as TSchema;
131
+
132
+ let validate: ValidateFunction | undefined;
133
+ let dataSchema: TSchema;
134
+ let parameters: TSchema;
135
+ let strict = true;
136
+
137
+ try {
138
+ const schemaResult = normalizeSchema(session.outputSchema);
139
+ // Convert JTD to JSON Schema if needed (auto-detected)
140
+ const normalizedSchema =
141
+ schemaResult.normalized !== undefined ? jtdToJsonSchema(schemaResult.normalized) : undefined;
142
+ let schemaError = schemaResult.error;
143
+
144
+ if (!schemaError && normalizedSchema === false) {
145
+ schemaError = "boolean false schema rejects all outputs";
146
+ }
147
+
148
+ if (normalizedSchema !== undefined && normalizedSchema !== false && !schemaError) {
149
+ try {
150
+ validate = ajv.compile(normalizedSchema as Record<string, unknown> | boolean);
151
+ } catch (err) {
152
+ schemaError = err instanceof Error ? err.message : String(err);
153
+ }
78
154
  }
155
+
156
+ const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
157
+ const schemaDescription = schemaError
158
+ ? `Structured JSON output (output schema invalid; accepting unconstrained object): ${schemaError}`
159
+ : `Structured output matching the schema:\n${schemaHint}`;
160
+ const sanitizedSchema =
161
+ !schemaError &&
162
+ normalizedSchema != null &&
163
+ typeof normalizedSchema === "object" &&
164
+ !Array.isArray(normalizedSchema)
165
+ ? sanitizeSchemaForStrictMode(normalizedSchema as Record<string, unknown>)
166
+ : !schemaError && normalizedSchema === true
167
+ ? {}
168
+ : undefined;
169
+
170
+ if (sanitizedSchema !== undefined) {
171
+ const resolved = resolveSchemaRefs({
172
+ ...sanitizedSchema,
173
+ description: schemaDescription,
174
+ });
175
+ dataSchema = Type.Unsafe(resolved);
176
+ } else {
177
+ dataSchema = Type.Record(Type.String(), Type.Any(), {
178
+ description: schemaError ? schemaDescription : "Structured JSON output (no schema specified)",
179
+ });
180
+ }
181
+ parameters = createParameters(dataSchema);
182
+ const strictParameters = enforceStrictSchema(parameters as unknown as Record<string, unknown>);
183
+ JSON.stringify(strictParameters);
184
+ // Verify the final parameters compile with AJV (catches unresolved $ref, etc.)
185
+ ajv.compile(parameters as Record<string, unknown>);
186
+ } catch (err) {
187
+ const errorMsg = err instanceof Error ? err.message : String(err);
188
+ dataSchema = Type.Record(Type.String(), Type.Any(), {
189
+ description: `Structured JSON output (schema processing failed: ${errorMsg})`,
190
+ });
191
+ parameters = createParameters(dataSchema);
192
+ validate = undefined;
193
+ strict = false;
79
194
  }
80
195
 
81
- this.#schemaError = schemaError;
82
-
83
- const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
84
-
85
- // Use actual schema if provided, otherwise fall back to Type.Any
86
- // Merge description into the JSON schema for better tool documentation
87
- const dataSchema = normalizedSchema
88
- ? Type.Unsafe({
89
- ...(normalizedSchema as object),
90
- description: `Structured output matching the schema:\n${schemaHint}`,
91
- })
92
- : Type.Record(Type.String(), Type.Any(), { description: "Structured JSON output (no schema specified)" });
93
-
94
- this.parameters = Type.Object(
95
- {
96
- result: Type.Union([
97
- Type.Object({ data: dataSchema }, { description: "Successfully completed the task" }),
98
- Type.Object({ error: Type.String({ description: "Error message when the task cannot be completed" }) }),
99
- ]),
100
- },
101
- {
102
- additionalProperties: false,
103
- description: "Submit either `data` for success or `error` for failure",
104
- },
105
- );
196
+ this.#validate = validate;
197
+ this.parameters = parameters;
198
+ this.strict = strict;
106
199
  }
107
200
 
108
201
  async execute(
@@ -130,20 +223,26 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
130
223
  }
131
224
 
132
225
  const status = errorMessage !== undefined ? "aborted" : "success";
226
+ let schemaValidationOverridden = false;
133
227
  if (status === "success") {
134
228
  if (data === undefined || data === null) {
135
229
  throw new Error("data is required when submit_result indicates success");
136
230
  }
137
- if (this.#schemaError) {
138
- throw new Error(`Invalid output schema: ${this.#schemaError}`);
139
- }
140
231
  if (this.#validate && !this.#validate(data)) {
141
- throw new Error(`Output does not match schema: ${formatAjvErrors(this.#validate.errors)}`);
232
+ this.#schemaValidationFailures++;
233
+ if (this.#schemaValidationFailures <= 1) {
234
+ throw new Error(`Output does not match schema: ${formatAjvErrors(this.#validate.errors)}`);
235
+ }
236
+ schemaValidationOverridden = true;
142
237
  }
143
238
  }
144
239
 
145
- const responseText = status === "aborted" ? `Task aborted: ${errorMessage}` : "Result submitted.";
146
-
240
+ const responseText =
241
+ status === "aborted"
242
+ ? `Task aborted: ${errorMessage}`
243
+ : schemaValidationOverridden
244
+ ? `Result submitted (schema validation overridden after ${this.#schemaValidationFailures} failed attempt(s)).`
245
+ : "Result submitted.";
147
246
  return {
148
247
  content: [{ type: "text", text: responseText }],
149
248
  details: { data, status, error: errorMessage },