Skip to content
This repository was archived by the owner on Sep 29, 2026. It is now read-only.
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
17 changes: 17 additions & 0 deletions .github/workflows/private-names.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Private names

on:
pull_request:
push:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: The check catches what it should
run: python3 scripts/check-private-names.py --self-test
- name: No private machine, person, or agent names
run: python3 scripts/check-private-names.py
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ jobs:
path: convoy
- uses: actions/checkout@v4
with:
repository: compoundingtech/pty
repository: compoundingtech/pty-original-experiment
path: pty
- uses: actions/checkout@v4
with:
repository: compoundingtech/smalltalk
repository: compoundingtech/smalltalk-original-experiment
path: smalltalk

- uses: actions/setup-node@v4
Expand Down
2 changes: 1 addition & 1 deletion LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A convoy **network** is a named home for one crew. Networks live under `$XDG_STA
```
$XDG_STATE_HOME/convoy/<network>/
smalltalk/ # the bus — SYNCED across machines (st sync over fabric)
<host>.<identity>/ # one folder per agent, hostname-prefixed (e.g. silber.cos-claude)
<host>.<identity>/ # one folder per agent, hostname-prefixed (e.g. example-mac.cos-claude)
inbox/ archive/ status # messages + the liveness heartbeat (the status file's mtime)
context/ # the agent's working memory (cold-boot state)
now.md decisions/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ network's path, the prefix, the longest suffix convoy appends, and the
separators.

This means the bound is **contextual**. A network at `/n` genuinely affords
longer agent names than one at `/home/user/.local/state/convoy/default`. A fixed
longer agent names than one at `/home/example/.local/state/convoy/default`. A fixed
constant is either wrong for short paths (needlessly restrictive) or wrong for
long ones (accepts names that cannot bind).

Expand Down
19 changes: 19 additions & 0 deletions context/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,22 @@ which machine issued it.
one machine.
- Promotion target: the supervision subsystem, or a dedicated child node for
cross-machine convergence.

## Resuming the harness conversation across respawn

A respawn is a cold boot today: recovery replays the session manifest, and the
agent rebuilds its context from its bus folder and its boot ritual. Keeping the
harness's conversation would make a respawn a continuation instead. The shape is
a conversation id kept in the agent's workspace: the first launch mints it and
starts the harness with it, and every later launch resumes it.

The hazard is resuming a conversation whose original process still holds it.
That fails at once, and under a supervisor it becomes a tight respawn loop. So
resume depends on the liveness rule in the
[supervision spec](./03-supervision/spec.md): a session reported gone is
respawned only when its process is gone too.

- Trigger: a respawned agent re-acts on work its previous run already handled,
or the boot ritual's cost dominates short restarts.
- Promotion target: restart durability in the transports subsystem spec, and
death and recovery in the supervision subsystem spec.
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";

pty.url = "github:compoundingtech/pty";
pty.url = "github:compoundingtech/pty-original-experiment";
pty.inputs.nixpkgs.follows = "nixpkgs";

smalltalk.url = "github:compoundingtech/smalltalk";
smalltalk.url = "github:compoundingtech/smalltalk-original-experiment";
smalltalk.inputs.nixpkgs.follows = "nixpkgs";
smalltalk.inputs.flake-utils.follows = "flake-utils";
# One pty in the closure: convoy links the same build smalltalk was built against.
Expand Down
85 changes: 0 additions & 85 deletions notes/NATIVE-LAUNCH-PLAN.md

This file was deleted.

148 changes: 148 additions & 0 deletions scripts/check-private-names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Keep private machine, person, and agent names out of this public repository.

Fails when a tracked file names one of the fleet's machines, a home directory
under /home or /Users, a person/ ID, or an internal agent or mission ID
(agent/fleet/, mission/fleet/, mission-run/fleet/). Write invented
examples instead: web1.example.com, /home/example, /Users/example,
person/alex, agent/example/worker.

Machine names are listed as SHA-256 digests of the lowercase name, so this
file does not publish the names it keeps out. To add a machine, append the
output of `printf %s NAME | shasum -a 256`.

A line that must stay as it is goes in .private-names-allow: one regular
expression per line, searched in `path:text of the line`. Blank lines and
lines starting with # are ignored.

Run it from anywhere inside the repository: python3 scripts/check-private-names.py
With --self-test it checks its own patterns instead of the repository.
"""

import hashlib
import re
import subprocess
import sys
from pathlib import Path

MACHINE_DIGESTS = {
"a994ffcab684f9e2136b0681c20d2a5ef5f23a5cb7b64ab8934d15a888cc4ea3",
"2c619e6895b513481a07dae6c4b5781282ef1eee7a7f16744fafe7dbbd3e5ca2",
"46b6312339466d3b206325f6f402e1fae56cd65f117c779e3b9259833ffbcdf0",
"9fe430e5274621be518924ed90d2355f39eeb1d4d98fb00ea0022f3a26ba5595",
"5881d66b8738b483578c8479c59f09856a5447b47bbd2660cff7dc92e1607a2d",
"294aa8d75483b8331e3ba6a7f24aea15202747f36de65197e7bc6194880b2558",
"151ec47d45b057450da03010df462bf604183571812f9ba66598b9fb8f8b692c",
}

ALLOW_FILE = ".private-names-allow"

WORD = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
HOME = re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)")
PERSON = re.compile(r"(?<![A-Za-z0-9_-])person/([A-Za-z0-9._-]+)")
FLEET_ID = re.compile(r"(?<![A-Za-z0-9_-])(?:agent|mission|mission-run)/fleet/")
INVENTED_PEOPLE = {"alex"}


def invented(name):
return name.lower().startswith("example")


def digest(word):
return hashlib.sha256(word.encode()).hexdigest()


def names_a_machine(line):
for word in WORD.findall(line.lower()):
for part in {word, *word.split("-")}:
if digest(part) in MACHINE_DIGESTS:
return True
return False


def problems(line):
if names_a_machine(line):
yield "names a private machine"
for match in HOME.finditer(line):
if not invented(match.group(1)):
yield f"home path {match.group(0)!r}: use /home/example or /Users/example"
for match in PERSON.finditer(line):
name = match.group(1)
if not invented(name) and name.lower() not in INVENTED_PEOPLE:
yield f"person ID {match.group(0)!r}: use person/alex"
if FLEET_ID.search(line):
yield "internal agent or mission ID: use agent/example/..."


def self_test():
"""Prove each kind of name is caught and each invented example is not."""
MACHINE_DIGESTS.add(digest("plantedhost"))
caught = [
"ssh plantedhost", "PlantedHost.local (cli)", "plantedhost-pty",
"cd /home/someone/src", "/Users/someone", "person/someone",
"agent/fleet/x/standing/y", "mission/fleet/x", "mission-run/fleet/x/1",
]
allowed = [
"ssh example-host", "plantedhostx", "/home/example/src", "/Users/Example99/Proj",
"person/alex", "person/example-2", "agent/example/worker",
]
wrong = [line for line in caught if not list(problems(line))]
wrong += [line for line in allowed if list(problems(line))]
for line in wrong:
print(f"self test: wrong answer for {line!r}")
return 1 if wrong else 0


def main():
if sys.argv[1:] == ["--self-test"]:
return self_test()
root = Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=True, capture_output=True, text=True,
).stdout.strip()
)
script = Path(__file__).resolve()
this = script.relative_to(root).as_posix() if script.is_relative_to(root) else None
allow_path = root / ALLOW_FILE
allowed = []
if allow_path.exists():
for raw in allow_path.read_text().splitlines():
if raw.strip() and not raw.startswith("#"):
allowed.append(re.compile(raw))

files = subprocess.run(
["git", "ls-files", "-z"],
cwd=root, check=True, capture_output=True,
).stdout.decode().split("\0")

found = 0
for path in files:
if not path or path in (this, ALLOW_FILE):
continue
full = root / path
if not full.is_file():
continue
data = full.read_bytes()
if b"\0" in data:
continue
for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
located = f"{path}:{line}"
if any(pattern.search(located) for pattern in allowed):
continue
for problem in problems(line):
print(f"{path}:{number}: {problem}")
found += 1

if found:
print(
f"\n{found} private name(s) found. Replace each with an invented "
f"example, or add a pattern to {ALLOW_FILE} if the line must stay.",
file=sys.stderr,
)
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
10 changes: 5 additions & 5 deletions src/agent-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe("parseAgentFile — schema + validation", () => {
const af = parseAgentFile(`
identity = "convoy-claude"
role = "worker"
host = "silber"
host = "example-mac"
workspace = "/repos/convoy"
harness = "codex"
transport = "mcp"
Expand All @@ -16,7 +16,7 @@ strategy = "permanent"
expect(af).toEqual({
identity: "convoy-claude",
role: "worker",
host: "silber",
host: "example-mac",
workspace: "/repos/convoy",
harness: "codex",
transport: "mcp",
Expand All @@ -30,7 +30,7 @@ strategy = "permanent"
});

it("LOWERCASES a hand-authored capitalized host (else it silently never matches the host-filter)", () => {
expect(parseAgentFile(`identity="wk"\nrole="worker"\nhost="Silber"\n`).host).toBe("silber");
expect(parseAgentFile(`identity="wk"\nrole="worker"\nhost="Example-Mac"\n`).host).toBe("example-mac");
});

it("throws on a missing required field", () => {
Expand Down Expand Up @@ -117,15 +117,15 @@ describe("agentFileToSpec — compile intent → AgentSpec", () => {
it("threads supervisor → spec.supervisor so a crash-ding reaches the parent (dropping it was the regression)", () => {
// The declarative flow (convoy up launches from the catalog) must carry the declared supervisor through
// to the spec — it becomes the session's convoy.spawner tag, which crashDingTargets pages on a crash.
expect(agentFileToSpec({ ...base, supervisor: "silber.cd-sup" }, { networkRoot: "/net" }).supervisor).toBe("silber.cd-sup");
expect(agentFileToSpec({ ...base, supervisor: "example-mac.cd-sup" }, { networkRoot: "/net" }).supervisor).toBe("example-mac.cd-sup");
// no supervisor declared → null (launch then falls back to the launching ST_AGENT).
expect(agentFileToSpec(base, { networkRoot: "/net" }).supervisor).toBeNull();
});
});

describe("agentFileToToml — serialize (what convoy add authors)", () => {
it("round-trips through parse: write then read yields the same AgentFile", () => {
const af: AgentFile = { identity: "cos-claude", role: "chief-of-staff", host: "silber", workspace: "/repos/cos", harness: "codex", transport: "mcp", persona: "/p.md", strategy: "permanent" };
const af: AgentFile = { identity: "cos-claude", role: "chief-of-staff", host: "example-mac", workspace: "/repos/cos", harness: "codex", transport: "mcp", persona: "/p.md", strategy: "permanent" };
expect(parseAgentFile(agentFileToToml(af))).toEqual(af);
});

Expand Down
Loading
Loading