diff --git a/experimental/papyrus/client/src/components/Sidebar.tsx b/experimental/papyrus/client/src/components/Sidebar.tsx
index 1d450c9..7762866 100644
--- a/experimental/papyrus/client/src/components/Sidebar.tsx
+++ b/experimental/papyrus/client/src/components/Sidebar.tsx
@@ -284,42 +284,6 @@ export function Sidebar() {
}
};
- // Doctor + conditionally delete a session that failed to resume. The server runs
- // `silverwood session doctor` and only removes the session if no conversation exists
- // on disk; a removed tab vanishes on the next reconcile.
- const doctorSession = async (tab: SessionTab) => {
- if (!selectedNodeId) return;
- setBusy(true);
- setConnectError(null);
- try {
- const res = await fetch(`/api/sessions/${selectedNodeId}/sessions/${tab.sessionId}/doctor`, {
- method: "POST",
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- setConnectError(data.error || "Failed to check session");
- return;
- }
- if (data.removed) {
- setPending((p) => {
- const n = { ...p };
- delete n[tab.sessionId];
- return n;
- });
- } else {
- setConnectError(
- data.conversationExists
- ? "A saved conversation exists — try Connect again."
- : "Session left in place.",
- );
- }
- } catch (e: any) {
- setConnectError(e.message);
- } finally {
- setBusy(false);
- }
- };
-
const displayColor = editColor || session?.customColor || session?.color || "#888";
// Workstream-level indicator: silverwood's algebraic state, plus the N/M-connected
// agent count for a Ready Basic workstream.
@@ -685,7 +649,7 @@ export function Sidebar() {
{activeTab.kind === "plain-shell"
? "Disconnected — connecting opens a new shell in the checkout."
- : "Disconnected — connecting resumes this Claude Code conversation."}
+ : "Disconnected — connecting resumes this Claude Code conversation (or starts it fresh if none was saved)."}
{activeTab.lock && !activeTab.lock.mine ? (
@@ -713,23 +677,6 @@ export function Sidebar() {
Connect
)}
- {activeTab.disconnectReason === "no-conversation" &&
- activeTab.doctorKind === "claude-code" && (
-
-
- No saved conversation was found on disk for this session.
-
-
doctorSession(activeTab)}
- disabled={busy}
- title="Runs `silverwood session doctor`, then removes the session only if no conversation exists on disk"
- className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded-md border border-red-500/40 text-red-300 text-sm font-medium hover:bg-red-500/10 disabled:opacity-50 transition-colors"
- >
- {busy ? : }
- Delete this claude session if it doesn't exist
-
-
- )}
{activeTab.kind === "plain-shell" && (
removeSession(activeTab)}
diff --git a/experimental/papyrus/client/src/stores/useStore.ts b/experimental/papyrus/client/src/stores/useStore.ts
index e36b4ab..abcca41 100644
--- a/experimental/papyrus/client/src/stores/useStore.ts
+++ b/experimental/papyrus/client/src/stores/useStore.ts
@@ -53,11 +53,6 @@ export interface SessionTab {
kind: string;
connected: boolean;
lock?: { holder: string; mine: boolean } | null;
- // Set on a disconnected tab whose last resume failed with "no conversation found".
- // `doctorKind` is the variant `silverwood session doctor` reported; the delete-session
- // button shows only when reason is "no-conversation" AND doctorKind is "claude-code".
- disconnectReason?: string;
- doctorKind?: string;
}
export interface AgentSession {
diff --git a/experimental/papyrus/server/routes/api.ts b/experimental/papyrus/server/routes/api.ts
index 9f6c2a9..9871c69 100644
--- a/experimental/papyrus/server/routes/api.ts
+++ b/experimental/papyrus/server/routes/api.ts
@@ -1,5 +1,5 @@
import { Hono } from "hono";
-import type { Agent, Session } from "../types";
+import type { Agent } from "../types";
import { randomUUID } from "crypto";
import { PORT } from "../config";
import {
@@ -8,7 +8,6 @@ import {
killTerminal,
resolveRuntime,
pruneWorkstreams,
- disconnectInfo,
HOLDER,
} from "../services/sessionManager";
import * as sw from "../services/silverwood";
@@ -85,14 +84,12 @@ async function buildNode(ws: sw.Workstream) {
} catch (e: any) {
logError(`\x1b[38;5;141m[sessions]\x1b[0m ${ws.id}: ${e.message}`);
}
+ // papyrus records a session durably BEFORE spawning its PTY, so every live PTY has a
+ // durable record here — no transient-tab projection is needed.
const tabs: any[] = [];
for (const [sid, rec] of Object.entries(durable)) {
// The registry key IS the session id, so a live PTY is a direct lookup.
const live = runtimes.some(([k]) => k === sid);
- // For a disconnected tab whose last resume failed with "no conversation found",
- // surface the reason and the variant `session doctor` reported (the button gates
- // on doctor's kind, not this projection's rec.kind).
- const info = !live ? disconnectInfo.get(sid) : undefined;
tabs.push({
sessionId: sid,
name: rec.name,
@@ -100,23 +97,6 @@ async function buildNode(ws: sw.Workstream) {
kind: rec.kind,
connected: live,
lock: rec.lock ? { holder: rec.lock.holder, mine: rec.lock.holder === HOLDER } : null,
- disconnectReason: info?.reason,
- doctorKind: info?.doctorKind,
- });
- }
- // 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 === "plain-shell";
- tabs.push({
- sessionId: key,
- name: isShell ? "shell" : "claude",
- createdAt: ws.created_at,
- kind: s.kind ?? "claude-code",
- connected: true,
- lock: null,
});
}
@@ -174,35 +154,27 @@ apiRoutes.get("/sessions", async (c) => {
}
});
-// Record a freshly-spawned session durably in silverwood (name "claude") and
-// acquire its advisory lock for this instance. papyrus mints the id and passes it
-// to `claude --session-id`, so this replaces what the plugin hook used to do —
-// no runtime callback. Best-effort: a failure is logged, not fatal (the live PTY
-// still runs; buildNode surfaces it as a transient tab until the record lands).
+// Record a fresh durable session in silverwood BEFORE its PTY is spawned — `spawn from-id`
+// reads the record to know how to run it, so the create is mandatory: this THROWS on failure
+// and the caller must not spawn without a record. papyrus mints the id (for a claude session it
+// is also the `claude --session-id`). For a claude kind it additionally acquires the advisory
+// lock (best-effort — advisory, so it never blocks the spawn). Returns whether the lock is held.
async function recordFreshSession(
+ variant: "claude-code" | "plain-shell",
wsId: string,
sessionId: string,
- session: Session,
-): Promise {
- try {
- await sw.sessionCreate("claude-code", wsId, sessionId, "claude");
- await sw.sessionLock(wsId, sessionId, HOLDER);
- session.holdsLock = true;
- } catch (e: any) {
- logError(`\x1b[38;5;141m[session-register]\x1b[0m ${sessionId}: ${e.message}`);
+): Promise {
+ if (variant === "plain-shell") {
+ await sw.sessionCreate("plain-shell", wsId, sessionId, "shell");
+ return false;
}
-}
-
-// 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 {
+ await sw.sessionCreate("claude-code", wsId, sessionId, "claude");
try {
- await sw.sessionCreate("plain-shell", wsId, sessionId, "shell");
+ await sw.sessionLock(wsId, sessionId, HOLDER);
+ return true;
} catch (e: any) {
- logError(`\x1b[38;5;141m[session-register]\x1b[0m ${sessionId}: ${e.message}`);
+ logError(`\x1b[38;5;141m[session-register]\x1b[0m lock ${sessionId}: ${e.message}`);
+ return false;
}
}
@@ -266,10 +238,10 @@ apiRoutes.post("/sessions", async (c) => {
});
});
-// 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.
+// Connect (reopen) an existing durable session: acquire its advisory lock (claude kinds
+// only), then spawn its PTY via `spawn from-id` — silverwood runs it the way it records
+// itself (a shell reopens fresh; a claude session resumes if a transcript exists, else
+// starts fresh). `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();
@@ -292,41 +264,35 @@ 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`.
+ // silverwood is authoritative for the session's kind (the tab may be stale). A shell
+ // kind carries no lock; a claude kind acquires the advisory lock. Either way the PTY is
+ // a uniform `spawn from-id`, and silverwood decides how to run it (a shell reopens fresh;
+ // a claude session resumes if a transcript exists, else starts fresh).
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 });
- }
+ const isShell = durable[sessionId]?.kind === "plain-shell";
- // 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).
+ // Acquire the advisory lock (claude kinds only). 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).
let holdsLock = false;
- try {
- await sw.sessionLock(wsId, sessionId, HOLDER, !!force);
- holdsLock = true;
- } catch (e: any) {
- const m = /is locked by (.+)$/.exec((e.message || "").trim());
- if (m) return c.json({ error: e.message, locked: true, holder: m[1] }, 409);
- log(`\x1b[38;5;141m[lock]\x1b[0m acquire ${sessionId}: ${e.message}`);
+ if (!isShell) {
+ try {
+ await sw.sessionLock(wsId, sessionId, HOLDER, !!force);
+ holdsLock = true;
+ } catch (e: any) {
+ const m = /is locked by (.+)$/.exec((e.message || "").trim());
+ if (m) return c.json({ error: e.message, locked: true, holder: m[1] }, 409);
+ log(`\x1b[38;5;141m[lock]\x1b[0m acquire ${sessionId}: ${e.message}`);
+ }
}
const session = spawnTerminal({
sessionKey: sessionId,
workstreamId: wsId,
cwd,
- resume: true,
+ kind: isShell ? "plain-shell" : "claude-code",
});
session.holdsLock = holdsLock;
log(`\x1b[38;5;141m[session]\x1b[0m connected ${sessionId}`);
@@ -334,13 +300,10 @@ 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.
-// 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.
+// "claude-code"). papyrus mints the id and records the durable silverwood session FIRST
+// (so `spawn from-id` can read it), then spawns its PTY — so the tab (and any rename)
+// persists workstream-scoped. A claude kind also acquires its advisory lock; a plain shell
+// carries none and reopens fresh. A record failure fails the request (nothing is spawned).
apiRoutes.post("/sessions/:wsId/sessions", async (c) => {
const wsId = c.req.param("wsId");
const body = await c.req.json().catch(() => ({}));
@@ -356,15 +319,15 @@ apiRoutes.post("/sessions/:wsId/sessions", async (c) => {
return c.json({ error: "checkout not ready" }, 400);
}
const sessionId = randomUUID();
- const session = spawnTerminal({
- sessionKey: sessionId,
- workstreamId: wsId,
- cwd,
- resume: false,
- kind: variant,
- });
- if (variant === "claude-code") await recordFreshSession(wsId, sessionId, session);
- else await recordFreshShell(wsId, sessionId);
+ let holdsLock: boolean;
+ try {
+ holdsLock = await recordFreshSession(variant, wsId, sessionId);
+ } catch (e: any) {
+ logError(`\x1b[38;5;141m[session]\x1b[0m record ${variant} ${sessionId}: ${e.message}`);
+ return c.json({ error: e.message }, 500);
+ }
+ const session = spawnTerminal({ sessionKey: sessionId, workstreamId: wsId, cwd, kind: variant });
+ session.holdsLock = holdsLock;
log(`\x1b[38;5;141m[session]\x1b[0m spawned fresh ${variant} ${sessionId}`);
return c.json({ sessionId, connected: true, variant });
});
@@ -378,8 +341,8 @@ apiRoutes.post("/sessions/:wsId/sessions/:sessionId/disconnect", (c) => {
});
// 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.
+// silverwood record, so the tab is gone for good. This is how any tab is closed for
+// good — a plain shell, or a claude session you no longer want.
apiRoutes.delete("/sessions/:wsId/sessions/:sessionId", async (c) => {
const wsId = c.req.param("wsId");
const sessionId = c.req.param("sessionId");
@@ -390,38 +353,9 @@ apiRoutes.delete("/sessions/:wsId/sessions/:sessionId", async (c) => {
} 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
-// conversation on disk (`conversation_exists === false`), removes the durable session
-// via `session rm`. A `true` (real history) or `null` (unknown variant) is left alone.
-apiRoutes.post("/sessions/:wsId/sessions/:sessionId/doctor", async (c) => {
- const wsId = c.req.param("wsId");
- const sessionId = c.req.param("sessionId");
- try {
- const report = await sw.sessionDoctor(wsId, sessionId);
- let removed = false;
- if (report.conversation_exists === false) {
- await sw.sessionRemove(wsId, sessionId);
- removed = true;
- log(`\x1b[38;5;141m[doctor]\x1b[0m removed orphaned ${sessionId}`);
- }
- disconnectInfo.delete(sessionId);
- return c.json({
- kind: report.kind,
- conversationExists: report.conversation_exists,
- removed,
- });
- } catch (e: any) {
- logError(`\x1b[38;5;141m[doctor]\x1b[0m ${sessionId}: ${e.message}`);
- return c.json({ error: e.message }, 500);
- }
-});
-
// Rename a session (its silverwood `name`, shown as the tab title). Name required
// and non-empty; the live PTY (if any) is untouched.
apiRoutes.patch("/sessions/:wsId/sessions/:sessionId", async (c) => {
diff --git a/experimental/papyrus/server/services/sessionManager.ts b/experimental/papyrus/server/services/sessionManager.ts
index 72ab665..845fdc1 100644
--- a/experimental/papyrus/server/services/sessionManager.ts
+++ b/experimental/papyrus/server/services/sessionManager.ts
@@ -28,23 +28,6 @@ const MAX_BUFFER_SIZE = 1000;
// here is cached from it.
export const sessions = new Map();
-// Claude Code prints this to the PTY when `claude --resume ` finds no saved
-// conversation, then exits. It's the only signal that a reconnect failed because
-// the session was never persisted (created but never prompted) — Claude emits no
-// structured status and the plugin hooks were removed. Brittle to Claude's wording;
-// this is the single place to update if it changes.
-const NO_CONVERSATION_MARKER = "No conversation found with session ID";
-
-// Why a session's PTY last exited, when it was the resumable-but-missing case above.
-// Populated on exit (keyed by session id) and cleared on the next spawn for that key
-// or when the doctor endpoint acts. `doctorKind` is filled in from `session doctor`
-// (the authority for the variant used to gate papyrus's delete-session button); it's
-// absent until that async call lands. Ephemeral, like the registry itself.
-export const disconnectInfo = new Map<
- string,
- { reason: "no-conversation"; doctorKind?: string }
->();
-
// Release the advisory lock this instance holds for a session, if any
// (best-effort; a crash skips it and the lock is recovered via a force-steal).
// The registry key IS the silverwood session id.
@@ -62,47 +45,44 @@ function releaseLock(sessionKey: string, session: Session): void {
// IN_NIX_SHELL/DEVENV_*/an augmented PATH): silverwood scrubs and rebuilds the
// real login env from this seed before it execs the agent, so none of papyrus's
// env reaches claude. HOME/USER/SHELL seed that reconstruction; SSH_AUTH_SOCK is
-// forwarded into it; SILVERWOOD_FOREST_PATH lets `spawn` resolve the same forest
-// papyrus's other silverwood calls do (all consumed by silverwood, then scrubbed).
+// forwarded into it; SILVERWOOD_FOREST_PATH lets `spawn from-id` resolve the same
+// forest papyrus's other silverwood calls do; CLAUDE_CONFIG_DIR lets it find Claude's
+// transcripts to decide first-run vs resume (all consumed by silverwood, then scrubbed).
function seedEnv(): Record {
const env: Record = { TERM: "xterm-256color" };
- for (const k of ["HOME", "USER", "LOGNAME", "SHELL", "SSH_AUTH_SOCK", "SILVERWOOD_FOREST_PATH"]) {
+ for (const k of [
+ "HOME",
+ "USER",
+ "LOGNAME",
+ "SHELL",
+ "SSH_AUTH_SOCK",
+ "SILVERWOOD_FOREST_PATH",
+ "CLAUDE_CONFIG_DIR",
+ ]) {
const v = process.env[k];
if (v) env[k] = v;
}
return env;
}
-/// Spawn a terminal for a session: a PTY running `silverwood spawn` in the checkout
-/// dir, registered under `sessionKey`. silverwood owns *how* the shell is created —
-/// it scrubs the environment and picks the command from the checkout mode — and
-/// `exec`s it, so this PTY tracks the shell's lifetime directly: when it exits the
-/// PTY exits and we tear down (releasing the lock, dropping the entry).
-///
-/// Two variants:
-/// - "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.
-/// - "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).
+/// Spawn a terminal for a session: a PTY running `silverwood spawn from-id
+/// --workstream ` in the checkout dir, registered under `sessionKey`. The durable
+/// session must already exist in silverwood (papyrus records it before spawning) — silverwood
+/// reads its kind and runs it (deciding first-run vs resume for a claude kind from whether a
+/// transcript exists), scrubs the environment, and `exec`s it, so this PTY tracks the shell's
+/// lifetime directly: when it exits the PTY exits and we tear down (releasing the lock, dropping
+/// the entry). `sessionKey` IS the silverwood session id (and, for a claude session, the claude
+/// session id); `kind` is recorded on the registry entry for reference but no longer shapes the
+/// command — silverwood decides everything from the durable record.
export function spawnTerminal(params: {
sessionKey: string;
workstreamId: string;
cwd: string;
- resume: boolean;
kind?: "claude-code" | "plain-shell";
}): Session {
- const { sessionKey, workstreamId, cwd, resume, kind = "claude-code" } = params;
-
- // A fresh spawn attempt supersedes any prior "no conversation" verdict for this id.
- disconnectInfo.delete(sessionKey);
+ const { sessionKey, workstreamId, cwd, kind = "claude-code" } = params;
- const args =
- kind === "plain-shell"
- ? ["spawn", workstreamId]
- : ["spawn", workstreamId, sessionKey, ...(resume ? ["--resume"] : [])];
+ const args = ["spawn", "from-id", sessionKey, "--workstream", workstreamId];
const ptyProcess = spawnPty(SILVERWOOD_EXE, args, {
name: "xterm-256color",
@@ -133,21 +113,11 @@ export function spawnTerminal(params: {
});
ptyProcess.onExit(() => {
- // Only tear down if this exact entry is still the live one for the key.
+ // Only tear down if this exact entry is still the live one for the key. A claude
+ // resume against a missing conversation no longer needs special handling: silverwood
+ // decides first-run vs resume from the transcript, so a never-prompted session starts
+ // fresh instead of exiting with an error.
if (sessions.get(sessionKey) === session) {
- // A claude-code resume that failed because the conversation was never saved
- // exits printing NO_CONVERSATION_MARKER. Record it so the disconnected screen
- // can offer to clean up the orphaned session, and ask `session doctor` for the
- // variant (its authority) to gate that button.
- if (kind === "claude-code" && session.outputBuffer.join("").includes(NO_CONVERSATION_MARKER)) {
- disconnectInfo.set(sessionKey, { reason: "no-conversation" });
- sw.sessionDoctor(workstreamId, sessionKey)
- .then((r) => {
- const info = disconnectInfo.get(sessionKey);
- if (info) info.doctorKind = r.kind;
- })
- .catch((e) => log(`\x1b[38;5;141m[doctor]\x1b[0m ${sessionKey}: ${e.message}`));
- }
releaseLock(sessionKey, session);
session.clients.clear();
sessions.delete(sessionKey);
diff --git a/experimental/papyrus/server/services/silverwood.ts b/experimental/papyrus/server/services/silverwood.ts
index 1143224..e3ba0ba 100644
--- a/experimental/papyrus/server/services/silverwood.ts
+++ b/experimental/papyrus/server/services/silverwood.ts
@@ -52,17 +52,6 @@ export interface AgentSession {
lock?: { holder: string; acquired_at: string };
}
-// The read-only report from `silverwood session doctor`: the session's variant and,
-// for a claude-code session, whether Claude's conversation transcript exists on disk
-// (the `--resume` ground truth). `conversation_exists` is null for a variant doctor
-// cannot yet check.
-export interface DoctorReport {
- workstream_id: string;
- session_id: string;
- kind: string;
- conversation_exists: boolean | null;
-}
-
// The `new` command tree, from `silverwood new-schema` (drives the New Workstream
// modal). `new` is a tree of nested subcommands; each complete path (leaf) declares
// its own positional args. There is NO fixed variant/mode/seed shape — a node may
@@ -125,12 +114,6 @@ export async function sessionLs(id: string): Promise {
- return run(["session", "doctor", id, sessionId]);
-}
-
// ---- writes (serialized per workstream — see below) ----
export function create(params: {