# Spec: Spens Non Interactive Mode
The goal of this specification is to describe new features / changes to spens 
to enable non-interactive mode, which can later be used for custom orchistrators. 
The goal is to make spens scriptable. A machine-readable control plane (launch /
status / cancel, structured lifecycle events, a versioned trace schema) that
an external tool — or CI — can drive. This is to be achived while also keeping 
the current functionlaity working. 

## Feature breakdown

### Output model (decision)

The event stream is the single source of truth. **Every session, in every
mode, always writes the full event stream to `<session_dir>/events.jsonl`
(and `state.json`).** The `--output` flag only controls what is *rendered to
the CLI's stdout*:

- `tty` — pretty-printed `[spens] ...` lines, now rendered via rich: styled
  prefixes on interactive terminals, docker build output constrained to a
  live panel, and a session-recap panel rendered from the `finished`
  event's `summary` dict. Piped/captured output stays plain and verbatim
  (rich emits no escape codes on non-terminal streams).
- `jsonl` — the raw event stream, one JSON object per line, flushed per event
- `background` — nothing on stdout; the session's output lives entirely in
  the session's `events.jsonl` / `state.json`

Agent container output is itself an event (`agent_output`, line-chunked),
so it lands in `events.jsonl` in every mode. The one exception remains
tty **interactive** mode, where the agent's docker `-it` stream is passed
through to the terminal directly as today (the agent is a TUI).

### Moving to an event driven architecture.
Currently the tty ouput and proccessing are tightly coupled , this makes adding 
non interactive or jsonl outputs difficult. The major part of this work will be 
seperation of the processing into an event driven approach, having the core operating
logic emit events that are consumed by syncs. 

the one exception to this is when in TTY mode, the docker output and input need to 
be streamed as they currently are to enable interactive mode (i.e. when docker run -it runs)

 `event` | `data` | emitted |
|---|---|---|
| `started` | `session_dir`, `traces_dir`, `audit_dir` | as soon as session dirs exist, before any build |
| `warning` | `message` | every current builder / runner warning print |
| `build_started` | `image` | before each docker build (agent + interceptor) |
| `build_finished` | `image` | after a successful docker build (long builds were
previously invisible to event readers) |
| `interceptor_ready` | — | addon + DNS markers observed |
| `agent_started` | `container` | agent container launched |
| `agent_output` | `chunk` (line-buffered text) | per line/chunk of agent container stdout/stderr (not in tty interactive mode) |
| `agent_exited` | `exit_code` | agent container exited (before cleanup) |
| `finished` | `exit_code`, `summary_path`, `summary` | after summary write; terminal. `summary` is the full session-summary dict as machine-readable JSON (not formatted text) — jsonl consumers read the numbers directly and the tty sink renders the recap panel from it |
| `canceled` | `reason` | terminal state written by `spens cancel` (the cancel command itself appends this event to `events.jsonl`) |
| `error` | `message` | setup failure; terminal (exit code 1) |

#### front-ends 
- `TtySink` — renders the `[spens] …` lines verbatim (word-for-word
  compatible text; on interactive terminals the `[spens]` token is styled,
  warnings/errors colored). The terminal `finished` event is rendered as a
  rich recap panel from its `summary` dict, and docker build output is
  streamed into a constrained live region (`spens.ui.BuildProgress`).
- `JsonSink` — the same event stream rendered as one JSON object per line on
  **stdout**, flushed per event (agent output included as `agent_output`
  events; it also lands in `events.jsonl` in every mode).
- `FileSink` — appends each event to `<session_dir>/events.jsonl` and
  atomically rewrites `<session_dir>/state.json` (tmp + `os.replace`) so
  status readers never see a torn state.

### Output flag
This feature will add an optional --output flag which defines the output of the tool.
it will support 3 options

1. tty (default) - the current behavior 
2. jsonl - stream jsonl events to the terminal
3. background - run in the background as a background tasks

for both jsonl and background mode the following are required, else the command should error
BEFORE creating any session state (a bare `error` event on stdout, no session files):
- a prompt argument (yolo mode) with an agent that has a `yolo_command`
- --accept-changes or --reject-changes (exactly one; passing both is an error)

Rationale: in yolo mode with neither flag, the nono save/rollback prompt baked
into the agent entrypoint would block forever in a non-interactive container —
so an explicit decision is mandatory.

In background mode the CLI returns exit code 0 as soon as the `started` event
is written, printing the session id to stdout. The final agent exit code is
obtained later via `spens status <session-id>` (exit_code in state.json).

In addition, for ANY session regardless of output mode, the core events are
streamed to `events.jsonl` in the session folder for that session

### state and events file
This feature will add two files to the spens session folder 

state.json - which defines the current state of spens 
events.jsonl - which is the output of the events in jsonl from the spens tool 

#### state.json
will look like this and be updated on every state transition of spens
```json
{"state": "building|interceptor_starting|running|exited|canceled|error|finished",
 "session_id": "...", "exit_code": null, "agent_container": "spens-agent-...",
 "interceptor_container": "spens-interceptor-...", "started_at": "...",
 "updated_at": "...", "prompt": "...", "env": "...", "agent": "..."}
```
Terminal states are `finished`, `canceled` and `error`. The first writer of a
terminal state wins: if `spens cancel` marks the session `canceled`, the
running spens process must not overwrite it with `exited`/`finished`.

#### events.jsonl
the event file will be a jsonl with each jsonl representing a key event

```json
{"schema_version": 1, "event": "...", "session_id": "...", "timestamp": "....", "data":"....."}
```

`schema_version` is a constant for now; consumers reject versions they do
not understand. Breaking changes to the event set bump it.

### CLI exit codes

- tty / jsonl foreground modes: the spens CLI exits with the agent's exit
code (as today), or 1 on setup failure, or 130 on SIGINT. In jsonl mode a
SIGINT additionally emits a terminal `canceled` event before exiting, so
`status` readers see a coherent terminal state rather than a stalled one.
- background mode: exit 0 after `started` (final code via `spens status`).
- status/cancel/ exit 0 on success, non-zero on unknown session or docker
failure; attach exits with the session's final exit code.

### --reject-changes flag
This feature adds a reject-changes flag. It is applied at image build time,
like `--accept-changes`: the agent entrypoint runs `nono run` with
`--no-rollback-prompt` so the session proceeds unattended with no prompt.
nono has no flag meaning "always reject" — so to reject, the entrypoint then
executes the **separate nono rollback command after the agent process exits**,
still inside the container, reverting the workspace to its pre-session state
before the container stops. The rollback command implicitly targets the
**latest audit session** of that container (no explicit hash is passed) —
safe because only one agent session ever runs in a given container. This
is the one nono behavior the implementation must verify first. No host-side
nono invocation is involved — the host only reads the resulting audit state
afterwards, as it does today.

### --session-id flag
This feature adds the ability to replace the randomly generated session id
with a specific, predefined session id, used for the `.spens/sessions` folder,
Docker object names (containers, networks, volume) and everywhere else the id
appears. The id must match `^[a-z0-9][a-z0-9-]{0,31}$` (it is interpolated into
docker CLI arguments and object names); anything else is a validation error.
Reusing an id whose session directory already exists is an error, not an
overwrite.

### status command
This feature will add a status, cancel and attach command to spens to manage the session lifecycle
It is important to note, this will work for ALL spens sessions, regardless of the ouput mode

All three are thin readers/writers over `state.json` + docker — no coupling
to a live spens process. Machine-readable output (JSON) always, for the
control subcommands.

#### Spens status command
This returns the status of the session with the given id.
```
spens status <session-id> [--spens-dir PATH]
    # → {"state": "building|interceptor_starting|running|exited|canceled|error|finished|unknown",
    #    "exit_code": 0, "agent_container": "...", "updated_at": "..."}
```
#### Spens cancel command
This will stop a running executing spens session
```
spens cancel <session-id> [--spens-dir PATH]
    # docker stop agent (+ interceptor); idempotent; marks state canceled
    # (first terminal state wins — the spens process will not overwrite it);
    # nono rollback discards workspace changes
```
#### Spens attach command
This will attach to a running spens session, following its progress until it
reaches a terminal state.
```
spens attach <session-id> [--spens-dir PATH]     # optional v1.5
    # tail events.jsonl + docker logs --follow until terminal state
```

## Technical approach

### Code organization

The work is broken out into dedicated modules so the core session logic,
the sinks and the control-plane subcommands each stay independently
maintainable — nothing grows inside `runner.py`/`cli.py`:

```
spens/
  events.py      # Event dataclass, emitter, state machine (no I/O rendering)
  sinks/
    __init__.py  # sink registry + get_sink(output_mode) factory
    tty.py       # TtySink — verbatim [spens] lines + rich recap panel
    jsonl.py     # JsonSink — stdout JSONL renderer
    file.py      # FileSink — events.jsonl + atomic state.json writes
  ui.py          # rich-based rendering: build progress panel, session recap
  control.py     # status / cancel / attach subcommand implementations
                 # (thin state.json + docker readers/writers)
  background.py  # background-mode daemonization/session-id-reuse plumbing
```

`runner.py`/`builder.py` depend only on `events.emit(...)`; they never import
a sink directly. `cli.py` stays a thin dispatcher: it parses args, wires the
sink via the factory, and delegates. Sinks implement a single small
interface (`on_event(event)`, plus `close()`), so adding a future sink
(e.g. a Prometheus push gateway) touches one new file plus the registry.

### Event bus and sinks

Introduce an `Event` dataclass (`event`, `session_id`, `timestamp` (ISO-8601
UTC), `data: dict`) and a small emitter in `spens/events.py`.
`run_session()` (and its helpers) stop calling `print()` directly and call
`emit(...)` instead. The **FileSink is always installed** and always receives
every event: it appends to `<session_dir>/events.jsonl` and atomically
rewrites `<session_dir>/state.json` via a `tmp` file + `os.replace()`. The
`--output` flag selects only the additional CLI renderer:

- `TtySink` — `sinks/tty.py`: keeps the exact current `[spens] ...` strings
  (each emit site carries its literal text; no rewording). Rendering goes
  through `spens/ui.py` (rich): on a terminal the `[spens]` token is
  highlighted and build output is constrained to a live panel; on pipes
  and captured streams the output is plain and byte-identical.
- `JsonSink` — `sinks/jsonl.py`: `json.dumps` + `flush()` per event to stdout.
- `background` — no CLI renderer at all.

The state machine (`building → interceptor_starting → running → exited →
finished|canceled|error`) is a dict of `state → allowed transitions` in
`events.py`; `FileSink` enforces it and refuses to overwrite an existing
terminal state (this is what makes external `cancel` race-free).

### Docker invocation changes

`_build_agent_command()` currently hardcodes `docker run -it`. It gains a
`tty: bool` parameter: `-it` only in tty output mode (the interactive case
the spec exempts); jsonl/background run without `-t` (and without `-i`
unless stdin streaming is ever needed). In those modes the agent container's
stdout/stderr are collected by the spens process and re-emitted as
`agent_output` events — so they are captured in `events.jsonl` regardless
of CLI renderer, and in jsonl mode they also stream to the caller's stdout.
In background mode the agent may be started detached (`-d`) with output
collected via `docker logs -f`.

### Background mode process management

`--output background`: the parent spens process validates, creates session
state, emits `started`, then spawns the session loop as a detached child —
`subprocess.Popen` of itself with `--output jsonl` plus a flag to reuse the
already-created session id. The child is fully detached from the parent's
terminal (`start_new_session=True` on POSIX; `DETACHED_PROCESS` +
`CREATE_NEW_PROCESS_GROUP` creation flags on Windows; stdin/stdout/stderr
redirected to `DEVNULL` — its output lives in the session's `events.jsonl`),
so **it survives the parent exiting** (or the terminal / SSH session
closing): the point of background mode is that the parent never blocks the
terminal, and its death must not affect the session. The parent exits 0
immediately after the spawn. Session-id reuse makes state ownership
unambiguous: the child appends to the same `events.jsonl` / `state.json`
under the standard session lock on `<session_dir>/.lock` (`fcntl.flock` on
POSIX, `msvcrt.locking` on Windows), which `cancel`/`status` also take for
read-modify-write of `state.json`.

### Rollback (--reject-changes / cancel)

Rollback always happens **inside the agent container, after the agent
process exits** — never on the host. `--reject-changes` is a build-time
input to `generate_agent_entrypoint()`, mirroring how `--accept-changes`
works today:

- `--accept-changes` — nono prefix gets `--no-rollback-prompt`; changes are
  kept, nothing else runs.
- `--reject-changes` — nono prefix also gets `--no-rollback-prompt` (so the
  session is never blocked on a prompt), and the entrypoint wraps the agent
  command so that once it exits, the **separate nono rollback command** is
  executed (e.g. `nono run ... -- agent; nono rollback ...`) inside the
  container, reverting the workspace. The audit/rollback state is written to
  the existing bind-mounted `<session_dir>/nono-audit` as today; the host
  only reads it afterwards. The rollback command implicitly targets the
  **latest audit session** of that container (no explicit hash passed) —
  safe because only one agent session ever runs in a given container; verify
  this "latest wins" behavior against the installed nono.sh first.

`spens cancel` relies on the same mechanism: SIGTERM delivered by
`docker stop` reaches the agent and nono, which runs its exit-time rollback
(path unchanged from a natural exit). If the stop escalates to SIGKILL
(15s timeout), rollback cannot run — the session is marked `canceled` with
a `warning` event that workspace changes may still be present.

### CLI surface

`cli.py` adds `--output`, `--reject-changes`, `--session-id`, and the
`status` / `cancel` / `attach` subcommands (argparse subparsers, dispatched
before the run-session parser so `spens status ...` needs no
environment/agent/workspace args). The subcommand logic lives in
`control.py`; the background-mode daemonization lives in `background.py` —
`cli.py` itself only parses and delegates. Control subcommands read
`state.json` + `events.jsonl` only — they never require a
live spens process; `cancel` shells out to `docker stop` on the container
names recorded in `state.json` (reconstructable from the session id, but
read from state so it works even mid-build).

### Unit tests

The existing suite lives in `tests/unit/` (`test_builder.py`,
`test_runner.py`, ...) and already asserts on stdout via `capsys` — that
convention extends naturally to the event system, since a sink is just an
object with `on_event()`. New/updated coverage:

- `tests/unit/test_events.py` (new) — the `Event` envelope (ISO-8601 UTC
timestamps, `session_id` presence) and the state machine: legal transitions
accepted, illegal ones rejected, and **a terminal state can never be
overwritten** (the `cancel` race guarantee).
- `tests/unit/test_sinks_tty.py` / `test_sinks_jsonl.py` / `test_sinks_file.py`
(new, one per sink file, mirroring the module split) —
  - TtySink: golden-file snapshots of the exact `[spens] ...` strings per
    event, asserting byte-identical output to today (the compatibility
    guarantee; regenerate goldens deliberately, never casually).
  - JsonSink: one JSON object per line, valid JSON per line, `flush()` called
    per event (assert via a mock/wrapper stream).
  - FileSink: appends to `events.jsonl` in order; `state.json` rewritten
    atomically (no temp file left behind, content valid after every event);
    truncated final `events.jsonl` line tolerated on read.
- `tests/unit/test_runner.py` (updated) —
  - existing `_configuration_mounts` / masking tests unchanged;
  - warning functions (`_warn_about_egress_mode`,
    `_warn_about_inject_headers`) currently assert on `capsys`; after the
    refactor they emit events, so these tests switch to capturing emitted
    events (a recording sink in `conftest.py`) while a parallel golden test
    pins their TtySink rendering;
  - `_build_agent_command` gains `tty`-parameter coverage: `-it` present in
    tty mode, absent in jsonl/background;
  - `--reject-changes` entrypoint generation: nono prefix contains
    `--no-rollback-prompt` AND the post-exit rollback command; `--accept-changes`
    contains `--no-rollback-prompt` and no rollback command.
- `tests/unit/test_control.py` (new) — `status` reads `state.json` and
  returns `unknown` for a missing session; `cancel` marks state `canceled`,
  is idempotent on an already-terminal session, and never overwrites an
  existing terminal state; both use a fake `docker` stub (no real docker in
  unit tests — docker-touching paths are already stubbed this way in
  `test_runner.py`).
- `tests/unit/test_cli.py` (new/updated) — flag validation matrix:
`--output jsonl|background` without a prompt or without exactly one of
`--accept-changes`/`--reject-changes` errors *before* any session directory
is created; both change flags together is an error; `--session-id` regex
and collision-with-existing-session-dir rejections.

E2E (`tests/e2e/`) is out of scope for this section; the unit suite must
cover everything that does not require a real Docker daemon.

### Compatibility and rollout

1. Mechanical refactor: all `print()` call sites in `runner.py`/`builder.py`
   routed through the emitter; tty output diffed against golden files in
   `tests/` to guarantee verbatim compatibility.
2. `--output jsonl` + `--session-id` + state/events files (FileSink always on).
3. `--reject-changes` (in-container auto-rollback flag) + `status` / `cancel`.
4. `--output background` (daemonize) + `attach` (v1.5).
