← All Claude Builds
Agent Build Spec

Ai Session Manager

One control tower for every terminal coding agent on your machine — Claude Code and Codex today, whatever you install next without a rewrite. Every session past and present in one ledger, live status for the ones running now, a shared task board that reconciles itself against reality, real plan usage for both agents, and buttons that end a session, resume it with the agent that made it, or hand it to the other one.

What Is This?

Every terminal coding agent keeps its own private record of what you did with it — its own format, its own folder, its own idea of what a session even is. Claude Code writes one JSONL transcript per session. Codex writes rollout files plus a SQLite state database. Neither can see the other. Open six windows across two agents and the only thing that knows what is running is you, and only while you remember.

This is the thing that knows instead: a local web app that indexes every session from every agent into one ledger, watches which ones are alive, and gives you one page to act from. It is deliberately agent-neutral — an agent is a small adapter file implementing a seven-method protocol, and the rest of the system never learns its name.

The list of past sessions is the small half. What takes the design is everything that has to stay true while windows open and close underneath you: a session identity that survives the server being down, a status that cannot be fooled by a recycled process id, a task board in your outliner that follows the real world without ever fighting an edit you made by hand, and a set of claims that stop two loops undoing each other’s work.

Below: what it does, how each layer works, the traps that cost real days, and a build spec you can hand to your own Claude or Codex.

What It Can Do

Every Agent, One List

Claude Code sessions and Codex threads in the same table, told apart by a chip, sorted together, searched together, counted together.

Live Status

Working / Waiting on you / Idle / Shell / Starting, polled every 5 seconds from each agent’s own running-session evidence — never from a hook that can be silently removed.

End A Session

Stop a running session and close the terminal window it lives in. It refuses when it cannot prove which process it is about to kill.

Resume With The Right Agent

A resume hands an agent back its own transcript. The agent on a resume is therefore fixed, and the UI says so — changing agents is a different button.

Continue With The Other One

Hand a piece of work from Claude to Codex or back. The new session opens with a prefix naming its predecessor and pointing at the project’s work log.

A Board That Reconciles Itself

Your outliner holds SPRINT PLAN / IN PROCESS / COMPLETE. A pure planner compares the board to reality and emits create / adopt / rename / drop / restore. Close a window and its card falls back to the plan tagged #dropped.

Start Work From A Card

Press START on a card and it tags itself with the agent you chose, moves to IN PROCESS, and launches a session carrying the card’s own prompt.

Real Usage, Both Agents

Each agent’s own account endpoint, read with a token taken fresh off disk and never written back. No number is ever estimated.

Context Meter Per Session

How full each session’s context window is, parsed from the tail of its transcript and re-derived while it is live. Null stays null — “no reading” is a state, not a zero.

Closeout, Proven Not Guessed

A session counts as closed out when its own harness recorded the closeout skill as having run. Every proxy for that over-claims — see the trap.

Real Project Attribution

Group by the folder a session worked on, scored from what it touched and which project skills it invoked — not by the folder its window happened to open in.

Deep Transcript Search

Search inside every transcript, message text only, bounded by time, hit count and bytes, off the event loop. Counts and a snippet, never a bare list of ids.

Type Into A Live Session

Send a slash command to a window that is already open — focus its tab, then type. It refuses unless it can prove the target.

Noise Stays Out

Sub-agents, headless one-shots, harness self-tests and windows you opened and shut are hidden from the list and never given a card. Nothing is deleted; one filter shows them all.

Activity Heat Map

Per-day turns across whole years, each agent drawn separately, computed by the same byte pass that derives everything else.

Session Rules As Code

The standing rules a new session must follow are injected by the hook at SessionStart — built from files, not from a paragraph you hope it reads.

The Five Layers

One page, five sources. Knowing which one answers what is most of the design — and most of the bugs are one layer answering a question that belonged to another.

LayerWhat it isWhat it gives the UIRefresh
The hookOne tiny script every agent runs on start / prompt / stop / end. It writes a JSON file and nothing else.Identity, names, working directory, launch tokens — the moment they existEvent-driven; drained every 2s
The ledgerOne SQLite database with one writer. Sessions, runs, events, launches, board links.The table, the counts, every status word on the pageWritten only by the server
LivenessA poll over the real process table, cross-checked against each agent’s own running-session evidenceThe status pills, the live set, END and JumpEvery 5s
The importA re-runnable sweep over every transcript on disk, incremental on (size, mtime)History, turns, first message, model, context, closeout, projectStartup and every 15 min
The boardYour outliner’s three lists, read by id, joined to the ledger’s link tableThe sprint page: cards, lanes, START / RESUME / CONTINUE / END / DONEEvery 20s, and immediately on any change

Two rules hold the whole thing up, and everything else is downstream of them: hooks record, they never decide, and exactly one process writes the database.

How It Works

1
Every agent runs the same hook. One stdlib-only script, installed into each agent’s own hook configuration. It computes a deterministic session id, writes one JSON file into an inbox folder (temp file, then rename), appends one line to its own log, and exits. It never reads the database and never talks to the server, so it cannot be slow and cannot be wrong while the server is down.
2
The server drains the inbox. Every 2 seconds it lists the folder, sorts by mtime then name, applies each event to the ledger (idempotent on event id), and moves the file to a dated archive. A file that will not parse is moved aside with one log line, never retried forever.
3
A poll decides who is alive. Every 5 seconds, for every session with an open run: is that process still there, and is it the same process? Anything with no live run and no clean exit becomes lost.
4
Launches carry a token. Starting a session mints a token, writes a small launcher script and a prompt file, and puts the token in the child’s environment and in the wrapper’s command line. The first event carrying the token binds the launch to the session; walking the process ancestry for it is the fallback when no event does.
5
An import fills in history. A per-agent discover() walks every transcript on disk and upserts it. One byte pass per file derives turns, per-day activity, folders touched, skills run, model, context tokens and the closeout marker. Cached on (size, mtime), so unchanged files are never re-read.
6
The board reconciles. A pure function takes the live set, the board, the links and the outstanding claims and returns a list of actions. Nothing in it does I/O, so the whole rule set is testable offline in a second.
7
Claims stop the two loops fighting. Every button that moves a card takes an in-memory claim before it acts, and the planner skips a claimed card entirely. Without this the reconciler undoes your click a second after you make it.
8
Serve. A small ASGI server on loopback holds the snapshot, exposes a REST API, and serves one self-contained HTML file. No build step, no framework.
9
Act. END claims the card, marks the row killed, then asks the adapter to close the window; RESUME reopens the same session id; CONTINUE WITH opens a different agent on the same card. Each of them wakes the reconciler, so the board moves within seconds rather than at the next timer.

Identity — The Decision Everything Rests On

The system needs one stable id per session that the hook can mint with the server down, that survives a restart, and that never collides across agents. Derive it rather than look it up:

the identity rule
asm_id = uuid5(NAMESPACE, f"{agent}:{agent_session_id}")

Deterministic, so every later event for the same agent session lands on the same row without a query. One row per (agent, agent_session_id), enforced by a unique index. Clearing a session’s context produces a new agent session id and therefore a new row — correctly, because it is a new conversation — and the two are linked by a shared process id in the runs table.

Trap 1 — a session is not a process, and a row is not a run

The same session can be resumed five times, in five terminals, over three days. Model it as one session row and many runs: runs(run_id, asm_id, pid, proc_start, launch_token, started_at, ended_at), unique on (asm_id, pid, proc_start). Every liveness question is asked of a run; every human question is asked of a session. Collapse the two and resume breaks the moment a process id is reused.

The event file the hook writes

Versioned, self-describing, and written as <id>.json.tmp then renamed so a half-written file is never drained. Anything the hook cannot compute is null — never omitted, never guessed:

inbox/<event_id>.json
{
  "v": 1,
  "event_id":         "<uuid4>",
  "asm_id":           "<uuid5, as above>",
  "agent":            "claude" | "codex" | "<yours>",
  "event":            "SessionStart" | "UserPromptSubmit" | "Stop" | "SessionEnd",
  "ts":               "<ISO-8601 UTC, ms>",
  "agent_session_id": "<from the harness>",
  "transcript_path":  "<from the harness, or null>",
  "cwd":              "<from the harness, or the hook's own cwd>",
  "source":           "<start source | end reason | null>",
  "launch_token":     "<env, or null>",
  "session_name":     "<env, or null>",
  "agent_pid":        <nearest ancestor whose exe is a known agent binary, or null>,
  "agent_proc_start": <that process's creation time, or null>,
  "ancestors":        [ {"pid","exe","created"}, ... up to 8, nearest first ],
  "nested":           true | false,   // two agent binaries in the ancestry = a sub-agent
  "entrypoint":       "<cli | desktop | sdk | ... , or null>",
  "pid_shared":       true | false,   // this binary is shared by a desktop app
  "hook_ms":          <wall time of the hook, up to the rename>,
  "payload":          { ...the raw harness object, untouched... }
}

Trap 2 — the hook must be boring or it becomes the problem

It runs on every prompt of every session. Standard library only, no imports from your project, no network, no database, no lock. Budget it at under 300 ms and measure that, because a hook is a tax on every turn you will ever take. Give it its own log line per run — including failures — because a hook that silently does nothing is indistinguishable from an agent that never started.

Trap 3 — some agents will not trust your hook until you say so from inside them

At least one popular CLI ignores hook entries until they are approved from within its own interface, and re-approval is required every time the hook text changes because approval is by content hash. Build a canary into the installer: run a one-shot session and assert an event landed in the inbox. Two more from the same family: that CLI did not wrap the hook command in quotes, so a path with a space silently never launched — ship a quote-free wrapper script; and its non-interactive mode reads standard input when it is not a terminal and hangs until end-of-file, so redirect from null in every canary.

The ledger

schema
sessions(asm_id PK, agent, agent_session_id, name, cwd, project, primary_folder, model,
         transcript_path, source, thread_source, nested, pid_shared, harness,
         started_at, last_seen, ended_at,
         status IN (starting, live, ended, lost, killed), end_reason,
         sprint_node, sprint_origin, continues_asm_id, resumed_from_asm_id, resumed_by,
         description, closeout_state, tags, archived,
         turns, first_message, summary, context_tokens, forked_from,
         UNIQUE(agent, agent_session_id))
runs(run_id PK, asm_id, pid, proc_start, launch_token, wrapper_pid,
     started_at, ended_at, start_source, UNIQUE(asm_id, pid, proc_start))
events(id PK, asm_id, agent, agent_session_id, event, ts, payload, ingested_at)
launches(token PK, agent, cwd, name, sprint_node, script_path, prompt_path,
         requested_at, matched_asm_id, matched_at, expired, continues_asm_id)
sprint_links(node_id PK, asm_id, origin, title, bound_at,
             dropped, dropped_at, completed, completed_at)
import_state(path PK, agent, size, mtime, asm_id)
meta(key PK, value)

Timestamps are ISO-8601 UTC strings with milliseconds, everywhere, with no exceptions — a single naive local timestamp in this schema will cost you an afternoon at some point. Process creation times are whatever integer your platform reports; store the raw number.

The only status transitions there are

FromEventTo
(none)SessionStartlive, row created, plus a run for (pid, proc_start)
(none)any other event with no row yetrow created from what the event carries, then the event applies
startinga start carrying its launch token, or matched by ancestrylive; the launch is marked matched
liveprompt / stoplive, last seen bumped
liveSessionEndended, run closed
livepoll finds no live process for any open runlost
liveEND pressedkilled — claimed before the kill, so a later poll cannot overwrite it
ended/lost/killeda start whose source is a resumelive again, new run
losta stop arrives after all (the poll was wrong)live

A duplicate event id is discarded before any of that runs. Events are applied in timestamp order within a drain, and a stop that arrives before its start still produces one row, not two.

Trap 4 — the word on the pill is not the word in the database

killed means you pressed END. lost means the window went away without saying so — an X, a crash, a reboot. Both are perfectly normal, and both should read ENDED on screen, with the difference in the tooltip. Map it at the last moment in the page; do not rename the database values, because your reconciler, your poll and every test key on them. A word that makes you think something went wrong when nothing did is a defect in the dashboard, not in you.

Liveness — A Poll, Never An Event

Hooks tell you a session started. Only the operating system can tell you it is still there. Every 5 seconds, for every session with an open run, ask whether (pid, proc_start) is alive — and cross-check against whatever running-session evidence that agent keeps for itself.

Trap 5 — a process id alone is a lie waiting to happen

Process ids are recycled. Always compare the recorded creation time against the live process’s real creation time; without that second check a recycled id resurrects a dead session and the dashboard confidently shows a ghost. Anything whose creation time predates the last boot is dead regardless of what any file says.

Trap 6 — guard on the executable path, not the process name

A desktop app can run dozens of processes with the same name as the CLI you are managing. Ending “the one called codex.exe” will one day close somebody’s chat app. Match on the full exe path from your agent config, treat a shared binary as un-endable, and say so plainly in the refusal rather than trying and failing.

Trap 7 — some sessions have no identity until their first turn

This one cost a day. One CLI mints its session id up front, so its window is a row before the process is even up. Another mints its own and writes it nowhere until the thread does something — not to the start hook, not to a transcript file, not to its state database. A window sitting at an empty composer is a process id and nothing else.

Two fixes, and you want both. One: a launch with an empty prompt sends one short opening line of its own (“say READY and wait”), written in the adapter and never taken from a request. One cheap turn and the window is an ordinary session. Two: an adoption pass that, for an unmatched launch of yours whose window is provably still open, claims the thread it opened out of that agent’s own records. Four proofs must hold together: the launch is yours and unmatched; its recorded process is alive; the thread is in that folder inside that window of time; and nothing else is driving it.

⚠️ A row that merely exists is not a thread that is taken. Building the adoption pass on the state database alone looks measured and does nothing, because these CLIs open a provisional thread at startup and discard it. Only a session your ledger considers live or starting means somebody else has it.

Trap 8 — a stale registry file is normal, not an error

Agents that write a per-process record usually unlink it in an exit handler, so a clean quit tidies up and a crash does not. Verify every entry against the live process table before believing it, and be careful about deleting the ones you find stale — if two managers ever run at once, the reaper of one destroys the evidence of the other.

The Agent Adapter

This is the contract that keeps the system agent-neutral. Everything else in the codebase codes against it and never against a specific CLI:

agents/<name>.py
class Agent:
    name: str            # "claude" | "codex" | ...
    tag: str             # the hashtag it wears on a board card
    instructions_path: str

    def discover(self)   -> Iterable[SessionRecord]      # every past session on disk
    def live(self)       -> dict[session_id, LiveInfo]   # what the poll sees right now
    def launch(self, cwd, prompt, name, node) -> LaunchRecord
    def resume(self, session_id, model=None)  -> LaunchRecord
    def end(self, asm_id) -> EndResult    # REFUSES when it cannot prove the window
    def usage(self)      -> UsageReading | None          # never estimated
    def hooks_install(self) -> Installed | AlreadyInstalled | NotInstalled(reason)

Write a third adapter you cannot test — some agent you do not have installed — from its documented surface alone, returning NotInstalled until its binary is on PATH and marked untested in config. It costs an hour and it is the only way to find out whether your protocol is actually general or just two special cases in a coat.

Config, not code, holds the per-agent facts: display name, hashtag, executable paths (globs allowed), hook file and format, where transcripts live, the resume command shape, and whether the binary is shared with a desktop app.

Trap 9 — a transport that preserved the length has not preserved the text

Prompts contain quotes, newlines, backticks, emoji and every shell metacharacter there is. Do not build a command string. Write the prompt to a UTF-8 file, pass the path as one argument, and have a tiny launcher read it and hand it to the agent as a single argument. Never through a shell. The launcher scripts themselves stay pure ASCII, because they are read by a console whose code page you do not control.

Trap 10 — a process started through a system API inherits no environment

If you spawn the terminal in a way that survives the shell that started it, your carefully-set launch token is not there. Put the token inside the generated launcher script as an assignment, and also leave it in the wrapper’s command line so ancestry-walking can find it. Two independent paths to the same fact, because the fast one silently does not always exist.

Trap 11 — ending a window is about the exit code, not the kill

Stopping the agent process leaves the shell that wrapped it sitting on a prompt. Whether the terminal window closes depends on how that wrapper exits. Work out the platform’s rule once, encode it in the adapter, and assert the consequence rather than the action: the End result reports window_closed, not ended. Count terminal windows before and after when you test it.

The Board — A Reconciler, Not A Sync

This is the part that turns a dashboard into a workflow, and it is the part most likely to go wrong, because two things are now writing to the same reality: you, by hand in your outliner, and a loop, every twenty seconds.

The board is three lists in whatever outliner you already live in — SPRINT PLAN, IN PROCESS, COMPLETE — addressed by id, never by name. A card is a line of text: TITLE #project #agent #dropped, with a prompt in its children. The binding between a card and a session lives in the ledger, not in the card.

The planner is pure

One function, no I/O, no clock, no database: it takes the live set, the board as read, the current links and the outstanding claims, and returns a list of actions. That purity is what makes the entire rule set testable offline in a second, and it is worth defending against every convenience that would break it.

the five rules
plan(live, board, links, claims, agents) -> [action]

1  A driven session with no card, THAT HAS A TITLE YET
     -> create a card in IN PROCESS, tagged with its agent

2  A card whose bound session is driven, but whose title or agent tag disagrees
     -> rename it (project chip and dropped state are preserved)

3  A card in IN PROCESS whose bound session is NOT driven, and no END owns it
     -> drop: move to SPRINT PLAN, append #dropped. THE BINDING STAYS.

4  A card in IN PROCESS with no binding at all
     -> adopt it (store only, no write) when exactly one unclaimed driven
        session plainly matches and the agent tags agree; otherwise drop

5  A #dropped or completed card whose bound session is driven again
     -> restore: untag, move to IN PROCESS, rebind

Pass one handles bound cards. Pass two handles unbound ones. In that order,
so a real binding is never adopted away by a look-alike title.

Trap 12 — claim before you act, on every path

You press START. The card moves. Twenty seconds later the reconciler reads a card in IN PROCESS whose session has not registered yet, and drops it. You press END. The reconciler sees a completed card whose session is still in the last five-second liveness snapshot, restores it to IN PROCESS, then drops it to the plan a tick later.

Both were measured, in production, with the log lines to prove it. The fix is a claim taken before anything moves, released on the failure path, and honoured by the planner in every pass. A claim is memory-only on purpose: a server restart mid-launch loses it and the card is tidied a minute later, which is a much better failure than a claim that outlives its process.

Trap 13 — an ended card must stay ended

Releasing the END claim the instant the card is filed is not enough, because the session is still in the liveness snapshot for a few seconds. Two independent defences, and you want both: hold the claim until the killed session has actually left the snapshot, and refuse to restore a completed card whose session status is already one of ended / lost / killed. The second half is the one that survives a server restart, where a memory-resident claim cannot.

The buttons, and the order they write in

ActionOrder of operationsRefuses when
STARTclaim → tag with the agent → move to IN PROCESS → bind → launchthe card is already in IN PROCESS, or it is dropped and has a session (offer resume instead)
RESUMEclaim → untag → move → rebind → resume the same session idthe session is live (jump to it), or the card never had one (offer start)
CONTINUE WITHclaim → re-tag to the new agent → untag → move → rebind → launch with a prefix naming the predecessorthe bound session is still live
ENDclaim the card → mark killed → ask the adapter to close the window → file the card to COMPLETEthe process cannot be proven, or the binary is shared with a desktop app
DONEuntag → move to COMPLETE → unbind → release claimsthe card’s session is still open — including the seconds right after an END

A drop re-checks the outstanding END claims at the last moment, because a pass reads the board seconds before it writes and a wrongly-dropped card is the one action you will not notice. Restore untags before it moves: a card arriving in IN PROCESS still wearing #dropped reads as a live session that has already died. Nothing but END and DONE ever writes to COMPLETE.

Trap 14 — a resume cannot change agents, and the UI has to say why

A resume hands an agent back its own transcript, in its own format. Neither can read the other’s. So on a resume the model is a choice and the agent is not: draw the agent as text with one line explaining it, and point at the button that does change agents. Then plumb the model all the way through, and be precise about the absent case — no model key in the request means the flag is withheld entirely (“whatever it resumed with before”), never a guessed default. An empty string is the same as absent.

Trap 15 — edit the tag you own, and nothing else

Setting the agent tag or the dropped tag must read the card’s current name first and rewrite only its own tag, so a title you retyped by hand five minutes ago survives. Escape exactly once on the way in and unescape exactly once on the way out; double-escaping is invisible until a card title contains an ampersand, and then it is permanent.

Cadence

Armed, the reconciler runs on a 20-second timer and immediately on a wake. The wake fires when a driven session arrives in or leaves the live set, and when any of the five buttons succeeds. Coalesce it: one flag however many times it is set, a short settle window so a burst becomes one pass, a minimum gap so two passes never run back to back, and a wake arriving mid-pass survives it — clear the flag before the pass, never after — so it earns exactly one follow-up and never a queue.

The wake is the real-time path; the timer is the safety net. Done right, a change on the dashboard shows up on the board a few seconds later and vice versa, while idle traffic to your outliner stays at about three reads a minute.

Trap 16 — the first pass after any restart is a dry run

Print every action it would take to the log, write nothing, and only then arm the timer. This is the cheapest possible insurance against a bad deploy rearranging a board full of real work, and the one time you need it, it will have paid for the whole feature.

Trap 17 — two boards, one of them fake, and a hard refusal between them

Build against a throwaway copy of the three lists, and make the code refuse to start in testing mode if any configured list id is one of the real ones. A configuration mistake here is not a wrong number on a page; it is your actual task board rearranged by a half-finished loop. Hardcode the real ids in the guard.

What Never Gets A Card, And What Never Shows

Left alone, this system will fill your board with garbage within a day, and the garbage all looks like a session. Every one of these was a real card on a real board before it became a rule:

Two things make this maintainable. First, one definition: the hidden test exists once, as SQL and as its exact Python twin, and the list, the counts, the heat map, the project tiles and the reconciler all call it — so they can never disagree about who counts. Second, nothing is deleted: one query parameter returns every hidden row.

Trap 18 — and a card already bound to an open window is left alone

All of the exclusions above are applied when deciding whether to mint a card. Apply them to dropping as well and you get the exact behaviour they were written to prevent: a session that is excluded, whose window is standing open, has its card yanked away. Keep a card whose bound session is present, whatever the mint rules think of it.

Closeout — The Obvious Rule Is The Wrong One

You want a badge saying whether a session was properly wrapped up, so you can go back and finish the ones that were not. The instinct is to look for a proxy: does it have a real description? did it write a log entry? did it last more than N turns?

Every proxy over-claims. The first rule tried here was “does this session have a real description” — on the reasoning that the session-start hook writes a placeholder and only the closeout replaces it. The premise was false, because the ordinary describe command clears the placeholder too. It badged 182 of 239 sessions as closed out. The owner had never once run the command.

The rule that works: a session is closed out when its own harness recorded the closeout skill as having run in it. Both major CLIs write a marker into the transcript when they load a skill, and neither marker can be produced by typing the words. Three values, and the third is not a synonym for the second:

ValueMeans
closedthe marker is in the transcript
openthe transcript was read and the marker is not there — a live session that has not run it yet and a dead one that never did are both this
nullthe transcript could not be read. Nobody can say, so nothing is claimed

Harvest the marker in the same byte pass that already counts turns and folders, so it costs nothing new, and store it on the row so a page load never opens a transcript.

Trap 19 — without a live refresh the badge goes stale under an open tab

A session closed itself out while its owner watched. The badge still said “Closeout?”, he pressed it, and the reopened session answered “already closed out — nothing has happened since.” The badge was stale, not wrong, and there is no way to tell those apart by looking at it. Re-derive it for live sessions on the poll, throttled, and only when the transcript actually grew.

Trap 20 — anything derived from a live transcript is a snapshot

The general form of the trap above, and it will bite you in at least three places: context tokens, project attribution, closeout. A value computed from a file that is still being written is true for an instant. Every one of them needs a refresh path — and that path must not be scoped only to sessions that are running, or you never take the answer computed from the finished file, which is the only one that was ever going to be complete.

A Project Is What The Session Worked On

Group sessions by their working directory and you will get a handful of enormous tiles, because most people open most windows in the same one or two places. Here it produced 18 tiles for 514 sessions, one of which held 460 of them.

Derive it instead, from the transcript:

1
Score every project folder the transcript names, one point per mention. The name must be a folder that really exists, so a stray path fragment can never invent a project.
2
Add 25 points per invocation of a skill or command that names a project. A session can read a file in a folder it is not working on; it does not invoke that project’s own command by accident.
3
Highest score wins. Break ties alphabetically, never by dictionary order — otherwise a session moves between projects on a restart.
4
Keep every scoring folder as a “touched” list. One answers “did this session work in there”; the winner answers “whose project is this”.
5
No folder at all falls back to the working directory’s own folder. That is the honest answer for a window that never named a project.

Same corpus, after: 57 tiles, and the primary counts sum to exactly the visible session count. Two counts per tile, deliberately: primary (sessions filed here, sums to the total) and touched (sessions that mention it, sums to more, because one session works in six folders).

Trap 21 — a command that runs in every session names no project

The 25-point rule is powerful and it is a loaded gun. If your session-start routine or your closeout runs a particular command, then nearly every session runs it — and at 25 points a hit, that one mapping quietly files website work, marketing work and everything else under whatever folder it points at. Keep an explicit list of the commands deliberately excluded, so nobody adds them back while tidying.

Usage — Never Estimated

Both major CLIs expose the same account snapshot their own usage screens read. One authenticated GET each, a bearer token read fresh out of the agent’s own credentials file on every call, used in memory, never written, never logged, and never refreshed — refreshing would race the agent’s own refresh and log you out of the thing you are managing. Cache 60 seconds, back off after a failure, and serve the last good reading marked stale rather than a blank.

Trap 22 — a cached payload goes stale plausibly

One agent’s figure was read out of whatever its own transcript last recorded, and it only records one while a turn is running. Idle overnight, the page cheerfully showed a percentage from eight hours earlier next to a window whose reset time had long since passed — so it read “25%, resets now”. Cache the absolute fact and recompute anything time-sensitive at paint time. A window whose reset has passed no longer exists: report it as zero with an unknown reset, never as an old number still being spent.

Trap 23 — two numbers that disagree are answering different questions

When a count on one panel does not match a count on another, the fix is never to relabel one of them. Find which event each is counting and make them count the same one. This applies to session counts, turn counts, project counts and day counts, and it will come up in all four.

Search That Goes Inside

One endpoint, two modes. Without a flag it matches the metadata the page already holds — name, first message, summary, description, folder. With deep=1 it reads inside every visible transcript, in every agent’s format.

The Interface

One self-contained HTML file, no build step. A tab bar with the board as the front page, a sticky usage row, and the full session list underneath. What matters is not the layout — it is these five rules, each of which was learned by breaking it:

Trap 24 — run the server windowless, and restart it by the port it owns

A console window is not a status light; it is the process, and it will get closed. Run it detached, and spawn every helper with the “no window” flag — then check that flag did not hide a window you actually wanted. To restart it, find the process that owns the port and stop that one. Never match on a command-line substring: that pattern has killed the shells of the person doing the restarting. And never match on window title — every session is a tab in one terminal window, and a title-matched kill takes them all.

Trap 25 — a failed port bind leaves the OLD process serving

Start a second copy while the first is running and the new one exits, quietly, while the old code keeps answering. You then test your fix against the code you just replaced and conclude the fix did not work. Read the log for the bind error; never trust the startup banner.

The API Surface

Small enough to hold in your head, which is the point. Every action the page can take is one POST, so anything that can send an HTTP request — a hardware key, a phone, a script — can drive it later without a second design.

RouteDoes
GET /api/sessionsThe whole list, one row per session, with project, context, closeout and agent
GET /api/liveThe 5-second payload: who is alive, their status, their context — and the board digest
GET /api/sprintThe board from the sweep’s memo; ?fresh=1 reads the outliner live
POST /api/sprint/{node}/start | resume | continue | doneThe four card actions. The browser sends an id and an agent name, never text
POST /api/sessions/{id}/end | resume | closeout | closeout-live | focusThe five session actions
POST /api/launchA new session: agent, model, folder, name, prompt
GET /api/projects | /api/heatmap | /api/usage | /api/searchThe derived panels
GET /api/healthMode, ports, counts, thread liveness — what you read first when something is wrong

Build It In This Order

Six phases. A phase is finished when its check has actually been run — not reasoned about — and the result is written down. Do not start the next one before that.

PhaseBuildsGate — must be run
0 · LedgerThe schema, config, the data folder, the identity ruleOffline verifier against a temp database: one session with two runs; a cleared context makes a second row; a crash becomes lost; a duplicate event is idempotent; a stop arriving before its start still makes one row; a nested session is hidden
1 · HookThe universal hook, the inbox drain, liveness, correlation, the installer, the history importLive: a one-shot session in a scratch folder leaves start + stop + end in the inbox, the process id matches, the hook runs under 300 ms. For each agent. Close a window with the X and it is lost within 5 seconds. Import counts match what is on disk
2 · ServerThe adapters, the launcher, the server, the dashboardLive: a launch from the dashboard is live within 5 seconds; END closes the window and marks it killed; RESUME reopens the same id; typing into a live session refuses when it cannot prove the window; END on a shared binary refuses with a plain sentence
3 · BoardThe outliner layer, the reconciler, the sprint pageOffline: the five rules as a table, the claims, the write orders, escape-once, the refusal to touch the real lists in testing mode. Live on a throwaway board: start, X the window, watch it drop within a minute, resume and watch it restore; continue-with makes one card, not two; the first sweep prints a dry run before the timer arms
4 · Second agentThe second adapter end to end, plus a third you cannot testEverything in phases 1–3, again, for the second agent. This is where your protocol either holds or turns out to be one special case in a coat
5 · CutoverTesting mode → live mode: the real board, the real logs, autostartA scripted cutover with a pre-flight, numbered steps, and a rollback that undoes them newest-first. Run the rollback once, on purpose, before you need it

Two modes, one switch. In testing mode nothing writes anything shared: no real board, no shared log, no reaping of another manager’s files, no autostart. Every guard that enforces that lives in code, not in your memory of which config you last edited.

Requirements

How To Run The Build

1
Give your agent the spec below and let it write the runbook first — the contracts, the schema, the file map — before a line of code. Everything after this reads from that file.
2
Split the work by file, not by feature. If you are fanning this out to sub-agents, no two may own the same file. The lead integrates and owns the runbook. Disjoint ownership is what makes parallel work survivable.
3
One read-only adversarial reviewer at the end of every phase. It writes nothing and reports to the lead. On this build the reviewer caught, in one pass, that a trust override had never actually worked and that a schema migration raced itself at startup.
4
Write down what you verified and what you assumed, per phase, with the command and the numbers. “Verified” means it was run. Anything else is “Assumed”, in writing, so the next session knows which is which.
5
Ask before you test what writes. Before running anything, ask what it writes, not what it calls. Stub the local file and database writes, not just the network. Verifiers use a temp database and never the real one.

Trap 26 — a document edit is an edit, never a read-modify-write script

On this build a sub-agent appended a section to the runbook with a small shell-embedded script that opened the file for writing instead of appending. Fifty-five kilobytes became seventeen: every contract, the file map and the history, gone, and three later sub-agents happily appended their sections to the empty file. A script that means “append” and writes “overwrite” leaves no error behind. Edit documents with an edit tool.

The Rules Worth Copying

  1. Hooks record; they never decide. Liveness is the process table plus the agent’s own evidence, with a creation-time guard.
  2. One writer for the database. Verifiers and importers use the library in-process, never a second server.
  3. Claim before you act, on every path, and release on failure.
  4. Assert the consequence, not the action. End reports the window closed, not the process signalled.
  5. Ids, never names. Lists by id, resume by session id, generated scripts pure ASCII.
  6. Never estimate a usage number. The provider’s number or nothing, with the last good reading served stale.
  7. Never write the agent’s credentials file and never implement token refresh.
  8. Never infer a state from a proxy when the real event is recorded. Check whether it is recorded first.
  9. Anything derived from a live file needs a refresh path, and that path must not be scoped to live sessions only.
  10. Nothing on the poll re-renders everything.
  11. A control on a running session acts on that process — it never launches a second copy.
  12. Anything that synthesises keystrokes refuses unless it can prove the window. A best-effort keystroke lands in whatever you happened to be reading.
  13. Where a judgement is genuinely the user’s — what counts as working in a project, what the context denominator is — ask, and record the answer in a comment.
  14. Delete nothing you did not create, and prefer hiding to deleting everywhere in the data model.

Get the Spec

Two files. The README is the human explainer for whoever inherits the project. The Prompt is the build spec — drop it in ~/.claude/skills/asm-build/SKILL.md (or hand it to Codex as an AGENTS.md section) and say “build the AI session manager.”

README.md
# AI Session Manager

One local control tower for every terminal coding agent on this machine. Every session
past and present in one ledger, live status for the ones running now, a shared task board
that reconciles itself against reality, real plan usage per agent, and buttons that end a
session, resume it with the agent that made it, or hand it to a different agent.

It is agent-neutral by construction: an agent is one adapter file implementing a
seven-method protocol, plus a row of config. Nothing else in the system knows its name.

## Running it

- Server: run it windowless, bound to loopback on your chosen port.
  Restart it by the process that OWNS THE PORT, never by matching a command line.
- Hooks:  the installer writes the hook entry for one agent at a time and self-checks
          with a canary session. Re-run it after any change to the hook text.
- History: the import is re-runnable and incremental on (size, mtime).
- Board:  the first sweep after any start is a printed DRY RUN. The timer arms only
          after it succeeds.

## Where things live

    <project>/
      config.toml          mode, ports, paths, list ids
      agents.toml          one row per agent: display, tag, exe paths, hook file, resume shape
      ledger.py            the schema and EVERY write to it. Single writer.
      inbox.py             drains the event folder into the ledger, idempotent
      hooks/hook.py        the universal hook. Stdlib only. One file for every agent.
      liveness.py          the poll: process table + registry, with the reuse guard
      correlate.py         matches a launch token or wrapper ancestry to a session
      install_hooks.py     writes the hook entry per agent, with a canary
      import_history.py    every past transcript into the ledger
      agents/base.py       the adapter protocol as code
      agents/<name>.py     one adapter per agent
      launch/              the shim: prompt file to one argument, environment scrubbed
      enrich.py            per-session per-day turns, folders, skills, context, closeout
      board.py             the outliner layer: ids never names, escape once, tag grammar
      reconcile.py         the pure planner, the claims, the five rules
      server.py            the ASGI app: drain 2s, poll 5s, sweep 20s, the API
      index.html           the dashboard, one file
      verify-*.py          offline verifiers. Temp database. No network.

    ~/.<app>/              (NEVER inside a synced folder)
      ledger.sqlite        write-ahead mode
      inbox/               one JSON per hook event, written tmp then renamed
      processed/<date>/    drained events, kept for audit
      launches/            one generated launcher and prompt file per launch
      hook.log             one line per hook run

## The two rules everything else follows from

1. Hooks RECORD. They never DECIDE. Liveness is the process table plus the agent's own
   evidence, with a process-creation-time guard against id reuse.
2. ONE writer for the database: the server. Everything else uses the library in-process.

## Modes

`testing` writes nothing shared: a throwaway board, no shared logs, no autostart, no
reaping of another tool's files. `live` is the real thing. The code REFUSES to start in
testing mode if a configured list id is one of the real ones.
PROMPT.md — Save as ~/.claude/skills/asm-build/SKILL.md
---
name: asm-build
description: >
  Build an agent-neutral AI Session Manager - a local web app that indexes every session
  from every terminal coding agent (Claude Code, Codex, and any future one) into a single
  SQLite ledger, tracks which are live, reconciles a three-list task board in the user's
  outliner against reality, reads real plan usage per agent, and can end, resume, or hand
  a session to a different agent. Trigger when the user says "build the AI session
  manager", "build ASM", "one dashboard for all my coding agents", or a close variation.
allowed-tools: Bash Read Write Edit
---

# AI Session Manager - Build Spec

You are building a local control tower for every terminal coding agent on this machine.
Work in phases. Do not start a phase before the previous phase's gate has been RUN.

## Step 0 - Read this whole file before you do anything

The design decisions below are not suggestions. Each one is here because the obvious
alternative was tried and failed, usually expensively. If you think one is wrong, say so
and ask - do not quietly build the other thing.

## Step 1 - Interview (one question at a time, confirm each answer)

1. Which agents? (Claude Code, Codex, others.) Get the exe path and the transcript folder
   for each. At least one must be installed.
2. Project folder for the build. Data folder for the ledger - MUST NOT be inside a sync
   service (OneDrive, Dropbox, iCloud). SQLite in write-ahead mode inside one corrupts.
3. Port for the app (loopback only).
4. Board: do they want it? If yes, which outliner, and get the THREE list ids for
   SPRINT PLAN / IN PROCESS / COMPLETE. Have them create a throwaway copy for testing and
   record BOTH sets of ids: the real three go into a hardcoded refusal list.
5. Where do their project folders live (the parent directory)?
6. Accent colour and app name.

Do not proceed with a guessed answer to any of these.

## Step 2 - Probe before you build against anything

For each agent, on this machine, actually look:

- Where are transcripts, and what is one line of one? Parse a real file, not a guess.
- Is there a per-process registry of running sessions? Read a real entry and record its
  fields. Treat the schema as DISCOVERABLE, not guaranteed: an unknown status value must
  degrade to a neutral pill, never crash the poll.
- What is the hook configuration format, and does the agent require the hook to be
  trusted from inside its own interface before it will run?
- Is there a usage endpoint, and where is the credentials file?

If a probe comes back empty, BUILD WITHOUT THAT FEATURE AND SAY SO. Never ship a status
column, a gauge or a badge that cannot get data.

## Step 3 - Phase 0: the ledger

Write the runbook FIRST - contracts, schema, file map - then the code.

Identity:  asm_id = uuid5(NAMESPACE, f"{agent}:{agent_session_id}")
Deterministic so the hook can mint it with the server down.

Schema: sessions / runs / events / launches / board_links / import_state / meta.
One session row, MANY runs. Unique (agent, agent_session_id) and (asm_id, pid, proc_start).
Every timestamp is an ISO-8601 UTC string with milliseconds. No exceptions.

Statuses: starting | live | ended | lost | killed. Write the transition table into the
runbook and implement exactly it. killed is claimed BEFORE the kill so a later poll cannot
overwrite it. Both killed and lost display as ENDED; map the word in the page, never in
the database.

Hidden rows (one definition, used as SQL and as its exact Python twin, called by the list,
the counts, the heat map, the projects and the reconciler): a sub-agent thread; a
non-user thread; a headless run; a session whose cwd is under a harness root (system temp,
the hooks folder, this app's data folder); and "nothing happened" - finished, never named,
one turn or none. Nothing is deleted; one query parameter returns them all.

GATE 0 - run an offline verifier against a temp database:
  one session with two runs; a cleared context makes a second row; a crash becomes lost;
  a duplicate event is idempotent; a stop arriving before its start still makes ONE row;
  a nested session is hidden.

## Step 4 - Phase 1: the hook, the drain, liveness, correlation, history

The hook is stdlib only. It computes the id, writes ONE json file (tmp then rename) into
the inbox, appends one line to its own log, exits. It never reads the database and never
talks to the server. Budget under 300ms and MEASURE it. Anything it cannot compute is
null - never omitted, never guessed. It records the process ancestry (up to 8, nearest
first) so the system can tell a sub-agent from a session and find a launch token.

The drain runs every 2s: list the folder (never .tmp), sort by mtime then name, apply
each event (idempotent on event id), move the file to a dated archive. A file that fails
to parse twice is moved aside, not retried forever. One bad file is one log line.

Liveness runs every 5s, and it is a POLL, never an event: is (pid, proc_start) alive?
ALWAYS compare the recorded creation time against the real one - without it a recycled
process id resurrects a dead session. Anything created before the last boot is dead.
Guard on the EXE PATH, not the process name: a desktop app can run dozens of processes
sharing a CLI's name, and those are un-endable - refuse with a plain sentence.

Correlation: a launch mints a token, writes a launcher script and a UTF-8 prompt file, and
puts the token BOTH in the child's environment (set inside the generated script, because a
process created through a system API inherits nothing) and in the wrapper's command line.
The fast path is an event carrying the token; the guaranteed path is walking ancestry for
it. Unmatched launches expire after 120s and the row stays as evidence.

Installer: writes the hook entry per agent, additively, then runs a CANARY session and
asserts an event landed. If the agent requires trust from inside its own interface, say so
and stop - do not bypass trust. If its non-interactive mode hangs, redirect stdin from
null. If it does not quote the hook command, ship a quote-free wrapper script.

GATE 1 - live, per agent: a one-shot session in a scratch folder leaves start + stop + end
in the inbox; the process id matches the registry; the hook is under 300ms; closing a
window with the X marks it lost within 5 seconds; the history import count equals what is
actually on disk.

## Step 5 - Phase 2: adapters, launcher, server, dashboard

Adapter protocol (agents/base.py, owned by the integrator; adapters implement it):
  discover() live() launch() resume() end() usage() hooks_install()

Also write a THIRD adapter for an agent you cannot test, from its documented surface,
returning NotInstalled until its binary is on PATH. It is the only way to find out whether
the protocol is general.

Launching: the prompt goes in a FILE and is passed as ONE argument. Never build a command
string, never go through a shell. Generated scripts are pure ASCII. All launching goes
through ONE builder - no hand-written command strings in individual endpoints.

Ending: work out the platform rule for making the wrapper shell exit so the window closes,
encode it in the adapter, and report window_closed rather than ended. Refuse when the
process cannot be proven or the binary is shared.

Server: ASGI on loopback. Threads: drain 2s, poll 5s, import 15min, board sweep 20s. It is
the ONLY writer of the database. Run it windowless; spawn helpers with the no-window flag,
then check that flag did not hide a window you wanted. A failed port bind leaves the OLD
process serving - read the log for the bind error, never trust the banner.

Dashboard: one self-contained HTML file, no build step.
  - NOTHING on the 5s poll may trigger a full re-render. Repaint cells in place via
    data-<field>-for="<id>". Only a name change, or a change in WHICH sessions are live,
    may re-sort. Wrap render() in a counter and idle two ticks: it must stay at zero.
  - No displayed fact may depend on a Refresh button. Background sweep + a revision
    counter that moves only when a displayed fact changed.
  - Context: null stays null. "No reading" is a state; never send 0.

GATE 2 - live: a launch from the dashboard is live within 5s; END closes the window and
marks it killed; RESUME reopens the same id; typing into a live session refuses when it
cannot prove the window; END on a shared binary refuses with a plain sentence.
Test END on a THROWAWAY session, never one the user cares about.

## Step 6 - Phase 3: the board

Three lists, addressed BY ID. Card grammar: TITLE #project #agent #dropped. Setting a tag
reads the card's CURRENT name and rewrites only its own tag, so a hand edit survives.
Escape once in, unescape once out. Memoise reads for 60s and bust on every write.
REFUSE TO START in testing mode if any configured id is one of the real lists.

The planner is PURE: plan(live, board, links, claims, agents) -> actions. No I/O, no clock,
no database. Actions: create, adopt, rename, drop, restore. Five rules:

  1  driven session, no card, AND IT HAS A TITLE  -> create in IN PROCESS with its agent tag
     (never mint a card called UNNAMED; show it as "not on the plan" until it is named)
  2  card bound to a driven session, title or agent disagrees -> rename (keep chip+dropped)
  3  card in IN PROCESS, bound session not driven, no END owns it -> drop to the plan with
     #dropped. THE BINDING STAYS - that is what makes resume possible.
  4  card in IN PROCESS with no binding -> adopt when exactly one unclaimed driven session
     plainly matches and the agent tags agree; otherwise drop
  5  #dropped or completed card whose bound session is driven again -> restore

Pass one (bound cards) runs before pass two (unbound), so a real binding is never adopted
away by a look-alike title.

CLAIMS. Every button takes an in-memory claim BEFORE it moves anything, and the planner
skips a claimed card in every pass. Without this the reconciler undoes the click a second
later - measured, twice, in production. Release on the failure path.

Write orders, asserted by the verifier:
  START     claim, tag, move, bind, launch    (a failure releases and unbinds)
  RESUME    claim, untag, move, rebind, resume the SAME session id
  CONTINUE  claim, retag, untag, move, REBIND, launch with a prefix naming the predecessor
  END       claim the card, mark killed, close the window, file the card to COMPLETE
  DONE      untag, move to COMPLETE, unbind, release - refused while the session is open
  drop      re-check the END claims AT THE LAST MOMENT, then move, then tag
  restore   untag BEFORE moving

AN ENDED CARD STAYS IN COMPLETE. Two independent defences: hold the END claim until the
killed session has left the liveness snapshot, AND refuse to restore a completed card
whose session status is ended/lost/killed. The second survives a server restart.

A RESUME CANNOT CHANGE AGENTS - an agent can only read its own transcript. Draw the agent
as text with one line saying why, offer the MODEL as a choice, and point at CONTINUE WITH.
No model key in the request means the flag is WITHHELD, never a guessed default.

Cadence: 20s timer plus a wake on (a) a driven session arriving in or leaving the live set
and (b) any of the five buttons succeeding. Coalesce: one flag, a settle window, a minimum
gap, and clear the flag BEFORE the pass so a wake arriving mid-pass earns exactly one
follow-up and never a queue. The FIRST pass after any start is a printed DRY RUN; the timer
arms only after it succeeds.

The poll carries a DIGEST of the board (lists + positions + links, hashed from what the
server already believes, costing no extra read). The page re-fetches the board when the
digest moves, after every button, AND whenever the set of live ids changes - the digest
does not hash the live set, and a launched session joins it 10-15s after the click.

GATE 3 - offline: the five rules as a table, the claims, the write orders, escape-once,
the real-list refusal, and that a dry run writes nothing. Live on the THROWAWAY board:
start, X the window, watch it drop within a minute, resume and watch it restore;
continue-with produces ONE card.

## Step 7 - Phase 4: the derived layer

One byte pass per transcript, cached on (size, mtime), producing all of:

  turns per day          - one prompt the user typed, by UTC date. Used by the heat map.
  folders touched        - every real project folder the transcript names
  skills invoked         - by name
  model, context tokens  - from the tail
  closeout marker        - see below

PROJECT: score each named folder 1 point; add 25 points per invocation of a skill that
NAMES a project; highest score wins; break ties ALPHABETICALLY (never dict order, or a
session moves between projects on a restart); keep every scoring folder as "touched"; fall
back to the cwd's own folder. KEEP AN EXPLICIT EXCLUSION LIST of commands that run in
every session (a session-start routine, a closeout) - at 25 points a hit, one of those
files every session under one folder.

CLOSEOUT: a session is closed ONLY when its own harness recorded the closeout skill as
having RUN in it. Do not use a proxy - "has a real description" over-claimed on 182 of 239
sessions here, because the ordinary describe command clears the same placeholder. Three
values: closed / open / null, and null is not a synonym for open.

REFRESH PATHS: anything derived from a LIVE transcript is a snapshot. Re-derive context,
project and closeout for live sessions on the poll, throttled, and only when the file grew.
That path must NOT be scoped to live sessions only, or you never take the answer computed
from the finished file.

USAGE: each agent's own account endpoint. Read the bearer token FRESH off disk on every
call, use it in memory, never write it, never log it, NEVER refresh it. Cache 60s, back
off after failure, serve the last good reading marked stale. NOTHING IS EVER ESTIMATED.
A window whose reset time has passed no longer exists: report 0 with an unknown reset,
never an old number still being spent.

SEARCH: one endpoint, shallow by default, deep=1 reads inside transcripts. Message text
only, never JSON keys. Count matches not sessions. Return a snippet. Bound it three ways
(seconds, hits, bytes per file) and say when a bound bit. Scan newest first. Refuse a
one-character query. Never on the event loop.

## Step 8 - Verify, then report

Before saying anything works:
- Ask what each test WRITES, not what it calls. Stub local file and database writes, not
  just the network. Verifiers use a temp database.
- Run every offline verifier. Then run the live gates, in order.
- Drive the dashboard in a real browser and read COMPUTED STYLES, not a screenshot. Three
  failures a screenshot will not show: a grid column resolving to 0px, a re-render storm on
  the poll, and content overflowing a card that measures perfectly. Measure overflow on the
  text range, not on element boxes - a padded box's rect includes its padding.
- Restart the server by the process that OWNS THE PORT. Never match a command-line
  substring; that has killed the shell doing the restarting.

Report in plain English, first sentence is the state: which phases are verified, what was
ASSUMED rather than run, what is left, and what needs the user. Then the launch command,
the URL, and the one rule that otherwise wastes an hour: editing the server does nothing
until you restart it.

## Critical rules

1.  Never touch, import from, or call any other session manager already on this machine.
2.  Hooks record; they never decide.
3.  One writer for the database.
4.  Claim before you act, on every path, and release on failure.
5.  Assert the consequence, not the action.
6.  Ids, never names. Generated scripts pure ASCII.
7.  Never estimate a usage number. Never write a credentials file. Never refresh a token.
8.  Never infer a state from a proxy when the real event is recorded - check first.
9.  Anything derived from a live file needs a refresh path.
10. Nothing on the poll re-renders everything.
11. A control on a running session acts on that process; it never launches a second copy.
12. Anything that synthesises keystrokes REFUSES unless it can prove the window.
13. Where a judgement is genuinely the user's, ASK, and record the answer in a comment.
14. Edit documents with an edit tool. A read-modify-write script that means "append" and
    writes "overwrite" destroys the file and leaves no error.
15. Delete only files you created, by full literal path. Never a folder.