Requirements: sessions that outlive every work item
Phase 1 of 4 (requirements → design → testing plan → tasks). This phase MUST be reviewed and approved before the design is derived from it.
Introduction
Everything the-loop spawns today is owned by a work item. loop-<slug> is minted from a WorkItemRef, the registry file is named after one, every control verb takes one, and every question the agent asks goes back to the ticket it came from. That is right for delivery work and wrong for everything else — and #277 names the everything else: watching the work items in flight, noticing one that is stuck, and letting an operator talk an agent through recovering it. None of that has a ticket, so today it has nowhere to run.
This work item adds standing sessions: named, long-lived tmux + harness sessions declared in the CLI config, brought up by the-loop start, addressed by name rather than by ref, and talked to on the two surfaces that exist for a session with no ticket — the control plane and Slack.
On the name. The ticket calls them ad-hoc sessions. "Ad-hoc" is already taken in this codebase, by something close enough to be confusing: pdlc-adhoc-loop / the-loop do (issue-225) is a tactical work item that skips the PDLC — it still has a ticket, still gets a loop-<slug> session, still finishes. What #277 describes is the opposite: no ticket, no completion, running as long as the-loop does. They are called standing sessions here to keep the two apart, and the ticket's vocabulary is recorded in decision-099.
flowchart LR
subgraph today["today — every session is a work item's"]
E[GitHub event] --> R[router] --> S["loop-github-owner-repo-15"]
end
subgraph new["this work item — sessions with no work item"]
ST["the-loop start"] --> N1["loop-standing-supervisor"]
CP[control plane] --> N1
SL[Slack thread] --> N1
endRequirements
Requirement 1 — Declared in the CLI config
User story: As an operator, I want to declare the sessions the-loop keeps for itself in the same file that declares its daemons, so that a new non-work-item use case is a config entry rather than a code change.
Acceptance criteria (EARS)
- WHEN the CLI config carries a
standingSessionsblock THEN the system SHALL validate it against.the-loop/cli-config.schema.json, and a config that fails validation SHALL be refused with the offending key named — never partially applied. - WHEN an entry declares
nameTHEN the system SHALL require it to match^[a-z0-9][a-z0-9-]{0,39}$, because the name is interpolated into a tmux target and a file name. - IF two entries declare the same
nameTHEN the system SHALL refuse the whole block with both positions named, rather than resolve the collision itself. - WHEN an entry omits
harnessTHEN the system SHALL userouting.defaultHarness; WHEN it omitsharnessArgsTHEN it SHALL userouting.harnessArgs[<harness>]; WHEN it omitscwdTHEN it SHALL userouting.spawnWorkdir. - WHEN an entry declares both
promptandpromptFileTHEN the system SHALL refuse the entry: two sources for one boot prompt has no defined precedence. - WHEN an entry declares
promptFileand the file cannot be read at start time THEN the system SHALL refuse to start that session, naming the path, and SHALL still start the others.
Requirement 2 — Brought up and taken down with the-loop
User story: As an operator, I want the-loop start / stop / status to cover these sessions too, so that "is the-loop up?" has one answer.
Acceptance criteria (EARS)
- WHEN
the-loop startruns ANDstandingSessions.enabledis true THEN the system SHALL start every entry whoseautoStartis true, after the control-plane service, and SHALL report one row per session alongside the service rows. - IF
standingSessions.enabledis false THENthe-loop startSHALL start none of them and SHALL report the block as disabled. - WHEN a session is started AND its tmux session is live THEN the system SHALL leave it untouched and report
already-running— a start SHALL never spawn over a live pane. - WHEN a session is started AND a record exists carrying a harness conversation id AND no live tmux session holds its name THEN the system SHALL spawn the harness with its resume argv, continuing that conversation.
- WHEN a session is started AND no record exists THEN the system SHALL spawn a fresh conversation with a pre-assigned session id and write the record.
- WHEN
the-loop stopruns THEN the system SHALL stop every recorded standing session regardless ofenabledandautoStart— a session disabled after it was started must still be stoppable. - WHEN a standing session is stopped THEN the system SHALL keep its record with
status: stoppedand its conversation id intact, so the next start resumes rather than forgets. - WHEN
the-loop statusruns THEN the system SHALL report each declared or recorded standing session with its name, harness, tmux target, running flag and conversation id; andokSHALL be false when a sessionstartwould have started (the block is enabled and the entry'sautoStartis true) is not running. A session that is declared withoutautoStart, or that is only in the registry because it was started by hand, SHALL be reported without affectingok. - WHEN a start finds a live tmux session holding the name but no record THEN the system SHALL refuse loudly and name the remedy, exactly as the work-item spawn path does — never kill an agent it cannot account for.
Requirement 3 — Addressable by name, on the control plane
User story: As an operator, I want to list, start, stop, restart and talk to these sessions from the control plane, because they have no ticket to comment on.
Acceptance criteria (EARS)
- There are three ways to interact with a standing session, and no others are built here (owner's ruling on PR #278): typing directly into its tmux session, a reply in its Slack thread, and the control plane's messaging path. The control plane is not modelled as a
channel— that alternative was considered and withdrawn (decision-100) — and the existing way to talk to a tmux session is reused rather than reinvented. - WHEN a caller addresses a standing session THEN it SHALL be addressed by name, and a work-item ref SHALL NOT resolve to one.
- WHEN
the-loop sessions listruns THEN standing sessions SHALL NOT appear in it: the two registries are separate namespaces, so nothing routes a GitHub event into a standing session by accident. - WHEN an authorized caller sends text to a running standing session THEN the system SHALL bracket-paste it into that session's TUI, submit it, emit
standing.said, and SHALL post the text to no ticket anywhere. - IF the addressed session is not running THEN the send SHALL be refused with an error naming
the-loop standing start <name>— a message SHALL never spawn a session. - WHEN the control plane exposes these operations THEN the CLI, the REST API, the MCP endpoint and the SDK SHALL all reach the same core functions, and the authored OpenAPI contract SHALL describe the REST half.
Requirement 4 — Reachable from Slack
User story: As an operator away from my terminal, I want to talk to a standing session in Slack, so that recovering a stuck work item does not require a shell.
Acceptance criteria (EARS)
- WHEN a standing session with
slack.enabledstarts ANDchannels.slackis enabled THEN the system SHALL post an announcement into that session's channel — its ownslack.channel, orchannels.slack.channelwhen it declares none — and SHALL bind the resulting thread to the session. - WHEN an authorized Slack member replies in that thread THEN the reply SHALL be delivered into the standing session's pane, and
channel.reply_receivedSHALL record it against the session. - WHEN a Slack reply is addressed to a standing session THEN the system SHALL NOT mirror it onto any work item — it has none — and SHALL record
channel.mirror_skippedwith the reason. - WHEN a Slack reply is unauthorized, bot-authored or unmapped THEN it SHALL be dropped exactly as it is today: the existing fail-closed authorization SHALL apply unchanged.
- IF
channels.slackis disabled, or the announcement cannot be posted, THEN the session SHALL still start — the Slack surface is best-effort and never gates the session.
Requirement 5 — A session that knows what it is not
User story: As an operator, I want a standing session to know it owns no work item, so that it does not answer a phase gate or run a control keyword on somebody else's ticket.
Acceptance criteria (EARS)
- WHEN a standing session is spawned THEN its boot prompt SHALL state its name, that it owns no work item, that it MUST NOT answer a phase-selection gate or post a control keyword on any ticket, and which surfaces its operator speaks to it on.
- WHEN the entry supplies
promptorpromptFileTHEN that text SHALL be appended to the directive above, never substituted for it — the same rule$interaction_directivefollows for work-item prompts.
Requirement 6 — Created and deleted at runtime, not only declared
User story: As an operator, I want to bring a standing session into existence and remove it again through the API, so that spinning one up does not mean editing a config file and restarting the-loop.
Added after the owner's ruling on PR #278: "Let's just do the APIs that create the adhoc session and delete that adhoc session", and "forget about control plane as a channel" — which withdrew the alternative this spec had been holding open. See decision-100.
Acceptance criteria (EARS)
- WHEN an authorized caller creates a standing session by name THEN the system SHALL record its whole definition — harness, arguments, working directory, brief, Slack binding — in the registry, and SHALL start it unless the caller asks otherwise.
- IF the name is already declared in
standingSessions.sessionsor already recorded THEN the create SHALL be refused: a name is one session, and silently adopting an existing one would let a create take over a running agent. - WHEN a session is created THEN every refusal a declared session's start applies SHALL apply unchanged — the name shape, a
cwdthat must exist, the live-occupant refusal, and the boot directive that cannot be replaced. - WHEN an authorized caller deletes a standing session THEN the system SHALL stop it (the same graceful termination
stopperforms) and then remove its record, so it does not come back. - IF the named session is declared in the config THEN delete SHALL be refused, naming the config key and
stopas the alternative — deleting a record the config would recreate is a lie about what happened. - WHEN a created session's record says it auto-starts THEN
the-loop startSHALL bring it back, exactly as it does a declared one. Without this,the-loop restartwould silently destroy every session created through the API, whilethe-loop stopstops them — an asymmetry that loses work. - WHEN a session exists only as a record (created, never declared) THEN
start,stop,restartandsaySHALL address it exactly as they address a declared one: the definition's source SHALL NOT change what the verbs do.
Non-functional requirements
- Observability. Every transition is an event:
standing.started,standing.resumed,standing.stopped,standing.said,standing.spawn_failed,standing.announced. Nothing about a standing session is inferable only from a logfile. - Isolation. No change to how a work item's event is routed, delivered or respawned. The runner gains target-addressed entry points; the work-item paths keep their exact behaviour.
- Idempotence.
startandstopare idempotent in both directions, as the service and daemon verbs already are.
Security considerations
- Actors & trust. Three inputs reach a standing session: the CLI config (the operator's own file — trusted, and the same trust
reviews.criticsalready carries, since both name a program the-loop runs), the control plane (already governed by the exposure guard and the deploying gateway, decision-059) and Slack (untrusted until it clearschannels.slack.authorizedUsers, which fails closed on an empty list). - What the config can do. An entry names a harness binary's arguments and a working directory, and the session runs with the operator's own credentials. That is the existing posture for
routing.harnessArgsandreviews.critics; it is called out in the config docs so an operator reviews astandingSessionsentry like code.cwdis resolved and must exist — a session is never spawned into a directory that is not there. - What it must not do. A standing session is never armed by a control keyword, never addressed by a work-item ref, and never appears in the session registry the router reads — so no GitHub event can be delivered into one, and no standing session can be mistaken for a work item's.
- No ticket, so the event log is the trail. A Slack reply into a standing session is not mirrored onto a work item because there is none. The paper-trail rule is not waived: it moves to the event log, which is why
channel.mirror_skippedrecords the reason rather than the pipeline staying silent. - Abuse case. An authorized Slack member sends a message that reads as an instruction to act on an unrelated repository. The session's boot prompt states the boundary (R5.1), the operator's allow-list is the gate, and the harness's own permission mode is unchanged by this work item — the-loop does not widen permissions here, exactly as it does not in
harnessTrust.
Out of scope
- Reading a Slack channel at large. The bot still reads only threads it is bound to (
fetch_replies). A standing session gets a thread of its own; it does not get permission to read the channel. - Spawning standing sessions on lifecycles other than
start. The ticket says "on start or other lifecycles"; onlystart(and the explicit verbs) exist here. The config shape leaves room —autoStartis a per-entry boolean, not a global one. - A standing session driving the work-item loop for you. What a supervisor session does is its prompt's business. This work item gives it somewhere to live and someone to talk to.