Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a35541d
fix(ask-user): key answers by question text for SDK AskUserQuestionOu…
tusharsingh May 19, 2026
2e16e6d
Merge pull request #2 from tusharsingh/fix/ask-user-answers-by-questi…
tusharsingh May 19, 2026
e0993a0
fix(sessions): persist title-origin flags so manual renames survive r…
tusharsingh May 19, 2026
7ac1021
Merge pull request #3 from tusharsingh/fix/preserve-manual-session-re…
tusharsingh May 19, 2026
4e2f50a
feat(sessions): add clay-sessions MCP server with spawn_session tool
tusharsingh May 19, 2026
7f88721
feat(sessions): make spawn_session self-triggering on user phrases
tusharsingh May 19, 2026
358e2d8
feat(sessions): disambiguate spawn_session from clay-ralph
tusharsingh May 19, 2026
28b98f0
feat(tui): expose Clay MCP tools to TUI claude via existing mcp-bridge
tusharsingh May 19, 2026
a6d7611
fix(spawn): defer TUI PTY launch until first session open
tusharsingh May 19, 2026
f3280c4
fix(tui): persist mode + relaunch claude --resume after restart
tusharsingh May 19, 2026
c73ccea
fix(tui): terminate --mcp-config before positional prompt
tusharsingh May 19, 2026
7d0c006
fix(spawn): launch TUI PTY eagerly so work starts on spawn
tusharsingh May 19, 2026
8133e7d
docs(spawn): note that title is auto-derived from /jira <KEY>
tusharsingh May 19, 2026
ae1eccc
feat(spawn): default new sessions to plan mode + high effort
tusharsingh May 19, 2026
00d8cc6
feat(spawn): return localId + cliSessionId so dispatcher can track
tusharsingh May 19, 2026
a885dcb
feat(sessions): add mark_session_done MCP tool
tusharsingh May 19, 2026
fb67ba6
Merge pull request #4 from tusharsingh/feat/spawn-session-mcp
tusharsingh May 19, 2026
9c640fb
docs(readme): document session fan-out, mark_session_done, and TUI MC…
tusharsingh May 20, 2026
85a7204
fix(tui): use visualViewport so iOS keyboard doesn't cover the xterm
tusharsingh May 20, 2026
f6a621d
fix(sessions): pin MCP tool calls to the calling session, not the act…
tusharsingh May 20, 2026
0259186
feat(tui): drag-drop and + button for attaching files to TUI sessions
tusharsingh May 20, 2026
e2d990f
feat(sessions): add rename_session MCP tool for dynamic session titles
tusharsingh May 20, 2026
96061b7
fix(sidebar): suppress per-row TUI badge
tusharsingh May 20, 2026
aa2b2ae
fix(import-cli): union FS scan with SDK listSessions so all conversat…
tusharsingh May 20, 2026
e0ee621
feat(import-cli): "Show all" toggle gates the wider FS scan
tusharsingh May 20, 2026
12ed1ac
fix(sidebar): keep TUI session lastActivity current on open + input
tusharsingh May 20, 2026
8d8df80
fix(sidebar): drop click-to-bump; only real input keeps lastActivity …
tusharsingh May 20, 2026
0233b75
feat(sessions): structural done flag + Active/Completed sidebar tabs
tusharsingh May 20, 2026
43b5a62
feat(sidebar): right-click "Mark done" / "Mark active" on session items
tusharsingh May 20, 2026
d9646f0
fix(connection): include done/mode/terminalId in initial session_list
tusharsingh May 20, 2026
ada1485
docs(skills): ship /jira and /done slash commands in skills/
tusharsingh May 20, 2026
d87f450
docs(skills): /jira Step 8 also marks the Clay session done
tusharsingh May 20, 2026
e67e096
docs(skills): make /jira's issue-display step non-skippable
tusharsingh May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <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 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -193,7 +207,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

Expand Down
6 changes: 6 additions & 0 deletions lib/project-connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}),
});
Expand Down
5 changes: 5 additions & 0 deletions lib/project-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
172 changes: 142 additions & 30 deletions lib/project-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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/<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, 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 ("<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);
} 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);
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion lib/project-user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading