design session delete - sinrimin/harness-nexus GitHub Wiki

Session deletion (#55) β€” platform assistant + client-agent native sessions

Issue #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/[email protected] 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/<slug>/<sessionId>/ β€” 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/<group>/<ts>_<id>.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 acpSessionIds) 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/*/<id>; 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 <SESSION> 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/<Y>/<M>/<D> 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.

⚠️ **GitHub.com Fallback** ⚠️