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
18 changes: 12 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1085,12 +1085,18 @@ folder every 5 minutes (`agents-enable`) and the jumpboxes looked nowhere. Measu
launcher: a live `--remote-control` session existed 0.9 s after a clone finished; worst case is the 5 s
scan plus about a second.

- **What is new.** A folder marked for interactive use after the watcher first looked: `claude-interactive`
in its git directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`). A checkout directly under
`~/*` or `~/src/*`, or a linked worktree of one wherever it lives; a subagent worktree under
`.claude/worktrees/` never counts. An unmarked clone gets no session (owner, 2026-10-13: sessions for
what is used interactively). The checkout must be finished (no `*.lock`, the index exists). The first run
only records what exists and launches nothing.
- **What is new.** A repository that appears after the watcher first looked. On dev boxes, every checkout
directly under `~/*` or `~/src/*` counts on its own: a clone or a new repository gets a session (owner, 2026-10-27:
"clone is fine, I just don't want one for every internal worktree that agents create"). A linked worktree
of one, wherever it lives, counts only when marked for interactive use: `claude-interactive` in its git
directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`); a subagent worktree under
`.claude/worktrees/` never counts. The checkout must be finished (no `*.lock`, the index exists). The
first run only records what exists and launches nothing, and so does the first scan of a state file from
before every clone counted (no `clones` key), so the upgrade does not open a session in every old clone.
- **The admin jumpboxes require explicit approval for new clones.** Both jumpboxes set `--require-clone-marker`
through their root-owned `ASS_ARGS` policy, including cold bootstrap. Their main checkouts require the same
`claude-interactive` marker as worktrees before either Claude or Codex starts. An allowed origin, including
the collaboration repository, does not authorize its startup hooks to run with administrator credentials.
- **Whose repos.** The account's own GitHub login (from gh's `hosts.yml`) plus
`~/.config/agent-session-sync/owners` and `--owner`. The ubuntu account on the metal boxes and jumpboxes
also gets `dolphin-labs-hq/dolphin-labs` by exact name; never an organisation wildcard.
Expand Down
41 changes: 35 additions & 6 deletions agent-session-sync.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# jumpboxes. Before this, the metal boxes looked once at boot (fcvm-claude-rc), nextjs-dev started only
# each user's one working folder every 5 minutes (agents-enable), and the jumpboxes looked nowhere, so
# a repo cloned today waited for a reboot or a person. See scripts/agent-session-sync.py for what
# counts as new: a checkout or worktree MARKED for interactive use (claude-interactive in its git dir), a
# finished clone, an allowed owner, and nothing that existed when the watcher first looked. After a reboot
# counts as new: any checkout on dev boxes, or a checkout MARKED for interactive use on admin jumpboxes;
# worktrees always require that marker (claude-interactive in the git dir). A finished clone, an allowed
# owner, and nothing that existed when the watcher first looked. After a reboot
# it opens the folders scripts/agent-active-dirs.py names (prompted or marked in the last 30 days).
#
# ONE script and ONE template unit, installed by the same snippet everywhere, so the boxes cannot
Expand Down Expand Up @@ -37,18 +38,46 @@ INSTALL
# ejc3's own repositories (its gh login is added automatically) and ONE collaboration repository by
# exact name, never an organisation wildcard (the same gate fcvm-claude-rc uses at boot).
agent_session_sync_ubuntu_args = "--owner ejc3 --repo dolphin-labs-hq/dolphin-labs"
# An allowed origin does not approve its hooks running with jumpbox administrator credentials.
agent_session_sync_admin_args = "${local.agent_session_sync_ubuntu_args} --require-clone-marker"

# Fence an old watcher before installing code whose dev-box default admits unmarked clones. Stopping
# this unit leaves interactive agents and tmux alive (KillMode=process). A failed stop must stop setup.
agent_session_sync_prepare_admin = <<-PREPARE
policy=/etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf
install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d || exit 1
printf '[Service]\nEnvironment="ASS_ARGS=%s"\n' '${local.agent_session_sync_admin_args}' > "$policy.new" || exit 1
chmod 644 "$policy.new" || exit 1
if ! cmp -s "$policy.new" "$policy"; then
if [ "$(systemctl show agent-session-sync@ubuntu.service -p LoadState --value 2>/dev/null)" != not-found ]; then
systemctl stop agent-session-sync@ubuntu.service || { rm -f "$policy.new"; exit 1; }
fi
mv -f "$policy.new" "$policy" || exit 1
systemctl daemon-reload || exit 1
else
rm -f "$policy.new"
fi
PREPARE

agent_session_sync_enable_ubuntu = <<-ENABLE
install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d
cat > /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf <<'POLICY'
install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d || exit 1
cat > /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new <<'POLICY' || exit 1
[Service]
Environment="ASS_ARGS=${local.agent_session_sync_ubuntu_args}"
POLICY
chmod 644 /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new \
&& mv -f /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf || exit 1
systemctl daemon-reload
systemctl enable agent-session-sync@ubuntu.service >/dev/null 2>&1 || true
# Starting a stopped watcher is safe; a running one is left alone so a re-run never resets it.
systemctl is-active --quiet agent-session-sync@ubuntu.service || systemctl start agent-session-sync@ubuntu.service || true
ENABLE

agent_session_sync_enable_admin = replace(
local.agent_session_sync_enable_ubuntu,
local.agent_session_sync_ubuntu_args,
local.agent_session_sync_admin_args,
)
}

# The jumpboxes ignore user_data changes and run no downloaded script as root, so a running one gets
Expand All @@ -64,13 +93,13 @@ resource "terraform_data" "admin_agent_session_sync" {
active = filesha256("${path.module}/scripts/agent-active-dirs.py")
unit = filesha256("${path.module}/scripts/agent-session-sync@.service")
helper = filesha256("${path.module}/scripts/ssm-agent-session-sync.sh")
args = local.agent_session_sync_ubuntu_args
args = local.agent_session_sync_admin_args
}

provisioner "local-exec" {
command = "bash ${path.module}/scripts/ssm-agent-session-sync.sh ${each.value} ${var.aws_region}"
environment = {
ASS_ARGS = local.agent_session_sync_ubuntu_args
ASS_ARGS = local.agent_session_sync_admin_args
}
}
}
10 changes: 6 additions & 4 deletions jumpbox2-user-data.tf
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,14 @@ FLEET_HOST_ROLE=admin
${local.codex_remote_control}

# ---------------------------------------------------------------- new repos within seconds
# A cheap 5-second watch (agent-session-sync.tf): a repository cloned later gets a Claude session and a
# Codex thread without a person or a reboot. It runs as ubuntu, starts nothing when there is no tmux
# server, and downloads nothing: the script is this file's own copy.
# A cheap 5-second watch (agent-session-sync.tf): a repository marked for interactive use gets a Claude
# session and a Codex thread without a reboot. Both admin jumpboxes require the marker before starting
# either agent; an allowed repository origin does not approve its startup hooks. It runs as ubuntu,
# starts nothing when there is no tmux server, and downloads nothing: the script is this file's own copy.
${local.codex_seed_thread_install}
${local.agent_session_sync_prepare_admin}
${local.agent_session_sync_install}
${local.agent_session_sync_enable_ubuntu}
${local.agent_session_sync_enable_admin}

# ---------------------------------------------------------------- user-level agent instructions
# A starter ~/.codex/AGENTS.md with ~/.claude/CLAUDE.md linked to it, only when ubuntu has
Expand Down
55 changes: 37 additions & 18 deletions scripts/agent-session-sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@
or agent-session-sync@<user>.service on nextjs-dev) on every box that keeps remote-control sessions.
It replaces "the boot launcher looked once, at boot" with a cheap watch loop (default every 5 s).

WHAT COUNTS AS NEW. A folder marked for interactive use after the watcher first looked: the file
`claude-interactive` in its git directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`, which
agents do when they clone a repository or create a worktree the user wants to prompt). Owner's rule,
2026-10-13: a session for what is used interactively, not for every clone. Looked for in:
* a checkout directly under one of the roots (default ~/* and ~/src/*) whose `.git` is a directory;
* a linked worktree of such a checkout, wherever it lives (git records it in .git/worktrees/<name>);
a subagent's worktree under .claude/worktrees/ never counts;
WHAT COUNTS AS NEW. A repository that appears after the watcher first looked:
* every checkout directly under one of the roots (default ~/* and ~/src/*) whose `.git` is a
directory: a clone or a new repository gets a session on its own (owner, 2026-10-27: "clone is fine,
I just don't want one for every internal worktree that agents create");
* a linked worktree of such a checkout, wherever it lives (git records it in .git/worktrees/<name>),
only when it is marked for interactive use: the file `claude-interactive` in its git directory
(`touch "$(git rev-parse --git-dir)/claude-interactive"`), which agents do for a worktree the user
wants to prompt; a subagent's worktree under .claude/worktrees/ never counts;
* finished: HEAD resolves, no *.lock in .git (a clone, fetch or checkout is still running) and the
index exists (a clone writes it last), so a half-cloned repo is not launched;
* allowed: its origin is owned by an allowed owner (the account's own GitHub login, plus
~/.config/agent-session-sync/owners and --owner) or is an exact --repo, and it is not claude-code-sync.
On administration jumpboxes, --require-clone-marker also requires claude-interactive for a main
checkout before either agent starts. An allowed origin alone does not approve repository startup hooks.
The FIRST run only records what already exists and launches nothing: the boot launcher decided about
those.
those. A state file written before every clone counted (no "clones" key) is adopted the same way: the
first scan with this version records the unmarked checkouts already there without launching them, so the
upgrade does not open a session in every old clone at once.

AFTER A REBOOT it starts Claude in every folder agent-active-dirs names (a person prompted there, or it was
marked, in the last 30 days): the same rule the boot launchers use, so a folder nobody prompted stays closed.
Expand Down Expand Up @@ -157,8 +162,8 @@ def is_allowed(path, owners, repos):
return owner.lower() in owners or "%s/%s" % (owner, repo) in repos


def candidates(roots):
"""Folders marked for interactive use: checkouts directly under the roots, and their linked worktrees."""
def candidates(roots, require_clone_marker=False):
"""Main checkouts (marked too on admin boxes) and marked linked worktrees."""
seen = set()
for pattern in roots:
for p in glob.glob(pattern):
Expand All @@ -169,7 +174,7 @@ def candidates(roots):
if not is_main_checkout(real):
continue
git = os.path.join(real, ".git")
if real not in seen and os.path.exists(os.path.join(git, MARKER)):
if real not in seen and (not require_clone_marker or os.path.exists(os.path.join(git, MARKER))):
seen.add(real)
yield real
for admin in sorted(glob.glob(os.path.join(git, "worktrees", "*"))):
Expand Down Expand Up @@ -279,6 +284,9 @@ def __init__(self, path, boot_id=None):
self.path = path
self.first_run = not os.path.exists(path)
self.known = {}
# True once the state was written by a version that launches every clone. An older file lacks it:
# its first scan here records the unmarked checkouts already present instead of launching them.
self.clones = self.first_run
# After a reboot: the active folders still to launch, and those launched this boot (trust is kept for them).
self.boot = {"pending": [], "launched": [], "tried": {}}
self.boot_id = current_boot_id() if boot_id is None else boot_id
Expand All @@ -290,6 +298,7 @@ def __init__(self, path, boot_id=None):
self.known = data.get("known", {})
self.boot.update(data.get("boot") or {})
saved = data.get("boot_id")
self.clones = bool(data.get("clones"))
except (OSError, ValueError):
self.known = {}
# An older state file has no boot id: not evidence of a reboot.
Expand All @@ -299,7 +308,8 @@ def save(self):
os.makedirs(os.path.dirname(self.path), exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path), prefix=".known.")
with os.fdopen(fd, "w") as fh:
json.dump({"known": self.known, "boot_id": self.boot_id, "boot": self.boot}, fh, indent=1, sort_keys=True)
json.dump({"known": self.known, "boot_id": self.boot_id, "boot": self.boot, "clones": self.clones},
fh, indent=1, sort_keys=True)
os.replace(tmp, self.path)


Expand All @@ -308,11 +318,14 @@ def save(self):
def scan_once(cfg, state, launcher, now=None):
"""One pass. Returns the repos a launch was attempted for."""
now = time.time() if now is None else now
found = list(candidates(cfg["roots"]))
found = list(candidates(cfg["roots"], cfg.get("require_clone_marker", False)))
present = set(found)
attempted = []

# A path that is no longer a marked checkout is forgotten, so deleting and re-cloning is new again.
# Recording only, not launching: the first run, or the first scan after upgrading to "every clone counts".
adopting = state.first_run or not state.clones

# A path that is no longer a candidate is forgotten, so deleting and re-cloning is new again.
for path in [p for p in state.known if p not in present]:
del state.known[path]

Expand Down Expand Up @@ -363,12 +376,12 @@ def scan_once(cfg, state, launcher, now=None):
log("new repository (its owner is allowed now): %s" % path)
if entry is None:
ident = repo_identity(path)
if not is_allowed(path, owners, repos):
state.known[path] = {"ignored": True, "seeded": state.first_run, "id": ident}
continue
if not is_finished(path):
continue # a clone still running: looked at again next tick
if state.first_run:
if not is_allowed(path, owners, repos):
state.known[path] = {"ignored": True, "seeded": adopting, "id": ident}
continue
if adopting:
state.known[path] = {"claude": True, "codex": True, "seeded": True, "id": ident}
continue
entry = state.known[path] = {"claude": False, "codex": False, "tried": {}, "id": ident}
Expand All @@ -394,7 +407,10 @@ def scan_once(cfg, state, launcher, now=None):
# a failed attempt waits RETRY_SECONDS, except "no tmux server yet" (cheap to poll)
if getattr(launcher, "cheap_retry", {}).get(kind):
tried.pop(kind, None)
if not state.first_run and not state.clones:
log("adopted the checkouts already present; from now on every new clone gets a session")
state.first_run = False
state.clones = True
keep_trust(cfg["home"], [p for p, e in state.known.items() if e.get("claude") and not e.get("seeded")]
+ list(state.boot.get("launched", [])), state)
state.save()
Expand Down Expand Up @@ -562,6 +578,7 @@ def build_config(args):
state_dir = args.state_dir or os.path.join(home, ".local", "state", "agent-session-sync")
return {
"home": home, "roots": roots, "state_dir": state_dir,
"require_clone_marker": args.require_clone_marker,
"owners": allowed_owners(home, args.owner or []), "owners_extra": list(args.owner or []),
"owners_sig": owners_signature(home), "repos": set(args.repo or []),
"tmux": args.tmux, "t_claude_arg": args.t_claude, "codex_seed": args.codex_seed,
Expand Down Expand Up @@ -596,6 +613,8 @@ def main(argv):
ap.add_argument("--root", action="append", help="glob of checkout directories (default ~/* and ~/src/*)")
ap.add_argument("--owner", action="append", help="allowed GitHub owner (also: the account's own gh login)")
ap.add_argument("--repo", action="append", help="one allowed OWNER/REPO, exactly")
ap.add_argument("--require-clone-marker", action="store_true",
help="require claude-interactive for main checkouts too (administration jumpboxes)")
ap.add_argument("--t-claude", default="auto", help="t-claude.zsh to source (default: the system copy, else ~/.config/t-claude.zsh)")
ap.add_argument("--codex-seed", default="/usr/local/bin/codex-seed-thread")
ap.add_argument("--active-dirs", default="/usr/local/bin/agent-active-dirs")
Expand Down
11 changes: 9 additions & 2 deletions scripts/ssm-agent-session-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,19 @@ echo $unit | base64 -d > "\$t/unit"
printf '[Service]\nEnvironment="ASS_ARGS=%s"\n' '$ASS_ARGS' > "\$t/policy"
restart=0
put() { cmp -s "\$1" "\$2" && return 1; install -m "\$3" "\$1" "\$2.new" && mv -f "\$2.new" "\$2"; }
# An old watcher self-execs with its old arguments when its script changes. Fence it and install the
# stricter admin policy BEFORE publishing new code; a failed stop aborts without publishing that code.
install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d
if ! cmp -s "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf; then
if [ "\$(systemctl show agent-session-sync@ubuntu.service -p LoadState --value 2>/dev/null)" != not-found ]; then
systemctl stop agent-session-sync@ubuntu.service
fi
put "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf 644
fi
put "\$t/script" /usr/local/bin/agent-session-sync 755 || true
put "\$t/seeder" /usr/local/bin/codex-seed-thread 755 || true # the watcher seeds Codex threads through it
put "\$t/active" /usr/local/bin/agent-active-dirs 755 || true # which folders open after a reboot
put "\$t/unit" /etc/systemd/system/agent-session-sync@.service 644 && restart=1
install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d
put "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf 644 && restart=1
systemctl daemon-reload
systemctl enable agent-session-sync@ubuntu.service >/dev/null 2>&1
if systemctl is-active --quiet agent-session-sync@ubuntu.service; then
Expand Down
Loading
Loading