clay-server 2.39.0-beta.1 → 2.39.0-beta.3
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 +245 -0
- package/lib/claude-jsonl-watcher.js +204 -0
- package/lib/project-connection.js +15 -0
- package/lib/project-http.js +4 -0
- package/lib/project-notifications.js +13 -0
- package/lib/project-sessions.js +224 -10
- package/lib/project.js +1 -0
- package/lib/public/app.js +13 -0
- package/lib/public/css/notifications-center.css +13 -1
- package/lib/public/css/sidebar.css +1 -1
- package/lib/public/css/tui-attention.css +688 -0
- package/lib/public/css/user-settings.css +65 -0
- package/lib/public/index.html +34 -0
- package/lib/public/modules/app-home-hub.js +41 -0
- package/lib/public/modules/app-messages.js +66 -6
- package/lib/public/modules/app-notifications.js +90 -3
- package/lib/public/modules/app-projects.js +2 -0
- package/lib/public/modules/session-tui-view.js +100 -6
- package/lib/public/modules/sidebar-sessions.js +6 -12
- package/lib/public/modules/tui-attention.js +262 -0
- package/lib/public/modules/user-settings.js +26 -0
- package/lib/public/modules/whats-new-article.js +70 -0
- package/lib/public/modules/whats-new.js +264 -0
- package/lib/public/style.css +1 -0
- package/lib/server.js +178 -0
- package/lib/sessions.js +14 -0
- package/lib/terminal-manager.js +24 -0
- package/lib/users-preferences.js +119 -9
- package/lib/users.js +8 -0
- package/lib/whats-new-content.js +68 -0
- package/lib/whats-new.js +54 -0
- package/lib/ws-schema.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// claude-hook-installer.js
|
|
2
|
+
//
|
|
3
|
+
// Registers Clay's notification webhook in `~/.claude/settings.json` so the
|
|
4
|
+
// `claude` CLI fires a request to the Clay daemon whenever it surfaces a
|
|
5
|
+
// Notification (permission requests, idle prompts, etc.). This is what makes
|
|
6
|
+
// TUI sessions feel like first-class Clay sessions: the user gets the same
|
|
7
|
+
// toasts and push notifications they would in GUI mode.
|
|
8
|
+
//
|
|
9
|
+
// Design notes:
|
|
10
|
+
// - Settings merge is idempotent: existing hooks are preserved, ours are
|
|
11
|
+
// identified by a marker substring so re-runs don't duplicate them.
|
|
12
|
+
// - The hook command uses curl with --max-time / --connect-timeout so that
|
|
13
|
+
// a downed daemon can't stall claude's UI for more than ~1s.
|
|
14
|
+
// - Multi-user mode: caller passes one home directory per OS user; we
|
|
15
|
+
// write each user's own settings.json so per-user filtering works.
|
|
16
|
+
|
|
17
|
+
var fs = require("fs");
|
|
18
|
+
var path = require("path");
|
|
19
|
+
|
|
20
|
+
// Substring that uniquely identifies a Clay-installed hook entry so we can
|
|
21
|
+
// safely remove/replace it without touching user-authored hooks.
|
|
22
|
+
var CLAY_HOOK_MARKER = "clay:tui-notify";
|
|
23
|
+
|
|
24
|
+
// 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 prefix matching (compound
|
|
28
|
+
// commands like `ls && rm -rf /` would otherwise sneak past `Bash(ls:*)`).
|
|
29
|
+
//
|
|
30
|
+
// User-authored entries are preserved -- on re-install we only strip
|
|
31
|
+
// patterns that appear in this constant list.
|
|
32
|
+
var CLAY_MANAGED_ALLOW = [
|
|
33
|
+
// Read-only built-in tools (no side effects).
|
|
34
|
+
"Read", "Glob", "Grep", "WebFetch", "WebSearch",
|
|
35
|
+
|
|
36
|
+
// Clay's own MCP servers. Strictly read/safe by design.
|
|
37
|
+
"mcp__clay-browser__browser_watch_tab",
|
|
38
|
+
"mcp__clay-browser__browser_unwatch_tab",
|
|
39
|
+
"mcp__clay-debate__propose_debate",
|
|
40
|
+
"mcp__clay-datastore__*",
|
|
41
|
+
"mcp__clay-history__*",
|
|
42
|
+
// Email: read-side only. Send / reply / mark_read still prompt.
|
|
43
|
+
"mcp__clay-email__clay_read_email",
|
|
44
|
+
"mcp__clay-email__clay_read_email_body",
|
|
45
|
+
"mcp__clay-email__clay_search_email",
|
|
46
|
+
"mcp__clay-email__clay_list_labels",
|
|
47
|
+
|
|
48
|
+
// Safe Bash commands. Match the curated set in sdk-bridge.js's
|
|
49
|
+
// safeBashCommands, restricted to ones whose pure read-only behavior
|
|
50
|
+
// doesn't depend on argument shape.
|
|
51
|
+
"Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)",
|
|
52
|
+
"Bash(file:*)", "Bash(stat:*)", "Bash(find:*)", "Bash(tree:*)",
|
|
53
|
+
"Bash(du:*)", "Bash(df:*)", "Bash(readlink:*)", "Bash(realpath:*)",
|
|
54
|
+
"Bash(basename:*)", "Bash(dirname:*)",
|
|
55
|
+
"Bash(grep:*)", "Bash(rg:*)", "Bash(ag:*)", "Bash(ack:*)",
|
|
56
|
+
"Bash(fgrep:*)", "Bash(egrep:*)",
|
|
57
|
+
"Bash(which:*)", "Bash(type:*)", "Bash(whereis:*)",
|
|
58
|
+
"Bash(echo:*)", "Bash(printf:*)", "Bash(env:*)", "Bash(printenv:*)",
|
|
59
|
+
"Bash(pwd:*)", "Bash(whoami:*)", "Bash(id:*)", "Bash(groups:*)",
|
|
60
|
+
"Bash(date:*)", "Bash(uname:*)", "Bash(hostname:*)", "Bash(uptime:*)",
|
|
61
|
+
"Bash(arch:*)", "Bash(nproc:*)", "Bash(free:*)",
|
|
62
|
+
"Bash(lsb_release:*)", "Bash(sw_vers:*)", "Bash(locale:*)",
|
|
63
|
+
// Git read-only subcommands. Listed individually so write subcommands
|
|
64
|
+
// (commit, push, reset, etc.) still prompt.
|
|
65
|
+
"Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)",
|
|
66
|
+
"Bash(git show:*)", "Bash(git branch:*)", "Bash(git tag:*)",
|
|
67
|
+
"Bash(git remote:*)", "Bash(git config --get:*)",
|
|
68
|
+
"Bash(git rev-parse:*)", "Bash(git ls-files:*)",
|
|
69
|
+
"Bash(git blame:*)", "Bash(git describe:*)",
|
|
70
|
+
// Package manager read-only subcommands
|
|
71
|
+
"Bash(npm list:*)", "Bash(npm ls:*)", "Bash(npm view:*)", "Bash(npm outdated:*)",
|
|
72
|
+
"Bash(npm config get:*)", "Bash(yarn list:*)", "Bash(pnpm list:*)",
|
|
73
|
+
// Version checks
|
|
74
|
+
"Bash(node --version:*)", "Bash(npm --version:*)", "Bash(python --version:*)",
|
|
75
|
+
"Bash(python3 --version:*)", "Bash(go version:*)", "Bash(ruby --version:*)",
|
|
76
|
+
// Text processing (pure stdin/stdout)
|
|
77
|
+
"Bash(jq:*)", "Bash(yq:*)", "Bash(sort:*)", "Bash(uniq:*)",
|
|
78
|
+
"Bash(cut:*)", "Bash(tr:*)", "Bash(awk:*)", "Bash(sed:*)",
|
|
79
|
+
"Bash(paste:*)", "Bash(column:*)", "Bash(rev:*)", "Bash(tac:*)",
|
|
80
|
+
"Bash(nl:*)", "Bash(fmt:*)", "Bash(comm:*)", "Bash(join:*)",
|
|
81
|
+
// Comparison / hashing (read-only)
|
|
82
|
+
"Bash(diff:*)", "Bash(cmp:*)", "Bash(md5sum:*)", "Bash(sha256sum:*)",
|
|
83
|
+
"Bash(sha1sum:*)", "Bash(shasum:*)", "Bash(cksum:*)", "Bash(base64:*)",
|
|
84
|
+
"Bash(xxd:*)", "Bash(od:*)", "Bash(hexdump:*)",
|
|
85
|
+
// Calendar / math
|
|
86
|
+
"Bash(cal:*)", "Bash(bc:*)", "Bash(expr:*)", "Bash(factor:*)", "Bash(seq:*)",
|
|
87
|
+
// Process / network introspection (read-only)
|
|
88
|
+
"Bash(ps:*)", "Bash(top:*)", "Bash(htop:*)", "Bash(pgrep:*)", "Bash(lsof:*)",
|
|
89
|
+
"Bash(netstat:*)", "Bash(ss:*)", "Bash(ifconfig:*)", "Bash(ip:*)",
|
|
90
|
+
"Bash(dig:*)", "Bash(nslookup:*)", "Bash(host:*)",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function buildHookCommand(notifyUrl) {
|
|
94
|
+
// Read stdin once (the JSON Claude Code pipes in), then post it. --max-time
|
|
95
|
+
// and --connect-timeout cap total hook latency around ~1s even if the
|
|
96
|
+
// daemon is down. --insecure (-k) is needed because Clay's optional TLS
|
|
97
|
+
// mode uses a locally generated CA that curl won't trust by default;
|
|
98
|
+
// safe to skip verification since we're going to 127.0.0.1. Output is
|
|
99
|
+
// silenced so it doesn't leak into the TUI. The marker comment keeps the
|
|
100
|
+
// entry recognizable when we re-merge.
|
|
101
|
+
var insecure = notifyUrl.indexOf("https://") === 0 ? " --insecure" : "";
|
|
102
|
+
return "curl --silent --show-error --max-time 1 --connect-timeout 1" + insecure +
|
|
103
|
+
" -X POST -H 'Content-Type: application/json' --data-binary @- " +
|
|
104
|
+
JSON.stringify(notifyUrl) + " > /dev/null 2>&1 # " + CLAY_HOOK_MARKER;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readSettings(settingsPath) {
|
|
108
|
+
try {
|
|
109
|
+
var raw = fs.readFileSync(settingsPath, "utf8");
|
|
110
|
+
return JSON.parse(raw);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeSettings(settingsPath, data) {
|
|
117
|
+
var dir = path.dirname(settingsPath);
|
|
118
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
|
|
119
|
+
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Merge our Notification hook into the user's settings.json without
|
|
123
|
+
// clobbering anything else. Returns true if the file changed.
|
|
124
|
+
function mergeHook(settingsPath, command) {
|
|
125
|
+
var data = readSettings(settingsPath);
|
|
126
|
+
if (!data.hooks || typeof data.hooks !== "object") data.hooks = {};
|
|
127
|
+
if (!Array.isArray(data.hooks.Notification)) data.hooks.Notification = [];
|
|
128
|
+
|
|
129
|
+
// Strip any prior Clay entries before inserting the fresh one so port /
|
|
130
|
+
// URL changes propagate cleanly.
|
|
131
|
+
var before = JSON.stringify(data.hooks.Notification);
|
|
132
|
+
var cleaned = [];
|
|
133
|
+
for (var i = 0; i < data.hooks.Notification.length; i++) {
|
|
134
|
+
var group = data.hooks.Notification[i];
|
|
135
|
+
if (!group || !Array.isArray(group.hooks)) { cleaned.push(group); continue; }
|
|
136
|
+
var filtered = group.hooks.filter(function (h) {
|
|
137
|
+
return !(h && typeof h.command === "string" && h.command.indexOf(CLAY_HOOK_MARKER) !== -1);
|
|
138
|
+
});
|
|
139
|
+
if (filtered.length > 0) cleaned.push({ matcher: group.matcher || "", hooks: filtered });
|
|
140
|
+
else if (group.matcher) cleaned.push(group);
|
|
141
|
+
}
|
|
142
|
+
data.hooks.Notification = cleaned;
|
|
143
|
+
|
|
144
|
+
data.hooks.Notification.push({
|
|
145
|
+
matcher: "",
|
|
146
|
+
hooks: [{ type: "command", command: command }],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
var after = JSON.stringify(data.hooks.Notification);
|
|
150
|
+
if (before === after) return false;
|
|
151
|
+
writeSettings(settingsPath, data);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Install (or refresh) the Notification hook in one or more user home dirs.
|
|
157
|
+
*
|
|
158
|
+
* opts:
|
|
159
|
+
* notifyUrl -- full URL Clay listens on, e.g. "http://127.0.0.1:2633/api/tui-notify".
|
|
160
|
+
* homeDirs -- array of absolute paths to user home directories. Each one
|
|
161
|
+
* gets its own settings.json updated. Defaults to [os.homedir()].
|
|
162
|
+
*
|
|
163
|
+
* Returns { installed: [paths], errors: [{path, error}] } so the caller can log.
|
|
164
|
+
*/
|
|
165
|
+
function installNotificationHook(opts) {
|
|
166
|
+
opts = opts || {};
|
|
167
|
+
var os = require("os");
|
|
168
|
+
var homes = opts.homeDirs && opts.homeDirs.length ? opts.homeDirs : [os.homedir()];
|
|
169
|
+
var notifyUrl = opts.notifyUrl;
|
|
170
|
+
if (!notifyUrl) throw new Error("installNotificationHook: notifyUrl is required");
|
|
171
|
+
|
|
172
|
+
var command = buildHookCommand(notifyUrl);
|
|
173
|
+
var installed = [];
|
|
174
|
+
var errors = [];
|
|
175
|
+
for (var i = 0; i < homes.length; i++) {
|
|
176
|
+
var home = homes[i];
|
|
177
|
+
if (!home) continue;
|
|
178
|
+
var settingsPath = path.join(home, ".claude", "settings.json");
|
|
179
|
+
try {
|
|
180
|
+
var changed = mergeHook(settingsPath, command);
|
|
181
|
+
if (changed) installed.push(settingsPath);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
errors.push({ path: settingsPath, error: e && e.message ? e.message : String(e) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { installed: installed, errors: errors };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Merge Clay's managed allow-list into permissions.allow without disturbing
|
|
190
|
+
// the user's own entries. We identify "ours" by membership in
|
|
191
|
+
// CLAY_MANAGED_ALLOW: re-install strips any existing CLAY_MANAGED_ALLOW
|
|
192
|
+
// patterns, then re-inserts the current list. User-authored patterns
|
|
193
|
+
// survive unchanged because they're never in CLAY_MANAGED_ALLOW.
|
|
194
|
+
function mergeAllowList(settingsPath, patterns) {
|
|
195
|
+
var data = readSettings(settingsPath);
|
|
196
|
+
if (!data.permissions || typeof data.permissions !== "object") data.permissions = {};
|
|
197
|
+
var allow = Array.isArray(data.permissions.allow) ? data.permissions.allow : [];
|
|
198
|
+
|
|
199
|
+
var managedSet = {};
|
|
200
|
+
for (var i = 0; i < CLAY_MANAGED_ALLOW.length; i++) managedSet[CLAY_MANAGED_ALLOW[i]] = true;
|
|
201
|
+
|
|
202
|
+
// Strip prior Clay-managed entries, then append the fresh list.
|
|
203
|
+
var preserved = allow.filter(function (p) { return !managedSet[p]; });
|
|
204
|
+
var next = preserved.concat(patterns);
|
|
205
|
+
|
|
206
|
+
var before = JSON.stringify(allow);
|
|
207
|
+
var after = JSON.stringify(next);
|
|
208
|
+
if (before === after) return false;
|
|
209
|
+
|
|
210
|
+
data.permissions.allow = next;
|
|
211
|
+
writeSettings(settingsPath, data);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Install (or refresh) Clay's auto-approve patterns into permissions.allow
|
|
217
|
+
* for one or more user home dirs. Mirrors installNotificationHook's API.
|
|
218
|
+
*/
|
|
219
|
+
function installAllowList(opts) {
|
|
220
|
+
opts = opts || {};
|
|
221
|
+
var os = require("os");
|
|
222
|
+
var homes = opts.homeDirs && opts.homeDirs.length ? opts.homeDirs : [os.homedir()];
|
|
223
|
+
var patterns = (opts.patterns && opts.patterns.length) ? opts.patterns : CLAY_MANAGED_ALLOW;
|
|
224
|
+
var installed = [];
|
|
225
|
+
var errors = [];
|
|
226
|
+
for (var i = 0; i < homes.length; i++) {
|
|
227
|
+
var home = homes[i];
|
|
228
|
+
if (!home) continue;
|
|
229
|
+
var settingsPath = path.join(home, ".claude", "settings.json");
|
|
230
|
+
try {
|
|
231
|
+
var changed = mergeAllowList(settingsPath, patterns);
|
|
232
|
+
if (changed) installed.push(settingsPath);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
errors.push({ path: settingsPath, error: e && e.message ? e.message : String(e) });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { installed: installed, errors: errors };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
installNotificationHook: installNotificationHook,
|
|
242
|
+
installAllowList: installAllowList,
|
|
243
|
+
CLAY_HOOK_MARKER: CLAY_HOOK_MARKER,
|
|
244
|
+
CLAY_MANAGED_ALLOW: CLAY_MANAGED_ALLOW,
|
|
245
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// claude-jsonl-watcher.js
|
|
2
|
+
//
|
|
3
|
+
// Tails the per-session jsonl that Claude Code writes to
|
|
4
|
+
// ~/.claude/projects/<encoded-cwd>/<cliSessionId>.jsonl, parsing new lines
|
|
5
|
+
// for title events:
|
|
6
|
+
//
|
|
7
|
+
// {"type":"ai-title","aiTitle":"..."} - auto-generated after the
|
|
8
|
+
// first exchange
|
|
9
|
+
// {"type":"custom-title","customTitle":"..."} - explicit /title or
|
|
10
|
+
// similar override
|
|
11
|
+
//
|
|
12
|
+
// Used by Clay TUI sessions to mirror claude's session naming into the
|
|
13
|
+
// sidebar so they don't sit as "New Session" forever.
|
|
14
|
+
//
|
|
15
|
+
// Public API:
|
|
16
|
+
// start(jsonlPath, onTitle) -> stop()
|
|
17
|
+
// onTitle(title, source) where source is "ai-title" or "custom-title".
|
|
18
|
+
// stop() detaches the watcher and clears any timer.
|
|
19
|
+
|
|
20
|
+
var fs = require("fs");
|
|
21
|
+
var path = require("path");
|
|
22
|
+
|
|
23
|
+
function pickLatestTitle(lines, current) {
|
|
24
|
+
// Walk lines in order; the final ai-title and custom-title win. Custom
|
|
25
|
+
// beats ai because users explicitly chose it.
|
|
26
|
+
var ai = current.aiTitle || null;
|
|
27
|
+
var custom = current.customTitle || null;
|
|
28
|
+
for (var i = 0; i < lines.length; i++) {
|
|
29
|
+
var raw = lines[i].trim();
|
|
30
|
+
if (!raw || raw[0] !== "{") continue;
|
|
31
|
+
var obj = null;
|
|
32
|
+
try { obj = JSON.parse(raw); } catch (e) { continue; }
|
|
33
|
+
if (!obj || !obj.type) continue;
|
|
34
|
+
if (obj.type === "ai-title" && typeof obj.aiTitle === "string") {
|
|
35
|
+
ai = obj.aiTitle;
|
|
36
|
+
} else if (obj.type === "custom-title" && typeof obj.customTitle === "string") {
|
|
37
|
+
custom = obj.customTitle;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
current.aiTitle = ai;
|
|
41
|
+
current.customTitle = custom;
|
|
42
|
+
return custom || ai;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pull the latest visible "text" block from any new assistant entries.
|
|
46
|
+
// Thinking and tool_use blocks are skipped - they're internals, not the
|
|
47
|
+
// response surface. Returns the most recent text seen, or null.
|
|
48
|
+
function extractLatestAssistantText(lines, seenUuids) {
|
|
49
|
+
var latest = null;
|
|
50
|
+
for (var i = 0; i < lines.length; i++) {
|
|
51
|
+
var raw = lines[i].trim();
|
|
52
|
+
if (!raw || raw[0] !== "{") continue;
|
|
53
|
+
var obj = null;
|
|
54
|
+
try { obj = JSON.parse(raw); } catch (e) { continue; }
|
|
55
|
+
if (!obj || obj.type !== "assistant" || !obj.message) continue;
|
|
56
|
+
if (obj.uuid && seenUuids[obj.uuid]) continue;
|
|
57
|
+
if (obj.uuid) seenUuids[obj.uuid] = true;
|
|
58
|
+
var blocks = obj.message.content || [];
|
|
59
|
+
if (!Array.isArray(blocks)) continue;
|
|
60
|
+
for (var j = 0; j < blocks.length; j++) {
|
|
61
|
+
var b = blocks[j];
|
|
62
|
+
if (b && b.type === "text" && typeof b.text === "string" && b.text.trim()) {
|
|
63
|
+
latest = b.text;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return latest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Watch a Claude Code session jsonl for title and response updates.
|
|
72
|
+
*
|
|
73
|
+
* start(jsonlPath, { onTitle, onResponse }) -> stop()
|
|
74
|
+
*
|
|
75
|
+
* onTitle(text, source) fires on ai-title / custom-title updates.
|
|
76
|
+
* onResponse(text) fires on new assistant text blocks (debounced so
|
|
77
|
+
* a multi-block turn coalesces to one callback,
|
|
78
|
+
* using only the *final* visible text). Pre-existing
|
|
79
|
+
* entries from before start() are ignored - the
|
|
80
|
+
* initial scan seeds the "seen" set so we don't
|
|
81
|
+
* spam notifications when the watcher boots up.
|
|
82
|
+
*/
|
|
83
|
+
function start(jsonlPath, opts) {
|
|
84
|
+
if (!jsonlPath) return function () {};
|
|
85
|
+
var onTitle = (opts && typeof opts.onTitle === "function") ? opts.onTitle : null;
|
|
86
|
+
var onResponse = (opts && typeof opts.onResponse === "function") ? opts.onResponse : null;
|
|
87
|
+
if (!onTitle && !onResponse) return function () {};
|
|
88
|
+
|
|
89
|
+
var pos = 0;
|
|
90
|
+
var leftover = "";
|
|
91
|
+
var current = { aiTitle: null, customTitle: null };
|
|
92
|
+
var lastTitleEmitted = null;
|
|
93
|
+
var seenAssistantUuids = {};
|
|
94
|
+
var didInitialScan = false;
|
|
95
|
+
var responseDebounceTimer = null;
|
|
96
|
+
var pendingResponseText = null;
|
|
97
|
+
var lastResponseEmitted = null;
|
|
98
|
+
var watcher = null;
|
|
99
|
+
var pollTimer = null;
|
|
100
|
+
var stopped = false;
|
|
101
|
+
|
|
102
|
+
var RESPONSE_DEBOUNCE_MS = 1500;
|
|
103
|
+
function maybeFireResponse() {
|
|
104
|
+
if (responseDebounceTimer) clearTimeout(responseDebounceTimer);
|
|
105
|
+
responseDebounceTimer = setTimeout(function () {
|
|
106
|
+
responseDebounceTimer = null;
|
|
107
|
+
if (!pendingResponseText) return;
|
|
108
|
+
if (pendingResponseText === lastResponseEmitted) return;
|
|
109
|
+
var t = pendingResponseText;
|
|
110
|
+
pendingResponseText = null;
|
|
111
|
+
lastResponseEmitted = t;
|
|
112
|
+
try { if (onResponse) onResponse(t); } catch (e) {}
|
|
113
|
+
}, RESPONSE_DEBOUNCE_MS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readNew() {
|
|
117
|
+
if (stopped) return;
|
|
118
|
+
try {
|
|
119
|
+
var stat = fs.statSync(jsonlPath);
|
|
120
|
+
if (stat.size <= pos) return;
|
|
121
|
+
var fd = fs.openSync(jsonlPath, "r");
|
|
122
|
+
var len = stat.size - pos;
|
|
123
|
+
var buf = Buffer.alloc(len);
|
|
124
|
+
fs.readSync(fd, buf, 0, len, pos);
|
|
125
|
+
fs.closeSync(fd);
|
|
126
|
+
pos = stat.size;
|
|
127
|
+
var chunk = leftover + buf.toString("utf8");
|
|
128
|
+
var parts = chunk.split("\n");
|
|
129
|
+
// Last fragment may be a partial line; hold it for next read.
|
|
130
|
+
leftover = parts.pop();
|
|
131
|
+
|
|
132
|
+
// Title side: fire whenever we see a new value.
|
|
133
|
+
if (onTitle) {
|
|
134
|
+
var resolved = pickLatestTitle(parts, current);
|
|
135
|
+
if (resolved && resolved !== lastTitleEmitted) {
|
|
136
|
+
lastTitleEmitted = resolved;
|
|
137
|
+
try { onTitle(resolved, current.customTitle === resolved ? "custom-title" : "ai-title"); } catch (e) {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Response side: collect new assistant text blocks. Initial scan
|
|
142
|
+
// seeds the "seen" set silently so old history doesn't fire.
|
|
143
|
+
var latest = extractLatestAssistantText(parts, seenAssistantUuids);
|
|
144
|
+
if (onResponse && didInitialScan && latest) {
|
|
145
|
+
pendingResponseText = latest;
|
|
146
|
+
maybeFireResponse();
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// File may not exist yet; readNew is called again on the next event.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function arm() {
|
|
154
|
+
if (stopped) return;
|
|
155
|
+
if (watcher) return;
|
|
156
|
+
try {
|
|
157
|
+
watcher = fs.watch(jsonlPath, { persistent: false }, function () {
|
|
158
|
+
readNew();
|
|
159
|
+
});
|
|
160
|
+
watcher.on("error", function () {
|
|
161
|
+
try { watcher.close(); } catch (e) {}
|
|
162
|
+
watcher = null;
|
|
163
|
+
// Fall back to polling; some filesystems (network mounts) don't
|
|
164
|
+
// deliver fs.watch events reliably.
|
|
165
|
+
});
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// Path doesn't exist yet; rely on the poll loop to retry once it appears.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Initial pass to capture anything already written before we attached.
|
|
172
|
+
// didInitialScan stays false during this pass so onResponse doesn't fire
|
|
173
|
+
// for old history; subsequent reads (live updates) will emit.
|
|
174
|
+
readNew();
|
|
175
|
+
didInitialScan = true;
|
|
176
|
+
arm();
|
|
177
|
+
|
|
178
|
+
// Poll every 2s as a safety net for fs.watch misses and for the
|
|
179
|
+
// "file doesn't exist yet" boot case.
|
|
180
|
+
pollTimer = setInterval(function () {
|
|
181
|
+
if (!watcher) arm();
|
|
182
|
+
readNew();
|
|
183
|
+
}, 2000);
|
|
184
|
+
|
|
185
|
+
return function stop() {
|
|
186
|
+
stopped = true;
|
|
187
|
+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
188
|
+
if (responseDebounceTimer) { clearTimeout(responseDebounceTimer); responseDebounceTimer = null; }
|
|
189
|
+
if (watcher) { try { watcher.close(); } catch (e) {} watcher = null; }
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Resolve the jsonl path for a (cwd, cliSessionId) without depending on
|
|
194
|
+
// utils.js so this module stays self-contained.
|
|
195
|
+
function jsonlPathFor(home, cwd, cliSessionId) {
|
|
196
|
+
if (!home || !cwd || !cliSessionId) return null;
|
|
197
|
+
var encoded = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
198
|
+
return path.join(home, ".claude", "projects", encoded, cliSessionId + ".jsonl");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
start: start,
|
|
203
|
+
jsonlPathFor: jsonlPathFor,
|
|
204
|
+
};
|
|
@@ -157,6 +157,21 @@ function attachConnection(ctx) {
|
|
|
157
157
|
var _comVal = _comUid ? usersModule.getClaudeOpenMode(_comUid) : "tui";
|
|
158
158
|
sendTo(ws, { type: "claude_open_mode_changed", claudeOpenMode: _comVal || "tui" });
|
|
159
159
|
}
|
|
160
|
+
|
|
161
|
+
// What's New: push the full entries list (for the home-page feed)
|
|
162
|
+
// plus the subset of unseen ids (for the auto-pop carousel). Content
|
|
163
|
+
// lives in lib/whats-new-content.js so adding an entry doesn't touch
|
|
164
|
+
// this file.
|
|
165
|
+
try {
|
|
166
|
+
var _wn = require("./whats-new");
|
|
167
|
+
var _wnUid = (wsUser && wsUser.id) || null;
|
|
168
|
+
var _wnState = _wnUid ? _wn.getStateForUser(_wnUid) : { entries: _wn.listEntries(), unseenIds: [] };
|
|
169
|
+
if (_wnState.entries.length > 0) {
|
|
170
|
+
sendTo(ws, { type: "whats_new_state", entries: _wnState.entries, unseenIds: _wnState.unseenIds });
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (debug) console.error("[project] whats_new send failed:", e && e.message);
|
|
174
|
+
}
|
|
160
175
|
_loop.sendConnectionState(ws);
|
|
161
176
|
if (_mcp) _mcp.sendConnectionState(ws);
|
|
162
177
|
if (_notifications) _notifications.sendConnectionState(ws, sendTo);
|
package/lib/project-http.js
CHANGED
|
@@ -236,6 +236,10 @@ function attachHTTP(ctx) {
|
|
|
236
236
|
return true;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// /api/tui-notify is handled at the server top-level (server.js
|
|
240
|
+
// appHandler) so a single hook in ~/.claude/settings.json can route
|
|
241
|
+
// to any project. Don't shadow it here.
|
|
242
|
+
|
|
239
243
|
// VAPID public key
|
|
240
244
|
if (req.method === "GET" && urlPath === "/api/vapid-public-key") {
|
|
241
245
|
if (pushModule) {
|
|
@@ -69,6 +69,19 @@ var formatters = {
|
|
|
69
69
|
};
|
|
70
70
|
},
|
|
71
71
|
|
|
72
|
+
tui_attention: function (data) {
|
|
73
|
+
return {
|
|
74
|
+
type: "tui_attention",
|
|
75
|
+
title: data.title || "Claude needs your attention",
|
|
76
|
+
body: data.body || "",
|
|
77
|
+
meta: {
|
|
78
|
+
terminalId: typeof data.terminalId === "number" ? data.terminalId : null,
|
|
79
|
+
cliSessionId: data.cliSessionId || null,
|
|
80
|
+
sessionTitle: data.sessionTitle || "",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
|
|
72
85
|
mate_dm: function (data) {
|
|
73
86
|
return {
|
|
74
87
|
type: "mate_dm",
|