Skip to content

runtime

Provenance

  • Source: .spec/spexcode/spec-cli/sessions/lifecycle/runtime/spec.md
  • Source SHA-256: 9e6dc20b47a7adde22da531ecc2201d9f22359f0a1ced5053f421ac28031550b

runtime

raw source

A session has runtime bookkeeping the harness scribbles for it — the lifecycle record, the originating prompt, a queued session's launch prompt, the launch script, the recorded inter-agent comms. None of it is the agent's spec/code work, and putting any of it in the worktree was the root of two problems: it polluted the tree the agent commits, and it forced a 1:1 worktree↔session identity (a path key), so two agents in one folder would clobber. So the runtime lives OUTSIDE the worktree entirely, in a per-user GLOBAL store keyed by SpexCode's governed session_id — the worktree is left pristine (zero SpexCode files), and each agent gets its own record even when several share a folder. Claude Code's harness id equals that governed id; Codex mints its own thread id, so the backend stores that separately as harness_session_id when codex-launch completes thread/start for the worktree.

expanded spec

The store mirrors Claude's ~/.claude/projects/<enc>/ shape: a per-session dir at

<SPEXCODE_HOME or ~/.spexcode>/projects/<enc(project-root)>/sessions/<session_id>/

<enc> encodes the project root with Claude's scheme (path separators → -); the project root is the MAIN checkout — dirname of the shared git common dir — which resolves identically from main or any linked worktree, so the board (at main) and a hook (in a worktree) land on the same dir. Every per-session artifact is a file in that dir:

file written by
session.json readRecord / writeRecord — the structured lifecycle record ([[state]]): state, governed, worktree_path, node, branch, createdAt, harness_session_id, …
prompt the originating human ask ([[launch]])
launch the deferred launch prompt of a still-queued session ([[launch]])
launch.sh the whole launch invocation (launchScript, run via bash <abs path>)
rv.path the rendezvous socket THIS runtime handed the agent at launch ([[harness-adapter]]) — a launch-time fact like the pid, so every later reader reaches the agent by the path it actually bound rather than re-deriving one, and two worlds holding the same id never share a transport
spec-checked / spec-of-file-seen the [[inject-spec-first]] / [[inject-spec-of-file]] once-per-session sentinel + ledger
cursors.json how far this session has read each log it follows, plus its own inbox ([[session-follow]])

layout.ts owns the seam — the one place that knows where the store sits: spexcodeHome() (the SPEXCODE_HOME override → ~/.spexcode), encodeProject() / projectKey(), runtimeRoot() (the per-PROJECT tier: projects/<enc>), sessionsRoot() (its sessions/ child — the board's enumeration dir), sessionStoreDir(id), sessionRecordPath(id), sessionArtifactPath(id, name), plus readRawRecord / listSessionIds for the board. Internal mutation/readiness code may inspect the exact raw record, but public consumers cross one additional layout-owned projection: readPublicRecordEntry. A valid launch-readiness-pending record replaces its candidate lifecycle/proposal/note/stopped/archived fields with the frozen original and carries forced offline liveness; a malformed fence, including an original with an out-of-enum lifecycle or proposal, is the same corrupt/unknown outcome on every surface. The session list/graph, resource report, resolved layout/settings, and timeline never parse or reinterpret pending bytes independently. The store has TWO slotted tiers under one per-project dir: the per-session dirs above, AND the per-TREE materialize slots — trees/<enc(worktree-toplevel)>/ — that [[hook-dispatch]] / [[harness-delivery]] materialize into. Each slot holds the artifacts that are a pure function of THAT tree's .plugins (the hook manifest, the content-hash freshness stamp, the plugin-folder ledger), keyed by the same encodeProject transform applied to the worktree's rev-parse --show-toplevel — the sessions pattern (shared global root, slotted by identity) applied to trees, so two worktrees with divergent .plugins never trade hook sets. The project tier also carries the Codex app-server socket/pid/log/lock and private versioned detached-launch receipt when Codex is launched through SpexCode, plus [[code-anchor]]'s versioned immutable history-event ledgers. project-store.ts owns the pure home/path encoding shared by layout.ts and the Git indexer, while layout.ts adds Git common-dir discovery; this keeps one project identity without introducing a git.tslayout.ts import cycle. The tier also carries a backend-instances/ registry: every supervisor generation atomically records its instance id, PID, operating-system start token, and project root before serving; it removes only its own matching record on a clean exit. backend.json names the endpoint generation clients currently use, but replacing that pointer does not erase an older still-live supervisor's ownership. This lets [[host-resource-budget]] charge a superseded or crashed generation to its exact backend owner without pretending it belongs to the session that happened to start it. Identity stripping is proven separately from the live process environment rather than by a registry claim. All of it lives under runtimeRoot(), NOT the worktree. So the worktree holds ZERO SpexCode-materialized runtime; the only in-tree artifacts are the harness-discovered contract files (CLAUDE.md/ AGENTS.md block) + shims, which MUST sit in-tree for the harness to find them. sessions.ts writes through storeDir(id) (mkdir-and-return) and the full typed readRecord / writeRecord; the shell hooks reimplement the SAME path scheme in bash (the one cross-language mirror — a change to the seam must update both, noted at the layout.ts helpers). Because the only in-tree SpexCode artifacts are gitignored (the materialize shims/skills) or tracked-and-committed (the contract block in CLAUDE.md/AGENTS.md), none shows as an uncommitted change, so the Stop-gate's dirty count needs no runtime filtering, and session.json is written one-field-per-line with every key present so the hot-path hook can READ it with a grep instead of jq ([[state]]) — it never writes the file itself, since the single structured writer ([[sessions-core]]) is the only thing that may compose a record. That writer lands each version by atomic replace, so a reader landing between two writes sees one whole record, never a half-written one. A record that is nonetheless unreadable may be quarantined (its bytes copied to the per-project corrupt/ shelf) when close is attempted, but the unreadable runtime dir remains the live residue: without an exact owner close fails before signaling a process or deleting runtime, worktree, or branch state.

session.json writes are by canonical governed session_id, never by cwd. Claude's harness id equals that record id. Codex hook payloads and spawned commands carry the acting thread id, while the shared app-server env may carry a stale SPEXCODE_SESSION_ID; those Codex ids are resolved through harness_session_id before a governed record is written. Self-launched agents with no governed record may still get raw-id sentinel dirs for spec-discipline hooks, but board lifecycle hooks no-op without governed:true. For a readable exact owner, close removes the worktree, sweeps the whole per-session store dir AND that worktree's trees/ materialize slot (computed before the removal — the slot key needs the live tree); exit keeps all of them, so an offline session is still on the board and --resume-able. Codex's project app-server is never swept or signaled by stopping or closing one session because several Codex sessions and several spexcode serve processes in the same project may be using the same control plane. It is a project resource with explicit sibling references ([[host-resource-budget]]), not a process-tree child owned by whichever session is being stopped; routing is by harness_session_id, not by socket ownership.

This is a CLEAN cut from the old per-worktree .session/ layout — there is no compat shim. An in-flight session launched under the old backend keeps its worktree .session/ until it drains; the new backend simply doesn't read it (those sessions relaunch into the global store). The old .gitignore entries for .session* are inert and may be dropped.