clay-server 2.40.0-beta.2 → 2.40.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,28 +16,25 @@
16
16
 
17
17
  var fs = require("fs");
18
18
  var path = require("path");
19
+ var { buildClayBashAllowPatterns, isClayManagedBashPattern } = require("./safe-bash-commands");
19
20
 
20
21
  // Substring that uniquely identifies a Clay-installed hook entry so we can
21
22
  // safely remove/replace it without touching user-authored hooks.
22
23
  var CLAY_HOOK_MARKER = "clay:tui-notify";
23
24
 
24
25
  // Allow-patterns Clay manages in ~/.claude/settings.json `permissions.allow`.
25
- // These mirror sdk-bridge.js `checkToolWhitelist` so TUI sessions get the
26
- // same auto-approval convenience as SDK sessions. Conservative: only
27
- // commands that stay safe even under Claude Code's wildcard matching
28
- // (compound commands like `ls && rm -rf /` would otherwise sneak past
29
- // `Bash(ls *)`).
26
+ // The Bash(...) patterns are GENERATED from safe-bash-commands.js so this
27
+ // list and sdk-bridge.js `checkToolWhitelist` can never drift - there is one
28
+ // source of truth for which bash commands Clay auto-approves.
30
29
  //
31
- // Pattern syntax note: claude 2.x uses space-wildcard form `Bash(cmd *)`
30
+ // Pattern syntax note: claude 2.x uses the space-wildcard form `Bash(cmd *)`
32
31
  // for prefix matching. The older colon form `Bash(cmd:*)` is flagged as
33
- // legacy in the CLI and no longer reliably matches argument-bearing
34
- // commands - the TUI was still prompting for read-only invocations like
35
- // `ls -la` even with the allow-list installed. Patterns below use the
36
- // modern form. The bare command (e.g. `Bash(ls)`) is included alongside
37
- // the wildcard so zero-arg invocations are also covered.
32
+ // legacy in the CLI and no longer reliably matches argument-bearing commands
33
+ // (the TUI kept prompting for read-only invocations like `ls -la`). The
34
+ // generator emits the modern form plus a bare `Bash(cmd)` for zero-arg use.
38
35
  //
39
- // User-authored entries are preserved -- on re-install we only strip
40
- // patterns that appear in this constant list.
36
+ // User-authored entries are preserved -- on re-install we only strip patterns
37
+ // that are Clay-managed (current generated list or the legacy colon shape).
41
38
  var CLAY_MANAGED_ALLOW = [
42
39
  // Read-only built-in tools (no side effects).
43
40
  "Read", "Glob", "Grep", "WebFetch", "WebSearch",
@@ -53,53 +50,7 @@ var CLAY_MANAGED_ALLOW = [
53
50
  "mcp__clay-email__clay_read_email_body",
54
51
  "mcp__clay-email__clay_search_email",
55
52
  "mcp__clay-email__clay_list_labels",
56
-
57
- // Safe Bash commands. Match the curated set in sdk-bridge.js's
58
- // safeBashCommands, restricted to ones whose pure read-only behavior
59
- // doesn't depend on argument shape.
60
- "Bash(ls)", "Bash(ls *)", "Bash(cat *)", "Bash(head *)", "Bash(tail *)", "Bash(wc *)",
61
- "Bash(file *)", "Bash(stat *)", "Bash(find *)", "Bash(tree)", "Bash(tree *)",
62
- "Bash(du *)", "Bash(df)", "Bash(df *)", "Bash(readlink *)", "Bash(realpath *)",
63
- "Bash(basename *)", "Bash(dirname *)",
64
- "Bash(grep *)", "Bash(rg *)", "Bash(ag *)", "Bash(ack *)",
65
- "Bash(fgrep *)", "Bash(egrep *)",
66
- "Bash(which *)", "Bash(type *)", "Bash(whereis *)",
67
- "Bash(echo)", "Bash(echo *)", "Bash(printf *)", "Bash(env)", "Bash(env *)", "Bash(printenv)", "Bash(printenv *)",
68
- "Bash(pwd)", "Bash(whoami)", "Bash(id)", "Bash(id *)", "Bash(groups)", "Bash(groups *)",
69
- "Bash(date)", "Bash(date *)", "Bash(uname)", "Bash(uname *)", "Bash(hostname)", "Bash(uptime)",
70
- "Bash(arch)", "Bash(nproc)", "Bash(free)", "Bash(free *)",
71
- "Bash(lsb_release *)", "Bash(sw_vers)", "Bash(sw_vers *)", "Bash(locale)", "Bash(locale *)",
72
- // Git read-only subcommands. Listed individually so write subcommands
73
- // (commit, push, reset, etc.) still prompt.
74
- "Bash(git status)", "Bash(git status *)", "Bash(git log)", "Bash(git log *)",
75
- "Bash(git diff)", "Bash(git diff *)", "Bash(git show)", "Bash(git show *)",
76
- "Bash(git branch)", "Bash(git branch *)", "Bash(git tag)", "Bash(git tag *)",
77
- "Bash(git remote)", "Bash(git remote *)", "Bash(git config --get *)",
78
- "Bash(git rev-parse *)", "Bash(git ls-files)", "Bash(git ls-files *)",
79
- "Bash(git blame *)", "Bash(git describe)", "Bash(git describe *)",
80
- // Package manager read-only subcommands
81
- "Bash(npm list)", "Bash(npm list *)", "Bash(npm ls)", "Bash(npm ls *)",
82
- "Bash(npm view *)", "Bash(npm outdated)", "Bash(npm outdated *)",
83
- "Bash(npm config get *)", "Bash(yarn list)", "Bash(yarn list *)", "Bash(pnpm list)", "Bash(pnpm list *)",
84
- // Version checks
85
- "Bash(node --version)", "Bash(npm --version)", "Bash(python --version)",
86
- "Bash(python3 --version)", "Bash(go version)", "Bash(ruby --version)",
87
- // Text processing (pure stdin/stdout)
88
- "Bash(jq *)", "Bash(yq *)", "Bash(sort)", "Bash(sort *)", "Bash(uniq)", "Bash(uniq *)",
89
- "Bash(cut *)", "Bash(tr *)", "Bash(awk *)", "Bash(sed *)",
90
- "Bash(paste *)", "Bash(column *)", "Bash(rev)", "Bash(rev *)", "Bash(tac)", "Bash(tac *)",
91
- "Bash(nl)", "Bash(nl *)", "Bash(fmt)", "Bash(fmt *)", "Bash(comm *)", "Bash(join *)",
92
- // Comparison / hashing (read-only)
93
- "Bash(diff *)", "Bash(cmp *)", "Bash(md5sum *)", "Bash(sha256sum *)",
94
- "Bash(sha1sum *)", "Bash(shasum *)", "Bash(cksum *)", "Bash(base64)", "Bash(base64 *)",
95
- "Bash(xxd *)", "Bash(od *)", "Bash(hexdump *)",
96
- // Calendar / math
97
- "Bash(cal)", "Bash(cal *)", "Bash(bc)", "Bash(bc *)", "Bash(expr *)", "Bash(factor *)", "Bash(seq *)",
98
- // Process / network introspection (read-only)
99
- "Bash(ps)", "Bash(ps *)", "Bash(top)", "Bash(top *)", "Bash(htop)", "Bash(pgrep *)", "Bash(lsof)", "Bash(lsof *)",
100
- "Bash(netstat)", "Bash(netstat *)", "Bash(ss)", "Bash(ss *)", "Bash(ifconfig)", "Bash(ifconfig *)", "Bash(ip *)",
101
- "Bash(dig *)", "Bash(nslookup *)", "Bash(host *)",
102
- ];
53
+ ].concat(buildClayBashAllowPatterns());
103
54
 
104
55
  function buildHookCommand(notifyUrl) {
105
56
  // Read stdin once (the JSON Claude Code pipes in), then post it. --max-time
@@ -197,27 +148,25 @@ function installNotificationHook(opts) {
197
148
  return { installed: installed, errors: errors };
198
149
  }
199
150
 
200
- // Pattern shapes Clay used to install in older versions. Listed here so an
201
- // upgrade can strip them from settings.json before re-installing the modern
202
- // list. Without this, users who already had Clay running would end up with
203
- // stale legacy-syntax entries next to the new ones - mostly harmless but
204
- // noisy, and confusing if anyone reads the file.
151
+ // Old colon-prefix Bash patterns Clay wrote before claude 2.x: Bash(ls:*),
152
+ // Bash(git status:*), etc. Stripped on upgrade so they don't linger next to
153
+ // the modern space-wildcard form. Modern Clay patterns never contain ":)" so
154
+ // this only catches the deprecated shape; it can't tell a user-authored colon
155
+ // pattern apart, but claude is phasing the colon form out anyway.
205
156
  function isLegacyClayPattern(p) {
206
157
  if (typeof p !== "string") return false;
207
- // Old colon-prefix Bash patterns: Bash(ls:*), Bash(git status:*), etc.
208
- // Modern patterns use space-asterisk and never contain ":*", so this is
209
- // a safe identifier for old Clay entries even though it can't tell apart
210
- // user-authored colon patterns. The risk is judged acceptable because
211
- // claude flags the colon form as legacy and is phasing it out anyway.
212
158
  if (p.indexOf("Bash(") === 0 && p.indexOf(":*)") !== -1) return true;
213
159
  return false;
214
160
  }
215
161
 
216
162
  // Merge Clay's managed allow-list into permissions.allow without disturbing
217
- // the user's own entries. We identify "ours" by membership in
218
- // CLAY_MANAGED_ALLOW (current) or isLegacyClayPattern (previous shape):
219
- // re-install strips both, then re-inserts the current list. User-authored
220
- // patterns survive unchanged.
163
+ // the user's own entries. "Ours" = exact membership in CLAY_MANAGED_ALLOW
164
+ // (the non-Bash built-in / MCP entries) OR isClayManagedBashPattern (any
165
+ // shape of a bash command we own, so stale variants from older versions get
166
+ // swept) OR the legacy colon shape. Everything else is preserved, then the
167
+ // fresh list is appended. Because the Bash patterns are generated from
168
+ // safe-bash-commands.js, a re-install always writes the current set and can
169
+ // never leave a stale Clay block behind.
221
170
  function mergeAllowList(settingsPath, patterns) {
222
171
  var data = readSettings(settingsPath);
223
172
  if (!data.permissions || typeof data.permissions !== "object") data.permissions = {};
@@ -226,10 +175,9 @@ function mergeAllowList(settingsPath, patterns) {
226
175
  var managedSet = {};
227
176
  for (var i = 0; i < CLAY_MANAGED_ALLOW.length; i++) managedSet[CLAY_MANAGED_ALLOW[i]] = true;
228
177
 
229
- // Strip prior Clay-managed entries (current shape + legacy colon shape),
230
- // then append the fresh list.
231
178
  var preserved = allow.filter(function (p) {
232
179
  if (managedSet[p]) return false;
180
+ if (isClayManagedBashPattern(p)) return false;
233
181
  if (isLegacyClayPattern(p)) return false;
234
182
  return true;
235
183
  });
@@ -175,6 +175,53 @@ function extractText(content) {
175
175
  * history entries (user_message, delta, tool_start, tool_executing, tool_result).
176
176
  * Returns a Promise that resolves to an array of history entries.
177
177
  */
178
+ // Convert one parsed CLI jsonl record into client history entries. Shared by
179
+ // the streaming (async) and synchronous readers so the format stays in lockstep.
180
+ // `state.toolCounter` carries across lines to mint unique tool ids.
181
+ function appendCliRecord(obj, state, history) {
182
+ if (!obj || !obj.message) return;
183
+
184
+ // User prompt
185
+ if (obj.type === "user" && obj.message.role === "user") {
186
+ // Skip tool_result records (they have type "user" but content is tool results)
187
+ var content = obj.message.content;
188
+ if (Array.isArray(content) && content.length > 0 && content[0].type === "tool_result") {
189
+ return;
190
+ }
191
+ var text = extractText(content);
192
+ if (text) history.push({ type: "user_message", text: text });
193
+ return;
194
+ }
195
+
196
+ // Assistant message
197
+ if (obj.message.role === "assistant" && Array.isArray(obj.message.content)) {
198
+ for (var i = 0; i < obj.message.content.length; i++) {
199
+ var block = obj.message.content[i];
200
+
201
+ if (block.type === "text" && block.text) {
202
+ history.push({ type: "delta", text: block.text });
203
+ }
204
+
205
+ if (block.type === "tool_use") {
206
+ var toolId = "cli-tool-" + (++state.toolCounter);
207
+ var toolName = block.name || "Tool";
208
+ history.push({ type: "tool_start", id: toolId, name: toolName });
209
+ history.push({
210
+ type: "tool_executing",
211
+ id: toolId,
212
+ name: toolName,
213
+ input: block.input || {},
214
+ });
215
+ // Emit ask_user_answered so the client re-enables input after replaying AskUserQuestion
216
+ if (toolName === "AskUserQuestion") {
217
+ history.push({ type: "ask_user_answered", toolId: toolId });
218
+ }
219
+ history.push({ type: "tool_result", id: toolId, content: "" });
220
+ }
221
+ }
222
+ }
223
+ }
224
+
178
225
  function readCliSessionHistory(cwd, sessionId) {
179
226
  var encoded = encodeCwd(cwd);
180
227
  var filePath = path.join(REAL_HOME, ".claude", "projects", encoded, sessionId + ".jsonl");
@@ -189,55 +236,12 @@ function readCliSessionHistory(cwd, sessionId) {
189
236
  }
190
237
 
191
238
  var rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
192
- var toolCounter = 0;
239
+ var state = { toolCounter: 0 };
193
240
 
194
241
  rl.on("line", function (line) {
195
242
  var obj;
196
243
  try { obj = JSON.parse(line); } catch (e) { return; }
197
-
198
- if (!obj.message) return;
199
-
200
- // User prompt
201
- if (obj.type === "user" && obj.message.role === "user") {
202
- // Skip tool_result records (they have type "user" but content is tool results)
203
- var content = obj.message.content;
204
- if (Array.isArray(content) && content.length > 0 && content[0].type === "tool_result") {
205
- return;
206
- }
207
- var text = extractText(content);
208
- if (text) {
209
- history.push({ type: "user_message", text: text });
210
- }
211
- return;
212
- }
213
-
214
- // Assistant message
215
- if (obj.message.role === "assistant" && Array.isArray(obj.message.content)) {
216
- for (var i = 0; i < obj.message.content.length; i++) {
217
- var block = obj.message.content[i];
218
-
219
- if (block.type === "text" && block.text) {
220
- history.push({ type: "delta", text: block.text });
221
- }
222
-
223
- if (block.type === "tool_use") {
224
- var toolId = "cli-tool-" + (++toolCounter);
225
- var toolName = block.name || "Tool";
226
- history.push({ type: "tool_start", id: toolId, name: toolName });
227
- history.push({
228
- type: "tool_executing",
229
- id: toolId,
230
- name: toolName,
231
- input: block.input || {},
232
- });
233
- // Emit ask_user_answered so the client re-enables input after replaying AskUserQuestion
234
- if (toolName === "AskUserQuestion") {
235
- history.push({ type: "ask_user_answered", toolId: toolId });
236
- }
237
- history.push({ type: "tool_result", id: toolId, content: "" });
238
- }
239
- }
240
- }
244
+ appendCliRecord(obj, state, history);
241
245
  });
242
246
 
243
247
  rl.on("close", function () {
@@ -255,10 +259,41 @@ function readCliSessionHistory(cwd, sessionId) {
255
259
  });
256
260
  }
257
261
 
262
+ // Synchronous variant for callers that run inside a synchronous request
263
+ // handler (e.g. switch_session, which must populate session.history before
264
+ // the session_switched broadcast). Reads the whole jsonl - these transcripts
265
+ // are small enough that blocking on a local read is fine.
266
+ // Modified-time (ms) of a CLI session's jsonl, or 0 if missing. Lets callers
267
+ // cheaply detect that the transcript grew (e.g. after a TUI turn) and re-read.
268
+ function cliSessionFileMtime(cwd, sessionId) {
269
+ var encoded = encodeCwd(cwd);
270
+ var filePath = path.join(REAL_HOME, ".claude", "projects", encoded, sessionId + ".jsonl");
271
+ try { return fs.statSync(filePath).mtimeMs; } catch (e) { return 0; }
272
+ }
273
+
274
+ function readCliSessionHistorySync(cwd, sessionId) {
275
+ var encoded = encodeCwd(cwd);
276
+ var filePath = path.join(REAL_HOME, ".claude", "projects", encoded, sessionId + ".jsonl");
277
+ var raw;
278
+ try { raw = fs.readFileSync(filePath, "utf8"); } catch (e) { return []; }
279
+ var history = [];
280
+ var state = { toolCounter: 0 };
281
+ var lines = raw.split("\n");
282
+ for (var i = 0; i < lines.length; i++) {
283
+ if (!lines[i]) continue;
284
+ var obj;
285
+ try { obj = JSON.parse(lines[i]); } catch (e) { continue; }
286
+ appendCliRecord(obj, state, history);
287
+ }
288
+ return history;
289
+ }
290
+
258
291
  module.exports = {
259
292
  listCliSessions: listCliSessions,
260
293
  getMostRecentCliSession: getMostRecentCliSession,
261
294
  readCliSessionHistory: readCliSessionHistory,
295
+ readCliSessionHistorySync: readCliSessionHistorySync,
296
+ cliSessionFileMtime: cliSessionFileMtime,
262
297
  parseSessionFile: parseSessionFile,
263
298
  encodeCwd: encodeCwd,
264
299
  extractText: extractText,
@@ -39,6 +39,7 @@ function attachConnection(ctx) {
39
39
  var _mcp = ctx._mcp;
40
40
  var _notifications = ctx._notifications;
41
41
  var hydrateImageRefs = ctx.hydrateImageRefs;
42
+ var resolveSessionForView = ctx.resolveSessionForView;
42
43
  var broadcastClientCount = ctx.broadcastClientCount;
43
44
  var broadcastPresence = ctx.broadcastPresence;
44
45
  var getProjectList = ctx.getProjectList;
@@ -221,8 +222,15 @@ function attachConnection(ctx) {
221
222
  sm.saveSessionFile(active);
222
223
  }
223
224
  ws._clayActiveSession = active.localId;
225
+ // Resolve the lazy-resume view (runtimeMode / runtimeTerminalId /
226
+ // tuiSuspended + transcript hydration) the same way switch_session does,
227
+ // so a born-TUI session restored on (re)connect shows the read-only
228
+ // history + Resume bar instead of an editable composer. No PTY spawn.
229
+ if (typeof resolveSessionForView === "function") {
230
+ try { resolveSessionForView(active, ws); } catch (e) {}
231
+ }
224
232
  var _vendorCaps = (sm.capabilitiesByVendor && sm.capabilitiesByVendor[active.vendor || sm.defaultVendor || "claude"]) || {};
225
- sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps, mode: active.mode || "gui", terminalId: typeof active.terminalId === "number" ? active.terminalId : null, runtimeMode: active.runtimeMode || null, runtimeTerminalId: typeof active.runtimeTerminalId === "number" ? active.runtimeTerminalId : null });
233
+ sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps, mode: active.mode || "gui", terminalId: typeof active.terminalId === "number" ? active.terminalId : null, runtimeMode: active.runtimeMode || null, runtimeTerminalId: typeof active.runtimeTerminalId === "number" ? active.runtimeTerminalId : null, tuiSuspended: !!active.tuiSuspended });
226
234
  // Send per-session context sources
227
235
  var sessionSources = loadContextSources(slug, active.localId);
228
236
  sendTo(ws, { type: "context_sources_state", active: sessionSources });
@@ -277,15 +277,25 @@ function attachSessions(ctx) {
277
277
  // every click while the user is reading the session in GUI mode).
278
278
  function prepareTuiSessionForGuiView(session) {
279
279
  if (!session || session.cliSessionId == null) return;
280
- var alreadyHydrated = (typeof session.terminalId !== "number") &&
281
- Array.isArray(session.history) && session.history.length > 0;
282
- if (alreadyHydrated) return;
283
280
  var cliSess;
284
281
  try { cliSess = require("./cli-sessions"); } catch (e) { return; }
282
+ // Re-read whenever the jsonl has changed since the last hydrate (a TUI
283
+ // turn appended messages), not just when history is empty. The earlier
284
+ // "already hydrated" short-circuit kept stale history after a
285
+ // Resume -> chat -> Close cycle (showed A instead of A').
286
+ var mtime = cliSess.cliSessionFileMtime(cwd, session.cliSessionId);
287
+ var fresh = (typeof session.terminalId !== "number") &&
288
+ Array.isArray(session.history) && session.history.length > 0 &&
289
+ session._historyMtime === mtime;
290
+ if (fresh) return;
285
291
  var history = null;
286
- try { history = cliSess.readCliSessionHistory(cwd, session.cliSessionId); } catch (e) { history = null; }
292
+ // Synchronous read: switch_session must populate session.history before
293
+ // the session_switched broadcast replays it. readCliSessionHistory is
294
+ // Promise-based and would resolve too late (the transcript came up empty).
295
+ try { history = cliSess.readCliSessionHistorySync(cwd, session.cliSessionId); } catch (e) { history = null; }
287
296
  if (Array.isArray(history)) {
288
297
  session.history = history;
298
+ session._historyMtime = mtime;
289
299
  }
290
300
  if (typeof session.terminalId === "number" && tm) {
291
301
  try { tm.close(session.terminalId); } catch (e) {}
@@ -294,6 +304,46 @@ function attachSessions(ctx) {
294
304
  try { sm.saveSessionFile(session); } catch (e) {}
295
305
  }
296
306
 
307
+ // Resolve how a session should be presented to a viewer WITHOUT spawning a
308
+ // PTY or broadcasting: set runtimeMode / runtimeTerminalId / tuiSuspended and
309
+ // hydrate the transcript for the read-only and GUI cases. Single source of
310
+ // truth shared by switch_session (which additionally spawns for born-GUI)
311
+ // and the connect/restore path (project-connection.js) so a refreshed
312
+ // born-TUI session shows the same read-only + Resume view as a fresh click.
313
+ function resolveSessionForView(session, ws) {
314
+ if (!session) return;
315
+ if (session.vendor && session.vendor !== "claude") { session.tuiSuspended = false; return; }
316
+ var pref = getClaudeOpenModeForWs(ws);
317
+ if (session.mode === "tui") {
318
+ if (pref === "gui") {
319
+ prepareTuiSessionForGuiView(session);
320
+ session.runtimeMode = "gui";
321
+ session.runtimeTerminalId = null;
322
+ session.tuiSuspended = false;
323
+ } else if (tm && typeof session.terminalId === "number" && tm.has(session.terminalId)) {
324
+ session.runtimeMode = "tui";
325
+ session.runtimeTerminalId = session.terminalId;
326
+ session.tuiSuspended = false;
327
+ } else if (tm && typeof session.runtimeTerminalId === "number" && tm.has(session.runtimeTerminalId)) {
328
+ session.runtimeMode = "tui";
329
+ session.tuiSuspended = false;
330
+ } else {
331
+ prepareTuiSessionForGuiView(session);
332
+ session.runtimeMode = null;
333
+ session.runtimeTerminalId = null;
334
+ session.tuiSuspended = true;
335
+ }
336
+ } else {
337
+ // Born-GUI: reattach a live resume PTY if one exists; never spawn here.
338
+ if (pref === "tui" && tm && typeof session.runtimeTerminalId === "number" && tm.has(session.runtimeTerminalId)) {
339
+ session.runtimeMode = "tui";
340
+ } else {
341
+ session.runtimeMode = null;
342
+ }
343
+ session.tuiSuspended = false;
344
+ }
345
+ }
346
+
297
347
  // Compute the runtimeMode the client should render for this session given
298
348
  // the user's current preference. Pure function over session + pref; the
299
349
  // caller decides whether to also mutate state (spawn PTY, convert, etc.)
@@ -374,8 +424,17 @@ function attachSessions(ctx) {
374
424
  initialInput: tuiCmd,
375
425
  kind: "tui-session",
376
426
  title: "claude " + tuiSid.slice(0, 8),
377
- onExit: function () {
378
- if (sm.sessions.has(tuiLocalId)) {
427
+ onExit: function (termSession) {
428
+ var s = sm.sessions.get(tuiLocalId);
429
+ if (!s) return;
430
+ if (termSession && termSession.reclaimed) {
431
+ // Reclaimed (idle sweep or explicit Close), not a real /exit:
432
+ // keep the session (its jsonl transcript stays on disk and
433
+ // lazy-resume can re-spawn claude). Just drop the dead PTY link.
434
+ s.terminalId = null;
435
+ try { sm.saveSessionFile(s); } catch (e) {}
436
+ try { sm.broadcastSessionList(); } catch (e) {}
437
+ } else {
379
438
  try { sm.deleteSessionQuiet(tuiLocalId); } catch (e) {}
380
439
  try { sm.broadcastSessionList(); } catch (e) {}
381
440
  }
@@ -527,56 +586,16 @@ function attachSessions(ctx) {
527
586
  var xmTarget = sm.sessions.get(msg.id);
528
587
  if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) {
529
588
  var xmPref = getClaudeOpenModeForWs(ws);
530
- // Born-TUI session whose PTY is gone (typically after a daemon
531
- // restart) AND the user wants TUI rendering this click. Respawn
532
- // via `claude --resume <cliSessionId>` so the rest of the pipeline
533
- // has a live PTY to point at. Skipped when the user's pref is GUI
534
- // - we'd just tear it back down two lines later.
535
- if (xmTarget.mode === "tui" && xmTarget.cliSessionId && tm && xmPref === "tui" &&
536
- (typeof xmTarget.terminalId !== "number" || !tm.has(xmTarget.terminalId))) {
537
- var rsSid = xmTarget.cliSessionId;
538
- var rsLocalId = xmTarget.localId;
539
- var rsCmd = "claude --resume " + rsSid + "; exit\n";
540
- var rsTerm = tm.create(80, 24, getOsUserInfoForWs(ws), ws, {
541
- initialInput: rsCmd,
542
- kind: "tui-session",
543
- title: "claude " + rsSid.slice(0, 8),
544
- onExit: function () {
545
- if (sm.sessions.has(rsLocalId)) {
546
- try { sm.deleteSessionQuiet(rsLocalId); } catch (e) {}
547
- try { sm.broadcastSessionList(); } catch (e) {}
548
- }
549
- },
550
- onData: makeTuiActivityHook(rsLocalId),
551
- });
552
- if (rsTerm) xmTarget.terminalId = rsTerm.id;
553
- startTitleWatcher(xmTarget);
554
- }
555
- var xmRuntime = computeRuntimeMode(xmTarget, xmPref);
556
- if (xmRuntime === "gui" && xmTarget.mode === "tui") {
557
- // Born-TUI session under GUI pref. Hydrate history + drop PTY
558
- // without flipping session.mode, so flipping back to TUI later
559
- // restores the embedded-terminal rendering. runtimeMode is set
560
- // to 'gui' explicitly so the client doesn't fall back to
561
- // session.mode (which is still 'tui').
562
- prepareTuiSessionForGuiView(xmTarget);
563
- xmTarget.runtimeMode = "gui";
564
- xmTarget.runtimeTerminalId = null;
565
- } else if (xmRuntime === "tui" && xmTarget.mode === "gui" && xmTarget.cliSessionId) {
566
- var xmRid = spawnRuntimeTuiPty(xmTarget, ws);
567
- if (typeof xmRid === "number") {
568
- xmTarget.runtimeMode = "tui";
569
- xmTarget.runtimeTerminalId = xmRid;
570
- } else {
571
- xmTarget.runtimeMode = null;
572
- xmTarget.runtimeTerminalId = null;
573
- }
574
- } else {
575
- // Same-mode click. Don't leak a stale runtime override into the
576
- // payload, but keep any background runtime PTY alive (it may be
577
- // re-attached on the next pref flip).
578
- xmTarget.runtimeMode = null;
589
+ // Born-GUI under TUI pref with no live resume PTY: spawn a transient
590
+ // `claude --resume` now (only switch_session spawns; born-TUI sessions
591
+ // stay lazy and the connect path never spawns). resolveSessionForView
592
+ // then turns the live PTY into runtimeMode='tui'.
593
+ if (xmTarget.mode === "gui" && xmTarget.cliSessionId &&
594
+ computeRuntimeMode(xmTarget, xmPref) === "tui" &&
595
+ !(tm && typeof xmTarget.runtimeTerminalId === "number" && tm.has(xmTarget.runtimeTerminalId))) {
596
+ spawnRuntimeTuiPty(xmTarget, ws);
579
597
  }
598
+ resolveSessionForView(xmTarget, ws);
580
599
  }
581
600
  // If the target session's vendor doesn't own the currently cached
582
601
  // model, clear sm.currentModel so the UI and next query don't leak
@@ -618,6 +637,63 @@ function attachSessions(ctx) {
618
637
  return true;
619
638
  }
620
639
 
640
+ // Lazy-resume: the user clicked "Resume" on a TUI session shown read-only.
641
+ // Spawn `claude --resume <cliSessionId>` now and re-broadcast
642
+ // session_switched so the client swaps the transcript for the live xterm.
643
+ if (msg.type === "resume_tui_session") {
644
+ if (msg.id && sm.sessions.has(msg.id)) {
645
+ var rtTarget = sm.sessions.get(msg.id);
646
+ var rtOk = rtTarget && (rtTarget.vendor === "claude" || !rtTarget.vendor) &&
647
+ rtTarget.cliSessionId && tm;
648
+ if (rtOk) {
649
+ if (usersModule.isMultiUser() && ws._clayUser &&
650
+ !usersModule.canAccessSession(ws._clayUser.id, rtTarget, { visibility: "public" })) {
651
+ return true;
652
+ }
653
+ var rtRid = spawnRuntimeTuiPty(rtTarget, ws);
654
+ if (typeof rtRid === "number") {
655
+ rtTarget.runtimeMode = "tui";
656
+ rtTarget.runtimeTerminalId = rtRid;
657
+ rtTarget.tuiSuspended = false;
658
+ startTitleWatcher(rtTarget);
659
+ }
660
+ ws._clayActiveSession = msg.id;
661
+ sm.switchSession(msg.id, ws, hydrateImageRefs);
662
+ }
663
+ }
664
+ return true;
665
+ }
666
+
667
+ // Explicit Close: the user closed a live TUI session from the title bar.
668
+ // Kill its PTY now (don't wait for the idle sweep) but keep the session -
669
+ // it drops to the read-only transcript + Resume bar, freeing resources
670
+ // immediately while staying resumable.
671
+ if (msg.type === "suspend_tui_session") {
672
+ if (msg.id && sm.sessions.has(msg.id)) {
673
+ var stTarget = sm.sessions.get(msg.id);
674
+ var stOk = stTarget && (stTarget.vendor === "claude" || !stTarget.vendor);
675
+ if (stOk && (!usersModule.isMultiUser() || !ws._clayUser ||
676
+ usersModule.canAccessSession(ws._clayUser.id, stTarget, { visibility: "public" }))) {
677
+ if (tm) {
678
+ var stTid = (typeof stTarget.terminalId === "number") ? stTarget.terminalId : null;
679
+ var stRid = (typeof stTarget.runtimeTerminalId === "number") ? stTarget.runtimeTerminalId : null;
680
+ if (stTid != null && tm.has(stTid)) { tm.markReclaimed(stTid); tm.close(stTid); }
681
+ if (stRid != null && tm.has(stRid)) { tm.markReclaimed(stRid); tm.close(stRid); }
682
+ }
683
+ stTarget.terminalId = null;
684
+ stTarget.runtimeTerminalId = null;
685
+ // Hydrate the transcript so the read-only view has content, then
686
+ // re-broadcast as suspended.
687
+ prepareTuiSessionForGuiView(stTarget);
688
+ stTarget.runtimeMode = null;
689
+ stTarget.tuiSuspended = true;
690
+ ws._clayActiveSession = msg.id;
691
+ sm.switchSession(msg.id, ws, hydrateImageRefs);
692
+ }
693
+ }
694
+ return true;
695
+ }
696
+
621
697
  if (msg.type === "set_mate_dm") {
622
698
  // Only store mateDm on non-mate projects (main project presence).
623
699
  // Mate projects should never hold mateDm to avoid circular restore loops.
@@ -1758,6 +1834,7 @@ function attachSessions(ctx) {
1758
1834
 
1759
1835
  return {
1760
1836
  handleSessionsMessage: handleSessionsMessage,
1837
+ resolveSessionForView: resolveSessionForView,
1761
1838
  };
1762
1839
  }
1763
1840
 
package/lib/project.js CHANGED
@@ -1429,6 +1429,7 @@ function createProjectContext(opts) {
1429
1429
  _loop: _loop,
1430
1430
  _mcp: _mcp,
1431
1431
  _notifications: _notifications,
1432
+ resolveSessionForView: _sessions.resolveSessionForView,
1432
1433
  hydrateImageRefs: hydrateImageRefs,
1433
1434
  broadcastClientCount: broadcastClientCount,
1434
1435
  broadcastPresence: broadcastPresence,
@@ -1414,7 +1414,7 @@
1414
1414
  }
1415
1415
 
1416
1416
  /* --- Terminal key toolbar (mobile) --- */
1417
- #terminal-toolbar {
1417
+ .term-toolbar {
1418
1418
  display: flex;
1419
1419
  align-items: center;
1420
1420
  gap: 6px;
@@ -1426,7 +1426,7 @@
1426
1426
  -webkit-overflow-scrolling: touch;
1427
1427
  }
1428
1428
 
1429
- #terminal-toolbar.hidden { display: none; }
1429
+ .term-toolbar.hidden { display: none; }
1430
1430
 
1431
1431
  .term-key {
1432
1432
  display: flex;
@@ -1154,6 +1154,18 @@
1154
1154
  .mobile-only { display: none !important; }
1155
1155
  }
1156
1156
 
1157
+ /* Mobile TUI: the composer is a plain conduit to the PTY (session-tui-view.js
1158
+ toggles body.tui-composer-active). Hide controls that don't apply to a
1159
+ terminal session - scheduled send, @mentions, and model/vendor config.
1160
+ Attach (files/images -> path injection) and voice input stay available. */
1161
+ body.tui-composer-active #schedule-btn,
1162
+ body.tui-composer-active #ask-mate-btn,
1163
+ body.tui-composer-active #vendor-toggle-wrap,
1164
+ body.tui-composer-active #active-vendor-indicator,
1165
+ body.tui-composer-active #config-chip-wrap {
1166
+ display: none !important;
1167
+ }
1168
+
1157
1169
  /* ==========================================================================
1158
1170
  Input More Bottom Sheet (mobile)
1159
1171
  ========================================================================== */
@@ -43,6 +43,29 @@
43
43
  #header-rename-btn:hover { color: var(--text-secondary); background: rgba(var(--overlay-rgb),0.04); border-color: var(--border); }
44
44
  #header-rename-btn .lucide { width: 13px; height: 13px; }
45
45
 
46
+ /* "Close terminal" button: sits just left of the rename pencil during a live
47
+ TUI session. Matches the rename button's chrome; reds out on hover since it
48
+ stops the running claude (the session stays resumable). */
49
+ #header-tui-close-btn {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ width: 24px;
54
+ height: 24px;
55
+ border: 1px solid transparent;
56
+ background: none;
57
+ color: var(--text-dimmer);
58
+ cursor: pointer;
59
+ border-radius: 8px;
60
+ flex-shrink: 0;
61
+ padding: 0;
62
+ margin-left: 4px;
63
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
64
+ }
65
+ #header-tui-close-btn:hover { color: var(--error); background: rgba(var(--overlay-rgb),0.04); border-color: var(--border); }
66
+ #header-tui-close-btn .lucide { width: 14px; height: 14px; }
67
+ #header-tui-close-btn.hidden { display: none; }
68
+
46
69
  #header-info-btn {
47
70
  display: flex;
48
71
  align-items: center;