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.
@@ -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/
@@ -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.
@@ -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()