From 7f2d6eb048a2c2b4abbf83105b63fc4dca84a7b5 Mon Sep 17 00:00:00 2001 From: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:16:02 +0530 Subject: [PATCH 1/3] feat(sessions): two-way terminal <-> dashboard links + one-click resume Terminal -> Dashboard: every session gets a short link (http://localhost:5180/s/<8-char-id>) that opens its timeline. Includes a `karma` shell script and a `GET /sessions/resolve/{short_id}` endpoint that also checks live-session records, so the link works even seconds into a brand new session, before its transcript file exists yet. Dashboard -> Terminal: the "resume" button (session card + session detail page) now does something instead of just copying a command. If the session is still running, it focuses that terminal. If it has ended, it opens a new terminal, cds into the project, and runs `claude --resume` for you. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 + api/CLAUDE.md | 2 + api/routers/sessions.py | 191 +++++++++++ api/schemas.py | 23 ++ api/services/terminal_launch.py | 110 ++++++ api/tests/api/test_session_resume.py | 323 ++++++++++++++++++ api/tests/test_terminal_launch.py | 77 +++++ .../lib/components/ResumeSessionButton.svelte | 171 ++++++++++ .../src/lib/components/SessionCard.svelte | 32 +- .../conversation/ConversationHeader.svelte | 31 +- frontend/src/routes/s/[short_id]/+server.ts | 40 +++ scripts/karma | 67 ++++ 12 files changed, 1025 insertions(+), 45 deletions(-) create mode 100644 api/services/terminal_launch.py create mode 100644 api/tests/api/test_session_resume.py create mode 100644 api/tests/test_terminal_launch.py create mode 100644 frontend/src/lib/components/ResumeSessionButton.svelte create mode 100644 frontend/src/routes/s/[short_id]/+server.ts create mode 100755 scripts/karma diff --git a/CLAUDE.md b/CLAUDE.md index f80e2b1f..da7222ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,8 @@ Project (entry point) | GET | `/projects` | List all projects | | GET | `/projects/{encoded_name}` | Project details with sessions | | GET | `/sessions/{uuid}` | Session details | +| GET | `/sessions/resolve/{short_id}` | Resolve a UUID or UUID prefix to its project (powers `/s/` short links) | +| POST | `/sessions/{uuid}/resume-in-terminal` | Focus the session's terminal if live, else open a terminal running `claude --resume` (localhost-only) | | GET | `/sessions/{uuid}/timeline` | Event timeline | | GET | `/sessions/{uuid}/tools` | Tool usage | | GET | `/sessions/{uuid}/file-activity` | File operations | @@ -183,6 +185,7 @@ Project (entry point) | `/plugins` | Plugins browser | | `/tools` | Tools browser | | `/sessions` | Global sessions browser | +| `/s/[short_id]` | Short session link → redirects to the session's project page (statusline / `scripts/karma`) | | `/archived` | Archived sessions | | `/about` | About page | | `/settings` | User settings | diff --git a/api/CLAUDE.md b/api/CLAUDE.md index 871896dc..92d404e7 100644 --- a/api/CLAUDE.md +++ b/api/CLAUDE.md @@ -149,6 +149,8 @@ Real-time state tracked in `~/.claude_karma/live-sessions/{slug}.json` via Claud | GET | `/sessions/{uuid}/file-activity` | File operations | | GET | `/sessions/{uuid}/subagents` | Subagent activity | | GET | `/analytics/projects/{encoded_name}` | Project analytics | +| GET | `/sessions/resolve/{short_id}` | Resolve a UUID/prefix to its project (short links) | +| POST | `/sessions/{uuid}/resume-in-terminal` | Focus if live, else launch `claude --resume` in a new terminal | ## Core Classes diff --git a/api/routers/sessions.py b/api/routers/sessions.py index d93844cd..75dba26e 100644 --- a/api/routers/sessions.py +++ b/api/routers/sessions.py @@ -7,6 +7,7 @@ import heapq import json import logging +import re import sqlite3 import sys from datetime import datetime, timezone @@ -94,11 +95,13 @@ def _enrich_chain_titles_by_slug(summaries: list[SessionSummary]) -> None: PlanDetail, ProjectFilterOption, SessionDetail, + SessionResolveResult, SessionWithContext, SkillUsage, StatusFilterOption, SubagentSummary, TaskSchema, + TerminalResumeResult, TodoItemSchema, ) from services.session_lookup import ( @@ -1769,3 +1772,191 @@ def set_session_title(uuid: str, request: SetTitleRequest): content={"status": "ok", "uuid": uuid, "title": title}, status_code=200, ) + + +# --------------------------------------------------------------------------- +# Short-link resolution + focus-or-resume +# --------------------------------------------------------------------------- + +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +# Short ids are UUID prefixes (statusline links use the first 8 hex chars). +_SHORT_ID_RE = re.compile(r"^[0-9a-f][0-9a-f-]{5,35}$") + + +def _find_session_files(uuid_prefix: str) -> list[tuple[Path, str]]: + """All session JSONLs whose uuid starts with ``uuid_prefix``. + + Returns (jsonl_path, project_encoded_name) pairs. The prefix is validated + by the caller, so it is safe to embed in a glob pattern. + """ + from utils import is_encoded_project_dir + + projects_dir = settings.projects_dir + if not projects_dir.exists(): + return [] + matches: list[tuple[Path, str]] = [] + for encoded_dir in projects_dir.iterdir(): + if not encoded_dir.is_dir() or not is_encoded_project_dir(encoded_dir.name): + continue + exact = encoded_dir / f"{uuid_prefix}.jsonl" + if exact.exists(): + return [(exact, encoded_dir.name)] + for jsonl in encoded_dir.glob(f"{uuid_prefix}*.jsonl"): + if not jsonl.name.startswith("agent-"): + matches.append((jsonl, encoded_dir.name)) + return matches + + +def _session_cwd(jsonl_path: Path) -> Optional[str]: + """The session's working directory, from the JSONL's own cwd field. + + Per-session cwd beats the project-level decoded path: worktree sessions + live under the main project's encoded dir but ran elsewhere. + """ + try: + with open(jsonl_path, "r", encoding="utf-8") as f: + for i, line in enumerate(f): + if i > 50: + break + try: + cwd = json.loads(line).get("cwd") + except (json.JSONDecodeError, AttributeError): + continue + if isinstance(cwd, str) and Path(cwd).is_absolute(): + return cwd + except (OSError, UnicodeDecodeError): + pass + return None + + +def _resolve_from_live_sessions(short_id: str) -> Optional[SessionResolveResult]: + """Resolve a uuid prefix against live-session records instead of JSONL files. + + A brand-new session has a live-session state (written immediately by the + SessionStart hook) before Claude Code has written its first message to + the transcript JSONL — so right at session start, `_find_session_files` + finds nothing even though the session genuinely exists. This is the + fallback that makes the short link work in that window. + """ + from models import Project + from models.live_session import load_all_live_sessions + + candidates = [ + state + for state in load_all_live_sessions() + if state.session_id.lower().startswith(short_id) or short_id == state.session_id.lower() + ] + if not candidates: + return None + newest = max(candidates, key=lambda s: s.updated_at) + return SessionResolveResult( + uuid=newest.session_id, + project_encoded_name=Project.encode_path(newest.cwd), + slug=newest.slug, + ) + + +@router.get("/resolve/{short_id}", response_model=SessionResolveResult) +def resolve_session_short_id(short_id: str) -> SessionResolveResult: + """Resolve a session UUID or UUID prefix to its dashboard location. + + Powers the frontend's short links (``/s/{first-8-of-uuid}``), which are + compact enough for a terminal statusline. Ambiguous prefixes resolve to + the most recently modified match. Falls back to live-session records + (see ``_resolve_from_live_sessions``) for a session too new to have a + transcript JSONL yet. + """ + short_id = short_id.lower() + if not _SHORT_ID_RE.match(short_id): + raise HTTPException(status_code=400, detail=f"Not a session id or prefix: {short_id}") + + matches = _find_session_files(short_id) + if not matches: + live_result = _resolve_from_live_sessions(short_id) + if live_result: + return live_result + raise HTTPException(status_code=404, detail=f"No session matches: {short_id}") + + def _mtime(match: tuple[Path, str]) -> float: + try: + return match[0].stat().st_mtime + except OSError: + return 0.0 + + jsonl_path, encoded_name = max(matches, key=_mtime) + uuid = jsonl_path.stem + + from models.live_session import load_live_session + + state = load_live_session(uuid) + return SessionResolveResult( + uuid=uuid, + project_encoded_name=encoded_name, + slug=state.slug if state else None, + ) + + +@router.post("/{uuid}/resume-in-terminal", response_model=TerminalResumeResult) +def resume_session_in_terminal(uuid: str, request: Request) -> TerminalResumeResult: + """Focus the session's terminal if it is still running, else resume it. + + A live claude process gets its existing terminal raised (same behavior as + ``/live-sessions/{id}/focus-terminal``). An ended session gets a fresh + terminal tab/window that cd's into the project and runs + ``claude --resume `` — the new session's hooks then re-register it + as live on their own. + + The shell command is built entirely server-side from the validated UUID + and Karma's own project path resolution; the request body carries nothing. + Returns 404 if the session is unknown, 409 if its project directory no + longer exists, 403 for cross-origin browser requests. + """ + from models import Project + from models.live_session import load_live_session + from routers.live_sessions import _reject_cross_origin + from services.terminal_focus import focus_terminal, pid_is_live_claude + from services.terminal_launch import launch_resume_in_terminal + + _reject_cross_origin(request, settings) + uuid = uuid.lower() + if not _UUID_RE.match(uuid): + raise HTTPException(status_code=400, detail=f"Not a session UUID: {uuid}") + + state = load_live_session(uuid) + terminal = state.terminal if state else None + + # Dedupe: never spawn a second claude on a session that is still running. + if terminal and terminal.pid and pid_is_live_claude(terminal.pid): + result = focus_terminal(terminal.model_dump()) + return TerminalResumeResult( + ok=bool(result.get("focused")), + action="focused", + method=result.get("method", "none"), + detail=result.get("detail", ""), + ) + + matches = _find_session_files(uuid) + if not matches: + raise HTTPException(status_code=404, detail=f"Session not found: {uuid}") + jsonl_path, encoded_name = matches[0] + + # Per-session cwd first (worktrees), then the live record, then the + # project-level path recovered from other sessions. + cwd = _session_cwd(jsonl_path) or (state.cwd if state else None) + if not cwd: + cwd = Project.from_encoded_name(encoded_name).path + if not Path(cwd).is_dir(): + raise HTTPException( + status_code=409, + detail=f"The session's project directory no longer exists: {cwd}", + ) + + result = launch_resume_in_terminal( + cwd, uuid, term_program=terminal.term_program if terminal else None + ) + return TerminalResumeResult( + ok=bool(result.get("launched")), + action="launched" if result.get("launched") else "failed", + method=result.get("method", "none"), + detail=result.get("detail", ""), + ) diff --git a/api/schemas.py b/api/schemas.py index 43b413a6..c66bed4c 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -988,6 +988,29 @@ class TerminalFocusResult(BaseModel): detail: str = Field(..., description="Human-readable detail about the attempt") +class TerminalResumeResult(BaseModel): + """Result of the focus-or-resume action on a session's terminal.""" + + ok: bool = Field(..., description="Whether the action succeeded") + action: Literal["focused", "launched", "failed"] = Field( + ..., + description=( + "'focused' when the session was already live and its terminal was raised; " + "'launched' when a new terminal was opened running claude --resume" + ), + ) + method: str = Field(..., description="Mechanism attempted (iterm-tab, terminal-window, ...)") + detail: str = Field(..., description="Human-readable detail about the attempt") + + +class SessionResolveResult(BaseModel): + """Resolution of a session UUID (or UUID prefix) to its dashboard location.""" + + uuid: str = Field(..., description="Full session UUID") + project_encoded_name: str = Field(..., description="Encoded project directory name") + slug: Optional[str] = Field(None, description="Session slug when tracked live") + + class LiveSessionSummary(BaseModel): """Summary of a live session for list display.""" diff --git a/api/services/terminal_launch.py b/api/services/terminal_launch.py new file mode 100644 index 00000000..0f9ef0bd --- /dev/null +++ b/api/services/terminal_launch.py @@ -0,0 +1,110 @@ +"""Launch a new terminal tab/window and run `claude --resume` in it. + +Counterpart to :mod:`services.terminal_focus`: focus raises the terminal of a +session that is still running; launch spawns a fresh terminal for a session +that has ended, cd's into the project, and starts ``claude --resume ``. +Once claude starts, its SessionStart hook re-captures the new tab's identity, +so the live dashboard (and the focus button) pick the session up on their own. + +macOS only for now, mirroring the focus service's platform support: + +- **iTerm2** : clean scripting API — a new tab in the current window + (or a new window when none exist), then ``write text``. +- **Terminal.app** : ``do script`` runs the command in a NEW WINDOW natively. + A new *tab* would require a System Events Cmd+T keystroke, which is racy + and needs extra Accessibility trust — deliberately not done. + +SECURITY: the shell command is built entirely server-side. ``project_path`` +must come from Karma's own project resolution and ``session_uuid`` must be a +validated UUID (the router enforces both); everything is still shell-quoted +and AppleScript-escaped here as defense in depth. No caller-provided text +may ever reach ``_build_command`` unvalidated. + +Everything is best-effort and honest: ``launched=False`` plus a +human-readable reason, never an exception. +""" + +from __future__ import annotations + +import shlex +import subprocess +import sys +from typing import Any, Dict, Optional + +from services.terminal_focus import _app_is_running, _applescript_str, _run + +# Launching runs a command in a brand-new shell; the safe default is the +# terminal every macOS install has. iTerm2 is used only when the session's +# recorded TERM_PROGRAM says it lived there AND iTerm2 is running. +_ITERM_LAUNCH_SCRIPT = ( + 'tell application "iTerm2"\n' + " activate\n" + " if (count of windows) is 0 then\n" + " create window with default profile\n" + " else\n" + " tell current window to create tab with default profile\n" + " end if\n" + ' tell current session of current window to write text "{command}"\n' + "end tell" +) + +_TERMINAL_LAUNCH_SCRIPT = ( + 'tell application "Terminal"\n do script "{command}"\n activate\nend tell' +) + + +def _build_command(project_path: str, session_uuid: str) -> str: + """The shell line typed into the new terminal, fully quoted.""" + return f"cd {shlex.quote(project_path)} && claude --resume {shlex.quote(session_uuid)}" + + +def _osascript(script: str) -> Optional[str]: + """Run an AppleScript; stderr text on failure, None on success.""" + try: + res = _run(["osascript", "-e", script]) + except (subprocess.SubprocessError, OSError) as exc: + return str(exc) + if res.returncode != 0: + return res.stderr.strip() or "osascript failed" + return None + + +def launch_resume_in_terminal( + project_path: str, + session_uuid: str, + term_program: Optional[str] = None, +) -> Dict[str, Any]: + """Open a terminal at ``project_path`` and run ``claude --resume``. + + ``term_program`` is the TERM_PROGRAM recorded when the session last ran, + used as a hint to resume in the same terminal app. Returns + ``{launched: bool, method: str, detail: str}``. + """ + if sys.platform != "darwin": + return { + "launched": False, + "method": "none", + "detail": "Resuming in a terminal is currently supported on macOS only.", + } + + command = _build_command(project_path, session_uuid) + escaped = _applescript_str(command) + + if term_program == "iTerm.app" and _app_is_running('application "iTerm2"'): + err = _osascript(_ITERM_LAUNCH_SCRIPT.format(command=escaped)) + if err is None: + return { + "launched": True, + "method": "iterm-tab", + "detail": f"opened an iTerm2 tab and ran: {command}", + } + return {"launched": False, "method": "iterm-tab", "detail": err} + + err = _osascript(_TERMINAL_LAUNCH_SCRIPT.format(command=escaped)) + if err is None: + return { + "launched": True, + "method": "terminal-window", + "detail": f"opened a Terminal window and ran: {command}", + } + return {"launched": False, "method": "terminal-window", "detail": err} diff --git a/api/tests/api/test_session_resume.py b/api/tests/api/test_session_resume.py new file mode 100644 index 00000000..d98cec1e --- /dev/null +++ b/api/tests/api/test_session_resume.py @@ -0,0 +1,323 @@ +"""Endpoint tests for short-link resolution and focus-or-resume. + +Covers: +- ``GET /sessions/resolve/{short_id}`` (exact uuid, prefix, ambiguity → + newest match, 404, 400) +- ``POST /sessions/{uuid}/resume-in-terminal`` (live → focus, ended → + launch, invalid uuid 400, unknown 404, missing project dir 409, + cross-origin 403) + +The actual OS launch/focus is mocked; ``services.terminal_launch`` has its +own unit tests. Both the projects dir and the live-sessions dir are +redirected to tmp paths. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +_tests_dir = Path(__file__).resolve().parent.parent +_api_dir = _tests_dir.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +UUID_A = "aaaaaaaa-1111-2222-3333-444444444444" +UUID_B = "aaaaaaaa-5555-6666-7777-888888888888" + + +def _write_jsonl(projects_dir: Path, encoded: str, uuid: str, cwd: str) -> Path: + project_dir = projects_dir / encoded + project_dir.mkdir(parents=True, exist_ok=True) + jsonl = project_dir / f"{uuid}.jsonl" + jsonl.write_text(json.dumps({"cwd": cwd, "sessionId": uuid}) + "\n") + return jsonl + + +def _write_live_state( + live_dir: Path, session_id: str, cwd: str, terminal: dict | None, slug: str | None = None +) -> None: + now = datetime.now(timezone.utc).isoformat() + data = { + "session_id": session_id, + "slug": slug, + "state": "LIVE", + "cwd": cwd, + "transcript_path": f"{cwd}/{session_id}.jsonl", + "permission_mode": "default", + "last_hook": "PostToolUse", + "updated_at": now, + "started_at": now, + "session_ids": [session_id], + } + if terminal is not None: + data["terminal"] = terminal + (live_dir / f"{slug or session_id}.json").write_text(json.dumps(data)) + + +@pytest.fixture +def env(tmp_path, monkeypatch): + """Test app plus redirected projects and live-sessions dirs.""" + projects_dir = tmp_path / "claude" / "projects" + projects_dir.mkdir(parents=True) + live_dir = tmp_path / "live-sessions" + live_dir.mkdir() + + import models.live_session as live_model + from routers import sessions + + monkeypatch.setattr(live_model, "get_live_sessions_dir", lambda: live_dir) + monkeypatch.setattr(sessions.settings, "claude_base", tmp_path / "claude") + monkeypatch.setattr(sessions.settings, "cors_origins", ["http://localhost:5180"]) + + app = FastAPI() + app.include_router(sessions.router, prefix="/sessions") + client = TestClient(app) + return client, projects_dir, live_dir + + +# --------------------------------------------------------------------------- +# GET /sessions/resolve/{short_id} +# --------------------------------------------------------------------------- + + +def test_resolve_full_uuid(env): + client, projects_dir, _ = env + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, "/Users/test/project") + + res = client.get(f"/sessions/resolve/{UUID_A}") + assert res.status_code == 200 + body = res.json() + assert body["uuid"] == UUID_A + assert body["project_encoded_name"] == "-Users-test-project" + assert body["slug"] is None + + +def test_resolve_short_prefix(env): + client, projects_dir, _ = env + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, "/Users/test/project") + + res = client.get(f"/sessions/resolve/{UUID_A[:8]}") + assert res.status_code == 200 + assert res.json()["uuid"] == UUID_A + + +def test_resolve_ambiguous_prefix_prefers_newest(env): + client, projects_dir, _ = env + older = _write_jsonl(projects_dir, "-Users-test-project", UUID_A, "/Users/test/project") + _write_jsonl(projects_dir, "-Users-test-project", UUID_B, "/Users/test/project") + old = time.time() - 3600 + os.utime(older, (old, old)) + + res = client.get(f"/sessions/resolve/{UUID_A[:8]}") + assert res.status_code == 200 + assert res.json()["uuid"] == UUID_B + + +def test_resolve_includes_live_slug(env): + client, projects_dir, live_dir = env + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, "/Users/test/project") + _write_live_state(live_dir, UUID_A, "/Users/test/project", None, slug="brave-quiet-fox") + + res = client.get(f"/sessions/resolve/{UUID_A}") + assert res.status_code == 200 + assert res.json()["slug"] == "brave-quiet-fox" + + +def test_resolve_falls_back_to_live_session_before_jsonl_exists(env, tmp_path): + """A session just past SessionStart has a live record but no JSONL yet — + the statusline link must still resolve instead of 404ing on startup.""" + client, _, live_dir = env + cwd = tmp_path / "repo" + cwd.mkdir() + _write_live_state(live_dir, UUID_A, str(cwd), None) + + res = client.get(f"/sessions/resolve/{UUID_A[:8]}") + assert res.status_code == 200 + body = res.json() + assert body["uuid"] == UUID_A + from models import Project + + assert body["project_encoded_name"] == Project.encode_path(str(cwd)) + + +def test_resolve_prefers_jsonl_over_live_session_once_it_exists(env, tmp_path): + client, projects_dir, live_dir = env + cwd = tmp_path / "repo" + cwd.mkdir() + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, str(cwd)) + # A stale/mismatched live record for the same uuid should lose to the + # authoritative on-disk transcript. + _write_live_state(live_dir, UUID_A, "/somewhere/else", None) + + res = client.get(f"/sessions/resolve/{UUID_A[:8]}") + assert res.status_code == 200 + assert res.json()["project_encoded_name"] == "-Users-test-project" + + +def test_resolve_unknown_returns_404(env): + client, _, _ = env + assert client.get("/sessions/resolve/deadbeef").status_code == 404 + + +def test_resolve_invalid_id_returns_400(env): + client, _, _ = env + assert client.get("/sessions/resolve/not..valid").status_code == 400 + assert client.get("/sessions/resolve/abc").status_code == 400 # too short + + +def test_resolve_ignores_agent_files(env): + client, projects_dir, _ = env + project_dir = projects_dir / "-Users-test-project" + project_dir.mkdir(parents=True) + (project_dir / f"agent-{UUID_A}.jsonl").write_text("{}\n") + + assert client.get(f"/sessions/resolve/{UUID_A[:8]}").status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /sessions/{uuid}/resume-in-terminal +# --------------------------------------------------------------------------- + + +def test_resume_invalid_uuid_returns_400(env): + client, _, _ = env + assert client.post("/sessions/not-a-uuid/resume-in-terminal").status_code == 400 + + +def test_resume_unknown_session_returns_404(env): + client, _, _ = env + assert client.post(f"/sessions/{UUID_A}/resume-in-terminal").status_code == 404 + + +def test_resume_cross_origin_rejected(env): + client, _, _ = env + res = client.post( + f"/sessions/{UUID_A}/resume-in-terminal", + headers={"Origin": "https://evil.example"}, + ) + assert res.status_code == 403 + + +def test_resume_live_session_focuses_instead(env, monkeypatch): + client, projects_dir, live_dir = env + cwd = "/Users/test/project" + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, cwd) + _write_live_state( + live_dir, + UUID_A, + cwd, + {"term_program": "Apple_Terminal", "pid": 4242, "tty": "/dev/ttys009"}, + ) + + import services.terminal_focus as tf + + focused_with = {} + monkeypatch.setattr(tf, "pid_is_live_claude", lambda pid: pid == 4242) + monkeypatch.setattr( + tf, + "focus_terminal", + lambda terminal: focused_with.update(terminal) + or {"focused": True, "method": "osascript-tab", "detail": "raised"}, + ) + + res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") + assert res.status_code == 200 + body = res.json() + assert body["action"] == "focused" + assert body["ok"] is True + assert focused_with["tty"] == "/dev/ttys009" + + +def test_resume_ended_session_launches_terminal(env, monkeypatch, tmp_path): + client, projects_dir, live_dir = env + cwd = tmp_path / "repo" + cwd.mkdir() + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, str(cwd)) + _write_live_state( + live_dir, + UUID_A, + str(cwd), + {"term_program": "iTerm.app", "pid": 4242, "tty": "/dev/ttys009"}, + ) + + import services.terminal_focus as tf + import services.terminal_launch as tl + + monkeypatch.setattr(tf, "pid_is_live_claude", lambda pid: False) + calls = {} + + def fake_launch(project_path, session_uuid, term_program=None): + calls.update(path=project_path, uuid=session_uuid, term=term_program) + return {"launched": True, "method": "iterm-tab", "detail": "opened"} + + monkeypatch.setattr(tl, "launch_resume_in_terminal", fake_launch) + + res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") + assert res.status_code == 200 + body = res.json() + assert body["action"] == "launched" + assert body["ok"] is True + assert calls == {"path": str(cwd), "uuid": UUID_A, "term": "iTerm.app"} + + +def test_resume_without_live_state_uses_jsonl_cwd(env, monkeypatch, tmp_path): + client, projects_dir, _ = env + cwd = tmp_path / "repo" + cwd.mkdir() + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, str(cwd)) + + import services.terminal_launch as tl + + calls = {} + monkeypatch.setattr( + tl, + "launch_resume_in_terminal", + lambda project_path, session_uuid, term_program=None: calls.update( + path=project_path, term=term_program + ) + or {"launched": True, "method": "terminal-window", "detail": "opened"}, + ) + + res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") + assert res.status_code == 200 + assert calls == {"path": str(cwd), "term": None} + + +def test_resume_missing_project_dir_returns_409(env): + client, projects_dir, _ = env + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, "/nonexistent/path/xyz") + + res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") + assert res.status_code == 409 + + +def test_resume_launch_failure_reports_failed(env, monkeypatch, tmp_path): + client, projects_dir, _ = env + cwd = tmp_path / "repo" + cwd.mkdir() + _write_jsonl(projects_dir, "-Users-test-project", UUID_A, str(cwd)) + + import services.terminal_launch as tl + + monkeypatch.setattr( + tl, + "launch_resume_in_terminal", + lambda *a, **k: {"launched": False, "method": "terminal-window", "detail": "no osascript"}, + ) + + res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") + assert res.status_code == 200 + body = res.json() + assert body["action"] == "failed" + assert body["ok"] is False + assert "osascript" in body["detail"] diff --git a/api/tests/test_terminal_launch.py b/api/tests/test_terminal_launch.py new file mode 100644 index 00000000..11d4e447 --- /dev/null +++ b/api/tests/test_terminal_launch.py @@ -0,0 +1,77 @@ +"""Unit tests for services.terminal_launch (osascript calls mocked).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_api_dir = Path(__file__).resolve().parent.parent +if str(_api_dir) not in sys.path: + sys.path.insert(0, str(_api_dir)) + +import services.terminal_launch as tl + +UUID = "aaaaaaaa-1111-2222-3333-444444444444" + + +def test_build_command_quotes_path(): + cmd = tl._build_command("/Users/me/my repo", UUID) + assert cmd == f"cd '/Users/me/my repo' && claude --resume {UUID}" + + +def test_build_command_quotes_hostile_path(): + cmd = tl._build_command("/tmp/a'; rm -rf ~", UUID) + # shlex.quote must neutralize the embedded quote — no bare `; rm` remains. + assert "; rm" not in cmd.replace("'; rm", "") + assert cmd.startswith("cd ") + + +def test_non_darwin_is_honest(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + result = tl.launch_resume_in_terminal("/tmp", UUID) + assert result["launched"] is False + assert "macOS" in result["detail"] + + +def test_iterm_hint_uses_iterm_when_running(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(tl, "_app_is_running", lambda target: True) + scripts = [] + monkeypatch.setattr(tl, "_osascript", lambda script: scripts.append(script) or None) + + result = tl.launch_resume_in_terminal("/tmp/repo", UUID, term_program="iTerm.app") + assert result["launched"] is True + assert result["method"] == "iterm-tab" + assert 'tell application "iTerm2"' in scripts[0] + assert f"claude --resume {UUID}" in scripts[0] + + +def test_iterm_hint_falls_back_when_not_running(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(tl, "_app_is_running", lambda target: False) + scripts = [] + monkeypatch.setattr(tl, "_osascript", lambda script: scripts.append(script) or None) + + result = tl.launch_resume_in_terminal("/tmp/repo", UUID, term_program="iTerm.app") + assert result["launched"] is True + assert result["method"] == "terminal-window" + assert 'tell application "Terminal"' in scripts[0] + + +def test_default_is_terminal_app(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + scripts = [] + monkeypatch.setattr(tl, "_osascript", lambda script: scripts.append(script) or None) + + result = tl.launch_resume_in_terminal("/tmp/repo", UUID, term_program="Apple_Terminal") + assert result["launched"] is True + assert result["method"] == "terminal-window" + + +def test_osascript_failure_is_honest(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(tl, "_osascript", lambda script: "execution error: not allowed (-1743)") + + result = tl.launch_resume_in_terminal("/tmp/repo", UUID) + assert result["launched"] is False + assert "-1743" in result["detail"] diff --git a/frontend/src/lib/components/ResumeSessionButton.svelte b/frontend/src/lib/components/ResumeSessionButton.svelte new file mode 100644 index 00000000..cd901b41 --- /dev/null +++ b/frontend/src/lib/components/ResumeSessionButton.svelte @@ -0,0 +1,171 @@ + + + + + diff --git a/frontend/src/lib/components/SessionCard.svelte b/frontend/src/lib/components/SessionCard.svelte index 89a5f94d..f69b9be9 100644 --- a/frontend/src/lib/components/SessionCard.svelte +++ b/frontend/src/lib/components/SessionCard.svelte @@ -1,5 +1,6 @@ {#if showResumeChip} - + {/if} {#if session.session_source === 'desktop'}
(null); let copyTimeout: ReturnType | null = null; @@ -261,6 +262,14 @@ // UUID is only present on main sessions (SessionDetail), not subagents. let mainSessionUuid = $derived(isSubagentSession(entity) ? null : entity.uuid); + + // Resume button: mirrors SessionCard's showResumeChip — hide it while the + // session is actively running (the focus button above already covers + // that terminal) and show it once it's waiting/stopped/stale/ended, or + // when the session was never tracked live at all. + let showResume = $derived( + !liveStatus || !['active', 'idle', 'starting'].includes(liveStatus.status) + ); @@ -494,22 +503,10 @@ {mainSessionUuid.slice(0, 8)} {/if} - - + {#if showResume} + + + {/if} {/if}
{/snippet} diff --git a/frontend/src/routes/s/[short_id]/+server.ts b/frontend/src/routes/s/[short_id]/+server.ts new file mode 100644 index 00000000..a26ce350 --- /dev/null +++ b/frontend/src/routes/s/[short_id]/+server.ts @@ -0,0 +1,40 @@ +import { error, redirect } from '@sveltejs/kit'; +import { API_BASE } from '$lib/config'; +import type { RequestHandler } from './$types'; + +interface SessionResolveResult { + uuid: string; + project_encoded_name: string; + slug: string | null; +} + +/** + * Short session links: /s/{uuid-or-prefix} → the session's project page. + * + * Compact enough for a terminal statusline or the `karma` shell command + * (the first 8 hex chars of the session UUID are all it takes). + */ +export const GET: RequestHandler = async ({ params, fetch }) => { + let res: Response; + try { + res = await fetch(`${API_BASE}/sessions/resolve/${encodeURIComponent(params.short_id)}`); + } catch { + error(502, 'Could not reach the Karma API to resolve this session link.'); + } + + if (res.status === 404) { + error(404, `No session matches "${params.short_id}".`); + } + if (!res.ok) { + error(502, `Could not resolve session link "${params.short_id}".`); + } + + const session: SessionResolveResult = await res.json(); + // UUID prefix as the identifier — the project session page accepts it and, + // unlike the slug, it never collides within a resumed chain. Lands on the + // Timeline tab, matching the dock/switcher's own session links. + redirect( + 302, + `/projects/${session.project_encoded_name}/${session.uuid.slice(0, 8)}?tab=timeline` + ); +}; diff --git a/scripts/karma b/scripts/karma new file mode 100755 index 00000000..c75778ab --- /dev/null +++ b/scripts/karma @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# karma — jump from this terminal to the Karma dashboard. +# +# With no arguments, resolves the Claude Code session running on THIS +# terminal (by tty, from ~/.claude_karma/live-sessions/) and opens its +# session page via the short link route (/s/{uuid-prefix}). Falls back to +# the dashboard home when no session is tracked on this tty. +# +# karma # open this terminal's session (or the dashboard) +# karma 1eecfa75 # open a specific session by uuid or uuid prefix +# +# Install: symlink somewhere on PATH, e.g. +# ln -s "$(pwd)/scripts/karma" /usr/local/bin/karma +# +# KARMA_URL overrides the dashboard base (default http://localhost:5180). + +set -euo pipefail + +KARMA_URL="${KARMA_URL:-http://localhost:5180}" +LIVE_DIR="${HOME}/.claude_karma/live-sessions" + +open_url() { + if command -v open >/dev/null 2>&1; then + open "$1" + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$1" + else + echo "$1" + fi +} + +if [[ $# -ge 1 ]]; then + open_url "${KARMA_URL}/s/$1" + exit 0 +fi + +tty_path="$(tty 2>/dev/null || true)" +short_id="" +if [[ -n "$tty_path" && "$tty_path" != "not a tty" && -d "$LIVE_DIR" ]]; then + short_id="$(python3 - "$LIVE_DIR" "$tty_path" <<'PY' +import glob, json, os, sys + +live_dir, tty = sys.argv[1], sys.argv[2] +best_mtime, best_id = 0.0, "" +for path in glob.glob(os.path.join(live_dir, "*.json")): + try: + with open(path) as f: + data = json.load(f) + except (OSError, ValueError): + continue + terminal = data.get("terminal") or {} + session_id = data.get("session_id") or "" + if terminal.get("tty") == tty and session_id: + mtime = os.path.getmtime(path) + if mtime > best_mtime: + best_mtime, best_id = mtime, session_id +print(best_id[:8]) +PY +)" +fi + +if [[ -n "$short_id" ]]; then + open_url "${KARMA_URL}/s/${short_id}" +else + echo "No Claude Code session tracked on this terminal — opening the dashboard." >&2 + open_url "$KARMA_URL" +fi From 586dbd758a986e901f0e04fdaf7085b98e40f785 Mon Sep 17 00:00:00 2001 From: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:23:04 +0530 Subject: [PATCH 2/3] fix(lint): reformat test_session_resume.py for ruff 0.16 CI's Python Lint job installs unpinned latest ruff (0.16.5), which wraps multi-line lambda-with-`or` bodies in parens differently than the repo's pinned dev ruff (0.14.11). Reformatted with 0.16.5 to match CI; both versions now pass clean. Co-Authored-By: Claude Fable 5 --- api/tests/api/test_session_resume.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/api/tests/api/test_session_resume.py b/api/tests/api/test_session_resume.py index d98cec1e..a4281e64 100644 --- a/api/tests/api/test_session_resume.py +++ b/api/tests/api/test_session_resume.py @@ -226,8 +226,10 @@ def test_resume_live_session_focuses_instead(env, monkeypatch): monkeypatch.setattr( tf, "focus_terminal", - lambda terminal: focused_with.update(terminal) - or {"focused": True, "method": "osascript-tab", "detail": "raised"}, + lambda terminal: ( + focused_with.update(terminal) + or {"focused": True, "method": "osascript-tab", "detail": "raised"} + ), ) res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") @@ -282,10 +284,10 @@ def test_resume_without_live_state_uses_jsonl_cwd(env, monkeypatch, tmp_path): monkeypatch.setattr( tl, "launch_resume_in_terminal", - lambda project_path, session_uuid, term_program=None: calls.update( - path=project_path, term=term_program - ) - or {"launched": True, "method": "terminal-window", "detail": "opened"}, + lambda project_path, session_uuid, term_program=None: ( + calls.update(path=project_path, term=term_program) + or {"launched": True, "method": "terminal-window", "detail": "opened"} + ), ) res = client.post(f"/sessions/{UUID_A}/resume-in-terminal") From 9a5a90e1de05ab226a618649c844022a121371b9 Mon Sep 17 00:00:00 2001 From: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:39:18 +0530 Subject: [PATCH 3/3] docs(setup): document the karma statusline link (optional, not auto-installed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Karma Link in Your Status Line" section to SETUP.md, next to the existing Open Terminal Button docs, covering scripts/karma and a new copy-from example script for the statusline. This is documentation only, not an installer: Claude Code's statusLine is a personal, global setting shared across every project, so Karma never writes to it automatically. scripts/example-karma-statusline.py is a minimal reference — point statusLine.command at it directly if you have none, or copy the karma_segment() function into an existing custom statusline instead of overwriting it. Co-Authored-By: Claude Fable 5 --- SETUP.md | 29 +++++++++++++ scripts/example-karma-statusline.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100755 scripts/example-karma-statusline.py diff --git a/SETUP.md b/SETUP.md index a6887b1d..bfe9a6f1 100644 --- a/SETUP.md +++ b/SETUP.md @@ -449,6 +449,35 @@ Once the tracker hook is installed, live sessions show a `>_` button (session pa Sessions started before this feature gain the button automatically after their next activity (the hook backfills terminal identity on any event). Details: [docs/feature/open-terminal/spec.md](./docs/feature/open-terminal/spec.md). +### Karma Link in Your Status Line (optional) + +Every session also gets a short link back to its Karma page: `http://localhost:5180/s/<8-char-id>`, which opens straight to that session's timeline. The link itself (`karma `, or pasting the URL) needs only Tier 1 — it just reads Claude Code's own session files. Auto-detecting *which* session you're currently in needs Tier 2's live tracking, since that's what records which terminal each session runs in: + +- **From any terminal:** `scripts/karma` opens a specific session with `karma ` (Tier 1 is enough), or auto-detects the session on your current tty with plain `karma` (needs Tier 2). Symlink it onto your PATH once: `ln -s "$(pwd)/scripts/karma" /usr/local/bin/karma`. +- **Always visible, in your status line:** unlike the hooks above, Karma does **not** touch your `statusLine` for you — it's a personal, global Claude Code setting shared across every project, so only you should decide what goes into it. [`scripts/example-karma-statusline.py`](./scripts/example-karma-statusline.py) is a copy-from reference, not an installer: + + - No status line yet? Point `statusLine.command` straight at it: + + ```json + { + "statusLine": { + "type": "command", + "command": "python3 /path/to/claude-code-karma/scripts/example-karma-statusline.py" + } + } + ``` + + - Already have a custom status line script? Copy the `karma_segment()` function into it instead of overwriting what you have. + +The link looks different depending on your terminal: + +| Terminal | What you see | +|----------|--------------| +| iTerm2, WezTerm, Kitty, Ghostty | A real clickable link — "see this in karma?", url hidden underneath it (Cmd+click) | +| Apple Terminal.app | The label plus the plain url, both visible — a plain click does nothing here; try Cmd+double-click or right-click the url and choose "Open URL" (Terminal.app has never implemented OSC 8, the standard that lets a label hide a different url; no setting fixes this) | + +**Prerequisite:** the link only opens something if Karma is reachable when you click it — either the dev servers are running, or the desktop app's autostart-at-login is on. Neither running means a blank page or browser error, not a bug. + --- ## Desktop App (macOS & Windows, Recommended) diff --git a/scripts/example-karma-statusline.py b/scripts/example-karma-statusline.py new file mode 100755 index 00000000..640df838 --- /dev/null +++ b/scripts/example-karma-statusline.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Example status-line segment: a "see this in Karma" link for the current session. + +This is a REFERENCE you copy from, not something Karma installs for you. +Claude Code's `statusLine` is a personal, global setting in +`~/.claude/settings.json` shared across every project you use Claude Code +in — only you should decide what goes into it, so Karma never touches it +automatically. + +Two ways to use this: + 1. You have no statusLine yet: point `statusLine.command` straight at this + file (see the settings.json snippet in SETUP.md). + 2. You already have a custom statusLine script: copy the `karma_segment()` + function into it and print its result alongside whatever else you show. + +Prerequisite: Karma's API needs to be reachable when the link is clicked — +either the dev servers are running, or the desktop app's autostart-at-login +is on. Without that, the link opens to nothing. +""" + +from __future__ import annotations + +import json +import os +import sys + +KARMA_URL = os.environ.get("KARMA_URL", "http://localhost:5180") + +# Terminals that render OSC 8 hyperlinks as clickable text (Cmd/Ctrl+click). +# Apple's Terminal.app is deliberately absent: it has never implemented OSC +# 8, so it can only make a *visible* URL clickable, never a short label with +# a different URL hidden underneath it. +OSC8_TERMS = {"iTerm.app", "WezTerm", "ghostty", "kitty", "Hyper", "vscode"} + +RESET, DIM, CYAN = "\x1b[0m", "\x1b[2m", "\x1b[36m" + + +def karma_segment(session_id: str | None) -> str | None: + """A "see this in Karma" link for the current session, or None.""" + if not session_id: + return None + url = f"{KARMA_URL}/s/{session_id[:8]}" + label = f"{CYAN}see this in karma?{RESET}" + if os.environ.get("TERM_PROGRAM") in OSC8_TERMS: + # Real OSC 8 hyperlink: the label is the only visible text, the url + # is invisible metadata underneath it. Cmd+click opens it on macOS. + return f"\x1b]8;;{url}\x07{label}\x1b]8;;\x07" + # Terminal.app can't hide a url behind a label, so both stay visible; + # a plain click does nothing there. Apple has never documented a fixed + # modifier for opening it — try Cmd+double-click or right-click it and + # choose "Open URL" (found to vary by machine). + return f"{label} {DIM}{url}{RESET}" + + +def main() -> None: + data = {} if sys.stdin.isatty() else json.load(sys.stdin) + segment = karma_segment(data.get("session_id")) + if segment: + print(segment) + + +if __name__ == "__main__": + main()