From a35541df775bbb07314954a1cb8b9ed861bd38e8 Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 10:56:43 -0400 Subject: [PATCH 01/30] fix(ask-user): key answers by question text for SDK AskUserQuestionOutput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canUseTool path forwarded the UI's numeric-index answers map ({"0": "...", "1": "..."}) straight to the SDK as updatedInput.answers. The Agent SDK's AskUserQuestionOutput schema keys answers by question text, so the SDK could not match any answer back to its question and the model received blank answers — visible as "I don't see an answer" or no acknowledgement of the user's selection. Remap numeric-index answers to {questionText: answer} before resolving the canUseTool promise. The MCP/mates path is unaffected: it builds a plain-text user message and never touches the SDK answers schema. --- lib/project-sessions.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 80cb87ad..de5c7f41 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -1070,12 +1070,20 @@ function attachSessions(ctx) { sdk.pushMessage(session, answerText); } } else { - // Claude native AskUserQuestion path (canUseTool). The SDK is - // synchronously blocked on the permission callback, so we must - // resolve it with the standard permission shape. + // Claude native AskUserQuestion path (canUseTool). The SDK's + // AskUserQuestionOutput schema keys answers by question text, not + // by the numeric index the UI sends. Remap before resolving or + // the model receives blank answers. + var qs = (pending.input && Array.isArray(pending.input.questions)) ? pending.input.questions : []; + var answersByText = {}; + for (var qi = 0; qi < qs.length; qi++) { + var qText = (qs[qi] && qs[qi].question) ? qs[qi].question : ("Question " + (qi + 1)); + var av = (answers[qi] != null) ? answers[qi] : answers[String(qi)]; + if (av != null) answersByText[qText] = String(av); + } pending.resolve({ behavior: "allow", - updatedInput: Object.assign({}, pending.input, { answers: answers }), + updatedInput: Object.assign({}, pending.input, { answers: answersByText }), }); } return true; From e0993a06c6508f2f3b0543fe9fd8940aeb0ae95b Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 11:11:21 -0400 Subject: [PATCH 02/30] fix(sessions): persist title-origin flags so manual renames survive restart session.titleManuallySet (set on rename_session) and titleAutoGenerated (set by the auto-title pass) gate the AUTO_TITLE_TURN_THRESHOLD check in sdk-message-processor.js, but neither flag was serialized in saveSessionFile / loadSessions. After any daemon restart the flags were lost while session.title persisted, and the next time the session crossed turn 2 the auto-title overwrote the user's rename. Persist both flags in the session meta and restore them on load. Add a backwards-compat shim so sessions whose files predate this change but already carry a non-default title also skip the next auto-title pass. --- lib/sessions.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/sessions.js b/lib/sessions.js index 039cf312..b13983a8 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -101,6 +101,11 @@ function createSessionManager(opts) { if (session.loop) metaObj.loop = session.loop; if (session.debateState) metaObj.debateState = session.debateState; if (session.debateSetupMode) metaObj.debateSetupMode = true; + // Persist title-origin flags so a daemon restart doesn't lose the + // signal that the title was set by the user (and shouldn't be + // overwritten by the auto-title pass at AUTO_TITLE_TURN_THRESHOLD). + if (session.titleManuallySet) metaObj.titleManuallySet = true; + if (session.titleAutoGenerated) metaObj.titleAutoGenerated = true; var meta = JSON.stringify(metaObj); var lines = [meta]; for (var i = 0; i < session.history.length; i++) { @@ -201,6 +206,15 @@ function createSessionManager(opts) { if (m.debateState) session.debateState = m.debateState; if (m.debateSetupMode) session.debateSetupMode = true; if (m.ownerId) session.ownerId = m.ownerId; + if (m.titleManuallySet) session.titleManuallySet = true; + if (m.titleAutoGenerated) session.titleAutoGenerated = true; + // Backwards-compat: older session files predate the persisted + // title-origin flags. If a non-default title is present but no flag + // was recorded, assume the title is already settled (either user + // rename or prior auto-title) and skip the next auto-title pass. + if (m.title && !m.titleManuallySet && !m.titleAutoGenerated) { + session.titleAutoGenerated = true; + } session.sessionVisibility = m.sessionVisibility || "shared"; session.bookmarked = !!m.bookmarked; session.favoriteOrder = typeof m.favoriteOrder === "number" ? m.favoriteOrder : null; From 4e2f50aad51378d6ec3aa24d7b7fa923e78079ea Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 11:27:44 -0400 Subject: [PATCH 03/30] feat(sessions): add clay-sessions MCP server with spawn_session tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single tool, spawn_session, lets an agent in a Clay session create new sessions in the current project and seed each with an initial user message. Returns immediately so the caller can fan out multiple sessions in one turn — primary use case is splitting a planning session's list of work items (e.g. JIRA issues) into per-item sessions, each started with the appropriate slash command (e.g. "/jira HARD-207"). Slash commands pass through verbatim to the SDK. The new session's title is marked titleManuallySet=true so Clay's auto-title pass at AUTO_TITLE_TURN_THRESHOLD does not overwrite it. Available in main project sessions (not mates), matching the scope of clay-debate. Inputs: title, initial_prompt, optional vendor (defaults to "claude"). --- lib/project.js | 28 ++++++++++ lib/spawn-mcp-server.js | 113 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 lib/spawn-mcp-server.js diff --git a/lib/project.js b/lib/project.js index 76bb25fc..2bc5992f 100644 --- a/lib/project.js +++ b/lib/project.js @@ -508,6 +508,34 @@ function createProjectContext(opts) { console.error("[project] Failed to create debate MCP server:", e.message); } + // Sessions MCP server: lets an agent spawn new sessions inside this + // project and seed each with an initial user message. Main project + // sessions only — mates run with a narrower tool surface. + if (!isMate) { + try { + var spawnMcp = require("./spawn-mcp-server"); + var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { + var newSess = sm.createSessionRaw({ vendor: spawnArgs.vendor || "claude" }); + newSess.title = String(spawnArgs.title).substring(0, 100); + newSess.titleManuallySet = true; + var userMsg = { type: "user_message", text: spawnArgs.initialPrompt, _ts: Date.now() }; + newSess.history.push(userMsg); + sm.saveSessionFile(newSess); + sm.broadcastSessionList(); + sendToSession(newSess.localId, userMsg); + newSess.isProcessing = true; + onProcessingChanged(); + sendToSession(newSess.localId, { type: "status", status: "processing" }); + sdk.startQuery(newSess, spawnArgs.initialPrompt, null, ensureProjectAccessForSession(newSess)); + return Promise.resolve({ sessionId: newSess.localId, title: newSess.title }); + }); + var spawnMcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: spawnToolDefs }); + if (spawnMcpConfig) servers[spawnMcpConfig.name || "clay-sessions"] = spawnMcpConfig; + } catch (e) { + console.error("[project] Failed to create spawn MCP server:", e.message); + } + } + // Clay-history MCP server (host agent only — Clay mate) // Gives Clay BM25 search + targeted reads across the user's full // workspace. Read-only. Other Mates and project sessions never see diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js new file mode 100644 index 00000000..61d8fd0a --- /dev/null +++ b/lib/spawn-mcp-server.js @@ -0,0 +1,113 @@ +// Spawn MCP Server for Clay +// Exposes a spawn_session tool that lets an agent create new Clay sessions +// inside the current project and seed each one with an initial user message. +// +// Primary use case: a planning session triages a list of work items (JIRA +// issues, GitHub tickets, etc.) and fans them out into per-item sessions, +// each pre-titled and started with the relevant command (e.g. "/jira HARD-207"). +// +// SDK-free: returns runtime-agnostic tool definitions for YOKE adapter. +// +// Usage: +// var spawnMcp = require("./spawn-mcp-server"); +// var toolDefs = spawnMcp.getToolDefs(onSpawn); +// var mcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: toolDefs }); + +var z; +try { z = require("zod"); } catch (e) { z = null; } + +function buildShape(props, required) { + if (!z) return {}; + var shape = {}; + var keys = Object.keys(props); + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var p = props[k]; + var field; + if (p.type === "number") field = z.number(); + else if (p.type === "boolean") field = z.boolean(); + else if (p.enum) field = z.enum(p.enum); + else field = z.string(); + if (p.description) field = field.describe(p.description); + if (!required || required.indexOf(k) === -1) field = field.optional(); + shape[k] = field; + } + return shape; +} + +// onSpawn(args) -> Promise<{ sessionId, title }> +// args: { title, initial_prompt, vendor? } +// Throws (or rejects) for invalid input or runtime errors; the wrapper +// translates errors into an isError tool result so the calling agent sees them. +function getToolDefs(onSpawn) { + var tools = []; + + tools.push({ + name: "spawn_session", + description: + "Create a new Clay session in the current project, set its title, and " + + "send an initial user message to it. Returns immediately so the caller " + + "can spawn multiple sessions in one turn. Use this to fan out a list " + + "of work items (e.g. JIRA issues) into per-item sessions, each started " + + "with the appropriate slash command (e.g. \"/jira HARD-207\"). The title " + + "is marked as manual and will not be overwritten by Clay's auto-title.", + inputSchema: buildShape({ + title: { + type: "string", + description: "Title for the new session, e.g. the issue key \"HARD-207\".", + }, + initial_prompt: { + type: "string", + description: + "First user message sent to the new session. Slash commands pass " + + "through verbatim (e.g. \"/jira HARD-207\").", + }, + vendor: { + type: "string", + enum: ["claude", "codex"], + description: "Optional vendor for the new session. Defaults to \"claude\".", + }, + }, ["title", "initial_prompt"]), + handler: function (args) { + var title = (args.title || "").trim(); + var initialPrompt = (args.initial_prompt || "").trim(); + var vendor = args.vendor || "claude"; + + if (!title) { + return Promise.resolve({ + content: [{ type: "text", text: "Error: title is required and must be non-empty." }], + isError: true, + }); + } + if (!initialPrompt) { + return Promise.resolve({ + content: [{ type: "text", text: "Error: initial_prompt is required and must be non-empty." }], + isError: true, + }); + } + + return Promise.resolve() + .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor }); }) + .then(function (result) { + var sid = result && result.sessionId; + var t = (result && result.title) || title; + return { + content: [{ + type: "text", + text: "Spawned session #" + sid + " \"" + t + "\". Initial prompt queued.", + }], + }; + }) + .catch(function (err) { + return { + content: [{ type: "text", text: "Error spawning session: " + (err && err.message || err) }], + isError: true, + }; + }); + }, + }); + + return tools; +} + +module.exports = { getToolDefs: getToolDefs }; From 7f88721af3863cb6a7adde26fba281287a2ba81a Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 11:30:36 -0400 Subject: [PATCH 04/30] feat(sessions): make spawn_session self-triggering on user phrases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beef up the tool description so the agent calls spawn_session on its own when the user says things like "start working on these in clay", "split these into sessions", or "open a session for each of these" — instead of requiring the user to point at the tool by name. Adds explicit trigger phrases, behavior guidance (call in parallel, no confirmation prompt, brief post-spawn summary), title-selection rules (use the item identifier), and initial-prompt selection (prefer a project slash command like "/jira " when one fits, fall back to a short natural-language instruction). --- lib/spawn-mcp-server.js | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 61d8fd0a..8a28e75d 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -45,12 +45,36 @@ function getToolDefs(onSpawn) { tools.push({ name: "spawn_session", description: - "Create a new Clay session in the current project, set its title, and " + - "send an initial user message to it. Returns immediately so the caller " + - "can spawn multiple sessions in one turn. Use this to fan out a list " + - "of work items (e.g. JIRA issues) into per-item sessions, each started " + - "with the appropriate slash command (e.g. \"/jira HARD-207\"). The title " + - "is marked as manual and will not be overwritten by Clay's auto-title.", + "Create a new Clay session in the current project and seed it with an " + + "initial user message. Returns immediately so multiple sessions can be " + + "spawned in one turn (call this tool in parallel — one call per work " + + "item).\n" + + "\n" + + "USE THIS TOOL WHEN the user asks to:\n" + + " - \"start working on these issues/tickets in clay\"\n" + + " - \"split these into sessions\"\n" + + " - \"open a session for each of these\"\n" + + " - \"fan these out\" / \"spin up sessions\" / \"begin work on these in parallel\"\n" + + " - any similar request to turn a list of work items into per-item Clay sessions.\n" + + "\n" + + "Do NOT ask the user to confirm session creation; the request itself is the " + + "approval. Spawn one session per item from the user's list, in a single " + + "turn, using parallel tool calls when possible.\n" + + "\n" + + "Title: use the item's identifier (e.g. the JIRA key \"HARD-207\", the " + + "GitHub issue number \"#1234\", etc.). Keep it short — it shows in the " + + "sidebar. The title is marked as user-set and Clay's auto-title will not " + + "overwrite it.\n" + + "\n" + + "Initial prompt: pick the slash command the user's project uses to load " + + "context for that item. If JIRA keys are mentioned, use \"/jira \"; " + + "if GitHub issues, use \"/issue \" or whichever discovery command " + + "is in the available slash commands. When no slash command fits, use a " + + "short natural-language instruction (e.g. \"Begin work on ticket HARD-207\"). " + + "Slash commands pass through verbatim to the SDK.\n" + + "\n" + + "After spawning, briefly tell the user which sessions were created — do " + + "NOT switch the user's current view; they stay in the planning session.", inputSchema: buildShape({ title: { type: "string", From 358e2d8c95293a93e4df50360169f1854540eb2f Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 11:40:49 -0400 Subject: [PATCH 05/30] feat(sessions): disambiguate spawn_session from clay-ralph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user said "Start working on these issues in phase 1 in clay", the agent picked the clay-ralph skill (which claims phrases like "I want to automate this task" or "run overnight") instead of spawn_session. Tighten the tool description so the agent reliably picks spawn_session for any "list of items → list of sessions" request: - Call out clay-ralph by name in a DISAMBIGUATION block: ralph is for a single autonomous loop against ONE prompt/judge pair; spawn_session is for splitting a list of work ITEMS into per-item interactive sessions. - Add explicit triggers for "work on these in clay", "do these in clay", "one session per issue", and "phase 1/2/..." phrasings. - Restrict clay-ralph to explicit "ralph"/"autonomous loop"/"AFK" cues. --- lib/spawn-mcp-server.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 8a28e75d..f4ee2a02 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -45,17 +45,28 @@ function getToolDefs(onSpawn) { tools.push({ name: "spawn_session", description: - "Create a new Clay session in the current project and seed it with an " + - "initial user message. Returns immediately so multiple sessions can be " + - "spawned in one turn (call this tool in parallel — one call per work " + - "item).\n" + + "Create a new INTERACTIVE Clay session in the current project, named " + + "after a specific work item, and seed it with an initial user message. " + + "Returns immediately so multiple sessions can be spawned in one turn " + + "(call this tool in parallel — one call per work item).\n" + "\n" + - "USE THIS TOOL WHEN the user asks to:\n" + + "USE THIS TOOL — do NOT use the clay-ralph skill — when the user asks to:\n" + " - \"start working on these issues/tickets in clay\"\n" + - " - \"split these into sessions\"\n" + - " - \"open a session for each of these\"\n" + + " - \"work on these in clay\" / \"open these in clay\" / \"do these in clay\"\n" + + " - \"split these into sessions\" / \"one session per issue\"\n" + + " - \"open a session for each of these\" / \"a session per ticket\"\n" + " - \"fan these out\" / \"spin up sessions\" / \"begin work on these in parallel\"\n" + - " - any similar request to turn a list of work items into per-item Clay sessions.\n" + + " - any similar request to turn a list of work items into per-item Clay sessions,\n" + + " INCLUDING phrasings with \"start\", \"begin\", \"kick off\", or \"phase 1/2/...\".\n" + + "\n" + + "DISAMBIGUATION vs clay-ralph: spawn_session creates regular interactive " + + "sessions that the human will steer one by one — each session waits for " + + "human input after its initial prompt runs. The clay-ralph skill is for a " + + "single autonomous loop that runs unattended against ONE prompt/judge " + + "pair (typically overnight, AFK). If the user is splitting a list of " + + "ITEMS into sessions, this is spawn_session. Only use clay-ralph when the " + + "user explicitly says \"ralph\", \"ralph loop\", \"autonomous loop\", \"run " + + "while I'm AFK\", or asks for a single self-driving iteration loop.\n" + "\n" + "Do NOT ask the user to confirm session creation; the request itself is the " + "approval. Spawn one session per item from the user's list, in a single " + From 28b98f0f0be299c1889fb1462fca428411a04c25 Mon Sep 17 00:00:00 2001 From: Tushar Singh Date: Tue, 19 May 2026 13:17:14 -0400 Subject: [PATCH 06/30] feat(tui): expose Clay MCP tools to TUI claude via existing mcp-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clay-tools stdio bridge at lib/yoke/mcp-bridge-server.js was built for Codex; reuse it to surface Clay's in-app MCP servers (clay-sessions, clay-debate, clay-history, clay-ask-user) inside TUI Claude sessions too. Without this, a TUI session only sees file-based skills like /clay-ralph — MCP tools were silently absent because Clay never wired its servers into the claude CLI's MCP config. Changes: - lib/tui-mcp-config.js: helper that writes a temp --mcp-config JSON pointing at the clay-tools bridge for the daemon's port/slug/auth, with a matching cleanup hook. Shared by all TUI launch paths. - lib/project-sessions.js: both TUI launch sites (new-TUI-session and runtime-resume PTY) now append --mcp-config and remove the temp file on PTY exit. - lib/spawn-mcp-server.js: spawn_session accepts mode ("tui" | "gui"), defaulting to "tui" so per-issue sessions inherit subscription billing. Tool description gates "gui" mode to explicit user requests or codex. - lib/project.js: onSpawn callback branches on mode. TUI spawn pre-assigns the cliSessionId and launches claude --session-id -n "" --mcp-config <cfg> "<prompt>" in an xterm via tm.create. GUI spawn keeps the existing SDK flow. --- lib/project-sessions.js | 13 ++++++-- lib/project.js | 49 +++++++++++++++++++++++++++-- lib/spawn-mcp-server.js | 24 ++++++++++++-- lib/tui-mcp-config.js | 69 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 lib/tui-mcp-config.js diff --git a/lib/project-sessions.js b/lib/project-sessions.js index de5c7f41..814ee476 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -3,6 +3,7 @@ var path = require("path"); var crypto = require("crypto"); var { execFileSync } = require("child_process"); var { CODEX_DEFAULTS, getCodexConfig } = require("./codex-defaults"); +var tuiMcp = require("./tui-mcp-config"); // Format a user's answer to an ask_user_questions card as a plain user // message so the MCP path can feed it back to the agent on the next turn. @@ -124,12 +125,16 @@ function attachSessions(ctx) { } var sid = session.cliSessionId; var localId = session.localId; - var cmd = "claude --resume " + sid + "; exit\n"; + var mcpConfigPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var cmd = "claude --resume " + sid; + if (mcpConfigPath) cmd += " --mcp-config " + tuiMcp.shellQuote(mcpConfigPath); + cmd += "; exit\n"; var term = tm.create(80, 24, getOsUserInfoForWs(ws), ws, { initialInput: cmd, kind: "tui-session", title: "claude (resume) " + sid.slice(0, 8), onExit: function () { + tuiMcp.cleanupTuiMcpConfig(mcpConfigPath); // Don't delete the session record - the underlying GUI session is // still real. Just drop the runtime link so the sidebar/icon can // refresh. @@ -243,12 +248,16 @@ function attachSessions(ctx) { if (tm) { var tuiSid = newSess.cliSessionId; var tuiLocalId = newSess.localId; - var tuiCmd = "claude --session-id " + tuiSid + "; exit\n"; + var tuiMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var tuiCmd = "claude --session-id " + tuiSid; + if (tuiMcpPath) tuiCmd += " --mcp-config " + tuiMcp.shellQuote(tuiMcpPath); + tuiCmd += "; exit\n"; var tuiTerm = tm.create(80, 24, getOsUserInfoForWs(ws), ws, { initialInput: tuiCmd, kind: "tui-session", title: "claude " + tuiSid.slice(0, 8), onExit: function () { + tuiMcp.cleanupTuiMcpConfig(tuiMcpPath); if (sm.sessions.has(tuiLocalId)) { try { sm.deleteSessionQuiet(tuiLocalId); } catch (e) {} try { sm.broadcastSessionList(); } catch (e) {} diff --git a/lib/project.js b/lib/project.js index 2bc5992f..962c7675 100644 --- a/lib/project.js +++ b/lib/project.js @@ -514,9 +514,52 @@ function createProjectContext(opts) { if (!isMate) { try { var spawnMcp = require("./spawn-mcp-server"); + var tuiMcpForSpawn = require("./tui-mcp-config"); var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { - var newSess = sm.createSessionRaw({ vendor: spawnArgs.vendor || "claude" }); - newSess.title = String(spawnArgs.title).substring(0, 100); + var vendor = spawnArgs.vendor || "claude"; + var mode = (vendor === "claude" && spawnArgs.mode === "tui") ? "tui" : "gui"; + var title = String(spawnArgs.title).substring(0, 100); + + // --- TUI path: launch claude CLI in an xterm with --mcp-config + prompt --- + if (mode === "tui") { + if (!tm) { + return Promise.reject(new Error("Terminal manager unavailable; cannot spawn TUI session.")); + } + var newSess = sm.createSessionRaw({ + vendor: "claude", + mode: "tui", + cliSessionId: crypto.randomUUID(), + }); + newSess.title = title; + newSess.titleManuallySet = true; + var spawnedSid = newSess.cliSessionId; + var spawnedLocalId = newSess.localId; + var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts); + var cmd = "claude --session-id " + spawnedSid + + " -n " + tuiMcpForSpawn.shellQuote(title); + if (mcpCfgPath) cmd += " --mcp-config " + tuiMcpForSpawn.shellQuote(mcpCfgPath); + cmd += " " + tuiMcpForSpawn.shellQuote(spawnArgs.initialPrompt); + cmd += "; exit\n"; + var spawnedTerm = tm.create(80, 24, null, null, { + initialInput: cmd, + kind: "tui-session", + title: "claude " + spawnedSid.slice(0, 8), + onExit: function () { + tuiMcpForSpawn.cleanupTuiMcpConfig(mcpCfgPath); + if (sm.sessions.has(spawnedLocalId)) { + try { sm.deleteSessionQuiet(spawnedLocalId); } catch (e) {} + try { sm.broadcastSessionList(); } catch (e) {} + } + }, + }); + if (spawnedTerm) newSess.terminalId = spawnedTerm.id; + sm.broadcastSessionList(); + return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "tui" }); + } + + // --- GUI path: SDK-driven query (existing flow) --- + var newSess = sm.createSessionRaw({ vendor: vendor }); + newSess.title = title; newSess.titleManuallySet = true; var userMsg = { type: "user_message", text: spawnArgs.initialPrompt, _ts: Date.now() }; newSess.history.push(userMsg); @@ -527,7 +570,7 @@ function createProjectContext(opts) { onProcessingChanged(); sendToSession(newSess.localId, { type: "status", status: "processing" }); sdk.startQuery(newSess, spawnArgs.initialPrompt, null, ensureProjectAccessForSession(newSess)); - return Promise.resolve({ sessionId: newSess.localId, title: newSess.title }); + return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "gui" }); }); var spawnMcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: spawnToolDefs }); if (spawnMcpConfig) servers[spawnMcpConfig.name || "clay-sessions"] = spawnMcpConfig; diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index f4ee2a02..0320fbe0 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -82,7 +82,12 @@ function getToolDefs(onSpawn) { "if GitHub issues, use \"/issue <number>\" or whichever discovery command " + "is in the available slash commands. When no slash command fits, use a " + "short natural-language instruction (e.g. \"Begin work on ticket HARD-207\"). " + - "Slash commands pass through verbatim to the SDK.\n" + + "Slash commands pass through verbatim to the SDK or CLI.\n" + + "\n" + + "Mode: default \"tui\" runs the real claude CLI in an xterm (Interactive " + + "billing bucket). Use \"gui\" only when the user explicitly asks for the " + + "SDK chat UI (permission cards, Clay's MCP tools surfaced natively) or " + + "for codex sessions (which always run gui).\n" + "\n" + "After spawning, briefly tell the user which sessions were created — do " + "NOT switch the user's current view; they stay in the planning session.", @@ -102,11 +107,23 @@ function getToolDefs(onSpawn) { enum: ["claude", "codex"], description: "Optional vendor for the new session. Defaults to \"claude\".", }, + mode: { + type: "string", + enum: ["tui", "gui"], + description: + "Optional session mode. \"tui\" launches the real claude CLI in an " + + "xterm (Interactive billing bucket); \"gui\" uses Clay's SDK-driven " + + "chat UI (Programmatic billing bucket). Defaults to \"tui\". Codex " + + "vendor always runs in gui regardless of this arg.", + }, }, ["title", "initial_prompt"]), handler: function (args) { var title = (args.title || "").trim(); var initialPrompt = (args.initial_prompt || "").trim(); var vendor = args.vendor || "claude"; + var mode = (args.mode === "gui" || args.mode === "tui") ? args.mode : "tui"; + // Codex has no TUI adapter; force gui for codex regardless of arg. + if (vendor === "codex") mode = "gui"; if (!title) { return Promise.resolve({ @@ -122,14 +139,15 @@ function getToolDefs(onSpawn) { } return Promise.resolve() - .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor }); }) + .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor, mode: mode }); }) .then(function (result) { var sid = result && result.sessionId; var t = (result && result.title) || title; + var m = (result && result.mode) || mode; return { content: [{ type: "text", - text: "Spawned session #" + sid + " \"" + t + "\". Initial prompt queued.", + text: "Spawned " + m + " session #" + sid + " \"" + t + "\". Initial prompt queued.", }], }; }) diff --git a/lib/tui-mcp-config.js b/lib/tui-mcp-config.js new file mode 100644 index 00000000..58df27d1 --- /dev/null +++ b/lib/tui-mcp-config.js @@ -0,0 +1,69 @@ +// TUI MCP config helper +// ---------------------- +// Builds a temporary --mcp-config JSON file that points the claude CLI at +// Clay's clay-tools stdio bridge (lib/yoke/mcp-bridge-server.js), so a TUI +// session can call Clay's in-process MCP tools (clay-sessions, clay-debate, +// clay-history, clay-ask-user). The bridge already exists for Codex; here +// we re-use it for the Claude TUI integration. +// +// Usage: +// var tuiMcp = require("./tui-mcp-config"); +// var cfgPath = tuiMcp.buildTuiMcpConfig(slug, { port, tls, authToken }); +// // launch: claude --mcp-config <cfgPath> ... +// // on session exit: tuiMcp.cleanupTuiMcpConfig(cfgPath); + +var fs = require("fs"); +var os = require("os"); +var path = require("path"); +var crypto = require("crypto"); + +var BRIDGE_PATH = path.join(__dirname, "yoke", "mcp-bridge-server.js"); + +function buildTuiMcpConfig(slug, opts) { + if (!fs.existsSync(BRIDGE_PATH)) return null; + var clayPort = (opts && opts.port) || 2633; + var clayTls = !!(opts && opts.tls); + var clayAuthToken = (opts && opts.authToken) || ""; + + var bridgeArgs = [BRIDGE_PATH, "--port", String(clayPort)]; + if (slug) bridgeArgs.push("--slug", slug); + if (clayTls) bridgeArgs.push("--tls"); + + var serverEntry = { + command: process.execPath, + args: bridgeArgs, + }; + if (clayAuthToken) serverEntry.env = { CLAY_AUTH_TOKEN: clayAuthToken }; + + var config = { mcpServers: { "clay-tools": serverEntry } }; + + var fname = "clay-tui-mcp-" + Date.now() + "-" + crypto.randomBytes(4).toString("hex") + ".json"; + var cfgPath = path.join(os.tmpdir(), fname); + try { + fs.writeFileSync(cfgPath, JSON.stringify(config)); + if (process.platform !== "win32") { + try { fs.chmodSync(cfgPath, 0o600); } catch (e) {} + } + } catch (e) { + console.error("[tui-mcp-config] Failed to write " + cfgPath + ":", e.message); + return null; + } + return cfgPath; +} + +function cleanupTuiMcpConfig(cfgPath) { + if (!cfgPath) return; + try { fs.unlinkSync(cfgPath); } catch (e) {} +} + +// Single-quote a string for inclusion in a shell command. Handles embedded +// single quotes by closing/escaping/reopening: ' -> '\''. +function shellQuote(s) { + return "'" + String(s).replace(/'/g, "'\\''") + "'"; +} + +module.exports = { + buildTuiMcpConfig: buildTuiMcpConfig, + cleanupTuiMcpConfig: cleanupTuiMcpConfig, + shellQuote: shellQuote, +}; From a6d7611126137e1c46a3de425163ec38a77958a0 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 13:31:00 -0400 Subject: [PATCH 07/30] fix(spawn): defer TUI PTY launch until first session open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When spawn_session created a TUI session, it launched the claude CLI with no attached client. claude exited almost immediately ("no interactive partner"), the PTY closed, onExit fired, and the session was deleted — making the spawned session appear in the sidebar for a few seconds and then disappear. Defer the PTY launch. spawn_session now creates the session record with mode=tui, cliSessionId preassigned, and pendingInitialPrompt stored. The PTY is launched in project-sessions.js switch_session when the user first opens the session, with the active ws as the PTY owner. By that point there is an attached client, claude stays interactive, and the conversation runs normally. Also derive the session title from the leading slash command's first positional arg (e.g. "/jira GP-222" -> "GP-222") so the sidebar title matches the issue identifier regardless of what the calling agent passed. Falls back to the agent-supplied title for free-form prompts. Persist pendingInitialPrompt across daemon restarts so a spawned session that the user opens after a restart still seeds claude with the right first message. On PTY exit, no longer delete the session: the conversation is saved under ~/.claude/projects/<cwd>/<uuid>.jsonl and the user can re-open. --- lib/project-sessions.js | 37 +++++++++++++++++++++++++++++++ lib/project.js | 48 ++++++++++++++++++----------------------- lib/sessions.js | 5 +++++ 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 814ee476..7db7af1d 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -491,6 +491,43 @@ function attachSessions(ctx) { // the session_switched and session_list broadcasts surface them to // the client without sessions.js needing to know about the pref. var xmTarget = sm.sessions.get(msg.id); + // Deferred launch for spawn_session TUI sessions: spawn_mcp creates + // the session record only and defers `claude` until first open so + // the PTY has an attached client and stays interactive. Launch now. + if (xmTarget && xmTarget.mode === "tui" && xmTarget.pendingInitialPrompt && !xmTarget.terminalId && tm) { + var dlPrompt = xmTarget.pendingInitialPrompt; + delete xmTarget.pendingInitialPrompt; + var dlSid = xmTarget.cliSessionId; + var dlLocalId = xmTarget.localId; + var dlTitle = xmTarget.title || "session"; + var dlMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var dlCmd = "claude --session-id " + dlSid + + " -n " + tuiMcp.shellQuote(dlTitle); + if (dlMcpPath) dlCmd += " --mcp-config " + tuiMcp.shellQuote(dlMcpPath); + dlCmd += " " + tuiMcp.shellQuote(dlPrompt); + dlCmd += "; exit\n"; + var dlTerm = tm.create(80, 24, getOsUserInfoForWs(ws), ws, { + initialInput: dlCmd, + kind: "tui-session", + title: "claude " + dlSid.slice(0, 8), + onExit: function () { + tuiMcp.cleanupTuiMcpConfig(dlMcpPath); + // Keep the session record: the conversation is persisted by + // the claude CLI under ~/.claude/projects/<cwd>/<uuid>.jsonl + // and the user can reopen it (resume via runtime PTY). + var ds = sm.sessions.get(dlLocalId); + if (ds) { + ds.terminalId = null; + try { sm.saveSessionFile(ds); } catch (e) {} + try { sm.broadcastSessionList(); } catch (e) {} + } + }, + }); + if (dlTerm) { + xmTarget.terminalId = dlTerm.id; + try { sm.saveSessionFile(xmTarget); } catch (e) {} + } + } if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) { var xmPref = getClaudeOpenModeForWs(ws); var xmRuntime = computeRuntimeMode(xmTarget, xmPref); diff --git a/lib/project.js b/lib/project.js index 962c7675..a1ea3fca 100644 --- a/lib/project.js +++ b/lib/project.js @@ -515,16 +515,29 @@ function createProjectContext(opts) { try { var spawnMcp = require("./spawn-mcp-server"); var tuiMcpForSpawn = require("./tui-mcp-config"); + // Pull the first positional argument out of a leading slash command + // (e.g. "/jira GP-222" -> "GP-222"). When the prompt starts with a + // slash command, that argument is almost always the right session + // title, regardless of what the calling agent passed. Falls back to + // the agent-supplied title if no slash-arg can be extracted. + function deriveTitleFromPrompt(prompt) { + if (!prompt) return null; + var m = /^\s*\/[A-Za-z0-9_-]+\s+(\S+)/.exec(prompt); + return m ? m[1].substring(0, 100) : null; + } var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { var vendor = spawnArgs.vendor || "claude"; var mode = (vendor === "claude" && spawnArgs.mode === "tui") ? "tui" : "gui"; - var title = String(spawnArgs.title).substring(0, 100); - - // --- TUI path: launch claude CLI in an xterm with --mcp-config + prompt --- + var derived = deriveTitleFromPrompt(spawnArgs.initialPrompt); + var title = (derived || String(spawnArgs.title)).substring(0, 100); + + // --- TUI path: register the session NOW, defer the `claude` PTY --- + // launch until the user first opens the session. The deferred launch + // happens in project-sessions.js switch_session, which has an + // attached client ws to bind as the PTY owner. Launching here with + // no attached client tends to make `claude` exit immediately, which + // would tear the session record down again. if (mode === "tui") { - if (!tm) { - return Promise.reject(new Error("Terminal manager unavailable; cannot spawn TUI session.")); - } var newSess = sm.createSessionRaw({ vendor: "claude", mode: "tui", @@ -532,27 +545,8 @@ function createProjectContext(opts) { }); newSess.title = title; newSess.titleManuallySet = true; - var spawnedSid = newSess.cliSessionId; - var spawnedLocalId = newSess.localId; - var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts); - var cmd = "claude --session-id " + spawnedSid + - " -n " + tuiMcpForSpawn.shellQuote(title); - if (mcpCfgPath) cmd += " --mcp-config " + tuiMcpForSpawn.shellQuote(mcpCfgPath); - cmd += " " + tuiMcpForSpawn.shellQuote(spawnArgs.initialPrompt); - cmd += "; exit\n"; - var spawnedTerm = tm.create(80, 24, null, null, { - initialInput: cmd, - kind: "tui-session", - title: "claude " + spawnedSid.slice(0, 8), - onExit: function () { - tuiMcpForSpawn.cleanupTuiMcpConfig(mcpCfgPath); - if (sm.sessions.has(spawnedLocalId)) { - try { sm.deleteSessionQuiet(spawnedLocalId); } catch (e) {} - try { sm.broadcastSessionList(); } catch (e) {} - } - }, - }); - if (spawnedTerm) newSess.terminalId = spawnedTerm.id; + newSess.pendingInitialPrompt = String(spawnArgs.initialPrompt); + sm.saveSessionFile(newSess); sm.broadcastSessionList(); return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "tui" }); } diff --git a/lib/sessions.js b/lib/sessions.js index b13983a8..d74e8322 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -106,6 +106,10 @@ function createSessionManager(opts) { // overwritten by the auto-title pass at AUTO_TITLE_TURN_THRESHOLD). if (session.titleManuallySet) metaObj.titleManuallySet = true; if (session.titleAutoGenerated) metaObj.titleAutoGenerated = true; + // Spawn-deferred TUI sessions keep their first-message prompt until + // the user opens the session; persist so a restart still primes the + // claude --session-id launch with the right prompt. + if (session.pendingInitialPrompt) metaObj.pendingInitialPrompt = session.pendingInitialPrompt; var meta = JSON.stringify(metaObj); var lines = [meta]; for (var i = 0; i < session.history.length; i++) { @@ -208,6 +212,7 @@ function createSessionManager(opts) { if (m.ownerId) session.ownerId = m.ownerId; if (m.titleManuallySet) session.titleManuallySet = true; if (m.titleAutoGenerated) session.titleAutoGenerated = true; + if (m.pendingInitialPrompt) session.pendingInitialPrompt = m.pendingInitialPrompt; // Backwards-compat: older session files predate the persisted // title-origin flags. If a non-default title is present but no flag // was recorded, assume the title is already settled (either user From f3280c43468dff30ef5165eaaf546e1ab3f822df Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 16:33:03 -0400 Subject: [PATCH 08/30] fix(tui): persist mode + relaunch claude --resume after restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps left TUI sessions invisible (or dead) after a daemon restart: 1. lib/project-sessions.js new_session TUI path created the session via createSessionRaw + switchSession but never called saveSessionFile, so no record was written to ~/.clay/sessions/<slug>/<uuid>.jsonl and loadSessions had nothing to bring back. 2. saveSessionFile/loadSessions did not include session.mode in the meta block, so any TUI session that did get persisted (e.g. via the spawn flow or via a later save) came back tagged as gui. The deferred-launch check in switch_session keys off mode === "tui" and would silently skip the relaunch. 3. Even with mode restored, a freshly loaded TUI session has no in-memory terminalId (terminals are not persisted). The previous deferred-launch block only handled the spawn-first-open case (pendingInitialPrompt). Post-restart sessions have no pending prompt but still need a PTY. Changes: - lib/sessions.js: meta.mode = "tui" written/read; mode is the only non-default value worth persisting (gui is implicit). - lib/project-sessions.js: saveSessionFile after TUI new_session creation so the record hits disk. - lib/project-sessions.js switch_session: the deferred-launch block now branches — pendingInitialPrompt -> claude --session-id <uuid> with the prompt; otherwise -> claude --resume <uuid>. Either way the PTY only launches when the user actually opens the session, with an attached ws, so claude stays interactive. --- lib/project-sessions.js | 42 ++++++++++++++++++++++++++++------------- lib/sessions.js | 4 ++++ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 7db7af1d..18af7137 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -268,6 +268,10 @@ function attachSessions(ctx) { newSess.terminalId = tuiTerm.id; } } + // Persist the TUI session metadata (cliSessionId, mode, vendor) so it + // survives a daemon restart. saveSessionFile no-ops without + // cliSessionId; TUI assigns one up-front so this always writes. + sm.saveSessionFile(newSess); sm.switchSession(newSess.localId, ws); } else { newSess = sm.createSession(sessionOpts, ws); @@ -491,20 +495,33 @@ function attachSessions(ctx) { // the session_switched and session_list broadcasts surface them to // the client without sessions.js needing to know about the pref. var xmTarget = sm.sessions.get(msg.id); - // Deferred launch for spawn_session TUI sessions: spawn_mcp creates - // the session record only and defers `claude` until first open so - // the PTY has an attached client and stays interactive. Launch now. - if (xmTarget && xmTarget.mode === "tui" && xmTarget.pendingInitialPrompt && !xmTarget.terminalId && tm) { - var dlPrompt = xmTarget.pendingInitialPrompt; - delete xmTarget.pendingInitialPrompt; + // TUI session with no live PTY: either a spawn_session first-open + // (use pendingInitialPrompt) or a post-restart resume (use --resume + // against the cliSessionId, since claude CLI persisted the + // conversation under ~/.claude/projects/<cwd>/<uuid>.jsonl). + if (xmTarget && xmTarget.mode === "tui" && !xmTarget.terminalId && tm && xmTarget.cliSessionId) { + var dlPrompt = xmTarget.pendingInitialPrompt || null; + if (dlPrompt) delete xmTarget.pendingInitialPrompt; var dlSid = xmTarget.cliSessionId; var dlLocalId = xmTarget.localId; var dlTitle = xmTarget.title || "session"; var dlMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts); - var dlCmd = "claude --session-id " + dlSid + - " -n " + tuiMcp.shellQuote(dlTitle); - if (dlMcpPath) dlCmd += " --mcp-config " + tuiMcp.shellQuote(dlMcpPath); - dlCmd += " " + tuiMcp.shellQuote(dlPrompt); + var dlCmd; + if (dlPrompt) { + // First open of a freshly-spawned session: start a brand-new + // claude conversation with the prompt pre-supplied. + dlCmd = "claude --session-id " + dlSid + + " -n " + tuiMcp.shellQuote(dlTitle); + if (dlMcpPath) dlCmd += " --mcp-config " + tuiMcp.shellQuote(dlMcpPath); + dlCmd += " " + tuiMcp.shellQuote(dlPrompt); + } else { + // Re-open of an existing TUI session (e.g. after daemon restart + // or after the user closed the previous xterm): resume the saved + // conversation rather than starting a new one. + dlCmd = "claude --resume " + dlSid + + " -n " + tuiMcp.shellQuote(dlTitle); + if (dlMcpPath) dlCmd += " --mcp-config " + tuiMcp.shellQuote(dlMcpPath); + } dlCmd += "; exit\n"; var dlTerm = tm.create(80, 24, getOsUserInfoForWs(ws), ws, { initialInput: dlCmd, @@ -512,9 +529,8 @@ function attachSessions(ctx) { title: "claude " + dlSid.slice(0, 8), onExit: function () { tuiMcp.cleanupTuiMcpConfig(dlMcpPath); - // Keep the session record: the conversation is persisted by - // the claude CLI under ~/.claude/projects/<cwd>/<uuid>.jsonl - // and the user can reopen it (resume via runtime PTY). + // Keep the session record: claude has persisted the + // conversation and the next open will spawn a fresh --resume. var ds = sm.sessions.get(dlLocalId); if (ds) { ds.terminalId = null; diff --git a/lib/sessions.js b/lib/sessions.js index d74e8322..6385a11c 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -94,6 +94,9 @@ function createSessionManager(opts) { }; if (session.ownerId) metaObj.ownerId = session.ownerId; if (session.vendor) metaObj.vendor = session.vendor; + // Persist mode so born-TUI sessions don't silently demote to GUI on + // restart (the in-memory default for a freshly loaded record is gui). + if (session.mode === "tui") metaObj.mode = "tui"; if (session.sessionVisibility) metaObj.sessionVisibility = session.sessionVisibility; if (session.bookmarked) metaObj.bookmarked = true; if (typeof session.favoriteOrder === "number") metaObj.favoriteOrder = session.favoriteOrder; @@ -206,6 +209,7 @@ function createSessionManager(opts) { lastRewindUuid: m.lastRewindUuid || null, }; if (m.vendor) session.vendor = m.vendor; + if (m.mode === "tui") session.mode = "tui"; if (m.loop) session.loop = m.loop; if (m.debateState) session.debateState = m.debateState; if (m.debateSetupMode) session.debateSetupMode = true; From c73cceab8b200258ca1e722f8883bb4dde9b4220 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 16:36:58 -0400 Subject: [PATCH 09/30] fix(tui): terminate --mcp-config before positional prompt claude's --mcp-config flag is variadic (<configs...>) so it greedily consumes following non-flag args. A spawn_session command like claude --session-id <uuid> -n 'GP-222' \ --mcp-config '/tmp/clay-tui-mcp-xxx.json' '/jira GP-222' was being parsed as two MCP config files. The CLI rejected the launch with "MCP config file not found: /jira GP-222". Insert "--" between the options list and the positional prompt so the prompt isn't swept into --mcp-config. --- lib/project-sessions.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 18af7137..5b284358 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -509,11 +509,15 @@ function attachSessions(ctx) { var dlCmd; if (dlPrompt) { // First open of a freshly-spawned session: start a brand-new - // claude conversation with the prompt pre-supplied. + // claude conversation with the prompt pre-supplied. Note: the + // CLI's --mcp-config flag is variadic ("<configs...>"), so a + // positional prompt immediately after it gets misparsed as a + // second config-file path. Use "--" to terminate the options + // list before the positional prompt. dlCmd = "claude --session-id " + dlSid + " -n " + tuiMcp.shellQuote(dlTitle); if (dlMcpPath) dlCmd += " --mcp-config " + tuiMcp.shellQuote(dlMcpPath); - dlCmd += " " + tuiMcp.shellQuote(dlPrompt); + dlCmd += " -- " + tuiMcp.shellQuote(dlPrompt); } else { // Re-open of an existing TUI session (e.g. after daemon restart // or after the user closed the previous xterm): resume the saved From 7d0c00625fd4111a8632c7df611335268c6d6d63 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 16:41:28 -0400 Subject: [PATCH 10/30] fix(spawn): launch TUI PTY eagerly so work starts on spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defer-until-click was a workaround for sessions disappearing when their PTY exited immediately — but that root cause was the variadic --mcp-config swallowing the positional prompt (fixed in c73ccea), not "no client attached". Restore eager launch so spawn_session actually fans out work in parallel: the user calls the tool, claude starts running /jira <KEY> in each spawned session in the background, and the user can come back later to see results. The deferred-launch path in switch_session still handles the post-exit re-open case (terminalId cleared on PTY exit, next click spawns `claude --resume <uuid>`), so subsequent opens resume the persisted conversation rather than starting a new one. --- lib/project.js | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/project.js b/lib/project.js index a1ea3fca..e6756c6b 100644 --- a/lib/project.js +++ b/lib/project.js @@ -531,13 +531,15 @@ function createProjectContext(opts) { var derived = deriveTitleFromPrompt(spawnArgs.initialPrompt); var title = (derived || String(spawnArgs.title)).substring(0, 100); - // --- TUI path: register the session NOW, defer the `claude` PTY --- - // launch until the user first opens the session. The deferred launch - // happens in project-sessions.js switch_session, which has an - // attached client ws to bind as the PTY owner. Launching here with - // no attached client tends to make `claude` exit immediately, which - // would tear the session record down again. + // --- TUI path: launch claude CLI in an xterm immediately so the + // initial slash command runs in the background and the user + // returns to in-progress (or completed) work. The "--" separator + // is required: claude's --mcp-config flag is variadic and would + // otherwise swallow the positional prompt as a second config file. if (mode === "tui") { + if (!tm) { + return Promise.reject(new Error("Terminal manager unavailable; cannot spawn TUI session.")); + } var newSess = sm.createSessionRaw({ vendor: "claude", mode: "tui", @@ -545,7 +547,34 @@ function createProjectContext(opts) { }); newSess.title = title; newSess.titleManuallySet = true; - newSess.pendingInitialPrompt = String(spawnArgs.initialPrompt); + var spawnedSid = newSess.cliSessionId; + var spawnedLocalId = newSess.localId; + var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts); + var cmd = "claude --session-id " + spawnedSid + + " -n " + tuiMcpForSpawn.shellQuote(title); + if (mcpCfgPath) cmd += " --mcp-config " + tuiMcpForSpawn.shellQuote(mcpCfgPath); + cmd += " -- " + tuiMcpForSpawn.shellQuote(spawnArgs.initialPrompt); + cmd += "; exit\n"; + var spawnedTerm = tm.create(80, 24, null, null, { + initialInput: cmd, + kind: "tui-session", + title: "claude " + spawnedSid.slice(0, 8), + onExit: function () { + tuiMcpForSpawn.cleanupTuiMcpConfig(mcpCfgPath); + // Keep the session record. If claude exited (after running + // the initial prompt, or any other reason), the conversation + // is persisted at ~/.claude/projects/<cwd>/<uuid>.jsonl and + // the next open will spawn `claude --resume <uuid>` via the + // switch_session deferred-launch path. + var ds = sm.sessions.get(spawnedLocalId); + if (ds) { + ds.terminalId = null; + try { sm.saveSessionFile(ds); } catch (e) {} + try { sm.broadcastSessionList(); } catch (e) {} + } + }, + }); + if (spawnedTerm) newSess.terminalId = spawnedTerm.id; sm.saveSessionFile(newSess); sm.broadcastSessionList(); return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "tui" }); From 8133e7d43f5c6433a537872eb17f89e345c619a9 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 16:45:39 -0400 Subject: [PATCH 11/30] docs(spawn): note that title is auto-derived from /jira <KEY> The agent's title arg is now overridden by the first positional argument of any leading slash command (e.g. \"/jira GP-222\" -> the title becomes \"GP-222\"). Document this in the tool description so the agent passes the right shape and isn't surprised when its title arg gets replaced. --- lib/spawn-mcp-server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 0320fbe0..64881a26 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -75,7 +75,10 @@ function getToolDefs(onSpawn) { "Title: use the item's identifier (e.g. the JIRA key \"HARD-207\", the " + "GitHub issue number \"#1234\", etc.). Keep it short — it shows in the " + "sidebar. The title is marked as user-set and Clay's auto-title will not " + - "overwrite it.\n" + + "overwrite it. NOTE: when initial_prompt starts with a slash command and " + + "a positional argument (e.g. \"/jira GP-222\"), Clay overrides the title " + + "field with that argument automatically. Pass a reasonable title anyway " + + "as a fallback for free-form prompts.\n" + "\n" + "Initial prompt: pick the slash command the user's project uses to load " + "context for that item. If JIRA keys are mentioned, use \"/jira <KEY>\"; " + From ae1eccc7dde958552406006030eb4655f9e1274e Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 16:56:02 -0400 Subject: [PATCH 12/30] feat(spawn): default new sessions to plan mode + high effort Add permission_mode and effort args to spawn_session. Both default toward planning-shaped per-issue work: - permission_mode defaults to "plan" so each spawned session loads context with the slash command, produces a plan, and waits for the user before editing. - effort defaults to "high" so the planning pass reasons carefully. Wired into the TUI launch as --permission-mode <mode> --effort <level>. GUI sessions don't honor these args yet (the SDK adapter reads effort and permission mode from per-session state at startQuery time and there is no clean hook from the MCP onSpawn handler); spawned GUI sessions inherit the daemon-level defaults for now. The tool description for both args calls out the planning-shaped default and the override path, so non-planning spawns can opt out. --- lib/project.js | 4 +++- lib/spawn-mcp-server.js | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/project.js b/lib/project.js index e6756c6b..95a3fcde 100644 --- a/lib/project.js +++ b/lib/project.js @@ -551,7 +551,9 @@ function createProjectContext(opts) { var spawnedLocalId = newSess.localId; var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts); var cmd = "claude --session-id " + spawnedSid + - " -n " + tuiMcpForSpawn.shellQuote(title); + " -n " + tuiMcpForSpawn.shellQuote(title) + + " --permission-mode " + tuiMcpForSpawn.shellQuote(spawnArgs.permissionMode || "plan") + + " --effort " + tuiMcpForSpawn.shellQuote(spawnArgs.effort || "high"); if (mcpCfgPath) cmd += " --mcp-config " + tuiMcpForSpawn.shellQuote(mcpCfgPath); cmd += " -- " + tuiMcpForSpawn.shellQuote(spawnArgs.initialPrompt); cmd += "; exit\n"; diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 64881a26..f1797d2c 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -119,6 +119,27 @@ function getToolDefs(onSpawn) { "chat UI (Programmatic billing bucket). Defaults to \"tui\". Codex " + "vendor always runs in gui regardless of this arg.", }, + permission_mode: { + type: "string", + enum: ["plan", "default", "acceptEdits", "auto", "bypassPermissions", "dontAsk"], + description: + "Optional starting permission mode for the new session. Defaults " + + "to \"plan\" so per-issue sessions begin in plan mode — they load " + + "context with the slash command (e.g. /jira <KEY>), produce a " + + "plan, and wait for the user to approve before editing. Pass " + + "\"default\" for normal per-tool approval, \"acceptEdits\" to " + + "auto-approve file writes, or any other claude permission mode " + + "if the spawn isn't planning-shaped.", + }, + effort: { + type: "string", + enum: ["low", "medium", "high", "xhigh", "max"], + description: + "Optional reasoning effort level. Defaults to \"high\" so " + + "planning sessions reason carefully before producing a plan. " + + "Drop to \"medium\" or \"low\" for cheap/fast spawns; raise to " + + "\"xhigh\"/\"max\" for hard problems.", + }, }, ["title", "initial_prompt"]), handler: function (args) { var title = (args.title || "").trim(); @@ -127,6 +148,8 @@ function getToolDefs(onSpawn) { var mode = (args.mode === "gui" || args.mode === "tui") ? args.mode : "tui"; // Codex has no TUI adapter; force gui for codex regardless of arg. if (vendor === "codex") mode = "gui"; + var permissionMode = args.permission_mode || "plan"; + var effort = args.effort || "high"; if (!title) { return Promise.resolve({ @@ -142,7 +165,7 @@ function getToolDefs(onSpawn) { } return Promise.resolve() - .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor, mode: mode }); }) + .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor, mode: mode, permissionMode: permissionMode, effort: effort }); }) .then(function (result) { var sid = result && result.sessionId; var t = (result && result.title) || title; From 00d8cc63a1c219807dcd6549edca077706a425c2 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 18:17:24 -0400 Subject: [PATCH 13/30] feat(spawn): return localId + cliSessionId so dispatcher can track spawn_session previously returned a free-text "Spawned session #N \"T\"" line, which forced the dispatcher agent to parse the localId out of prose and gave it no way to refer back to the spawned session's claude UUID. Two consequences: - No clean handle to pass to future tools like mark_session_done, send_to_session, or any other operate-on-session helper. - No claude --resume target the dispatcher could surface to the user ("session #5 is GP-222, resume from the terminal with claude --resume <uuid>"). Return an additional content block per call: [clay-sessions/spawn_session] localId=42 cliSessionId=50a893... \ title="GP-222" mode=tui vendor=claude The summary line is kept for human-readable rendering. The onSpawn callback's resolved value now includes cliSessionId, mode, and vendor so the MCP wrapper can render the tracking line without re-deriving. TUI sessions have cliSessionId from spawn-time (we preassign); GUI sessions report cliSessionId=(pending) until the SDK emits init and sm.saveSessionFile updates the record. --- lib/project.js | 16 ++++++++++++++-- lib/spawn-mcp-server.js | 31 +++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/project.js b/lib/project.js index 95a3fcde..f3c71b48 100644 --- a/lib/project.js +++ b/lib/project.js @@ -579,7 +579,13 @@ function createProjectContext(opts) { if (spawnedTerm) newSess.terminalId = spawnedTerm.id; sm.saveSessionFile(newSess); sm.broadcastSessionList(); - return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "tui" }); + return Promise.resolve({ + sessionId: newSess.localId, + cliSessionId: newSess.cliSessionId, + title: newSess.title, + mode: "tui", + vendor: "claude", + }); } // --- GUI path: SDK-driven query (existing flow) --- @@ -595,7 +601,13 @@ function createProjectContext(opts) { onProcessingChanged(); sendToSession(newSess.localId, { type: "status", status: "processing" }); sdk.startQuery(newSess, spawnArgs.initialPrompt, null, ensureProjectAccessForSession(newSess)); - return Promise.resolve({ sessionId: newSess.localId, title: newSess.title, mode: "gui" }); + return Promise.resolve({ + sessionId: newSess.localId, + cliSessionId: newSess.cliSessionId || null, + title: newSess.title, + mode: "gui", + vendor: vendor, + }); }); var spawnMcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: spawnToolDefs }); if (spawnMcpConfig) servers[spawnMcpConfig.name || "clay-sessions"] = spawnMcpConfig; diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index f1797d2c..58bfa51c 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -93,7 +93,13 @@ function getToolDefs(onSpawn) { "for codex sessions (which always run gui).\n" + "\n" + "After spawning, briefly tell the user which sessions were created — do " + - "NOT switch the user's current view; they stay in the planning session.", + "NOT switch the user's current view; they stay in the planning session.\n" + + "\n" + + "Return: each call's tool result includes a [clay-sessions/spawn_session] " + + "tracking line with localId (Clay's per-daemon session number) and " + + "cliSessionId (the claude UUID). Keep these in mind so the dispatcher " + + "can refer back to specific sessions later (e.g. \"session #5 (GP-222) " + + "is the auth refactor\").", inputSchema: buildShape({ title: { type: "string", @@ -167,14 +173,27 @@ function getToolDefs(onSpawn) { return Promise.resolve() .then(function () { return onSpawn({ title: title, initialPrompt: initialPrompt, vendor: vendor, mode: mode, permissionMode: permissionMode, effort: effort }); }) .then(function (result) { - var sid = result && result.sessionId; + // Return a structured identifier line the dispatcher can parse out + // of context, plus a human-readable summary. The cliSessionId is + // the UUID claude uses internally (useful for later `claude + // --resume`); localId is Clay's per-daemon session number (the + // value other clay-sessions tools accept as `session_id`). + var localId = result && result.sessionId; + var cliSessionId = (result && result.cliSessionId) || null; var t = (result && result.title) || title; var m = (result && result.mode) || mode; + var v = (result && result.vendor) || vendor; + var summary = "Spawned " + m + " session #" + localId + " \"" + t + "\"."; + var trackingLine = "[clay-sessions/spawn_session] localId=" + localId + + " cliSessionId=" + (cliSessionId || "(pending)") + + " title=" + JSON.stringify(t) + + " mode=" + m + + " vendor=" + v; return { - content: [{ - type: "text", - text: "Spawned " + m + " session #" + sid + " \"" + t + "\". Initial prompt queued.", - }], + content: [ + { type: "text", text: summary }, + { type: "text", text: trackingLine }, + ], }; }) .catch(function (err) { From a885dcb5df51bcc9d3b17a1cf4065ad6b6a7ca9b Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 18:30:55 -0400 Subject: [PATCH 14/30] feat(sessions): add mark_session_done MCP tool Companion to spawn_session for closing out work. The user's /done skill (or any agent) calls mcp__clay-sessions__mark_session_done with an optional session_id; Clay prefixes the matching session's title with "done - " (idempotent) and broadcasts the sidebar update so the user sees the session visually marked. - session_id omitted: operates on the currently active session (the natural mode for /done invoked from inside the session being closed). - session_id from a dispatcher's tracked spawn_session call: closes a peer session without switching to it. - undo: true reverses the prefix to recover the original title. Title-origin flags carry over: titleManuallySet is set so Clay's auto-title pass cannot overwrite the marker. SDK rename is broadcast to claude so the prompt-box display name (-n) stays in sync. Tool result mirrors spawn_session's shape: a human-readable summary plus a [clay-sessions/mark_session_done] tracking line carrying localId / cliSessionId / new title / action / changed. The dispatcher keeps a structured handle to refer back to the affected session. --- lib/project.js | 42 +++++++++++++++++++- lib/spawn-mcp-server.js | 85 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/lib/project.js b/lib/project.js index f3c71b48..a00f02f9 100644 --- a/lib/project.js +++ b/lib/project.js @@ -525,6 +525,46 @@ function createProjectContext(opts) { var m = /^\s*\/[A-Za-z0-9_-]+\s+(\S+)/.exec(prompt); return m ? m[1].substring(0, 100) : null; } + var DONE_PREFIX = "done - "; + function onMarkDone(args) { + var sessionId = (args && typeof args.sessionId === "number") ? args.sessionId : null; + var target = sessionId != null ? sm.sessions.get(sessionId) : sm.getActiveSession(); + if (!target) { + return Promise.reject(new Error( + sessionId != null + ? ("No session with localId " + sessionId) + : "No active session to mark — pass session_id explicitly when calling from a dispatcher." + )); + } + var oldTitle = target.title || ""; + var hasPrefix = oldTitle.indexOf(DONE_PREFIX) === 0; + var newTitle; + var wasAlreadyDone = false; + if (args && args.undo) { + newTitle = hasPrefix ? oldTitle.substring(DONE_PREFIX.length) : oldTitle; + } else { + if (hasPrefix) { newTitle = oldTitle; wasAlreadyDone = true; } + else { newTitle = DONE_PREFIX + oldTitle; } + } + if (newTitle !== oldTitle) { + target.title = newTitle.substring(0, 100); + target.titleManuallySet = true; + sm.saveSessionFile(target); + sm.broadcastSessionList(); + if (target.cliSessionId && typeof adapter.renameSession === "function") { + adapter.renameSession(target.cliSessionId, target.title, { dir: cwd }).catch(function (e) { + console.error("[mark_session_done] SDK renameSession failed:", e.message || e); + }); + } + } + return Promise.resolve({ + sessionId: target.localId, + cliSessionId: target.cliSessionId || null, + oldTitle: oldTitle, + newTitle: target.title, + wasAlreadyDone: wasAlreadyDone, + }); + } var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { var vendor = spawnArgs.vendor || "claude"; var mode = (vendor === "claude" && spawnArgs.mode === "tui") ? "tui" : "gui"; @@ -608,7 +648,7 @@ function createProjectContext(opts) { mode: "gui", vendor: vendor, }); - }); + }, onMarkDone); var spawnMcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: spawnToolDefs }); if (spawnMcpConfig) servers[spawnMcpConfig.name || "clay-sessions"] = spawnMcpConfig; } catch (e) { diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 58bfa51c..5a39e4bb 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -35,11 +35,12 @@ function buildShape(props, required) { return shape; } -// onSpawn(args) -> Promise<{ sessionId, title }> -// args: { title, initial_prompt, vendor? } -// Throws (or rejects) for invalid input or runtime errors; the wrapper +// Callbacks: +// onSpawn(args) -> Promise<{ sessionId, cliSessionId?, title, mode, vendor }> +// onMarkDone({ sessionId?: number, undo?: boolean }) -> Promise<{ sessionId, cliSessionId?, oldTitle, newTitle, wasAlreadyDone }> +// Both throw (or reject) on invalid input or runtime errors; the wrapper // translates errors into an isError tool result so the calling agent sees them. -function getToolDefs(onSpawn) { +function getToolDefs(onSpawn, onMarkDone) { var tools = []; tools.push({ @@ -205,6 +206,82 @@ function getToolDefs(onSpawn) { }, }); + if (typeof onMarkDone === "function") { + tools.push({ + name: "mark_session_done", + description: + "Mark a Clay session as done by prefixing its title with \"done - \". " + + "Idempotent: a session that's already prefixed is left unchanged.\n" + + "\n" + + "USE THIS TOOL when the user signals work on the current session (or a " + + "specific session) is complete — e.g. \"/done\", \"that's done\", " + + "\"mark this one finished\", \"close out GP-222\". This is the Clay " + + "side of the workflow: the user's /done skill should also transition " + + "the corresponding JIRA ticket via the Atlassian MCP tools.\n" + + "\n" + + "session_id: optional Clay localId of the session to mark. Omit to " + + "mark the currently active session (works when the agent is calling " + + "from within the session being completed). Pass an explicit id from " + + "a dispatcher session to mark a peer session you tracked from " + + "spawn_session.\n" + + "\n" + + "undo: pass true to remove a previously applied \"done - \" prefix " + + "(restore the original title).", + inputSchema: buildShape({ + session_id: { + type: "number", + description: + "Clay localId of the session to mark (the same number returned " + + "by spawn_session as localId). Omit to use the currently active " + + "session.", + }, + undo: { + type: "boolean", + description: "Pass true to strip an existing \"done - \" prefix.", + }, + }, []), + handler: function (args) { + var sessionId = (typeof args.session_id === "number") ? args.session_id : null; + var undo = !!args.undo; + return Promise.resolve() + .then(function () { return onMarkDone({ sessionId: sessionId, undo: undo }); }) + .then(function (result) { + var localId = result && result.sessionId; + var oldTitle = (result && result.oldTitle) || ""; + var newTitle = (result && result.newTitle) || ""; + var alreadyDone = !!(result && result.wasAlreadyDone); + var verb = undo ? "Restored" : "Marked done"; + var changed = (oldTitle !== newTitle); + var summary; + if (!changed && alreadyDone && !undo) { + summary = "Session #" + localId + " already marked done (\"" + newTitle + "\")."; + } else if (!changed && undo) { + summary = "Session #" + localId + " was not marked done (\"" + newTitle + "\")."; + } else { + summary = verb + " session #" + localId + ": \"" + oldTitle + "\" -> \"" + newTitle + "\"."; + } + var trackingLine = "[clay-sessions/mark_session_done] localId=" + localId + + " cliSessionId=" + ((result && result.cliSessionId) || "(none)") + + " title=" + JSON.stringify(newTitle) + + " action=" + (undo ? "undo" : "done") + + " changed=" + changed; + return { + content: [ + { type: "text", text: summary }, + { type: "text", text: trackingLine }, + ], + }; + }) + .catch(function (err) { + return { + content: [{ type: "text", text: "Error marking session done: " + (err && err.message || err) }], + isError: true, + }; + }); + }, + }); + } + return tools; } From 9c640fbfacc0e6678bde3a33311369ebcaa307f4 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 20:33:53 -0400 Subject: [PATCH 15/30] docs(readme): document session fan-out, mark_session_done, and TUI MCP bridge Surfaces the new spawn_session / mark_session_done workflow and the TUI MCP bridge so users can find them without reading the source: - Top-level "What's Clay?" bullet now mentions fan-out alongside Ralph Loop and cron. - New "Session fan-out: one prompt, many sessions" section with the literal trigger sentence, the per-ticket title + auto-loaded /jira context + plan-mode-at-high-effort behaviour, and the /done close-out flow that flips JIRA status and prefixes the Clay sidebar entry. - MCP FAQ updated to list the new "sessions" built-in server and the TUI bridge that exposes Clay's in-app servers to the real claude CLI via --mcp-config. - Architecture diagram's Built-in MCP servers node updated to include "sessions" next to ask-user / browser / debate / email. --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66d4dabd..26732644 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ npx clay-server - **Your virtual team.** Mates with names, memory, and roles — architect, reviewer, designer, whoever you need. They learn your codebase, push back on bad ideas, and don't reset between sessions. - **A multi-project dashboard.** Every repo on your machine in one sidebar. Run agents across several in parallel; permission requests and completions surface as notifications. - **A self-hosted dev server.** Runs on your machine in plain JSONL and Markdown. No proprietary database, no cloud relay, no lock-in. Walk away whenever — your data walks with you. -- **A work-automation system.** Ralph Loop iterates a feature overnight; cron schedules agents while you're away. Wake up to results, or a clean failure trace. +- **A work-automation system.** Ralph Loop iterates a feature overnight; cron schedules agents while you're away. Spawn one session per JIRA ticket in a single prompt and let each one plan in parallel. Wake up to results, or a clean failure trace. ## What it does @@ -66,6 +66,18 @@ Stuck on REST vs GraphQL? Monorepo or split? Surface the question to a debate. P Detect existing git worktrees, spin up new ones from the sidebar, and run agents in each one independently. No more "wait, I have uncommitted changes." Each worktree is an isolated session with its own history. +### Session fan-out: one prompt, many sessions + +Plan a sprint in one chat, then turn the result into per-ticket sessions in a single sentence: + +> *"Let's work on GP-222 and GP-232 in clay please"* + +Clay spawns one session per work item in parallel, each titled with the issue key and pre-running your `/jira <KEY>` (or any other discovery skill). The sidebar fills with `GP-222`, `GP-232`, … each one a separate xterm or chat tab you can pick up later. Sessions launch in plan mode at high effort by default, so each one loads context and produces a plan before touching code — you walk back into ready-to-review proposals. + +When you finish a ticket, `/done` from inside that session flips the JIRA status and prefixes the Clay sidebar entry with `done - `, so progress is visible at a glance. + +Two MCP tools drive this — `spawn_session` (fan-out) and `mark_session_done` (close-out). Both work from GUI and TUI Claude sessions, because Clay's in-app MCP servers are bridged into the real `claude` CLI via `--mcp-config`. So the planning conversation can be a TUI session (subscription billing) and still command the rest of your workspace. + ### Ralph Loop: autonomous coding while you sleep Write a `PROMPT.md`, optionally a `JUDGE.md`, hit go. Clay iterates: code, evaluate, retry, until the judge approves or you cap the loop. Run it once, or schedule it on standard Unix cron. Wake up to a finished feature or a clean failure trace. @@ -153,7 +165,7 @@ No. Share one org-wide key, or let each user bring their own. On Linux with OS-l On Linux, opt in and Clay provisions each user as a real Linux account. File ACLs are enforced via `setfacl`, agent processes spawn under the user's UID/GID, and the kernel handles the rest. One teammate can't read another's project files, even by accident. The guarantee comes from the OS, not from a promise in our code. **"Does it work with MCP servers?"** -Yes. User-configured MCPs from `~/.clay/mcp.json` plus built-in browser, email, ask-user, and debate servers. All work in both Claude and Codex sessions. +Yes. User-configured MCPs from `~/.clay/mcp.json` plus built-in browser, email, ask-user, debate, and sessions (spawn / mark-done) servers. All work in Claude (GUI and TUI) and Codex sessions — TUI Claude sessions reach Clay's in-app servers through a stdio bridge launched via `--mcp-config`. **"Can I use it on my phone?"** Yes. Install as a PWA on iOS or Android. Push notifications for approvals, errors, and task completion. @@ -193,7 +205,7 @@ graph LR Server["HTTP / WS Server"] Project["Project Context"] YOKE["YOKE Adapter Layer"] - MCP["Built-in MCP servers<br/>ask-user / browser /<br/>debate / email"] + MCP["Built-in MCP servers<br/>ask-user / browser / debate /<br/>email / sessions"] Push["Push (VAPID)"] end From 85a720497d89e12e633bdaceb6b33e03669ea555 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 20:38:06 -0400 Subject: [PATCH 16/30] fix(tui): use visualViewport so iOS keyboard doesn't cover the xterm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI session host computed its bottom edge from window.innerHeight, which iOS Safari (and the PWA standalone runtime) leaves at the full viewport height even while the on-screen keyboard is up. The lower portion of the xterm slid behind the keyboard with no way to bring it back without dismissing input. Switch the bottom-edge calculation to window.visualViewport.offsetTop + visualViewport.height, which tracks the visible area above the keyboard and accounts for any layout shift iOS applies. Subscribe the view to visualViewport's resize and scroll events too — neither show up on the regular window resize listener — so the host re-fits and fitAddon recomputes cols/rows when the keyboard slides in or out. --- lib/public/modules/session-tui-view.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/public/modules/session-tui-view.js b/lib/public/modules/session-tui-view.js index 89760e20..aa3772ad 100644 --- a/lib/public/modules/session-tui-view.js +++ b/lib/public/modules/session-tui-view.js @@ -51,9 +51,13 @@ var webglAddon = null; var currentTermId = null; var resizeObserver = null; var windowResizeBound = false; +var viewportResizeBound = false; function onWindowResize() { if (currentTermId != null) fitNow(); } +function onViewportChange() { + if (currentTermId != null) fitNow(); +} function ensureHostEl() { if (hostEl) return hostEl; @@ -79,13 +83,17 @@ function syncHostBounds() { var messagesEl = document.getElementById("messages"); if (!messagesEl) return; var r = messagesEl.getBoundingClientRect(); - // Extend down to the bottom of the viewport so the empty band that used - // to sit below #messages (where #input-area lived before we hid it) gets - // covered by the same terminal background instead of showing through. + // Extend down to the bottom of the *visible* viewport — not the layout + // viewport — so the on-screen keyboard on iOS doesn't cover the xterm. + // window.innerHeight reports the full viewport even when the keyboard is + // up; visualViewport.height shrinks accordingly, and offsetTop captures + // the layout shift iOS applies when the keyboard pushes the page up. + var vv = window.visualViewport; + var visibleBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight; hostEl.style.top = r.top + "px"; hostEl.style.left = r.left + "px"; hostEl.style.width = r.width + "px"; - hostEl.style.height = (window.innerHeight - r.top) + "px"; + hostEl.style.height = Math.max(0, visibleBottom - r.top) + "px"; } function hideGuiChrome(hide) { @@ -218,6 +226,14 @@ export function attachTuiView(terminalId) { window.addEventListener("resize", onWindowResize); windowResizeBound = true; } + // iOS keyboard show/hide and address-bar collapse fire visualViewport + // events, not window resize. Subscribe to both resize and scroll on the + // visualViewport so the host re-fits above the keyboard. + if (!viewportResizeBound && window.visualViewport) { + window.visualViewport.addEventListener("resize", onViewportChange); + window.visualViewport.addEventListener("scroll", onViewportChange); + viewportResizeBound = true; + } } export function detachTuiView() { From f6a621dee3423fd43a8ccefdfea9cfc5540dbcf3 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 20:49:31 -0400 Subject: [PATCH 17/30] fix(sessions): pin MCP tool calls to the calling session, not the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /done from a TUI session marked the WRONG session done when the user switched sessions while claude was mid-/done (e.g. waiting on a JIRA transition). mark_session_done's fallback was sm.getActiveSession(), which returns whichever session the user is currently viewing — so by the time the tool fired, "active" had moved to the user's new session and got the "done - " prefix. Plumb the calling session's cliSessionId from the TUI bridge through to the MCP tool handler so it's deterministic: - lib/yoke/mcp-bridge-server.js: accept --session-id <uuid>, include it as callingCliSessionId in every call_tool POST body. - lib/tui-mcp-config.js: buildTuiMcpConfig now accepts a cliSessionId arg and adds --session-id to the bridge launch args. - lib/project-sessions.js + lib/project.js: all four TUI launch sites (new TUI session, runtime resume PTY, switch_session deferred re-open, spawn_session) pass the session's cliSessionId through. - lib/project-http.js: /api/mcp-bridge call_tool extracts callingCliSessionId from the body and injects it into the tool args as __callingCliSessionId. - lib/sessions.js: new findSessionByCliSessionId() helper. - lib/project.js onMarkDone: resolves the target in priority order — explicit session_id > __callingCliSessionId lookup > getActiveSession. GUI MCP tool calls don't go through the bridge, so they still fall back to getActiveSession; that path doesn't have the same async race because GUI tool calls are in-process and the session is identifiable from the SDK invocation context. A future change can plumb the calling session into GUI tool handlers too. --- lib/project-http.js | 5 +++++ lib/project-sessions.js | 6 +++--- lib/project.js | 14 ++++++++++++-- lib/sessions.js | 6 ++++++ lib/tui-mcp-config.js | 6 +++++- lib/yoke/mcp-bridge-server.js | 14 ++++++++++++-- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/project-http.js b/lib/project-http.js index 3af822ed..af98b8f5 100644 --- a/lib/project-http.js +++ b/lib/project-http.js @@ -713,6 +713,11 @@ function attachHTTP(ctx) { var server = body.server; var tool = body.tool; var args = body.args || {}; + // Pass the calling session through to session-aware tools. + // The TUI bridge sends callingCliSessionId so mark_session_done + // (and similar) can operate on the session that invoked them + // rather than the globally-active session. + if (body.callingCliSessionId) args.__callingCliSessionId = body.callingCliSessionId; console.log("[mcp-bridge-http] call_tool:", server + "/" + tool); handler.callTool(server, tool, args).then(function (result) { res.writeHead(200, { "Content-Type": "application/json" }); diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 5b284358..3001e270 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -125,7 +125,7 @@ function attachSessions(ctx) { } var sid = session.cliSessionId; var localId = session.localId; - var mcpConfigPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var mcpConfigPath = tuiMcp.buildTuiMcpConfig(slug, opts, sid); var cmd = "claude --resume " + sid; if (mcpConfigPath) cmd += " --mcp-config " + tuiMcp.shellQuote(mcpConfigPath); cmd += "; exit\n"; @@ -248,7 +248,7 @@ function attachSessions(ctx) { if (tm) { var tuiSid = newSess.cliSessionId; var tuiLocalId = newSess.localId; - var tuiMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var tuiMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts, tuiSid); var tuiCmd = "claude --session-id " + tuiSid; if (tuiMcpPath) tuiCmd += " --mcp-config " + tuiMcp.shellQuote(tuiMcpPath); tuiCmd += "; exit\n"; @@ -505,7 +505,7 @@ function attachSessions(ctx) { var dlSid = xmTarget.cliSessionId; var dlLocalId = xmTarget.localId; var dlTitle = xmTarget.title || "session"; - var dlMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts); + var dlMcpPath = tuiMcp.buildTuiMcpConfig(slug, opts, dlSid); var dlCmd; if (dlPrompt) { // First open of a freshly-spawned session: start a brand-new diff --git a/lib/project.js b/lib/project.js index a00f02f9..35fc2550 100644 --- a/lib/project.js +++ b/lib/project.js @@ -528,7 +528,17 @@ function createProjectContext(opts) { var DONE_PREFIX = "done - "; function onMarkDone(args) { var sessionId = (args && typeof args.sessionId === "number") ? args.sessionId : null; - var target = sessionId != null ? sm.sessions.get(sessionId) : sm.getActiveSession(); + var callingCliSessionId = (args && args.__callingCliSessionId) || null; + var target = null; + if (sessionId != null) { + target = sm.sessions.get(sessionId); + } else if (callingCliSessionId && typeof sm.findSessionByCliSessionId === "function") { + // The MCP bridge passed through the cliSessionId of the calling + // session — prefer it over getActiveSession() so a session + // switch mid-tool-call doesn't move the prefix. + target = sm.findSessionByCliSessionId(callingCliSessionId); + } + if (!target) target = sm.getActiveSession(); if (!target) { return Promise.reject(new Error( sessionId != null @@ -589,7 +599,7 @@ function createProjectContext(opts) { newSess.titleManuallySet = true; var spawnedSid = newSess.cliSessionId; var spawnedLocalId = newSess.localId; - var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts); + var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts, spawnedSid); var cmd = "claude --session-id " + spawnedSid + " -n " + tuiMcpForSpawn.shellQuote(title) + " --permission-mode " + tuiMcpForSpawn.shellQuote(spawnArgs.permissionMode || "plan") + diff --git a/lib/sessions.js b/lib/sessions.js index 6385a11c..c61a3f28 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -939,6 +939,12 @@ function createSessionManager(opts) { return total; }, saveSessionFile: saveSessionFile, + findSessionByCliSessionId: function (cliSessionId) { + if (!cliSessionId) return null; + var found = null; + sessions.forEach(function (s) { if (s.cliSessionId === cliSessionId) found = s; }); + return found; + }, appendToSessionFile: appendToSessionFile, sendAndRecord: doSendAndRecord, subscribeSession: function (localId, cb) { diff --git a/lib/tui-mcp-config.js b/lib/tui-mcp-config.js index 58df27d1..c88856d4 100644 --- a/lib/tui-mcp-config.js +++ b/lib/tui-mcp-config.js @@ -19,7 +19,7 @@ var crypto = require("crypto"); var BRIDGE_PATH = path.join(__dirname, "yoke", "mcp-bridge-server.js"); -function buildTuiMcpConfig(slug, opts) { +function buildTuiMcpConfig(slug, opts, cliSessionId) { if (!fs.existsSync(BRIDGE_PATH)) return null; var clayPort = (opts && opts.port) || 2633; var clayTls = !!(opts && opts.tls); @@ -27,6 +27,10 @@ function buildTuiMcpConfig(slug, opts) { var bridgeArgs = [BRIDGE_PATH, "--port", String(clayPort)]; if (slug) bridgeArgs.push("--slug", slug); + // Pin the bridge to the cliSessionId of the launching session so + // session-aware MCP tools can resolve "the calling session" without + // racing the user's active-session view. + if (cliSessionId) bridgeArgs.push("--session-id", cliSessionId); if (clayTls) bridgeArgs.push("--tls"); var serverEntry = { diff --git a/lib/yoke/mcp-bridge-server.js b/lib/yoke/mcp-bridge-server.js index a57b6440..efc15006 100644 --- a/lib/yoke/mcp-bridge-server.js +++ b/lib/yoke/mcp-bridge-server.js @@ -24,6 +24,7 @@ var args = process.argv.slice(2); var clayPort = 2633; var claySlug = ""; var clayTls = false; +var callingCliSessionId = ""; for (var i = 0; i < args.length; i++) { if (args[i] === "--port" && args[i + 1]) { @@ -32,6 +33,9 @@ for (var i = 0; i < args.length; i++) { } else if (args[i] === "--slug" && args[i + 1]) { claySlug = args[i + 1]; i++; + } else if (args[i] === "--session-id" && args[i + 1]) { + callingCliSessionId = args[i + 1]; + i++; } else if (args[i] === "--tls") { clayTls = true; } @@ -132,12 +136,18 @@ function fetchTools() { // --- Call a tool via Clay --- function callTool(serverName, toolName, args) { - return postJson(CLAY_MCP_PATH, { + var body = { action: "call_tool", server: serverName, tool: toolName, args: args || {}, - }); + }; + // Pass the calling session's cliSessionId so session-aware tools + // (e.g. mark_session_done) operate on the session that invoked them + // rather than whatever session the user happens to be viewing at + // the moment Clay's handler runs. + if (callingCliSessionId) body.callingCliSessionId = callingCliSessionId; + return postJson(CLAY_MCP_PATH, body); } // --- JSON-RPC stdio protocol --- From 0259186f527e2353f749a40e93d2a75e43c6a46e Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 20:52:41 -0400 Subject: [PATCH 18/30] feat(tui): drag-drop and + button for attaching files to TUI sessions Two ways to hand a file's server-side path to the TUI's claude CLI: - Drag-and-drop onto the xterm host (desktop). A dashed outline appears while a file is dragged over. - A floating "+" button at the bottom-right of the host, sized for touch. On iOS it opens the native picker which offers both Photo Library and Files; on Android the equivalent picker. Both paths read each selected/dropped file as base64, POST it to the existing /api/upload endpoint (which writes it to /tmp/clay-<hash>/ and returns an absolute path on the server), then send the space-separated paths to the PTY as a single term_input frame. The paths arrive at claude's prompt exactly as if the user had typed them, so they append to whatever the user was composing and can be referenced (e.g. claude Read, attach as image) per its usual rules. Drag and drop events aren't fired on touch devices, so mobile uses the + button only. The button sits inside hostEl, which is sized above the iOS keyboard via visualViewport, so it stays reachable when the keyboard is up. Uploads run in parallel; paths are batched into one PTY write so they appear at one cursor position rather than racing as each upload completes. --- lib/public/modules/session-tui-view.js | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/lib/public/modules/session-tui-view.js b/lib/public/modules/session-tui-view.js index aa3772ad..66781348 100644 --- a/lib/public/modules/session-tui-view.js +++ b/lib/public/modules/session-tui-view.js @@ -75,9 +75,159 @@ function ensureHostEl() { hostEl.style.overflow = "hidden"; hostEl.style.boxSizing = "border-box"; document.body.appendChild(hostEl); + installFileAttachUI(hostEl); return hostEl; } +// --- File attach: + button and drag-drop --- +// Both paths POST the file bytes to /api/upload, which saves them to a +// per-cwd /tmp/clay-<hash>/ directory and returns an absolute path on +// the server. We then write the path(s) into the PTY as if the user +// typed them — claude reads them as part of its current prompt buffer +// and can use Read on them or attach as images per its usual rules. + +var MAX_TUI_UPLOAD_BYTES = 50 * 1024 * 1024; // matches MAX_UPLOAD_BYTES in /api/upload + +function installFileAttachUI(host) { + // Hidden file input — driven by the + button. accept="*/*" so iOS + // and Android pickers offer both Photos and Files; the user can pick + // any kind of file. + var fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.style.position = "absolute"; + fileInput.style.left = "-9999px"; + fileInput.style.opacity = "0"; + fileInput.style.pointerEvents = "none"; + fileInput.addEventListener("change", function () { + var files = Array.from(fileInput.files || []); + fileInput.value = ""; // reset so re-selecting the same file fires change again + handleAttachFiles(files); + }); + host.appendChild(fileInput); + + // Floating + button at bottom-right of the TUI host. Sized for touch. + // Sits above the iOS keyboard because the host itself is sized via + // visualViewport (see syncHostBounds). + var addBtn = document.createElement("button"); + addBtn.id = "tui-attach-btn"; + addBtn.type = "button"; + addBtn.setAttribute("aria-label", "Attach files"); + addBtn.title = "Attach files"; + addBtn.textContent = "+"; + addBtn.style.position = "absolute"; + addBtn.style.right = "12px"; + addBtn.style.bottom = "12px"; + addBtn.style.width = "44px"; + addBtn.style.height = "44px"; + addBtn.style.borderRadius = "50%"; + addBtn.style.border = "1px solid #444"; + addBtn.style.background = "rgba(40, 40, 40, 0.85)"; + addBtn.style.color = "#e5e5e5"; + addBtn.style.fontSize = "24px"; + addBtn.style.lineHeight = "1"; + addBtn.style.fontWeight = "300"; + addBtn.style.cursor = "pointer"; + addBtn.style.zIndex = "10"; + addBtn.style.display = "flex"; + addBtn.style.alignItems = "center"; + addBtn.style.justifyContent = "center"; + addBtn.style.padding = "0"; + addBtn.style.userSelect = "none"; + addBtn.style.webkitTapHighlightColor = "transparent"; + addBtn.addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + fileInput.click(); + }); + host.appendChild(addBtn); + + // Drag-and-drop on the host. Touch devices don't fire drag events, + // so this is desktop-only — the + button is the mobile path. + host.addEventListener("dragover", function (e) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + host.style.outline = "2px dashed #4a90e2"; + host.style.outlineOffset = "-4px"; + }); + host.addEventListener("dragleave", function (e) { + if (e.target === host) { + host.style.outline = ""; + host.style.outlineOffset = ""; + } + }); + host.addEventListener("drop", function (e) { + e.preventDefault(); + host.style.outline = ""; + host.style.outlineOffset = ""; + var files = Array.from((e.dataTransfer && e.dataTransfer.files) || []); + handleAttachFiles(files); + }); +} + +function handleAttachFiles(files) { + if (!files || files.length === 0) return; + if (currentTermId == null) return; + var ws = getWs(); + if (!ws || ws.readyState !== 1) return; + var ups = files.map(uploadOneFile); + Promise.allSettled(ups).then(function (results) { + var paths = []; + var failed = []; + for (var i = 0; i < results.length; i++) { + if (results[i].status === "fulfilled" && results[i].value) { + paths.push(results[i].value); + } else { + var name = (files[i] && files[i].name) || "file"; + failed.push(name); + } + } + if (paths.length > 0) { + var text = " " + paths.join(" ") + " "; + var ws2 = getWs(); + if (ws2 && ws2.readyState === 1 && currentTermId != null) { + ws2.send(JSON.stringify({ type: "term_input", id: currentTermId, data: text })); + } + } + if (failed.length > 0) { + console.error("[tui-attach] upload failed:", failed.join(", ")); + } + }); +} + +function uploadOneFile(file) { + return new Promise(function (resolve, reject) { + if (!file) { reject(new Error("no file")); return; } + if (file.size > MAX_TUI_UPLOAD_BYTES) { + reject(new Error("File too large (max 50MB): " + file.name)); + return; + } + var reader = new FileReader(); + reader.onload = function (ev) { + var dataUrl = ev.target.result || ""; + var commaIdx = String(dataUrl).indexOf(","); + var b64 = commaIdx !== -1 ? String(dataUrl).substring(commaIdx + 1) : ""; + var xhr = new XMLHttpRequest(); + xhr.open("POST", "api/upload"); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.onload = function () { + if (xhr.status === 200) { + try { + var resp = JSON.parse(xhr.responseText); + resolve(resp && resp.path); + } catch (e) { reject(e); } + } else { + reject(new Error("Upload HTTP " + xhr.status)); + } + }; + xhr.onerror = function () { reject(new Error("Upload network error")); }; + xhr.send(JSON.stringify({ name: file.name, data: b64 })); + }; + reader.onerror = function () { reject(new Error("Could not read file")); }; + reader.readAsDataURL(file); + }); +} + function syncHostBounds() { if (!hostEl) return; var messagesEl = document.getElementById("messages"); From e2d990f4595ca9aaa69a0e3ac011fbe639dd2c9f Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Tue, 19 May 2026 21:46:47 -0400 Subject: [PATCH 19/30] feat(sessions): add rename_session MCP tool for dynamic session titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn_session derives the session title from the leading slash command's first positional arg ("/jira GP-222" -> "GP-222"), which keeps the sidebar clean but loses context. A skill that has just fetched the issue summary now has a way to refine the title to something like "GP-222 - Implement OAuth refresh". rename_session({title, session_id?}) sets the title on a session. session_id is optional; omitted it routes to the calling session via the same __callingCliSessionId path mark_session_done already uses, so the rename always hits the session that invoked the tool — not the user's globally-active view. Refactored onMarkDone to share resolveTargetSession() with the new onRename so both apply the same priority: explicit session_id > __callingCliSessionId > getActiveSession(). The new title is marked titleManuallySet=true so Clay's auto-title pass at AUTO_TITLE_TURN_THRESHOLD doesn't overwrite it; the SDK rename is broadcast so the claude prompt-box display name stays in sync. --- lib/project.js | 54 +++++++++++++++++++------- lib/spawn-mcp-server.js | 86 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/lib/project.js b/lib/project.js index 35fc2550..2aba3009 100644 --- a/lib/project.js +++ b/lib/project.js @@ -526,24 +526,52 @@ function createProjectContext(opts) { return m ? m[1].substring(0, 100) : null; } var DONE_PREFIX = "done - "; - function onMarkDone(args) { + function resolveTargetSession(args) { var sessionId = (args && typeof args.sessionId === "number") ? args.sessionId : null; var callingCliSessionId = (args && args.__callingCliSessionId) || null; - var target = null; if (sessionId != null) { - target = sm.sessions.get(sessionId); - } else if (callingCliSessionId && typeof sm.findSessionByCliSessionId === "function") { - // The MCP bridge passed through the cliSessionId of the calling - // session — prefer it over getActiveSession() so a session - // switch mid-tool-call doesn't move the prefix. - target = sm.findSessionByCliSessionId(callingCliSessionId); + var bySid = sm.sessions.get(sessionId); + if (bySid) return bySid; + } + if (callingCliSessionId && typeof sm.findSessionByCliSessionId === "function") { + var byCli = sm.findSessionByCliSessionId(callingCliSessionId); + if (byCli) return byCli; + } + return sm.getActiveSession(); + } + function onRename(args) { + var target = resolveTargetSession(args); + if (!target) { + return Promise.reject(new Error( + "No session to rename — pass session_id explicitly or call from inside a session." + )); } - if (!target) target = sm.getActiveSession(); + var oldTitle = target.title || ""; + var newTitle = String(args.title || "").trim().substring(0, 100); + if (!newTitle) return Promise.reject(new Error("title must be non-empty")); + if (newTitle !== oldTitle) { + target.title = newTitle; + target.titleManuallySet = true; + sm.saveSessionFile(target); + sm.broadcastSessionList(); + if (target.cliSessionId && typeof adapter.renameSession === "function") { + adapter.renameSession(target.cliSessionId, target.title, { dir: cwd }).catch(function (e) { + console.error("[rename_session] SDK renameSession failed:", e.message || e); + }); + } + } + return Promise.resolve({ + sessionId: target.localId, + cliSessionId: target.cliSessionId || null, + oldTitle: oldTitle, + newTitle: target.title, + }); + } + function onMarkDone(args) { + var target = resolveTargetSession(args); if (!target) { return Promise.reject(new Error( - sessionId != null - ? ("No session with localId " + sessionId) - : "No active session to mark — pass session_id explicitly when calling from a dispatcher." + "No session to mark — pass session_id explicitly or call from inside a session." )); } var oldTitle = target.title || ""; @@ -658,7 +686,7 @@ function createProjectContext(opts) { mode: "gui", vendor: vendor, }); - }, onMarkDone); + }, onMarkDone, onRename); var spawnMcpConfig = adapter.createToolServer({ name: "clay-sessions", version: "1.0.0", tools: spawnToolDefs }); if (spawnMcpConfig) servers[spawnMcpConfig.name || "clay-sessions"] = spawnMcpConfig; } catch (e) { diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index 5a39e4bb..d745fda8 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -38,9 +38,10 @@ function buildShape(props, required) { // Callbacks: // onSpawn(args) -> Promise<{ sessionId, cliSessionId?, title, mode, vendor }> // onMarkDone({ sessionId?: number, undo?: boolean }) -> Promise<{ sessionId, cliSessionId?, oldTitle, newTitle, wasAlreadyDone }> -// Both throw (or reject) on invalid input or runtime errors; the wrapper +// onRename({ sessionId?: number, title: string }) -> Promise<{ sessionId, cliSessionId?, oldTitle, newTitle }> +// All three throw (or reject) on invalid input or runtime errors; the wrapper // translates errors into an isError tool result so the calling agent sees them. -function getToolDefs(onSpawn, onMarkDone) { +function getToolDefs(onSpawn, onMarkDone, onRename) { var tools = []; tools.push({ @@ -206,6 +207,87 @@ function getToolDefs(onSpawn, onMarkDone) { }, }); + if (typeof onRename === "function") { + tools.push({ + name: "rename_session", + description: + "Set the title of a Clay session. The title shows in the sidebar and " + + "in the claude CLI prompt-box display name. Idempotent; setting the " + + "same title twice is a no-op.\n" + + "\n" + + "Typical use: a slash command like /jira fetches issue details and " + + "renames the current session to something like \"GP-222 - Implement " + + "OAuth refresh\" so the sidebar entry carries a short description " + + "alongside the key. Spawn-time titles are deliberately just the key " + + "(the auto-derivation from \"/jira <KEY>\"), so the slash command is " + + "expected to refine the title once it has the issue summary.\n" + + "\n" + + "session_id is optional. Omit it to rename the currently active " + + "session (or, when called from a TUI bridge, the session that " + + "invoked the tool). Pass an explicit localId from a dispatcher to " + + "rename a peer session you tracked from spawn_session.\n" + + "\n" + + "The new title is marked as user-set so Clay's auto-title pass will " + + "not overwrite it.", + inputSchema: buildShape({ + title: { + type: "string", + description: "New title for the session (truncated to 100 chars).", + }, + session_id: { + type: "number", + description: + "Clay localId of the session to rename. Omit for the calling " + + "session.", + }, + }, ["title"]), + handler: function (args) { + var title = (args && typeof args.title === "string") ? args.title.trim() : ""; + if (!title) { + return Promise.resolve({ + content: [{ type: "text", text: "Error: title is required and must be non-empty." }], + isError: true, + }); + } + var sessionId = (args && typeof args.session_id === "number") ? args.session_id : null; + var callingCliSessionId = (args && args.__callingCliSessionId) || null; + return Promise.resolve() + .then(function () { + return onRename({ + sessionId: sessionId, + title: title, + __callingCliSessionId: callingCliSessionId, + }); + }) + .then(function (result) { + var localId = result && result.sessionId; + var oldTitle = (result && result.oldTitle) || ""; + var newTitle = (result && result.newTitle) || title; + var changed = (oldTitle !== newTitle); + var summary = changed + ? ("Renamed session #" + localId + ": \"" + oldTitle + "\" -> \"" + newTitle + "\".") + : ("Session #" + localId + " title unchanged (already \"" + newTitle + "\")."); + var trackingLine = "[clay-sessions/rename_session] localId=" + localId + + " cliSessionId=" + ((result && result.cliSessionId) || "(none)") + + " title=" + JSON.stringify(newTitle) + + " changed=" + changed; + return { + content: [ + { type: "text", text: summary }, + { type: "text", text: trackingLine }, + ], + }; + }) + .catch(function (err) { + return { + content: [{ type: "text", text: "Error renaming session: " + (err && err.message || err) }], + isError: true, + }; + }); + }, + }); + } + if (typeof onMarkDone === "function") { tools.push({ name: "mark_session_done", From 96061b76594fc935f6d3e5d778ce7a445b128b28 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 09:07:39 -0400 Subject: [PATCH 20/30] fix(sidebar): suppress per-row TUI badge Now that claudeOpenMode defaults to tui and spawn_session creates TUI sessions by default, the "terminal" badge appeared on basically every sidebar row and stopped carrying signal. Removing it matches what the session list already implicitly conveys. The reload-then-icon-disappears asymmetry the user noticed (mode wasn't persisted before, then was, so badges came and went across restarts) also goes away with the badge. --- lib/public/modules/sidebar-sessions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index f89074d5..cdfa9c82 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -1134,9 +1134,9 @@ function renderSessionItem(s) { if (store.get('isMultiUserMode') && s.sessionVisibility === "private") { textHtml += '<span class="session-private-icon" title="Private session">' + iconHtml("lock") + '</span>'; } - if (s.mode === "tui") { - textHtml += '<span class="session-tui-icon" title="Claude Code terminal session">' + iconHtml("terminal") + '</span>'; - } + // Previously rendered a "terminal" badge next to TUI session titles. + // Everything in Clay now runs as TUI by default (per claudeOpenMode), + // so the badge would just be noise on every row; suppress it. textHtml += highlightMatch(s.title || "New Session", searchQuery); textSpan.innerHTML = textHtml; el.appendChild(textSpan); From aa2b2aeb6441192d098d4bd28fb4fb6264d0ceee Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 09:26:00 -0400 Subject: [PATCH 21/30] fix(import-cli): union FS scan with SDK listSessions so all conversations show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Import CLI picker called adapter.listSessions(cwd) and only fell back to the FS-based parser when the SDK call threw. In practice the SDK's listSessions returns a strict subset of what's on disk for some projects — conversations claude --resume happily finds via direct .jsonl scan never appeared in Clay's Import CLI even though they weren't tracked by Clay yet. Users couldn't bring orphaned claude sessions back into Clay's sidebar. Union the two sources: always run cli-sessions.listCliSessions (which parses every .jsonl in ~/.claude/projects/<encoded-cwd>/), then overlay richer metadata from the SDK where present. The filter that hides sessions already known to Clay still runs. After this change, anything `claude --resume` can resume is reachable from Clay's Import CLI too. In particular: spawn-created TUI sessions whose Clay record was wiped by older deletion-on-exit code (before 7d0c006) can be recovered by clicking them in Import CLI. --- lib/project-sessions.js | 70 +++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 3001e270..78f3bfa5 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -449,31 +449,53 @@ function attachSessions(ctx) { } } catch (e) {} - adapter.listSessions({ dir: cwd }).then(function(sdkSessions) { - var filtered = sdkSessions.filter(function(s) { - return !relayIds[s.sessionId]; - }).map(function(s) { - return { - sessionId: s.sessionId, - firstPrompt: s.summary || s.firstPrompt || "", - model: null, - gitBranch: s.gitBranch || null, - startTime: s.createdAt ? new Date(s.createdAt).toISOString() : null, - lastActivity: s.lastModified ? new Date(s.lastModified).toISOString() : null, - }; - }); - sendTo(ws, { type: "cli_session_list", sessions: filtered }); - }).catch(function() { - // Fallback to manual parsing if SDK fails - var cliSessions = require("./cli-sessions"); - cliSessions.listCliSessions(cwd).then(function(sessions) { - var filtered = sessions.filter(function(s) { - return !relayIds[s.sessionId]; - }); - sendTo(ws, { type: "cli_session_list", sessions: filtered }); - }).catch(function() { - sendTo(ws, { type: "cli_session_list", sessions: [] }); + // Union of two sources, because the SDK's listSessions returns a + // subset of what's on disk for some projects (sessions claude --resume + // happily finds via direct .jsonl scan don't always come back from + // the SDK). We always scan the filesystem and merge any SDK metadata + // on top, so Import CLI shows everything claude itself can resume. + var cliSessionsMod = require("./cli-sessions"); + var fsScan = cliSessionsMod.listCliSessions(cwd).catch(function () { return []; }); + var sdkScan = adapter.listSessions({ dir: cwd }).catch(function () { return []; }); + Promise.all([fsScan, sdkScan]).then(function (pair) { + var fsList = pair[0] || []; + var sdkList = pair[1] || []; + var sdkById = {}; + for (var si = 0; si < sdkList.length; si++) { + if (sdkList[si] && sdkList[si].sessionId) sdkById[sdkList[si].sessionId] = sdkList[si]; + } + var merged = {}; + // Seed from FS scan (authoritative for existence). + for (var fi = 0; fi < fsList.length; fi++) { + if (fsList[fi] && fsList[fi].sessionId) merged[fsList[fi].sessionId] = fsList[fi]; + } + // Add any SDK-only entries we haven't seen (shouldn't normally happen). + for (var sj = 0; sj < sdkList.length; sj++) { + var s2 = sdkList[sj]; + if (s2 && s2.sessionId && !merged[s2.sessionId]) merged[s2.sessionId] = s2; + } + // Overlay richer SDK fields (summary, gitBranch, etc.) where present. + var ids = Object.keys(merged); + var combined = []; + for (var ki = 0; ki < ids.length; ki++) { + var entry = merged[ids[ki]]; + var sdkEntry = sdkById[ids[ki]]; + if (sdkEntry) { + entry = Object.assign({}, entry, { + firstPrompt: (sdkEntry.summary || sdkEntry.firstPrompt || entry.firstPrompt || ""), + gitBranch: sdkEntry.gitBranch || entry.gitBranch || null, + startTime: sdkEntry.createdAt ? new Date(sdkEntry.createdAt).toISOString() : entry.startTime, + lastActivity: sdkEntry.lastModified ? new Date(sdkEntry.lastModified).toISOString() : entry.lastActivity, + }); + } + if (!relayIds[entry.sessionId]) combined.push(entry); + } + combined.sort(function (a, b) { + var ta = a.lastActivity || ""; + var tb = b.lastActivity || ""; + return ta < tb ? 1 : ta > tb ? -1 : 0; }); + sendTo(ws, { type: "cli_session_list", sessions: combined }); }); return true; } From e0ee621a8a207fa6f6f8990b21104629f8992de8 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 09:31:09 -0400 Subject: [PATCH 22/30] feat(import-cli): "Show all" toggle gates the wider FS scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default behaviour for Import CLI is back to the original — list the sessions the Agent SDK enumerates for this cwd, hiding any UUIDs Clay already tracks. Quiet, fast. Add a "Show all" checkbox in the picker that, when checked, also scans the filesystem at ~/.claude/projects/<encoded-cwd>/ and unions the results. That surfaces conversations the SDK doesn't list — typically orphaned sessions (e.g. TUI records wiped by pre-7d0c006 deletion-on-exit code) — so the user can recover them. Server: list_cli_sessions accepts an optional show_all flag and runs the filesystem scan only when true. Client: HTML checkbox, JS that re-fires list_cli_sessions on toggle, small CSS to host the new controls row. --- lib/project-sessions.js | 20 ++++++++++++-------- lib/public/css/overlays.css | 20 ++++++++++++++++++++ lib/public/index.html | 5 +++++ lib/public/modules/sidebar-sessions.js | 18 +++++++++++++++--- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 78f3bfa5..27b1943b 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -449,13 +449,17 @@ function attachSessions(ctx) { } } catch (e) {} - // Union of two sources, because the SDK's listSessions returns a - // subset of what's on disk for some projects (sessions claude --resume - // happily finds via direct .jsonl scan don't always come back from - // the SDK). We always scan the filesystem and merge any SDK metadata - // on top, so Import CLI shows everything claude itself can resume. + // Default source is the Agent SDK's listSessions, which returns the + // sessions claude has explicitly indexed. The "show_all" flag opts + // into a wider filesystem scan that also surfaces conversations the + // SDK doesn't enumerate (orphans whose Clay record was wiped by old + // deletion-on-exit code, sessions from before claude's session + // indexing, etc.) — useful for recovery, noisy by default. + var showAll = !!msg.show_all; var cliSessionsMod = require("./cli-sessions"); - var fsScan = cliSessionsMod.listCliSessions(cwd).catch(function () { return []; }); + var fsScan = showAll + ? cliSessionsMod.listCliSessions(cwd).catch(function () { return []; }) + : Promise.resolve([]); var sdkScan = adapter.listSessions({ dir: cwd }).catch(function () { return []; }); Promise.all([fsScan, sdkScan]).then(function (pair) { var fsList = pair[0] || []; @@ -465,11 +469,11 @@ function attachSessions(ctx) { if (sdkList[si] && sdkList[si].sessionId) sdkById[sdkList[si].sessionId] = sdkList[si]; } var merged = {}; - // Seed from FS scan (authoritative for existence). + // Seed with FS scan when showAll is set (authoritative for existence). for (var fi = 0; fi < fsList.length; fi++) { if (fsList[fi] && fsList[fi].sessionId) merged[fsList[fi].sessionId] = fsList[fi]; } - // Add any SDK-only entries we haven't seen (shouldn't normally happen). + // Always include SDK entries. for (var sj = 0; sj < sdkList.length; sj++) { var s2 = sdkList[sj]; if (s2 && s2.sessionId && !merged[s2.sessionId]) merged[s2.sessionId] = s2; diff --git a/lib/public/css/overlays.css b/lib/public/css/overlays.css index 1bd70ba7..e6d6b1e4 100644 --- a/lib/public/css/overlays.css +++ b/lib/public/css/overlays.css @@ -864,6 +864,26 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc .resume-picker-dialog { min-width: 380px; max-width: 480px; } .resume-picker-body { margin-bottom: 12px; } +.resume-picker-controls { + margin: 0 0 10px 0; + padding: 6px 8px; + border-radius: 6px; + background: var(--surface-sunken, rgba(0, 0, 0, 0.04)); +} +.resume-picker-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + user-select: none; +} +.resume-picker-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + .resume-picker-loading { display: flex; align-items: center; gap: 10px; padding: 24px 0; justify-content: center; diff --git a/lib/public/index.html b/lib/public/index.html index eac84803..f942560d 100644 --- a/lib/public/index.html +++ b/lib/public/index.html @@ -1526,6 +1526,11 @@ <h2>Email</h2> <div class="confirm-backdrop"></div> <div class="confirm-dialog resume-picker-dialog"> <div class="resume-modal-title">Resume CLI session</div> + <div class="resume-picker-controls"> + <label class="resume-picker-toggle"> + <input type="checkbox" id="resume-show-all"> Show all (include conversations the Agent SDK doesn't list) + </label> + </div> <div class="resume-picker-body"> <div id="resume-picker-loading" class="resume-picker-loading"> <div class="resume-picker-spinner"></div> diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index cdfa9c82..a8427962 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -551,16 +551,25 @@ export function initSidebarSessions() { var pickerEmpty = document.getElementById("resume-picker-empty"); var pickerList = document.getElementById("resume-picker-list"); - function openResumeModal() { - resumeModal.classList.remove("hidden"); + var resumeShowAll = document.getElementById("resume-show-all"); + + function requestList() { pickerLoading.classList.remove("hidden"); pickerEmpty.classList.add("hidden"); pickerList.classList.add("hidden"); pickerList.innerHTML = ""; if (getWs() && store.get('connected')) { - getWs().send(JSON.stringify({ type: "list_cli_sessions" })); + getWs().send(JSON.stringify({ + type: "list_cli_sessions", + show_all: !!(resumeShowAll && resumeShowAll.checked), + })); } } + + function openResumeModal() { + resumeModal.classList.remove("hidden"); + requestList(); + } openResumePickerModal = openResumeModal; function closeResumeModal() { @@ -569,6 +578,9 @@ export function initSidebarSessions() { resumeCancel.addEventListener("click", closeResumeModal); resumeModal.querySelector(".confirm-backdrop").addEventListener("click", closeResumeModal); + if (resumeShowAll) { + resumeShowAll.addEventListener("change", requestList); + } // --- Schedule countdown timer --- startCountdownTimer(); From 12ed1acbd9ec33fabb7663a4a0b15f0c88cc5212 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 09:43:34 -0400 Subject: [PATCH 23/30] fix(sidebar): keep TUI session lastActivity current on open + input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI sessions don't route their typing through Clay (claude writes the jsonl to its own per-cwd store), so once the meta file was saved at spawn time the sidebar timestamp never moved — a session created yesterday and used heavily today still rendered as "yesterday". Two new fresh-activity signals: - switch_session: forced bump on open. Clicking into a session is a strong "I'm using this now" signal regardless of whether the user goes on to type. Forced persistence so the sidebar reorders right away. - term_input: bump per keystroke / chunk written to the TUI's PTY, throttled to one meta-file write per 60 seconds per session so we don't thrash disk on every byte. sessions.js gets two helpers: findSessionByTerminalId (the term_input handler doesn't otherwise know which session owns a terminal) and bumpSessionActivity(localId, {force}) which centralises the throttle. GUI sessions still update via the existing message-flow code paths; this change only fixes the gap for TUI. --- lib/project-sessions.js | 6 ++++++ lib/project-user-message.js | 11 ++++++++++- lib/sessions.js | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 27b1943b..f06a9f28 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -626,6 +626,12 @@ function attachSessions(ctx) { ws._clayActiveSession = msg.id; sm.switchSession(msg.id, ws, hydrateImageRefs); } + // Opening a session is a fresh activity signal — bump lastActivity + // so the sidebar moves it to "today" even if no further input + // arrives. TUI sessions don't otherwise route their typing through + // Clay's history (claude writes the jsonl directly), so this is + // the primary mechanism to keep their timestamp current. + try { sm.bumpSessionActivity(msg.id, { force: true }); } catch (e) {} // Send per-session context sources if (typeof loadContextSources === "function") { var switchedSources = loadContextSources(slug, msg.id); diff --git a/lib/project-user-message.js b/lib/project-user-message.js index b1600820..6709ab74 100644 --- a/lib/project-user-message.js +++ b/lib/project-user-message.js @@ -167,7 +167,16 @@ function attachUserMessage(ctx) { } if (msg.type === "term_input") { - if (msg.id) tm.write(msg.id, msg.data); + if (msg.id) { + tm.write(msg.id, msg.data); + // Treat any keystroke heading into a TUI session's PTY as fresh + // activity for that session. Throttled inside bumpSessionActivity + // so we don't write the meta file on every byte. + if (typeof sm.findSessionByTerminalId === "function") { + var _ts = sm.findSessionByTerminalId(msg.id); + if (_ts) sm.bumpSessionActivity(_ts.localId); + } + } return true; } diff --git a/lib/sessions.js b/lib/sessions.js index c61a3f28..5814d51a 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -945,6 +945,30 @@ function createSessionManager(opts) { sessions.forEach(function (s) { if (s.cliSessionId === cliSessionId) found = s; }); return found; }, + findSessionByTerminalId: function (terminalId) { + if (typeof terminalId !== "number") return null; + var found = null; + sessions.forEach(function (s) { + if (s.terminalId === terminalId || s.runtimeTerminalId === terminalId) found = s; + }); + return found; + }, + // Bump lastActivity. Persistence/broadcast is throttled per-session + // (default 60s) so high-frequency callers (PTY input) don't write the + // meta jsonl on every keystroke. Pass {force: true} to persist + // immediately (e.g. on user session open). + bumpSessionActivity: function (localId, optsOrUndef) { + var s = sessions.get(localId); + if (!s) return; + var force = !!(optsOrUndef && optsOrUndef.force); + var now = Date.now(); + s.lastActivity = now; + if (force || !s._lastActivityPersistedAt || (now - s._lastActivityPersistedAt) > 60000) { + s._lastActivityPersistedAt = now; + saveSessionFile(s); + broadcastSessionList(); + } + }, appendToSessionFile: appendToSessionFile, sendAndRecord: doSendAndRecord, subscribeSession: function (localId, cb) { From 8d8df800e247bc01b46271b94053e757f2417609 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 09:45:07 -0400 Subject: [PATCH 24/30] fix(sidebar): drop click-to-bump; only real input keeps lastActivity current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping on session open meant a user just browsing through TUIs would mark them all "today" even though they did nothing. Per user request, revert that part — only PTY input (term_input) bumps lastActivity now. The 60s throttle in bumpSessionActivity still applies, so heavy typing doesn't thrash the meta jsonl. First keystroke after a long idle still forces a persist because _lastActivityPersistedAt is unset, giving immediate sidebar reorder once the user actually engages. --- lib/project-sessions.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index f06a9f28..27b1943b 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -626,12 +626,6 @@ function attachSessions(ctx) { ws._clayActiveSession = msg.id; sm.switchSession(msg.id, ws, hydrateImageRefs); } - // Opening a session is a fresh activity signal — bump lastActivity - // so the sidebar moves it to "today" even if no further input - // arrives. TUI sessions don't otherwise route their typing through - // Clay's history (claude writes the jsonl directly), so this is - // the primary mechanism to keep their timestamp current. - try { sm.bumpSessionActivity(msg.id, { force: true }); } catch (e) {} // Send per-session context sources if (typeof loadContextSources === "function") { var switchedSources = loadContextSources(slug, msg.id); From 0233b75aa467c4aeafcc30e6d92504a0e09a4296 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 10:50:22 -0400 Subject: [PATCH 25/30] feat(sessions): structural done flag + Active/Completed sidebar tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "done - " title prefix with a real boolean on the session record. The sidebar now has Active and Completed tabs that partition sessions by the flag; tag counts on each tab reflect what's hidden. Backend: - lib/sessions.js: persist meta.done in saveSessionFile, read it in loadSessions, and migrate legacy "done - "-prefixed titles on load (strip the prefix, set done=true). The migration runs only when meta.done isn't already recorded, so it's idempotent. - lib/sessions.js mapSessionForClient: include done in the broadcast payload so the sidebar can filter without an extra round trip. - lib/project.js onMarkDone: flip session.done instead of mutating title. undo:true now clears the flag back to false. - lib/spawn-mcp-server.js: tool description + summary updated to talk about a flag, not a prefix. Frontend: - lib/public/modules/sidebar-sessions.js: module-level sessionListTab ("active" by default), renderSessionTabBar() with counts, filter items by tab before partitioning into bookmarked/regular. Clicking a tab re-renders. Loop groups always live in Active. - lib/public/css/sidebar.css: tab bar styling (subdued chips, count badge highlight on the active tab). Existing sessions with "done - X" titles get auto-migrated to done=true with the prefix stripped on next load — the user's accumulated backlog of completed JIRA work shows up in Completed without manual re-tagging. --- lib/project.js | 30 +++++---------- lib/public/css/sidebar.css | 50 +++++++++++++++++++++++++ lib/public/modules/sidebar-sessions.js | 52 ++++++++++++++++++++++++++ lib/sessions.js | 16 ++++++++ lib/spawn-mcp-server.js | 37 +++++++++--------- 5 files changed, 146 insertions(+), 39 deletions(-) diff --git a/lib/project.js b/lib/project.js index 2aba3009..d31a281e 100644 --- a/lib/project.js +++ b/lib/project.js @@ -525,7 +525,6 @@ function createProjectContext(opts) { var m = /^\s*\/[A-Za-z0-9_-]+\s+(\S+)/.exec(prompt); return m ? m[1].substring(0, 100) : null; } - var DONE_PREFIX = "done - "; function resolveTargetSession(args) { var sessionId = (args && typeof args.sessionId === "number") ? args.sessionId : null; var callingCliSessionId = (args && args.__callingCliSessionId) || null; @@ -574,33 +573,22 @@ function createProjectContext(opts) { "No session to mark — pass session_id explicitly or call from inside a session." )); } - var oldTitle = target.title || ""; - var hasPrefix = oldTitle.indexOf(DONE_PREFIX) === 0; - var newTitle; - var wasAlreadyDone = false; - if (args && args.undo) { - newTitle = hasPrefix ? oldTitle.substring(DONE_PREFIX.length) : oldTitle; - } else { - if (hasPrefix) { newTitle = oldTitle; wasAlreadyDone = true; } - else { newTitle = DONE_PREFIX + oldTitle; } - } - if (newTitle !== oldTitle) { - target.title = newTitle.substring(0, 100); - target.titleManuallySet = true; + var wantDone = !(args && args.undo); + var wasAlready = !!target.done === wantDone; + target.done = wantDone; + if (!wasAlready) { sm.saveSessionFile(target); sm.broadcastSessionList(); - if (target.cliSessionId && typeof adapter.renameSession === "function") { - adapter.renameSession(target.cliSessionId, target.title, { dir: cwd }).catch(function (e) { - console.error("[mark_session_done] SDK renameSession failed:", e.message || e); - }); - } } return Promise.resolve({ sessionId: target.localId, cliSessionId: target.cliSessionId || null, - oldTitle: oldTitle, + // Kept for backwards compat with the tool's response shape; + // the title itself no longer changes when marking done. + oldTitle: target.title, newTitle: target.title, - wasAlreadyDone: wasAlreadyDone, + wasAlreadyDone: wasAlready && wantDone, + done: target.done, }); } var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { diff --git a/lib/public/css/sidebar.css b/lib/public/css/sidebar.css index 260c95e2..22089698 100644 --- a/lib/public/css/sidebar.css +++ b/lib/public/css/sidebar.css @@ -1019,6 +1019,56 @@ transition: background 0.15s, color 0.15s, opacity 0.15s; } +.session-tab-bar { + display: flex; + gap: 2px; + padding: 6px 0 4px; + border-bottom: 1px solid var(--border); +} +.session-tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 8px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + opacity: 0.7; + transition: background 0.12s, color 0.12s, opacity 0.12s; +} +.session-tab:hover { + background: var(--surface-sunken, rgba(0, 0, 0, 0.04)); + opacity: 1; +} +.session-tab.active { + background: var(--surface-elevated, rgba(0, 0, 0, 0.08)); + color: var(--text); + opacity: 1; +} +.session-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--surface-sunken, rgba(0, 0, 0, 0.08)); + font-size: 11px; + font-weight: 600; + color: var(--text-muted); +} +.session-tab.active .session-tab-count { + background: var(--accent, #4a90e2); + color: #fff; +} + .session-top-action .lucide, .session-top-action svg { width: 14px; diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index a8427962..f6e9c061 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -21,6 +21,9 @@ var searchMatchIds = null; // null = no search, Set of matched session IDs var searchDebounce = null; var expandedLoopGroups = new Set(); var expandedLoopRuns = new Set(); +// "active" | "completed". Active hides done sessions; completed shows +// only done sessions. Persists for the lifetime of the page only. +var sessionListTab = "active"; // --- Session presence (multi-user: who is viewing which session) --- var sessionPresence = {}; // { sessionId: [{ id, displayName, avatarStyle, avatarSeed }] } @@ -409,6 +412,30 @@ function renderSessionTopActions() { return wrap; } +function renderSessionTabBar(activeCount, completedCount) { + var bar = document.createElement("div"); + bar.className = "session-tab-bar"; + + function makeTab(key, label, count) { + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "session-tab" + (sessionListTab === key ? " active" : ""); + btn.dataset.tab = key; + btn.innerHTML = '<span class="session-tab-label">' + escapeHtml(label) + '</span>' + + '<span class="session-tab-count">' + count + '</span>'; + btn.addEventListener("click", function () { + if (sessionListTab === key) return; + sessionListTab = key; + renderSessionList(null); + }); + return btn; + } + + bar.appendChild(makeTab("active", "Active", activeCount)); + bar.appendChild(makeTab("completed", "Completed", completedCount)); + return bar; +} + function runSessionSearch(query) { var normalizedQuery = query || ""; var trimmedQuery = normalizedQuery.trim(); @@ -1245,6 +1272,29 @@ export function renderSessionList(sessions) { // Sort by lastActivity descending items.sort(compareSessionListItems); + // Tab counts (computed from all items pre-filter so the unselected tab + // can show how many are over there). + var activeCount = 0; + var completedCount = 0; + for (var tc = 0; tc < items.length; tc++) { + var ti = items[tc]; + if (ti.type === "session" && ti.data) { + if (ti.data.done) completedCount++; else activeCount++; + } else if (ti.type === "loop") { + // Loop groups always render in Active; they don't carry a done flag. + activeCount++; + } + } + + function itemMatchesTab(item) { + if (sessionListTab !== "completed") { + // Active tab: include everything that's not done. + return !(item.type === "session" && item.data && item.data.done); + } + // Completed tab: only done sessions, no loop groups. + return item.type === "session" && item.data && !!item.data.done; + } + var bookmarkedItems = []; var regularItems = []; for (var n = 0; n < items.length; n++) { @@ -1252,6 +1302,7 @@ export function renderSessionList(sessions) { if (item.type === "session" && item.data && !isSessionVisibleBySearch(item.data.id)) { continue; } + if (!itemMatchesTab(item)) continue; if (item.type === "session" && item.data && item.data.bookmarked) { bookmarkedItems.push(item); } else { @@ -1283,6 +1334,7 @@ export function renderSessionList(sessions) { stickyTop.appendChild(favoritesContainer); stickyTop.appendChild(divider); stickyTop.appendChild(renderSessionTopActions()); + stickyTop.appendChild(renderSessionTabBar(activeCount, completedCount)); getSessionListEl().appendChild(stickyTop); var currentGroup = ""; diff --git a/lib/sessions.js b/lib/sessions.js index 5814d51a..8c3a4362 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -113,6 +113,10 @@ function createSessionManager(opts) { // the user opens the session; persist so a restart still primes the // claude --session-id launch with the right prompt. if (session.pendingInitialPrompt) metaObj.pendingInitialPrompt = session.pendingInitialPrompt; + // Structured "done" state. Persist when true OR when explicitly + // false (so the legacy "done - " migration can record that this + // session has already been considered and shouldn't be re-migrated). + if (typeof session.done === "boolean") metaObj.done = !!session.done; var meta = JSON.stringify(metaObj); var lines = [meta]; for (var i = 0; i < session.history.length; i++) { @@ -217,6 +221,17 @@ function createSessionManager(opts) { if (m.titleManuallySet) session.titleManuallySet = true; if (m.titleAutoGenerated) session.titleAutoGenerated = true; if (m.pendingInitialPrompt) session.pendingInitialPrompt = m.pendingInitialPrompt; + // Done flag. Falls back to migrating the legacy "done - " title + // prefix when the field hasn't been recorded yet. + if (typeof m.done === "boolean") { + session.done = m.done; + } else if (typeof m.title === "string" && m.title.indexOf("done - ") === 0) { + session.done = true; + session.title = m.title.substring("done - ".length); + session.titleManuallySet = true; + } else { + session.done = false; + } // Backwards-compat: older session files predate the persisted // title-origin flags. If a non-default title is present but no flag // was recorded, assume the title is already settled (either user @@ -270,6 +285,7 @@ function createSessionManager(opts) { unread: unreadMap[s.localId] || 0, vendor: s.vendor || null, mode: s.mode || "gui", + done: !!s.done, terminalId: typeof s.terminalId === "number" ? s.terminalId : null, runtimeMode: s.runtimeMode || null, runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null, diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js index d745fda8..5fcde35a 100644 --- a/lib/spawn-mcp-server.js +++ b/lib/spawn-mcp-server.js @@ -292,8 +292,9 @@ function getToolDefs(onSpawn, onMarkDone, onRename) { tools.push({ name: "mark_session_done", description: - "Mark a Clay session as done by prefixing its title with \"done - \". " + - "Idempotent: a session that's already prefixed is left unchanged.\n" + + "Toggle the structural \"done\" flag on a Clay session. The title " + + "is left unchanged; the Clay sidebar moves the session into its " + + "\"Completed\" tab instead. Idempotent.\n" + "\n" + "USE THIS TOOL when the user signals work on the current session (or a " + "specific session) is complete — e.g. \"/done\", \"that's done\", " + @@ -307,8 +308,7 @@ function getToolDefs(onSpawn, onMarkDone, onRename) { "a dispatcher session to mark a peer session you tracked from " + "spawn_session.\n" + "\n" + - "undo: pass true to remove a previously applied \"done - \" prefix " + - "(restore the original title).", + "undo: pass true to clear the done flag (move it back to Active).", inputSchema: buildShape({ session_id: { type: "number", @@ -319,34 +319,35 @@ function getToolDefs(onSpawn, onMarkDone, onRename) { }, undo: { type: "boolean", - description: "Pass true to strip an existing \"done - \" prefix.", + description: "Pass true to clear the done flag (move it back to Active).", }, }, []), handler: function (args) { var sessionId = (typeof args.session_id === "number") ? args.session_id : null; var undo = !!args.undo; + var callingCliSessionId = (args && args.__callingCliSessionId) || null; return Promise.resolve() - .then(function () { return onMarkDone({ sessionId: sessionId, undo: undo }); }) + .then(function () { return onMarkDone({ sessionId: sessionId, undo: undo, __callingCliSessionId: callingCliSessionId }); }) .then(function (result) { var localId = result && result.sessionId; - var oldTitle = (result && result.oldTitle) || ""; - var newTitle = (result && result.newTitle) || ""; - var alreadyDone = !!(result && result.wasAlreadyDone); - var verb = undo ? "Restored" : "Marked done"; - var changed = (oldTitle !== newTitle); + var title = (result && result.newTitle) || ""; + var nowDone = !!(result && result.done); + var alreadyInState = !!(result && result.wasAlreadyDone); var summary; - if (!changed && alreadyDone && !undo) { - summary = "Session #" + localId + " already marked done (\"" + newTitle + "\")."; - } else if (!changed && undo) { - summary = "Session #" + localId + " was not marked done (\"" + newTitle + "\")."; + if (alreadyInState && !undo) { + summary = "Session #" + localId + " was already marked done."; + } else if (undo && !nowDone && !alreadyInState) { + summary = "Cleared done flag on session #" + localId + " (\"" + title + "\")."; + } else if (!undo && nowDone) { + summary = "Marked session #" + localId + " done (\"" + title + "\")."; } else { - summary = verb + " session #" + localId + ": \"" + oldTitle + "\" -> \"" + newTitle + "\"."; + summary = "Session #" + localId + " is " + (nowDone ? "done" : "active") + "."; } var trackingLine = "[clay-sessions/mark_session_done] localId=" + localId + " cliSessionId=" + ((result && result.cliSessionId) || "(none)") + - " title=" + JSON.stringify(newTitle) + + " title=" + JSON.stringify(title) + " action=" + (undo ? "undo" : "done") + - " changed=" + changed; + " done=" + nowDone; return { content: [ { type: "text", text: summary }, From 43b5a62b4ade264b27a71ee3cb48bf1edf84d009 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 11:20:44 -0400 Subject: [PATCH 26/30] feat(sidebar): right-click "Mark done" / "Mark active" on session items Context menu now has an explicit toggle between Active and Completed that doesn't require invoking the /done skill. Mirrors what mark_session_done does from the MCP side: flips session.done, saves, broadcasts. The label and icon swap based on the session's current state ("Mark done" with a check / "Mark active" with a back-arrow). Adds set_session_done to ws-schema as a c2s message. --- lib/project-sessions.js | 12 ++++++++++++ lib/public/modules/sidebar-sessions.js | 18 ++++++++++++++++++ lib/ws-schema.js | 1 + 3 files changed, 31 insertions(+) diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 27b1943b..8d0e1e1c 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -301,6 +301,18 @@ function attachSessions(ctx) { return true; } + if (msg.type === "set_session_done") { + if (typeof msg.sessionId === "number") { + var doneTarget = sm.sessions.get(msg.sessionId); + if (doneTarget) { + doneTarget.done = !!msg.done; + sm.saveSessionFile(doneTarget); + sm.broadcastSessionList(); + } + } + return true; + } + if (msg.type === "set_session_bookmark") { if (typeof msg.sessionId === "number") { var bookmarkTarget = sm.sessions.get(msg.sessionId); diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index f6e9c061..3918ff14 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -672,6 +672,24 @@ function showSessionCtxMenu(anchorBtn, sessionId, title, cliSid, sessionData) { }); menu.appendChild(renameItem); + var isDone = !!(sessionData && sessionData.done); + var doneItem = document.createElement("button"); + doneItem.className = "session-ctx-item"; + doneItem.innerHTML = iconHtml(isDone ? "rotate-ccw" : "check") + + " <span>" + (isDone ? "Mark active" : "Mark done") + "</span>"; + doneItem.addEventListener("click", function (e) { + e.stopPropagation(); + closeSessionCtxMenu(); + if (getWs() && store.get('connected')) { + getWs().send(JSON.stringify({ + type: "set_session_done", + sessionId: sessionId, + done: !isDone, + })); + } + }); + menu.appendChild(doneItem); + // Session visibility toggle (only the session owner can change) if (store.get('isMultiUserMode') && sessionData && sessionData.ownerId && sessionData.ownerId === store.get('myUserId')) { var currentVis = (sessionData && sessionData.sessionVisibility) || "shared"; diff --git a/lib/ws-schema.js b/lib/ws-schema.js index 1f6b9a68..2f8e161b 100644 --- a/lib/ws-schema.js +++ b/lib/ws-schema.js @@ -25,6 +25,7 @@ var schema = { "bulk_delete_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "Delete a group of sessions at once" }, "resume_session": { direction: "c2s", handler: "lib/project-sessions.js", description: "Resume a CLI session by its CLI session ID" }, "set_session_visibility": { direction: "c2s", handler: "lib/project-sessions.js", description: "Show or hide a session in the sidebar" }, + "set_session_done": { direction: "c2s", handler: "lib/project-sessions.js", description: "Toggle the done flag on a session (moves between Active and Completed tabs)" }, "search_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "Search session titles" }, "search_session_content": { direction: "c2s", handler: "lib/project-sessions.js", description: "Full-text search within a session" }, "list_cli_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "List active CLI sessions available for resume" }, From d9646f010643f3e67f43fed144e994e2da66918d Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 11:34:49 -0400 Subject: [PATCH 27/30] fix(connection): include done/mode/terminalId in initial session_list There are two session_list construction paths: broadcastSessionList in sessions.js calls mapSessionForClient and includes all fields; the initial-connection sender in project-connection.js (lines 165-189) had its own hand-rolled payload that was a strict subset. The on-connect payload was missing the new "done" field, so after a restart the browser's first session_list arrived with done=undefined on every session and the new Active/Completed tabs ignored the migration. Subsequent broadcasts wouldn't fire until something mutated, so the user was stuck looking at everything in Active even though loadSessions had correctly set done=true on 227 sessions. Pull the missing fields (done, mode, vendor, terminalId, runtimeMode, runtimeTerminalId) into the on-connect payload so the initial render matches what later broadcasts contain. --- lib/project-connection.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/project-connection.js b/lib/project-connection.js index 14590967..c27aa100 100644 --- a/lib/project-connection.js +++ b/lib/project-connection.js @@ -185,6 +185,12 @@ function attachConnection(ctx) { sessionVisibility: s.sessionVisibility || "shared", bookmarked: !!s.bookmarked, favoriteOrder: typeof s.favoriteOrder === "number" ? s.favoriteOrder : null, + vendor: s.vendor || null, + mode: s.mode || "gui", + done: !!s.done, + terminalId: typeof s.terminalId === "number" ? s.terminalId : null, + runtimeMode: s.runtimeMode || null, + runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null, }; }), }); From ada14855ec0be9ea4aa66205ca2de0acc6b643ef Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 11:48:24 -0400 Subject: [PATCH 28/30] docs(skills): ship /jira and /done slash commands in skills/ Add a top-level skills/ directory with ready-to-install reference implementations of the two Clay-flavoured slash commands the README's "Session fan-out" section assumes: - skills/jira.md: the full JIRA -> implementation workflow. Fetches the issue, renames the Clay session to "<KEY> - <summary>", transitions In Progress, dispatches parallel subagents for codebase exploration, produces a plan with two approval gates, implements, transitions to Done. - skills/done.md: wraps up the session. Transitions the JIRA ticket and calls mark_session_done, which flips the structural flag (no longer prefixes the title) so the sidebar moves it from Active to Completed automatically. - skills/README.md: install instructions (user-level via ~/.claude/commands/ or project-level via .claude/commands/) plus pointers to the Clay MCP tools the skills depend on. Main README's Session fan-out section updated to: - mention rename_session alongside spawn_session / mark_session_done - describe the done flow as moving sessions to the Completed tab (instead of the old "done - " title-prefix wording) - link to skills/ as the install path. The shipped /done text uses the new flag-based behaviour from 0233b75 and references the bridge --session-id routing from f6a621d. --- README.md | 6 +- skills/README.md | 67 +++++++++++++++++++ skills/done.md | 58 +++++++++++++++++ skills/jira.md | 163 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 skills/README.md create mode 100644 skills/done.md create mode 100644 skills/jira.md diff --git a/README.md b/README.md index 26732644..7dabb051 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,11 @@ Plan a sprint in one chat, then turn the result into per-ticket sessions in a si Clay spawns one session per work item in parallel, each titled with the issue key and pre-running your `/jira <KEY>` (or any other discovery skill). The sidebar fills with `GP-222`, `GP-232`, … each one a separate xterm or chat tab you can pick up later. Sessions launch in plan mode at high effort by default, so each one loads context and produces a plan before touching code — you walk back into ready-to-review proposals. -When you finish a ticket, `/done` from inside that session flips the JIRA status and prefixes the Clay sidebar entry with `done - `, so progress is visible at a glance. +When you finish a ticket, `/done` from inside that session transitions the JIRA ticket and flips the session's structural `done` flag — Clay moves it from the **Active** tab to the **Completed** tab in the sidebar automatically. -Two MCP tools drive this — `spawn_session` (fan-out) and `mark_session_done` (close-out). Both work from GUI and TUI Claude sessions, because Clay's in-app MCP servers are bridged into the real `claude` CLI via `--mcp-config`. So the planning conversation can be a TUI session (subscription billing) and still command the rest of your workspace. +Three MCP tools drive this — `spawn_session` (fan-out), `rename_session` (refine the title once context is loaded), and `mark_session_done` (close-out). All three work from GUI and TUI Claude sessions, because Clay's in-app MCP servers are bridged into the real `claude` CLI via `--mcp-config`. So the planning conversation can be a TUI session (subscription billing) and still command the rest of your workspace. + +Ready-to-use `/jira` and `/done` slash commands ship in [`skills/`](skills/) — drop them into `~/.claude/commands/` to get the full workflow on your machine. ### Ralph Loop: autonomous coding while you sleep diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000..cd1fe559 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,67 @@ +# Clay Skills + +Optional slash-command skills that drive Clay's MCP servers (`clay-sessions`, +`clay-debate`, …). Drop the `.md` files into your Claude Code commands +directory and they show up as `/<name>` slash commands in any Claude +session. + +## Install + +```bash +# User-level (every project on this machine): +cp skills/*.md ~/.claude/commands/ + +# Project-level (only this repo): +mkdir -p .claude/commands +cp skills/*.md .claude/commands/ +``` + +Restart any open Claude session (in Clay's UI or in `claude` CLI) so the +new commands appear in the slash-command list. + +## What's included + +| Skill | What it does | Requires | +|-------|--------------|----------| +| [`/jira <KEY>`](jira.md) | Full JIRA→implementation workflow: fetch issue, rename the Clay session to `KEY - <summary>`, transition to In Progress, dispatch parallel subagents for codebase exploration, produce a plan with two approval gates, implement, transition to Done. | Clay (for `rename_session`), the Atlassian Rovo MCP server, your project's `CLAUDE.md` | +| [`/done`](done.md) | Wraps up the session: transitions the JIRA ticket to Done via Atlassian Rovo, then calls Clay's `mark_session_done` so the sidebar moves the session from **Active** to **Completed**. | Clay (for `mark_session_done`), the Atlassian Rovo MCP server | + +Both skills are designed to work inside a Clay session — they call into +Clay's `clay-sessions` MCP server. GUI sessions reach the tools directly; +TUI (xterm) sessions reach them through the `clay-tools` stdio bridge +(`lib/yoke/mcp-bridge-server.js`), which Clay launches automatically with +`--session-id` so session-aware tools always operate on the calling +session. + +## Customising + +The shipped files are reference implementations. Edit them in place — +they're plain markdown that Claude reads at slash-command invocation +time. Common tweaks: + +- **Different JIRA naming conventions** — change the `KEY - <summary>` + format in `jira.md` Step 2.5. +- **Different "done" transitions** — the matcher in `done.md` Step 2 + prefers "Done" but falls back to "Complete" / "Closed" / "Resolved"; + adjust if your workflow uses something else. +- **Skip the agent-team dispatch** for smaller projects — remove or + trim Step 3's parallel-subagent block in `jira.md` if a single + sequential pass works for you. + +## Building your own + +A Clay-flavoured skill is just a markdown file in `~/.claude/commands/`. +Useful Clay-specific MCP tools you can reference: + +- `mcp__clay-tools__clay-sessions__spawn_session(title, initial_prompt, mode?, permission_mode?, effort?)` + — fan out per-item work sessions. +- `mcp__clay-tools__clay-sessions__rename_session(title)` — set the + current session's title. +- `mcp__clay-tools__clay-sessions__mark_session_done(undo?)` — flip the + Active/Completed flag. +- `mcp__clay-tools__clay-debate__propose_debate(...)` — kick off a + structured multi-Mate debate. + +In TUI sessions the tools are exposed via the `clay-tools` bridge, hence +the namespaced names above. In GUI sessions they're available without the +bridge prefix. diff --git a/skills/done.md b/skills/done.md new file mode 100644 index 00000000..88fb4cae --- /dev/null +++ b/skills/done.md @@ -0,0 +1,58 @@ +# Mark Current JIRA Issue and Clay Session as Done + +The user has signalled that work on the current JIRA issue is complete. +Carry out the two actions below. Run JIRA and Clay updates in parallel +where the tool calls don't depend on each other. + +--- + +## Step 1: Identify the JIRA Issue Key + +Look back through this session's earlier messages for a `/jira <KEY>` +invocation or any explicit `ABC-123`-style JIRA key. That key is the +ticket to close. + +If you cannot find a JIRA key in the conversation, stop and ask the user +to confirm which issue to mark done. Do not guess. + +## Step 2: Transition the Issue to Done in JIRA + +1. Call `mcp__atlassian__getAccessibleAtlassianResources` to get the + cloud ID (skip if you already have it cached in this session). +2. Call `mcp__atlassian__getTransitionsForJiraIssue` with the cloud ID + and the issue key to discover available transitions. +3. Pick the transition whose name best matches "Done" (case-insensitive; + also accept "Complete", "Closed", "Resolved" if no exact "Done" + exists). +4. Call `mcp__atlassian__transitionJiraIssue` with the matched + transition ID. + +If the issue is already in a done-shaped status, skip the transition +and note this in the final summary instead of erroring out. + +## Step 3: Mark the Clay Session as Done + +Call the `mark_session_done` tool from the `clay-sessions` MCP server +(exposed as `mark_session_done` in GUI sessions, or +`clay-sessions__mark_session_done` via the `clay-tools` bridge in TUI +sessions). Pass no arguments — it operates on the calling session, +which is this one. The tool routes via the bridge's +`--session-id` plumbing so it always hits the session running `/done`, +even if the user has clicked into a different one while you're working. + +The tool flips the session's structural `done` flag (it does NOT +modify the title). The Clay sidebar moves the session from its +"Active" tab into the "Completed" tab automatically. Idempotent — +calling it on an already-done session is a no-op. + +## Step 4: Summarise + +In ONE short sentence, tell the user: + +- Which JIRA transition you applied (or that the issue was already + done). +- That the Clay session is now marked done. + +Do NOT switch the user to a different session, do NOT keep working on +the issue, do NOT propose follow-up work unless they ask. The user is +done with this one. diff --git a/skills/jira.md b/skills/jira.md new file mode 100644 index 00000000..e26099cc --- /dev/null +++ b/skills/jira.md @@ -0,0 +1,163 @@ +# JIRA Issue Workflow: $ARGUMENTS + +You are executing a structured development workflow for JIRA issue **$ARGUMENTS**. Follow these steps in order. Do NOT skip steps or combine them. + +--- + +## Step 1: Discover Atlassian Site + +Call `mcp__atlassian__getAccessibleAtlassianResources` to get the cloud ID. +Store it for all subsequent JIRA calls. If this fails, stop and report the error. + +## Step 2: Fetch Issue Details + +Call `mcp__atlassian__getJiraIssue` with the cloud ID, issue key `$ARGUMENTS`, +and `responseContentFormat: "markdown"`. + +Display to the user: issue summary, description, type, priority, current status, +and acceptance criteria (if any). + +If the issue is not found, stop and ask the user to verify the issue key. + +### Step 2.5: Rename the Clay Session + +As soon as you have the issue summary, call the Clay MCP tool +`rename_session` (exposed as `rename_session` in GUI sessions, or +`clay-sessions__rename_session` via the `clay-tools` bridge in TUI +sessions) with: + +``` +title = "$ARGUMENTS - <issue summary>" +``` + +Truncate the summary so the full title stays under ~80 characters (the +sidebar gets noisy past that). Do not pass `session_id`; the tool will +operate on the calling session. + +If the rename tool is not available in this session (e.g. running +outside Clay), skip this step silently. + +## Step 3: Transition to In Progress and Plan + +As soon as you begin planning, transition the issue to "In Progress" so the +JIRA status reflects active work: + +1. **Transition to In Progress**: Call `mcp__atlassian__getTransitionsForJiraIssue` + to discover available transitions. Find the best match for "In Progress" + (case-insensitive, also match "Start Progress", "In Development", etc.). + Call `mcp__atlassian__transitionJiraIssue` with the matched transition ID. + If no suitable transition exists or the issue is already in progress, report + this and continue. + +2. **Enter plan mode and dispatch agent teams**. Use parallel subagents + for the exploration phase whenever the work spans multiple files, + modules, or layers. A single sequential pass through the codebase is + slow and chews up the main turn's context; subagents run in + isolated context windows and return summaries. + + Pick subagents that match the work. Send the calls in a single + message so they run concurrently: + + - **Explore** (`subagent_type: "Explore"`) for "where is X defined", + "which files reference Y", "what's the test coverage for module Z". + One call per distinct lookup; many in parallel is fine. + - **architect** (`everything-claude-code:architect`) for system-design + and scalability questions, refactoring scope, technical trade-offs. + - **planner** (`everything-claude-code:planner`) for breaking the + issue itself into ordered implementation steps. Useful when the + ticket is larger than a single PR. + - **code-reviewer** / **security-reviewer** / **database-reviewer** + for risk surfaces touched by the change (auth, data model, perf). + - **general-purpose** as a fallback for open-ended research. + + Prefer reading source files **directly** with the Read tool rather + than `bash` running `cat`, `head`, or `tail`. Use **Grep** for + substring/regex searches and **Glob** for filename patterns instead + of `bash` running `grep` / `find` / `ls`. The native tools return + structured output the harness can track and integrate with rewind + /checkpoint behaviour; shell commands skip that. + + Reserve `bash` for things only the shell can do: running tests, + building, git status, running a script. Not for browsing the tree. + + Once the subagents return, synthesise their findings into a detailed + implementation plan including: + - Summary of the JIRA issue requirements + - Files to create or modify (with paths) + - Implementation approach for each change + - Test strategy + - Risks or edge cases (call out what each reviewer subagent surfaced) + + Present the plan and **WAIT for explicit user approval before continuing**. + +## Step 4: Post Plan to JIRA + +After the user approves the plan: + +1. **Add plan as JIRA comment**: Call `mcp__atlassian__addCommentToJiraIssue` + with the plan formatted in markdown (`contentFormat: "markdown"`). + +## Step 5: Implement + +Implement all changes according to the approved plan. Follow the conventions +and patterns defined in the project's CLAUDE.md and existing codebase. + +## Step 6: Write Tests + +Write tests for all new functionality: +- Follow existing test patterns in the codebase +- Cover happy paths and error cases +- Run the tests and verify they pass + +## Step 7: Present Work for Review + +Present a summary of all changes: +- Files created and modified +- Brief description of each change +- Test results + +**WAIT for explicit user approval before committing.** + +## Step 8: Commit and Close Issue + +After the user approves: + +1. **Commit**: Follow the project's commit message conventions from CLAUDE.md. + Stage only the relevant files (never `git add -A` or `git add .`). + +2. **Transition to Done**: Call `mcp__atlassian__getTransitionsForJiraIssue` + to discover available transitions. Find the best match for the final status + (match "Done", "Closed", "Resolved", "Complete" -- pick the closest). + Call `mcp__atlassian__transitionJiraIssue` with the matched transition ID. + If no suitable transition exists, report available transitions and let the + user decide. + +--- + +## Rules + +- **Two approval gates**: ALWAYS wait for explicit user approval after Step 3 + (plan) and Step 7 (work review). Never auto-proceed past these gates. +- **Dynamic discovery**: Always discover cloud ID and transition IDs at runtime. + Never hardcode them. +- **Error resilience**: If a JIRA API call fails (comment, transition), report + the error but continue with development work. JIRA updates are secondary to + the code changes. +- **Selective staging**: When committing, add specific files by name. +- **Project conventions**: Always defer to the project's CLAUDE.md for commit + message format, coding style, and other conventions. +- **Agent teams over solo exploration**: When a step needs codebase-wide + context (Step 3 planning, Step 6 test coverage discovery), dispatch + parallel subagents via the Task tool rather than doing everything + sequentially. One call per independent lookup, sent in a single + message so they run concurrently. The Explore agent is the default + for "find / where / which"; the architect / planner / reviewer + agents for judgement calls. Don't dispatch a subagent for trivial + single-file lookups — the round-trip cost outweighs the benefit + there. +- **Native tools over bash for file access**: Read files with the Read + tool, search with Grep, find paths with Glob. Reserve `bash` for + operations only the shell can do (tests, builds, `git status`, + scripts). Do NOT `cat`, `head`, `tail`, `grep`, `find`, or `ls` + through bash — the native tools integrate with rewind / + checkpointing and produce structured output the harness can track. From d87f450b4f89620cb61181f434fa10aeee887710 Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 12:19:10 -0400 Subject: [PATCH 29/30] docs(skills): /jira Step 8 also marks the Clay session done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /jira skill's Step 8 transitions the JIRA ticket to Done but didn't touch Clay's sidebar, so after a clean /jira run the ticket showed Done in Atlassian while the Clay session sat in Active. Add a third sub-step that calls mark_session_done so the two endpoints stay in sync. Mirrors what /done does on its own — the difference is that /jira already has the issue key in context, so step ordering is "transition JIRA, mark Clay" rather than the standalone /done's "find key, transition JIRA, mark Clay". --- skills/jira.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/skills/jira.md b/skills/jira.md index e26099cc..cd377480 100644 --- a/skills/jira.md +++ b/skills/jira.md @@ -132,6 +132,15 @@ After the user approves: If no suitable transition exists, report available transitions and let the user decide. +3. **Mark the Clay session done**: Call the `mark_session_done` tool from + the `clay-sessions` MCP server (exposed as `mark_session_done` in GUI + sessions, or `clay-sessions__mark_session_done` via the `clay-tools` + bridge in TUI sessions). Pass no arguments — it operates on the + calling session. The tool flips the session's structural `done` flag + so Clay moves it from the **Active** tab to the **Completed** tab in + the sidebar. Skip silently if the tool isn't available (running + outside Clay). + --- ## Rules From e67e0960dcd8c251121cf8910890b108992fb9eb Mon Sep 17 00:00:00 2001 From: Tushar Singh <tushar.singh@starshipdreams.com> Date: Wed, 20 May 2026 12:34:10 -0400 Subject: [PATCH 30/30] docs(skills): make /jira's issue-display step non-skippable When /jira launches in a freshly spawned session under plan mode at high effort, the agent was diving straight into exploration and folding the issue summary into its plan instead of printing the description block to the user as Step 2 requires. The user lost the "what does this ticket actually say" view they expect at the top of every spawn. Tighten Step 2: - Bold "Print the issue to the user before doing anything else." - Provide a concrete markdown template (heading with the key + summary, Type/Priority/Status row, Description body, Acceptance Criteria) so there's no ambiguity about layout. Add a matching Rules entry: - Step 2's block is non-skippable, must print before exploration, must print even when the model thinks the user "already saw" the issue. Both changes mirror to skills/jira.md and the local ~/.claude/commands/jira.md. --- skills/jira.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/skills/jira.md b/skills/jira.md index cd377480..64027697 100644 --- a/skills/jira.md +++ b/skills/jira.md @@ -14,8 +14,23 @@ Store it for all subsequent JIRA calls. If this fails, stop and report the error Call `mcp__atlassian__getJiraIssue` with the cloud ID, issue key `$ARGUMENTS`, and `responseContentFormat: "markdown"`. -Display to the user: issue summary, description, type, priority, current status, -and acceptance criteria (if any). +**Print the issue to the user before doing anything else.** This is the +first thing the user sees in the session and the only time they see the +ticket spelled out — don't fold it into your plan or skip to exploration. +Use a markdown block laid out like this (omit fields the API doesn't +return): + +``` +## $ARGUMENTS — <summary> + +**Type:** <type> **Priority:** <priority> **Status:** <status> + +### Description +<full description body — preserve markdown formatting> + +### Acceptance Criteria +<bulleted list from AC field, or "(none specified)"> +``` If the issue is not found, stop and ask the user to verify the issue key. @@ -145,6 +160,12 @@ After the user approves: ## Rules +- **Always print the issue first**: Step 2's full issue block (summary, + description, AC) is non-skippable — print it to the user before any + exploration, planning, or tool calls beyond the initial JIRA fetch. + The user needs the description in plain text to decide whether to + approve the plan; folding it into the plan or skipping it because + "the model already read it" is wrong. - **Two approval gates**: ALWAYS wait for explicit user approval after Step 3 (plan) and Step 7 (work review). Never auto-proceed past these gates. - **Dynamic discovery**: Always discover cloud ID and transition IDs at runtime.