diff --git a/.github/skills/coc-knowledge/references/admin-config.md b/.github/skills/coc-knowledge/references/admin-config.md index b31deb9ef..6f31b3077 100644 --- a/.github/skills/coc-knowledge/references/admin-config.md +++ b/.github/skills/coc-knowledge/references/admin-config.md @@ -27,7 +27,7 @@ Cross-field constraints belong in `CLIConfigSchema`/`validateConfigWithSchema()` The `spaHtml` function in `packages/coc/src/server/index.ts` reads the RuntimeConfigService snapshot on every page request, so feature-flag changes (e.g. `terminal.enabled`) take effect on the next browser reload — no server restart required. -Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.idleCheckIntervalMs`, `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, `dreams.conversationLimit`, and `dreams.timeoutMs`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill` and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. +Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Admin -> Knowledge -> Dreams renders `dreams.enabled`, the restart-required `dreams.idleCheckIntervalMs` editor, and idle-run defaults for `dreams.provider`, `dreams.model`, and `dreams.timeoutMs`; interval and timeout are entered in minutes and persisted to the global config as milliseconds. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, and `dreams.conversationLimit`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, `features.nativeCliSessions` enables the read-only provider-switched CLI Sessions surface over native Copilot/Codex/Claude stores and is disabled by default, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill`, `features.nativeCliSessions`, and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. The AI provider admin card stores `defaultProvider` as a top-level concrete fallback key. It accepts only `copilot`, `codex`, or `claude` and is used for provider-omitted flows when Auto routing is disabled; individual chat payloads can still set `payload.provider`, and follow-ups continue with the provider recorded on the original process. `features.autoAgentProviderRouting` is the sole user-controlled Auto enablement switch. When it is true, provider-omitted new chats, tasks, and API-created work route through `agentProviderRouting.auto` by default; explicit provider selections still win. Auto routing profile configuration lives under `agentProviderRouting.auto`, with the default ordered profile `claude -> codex -> copilot`, normal thresholds `33/33/10`, matching weekly guard thresholds, and fallback `copilot`. The Admin -> AI Provider -> Provider routing subtab contains the Auto enable toggle, ordered rule editor, fallback selector, weekly-guard help text, and current-selection preview; Admin -> Configure -> Features does not expose a second Auto routing toggle. diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index f7bdbdb93..0d4488eaa 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -23,6 +23,7 @@ spa/client/react/ │ ├── chat/ # Chat UI: ChatDetail, ChatListPane, ConversationArea │ ├── dreams/ # Workspace Dreams review panel with feature/opt-in states, queue-backed run-now task summary, provider-attributed Activity/Admin AI Provider visibility, filters, plain-language card guidance, source evidence links, and card lifecycle actions │ ├── memory/ # Memory V2 route, facts/review/episodes tabs, repo memory settings section +│ ├── native-copilot-sessions/ # Read-only CLI Sessions tab over native Copilot/Codex/Claude stores (see CLI Sessions Tab) │ ├── notes/ # Notes UI: NoteEditor, Mermaid zoom/pan, sidebar, multi-root dropdown with modifier/range root selection and bulk root removal (useNotesRoots) │ ├── pull-requests/ # PR dashboard: attention groups, provider-derived PR helpers, shared provider-id/displayName Team author matching, Team auto-classification triggers, real diff-stat queue badges/risk, deterministic review summary, BatchCommandPanel │ └── terminal/ # Terminal UI: TerminalView, pin/unpin @@ -520,13 +521,21 @@ The legacy "Tools" popover has been migrated into the Admin page's left sidebar, but there is no longer a generic Tools group. The Admin sidebar is grouped by user task: Configure, Knowledge, Connections, Operations, and Developer / Internals. Embedded tool rows keep stable ids (`memory-toggle`, -`skills-toggle`, `logs-toggle`, `stats-toggle`, +`skills-toggle`, `dreams-admin-toggle`, `logs-toggle`, `stats-toggle`, `servers-toggle`) and `data-tab` still carries the matching dashboard route; -Servers is shown only when `isServersEnabled()` is true. +Servers is shown only when `isServersEnabled()` is true. The Knowledge group's +**Dreams** row (`dreams-admin-toggle`, route `#dreams-admin`) renders +`features/dreams/DreamsView.tsx` and is the admin home for global Dreams config: +the live `dreams.enabled` toggle, `dreams.idleCheckIntervalMs` edited in minutes +with a restart hint, idle-run defaults for provider, model, and timeout +(`dreams.provider`, `dreams.model`, `dreams.timeoutMs`), and the relocated +**Dreams provider activity** queue + history section +(`features/dreams/ProviderActivitySection.tsx`); that section no longer lives on +the AI Provider page. It is distinct from the per-workspace `DreamsPanel`. Clicking an embedded tool row dispatches `SET_ACTIVE_TAB` and updates `location.hash` to the corresponding top-level route (`#memory`, `#skills`, -`#logs`, `#stats`, `#servers`). The Router maps every embedded tool +`#dreams-admin`, `#logs`, `#stats`, `#servers`). The Router maps every embedded tool tab plus `'admin'` itself to a single `` render, so the admin shell (sidebar + breadcrumb + right pane) stays mounted across navigation. `AdminPanel` switches on `state.activeTab` — when it matches an embedded tool @@ -566,6 +575,16 @@ Ralph activity deep-links mount `RalphWorkflowPane`, which shows a unified task The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated review surface separate from Work Items. It is included in repo tab strips only when the global `dreams.enabled` feature flag is on, then requires the workspace `preferences.dreams.enabled` opt-in before calling Dreams routes. Once enabled, it lists visible cards by default, supports status filters for hidden lifecycle history, exposes a manual **Run dream now** action, shows run summaries/no-new-dreams states, links source process turn ranges back to the Activity conversation route, and offers card lifecycle actions: approve, dismiss, record conversion, and supersede. Approved cards also expose an explicit **Take next action** dialog: skill/prompt cards can queue an Ask-mode skill-hardening task, user-workflow cards can save to Notes or Memory V2, and product cards can create a new Work Item or append the recommendation to an existing Work Item. Each next action runs only after the dialog submit and then records the resulting artifact as a dream conversion. +## CLI Sessions Tab + +The repo-scoped `CLI Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`, exported as `NativeCliSessionsPanel`) is a read-only provider-switched view of native Copilot, Codex, and Claude Code CLI sessions for the active workspace. It is gated by `features.nativeCliSessions` / `nativeCliSessionsEnabled` (disabled by default; `useNativeCliSessionsEnabled()` tracks live runtime-config updates), reads through `coc-client`'s `nativeCliSessions` domain, and registers as the `cli-sessions` repo sub-tab while accepting the legacy hidden `copilot-sessions` key for old links. The panel renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens. A provider switcher defaults to Copilot for legacy compatibility and selects Copilot, Codex, or Claude; the header uses the shared `ProviderBadge` palette (Copilot green, Codex indigo, Claude coral), a provider-specific native-session label, and a read-only badge whose tooltip shows the selected provider's local store path. + +The list supports text query, session-ID, branch, date-range filters, and pagination. Codex and Claude use on-demand substring search over JSONL transcripts and report `searchIndexAvailable: false`; when a query is active the panel explains that there is no native search index. Copilot delegates to the native SQLite provider and reports its native search-index availability. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, optional match snippets, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The selected session is deep-linked through the URL hash (`#repos/{wsId}/cli-sessions/{provider}/{sessionId}`, parsed/built via `parseNativeCliSessionDeepLink`/`buildNativeCliSessionHash`) so selections survive refresh/back-forward and are shareable; `#repos/{wsId}/copilot-sessions/{sessionId}` is parsed as a legacy Copilot provider link. + +The list route deduplicates against the Activity tab: native sessions whose provider session ID matches a CoC process `sdk_session_id` for the workspace (resolved via `ProcessStore.getSdkSessionIds(workspaceId)`) are hidden, and the response `deduplicatedCount` drives a `native-sessions-deduplicated` hint reading `N sessions hidden — already tracked in CoC Activity`. Automated Copilot background-job sessions whose first turn matches `BACKGROUND_JOB_PROMPT_PREFIXES` are hidden by default and counted in `backgroundJobCount`, which drives a `native-sessions-background-hidden` hint. The panel renders distinct disabled/unavailable (`store-missing`/`store-invalid`)/loading/empty/error states per provider. + +The detail pane reconstructs the selected session as a rich CoC chat transcript rather than a plain text dump. The unified detail endpoint (`GET /api/workspaces/:id/native-cli-sessions/:sessionId?provider=...`) returns provider-tagged metadata, `storePath`, `searchIndexAvailable`, and an always-present `conversation: ReconstructedConversationTurn[]`. Copilot reconstruction prefers the native `session-state//events.jsonl` log and falls back to flat `session-store.db` turns; Codex and Claude reconstruction comes from defensive JSONL parsers that skip malformed or unknown records and preserve user/assistant messages, tool start/complete/failed timeline items, thinking/reasoning, data-URL images, and model metadata when present. Codex `event_msg` user-message image metadata is merged into the matching user turn; `local_images` paths are shown as read-only markdown references because the existing chat image gallery only renders data URLs. The SPA maps each turn to `ClientConversationTurn` via `nativeConversationTurns.ts` (`toClientConversationTurns`, folding assistant `thinking` into a leading markdown blockquote since `ClientConversationTurn` has no reasoning field) and renders one read-only `ConversationTurnBubble` per turn under a `native-session-conversation` card (`Conversation (N)`) with the selected provider passed through for avatar coloring. The whole feature is strictly read-only: no input box, streaming, resume, follow-up, archive, pin, delete, retry, or turn actions are exposed; stored HTML/scripts never execute. + ## Memory Route The top-level `#memory` route is embedded in the Admin shell's Knowledge group and renders `MemoryV2Panel` in the right pane. The panel root owns the stable `#view-memory` id. `MemorySubTab` values are `facts`, `review`, `episodes`, and `settings`; hash links such as `#memory/review` and `#memory/settings` select the matching V2 tab. The legacy memory-config panel is not rendered on the Memory route (the tool-call/explore cache has been removed). Repo settings still use `RepoMemorySection` for repo-scoped bounded memory and raw memory inspection. diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index 1a9b6943a..ec329b49c 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -175,6 +175,24 @@ All Map Reduce routes are workspace-scoped and gated by `mapReduce.enabled` (def | POST | `/api/workspaces/:id/map-reduce-runs/:runId/reduce/retry` | Retry a failed reduce step as a new child chat | | POST | `/api/workspaces/:id/map-reduce-runs/:runId/cancel` | Cancel remaining work, mark pending/running map items skipped, cancel a pending/running/failed reduce step, and cancel active child tasks when available | +## Native Copilot Sessions + +Read-only compatibility views over the server user's native GitHub Copilot CLI session store (`~/.copilot/session-store.db`). These legacy routes share the disabled-by-default `features.nativeCliSessions` live guard with the unified CLI Sessions API, so there is one operational switch for native Copilot/Codex/Claude session browsing. CoC opens the native SQLite store read-only with short-lived per-request connections, never writes to it, and never imports native sessions into CoC process history. Disabled and unavailable states return HTTP 200 with typed payloads: `{ enabled: false, reason: 'feature-disabled' }` when the flag is off, and `{ enabled: true, available: false, reason: 'db-missing' | 'db-invalid' }` when the store is absent or unreadable. Workspace scoping matches native `sessions.cwd` against the registered workspace root (equal or descendant path) or native `sessions.repository` against the workspace's origin-remote `owner/repo` (case-insensitive). `@plusplusoneplusplus/coc-client` keeps exposing these compatibility routes through `client.nativeCopilotSessions`; new UI code uses `client.nativeCliSessions`. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally), `deduplicatedCount` (native sessions hidden because their `sessions.id` matches a CoC process `sdk_session_id` for the workspace), and `backgroundJobCount` (automated background-job sessions hidden by first-turn or stored-summary prompt match, e.g. title summarization) | +| GET | `/api/workspaces/:id/native-copilot-sessions/:sessionId` | Read one workspace-matching native session: metadata, full stored summary, and turns ordered by `turn_index` with per-turn char counts and search-index diagnostics (`searchIndexSourceId`/`searchIndexChars`, null when not indexed). Also returns `conversation` (always present): a reconstructed `ReconstructedConversationTurn[]` transcript for rich chat rendering, built from the per-session `session-state//events.jsonl` log when available, else mapped from the flat DB turns as text-only user/assistant turns. Sessions outside the workspace or unknown IDs return 404 | + +## Native CLI Sessions + +Unified read-only, workspace-scoped views over native Copilot (`~/.copilot/session-store.db`), Codex (`~/.codex/sessions`), and Claude Code (`~/.claude/projects`) CLI stores. Gated by the disabled-by-default live `features.nativeCliSessions` flag and exposed through `@plusplusoneplusplus/coc-client` as `client.nativeCliSessions`. Query parameter `provider=copilot|codex|claude` selects the backing provider; omitted provider defaults to `copilot`. Disabled and unavailable states return HTTP 200 typed payloads using `{ enabled: false, reason: 'feature-disabled' }` or `{ enabled: true, available: false, reason: 'store-missing' | 'store-invalid' }`. The route deduplicates against `ProcessStore.getSdkSessionIds(workspaceId)`. Codex and Claude text search is on-demand substring search over JSONL files and reports `searchIndexAvailable: false`; Copilot delegates to the native SQLite provider and reports its native search-index availability. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/:id/native-cli-sessions?provider=copilot|codex|claude` | List workspace-matching native CLI sessions. Query also accepts `q`, `sessionId`, `branch`, `from`/`to`, `limit`, and `offset`. Response includes provider-tagged `items`, `total`, `searchIndexAvailable`, `deduplicatedCount`, `backgroundJobCount`, `limit`, and `offset`. | +| GET | `/api/workspaces/:id/native-cli-sessions/:sessionId?provider=copilot|codex|claude` | Read one workspace-matching native CLI session, returning provider-tagged metadata, store path, and reconstructed `conversation: ReconstructedConversationTurn[]`. Unknown or out-of-workspace sessions return 404. | + ## Dreams All Dreams routes are workspace-scoped and gated by `dreams.enabled` (default `false`). Dream generation also requires the target workspace's `preferences.dreams.enabled` opt-in. Cards are review records only: approval records user intent, conversion records an explicit resulting artifact link, and no route mutates skills, prompts, notes, memory, work items, or code directly. diff --git a/.github/skills/coc-knowledge/references/sdk-wrapper.md b/.github/skills/coc-knowledge/references/sdk-wrapper.md index 5a2ebceb1..b53a96aaf 100644 --- a/.github/skills/coc-knowledge/references/sdk-wrapper.md +++ b/.github/skills/coc-knowledge/references/sdk-wrapper.md @@ -127,7 +127,7 @@ Codex image attachments are passed at the provider boundary as `@openai/codex-sd Codex token usage is mapped from `turn.completed.usage` into the shared `TokenUsage` result shape when the SDK reports it. The adapter fills per-turn totals only: `inputTokens`, `outputTokens`, `cacheReadTokens` from `cached_input_tokens`, `cacheWriteTokens: 0`, `totalTokens`, and `turnCount`. Codex has no provider-native USD field; Forge estimates USD from the shared Copilot pricing table and populates native-first display cost fields for stats/read models. Session/context fields (`tokenLimit`, `currentTokens`, `systemTokens`, `toolDefinitionsTokens`, `conversationTokens`) remain absent because Codex does not expose an equivalent context-window usage source. -Codex `file_change` stream items are normalized to `apply_patch` tool calls. Because the Codex SDK reports only changed paths and change kinds, `CodexSDKService` snapshots dirty worktree files before the turn starts and enriches completed file-change parameters with a best-effort unified `diff` when a workspace git root is available. Clean files diff against `HEAD`, paths dirty before the turn diff against their pre-turn worktree snapshot, and later changes to the same path diff against the previous captured snapshot. Diff enrichment is display metadata only; failures fall back to the original `{ changes }` file list without failing the Codex turn. +Codex stream items are normalized into the shared `ToolCall` shape before they reach CoC process storage. `command_execution`/`commandExecution` becomes `shell`; `file_change`/`fileChange` becomes `apply_patch`; `mcp_tool_call`/`mcpToolCall` becomes the MCP tool name; `web_search`/`webSearch` becomes `web_search`; `dynamicToolCall` keeps the dynamic tool name unless it is an agent start/wait operation; and `collabAgentToolCall` maps Codex collaboration sub-agent operations onto the dashboard's existing `task` and `read_agent` vocabulary. The adapter handles `item.started`, `item.updated`, and `item.completed`; terminal updates complete a stored tool call even when the final state arrives before a distinct completion event. For file changes, `CodexSDKService` snapshots dirty worktree files before the turn starts and enriches completed file-change parameters with a best-effort unified `diff` when a workspace git root is available. Clean files diff against `HEAD`, paths dirty before the turn diff against their pre-turn worktree snapshot, and later changes to the same path diff against the previous captured snapshot. Diff enrichment is display metadata only; failures fall back to the original `{ changes }` file list without failing the Codex turn. **Thread ↔ session mapping:** Every CoC session ID maps to exactly one Codex thread. The mapping is created on the first `sendMessage()` call for a session and removed on abort or dispose. @@ -143,7 +143,7 @@ sdkServiceRegistry.register(SDK_PROVIDER_CODEX, svc); **Lazy loading:** No SDK module is loaded until the first `isAvailable()` or `sendMessage()` call. -**CoC LLM tools:** when `options.tools` is present, a per-request `Codex` client is built with `config.mcp_servers.coc_llm_tools` pointing at the stdio bridge (see *CoC LLM Tools over MCP*). Captured tool calls from this first-party MCP server store the actual tool input directly in `args` (for example `args.questions` for `ask_user`) so process timelines match the Copilot and Claude display contract; external MCP tool calls retain `{ server, arguments }` metadata. +**CoC LLM tools:** when `options.tools` is present, a per-request `Codex` client is built with `config.mcp_servers.coc_llm_tools` pointing at the stdio bridge (see *CoC LLM Tools over MCP*). Captured tool calls from this first-party MCP server store the actual tool input directly in `args` (for example `args.questions` for `ask_user`) so process timelines match the Copilot and Claude display contract; external MCP tool calls retain `{ server, arguments }` metadata. Codex sub-agent spawn calls store `task` args with `agent_type: "codex"`, `agent_id`/`agent_ids`, prompt/model metadata, and agent state; Codex wait calls store `read_agent` args with `agent_id`, `wait: true`, and the latest agent state so existing dashboard grouping and nesting logic applies. ## ClaudeSDKService Architecture @@ -192,7 +192,7 @@ Claude image attachments are converted at the provider boundary. When `SendMessa `ClaudeSDKService` wires CoC LLM tools and any caller-provided `mcpServers` into `query({ options: { mcpServers } })`; CoC tools ride a stdio bridge entry (`coc_llm_tools`, `alwaysLoad: true`), are pre-approved via `options.allowedTools` (`mcp__coc_llm_tools__`) so Claude Code never prompts for them, and bridged `tool_use` names are de-namespaced (see *CoC LLM Tools over MCP*). -Claude tool-call capture treats assistant `tool_use` blocks as start events and user `tool_result` / `tool_use_result` payloads as terminal events. Stored tool calls keep the original input parameters in `args` and preserve the actual tool output in `result` or `error`; the adapter does not synthesize completion results from tool input JSON. +Claude tool-call capture treats assistant `tool_use` blocks as start events and user `tool_result` / `tool_use_result` payloads as terminal events. Stored tool calls keep the original input parameters in `args` and preserve the actual tool output in `result` or `error`; the adapter does not synthesize completion results from tool input JSON. Built-in Claude sub-agent starts (`Agent`/`Task`) are normalized to CoC's `task` tool shape, with `subagent_type` copied to `agent_type` and terminal agent metadata (`agentId`, `agentType`, status, output file, prompt/description) merged back into `args` when Claude reports it. Claude background-agent waits (`TaskOutput`) are normalized to `read_agent`, including `agent_id`, `wait`, and timeout metadata. Assistant messages emitted from inside a Claude sub-agent preserve `parent_tool_use_id` as `parentToolCallId`, so nested child tools render under the parent task in process timelines. ## RequestRunner — sendMessage() Flow (Copilot) diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index 30f5197c2..a36426b87 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -103,6 +103,7 @@ The `src/server/` tree is grouped by feature domain. Cross-cutting plumbing stay | `ralph/` | Iterative execution sessions and file-backed journal (see [ralph.md](ralph.md)) | | `for-each/` | Dedicated For Each run records, item-plan validation, file-backed repo-scoped draft/approval storage, and sequential child-chat orchestration | | `map-reduce/` | Dedicated Map Reduce plan generation, run records, map-plan validation, reduce-step state, per-run parallelism configuration, file-backed repo-scoped draft/approval/execution storage with parallel map claiming, and child-chat orchestration that auto-chains reduce after successful map completion | +| `native-copilot-sessions/` | Read-only native session services. Copilot reads the server user's native GitHub Copilot CLI SQLite store (`~/.copilot/session-store.db`) with short-lived read-only connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, and typed `db-missing`/`db-invalid` unavailable states. Codex and Claude filesystem providers scan `~/.codex/sessions` rollout JSONL and `~/.claude/projects/` transcript JSONL with `readFileSync`/`existsSync`, mtime-keyed metadata, workspace scoping by transcript `cwd` (Claude requires every recorded transcript `cwd` to stay under the workspace root), duplicate native IDs collapsed to the newest transcript for stable deep links, substring text search, and typed `store-missing`/`store-invalid` unavailable states. CoC never writes to native CLI stores | | `models/` | Model registry endpoints | | `agent-providers/` | Agent-provider quota cache, provider status routes, SDK install helpers, and the pure Auto provider router that evaluates configured priority, availability, normal quota thresholds, weekly guards, fallback, and selection warnings before callers expand effort tiers. Queue/fresh-terminal defaults, explicit SPA Auto requests (`context.autoProviderRouting.requested`), direct Ralph, For Each, and work-item enqueue surfaces use the shared quota cache and refresh it only when missing or stale. | | `messaging/` | Teams bot integration: manager, command router, per-user state | @@ -196,6 +197,7 @@ claude: features: autoAgentProviderRouting: false # enables Auto for omitted-provider default paths ralphMultiAgentGrill: false # gated multi-agent Ralph grilling setup and agent preflight + nativeCliSessions: false # read-only CLI Sessions tab over native Copilot, Codex, and Claude stores agentProviderRouting: auto: diff --git a/packages/coc-agent-sdk/src/claude-sdk-service.ts b/packages/coc-agent-sdk/src/claude-sdk-service.ts index 5caa439fa..c5e9a4262 100644 --- a/packages/coc-agent-sdk/src/claude-sdk-service.ts +++ b/packages/coc-agent-sdk/src/claude-sdk-service.ts @@ -91,6 +91,7 @@ interface ClaudeAssistantMessage { message: { content: ClaudeContentBlock[]; }; + parent_tool_use_id?: string | null; session_id?: string; } @@ -161,6 +162,8 @@ interface ClaudeUserMessage { parent_tool_use_id?: string | null; tool_use_result?: unknown; session_id?: string; + subagent_type?: string; + task_description?: string; } interface ClaudeStreamingUserMessage { @@ -693,7 +696,7 @@ export class ClaudeSDKService implements ISDKService { chunks.push(block.text); options.onStreamingChunk?.(block.text); } else if (this.isClaudeToolUseBlock(block)) { - this.handleClaudeToolUse(block, options, toolCalls, startedToolCalls); + this.handleClaudeToolUse(block, options, toolCalls, startedToolCalls, msg.parent_tool_use_id ?? undefined); } } } else if (this.isUserMessage(msg)) { @@ -961,12 +964,11 @@ export class ClaudeSDKService implements ISDKService { options: SendMessageOptions, toolCalls: Map, startedToolCalls: Set, + parentToolCallId?: string, ): void { const id = block.id ?? crypto.randomUUID(); - const toolName = normalizeBridgedToolName(block.name ?? 'unknown_tool'); - const parameters = (typeof block.input === 'object' && block.input !== null) - ? (block.input as Record) - : {}; + const toolName = normalizeClaudeToolName(block.name ?? 'unknown_tool'); + const parameters = this.normalizeClaudeToolInput(toolName, block.input); if (!startedToolCalls.has(id)) { startedToolCalls.add(id); @@ -977,12 +979,14 @@ export class ClaudeSDKService implements ISDKService { status: 'running', startTime: now, args: parameters, + ...(parentToolCallId ? { parentToolCallId } : {}), }); this.emitToolEvent(options, { type: 'tool-start', toolCallId: id, toolName, parameters, + ...(parentToolCallId ? { parentToolCallId } : {}), }); } } @@ -1026,12 +1030,17 @@ export class ClaudeSDKService implements ISDKService { ): void { const existing = toolCalls.get(toolCallId); const toolName = existing?.name ?? 'unknown_tool'; + const parentToolCallId = existing?.parentToolCallId; const result = this.stringifyClaudeToolResult(content); + const resultMetadata = this.extractClaudeToolResultMetadata(toolName, content, result); const now = new Date(); if (existing) { existing.status = isError ? 'failed' : 'completed'; existing.endTime = now; + if (resultMetadata.parameters) { + existing.args = { ...existing.args, ...resultMetadata.parameters }; + } if (isError) { existing.error = result || 'Claude tool failed'; } else { @@ -1044,26 +1053,132 @@ export class ClaudeSDKService implements ISDKService { status: isError ? 'failed' : 'completed', startTime: now, endTime: now, - args: {}, + args: resultMetadata.parameters ?? {}, ...(isError ? { error: result || 'Claude tool failed' } : { result }), }); } + const completedParameters = toolCalls.get(toolCallId)?.args; this.emitToolEvent(options, isError ? { type: 'tool-failed', toolCallId, toolName, + ...(parentToolCallId ? { parentToolCallId } : {}), error: result || 'Claude tool failed', } : { type: 'tool-complete', toolCallId, toolName, + ...(parentToolCallId ? { parentToolCallId } : {}), + ...(completedParameters ? { parameters: completedParameters } : {}), result, }); } + private normalizeClaudeToolInput(toolName: string, input: unknown): Record { + const parameters = (typeof input === 'object' && input !== null && !Array.isArray(input)) + ? { ...(input as Record) } + : {}; + + if (toolName === 'task') { + const agentType = typeof parameters.subagent_type === 'string' + ? parameters.subagent_type + : typeof parameters.agent_type === 'string' + ? parameters.agent_type + : 'claude'; + return { + ...parameters, + agent_type: agentType, + }; + } + + if (toolName === 'read_agent') { + if (typeof parameters.agent_id !== 'string' && typeof parameters.task_id === 'string') { + parameters.agent_id = parameters.task_id; + } + if (typeof parameters.block === 'boolean' && parameters.wait == null) { + parameters.wait = parameters.block; + } + if (typeof parameters.timeout === 'number') { + parameters.timeout_ms = parameters.timeout; + parameters.timeout = Math.ceil(parameters.timeout / 1000); + } + } + + return parameters; + } + + private extractClaudeToolResultMetadata( + toolName: string, + content: unknown, + result: string, + ): { parameters?: Record } { + if (toolName === 'task') { + const parameters: Record = {}; + if (typeof content === 'object' && content !== null && !Array.isArray(content)) { + const record = content as Record; + const agentId = typeof record.agentId === 'string' ? record.agentId : undefined; + const agentType = typeof record.agentType === 'string' ? record.agentType : undefined; + if (agentId) { + parameters.agent_id = agentId; + parameters.agent_ids = [agentId]; + } + if (agentType) { + parameters.agent_type = agentType; + } + if (typeof record.status === 'string') { + parameters.agent_status = record.status; + } + if (typeof record.outputFile === 'string') { + parameters.output_file = record.outputFile; + } + if (typeof record.description === 'string') { + parameters.description = record.description; + } + if (typeof record.prompt === 'string') { + parameters.prompt = record.prompt; + } + } + + const textAgentId = this.extractClaudeAgentId(result); + if (textAgentId && typeof parameters.agent_id !== 'string') { + parameters.agent_id = textAgentId; + parameters.agent_ids = [textAgentId]; + } + + return Object.keys(parameters).length > 0 ? { parameters } : {}; + } + + if (toolName === 'read_agent') { + const parameters: Record = {}; + if (typeof content === 'object' && content !== null && !Array.isArray(content)) { + const record = content as Record; + const agentId = typeof record.agentId === 'string' + ? record.agentId + : typeof record.task_id === 'string' + ? record.task_id + : undefined; + if (agentId) parameters.agent_id = agentId; + if (typeof record.status === 'string') parameters.agent_status = record.status; + } + const textAgentId = this.extractClaudeAgentId(result); + if (textAgentId && typeof parameters.agent_id !== 'string') { + parameters.agent_id = textAgentId; + } + return Object.keys(parameters).length > 0 ? { parameters } : {}; + } + + return {}; + } + + private extractClaudeAgentId(text: string): string | undefined { + const match = /\bagentId:\s*([A-Za-z0-9._:-]+)/.exec(text) + ?? /\bagent_id:\s*([A-Za-z0-9._:-]+)/.exec(text); + return match?.[1]; + } + private stringifyClaudeToolResult(content: unknown): string { if (content == null) return ''; if (typeof content === 'string') return content; @@ -1076,6 +1191,16 @@ export class ClaudeSDKService implements ISDKService { if (typeof content === 'object') { const record = content as Record; if (record.type === 'text' && typeof record.text === 'string') return record.text; + if (Array.isArray(record.content)) { + const parts = record.content + .map(item => this.stringifyClaudeToolResult(item)) + .filter(text => text.length > 0); + if (parts.length > 0) return parts.join('\n'); + } + if (record.status === 'async_launched' && typeof record.agentId === 'string') { + const description = typeof record.description === 'string' ? `: ${record.description}` : ''; + return `Agent started with agent_id: ${record.agentId}${description}`; + } const stdout = typeof record.stdout === 'string' ? record.stdout : ''; const stderr = typeof record.stderr === 'string' ? record.stderr : ''; if (stdout || stderr) return [stdout, stderr].filter(Boolean).join('\n'); @@ -1306,6 +1431,19 @@ function normalizeBridgedToolName(name: string): string { return name.startsWith(prefix) ? name.slice(prefix.length) : name; } +function normalizeClaudeToolName(name: string): string { + const normalized = normalizeBridgedToolName(name); + switch (normalized) { + case 'Agent': + case 'Task': + return 'task'; + case 'TaskOutput': + return 'read_agent'; + default: + return normalized; + } +} + /** * Normalize a forge `MCPServerConfig` into the Claude Code `mcpServers` shape. * Returns `undefined` for configs missing the fields Claude requires. diff --git a/packages/coc-agent-sdk/src/codex-sdk-service.ts b/packages/coc-agent-sdk/src/codex-sdk-service.ts index 0ebae5b65..738ecd0d4 100644 --- a/packages/coc-agent-sdk/src/codex-sdk-service.ts +++ b/packages/coc-agent-sdk/src/codex-sdk-service.ts @@ -160,19 +160,47 @@ interface CodexItemEvent { text?: string; command?: string; aggregated_output?: string; + aggregatedOutput?: string | null; exit_code?: number; + exitCode?: number | null; status?: string; changes?: Array<{ path?: string; kind?: string }>; server?: string; + namespace?: string | null; tool?: string; arguments?: unknown; result?: unknown; - error?: { message?: string }; + error?: { message?: string } | string | null; query?: string; + contentItems?: unknown[] | null; + content_items?: unknown[] | null; + success?: boolean | null; + senderThreadId?: string; + sender_thread_id?: string; + receiverThreadIds?: string[]; + receiver_thread_ids?: string[]; + receiverThreadId?: string; + receiver_thread_id?: string; + newThreadId?: string; + new_thread_id?: string; + prompt?: string | null; + model?: string | null; + reasoningEffort?: string | null; + reasoning_effort?: string | null; + agentsStates?: Record; + agents_states?: Record; }; } type CodexThreadEvent = CodexThreadStartedEvent | CodexTurnCompletedEvent | CodexTurnFailedEvent | CodexErrorEvent | CodexItemEvent; +type CodexToolPhase = 'started' | 'updated' | 'completed'; +interface NormalizedCodexToolItem { + id: string; + toolName: string; + parameters: Record; + result?: string; + error?: string; +} interface CodexFileChange { path?: string; @@ -847,6 +875,7 @@ export class CodexSDKService implements ISDKService { let sessionCreatedNotified = false; const toolCalls = new Map(); const startedToolCalls = new Set(); + const completedToolCalls = new Set(); const fileChangeDiffTracker = new CodexFileChangeDiffTracker(options.workingDirectory); // Releases the per-invocation CoC LLM-tool MCP bridge (no-op when no tools). let mcpCleanup: () => void = () => {}; @@ -899,16 +928,20 @@ export class CodexSDKService implements ISDKService { throw new Error(event.message ?? 'Codex stream error'); } if (event.type === 'item.started') { - await this.handleCodexToolItem(event.item, 'started', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(event.item, 'started', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); + continue; + } + if (event.type === 'item.updated') { + await this.handleCodexToolItem(event.item, 'updated', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); continue; } if (event.type === 'item.completed') { - if (event.item?.type === 'agent_message' && event.item.text) { + if ((event.item?.type === 'agent_message' || event.item?.type === 'agentMessage') && event.item.text) { chunks.push(event.item.text); options.onStreamingChunk?.(event.item.text); continue; } - await this.handleCodexToolItem(event.item, 'completed', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(event.item, 'completed', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); continue; } } @@ -938,17 +971,25 @@ export class CodexSDKService implements ISDKService { private async handleCodexToolItem( item: CodexItemEvent['item'] | undefined, - phase: 'started' | 'completed', + phase: CodexToolPhase, options: SendMessageOptions, toolCalls: Map, startedToolCalls: Set, + completedToolCalls: Set, fileChangeDiffTracker: CodexFileChangeDiffTracker, ): Promise { - const normalized = await this.normalizeCodexToolItem(item, phase, fileChangeDiffTracker); + const effectivePhase = phase === 'updated' && this.isTerminalCodexToolItem(item) ? 'completed' : phase; + const normalized = await this.normalizeCodexToolItem(item, effectivePhase, fileChangeDiffTracker); if (!normalized) return; - if (phase === 'started') { - if (startedToolCalls.has(normalized.id)) return; + if (effectivePhase !== 'completed') { + if (startedToolCalls.has(normalized.id)) { + const existing = toolCalls.get(normalized.id); + if (existing) { + existing.args = normalized.parameters; + } + return; + } startedToolCalls.add(normalized.id); const now = new Date(); toolCalls.set(normalized.id, { @@ -967,8 +1008,9 @@ export class CodexSDKService implements ISDKService { return; } + if (completedToolCalls.has(normalized.id)) return; if (!startedToolCalls.has(normalized.id)) { - await this.handleCodexToolItem(item, 'started', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(item, 'started', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); } const existing = toolCalls.get(normalized.id); @@ -983,6 +1025,7 @@ export class CodexSDKService implements ISDKService { existing.result = normalized.result; } } + completedToolCalls.add(normalized.id); this.emitToolEvent(options, normalized.error ? { @@ -1011,31 +1054,26 @@ export class CodexSDKService implements ISDKService { private async normalizeCodexToolItem( item: CodexItemEvent['item'] | undefined, - phase: 'started' | 'completed', + phase: CodexToolPhase, fileChangeDiffTracker: CodexFileChangeDiffTracker, - ): Promise<{ - id: string; - toolName: string; - parameters: Record; - result?: string; - error?: string; - } | undefined> { + ): Promise { if (!item?.type || !item.id) return undefined; - switch (item.type) { - case 'command_execution': { + switch (this.normalizeCodexItemType(item.type)) { + case 'commandexecution': { const command = typeof item.command === 'string' ? item.command : ''; - const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; - const failed = item.status === 'failed'; + const output = this.getStringField(item, 'aggregated_output', 'aggregatedOutput') ?? ''; + const exitCode = this.getNumberField(item, 'exit_code', 'exitCode'); + const failed = this.isFailedStatus(item.status); return { id: item.id, toolName: 'shell', parameters: { command }, - ...(failed ? { error: output || `Command failed${typeof item.exit_code === 'number' ? ` with exit code ${item.exit_code}` : ''}` } : { result: output }), + ...(failed ? { error: output || `Command failed${typeof exitCode === 'number' ? ` with exit code ${exitCode}` : ''}` } : { result: output }), }; } - case 'file_change': { + case 'filechange': { const changes = Array.isArray(item.changes) ? item.changes : []; - const failed = item.status === 'failed'; + const failed = this.isFailedStatus(item.status); const parameters = phase === 'completed' && !failed ? await fileChangeDiffTracker.enrichParameters(changes) : { changes }; @@ -1046,10 +1084,10 @@ export class CodexSDKService implements ISDKService { ...(failed ? { error: 'File change failed' } : { result: this.summarizeFileChanges(changes) }), }; } - case 'mcp_tool_call': { + case 'mcptoolcall': { const tool = typeof item.tool === 'string' && item.tool ? item.tool : 'mcp_tool'; const server = typeof item.server === 'string' ? item.server : undefined; - const error = item.error?.message; + const error = this.getCodexErrorMessage(item.error) ?? (this.isFailedStatus(item.status) ? `${tool} failed` : undefined); const toolArguments = (item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)) ? item.arguments as Record : {}; @@ -1066,7 +1104,7 @@ export class CodexSDKService implements ISDKService { ...(error ? { error } : { result: this.stringifyCodexResult(item.result) }), }; } - case 'web_search': { + case 'websearch': { const query = typeof item.query === 'string' ? item.query : ''; return { id: item.id, @@ -1075,11 +1113,293 @@ export class CodexSDKService implements ISDKService { result: query ? `Searched: ${query}` : 'Search completed', }; } + case 'dynamictoolcall': + return this.normalizeCodexDynamicToolCall(item); + case 'collabagenttoolcall': + case 'collabtoolcall': + return this.normalizeCodexCollabAgentToolCall(item); default: return undefined; } } + private normalizeCodexDynamicToolCall(item: NonNullable): NormalizedCodexToolItem | undefined { + if (!item.id) return undefined; + const rawToolName = this.getStringField(item, 'tool') ?? 'dynamic_tool'; + const toolKey = this.normalizeCodexItemType(rawToolName); + const namespace = this.getStringField(item, 'namespace'); + const toolArguments = this.normalizeCodexArguments(item.arguments); + const failed = this.isFailedStatus(item.status) || item.success === false; + const output = this.stringifyDynamicContentItems(item.contentItems ?? item.content_items) ?? this.stringifyCodexResult(item.result); + const error = this.getCodexErrorMessage(item.error) ?? (failed ? output ?? `${rawToolName} failed` : undefined); + + if (toolKey === 'spawnagent' || toolKey === 'task') { + const parameters = this.normalizeAgentParameters(toolArguments); + return { + id: item.id, + toolName: 'task', + parameters: { + ...parameters, + agent_type: typeof parameters.agent_type === 'string' ? parameters.agent_type : 'codex', + }, + ...(error ? { error } : { result: output ?? this.summarizeDynamicAgentResult('spawnAgent', parameters) }), + }; + } + + if (toolKey === 'waitagent' || toolKey === 'readagent' || toolKey === 'wait') { + const parameters = this.normalizeAgentParameters(toolArguments); + return { + id: item.id, + toolName: 'read_agent', + parameters: { + ...parameters, + wait: parameters.wait ?? true, + }, + ...(error ? { error } : { result: output ?? this.summarizeDynamicAgentResult('wait', parameters) }), + }; + } + + const parameters = namespace + ? { namespace, arguments: toolArguments } + : toolArguments; + return { + id: item.id, + toolName: rawToolName, + parameters, + ...(error ? { error } : { result: output }), + }; + } + + private normalizeCodexCollabAgentToolCall(item: NonNullable): NormalizedCodexToolItem | undefined { + if (!item.id) return undefined; + const tool = this.getStringField(item, 'tool') ?? 'collabAgentToolCall'; + const toolKey = this.normalizeCodexItemType(tool); + const receiverThreadIds = this.getReceiverThreadIds(item); + const agentStates = this.getAgentStates(item); + const prompt = this.getStringField(item, 'prompt'); + const baseParameters = this.buildCollabAgentParameters(item, receiverThreadIds, agentStates); + const failed = this.isFailedStatus(item.status); + const result = this.summarizeCollabAgentResult(tool, receiverThreadIds, agentStates); + const error = this.getCodexErrorMessage(item.error) ?? (failed ? result ?? `${tool} failed` : undefined); + + if (toolKey === 'spawnagent') { + return { + id: item.id, + toolName: 'task', + parameters: { + agent_type: 'codex', + ...(prompt ? { description: prompt, prompt } : {}), + ...baseParameters, + }, + ...(error ? { error } : { result: result ?? this.summarizeCollabAgentSpawn(receiverThreadIds) }), + }; + } + + if (toolKey === 'wait') { + return { + id: item.id, + toolName: 'read_agent', + parameters: { + ...baseParameters, + wait: true, + }, + ...(error ? { error } : { result: result ?? 'Agent wait completed' }), + }; + } + + return { + id: item.id, + toolName: `codex_${this.toSnakeCase(tool)}`, + parameters: { + operation: tool, + ...(prompt ? { prompt } : {}), + ...baseParameters, + }, + ...(error ? { error } : { result: result ?? `${tool} completed` }), + }; + } + + private isTerminalCodexToolItem(item: CodexItemEvent['item'] | undefined): boolean { + if (!item) return false; + if (this.isCompletedStatus(item.status) || this.isFailedStatus(item.status)) return true; + return typeof item.success === 'boolean'; + } + + private normalizeCodexItemType(type: string): string { + return type.replace(/[_-]/g, '').toLowerCase(); + } + + private isCompletedStatus(status: unknown): boolean { + return typeof status === 'string' && status.toLowerCase() === 'completed'; + } + + private isFailedStatus(status: unknown): boolean { + if (typeof status !== 'string') return false; + const normalized = status.toLowerCase(); + return normalized === 'failed' || normalized === 'errored'; + } + + private getStringField(value: unknown, ...keys: string[]): string | undefined { + if (!this.isRecord(value)) return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === 'string' && field.length > 0) return field; + } + return undefined; + } + + private getNumberField(value: unknown, ...keys: string[]): number | undefined { + if (!this.isRecord(value)) return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === 'number') return field; + } + return undefined; + } + + private normalizeCodexArguments(value: unknown): Record { + if (this.isRecord(value)) return { ...value }; + return value === undefined ? {} : { arguments: value }; + } + + private normalizeAgentParameters(parameters: Record): Record { + const normalized = { ...parameters }; + if (typeof normalized.agent_id !== 'string') { + for (const key of ['agentId', 'threadId', 'thread_id', 'receiverThreadId', 'receiver_thread_id']) { + if (typeof normalized[key] === 'string') { + normalized.agent_id = normalized[key]; + break; + } + } + } + return normalized; + } + + private getCodexErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string' && error.length > 0) return error; + if (this.isRecord(error) && typeof error.message === 'string' && error.message.length > 0) { + return error.message; + } + return undefined; + } + + private getReceiverThreadIds(item: NonNullable): string[] { + const ids = this.getStringArrayField(item, 'receiverThreadIds', 'receiver_thread_ids'); + for (const key of ['receiverThreadId', 'receiver_thread_id', 'newThreadId', 'new_thread_id']) { + const id = this.getStringField(item, key); + if (id && !ids.includes(id)) ids.push(id); + } + return ids; + } + + private getStringArrayField(value: unknown, ...keys: string[]): string[] { + if (!this.isRecord(value)) return []; + for (const key of keys) { + const field = value[key]; + if (Array.isArray(field)) { + return field.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); + } + } + return []; + } + + private getAgentStates(item: NonNullable): Record { + const raw = this.isRecord(item.agentsStates) ? item.agentsStates : item.agents_states; + if (!this.isRecord(raw)) return {}; + const states: Record = {}; + for (const [id, state] of Object.entries(raw)) { + if (!this.isRecord(state)) continue; + states[id] = { + ...(typeof state.status === 'string' ? { status: state.status } : {}), + ...(typeof state.message === 'string' || state.message === null ? { message: state.message } : {}), + }; + } + return states; + } + + private buildCollabAgentParameters( + item: NonNullable, + receiverThreadIds: string[], + agentStates: Record, + ): Record { + const firstAgentId = receiverThreadIds[0]; + const firstAgentState = firstAgentId ? agentStates[firstAgentId] : undefined; + const senderThreadId = this.getStringField(item, 'senderThreadId', 'sender_thread_id'); + const model = this.getStringField(item, 'model'); + const reasoningEffort = this.getStringField(item, 'reasoningEffort', 'reasoning_effort'); + return { + ...(firstAgentId ? { agent_id: firstAgentId } : {}), + ...(receiverThreadIds.length > 0 ? { agent_ids: receiverThreadIds } : {}), + ...(senderThreadId ? { sender_thread_id: senderThreadId } : {}), + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + ...(Object.keys(agentStates).length > 0 ? { agents_states: agentStates } : {}), + ...(firstAgentState?.status ? { agent_status: firstAgentState.status } : {}), + ...(firstAgentState?.message ? { agent_message: firstAgentState.message } : {}), + }; + } + + private summarizeCollabAgentSpawn(receiverThreadIds: string[]): string { + return receiverThreadIds.length > 0 + ? `Agent started with agent_id: ${receiverThreadIds.join(', ')}` + : 'Agent started'; + } + + private summarizeCollabAgentResult( + tool: string, + receiverThreadIds: string[], + agentStates: Record, + ): string | undefined { + const stateSummary = this.summarizeAgentStates(receiverThreadIds, agentStates); + if (stateSummary) return stateSummary; + if (this.normalizeCodexItemType(tool) === 'spawnagent') return this.summarizeCollabAgentSpawn(receiverThreadIds); + if (receiverThreadIds.length > 0) return `${tool} completed for ${receiverThreadIds.join(', ')}`; + return undefined; + } + + private summarizeAgentStates( + receiverThreadIds: string[], + agentStates: Record, + ): string | undefined { + const ids = receiverThreadIds.length > 0 ? receiverThreadIds : Object.keys(agentStates); + const lines = ids + .map(id => { + const state = agentStates[id]; + if (!state) return undefined; + const status = state.status ?? 'unknown'; + return `${id} ${status}${state.message ? `: ${state.message}` : ''}`; + }) + .filter((line): line is string => !!line); + return lines.length > 0 ? lines.join('\n') : undefined; + } + + private summarizeDynamicAgentResult(tool: 'spawnAgent' | 'wait', parameters: Record): string { + const agentId = typeof parameters.agent_id === 'string' ? parameters.agent_id : undefined; + if (tool === 'spawnAgent') { + return agentId ? `Agent started with agent_id: ${agentId}` : 'Agent started'; + } + return agentId ? `Agent ${agentId} completed` : 'Agent wait completed'; + } + + private stringifyDynamicContentItems(contentItems: unknown): string | undefined { + if (!Array.isArray(contentItems)) return undefined; + const parts = contentItems + .map(item => this.isRecord(item) && typeof item.text === 'string' ? item.text : undefined) + .filter((text): text is string => !!text); + return parts.length > 0 ? parts.join('\n') : undefined; + } + + private toSnakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase(); + } + + private isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + private summarizeFileChanges(changes: Array<{ path?: string; kind?: string }>): string { if (changes.length === 0) return 'File changes applied'; const byKind = new Map(); diff --git a/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts b/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts index 6d1e8827f..9d76b61dc 100644 --- a/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts +++ b/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts @@ -999,6 +999,252 @@ describe('ClaudeSDKService.sendMessage', () => { }); }); + it('normalizes Claude Agent tool calls to task and preserves subagent child nesting', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + parent_tool_use_id: null, + message: { + content: [ + { + type: 'tool_use', + id: 'agent-tool', + name: 'Agent', + input: { + description: 'Get time', + prompt: 'Run date -u', + subagent_type: 'general-purpose', + }, + }, + ], + }, + }, + { + type: 'assistant', + parent_tool_use_id: 'agent-tool', + message: { + content: [ + { + type: 'tool_use', + id: 'bash-child', + name: 'Bash', + input: { command: 'date -u' }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'bash-child', + content: 'Sat Jun 13 23:35:39 UTC 2026', + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'agent-tool', + content: 'done\nagentId: af43d1cb10a1f5b7d', + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const toolEvents: object[] = []; + const result = await svc.sendMessage({ + prompt: 'ask a subagent', + onToolEvent: (e) => toolEvents.push(e), + }); + + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'agent-tool', + name: 'task', + status: 'completed', + args: { + description: 'Get time', + prompt: 'Run date -u', + subagent_type: 'general-purpose', + agent_type: 'general-purpose', + agent_id: 'af43d1cb10a1f5b7d', + agent_ids: ['af43d1cb10a1f5b7d'], + }, + result: 'done\nagentId: af43d1cb10a1f5b7d', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'bash-child', + name: 'Bash', + status: 'completed', + parentToolCallId: 'agent-tool', + args: { command: 'date -u' }, + result: 'Sat Jun 13 23:35:39 UTC 2026', + }); + expect(toolEvents).toEqual([ + expect.objectContaining({ + type: 'tool-start', + toolCallId: 'agent-tool', + toolName: 'task', + parameters: expect.objectContaining({ + agent_type: 'general-purpose', + description: 'Get time', + }), + }), + expect.objectContaining({ + type: 'tool-start', + toolCallId: 'bash-child', + toolName: 'Bash', + parentToolCallId: 'agent-tool', + }), + expect.objectContaining({ + type: 'tool-complete', + toolCallId: 'bash-child', + parentToolCallId: 'agent-tool', + result: 'Sat Jun 13 23:35:39 UTC 2026', + }), + expect.objectContaining({ + type: 'tool-complete', + toolCallId: 'agent-tool', + toolName: 'task', + parameters: expect.objectContaining({ + agent_id: 'af43d1cb10a1f5b7d', + agent_ids: ['af43d1cb10a1f5b7d'], + }), + result: 'done\nagentId: af43d1cb10a1f5b7d', + }), + ]); + }); + + it('captures structured Claude Agent output metadata on task tool calls', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'agent-structured', + name: 'Agent', + input: { + description: 'Review patch', + prompt: 'Review the diff', + }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'agent-structured', + content: { + status: 'completed', + agentId: 'agent-42', + agentType: 'reviewer', + content: [{ type: 'text', text: 'all done' }], + totalToolUseCount: 1, + totalDurationMs: 12, + totalTokens: 34, + usage: { + input_tokens: 10, + output_tokens: 24, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + service_tier: null, + cache_creation: null, + }, + prompt: 'Review the diff', + }, + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const result = await svc.sendMessage({ prompt: 'review with subagent' }); + + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'agent-structured', + name: 'task', + status: 'completed', + args: { + description: 'Review patch', + prompt: 'Review the diff', + agent_type: 'reviewer', + agent_id: 'agent-42', + agent_ids: ['agent-42'], + agent_status: 'completed', + }, + result: 'all done', + }); + }); + + it('normalizes Claude TaskOutput calls to read_agent with wait metadata', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'task-output', + name: 'TaskOutput', + input: { + task_id: 'agent-bg', + block: true, + timeout: 120000, + }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'task-output', + content: 'background done', + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const result = await svc.sendMessage({ prompt: 'wait for background agent' }); + + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'task-output', + name: 'read_agent', + status: 'completed', + args: { + task_id: 'agent-bg', + agent_id: 'agent-bg', + block: true, + wait: true, + timeout: 120, + timeout_ms: 120000, + }, + result: 'background done', + }); + }); + it('marks Claude tool_result blocks with is_error as failed', async () => { queryFn.mockReturnValueOnce(makeMessages([ { diff --git a/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts b/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts new file mode 100644 index 000000000..ef644acb1 --- /dev/null +++ b/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CodexSDKService } from '../../src/codex-sdk-service'; + +type CodexItemTestEvent = { + type: 'item.started' | 'item.updated' | 'item.completed'; + item: Record; +}; + +function makeThread(threadId = 'thread-1', itemEvents: CodexItemTestEvent[] = []) { + return { + id: threadId, + runStreamed: vi.fn(async () => ({ + events: (async function* () { + yield { type: 'thread.started' as const, thread_id: threadId }; + for (const event of itemEvents) { + yield event; + } + yield { type: 'item.completed' as const, item: { id: 'msg-1', type: 'agent_message', text: 'ok' } }; + })(), + })), + }; +} + +async function sendWithEvents(itemEvents: CodexItemTestEvent[], onToolEvent?: (event: any) => void) { + const svc = new CodexSDKService(); + const client = { + startThread: vi.fn(() => makeThread('thread-collab', itemEvents)), + resumeThread: vi.fn(), + }; + (svc as unknown as { sdk: unknown }).sdk = client; + (svc as unknown as { availabilityCache: unknown }).availabilityCache = { available: true }; + + try { + return await svc.sendMessage({ prompt: 'run sub agents', onToolEvent }); + } finally { + svc.dispose(); + } +} + +describe('CodexSDKService collaboration tool capture', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps Codex collabAgentToolCall spawnAgent and wait into task/read_agent calls', async () => { + const toolEvents: any[] = []; + const result = await sendWithEvents([ + { + type: 'item.started', + item: { + id: 'collab-spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'inProgress', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + prompt: 'Report the current time', + model: 'gpt-5.4-codex', + reasoningEffort: 'medium', + agentsStates: { 'agent-0': { status: 'pendingInit', message: null } }, + }, + }, + { + type: 'item.completed', + item: { + id: 'collab-spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'completed', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + prompt: 'Report the current time', + model: 'gpt-5.4-codex', + reasoningEffort: 'medium', + agentsStates: { 'agent-0': { status: 'running', message: 'Checking clock' } }, + }, + }, + { + type: 'item.started', + item: { + id: 'collab-wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'inProgress', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + agentsStates: { 'agent-0': { status: 'running', message: 'Checking clock' } }, + }, + }, + { + type: 'item.updated', + item: { + id: 'collab-wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'completed', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + agentsStates: { 'agent-0': { status: 'completed', message: 'It is 23:15 UTC' } }, + }, + }, + ], event => toolEvents.push(event)); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.response).toBe('ok'); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'collab-spawn-1', + name: 'task', + status: 'completed', + args: { + agent_type: 'codex', + agent_id: 'agent-0', + agent_ids: ['agent-0'], + description: 'Report the current time', + prompt: 'Report the current time', + sender_thread_id: 'parent-thread', + model: 'gpt-5.4-codex', + reasoning_effort: 'medium', + agent_status: 'running', + agent_message: 'Checking clock', + }, + result: 'agent-0 running: Checking clock', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'collab-wait-1', + name: 'read_agent', + status: 'completed', + args: { + agent_id: 'agent-0', + agent_ids: ['agent-0'], + sender_thread_id: 'parent-thread', + wait: true, + agent_status: 'completed', + agent_message: 'It is 23:15 UTC', + }, + result: 'agent-0 completed: It is 23:15 UTC', + }); + expect(toolEvents.map(event => `${event.type}:${event.toolName}`)).toEqual([ + 'tool-start:task', + 'tool-complete:task', + 'tool-start:read_agent', + 'tool-complete:read_agent', + ]); + }); + + it('maps dynamic spawn_agent and wait_agent host tools into task/read_agent calls', async () => { + const result = await sendWithEvents([ + { + type: 'item.completed', + item: { + id: 'dynamic-spawn-1', + type: 'dynamicToolCall', + tool: 'spawn_agent', + status: 'completed', + arguments: { + agentId: 'agent-d', + prompt: 'Summarize the file', + description: 'Summarize the file', + }, + contentItems: [{ type: 'inputText', text: 'Agent started with agent_id: agent-d' }], + success: true, + }, + }, + { + type: 'item.completed', + item: { + id: 'dynamic-wait-1', + type: 'dynamicToolCall', + tool: 'wait_agent', + status: 'completed', + arguments: { + agentId: 'agent-d', + timeout: 30, + }, + contentItems: [{ type: 'inputText', text: 'agent-d completed: done' }], + success: true, + }, + }, + ]); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'dynamic-spawn-1', + name: 'task', + status: 'completed', + args: { + agent_type: 'codex', + agentId: 'agent-d', + agent_id: 'agent-d', + prompt: 'Summarize the file', + description: 'Summarize the file', + }, + result: 'Agent started with agent_id: agent-d', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'dynamic-wait-1', + name: 'read_agent', + status: 'completed', + args: { + agentId: 'agent-d', + agent_id: 'agent-d', + timeout: 30, + wait: true, + }, + result: 'agent-d completed: done', + }); + }); + + it('keeps non-agent dynamic tool calls visible with namespace metadata and failures', async () => { + const result = await sendWithEvents([ + { + type: 'item.completed', + item: { + id: 'dynamic-generic-1', + type: 'dynamicToolCall', + namespace: 'host', + tool: 'lookup', + status: 'failed', + arguments: { query: 'open issues' }, + contentItems: [{ type: 'inputText', text: 'lookup failed' }], + success: false, + }, + }, + ]); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'dynamic-generic-1', + name: 'lookup', + status: 'failed', + args: { + namespace: 'host', + arguments: { query: 'open issues' }, + }, + error: 'lookup failed', + }); + }); +}); diff --git a/packages/coc-client/src/client.ts b/packages/coc-client/src/client.ts index 47152b467..405ae9c36 100644 --- a/packages/coc-client/src/client.ts +++ b/packages/coc-client/src/client.ts @@ -1,4 +1,4 @@ -import { AdminClient, AgentProvidersClient, CanvasesClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TaskGroupsClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; +import { AdminClient, AgentProvidersClient, CanvasesClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NativeCliSessionsClient, NativeCopilotSessionsClient, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TaskGroupsClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; import { HttpTransport, normalizeOptions } from './http'; import { EventsClient } from './realtime'; import type { CocClientOptions, CocRequestOptions, NormalizedCocClientOptions } from './types'; @@ -16,6 +16,8 @@ export class CocClient { readonly health: HealthClient; readonly memory: MemoryClient; readonly memoryV2: MemoryV2Client; + readonly nativeCliSessions: NativeCliSessionsClient; + readonly nativeCopilotSessions: NativeCopilotSessionsClient; readonly notes: NotesClient; readonly preferences: PreferencesClient; readonly processes: ProcessesClient; @@ -57,6 +59,8 @@ export class CocClient { this.health = new HealthClient(this.transport); this.memory = new MemoryClient(this.transport); this.memoryV2 = new MemoryV2Client(this.transport); + this.nativeCliSessions = new NativeCliSessionsClient(this.transport); + this.nativeCopilotSessions = new NativeCopilotSessionsClient(this.transport); this.notes = new NotesClient(this.transport); this.preferences = new PreferencesClient(this.transport); this.processes = new ProcessesClient(this.transport, this.options); diff --git a/packages/coc-client/src/contracts/admin.ts b/packages/coc-client/src/contracts/admin.ts index d3c389bcb..04361a4bd 100644 --- a/packages/coc-client/src/contracts/admin.ts +++ b/packages/coc-client/src/contracts/admin.ts @@ -86,6 +86,8 @@ export interface AdminResolvedConfig { mapReduce?: { enabled?: boolean }; dreams?: { enabled?: boolean; + provider?: AdminDefaultProvider; + model?: string; idleCheckIntervalMs?: number; minIdleMs?: number; confidenceThreshold?: number; @@ -116,6 +118,7 @@ export interface AdminResolvedConfig { commitChatLensDormantMode?: 'ghost' | 'pill'; autoAgentProviderRouting?: boolean; ralphMultiAgentGrill?: boolean; + nativeCliSessions?: boolean; }; workItems?: { hierarchy?: { enabled?: boolean }; sync?: { enabled?: boolean }; aiAuthoring?: { enabled?: boolean }; workflow?: { enabled?: boolean } }; effortLevels?: { enabled?: boolean }; @@ -174,6 +177,10 @@ export interface AdminConfigUpdate { 'forEach.enabled'?: boolean; 'mapReduce.enabled'?: boolean; 'dreams.enabled'?: boolean; + 'dreams.provider'?: AdminDefaultProvider | null; + 'dreams.model'?: string | null; + 'dreams.idleCheckIntervalMs'?: number; + 'dreams.timeoutMs'?: number; 'excalidraw.enabled'?: boolean; 'mcpOauth.enabled'?: boolean; 'mcpOauth.autoRefresh.enabled'?: boolean; @@ -191,6 +198,7 @@ export interface AdminConfigUpdate { 'features.commitChatLens'?: boolean; 'features.commitChatLensDormantMode'?: 'ghost' | 'pill'; 'features.autoAgentProviderRouting'?: boolean; + 'features.nativeCliSessions'?: boolean; 'effortLevels.enabled'?: boolean; [key: string]: unknown; } @@ -239,6 +247,7 @@ export interface RuntimeDashboardConfig { commitChatLensEnabled: boolean; commitChatLensDormantMode: 'ghost' | 'pill'; effortLevelsEnabled: boolean; + nativeCliSessionsEnabled: boolean; }; hostname?: string; bindAddress?: string; diff --git a/packages/coc-client/src/contracts/index.ts b/packages/coc-client/src/contracts/index.ts index 4527e6084..71507dc0c 100644 --- a/packages/coc-client/src/contracts/index.ts +++ b/packages/coc-client/src/contracts/index.ts @@ -8,6 +8,7 @@ export * from './for-each'; export * from './git'; export * from './map-reduce'; export * from './memory'; +export * from './native-copilot-sessions'; export * from './notes'; export * from './preferences'; export * from './processes'; diff --git a/packages/coc-client/src/contracts/native-copilot-sessions.ts b/packages/coc-client/src/contracts/native-copilot-sessions.ts new file mode 100644 index 000000000..8f6f30d8b --- /dev/null +++ b/packages/coc-client/src/contracts/native-copilot-sessions.ts @@ -0,0 +1,191 @@ +/** + * Native GitHub Copilot CLI session contracts. + * + * Read-only, workspace-scoped views over the CoC server user's native + * Copilot CLI session store (`~/.copilot/session-store.db`). These sessions + * are external data: CoC never modifies them and never imports them into + * CoC process history. + */ + +/** Reason a native-session response carries no data. */ +export type NativeCopilotSessionsUnavailableReason = 'feature-disabled' | 'db-missing' | 'db-invalid'; + +export interface NativeCopilotSessionListItem { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summaryPreview: string; + createdAt: string | null; + updatedAt: string | null; + turnCount: number; + matchSnippets: string[]; +} + +export interface NativeCopilotSessionTurn { + id: number; + turnIndex: number; + timestamp: string | null; + userMessage: string; + assistantResponse: string; + userChars: number; + assistantChars: number; + searchIndexSourceId: string | null; + searchIndexChars: number | null; +} + +/** + * One reconstructed tool call inside a {@link ReconstructedConversationTurn}. + * Mirrors the SPA-side `ClientToolCall` so the dashboard chat components + * (`ConversationArea` / `ConversationTurnBubble`) render it without a fork. + */ +export interface ReconstructedToolCall { + id: string; + toolName: string; + /** Raw tool arguments object as recorded by the native CLI. */ + args: unknown; + /** Tool result text (full `detailedContent`, else short `content`) when it succeeded. */ + result?: string; + /** Error message when the tool call failed. */ + error?: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + startTime?: string; + endTime?: string; +} + +/** + * One timeline event inside a {@link ReconstructedConversationTurn}, mirroring + * the SPA-side `ClientTimelineItem` so the chat bubble can interleave assistant + * text and tool cards in chronological order. + */ +export interface ReconstructedTimelineItem { + type: 'content' | 'tool-start' | 'tool-complete' | 'tool-failed'; + timestamp: string; + content?: string; + toolCall?: ReconstructedToolCall; +} + +/** + * A single reconstructed conversation turn, mirroring the subset of the + * SPA-side `ClientConversationTurn` that the read-only native-session detail + * view populates. Built either from the rich `session-state//events.jsonl` + * log or, as a fallback, from the flat `session-store.db` turns. + */ +export interface ReconstructedConversationTurn { + role: 'user' | 'assistant'; + /** Primary markdown content of the turn. */ + content: string; + timestamp?: string; + turnIndex?: number; + toolCalls?: ReconstructedToolCall[]; + timeline: ReconstructedTimelineItem[]; + /** Base64 data-URL strings for images attached to or produced in this turn. */ + images?: string[]; + /** Readable model reasoning/thinking for an assistant turn. */ + thinking?: string; + /** Skills invoked during this turn. */ + skillNames?: string[]; + /** Model that produced an assistant turn (e.g. `gpt-5.5`, `claude-opus-4.8`). */ + model?: string; + /** True when an assistant turn ended in an error. */ + isError?: boolean; +} + +export interface NativeCopilotSessionDetail { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summary: string; + createdAt: string | null; + updatedAt: string | null; + turns: NativeCopilotSessionTurn[]; + /** + * Reconstructed chat transcript for rich rendering: parser output from the + * native `session-state//events.jsonl` log when available, else text-only + * turns mapped from the flat `turns` above. Always present (possibly empty). + */ + conversation: ReconstructedConversationTurn[]; +} + +export interface ListNativeCopilotSessionsOptions { + /** Free-text query against natively indexed content. */ + q?: string; + /** Exact or partial session ID. */ + sessionId?: string; + /** Exact branch filter. */ + branch?: string; + /** ISO timestamp lower bound on updated time (inclusive). */ + from?: string; + /** ISO timestamp upper bound on updated time (inclusive). */ + to?: string; + limit?: number; + offset?: number; +} + +export interface ListNativeCopilotSessionsResponse { + enabled: boolean; + /** Present when `enabled` is true; false when the native DB is missing/invalid. */ + available?: boolean; + reason?: NativeCopilotSessionsUnavailableReason; + items: NativeCopilotSessionListItem[]; + total: number; + /** False when metadata tables exist but the native search index is absent. */ + searchIndexAvailable?: boolean; + /** Count of native sessions hidden because they are already tracked as CoC processes (Activity tab). */ + deduplicatedCount?: number; + /** Count of native sessions hidden because they are background jobs (e.g. title summarization). */ + backgroundJobCount?: number; + limit: number; + offset: number; +} + +export interface NativeCopilotSessionDetailResponse { + enabled: boolean; + available?: boolean; + reason?: NativeCopilotSessionsUnavailableReason; + session?: NativeCopilotSessionDetail; +} + +export type NativeCliSessionProviderId = 'copilot' | 'codex' | 'claude'; +export type NativeCliSessionsUnavailableReason = 'feature-disabled' | 'store-missing' | 'store-invalid'; + +export interface NativeCliSessionListItem extends NativeCopilotSessionListItem { + provider: NativeCliSessionProviderId; + storePath: string; + searchIndexAvailable: boolean; +} + +export interface NativeCliSessionDetail extends NativeCopilotSessionDetail { + provider: NativeCliSessionProviderId; + storePath: string; + searchIndexAvailable: boolean; +} + +export interface ListNativeCliSessionsOptions extends ListNativeCopilotSessionsOptions { + provider: NativeCliSessionProviderId; +} + +export interface ListNativeCliSessionsResponse { + enabled: boolean; + available?: boolean; + reason?: NativeCliSessionsUnavailableReason; + provider?: NativeCliSessionProviderId; + items: NativeCliSessionListItem[]; + total: number; + searchIndexAvailable?: boolean; + deduplicatedCount?: number; + backgroundJobCount?: number; + limit: number; + offset: number; +} + +export interface NativeCliSessionDetailResponse { + enabled: boolean; + available?: boolean; + reason?: NativeCliSessionsUnavailableReason; + provider?: NativeCliSessionProviderId; + session?: NativeCliSessionDetail; +} diff --git a/packages/coc-client/src/contracts/preferences.ts b/packages/coc-client/src/contracts/preferences.ts index d1905be16..c0f3ee653 100644 --- a/packages/coc-client/src/contracts/preferences.ts +++ b/packages/coc-client/src/contracts/preferences.ts @@ -96,11 +96,36 @@ export interface TaskSettingsUpdate { folderPaths: string[]; } +/** + * Compact, display-only description of a single LLM tool input parameter. + * Derived from a tool's JSON-schema `parameters` for the settings UI; it never + * affects tool execution or persisted preferences. + */ +export interface LlmToolParam { + /** Parameter name as declared in the tool input schema. */ + name: string; + /** + * Compact type label: a JSON-schema primitive (`string`, `number`, + * `boolean`, `integer`), `{...}` for nested objects, `[...]` for arrays, + * `enum` for typeless enums, or `any` when the type cannot be determined. + */ + type: string; + /** Whether the parameter is required by the tool's input schema. */ + required: boolean; +} + export interface LlmToolMeta { name: string; label: string; description: string; enabledByDefault: boolean; + /** + * Optional, additive compact parameter summary derived from the tool's input + * schema. Absent when no JSON-schema is available (render as "parameters + * unavailable"); an empty array means the tool takes no parameters. Existing + * clients that only read name/label/description/enabledByDefault ignore this. + */ + params?: LlmToolParam[]; } export interface LlmToolsConfig { diff --git a/packages/coc-client/src/domains/index.ts b/packages/coc-client/src/domains/index.ts index a1fa2d66e..1900ebb89 100644 --- a/packages/coc-client/src/domains/index.ts +++ b/packages/coc-client/src/domains/index.ts @@ -9,6 +9,8 @@ export { ForEachClient } from './for-each'; export { GitClient } from './git'; export { HealthClient } from './health'; export { MemoryClient, MemoryV2Client } from './memory'; +export { NativeCliSessionsClient } from './native-cli-sessions'; +export { NativeCopilotSessionsClient } from './native-copilot-sessions'; export { NotesClient } from './notes'; export { PreferencesClient } from './preferences'; export { ProcessesClient } from './processes'; diff --git a/packages/coc-client/src/domains/native-cli-sessions.ts b/packages/coc-client/src/domains/native-cli-sessions.ts new file mode 100644 index 000000000..4c4eeae5a --- /dev/null +++ b/packages/coc-client/src/domains/native-cli-sessions.ts @@ -0,0 +1,51 @@ +import type { + ListNativeCliSessionsOptions, + ListNativeCliSessionsResponse, + NativeCliSessionDetailResponse, + NativeCliSessionProviderId, +} from '../contracts'; +import type { RequestAdapter } from '../types'; +import { encodePathSegment } from '../url'; + +function sessionsPath(workspaceId: string, suffix = ''): string { + return `/workspaces/${encodePathSegment(workspaceId)}/native-cli-sessions${suffix}`; +} + +function listQuery(options: ListNativeCliSessionsOptions): Record { + return { + provider: options.provider, + q: options.q, + sessionId: options.sessionId, + branch: options.branch, + from: options.from, + to: options.to, + limit: options.limit, + offset: options.offset, + }; +} + +/** + * Read-only client for native Copilot, Codex, and Claude Code CLI sessions. + * The server exposes list and detail reads only; there are no mutation endpoints. + */ +export class NativeCliSessionsClient { + constructor(private readonly transport: RequestAdapter) {} + + list(workspaceId: string, options: ListNativeCliSessionsOptions): Promise { + return this.transport.request( + sessionsPath(workspaceId), + { query: listQuery(options) }, + ); + } + + get( + workspaceId: string, + sessionId: string, + provider: NativeCliSessionProviderId, + ): Promise { + return this.transport.request( + sessionsPath(workspaceId, `/${encodePathSegment(sessionId)}`), + { query: { provider } }, + ); + } +} diff --git a/packages/coc-client/src/domains/native-copilot-sessions.ts b/packages/coc-client/src/domains/native-copilot-sessions.ts new file mode 100644 index 000000000..955ad75b9 --- /dev/null +++ b/packages/coc-client/src/domains/native-copilot-sessions.ts @@ -0,0 +1,46 @@ +import type { + ListNativeCopilotSessionsOptions, + ListNativeCopilotSessionsResponse, + NativeCopilotSessionDetailResponse, +} from '../contracts'; +import type { RequestAdapter } from '../types'; +import { encodePathSegment } from '../url'; + +function sessionsPath(workspaceId: string, suffix = ''): string { + return `/workspaces/${encodePathSegment(workspaceId)}/native-copilot-sessions${suffix}`; +} + +function listQuery(options: ListNativeCopilotSessionsOptions | undefined): Record | undefined { + if (!options) return undefined; + return { + q: options.q, + sessionId: options.sessionId, + branch: options.branch, + from: options.from, + to: options.to, + limit: options.limit, + offset: options.offset, + }; +} + +/** + * Read-only client for native GitHub Copilot CLI sessions. The server exposes + * list and detail reads only; there are no mutation endpoints for this domain. + */ +export class NativeCopilotSessionsClient { + constructor(private readonly transport: RequestAdapter) {} + + list(workspaceId: string, options?: ListNativeCopilotSessionsOptions): Promise { + const query = listQuery(options); + return this.transport.request( + sessionsPath(workspaceId), + query ? { query } : undefined, + ); + } + + get(workspaceId: string, sessionId: string): Promise { + return this.transport.request( + sessionsPath(workspaceId, `/${encodePathSegment(sessionId)}`), + ); + } +} diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index fe812b8b4..dd72ec1cc 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -123,6 +123,41 @@ all have their own `references/*.md`. per-trigger cap and low priority instead of adding client-side POST loops. The Team toolbar status UI should read batch status and route manual "Classify now" actions through the same bounded server helper. +- **Native Copilot session reads** (`src/server/native-copilot-sessions/`) + must stay strictly read-only against the native store: open + `~/.copilot/session-store.db` with short-lived `readonly` SQLite connections, + keep every user-provided filter parameterized (FTS terms literal-quoted), and + return typed `db-missing`/`db-invalid` states instead of throwing. Never route + native session IDs into CoC process/chat action handlers. Rich detail + reconstruction reads the per-session log + `~/.copilot/session-state//events.jsonl` via `session-state-parser.ts` + (`parseNativeSessionState`), which maps the newline-delimited + `{type,id,parentId,timestamp,data}` events (`user.message`, + `assistant.message` with `content`/`reasoningText`/`model`, + `tool.execution_start`/`_complete` correlated by `toolCallId`, + `skill.invoked`) into `ReconstructedConversationTurn[]` and returns `null` + (never throws) on a missing/malformed/empty log so callers fall back to the + flat `session-store.db` turns. `getSession` populates + `NativeCopilotSessionDetail.conversation` (always present) from the parser + when it yields turns, else maps the flat DB turns into text-only + user/assistant turns; the service accepts `sessionStateDir`/`parseSessionState` + overrides for hermetic tests. The parser never writes to `~/.copilot` and + rejects unsafe session ids (path traversal). The list route dedups + against CoC processes by excluding native `sessions.id` values that match a + workspace's `ProcessStore.getSdkSessionIds(workspaceId)` (the Copilot SDK/CLI + session id equals the native store id) and hides automated background-job + sessions whose first flat turn or stored summary matches + `BACKGROUND_JOB_PROMPT_PREFIXES` (e.g. title summarization); the hidden counts + are returned as `deduplicatedCount` and `backgroundJobCount`. The panel + deep-links the selected session via + `#repos/{wsId}/copilot-sessions/{sessionId}`. The read-only detail pane + renders `NativeCopilotSessionDetail.conversation` as a rich transcript by + reusing the existing chat `ConversationTurnBubble` (no fork): the SPA-local + `nativeConversationTurns.ts` maps `ReconstructedConversationTurn[]` → + `ClientConversationTurn[]`, folding assistant `thinking` into the content + timeline as a markdown blockquote (the chat turn shape has no reasoning + field). The metadata header is preserved and no follow-up/streaming/resume or + per-turn (pin/archive/delete) actions are wired. - **Work-item create/update side effects** (hierarchy `parentId` validation, GitHub/Azure Boards provider sync, response-cache invalidation, dashboard broadcasts, auto-execute) live in the shared command service diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index d6b2a2694..e775a0fc1 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -176,6 +176,10 @@ export interface CLIConfig { /** Dreams review subsystem configuration. Disabled by default. */ dreams?: { enabled?: boolean; + /** Default provider for idle-triggered Dream runs. Defaults to the global provider when unset. */ + provider?: DefaultAgentProvider; + /** Default model for idle-triggered Dream runs. Defaults to the provider default when unset. */ + model?: string; /** Period between automatic idle dream checks. Default: 5 minutes. */ idleCheckIntervalMs?: number; /** Minimum quiet-window duration before automatic dream analysis may run. Default: 15 minutes. */ @@ -249,6 +253,8 @@ export interface CLIConfig { autoAgentProviderRouting?: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill?: boolean; + /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ + nativeCliSessions?: boolean; }; /** Memory promotion configuration */ memoryPromotion?: { @@ -456,6 +462,8 @@ export interface ResolvedCLIConfig { /** Dreams review subsystem configuration. */ dreams: { enabled: boolean; + provider?: DefaultAgentProvider; + model?: string; idleCheckIntervalMs: number; minIdleMs: number; confidenceThreshold: number; @@ -532,6 +540,8 @@ export interface ResolvedCLIConfig { autoAgentProviderRouting: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill: boolean; + /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ + nativeCliSessions: boolean; }; /** Memory promotion configuration */ memoryPromotion: { @@ -687,6 +697,8 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { }, dreams: { enabled: false, + provider: undefined, + model: undefined, idleCheckIntervalMs: 5 * 60 * 1000, minIdleMs: 15 * 60 * 1000, confidenceThreshold: 0.85, @@ -763,6 +775,7 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { commitChatLensDormantMode: 'ghost', autoAgentProviderRouting: false, ralphMultiAgentGrill: false, + nativeCliSessions: false, }, memoryPromotion: { batchSize: 50, diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index cd2a6865f..81b3c37ae 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -56,6 +56,8 @@ export type AdminSettingValueSpec = | { kind: 'enum'; values: readonly string[]; + /** Accept null/undefined; applying null clears the stored value. */ + nullable?: boolean; /** Validation error message override. */ message?: string; } @@ -226,8 +228,10 @@ export function validateAdminSettingValue(def: AdminSettingDefinition, value: un return ok ? undefined : numberMessage(def.key, spec); } case 'enum': { + if (spec.nullable && (value === null || value === undefined)) return undefined; const ok = typeof value === 'string' && spec.values.includes(value); - return ok ? undefined : (spec.message ?? `${def.key} must be one of: ${spec.values.join(', ')}`); + const base = spec.message ?? `${def.key} must be one of: ${spec.values.join(', ')}`; + return ok ? undefined : (spec.nullable ? `${base}, or null to clear` : base); } case 'custom': return spec.validate(value); @@ -241,6 +245,7 @@ function clearsStoredValue(def: AdminSettingDefinition, value: unknown): boolean if (spec.kind === 'string' && spec.nullable) { return value === null || (spec.clearOnEmpty === true && value === ''); } + if (spec.kind === 'enum' && spec.nullable) return value === null; return false; } @@ -580,14 +585,36 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ testId: 'toggle-loops-enabled', }, }), + // `dreams.enabled` is rendered bespoke in the admin Dreams tab + // (Knowledge nav group), not on the general Settings → Features grid, so it + // intentionally omits a `ui` block. Runtime flag + PUT validation are unchanged. bool({ key: 'dreams.enabled', default: false, runtime: 'live', runtimeFlag: 'dreamsEnabled', - ui: { - group: 'aiModes', order: 50, label: 'Dreams', badge: 'experimental', - hint: 'Enables workspace opt-in review cards from idle-time reflection. Disabled by default; workspaces must also opt in individually.', - testId: 'toggle-dreams-enabled', - }, }), + { + key: 'dreams.provider', + value: { kind: 'enum', values: ['copilot', 'codex', 'claude'], nullable: true, message: 'dreams.provider must be "copilot", "codex", or "claude"' }, + default: undefined, + runtime: 'live', + }, + { + key: 'dreams.model', + value: { kind: 'string', nullable: true, clearOnEmpty: true }, + default: undefined, + runtime: 'live', + }, + { + key: 'dreams.idleCheckIntervalMs', + value: { kind: 'number', integer: true, gt: 0, message: 'dreams.idleCheckIntervalMs must be a positive integer number of milliseconds' }, + default: 5 * 60 * 1000, + runtime: 'restartRequired', + }, + { + key: 'dreams.timeoutMs', + value: { kind: 'number', integer: true, gt: 0, message: 'dreams.timeoutMs must be a positive integer number of milliseconds' }, + default: 3_600_000, + runtime: 'live', + }, bool({ key: 'excalidraw.enabled', default: false, runtime: 'live', runtimeFlag: 'excalidrawEnabled', ui: { @@ -690,6 +717,14 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ }, }, bool({ key: 'features.autoAgentProviderRouting', default: false, runtime: 'restartRequired', runtimeFlag: 'autoAgentProviderRoutingEnabled' }), + bool({ + key: 'features.nativeCliSessions', default: false, runtime: 'live', runtimeFlag: 'nativeCliSessionsEnabled', + ui: { + group: 'dashboard', order: 60, label: 'Native CLI sessions', badge: 'experimental', + hint: 'Read-only CLI Sessions tab that lists native Copilot, Codex, and Claude Code sessions for the active workspace. Disabled by default.', + testId: 'toggle-native-cli-sessions-enabled', + }, + }), bool({ key: 'features.ralphMultiAgentGrill', default: false, runtime: 'live', runtimeFlag: 'ralphMultiAgentGrillEnabled', ui: { diff --git a/packages/coc/src/config/namespace-registry.ts b/packages/coc/src/config/namespace-registry.ts index b6e860f06..a240c366e 100644 --- a/packages/coc/src/config/namespace-registry.ts +++ b/packages/coc/src/config/namespace-registry.ts @@ -77,7 +77,6 @@ const DREAMS_BASE_SOURCE_KEYS = [ 'dreams.confidenceThreshold', 'dreams.maxCandidates', 'dreams.conversationLimit', - 'dreams.timeoutMs', ] as const; const MEMORY_PROMOTION_SOURCE_KEYS = [ @@ -232,7 +231,6 @@ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly str confidenceThreshold: override?.dreams?.confidenceThreshold ?? base.dreams?.confidenceThreshold ?? 0.85, maxCandidates: override?.dreams?.maxCandidates ?? base.dreams?.maxCandidates ?? 8, conversationLimit: override?.dreams?.conversationLimit ?? base.dreams?.conversationLimit ?? 20, - timeoutMs: override?.dreams?.timeoutMs ?? base.dreams?.timeoutMs ?? 3_600_000, } as ResolvedCLIConfig['dreams'], }), }, diff --git a/packages/coc/src/config/schema.ts b/packages/coc/src/config/schema.ts index 23f9a87d7..9b21823c1 100644 --- a/packages/coc/src/config/schema.ts +++ b/packages/coc/src/config/schema.ts @@ -77,7 +77,9 @@ function zodLeafForSpec(key: string, spec: AdminSettingValueSpec): z.ZodTypeAny case 'string': return z.string(); case 'enum': - return z.enum(spec.values as [string, ...string[]]); + return spec.nullable + ? z.enum(spec.values as [string, ...string[]]).nullish() + : z.enum(spec.values as [string, ...string[]]); case 'number': { let leaf = z.number(); if (spec.integer) leaf = leaf.int(); diff --git a/packages/coc/src/server/index.ts b/packages/coc/src/server/index.ts index ead105d3b..fe52dcdb9 100644 --- a/packages/coc/src/server/index.ts +++ b/packages/coc/src/server/index.ts @@ -532,6 +532,8 @@ export async function createExecutionServer(options: ExecutionServerOptions = {} hostname: os.hostname(), bindAddress: host, syncEngines, + nativeCopilotSessionDbPath: options.nativeCopilotSessionDbPath, + nativeCopilotSessionStateDir: options.nativeCopilotSessionStateDir, }); // Restore auto-commit timers for all workspaces that had it enabled notesGitTimerManager.startAll(store, dataDir).catch(() => { /* best-effort */ }); diff --git a/packages/coc/src/server/llm-tools/index.ts b/packages/coc/src/server/llm-tools/index.ts index c3adb3b7a..479a494c1 100644 --- a/packages/coc/src/server/llm-tools/index.ts +++ b/packages/coc/src/server/llm-tools/index.ts @@ -44,7 +44,10 @@ export { isLlmToolEnabled, filterDisabledLlmTools, type LlmToolMeta, + type LlmToolParam, } from './llm-tool-registry'; +export { summarizeToolParameters, compactParamType } from './llm-tool-parameters'; +export { LLM_TOOL_PARAMETER_SCHEMAS, withToolParameterMetadata } from './llm-tool-parameter-schemas'; export { createCreateLoopTool, createCancelLoopTool, diff --git a/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts new file mode 100644 index 000000000..a1b41f7b7 --- /dev/null +++ b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts @@ -0,0 +1,239 @@ +/** + * LLM tool parameter schemas (display-only mirror) + * + * A compact, structural mirror of each toggleable LLM tool's input-schema + * `parameters`, used purely to derive the additive `params` display metadata + * surfaced on the workspace LLM Tools settings page. + * + * Why a mirror instead of importing the live schemas? + * - The real schemas are declared inline inside each `defineTool()` call, and + * several factories build heavyweight dependencies at construction time + * (e.g. `create_update_work_item` instantiates a `FileWorkItemStore`). The + * settings route must NOT instantiate tools just to read a schema. + * - This module is display-only: it never affects tool execution, validation, + * provider routing, or persisted preferences (per the feature's scope). + * + * Only the structure the compact summary cares about is mirrored — property + * names, JSON-schema `type`, and the `required` list. Descriptions, defaults, + * enums, and bounds are intentionally omitted because `summarizeToolParameters` + * ignores them. + * + * KEEP IN SYNC: when a tool's `parameters` gains/loses a property or changes a + * property's type or required-ness, update the matching entry here. The + * `llm-tool-parameter-schemas` drift-guard test compares the summaries derived + * here against the live tool schemas for every tool that is cheap to construct. + * + * Tools with no entry here (e.g. the built-in `memory` tool, whose schema is + * not declared locally) intentionally render as "parameters unavailable". + */ + +import type { LlmToolMeta } from './llm-tool-registry'; +import { summarizeToolParameters } from './llm-tool-parameters'; + +/** + * Mirror of each tool's input-schema structure, keyed by the tool name as + * registered in {@link LLM_TOOL_REGISTRY}. + */ +export const LLM_TOOL_PARAMETER_SCHEMAS: Record> = { + suggest_follow_ups: { + type: 'object', + properties: { + suggestions: { type: 'array', items: { type: 'string' } }, + }, + required: ['suggestions'], + }, + search_conversations: { + type: 'object', + properties: { + query: { type: 'string' }, + workspaceId: { type: 'string' }, + since: { type: 'string' }, + until: { type: 'string' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + summarize: { type: 'boolean' }, + }, + required: [], + }, + get_conversation: { + type: 'object', + properties: { + processId: { type: 'string' }, + maxChars: { type: 'number' }, + includeToolCalls: { type: 'boolean' }, + fromTurn: { type: 'number' }, + toTurn: { type: 'number' }, + }, + required: ['processId'], + }, + ask_user: { + type: 'object', + properties: { + questions: { type: 'array', items: { type: 'object' } }, + }, + required: ['questions'], + }, + get_work_item: { + type: 'object', + properties: { + workItemId: { type: 'string' }, + target: { type: 'string' }, + workItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + }, + required: [], + }, + create_update_work_item: { + type: 'object', + properties: { + workItemId: { type: 'string' }, + target: { type: 'string' }, + workItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + type: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + priority: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + plan: { type: 'string' }, + summary: { type: 'string' }, + parentId: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + parentTarget: { type: 'string' }, + parentWorkItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + }, + required: [], + }, + save_memory: { + type: 'object', + properties: { + content: { type: 'string' }, + importance: { type: 'number' }, + tags: { type: 'array', items: { type: 'string' } }, + target: { type: 'string' }, + }, + required: ['content'], + }, + recall_memory: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['query'], + }, + scheduleWakeup: { + type: 'object', + properties: { + prompt: { type: 'string' }, + delay: { type: ['string', 'number'] }, + model: { type: 'string' }, + }, + required: ['prompt', 'delay'], + }, + create_or_update_excalidraw: { + type: 'object', + properties: { + filename: { type: 'string' }, + content: { type: 'object' }, + }, + required: ['filename', 'content'], + }, + read_excalidraw: { + type: 'object', + properties: { + filename: { type: 'string' }, + }, + required: ['filename'], + }, + write_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + title: { type: 'string' }, + content: { type: 'string' }, + edits: { + type: 'array', + items: { + type: 'object', + properties: { + oldText: { type: 'string' }, + newText: { type: 'string' }, + }, + required: ['oldText', 'newText'], + }, + }, + type: { type: 'string' }, + language: { type: 'string' }, + expectedRevision: { type: 'number' }, + }, + required: [], + }, + read_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + }, + required: ['canvasId'], + }, + extension_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + capability: { type: 'string' }, + params: { type: 'object' }, + title: { type: 'string' }, + description: { type: 'string' }, + capabilities: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + paramsDescription: { type: 'string' }, + }, + required: ['name', 'description'], + }, + }, + capabilitiesJs: { type: 'string' }, + uiHtml: { type: 'string' }, + initialState: { type: 'object' }, + }, + required: [], + }, + tavily_web_search: { + type: 'object', + properties: { + query: { type: 'string' }, + searchDepth: { type: 'string' }, + topic: { type: 'string' }, + maxResults: { type: 'number' }, + includeAnswer: { type: 'boolean' }, + includeRawContent: { type: 'boolean' }, + includeDomains: { type: 'array', items: { type: 'string' } }, + excludeDomains: { type: 'array', items: { type: 'string' } }, + days: { type: 'number' }, + }, + required: ['query'], + }, +}; + +/** + * Return a shallow copy of each tool meta augmented with the additive, + * display-only `params` summary derived from {@link LLM_TOOL_PARAMETER_SCHEMAS}. + * + * - When a schema is available, `params` is attached (an empty array means the + * tool declares no parameters). + * - When no schema is available (no map entry), `params` is left absent so + * clients render "parameters unavailable". + * + * The returned metas are fresh objects; the input registry is not mutated. + */ +export function withToolParameterMetadata(tools: readonly LlmToolMeta[]): LlmToolMeta[] { + return tools.map((tool) => { + const schema = LLM_TOOL_PARAMETER_SCHEMAS[tool.name]; + if (schema === undefined) { + return { ...tool }; + } + const params = summarizeToolParameters(schema); + return params === undefined ? { ...tool } : { ...tool, params }; + }); +} diff --git a/packages/coc/src/server/llm-tools/llm-tool-parameters.ts b/packages/coc/src/server/llm-tools/llm-tool-parameters.ts new file mode 100644 index 000000000..6ca919a42 --- /dev/null +++ b/packages/coc/src/server/llm-tools/llm-tool-parameters.ts @@ -0,0 +1,88 @@ +/** + * LLM tool parameter summarization + * + * Pure, display-only helpers that compress a tool's JSON-schema `parameters` + * into a compact, scannable list for the workspace LLM tools settings UI. + * + * This module intentionally has no runtime dependencies and never affects tool + * execution, validation, or persisted preferences — it only derives additive + * display metadata from the schemas tools already declare via `defineTool()`. + */ + +import type { LlmToolParam } from './llm-tool-registry'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Normalize a JSON-schema `type` field to a single primitive type name. + * Handles union types (`['string', 'null']`) by picking the first non-null + * concrete type. Returns `undefined` when no usable type string is present. + */ +function normalizeType(type: unknown): string | undefined { + if (typeof type === 'string') return type; + if (Array.isArray(type)) { + const first = type.find((t) => typeof t === 'string' && t !== 'null'); + return typeof first === 'string' ? first : undefined; + } + return undefined; +} + +/** + * Compress a single JSON-schema property into a compact type label. + * + * - objects (explicit `type: 'object'` or a `properties` map) → `{...}` + * - arrays (explicit `type: 'array'` or an `items` shape) → `[...]` + * - primitives → their JSON-schema type name (`string`, `number`, …) + * - typeless enums → `enum` + * - anything indeterminate → `any` + */ +export function compactParamType(prop: unknown): string { + if (!isPlainObject(prop)) return 'any'; + const type = normalizeType(prop.type); + if (type === 'object') return '{...}'; + if (type === 'array') return '[...]'; + if (type) return type; + // No explicit type — infer a concise shape from the schema structure. + if (isPlainObject(prop.properties)) return '{...}'; + if (prop.items !== undefined) return '[...]'; + if (Array.isArray(prop.enum)) return 'enum'; + return 'any'; +} + +/** + * Derive a compact, display-only parameter summary from a tool's `parameters` + * JSON schema. + * + * Returns: + * - an array (possibly empty) when `schema` is a usable JSON-schema object; + * an empty array means the tool declares no parameters. + * - `undefined` when no JSON-schema object is available — e.g. a Zod schema, + * a non-object schema, or no schema at all — so callers can distinguish + * "no parameters" (empty array) from "parameters unavailable" (undefined). + * + * Parameters preserve the declaration order of the schema's `properties`. + */ +export function summarizeToolParameters(schema: unknown): LlmToolParam[] | undefined { + if (!isPlainObject(schema)) return undefined; + + const type = normalizeType(schema.type); + const properties = schema.properties; + const isObjectSchema = type === 'object' || isPlainObject(properties); + if (!isObjectSchema) return undefined; + if (!isPlainObject(properties)) return []; + + const requiredList = schema.required; + const required = new Set( + Array.isArray(requiredList) + ? requiredList.filter((n): n is string => typeof n === 'string') + : [], + ); + + return Object.entries(properties).map(([name, prop]) => ({ + name, + type: compactParamType(prop), + required: required.has(name), + })); +} diff --git a/packages/coc/src/server/llm-tools/llm-tool-registry.ts b/packages/coc/src/server/llm-tools/llm-tool-registry.ts index 54165e495..caa5d4f50 100644 --- a/packages/coc/src/server/llm-tools/llm-tool-registry.ts +++ b/packages/coc/src/server/llm-tools/llm-tool-registry.ts @@ -10,6 +10,25 @@ * be toggled by the user. */ +/** + * Compact, display-only description of a single LLM tool input parameter. + * + * Derived from a tool's JSON-schema `parameters` purely for the settings UI; + * it never affects tool execution, validation, or persisted preferences. + */ +export interface LlmToolParam { + /** Parameter name as declared in the tool input schema. */ + name: string; + /** + * Compact type label: a JSON-schema primitive (`string`, `number`, + * `boolean`, `integer`), `{...}` for nested objects, `[...]` for arrays, + * `enum` for typeless enums, or `any` when the type cannot be determined. + */ + type: string; + /** Whether the parameter is required by the tool's input schema. */ + required: boolean; +} + export interface LlmToolMeta { /** Tool name as registered with `defineTool()` (matches the AI-facing name). */ name: string; @@ -19,6 +38,13 @@ export interface LlmToolMeta { description: string; /** Whether this tool is enabled by default when no explicit preference exists. */ enabledByDefault: boolean; + /** + * Optional, additive compact parameter summary derived from the tool's + * input schema for display in the settings UI. Absent when no JSON-schema + * is available (render as "parameters unavailable"); an empty array means + * the tool takes no parameters. Existing clients can ignore this field. + */ + params?: LlmToolParam[]; } /** diff --git a/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts b/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts new file mode 100644 index 000000000..8fd07a3a7 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts @@ -0,0 +1,486 @@ +import type { + ReconstructedConversationTurn, + ReconstructedTimelineItem, + ReconstructedToolCall, +} from './types'; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function stringFromUnknown(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (value === undefined || value === null) { + return undefined; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function parseJsonl(rawJsonl: string): Record[] { + const records: Record[] = []; + for (const line of rawJsonl.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed); + const rec = asRecord(parsed); + if (rec) { + records.push(rec); + } + } catch { + // External CLI logs may be partially written. Keep usable lines. + } + } + return records; +} + +function newTurn(role: 'user' | 'assistant', timestamp?: string): ReconstructedConversationTurn { + return { role, content: '', timestamp, timeline: [] }; +} + +function appendText(turn: ReconstructedConversationTurn, text: string, timestamp?: string): void { + if (!text) { + return; + } + turn.content = turn.content ? `${turn.content}\n\n${text}` : text; + if (turn.role === 'assistant') { + turn.timeline.push({ type: 'content', timestamp: timestamp ?? '', content: text }); + } +} + +function appendThinking(turn: ReconstructedConversationTurn, text: string): void { + if (!text) { + return; + } + turn.thinking = turn.thinking ? `${turn.thinking}\n\n${text}` : text; +} + +function stampTurnIndexes(turns: ReconstructedConversationTurn[]): ReconstructedConversationTurn[] | null { + const visible = turns.filter(turn => { + return turn.content.length > 0 + || Boolean(turn.thinking) + || (turn.toolCalls?.length ?? 0) > 0 + || (turn.images?.length ?? 0) > 0; + }); + if (visible.length === 0) { + return null; + } + visible.forEach((turn, index) => { + turn.turnIndex = index; + }); + return visible; +} + +function dataUrlFromImageBlock(block: Record): string | undefined { + const source = asRecord(block.source); + const data = asString(source?.data) ?? asString(block.data); + const mediaType = asString(source?.media_type) ?? asString(source?.mime_type) ?? asString(block.mime_type); + if (!data || !mediaType || !mediaType.startsWith('image/')) { + return undefined; + } + return data.startsWith('data:') ? data : `data:${mediaType};base64,${data}`; +} + +function isDataImageUrl(value: string): boolean { + return /^data:image\/[^;]+;base64,/i.test(value.trim()); +} + +function extractCodexEventImages(payload: Record): { images: string[]; localImages: string[] } { + const images: string[] = []; + const rawImages = payload.images; + if (Array.isArray(rawImages)) { + for (const value of rawImages) { + if (typeof value === 'string') { + if (isDataImageUrl(value)) { + images.push(value); + } + continue; + } + const block = asRecord(value); + const image = block ? dataUrlFromImageBlock(block) : undefined; + if (image) { + images.push(image); + } + } + } + + const localImages = Array.isArray(payload.local_images) + ? payload.local_images.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : []; + + return { images, localImages }; +} + +function extractTextFromBlocks(blocks: unknown): { text: string[]; thinking: string[]; images: string[] } { + const text: string[] = []; + const thinking: string[] = []; + const images: string[] = []; + if (!Array.isArray(blocks)) { + const fallback = asString(blocks); + return { text: fallback ? [fallback] : [], thinking, images }; + } + for (const entry of blocks) { + const block = asRecord(entry); + if (!block) { + continue; + } + const type = asString(block.type); + const maybeText = asString(block.text) ?? asString(block.input_text) ?? asString(block.output_text); + if (maybeText && (type === 'text' || type === 'input_text' || type === 'output_text' || !type)) { + text.push(maybeText); + continue; + } + const maybeThinking = asString(block.thinking) ?? asString(block.summary); + if (maybeText && type === 'summary_text') { + thinking.push(maybeText); + continue; + } + if (maybeThinking && (type === 'thinking' || type === 'reasoning' || type === 'summary_text')) { + thinking.push(maybeThinking); + continue; + } + if (type === 'image' || type === 'input_image' || type === 'local_image') { + const image = dataUrlFromImageBlock(block); + if (image) { + images.push(image); + } + } + } + return { text, thinking, images }; +} + +function addToolStart( + turn: ReconstructedConversationTurn, + toolCallsById: Map, + id: string, + toolName: string, + args: unknown, + timestamp?: string, +): ReconstructedToolCall { + const toolCall: ReconstructedToolCall = { + id, + toolName, + args, + status: 'running', + startTime: timestamp, + }; + toolCallsById.set(id, toolCall); + (turn.toolCalls ??= []).push(toolCall); + turn.timeline.push({ type: 'tool-start', timestamp: timestamp ?? '', toolCall }); + return toolCall; +} + +function addToolResult( + owner: ReconstructedConversationTurn | null, + toolCall: ReconstructedToolCall | undefined, + result: unknown, + isError: boolean, + timestamp?: string, +): void { + if (!toolCall) { + return; + } + toolCall.status = isError ? 'failed' : 'completed'; + toolCall.endTime = timestamp; + const resultText = stringFromUnknown(result); + if (isError) { + toolCall.error = resultText ?? 'Tool call failed'; + } else if (resultText !== undefined) { + toolCall.result = resultText; + } + owner?.timeline.push({ + type: isError ? 'tool-failed' : 'tool-complete', + timestamp: timestamp ?? '', + toolCall, + }); +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** + * Reconstruct a Claude Code transcript JSONL file into read-only dashboard turns. + * Malformed lines and unknown block shapes are skipped; returns null when no + * usable transcript content remains. + */ +export function parseClaudeTranscript(rawJsonl: string): ReconstructedConversationTurn[] | null { + const turns: ReconstructedConversationTurn[] = []; + const toolCallsById = new Map(); + const toolOwnersById = new Map(); + let currentAssistant: ReconstructedConversationTurn | null = null; + + for (const rec of parseJsonl(rawJsonl)) { + const type = asString(rec.type); + if (type !== 'user' && type !== 'assistant') { + continue; + } + const timestamp = asString(rec.timestamp); + const message = asRecord(rec.message); + const role = asString(message?.role) ?? type; + const content = message?.content; + + if (role === 'user') { + const extracted = extractTextFromBlocks(content); + const hasToolResults = Array.isArray(content) + && content.some(block => asString(asRecord(block)?.type) === 'tool_result'); + if (extracted.text.length > 0 || extracted.images.length > 0) { + const turn = newTurn('user', timestamp); + for (const text of extracted.text) { + appendText(turn, text, timestamp); + } + if (extracted.images.length > 0) { + turn.images = extracted.images; + } + turns.push(turn); + currentAssistant = null; + } else if (!hasToolResults && typeof content === 'string') { + const turn = newTurn('user', timestamp); + appendText(turn, content, timestamp); + turns.push(turn); + currentAssistant = null; + } + if (Array.isArray(content)) { + for (const blockValue of content) { + const block = asRecord(blockValue); + if (!block || asString(block.type) !== 'tool_result') { + continue; + } + const toolUseId = asString(block.tool_use_id); + const toolCall = toolUseId ? toolCallsById.get(toolUseId) : undefined; + const owner = toolUseId ? toolOwnersById.get(toolUseId) ?? currentAssistant : currentAssistant; + const extractedResult = extractTextFromBlocks(block.content); + const result = extractedResult.text.length > 0 ? extractedResult.text.join('\n\n') : block.content; + addToolResult(owner, toolCall, result, block.is_error === true, timestamp); + } + } + continue; + } + + if (role !== 'assistant') { + continue; + } + currentAssistant = newTurn('assistant', timestamp); + const model = asString(message?.model); + if (model) { + currentAssistant.model = model; + } + if (Array.isArray(content)) { + for (const blockValue of content) { + const block = asRecord(blockValue); + if (!block) { + continue; + } + const blockType = asString(block.type); + if (blockType === 'tool_use') { + const id = asString(block.id); + if (!id) { + continue; + } + const toolCall = addToolStart( + currentAssistant, + toolCallsById, + id, + asString(block.name) ?? 'unknown', + block.input, + timestamp, + ); + toolOwnersById.set(toolCall.id, currentAssistant); + continue; + } + const extracted = extractTextFromBlocks([block]); + for (const text of extracted.text) { + appendText(currentAssistant, text, timestamp); + } + for (const thinking of extracted.thinking) { + appendThinking(currentAssistant, thinking); + } + if (extracted.images.length > 0) { + currentAssistant.images = [...(currentAssistant.images ?? []), ...extracted.images]; + } + } + } else { + const text = asString(content); + if (text) { + appendText(currentAssistant, text, timestamp); + } + } + turns.push(currentAssistant); + } + + return stampTurnIndexes(turns); +} + +/** + * Reconstruct an OpenAI Codex CLI rollout JSONL file into read-only dashboard + * turns. The Codex rollout envelope is intentionally treated defensively so + * newly-added event payloads do not break older CoC readers. + */ +export function parseCodexRollout(rawJsonl: string): ReconstructedConversationTurn[] | null { + const turns: ReconstructedConversationTurn[] = []; + const toolCallsById = new Map(); + const toolOwnersById = new Map(); + let currentAssistant: ReconstructedConversationTurn | null = null; + let currentModel: string | undefined; + + for (const rec of parseJsonl(rawJsonl)) { + const timestamp = asString(rec.timestamp); + const envelopeType = asString(rec.type); + const payload = asRecord(rec.payload); + if (!payload) { + continue; + } + + if (envelopeType === 'turn_context') { + currentModel = asString(payload.model) ?? currentModel; + continue; + } + if (envelopeType === 'event_msg' && asString(payload.type) === 'user_message') { + const message = asString(payload.message); + const { images, localImages } = extractCodexEventImages(payload); + if (!message && images.length === 0 && localImages.length === 0) { + continue; + } + + const previous = turns[turns.length - 1]; + const turn = previous?.role === 'user' && (!message || previous.content === message) + ? previous + : newTurn('user', timestamp); + + if (turn !== previous) { + if (message) { + appendText(turn, message, timestamp); + } + turns.push(turn); + } + if (images.length > 0) { + turn.images = [...(turn.images ?? []), ...images]; + } + if (localImages.length > 0) { + appendText( + turn, + localImages.map(imagePath => `Attached local image: \`${imagePath}\``).join('\n'), + timestamp, + ); + } + currentAssistant = null; + continue; + } + if (envelopeType !== 'response_item') { + continue; + } + + const itemType = asString(payload.type); + if (itemType === 'message') { + const role = asString(payload.role); + if (role === 'user') { + const extracted = extractTextFromBlocks(payload.content); + if (extracted.text.length === 0 && extracted.images.length === 0) { + continue; + } + const turn = newTurn('user', timestamp); + for (const text of extracted.text) { + appendText(turn, text, timestamp); + } + if (extracted.images.length > 0) { + turn.images = extracted.images; + } + turns.push(turn); + currentAssistant = null; + continue; + } + if (role === 'assistant') { + currentAssistant = newTurn('assistant', timestamp); + if (currentModel) { + currentAssistant.model = currentModel; + } + const extracted = extractTextFromBlocks(payload.content); + for (const text of extracted.text) { + appendText(currentAssistant, text, timestamp); + } + for (const thinking of extracted.thinking) { + appendThinking(currentAssistant, thinking); + } + if (extracted.images.length > 0) { + currentAssistant.images = extracted.images; + } + turns.push(currentAssistant); + } + continue; + } + + if (itemType === 'reasoning') { + if (!currentAssistant) { + currentAssistant = newTurn('assistant', timestamp); + if (currentModel) { + currentAssistant.model = currentModel; + } + turns.push(currentAssistant); + } + const summary = extractTextFromBlocks(payload.summary); + for (const thinking of summary.text.length > 0 ? summary.text : summary.thinking) { + appendThinking(currentAssistant, thinking); + } + const encrypted = asString(payload.encrypted_content); + if (!currentAssistant.thinking && encrypted) { + appendThinking(currentAssistant, '[encrypted reasoning]'); + } + continue; + } + + if (itemType === 'function_call') { + if (!currentAssistant) { + currentAssistant = newTurn('assistant', timestamp); + if (currentModel) { + currentAssistant.model = currentModel; + } + turns.push(currentAssistant); + } + const id = asString(payload.call_id) ?? asString(payload.id); + if (!id) { + continue; + } + const toolCall = addToolStart( + currentAssistant, + toolCallsById, + id, + asString(payload.name) ?? 'unknown', + parseMaybeJson(payload.arguments), + timestamp, + ); + toolOwnersById.set(toolCall.id, currentAssistant); + continue; + } + + if (itemType === 'function_call_output') { + const id = asString(payload.call_id) ?? asString(payload.id); + const toolCall = id ? toolCallsById.get(id) : undefined; + const owner = id ? toolOwnersById.get(id) ?? currentAssistant : currentAssistant; + const isError = payload.is_error === true || asString(payload.status) === 'failed'; + addToolResult(owner, toolCall, payload.output, isError, timestamp); + } + } + + return stampTurnIndexes(turns); +} diff --git a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts new file mode 100644 index 000000000..96e2a2dd6 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts @@ -0,0 +1,572 @@ +/** + * Read-only native CLI session providers for filesystem-backed agent stores. + * + * Codex and Claude Code persist JSONL transcripts in user-owned CLI stores. + * CoC scans those stores with read-only filesystem calls, reconstructs + * transcripts into the same shape as the Copilot native-session view, and never + * writes, imports, resumes, or mutates external sessions. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { parseClaudeTranscript, parseCodexRollout } from './cli-session-parsers'; +import { + DEFAULT_NATIVE_SESSION_LIST_LIMIT, + sessionMatchesWorkspace, +} from './native-copilot-session-service'; +import type { NativeCopilotSessionService } from './native-copilot-session-service'; +import type { + NativeCliSessionDetail, + NativeCliSessionDetailResult, + NativeCliSessionListItem, + NativeCliSessionListOptions, + NativeCliSessionListResult, + NativeCliSessionProviderId, + NativeSessionProvider, + NativeSessionWorkspaceScope, + ReconstructedConversationTurn, +} from './types'; + +function mapCopilotUnavailableReason(reason: 'db-missing' | 'db-invalid'): 'store-missing' | 'store-invalid' { + return reason === 'db-missing' ? 'store-missing' : 'store-invalid'; +} + +const MAX_NATIVE_SESSION_LIST_LIMIT = 200; +const SUMMARY_PREVIEW_MAX_CHARS = 200; + +interface ParsedJsonlLine { + record: Record; +} + +interface NativeCliSessionMetadata { + id: string; + provider: NativeCliSessionProviderId; + filePath: string; + storePath: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summary: string; + createdAt: string | null; + updatedAt: string | null; + turnCount: number; + recordedCwds?: string[]; +} + +interface CachedMetadata { + mtimeMs: number; + size: number; + metadata: NativeCliSessionMetadata | null; +} + +interface FileSessionProviderOptions { + storePath?: string; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function parseJsonlLines(raw: string): ParsedJsonlLine[] { + const lines: ParsedJsonlLine[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const record = asRecord(JSON.parse(trimmed)); + if (record) { + lines.push({ record }); + } + } catch { + // CLI stores can contain partially-written trailing lines. + } + } + return lines; +} + +function normalizePathForMatch(value: string): string { + let normalized = path.normalize(value.trim()).replace(/\\/g, '/'); + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function pathMatchesWorkspace(cwd: string | null, rootPath: string | undefined): boolean { + if (!cwd || !rootPath) { + return false; + } + const root = normalizePathForMatch(rootPath); + const candidate = normalizePathForMatch(cwd); + return candidate === root || candidate.startsWith(`${root}/`); +} + +function clampLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return DEFAULT_NATIVE_SESSION_LIST_LIMIT; + } + return Math.min(Math.max(Math.floor(limit), 1), MAX_NATIVE_SESSION_LIST_LIMIT); +} + +function clampOffset(offset: number | undefined): number { + if (offset === undefined || !Number.isFinite(offset)) { + return 0; + } + return Math.max(Math.floor(offset), 0); +} + +function parseTimestamp(value: string | null | undefined): number { + return value ? Date.parse(value) : Number.NaN; +} + +function compareMetadataUpdatedDesc( + a: NativeCliSessionMetadata, + b: NativeCliSessionMetadata, +): number { + const aTs = parseTimestamp(a.updatedAt); + const bTs = parseTimestamp(b.updatedAt); + return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); +} + +function summaryPreview(summary: string | null): string { + if (!summary) { + return ''; + } + const firstLine = summary.split('\n', 1)[0].trim(); + return firstLine.length > SUMMARY_PREVIEW_MAX_CHARS + ? `${firstLine.slice(0, SUMMARY_PREVIEW_MAX_CHARS)}…` + : firstLine; +} + +function firstTextSummary(conversation: ReconstructedConversationTurn[] | null): string { + const first = conversation?.find(turn => turn.content.trim().length > 0); + return first?.content.trim() ?? ''; +} + +function toListItem(metadata: NativeCliSessionMetadata, matchSnippets: string[]): NativeCliSessionListItem { + return { + id: metadata.id, + provider: metadata.provider, + storePath: metadata.storePath, + repository: metadata.repository, + cwd: metadata.cwd, + hostType: metadata.hostType, + branch: metadata.branch, + summaryPreview: summaryPreview(metadata.summary), + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + turnCount: metadata.turnCount, + matchSnippets, + searchIndexAvailable: false, + }; +} + +function toDetail(metadata: NativeCliSessionMetadata, conversation: ReconstructedConversationTurn[]): NativeCliSessionDetail { + return { + id: metadata.id, + provider: metadata.provider, + storePath: metadata.storePath, + repository: metadata.repository, + cwd: metadata.cwd, + hostType: metadata.hostType, + branch: metadata.branch, + summary: metadata.summary, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + turns: [], + conversation, + searchIndexAvailable: false, + }; +} + +function snippetForQuery(raw: string, query: string | undefined): string[] { + if (!query?.trim()) { + return []; + } + const lowerRaw = raw.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const index = lowerRaw.indexOf(lowerQuery); + if (index < 0) { + return []; + } + const start = Math.max(0, index - 60); + const end = Math.min(raw.length, index + query.length + 60); + return [raw.slice(start, end).replace(/\s+/g, ' ').trim()]; +} + +function readUtf8(filePath: string): string | null { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +function safeStat(filePath: string): fs.Stats | null { + try { + return fs.statSync(filePath); + } catch { + return null; + } +} + +abstract class JsonlFileNativeSessionProvider implements NativeSessionProvider { + readonly provider: NativeCliSessionProviderId; + readonly label: string; + readonly storePath: string; + private readonly metadataCache = new Map(); + + protected constructor(provider: NativeCliSessionProviderId, label: string, storePath: string) { + this.provider = provider; + this.label = label; + this.storePath = storePath; + } + + listSessions( + scope: NativeSessionWorkspaceScope, + options: NativeCliSessionListOptions = {}, + ): NativeCliSessionListResult & { limit: number; offset: number } { + const limit = clampLimit(options.limit); + const offset = clampOffset(options.offset); + const storeState = this.getStoreState(); + if (storeState !== 'ok') { + return { available: false, reason: storeState, limit, offset }; + } + + let files: string[]; + try { + files = this.listCandidateFiles(scope); + } catch { + return { available: false, reason: 'store-invalid', limit, offset }; + } + + const fromTs = options.from ? parseTimestamp(options.from) : undefined; + const toTs = options.to ? parseTimestamp(options.to) : undefined; + const q = options.q?.trim(); + const deduplicatedSessionIds = new Set(); + const rows: Array<{ metadata: NativeCliSessionMetadata; snippets: string[] }> = []; + + for (const filePath of files) { + const metadata = this.getMetadata(filePath); + if (!metadata || !this.metadataMatchesWorkspace(metadata, scope)) { + continue; + } + if (options.sessionId && !metadata.id.includes(options.sessionId)) { + continue; + } + if (options.branch && metadata.branch !== options.branch) { + continue; + } + const updated = parseTimestamp(metadata.updatedAt); + if (fromTs !== undefined || toTs !== undefined) { + if (Number.isNaN(updated)) { + continue; + } + if (fromTs !== undefined && !Number.isNaN(fromTs) && updated < fromTs) { + continue; + } + if (toTs !== undefined && !Number.isNaN(toTs) && updated > toTs) { + continue; + } + } + if (options.excludeSessionIds?.has(metadata.id)) { + deduplicatedSessionIds.add(metadata.id); + continue; + } + const raw = q ? readUtf8(filePath) : null; + const snippets = raw && q ? snippetForQuery(raw, q) : []; + if (q && snippets.length === 0) { + continue; + } + rows.push({ metadata, snippets }); + } + + const uniqueRowsById = new Map(); + for (const row of rows) { + const existing = uniqueRowsById.get(row.metadata.id); + if (!existing || compareMetadataUpdatedDesc(row.metadata, existing.metadata) < 0) { + uniqueRowsById.set(row.metadata.id, row); + } + } + + const uniqueRows = [...uniqueRowsById.values()]; + uniqueRows.sort((a, b) => compareMetadataUpdatedDesc(a.metadata, b.metadata)); + + const total = uniqueRows.length; + const page = uniqueRows.slice(offset, offset + limit); + return { + available: true, + items: page.map(row => toListItem(row.metadata, row.snippets)), + total, + searchIndexAvailable: false, + deduplicatedCount: deduplicatedSessionIds.size, + backgroundJobCount: 0, + limit, + offset, + }; + } + + getSession(scope: NativeSessionWorkspaceScope, id: string): NativeCliSessionDetailResult { + const storeState = this.getStoreState(); + if (storeState !== 'ok') { + return { available: false, reason: storeState }; + } + let files: string[]; + try { + files = this.listCandidateFiles(scope); + } catch { + return { available: false, reason: 'store-invalid' }; + } + const matches: Array<{ metadata: NativeCliSessionMetadata; filePath: string }> = []; + for (const filePath of files) { + const metadata = this.getMetadata(filePath); + if (!metadata || metadata.id !== id || !this.metadataMatchesWorkspace(metadata, scope)) { + continue; + } + matches.push({ metadata, filePath }); + } + matches.sort((a, b) => compareMetadataUpdatedDesc(a.metadata, b.metadata)); + const match = matches[0]; + if (match) { + const raw = readUtf8(match.filePath); + if (raw === null) { + return { available: false, reason: 'store-invalid' }; + } + const conversation = this.parseConversation(raw) ?? []; + return { available: true, session: toDetail(match.metadata, conversation) }; + } + return { available: true, session: null }; + } + + protected abstract listCandidateFiles(scope: NativeSessionWorkspaceScope): string[]; + protected abstract parseMetadata(filePath: string, raw: string, stat: fs.Stats): NativeCliSessionMetadata | null; + protected abstract parseConversation(raw: string): ReconstructedConversationTurn[] | null; + + protected metadataMatchesWorkspace(metadata: NativeCliSessionMetadata, scope: NativeSessionWorkspaceScope): boolean { + return sessionMatchesWorkspace({ repository: metadata.repository, cwd: metadata.cwd }, scope); + } + + private getStoreState(): 'ok' | 'store-missing' | 'store-invalid' { + if (!fs.existsSync(this.storePath)) { + return 'store-missing'; + } + const stat = safeStat(this.storePath); + return stat?.isDirectory() ? 'ok' : 'store-invalid'; + } + + private getMetadata(filePath: string): NativeCliSessionMetadata | null { + const stat = safeStat(filePath); + if (!stat?.isFile()) { + return null; + } + const cached = this.metadataCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.metadata; + } + const raw = readUtf8(filePath); + const metadata = raw === null ? null : this.parseMetadata(filePath, raw, stat); + this.metadataCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, metadata }); + return metadata; + } +} + +function walkJsonlFiles(root: string, predicate: (filePath: string) => boolean): string[] { + const found: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.jsonl') && predicate(fullPath)) { + found.push(fullPath); + } + } + } + return found; +} + +export class CodexNativeSessionProvider extends JsonlFileNativeSessionProvider { + constructor(options: FileSessionProviderOptions = {}) { + super('codex', 'Codex', options.storePath ?? path.join(os.homedir(), '.codex', 'sessions')); + } + + protected listCandidateFiles(): string[] { + return walkJsonlFiles(this.storePath, filePath => path.basename(filePath).startsWith('rollout-')); + } + + protected parseMetadata(filePath: string, raw: string, stat: fs.Stats): NativeCliSessionMetadata | null { + const lines = parseJsonlLines(raw); + const meta = lines + .map(line => line.record) + .find(record => asString(record.type) === 'session_meta'); + const payload = asRecord(meta?.payload); + const id = asString(payload?.id); + const cwd = asString(payload?.cwd) ?? null; + if (!id || !cwd) { + return null; + } + const git = asRecord(payload?.git); + const branch = asString(git?.branch) ?? asString(payload?.branch) ?? null; + const timestamp = asString(payload?.timestamp) ?? asString(meta?.timestamp) ?? null; + const conversation = parseCodexRollout(raw); + return { + id, + provider: this.provider, + filePath, + storePath: this.storePath, + repository: null, + cwd, + hostType: 'codex', + branch, + summary: firstTextSummary(conversation), + createdAt: timestamp, + updatedAt: new Date(stat.mtimeMs).toISOString(), + turnCount: conversation?.length ?? 0, + }; + } + + protected parseConversation(raw: string): ReconstructedConversationTurn[] | null { + return parseCodexRollout(raw); + } +} + +export class CopilotNativeSessionProvider implements NativeSessionProvider { + readonly provider = 'copilot' as const; + readonly label = 'GitHub Copilot'; + readonly storePath: string; + + constructor(private readonly service: NativeCopilotSessionService) { + this.storePath = service.getStorePath(); + } + + listSessions( + scope: NativeSessionWorkspaceScope, + options: NativeCliSessionListOptions = {}, + ): NativeCliSessionListResult & { limit: number; offset: number } { + const result = this.service.listSessions(scope, options); + if (!result.available) { + return { + available: false, + reason: mapCopilotUnavailableReason(result.reason), + limit: result.limit, + offset: result.offset, + }; + } + return { + available: true, + items: result.items.map(item => ({ + ...item, + provider: this.provider, + storePath: this.storePath, + searchIndexAvailable: result.searchIndexAvailable, + })), + total: result.total, + searchIndexAvailable: result.searchIndexAvailable, + deduplicatedCount: result.deduplicatedCount, + backgroundJobCount: result.backgroundJobCount, + limit: result.limit, + offset: result.offset, + }; + } + + getSession(scope: NativeSessionWorkspaceScope, id: string): NativeCliSessionDetailResult { + const result = this.service.getSession(scope, id); + if (!result.available) { + return { available: false, reason: mapCopilotUnavailableReason(result.reason) }; + } + return { + available: true, + session: result.session + ? { + ...result.session, + provider: this.provider, + storePath: this.storePath, + searchIndexAvailable: true, + } + : null, + }; + } +} + +/** + * Encode an absolute workspace root into Claude Code's `~/.claude/projects/` + * directory name. Claude replaces path separators AND the Windows drive-letter + * colon with dashes (a literal `:` is not a valid path segment on Windows), so + * `C:\Users\me\repo` becomes `C--Users-me-repo`. Exported for cross-platform + * regression coverage of the colon encoding. + */ +export function dashEncodeWorkspaceRoot(rootPath: string | undefined): string | undefined { + if (!rootPath) { + return undefined; + } + return path.resolve(rootPath).replace(/\\/g, '/').replace(/[/:]/g, '-'); +} + +export class ClaudeNativeSessionProvider extends JsonlFileNativeSessionProvider { + constructor(options: FileSessionProviderOptions = {}) { + super('claude', 'Claude Code', options.storePath ?? path.join(os.homedir(), '.claude', 'projects')); + } + + protected listCandidateFiles(scope: NativeSessionWorkspaceScope): string[] { + const encoded = dashEncodeWorkspaceRoot(scope.rootPath); + const roots = encoded ? [path.join(this.storePath, encoded)] : [this.storePath]; + return roots.flatMap(root => fs.existsSync(root) ? walkJsonlFiles(root, () => true) : []); + } + + protected metadataMatchesWorkspace(metadata: NativeCliSessionMetadata, scope: NativeSessionWorkspaceScope): boolean { + const cwdValues = metadata.recordedCwds?.length ? metadata.recordedCwds : (metadata.cwd ? [metadata.cwd] : []); + return cwdValues.length > 0 && cwdValues.every(cwd => pathMatchesWorkspace(cwd, scope.rootPath)); + } + + protected parseMetadata(filePath: string, raw: string, stat: fs.Stats): NativeCliSessionMetadata | null { + const records = parseJsonlLines(raw).map(line => line.record); + const firstWithSession = records.find(record => asString(record.sessionId)); + const recordedCwds = Array.from(new Set(records.map(record => asString(record.cwd)).filter((cwd): cwd is string => Boolean(cwd)))); + const id = asString(firstWithSession?.sessionId) ?? path.basename(filePath, '.jsonl'); + const cwd = recordedCwds[0] ?? null; + if (!id || !cwd) { + return null; + } + const firstTimestamp = records.map(record => asString(record.timestamp)).find(Boolean) ?? null; + const lastTimestamp = [...records].reverse().map(record => asString(record.timestamp)).find(Boolean) ?? null; + const branch = records.map(record => asString(record.gitBranch)).find(Boolean) ?? null; + const conversation = parseClaudeTranscript(raw); + return { + id, + provider: this.provider, + filePath, + storePath: this.storePath, + repository: null, + cwd, + hostType: 'claude', + branch, + summary: firstTextSummary(conversation), + createdAt: firstTimestamp, + updatedAt: lastTimestamp ?? new Date(stat.mtimeMs).toISOString(), + turnCount: conversation?.length ?? 0, + recordedCwds, + }; + } + + protected parseConversation(raw: string): ReconstructedConversationTurn[] | null { + return parseClaudeTranscript(raw); + } +} diff --git a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts new file mode 100644 index 000000000..29198b684 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts @@ -0,0 +1,556 @@ +/** + * Read-only query service for the native GitHub Copilot CLI session store. + * + * The native store (`~/.copilot/session-store.db`) is external data owned by + * the Copilot CLI. This service opens it with short-lived read-only SQLite + * connections per request and never executes a write statement against it. + * Missing or invalid stores produce typed unavailable results instead of + * throwing, so the dashboard can render a non-fatal state. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import DatabaseConstructor from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; +import type { + NativeCopilotSessionDetail, + NativeCopilotSessionDetailResult, + NativeCopilotSessionListItem, + NativeCopilotSessionListOptions, + NativeCopilotSessionListResult, + NativeCopilotSessionTurn, + NativeSessionWorkspaceScope, + ReconstructedConversationTurn, +} from './types'; +import { parseNativeSessionState } from './session-state-parser'; + +export const DEFAULT_NATIVE_SESSION_LIST_LIMIT = 50; +const MAX_NATIVE_SESSION_LIST_LIMIT = 200; +const MAX_MATCH_SNIPPETS = 3; +const SUMMARY_PREVIEW_MAX_CHARS = 200; + +/** + * Prompt prefixes that mark a native session as an automated background job + * rather than a user-driven conversation. Matched case-insensitively against + * the first flat turn user message and, for native records that have no flat + * turns, the stored session summary. The Copilot CLI spawns these (e.g. to + * generate a conversation title), so they are hidden from the read-only session + * browser by default. + */ +const BACKGROUND_JOB_PROMPT_PREFIXES = [ + 'Summarise the following conversation as a short title', + 'Summarize the following conversation as a short title', +]; + +/** Default native Copilot CLI session store location for the server user. */ +export function getDefaultNativeCopilotSessionDbPath(): string { + return path.join(os.homedir(), '.copilot', 'session-store.db'); +} + +interface SessionRow { + id: string; + cwd: string | null; + repository: string | null; + host_type: string | null; + branch: string | null; + summary: string | null; + created_at: string | null; + updated_at: string | null; +} + +interface TurnRow { + id: number; + turn_index: number; + user_message: string | null; + assistant_response: string | null; + timestamp: string | null; +} + +type DbOpenResult = + | { ok: true; db: Database } + | { ok: false; reason: 'db-missing' | 'db-invalid' }; + +/** Normalize a filesystem path for cross-platform equality/prefix matching. */ +function normalizePathForMatch(value: string): string { + let normalized = path.normalize(value.trim()).replace(/\\/g, '/'); + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +/** True when a native session belongs to the given CoC workspace. */ +export function sessionMatchesWorkspace( + row: { repository: string | null; cwd: string | null }, + scope: NativeSessionWorkspaceScope, +): boolean { + if (scope.repository && row.repository + && row.repository.trim().toLowerCase() === scope.repository.trim().toLowerCase()) { + return true; + } + if (scope.rootPath && row.cwd) { + const root = normalizePathForMatch(scope.rootPath); + const cwd = normalizePathForMatch(row.cwd); + if (cwd === root || cwd.startsWith(`${root}/`)) { + return true; + } + } + return false; +} + +/** + * Convert free text into a safe FTS5 MATCH expression: each whitespace-separated + * term becomes a quoted string literal so user input can never inject FTS syntax. + * Returns null when the query has no usable terms. + */ +export function buildFtsMatchExpression(query: string): string | null { + const terms = query.split(/\s+/).map(term => term.trim()).filter(Boolean); + if (terms.length === 0) { + return null; + } + return terms.map(term => `"${term.replace(/"/g, '""')}"`).join(' '); +} + +function summaryPreview(summary: string | null): string { + if (!summary) { + return ''; + } + const firstLine = summary.split('\n', 1)[0].trim(); + return firstLine.length > SUMMARY_PREVIEW_MAX_CHARS + ? `${firstLine.slice(0, SUMMARY_PREVIEW_MAX_CHARS)}…` + : firstLine; +} + +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return Number.NaN; + } + return Date.parse(value); +} + +function clampLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return DEFAULT_NATIVE_SESSION_LIST_LIMIT; + } + return Math.min(Math.max(Math.floor(limit), 1), MAX_NATIVE_SESSION_LIST_LIMIT); +} + +function clampOffset(offset: number | undefined): number { + if (offset === undefined || !Number.isFinite(offset)) { + return 0; + } + return Math.max(Math.floor(offset), 0); +} + +/** + * Map flat `session-store.db` turns into text-only reconstructed conversation + * turns — the fallback transcript used when the rich `events.jsonl` log is + * missing or unparseable. Each DB turn yields up to two turns (a user turn for a + * non-empty user message, then an assistant turn for a non-empty response), with + * a stable sequential `turnIndex`. Mirrors the parser's convention: user turns + * carry only `content`; assistant turns also expose a single `content` timeline + * item so the chat bubble renders them identically to a rich turn. + */ +function buildFallbackConversation(turnRows: TurnRow[]): ReconstructedConversationTurn[] { + const conversation: ReconstructedConversationTurn[] = []; + let index = 0; + for (const turn of turnRows) { + const userMessage = turn.user_message ?? ''; + const assistantResponse = turn.assistant_response ?? ''; + const timestamp = turn.timestamp ?? undefined; + if (userMessage) { + conversation.push({ + role: 'user', + content: userMessage, + timestamp, + turnIndex: index++, + timeline: [], + }); + } + if (assistantResponse) { + conversation.push({ + role: 'assistant', + content: assistantResponse, + timestamp, + turnIndex: index++, + timeline: [{ type: 'content', timestamp: timestamp ?? '', content: assistantResponse }], + }); + } + } + return conversation; +} + +export interface NativeCopilotSessionServiceOptions { + /** Override of the native session store path (tests use synthetic fixtures). */ + dbPath?: string; + /** + * Override of the native `session-state` base directory passed to the rich + * transcript parser (tests point this at synthetic fixtures). Defaults to + * `~/.copilot/session-state`. + */ + sessionStateDir?: string; + /** + * Override of the rich-transcript parser. Defaults to + * {@link parseNativeSessionState} reading `session-state//events.jsonl`. + * Injected by tests to exercise the rich and DB-fallback paths without + * touching the filesystem. + */ + parseSessionState?: (sessionId: string) => ReconstructedConversationTurn[] | null; +} + +export class NativeCopilotSessionService { + private readonly dbPath: string; + private readonly parseSessionState: (sessionId: string) => ReconstructedConversationTurn[] | null; + + constructor(options: NativeCopilotSessionServiceOptions = {}) { + this.dbPath = options.dbPath ?? getDefaultNativeCopilotSessionDbPath(); + this.parseSessionState = options.parseSessionState + ?? (sessionId => parseNativeSessionState(sessionId, { sessionStateDir: options.sessionStateDir })); + } + + getStorePath(): string { + return this.dbPath; + } + + /** List native sessions scoped to one workspace, newest `updated_at` first. */ + listSessions( + scope: NativeSessionWorkspaceScope, + options: NativeCopilotSessionListOptions = {}, + ): NativeCopilotSessionListResult & { limit: number; offset: number } { + const limit = clampLimit(options.limit); + const offset = clampOffset(options.offset); + const opened = this.openReadOnly(); + if (!opened.ok) { + return { available: false, reason: opened.reason, limit, offset }; + } + const db = opened.db; + try { + if (!this.hasValidSchema(db)) { + return { available: false, reason: 'db-invalid', limit, offset }; + } + const searchIndexAvailable = this.hasSearchIndex(db); + + // Text query resolves through the native FTS index first. Without + // the index, text search yields no hits but stays non-fatal. + let textHits: Map | null = null; + const matchExpression = options.q ? buildFtsMatchExpression(options.q) : null; + if (matchExpression) { + textHits = searchIndexAvailable ? this.queryTextHits(db, matchExpression) : new Map(); + } + if (textHits && textHits.size === 0) { + return { available: true, items: [], total: 0, searchIndexAvailable, deduplicatedCount: 0, backgroundJobCount: 0, limit, offset }; + } + + const rows = this.querySessionRows(db, options, textHits); + + const fromTs = options.from ? parseTimestamp(options.from) : undefined; + const toTs = options.to ? parseTimestamp(options.to) : undefined; + const excludeSessionIds = options.excludeSessionIds; + let deduplicatedCount = 0; + const scoped = rows.filter(row => { + if (!sessionMatchesWorkspace(row, scope)) { + return false; + } + if (fromTs !== undefined || toTs !== undefined) { + const updated = parseTimestamp(row.updated_at); + if (Number.isNaN(updated)) { + return false; + } + if (fromTs !== undefined && !Number.isNaN(fromTs) && updated < fromTs) { + return false; + } + if (toTs !== undefined && !Number.isNaN(toTs) && updated > toTs) { + return false; + } + } + // Hide native sessions already tracked as CoC processes (dedup). + if (excludeSessionIds && excludeSessionIds.has(row.id)) { + deduplicatedCount += 1; + return false; + } + return true; + }); + + // Hide automated background-job sessions (e.g. title summarization) + // unless explicitly requested. Detected by the first-turn prompt. + let backgroundJobCount = 0; + let visible = scoped; + if (!options.includeBackgroundJobs) { + const backgroundJobIds = this.queryBackgroundJobSessionIds(db, scoped.map(row => row.id)); + if (backgroundJobIds.size > 0) { + visible = scoped.filter(row => { + if (backgroundJobIds.has(row.id)) { + backgroundJobCount += 1; + return false; + } + return true; + }); + } + } + + visible.sort((a, b) => { + const aTs = parseTimestamp(a.updated_at); + const bTs = parseTimestamp(b.updated_at); + return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); + }); + + const total = visible.length; + const page = visible.slice(offset, offset + limit); + const turnCounts = this.queryTurnCounts(db, page.map(row => row.id)); + + const items: NativeCopilotSessionListItem[] = page.map(row => ({ + id: row.id, + repository: row.repository, + cwd: row.cwd, + hostType: row.host_type, + branch: row.branch, + summaryPreview: summaryPreview(row.summary), + createdAt: row.created_at, + updatedAt: row.updated_at, + turnCount: turnCounts.get(row.id) ?? 0, + matchSnippets: textHits?.get(row.id)?.slice(0, MAX_MATCH_SNIPPETS) ?? [], + })); + + return { available: true, items, total, searchIndexAvailable, deduplicatedCount, backgroundJobCount, limit, offset }; + } catch { + return { available: false, reason: 'db-invalid', limit, offset }; + } finally { + db.close(); + } + } + + /** Read one native session with ordered turns, scoped to the workspace. */ + getSession( + scope: NativeSessionWorkspaceScope, + sessionId: string, + ): NativeCopilotSessionDetailResult { + const opened = this.openReadOnly(); + if (!opened.ok) { + return { available: false, reason: opened.reason }; + } + const db = opened.db; + try { + if (!this.hasValidSchema(db)) { + return { available: false, reason: 'db-invalid' }; + } + const row = db.prepare( + 'SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions WHERE id = ?', + ).get(sessionId) as SessionRow | undefined; + if (!row || !sessionMatchesWorkspace(row, scope)) { + return { available: true, session: null }; + } + + const turnRows = db.prepare( + 'SELECT id, turn_index, user_message, assistant_response, timestamp FROM turns WHERE session_id = ? ORDER BY turn_index ASC', + ).all(sessionId) as TurnRow[]; + + const indexDiagnostics = this.querySearchIndexDiagnostics(db, sessionId); + + const turns: NativeCopilotSessionTurn[] = turnRows.map(turn => { + const userMessage = turn.user_message ?? ''; + const assistantResponse = turn.assistant_response ?? ''; + const sourceId = `${sessionId}:turn:${turn.turn_index}`; + const indexedChars = indexDiagnostics.get(sourceId); + return { + id: turn.id, + turnIndex: turn.turn_index, + timestamp: turn.timestamp, + userMessage, + assistantResponse, + userChars: userMessage.length, + assistantChars: assistantResponse.length, + searchIndexSourceId: indexedChars === undefined ? null : sourceId, + searchIndexChars: indexedChars === undefined ? null : indexedChars, + }; + }); + + // Prefer the rich `session-state//events.jsonl` transcript; + // fall back to text-only turns mapped from the flat DB rows when the + // state log is missing or unparseable. Read-only either way. + const conversation = this.parseSessionState(sessionId) + ?? buildFallbackConversation(turnRows); + + const session: NativeCopilotSessionDetail = { + id: row.id, + repository: row.repository, + cwd: row.cwd, + hostType: row.host_type, + branch: row.branch, + summary: row.summary ?? '', + createdAt: row.created_at, + updatedAt: row.updated_at, + turns, + conversation, + }; + return { available: true, session }; + } catch { + return { available: false, reason: 'db-invalid' }; + } finally { + db.close(); + } + } + + private openReadOnly(): DbOpenResult { + if (!fs.existsSync(this.dbPath)) { + return { ok: false, reason: 'db-missing' }; + } + try { + const db = new DatabaseConstructor(this.dbPath, { readonly: true, fileMustExist: true }); + return { ok: true, db }; + } catch { + return { ok: false, reason: 'db-invalid' }; + } + } + + private hasValidSchema(db: Database): boolean { + try { + // prepare() fails when a table or expected column is absent. + db.prepare('SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions LIMIT 0').all(); + db.prepare('SELECT id, session_id, turn_index, user_message, assistant_response, timestamp FROM turns LIMIT 0').all(); + return true; + } catch { + return false; + } + } + + private hasSearchIndex(db: Database): boolean { + try { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'search_index'", + ).get(); + return row !== undefined; + } catch { + return false; + } + } + + private queryTextHits(db: Database, matchExpression: string): Map { + const hits = new Map(); + try { + const rows = db.prepare( + "SELECT session_id AS sessionId, snippet(search_index, 0, '', '', '…', 12) AS snip FROM search_index WHERE search_index MATCH ?", + ).all(matchExpression) as { sessionId: string | null; snip: string | null }[]; + for (const row of rows) { + if (!row.sessionId) { + continue; + } + const existing = hits.get(row.sessionId) ?? []; + if (row.snip && existing.length < MAX_MATCH_SNIPPETS) { + existing.push(row.snip); + } + hits.set(row.sessionId, existing); + } + } catch { + // A broken FTS index behaves like an absent one: no text hits. + hits.clear(); + } + return hits; + } + + private querySessionRows( + db: Database, + options: NativeCopilotSessionListOptions, + textHits: Map | null, + ): SessionRow[] { + const where: string[] = []; + const params: unknown[] = []; + + if (options.branch) { + where.push('branch = ?'); + params.push(options.branch); + } + if (options.sessionId) { + const escaped = options.sessionId.replace(/([\\%_])/g, '\\$1'); + where.push("(id = ? OR id LIKE ? ESCAPE '\\')"); + params.push(options.sessionId, `%${escaped}%`); + } + if (textHits) { + const ids = [...textHits.keys()]; + where.push(`id IN (${ids.map(() => '?').join(', ')})`); + params.push(...ids); + } + + const whereClause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''; + return db.prepare( + `SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions${whereClause}`, + ).all(...params) as SessionRow[]; + } + + private queryTurnCounts(db: Database, sessionIds: string[]): Map { + const counts = new Map(); + if (sessionIds.length === 0) { + return counts; + } + const rows = db.prepare( + `SELECT session_id AS sessionId, COUNT(*) AS turnCount FROM turns WHERE session_id IN (${sessionIds.map(() => '?').join(', ')}) GROUP BY session_id`, + ).all(...sessionIds) as { sessionId: string; turnCount: number }[]; + for (const row of rows) { + counts.set(row.sessionId, row.turnCount); + } + return counts; + } + + /** + * Return the subset of the given session ids whose first turn (turn_index 0) + * is an automated background-job prompt (e.g. conversation-title + * summarization). Chunked to stay within SQLite's bound-parameter limit. + */ + private queryBackgroundJobSessionIds(db: Database, sessionIds: string[]): Set { + const matches = new Set(); + if (sessionIds.length === 0 || BACKGROUND_JOB_PROMPT_PREFIXES.length === 0) { + return matches; + } + // Escape LIKE metacharacters in prefixes, then append the wildcard. + const likePatterns = BACKGROUND_JOB_PROMPT_PREFIXES.map( + prefix => `${prefix.replace(/([\\%_])/g, '\\$1')}%`, + ); + const turnPromptClause = `(${likePatterns.map(() => "user_message LIKE ? ESCAPE '\\'").join(' OR ')})`; + const summaryPromptClause = `(${likePatterns.map(() => "summary LIKE ? ESCAPE '\\'").join(' OR ')})`; + const CHUNK = 400; + for (let i = 0; i < sessionIds.length; i += CHUNK) { + const chunk = sessionIds.slice(i, i + CHUNK); + const turnRows = db.prepare( + `SELECT DISTINCT session_id AS sessionId FROM turns + WHERE turn_index = 0 AND session_id IN (${chunk.map(() => '?').join(', ')}) AND ${turnPromptClause}`, + ).all(...chunk, ...likePatterns) as { sessionId: string }[]; + for (const row of turnRows) { + matches.add(row.sessionId); + } + const summaryRows = db.prepare( + `SELECT DISTINCT id AS sessionId FROM sessions + WHERE id IN (${chunk.map(() => '?').join(', ')}) + AND NOT EXISTS ( + SELECT 1 FROM turns + WHERE turns.session_id = sessions.id AND turns.turn_index = 0 + ) + AND ${summaryPromptClause}`, + ).all(...chunk, ...likePatterns) as { sessionId: string }[]; + for (const row of summaryRows) { + matches.add(row.sessionId); + } + } + return matches; + } + + private querySearchIndexDiagnostics(db: Database, sessionId: string): Map { + const diagnostics = new Map(); + if (!this.hasSearchIndex(db)) { + return diagnostics; + } + try { + const rows = db.prepare( + 'SELECT source_id AS sourceId, length(content) AS chars FROM search_index WHERE session_id = ?', + ).all(sessionId) as { sourceId: string | null; chars: number | null }[]; + for (const row of rows) { + if (row.sourceId) { + diagnostics.set(row.sourceId, row.chars ?? 0); + } + } + } catch { + // Index diagnostics are optional; a broken index reads as "not indexed". + diagnostics.clear(); + } + return diagnostics; + } +} diff --git a/packages/coc/src/server/native-copilot-sessions/session-state-parser.ts b/packages/coc/src/server/native-copilot-sessions/session-state-parser.ts new file mode 100644 index 000000000..439132005 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/session-state-parser.ts @@ -0,0 +1,289 @@ +/** + * Read-only parser for the native GitHub Copilot CLI per-session state log. + * + * Each native session keeps a rich event log at + * `~/.copilot/session-state//events.jsonl` — a newline-delimited + * stream of `{ type, id, parentId, timestamp, data }` records covering user and + * assistant messages, tool calls + results, model reasoning, images, and + * errors. This module reconstructs that stream into ordered + * {@link ReconstructedConversationTurn}s so the dashboard can render a native + * session as a rich CoC chat transcript. + * + * This data is external and owned by the Copilot CLI: the parser only reads it + * and never writes to `~/.copilot`. Missing or malformed input yields `null` + * (never throws) so the caller can fall back to the flat `session-store.db`. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { + ReconstructedConversationTurn, + ReconstructedTimelineItem, + ReconstructedToolCall, +} from './types'; + +/** Default location of the native per-session state directory for the server user. */ +export function getDefaultNativeSessionStateDir(): string { + return path.join(os.homedir(), '.copilot', 'session-state'); +} + +export interface ParseNativeSessionStateOptions { + /** Override of the `session-state` base directory (tests use synthetic fixtures). */ + sessionStateDir?: string; +} + +/** One raw `events.jsonl` record. `data` shape varies by `type`. */ +interface RawEvent { + type?: unknown; + timestamp?: unknown; + data?: Record | null; +} + +/** Reject session ids that could escape the session-state directory. */ +function isSafeSessionId(sessionId: string): boolean { + return sessionId.length > 0 + && !sessionId.includes('/') + && !sessionId.includes('\\') + && !sessionId.includes('\0') + && sessionId !== '.' + && sessionId !== '..'; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Collect base64 data-URL images from a `user.message` attachments array. + * Only embedded base64 attachments can be rendered inline; file-path + * attachments (`{ type:'file', path, displayName }`) cannot be read here and + * are surfaced as a text note by the caller instead. + */ +function collectImages(attachments: unknown): string[] { + if (!Array.isArray(attachments)) { + return []; + } + const images: string[] = []; + for (const entry of attachments) { + const rec = asRecord(entry); + if (!rec) { + continue; + } + const data = asString(rec.data); + const mimeType = asString(rec.mimeType) ?? asString(rec.mediaType); + if (data && mimeType && mimeType.startsWith('image/')) { + images.push(data.startsWith('data:') ? data : `data:${mimeType};base64,${data}`); + } + } + return images; +} + +/** Extract the tool result text, preferring the full detailed content. */ +function extractResult(result: unknown): string | undefined { + const rec = asRecord(result); + if (!rec) { + return asString(result); + } + return asString(rec.detailedContent) ?? asString(rec.content); +} + +/** + * Parse a native session's `events.jsonl` into ordered conversation turns. + * + * Returns `null` when the log is missing, unreadable, or yields no usable + * turns (malformed) — the caller then falls back to `session-store.db`. + */ +export function parseNativeSessionState( + sessionId: string, + options: ParseNativeSessionStateOptions = {}, +): ReconstructedConversationTurn[] | null { + try { + if (!isSafeSessionId(sessionId)) { + return null; + } + const baseDir = options.sessionStateDir ?? getDefaultNativeSessionStateDir(); + const eventsPath = path.join(baseDir, sessionId, 'events.jsonl'); + if (!fs.existsSync(eventsPath)) { + return null; + } + const raw = fs.readFileSync(eventsPath, 'utf8'); + return reconstructTurns(raw); + } catch { + return null; + } +} + +/** + * Reconstruct ordered turns from the raw `events.jsonl` text. Exposed for unit + * testing without touching the filesystem. Returns `null` when no usable turns + * could be reconstructed. + */ +export function reconstructTurns(rawJsonl: string): ReconstructedConversationTurn[] | null { + const turns: ReconstructedConversationTurn[] = []; + // Tool calls are correlated start↔complete by toolCallId across events. + const toolCallsById = new Map(); + let currentAssistant: ReconstructedConversationTurn | null = null; + let currentTurnId: string | undefined; + let validEvents = 0; + + const newTurn = (role: 'user' | 'assistant', timestamp?: string): ReconstructedConversationTurn => ({ + role, + content: '', + timestamp, + timeline: [], + }); + + for (const line of rawJsonl.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + let event: RawEvent; + try { + event = JSON.parse(trimmed) as RawEvent; + } catch { + // Skip a single malformed line; a partially-written log stays usable. + continue; + } + const type = asString(event.type); + const data = asRecord(event.data); + if (!type) { + continue; + } + const timestamp = asString(event.timestamp); + validEvents += 1; + + switch (type) { + case 'user.message': { + currentAssistant = null; + currentTurnId = undefined; + const turn = newTurn('user', timestamp); + turn.content = asString(data?.content) ?? ''; + const images = collectImages(data?.attachments); + if (images.length > 0) { + turn.images = images; + } + turns.push(turn); + break; + } + case 'assistant.message': { + const turnId = asString(data?.turnId); + // Coalesce consecutive assistant messages of the same model turn. + if (!currentAssistant || (turnId !== undefined && turnId !== currentTurnId)) { + currentAssistant = newTurn('assistant', timestamp); + turns.push(currentAssistant); + currentTurnId = turnId; + } + const text = asString(data?.content); + if (text) { + currentAssistant.content = currentAssistant.content + ? `${currentAssistant.content}\n\n${text}` + : text; + currentAssistant.timeline.push({ + type: 'content', + timestamp: timestamp ?? '', + content: text, + }); + } + const reasoning = asString(data?.reasoningText); + if (reasoning) { + currentAssistant.thinking = currentAssistant.thinking + ? `${currentAssistant.thinking}\n\n${reasoning}` + : reasoning; + } + const model = asString(data?.model); + if (model && !currentAssistant.model) { + currentAssistant.model = model; + } + break; + } + case 'tool.execution_start': { + const toolCallId = asString(data?.toolCallId); + if (!toolCallId) { + break; + } + // A tool call always belongs to an assistant turn; synthesize one + // if the log starts mid-stream without a preceding message. + if (!currentAssistant) { + currentAssistant = newTurn('assistant', timestamp); + turns.push(currentAssistant); + } + const toolCall: ReconstructedToolCall = { + id: toolCallId, + toolName: asString(data?.toolName) ?? 'unknown', + args: data?.arguments, + status: 'running', + startTime: timestamp, + }; + toolCallsById.set(toolCallId, toolCall); + (currentAssistant.toolCalls ??= []).push(toolCall); + currentAssistant.timeline.push({ + type: 'tool-start', + timestamp: timestamp ?? '', + toolCall, + }); + break; + } + case 'tool.execution_complete': { + const toolCallId = asString(data?.toolCallId); + if (!toolCallId) { + break; + } + const toolCall = toolCallsById.get(toolCallId); + if (!toolCall) { + break; + } + const success = data?.success !== false; + toolCall.status = success ? 'completed' : 'failed'; + toolCall.endTime = timestamp; + if (success) { + const result = extractResult(data?.result); + if (result !== undefined) { + toolCall.result = result; + } + } else { + const err = asRecord(data?.error); + toolCall.error = asString(err?.message) ?? asString(data?.error) ?? 'Tool call failed'; + } + const owner = currentAssistant; + if (owner) { + owner.timeline.push({ + type: success ? 'tool-complete' : 'tool-failed', + timestamp: timestamp ?? '', + toolCall, + }); + } + break; + } + case 'skill.invoked': { + const name = asString(data?.name); + if (name && currentAssistant) { + (currentAssistant.skillNames ??= []).push(name); + } + break; + } + default: + // system.message, hook.*, permission.*, external_tool.*, + // session.*, subagent.*, assistant.turn_start/end — not part of + // the rendered transcript. + break; + } + } + + if (validEvents === 0 || turns.length === 0) { + return null; + } + + // Stamp a stable turn index for the renderer. + turns.forEach((turn, index) => { + turn.turnIndex = index; + }); + return turns; +} diff --git a/packages/coc/src/server/native-copilot-sessions/types.ts b/packages/coc/src/server/native-copilot-sessions/types.ts new file mode 100644 index 000000000..140bd86a9 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/types.ts @@ -0,0 +1,226 @@ +/** + * Native GitHub Copilot CLI session types. + * + * These records are read from the current user's native Copilot CLI session + * store (`~/.copilot/session-store.db`). The store is external data owned by + * the Copilot CLI — CoC reads it strictly read-only and never imports these + * sessions into CoC process history. + */ + +/** Reason a native-session response carries no data. */ +export type NativeCopilotSessionsUnavailableReason = 'feature-disabled' | 'db-missing' | 'db-invalid'; + +/** One row in the native session list. */ +export interface NativeCopilotSessionListItem { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + /** First line of the stored summary, truncated for list display. */ + summaryPreview: string; + createdAt: string | null; + updatedAt: string | null; + turnCount: number; + /** Snippets from indexed content matched by a text query (empty without a text hit). */ + matchSnippets: string[]; +} + +/** One ordered turn of a native session. */ +export interface NativeCopilotSessionTurn { + id: number; + turnIndex: number; + timestamp: string | null; + userMessage: string; + assistantResponse: string; + userChars: number; + assistantChars: number; + /** search_index source id for this turn when an index row exists. */ + searchIndexSourceId: string | null; + /** Indexed content length for this turn when an index row exists. */ + searchIndexChars: number | null; +} + +/** Full native session detail. */ +export interface NativeCopilotSessionDetail { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summary: string; + createdAt: string | null; + updatedAt: string | null; + turns: NativeCopilotSessionTurn[]; + /** + * Reconstructed chat transcript for rich dashboard rendering. Built from the + * rich `session-state//events.jsonl` log when it is available and + * parseable; otherwise mapped from the flat {@link NativeCopilotSessionTurn}s + * above as text-only user/assistant turns. Always present (possibly empty). + */ + conversation: ReconstructedConversationTurn[]; +} + +/** + * One reconstructed tool call inside a {@link ReconstructedConversationTurn}. + * + * Mirrors the SPA-side `ClientToolCall` so the dashboard chat components + * (`ConversationArea` / `ConversationTurnBubble`) can render it without a fork. + */ +export interface ReconstructedToolCall { + id: string; + toolName: string; + /** Raw tool arguments object as recorded by the native CLI. */ + args: unknown; + /** Tool result text (full `detailedContent`, else short `content`) when it succeeded. */ + result?: string; + /** Error message when the tool call failed. */ + error?: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + startTime?: string; + endTime?: string; +} + +/** + * One timeline event inside a {@link ReconstructedConversationTurn}, mirroring + * the SPA-side `ClientTimelineItem` so the chat bubble can interleave assistant + * text and tool cards in chronological order. + */ +export interface ReconstructedTimelineItem { + type: 'content' | 'tool-start' | 'tool-complete' | 'tool-failed'; + timestamp: string; + content?: string; + toolCall?: ReconstructedToolCall; +} + +/** + * A single reconstructed conversation turn, mirroring the subset of the + * SPA-side `ClientConversationTurn` that the read-only native-session detail + * view populates. Built either from the rich `session-state//events.jsonl` + * log or, as a fallback, from the flat `session-store.db` turns. + */ +export interface ReconstructedConversationTurn { + role: 'user' | 'assistant'; + /** Primary markdown content of the turn. */ + content: string; + timestamp?: string; + turnIndex?: number; + toolCalls?: ReconstructedToolCall[]; + timeline: ReconstructedTimelineItem[]; + /** Base64 data-URL strings for images attached to or produced in this turn. */ + images?: string[]; + /** Readable model reasoning/thinking for an assistant turn (events.jsonl `reasoningText`). */ + thinking?: string; + /** Skills invoked during this turn. */ + skillNames?: string[]; + /** Model that produced an assistant turn (e.g. `gpt-5.5`, `claude-opus-4.8`). */ + model?: string; + /** True when an assistant turn ended in an error. */ + isError?: boolean; +} + +/** Filters accepted by the native session list query. */ +export interface NativeCopilotSessionListOptions { + /** Free-text query against natively indexed content (search_index FTS). */ + q?: string; + /** Exact or partial session ID match. */ + sessionId?: string; + /** Exact branch filter. */ + branch?: string; + /** ISO timestamp lower bound on updated_at (inclusive). */ + from?: string; + /** ISO timestamp upper bound on updated_at (inclusive). */ + to?: string; + limit?: number; + offset?: number; + /** + * Native `sessions.id` values to exclude from results. Used to deduplicate + * against native sessions already tracked as CoC processes (the Copilot + * SDK/CLI session id equals the native store id). Server-internal — not a + * client-supplied query parameter. + */ + excludeSessionIds?: ReadonlySet; + /** + * When true, include background-job sessions (e.g. conversation-title + * summarization) that are otherwise hidden. Defaults to false (hide them). + */ + includeBackgroundJobs?: boolean; +} + +/** Workspace identity used to scope native sessions to the active CoC workspace. */ +export interface NativeSessionWorkspaceScope { + /** Registered workspace root path; matches native `sessions.cwd` (equal or descendant). */ + rootPath?: string; + /** Workspace `owner/repo` identity; matches native `sessions.repository` case-insensitively. */ + repository?: string; +} + +export type NativeCopilotSessionListResult = + | { + available: true; + items: NativeCopilotSessionListItem[]; + total: number; + /** False when metadata tables exist but the native search_index is absent. */ + searchIndexAvailable: boolean; + /** Count of workspace-scoped native sessions hidden because they are already tracked as CoC processes. */ + deduplicatedCount: number; + /** Count of workspace-scoped native sessions hidden because they are background jobs (e.g. title summarization). */ + backgroundJobCount: number; + } + | { + available: false; + reason: Exclude; + }; + +export type NativeCopilotSessionDetailResult = + | { available: true; session: NativeCopilotSessionDetail | null } + | { available: false; reason: Exclude }; + +export type NativeCliSessionProviderId = 'copilot' | 'codex' | 'claude'; + +export type NativeCliSessionsUnavailableReason = 'feature-disabled' | 'store-missing' | 'store-invalid'; + +export interface NativeCliSessionListItem extends NativeCopilotSessionListItem { + provider: NativeCliSessionProviderId; + storePath: string; + searchIndexAvailable: boolean; +} + +export interface NativeCliSessionDetail extends NativeCopilotSessionDetail { + provider: NativeCliSessionProviderId; + storePath: string; + searchIndexAvailable: boolean; +} + +export interface NativeCliSessionListOptions extends Omit { + provider?: NativeCliSessionProviderId; +} + +export type NativeCliSessionListResult = + | { + available: true; + items: NativeCliSessionListItem[]; + total: number; + searchIndexAvailable: boolean; + deduplicatedCount: number; + backgroundJobCount: number; + } + | { + available: false; + reason: Exclude; + }; + +export type NativeCliSessionDetailResult = + | { available: true; session: NativeCliSessionDetail | null } + | { available: false; reason: Exclude }; + +export interface NativeSessionProvider { + readonly provider: NativeCliSessionProviderId; + readonly label: string; + readonly storePath: string; + listSessions( + scope: NativeSessionWorkspaceScope, + options?: NativeCliSessionListOptions, + ): NativeCliSessionListResult & { limit: number; offset: number }; + getSession(scope: NativeSessionWorkspaceScope, id: string): NativeCliSessionDetailResult; +} diff --git a/packages/coc/src/server/routes/api-workspace-routes.ts b/packages/coc/src/server/routes/api-workspace-routes.ts index 12bcd93ca..f3d49daaa 100644 --- a/packages/coc/src/server/routes/api-workspace-routes.ts +++ b/packages/coc/src/server/routes/api-workspace-routes.ts @@ -25,6 +25,7 @@ import { validatePerRepoPreferences, } from '../preferences-handler'; import { getEffectiveDefaultDisabledTools, getEffectiveLlmToolRegistry } from '../llm-tools/llm-tool-registry'; +import { withToolParameterMetadata } from '../llm-tools/llm-tool-parameter-schemas'; import { detectEnDevEligibility } from '../endev/endev-detector'; import { skillCache } from '../skills/skill-handler'; import { @@ -735,7 +736,7 @@ export function registerApiWorkspaceRoutes(ctx: ApiRouteContext): void { const ws = await resolveWorkspaceOrFail(store, match!, res); if (!ws) return; const liveFlags = ctx.getLiveFeatureFlags?.() ?? { excalidrawEnabled: false, canvasEnabled: false }; - const effectiveRegistry = getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: liveFlags.excalidrawEnabled, canvasEnabled: liveFlags.canvasEnabled }); + const effectiveRegistry = withToolParameterMetadata(getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: liveFlags.excalidrawEnabled, canvasEnabled: liveFlags.canvasEnabled })); const conversationRetrievalAvailable = typeof ctx.store.searchConversations === 'function'; if (!ctx.dataDir) { sendJSON(res, 200, { @@ -782,7 +783,7 @@ export function registerApiWorkspaceRoutes(ctx: ApiRouteContext): void { writeRepoPreferences(ctx.dataDir, ws.id, merged); const globalPrefs = readGlobalPreferences(ctx.dataDir); sendJSON(res, 200, { - tools: getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: ctx.getLiveFeatureFlags?.()?.excalidrawEnabled ?? false, canvasEnabled: ctx.getLiveFeatureFlags?.()?.canvasEnabled ?? false }), + tools: withToolParameterMetadata(getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: ctx.getLiveFeatureFlags?.()?.excalidrawEnabled ?? false, canvasEnabled: ctx.getLiveFeatureFlags?.()?.canvasEnabled ?? false })), disabledLlmTools: merged.disabledLlmTools ?? getEffectiveDefaultDisabledTools(globalPrefs.uiLayoutMode), conversationRetrievalAvailable: typeof ctx.store.searchConversations === 'function', }); diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index 9aa82a6fc..3cdb4c172 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -111,6 +111,11 @@ import { registerMapReduceRoutes } from './map-reduce-routes'; import { FileMapReduceRunStore } from '../map-reduce/map-reduce-run-store'; import { createMapReducePlanGenerator } from '../map-reduce/map-reduce-plan-generator'; import { MapReduceRunExecutor } from '../map-reduce/map-reduce-run-executor'; +import { registerNativeCopilotSessionRoutes } from './native-copilot-session-routes'; +import { NativeCopilotSessionService } from '../native-copilot-sessions/native-copilot-session-service'; +import { ClaudeNativeSessionProvider, CodexNativeSessionProvider, CopilotNativeSessionProvider } from '../native-copilot-sessions/native-cli-session-service'; +import { registerNativeCliSessionRoutes } from './native-cli-session-routes'; +import type { NativeCliSessionProviderId, NativeSessionProvider } from '../native-copilot-sessions/types'; import { registerDreamRoutes } from '../dreams/dream-routes'; import { FileDreamStore } from '../dreams/dream-store'; import { DreamRunExecutor, type DreamRunRequestOptions } from '../dreams/dream-runner'; @@ -209,6 +214,10 @@ export interface RegisterRoutesOptions { hostname?: string; bindAddress?: string; syncEngines?: Map; + /** Native Copilot CLI session store path override (for tests). */ + nativeCopilotSessionDbPath?: string; + /** Native Copilot CLI `session-state` base directory override (for tests). */ + nativeCopilotSessionStateDir?: string; } export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): { wikiManager: WikiManager | undefined; workItemGitHubPullPoller: WorkItemGitHubPullPoller; workItemAzureBoardsPullPoller: WorkItemAzureBoardsPullPoller; agentProvidersQuotaCache?: AgentProvidersQuotaCache; activeWorkspaceBackgroundRefresher: ActiveWorkspaceBackgroundRefresher; dreamIdleScheduler: DreamIdleScheduler } { @@ -711,6 +720,39 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): resolveDefaultProvider, }); + // Legacy Native Copilot CLI session routes: read-only compatibility aliases + // over the server user's native Copilot store. They share the unified + // `features.nativeCliSessions` live guard so there is one operational switch + // for the CLI Sessions surface. + const getNativeCopilotSessionsEnabled = opts.runtimeConfigService + ? () => opts.runtimeConfigService!.config.features?.nativeCliSessions ?? false + : () => opts.resolvedConfig?.features?.nativeCliSessions ?? false; + const nativeCopilotSessionService = new NativeCopilotSessionService({ + dbPath: opts.nativeCopilotSessionDbPath, + sessionStateDir: opts.nativeCopilotSessionStateDir, + }); + registerNativeCopilotSessionRoutes({ + routes, + store, + getEnabled: getNativeCopilotSessionsEnabled, + service: nativeCopilotSessionService, + }); + + const getNativeCliSessionsEnabled = opts.runtimeConfigService + ? () => opts.runtimeConfigService!.config.features?.nativeCliSessions ?? false + : () => opts.resolvedConfig?.features?.nativeCliSessions ?? false; + const nativeCliSessionProviders = new Map([ + ['copilot', new CopilotNativeSessionProvider(nativeCopilotSessionService)], + ['codex', new CodexNativeSessionProvider()], + ['claude', new ClaudeNativeSessionProvider()], + ]); + registerNativeCliSessionRoutes({ + routes, + store, + getEnabled: getNativeCliSessionsEnabled, + providers: nativeCliSessionProviders, + }); + const workItemStore = new FileWorkItemStore({ dataDir }); // Dreams routes: reviewable, workspace-scoped cards plus manual run trigger. @@ -770,6 +812,8 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): const getDefaultDreamRunOptions = (): DreamRunRequestOptions => { const dreams = (opts.runtimeConfigService?.config ?? opts.resolvedConfig)?.dreams; return { + provider: dreams?.provider, + model: dreams?.model, minIdleMs: dreams?.minIdleMs, confidenceThreshold: dreams?.confidenceThreshold, maxCandidates: dreams?.maxCandidates, diff --git a/packages/coc/src/server/routes/native-cli-session-routes.ts b/packages/coc/src/server/routes/native-cli-session-routes.ts new file mode 100644 index 000000000..ccfa36fb4 --- /dev/null +++ b/packages/coc/src/server/routes/native-cli-session-routes.ts @@ -0,0 +1,192 @@ +/** + * Unified native CLI session routes. + * + * Read-only, workspace-scoped views over native Copilot, Codex, and Claude Code + * CLI session stores. The route delegates all provider-specific store access to + * short-lived read-only providers and never mutates external CLI data. + */ + +import * as url from 'url'; +import * as http from 'http'; +import type { ProcessStore, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import type { Route } from '../types'; +import { sendJSON } from '../core/api-handler'; +import { badRequest, handleAPIError, notFound } from '../errors'; +import { resolveWorkspaceOrFail } from '../shared/handler-utils'; +import { DEFAULT_NATIVE_SESSION_LIST_LIMIT } from '../native-copilot-sessions/native-copilot-session-service'; +import type { + NativeCliSessionProviderId, + NativeSessionProvider, + NativeSessionWorkspaceScope, +} from '../native-copilot-sessions/types'; +import { parseGitHubRemoteUrl, readGitOriginRemote } from '../work-items/work-item-sync-github-repo'; + +export interface NativeCliSessionRouteContext { + routes: Route[]; + store: ProcessStore; + getEnabled: () => boolean; + providers: ReadonlyMap; + /** Override of workspace `owner/repo` resolution (tests avoid real git calls). */ + resolveWorkspaceRepository?: (workspace: WorkspaceInfo) => string | undefined; +} + +function defaultResolveWorkspaceRepository(workspace: WorkspaceInfo): string | undefined { + if (!workspace.rootPath) { + return undefined; + } + const remote = readGitOriginRemote(workspace.rootPath); + if (!remote) { + return undefined; + } + const parsed = parseGitHubRemoteUrl(remote); + return parsed ? `${parsed.owner}/${parsed.repo}` : undefined; +} + +function queryString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function queryNumber(value: unknown): number | undefined { + const raw = queryString(value); + if (raw === undefined) { + return undefined; + } + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function parseProvider(value: unknown): NativeCliSessionProviderId | undefined { + const raw = queryString(value) ?? 'copilot'; + return raw === 'copilot' || raw === 'codex' || raw === 'claude' ? raw : undefined; +} + +export function registerNativeCliSessionRoutes(ctx: NativeCliSessionRouteContext): void { + const { routes, store, getEnabled, providers } = ctx; + const resolveRepository = ctx.resolveWorkspaceRepository ?? defaultResolveWorkspaceRepository; + + const buildScope = (workspace: WorkspaceInfo): NativeSessionWorkspaceScope => ({ + rootPath: workspace.rootPath, + repository: resolveRepository(workspace), + }); + + const resolveProvider = (res: http.ServerResponse, raw: unknown): NativeSessionProvider | null => { + const providerId = parseProvider(raw); + if (!providerId) { + handleAPIError(res, badRequest('provider must be one of: copilot, codex, claude')); + return null; + } + const provider = providers.get(providerId); + if (!provider) { + handleAPIError(res, badRequest(`Native CLI session provider is not registered: ${providerId}`)); + return null; + } + return provider; + }; + + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-cli-sessions$/, + handler: async (req, res, match) => { + const query = url.parse(req.url || '', true).query; + const limit = queryNumber(query.limit) ?? DEFAULT_NATIVE_SESSION_LIST_LIMIT; + const offset = queryNumber(query.offset) ?? 0; + if (!getEnabled()) { + sendJSON(res, 200, { + enabled: false, + reason: 'feature-disabled', + items: [], + total: 0, + limit, + offset, + }); + return; + } + + const provider = resolveProvider(res, query.provider); + if (!provider) { return; } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + const result = provider.listSessions(buildScope(workspace), { + provider: provider.provider, + q: queryString(query.q), + sessionId: queryString(query.sessionId), + branch: queryString(query.branch), + from: queryString(query.from), + to: queryString(query.to), + limit: queryNumber(query.limit), + offset: queryNumber(query.offset), + excludeSessionIds: store.getSdkSessionIds?.(workspace.id), + }); + + if (!result.available) { + sendJSON(res, 200, { + enabled: true, + available: false, + reason: result.reason, + items: [], + total: 0, + limit: result.limit, + offset: result.offset, + provider: provider.provider, + }); + return; + } + + sendJSON(res, 200, { + enabled: true, + available: true, + provider: provider.provider, + items: result.items, + total: result.total, + searchIndexAvailable: result.searchIndexAvailable, + deduplicatedCount: result.deduplicatedCount, + backgroundJobCount: result.backgroundJobCount, + limit: result.limit, + offset: result.offset, + }); + }, + }); + + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-cli-sessions\/([^/]+)$/, + handler: async (req, res, match) => { + const query = url.parse(req.url || '', true).query; + if (!getEnabled()) { + sendJSON(res, 200, { enabled: false, reason: 'feature-disabled' }); + return; + } + const provider = resolveProvider(res, query.provider); + if (!provider) { return; } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + const sessionId = decodeURIComponent(match![2]); + const result = provider.getSession(buildScope(workspace), sessionId); + if (!result.available) { + sendJSON(res, 200, { + enabled: true, + available: false, + reason: result.reason, + provider: provider.provider, + }); + return; + } + if (!result.session) { + handleAPIError(res, notFound('Native CLI session')); + return; + } + sendJSON(res, 200, { + enabled: true, + available: true, + provider: provider.provider, + session: result.session, + }); + }, + }); +} diff --git a/packages/coc/src/server/routes/native-copilot-session-routes.ts b/packages/coc/src/server/routes/native-copilot-session-routes.ts new file mode 100644 index 000000000..94c774991 --- /dev/null +++ b/packages/coc/src/server/routes/native-copilot-session-routes.ts @@ -0,0 +1,158 @@ +/** + * Native GitHub Copilot CLI session routes. + * + * Read-only, workspace-scoped views over the current server user's native + * Copilot CLI session store. These compatibility routes are gated by the + * disabled-by-default `features.nativeCliSessions` flag with a live guard so + * admin toggles take effect without restart. Disabled and unavailable states return + * HTTP 200 with typed payloads so the dashboard renders non-fatal states. + */ + +import * as url from 'url'; +import type { Route } from '../types'; +import { sendJSON } from '../core/api-handler'; +import { handleAPIError, notFound } from '../errors'; +import { resolveWorkspaceOrFail } from '../shared/handler-utils'; +import type { ProcessStore, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import type { NativeCopilotSessionService } from '../native-copilot-sessions/native-copilot-session-service'; +import { DEFAULT_NATIVE_SESSION_LIST_LIMIT } from '../native-copilot-sessions/native-copilot-session-service'; +import type { NativeSessionWorkspaceScope } from '../native-copilot-sessions/types'; +import { parseGitHubRemoteUrl, readGitOriginRemote } from '../work-items/work-item-sync-github-repo'; + +export interface NativeCopilotSessionRouteContext { + routes: Route[]; + store: ProcessStore; + getEnabled: () => boolean; + service: NativeCopilotSessionService; + /** Override of workspace `owner/repo` resolution (tests avoid real git calls). */ + resolveWorkspaceRepository?: (workspace: WorkspaceInfo) => string | undefined; +} + +function defaultResolveWorkspaceRepository(workspace: WorkspaceInfo): string | undefined { + if (!workspace.rootPath) { + return undefined; + } + const remote = readGitOriginRemote(workspace.rootPath); + if (!remote) { + return undefined; + } + const parsed = parseGitHubRemoteUrl(remote); + return parsed ? `${parsed.owner}/${parsed.repo}` : undefined; +} + +function queryString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function queryNumber(value: unknown): number | undefined { + const raw = queryString(value); + if (raw === undefined) { + return undefined; + } + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRouteContext): void { + const { routes, store, getEnabled, service } = ctx; + const resolveRepository = ctx.resolveWorkspaceRepository ?? defaultResolveWorkspaceRepository; + + const buildScope = (workspace: WorkspaceInfo): NativeSessionWorkspaceScope => ({ + rootPath: workspace.rootPath, + repository: resolveRepository(workspace), + }); + + // GET /api/workspaces/:id/native-copilot-sessions + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-copilot-sessions$/, + handler: async (req, res, match) => { + const query = url.parse(req.url || '', true).query; + const limit = queryNumber(query.limit) ?? DEFAULT_NATIVE_SESSION_LIST_LIMIT; + const offset = queryNumber(query.offset) ?? 0; + if (!getEnabled()) { + sendJSON(res, 200, { + enabled: false, + reason: 'feature-disabled', + items: [], + total: 0, + limit, + offset, + }); + return; + } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + // Dedup: hide native sessions already tracked as CoC processes for + // this workspace. The Copilot SDK/CLI session id equals the native + // store id, so a single indexed query yields the exclusion set. + const excludeSessionIds = store.getSdkSessionIds?.(workspace.id); + + const result = service.listSessions(buildScope(workspace), { + q: queryString(query.q), + sessionId: queryString(query.sessionId), + branch: queryString(query.branch), + from: queryString(query.from), + to: queryString(query.to), + limit: queryNumber(query.limit), + offset: queryNumber(query.offset), + excludeSessionIds, + }); + + if (!result.available) { + sendJSON(res, 200, { + enabled: true, + available: false, + reason: result.reason, + items: [], + total: 0, + limit: result.limit, + offset: result.offset, + }); + return; + } + sendJSON(res, 200, { + enabled: true, + available: true, + items: result.items, + total: result.total, + searchIndexAvailable: result.searchIndexAvailable, + deduplicatedCount: result.deduplicatedCount, + backgroundJobCount: result.backgroundJobCount, + limit: result.limit, + offset: result.offset, + }); + }, + }); + + // GET /api/workspaces/:id/native-copilot-sessions/:sessionId + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-copilot-sessions\/([^/]+)$/, + handler: async (_req, res, match) => { + if (!getEnabled()) { + sendJSON(res, 200, { enabled: false, reason: 'feature-disabled' }); + return; + } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + const sessionId = decodeURIComponent(match![2]); + const result = service.getSession(buildScope(workspace), sessionId); + if (!result.available) { + sendJSON(res, 200, { enabled: true, available: false, reason: result.reason }); + return; + } + if (!result.session) { + handleAPIError(res, notFound('Native Copilot session')); + return; + } + sendJSON(res, 200, { enabled: true, available: true, session: result.session }); + }, + }); +} diff --git a/packages/coc/src/server/spa/client/react/admin/AIProviderPage.tsx b/packages/coc/src/server/spa/client/react/admin/AIProviderPage.tsx index 6283400c7..84597b22a 100644 --- a/packages/coc/src/server/spa/client/react/admin/AIProviderPage.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AIProviderPage.tsx @@ -26,7 +26,7 @@ import { getTightestFiniteQuotaType, getUnlimitedQuotaTypes, } from '../shared/quotaUtils'; -import { formatProviderActivityTimeout, type AgentProviderWorkActivity } from '../shared/providerActivity'; +import { PROVIDER_LABELS, ProviderAvatar } from '../shared/providerVisuals'; const ProviderModelsSection = lazy(() => import('../features/models/ProviderModelsSection').then(m => ({ default: m.ProviderModelsSection }))); const ProviderEffortTiersSection = lazy(() => import('../features/models/ProviderEffortTiersSection').then(m => ({ default: m.ProviderEffortTiersSection }))); @@ -71,14 +71,10 @@ export interface AIProviderPageProps { quotaLoading: boolean; quotaError: string | null; onRefreshQuota: (options?: { force?: boolean }) => void; - providerActivity?: AgentProviderWorkActivity[]; - providerActivityError?: string | null; - onRefreshProviderActivity?: () => void; sources: Record; } -const PROVIDER_LABELS: Record = { copilot: 'Copilot', codex: 'Codex', claude: 'Claude' }; const DEFAULT_PROVIDER_LABELS: Record = PROVIDER_LABELS; const PROVIDER_IDS: Provider[] = ['copilot', 'codex', 'claude']; export const DEFAULT_AUTO_PROVIDER_ROUTING_CONFIG: NormalizedAutoProviderRoutingConfig = { @@ -161,45 +157,6 @@ function formatCheckStatus(check: { status: string; reason: string } | undefined return `${label.charAt(0).toUpperCase()}${label.slice(1)} — ${check.reason}`; } -function CopilotIcon() { - return ( - - ); -} - -function OpenAIIcon() { - return ( - - ); -} - -function ClaudeIcon() { - return ( - - ); -} - -const PROVIDER_ICONS: Record JSX.Element> = { - copilot: CopilotIcon, - codex: OpenAIIcon, - claude: ClaudeIcon, -}; - -function ProviderAvatar({ provider }: { provider: Provider }) { - const Icon = PROVIDER_ICONS[provider]; - return ( - - ); -} - function StatusBadge({ available }: { available: boolean }) { return available ? Available @@ -390,74 +347,6 @@ function AdminPercentInput({ value, onChange, testId }: { ); } -function ProviderActivitySection({ - activity, - error, - onRefresh, -}: { - activity: AgentProviderWorkActivity[]; - error?: string | null; - onRefresh?: () => void; -}) { - return ( -
-
-
-
- Dreams provider activity - queue + history -
-
- Active and recent Dream jobs are attributed to the provider, model, and timeout selected for each run. -
-
- {onRefresh && ( - - )} -
- {error ? ( -
⚠ {error}
- ) : activity.length === 0 ? ( -
- No active or recent Dreams work. -
- ) : ( -
- {activity.map(item => { - const providerLabel = PROVIDER_LABELS[item.provider] ?? item.provider; - const trigger = item.trigger === 'idle' ? 'Idle' : item.trigger === 'manual' ? 'Manual' : 'Dreams'; - const status = item.status ? item.status.replace(/-/g, ' ') : 'unknown'; - return ( -
-
- -
-
- {item.label} - {providerLabel} -
-
- {trigger} · {status} · {item.model ?? 'provider default'} · {formatProviderActivityTimeout(item.timeoutMs)} -
- {item.error &&
✕ {item.error}
} -
-
-
- ); - })} -
- )} -
- ); -} - type AIProviderSubTab = 'routing' | 'models'; const AI_PROVIDER_SUBTABS: { id: AIProviderSubTab; label: string; icon: string }[] = [ { id: 'routing', label: 'Provider routing', icon: '◇' }, @@ -473,7 +362,6 @@ export function AIProviderPage(props: AIProviderPageProps) { providerAvailability, sdkInstallStatuses, sdkInstallErrors, onInstallSdk, dirty, saving, onSave, onCancel, quotaData, quotaLoading, quotaError, onRefreshQuota, - providerActivity = [], providerActivityError, onRefreshProviderActivity, } = props; const normalizedAutoRouting = normalizeAutoProviderRoutingConfig(autoRoutingConfig); @@ -898,11 +786,6 @@ export function AIProviderPage(props: AIProviderPageProps) { - {/* Unavailability warnings */} {defaultProvider === 'codex' && providerAvailability['codex'] && !providerAvailability['codex'].available && (
diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index d80c67d4c..42f217d96 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -7,7 +7,7 @@ */ import type { AdminAutoProviderRoutingConfig, AdminDefaultProvider, ProviderInstallStatus } from '@plusplusoneplusplus/coc-client'; -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { getSpaCocClient, getSpaCocClientErrorMessage } from '../api/cocClient'; import { useApp } from '../contexts/AppContext'; import { SHOW_WELCOME_TUTORIAL } from '../featureFlags'; @@ -26,9 +26,11 @@ import { DbBrowserSection } from './DbBrowserSection'; import { PromptsPanel } from './PromptsPanel'; import { ProviderTokensSection } from './ProviderTokensSection'; import { SettingsCard } from './SettingsCard'; +import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle, SourceBadge } from './adminControls'; import { applyRuntimeConfigPatch, isContainerMode, isServersEnabled } from '../utils/config'; import { AIProviderPage, normalizeAutoProviderRoutingConfig, type NormalizedAutoProviderRoutingConfig } from './AIProviderPage'; +import type { DreamsConfigForm } from '../features/dreams/DreamsView'; import { ADMIN_SETTING_DEFINITIONS, FEATURE_CARD_GROUPS, @@ -50,6 +52,7 @@ const UsageStatsView = lazy(() => import('../features/stats/UsageStatsView').the const ServersView = lazy(() => import('../features/servers/ServersView').then(m => ({ default: m.ServersView }))); const MemoryV2Panel = lazy(() => import('../features/memory/MemoryV2Panel').then(m => ({ default: m.MemoryV2Panel }))); const ProviderModelsSection = lazy(() => import('../features/models/ProviderModelsSection').then(m => ({ default: m.ProviderModelsSection }))); +const DreamsView = lazy(() => import('../features/dreams/DreamsView').then(m => ({ default: m.DreamsView }))); function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; @@ -198,6 +201,7 @@ interface ToolNavItem { export const ALL_TOOL_NAV_ITEMS: ToolNavItem[] = [ { id: 'memory-toggle', tab: 'memory', label: 'Memory', icon: '◈', description: 'View and manage global and workspace memory facts, reviews, and episodes.' }, { id: 'skills-toggle', tab: 'skills', label: 'Skills', icon: '⚡', description: 'Install, configure, and inspect agent skills surfaced to the assistant.' }, + { id: 'dreams-admin-toggle', tab: 'dreams-admin', label: 'Dreams', icon: '☾', description: 'Enable Dreams, tune the idle-reflection schedule and defaults, and watch provider activity.' }, { id: 'logs-toggle', tab: 'logs', label: 'Logs', icon: '📋', description: 'Live and historical server logs streamed via SSE.' }, { id: 'stats-toggle', tab: 'stats', label: 'Usage & Costs', icon: '📊', description: 'Aggregated usage statistics for chats, tokens, costs, and processes.' }, { id: 'servers-toggle', tab: 'servers', label: 'Servers', icon: '🖥', description: 'Browse running CoC server instances and their health.' }, @@ -205,6 +209,7 @@ export const ALL_TOOL_NAV_ITEMS: ToolNavItem[] = [ export const TOOL_TAB_GROUP_LABELS: Partial> = { memory: 'Knowledge', skills: 'Knowledge', + 'dreams-admin': 'Knowledge', servers: 'Configure', stats: 'Operations', logs: 'Operations', @@ -408,6 +413,12 @@ export function AdminPanel() { const [dreamProviderActivity, setDreamProviderActivity] = useState([]); const [dreamProviderActivityError, setDreamProviderActivityError] = useState(null); + // Dreams tab config (global). Owned here so it loads with the rest of the + // admin config; edited + saved from the Dreams tab (Knowledge nav group). + const [dreamsForm, setDreamsForm] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }); + const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }); + const [dreamsSaving, setDreamsSaving] = useState(false); + // Snapshots for per-card dirty tracking (set when config/prefs loads) const [aiExecSnapshot, setAiExecSnapshot] = useState({ model: '', parallel: '1', timeout: '', output: 'table' }); const [defaultProviderSnapshot, setDefaultProviderSnapshot] = useState({ @@ -510,6 +521,17 @@ export function AdminPanel() { const loadedFeatures = readFeatureValues(resolved); setFeatureValues(loadedFeatures); setFeaturesSnapshot(loadedFeatures); + const loadedDreams: DreamsConfigForm = { + enabled: resolved.dreams?.enabled ?? false, + provider: resolved.dreams?.provider === 'codex' || resolved.dreams?.provider === 'claude' || resolved.dreams?.provider === 'copilot' + ? resolved.dreams.provider + : '', + model: resolved.dreams?.model ?? '', + timeoutMinutes: String(Math.round((resolved.dreams?.timeoutMs ?? 3_600_000) / 60_000)), + intervalMinutes: String(Math.round((resolved.dreams?.idleCheckIntervalMs ?? 5 * 60 * 1000) / 60_000)), + }; + setDreamsForm(loadedDreams); + setDreamsSnapshot(loadedDreams); const aapre = resolved.features?.autoAgentProviderRouting ?? false; setAutoAgentProviderRoutingEnabled(aapre); const cxe = resolved.codex?.enabled ?? false; @@ -621,6 +643,12 @@ export function AdminPanel() { const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]); + const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled || + dreamsForm.provider !== dreamsSnapshot.provider || + dreamsForm.model !== dreamsSnapshot.model || + dreamsForm.timeoutMinutes !== dreamsSnapshot.timeoutMinutes || + dreamsForm.intervalMinutes !== dreamsSnapshot.intervalMinutes; + // ── AI & Execution card ── const handleSaveAiExec = useCallback(async () => { const errors: string[] = []; @@ -757,8 +785,16 @@ export function AdminPanel() { return; } void handleRefreshQuota(); + }, [activeTab, handleRefreshQuota]); + + // Dreams provider activity now lives in the admin Dreams tab; auto-load it + // whenever that tab becomes the active dashboard route. + useEffect(() => { + if (activeDashboardTab !== 'dreams-admin' || isContainerMode()) { + return; + } void refreshDreamProviderActivity(); - }, [activeTab, handleRefreshQuota, refreshDreamProviderActivity]); + }, [activeDashboardTab, refreshDreamProviderActivity]); // ── Chat Experience card ── const handleSaveChat = useCallback(async () => { @@ -875,6 +911,42 @@ export function AdminPanel() { setFeatureValues({ ...featuresSnapshot }); }, [featuresSnapshot]); + // ── Dreams tab config card ── + const handleSaveDreams = useCallback(async () => { + const intervalMinutes = Number(dreamsForm.intervalMinutes); + if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { + addToast('Dreams idle check interval must be a positive whole number of minutes', 'error'); + return; + } + const timeoutMinutes = Number(dreamsForm.timeoutMinutes); + if (!Number.isInteger(timeoutMinutes) || timeoutMinutes < 1) { + addToast('Dreams run timeout must be a positive whole number of minutes', 'error'); + return; + } + setDreamsSaving(true); + try { + await getSpaCocClient().admin.updateConfig({ + 'dreams.enabled': dreamsForm.enabled, + 'dreams.provider': dreamsForm.provider || null, + 'dreams.model': dreamsForm.model.trim() || null, + 'dreams.idleCheckIntervalMs': intervalMinutes * 60_000, + 'dreams.timeoutMs': timeoutMinutes * 60_000, + }); + addToast('Settings saved', 'success'); + invalidateDisplaySettings(); + applyRuntimeConfigPatch({ dreamsEnabled: dreamsForm.enabled }); + setDreamsSnapshot({ ...dreamsForm }); + } catch (err: unknown) { + addToast(getSpaCocClientErrorMessage(err, 'Save failed'), 'error'); + } finally { + setDreamsSaving(false); + } + }, [dreamsForm, addToast]); + + const handleCancelDreams = useCallback(() => { + setDreamsForm({ ...dreamsSnapshot }); + }, [dreamsSnapshot]); + const handleSaveServerName = useCallback(async () => { const trimmed = serverName.trim(); try { @@ -1113,6 +1185,7 @@ export function AdminPanel() { items: [ toolNavItem('memory'), toolNavItem('skills'), + toolNavItem('dreams-admin'), ], }, { @@ -1284,6 +1357,17 @@ export function AdminPanel() { }} />} {activeToolItem.tab === 'skills' && } + {activeToolItem.tab === 'dreams-admin' && setDreamsForm(prev => ({ ...prev, ...patch }))} + configDirty={dreamsDirty} + configSaving={dreamsSaving} + onSaveConfig={handleSaveDreams} + onCancelConfig={handleCancelDreams} + providerActivity={dreamProviderActivity} + providerActivityError={dreamProviderActivityError} + onRefreshProviderActivity={refreshDreamProviderActivity} + />} {activeToolItem.tab === 'logs' && } {activeToolItem.tab === 'stats' && } {activeToolItem.tab === 'servers' && } @@ -1983,9 +2067,6 @@ export function AdminPanel() { quotaLoading={quotaLoading} quotaError={quotaError} onRefreshQuota={handleRefreshQuota} - providerActivity={dreamProviderActivity} - providerActivityError={dreamProviderActivityError} - onRefreshProviderActivity={refreshDreamProviderActivity} sources={sources} /> )} @@ -2015,105 +2096,3 @@ function resolveNestedValue(obj: Record, key: string): unknown } return current; } - -function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) { - const s = source || 'default'; - const variant = - s === 'cli' ? 'ar-src-cli' : - s === 'env' ? 'ar-src-env' : - s === 'file' || s === 'config' ? 'ar-src-config' : - ''; - const modifiedClass = isDefault === false ? ' ar-src-modified' : ''; - const label = isDefault === false ? 'modified' : s; - const title = isDefault === false - ? `Value differs from the built-in default (source: ${s})` - : `Source: ${s}`; - return {label}; -} - -/* ── Row primitives that produce the new visual without changing behaviour ── */ - -interface AdminRowProps { - name: ReactNode; - hint?: ReactNode; - children: ReactNode; - 'data-testid'?: string; -} -function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) { - return ( -
-
-
{name}
- {hint &&
{hint}
} -
-
{children}
-
- ); -} - -interface AdminToggleProps { - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - 'data-testid'?: string; - 'aria-label'?: string; -} -function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) { - return ( - - ); -} - -interface AdminSegOption { - value: T; - label: string; - testId?: string; -} -interface AdminSegProps { - value: T; - onChange: (value: T) => void; - options: ReadonlyArray>; - 'aria-label'?: string; -} -function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) { - return ( -
- {options.map(opt => ( - - ))} -
- ); -} - -interface AdminInputSuffixProps { - suffix: string; - children: ReactNode; -} -function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) { - return ( - - {children} - {suffix} - - ); -} diff --git a/packages/coc/src/server/spa/client/react/admin/admin-redesign.css b/packages/coc/src/server/spa/client/react/admin/admin-redesign.css index 348b4aabc..7f7f2b18a 100644 --- a/packages/coc/src/server/spa/client/react/admin/admin-redesign.css +++ b/packages/coc/src/server/spa/client/react/admin/admin-redesign.css @@ -344,6 +344,16 @@ flex: 1; min-height: 0; } +/* Dreams admin reuses the AI-provider `.aip-page` shell but, unlike that page + (which scrolls via `.ar-main`), it renders inside `.ar-tool-embed` where + `.ar-main--embed` suppresses the outer scroller. The Skills/Memory embeds + own an inner scroll region; the `.aip-page` grid has none, so give the + embedded page its own scroll region plus page padding (mirroring `.ar-page`) + so long content — the provider-activity queue — stays reachable. */ +.admin-redesign .ar-tool-embed > .aip-page { + overflow-y: auto; + padding: 28px 32px 120px; +} .admin-redesign .ar-topbar { display: flex; align-items: center; @@ -1151,6 +1161,9 @@ .admin-redesign .ar-page { padding: 20px 16px 80px; } + .admin-redesign .ar-tool-embed > .aip-page { + padding: 20px 16px 80px; + } .admin-redesign .ar-row { flex-direction: column; align-items: stretch; diff --git a/packages/coc/src/server/spa/client/react/admin/adminControls.tsx b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx new file mode 100644 index 000000000..3741bae61 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx @@ -0,0 +1,110 @@ +/** + * Admin row primitives — shared presentational controls for settings surfaces. + * + * Extracted from `AdminPanel.tsx` so other admin-shell views (e.g. the Dreams + * tab) can render the same Linear-inspired rows/toggles/segments without + * duplicating markup. Visuals come from `admin-redesign.css`; these components + * are pure and carry no behaviour of their own. + */ + +import type { ReactNode } from 'react'; + +export function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) { + const s = source || 'default'; + const variant = + s === 'cli' ? 'ar-src-cli' : + s === 'env' ? 'ar-src-env' : + s === 'file' || s === 'config' ? 'ar-src-config' : + ''; + const modifiedClass = isDefault === false ? ' ar-src-modified' : ''; + const label = isDefault === false ? 'modified' : s; + const title = isDefault === false + ? `Value differs from the built-in default (source: ${s})` + : `Source: ${s}`; + return {label}; +} + +export interface AdminRowProps { + name: ReactNode; + hint?: ReactNode; + children: ReactNode; + 'data-testid'?: string; +} +export function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) { + return ( +
+
+
{name}
+ {hint &&
{hint}
} +
+
{children}
+
+ ); +} + +export interface AdminToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + 'data-testid'?: string; + 'aria-label'?: string; +} +export function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) { + return ( + + ); +} + +interface AdminSegOption { + value: T; + label: string; + testId?: string; +} +export interface AdminSegProps { + value: T; + onChange: (value: T) => void; + options: ReadonlyArray>; + 'aria-label'?: string; +} +export function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) { + return ( +
+ {options.map(opt => ( + + ))} +
+ ); +} + +interface AdminInputSuffixProps { + suffix: string; + children: ReactNode; +} +export function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) { + return ( + + {children} + {suffix} + + ); +} diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx new file mode 100644 index 000000000..7fc83a093 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx @@ -0,0 +1,168 @@ +// Admin "Dreams" tab — the single home for Dreams configuration and activity. +// +// Lives in the dashboard's "Knowledge" nav group (alongside Memory and +// Skills) and is embedded inside the admin shell. This tab owns: +// • the global `dreams.enabled` toggle, +// • the running-interval (`dreams.idleCheckIntervalMs`, edited in minutes), +// • default provider / model / timeout for idle-triggered Dream runs, and +// • the "Dreams provider activity" queue + history. +// +// The per-workspace dream-cards review panel (`DreamsPanel`) is a separate, +// untouched surface under each repo's detail view. +// +// Config (form + dirty/save) and the provider-activity feed are owned by +// `AdminPanel` and passed in as props, so they load with the rest of the admin +// config and reuse the shared toast + runtime-flag plumbing. + +import { SettingsCard } from '../../admin/SettingsCard'; +import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle } from '../../admin/adminControls'; +import type { AgentProviderWorkActivity } from '../../shared/providerActivity'; +import { ProviderActivitySection } from './ProviderActivitySection'; + +/** Editable global Dreams settings surfaced on the Dreams tab. */ +export interface DreamsConfigForm { + /** Global `dreams.enabled` flag — gates idle-time reflection everywhere. */ + enabled: boolean; + /** Default provider for idle-triggered Dream runs; blank uses the global default provider. */ + provider: '' | 'copilot' | 'codex' | 'claude'; + /** Optional default model for idle-triggered Dream runs. */ + model: string; + /** Default Dream AI request timeout, edited in minutes and persisted as milliseconds. */ + timeoutMinutes: string; + /** Automatic idle-check cadence, edited in minutes and persisted as milliseconds. */ + intervalMinutes: string; +} + +export interface DreamsViewProps { + config?: DreamsConfigForm; + onConfigChange?: (patch: Partial) => void; + configDirty?: boolean; + configSaving?: boolean; + onSaveConfig?: () => void; + onCancelConfig?: () => void; + providerActivity?: AgentProviderWorkActivity[]; + providerActivityError?: string | null; + onRefreshProviderActivity?: () => void; +} + +const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }; + +export function DreamsView({ + config = DEFAULT_CONFIG, + onConfigChange, + configDirty, + configSaving, + onSaveConfig, + onCancelConfig, + providerActivity = [], + providerActivityError, + onRefreshProviderActivity, +}: DreamsViewProps = {}) { + return ( +
+
+
+

Dreams

+

+ Enable Dreams, tune the idle-reflection schedule and defaults, and watch the + provider activity queue — all from one place. +

+
+ Restart-aware +
+ + + Enable Dreams Experimental} + hint="Enables workspace opt-in review cards from idle-time reflection. Disabled by default; workspaces must also opt in individually." + > + onConfigChange?.({ enabled })} + data-testid="toggle-dreams-enabled" + /> + + Idle check interval Restart} + hint="How often the server checks for idle workspaces that are ready for automatic Dream runs. Saved immediately; restart the server for the scheduler cadence to use the new value." + > + + onConfigChange?.({ intervalMinutes: event.target.value })} + data-testid="dreams-idle-check-interval-minutes" + /> + + + + onConfigChange?.({ provider })} + aria-label="Dreams default provider" + options={[ + { value: '', label: 'Global', testId: 'dreams-provider-global' }, + { value: 'copilot', label: 'Copilot', testId: 'dreams-provider-copilot' }, + { value: 'codex', label: 'Codex', testId: 'dreams-provider-codex' }, + { value: 'claude', label: 'Claude', testId: 'dreams-provider-claude' }, + ]} + /> + + + onConfigChange?.({ model: event.target.value })} + placeholder="Provider default" + data-testid="dreams-default-model" + /> + + + + onConfigChange?.({ timeoutMinutes: event.target.value })} + data-testid="dreams-timeout-minutes" + /> + + + + + +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx new file mode 100644 index 000000000..5c4f4801b --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx @@ -0,0 +1,76 @@ +/** + * ProviderActivitySection — the "Dreams provider activity" queue + history card. + * + * Renders active and recent Dream runs attributed to the provider, model, and + * timeout selected for each run, with an optional Refresh control. Lives in the + * admin **Dreams** tab (`DreamsView`); the state, fetch, and refresh handler are + * owned by `AdminPanel` and passed in as props. + */ +import { formatProviderActivityTimeout, type AgentProviderWorkActivity } from '../../shared/providerActivity'; +import { PROVIDER_LABELS, ProviderAvatar } from '../../shared/providerVisuals'; + +export interface ProviderActivitySectionProps { + activity: AgentProviderWorkActivity[]; + error?: string | null; + onRefresh?: () => void; +} + +export function ProviderActivitySection({ activity, error, onRefresh }: ProviderActivitySectionProps) { + return ( +
+
+
+
+ Dreams provider activity + queue + history +
+
+ Active and recent Dream jobs are attributed to the provider, model, and timeout selected for each run. +
+
+ {onRefresh && ( + + )} +
+ {error ? ( +
⚠ {error}
+ ) : activity.length === 0 ? ( +
+ No active or recent Dreams work. +
+ ) : ( +
+ {activity.map(item => { + const providerLabel = PROVIDER_LABELS[item.provider] ?? item.provider; + const trigger = item.trigger === 'idle' ? 'Idle' : item.trigger === 'manual' ? 'Manual' : 'Dreams'; + const status = item.status ? item.status.replace(/-/g, ' ') : 'unknown'; + return ( +
+
+ +
+
+ {item.label} + {providerLabel} +
+
+ {trigger} · {status} · {item.model ?? 'provider default'} · {formatProviderActivityTimeout(item.timeoutMs)} +
+ {item.error &&
✕ {item.error}
} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx new file mode 100644 index 000000000..d13cae007 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -0,0 +1,570 @@ +/** + * NativeCliSessionsPanel — read-only dashboard view for native Copilot, + * Codex, and Claude Code CLI sessions scoped to the active workspace. + * + * Native sessions are external data read from the server user's + * external CLI stores. This surface intentionally renders no CoC + * chat actions (no follow-up, archive, pin, delete, resume, retry, or turn + * actions) and labels every session as an external read-only record. All + * stored text renders as plain pre-wrapped text so stored HTML/scripts never + * execute. + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { + ListNativeCliSessionsResponse, + NativeCliSessionDetail, + NativeCliSessionListItem, + NativeCliSessionProviderId, + NativeCliSessionsUnavailableReason, +} from '@plusplusoneplusplus/coc-client'; +import { getSpaCocClient } from '../../api/cocClient'; +import { Button, Spinner, cn } from '../../ui'; +import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; +import { buildNativeCliSessionHash, parseNativeCliSessionDeepLink } from '../../layout/Router'; +import { ConversationTurnBubble } from '../chat/conversation/ConversationTurnBubble'; +import { ProviderBadge } from '../chat/ProviderBadge'; +import { toClientConversationTurns } from './nativeConversationTurns'; + +const PROVIDERS: NativeCliSessionProviderId[] = ['copilot', 'codex', 'claude']; + +const PROVIDER_META: Record = { + codex: { + label: 'Codex', + externalLabel: 'Native Codex CLI session', + store: '~/.codex/sessions', + }, + claude: { + label: 'Claude', + externalLabel: 'Native Claude Code session', + store: '~/.claude/projects', + }, + copilot: { + label: 'Copilot', + externalLabel: 'Native Copilot CLI session', + store: '~/.copilot/session-store.db', + }, +}; + +function readOnlyTooltip(provider: NativeCliSessionProviderId, storePath?: string | null): string { + const path = storePath || PROVIDER_META[provider].store; + return `This data is read from the local ${PROVIDER_META[provider].label} CLI session store (${path}) and cannot be modified from CoC.`; +} + +interface NativeCliSessionsPanelProps { + workspaceId: string; +} + +interface ListFilters { + q: string; + sessionId: string; + branch: string; + from: string; + to: string; +} + +const EMPTY_FILTERS: ListFilters = { q: '', sessionId: '', branch: '', from: '', to: '' }; + +function formatTimestamp(value: string | null): string { + if (!value) return '—'; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(); +} + +function unavailableCopy(provider: NativeCliSessionProviderId, reason: NativeCliSessionsUnavailableReason | undefined): { title: string; body: string } { + if (reason === 'store-missing') { + return { + title: 'Native session store not found', + body: `No native ${PROVIDER_META[provider].label} CLI session store exists at ${PROVIDER_META[provider].store} on the CoC server. Run the CLI at least once to create it.`, + }; + } + return { + title: 'Native session store unavailable', + body: `The native ${PROVIDER_META[provider].label} CLI session store could not be read. It may be corrupt or use an unsupported schema.`, + }; +} + +function ReadOnlyBadge({ provider, storePath }: { provider: NativeCliSessionProviderId; storePath?: string | null }) { + return ( + + Read-only + + ); +} + +function ExternalLabel({ provider, storePath }: { provider: NativeCliSessionProviderId; storePath?: string | null }) { + return ( + + {PROVIDER_META[provider].externalLabel} + + ); +} + +export function NativeCliSessionsPanel({ workspaceId }: NativeCliSessionsPanelProps) { + const enabled = useNativeCliSessionsEnabled(); + const [provider, setProvider] = useState('copilot'); + + const [filterDraft, setFilterDraft] = useState(EMPTY_FILTERS); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [offset, setOffset] = useState(0); + const [listLoading, setListLoading] = useState(false); + const [listError, setListError] = useState(null); + const [listResponse, setListResponse] = useState(null); + + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [detail, setDetail] = useState(null); + + const loadList = useCallback(async () => { + if (!enabled) return; + setListLoading(true); + setListError(null); + try { + const response = await getSpaCocClient().nativeCliSessions.list(workspaceId, { + provider, + q: filters.q || undefined, + sessionId: filters.sessionId || undefined, + branch: filters.branch || undefined, + from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined, + to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined, + offset, + }); + setListResponse(response); + } catch (error) { + setListError(error instanceof Error ? error.message : String(error)); + setListResponse(null); + } finally { + setListLoading(false); + } + }, [enabled, workspaceId, provider, filters, offset]); + + useEffect(() => { void loadList(); }, [loadList]); + + // Reset paging/filters when the workspace changes. Selection is driven by + // the URL hash (see the deep-link sync effect below). + useEffect(() => { + setDetail(null); + setOffset(0); + setFilterDraft(EMPTY_FILTERS); + setFilters(EMPTY_FILTERS); + }, [workspaceId, provider]); + + // Deep-link: keep the selected session in sync with the URL hash + // (`#repos/{wsId}/copilot-sessions/{sessionId}`) so selections survive + // refresh/back/forward and can be shared as links. + useEffect(() => { + const apply = () => { + const parsed = parseNativeCliSessionDeepLink(window.location.hash); + if (parsed && parsed.workspaceId === workspaceId) { + setProvider(prev => (prev === parsed.provider ? prev : parsed.provider)); + setSelectedSessionId(prev => (prev === parsed.sessionId ? prev : parsed.sessionId)); + return; + } + setSelectedSessionId(prev => (prev === null ? prev : null)); + }; + apply(); + window.addEventListener('hashchange', apply); + return () => window.removeEventListener('hashchange', apply); + }, [workspaceId]); + + // Selecting (or clearing) a session writes the deep-link hash; the + // hashchange listener above then reconciles `selectedSessionId`. + const selectSession = useCallback((sessionId: string | null) => { + setSelectedSessionId(sessionId); + const next = buildNativeCliSessionHash(workspaceId, provider, sessionId); + if (window.location.hash !== next) { + window.location.hash = next; + } + }, [workspaceId, provider]); + + const switchProvider = useCallback((nextProvider: NativeCliSessionProviderId) => { + setProvider(nextProvider); + setSelectedSessionId(null); + setOffset(0); + const next = buildNativeCliSessionHash(workspaceId, nextProvider, null); + if (window.location.hash !== next) { + window.location.hash = next; + } + }, [workspaceId]); + + useEffect(() => { + if (!enabled || !selectedSessionId) { + setDetail(null); + return; + } + let cancelled = false; + setDetailLoading(true); + setDetailError(null); + getSpaCocClient().nativeCliSessions.get(workspaceId, selectedSessionId, provider) + .then(response => { + if (cancelled) return; + if (!response.enabled || response.available === false || !response.session) { + setDetail(null); + setDetailError('This native session is unavailable.'); + return; + } + setDetail(response.session); + }) + .catch(error => { + if (cancelled) return; + setDetail(null); + const message = error instanceof Error ? error.message : String(error); + setDetailError(/not found/i.test(message) + ? 'Session not found in this workspace.' + : message); + }) + .finally(() => { if (!cancelled) setDetailLoading(false); }); + return () => { cancelled = true; }; + }, [enabled, workspaceId, provider, selectedSessionId]); + + if (!enabled) { + return ( +
+
+

CLI Sessions is disabled

+

+ Enable the features.nativeCliSessions flag in Admin to browse native + Codex, Claude Code, and Copilot CLI sessions for this workspace in read-only mode. +

+
+
+ ); + } + + const unavailable = listResponse && (listResponse.enabled === false || listResponse.available === false); + const items = listResponse?.items ?? []; + const total = listResponse?.total ?? 0; + const limit = listResponse?.limit ?? 50; + const hasFilters = Boolean(filters.q || filters.sessionId || filters.branch || filters.from || filters.to); + + const applyFilters = (e: React.FormEvent) => { + e.preventDefault(); + setOffset(0); + setFilters(filterDraft); + }; + + const listPane = ( +
+
+
+

CLI Sessions

+ + + + + {total.toLocaleString()} session{total === 1 ? '' : 's'} + +
+
+ {PROVIDERS.map(candidate => ( + + ))} +
+
+
+ + setFilterDraft(prev => ({ ...prev, q: e.target.value }))} + placeholder="Search transcript text…" + className="h-7 w-full rounded-md border border-[#d0d7de] pl-7 pr-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + data-testid="native-sessions-search-input" + /> +
+ setFilterDraft(prev => ({ ...prev, sessionId: e.target.value }))} + placeholder="Session ID" + className="h-7 w-28 rounded-md border border-[#d0d7de] px-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + data-testid="native-sessions-session-id-input" + /> + setFilterDraft(prev => ({ ...prev, branch: e.target.value }))} + placeholder="Branch" + className="h-7 w-24 rounded-md border border-[#d0d7de] px-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + data-testid="native-sessions-branch-input" + /> + + + +
+ {listResponse?.available === true && listResponse.searchIndexAvailable === false && filters.q && ( +

+ This provider has no native search index. CoC is using on-demand substring search for the current filter. +

+ )} + {listResponse?.available === true && (listResponse.deduplicatedCount ?? 0) > 0 && ( +

+ {listResponse.deduplicatedCount} session{listResponse.deduplicatedCount === 1 ? '' : 's'} hidden — already tracked in CoC Activity. +

+ )} + {listResponse?.available === true && (listResponse.backgroundJobCount ?? 0) > 0 && ( +

+ {listResponse.backgroundJobCount} background job{listResponse.backgroundJobCount === 1 ? '' : 's'} hidden (e.g. title generation). +

+ )} +
+
+ {listLoading && ( +
+ )} + {!listLoading && listError && ( +
+ Failed to load native sessions: {listError} +
+
+ )} + {!listLoading && !listError && unavailable && ( +
+

{unavailableCopy(provider, listResponse?.reason).title}

+

{unavailableCopy(provider, listResponse?.reason).body}

+
+ )} + {!listLoading && !listError && !unavailable && items.length === 0 && ( +
+ {hasFilters + ? `No native ${PROVIDER_META[provider].label} CLI sessions match the current filters.` + : `No native ${PROVIDER_META[provider].label} CLI sessions were found for this workspace.`} +
+ )} + {!listLoading && !listError && !unavailable && items.length > 0 && ( + + + {items.map(item => ( + selectSession(item.id)} + /> + ))} + +
+ )} +
+ {!unavailable && total > limit && ( +
+ + {offset + 1}–{Math.min(offset + limit, total)} of {total} + +
+ )} +
+ ); + + const detailPane = ( +
+ {!selectedSessionId && ( +
+
+ +
+

Select a native session to view its summary and turns.

+
+ )} + {selectedSessionId && detailLoading && ( +
+ )} + {selectedSessionId && !detailLoading && detailError && ( +
+ {detailError} +
+ )} + {selectedSessionId && !detailLoading && !detailError && detail && ( + selectSession(null)} /> + )} +
+ ); + + // Wide screens render the searchable table beside the detail; narrow screens + // stack panes and show one at a time based on selection. + return ( +
+
+
+ {listPane} +
+
+ {detailPane} +
+
+
+ ); +} + +/** @deprecated Use NativeCliSessionsPanel. */ +export const NativeCopilotSessionsPanel = NativeCliSessionsPanel; + +function SessionRow({ item, selected, onSelect }: { + item: NativeCliSessionListItem; + selected: boolean; + onSelect: () => void; +}) { + const location = item.repository || item.cwd || ''; + return ( + + +
+
+
+ {item.id.slice(0, 8)} + {formatTimestamp(item.updatedAt)} +
+
{item.summaryPreview || No summary stored}
+ {item.matchSnippets.length > 0 && ( +
+ {item.matchSnippets.map((snippet, index) => ( +
{snippet}
+ ))} +
+ )} + {location && ( +
+ + {location} +
+ )} +
+
+ + {item.turnCount} turn{item.turnCount === 1 ? '' : 's'} + + + + {item.branch || 'Unknown branch'} + +
+
+ + + ); +} + +function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCliSessionDetail; workspaceId: string; onBack: () => void }) { + // Reconstructed transcript (rich `events.jsonl` parse, else flat DB + // fallback) mapped into the SPA chat shape so we can reuse the existing + // read-only chat bubble — no input box, streaming, or resume actions. + const conversation = toClientConversationTurns(detail.conversation); + return ( +
+
+
+ + + + +
+

{detail.id}

+

+ {readOnlyTooltip(detail.provider, detail.storePath)} +

+
+
Repository
{detail.repository || '—'}
+
Branch
{detail.branch || 'Unknown branch'}
+
Working dir
{detail.cwd || '—'}
+
Host
{detail.hostType || '—'}
+
Created
{formatTimestamp(detail.createdAt)}
+
Updated
{formatTimestamp(detail.updatedAt)}
+
+ {detail.summary && ( +
+

Stored summary

+
{detail.summary}
+
+ )} +
+ +
+

Conversation ({conversation.length})

+

+ Read-only reconstruction of the native {PROVIDER_META[detail.provider].label} CLI transcript. No follow-up, streaming, or resume. +

+ {conversation.length === 0 ? ( +

This native session has no stored turns.

+ ) : ( +
+ {conversation.map((turn, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts new file mode 100644 index 000000000..76d99dd36 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/nativeConversationTurns.ts @@ -0,0 +1,101 @@ +/** + * Map the backend-reconstructed native Copilot conversation + * ({@link ReconstructedConversationTurn}, from `session-state//events.jsonl` + * or the `session-store.db` fallback) into the SPA chat shape + * ({@link ClientConversationTurn}) so the read-only detail view can reuse the + * existing `ConversationArea` / `ConversationTurnBubble` components without a + * fork. + * + * The two shapes are deliberately near-identical; the only structural gap is + * `thinking`: `ClientConversationTurn` has no reasoning field, so the model's + * readable reasoning is folded into the assistant turn's content stream as a + * markdown blockquote at map time (see {@link thinkingToMarkdown}). + */ + +import type { + ReconstructedConversationTurn, + ReconstructedTimelineItem, + ReconstructedToolCall, +} from '@plusplusoneplusplus/coc-client'; +import type { + ClientConversationTurn, + ClientTimelineItem, + ClientToolCall, +} from '../../types/dashboard'; + +/** + * Render a turn's readable reasoning as a markdown blockquote so it folds into + * the assistant bubble's content stream (the chat components have no dedicated + * reasoning slot). A trailing blank line keeps the blockquote a separate + * markdown block from the assistant text that follows it — timeline content + * items are concatenated without separators before markdown parsing. + */ +export function thinkingToMarkdown(thinking: string): string { + const quoted = thinking + .split('\n') + .map(line => (line.length > 0 ? `> ${line}` : '>')) + .join('\n'); + return `> 🧠 **Reasoning**\n>\n${quoted}\n\n`; +} + +function mapToolCall(toolCall: ReconstructedToolCall): ClientToolCall { + return { + id: toolCall.id, + toolName: toolCall.toolName, + args: toolCall.args, + status: toolCall.status, + ...(toolCall.result !== undefined ? { result: toolCall.result } : {}), + ...(toolCall.error !== undefined ? { error: toolCall.error } : {}), + ...(toolCall.startTime !== undefined ? { startTime: toolCall.startTime } : {}), + ...(toolCall.endTime !== undefined ? { endTime: toolCall.endTime } : {}), + }; +} + +function mapTimelineItem(item: ReconstructedTimelineItem): ClientTimelineItem { + return { + type: item.type, + timestamp: item.timestamp, + ...(item.content !== undefined ? { content: item.content } : {}), + ...(item.toolCall ? { toolCall: mapToolCall(item.toolCall) } : {}), + }; +} + +/** Map a single reconstructed turn into the SPA chat turn shape. */ +export function toClientConversationTurn(turn: ReconstructedConversationTurn): ClientConversationTurn { + const timeline: ClientTimelineItem[] = Array.isArray(turn.timeline) + ? turn.timeline.map(mapTimelineItem) + : []; + let content = turn.content ?? ''; + + // Fold assistant reasoning into the content stream. Prepending a content + // timeline item makes it render above the assistant text (assistant turns + // render from the timeline); also prepending to `content` covers the + // tool-only / empty-timeline fallback path and keeps copy/raw faithful. + if (turn.role === 'assistant' && turn.thinking) { + const reasoning = thinkingToMarkdown(turn.thinking); + timeline.unshift({ type: 'content', timestamp: turn.timestamp ?? '', content: reasoning }); + content = content ? `${reasoning}${content}` : reasoning; + } + + const mapped: ClientConversationTurn = { + role: turn.role, + content, + timeline, + }; + if (turn.timestamp !== undefined) mapped.timestamp = turn.timestamp; + if (turn.turnIndex !== undefined) mapped.turnIndex = turn.turnIndex; + if (turn.toolCalls && turn.toolCalls.length > 0) mapped.toolCalls = turn.toolCalls.map(mapToolCall); + if (turn.images && turn.images.length > 0) mapped.images = turn.images; + if (turn.skillNames && turn.skillNames.length > 0) mapped.skillNames = turn.skillNames; + if (turn.model) mapped.model = turn.model; + if (turn.isError) mapped.isError = true; + return mapped; +} + +/** Map a reconstructed conversation into SPA chat turns (empty when absent). */ +export function toClientConversationTurns( + conversation: ReconstructedConversationTurn[] | undefined | null, +): ClientConversationTurn[] { + if (!Array.isArray(conversation)) return []; + return conversation.map(toClientConversationTurn); +} diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx index a044b54e2..e730fdde0 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx @@ -24,6 +24,7 @@ import { WorkflowDetailView } from '../../processes/dag'; import { TerminalView } from '../terminal/TerminalView'; import { NotesView } from '../notes/NotesView'; import { DreamsPanel } from '../dreams/DreamsPanel'; +import { NativeCliSessionsPanel } from '../native-copilot-sessions/NativeCopilotSessionsPanel'; import { AddRepoDialog } from '../../repos/AddRepoDialog'; import { ErrorBoundary } from '../../ui/ErrorBoundary'; @@ -38,6 +39,7 @@ import { useNotesEnabled } from '../notes/hooks/useNotesEnabled'; import { useWorkflowsEnabled } from '../../hooks/feature-flags/useWorkflowsEnabled'; import { usePullRequestsEnabled } from '../../hooks/feature-flags/usePullRequestsEnabled'; import { useDreamsEnabled } from '../../hooks/feature-flags/useDreamsEnabled'; +import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; import { MobileTabBar } from '../../layout/MobileTabBar'; import { buildRepoSubTabSuffix } from '../../layout/Router'; import { SHOW_WIKI_TAB } from '../../layout/TopBar'; @@ -59,6 +61,7 @@ interface RepoDetailProps { export const SUB_TABS: { key: RepoSubTab; label: string; shortcut?: string }[] = [ { key: 'chats', label: 'Chats', shortcut: 'Alt+A' }, + { key: 'cli-sessions', label: 'CLI Sessions' }, { key: 'git', label: 'Git', shortcut: 'Alt+G' }, { key: 'terminal', label: 'Terminal' }, { key: 'work-items', label: 'Work Items', shortcut: 'Alt+I' }, @@ -84,7 +87,7 @@ export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB * Group identity is purely visual and does not affect functionality. */ const TAB_GROUP_INDEX: Record = { - 'chats': 1, 'activity': 1, 'git': 1, 'terminal': 1, + 'chats': 1, 'activity': 1, 'cli-sessions': 1, 'copilot-sessions': 1, 'git': 1, 'terminal': 1, 'work-items': 2, 'dreams': 2, 'pull-requests': 2, 'tasks': 2, 'explorer': 3, 'workflows': 3, 'schedules': 3, 'notes': 4, 'settings': 4, 'wiki': 4, @@ -134,6 +137,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const workflowsEnabled = useWorkflowsEnabled(); const pullRequestsEnabled = usePullRequestsEnabled(); const dreamsEnabled = useDreamsEnabled(); + const nativeCliSessionsEnabled = useNativeCliSessionsEnabled(); const sessionContextAttachmentsEnabled = isSessionContextAttachmentsEnabled(); const canRetrieveConversations = useConversationRetrievalCapability(ws.id, sessionContextAttachmentsEnabled); const [headerContextDropTarget, setHeaderContextDropTarget] = useState<'task' | 'ask' | null>(null); @@ -162,6 +166,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const prevWorkflowsEnabled = useRef(workflowsEnabled); const prevPullRequestsEnabled = useRef(pullRequestsEnabled); const prevDreamsEnabled = useRef(dreamsEnabled); + const prevNativeCliSessionsEnabled = useRef(nativeCliSessionsEnabled); const visibleSubTabs = useMemo(() => { let tabs = VISIBLE_SUB_TABS; @@ -171,6 +176,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { if (!workflowsEnabled) tabs = tabs.filter(t => t.key !== 'workflows'); if (!pullRequestsEnabled) tabs = tabs.filter(t => t.key !== 'pull-requests'); if (!dreamsEnabled) tabs = tabs.filter(t => t.key !== 'dreams'); + if (!nativeCliSessionsEnabled) tabs = tabs.filter(t => t.key !== 'cli-sessions' && t.key !== 'copilot-sessions'); // Layout mode filtering if (uiLayoutMode === 'classic') { // Classic: replace Chats with Activity, relabel Tasks as Plans @@ -184,7 +190,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { 'pull-requests': 'Full Requests', }; const devWorkflowOrder: RepoSubTab[] = [ - 'chats', 'work-items', 'dreams', 'schedules', 'explorer', + 'chats', 'cli-sessions', 'work-items', 'dreams', 'schedules', 'explorer', 'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings', ]; const tabMap = new Map(tabs.map(t => [t.key, t])); @@ -204,7 +210,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { tabs = ordered; } return tabs; - }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, uiLayoutMode]); + }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]); // Redirect away from git/pull-requests tab when switching to a non-git repo useEffect(() => { @@ -253,6 +259,14 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { prevDreamsEnabled.current = dreamsEnabled; }, [activeSubTab, dreamsEnabled, dispatch]); + // Redirect away from CLI sessions tab only when the feature transitions to disabled + useEffect(() => { + if ((activeSubTab === 'cli-sessions' || activeSubTab === 'copilot-sessions') && !nativeCliSessionsEnabled && prevNativeCliSessionsEnabled.current) { + dispatch({ type: 'SET_REPO_SUB_TAB', tab: 'chats' }); + } + prevNativeCliSessionsEnabled.current = nativeCliSessionsEnabled; + }, [activeSubTab, nativeCliSessionsEnabled, dispatch]); + // Redirect when switching layout modes useEffect(() => { if (uiLayoutMode === 'classic' && activeSubTab === 'chats') { @@ -777,7 +791,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { )}
) : ( -
+
{activeSubTab === 'settings' && } {activeSubTab === 'workflows' && } {/* @@ -834,6 +848,11 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { {wasVisited('dreams') && }
)} + {nativeCliSessionsEnabled && ( +
+ {(wasVisited('cli-sessions') || wasVisited('copilot-sessions')) && } +
+ )} {activeSubTab === 'workflow' && state.selectedWorkflowProcessId && }
)} diff --git a/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx b/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx index 4711ff008..d1b65273b 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-settings/LlmToolsPanel.tsx @@ -4,7 +4,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import type { LlmToolMeta, LlmToolsConfig } from '@plusplusoneplusplus/coc-client'; +import type { LlmToolMeta, LlmToolParam, LlmToolsConfig } from '@plusplusoneplusplus/coc-client'; import { getSpaCocClient } from '../../api/cocClient'; import { useGlobalToast } from '../../contexts/ToastContext'; @@ -12,6 +12,91 @@ interface LlmToolsPanelProps { workspaceId: string; } +/** + * Render one compact parameter token: `name: type*` for required params and + * `name?: type` for optional ones. The `type` is already a compact label such + * as a primitive, `{...}` (nested object) or `[...]` (array), so nested shapes + * stay collapsed. + */ +function formatParam(param: LlmToolParam): string { + return `${param.name}${param.required ? '' : '?'}: ${param.type}${param.required ? '*' : ''}`; +} + +/** + * Compact, inline-expandable parameter summary for a single tool. Lives outside + * the toggle