Skip to content

sessions ​

The work-item ↔ harness-session registry, the five execution-control commands, and the reset that forgets a work item entirely.

bash
# registry
the-loop sessions register --work-item github:OWNER/REPO#N --harness claude \
    --harness-session-id "$CLAUDE_SESSION_ID" [--cwd .] [--force]
the-loop sessions link-pr --work-item github:OWNER/REPO#N --pull-request REF|N
the-loop sessions link-pr --work-item github:OWNER/REPO#N --discover [--branch B] [--repository R]
the-loop sessions list   [--status active|paused|closed] [--format table|json]
the-loop sessions attach --work-item github:OWNER/REPO#N [--read-only]
the-loop sessions close  --work-item github:OWNER/REPO#N [--keep-tmux|--kill-tmux]
the-loop sessions reset  --work-item github:OWNER/REPO#N [--work-item …] [--dry-run]
the-loop sessions reset  --all [--dry-run]
the-loop sessions restart --all [--dry-run] [--format text|json]
the-loop sessions restart --work-item github:OWNER/REPO#N [--work-item …] [--dry-run]

# execution control — the same five commands as the comment keywords
the-loop sessions start   --work-item github:OWNER/REPO#N [--no-comment]
the-loop sessions pause   --work-item github:OWNER/REPO#N [--no-comment]
the-loop sessions resume  --work-item github:OWNER/REPO#N [--no-comment]
the-loop sessions stop    --work-item github:OWNER/REPO#N [--no-comment]
the-loop sessions cleanup --work-item github:OWNER/REPO#N [--no-comment]

Every subcommand accepts --registry-dir, defaulting to routing.registryDir.

start can succeed before a harness session exists. When the graph waits at its first human gate, it reports waiting and keeps the start request armed. Answer the gate on the work item; the daemon then launches the selected harness. A failed spawn still reports failure and clears its request.

--registry-dir / --portable-dir and the service

Since issue-161 these commands run through the control-plane service, which reads its own configured state directories. The two path flags therefore only take effect on the in-process path the test suite uses; to point real invocations at other state, point the service at it — the-loop --config other.yaml start, or set routing.registryDir / state.root in the config the service runs on. Arbitrary filesystem paths are deliberately not accepted over HTTP.

The registry ​

One human-inspectable JSON file per session under <registryDir> (default <state.root>/local/, git-ignored). Writes are atomic, so concurrent sessions on one machine are safe.

The invariant is one work item ↔ one active session. --force replaces a stale registration.

Claude Code sessions register with $CLAUDE_SESSION_ID. Cursor sessions register with the chat id they were launched with — non-interactive cursor-agent ls is unreliable for id discovery, so the id is captured at registration time.

A missing harness binary only warns

register succeeds even when the harness CLI is not on PATH, telling you events cannot be dispatched until it is installed. Registration is bookkeeping; dispatch is what needs the binary.

register ​

FlagRequiredMeaning
--work-itemyesWork-item ref, e.g. github:OWNER/REPO#15.
--harnessyesclaude or cursor.
--harness-session-idyesClaude session id, or Cursor chat id.
--cwdno (.)Directory the session runs in — resume is scoped to it.
--forcenoReplace an existing active registration for this work item.

Record a pull request as delivering a work item, so its comments, reviews and CI results route back to that work item's session.

FlagRequiredMeaning
--work-itemyesWork-item ref, e.g. github:OWNER/REPO#15.
--pull-requestone of these twoThe pull request that delivers it: its number in the work item's own repository (16, #16), or a full ref for one in another repository (github:OWNER/OTHER#16).
--discoverone of these twoAsk GitHub instead (issue-447): list the open pull requests whose head is this checkout's branch, and record each one. None found is exit 0.
--branchnoWith --discover: the head branch. Default: the checked-out branch; a detached HEAD needs the flag.
--head-ownernoWith --discover: the fork's owner when the head branch is a fork's. The work item's repository is asked for <owner>:<branch> too.
--repositorynoWith --discover: [HOST/]OWNER/REPO to look in. Default: the checkout's origin when it is on a trusted GitHub host, else the work item's repository.

Run it in the same step as opening the pull request. Routing normally infers which work item a PR delivers from GitHub's closingIssuesReferences, an issue-<n> head branch, or a closing keyword in the PR body — and a pull request the-loop itself opens carries none of those: a spec PR must not close its ticket, and the-loop's branches are loop/<id>-…. Without the binding, a review comment on that PR resolves to the PR as a work item of its own, which nobody armed, and is refused as unstarted.

Since issue-370 this is also the only writer of the work item's own pullRequests[]: inference decides where an event is delivered, and nothing else, so a pull request nobody recorded is not part of the work item's tracking and is not in a work-item review's scope.

bash
# opened some other way (by hand, by a GitHub MCP server):
the-loop sessions link-pr --work-item github:octo/repo#15 --pull-request 16
the-loop sessions link-pr --work-item github:octo/repo#15 --discover

the-loop pr create opens the pull request and records it, so a session that opens its PRs with it never runs this.

In Claude Code, the plugin runs this for you

the-loop's PostToolUse hook (hooks/the-loop-link-pr.py) runs link-pr --discover after a git push or anything that opens a pull request (gh pr create, a GitHub MCP server's create_pull_request), with the work item from THE_LOOP_WORK_ITEM or this registry. It parses no tool's output (issue-447). Running the command yourself afterwards is harmless, since it is idempotent, and it is still the way in a harness with no such hook.

When the call records the pull request for the first time, it also puts every label in routing.autoExecuteLabels on it (issue-466). The poller lists a pull request only when it carries all of them, so a linked PR without one is never polled and its review comments reach no session. A re-run, or a pull request that was already recorded, asks GitHub nothing, so a label someone removed on purpose stays off. If GitHub refuses the labels, the link still stands: the call exits 0, prints a note: on stderr, and the labels have to be added by hand.

Idempotent: a pull request already recorded is reported and exits 0. Exit 1 when the work item has no session record on this machine (register the session first — recording an endpoint for a record that does not exist would invent a work item), exit 2 for a malformed ref or a work item linked to itself.

There is no unlink

A mistaken binding is cleared with reset, which forgets this machine's whole record for the work item.

list ​

FlagDefaultMeaning
--statusallactive, paused or closed.
--formattabletable or json.

Shows each session's status — paused included — and its last control command. Retained tmux sessions accumulate after their work items close; --status closed is how you find them.

attach ​

FlagDefaultMeaning
--work-itemrequiredWhich session to attach to.
--read-onlyoffObserve without a keyboard (tmux attach -r).

Works after the work item is closed too — and is then always read-only, because a finished session takes no input. Equivalent to tmux attach -t loop-<slug>.

Attaching read-only is safe to leave running: the daemon delivers events by pasting into the pane, which consults no tmux client, so an observer cannot block the session's input. That was not true before 10.3.0 — the submit was a tmux send-keys, which resolves its target client from the session's current client, and tmux ≥ 3.7 refused it with client is read-only while anyone was attached this way, failing every delivery (issue-240).

A session registered by hand (sessions register) has no tmux session until its first dispatched event spawns one; until then attach errors, telling you no tmux session is recorded yet.

close ​

FlagDefaultMeaning
--work-itemrequiredWhich session to close.
--keep-tmux / --kill-tmuxrouting.tmux.keepSessionOnCloseWhether the tmux session survives.

Mutually exclusive. --keep-tmux keeps the transcript readable; the harness inside it is still ended unless routing.tmux.killHarnessOnClose is false. --kill-tmux ends it for good.

Sessions usually close themselves ​

When the work item ends — the issue closed, or, when the PR is the work item, that PR merged or closed — the session is auto-closed. No manual close needed.

A PR merely linked to the work item closing leaves the session running: one item is often delivered by several PRs, so only the item's own close ends it. Both ingress paths do it — the receiver on the closed event of the registered item, and the poller by noticing the item has left the open listing and confirming upstream that it really ended.

A tmux-hosted session is kept so you can read back what happened, but the harness inside it is ended so nothing can be typed into finished work.

reset ​

Forget everything this machine remembers about a work item, so it starts over on the code you have just fixed. The command #137 asked for: after fixing a bug in the-loop itself and releasing, an in-progress item is still holding a conversation the old CLI started, a poll ledger saying every comment is handled, and a control record saying it is armed.

FlagDefaultMeaning
--work-item—Which work item to reset. Repeatable.
--alloffEvery work item this machine holds state for. Mutually exclusive with --work-item.
--dry-runoffReport what would go; change nothing.
console
$ the-loop sessions reset --work-item github:octo/repo#15
github:octo/repo#15: ended a live session
github:octo/repo#15: removed the workspace checkout — uncommitted work in it is gone
github:octo/repo#15: reset — removed session, control, poll
reset 1 work item

What it removes ​

What goesWhy
the live sessionended through the normal close pathno harness is left running against records that have gone
<state.root>/local/<slug>.jsondeleted, not closeda closed record still lists, and is still attach-able — that is the "still remembered" a reset ends
portable/<slug>.json controlclearedthe item is disarmed: it waits for an explicit start rather than resuming itself
portable/<slug>.json pollclearedthe thread is first-sight again, so a fresh session re-reads it instead of finding everything already seen
the workspace checkoutper workspace.keepCheckoutOnClosereset reuses the close path rather than inventing a second policy

What it does not ​

The event log is appended to, never rewritten — the reset itself lands in it as session.reset, so "someone reset this" is a visible cause rather than an unexplained gap. And nothing in your repository is touched: docs/specs/<id>/work-item-state.json, the spec artifacts and the phase label are checked in on the work item's branch, and the process graph re-derives the current node from the artifacts anyway.

Two things it will tell you about

A reset run while gh-webhook is up can be partly undone — the daemon holds poll state in memory and may write it back. Stop it first for a clean slate.

And when requireStartCommand is false, clearing the poll section makes the item first-sight again, so the next poll cycle may re-spawn it rather than wait for a start. The command warns in both cases and still does the work — the judgement is yours.

Selection is deliberate: a bare reset is a usage error rather than "reset everything", and one bad ref in a list resets none of them. Nothing is posted to the ticket — there is no reset keyword (a comment must not be able to delete local state), and posting stop-execution would record intent the reset has just cleared (decision-050).

restart ​

Relaunch running sessions on the session environment the config declares now: the names env.sessionPassthrough forwards, with their current values, and every other name env.file declares, plus every credential name, kept out (#482). The command #410 asked for: a session's environment is whatever tmux handed its pane when the pane was forked, and nothing re-reads it afterwards — so changing a forwarded value leaves every session already running on the old one, indefinitely.

It is also the upgrade step for #482. A pane spawned by an earlier version was handed every env.file value, the GitHub token included, and still holds it. restart --all relaunches each one without it.

FlagDefaultMeaning
--work-item—Which work item's session to relaunch. Repeatable.
--alloffEvery running session this machine manages. Mutually exclusive with --work-item.
--dry-runoffReport what would be relaunched, and why the rest would not; touch no pane.
--formattextjson prints the per-session rows, the forwarded variables' fingerprints and the names kept out.
console
$ the-loop sessions restart --all
restarting 3 session(s): forwarding 1 variable(s) [HTTPS_PROXY], keeping out 3 [GH_TOKEN, GITHUB_TOKEN, JIRA_API_TOKEN]
github:octo/repo#15                      restarted
github:octo/repo#16                      restarted
github:octo/repo#17                      skipped — not running — `the-loop sessions start` brings a stopped session up

Each session is respawned in place: respawn-pane -k replaces the harness inside the existing pane, so the tmux session and its window keep their identity and an attached operator stays attached rather than being dropped. The harness is always asked to resume the conversation the registry records, in the working directory it records — a relaunch costs nothing the agent knew.

The pane's scrollback is cleared, as it would be by a fresh session: respawn-pane does not carry history across. The conversation survives (that is what --resume is for) and so does the work item's own record; what is lost is the rendered terminal history of the session so far.

What it will not do ​

start a stopped sessionskipped — start is that verb
replace an unresumable conversation with a blank oneskipped, and the pane is left running: losing twenty conversations is a worse outcome than a stale value
run with neither env.file nor env.sessionPassthrough configuredrefuses — there is no declared environment to roll onto, and a respawn for nothing still costs you every pane
guess at a fleet-wide relauncha bare restart is a usage error, exactly as reset is
re-derive the harness launch flagsout of scope and filed separately — this refreshes the environment, and a session comes back on the command line it went down on

It proves the relaunch ​

Afterwards the new process's own environment is read back and compared, name by name: a forwarded name must hold its current value, and a kept-out name must not be there at all. A session that does not match is named, with the variables that differ, and the command exits non-zero.

A host that will not report a process's environment yields unverified rather than either verdict, and does not fail the run on that ground alone.

No value is ever printed. A forwarded variable is identified by name and by a truncated SHA-256 fingerprint of what it should be; a kept-out variable by name and "held; a session must not hold it".

What reaches tmux, and what does not

A forwarded value reaches tmux through -e NAME=VALUE, which is an argv: ps shows it, and when that call starts the tmux server, the server keeps it as its process name for its whole life (#485). That is why only non-secret names may be forwarded, and why a credential name is refused there.

A kept-out name never reaches an argv as a value. The harness is started behind env -u NAME …, so it does not inherit the name from the tmux server either — which matters, because a server the daemon started inherited the daemon's environment, env file and all. tmux itself is run with those names removed, so a server a spawn starts holds none of them.

Upgrading from a version before #482

Panes spawned by an earlier version keep -e GH_TOKEN=… (and every other env.file value) in their command line, and in the tmux server's when one of those spawns started it, until they are relaunched. Run the-loop sessions restart --all. The server's own command line clears only when the server exits: run tmux kill-server once no session needs keeping, and the next spawn starts a clean one. Rotate any token that was exposed.

Spotting the drift before it bites ​

the-loop status carries one line when running sessions hold a forwarded value that differs, or a name they must not hold, and nothing at all when they do not:

text
sessions    2 of 6 running with a stale environment — `the-loop sessions restart --all`

A session spawned after a change is not born stale: the daemon re-reads the config and env.file per spawn rather than letting the pane inherit the tmux server's long-frozen copy.

Execution control ​

start / pause / resume / stop / cleanup apply exactly what the corresponding comment keyword applies, from the machine running the-loop:

  • start spawns through the same dispatcher the daemon uses — the work item's channel conversation opened first (the Slack thread, issue-317), then workspace checkout, harness trust, tmux hosting, announcement — or resumes a paused session, and prints the tmux target it spawned into.
  • pause holds events; the session keeps its conversation.
  • resume delivers events again.
  • stop takes the normal close path.
  • cleanup releases the work item's local resources — the end of the life cycle, and the only one of the five that destroys anything.
FlagDefaultMeaning
--work-itemrequiredWhich work item to act on.
--comment / --no-commentonPost the equivalent keyword comment on the work item.

Each invocation records the command in that work item's portable record (<state.root>/portable/<slug>.json, control section) and posts the same keyword back to the work item, so the ticket stays the full record of who asked for what. That comment carries the loop-prevention marker, so the daemon never reads its own action back and re-applies it.

Posting is best-effort: --no-comment skips it, and a missing token or a failing post only warns — it never undoes the local action.

cleanup ​

Release the local resources a finished work item accumulated on this machine, and nothing else. The counterpart to start: where that verb creates a checkout and a tmux session, this one reclaims them.

console
$ the-loop sessions cleanup --work-item github:octo/repo#15
ended github:octo/repo#16, github:octo/repo#15 — their tmux sessions and transcripts are gone
removed the workspace checkout under /srv/the-loop/ws — uncommitted work in it is gone
removed the machine-local session record
cleaned up github:octo/repo#15 — its portable record (control, poll, graph) is kept, and nothing remote was touched
commented 'the-loop cleanup' on github:octo/repo#15

Destructive, and deliberately unconditional

It ignores tmux.keepSessionOnClose and workspace.keepCheckoutOnClose. Those settings answer "what should survive the end of the work"; cleanup is you saying you are done with all of it, and a retention default that silently made this a no-op would be a verb that lies. Uncommitted work in the checkout is gone.

What goesWhat stays
tmuxevery endpoint's session — the work item's own and one per PR delivering it, harness ended first—
workspacethe work item's worktree, or its whole folder under the clone strategythe shared per-repository clone
registry<state.root>/local/<slug>.json—
portable—control, poll and the frozen graph: persistence and tracking outlive the machine
remote—everything: no branch, pull request, issue or label is touched

The work item's graph pointer moves to the terminal cleanup node first — so the ticket carries a loop:cleanup label and the transition is on the record — and the item is durably disarmed, like stop, so nothing re-spawns afterwards.

It works with or without a live session, and with or without a record: a checkout left behind by a crash is located from the work-item ref alone. "Nothing to clean up" is a normal outcome, not an error.

cleanup vs reset ​

Both remove things; they answer different questions.

cleanupreset
Asks"this work is over — reclaim the machine's resources""start this work item over on the code I have just fixed"
Portable recordkeptcleared (control + poll)
Retention settingsignoredhonoured
Triggera comment keyword, the CLI, or an authorized closurethe CLI only
Posts to the ticketyes — it is a control verbno, deliberately (decision-050)

Label-gated auto-execution ​

With spawnOnUnmatched: labeled: give an issue or PR every label in the configurable autoExecuteLabels, have an authorized user comment the-loop start (or run sessions start), and the ingress spawns a session and starts /the-loop:work-on on it — then routes that item's later activity (comments, reviews, CI, and every PR linked to it) to the same session, and auto-closes when the item itself closes. Label presence is read straight from the webhook payload, with no extra API call. A new issue without the label is received and ignored.

PRs are work items too ​

The label applies to PRs directly: a labelled PR with no linked issue is routed as its own work item, github:OWNER/REPO#<pr-number>.

That makes PRs monitorable even when the ticketing system is Jira or another provider — the ticket cannot be routed, but the PR delivering it can. /the-loop:work-on <jira-id> adds the label to the PR it opens and registers its session against the PR's ref automatically, so PR activity resumes the session and that PR's merge or close ends it, exactly like a GitHub-ticketed item.

See also ​

Released under the MIT License.