crowsnest 0.0.2__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.
- crowsnest-0.0.2/.github/workflows/ci.yml +24 -0
- crowsnest-0.0.2/.gitignore +129 -0
- crowsnest-0.0.2/LICENSE +21 -0
- crowsnest-0.0.2/PKG-INFO +118 -0
- crowsnest-0.0.2/README.md +91 -0
- crowsnest-0.0.2/crowsnest/__init__.py +52 -0
- crowsnest-0.0.2/crowsnest/__main__.py +234 -0
- crowsnest-0.0.2/crowsnest/activity.py +353 -0
- crowsnest-0.0.2/crowsnest/data/agents/crowsnest-scout.md +63 -0
- crowsnest-0.0.2/crowsnest/data/skills/crowsnest/SKILL.md +123 -0
- crowsnest-0.0.2/crowsnest/registry.py +218 -0
- crowsnest-0.0.2/crowsnest/skills.py +158 -0
- crowsnest-0.0.2/crowsnest/tools.py +108 -0
- crowsnest-0.0.2/crowsnest/watch.py +140 -0
- crowsnest-0.0.2/pyproject.toml +63 -0
- crowsnest-0.0.2/tests/conftest.py +4 -0
- crowsnest-0.0.2/tests/fixtures.py +206 -0
- crowsnest-0.0.2/tests/test_activity.py +133 -0
- crowsnest-0.0.2/tests/test_cli.py +67 -0
- crowsnest-0.0.2/tests/test_registry.py +47 -0
- crowsnest-0.0.2/tests/test_watch.py +85 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# wads CI — calls the reusable workflow hosted in i2mint/wads.
|
|
2
|
+
#
|
|
3
|
+
# All configuration comes from this repo's pyproject.toml [tool.wads.ci.*].
|
|
4
|
+
# To customize the workflow itself (rare), replace this file with the
|
|
5
|
+
# full inline template `wads/data/github_ci_uv.yml` from i2mint/wads.
|
|
6
|
+
#
|
|
7
|
+
# Permissions: the reusable workflow needs `contents: write` (version-bump
|
|
8
|
+
# push-back, gh-pages branch push) and `pages: write` (Pages REST config).
|
|
9
|
+
# Both default to read-only on personal-account callers, so they are granted
|
|
10
|
+
# explicitly here.
|
|
11
|
+
name: Continuous Integration
|
|
12
|
+
on: [push, pull_request]
|
|
13
|
+
jobs:
|
|
14
|
+
ci:
|
|
15
|
+
uses: i2mint/wads/.github/workflows/uv-ci.yml@master
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write
|
|
18
|
+
pages: write
|
|
19
|
+
# Explicit pass-through (not `secrets: inherit`, which does not reliably
|
|
20
|
+
# propagate caller-repo secrets to a reusable workflow owned by a different
|
|
21
|
+
# account). crowsnest reads local files and never reaches the network, so
|
|
22
|
+
# PYPI_PASSWORD is the only secret it needs.
|
|
23
|
+
secrets:
|
|
24
|
+
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
docs/_build/
|
|
2
|
+
wads_configs.json
|
|
3
|
+
data/wads_configs.json
|
|
4
|
+
wads/data/wads_configs.json
|
|
5
|
+
|
|
6
|
+
# Byte-compiled / optimized / DLL files
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
.DS_Store
|
|
13
|
+
# C extensions
|
|
14
|
+
*.so
|
|
15
|
+
|
|
16
|
+
# Distribution / packaging
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
develop-eggs/
|
|
20
|
+
dist/
|
|
21
|
+
downloads/
|
|
22
|
+
eggs/
|
|
23
|
+
.eggs/
|
|
24
|
+
lib/
|
|
25
|
+
lib64/
|
|
26
|
+
parts/
|
|
27
|
+
sdist/
|
|
28
|
+
var/
|
|
29
|
+
wheels/
|
|
30
|
+
*.egg-info/
|
|
31
|
+
.installed.cfg
|
|
32
|
+
*.egg
|
|
33
|
+
MANIFEST
|
|
34
|
+
_build
|
|
35
|
+
|
|
36
|
+
# PyInstaller
|
|
37
|
+
# Usually these files are written by a python script from a template
|
|
38
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
39
|
+
*.manifest
|
|
40
|
+
*.spec
|
|
41
|
+
|
|
42
|
+
# Installer logs
|
|
43
|
+
pip-log.txt
|
|
44
|
+
pip-delete-this-directory.txt
|
|
45
|
+
|
|
46
|
+
# Unit test / coverage reports
|
|
47
|
+
htmlcov/
|
|
48
|
+
.tox/
|
|
49
|
+
.coverage
|
|
50
|
+
.coverage.*
|
|
51
|
+
.cache
|
|
52
|
+
nosetests.xml
|
|
53
|
+
coverage.xml
|
|
54
|
+
*.cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
|
|
58
|
+
# Translations
|
|
59
|
+
*.mo
|
|
60
|
+
*.pot
|
|
61
|
+
|
|
62
|
+
# Django stuff:
|
|
63
|
+
*.log
|
|
64
|
+
local_settings.py
|
|
65
|
+
db.sqlite3
|
|
66
|
+
|
|
67
|
+
# Flask stuff:
|
|
68
|
+
instance/
|
|
69
|
+
.webassets-cache
|
|
70
|
+
|
|
71
|
+
# Scrapy stuff:
|
|
72
|
+
.scrapy
|
|
73
|
+
|
|
74
|
+
# Sphinx documentation
|
|
75
|
+
docs/_build/
|
|
76
|
+
|
|
77
|
+
# PyBuilder
|
|
78
|
+
target/
|
|
79
|
+
|
|
80
|
+
# Jupyter Notebook
|
|
81
|
+
.ipynb_checkpoints
|
|
82
|
+
|
|
83
|
+
# pyenv
|
|
84
|
+
.python-version
|
|
85
|
+
|
|
86
|
+
# celery beat schedule file
|
|
87
|
+
celerybeat-schedule
|
|
88
|
+
|
|
89
|
+
# SageMath parsed files
|
|
90
|
+
*.sage.py
|
|
91
|
+
|
|
92
|
+
# Environments
|
|
93
|
+
.env
|
|
94
|
+
.venv
|
|
95
|
+
env/
|
|
96
|
+
venv/
|
|
97
|
+
ENV/
|
|
98
|
+
env.bak/
|
|
99
|
+
venv.bak/
|
|
100
|
+
|
|
101
|
+
# Spyder project settings
|
|
102
|
+
.spyderproject
|
|
103
|
+
.spyproject
|
|
104
|
+
|
|
105
|
+
# Rope project settings
|
|
106
|
+
.ropeproject
|
|
107
|
+
|
|
108
|
+
# mkdocs documentation
|
|
109
|
+
/site
|
|
110
|
+
|
|
111
|
+
# mypy
|
|
112
|
+
.mypy_cache/
|
|
113
|
+
|
|
114
|
+
# PyCharm
|
|
115
|
+
.idea
|
|
116
|
+
|
|
117
|
+
# Ruff
|
|
118
|
+
.ruff_cache/
|
|
119
|
+
|
|
120
|
+
# VS Code
|
|
121
|
+
.vscode/
|
|
122
|
+
|
|
123
|
+
# Temporary test outputs
|
|
124
|
+
batch_results.json
|
|
125
|
+
*.tmp
|
|
126
|
+
tmp/
|
|
127
|
+
temp/
|
|
128
|
+
# Local session handoffs (not for commit)
|
|
129
|
+
.claude/handoffs/
|
crowsnest-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thor Whalen
|
|
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.
|
crowsnest-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crowsnest
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: One session that watches your other Claude Code sessions: who is busy, who is waiting on you, what each one just said, and how to ask one directly
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/crowsnest
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/crowsnest
|
|
7
|
+
Project-URL: Issues, https://github.com/thorwhalen/crowsnest/issues
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,claude-code,monitor,sessions,transcripts
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: cw<0.2,>=0.1.1
|
|
22
|
+
Requires-Dist: openloops>=0.1.8
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# crowsnest
|
|
29
|
+
|
|
30
|
+
**One session that watches your other Claude Code sessions.**
|
|
31
|
+
|
|
32
|
+
A machine running many Claude Code sessions has a question nobody answers: *what are they all doing, and which of them needs me?* Each session knows only itself, the terminal tabs are silent until you click them, and the answer lives in forty scrollbacks.
|
|
33
|
+
|
|
34
|
+
`crowsnest` reads what Claude Code already writes, the registry it keeps for every running session and the transcript each one appends to, and answers in three tiers, cheapest first. It never sends, spawns, kills or writes into another session.
|
|
35
|
+
|
|
36
|
+
## Start here
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install crowsnest # Python 3.10+. Puts a `crowsnest` command on your PATH.
|
|
40
|
+
crowsnest install-skills # link the skill and the scout subagent into ~/.claude
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then open a session, name it, and ask in whatever words you would have used anyway:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
claude -n lookout
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> **what are my sessions doing, and is anything waiting on me?**
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
## Waiting on you
|
|
53
|
+
- xa_needed_or_not (xa, 25m) — input needed
|
|
54
|
+
|
|
55
|
+
## Just finished
|
|
56
|
+
- monitor (proj, 10m) — "The sweep landed. It's the repo-side view, and it's complementary…"
|
|
57
|
+
|
|
58
|
+
## Working
|
|
59
|
+
- openloops: one session, mid-turn on the one-command test.
|
|
60
|
+
|
|
61
|
+
## Headline
|
|
62
|
+
1 waiting on you, 1 just finished, 1 working, 29 idle.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That answer is a synthesis. Every row behind it is one the `crowsnest` command printed, and the skill tells the session how to go deeper when a row is not enough: read a session's earlier turns, ask the session itself, or arm the event stream and be told.
|
|
66
|
+
|
|
67
|
+
## The three tiers
|
|
68
|
+
|
|
69
|
+
| Tier | What | Costs the watched session |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 1. read | `crowsnest`, `crowsnest show X`, `crowsnest turns X` — the registry and the transcript tail | nothing |
|
|
72
|
+
| 2. ask | message a running session (Claude Code's `SendMessage`) and get an answer from its own context | one turn of its context |
|
|
73
|
+
| 3. be told | `crowsnest watch`, one line per change, fed to the watching session's `Monitor` tool | nothing |
|
|
74
|
+
|
|
75
|
+
The skill's rule is to never spend a costlier tier when a cheaper one answers, and never to ask a session that is `busy` or `waiting`.
|
|
76
|
+
|
|
77
|
+
## The command
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
crowsnest who is alive: waiting on you first, then busy, then idle
|
|
81
|
+
crowsnest show <session> one session: last asked, last said, running now, pending question
|
|
82
|
+
crowsnest turns <session> -n 5 the last five turns, oldest first; --before N pages back
|
|
83
|
+
crowsnest watch one line per change, forever (started, exited, idle, busy, waiting, error)
|
|
84
|
+
crowsnest install-skills link the skill and the scout subagent into ~/.claude
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`<session>` is the name you gave the session with `claude -n <name>`, a unique prefix of one, a session-id prefix, or a pid.
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
waiting 25m xa_needed_or_not xa input needed · Let me split that into smaller steps.
|
|
91
|
+
busy 14m session_monitor openloops → Bash: Run the one-command test
|
|
92
|
+
idle 10m monitor proj "The sweep landed. It's the repo-side view…"
|
|
93
|
+
-- 32 live: 1 waiting, 1 busy, 30 idle
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## What it reads
|
|
97
|
+
|
|
98
|
+
- `~/.claude/sessions/<pid>.json`: written while a session runs. Name, session id, working directory, `busy` / `idle` / `waiting`, and when waiting, what for. Checked against a live process before it is reported, because a crash leaves the file behind.
|
|
99
|
+
- `~/.claude/projects/<slug>/<session-id>.jsonl`: the transcript. Read from the end, widening until one human prompt is in view; `turns` reads the whole file on request.
|
|
100
|
+
|
|
101
|
+
What a transcript's content *means* is [openloops](https://github.com/thorwhalen/openloops)' business, and crowsnest calls it rather than re-implementing it. openloops deliberately never looks at whether a process is running; crowsnest is that other half.
|
|
102
|
+
|
|
103
|
+
## From Python
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from crowsnest import roster, show, turns, events, live_sessions
|
|
107
|
+
|
|
108
|
+
roster()["counts"] # {'waiting': 1, 'busy': 1, 'idle': 30, 'other': 0}
|
|
109
|
+
show("monitor")["activity"]["last_assistant_text"]
|
|
110
|
+
for event in events(interval=5): # forever
|
|
111
|
+
...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every function takes `home=` (the Claude Code config directory; a synced copy of another machine's works the same way) and the readers take `is_alive=` (how a registry pid is confirmed running).
|
|
115
|
+
|
|
116
|
+
## Not in crowsnest
|
|
117
|
+
|
|
118
|
+
Starting a session, in some directory, from a phone, is [xa](https://github.com/thorwhalen/xa)'s job (`xa spawn`). Asking a session a question is Claude Code's own `SendMessage`; the skill says when.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# crowsnest
|
|
2
|
+
|
|
3
|
+
**One session that watches your other Claude Code sessions.**
|
|
4
|
+
|
|
5
|
+
A machine running many Claude Code sessions has a question nobody answers: *what are they all doing, and which of them needs me?* Each session knows only itself, the terminal tabs are silent until you click them, and the answer lives in forty scrollbacks.
|
|
6
|
+
|
|
7
|
+
`crowsnest` reads what Claude Code already writes, the registry it keeps for every running session and the transcript each one appends to, and answers in three tiers, cheapest first. It never sends, spawns, kills or writes into another session.
|
|
8
|
+
|
|
9
|
+
## Start here
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install crowsnest # Python 3.10+. Puts a `crowsnest` command on your PATH.
|
|
13
|
+
crowsnest install-skills # link the skill and the scout subagent into ~/.claude
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then open a session, name it, and ask in whatever words you would have used anyway:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
claude -n lookout
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
> **what are my sessions doing, and is anything waiting on me?**
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
## Waiting on you
|
|
26
|
+
- xa_needed_or_not (xa, 25m) — input needed
|
|
27
|
+
|
|
28
|
+
## Just finished
|
|
29
|
+
- monitor (proj, 10m) — "The sweep landed. It's the repo-side view, and it's complementary…"
|
|
30
|
+
|
|
31
|
+
## Working
|
|
32
|
+
- openloops: one session, mid-turn on the one-command test.
|
|
33
|
+
|
|
34
|
+
## Headline
|
|
35
|
+
1 waiting on you, 1 just finished, 1 working, 29 idle.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
That answer is a synthesis. Every row behind it is one the `crowsnest` command printed, and the skill tells the session how to go deeper when a row is not enough: read a session's earlier turns, ask the session itself, or arm the event stream and be told.
|
|
39
|
+
|
|
40
|
+
## The three tiers
|
|
41
|
+
|
|
42
|
+
| Tier | What | Costs the watched session |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| 1. read | `crowsnest`, `crowsnest show X`, `crowsnest turns X` — the registry and the transcript tail | nothing |
|
|
45
|
+
| 2. ask | message a running session (Claude Code's `SendMessage`) and get an answer from its own context | one turn of its context |
|
|
46
|
+
| 3. be told | `crowsnest watch`, one line per change, fed to the watching session's `Monitor` tool | nothing |
|
|
47
|
+
|
|
48
|
+
The skill's rule is to never spend a costlier tier when a cheaper one answers, and never to ask a session that is `busy` or `waiting`.
|
|
49
|
+
|
|
50
|
+
## The command
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
crowsnest who is alive: waiting on you first, then busy, then idle
|
|
54
|
+
crowsnest show <session> one session: last asked, last said, running now, pending question
|
|
55
|
+
crowsnest turns <session> -n 5 the last five turns, oldest first; --before N pages back
|
|
56
|
+
crowsnest watch one line per change, forever (started, exited, idle, busy, waiting, error)
|
|
57
|
+
crowsnest install-skills link the skill and the scout subagent into ~/.claude
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`<session>` is the name you gave the session with `claude -n <name>`, a unique prefix of one, a session-id prefix, or a pid.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
waiting 25m xa_needed_or_not xa input needed · Let me split that into smaller steps.
|
|
64
|
+
busy 14m session_monitor openloops → Bash: Run the one-command test
|
|
65
|
+
idle 10m monitor proj "The sweep landed. It's the repo-side view…"
|
|
66
|
+
-- 32 live: 1 waiting, 1 busy, 30 idle
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What it reads
|
|
70
|
+
|
|
71
|
+
- `~/.claude/sessions/<pid>.json`: written while a session runs. Name, session id, working directory, `busy` / `idle` / `waiting`, and when waiting, what for. Checked against a live process before it is reported, because a crash leaves the file behind.
|
|
72
|
+
- `~/.claude/projects/<slug>/<session-id>.jsonl`: the transcript. Read from the end, widening until one human prompt is in view; `turns` reads the whole file on request.
|
|
73
|
+
|
|
74
|
+
What a transcript's content *means* is [openloops](https://github.com/thorwhalen/openloops)' business, and crowsnest calls it rather than re-implementing it. openloops deliberately never looks at whether a process is running; crowsnest is that other half.
|
|
75
|
+
|
|
76
|
+
## From Python
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from crowsnest import roster, show, turns, events, live_sessions
|
|
80
|
+
|
|
81
|
+
roster()["counts"] # {'waiting': 1, 'busy': 1, 'idle': 30, 'other': 0}
|
|
82
|
+
show("monitor")["activity"]["last_assistant_text"]
|
|
83
|
+
for event in events(interval=5): # forever
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every function takes `home=` (the Claude Code config directory; a synced copy of another machine's works the same way) and the readers take `is_alive=` (how a registry pid is confirmed running).
|
|
88
|
+
|
|
89
|
+
## Not in crowsnest
|
|
90
|
+
|
|
91
|
+
Starting a session, in some directory, from a phone, is [xa](https://github.com/thorwhalen/xa)'s job (`xa spawn`). Asking a session a question is Claude Code's own `SendMessage`; the skill says when.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""One session that watches the others.
|
|
2
|
+
|
|
3
|
+
A machine running many Claude Code sessions has a question nobody answers: *what are they
|
|
4
|
+
all doing, and which of them needs me?* Each session knows only itself, the terminal tabs
|
|
5
|
+
are silent until you click them, and the answer lives in forty scrollbacks.
|
|
6
|
+
|
|
7
|
+
``crowsnest`` reads what Claude Code already writes -- the registry it keeps for every
|
|
8
|
+
running session, and the transcript each session appends to -- and answers in three
|
|
9
|
+
tiers, cheapest first:
|
|
10
|
+
|
|
11
|
+
1. **The roster** (:func:`crowsnest.tools.roster`): who is alive, busy, idle or waiting,
|
|
12
|
+
where, since when. Instant; no transcript is read.
|
|
13
|
+
2. **The activity** (:func:`crowsnest.tools.show`, :func:`crowsnest.tools.turns`): what a
|
|
14
|
+
session was last asked, what it last said, the tool it is running now, the question
|
|
15
|
+
it is waiting on -- read from the tail of its transcript, which costs the watched
|
|
16
|
+
session nothing and never interrupts it. ``turns`` pages further back when the tail is
|
|
17
|
+
not enough.
|
|
18
|
+
3. **The ask**: a running session can be *messaged* and will answer from its own
|
|
19
|
+
context. That is a Claude Code feature, not a Python one, so it lives in the shipped
|
|
20
|
+
skill (``crowsnest/data/skills/crowsnest/SKILL.md``) rather than here -- with the rule
|
|
21
|
+
that says when it is worth a turn of someone else's context and when it is not.
|
|
22
|
+
|
|
23
|
+
And one stream: :func:`crowsnest.watch.events` yields a line every time a session starts,
|
|
24
|
+
exits, finishes a turn, or starts waiting on its human, so a monitor is told rather than
|
|
25
|
+
made to poll.
|
|
26
|
+
|
|
27
|
+
Everything here is read-only. Nothing sends, spawns, kills, or writes into another
|
|
28
|
+
session; the one write in the package is the skill installer, and it writes symlinks.
|
|
29
|
+
|
|
30
|
+
>>> from crowsnest import live_sessions, roster
|
|
31
|
+
>>> live_sessions(home='/nonexistent-dir-for-doctest')
|
|
32
|
+
[]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from crowsnest.activity import Activity, Turn, read_activity, read_turns
|
|
36
|
+
from crowsnest.registry import LiveSession, live_sessions
|
|
37
|
+
from crowsnest.tools import resolve, roster, show, turns
|
|
38
|
+
from crowsnest.watch import events
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Activity",
|
|
42
|
+
"LiveSession",
|
|
43
|
+
"Turn",
|
|
44
|
+
"events",
|
|
45
|
+
"live_sessions",
|
|
46
|
+
"read_activity",
|
|
47
|
+
"read_turns",
|
|
48
|
+
"resolve",
|
|
49
|
+
"roster",
|
|
50
|
+
"show",
|
|
51
|
+
"turns",
|
|
52
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The ``crowsnest`` command: the one surface v0 builds.
|
|
2
|
+
|
|
3
|
+
Every verb is a thin renderer over a function in :mod:`crowsnest.tools`, the single list
|
|
4
|
+
all surfaces dispatch from. The core prints nothing and exits nothing; the formatting is
|
|
5
|
+
here so that a later MCP or HTTP adapter needs no change to the core.
|
|
6
|
+
|
|
7
|
+
Bare ``crowsnest`` prints the roster, because the fewest keystrokes have to produce the
|
|
8
|
+
useful thing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as _json
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from crowsnest import skills as _skills
|
|
20
|
+
from crowsnest import tools
|
|
21
|
+
from crowsnest import watch as _watch
|
|
22
|
+
|
|
23
|
+
__all__ = ["main"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_COMMAND = "roster"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _age(epoch: float | None) -> str:
|
|
29
|
+
if not epoch:
|
|
30
|
+
return "?"
|
|
31
|
+
seconds = max(0.0, datetime.now(timezone.utc).timestamp() - epoch)
|
|
32
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
33
|
+
if seconds >= size:
|
|
34
|
+
return f"{seconds / size:.0f}{unit}"
|
|
35
|
+
return f"{seconds:.0f}s"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _one_line(text: str, limit: int) -> str:
|
|
39
|
+
text = " ".join((text or "").split())
|
|
40
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _local(stamp: str) -> str:
|
|
44
|
+
"""An ISO timestamp as local ``HH:MM``, or the raw value when unparseable."""
|
|
45
|
+
try:
|
|
46
|
+
return (
|
|
47
|
+
datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
48
|
+
.astimezone()
|
|
49
|
+
.strftime("%H:%M")
|
|
50
|
+
)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return stamp
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _row_detail(row: dict, limit: int) -> str:
|
|
56
|
+
act = row.get("activity") or {}
|
|
57
|
+
status = row["status"]
|
|
58
|
+
if status == "waiting":
|
|
59
|
+
cause = act.get("pending_question") or "; ".join(act.get("in_flight") or ())
|
|
60
|
+
parts = [
|
|
61
|
+
row.get("waiting_for") or "waiting",
|
|
62
|
+
cause or act.get("last_assistant_text", ""),
|
|
63
|
+
]
|
|
64
|
+
return _one_line(" · ".join(p for p in parts if p), limit)
|
|
65
|
+
if status == "busy":
|
|
66
|
+
running = "; ".join(act.get("in_flight") or ())
|
|
67
|
+
if running:
|
|
68
|
+
return _one_line("→ " + running, limit)
|
|
69
|
+
return _one_line("asked: " + act.get("last_user_prompt", ""), limit)
|
|
70
|
+
said = act.get("last_assistant_text", "")
|
|
71
|
+
mark = "⚠ " if act.get("errored") else ""
|
|
72
|
+
return _one_line(f'{mark}"{said}"' if said else "", limit)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def roster(*, home: str | None = None, brief: bool = False, width: int = 110):
|
|
76
|
+
"""Who is alive, most urgent first: waiting on you, then busy, then idle.
|
|
77
|
+
|
|
78
|
+
`--brief` answers from the registry alone, without reading any transcript.
|
|
79
|
+
"""
|
|
80
|
+
result = tools.roster(home=home, activity=not brief)
|
|
81
|
+
lines = []
|
|
82
|
+
for row in result["sessions"]:
|
|
83
|
+
head = f"{row['status']:<8}{_age(row['status_since']):>4} {row['label'][:26]:<27}{row['project'][:16]:<17}"
|
|
84
|
+
detail = "" if brief else _row_detail(row, max(20, width - len(head)))
|
|
85
|
+
lines.append((head + detail).rstrip())
|
|
86
|
+
counts = result["counts"]
|
|
87
|
+
summary = ", ".join(f"{n} {k}" for k, n in counts.items() if n)
|
|
88
|
+
lines.append(f"-- {len(result['sessions'])} live: {summary or 'none'}")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def show(session: str, *, home: str | None = None, recent: int = 8, json: bool = False):
|
|
93
|
+
"""One session in full: what it was asked, what it said, what it is running now.
|
|
94
|
+
|
|
95
|
+
`session` is a registry name, a unique prefix of one, a session-id prefix, or a pid.
|
|
96
|
+
"""
|
|
97
|
+
result = tools.show(session, home=home, recent=recent)
|
|
98
|
+
if json:
|
|
99
|
+
return _json.dumps(result, indent=2)
|
|
100
|
+
s, act = result["session"], result["activity"]
|
|
101
|
+
since = _age(s["status_since"])
|
|
102
|
+
out = [
|
|
103
|
+
f"# {s['label']} ({s['status']} for {since}"
|
|
104
|
+
+ (f", {s['waiting_for']}" if s["waiting_for"] else "")
|
|
105
|
+
+ ")"
|
|
106
|
+
]
|
|
107
|
+
out.append(
|
|
108
|
+
f"pid {s['pid']} · session {s['session_id'][:8]} · {s['cwd']}"
|
|
109
|
+
+ (f" · branch {act['git_branch']}" if act["git_branch"] else "")
|
|
110
|
+
+ (" · remote control on" if s["remote_control"] else "")
|
|
111
|
+
)
|
|
112
|
+
if act["pending_question"]:
|
|
113
|
+
out += ["", "## Waiting on you", act["pending_question"]]
|
|
114
|
+
if act["in_flight"]:
|
|
115
|
+
out += ["", "## In flight", *[f"- {t}" for t in act["in_flight"]]]
|
|
116
|
+
out += [
|
|
117
|
+
"",
|
|
118
|
+
f"## Last asked ({_local(act['last_prompt_at'])})",
|
|
119
|
+
act["last_user_prompt"] or "(none in the tail)",
|
|
120
|
+
]
|
|
121
|
+
out += [
|
|
122
|
+
"",
|
|
123
|
+
f"## Last said ({_local(act['last_text_at'])})",
|
|
124
|
+
act["last_assistant_text"] or "(none in the tail)",
|
|
125
|
+
]
|
|
126
|
+
if act["recent_tools"]:
|
|
127
|
+
out += ["", "## Recent tools", *[f"- {t}" for t in act["recent_tools"]]]
|
|
128
|
+
flags = [k for k in ("turn_open", "errored") if act[k]]
|
|
129
|
+
if flags or not act["tail_complete"]:
|
|
130
|
+
out += [
|
|
131
|
+
"",
|
|
132
|
+
"flags: "
|
|
133
|
+
+ ", ".join(flags + ([] if act["tail_complete"] else ["tail only"])),
|
|
134
|
+
]
|
|
135
|
+
return "\n".join(out)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def turns(
|
|
139
|
+
session: str,
|
|
140
|
+
*,
|
|
141
|
+
last: int = 5,
|
|
142
|
+
before: int | None = None,
|
|
143
|
+
home: str | None = None,
|
|
144
|
+
json: bool = False,
|
|
145
|
+
):
|
|
146
|
+
"""The last few turns of a session, oldest first. `--before N` pages back from turn N."""
|
|
147
|
+
result = tools.turns(session, last=last, before=before, home=home)
|
|
148
|
+
if json:
|
|
149
|
+
return _json.dumps(result, indent=2)
|
|
150
|
+
out = [f"# {result['session']['label']} — turns"]
|
|
151
|
+
for t in result["turns"]:
|
|
152
|
+
out += [
|
|
153
|
+
"",
|
|
154
|
+
f"## turn {t['index']} ({_local(t['prompt_at'])})",
|
|
155
|
+
f"> {t['prompt']}",
|
|
156
|
+
]
|
|
157
|
+
if t["tools"]:
|
|
158
|
+
out.append(
|
|
159
|
+
f"tools ({len(t['tools'])}): "
|
|
160
|
+
+ "; ".join(t["tools"][:8])
|
|
161
|
+
+ (" …" if len(t["tools"]) > 8 else "")
|
|
162
|
+
)
|
|
163
|
+
out.append(t["reply"] or "(no final text)")
|
|
164
|
+
if not result["turns"]:
|
|
165
|
+
out.append("(no turns)")
|
|
166
|
+
return "\n".join(out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def watch(
|
|
170
|
+
*, interval: float = _watch.DFLT_INTERVAL, home: str | None = None, json: bool = False
|
|
171
|
+
):
|
|
172
|
+
"""Print one line per change, forever: started, exited, idle, busy, waiting, error.
|
|
173
|
+
|
|
174
|
+
Built for Claude Code's `Monitor` tool: each line becomes a notification in the
|
|
175
|
+
watching session. Stop with Ctrl-C.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
for event in _watch.events(interval=interval, home=home):
|
|
179
|
+
if json:
|
|
180
|
+
line = _json.dumps(event)
|
|
181
|
+
else:
|
|
182
|
+
when = _local(event["at"])
|
|
183
|
+
line = f"{when} {event['kind']:<8} {event['name']} ({event['project']})"
|
|
184
|
+
if event["detail"]:
|
|
185
|
+
line += f" — {event['detail']}"
|
|
186
|
+
print(line, flush=True)
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def install_skills(
|
|
192
|
+
*,
|
|
193
|
+
target: str | None = None,
|
|
194
|
+
only: str | None = None,
|
|
195
|
+
force: bool = False,
|
|
196
|
+
dry_run: bool = False,
|
|
197
|
+
):
|
|
198
|
+
"""Link the bundled skill and subagent into ~/.claude (or `--target`). Idempotent."""
|
|
199
|
+
names = [n for n in (only or "").split(",") if n.strip()] or None
|
|
200
|
+
plan = _skills.install_skills(target=target, only=names, force=force, dry_run=dry_run)
|
|
201
|
+
lines = [f"{'would install' if dry_run else 'installed'} into {plan['target']}"]
|
|
202
|
+
for row in plan["actions"]:
|
|
203
|
+
how = f" ({row['method']})" if row["method"] else ""
|
|
204
|
+
lines.append(
|
|
205
|
+
f"{row['action']:<9}{row['kind']:<7}{row['name']:<18}{row['reason']}{how}"
|
|
206
|
+
)
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_commands = [roster, show, turns, watch, install_skills]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> None:
|
|
214
|
+
"""Dispatch the ``crowsnest`` command. Bare ``crowsnest`` runs :func:`roster`."""
|
|
215
|
+
import cw
|
|
216
|
+
|
|
217
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
218
|
+
if not argv or argv[0].startswith("-") and argv[0] not in ("-h", "--help"):
|
|
219
|
+
argv = [DEFAULT_COMMAND, *argv]
|
|
220
|
+
parser = cw.mk_parser(
|
|
221
|
+
_commands, prog="crowsnest", description=__doc__.splitlines()[0]
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
code = cw.run(parser, argv)
|
|
225
|
+
except (ValueError, KeyError) as exc:
|
|
226
|
+
message = exc.args[0] if exc.args else str(exc)
|
|
227
|
+
print(f"crowsnest: {message}", file=sys.stderr)
|
|
228
|
+
sys.exit(2)
|
|
229
|
+
if code:
|
|
230
|
+
raise SystemExit(code)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|