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.
- unwind_labs-0.1.0/LICENSE +21 -0
- unwind_labs-0.1.0/PKG-INFO +148 -0
- unwind_labs-0.1.0/README.md +101 -0
- unwind_labs-0.1.0/pyproject.toml +51 -0
- unwind_labs-0.1.0/src/unwind/__init__.py +3 -0
- unwind_labs-0.1.0/src/unwind/__main__.py +4 -0
- unwind_labs-0.1.0/src/unwind/api/__init__.py +10 -0
- unwind_labs-0.1.0/src/unwind/api/projects.py +106 -0
- unwind_labs-0.1.0/src/unwind/api/sessions_api.py +361 -0
- unwind_labs-0.1.0/src/unwind/api/ws.py +64 -0
- unwind_labs-0.1.0/src/unwind/callstack.py +418 -0
- unwind_labs-0.1.0/src/unwind/cli.py +119 -0
- unwind_labs-0.1.0/src/unwind/dialog.py +88 -0
- unwind_labs-0.1.0/src/unwind/events.py +102 -0
- unwind_labs-0.1.0/src/unwind/fork_detect.py +301 -0
- unwind_labs-0.1.0/src/unwind/jsonl.py +236 -0
- unwind_labs-0.1.0/src/unwind/messages.py +595 -0
- unwind_labs-0.1.0/src/unwind/processes.py +88 -0
- unwind_labs-0.1.0/src/unwind/projects.py +73 -0
- unwind_labs-0.1.0/src/unwind/registry.py +144 -0
- unwind_labs-0.1.0/src/unwind/server.py +125 -0
- unwind_labs-0.1.0/src/unwind/server_state.py +13 -0
- unwind_labs-0.1.0/src/unwind/sessions.py +89 -0
- unwind_labs-0.1.0/src/unwind/static/assets/index-BmEdph2O.js +175 -0
- unwind_labs-0.1.0/src/unwind/static/assets/index-D7iPqINC.css +1 -0
- unwind_labs-0.1.0/src/unwind/static/index.html +14 -0
- unwind_labs-0.1.0/src/unwind/subagents.py +156 -0
- unwind_labs-0.1.0/src/unwind/watcher.py +277 -0
|
@@ -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,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
|
+
)
|