Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
# resolves to nothing — it has to be a full version.
- uses: astral-sh/setup-uv@v10.0.1

# A mismatch would publish v0.2.0 as an asset named lochy-0.1.0, and the
# A mismatch would publish v0.2.0 as an asset named loch-0.1.0, and the
# install path is a pinned asset URL, so it is not something to discover
# afterwards.
- name: Check the tag against the packaged version
Expand All @@ -39,7 +39,7 @@ jobs:
run: |
python3 -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
test "$(/tmp/smoke/bin/lochy --version)" = "lochy ${GITHUB_REF_NAME#v}"
test "$(/tmp/smoke/bin/loch --version)" = "loch ${GITHUB_REF_NAME#v}"

# Both artifacts: the wheel is what `uv tool install` points at, the
# sdist is what anyone building from source needs.
Expand Down
32 changes: 21 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# lochy — repo overview for Claude
# loch — repo overview for Claude

## What this is

A standalone CLI that makes agent coding sessions portable between
machines. Agent CLIs store transcripts in machine-local directories keyed
by the working directory they ran in, which strands a session on the
laptop that produced it. `lochy` packs those transcripts into a
laptop that produced it. `loch` packs those transcripts into a
content-addressed bundle, stores it anywhere S3-compatible, and unpacks
it elsewhere with paths rewritten so the session actually resumes.

Expand Down Expand Up @@ -105,7 +105,7 @@ public API. Re-verify if something breaks.
disambiguator**, and the only one available — no field marks which cwd
is current.
- `"cwd"` also appears *inside* tool inputs and results, naming some
other process's directory. lochy's own `--json` output is one such
other process's directory. loch's own `--json` output is one such
source, so anything counting cwds has to parse each record and read the
top-level field, not grep the text.
- Scale reference: 750MB across 428 project dirs on one active machine;
Expand All @@ -129,7 +129,7 @@ public API. Re-verify if something breaks.
Python 3.10 through 3.13, Poetry, boto3 behind an extra.

```
lochy/
loch/
├── cli.py # entry point, arg parsing, list/save/restore/delete/reindex
├── claude.py # Claude Code adapter: path encoding, session discovery, metadata
├── bundle.py # bundle format, gzip, content-addressed ref
Expand Down Expand Up @@ -295,7 +295,7 @@ given and neither is inferred.

**Leaving foreign cwds unrewritten is possible — it is just not better.**
The obvious objection, that it needs knowing which subtrees exist on the
destination, does not apply: lochy has the exact set of candidate cwds
destination, does not apply: loch has the exact set of candidate cwds
already, having computed them to choose one, and those specific strings
are known to be session working directories rather than dotfile or config
references. So the rule "the home pair declines a value exactly equal to
Expand Down Expand Up @@ -325,7 +325,7 @@ single-pass guarantee depends on a nested path matching before the
shorter prefix that contains it, and caller order is arbitrary. This is
a "keep doing that" note, not a change.

What makes it safe is that **the caller states the mapping; lochy never
What makes it safe is that **the caller states the mapping; loch never
infers it.** The first consumer (Harness "send worktree to remote") can
name all four paths without a guess: the origin worktree is what the
user selected, the origin repo root comes from
Expand Down Expand Up @@ -406,7 +406,7 @@ produce the same ref.

## The JSON contract

`lochy` is meant to be embedded in other tools — Harness is the first — and
`loch` is meant to be embedded in other tools — Harness is the first — and
a caller that scrapes prose breaks the moment someone rewords a sentence.
So every command takes `--json`, accepted either before or after the
subcommand, and answers with exactly one document on stdout:
Expand Down Expand Up @@ -523,14 +523,14 @@ is pinned to the floor rather than following the running interpreter, so a
**boto3 is an optional `[s3]` extra.** What makes that safe is the lazy import
in `S3Store._client`: the `file://` backend loads nothing third-party at
runtime, so the SDK was only ever an install-time cost — 8 packages and 27MB,
of which botocore alone is 24MB against lochy's own 172KB. A default install
of which botocore alone is 24MB against loch's own 172KB. A default install
is one package and 184KB. Keep the import inside the method, and keep any new
`botocore` import *after* the `_client()` call that would have failed first,
or the SDK's absence surfaces as a bare `ImportError` and gets mislabelled
`store-unreachable`. The dev group still carries boto3, moto and the stubs, so
the S3 backend stays fully covered by the suite.

**`lochy --version` reads package metadata**, so pyproject is the only place
**`loch --version` reads package metadata**, so pyproject is the only place
the number is set. `importlib.metadata` raises `PackageNotFoundError` in a
source checkout that was never installed, which reports `"unknown"` — pyproject
can't be read as a fallback, since `tomllib` arrived in 3.11 and the floor is
Expand Down Expand Up @@ -581,7 +581,7 @@ records were on `feat/send-worktree-to-remote`. Branch and version are
now read from records carrying the *chosen* cwd. This is worth its own
release note rather than folding into the cwd fix, because the blast
radius is different: a consumer that carries the branch itself sees a
healthy-looking transfer while the lochy entry sits under a name nobody
healthy-looking transfer while the loch entry sits under a name nobody
would look for it on, and every already-saved bundle from a moved session
is filed wrong until re-saved or reindexed.

Expand Down Expand Up @@ -612,7 +612,7 @@ coverage now, which is more than it had, but mocks agree with your
assumptions by construction. A live run failed on `Access Denied` (the
IAM user lacked `s3:PutObject`); the region mismatch that precedes that
failure surfaces as "The bucket you are attempting to access must be
addressed using the specified endpoint," so set `LOCHY_S3_REGION`
addressed using the specified endpoint," so set `LOCH_S3_REGION`
when the bucket's region differs from the AWS config default.

Listing and deleting need IAM actions the bucket policy may not grant
Expand Down Expand Up @@ -648,6 +648,16 @@ CI and no release, so the first tag push is also the first execution of the
release path. No release exists yet, so the wheel URL in the README points at
a `v0.1.0` that has to be cut before it resolves.

The rename from lochy carried no compatibility shim: `LOCHY_STORE`,
`LOCHY_S3_ENDPOINT`, `LOCHY_S3_REGION` and the `~/.lochy/store` default are
simply no longer read. That was chosen because nothing had been released and
no consumer set the old variables — but the failure it leaves is the silent
kind, so it is worth knowing about: a stale `LOCHY_STORE=s3://...` makes
`save` succeed into the local default with `ok: true`, and an unmoved
`~/.lochy` makes `list --remote` an empty, healthy-looking answer. If a
shim is ever wanted, the shape is to read `LOCH_*` first, fall back to the
old name, and warn on stderr in text mode only.

Also missing: `restore --map` (design intent recorded above, deliberately
not built on this branch), any other index dimension (`cwd` is the
obvious next one, and the layout takes it without a migration), any git
Expand Down
57 changes: 31 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# lochy
# loch

Save and restore agent coding sessions across machines.

Expand All @@ -7,7 +7,7 @@ working directory they ran in. That makes a session effectively stuck to the
laptop that produced it — you can't hand a teammate the conversation that
produced a branch, and you can't pick one up on another machine.

`lochy` packs those transcripts into a portable, content-addressed
`loch` packs those transcripts into a portable, content-addressed
bundle, stores it anywhere S3-compatible, and unpacks it on another machine
with the paths rewritten so the session actually resumes.

Expand All @@ -16,14 +16,14 @@ Currently supports Claude Code.
## Install

```sh
uv tool install https://github.com/ness-dev/lochy/releases/download/v0.1.0/lochy-0.1.0-py3-none-any.whl
lochy --version
uv tool install https://github.com/ness-dev/loch/releases/download/v0.1.0/loch-0.1.0-py3-none-any.whl
loch --version
```

The wheel is `py3-none-any`, and [uv](https://docs.astral.sh/uv/) is a static
binary that brings its own CPython, so this needs no Python on the machine and
no PyPI account behind it. Releases are attached to
[GitHub releases](https://github.com/ness-dev/lochy/releases) as a wheel
[GitHub releases](https://github.com/ness-dev/loch/releases) as a wheel
and an sdist.

Python 3.10 or newer if you'd rather install it yourself — `pipx install`,
Expand All @@ -34,7 +34,7 @@ in. The backend imports boto3 lazily, so a `file://` store never loads it and
paying 27MB for a dependency most installs never reach isn't worth it:

```sh
uv tool install "lochy[s3] @ https://github.com/ness-dev/lochy/releases/download/v0.1.0/lochy-0.1.0-py3-none-any.whl"
uv tool install "loch[s3] @ https://github.com/ness-dev/loch/releases/download/v0.1.0/loch-0.1.0-py3-none-any.whl"
```

Without it, an `s3://` store fails with `code: "s3-extra-missing"` rather than
Expand All @@ -44,35 +44,35 @@ For development:

```sh
poetry install # brings boto3, so the S3 backend is covered by the tests
poetry run lochy --help
poetry run loch --help
poetry run pytest
```

## Use

```sh
# what's on this machine for the current repo?
lochy list
lochy list --branch feature/checkout-flow
loch list
loch list --branch feature/checkout-flow

# pack them up and push to a store
lochy save --branch feature/checkout-flow --store s3://my-bucket/sessions
loch save --branch feature/checkout-flow --store s3://my-bucket/sessions
# -> packed e71cdcb1... [feature/checkout-flow] — redacted 2 secrets
# -> ref 10482276de745032...

# on another machine: what's been saved for the branch I'm on?
lochy list --remote --store s3://my-bucket/sessions
lochy list --remote --branch feature/checkout-flow
lochy list --all # every branch in the store
loch list --remote --store s3://my-bucket/sessions
loch list --remote --branch feature/checkout-flow
loch list --all # every branch in the store

lochy restore 10482276de745032... --store s3://my-bucket/sessions
loch restore 10482276de745032... --store s3://my-bucket/sessions
# -> cd /path/to/repo && claude --resume e71cdcb1-7c2c-410b-8c73-91cdf0cba4b8

# a transcript that shouldn't be there any more
lochy delete 10482276de745032...
loch delete 10482276de745032...

# rebuild the index from the bundles, if a save was interrupted
lochy reindex
loch reindex
```

`save` bundles every session matching the filter, so a branch with several
Expand All @@ -84,22 +84,22 @@ Every command takes `--json`, before or after the subcommand, and prints one
document on stdout instead of the text above:

```sh
lochy save --json --branch feature/checkout-flow --store /tmp/outbound
loch save --json --branch feature/checkout-flow --store /tmp/outbound
# {"schema":1,"ok":true,"command":"save","ref":"10482276...","bytes":4712,
# "redacted":2,"sessions":[{"sessionId":"e71cdcb1-...","branch":"feature/checkout-flow",
# "redactions":{"aws-access-key":1,"jwt":1}}],"indexed":[...]}

ref=$(lochy save --json --store /tmp/outbound | jq -r .ref)
lochy restore --json "$ref" --into /path/to/repo --store /tmp/outbound
ref=$(loch save --json --store /tmp/outbound | jq -r .ref)
loch restore --json "$ref" --into /path/to/repo --store /tmp/outbound

lochy --version --json
loch --version --json
# {"schema":1,"ok":true,"command":"version","version":"0.1.0"}
```

Failures are documents too, on stdout, so one stream carries both outcomes:

```sh
lochy restore --json deadbeef --store /tmp/outbound; echo "exit $?"
loch restore --json deadbeef --store /tmp/outbound; echo "exit $?"
# {"schema":1,"ok":false,"command":"restore","code":"bundle-not-found",
# "error":"could not read deadbeef from /tmp/outbound: ..."}
# exit 1
Expand All @@ -125,9 +125,9 @@ bundles/<ref>.loch the bundle, named by the hash of its bytes
index/branch/<branch>/<ref> a small entry per branch the bundle touches
```

`lochy list --remote` reads only the index, so listing a branch never
`loch list --remote` reads only the index, so listing a branch never
downloads a bundle. Entries are derived from the bundles and hold nothing
that isn't already in them — `lochy reindex` rebuilds the whole index by
that isn't already in them — `loch reindex` rebuilds the whole index by
scanning `bundles/`, and drops entries no bundle backs.

Branch names are percent-encoded, so `feature/foo` stays one path segment
Expand All @@ -148,9 +148,14 @@ itself for `list`/`reindex` and `s3:DeleteObject` for `delete`.

| Variable | Purpose |
| --- | --- |
| `LOCHY_STORE` | default store URI (else `~/.lochy/store`) |
| `LOCHY_S3_ENDPOINT` | custom endpoint for R2, MinIO, Backblaze, ... |
| `LOCHY_S3_REGION` | region override |
| `LOCH_STORE` | default store URI (else `~/.loch/store`) |
| `LOCH_S3_ENDPOINT` | custom endpoint for R2, MinIO, Backblaze, ... |
| `LOCH_S3_REGION` | region override |

These were `LOCHY_*` before the rename, and the default store was
`~/.lochy/store`. The old names are not read, so an old variable is silently
ignored rather than rejected — rename it, and `mv ~/.lochy ~/.loch` to keep
existing bundles visible.

Credentials come from the standard AWS chain, so `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY`, a shared profile, or an instance role all work.
Expand Down
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion lochy/claude.py → loch/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def _records(raw: str) -> Iterator[dict[str, Any]]:
def transcript_cwds(raw: str) -> dict[str, int]:
"""Every cwd a transcript's records name, with how many name it, in order
of first appearance. Only the top-level field counts: `"cwd"` also occurs
inside tool inputs and results — lochy's own --json output among them —
inside tool inputs and results — loch's own --json output among them —
where it names some other process's directory."""
counts: dict[str, int] = {}
for record in _records(raw):
Expand Down
30 changes: 15 additions & 15 deletions lochy/cli.py → loch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@
)
from .store import MissingDependency, MissingObject, create_store, resolve_store_uri

USAGE = """lochy — save and restore agent coding sessions across machines
USAGE = """loch — save and restore agent coding sessions across machines

Usage:
lochy list [--cwd <path>] [--branch <name>]
lochy list --remote [--branch <name>] [--all] [--store <uri>]
lochy save [--cwd <path>] [--branch <name>] [--session <id>] [--store <uri>]
lochy restore <ref> [--into <path>] [--store <uri>] [--force] [--new-id]
lochy delete <ref> [--store <uri>]
lochy reindex [--store <uri>]
lochy --version
loch list [--cwd <path>] [--branch <name>]
loch list --remote [--branch <name>] [--all] [--store <uri>]
loch save [--cwd <path>] [--branch <name>] [--session <id>] [--store <uri>]
loch restore <ref> [--into <path>] [--store <uri>] [--force] [--new-id]
loch delete <ref> [--store <uri>]
loch reindex [--store <uri>]
loch --version

Every command takes --json, which replaces the text below with one machine-
readable document on stdout.
Expand All @@ -74,9 +74,9 @@
s3://bucket/prefix any S3-compatible endpoint

Environment:
LOCHY_STORE default store URI
LOCHY_S3_ENDPOINT custom endpoint (R2, MinIO, ...)
LOCHY_S3_REGION region override
LOCH_STORE default store URI
LOCH_S3_ENDPOINT custom endpoint (R2, MinIO, ...)
LOCH_S3_REGION region override
"""


Expand Down Expand Up @@ -162,7 +162,7 @@ def print_help(self, file: Any = None) -> NoReturn:


def _parser(command: str) -> argparse.ArgumentParser:
parser = _Parser(prog=f"lochy {command}")
parser = _Parser(prog=f"loch {command}")
# Registered so the flag parses after the subcommand; main() reads the mode
# off argv itself, because a failure before or during parsing has to render
# in the mode the caller asked for.
Expand Down Expand Up @@ -600,15 +600,15 @@ def installed_version() -> str:
that was never installed has no metadata to read, and pyproject can't be
parsed for it either — tomllib arrived in 3.11 and the floor is 3.10."""
try:
return importlib.metadata.version("lochy")
return importlib.metadata.version("loch")
except importlib.metadata.PackageNotFoundError:
return "unknown"


def command_version(argv: list[str]) -> Result:
version = installed_version()
return Result(
command="version", payload={"version": version}, text=f"lochy {version}\n"
command="version", payload={"version": version}, text=f"loch {version}\n"
)


Expand Down Expand Up @@ -648,7 +648,7 @@ def main() -> None:
try:
handler = COMMANDS.get(command)
if handler is None:
fail("unknown-command", f"unknown command '{command}' (try: lochy help)")
fail("unknown-command", f"unknown command '{command}' (try: loch help)")
result = handler(argv[1:])
except HelpRequested as help_text:
result = Result(command, {"usage": help_text.text}, text=help_text.text)
Expand Down
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion lochy/output.py → loch/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def emit_error(command: str, error: CommandError, as_json: bool) -> NoReturn:
else:
for warning in error.warnings:
sys.stderr.write(warning)
sys.stderr.write(error.prose if error.prose else f"lochy: {error.message}\n")
sys.stderr.write(error.prose if error.prose else f"loch: {error.message}\n")
raise SystemExit(error.exit_status)


Expand Down
File renamed without changes.
File renamed without changes.
10 changes: 5 additions & 5 deletions lochy/store.py → loch/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
if TYPE_CHECKING:
from mypy_boto3_s3.client import S3Client

DEFAULT_STORE = os.path.join(str(Path.home()), ".lochy", "store")
DEFAULT_STORE = os.path.join(str(Path.home()), ".loch", "store")


class MissingObject(Exception):
Expand Down Expand Up @@ -101,11 +101,11 @@ def _client(self) -> "S3Client":
except ImportError as error:
raise MissingDependency(
"the S3 backend needs boto3, which this install doesn't have "
"(reinstall as lochy[s3])"
"(reinstall as loch[s3])"
) from error

endpoint = os.environ.get("LOCHY_S3_ENDPOINT")
region = os.environ.get("LOCHY_S3_REGION")
endpoint = os.environ.get("LOCH_S3_ENDPOINT")
region = os.environ.get("LOCH_S3_REGION")
return boto3.client(
"s3",
endpoint_url=endpoint,
Expand Down Expand Up @@ -175,4 +175,4 @@ def create_store(uri: str) -> Store:
def resolve_store_uri(explicit: str | None = None) -> str:
if explicit is not None:
return explicit
return os.environ.get("LOCHY_STORE", DEFAULT_STORE)
return os.environ.get("LOCH_STORE", DEFAULT_STORE)
Loading
Loading