unwind-labs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 unwind-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.3
2
+ Name: unwind-labs
3
+ Version: 0.1.0
4
+ Summary: Outside-in web observer for Claude Code sessions and callstack call trees
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 unwind-labs
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ Keywords: claude-code,agents,observability,callstack
27
+ Author: Amol Kelkar
28
+ Requires-Python: >=3.11
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Classifier: Topic :: Software Development :: Debuggers
35
+ Requires-Dist: fastapi (>=0.115)
36
+ Requires-Dist: psutil (>=5.9)
37
+ Requires-Dist: pydantic (>=2.7)
38
+ Requires-Dist: pyyaml (>=6.0)
39
+ Requires-Dist: rich (>=13.7)
40
+ Requires-Dist: typer (>=0.12)
41
+ Requires-Dist: uvicorn[standard] (>=0.30)
42
+ Requires-Dist: watchdog (>=4.0)
43
+ Project-URL: Homepage, https://github.com/amolk/agent-callstack
44
+ Project-URL: Repository, https://github.com/amolk/agent-callstack
45
+ Description-Content-Type: text/markdown
46
+
47
+ # unwind
48
+
49
+ Local web observer for Claude Code sessions. Run `unwind` in a project folder; a browser tab opens showing every Claude Code session that has run there, the call tree between forked sessions, and a live-updating view of each session's conversation.
50
+
51
+ `unwind` is an **outside-in observer**. It never wraps or intercepts Claude Code. It reads:
52
+
53
+ - `~/.claude/projects/<slug>/*.jsonl` — Claude Code's own session logs
54
+ - `<project>/.claude/callstack/log/` — [callstack](https://github.com/amolk/agent-callstack) plugin invocation reports
55
+
56
+ …and renders them in a web UI. Claude Code itself keeps running in your terminal, untouched.
57
+
58
+ ## What you get
59
+
60
+ - **Left pane** — every session in the project, newest first. Status dot (green = live, amber = idle, grey = done), title (first user prompt), relative time, message count, git branch.
61
+ - **Middle pane** — the call tree rooted at the selected session. Nested rows for callstack forks. Chevrons collapse/expand; each row shows task label, status badge, duration, short session id.
62
+ - **Right pane** — the thread for the selected session (or selected tree node). Markdown rendered, tool calls in collapsible cards pairing `tool_use` with its matching `tool_result`, meta events togglable.
63
+ - **Live updates** — new sessions, new messages, and new callstack children appear without a refresh, driven by a WebSocket fed from a `watchdog` filesystem observer.
64
+ - **Keyboard** — `j`/`k` step through sessions, `/` focuses search.
65
+ - **`--all`** — project picker across every `~/.claude/projects/` entry.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install unwind
71
+ ```
72
+
73
+ From source:
74
+
75
+ ```bash
76
+ git clone https://github.com/amolk/agent-callstack
77
+ cd agent-callstack/unwind
78
+ poetry install
79
+ cd web && npm install && npm run build && cd ..
80
+ poetry run unwind
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ ```bash
86
+ cd /any/claude-project
87
+ unwind
88
+ ```
89
+
90
+ A browser tab opens on an ephemeral `127.0.0.1` port with that project's sessions.
91
+
92
+ Flags:
93
+
94
+ | Flag | Purpose |
95
+ | ---- | ------- |
96
+ | `unwind [path]` | Serve a folder other than CWD. |
97
+ | `unwind --port 8765` | Fix the port. |
98
+ | `unwind --no-browser` | Print the URL, don't open. |
99
+ | `unwind --all` | Project picker over every `~/.claude/projects/` dir. |
100
+ | `unwind --host 0.0.0.0` | Bind beyond loopback (use with care — no auth). |
101
+
102
+ ## Dev loop
103
+
104
+ Two terminals:
105
+
106
+ ```bash
107
+ # terminal 1 — Python backend with autoreload
108
+ poetry shell
109
+ UNWIND_DEFAULT_PATH=/some/project \
110
+ UNWIND_DEFAULT_SLUG=-some-project \
111
+ uvicorn unwind.server:create_app --factory --reload --port 8765
112
+
113
+ # terminal 2 — Vite dev server (proxies /api + WebSocket to :8765)
114
+ cd web && npm run dev
115
+ # open http://localhost:5173
116
+ ```
117
+
118
+ Smoke test:
119
+
120
+ ```bash
121
+ ./dev/smoke.sh
122
+ ```
123
+
124
+ ## Architecture
125
+
126
+ See [dev/PRD.md](dev/PRD.md) and [dev/PLAN.md](dev/PLAN.md) for the full design. Short version:
127
+
128
+ - **Backend** (`src/unwind/`): FastAPI app factory, per-project `SessionIndex` with mtime/size caching, `CallstackIndex` reading `report.yaml`, `ProjectWatcher` (watchdog) publishing typed events to a per-slug `EventBus`, WebSocket endpoint that fans out events to subscribers.
129
+ - **Frontend** (`web/`): Vite + React 18 + TypeScript + Tailwind v3 + hand-written shadcn primitives. TanStack Query for REST; a reconnecting WebSocket client patches the query cache so panes update without polling. zustand for UI state.
130
+ - **Packaging**: `npm run build` in `web/` emits directly into `src/unwind/static/`, which is included in the wheel automatically as package data. One `pip install` = full app.
131
+
132
+ ## Scope / non-goals
133
+
134
+ unwind does **not**:
135
+ - run or control Claude Code
136
+ - send input to any session
137
+ - modify or delete any JSONL or callstack data
138
+ - talk to any remote service (binds to `127.0.0.1` by default)
139
+
140
+ If you want compaction assist, session control, or forking UX, that belongs in Claude Code itself or in a plugin — unwind stays a read-only view.
141
+
142
+ ## Status
143
+
144
+ Early alpha. Tested only on macOS / Python 3.12. Windows is out of scope for v1. Known rough edges:
145
+
146
+ - `claude` process detection can't map a PID to a specific session id (Claude doesn't expose that), so status is scoped to the project. A session counts as "live" if its JSONL was modified in the last ~45s *and* any `claude` process is running in the project cwd.
147
+ - Very large JSONLs (tens of MB) load the whole thread in one shot. Pagination is a Phase 7 item.
148
+
@@ -0,0 +1,101 @@
1
+ # unwind
2
+
3
+ Local web observer for Claude Code sessions. Run `unwind` in a project folder; a browser tab opens showing every Claude Code session that has run there, the call tree between forked sessions, and a live-updating view of each session's conversation.
4
+
5
+ `unwind` is an **outside-in observer**. It never wraps or intercepts Claude Code. It reads:
6
+
7
+ - `~/.claude/projects/<slug>/*.jsonl` — Claude Code's own session logs
8
+ - `<project>/.claude/callstack/log/` — [callstack](https://github.com/amolk/agent-callstack) plugin invocation reports
9
+
10
+ …and renders them in a web UI. Claude Code itself keeps running in your terminal, untouched.
11
+
12
+ ## What you get
13
+
14
+ - **Left pane** — every session in the project, newest first. Status dot (green = live, amber = idle, grey = done), title (first user prompt), relative time, message count, git branch.
15
+ - **Middle pane** — the call tree rooted at the selected session. Nested rows for callstack forks. Chevrons collapse/expand; each row shows task label, status badge, duration, short session id.
16
+ - **Right pane** — the thread for the selected session (or selected tree node). Markdown rendered, tool calls in collapsible cards pairing `tool_use` with its matching `tool_result`, meta events togglable.
17
+ - **Live updates** — new sessions, new messages, and new callstack children appear without a refresh, driven by a WebSocket fed from a `watchdog` filesystem observer.
18
+ - **Keyboard** — `j`/`k` step through sessions, `/` focuses search.
19
+ - **`--all`** — project picker across every `~/.claude/projects/` entry.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install unwind
25
+ ```
26
+
27
+ From source:
28
+
29
+ ```bash
30
+ git clone https://github.com/amolk/agent-callstack
31
+ cd agent-callstack/unwind
32
+ poetry install
33
+ cd web && npm install && npm run build && cd ..
34
+ poetry run unwind
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ cd /any/claude-project
41
+ unwind
42
+ ```
43
+
44
+ A browser tab opens on an ephemeral `127.0.0.1` port with that project's sessions.
45
+
46
+ Flags:
47
+
48
+ | Flag | Purpose |
49
+ | ---- | ------- |
50
+ | `unwind [path]` | Serve a folder other than CWD. |
51
+ | `unwind --port 8765` | Fix the port. |
52
+ | `unwind --no-browser` | Print the URL, don't open. |
53
+ | `unwind --all` | Project picker over every `~/.claude/projects/` dir. |
54
+ | `unwind --host 0.0.0.0` | Bind beyond loopback (use with care — no auth). |
55
+
56
+ ## Dev loop
57
+
58
+ Two terminals:
59
+
60
+ ```bash
61
+ # terminal 1 — Python backend with autoreload
62
+ poetry shell
63
+ UNWIND_DEFAULT_PATH=/some/project \
64
+ UNWIND_DEFAULT_SLUG=-some-project \
65
+ uvicorn unwind.server:create_app --factory --reload --port 8765
66
+
67
+ # terminal 2 — Vite dev server (proxies /api + WebSocket to :8765)
68
+ cd web && npm run dev
69
+ # open http://localhost:5173
70
+ ```
71
+
72
+ Smoke test:
73
+
74
+ ```bash
75
+ ./dev/smoke.sh
76
+ ```
77
+
78
+ ## Architecture
79
+
80
+ See [dev/PRD.md](dev/PRD.md) and [dev/PLAN.md](dev/PLAN.md) for the full design. Short version:
81
+
82
+ - **Backend** (`src/unwind/`): FastAPI app factory, per-project `SessionIndex` with mtime/size caching, `CallstackIndex` reading `report.yaml`, `ProjectWatcher` (watchdog) publishing typed events to a per-slug `EventBus`, WebSocket endpoint that fans out events to subscribers.
83
+ - **Frontend** (`web/`): Vite + React 18 + TypeScript + Tailwind v3 + hand-written shadcn primitives. TanStack Query for REST; a reconnecting WebSocket client patches the query cache so panes update without polling. zustand for UI state.
84
+ - **Packaging**: `npm run build` in `web/` emits directly into `src/unwind/static/`, which is included in the wheel automatically as package data. One `pip install` = full app.
85
+
86
+ ## Scope / non-goals
87
+
88
+ unwind does **not**:
89
+ - run or control Claude Code
90
+ - send input to any session
91
+ - modify or delete any JSONL or callstack data
92
+ - talk to any remote service (binds to `127.0.0.1` by default)
93
+
94
+ If you want compaction assist, session control, or forking UX, that belongs in Claude Code itself or in a plugin — unwind stays a read-only view.
95
+
96
+ ## Status
97
+
98
+ Early alpha. Tested only on macOS / Python 3.12. Windows is out of scope for v1. Known rough edges:
99
+
100
+ - `claude` process detection can't map a PID to a specific session id (Claude doesn't expose that), so status is scoped to the project. A session counts as "live" if its JSONL was modified in the last ~45s *and* any `claude` process is running in the project cwd.
101
+ - Very large JSONLs (tens of MB) load the whole thread in one shot. Pagination is a Phase 7 item.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=2.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "unwind-labs"
7
+ version = "0.1.0"
8
+ description = "Outside-in web observer for Claude Code sessions and callstack call trees"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Amol Kelkar" }]
13
+ keywords = ["claude-code", "agents", "observability", "callstack"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: Debuggers",
21
+ ]
22
+ dependencies = [
23
+ "fastapi>=0.115",
24
+ "uvicorn[standard]>=0.30",
25
+ "watchdog>=4.0",
26
+ "pydantic>=2.7",
27
+ "typer>=0.12",
28
+ "pyyaml>=6.0",
29
+ "psutil>=5.9",
30
+ "rich>=13.7",
31
+ ]
32
+
33
+ [project.scripts]
34
+ unwind = "unwind.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/amolk/agent-callstack"
38
+ Repository = "https://github.com/amolk/agent-callstack"
39
+
40
+ [tool.poetry]
41
+ packages = [{ include = "unwind", from = "src" }]
42
+ include = [
43
+ { path = "src/unwind/static/**/*", format = ["sdist", "wheel"] },
44
+ ]
45
+
46
+ [tool.poetry.group.dev.dependencies]
47
+ pytest = "^8.0"
48
+ pytest-asyncio = "^0.23"
49
+ websockets = "^12.0"
50
+ httpx = "^0.27"
51
+ build = "^1.2"
@@ -0,0 +1,3 @@
1
+ """unwind: outside-in web observer for Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,10 @@
1
+ """FastAPI routers. Aggregated by ``routers()``."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from . import projects, sessions_api, ws
7
+
8
+
9
+ def routers() -> list[APIRouter]:
10
+ return [projects.router, sessions_api.router, ws.router]
@@ -0,0 +1,106 @@
1
+ """Project endpoints: list projects, resolve a slug."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter
8
+ from pydantic import BaseModel
9
+
10
+ from ..dialog import pick_folder
11
+ from ..projects import slug_for
12
+ from ..registry import (
13
+ forget_slug,
14
+ index_for_slug,
15
+ last_activity_for,
16
+ list_known_projects,
17
+ register_default_project,
18
+ )
19
+ from ..server_state import default_source_path, default_slug
20
+ from ..watcher import stop_watcher
21
+
22
+ router = APIRouter(tags=["projects"])
23
+
24
+
25
+ class ProjectSummary(BaseModel):
26
+ slug: str
27
+ source_path: str
28
+ last_activity: Optional[datetime]
29
+ session_count: int
30
+
31
+
32
+ class DefaultProject(BaseModel):
33
+ slug: Optional[str]
34
+ source_path: Optional[str]
35
+
36
+
37
+ class PickedFolder(BaseModel):
38
+ """Result of the system folder-picker dialog.
39
+
40
+ ``cancelled`` is ``True`` when the user dismissed the dialog; in that case
41
+ ``slug`` and ``source_path`` are ``None``.
42
+ """
43
+
44
+ cancelled: bool
45
+ slug: Optional[str] = None
46
+ source_path: Optional[str] = None
47
+
48
+
49
+ @router.get("/projects", response_model=list[ProjectSummary])
50
+ def list_projects() -> list[ProjectSummary]:
51
+ out: list[ProjectSummary] = []
52
+ for slug, source in list_known_projects():
53
+ index = index_for_slug(slug)
54
+ sessions = index.list_sessions()
55
+ last_ts = sessions[0].last_timestamp if sessions else None
56
+ if last_ts is None:
57
+ mtime = last_activity_for(slug)
58
+ if mtime is not None:
59
+ last_ts = datetime.fromtimestamp(mtime, tz=timezone.utc)
60
+ out.append(
61
+ ProjectSummary(
62
+ slug=slug,
63
+ source_path=str(source),
64
+ last_activity=last_ts,
65
+ session_count=len(sessions),
66
+ )
67
+ )
68
+ out.sort(
69
+ key=lambda p: p.last_activity or datetime.fromtimestamp(0, tz=timezone.utc),
70
+ reverse=True,
71
+ )
72
+ return out
73
+
74
+
75
+ @router.get("/projects/default", response_model=DefaultProject)
76
+ def get_default_project() -> DefaultProject:
77
+ path = default_source_path()
78
+ if path is not None:
79
+ register_default_project(path)
80
+ return DefaultProject(slug=default_slug(), source_path=path)
81
+
82
+
83
+ @router.post("/projects/pick-folder", response_model=PickedFolder)
84
+ def pick_folder_endpoint() -> PickedFolder:
85
+ """Open a native folder-picker on the host and register the result.
86
+
87
+ The dialog blocks the request until the user picks or cancels. On pick we
88
+ register the folder so future ``/api/projects`` calls surface its real
89
+ source path (not just a slug-derived synthetic one).
90
+ """
91
+ chosen = pick_folder()
92
+ if chosen is None:
93
+ return PickedFolder(cancelled=True)
94
+ # The slug may already have a cached index built from the synthetic
95
+ # ``for_slug`` fallback (the projects-list endpoint visits every directory
96
+ # under ``~/.claude/projects/``). Drop everything for this slug so the
97
+ # next request rebuilds against the real source path.
98
+ slug = slug_for(chosen)
99
+ stop_watcher(slug)
100
+ forget_slug(slug)
101
+ register_default_project(str(chosen))
102
+ return PickedFolder(
103
+ cancelled=False,
104
+ slug=slug,
105
+ source_path=str(chosen),
106
+ )