# Session deletion (#55) — platform assistant + client-agent native sessions > Issue [#55](https://github.com/sinrimin/harness-nexus/issues/55) · shipped > 2026-10-15 (Phase A). The rail finally deletes: desktop hover-trash, phone > swipe-left, both rails (platform assistant rows + native agent rows). ## Research ground truth (2026-10-15, web-verified) **ACP**: `session/delete` is a STABILIZED method (RFD completed 2026-06-05) — params `{sessionId}`, result `{}`, idempotent ("deleting an already-deleted session, or one that never existed, SHOULD succeed silently"). Capability bit `agentCapabilities.sessionCapabilities.delete` — an OBJECT (`{}`), not a boolean; the daemon's `deriveSessionCaps` reads presence (`!== undefined`), which matches. | target | delete channel | ground truth | | --- | --- | --- | | claude-code | ACP `session/delete` | pinned `@agentclientprotocol/claude-agent-acp@0.79.0` implements + advertises it; internally calls the Claude Agent SDK's own `deleteSession` (tears down in-memory state before removing the transcript) | | opencode | ACP, version-gated | **v2.0.7+ only** (v2.0.0–2.0.6 and ALL 1.x lack it; 1.18.31 verified locally). V2 moved persistence to SQLite, so file deletion is not an option. Absent cap ⇒ honest "does not support session/delete" error. Current stable line is 2.0.29 under npm `@opencode/cli` (old `@opencode-ai/opencode` name is gone) | | codex | ❌ no ACP support in ANY @zed-industries/codex-acp (0.3.1–0.16.0) | Phase B: shell out to the codex CLI's own **`codex delete`** (app-server `thread/delete`). Do NOT unlink rollout files — codex keeps `~/.codex/session_index.jsonl` + SQLite state rows + a rollout reference graph; a raw unlink leaves them stale | | deepseek (dsh) | file | `~/.dsh/sessions///` — the id IS the directory name (generations `session.vN.jsonl.zstd` coexist inside); delete the whole directory. dsh's ACP profile explicitly does not implement session/delete (their design note says it "remains unsupported"). Residual: the opt-in derived SQLite FTS index (`session-query-sqlite`) is not refreshed by an unlink — not enabled in shipped compositions | | pi | file | `~/.pi/agent/sessions//_.jsonl`; unlink is officially sanctioned by pi's session-format docs (listing is a plain readdir). Residual: a FORKED child carries a `parentSession` file pointer — deleting a parent leaves a dangling reference in children (pi behavior undocumented) | Repo notes: opencode lives at `anomalyco/opencode` (default branch `dev`), the ACP spec at `agentclientprotocol/agent-client-protocol`, pi at `earendil-works/pi`, dsh is public at `deepseek-ai/deepseek-harness` (npm `@deepseek-ai/dsh`, floor 0.2.0-rc.2 per #46). The SPEC leaves deleting a live/loaded session implementation-defined; we are STRICTER (close-first, below) as a product rule. ## Wire & capability - **shared**: `sessionsDeleteRequestSchema` `{requestId, target, sessionId}` → `sessionsDeleteResultEventSchema` `{requestId, error?}` — same requestId/result round-trip shape as `sessions:list`. - **daemon capability bit `sessions-delete`** added to `DAEMON_CAPABILITIES`. The server 409s `DAEMON_NO_SESSIONS_DELETE` (upgrade hint) against pre-#55 daemons instead of timing out on a request they never answer. - **REST**: `DELETE /api/agent-instances/:id/sessions/:nativeSessionId` — owner-or-admin gate like the listing, machine-online + capability checks, live-channel guard, then the /ctl round-trip (`SessionsCoordinator` grew a delete waiter arm; `failMachine` sweeps both). Error mapping mirrors the listing: 502 `DAEMON_SESSION_DELETE_FAILED`, 409 `MACHINE_OFFLINE`, 504 `SESSIONS_DELETE_TIMEOUT`. - **Assistant REST** (`DELETE /api/assistant-sessions/:id`, pre-existing): ownership FIRST (404-hides foreign ids), then the live guard (409 `ASSISTANT_SESSION_LIVE` — close the chat first), then the hard delete (cascade is clean: messages + tool calls ON DELETE CASCADE), then `pushSessions(owner)` so every rail drops the row immediately. ## Daemon arms (`packages/cli/src/daemon/sessions.ts`) `sessions:delete` mirrors the listing's dispatch table, plus: 1. **Live guard, all arms** — `liveNativeIdsFor(target)` (a new `ChatHandlersHandle` seam over the chat registry's `acpSessionId`s) refuses a session an open channel holds: *"session is open in a live channel — close it first"*. The server refuses first; this re-checks against the daemon's own table so a racing open cannot slip under. 2. **ACP trio** (claude-code/codex/opencode): live connection `session/delete` first (concurrent request, no spawn), else a short-lived adapter spawn. `AcpSessionCaps` grew a `delete` bit; the spawn path is CAPABILITY-GATED — an adapter that did not advertise it (codex today, opencode 1.x) gets a clear "does not support session/delete" error rather than a method-not-found rejection. Upstream shipping it later lights the path up with zero daemon changes. 3. **dsh**: `findSessionDir` (new export; `findTranscript` refolds onto it) resolves `~/.dsh/sessions/*/`; `rmSync(recursive, force)`. 4. **pi**: `piFindSessionFile` (the rail's existing resolver) → unlink. 5. File arms are IDEMPOTENT (absent = success), matching ACP semantics. A successful delete drops the target's listing-cache entry. ## Web - `components/chat/swipe-row.tsx` — hand-written touch swipe (zero-dep, the repo carries no gesture library): translateX driven by touch deltas, 6px slop-locked axis (vertical scrolls never fight it), rubber-band at reveal +24px, half-width snap. The moving layer is opaque (`bg-background`) so the destructive reveal (w-[72px], `bg-destructive`) is hidden until swiped. The reveal is inset **2px vertically** — rows carry fractional heights and WebKit rounds an `inset-y-0` absolute layer and the in-flow cover differently, which peeked a 1px red sliver below fractionally-unlucky rows on iOS (post-ship fix, not reproducible in Chromium). - `components/chat/session-row.tsx` — the shared affordance shell: desktop hover trash (`opacity-0 → group-hover/row:opacity-100`, also focus-visible) + the phone SwipeRow lane (`md:hidden`); `onDelete === undefined` renders neither (a live/open row must not offer what the server refuses). - Both rails wrap their existing row buttons verbatim (the shell never restructures the row); confirm rides the kit `ConfirmDialog` (danger, irreversible, impact = the row title). Errors toast `chat.deleteFailed {error}` (the server's message rides through — e.g. `DAEMON_NO_SESSIONS_DELETE` upgrade hint, adapter-unsupported reason). - Native rows hide the affordance while a channel holds them (`attached !== undefined`); assistant rows while viewed or busy. - i18n: `chat.deleteSessionTitle/Action/Consequence`, `chat.deleteFailed`. ## Verification (what ran) - Unit: cli `sessions-delete.test.ts` (7 — fixture adapter with/without the cap, live-connection zero-spawn, dsh dir, pi file, live guard, proto errors), server `sessions-route.test.ts` delete describe (5) + `assistant-delete.test.ts` (3 — foreign 404, live 409 + push, idle delete), web `swipe-row.test.tsx` (5 — axis lock, clamp/rubber, snap, onDelete, affordance presence). - smoke: `[#55] native session delete` block (ACP arm round-trip + idempotency, dsh file arm + disk check + empty listing) — 486/486. - Browser (docker rig, real machine + overlaid daemon): desktop hover→trash→ confirm on an assistant row and on a real claude-code row (the REAL adapter deleted the transcript from `~/.claude/projects` — verified on disk); 375px drawer delete via the SwipeRow reveal → confirm → row + DB gone; dsh/pi arms via API against the real machine's stores. - Known gaps: the literal finger-drag could not be driven live (synthetic TouchEvents don't reach React's delegated handlers from a Radix portal; agent-browser has no CDP touch dispatch) — the gesture math is unit-tested and the reveal+delete flow was clicked for real. opencode's rail on the dev rig is down for a PRE-EXISTING config break (its `opencode.json` lacks a required key), so its degradation toast was not exercised live (covered by unit test). ## Phase B (shipped 2026-10-15) — codex rides the CLI's own `codex delete` Rig recon (codex-cli 0.154.0): `codex delete --force ` accepts the session UUID (exactly what the ACP sessionId is — the rollout filename uuid), requires `--force` headless ("cannot confirm session deletion without an interactive terminal"), and is NOT idempotent (a repeat exits 1 "failed to delete session"). The daemon arm therefore uses the ROLLOUT FILE as its truth: absent = already gone = success without spawning the CLI; a nonzero exit still counts when the file verifiably went away (a race settled in our favor). `findCodexRollout` (exported) scans `~/.codex/sessions///` for a filename containing the id. When codex-acp eventually implements `session/delete`, the CLI path stays — it is the vendor's own cleanup (rollout + session index + SQLite state together), equally correct.