Skip to content

Commit 6b321c9

Browse files
aorumbayevclaude
andauthored
feat(tui): clickable file paths in chat tool calls (OSC 8) (#141)
* feat(tui): clickable file paths in chat tool calls (OSC 8) Adds _osc8.py with a cached capability probe (env-var driven, no tty round-trip) and file_link() helper that emits ESC]8 hyperlinks in capable terminals (iTerm2, WezTerm, kitty, ghostty, vscode terminal, Alacritty). ToolCallView._header_line() wraps the path= argument of file-operating tools so users can cmd-click to open the file directly in their editor. Falls back to plain text in unsupported terminals. Override with KAGAN_OSC8=1 / KAGAN_OSC8=0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: delete unimplemented unified-client proposal, document pi_rpc, sweep drift (R11) - docs/internal/architecture/unified-client.md deleted: 1229-line document opening with "This document proposes a breaking change" with no inbound references; pure unimplemented proposal with no place in the authoritative architecture directory. - docs/internal/architecture/core.md: added "Pi RPC adapter" section (~55 lines) covering wire framing, byte guards, lifecycle, event translation table, cancellation, and backend registry hook; updated backend registry table to include pi-coding-agent; added adapters/pi_rpc.py and adapters/pi_rpc_messages.py to module layout; updated "Two Streaming Paths" to "Three Streaming Paths" to reflect the Pi RPC path. - Drift fixed (2 hits): _transitions.py filename corrected to transitions.py in module layout and section header (the module was renamed to a public module without underscore prefix). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(web): collapse duplicate KaganApiClient into shared base (R7) - packages/web/src/lib/api/client.ts: replaced 711-LOC full reimplementation with 134-LOC thin subclass extending shared KaganApiClient; web-specific behaviour (configureBundledWeb, bundled-mode URL routing, legacy aliases) is isolated in the subclass. - packages/shared/api-client/src/client.ts: promoted 20 methods from the web client that are generic (analytics, integrations, doctor, interruptChatTurn, getChatMessages, searchMentions, resolveProjectFolder) and fixed getTasks to accept repoId, getTurnStatus to return TurnStatusResponse. - packages/shared/api-client/src/wire.ts + scripts/generate_wire_types.py: added ChatStreamError, ChatStreamSessionUpdated, CHAT_STREAM_EVENT const to cover the full /api/chat/{id}/stream wire surface. - packages/web/src/lib/chat/use-chat-stream.ts: CHAT_STREAM_EVENT const and ChatStreamEventType now imported from @kagan/shared-api-client; fixed bug where SESSION_UPDATED handler read msg.label instead of msg.session.label. - packages/web/src/components/board/edit-task-dialog.tsx: removed github_issue from PATCH updateTask call (field not in TaskUpdateRequest on server). LOC delta: web/client.ts −577, shared/client.ts +207, use-chat-stream −5 = −375 net. Methods moved to shared: getTasks(repoId), getTurnStatus(TurnStatusResponse), interruptChatTurn, getChatMessages, resolveProjectFolder, searchMentions, getBackendStats, getSessionTimeline, getAnalyticsExport, getRecommendedBackend, getAnalyticsByRole, getAnalyticsByTaskType, getAnalyticsByRoleAndTaskType, recommendBackendForTask, getStatsByRole, getStatsByTaskType, getCombinedStats, getIntegrations, getIntegrationPreflight, detectIntegrationRepo, previewIntegrationIssues, runIntegrationSync, getDoctorReport. Browser-only methods on subclass: configureBundledWeb, isBundledWeb, getFullUrl override, getHealth override, getBaseUrl override. Kept _doRequest override: web uses relative URLs (same-origin), shared uses full protocol://host URLs — the subclass overrides getFullUrl to return bare path in bundled mode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(core): typed ChatSessionView/SessionSummary, kill legacy dicts (R6) - Deleted chat_session_to_legacy_dict (dict[str, Any] with magic string keys) and active_session_summaries returning dict[str, dict[str, Any]] - Replaced with ChatSessionView (Pydantic BaseModel, mutable for in-place label mutation by ensure_session_title) and SessionSummary in core/_sessions.py - Updated 13 call sites: cli/chat controller + picker + title, server _chat_routes, tui orchestrator_sessions + kanban + session_resume_modal + chat widget, plus 4 test files; test helpers use .model_dump() to preserve backward-compatible return type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(tests): move fixtures to helpers, kill asyncio.sleep, narrow github mocks (R10) - Fixtures: moved client/config/integration to tests/integrations/conftest.py, browse_endpoint to tests/server/conftest.py, git_board deduped into shared helpers; added bare_board for project-management tests (6 fixture definitions removed from test files) - Sleeps: replaced asyncio.sleep(0.040/0.05) with asyncio.wait_for / asyncio.Event().wait() in test_chat_batched_approvals, test_acp_session, test_chat_sse, test_agent_kill (4 timing-based sleeps eliminated) - GitHub: private _extract_label_names/_map_labels unit tests moved to tests/unit/test_github_helpers.py; integration tests kept in tests/integrations/ using conftest fixtures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(web): delete dead ArtifactsPanel; dedupe cycleDockMode util (R12) Deleted: ArtifactsPanel.tsx (160 LOC), ArtifactsPanel.test.tsx (140 LOC), lib/atoms/artifacts.ts (32 LOC) — speculative subtree with zero live callers. Extracted DockedChatRailMode type and cycleDockMode function from both use-global-shortcuts.ts and app-layout.tsx into a single canonical util at packages/web/src/lib/layout/dock-mode.ts (9 LOC). Both files now import from it. Net: -335 LOC, tsc + vitest (367 tests) + build all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(core): delete Fernet-at-rest from settings (no production callers) (R14) - Deleted 107 LOC from src/kagan/core/_settings.py: _SENSITIVE_SUFFIXES, _FERNET_PREFIX, _is_sensitive_key, _secret_key_path, _load_or_create_fernet_key, _encrypt_value, _decrypt_value, SettingsDecryptError, _maybe_encrypt, _maybe_decrypt and all Fernet call sites in get_settings/set_settings - cryptography dep NOT removed — still required by src/kagan/server/crypto/_tls.py (x509/TLS) - Deleted 6 Fernet tests from tests/core/test_audit_cleanup.py; 5 F4 injection + 1 rate-limit tests retained Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(core/server): match event.kind for ChatEvent dispatch (R9) - _chat_routes._chat_event_to_sse_frame: replaced 8-arm isinstance chain with match event.kind (ChatEvent discriminator); removed 8 now-unused variant imports from kagan.core.chat - core/chat/acp.acp_update_to_chat_event: replaced 5-arm ACP isinstance chain with match update.session_update (ACP schema discriminator); removed deferred acp.schema imports no longer needed at call sites - cli/chat/_renderer.on_event: replaced 6-arm ChatEvent isinstance chain with match event.kind; fallthrough to case _: pass preserves prior implicit no-op for unrecognised variants; pyrefly exhaustiveness clean McCabe: all three functions drop by 1–2 (branch elision from match vs elif); no semantic change — pure structural refactor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(tests): purge tautology tests + relocate misplaced units per testing.md (R21) - Moved 4 files (server/test_{middleware,presence,sse_polling,web_ui}.py) to tests/unit/server/; added pytestmark=[pytest.mark.unit] to test_web_ui.py which was missing it (testing.md L48). - Moved 3 files (core/test_pi_coding_agent_backend.py, mcp/contract/ test_doctor_endpoint.py, core/test_cli_update_bootstrap.py) to tests/unit/{,mcp/,cli/}; all import private modules (_agent, _environment_checks, _web_ui, _sse, _bootstrap) — unit/ is the correct home (testing.md L46-47). - Deleted 4 files: core/test_analytics.py + core/test_analytics_smoke.py duplicate tests/unit/core/test_task_classification.py verbatim; server/test_chat_sse.py imports _chat_event_to_sse_frame + patches SpawnPerTurnACPFactory directly on the production module (L332, L334); integrations/test_mcp_github_tools.py calls tool functions bypassing the MCP router tautology — shape asserted not behavior (L332). - Deleted integrations/test_github_two_way_sync.py: imports 6 private github helpers (_parse_criteria_lines, _render_criteria_comment, etc.) and patches 4 network functions; observable behavior covered by tests/integrations/test_github.py. NOTE: parser-unit coverage for _parse_criteria_lines/_render_criteria_comment is now dark — a follow-up R21b can add tests/unit/integrations/test_github_parsers.py. - Rewrote test_install_rc_zero_promotes_settings_and_emits_telemetry in tests/tui/test_doctor_modal.py: removed direct TelemetryEvent DB row query (testing.md L333); replaced with observable-state assertions (settings written, modal dismissed to setup-flow). - Fixed tests/core/test_cli_surface.py: replaced kagan.cli._bootstrap.make_client with kagan.core.KaganCore (public API) — private import in a behavioral suite (testing.md L339). CLI-surface monkeypatching retained per L334-336 carve-out. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(server): type ServerContext through require_context (R13) - Replaced 81 ctx: Any annotations with ctx: ServerContext across 9 server modules - Approach A (direct annotation): no decorator changes, no call-site edits - Added ServerContext to TYPE_CHECKING imports in each affected file; no public alias needed as ServerContext is already public in kagan.server.mcp.server - Pyrefly: 0 errors before → 0 errors after (73 suppressed unchanged); _poll_db_changes retains ctx_or_tasks: Any intentionally (accepts ServerContext or bare Tasks in tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(web): simplify VisuallyHidden to span (R20) * refactor(core): inline single integration protocol (R16) * refactor(core): flatten ACP client base (R17) * refactor(tui): dedupe task data protocol (R18) * refactor(core): collapse hook framework to guard functions (R15) * refactor(chat): collapse spawn-per-turn ACP factory (R19) * docs: refresh post-guido architecture notes * fix(tui): preserve full OSC 8 file link targets * ci: unblock Snyk security scans * fix(web): use same-origin health check before auth hydration --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f228035 commit 6b321c9

107 files changed

Lines changed: 2872 additions & 6068 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/snyk.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # snyk/actions/setup@master
6969

7070
- name: Snyk code scan
71-
run: snyk code test --sarif-file-output=snyk-code.sarif
71+
run: snyk code test --severity-threshold=high --sarif-file-output=snyk-code.sarif
7272
env:
7373
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
7474

docs/concepts/architecture-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Set globally via `attached_launcher`, or override per task.
8181

8282
## Integrations
8383

84-
GitHub import is a native integration ([guide](../guides/github.md)). New trackers (Jira, Linear, …) are added as new modules under `kagan.core.integrations`; there is no plugin system.
84+
GitHub import is the native integration ([guide](../guides/github.md)); there is no plugin system.
8585

8686
## Data
8787

docs/internal/architecture/chat.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ ChatController.process_input()
146146

147147
### ACP Integration
148148

149-
- `_OrchestratorACPClient`implements `ACPClientBase`
149+
- `_OrchestratorACPClient`concrete ACP client adapter
150150
- `_CaptureACPClient` — silent variant for title generation
151151
- `warm_orchestrator_backend()` — pre-warms agent to reduce latency
152152
- Tool calls rendered with status indicators (pending ✓/✗)

docs/internal/architecture/core.md

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ kagan/core/
7171
├── _sessions.py # agent session repository
7272
├── _settings.py # settings repository
7373
├── _tasks.py # task repository
74-
├── _transitions.py # task lifecycle state machine
74+
├── transitions.py # task lifecycle state machine (public — no underscore)
7575
├── _utils.py # utility functions
7676
├── _watcher.py # DBWatcher: filesystem DB change watcher
7777
├── _worktrees.py # worktree management logic
@@ -80,7 +80,7 @@ kagan/core/
8080
├── _checkpoints.py # task execution checkpoints (create, list, rewind, cleanup)
8181
├── _compaction.py # database compaction
8282
├── _event_rendering.py # event display rendering helpers
83-
├── _hooks.py # hook system (Hook, HookRunner, HookContext)
83+
├── _hooks.py # guard functions for repeated and dangerous tool calls
8484
├── _insights.py # project insight extraction with decay and relevance
8585
├── _prompt_export.py # prompt export functionality
8686
├── _security.py # security helpers
@@ -89,20 +89,18 @@ kagan/core/
8989
9090
└── adapters/ # adapter sub-package
9191
├── __init__.py
92+
├── pi_rpc.py # JSONL-framed RPC adapter for pi-coding-agent
93+
├── pi_rpc_messages.py # Typed message models for pi RPC protocol
9294
└── db/ # database adapters
9395
```
9496

9597
~42 files. Flat structure with private modules (underscore prefix) and adapters sub-package.
9698

9799
## Integrations
98100

99-
Native integrations live in `kagan.core.integrations`. Each integration is a plain class that
100-
satisfies the `Integration` typing.Protocol (defined in `_base.py`): three methods — `preflight`,
101-
`preview`, and `sync`. No ABCs, no metaclasses, no entry-point discovery.
102-
103-
The module exports `all_enabled(client) -> list[Integration]`. Today it returns `[github]`.
104-
Adding a new integration (Jira, Linear, Azure DevOps) means: create a submodule, implement the
105-
three methods, register in `all_enabled()`. That is the complete API surface change required.
101+
Native integrations live in `kagan.core.integrations`. Today GitHub is the only native
102+
integration, exposed through the module-level `github` singleton and `all_enabled()`.
103+
There is no protocol, ABC, metaclass, or entry-point discovery layer around it.
106104

107105
The old entry-point plugin system (ABC hierarchy, dynamic discovery, community-plugin env flag)
108106
was removed in the `refactor/native-integrations` branch. There are no backwards-compat shims.
@@ -265,20 +263,22 @@ A task can be executed across multiple sessions with different personas in seque
265263

266264
`tasks.events.stream()` is an async generator yielding `SessionEvent` rows reactively using `asyncio.Event` signaling (not polling). When `emit()` inserts a row, it signals waiting streams. A 5-second safety timeout ensures liveness.
267265

268-
### Two Streaming Paths
266+
### Three Streaming Paths
269267

270-
| | ACP (managed) | MCP (interactive / external) |
271-
| ----------------- | --------------------------------- | ------------------------------------ |
272-
| **When** | All managed executions | Interactive launches, IDE hosts |
273-
| **Transport** | Direct STDIO JSON-RPC | Agent spawns kagan MCP as subprocess |
274-
| **Bidirectional** | Yes — kagan sends prompts, cancel | No — caller invokes tools |
275-
| **Process** | Kagan owns (can terminate) | Agent runs in external environment |
268+
| | ACP (managed) | MCP (interactive / external) | Pi RPC (pi-coding-agent) |
269+
| ----------------- | --------------------------------- | ------------------------------------ | ------------------------------------ |
270+
| **When** | ACP-capable backends | Interactive launches, IDE hosts | `pi-coding-agent` backend |
271+
| **Transport** | Direct STDIO JSON-RPC (ACP) | Agent spawns kagan MCP as subprocess | JSONL over subprocess stdin/stdout |
272+
| **Bidirectional** | Yes — kagan sends prompts, cancel | No — caller invokes tools | Yes — kagan sends commands, abort |
273+
| **Process** | Kagan owns (can terminate) | Agent runs in external environment | Kagan owns (long-lived per session) |
276274

277-
**Path A — ACP:** Backends with `supports_acp: True` use piped stdin/stdout. Events flow through `KaganACPClient.session_update()``map_acp_update_to_event()``Events.emit()`. A repetition guard hashes tool calls and cancels stuck agents (≥4 identical calls in last 10).
275+
**Path A — ACP:** Backends with `ACP_STREAMING` capability use piped stdin/stdout. Events flow through `KaganACPClient.session_update()``map_acp_update_to_event()``Events.emit()`. A repetition guard hashes tool calls and cancels stuck agents (≥4 identical calls in last 10).
278276

279277
**Path B — MCP:** Agent discovers `.mcp.json` in worktree and spawns `kagan mcp --session-id {id}`. MCP tool calls write events to the DB.
280278

281-
Both converge at `tasks.events.stream()`.
279+
**Path C — Pi RPC:** `pi-coding-agent` uses `adapters/pi_rpc.PiRpcClient`. The process is spawned once per session and kept alive across prompts. `translate_pi_rpc_message()` converts pi JSONL frames to `AgentEvent` instances. See the Pi RPC adapter section below.
280+
281+
All three converge at `tasks.events.stream()`.
282282

283283
### Secret Scrubbing
284284

@@ -317,7 +317,7 @@ Key points: **DB is the durable buffer** — both paths write to the same table.
317317

318318
Provides `create_db_engine(db_path)` and `default_db_path()`. Sync SQLModel engine with WAL mode and FK enforcement. Creates all tables on first use.
319319

320-
### `_transitions.py` — Task Lifecycle State Machine
320+
### `transitions.py` — Task Lifecycle State Machine
321321

322322
Valid transitions:
323323

@@ -357,8 +357,9 @@ Kagan supports any CLI-based coding agent through a backend registry.
357357
| `stakpak` | `stakpak` | Infrastructure |
358358
| `mistral-vibe` | `vibe` | Mistral |
359359
| `vt-code` | `vtcode` | VT Code |
360+
| `pi-coding-agent`| `npx` | Pi (Mariozechner) — JSONL-RPC, not CLI-launched |
360361

361-
**Backend aliases:** `claude``claude-code`; `gemini``gemini-cli`; `kimi``kimi-cli`.
362+
**Backend aliases:** `claude``claude-code`; `gemini``gemini-cli`; `kimi``kimi-cli`; `pi``pi-coding-agent`.
362363

363364
**Launch sequence:**
364365

@@ -386,6 +387,64 @@ Kagan supports any CLI-based coding agent through a backend registry.
386387

387388
The `.mcp.json` file tells the environment to discover kagan's MCP server scoped to this session.
388389

390+
### `adapters/pi_rpc.py` — Pi RPC Adapter
391+
392+
`pi-coding-agent` does not accept a prompt as a CLI argument. Instead, it exposes a JSONL-framed
393+
RPC protocol over subprocess stdin/stdout. `PiRpcClient` in `adapters/pi_rpc.py` wraps that
394+
protocol so the rest of the system interacts with a familiar async interface.
395+
396+
**Wire framing.** Commands to the agent are JSON objects written one per line to stdin. Events
397+
from the agent arrive as JSON objects one per line on stdout (`AgentSessionEvent` shape from the
398+
pi protocol). Neither direction uses length prefixes or delimiters beyond the newline.
399+
400+
**Byte guards (CWE-770).** Two limits cap runaway output: 10 MB per JSONL line
401+
(`_PI_RPC_MAX_LINE_BYTES`) and 500 MB cumulative per prompt invocation
402+
(`_PI_RPC_MAX_CUMULATIVE_BYTES`). The cumulative counter resets at the start of each `prompt()`
403+
call, so a long-lived client across many prompts does not accumulate the budget.
404+
405+
**Lifecycle.** `PiRpcClient` is an async context manager. On `__aenter__`, it spawns
406+
`npx @mariozechner/pi-coding-agent --mode rpc` via `asyncio.create_subprocess_exec`. Pi's process
407+
does not auto-exit after completing a prompt, so the caller is responsible for termination.
408+
`aclose()` sends `SIGTERM`, waits up to 2 seconds (`_KILL_GRACE_SECONDS`), then sends `SIGKILL`
409+
if the process has not exited.
410+
411+
**Event translation.** `translate_pi_rpc_message()` converts a single parsed pi frame into an
412+
`AgentEvent`. The mapping:
413+
414+
| Pi frame type | `AgentEvent` variant | Notes |
415+
| -------------------- | --------------------- | -------------------------------------------- |
416+
| `agent_start` | `AgentStart` | |
417+
| `agent_end` | `AgentEnd` | Also sets `done = True` to end the read loop |
418+
| `turn_start` | `TurnStart` | turn_index supplied by caller counter |
419+
| `turn_end` | `TurnEnd` | |
420+
| `message_start` | `MessageStart` | Assistant messages only; user messages skipped |
421+
| `message_update` | `MessageUpdate` | `text_delta` and `thinking_delta` only |
422+
| `message_end` | `MessageEnd` | Assistant messages only |
423+
| `tool_execution_start` | `ToolExecutionStart` | |
424+
| `tool_execution_update` | `ToolExecutionUpdate` | |
425+
| `tool_execution_end` | `ToolExecutionEnd` | |
426+
| `compaction_start` | `CompactionOccurred` | `compaction_end` is ignored (start suffices) |
427+
| `response` | `None` | RPC ack frames |
428+
| `extension_ui_request` | `None` | UI frames not applicable in headless mode |
429+
| anything else | `None` | Silently discarded |
430+
431+
**Cancellation.** `prompt()` accepts an `asyncio.Event cancel_event`. When set, a background
432+
task sends `{"type": "abort"}` to stdin. The read loop still drains remaining output until EOF
433+
or `agent_end`.
434+
435+
**Backend registry hook.** `_agent.py` registers `pi-coding-agent` with
436+
`BackendCapability.PI_RPC_STREAMING`. The capability is distinct from `ACP_STREAMING` because
437+
the transport and launch model differ: pi is not a detached process and does not report through
438+
MCP. The `_sessions.py` run path currently routes all non-ACP backends through the detached
439+
launcher (`spawn_agent`); wiring `PI_RPC_STREAMING` to call `PiRpcClient.prompt()` instead is
440+
a pending integration step.
441+
442+
**Message models.** `adapters/pi_rpc_messages.py` contains typed dataclass models
443+
(`PiAgentStart`, `PiMessageUpdate`, `PiToolCallStart`, etc.) and `parse_pi_rpc_message()`, which
444+
validates raw dicts against those models. `translate_pi_rpc_message()` pattern-matches on those
445+
typed instances, not on raw dicts, so unknown future pi frame types are safely discarded rather
446+
than raising.
447+
389448
### `_config.py` — Bootstrap Config
390449

391450
Reads/writes TOML from `~/.config/kagan/config.toml`. **Bootstrap-only** — settings needed before the DB exists: `db_path`, `log_level`. Runtime preferences live in the DB `Setting` table.

0 commit comments

Comments
 (0)