clay-server 2.40.0-beta.3 → 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.
- package/lib/claude-hook-installer.js +24 -76
- package/lib/cli-sessions.js +80 -45
- package/lib/project-connection.js +9 -1
- package/lib/project-sessions.js +132 -55
- package/lib/project.js +1 -0
- package/lib/public/css/filebrowser.css +2 -2
- package/lib/public/css/input.css +12 -0
- package/lib/public/css/menus.css +23 -0
- package/lib/public/css/sidebar.css +0 -85
- package/lib/public/css/tui-attention.css +41 -0
- package/lib/public/index.html +2 -1
- package/lib/public/modules/app-messages.js +4 -1
- package/lib/public/modules/input.js +43 -5
- package/lib/public/modules/session-tui-view.js +224 -10
- package/lib/public/modules/sidebar-mobile.js +2 -1
- package/lib/public/modules/sidebar-sessions.js +11 -83
- package/lib/public/modules/terminal-toolbar.js +129 -0
- package/lib/public/modules/terminal.js +15 -99
- package/lib/safe-bash-commands.js +171 -0
- package/lib/sdk-bridge.js +6 -51
- package/lib/sessions.js +1 -1
- package/lib/terminal-manager.js +44 -0
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
});
|
package/lib/cli-sessions.js
CHANGED
|
@@ -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
|
|
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 });
|
package/lib/project-sessions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
(typeof xmTarget.
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1429
|
+
.term-toolbar.hidden { display: none; }
|
|
1430
1430
|
|
|
1431
1431
|
.term-key {
|
|
1432
1432
|
display: flex;
|
package/lib/public/css/input.css
CHANGED
|
@@ -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
|
========================================================================== */
|
package/lib/public/css/menus.css
CHANGED
|
@@ -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;
|