collossal 0.1.0__py3-none-any.whl
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.
- collossal-0.1.0.dist-info/METADATA +218 -0
- collossal-0.1.0.dist-info/RECORD +26 -0
- collossal-0.1.0.dist-info/WHEEL +4 -0
- collossal-0.1.0.dist-info/entry_points.txt +5 -0
- main_mcp/__init__.py +16 -0
- main_mcp/agents.py +278 -0
- main_mcp/capture/__init__.py +1 -0
- main_mcp/capture/checkouts.py +83 -0
- main_mcp/capture/history.py +349 -0
- main_mcp/capture/hook.py +386 -0
- main_mcp/capture/redact.py +200 -0
- main_mcp/capture/shell_edits.py +305 -0
- main_mcp/capture/ship.py +135 -0
- main_mcp/capture/tailer.py +430 -0
- main_mcp/capture/transcript.py +259 -0
- main_mcp/capture/turn_worker.py +124 -0
- main_mcp/capture/turns.py +148 -0
- main_mcp/capture/windows.py +509 -0
- main_mcp/capture/worker.py +130 -0
- main_mcp/cli.py +517 -0
- main_mcp/client.py +249 -0
- main_mcp/config.py +114 -0
- main_mcp/format.py +64 -0
- main_mcp/git_context.py +122 -0
- main_mcp/room.py +747 -0
- main_mcp/server.py +319 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: collossal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Collossal CLI: connects your coding agent to your team's sessions, memory and rooms.
|
|
5
|
+
Author: Vipul
|
|
6
|
+
Author-email: Vipul <vilulgupta2802@gmail.com>
|
|
7
|
+
Requires-Dist: mcp[cli]>=2.2.0
|
|
8
|
+
Requires-Dist: httpx2>=2.12.0
|
|
9
|
+
Requires-Dist: websockets>=13.0
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Collossal MCP
|
|
14
|
+
|
|
15
|
+
Shared memory for coding agents. Runs on a developer's machine and talks to the
|
|
16
|
+
Collossal API over HTTPS — it holds an API key and never database credentials.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
One command per machine — the dashboard's Setup page shows it with your
|
|
21
|
+
addresses filled in:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
curl -fsSL https://<your-collossal>/install.sh | sh -s -- --api-url https://<your-collossal>/api/v1
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That installs the `collossal` command (with uv, which brings its own Python) and
|
|
28
|
+
runs `collossal install`, which:
|
|
29
|
+
|
|
30
|
+
1. **Signs the machine in through the browser.** It shows a code and opens the
|
|
31
|
+
dashboard; you check the code and approve. The machine receives an API key
|
|
32
|
+
of your own — nothing to copy — saved to `~/.collossal/config.json` (0600).
|
|
33
|
+
2. **Sets up Claude Code for every project:** the hooks below go into
|
|
34
|
+
`~/.claude/settings.json` (your other settings are kept, and the original is
|
|
35
|
+
backed up once), and the MCP server is registered with
|
|
36
|
+
`claude mcp add --scope user`.
|
|
37
|
+
3. **Uploads your past sessions** from `~/.claude/projects` — every one,
|
|
38
|
+
whatever it ran in — with their real timestamps, titles and redaction, so
|
|
39
|
+
your history appears on the dashboard, not just what happens from now on.
|
|
40
|
+
|
|
41
|
+
From then on every Claude Code session on the machine streams to your team's
|
|
42
|
+
Sessions tab as it happens, and every teammate can open it. Nothing has to be
|
|
43
|
+
connected first: a session is labelled by the repository it ran in (or, begun
|
|
44
|
+
in a folder holding several, the one it works in most), or by its folder. A
|
|
45
|
+
repository connected to a project additionally gets memory — decisions
|
|
46
|
+
anchored to its files.
|
|
47
|
+
|
|
48
|
+
Each person chooses which of their sessions the team sees, under **Sessions →
|
|
49
|
+
Configure**: all of them (the default), or only those in the repositories and
|
|
50
|
+
folders they pick. The choice applies at once to past sessions as well as new
|
|
51
|
+
ones. A session outside it is still recorded, and its owner still sees it under
|
|
52
|
+
My sessions — nobody else does: not teammates, not workspace admins, not an
|
|
53
|
+
agent's `team_sessions` or a handoff. Setting it before running the installer
|
|
54
|
+
keeps the upload of past sessions within it from the start.
|
|
55
|
+
|
|
56
|
+
Until the CLI is published, install it from this checkout instead:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv tool install --editable /path/to/main-mcp && collossal install --api-url http://localhost:3000/api/v1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Command | Does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `collossal install` | All of the above; safe to run again |
|
|
65
|
+
| `collossal import` | Upload past sessions again — e.g. after connecting another repo |
|
|
66
|
+
| `collossal status` | Who this machine is signed in as, and what is set up |
|
|
67
|
+
| `collossal doctor` | The same, plus what is wrong and how to fix it |
|
|
68
|
+
| `collossal login` | Sign in again without changing anything else |
|
|
69
|
+
| `collossal uninstall` | Remove the hooks, the MCP server and the sign-in |
|
|
70
|
+
|
|
71
|
+
`COLLOSSAL_API_KEY` and `COLLOSSAL_API_URL` still work and take precedence over
|
|
72
|
+
the saved sign-in — for CI, or to pin one project to a different workspace.
|
|
73
|
+
|
|
74
|
+
## Tools
|
|
75
|
+
|
|
76
|
+
**`memory_write`** — records a decision and anchors it to the files it governs.
|
|
77
|
+
|
|
78
|
+
**`memory_search`** — natural-language search across the project. Results can
|
|
79
|
+
come from a *different repository* than the one being edited; that is the point.
|
|
80
|
+
|
|
81
|
+
**`memory_for_files`** — decisions attached to specific files. Needs no query,
|
|
82
|
+
so it surfaces context an agent would not have known to search for. Anchors
|
|
83
|
+
belong to a repo, so this looks within the current one; `memory_search` is the
|
|
84
|
+
cross-repo path.
|
|
85
|
+
|
|
86
|
+
**`team_sessions`** — teammates' recent agent sessions, to inherit from. Lists
|
|
87
|
+
the whole workspace's, whatever repository or folder they ran in; excludes your
|
|
88
|
+
own and any a teammate keeps private, and omits sessions that recorded nothing
|
|
89
|
+
— an empty run is noise in a list an agent acts on.
|
|
90
|
+
|
|
91
|
+
**`session_handoff`** — takes a teammate's session over: where the work is, the
|
|
92
|
+
end of their conversation, and the decisions already anchored to the files
|
|
93
|
+
involved, written for the receiving agent to read on its first turn. Accepts a
|
|
94
|
+
session id or a pasted dashboard link. No agent will adopt a foreign session id,
|
|
95
|
+
so what transfers is the context, not the process.
|
|
96
|
+
|
|
97
|
+
The agent supplies the claim, rationale, and files. Everything identifying —
|
|
98
|
+
repo, branch, commit, project — is read from the git checkout, because asking an
|
|
99
|
+
agent for a project id invites it to guess.
|
|
100
|
+
|
|
101
|
+
File paths are normalised to repo-relative before storage: an absolute path
|
|
102
|
+
would anchor the memory to one machine's directory layout and never match for a
|
|
103
|
+
teammate.
|
|
104
|
+
|
|
105
|
+
A decision replaced by a newer one is still returned, labelled `[SUPERSEDED]`
|
|
106
|
+
with the claim that replaced it, stated *before* its rationale — an agent must
|
|
107
|
+
not be able to read stale reasoning without first seeing that it no longer
|
|
108
|
+
holds.
|
|
109
|
+
|
|
110
|
+
## Automatic capture
|
|
111
|
+
|
|
112
|
+
Two hooks, because the transcript on disk already holds every tool call and
|
|
113
|
+
message — there is nothing to gain from hooking each tool.
|
|
114
|
+
|
|
115
|
+
| Hook | Does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `SessionStart` | Injects this project's decisions into the new session, and starts streaming it to the dashboard |
|
|
118
|
+
| `Stop` | Captures what the turn taught, and delivers any instruction a teammate sent |
|
|
119
|
+
| `SessionEnd` | Ships the rest of the transcript, and uploads the whole session if it was never streamed — one that began before Collossal was installed |
|
|
120
|
+
|
|
121
|
+
`collossal install` puts them in `~/.claude/settings.json` for every project. To
|
|
122
|
+
set them up by hand instead, merge `hooks.example.json` there, or into one
|
|
123
|
+
project's `.claude/settings.json` to scope them to that repo — but not both, or
|
|
124
|
+
that project's sessions are streamed twice.
|
|
125
|
+
|
|
126
|
+
### How a session becomes memory
|
|
127
|
+
|
|
128
|
+
The worker reads from the last cursor, then looks for four mechanically
|
|
129
|
+
detectable moments — nothing here calls a model:
|
|
130
|
+
|
|
131
|
+
- **`FAILURE_FIX`** — a command failed, edits followed, the same command passed.
|
|
132
|
+
Matched on command *shape* rather than exact text, because an agent re-runs
|
|
133
|
+
the same tool against a different file rather than retyping the failure.
|
|
134
|
+
- **`REVERSAL`** — one file rewritten six or more times, with the reasoning
|
|
135
|
+
stated nearest the final edit. Fewer than six is ordinary iteration.
|
|
136
|
+
- **`USER_CORRECTION`** — the user pushed back right after an assistant action.
|
|
137
|
+
- **`STATED_CONSTRAINT`** — the user stated a rule.
|
|
138
|
+
|
|
139
|
+
A session reaching none of these is dropped having cost one file read. Measured
|
|
140
|
+
across 78 real transcripts (296 MB), that gate discards 39% for free and the
|
|
141
|
+
rest yield about 6 windows each.
|
|
142
|
+
|
|
143
|
+
Windows are stored separately from decisions, so the store of durable claims
|
|
144
|
+
never fills with unprocessed transcript fragments. A stub promotes `FAILURE_FIX`
|
|
145
|
+
windows to decisions at confidence 0.4; injection ignores anything below 0.5, so
|
|
146
|
+
raw machine guesses never reach an agent's context before distillation exists.
|
|
147
|
+
|
|
148
|
+
### Redaction
|
|
149
|
+
|
|
150
|
+
Credentials are stripped in the worker, immediately before the push — not on
|
|
151
|
+
the server, because scanning there would still mean the secret crossed the
|
|
152
|
+
network and landed in a request log.
|
|
153
|
+
|
|
154
|
+
Three layers: known vendor formats (Anthropic, OpenAI, GitHub, AWS, Slack,
|
|
155
|
+
Google, Stripe, JWTs, private keys, connection strings), assignments whose
|
|
156
|
+
*name* claims to hold a credential (`API_KEY=`, `SECRET=`), and a Shannon
|
|
157
|
+
entropy fallback for credentials with no recognisable shape.
|
|
158
|
+
|
|
159
|
+
The trade is deliberately asymmetric: a false positive costs a slightly less
|
|
160
|
+
readable memory, a false negative puts a live credential in a database forever.
|
|
161
|
+
|
|
162
|
+
Measured against 564 fields from real transcripts: **zero false positives**,
|
|
163
|
+
and zero secrets missed across a suite of 15 credential formats. Absolute paths
|
|
164
|
+
are rewritten repo-relative in the same pass, so memory does not carry one
|
|
165
|
+
machine's directory layout to everyone else.
|
|
166
|
+
|
|
167
|
+
### Safety properties
|
|
168
|
+
|
|
169
|
+
The hook always exits 0 and swallows every error to `~/.collossal/hook.log`. It
|
|
170
|
+
cannot fail a session, and with the API unreachable a session starts normally
|
|
171
|
+
with no memory rather than an error.
|
|
172
|
+
|
|
173
|
+
## Live sessions
|
|
174
|
+
|
|
175
|
+
A workspace admin can watch a session as it happens and step into it.
|
|
176
|
+
|
|
177
|
+
| Piece | Does |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `tailer.py` | Spawned at SessionStart, detached. Follows the transcript, redacts, and pushes render-ready events plus snapshots of uncommitted work — `git diff`, and files not yet added — which a session's Changes tab and the workspace owner's Code diff view show. An empty snapshot means the tree is clean again. |
|
|
180
|
+
| `Stop` hook | Collects instructions queued from the dashboard and hands them to the agent as its next input. |
|
|
181
|
+
|
|
182
|
+
Two properties worth knowing.
|
|
183
|
+
|
|
184
|
+
**It tails, it does not replay.** The tailer starts from the current end of the
|
|
185
|
+
transcript. A hook firing on a resumed session would otherwise push its entire
|
|
186
|
+
history — megabytes — as though it had all just happened.
|
|
187
|
+
|
|
188
|
+
**Instructions arrive at a turn boundary, not instantly.** There is no way to
|
|
189
|
+
interrupt a turn already running, so anything sent is queued and delivered the
|
|
190
|
+
moment the agent would otherwise have stopped. Delivered exactly once: a
|
|
191
|
+
repeated instruction would have the agent redo work.
|
|
192
|
+
|
|
193
|
+
Everything streamed is redacted on the machine first, and absolute paths are
|
|
194
|
+
rewritten repo-relative — a watcher sees the repository, not someone's home
|
|
195
|
+
directory. Events are kept for `SESSION_RETENTION_DAYS` (90 days by default),
|
|
196
|
+
so a session can be read back after it ends; the durable record of a session
|
|
197
|
+
remains the decisions distilled from it.
|
|
198
|
+
|
|
199
|
+
Sending an instruction also writes a `NOTICE` into the stream the watched
|
|
200
|
+
person can see. Being observed and redirected with no indication of it is not
|
|
201
|
+
something to ship.
|
|
202
|
+
|
|
203
|
+
## Layout
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
config.py env, validated once at startup
|
|
207
|
+
git_context.py repo/branch/commit discovery and path normalisation
|
|
208
|
+
client.py Collossal API client
|
|
209
|
+
format.py renders decisions for a context window
|
|
210
|
+
server.py MCPServer and the tools
|
|
211
|
+
capture/
|
|
212
|
+
hook.py lifecycle hook: injection, and spawning the worker
|
|
213
|
+
worker.py detached — reads transcript, gates, ships
|
|
214
|
+
transcript.py streaming JSONL reader
|
|
215
|
+
windows.py the four detectors
|
|
216
|
+
redact.py strips credentials before anything leaves the machine
|
|
217
|
+
ship.py API calls for capture and injection
|
|
218
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
main_mcp/__init__.py,sha256=pOC5-Hzx2P87T0n2QIndADb99cir8yvZqRa-fcRZLks,339
|
|
2
|
+
main_mcp/agents.py,sha256=7MLdCUNcLkYVSQ9OqiZZIFxQOB9LcyHnrDd8HVsQhmc,9602
|
|
3
|
+
main_mcp/capture/__init__.py,sha256=G6vFZGqD2uV8Yn0G7wxQsf8n4d5OXYZbZfLknN0jTW4,78
|
|
4
|
+
main_mcp/capture/checkouts.py,sha256=-no8vd-8FmPcuQRuo-sVREdZ_esbTfSBKeCfVqsVfGM,3134
|
|
5
|
+
main_mcp/capture/history.py,sha256=99IYY7VjRT8D78L-sDb7TK8PeEwtDmkurp1TRfEdaQU,12146
|
|
6
|
+
main_mcp/capture/hook.py,sha256=1Pn9x0TdTzdclnU6Tkg2iqiWFKxXrGB2eTV72TKw-Vk,13421
|
|
7
|
+
main_mcp/capture/redact.py,sha256=XV3gdnj7ofdNaiNH8LZeRlJ7j-yO867O1U10J8zPsaM,7934
|
|
8
|
+
main_mcp/capture/shell_edits.py,sha256=rMBJq9aKXa-8BUpwQ-YO4ik57kUTZF3PD3nkc9JqiPM,10407
|
|
9
|
+
main_mcp/capture/ship.py,sha256=zzF8tNMlGy7EJ1G4P7T5klLre8L1Piu4z7xi3svabGo,4731
|
|
10
|
+
main_mcp/capture/tailer.py,sha256=LknoK284x-yUdEu-aiHpqELpO4T-nTi3wCC9odrXvRI,16547
|
|
11
|
+
main_mcp/capture/transcript.py,sha256=csDDO_DCLqbH5unpuHRDd375S0kjDlZ5jnhK78JZffQ,9528
|
|
12
|
+
main_mcp/capture/turn_worker.py,sha256=jCIL-4byWqxft9EamPwg6llMMaNe3N0My0fDw_l51fI,4488
|
|
13
|
+
main_mcp/capture/turns.py,sha256=tb3k-0T6-uxeAzg4rP8fhL2RUrcIToHOtW5KTnfJ5_g,4931
|
|
14
|
+
main_mcp/capture/windows.py,sha256=mi30F6LeNyMM3LylPyKFz7Btu5hsG3sOv1enPO2NuH0,19666
|
|
15
|
+
main_mcp/capture/worker.py,sha256=Tp6CZYT63HUuC3DKC9JwwtvPSe2MDr4hWMc_R98x51Q,3937
|
|
16
|
+
main_mcp/cli.py,sha256=KorhhKiOJAGzwqgLVxBs8-RRMqxo3rSi99PdNJoQq5U,18603
|
|
17
|
+
main_mcp/client.py,sha256=YNTZPqo5ChDX4_XEkRO8yYn1XjXQWNx5kr88FvmQtdI,9563
|
|
18
|
+
main_mcp/config.py,sha256=p9QM9WuX9zfSo2hDhnMOkXWjqq6H7_DK2T6-RcdFV1c,3571
|
|
19
|
+
main_mcp/format.py,sha256=CE2Lh5r_Lfae3Sd7_VAjCwa5Q2avJsJMg3yGeBj322s,2107
|
|
20
|
+
main_mcp/git_context.py,sha256=Jd2Tz43DbXL5-a2bhva63ROxVOMKhviW2Me5LvLvhvE,3912
|
|
21
|
+
main_mcp/room.py,sha256=as_uB6GvO83SeiwANRs24VqvriAM8bHiKneIhBG22DA,28443
|
|
22
|
+
main_mcp/server.py,sha256=6ebPVcKcLq2MkEBiHWcS2J1IF6-Be--Z5w1TjMKxf9E,10522
|
|
23
|
+
collossal-0.1.0.dist-info/WHEEL,sha256=WKk67IZPzp9uSkBO-iWoZzrqp9-AwH7FE9EB8EoJbf4,81
|
|
24
|
+
collossal-0.1.0.dist-info/entry_points.txt,sha256=vRI9UufT43S63JrxaVDh0HequVRVgzWCyrs5W4A42yg,130
|
|
25
|
+
collossal-0.1.0.dist-info/METADATA,sha256=1xkFgRT1yhb4L0lbf_o5sOamGEwfNqbuFi9N46BaHpo,10528
|
|
26
|
+
collossal-0.1.0.dist-info/RECORD,,
|
main_mcp/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Collossal MCP server."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
"""Entry point.
|
|
6
|
+
|
|
7
|
+
The import is deferred so that `python -m main_mcp.server` does not load the
|
|
8
|
+
server module twice — which would run its startup config load and client
|
|
9
|
+
construction a second time.
|
|
10
|
+
"""
|
|
11
|
+
from .server import main as run
|
|
12
|
+
|
|
13
|
+
run()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["main"]
|
main_mcp/agents.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Wiring Collossal into the coding agents on this machine.
|
|
2
|
+
|
|
3
|
+
For Claude Code that is two things, both at user scope so they apply to every
|
|
4
|
+
project without anything being committed to one:
|
|
5
|
+
|
|
6
|
+
* the lifecycle hooks, in ``~/.claude/settings.json`` — they put the project's
|
|
7
|
+
memory in front of each new session, stream it to the dashboard, deliver
|
|
8
|
+
teammates' instructions between turns, and complete its history at the end;
|
|
9
|
+
* the MCP server, registered with ``claude mcp add --scope user``, so every
|
|
10
|
+
session has the memory and handoff tools.
|
|
11
|
+
|
|
12
|
+
Both are recognised by the command they run, so installing again replaces them
|
|
13
|
+
rather than stacking a second copy, and uninstalling removes exactly them and
|
|
14
|
+
nothing the person put there themselves.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
HOOK_EVENTS = ("UserPromptSubmit", "SessionStart", "Stop", "SessionEnd")
|
|
29
|
+
#: How long Claude Code may wait for each. `UserPromptSubmit` holds up the
|
|
30
|
+
#: person's prompt until it returns, so it gets a far shorter leash than the
|
|
31
|
+
#: rest — the hook's own budget is tighter still, and this is the backstop for
|
|
32
|
+
#: the case where it hangs somewhere the budget does not cover.
|
|
33
|
+
HOOK_TIMEOUTS = {"UserPromptSubmit": 5}
|
|
34
|
+
DEFAULT_HOOK_TIMEOUT = 10
|
|
35
|
+
#: How a hook is known to be Collossal's: it runs this command.
|
|
36
|
+
HOOK_MARKER = "collossal-hook"
|
|
37
|
+
MCP_NAME = "collossal"
|
|
38
|
+
#: The same two, from when the product was spelled with one "l". Installing
|
|
39
|
+
#: removes them, so a machine set up then does not run both.
|
|
40
|
+
LEGACY_HOOK_MARKER = "colossal-hook"
|
|
41
|
+
LEGACY_MCP_NAME = "colossal"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SettingsError(RuntimeError):
|
|
45
|
+
"""A settings file that cannot be safely rewritten."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def claude_dir() -> Path:
|
|
49
|
+
"""Claude Code's own directory, which ``CLAUDE_CONFIG_DIR`` moves."""
|
|
50
|
+
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
51
|
+
return Path(override).expanduser() if override else Path.home() / ".claude"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def settings_path() -> Path:
|
|
55
|
+
return claude_dir() / "settings.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def claude_installed() -> bool:
|
|
59
|
+
return claude_dir().is_dir() or shutil.which("claude") is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def executable(name: str) -> Path | None:
|
|
63
|
+
"""One of this package's own commands, by absolute path.
|
|
64
|
+
|
|
65
|
+
Taken from beside the interpreter running this — where an installed tool
|
|
66
|
+
keeps its scripts — so a hook keeps working whatever PATH the agent happens
|
|
67
|
+
to be launched with.
|
|
68
|
+
"""
|
|
69
|
+
beside = Path(sys.executable).parent / name
|
|
70
|
+
if beside.exists():
|
|
71
|
+
return beside
|
|
72
|
+
found = shutil.which(name)
|
|
73
|
+
return Path(found) if found else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Hooks
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return {}
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
# Never overwrite what could not be read: whatever else is in it is the
|
|
88
|
+
# person's, and a rewrite from nothing would silently drop it.
|
|
89
|
+
raise SettingsError(
|
|
90
|
+
f"{path} is not valid JSON ({exc}). Fix it, then run this again."
|
|
91
|
+
) from exc
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise SettingsError(f"{path} does not hold a JSON object.")
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _write(path: Path, data: dict[str, Any]) -> None:
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
# The first time only: the file as it was before Collossal ever touched it.
|
|
100
|
+
backup = path.with_name(f"{path.name}.collossal-backup")
|
|
101
|
+
if path.exists() and not backup.exists():
|
|
102
|
+
shutil.copy2(path, backup)
|
|
103
|
+
tmp = path.with_name(f".{path.name}.tmp")
|
|
104
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
105
|
+
os.replace(tmp, path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ours(item: Any) -> bool:
|
|
109
|
+
if not isinstance(item, dict):
|
|
110
|
+
return False
|
|
111
|
+
command = str(item.get("command", ""))
|
|
112
|
+
return HOOK_MARKER in command or LEGACY_HOOK_MARKER in command
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _without_ours(groups: Any) -> list[Any]:
|
|
116
|
+
"""An event's hook groups, minus Collossal's — and minus any group that
|
|
117
|
+
held nothing else. Everything else is kept exactly as it was."""
|
|
118
|
+
if not isinstance(groups, list):
|
|
119
|
+
return []
|
|
120
|
+
kept: list[Any] = []
|
|
121
|
+
for group in groups:
|
|
122
|
+
items = group.get("hooks") if isinstance(group, dict) else None
|
|
123
|
+
if not isinstance(items, list):
|
|
124
|
+
kept.append(group)
|
|
125
|
+
continue
|
|
126
|
+
rest = [item for item in items if not _ours(item)]
|
|
127
|
+
if len(rest) == len(items):
|
|
128
|
+
kept.append(group)
|
|
129
|
+
elif rest:
|
|
130
|
+
kept.append({**group, "hooks": rest})
|
|
131
|
+
return kept
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def hook_command() -> str | None:
|
|
135
|
+
hook = executable("collossal-hook")
|
|
136
|
+
return shlex.quote(str(hook)) if hook else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def install_hooks(command: str) -> Path:
|
|
140
|
+
path = settings_path()
|
|
141
|
+
settings = _read(path)
|
|
142
|
+
hooks = settings.get("hooks") if isinstance(settings.get("hooks"), dict) else {}
|
|
143
|
+
for event in HOOK_EVENTS:
|
|
144
|
+
groups = _without_ours(hooks.get(event))
|
|
145
|
+
groups.append(
|
|
146
|
+
{
|
|
147
|
+
"hooks": [
|
|
148
|
+
{
|
|
149
|
+
"type": "command",
|
|
150
|
+
"command": command,
|
|
151
|
+
"timeout": HOOK_TIMEOUTS.get(event, DEFAULT_HOOK_TIMEOUT),
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
hooks[event] = groups
|
|
157
|
+
settings["hooks"] = hooks
|
|
158
|
+
_write(path, settings)
|
|
159
|
+
return path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def remove_hooks() -> bool:
|
|
163
|
+
path = settings_path()
|
|
164
|
+
if not path.exists():
|
|
165
|
+
return False
|
|
166
|
+
settings = _read(path)
|
|
167
|
+
hooks = settings.get("hooks")
|
|
168
|
+
if not isinstance(hooks, dict):
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
changed = False
|
|
172
|
+
for event in list(hooks):
|
|
173
|
+
rest = _without_ours(hooks[event])
|
|
174
|
+
if rest != hooks[event]:
|
|
175
|
+
changed = True
|
|
176
|
+
if rest:
|
|
177
|
+
hooks[event] = rest
|
|
178
|
+
else:
|
|
179
|
+
del hooks[event]
|
|
180
|
+
if not changed:
|
|
181
|
+
return False
|
|
182
|
+
if not hooks:
|
|
183
|
+
del settings["hooks"]
|
|
184
|
+
_write(path, settings)
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def installed_hook() -> str | None:
|
|
189
|
+
"""The command Collossal's hooks run, if all of them are in place."""
|
|
190
|
+
try:
|
|
191
|
+
hooks = _read(settings_path()).get("hooks")
|
|
192
|
+
except SettingsError:
|
|
193
|
+
return None
|
|
194
|
+
if not isinstance(hooks, dict):
|
|
195
|
+
return None
|
|
196
|
+
commands = []
|
|
197
|
+
for event in HOOK_EVENTS:
|
|
198
|
+
groups = hooks.get(event) if isinstance(hooks.get(event), list) else []
|
|
199
|
+
found = [
|
|
200
|
+
item["command"]
|
|
201
|
+
for group in groups
|
|
202
|
+
if isinstance(group, dict) and isinstance(group.get("hooks"), list)
|
|
203
|
+
for item in group["hooks"]
|
|
204
|
+
if _ours(item)
|
|
205
|
+
]
|
|
206
|
+
if not found:
|
|
207
|
+
return None
|
|
208
|
+
commands.append(found[0])
|
|
209
|
+
return commands[0]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def duplicate_hook_files(directories: list[str]) -> list[Path]:
|
|
213
|
+
"""Project settings that also run Collossal's hooks.
|
|
214
|
+
|
|
215
|
+
Left over from setting a project up by hand. With the hooks installed for
|
|
216
|
+
every project, each of these would run a second time — two streams for one
|
|
217
|
+
session.
|
|
218
|
+
"""
|
|
219
|
+
found: list[Path] = []
|
|
220
|
+
for directory in sorted(set(directories)):
|
|
221
|
+
for name in ("settings.json", "settings.local.json"):
|
|
222
|
+
path = Path(directory) / ".claude" / name
|
|
223
|
+
try:
|
|
224
|
+
text = path.read_text(encoding="utf-8")
|
|
225
|
+
if HOOK_MARKER in text or LEGACY_HOOK_MARKER in text:
|
|
226
|
+
found.append(path)
|
|
227
|
+
except OSError:
|
|
228
|
+
continue
|
|
229
|
+
return found
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# MCP server
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _claude(*args: str) -> subprocess.CompletedProcess[str] | None:
|
|
238
|
+
claude = shutil.which("claude")
|
|
239
|
+
if not claude:
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
return subprocess.run(
|
|
243
|
+
[claude, "mcp", *args], capture_output=True, text=True, timeout=60
|
|
244
|
+
)
|
|
245
|
+
except (OSError, subprocess.SubprocessError):
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def register_mcp(server: Path) -> tuple[bool, str]:
|
|
250
|
+
# Replaced rather than added beside: `add` refuses a name already taken,
|
|
251
|
+
# and an older registration may point at a command that no longer exists.
|
|
252
|
+
_claude("remove", "--scope", "user", MCP_NAME)
|
|
253
|
+
_claude("remove", "--scope", "user", LEGACY_MCP_NAME)
|
|
254
|
+
result = _claude("add", "--scope", "user", MCP_NAME, "--", str(server))
|
|
255
|
+
if result is None:
|
|
256
|
+
return False, "the `claude` command is not on PATH"
|
|
257
|
+
return result.returncode == 0, (result.stderr or result.stdout).strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def unregister_mcp() -> bool:
|
|
261
|
+
result = _claude("remove", "--scope", "user", MCP_NAME)
|
|
262
|
+
return bool(result and result.returncode == 0)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def registered_mcp() -> str | None:
|
|
266
|
+
"""The command the registered server runs.
|
|
267
|
+
|
|
268
|
+
Read from Claude Code's config rather than asked of `claude mcp get`, which
|
|
269
|
+
starts the server to health-check it — too slow for a status line.
|
|
270
|
+
"""
|
|
271
|
+
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
272
|
+
path = Path(override).expanduser() / ".claude.json" if override else Path.home() / ".claude.json"
|
|
273
|
+
try:
|
|
274
|
+
servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers") or {}
|
|
275
|
+
except (OSError, ValueError, AttributeError):
|
|
276
|
+
return None
|
|
277
|
+
server = servers.get(MCP_NAME) if isinstance(servers, dict) else None
|
|
278
|
+
return server.get("command") if isinstance(server, dict) else None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Automatic capture: lifecycle hooks, transcript windowing, and shipping."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Which repository a session is working in, judged by what it touches.
|
|
2
|
+
|
|
3
|
+
For a session begun in a folder that is not a checkout itself — a directory
|
|
4
|
+
holding several repositories side by side, which is how many people open their
|
|
5
|
+
agent. Such a session has no repository by where it began, but almost always
|
|
6
|
+
one by what it does: the files it reads and edits, and where its commands run.
|
|
7
|
+
|
|
8
|
+
Shared by the live stream, which keeps a running tally as events arrive, and
|
|
9
|
+
the history import, which tallies a finished transcript in one go.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from .transcript import Event
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def checkout_of(path: Path, cache: dict[Path, Path | None]) -> Path | None:
|
|
21
|
+
"""The git checkout a path lies in.
|
|
22
|
+
|
|
23
|
+
Found by looking for `.git` rather than by asking git: a session can touch
|
|
24
|
+
hundreds of paths, and a subprocess apiece would make that crawl. Every
|
|
25
|
+
directory passed on the way up is remembered, so most lookups are one
|
|
26
|
+
dictionary hit.
|
|
27
|
+
"""
|
|
28
|
+
passed: list[Path] = []
|
|
29
|
+
found: Path | None = None
|
|
30
|
+
for directory in (path, *path.parents):
|
|
31
|
+
if directory in cache:
|
|
32
|
+
found = cache[directory]
|
|
33
|
+
break
|
|
34
|
+
passed.append(directory)
|
|
35
|
+
# A directory for a clone; a file for a worktree or a submodule.
|
|
36
|
+
if (directory / ".git").exists():
|
|
37
|
+
found = directory
|
|
38
|
+
break
|
|
39
|
+
for directory in passed:
|
|
40
|
+
cache[directory] = found
|
|
41
|
+
return found
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Tally:
|
|
45
|
+
"""A running count of where a session's tool calls have landed.
|
|
46
|
+
|
|
47
|
+
An edit says more about what a session is for than a read does, so it
|
|
48
|
+
counts three times over; ties go to whichever was touched first.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, base: Path | None) -> None:
|
|
52
|
+
#: Where relative paths are resolved from when an event has no cwd.
|
|
53
|
+
self._base = base
|
|
54
|
+
self._cache: dict[Path, Path | None] = {}
|
|
55
|
+
self._weight: dict[Path, int] = {}
|
|
56
|
+
self._first: dict[Path, int] = {}
|
|
57
|
+
self._seen = 0
|
|
58
|
+
|
|
59
|
+
def add(self, events: Iterable[Event]) -> None:
|
|
60
|
+
for e in events:
|
|
61
|
+
self._seen += 1
|
|
62
|
+
if e.kind != "tool_use":
|
|
63
|
+
continue
|
|
64
|
+
edited = set(e.edited_paths)
|
|
65
|
+
touched = [(p, 3 if p in edited else 1) for p in [e.file_path, *edited] if p]
|
|
66
|
+
if e.cwd:
|
|
67
|
+
touched.append((e.cwd, 1))
|
|
68
|
+
for raw, points in touched:
|
|
69
|
+
path = Path(raw)
|
|
70
|
+
if not path.is_absolute():
|
|
71
|
+
where = Path(e.cwd) if e.cwd else self._base
|
|
72
|
+
if where is None:
|
|
73
|
+
continue
|
|
74
|
+
path = where / path
|
|
75
|
+
root = checkout_of(path, self._cache)
|
|
76
|
+
if root is None:
|
|
77
|
+
continue
|
|
78
|
+
self._weight[root] = self._weight.get(root, 0) + points
|
|
79
|
+
self._first.setdefault(root, self._seen)
|
|
80
|
+
|
|
81
|
+
def ranked(self) -> list[Path]:
|
|
82
|
+
"""Checkouts touched so far, most-worked first."""
|
|
83
|
+
return sorted(self._weight, key=lambda root: (-self._weight[root], self._first[root]))
|