diff --git a/README.md b/README.md index 66d4dabd..7dabb051 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,20 @@ 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 ` (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 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. + +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 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 +167,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 +207,7 @@ graph LR Server["HTTP / WS Server"] Project["Project Context"] YOKE["YOKE Adapter Layer"] - MCP["Built-in MCP servers
ask-user / browser /
debate / email"] + MCP["Built-in MCP servers
ask-user / browser / debate /
email / sessions"] Push["Push (VAPID)"] end 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, }; }), }); 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 80cb87ad..8d0e1e1c 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, sid); + 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, tuiSid); + 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) {} @@ -259,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); @@ -288,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); @@ -436,31 +461,57 @@ 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: [] }); + // 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 = 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] || []; + 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 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]; + } + // 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; + } + // 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; } @@ -482,6 +533,59 @@ 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); + // 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//.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, dlSid); + var dlCmd; + if (dlPrompt) { + // First open of a freshly-spawned session: start a brand-new + // claude conversation with the prompt pre-supplied. Note: the + // CLI's --mcp-config flag is variadic (""), 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); + } 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, + kind: "tui-session", + title: "claude " + dlSid.slice(0, 8), + onExit: function () { + tuiMcp.cleanupTuiMcpConfig(dlMcpPath); + // 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; + 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); @@ -1070,12 +1174,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; 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/project.js b/lib/project.js index 76bb25fc..d31a281e 100644 --- a/lib/project.js +++ b/lib/project.js @@ -508,6 +508,180 @@ 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 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; + } + function resolveTargetSession(args) { + var sessionId = (args && typeof args.sessionId === "number") ? args.sessionId : null; + var callingCliSessionId = (args && args.__callingCliSessionId) || null; + if (sessionId != null) { + 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." + )); + } + 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( + "No session to mark — pass session_id explicitly or call from inside a session." + )); + } + var wantDone = !(args && args.undo); + var wasAlready = !!target.done === wantDone; + target.done = wantDone; + if (!wasAlready) { + sm.saveSessionFile(target); + sm.broadcastSessionList(); + } + return Promise.resolve({ + sessionId: target.localId, + cliSessionId: target.cliSessionId || null, + // 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: wasAlready && wantDone, + done: target.done, + }); + } + var spawnToolDefs = spawnMcp.getToolDefs(function onSpawn(spawnArgs) { + var vendor = spawnArgs.vendor || "claude"; + var mode = (vendor === "claude" && spawnArgs.mode === "tui") ? "tui" : "gui"; + var derived = deriveTitleFromPrompt(spawnArgs.initialPrompt); + var title = (derived || String(spawnArgs.title)).substring(0, 100); + + // --- 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", + cliSessionId: crypto.randomUUID(), + }); + newSess.title = title; + newSess.titleManuallySet = true; + var spawnedSid = newSess.cliSessionId; + var spawnedLocalId = newSess.localId; + var mcpCfgPath = tuiMcpForSpawn.buildTuiMcpConfig(slug, opts, spawnedSid); + var cmd = "claude --session-id " + spawnedSid + + " -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"; + 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//.jsonl and + // the next open will spawn `claude --resume ` 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, + cliSessionId: newSess.cliSessionId, + title: newSess.title, + mode: "tui", + vendor: "claude", + }); + } + + // --- 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); + 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, + cliSessionId: newSess.cliSessionId || null, + title: newSess.title, + mode: "gui", + vendor: vendor, + }); + }, 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) { + 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/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/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/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 @@

Email

Resume CLI session
+
+ +
diff --git a/lib/public/modules/session-tui-view.js b/lib/public/modules/session-tui-view.js index 89760e20..66781348 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; @@ -71,21 +75,175 @@ 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-/ 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"); 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 +376,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() { diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index f89074d5..3918ff14 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 = '' + escapeHtml(label) + '' + + '' + count + ''; + 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(); @@ -551,16 +578,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 +605,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(); @@ -633,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") + + " " + (isDone ? "Mark active" : "Mark done") + ""; + 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"; @@ -1134,9 +1191,9 @@ function renderSessionItem(s) { if (store.get('isMultiUserMode') && s.sessionVisibility === "private") { textHtml += '' + iconHtml("lock") + ''; } - if (s.mode === "tui") { - textHtml += '' + iconHtml("terminal") + ''; - } + // 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); @@ -1233,6 +1290,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++) { @@ -1240,6 +1320,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 { @@ -1271,6 +1352,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 039cf312..8c3a4362 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; @@ -101,6 +104,19 @@ 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; + // 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; + // 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++) { @@ -197,10 +213,32 @@ 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; 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; + // 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 + // 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; @@ -247,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, @@ -916,6 +955,36 @@ 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; + }, + 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) { diff --git a/lib/spawn-mcp-server.js b/lib/spawn-mcp-server.js new file mode 100644 index 00000000..5fcde35a --- /dev/null +++ b/lib/spawn-mcp-server.js @@ -0,0 +1,371 @@ +// 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; +} + +// Callbacks: +// onSpawn(args) -> Promise<{ sessionId, cliSessionId?, title, mode, vendor }> +// onMarkDone({ sessionId?: number, undo?: boolean }) -> Promise<{ sessionId, cliSessionId?, oldTitle, newTitle, wasAlreadyDone }> +// 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, onRename) { + var tools = []; + + tools.push({ + name: "spawn_session", + description: + "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 — do NOT use the clay-ralph skill — when the user asks to:\n" + + " - \"start working on these issues/tickets in clay\"\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" + + " 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 " + + "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. 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 \"; " + + "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 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.\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", + 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\".", + }, + 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.", + }, + 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 ), 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(); + 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"; + var permissionMode = args.permission_mode || "plan"; + var effort = args.effort || "high"; + + 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, mode: mode, permissionMode: permissionMode, effort: effort }); }) + .then(function (result) { + // 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: summary }, + { type: "text", text: trackingLine }, + ], + }; + }) + .catch(function (err) { + return { + content: [{ type: "text", text: "Error spawning session: " + (err && err.message || err) }], + isError: true, + }; + }); + }, + }); + + 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 \"), 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", + description: + "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\", " + + "\"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 clear the done flag (move it back to Active).", + 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 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, __callingCliSessionId: callingCliSessionId }); }) + .then(function (result) { + var localId = result && result.sessionId; + var title = (result && result.newTitle) || ""; + var nowDone = !!(result && result.done); + var alreadyInState = !!(result && result.wasAlreadyDone); + var summary; + 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 = "Session #" + localId + " is " + (nowDone ? "done" : "active") + "."; + } + var trackingLine = "[clay-sessions/mark_session_done] localId=" + localId + + " cliSessionId=" + ((result && result.cliSessionId) || "(none)") + + " title=" + JSON.stringify(title) + + " action=" + (undo ? "undo" : "done") + + " done=" + nowDone; + 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; +} + +module.exports = { getToolDefs: getToolDefs }; diff --git a/lib/tui-mcp-config.js b/lib/tui-mcp-config.js new file mode 100644 index 00000000..c88856d4 --- /dev/null +++ b/lib/tui-mcp-config.js @@ -0,0 +1,73 @@ +// 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 ... +// // 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, cliSessionId) { + 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); + // 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 = { + 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, +}; 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" }, 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 --- 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 `/` 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 `](jira.md) | Full JIRA→implementation workflow: fetch issue, rename the Clay session to `KEY - `, 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 - ` + 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 ` +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..64027697 --- /dev/null +++ b/skills/jira.md @@ -0,0 +1,193 @@ +# 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"`. + +**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 — + +**Type:** **Priority:** **Status:** + +### Description + + +### Acceptance Criteria + +``` + +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 - " +``` + +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. + +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 + +- **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. + 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.