Skip to content

Design: a second kind of session, in its own namespace

Phase 2 of 4 (requirements → design → testing plan → tasks). Derives from the approved requirements.md. MUST be reviewed and approved before the testing plan and the tasks breakdown are derived from it.

Overview

A standing session is a new namespace, not a work item with a fake ref. It gets its own declaration (standingSessions in the CLI config), its own record store (<root>/local/standing/<name>.json), its own tmux name (loop-standing-<name>), its own core module and its own verbs. The two things it shares with a work item's session are the two that are genuinely the same job: the tmux runner and the harness adapters, both of which are refactored to be addressed by target rather than by work item.

Everything else stays where it is. The router, the dispatcher, the poller, the session registry and every work-item verb are untouched — which is the point of the separation (R3.1, R3.2): a GitHub event has no path into a standing session, and a standing session has no path onto a ticket.

mermaid
flowchart TB
  subgraph cfg["cli-config.yaml"]
    SC["standingSessions.sessions[]"]
  end
  subgraph core["the_loop.core.standing"]
    L[list/get] --- ST[start] --- SP[stop] --- SAY[say]
  end
  subgraph surf["surfaces"]
    CLI["the-loop standing …"]
    API["/api/v1/standing-sessions*"]
    MCP["MCP tools"]
    SDK["loop.standing.*"]
    LC["the-loop start/stop/status"]
  end
  SLACK["Slack thread"] --> IN["channels.inbound"] --> SAY
  cfg --> core
  surf --> core
  core --> REG["StandingRegistry\n<root>/local/standing/<name>.json"]
  core --> RUN["TmuxRunner\nloop-standing-<name>"]
  core --> AD["harness adapters\n(trust + plugins prepared)"]

Design decisions

D1 — A separate registry, not a widened WorkItemRef

WorkItemRef is provider:[host/]owner/repo#number, and every one of its consumers — the router's linkage resolution, the poll ledger, the browser URL, the registry file name — assumes that shape means a ticket exists. Teaching it a non-ticket form would put a value that has no owner, no repo and no number through code whose whole job is to decide which ticket an event belongs to. So standing sessions get StandingRegistry, a file-per-name store with the same atomic-write discipline as SessionRegistry and none of its shared identity.

The cost is a second store. The benefit is R3.2 for free: the-loop sessions list cannot show a standing session because it never reads that directory, and no event can be routed into one because the router resolves refs, not names.

D2 — The runner learns targets; the work-item methods delegate

TmuxRunner.spawn, deliver, kill and terminate_harness each take a work item or a Session for exactly one reason: to get a tmux target string out of it. Each is split:

todaybecomeswork-item entry point
spawn(work_item, …)spawn_in(target, …)spawnspawn_in(self.target_for(item), …)
deliver(session, …)deliver_to(target, …)deliver keeps its ref-specific refusals, then delegates
kill(session)kill_target(target)killkill_target(session.tmux_target)
terminate_harness(session, …)terminate_harness_in(target, label, …)terminate_harness → passes session.work_item.ref as the label

label exists only so the refusal log line ("refusing to terminate processes in tmux target X for Y") keeps naming what the target belonged to. No behaviour changes on the work-item side; every existing test of these methods must keep passing untouched, which is the check that the refactor is a refactor.

D3 — loop-standing-<name>, and why it cannot collide

WorkItemRef.slug is <provider>-<path…>-<number>, so every work-item target ends in -<digits>; name is ^[a-z0-9][a-z0-9-]{0,39}$, and a name ending in digits still produces loop-standing-foo1 while the only work item that could reach that string would need provider standing, which does not exist. The name is inside _LOOP_TARGET_RE (^loop-[A-Za-z0-9_-]+$), so the guard that stops the-loop signalling processes in a tmux session it did not create keeps working unchanged, and tmux_session_name is a no-op on it (no ., no :) — the issue-154 rewrite has nothing to rewrite.

D4 — Start resumes; stop keeps the record

A supervisor session that forgets everything on the-loop restart is not a supervisor. So the record outlives the process:

mermaid
stateDiagram-v2
  [*] --> absent
  absent --> running: start — fresh conversation (--session-id <uuid>)
  running --> running: start — already-running, nothing touched
  running --> stopped: stop — SIGTERM the pane, then kill the tmux session
  stopped --> running: start — resume (--resume <id>)
  running --> refused: start, LIVE tmux session but no record — refuse loudly

stop terminates the harness gracefully first (terminate_harness_in: SIGTERM, then SIGKILL after routing.tmux.harnessKillGraceSeconds) and only then kills the tmux session. The order is load-bearing for the resume in D4: Claude Code flushes its conversation on exit, and a kill-session straight to SIGHUP is how a resumable id becomes an unresumable one.

The resume is probed the way the respawn path probes it (survived, routing.tmux.resumeProbeSeconds): claude --resume <unknown-id> exits in well under a second, so a resume that did not take falls back to a fresh conversation instead of registering a corpse.

D5 — One health answer

the-loop start, stop and status each grow a standingSessions list beside their services list, and standing sessions count toward ok. A session that start would have started (standingSessions.enabled and the entry's autoStart) and that is not running makes the-loop status exit non-zero, because that is what an operator scripting a health check means by "is the-loop up". A session that is declared but not autoStart, or one that is only in the registry because it was started by hand, is reported and does not affect ok.

The services list keeps its exact current shape — nothing that reads it today has to change.

D6 — Slack: the announcement message is the session's chat thread

The existing Slack bot already does exactly what a standing session needs, keyed on a string it treats as opaque: ChannelState.bind(ts, work_item, channel) binds a thread, and fetch_replies reads back only bound threads. So a standing session's Slack surface is one announcement post, bound under the ref string standing:<name>:

mermaid
sequenceDiagram
  participant S as the-loop start
  participant B as SlackBotChannel
  participant T as thread
  participant P as channels.inbound
  participant Z as core.standing.say
  S->>B: post "standing session <name> is up"
  B->>T: chat.postMessage (channel = entry.slack.channel or channels.slack.channel)
  B->>B: bind(ts, "standing:<name>")
  T-->>P: authorized member replies in the thread
  P->>P: mirror step SKIPPED (no work item) → channel.mirror_skipped
  P->>Z: deliver into loop-standing-<name>

Two branches are added to the inbound pipeline, both keyed on standing.parse_standing_ref(reply.work_item):

  1. _mirror returns early for a standing target and emits channel.mirror_skipped with reason="standing-session". The paper trail does not vanish — the event log is where a session with no ticket keeps it.
  2. _deliver calls core.standing.say instead of core.sessions.reply_session.

Everything before those two — the bot drop, the authorization allow-list, the cursor advance — is untouched and applies unchanged (R4.4). The bot still cannot read the channel at large: it reads bound threads only, and the only thread it binds for a standing session is the one it posted itself.

slack.channel per entry, empty meaning channels.slack.channel, is implemented as dataclasses.replace(SlackChannelConfig.from_mapping(cfg), channel=entry.slack.channel) — no second parser, and every other Slack setting (tokens, verbosity, allow-list, read mode) stays centrally declared, which is the "link to an existing one" half of the ticket's ask.

D7 — The directive is not configurable

A standing session's boot prompt is _DIRECTIVE (in code) followed by the operator's own prompt/promptFile text. There is deliberately no promptTemplate key: the directive states the boundary R5.1 requires — you own no work item, do not answer a phase gate, do not post a control keyword on any ticket — and a template key would exist only to let an operator delete it. This is the same reasoning $interaction_directive already follows for work-item prompts, where a custom template that omits the placeholder gets the directive appended anyway.

D8 — A definition comes from the config or from the registry, and the verbs cannot tell

create is what makes a standing session exist without a config edit (requirement R6, decision-100). The cheap way to build it would be a second code path — "created sessions" with their own start and stop. That is the way to two divergent lifecycles.

Instead there is one seam, _entry_for(name), which answers what is this session from whichever source has it:

mermaid
flowchart LR
  N[a name] --> E{declared in\nstandingSessions.sessions?}
  E -- yes --> D[StandingSession from the config]
  E -- no --> R{recorded?}
  R -- yes --> S[StandingSession synthesized\nfrom the record]
  R -- no --> X[LookupError]
  D --> V[start / stop / restart / say]
  S --> V

Every verb goes through it, so start, stop, restart and say behave identically whichever source the definition came from (R6.7). The record therefore has to carry the whole definition — prompt, description, harnessArgs, autoStart join the fields it already had — because for a created session the record is the declaration.

Two asymmetries are deliberate:

  • create refuses a name that is already declared or already recorded (R6.2). A name is one session; adopting an existing one would let a create silently take over a running agent.
  • delete refuses a declared session (R6.5), naming the config key and stop instead. Deleting a record that the-loop start would recreate on the next boot is a lie about what happened. For a declared session the honest removal is a config edit.

And one symmetry that is load-bearing: stop_all stops every recorded session, so start_all restores every recorded one whose autoStart is set (R6.6). Without that, the-loop restart would destroy exactly the sessions the API created while dutifully stopping them first.

Components and interfaces

the_loop/standing.py — declaration, ref grammar, record store

python
STANDING_PREFIX = "standing:"
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")

def standing_ref(name: str) -> str                      # "standing:<name>"
def parse_standing_ref(ref: str) -> Optional[str]       # name, or None if not one
def tmux_target_for(name: str) -> str                   # "loop-standing-<name>"

@dataclass(frozen=True)
class SlackBinding:      enabled: bool; channel: str
@dataclass(frozen=True)
class StandingSession:   name, description, harness, harness_args, cwd, prompt,
                         prompt_file, auto_start, slack
@dataclass(frozen=True)
class StandingConfig:
    enabled: bool
    sessions: Tuple[StandingSession, ...]
    @classmethod
    def from_mapping(cls, cli_config) -> "StandingConfig"   # ValueError on a bad block
    def get(self, name) -> Optional[StandingSession]

@dataclass
class StandingRecord:    name, harness, harness_session_id, cwd, tmux_target,
                         status ("running" | "stopped"), created_at, started_at,
                         last_message_at, slack_thread, slack_channel

class StandingRegistry:  read(name) / write(record) / delete(name) / list()

StandingConfig.from_mapping raises — it is the one place that can refuse a whole block, and R1.1/R1.3/R1.5 all say refuse rather than resolve. Every verb lets it raise, with one deliberate exception: stop, which parses tolerantly because it is the recovery verb. stop works off the registry rather than the declaration, and an operator whose config broke after a start still has to be able to stop what is running. The lifecycle catches the raise and renders it as a single misconfigured row, exactly as it does for an enabled poller with no sources.

the_loop/core/standing.py — the capability

functionreturnsrefusals
list_standing(config)one row per declared or recorded session, merged by nameValueError on a malformed block — a read that answers "none" for a config with a typo in it is a wrong answer that looks like a fact
get_standing(name, config)that rowthe above, plus LookupError when neither declared nor recorded
start_standing(name="", config=…, auto_only=False){"sessions": [row…], "ok": bool}ValueError on a malformed block, LookupError on an undeclared name; a single session that fails to start is a per-session outcome: failed with a detail, never an exception
stop_standing(name="", config=…)same shape— (the tolerant parse; see above)
restart_standing(name, config)same shapeLookupError on an unknown name
create_standing(name, …, start=True, config=…)same shapeValueError on a bad name or one already declared/recorded
delete_standing(name, config=…)same shapeLookupError when unrecorded, ValueError when the name is declared
say_standing(name, text, actor, config){"name", "delivered", "exitCode", "messages"}LookupError (unknown / not running), ValueError (empty text)

start_standing("") starts every declared session; with auto_only=True it starts only the autoStart ones, which is what the lifecycle calls. stop_standing("") stops every recorded one — R2.6's "regardless of enabled" falls out of reading the registry rather than the config.

Row shape, one per session, used by all three verbs and by status:

json
{"name": "supervisor", "declared": true, "autoStart": true, "harness": "claude",
 "tmuxTarget": "loop-standing-supervisor", "cwd": "/home/me/dev",
 "running": true, "status": "running", "harnessSessionId": "…",
 "slackChannel": "C0…", "slackThread": "1712…", "outcome": "started", "detail": "…"}

Lifecycle integration

core/lifecycle.py gains three calls and one key:

  • start_all → after the service/ingress rows, standing.start_standing(auto_only=True) when standingSessions.enabled; its rows land under report["standingSessions"] and its ok is ANDed into the report's.
  • stop_allstanding.stop_standing() first, before the ingresses and the service, so a session is taken down while the control plane it may be talking to is still up.
  • status_allstanding.list_standing() rows under report["standingSessions"], and ok is false when a would-be-autostarted session is not running (D5).

Surfaces

surfaceshape
CLIthe-loop standing list | create <name> | delete <name> | start [name] | stop [name] | restart <name> | say <name> --text … (--json on list)
RESTGET /api/v1/standing-sessions, GET …/one?name=, POST …/create, POST …/delete, POST …/control {name, verb}, POST …/say {name, text, actor}
MCPlist_standing_sessions, get_standing_session, say_to_standing_sessioncontrol, create and delete are not exposed: an agent that could stop a standing session could stop the one supervising it, and bringing a harness process into (or out of) existence is an operator's act. Same reasoning that keeps restart and sessions reset off MCP
SDKloop.standing.list() / get() / create() / delete() / control() / say()

The authored OpenAPI contract (docs/api-specs/openapi/the-loop.v1.yaml) gains the four REST operations; test_api_contract_parity proves the served app matches it.

Error handling

situationbehaviour
standingSessions malformedevery verb but stop raises with the offending key named; the-loop start renders it as one misconfigured row, and stop still works off the registry so what is running stays stoppable
duplicate namewhole block refused, both positions named
prompt and promptFilethat entry refused by name
promptFile unreadable at startthat session failed, others still start (R1.6)
cwd does not existthat session failed, naming the path — never spawned into a missing directory
harness binary missingstanding.spawn_failed at error, row failed, naming the binary
cursor as the harnessUnsupportedRunnerError from the adapter → row failed with the adapter's message (cursor has no pre-assignable conversation id; unchanged from work items)
live tmux session, no recordrefused loudly, naming tmux attach -r -t … and the remedy (R2.9); stop refuses to signal it too — the-loop releases what the-loop started
dead retained panecleared and respawned, with or without a record: nothing is running in it, so the cost is the scrollback
tmux does not answer the probenothing spawned — silence is not absence (issue-146)
resume did not survive the probefall back to a fresh conversation; standing.resume_failed at warning, then standing.started
Slack post failsstanding.announce_failed at warning; the session is up regardless (R4.5)
say into a stopped sessionLookupError naming the-loop standing start <name> (R3.4)
create for a name already declared or recordedValueError naming which, and start/delete as the alternatives
delete of a declared sessionValueError naming standingSessions.sessions and stop — a record the config recreates must not be deletable
delete of an unrecorded nameLookupError

Testing strategy

Unit tests around the parser (every refusal), the registry (round-trip, atomicity, unreadable file), the ref grammar and the runner split (that a work-item spawn still produces the identical argv). Integration tests, with Gherkin docstrings, for the four scenarios that are the actual feature: start→resume across a stop, the lifecycle rows, a Slack thread reply reaching the pane without touching a ticket, and the refusal to spawn over a live unaccounted-for session. Fully detailed in testing-plan.md.

Security considerations

Design-level enforcement of the requirements' trust boundaries:

  • The config is executable-adjacent. harnessArgs and cwd name what runs and where. This is the posture reviews.critics and routing.harnessArgs already carry; the config docs say so explicitly, and standingSessions is added to nothing that would let an agent edit it — /api/v1/config is already off the MCP surface for exactly this reason.
  • No cross-namespace addressing. parse_standing_ref is the only way a standing: string is recognised, and it is called in two places: the two inbound branches. Nothing in the router, the dispatcher or SessionRegistry learns the prefix, so a crafted ref cannot make a work-item path resolve to a standing session or the reverse.
  • Slack authorization is not re-implemented. The standing branches sit after the bot-drop and the allow-list check in process_reply; there is no second entry point into the pipeline.
  • say is not a spawn. It refuses rather than starting a session, so neither the API nor a Slack message can bring a harness process into existence — starting one stays an operator's act on the control plane or the terminal.
  • The event log is the paper trail for a session with no ticket: standing.* for every transition, and channel.mirror_skipped where a work item would have had a mirrored comment.

Released under the MIT License.