agentgui 1.0.1117 → 1.0.1118
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/.gm/.embed-generation.code_chunks +1 -0
- package/.gm/.embed-generation.git_commit_vectors +1 -0
- package/.gm/.embed-generation.memories +1 -0
- package/.gm/.embed-generation.rssearch_vectors +1 -0
- package/.gm/prd.yml +15 -0
- package/AGENTS.md +1 -1
- package/lib/acp-sdk-manager.js +15 -2
- package/lib/claude-runner-agents.js +8 -1
- package/lib/http-handler.js +35 -566
- package/lib/http-routes/mutations.js +199 -0
- package/lib/http-routes/reads.js +212 -0
- package/lib/http-routes/shared.js +213 -0
- package/lib/ws-handlers/agents.js +137 -0
- package/lib/ws-handlers/chat.js +157 -0
- package/lib/ws-handlers/git.js +159 -0
- package/lib/ws-handlers/misc.js +36 -0
- package/lib/ws-handlers/shared.js +47 -0
- package/lib/ws-handlers/terminal-state.js +16 -0
- package/lib/ws-handlers-util.js +8 -526
- package/package.json +1 -1
- package/site/app/js/app.js +8 -103
- package/site/app/js/chat-persistence.js +123 -0
- package/site/app/vendor/anentrypoint-design/247420.css +1614 -488
- package/site/app/vendor/anentrypoint-design/247420.js +363 -77
- package/UX_OPTIMIZATION_SUMMARY.md +0 -238
- package/agentgui-after.png +0 -0
- package/agentgui-current.png +0 -0
- package/agentgui-final.png +0 -0
- package/agentgui-nobrand.png +0 -0
- package/agentgui-now.png +0 -0
- package/agentgui-v2.png +0 -0
- package/agentgui-v4.png +0 -0
- package/bash.exe.stackdump +0 -28
- package/design-reference.png +0 -0
package/lib/ws-handlers-util.js
CHANGED
|
@@ -1,529 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { execSync, spawnSync, execFile } from 'child_process';
|
|
6
|
-
import { runClaudeWithStreaming } from './claude-runner-run.js';
|
|
7
|
-
import { registry } from './claude-runner-agents.js';
|
|
8
|
-
import { confineToRoots, fsAllowRoots } from './http-handler.js';
|
|
9
|
-
import { restart as restartAcpAgent } from './acp-sdk-manager.js';
|
|
10
|
-
|
|
11
|
-
function err(code, message) { const e = new Error(message); e.code = code; throw e; }
|
|
12
|
-
|
|
13
|
-
// Promisified execFile — array-args form only, never a shell string, so a
|
|
14
|
-
// crafted branch/path/file value cannot be interpreted as a shell command.
|
|
15
|
-
function execFileP(cmd, args, opts) {
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
execFile(cmd, args, { encoding: 'utf-8', timeout: 30000, maxBuffer: 20 * 1024 * 1024, ...opts }, (e, stdout, stderr) => {
|
|
18
|
-
if (e) { e.stdout = stdout; e.stderr = stderr; reject(e); return; }
|
|
19
|
-
resolve({ stdout, stderr });
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Resolve + confine a client-supplied cwd to the same allowlist the file
|
|
25
|
-
// explorer / chat.sendMessage use (fsAllowRoots via confineToRoots). Falls
|
|
26
|
-
// back to STARTUP_CWD (itself an allowed root) when no cwd is given.
|
|
27
|
-
function resolveGitCwd(p, STARTUP_CWD) {
|
|
28
|
-
if (!p?.cwd) return STARTUP_CWD;
|
|
29
|
-
const conf = confineToRoots(p.cwd, fsAllowRoots());
|
|
30
|
-
if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${p.cwd}`);
|
|
31
|
-
return conf.realPath;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// A relative in-repo file path: no leading '-' (flag injection), no shell
|
|
35
|
-
// metacharacters, no absolute path (git resolves it relative to cwd already).
|
|
36
|
-
const SAFE_REL_PATH_RE = /^[^\0]{1,4096}$/;
|
|
37
|
-
function assertSafeRelPath(v, label) {
|
|
38
|
-
if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
|
|
39
|
-
if (v.startsWith('-')) err(400, `${label} must not start with '-'`);
|
|
40
|
-
if (/[\0]/.test(v)) err(400, `${label} contains invalid characters`);
|
|
41
|
-
if (!SAFE_REL_PATH_RE.test(v)) err(400, `${label} invalid`);
|
|
42
|
-
return v;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// A git branch/ref name — conservative allowlist (git's own ref-format is
|
|
46
|
-
// more permissive, but this blocks every shell-metacharacter and flag-
|
|
47
|
-
// injection vector while still covering normal branch names).
|
|
48
|
-
const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9_.\/-]{0,199}$/;
|
|
49
|
-
function assertSafeBranch(v, label) {
|
|
50
|
-
if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
|
|
51
|
-
if (v.startsWith('-') || v.includes('..') || v.includes('//')) err(400, `${label} invalid`);
|
|
52
|
-
if (!SAFE_BRANCH_RE.test(v)) err(400, `${label} invalid`);
|
|
53
|
-
return v;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const SUB_AGENT_MAP = {
|
|
57
|
-
'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }], 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
|
|
58
|
-
'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }], 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
|
|
59
|
-
'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }], 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
|
|
60
|
-
'codex': [], 'cli-codex': []
|
|
61
|
-
};
|
|
1
|
+
import { register as registerAgents } from './ws-handlers/agents.js';
|
|
2
|
+
import { register as registerChat } from './ws-handlers/chat.js';
|
|
3
|
+
import { register as registerGit } from './ws-handlers/git.js';
|
|
4
|
+
import { register as registerMisc } from './ws-handlers/misc.js';
|
|
62
5
|
|
|
63
6
|
export function register(router, deps) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// fire-and-forget broadcast the client never sees, hanging it busy forever.
|
|
69
|
-
// A re-subscribing client gets the buffered terminal frame replayed.
|
|
70
|
-
const TERMINAL_TTL_MS = 60000;
|
|
71
|
-
const terminalEvents = new Map(); // sessionId -> terminal event
|
|
72
|
-
function recordTerminal(sessionId, event) {
|
|
73
|
-
terminalEvents.set(sessionId, event);
|
|
74
|
-
const t = setTimeout(() => {
|
|
75
|
-
if (terminalEvents.get(sessionId) === event) terminalEvents.delete(sessionId);
|
|
76
|
-
}, TERMINAL_TTL_MS);
|
|
77
|
-
if (typeof t.unref === 'function') t.unref();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// --- agents.list: enumerate registered ACP agents + claude-code ---
|
|
81
|
-
router.handle('agents.list', () => {
|
|
82
|
-
const agents = registry.list().map(a => ({
|
|
83
|
-
id: a.id,
|
|
84
|
-
name: a.name,
|
|
85
|
-
protocol: a.protocol,
|
|
86
|
-
supportsStdin: !!a.supportsStdin,
|
|
87
|
-
features: a.supportedFeatures || [],
|
|
88
|
-
available: registry.isAvailable(a.id),
|
|
89
|
-
npxInstallable: !!a.npxPackage,
|
|
90
|
-
// The CLI binary name a manual (non-npx) install would need - lets the
|
|
91
|
-
// settings panel say what to install instead of just "not installed".
|
|
92
|
-
cmd: a.command || null,
|
|
93
|
-
}));
|
|
94
|
-
return { agents };
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// --- agents.models: model choices for a given agent ---
|
|
98
|
-
router.handle('agents.models', async (p) => {
|
|
99
|
-
const id = p?.id || p?.agentId;
|
|
100
|
-
if (!id) err(400, 'agent id required');
|
|
101
|
-
if (id === 'claude-code') {
|
|
102
|
-
return { models: [
|
|
103
|
-
{ id: 'sonnet', name: 'Claude Sonnet (latest)' },
|
|
104
|
-
{ id: 'opus', name: 'Claude Opus (latest)' },
|
|
105
|
-
{ id: 'haiku', name: 'Claude Haiku (latest)' },
|
|
106
|
-
] };
|
|
107
|
-
}
|
|
108
|
-
// Other agents: discover their models via getModelsForAgent (queries the
|
|
109
|
-
// running ACP server). Fail closed to an empty list on any error or when
|
|
110
|
-
// the dep isn't a function.
|
|
111
|
-
if (typeof getModelsForAgent === 'function') {
|
|
112
|
-
try {
|
|
113
|
-
const raw = await getModelsForAgent(id);
|
|
114
|
-
const list = Array.isArray(raw) ? raw : (raw?.models || []);
|
|
115
|
-
return { models: list.map(m => ({ id: m.id, name: m.name || m.label || m.id })) };
|
|
116
|
-
} catch { return { models: [] }; }
|
|
117
|
-
}
|
|
118
|
-
return { models: [] };
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// --- conversation.subscribe: register this ws for sessionId broadcasts ---
|
|
122
|
-
router.handle('conversation.subscribe', (p, ws) => {
|
|
123
|
-
const sid = p?.sessionId;
|
|
124
|
-
if (!sid || typeof sid !== 'string') err(400, 'sessionId required');
|
|
125
|
-
if (!subscriptionIndex.has(sid)) subscriptionIndex.set(sid, new Set());
|
|
126
|
-
subscriptionIndex.get(sid).add(ws);
|
|
127
|
-
ws.subscriptions = ws.subscriptions || new Set();
|
|
128
|
-
ws.subscriptions.add(sid);
|
|
129
|
-
// Replay a buffered terminal frame (complete/error/cancelled) so a client
|
|
130
|
-
// that re-subscribes after a ws drop learns the turn already ended.
|
|
131
|
-
const term = terminalEvents.get(sid);
|
|
132
|
-
if (term) { try { wsOptimizer.sendToClient(ws, { ...term, replayed: true }); } catch {} }
|
|
133
|
-
return { subscribed: true, sessionId: sid, replayedTerminal: !!term };
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// --- chat.sendMessage: start a one-shot streaming chat with an agent.
|
|
137
|
-
// Bypasses the gutted db-queries layer entirely; calls runClaudeWithStreaming
|
|
138
|
-
// directly and broadcasts streaming_* events scoped to an ephemeral sessionId.
|
|
139
|
-
router.handle('chat.sendMessage', async (p, ws) => {
|
|
140
|
-
let content = (p?.content || '').toString();
|
|
141
|
-
if (!content) err(400, 'content required');
|
|
142
|
-
const agentId = p?.agentId || 'claude-code';
|
|
143
|
-
// For non-resume agents (not claude-code which uses --resume), prepend prior
|
|
144
|
-
// conversation turns so the agent has context. claude-code handles multi-turn
|
|
145
|
-
// natively via resumeSessionId; direct runners (agy, etc.) get a preamble.
|
|
146
|
-
const priorMessages = Array.isArray(p?.messages) ? p.messages.filter(m => m?.role && m?.content) : [];
|
|
147
|
-
if (agentId !== 'claude-code' && !p?.resumeSid && !p?.resumeSessionId && priorMessages.length > 0) {
|
|
148
|
-
const preamble = priorMessages.map(m => (m.role === 'user' ? 'User: ' : 'Assistant: ') + (m.content || '').trim()).join('\n\n');
|
|
149
|
-
content = '[Prior conversation]\n' + preamble + '\n\n[Current message]\n' + content;
|
|
150
|
-
}
|
|
151
|
-
const model = p?.model || undefined;
|
|
152
|
-
const subAgent = p?.subAgent || undefined;
|
|
153
|
-
const cwd = p?.cwd || STARTUP_CWD;
|
|
154
|
-
const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
|
|
155
|
-
if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
|
|
156
|
-
// A client-supplied cwd must be confined to the SAME allowlist as the Files
|
|
157
|
-
// routes (fsAllowRoots) - an unconfined cwd would let a client spawn the
|
|
158
|
-
// agent CLI anywhere on disk. Use the realpath-resolved value as the spawn
|
|
159
|
-
// cwd (defeats symlink escape, same as the HTTP file routes). No p.cwd ->
|
|
160
|
-
// STARTUP_CWD, which is itself an allowed root, so it needs no check.
|
|
161
|
-
let spawnCwd = STARTUP_CWD;
|
|
162
|
-
if (p?.cwd) {
|
|
163
|
-
const conf = confineToRoots(cwd, fsAllowRoots());
|
|
164
|
-
if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${cwd}`);
|
|
165
|
-
spawnCwd = conf.realPath;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
|
|
169
|
-
// Auto-subscribe the originating ws so it receives its own broadcasts.
|
|
170
|
-
if (!subscriptionIndex.has(sessionId)) subscriptionIndex.set(sessionId, new Set());
|
|
171
|
-
subscriptionIndex.get(sessionId).add(ws);
|
|
172
|
-
ws.subscriptions = ws.subscriptions || new Set();
|
|
173
|
-
ws.subscriptions.add(sessionId);
|
|
174
|
-
|
|
175
|
-
const ctrl = { aborted: false, proc: null, agentId, model, cwd: spawnCwd, startedAt: Date.now() };
|
|
176
|
-
activeChats.set(sessionId, ctrl);
|
|
177
|
-
// Push-driven hint so clients refresh active-session state without
|
|
178
|
-
// waiting for the 3s chat.active poll (finding 48).
|
|
179
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'started', sessionId, agentId, timestamp: Date.now() });
|
|
180
|
-
|
|
181
|
-
// Fire-and-forget. Errors broadcast as streaming_error.
|
|
182
|
-
(async () => {
|
|
183
|
-
let eventCount = 0;
|
|
184
|
-
broadcastSync({ type: 'streaming_start', sessionId, agentId, timestamp: Date.now() });
|
|
185
|
-
let claudeSessionBroadcast = false;
|
|
186
|
-
const onEvent = (parsed) => {
|
|
187
|
-
eventCount++;
|
|
188
|
-
// Surface claude's REAL session id (from the stream) once, so the client
|
|
189
|
-
// can --resume this conversation on its next turn. The ephemeral
|
|
190
|
-
// 'chat-...' sessionId is not a claude session id and cannot be resumed.
|
|
191
|
-
if (!claudeSessionBroadcast && parsed?.session_id) {
|
|
192
|
-
claudeSessionBroadcast = true;
|
|
193
|
-
ctrl.claudeSessionId = parsed.session_id;
|
|
194
|
-
broadcastSync({ type: 'streaming_session', sessionId, claudeSessionId: parsed.session_id, agentId, timestamp: Date.now() });
|
|
195
|
-
}
|
|
196
|
-
if (parsed?.type === 'assistant' && parsed.message?.content) {
|
|
197
|
-
for (const block of parsed.message.content) {
|
|
198
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'assistant', seq: eventCount, timestamp: Date.now() });
|
|
199
|
-
}
|
|
200
|
-
} else if (parsed?.type === 'user' && parsed.message?.content) {
|
|
201
|
-
const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
|
202
|
-
for (const block of blocks) {
|
|
203
|
-
if (block?.type === 'tool_result') {
|
|
204
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'tool_result', seq: eventCount, timestamp: Date.now() });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
} else if (parsed?.type === 'result') {
|
|
208
|
-
const block = { type: 'result', result: parsed.result, subtype: parsed.subtype, duration_ms: parsed.duration_ms, total_cost_usd: parsed.total_cost_usd, is_error: !!parsed.is_error };
|
|
209
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'result', seq: eventCount, isResult: true, timestamp: Date.now() });
|
|
210
|
-
}
|
|
211
|
-
};
|
|
212
|
-
try {
|
|
213
|
-
const config = {
|
|
214
|
-
verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
|
|
215
|
-
model, subAgent, onEvent, resumeSessionId,
|
|
216
|
-
onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
|
|
217
|
-
};
|
|
218
|
-
await runClaudeWithStreaming(content, spawnCwd, agentId, config);
|
|
219
|
-
if (!ctrl.aborted) {
|
|
220
|
-
const ev = { type: 'streaming_complete', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, eventCount, timestamp: Date.now() };
|
|
221
|
-
recordTerminal(sessionId, ev);
|
|
222
|
-
broadcastSync(ev);
|
|
223
|
-
}
|
|
224
|
-
} catch (e) {
|
|
225
|
-
if (!ctrl.aborted) {
|
|
226
|
-
const ev = { type: 'streaming_error', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() };
|
|
227
|
-
recordTerminal(sessionId, ev);
|
|
228
|
-
broadcastSync(ev);
|
|
229
|
-
}
|
|
230
|
-
} finally {
|
|
231
|
-
activeChats.delete(sessionId);
|
|
232
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'ended', sessionId, agentId, timestamp: Date.now() });
|
|
233
|
-
}
|
|
234
|
-
})();
|
|
235
|
-
|
|
236
|
-
return { sessionId, started: true };
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// --- chat.active: list in-flight chats started via this server ---
|
|
240
|
-
router.handle('chat.active', () => {
|
|
241
|
-
const sessions = [];
|
|
242
|
-
for (const [sid, c] of activeChats) {
|
|
243
|
-
sessions.push({ sessionId: sid, claudeSessionId: c.claudeSessionId || null, agentId: c.agentId || null, model: c.model || null, cwd: c.cwd || null, startedAt: c.startedAt || null, pid: c.proc?.pid || null });
|
|
244
|
-
}
|
|
245
|
-
return { sessions };
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// --- chat.cancel: abort an in-flight chat ---
|
|
249
|
-
router.handle('chat.cancel', (p) => {
|
|
250
|
-
const sid = p?.sessionId;
|
|
251
|
-
if (!sid) err(400, 'sessionId required');
|
|
252
|
-
const ctrl = activeChats.get(sid);
|
|
253
|
-
if (!ctrl) return { cancelled: false, reason: 'not-found' };
|
|
254
|
-
ctrl.aborted = true;
|
|
255
|
-
// Broadcast the terminal 'cancelled' frame BEFORE killing the proc so a
|
|
256
|
-
// remote cancellation (other tab, dashboard stop-all) does not read as a
|
|
257
|
-
// normal completion (finding 44). streaming_cancelled is in BROADCAST_TYPES
|
|
258
|
-
// so every connected client sees it; it is also buffered for re-subscribers.
|
|
259
|
-
const ev = { type: 'streaming_cancelled', sessionId: sid, claudeSessionId: ctrl.claudeSessionId || null, agentId: ctrl.agentId || null, cancelled: true, timestamp: Date.now() };
|
|
260
|
-
recordTerminal(sid, ev);
|
|
261
|
-
broadcastSync(ev);
|
|
262
|
-
try { ctrl.proc?.kill?.(); } catch {}
|
|
263
|
-
activeChats.delete(sid);
|
|
264
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'cancelled', sessionId: sid, agentId: ctrl.agentId || null, timestamp: Date.now() });
|
|
265
|
-
return { cancelled: true };
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
|
|
271
|
-
|
|
272
|
-
router.handle('folders', (p) => {
|
|
273
|
-
const folderPath = p.path || STARTUP_CWD;
|
|
274
|
-
try {
|
|
275
|
-
const raw = folderPath.startsWith('~') ? folderPath.replace('~', os.homedir()) : folderPath;
|
|
276
|
-
const entries = fs.readdirSync(path.resolve(raw), { withFileTypes: true });
|
|
277
|
-
return { folders: entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => ({ name: e.name })).sort((a, b) => a.name.localeCompare(b.name)) };
|
|
278
|
-
} catch (e) { err(400, e.message); }
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
router.handle('clone', (p) => {
|
|
282
|
-
const repo = (p.repo || '').trim();
|
|
283
|
-
if (!repo || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) {
|
|
284
|
-
err(400, 'Invalid repo format. Use org/repo or user/repo');
|
|
285
|
-
}
|
|
286
|
-
const cloneDir = STARTUP_CWD || os.homedir();
|
|
287
|
-
const repoName = repo.split('/')[1];
|
|
288
|
-
const targetPath = path.join(cloneDir, repoName);
|
|
289
|
-
if (fs.existsSync(targetPath)) err(409, `Directory already exists: ${repoName}`);
|
|
290
|
-
try {
|
|
291
|
-
const isWindows = os.platform() === 'win32';
|
|
292
|
-
execSync('git clone https://github.com/' + repo + '.git', {
|
|
293
|
-
cwd: cloneDir, encoding: 'utf-8', timeout: 120000,
|
|
294
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
295
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
296
|
-
shell: isWindows
|
|
297
|
-
});
|
|
298
|
-
return { ok: true, repo, path: targetPath, name: repoName };
|
|
299
|
-
} catch (e) { err(500, (e.stderr || e.message || 'Clone failed').trim()); }
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
router.handle('git.check', () => {
|
|
303
|
-
try {
|
|
304
|
-
const isWindows = os.platform() === 'win32';
|
|
305
|
-
const devnull = isWindows ? '' : ' 2>/dev/null';
|
|
306
|
-
const remoteUrl = execSync('git remote get-url origin' + devnull, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows }).trim();
|
|
307
|
-
const statusResult = execSync('git status --porcelain' + devnull, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows });
|
|
308
|
-
const hasChanges = statusResult.trim().length > 0;
|
|
309
|
-
const unpushedResult = execSync('git rev-list --count --not --remotes' + devnull, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows });
|
|
310
|
-
const hasUnpushed = parseInt(unpushedResult.trim() || '0', 10) > 0;
|
|
311
|
-
const githubUser = process.env.GITHUB_USER;
|
|
312
|
-
const ownsRemote = !remoteUrl.includes('github.com/') || (!!githubUser && remoteUrl.includes(githubUser));
|
|
313
|
-
return { ownsRemote, hasChanges, hasUnpushed, remoteUrl };
|
|
314
|
-
} catch {
|
|
315
|
-
return { ownsRemote: false, hasChanges: false, hasUnpushed: false, remoteUrl: '' };
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
router.handle('git.push', () => {
|
|
320
|
-
try {
|
|
321
|
-
const isWindows = os.platform() === 'win32';
|
|
322
|
-
const cmd = isWindows
|
|
323
|
-
? 'git add -A & git commit -m "Auto-commit" & git push'
|
|
324
|
-
: 'git add -A && git commit -m "Auto-commit" && git push';
|
|
325
|
-
execSync(cmd, { encoding: 'utf-8', cwd: STARTUP_CWD, shell: isWindows });
|
|
326
|
-
return { success: true };
|
|
327
|
-
} catch (e) { err(500, e.message); }
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// --- git.status: changed-file list (staged/unstaged/untracked) for
|
|
331
|
-
// GitStatusPanel. Porcelain=v1 line format "XY PATH" (rename: "XY OLD -> NEW"),
|
|
332
|
-
// X = index state, Y = worktree state, '?'/' ' = untouched/untracked.
|
|
333
|
-
router.handle('git.status', async (p) => {
|
|
334
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
335
|
-
try {
|
|
336
|
-
const { stdout } = await execFileP('git', ['status', '--porcelain=v1'], { cwd });
|
|
337
|
-
const files = [];
|
|
338
|
-
for (const line of stdout.split('\n')) {
|
|
339
|
-
if (!line) continue;
|
|
340
|
-
const x = line[0], y = line[1];
|
|
341
|
-
const path = line.slice(3);
|
|
342
|
-
if (x === '?' && y === '?') { files.push({ path, status: '?' }); continue; }
|
|
343
|
-
if (x !== ' ' && x !== '?') files.push({ path, status: x, staged: true });
|
|
344
|
-
else if (y !== ' ' && y !== '?') files.push({ path, status: y, staged: false });
|
|
345
|
-
}
|
|
346
|
-
return { files };
|
|
347
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git status failed').trim()); }
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// --- git.diff: unified diff for a single file or the whole working tree.
|
|
351
|
-
// p.cwd (optional) is confined via confineToRoots/fsAllowRoots, same as
|
|
352
|
-
// chat.sendMessage; p.file (optional) is validated to reject flags/shell
|
|
353
|
-
// metacharacters. execFile array-args form only, never a shell string.
|
|
354
|
-
router.handle('git.diff', async (p) => {
|
|
355
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
356
|
-
const args = ['diff', '--no-color'];
|
|
357
|
-
if (p?.staged) args.push('--staged');
|
|
358
|
-
if (p?.file) args.push('--', assertSafeRelPath(p.file, 'file'));
|
|
359
|
-
try {
|
|
360
|
-
const { stdout } = await execFileP('git', args, { cwd });
|
|
361
|
-
// git prints "Binary files a/x and b/x differ" for binary paths instead
|
|
362
|
-
// of a unified diff - an empty `diff` string then reads to the client as
|
|
363
|
-
// "no diff to show" with no explanation. Detect it so the UI can say why.
|
|
364
|
-
const binary = /^Binary files .* differ$/m.test(stdout);
|
|
365
|
-
return { diff: stdout, binary, file: p?.file || null, staged: !!p?.staged };
|
|
366
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git diff failed').trim()); }
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
// --- git.log: recent commit list, limit param (default 20, capped 200).
|
|
370
|
-
router.handle('git.log', async (p) => {
|
|
371
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
372
|
-
const limit = Math.max(1, Math.min(200, parseInt(p?.limit, 10) || 20));
|
|
373
|
-
const FIELD_SEP = '\x1f';
|
|
374
|
-
const REC_SEP = '\x1e';
|
|
375
|
-
const args = ['log', `-n${limit}`, `--pretty=format:%H${FIELD_SEP}%an${FIELD_SEP}%ad${FIELD_SEP}%s${REC_SEP}`, '--date=iso-strict'];
|
|
376
|
-
try {
|
|
377
|
-
const { stdout } = await execFileP('git', args, { cwd });
|
|
378
|
-
const commits = stdout.split(REC_SEP).map(s => s.trim()).filter(Boolean).map(rec => {
|
|
379
|
-
const [hash, author, date, ...rest] = rec.split(FIELD_SEP);
|
|
380
|
-
return { hash, author, date, subject: rest.join(FIELD_SEP) };
|
|
381
|
-
});
|
|
382
|
-
return { commits };
|
|
383
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git log failed').trim()); }
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// --- worktree.list / create / remove ---
|
|
387
|
-
router.handle('worktree.list', async (p) => {
|
|
388
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
389
|
-
try {
|
|
390
|
-
const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd });
|
|
391
|
-
// Parse the porcelain block format: records separated by blank lines,
|
|
392
|
-
// each line "key value" or a bare flag ("bare", "detached", "locked").
|
|
393
|
-
const worktrees = [];
|
|
394
|
-
let cur = null;
|
|
395
|
-
for (const line of stdout.split('\n')) {
|
|
396
|
-
if (!line.trim()) { if (cur) { worktrees.push(cur); cur = null; } continue; }
|
|
397
|
-
if (!cur) cur = {};
|
|
398
|
-
const sp = line.indexOf(' ');
|
|
399
|
-
if (sp === -1) { cur[line] = true; continue; }
|
|
400
|
-
cur[line.slice(0, sp)] = line.slice(sp + 1);
|
|
401
|
-
}
|
|
402
|
-
if (cur) worktrees.push(cur);
|
|
403
|
-
return { worktrees: worktrees.map(w => ({ path: w.worktree || null, head: w.HEAD || null, branch: w.branch || null, bare: !!w.bare, detached: !!w.detached, locked: !!w.locked })) };
|
|
404
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git worktree list failed').trim()); }
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
router.handle('worktree.create', async (p) => {
|
|
408
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
409
|
-
const wtPath = assertSafeRelPath(p?.path, 'path');
|
|
410
|
-
const args = ['worktree', 'add'];
|
|
411
|
-
if (p?.newBranch) { args.push('-b', assertSafeBranch(p.newBranch, 'newBranch')); }
|
|
412
|
-
args.push(wtPath);
|
|
413
|
-
if (p?.branch) args.push(assertSafeBranch(p.branch, 'branch'));
|
|
414
|
-
try {
|
|
415
|
-
const { stdout, stderr } = await execFileP('git', args, { cwd });
|
|
416
|
-
return { ok: true, output: (stdout || stderr || '').trim(), path: wtPath };
|
|
417
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git worktree add failed').trim()); }
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
router.handle('worktree.remove', async (p) => {
|
|
421
|
-
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
422
|
-
const wtPath = assertSafeRelPath(p?.path, 'path');
|
|
423
|
-
const args = ['worktree', 'remove'];
|
|
424
|
-
if (p?.force) args.push('--force');
|
|
425
|
-
args.push(wtPath);
|
|
426
|
-
try {
|
|
427
|
-
const { stdout, stderr } = await execFileP('git', args, { cwd });
|
|
428
|
-
return { ok: true, output: (stdout || stderr || '').trim() };
|
|
429
|
-
} catch (e) { err(500, (e.stderr || e.message || 'git worktree remove failed').trim()); }
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
router.handle('auth.configs', () => getProviderConfigs());
|
|
433
|
-
|
|
434
|
-
router.handle('auth.save', (p) => {
|
|
435
|
-
const { providerId, apiKey, defaultModel } = p;
|
|
436
|
-
if (typeof providerId !== 'string' || !providerId.length || providerId.length > 100) err(400, 'Invalid providerId');
|
|
437
|
-
if (typeof apiKey !== 'string' || !apiKey.length || apiKey.length > 10000) err(400, 'Invalid apiKey');
|
|
438
|
-
if (defaultModel !== undefined && (typeof defaultModel !== 'string' || defaultModel.length > 200)) err(400, 'Invalid defaultModel');
|
|
439
|
-
const configPath = saveProviderConfig(providerId, apiKey, defaultModel || '');
|
|
440
|
-
return { success: true, path: configPath };
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
router.handle('import.claude', () => ({ imported: queries.importClaudeCodeConversations() }));
|
|
444
|
-
|
|
445
|
-
router.handle('discover.claude', () => ({ discovered: queries.discoverClaudeCodeConversations() }));
|
|
446
|
-
|
|
447
|
-
router.handle('ws.stats', () => wsOptimizer.getStats());
|
|
448
|
-
|
|
449
|
-
router.handle('acp.restart', async (p) => {
|
|
450
|
-
if (!p.id) err(400, 'Missing agent id');
|
|
451
|
-
const ok = await restartAcpAgent(p.id);
|
|
452
|
-
return { ok: !!ok };
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
router.handle('agent.subagents', async (p) => {
|
|
456
|
-
if (!p.id) err(400, 'Missing agent id');
|
|
457
|
-
if (p.id === 'claude-code' || p.id === 'cli-claude') {
|
|
458
|
-
const spawnEnv = { ...process.env }; delete spawnEnv.CLAUDECODE;
|
|
459
|
-
const result = spawnSync('claude', ['agents', 'list'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env: spawnEnv });
|
|
460
|
-
if (result.status !== 0 || !result.stdout) return { subAgents: [] };
|
|
461
|
-
const agents = result.stdout.trim().split('\n').filter(l => l.trim()).map(l => l.match(/^ (\S+)\s+·/)).filter(Boolean).map(m => ({ id: m[1], name: m[1] }));
|
|
462
|
-
return { subAgents: agents };
|
|
463
|
-
}
|
|
464
|
-
return { subAgents: SUB_AGENT_MAP[p.id] || [] };
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
// --- models.availability: composes agentgui's REAL model surface into the
|
|
468
|
-
// ModelsConfig component's shape (design SDK's ModelsConfig.js). agentgui
|
|
469
|
-
// has no freddie-style probed-availability matrix (no /v1/models round-trip
|
|
470
|
-
// per mode) - what it actually has is: discovered agent CLIs (registry,
|
|
471
|
-
// same data agents.list already exposes), each agent's model choices
|
|
472
|
-
// (agents.models' getModelsForAgent), and per-provider API-key presence
|
|
473
|
-
// (auth.configs' getProviderConfigs). This handler is a read-only
|
|
474
|
-
// composition of those three real sources, not a new probe and not fake
|
|
475
|
-
// data - "usable_in_any_mode" is derived from registry.isAvailable(agentId)
|
|
476
|
-
// (the CLI binary was actually found on this server), and "key_present"
|
|
477
|
-
// comes straight from getProviderConfigs' filesystem check.
|
|
478
|
-
router.handle('models.availability', async () => {
|
|
479
|
-
const agents = registry.list();
|
|
480
|
-
const providerConfigs = typeof getProviderConfigs === 'function' ? getProviderConfigs() : {};
|
|
481
|
-
const providers = [];
|
|
482
|
-
for (const a of agents) {
|
|
483
|
-
const available = registry.isAvailable(a.id);
|
|
484
|
-
let models = [];
|
|
485
|
-
if (a.id === 'claude-code') {
|
|
486
|
-
models = [
|
|
487
|
-
{ id: 'sonnet', name: 'Claude Sonnet (latest)' },
|
|
488
|
-
{ id: 'opus', name: 'Claude Opus (latest)' },
|
|
489
|
-
{ id: 'haiku', name: 'Claude Haiku (latest)' },
|
|
490
|
-
];
|
|
491
|
-
} else if (typeof getModelsForAgent === 'function') {
|
|
492
|
-
try {
|
|
493
|
-
const raw = await getModelsForAgent(a.id);
|
|
494
|
-
const list = Array.isArray(raw) ? raw : (raw?.models || []);
|
|
495
|
-
models = list.map((m) => ({ id: m.id, name: m.name || m.label || m.id }));
|
|
496
|
-
} catch { models = []; }
|
|
497
|
-
}
|
|
498
|
-
// Map onto agentgui's one real signal per model: is the agent CLI itself
|
|
499
|
-
// available on this server (registry.isAvailable). agentgui does not
|
|
500
|
-
// probe individual (model, mode) cells the way freddie's matrix does,
|
|
501
|
-
// so every model under an available agent reports the same single
|
|
502
|
-
// 'cli' mode rather than fabricating per-mode probe results.
|
|
503
|
-
providers.push({
|
|
504
|
-
id: a.id,
|
|
505
|
-
key_present: !!(providerConfigs[a.id] && providerConfigs[a.id].hasKey) || available,
|
|
506
|
-
discovery_error: available ? null : ((a.name || a.id) + ' CLI not found on this server'),
|
|
507
|
-
models: models.map((m) => ({
|
|
508
|
-
id: m.id,
|
|
509
|
-
name: m.name,
|
|
510
|
-
discovered_via: a.protocol || 'cli',
|
|
511
|
-
modes: { cli: { ok: !!available, skipped: !available, reason: available ? undefined : 'agent_not_installed' } },
|
|
512
|
-
usable_in_any_mode: !!available,
|
|
513
|
-
})),
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
const totalModels = providers.reduce((n, p) => n + p.models.length, 0);
|
|
517
|
-
const usableModels = providers.reduce((n, p) => n + p.models.filter((m) => m.usable_in_any_mode).length, 0);
|
|
518
|
-
return {
|
|
519
|
-
timestamp: new Date().toISOString(),
|
|
520
|
-
providers,
|
|
521
|
-
sampler: [],
|
|
522
|
-
summary: {
|
|
523
|
-
total_providers: providers.length,
|
|
524
|
-
total_models: totalModels,
|
|
525
|
-
usable_in_any_mode: usableModels,
|
|
526
|
-
},
|
|
527
|
-
};
|
|
528
|
-
});
|
|
7
|
+
registerAgents(router, deps);
|
|
8
|
+
registerChat(router, deps);
|
|
9
|
+
registerGit(router, deps);
|
|
10
|
+
registerMisc(router, deps);
|
|
529
11
|
}
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { h, mount, installStyles, components as C } from 'anentrypoint-design';
|
|
2
2
|
import * as B from './backend.js';
|
|
3
|
+
import { createChatPersistence } from './chat-persistence.js';
|
|
3
4
|
|
|
4
5
|
installStyles().catch(() => {});
|
|
5
6
|
|
|
@@ -2916,109 +2917,13 @@ function cancelChat() {
|
|
|
2916
2917
|
render();
|
|
2917
2918
|
}
|
|
2918
2919
|
|
|
2919
|
-
|
|
2920
|
-
//
|
|
2921
|
-
//
|
|
2922
|
-
//
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
}
|
|
2926
|
-
// Cap the persisted footprint: the last 100 messages, with any tool args/result
|
|
2927
|
-
// payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
|
|
2928
|
-
// full payload). A quota failure is surfaced once instead of silently dropping
|
|
2929
|
-
// persistence forever.
|
|
2930
|
-
const PERSIST_MSG_CAP = 100;
|
|
2931
|
-
const PERSIST_PART_CAP = 4096;
|
|
2932
|
-
let _persistFailedOnce = false;
|
|
2933
|
-
let _lastPersistTs = 0;
|
|
2934
|
-
function trimPartForStorage(p) {
|
|
2935
|
-
if (!p || typeof p !== 'object') return p;
|
|
2936
|
-
let out = p;
|
|
2937
|
-
for (const k of ['result', 'args']) {
|
|
2938
|
-
const v = out[k];
|
|
2939
|
-
if (v == null) continue;
|
|
2940
|
-
let s;
|
|
2941
|
-
if (typeof v === 'string') s = v;
|
|
2942
|
-
else { try { s = JSON.stringify(v); } catch { s = String(v); } }
|
|
2943
|
-
if (s.length > PERSIST_PART_CAP) {
|
|
2944
|
-
if (out === p) out = { ...p };
|
|
2945
|
-
out[k] = s.slice(0, PERSIST_PART_CAP);
|
|
2946
|
-
out.truncatedForStorage = true;
|
|
2947
|
-
}
|
|
2948
|
-
}
|
|
2949
|
-
return out;
|
|
2950
|
-
}
|
|
2951
|
-
function persistChat() {
|
|
2952
|
-
try {
|
|
2953
|
-
const eligible = state.chat.messages.filter(m => !isEmptyTurn(m));
|
|
2954
|
-
// On-screen transcript can exceed the cap; a reload only restores the last
|
|
2955
|
-
// PERSIST_MSG_CAP turns - surface that once so it's not a silent surprise,
|
|
2956
|
-
// same Alert pattern as the sibling quota-failure banner below.
|
|
2957
|
-
const wasCapped = eligible.length > PERSIST_MSG_CAP;
|
|
2958
|
-
if (wasCapped !== !!state.chat.persistTruncated) {
|
|
2959
|
-
state.chat.persistTruncated = wasCapped;
|
|
2960
|
-
scheduleRender();
|
|
2961
|
-
}
|
|
2962
|
-
const msgs = eligible
|
|
2963
|
-
.slice(-PERSIST_MSG_CAP)
|
|
2964
|
-
.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
|
|
2965
|
-
const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
|
|
2966
|
-
// Persist when there is a transcript OR a non-empty draft (a typed-but-not-
|
|
2967
|
-
// sent message must survive a reload too).
|
|
2968
|
-
if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
|
|
2969
|
-
_lastPersistTs = Date.now();
|
|
2970
|
-
localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
|
|
2971
|
-
} catch {
|
|
2972
|
-
if (!_persistFailedOnce) {
|
|
2973
|
-
_persistFailedOnce = true;
|
|
2974
|
-
state.chat.persistError = true;
|
|
2975
|
-
announce('chat too large to save locally - export it from settings');
|
|
2976
|
-
scheduleRender();
|
|
2977
|
-
}
|
|
2978
|
-
}
|
|
2979
|
-
}
|
|
2980
|
-
const debouncedPersistDraft = debounce(persistChat, 500);
|
|
2981
|
-
// Another GUI tab rewrote the shared chat key: never silently diverge - surface
|
|
2982
|
-
// a banner offering to reload the newer copy (last-writer-wins with a ts guard).
|
|
2983
|
-
window.addEventListener('storage', (e) => {
|
|
2984
|
-
if (e.key !== CHAT_KEY || !e.newValue) return;
|
|
2985
|
-
try {
|
|
2986
|
-
const remote = JSON.parse(e.newValue);
|
|
2987
|
-
if (remote && remote.ts && remote.ts > _lastPersistTs) {
|
|
2988
|
-
state.chat.externalUpdate = true;
|
|
2989
|
-
scheduleRender();
|
|
2990
|
-
}
|
|
2991
|
-
} catch {}
|
|
2992
|
-
});
|
|
2993
|
-
function restoreChat() {
|
|
2994
|
-
try {
|
|
2995
|
-
const raw = lsGet(CHAT_KEY);
|
|
2996
|
-
if (!raw) return;
|
|
2997
|
-
const saved = JSON.parse(raw);
|
|
2998
|
-
if (typeof saved.draft === 'string' && saved.draft.trim()) {
|
|
2999
|
-
state.chat.draft = saved.draft;
|
|
3000
|
-
// A restored draft is set programmatically, so Ctrl+Z has nothing in the
|
|
3001
|
-
// browser's native undo history to act on - a one-time note explains why
|
|
3002
|
-
// undo appears to do nothing right after a reload.
|
|
3003
|
-
announce('draft restored - note: undo history does not carry over a reload');
|
|
3004
|
-
}
|
|
3005
|
-
if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
|
|
3006
|
-
if (Array.isArray(saved.messages) && saved.messages.length) {
|
|
3007
|
-
state.chat.messages = saved.messages
|
|
3008
|
-
.filter(m => !isEmptyTurn(m))
|
|
3009
|
-
.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
|
|
3010
|
-
// Prefer the per-message cost sum when present (it self-corrects after
|
|
3011
|
-
// edit/retry truncation); the scalar is only the legacy fallback.
|
|
3012
|
-
const derived = computeTotalCost();
|
|
3013
|
-
if (derived) state.chat.totalCost = derived;
|
|
3014
|
-
state.chat.resumeSid = saved.resumeSid || null;
|
|
3015
|
-
// Restore the agent/model the transcript belongs to, so a restored chat
|
|
3016
|
-
// isn't silently shown under whatever agent the picker defaulted to.
|
|
3017
|
-
state.chat.restoredAgent = saved.agent || null;
|
|
3018
|
-
state.chat.restoredModel = saved.model || null;
|
|
3019
|
-
}
|
|
3020
|
-
} catch {}
|
|
3021
|
-
}
|
|
2920
|
+
// Chat transcript localStorage persistence - extracted to chat-persistence.js
|
|
2921
|
+
// (vertical slice per AGENTS.md SOLID/Clean-Architecture preferences). The
|
|
2922
|
+
// factory closes over the SAME live `state` object every other app.js concern
|
|
2923
|
+
// mutates by reference, so every call site below (persistChat/restoreChat/
|
|
2924
|
+
// debouncedPersistDraft/isEmptyTurn) keeps working unchanged.
|
|
2925
|
+
const { persistChat, restoreChat, debouncedPersistDraft, isEmptyTurn } =
|
|
2926
|
+
createChatPersistence(state, { announce, scheduleRender, lsGet, lsRemove, debounce });
|
|
3022
2927
|
|
|
3023
2928
|
// Flatten a message's content (string content or md/text/tool parts) to plain
|
|
3024
2929
|
// text for the copy action.
|