@oh-my-pi/pi-coding-agent 13.3.5 → 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 +38 -0
- package/package.json +7 -7
- package/src/capability/mcp.ts +5 -0
- package/src/cli/args.ts +1 -0
- package/src/config/prompt-templates.ts +7 -1
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/mcp-json.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/mcp/config.ts +1 -0
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/mcp/types.ts +5 -0
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/controllers/mcp-command-controller.ts +6 -2
- package/src/prompts/agents/reviewer.md +5 -5
- package/src/prompts/system/custom-system-prompt.md +0 -10
- package/src/prompts/system/subagent-submit-reminder.md +2 -2
- package/src/prompts/system/subagent-system-prompt.md +2 -2
- package/src/prompts/system/system-prompt.md +0 -8
- package/src/prompts/tools/task.md +0 -1
- package/src/sdk.ts +0 -4
- package/src/session/agent-session.ts +32 -1
- package/src/system-prompt.ts +3 -34
- package/src/task/executor.ts +0 -2
- package/src/task/index.ts +8 -55
- package/src/task/template.ts +2 -4
- package/src/task/types.ts +0 -5
- package/src/task/worktree.ts +6 -2
- package/src/tools/jtd-to-json-schema.ts +29 -13
- package/src/tools/submit-result.ts +159 -43
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
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
|
+
|
|
38
|
+
## [13.3.6] - 2026-02-26
|
|
39
|
+
### Breaking Changes
|
|
40
|
+
|
|
41
|
+
- Changed `submit_result` tool parameter structure from top-level `data` or `error` fields to nested `result` object containing either `result.data` or `result.error`
|
|
42
|
+
|
|
5
43
|
## [13.3.5] - 2026-02-26
|
|
6
44
|
### Added
|
|
7
45
|
|
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.
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.3.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.3.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.3.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.3.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.3.
|
|
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",
|
package/src/capability/mcp.ts
CHANGED
|
@@ -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 =>
|
|
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
|
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -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
|
|
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
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -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 = {
|
|
@@ -112,11 +112,11 @@ Each `report_finding` requires:
|
|
|
112
112
|
- `file_path`: Absolute path
|
|
113
113
|
- `line_start`, `line_end`: Range ≤10 lines, must overlap diff
|
|
114
114
|
|
|
115
|
-
Final `submit_result` call (payload under `data`):
|
|
116
|
-
- `data.overall_correctness`: "correct" (no bugs/blockers) or "incorrect"
|
|
117
|
-
- `data.explanation`: Plain text, 1-3 sentences summarizing verdict. Don't repeat findings (captured via `report_finding`).
|
|
118
|
-
- `data.confidence`: 0.0-1.0
|
|
119
|
-
- `data.findings`: Optional; **MUST** omit (auto-populated from `report_finding`)
|
|
115
|
+
Final `submit_result` call (payload under `result.data`):
|
|
116
|
+
- `result.data.overall_correctness`: "correct" (no bugs/blockers) or "incorrect"
|
|
117
|
+
- `result.data.explanation`: Plain text, 1-3 sentences summarizing verdict. Don't repeat findings (captured via `report_finding`).
|
|
118
|
+
- `result.data.confidence`: 0.0-1.0
|
|
119
|
+
- `result.data.findings`: Optional; **MUST** omit (auto-populated from `report_finding`)
|
|
120
120
|
|
|
121
121
|
You **MUST NOT** output JSON or code blocks.
|
|
122
122
|
|
|
@@ -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.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
You stopped without calling submit_result. This is reminder {{retryCount}} of {{maxRetries}}.
|
|
3
3
|
|
|
4
4
|
You **MUST** call submit_result as your only action now. Choose one:
|
|
5
|
-
- If task is complete: call submit_result with your result in
|
|
6
|
-
- If task failed: call submit_result with
|
|
5
|
+
- If task is complete: call submit_result with your result in `result.data`
|
|
6
|
+
- If task failed: call submit_result with `result.error` describing what happened
|
|
7
7
|
|
|
8
8
|
You **MUST NOT** give up if you can still complete the task through exploration (using available tools or repo context). If you submit an error, you **MUST** include what you tried and the exact blocker.
|
|
9
9
|
|
|
@@ -19,7 +19,7 @@ No TODO tracking, no progress updates. Execute, call `submit_result`, done.
|
|
|
19
19
|
|
|
20
20
|
When finished, you **MUST** call `submit_result` exactly once. This is like writing to a ticket, provide what is required, and close it.
|
|
21
21
|
|
|
22
|
-
This is your only way to return a result. You **MUST NOT** put JSON in plain text, and you **MUST NOT** substitute a text summary for the structured `data` parameter.
|
|
22
|
+
This is your only way to return a result. You **MUST NOT** put JSON in plain text, and you **MUST NOT** substitute a text summary for the structured `result.data` parameter.
|
|
23
23
|
|
|
24
24
|
{{#if outputSchema}}
|
|
25
25
|
Your result **MUST** match this TypeScript interface:
|
|
@@ -29,7 +29,7 @@ Your result **MUST** match this TypeScript interface:
|
|
|
29
29
|
{{/if}}
|
|
30
30
|
|
|
31
31
|
{{SECTION_SEPERATOR "Giving Up"}}
|
|
32
|
-
If you cannot complete the assignment, you **MUST** call `submit_result` exactly once with
|
|
32
|
+
If you cannot complete the assignment, you **MUST** call `submit_result` exactly once with `result.error` describing what you tried and the exact blocker.
|
|
33
33
|
|
|
34
34
|
Giving up is a last resort.
|
|
35
35
|
You **MUST NOT** give up due to uncertainty or missing information obtainable via tools or repo context.
|
|
@@ -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, {
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
-
/**
|
|
335
|
+
/** Skills provided directly to system prompt construction. */
|
|
354
336
|
skills?: Skill[];
|
|
355
|
-
/**
|
|
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 =
|
|
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,
|
package/src/task/executor.ts
CHANGED
|
@@ -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 <
|
|
744
|
-
const t =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
894
|
+
const task = tasksWithContext[index];
|
|
942
895
|
return {
|
|
943
896
|
index,
|
|
944
897
|
id: task.id,
|
package/src/task/template.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/task/worktree.ts
CHANGED
|
@@ -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 = (
|
|
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 = (
|
|
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
|
-
|
|
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,47 +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
|
-
|
|
109
|
+
strict = true;
|
|
110
|
+
lenientArgValidation = true;
|
|
62
111
|
|
|
63
112
|
readonly #validate?: ValidateFunction;
|
|
64
|
-
|
|
113
|
+
#schemaValidationFailures = 0;
|
|
65
114
|
|
|
66
115
|
constructor(session: ToolSession) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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";
|
|
78
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
|
+
}
|
|
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.#
|
|
82
|
-
|
|
83
|
-
|
|
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.Object({}, { additionalProperties: true, description: "Structured JSON output (no schema specified)" });
|
|
93
|
-
|
|
94
|
-
this.parameters = Type.Union([
|
|
95
|
-
Type.Object({
|
|
96
|
-
data: dataSchema,
|
|
97
|
-
}),
|
|
98
|
-
Type.Object({
|
|
99
|
-
error: Type.String({ description: "Error message when the task cannot be completed" }),
|
|
100
|
-
}),
|
|
101
|
-
]);
|
|
196
|
+
this.#validate = validate;
|
|
197
|
+
this.parameters = parameters;
|
|
198
|
+
this.strict = strict;
|
|
102
199
|
}
|
|
103
200
|
|
|
104
201
|
async execute(
|
|
@@ -109,24 +206,43 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
|
|
|
109
206
|
_context?: AgentToolContext,
|
|
110
207
|
): Promise<AgentToolResult<SubmitResultDetails>> {
|
|
111
208
|
const raw = params as Record<string, unknown>;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
209
|
+
const rawResult = raw.result;
|
|
210
|
+
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
|
|
211
|
+
throw new Error("result must be an object containing either data or error");
|
|
212
|
+
}
|
|
115
213
|
|
|
214
|
+
const resultRecord = rawResult as Record<string, unknown>;
|
|
215
|
+
const errorMessage = typeof resultRecord.error === "string" ? resultRecord.error : undefined;
|
|
216
|
+
const data = resultRecord.data;
|
|
217
|
+
|
|
218
|
+
if (errorMessage !== undefined && data !== undefined) {
|
|
219
|
+
throw new Error("result cannot contain both data and error");
|
|
220
|
+
}
|
|
221
|
+
if (errorMessage === undefined && data === undefined) {
|
|
222
|
+
throw new Error("result must contain either data or error");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const status = errorMessage !== undefined ? "aborted" : "success";
|
|
226
|
+
let schemaValidationOverridden = false;
|
|
116
227
|
if (status === "success") {
|
|
117
228
|
if (data === undefined || data === null) {
|
|
118
229
|
throw new Error("data is required when submit_result indicates success");
|
|
119
230
|
}
|
|
120
|
-
if (this.#schemaError) {
|
|
121
|
-
throw new Error(`Invalid output schema: ${this.#schemaError}`);
|
|
122
|
-
}
|
|
123
231
|
if (this.#validate && !this.#validate(data)) {
|
|
124
|
-
|
|
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;
|
|
125
237
|
}
|
|
126
238
|
}
|
|
127
239
|
|
|
128
|
-
const responseText =
|
|
129
|
-
|
|
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.";
|
|
130
246
|
return {
|
|
131
247
|
content: [{ type: "text", text: responseText }],
|
|
132
248
|
details: { data, status, error: errorMessage },
|