diff --git a/experimental/papyrus/DESIGN.md b/experimental/papyrus/DESIGN.md index ff8c9c9..0ef4f46 100644 --- a/experimental/papyrus/DESIGN.md +++ b/experimental/papyrus/DESIGN.md @@ -60,8 +60,12 @@ no private state of its own: silverwood too — that is the whole point. - **Display name** → the workstream `name` (edited via `silverwood rename`). - **Working directory** → silverwood's per-forest checkout location. -- **Agent runs** → silverwood sessions (themselves KV, under `app.andref.silverwood.session`), - recorded when Claude Code first reports its session id. +- **Session tabs** → silverwood sessions (themselves KV, under `app.andref.silverwood.session`). + A tab is one session record: a `claude-code` agent session (papyrus mints its id and records + it on spawn) or a `plain-shell` login shell (also durable, so its name persists and is + workstream-scoped; it carries no lock and reopening spawns a fresh shell — there is no process + to resume). Storing shell tabs here — not in papyrus-local client state — is what keeps a + renamed shell from leaking across workstreams. `server/services/silverwood.ts` is the single persistence boundary; it serializes writes per workstream (silverwood does read-modify-overwrite with no locking). Dropped from the diff --git a/experimental/papyrus/server/routes/api.ts b/experimental/papyrus/server/routes/api.ts index e320021..76841ab 100644 --- a/experimental/papyrus/server/routes/api.ts +++ b/experimental/papyrus/server/routes/api.ts @@ -104,13 +104,12 @@ async function buildNode(ws: sw.Workstream) { doctorKind: info?.doctorKind, }); } - // A live PTY with no durable session record: either an agent session in the tiny - // window between spawn and `session create` landing, or an ephemeral "shell" - // (which never gets a record) for as long as its PTY runs. Surface it as a - // transient tab, rendered from the registry entry's kind. + // A live PTY not yet in the durable list: an agent or shell session in the tiny + // window between spawn and its `session create` landing. Surface it as a transient + // tab (rendered from the registry entry's kind) until the record appears in loop 1. for (const [key, s] of runtimes) { if (durable[key]) continue; - const isShell = s.kind === "shell"; + const isShell = s.kind === "plain-shell"; tabs.push({ sessionId: key, name: isShell ? "shell" : "claude", @@ -186,7 +185,7 @@ async function recordFreshSession( session: Session, ): Promise { try { - await sw.sessionCreate(wsId, sessionId, "claude"); + await sw.sessionCreate("claude-code", wsId, sessionId, "claude"); await sw.sessionLock(wsId, sessionId, HOLDER); session.holdsLock = true; } catch (e: any) { @@ -194,6 +193,19 @@ async function recordFreshSession( } } +// Record a freshly-spawned plain shell durably (name "shell"), so its tab and any +// rename persist in silverwood (workstream-scoped) like a claude session. A shell +// carries no lock — every reopen is an independent fresh login shell. Best-effort: +// a failure is logged, not fatal (buildNode surfaces the live PTY as a transient +// tab until the record lands). +async function recordFreshShell(wsId: string, sessionId: string): Promise { + try { + await sw.sessionCreate("plain-shell", wsId, sessionId, "shell"); + } catch (e: any) { + logError(`\x1b[38;5;141m[session-register]\x1b[0m ${sessionId}: ${e.message}`); + } +} + // Create a node = register a silverwood workstream, then respond at the accept // boundary so the New Workstream modal closes immediately. The (possibly slow) checkout // is provisioned in the BACKGROUND; the node appears pending → ready/failed via the @@ -265,8 +277,10 @@ apiRoutes.post("/sessions", async (c) => { }); }); -// Connect (resume) an existing session: acquire its advisory lock, then spawn a -// `claude --resume ` PTY in the checkout. `force` steals a lock held elsewhere. +// Connect an existing session. For a claude-code session this acquires its advisory +// lock and spawns a `claude --resume ` PTY; for a plain shell (no lock, no +// process to resume) it just spawns a fresh login shell keyed by the durable id. +// `force` steals a claude lock held elsewhere. apiRoutes.post("/sessions/:wsId/sessions/connect", async (c) => { const wsId = c.req.param("wsId"); const { sessionId, force } = await c.req.json(); @@ -289,6 +303,23 @@ apiRoutes.post("/sessions/:wsId/sessions/connect", async (c) => { return c.json({ error: "checkout not ready" }, 400); } + // silverwood is authoritative for the session's kind (the tab may be stale). A + // plain shell reopens as a fresh login shell — no lock, no `--resume`. + const durable = await sw + .sessionLs(wsId) + .catch(() => ({}) as Record); + if (durable[sessionId]?.kind === "plain-shell") { + spawnTerminal({ + sessionKey: sessionId, + workstreamId: wsId, + cwd, + resume: false, + kind: "plain-shell", + }); + log(`\x1b[38;5;141m[session]\x1b[0m reopened shell ${sessionId}`); + return c.json({ sessionId, connected: true }); + } + // Acquire the advisory lock. A contention failure → 409 so the UI can offer // Force; any other silverwood error is logged and ignored (the lock is advisory, // so it must never block a connect on its own). @@ -315,15 +346,16 @@ apiRoutes.post("/sessions/:wsId/sessions/connect", async (c) => { // Add a fresh session tab of the requested `variant` (body `{ variant }`, default // "claude-code" — an empty/absent body keeps the old behavior). papyrus mints the id. -// - "claude-code": spawn `claude --session-id ` and record the durable -// silverwood session + advisory lock immediately (no hook). -// - "shell": spawn an ephemeral `silverwood spawn ` login shell — no durable -// record, no lock. It lives only while its PTY runs (buildNode surfaces it as a -// transient tab); it vanishes on exit/disconnect. +// Both variants get a durable silverwood record immediately, so the tab (and any +// rename) persists workstream-scoped: +// - "claude-code": spawn `claude --session-id `, record the session + acquire +// its advisory lock (no hook). +// - "plain-shell": spawn `silverwood spawn ` (a login shell), record the +// session with no lock. Reopening later spawns a fresh login shell. apiRoutes.post("/sessions/:wsId/sessions", async (c) => { const wsId = c.req.param("wsId"); const body = await c.req.json().catch(() => ({})); - const variant = body.variant === "shell" ? "shell" : "claude-code"; + const variant = body.variant === "plain-shell" ? "plain-shell" : "claude-code"; let ws: sw.Workstream; try { ws = await sw.get(wsId); @@ -343,6 +375,7 @@ apiRoutes.post("/sessions/:wsId/sessions", async (c) => { kind: variant, }); if (variant === "claude-code") await recordFreshSession(wsId, sessionId, session); + else await recordFreshShell(wsId, sessionId); log(`\x1b[38;5;141m[session]\x1b[0m spawned fresh ${variant} ${sessionId}`); return c.json({ sessionId, connected: true, variant }); }); @@ -355,6 +388,23 @@ apiRoutes.post("/sessions/:wsId/sessions/:sessionId/disconnect", (c) => { return c.json({ success: true }); }); +// Remove a session entirely: kill its live PTY (if any) and delete the durable +// silverwood record, so the tab is gone for good. This is how a plain shell is +// closed — `doctor` can't retire a conversation-less shell, so removal is explicit. +apiRoutes.delete("/sessions/:wsId/sessions/:sessionId", async (c) => { + const wsId = c.req.param("wsId"); + const sessionId = c.req.param("sessionId"); + const r = resolveRuntime(sessionId); + if (r) killTerminal(r[0]); + try { + await sw.sessionRemove(wsId, sessionId); + } catch (e: any) { + return c.json({ error: e.message }, 400); + } + disconnectInfo.delete(sessionId); + return c.json({ success: true }); +}); + // Doctor a session, then delete it if it's an orphan. This backs the disconnected // screen's "Delete this claude session if it doesn't exist" button: it runs the // read-only `silverwood session doctor` and, only when a checked variant reports no diff --git a/experimental/papyrus/server/services/sessionManager.ts b/experimental/papyrus/server/services/sessionManager.ts index 6b2b09b..72ab665 100644 --- a/experimental/papyrus/server/services/sessionManager.ts +++ b/experimental/papyrus/server/services/sessionManager.ts @@ -83,15 +83,16 @@ function seedEnv(): Record { /// - "claude-code" (default): `silverwood spawn [--resume]` — the agent /// shell (`claude --session-id`/`--resume `). `sessionKey` IS the claude /// session id, so the registry key === claude id === silverwood key. -/// - "shell": `silverwood spawn ` (no session id) — an ephemeral login shell. -/// `sessionKey` is a papyrus-local key used only for the registry/WebSocket; it -/// is never passed to silverwood, and `resume` is ignored. +/// - "plain-shell": `silverwood spawn ` (no session id) — a login shell. +/// `sessionKey` is a papyrus-local key used only for the registry/WebSocket and to +/// key the durable record; it is never passed to silverwood, and `resume` is +/// ignored (a shell has no process to resume — reopening spawns a fresh one). export function spawnTerminal(params: { sessionKey: string; workstreamId: string; cwd: string; resume: boolean; - kind?: "claude-code" | "shell"; + kind?: "claude-code" | "plain-shell"; }): Session { const { sessionKey, workstreamId, cwd, resume, kind = "claude-code" } = params; @@ -99,7 +100,7 @@ export function spawnTerminal(params: { disconnectInfo.delete(sessionKey); const args = - kind === "shell" + kind === "plain-shell" ? ["spawn", workstreamId] : ["spawn", workstreamId, sessionKey, ...(resume ? ["--resume"] : [])]; diff --git a/experimental/papyrus/server/services/silverwood.ts b/experimental/papyrus/server/services/silverwood.ts index 48ed2fe..1e67ec0 100644 --- a/experimental/papyrus/server/services/silverwood.ts +++ b/experimental/papyrus/server/services/silverwood.ts @@ -187,13 +187,17 @@ export function unsetKv(id: string, key: string): Promise { return serialize(id, () => run(["kv", "unset", id, PAPYRUS_NS, key])); } +/// Record a durable session of `kind` under a caller-minted id. `session create` +/// is a per-kind subcommand (`claude-code` | `plain-shell`); the kind flows through +/// as that subcommand name. export function sessionCreate( + kind: "claude-code" | "plain-shell", id: string, sessionId: string, name: string, ): Promise { return serialize(id, () => - run(["session", "create", "claude-code", id, sessionId, "--name", name]), + run(["session", "create", kind, id, sessionId, "--name", name]), ); } diff --git a/experimental/papyrus/server/types/index.ts b/experimental/papyrus/server/types/index.ts index 739d310..9e6bc27 100644 --- a/experimental/papyrus/server/types/index.ts +++ b/experimental/papyrus/server/types/index.ts @@ -16,11 +16,11 @@ export interface Session { outputBuffer: string[]; // Whether this instance currently holds the session's advisory lock. holdsLock?: boolean; - // The session variant this PTY runs. "claude-code" (default) is a durable agent - // session; "shell" is an ephemeral `silverwood spawn` login shell with no - // silverwood record — it only ever exists here, so buildNode surfaces it as a - // transient tab for the PTY's lifetime. - kind?: "claude-code" | "shell"; + // The session variant this PTY runs. Both are durable silverwood session records; + // "claude-code" (default) is a resumable agent session with an advisory lock, + // "plain-shell" is a `silverwood spawn ` login shell that carries no lock and + // reopens as a fresh shell (there is no process to resume). + kind?: "claude-code" | "plain-shell"; } export interface LinearTicket {