glad-web 1.0.19 → 1.0.21
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/config.js +82 -0
- package/lib/claude/structured-session.js +707 -0
- package/lib/commands/web.js +70 -12
- package/lib/session/session-manager.js +317 -2
- package/lib/web/index.html +1016 -2
- package/package.json +2 -1
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { normalizeEffort, normalizeModel, resolveClaudeModel } = require('./config');
|
|
4
|
+
|
|
5
|
+
const PERMISSION_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan']);
|
|
6
|
+
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
7
|
+
const EXIT_PLAN_TOOLS = new Set(['exit_plan_mode', 'ExitPlanMode']);
|
|
8
|
+
const DENY_PERMISSION_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
|
|
9
|
+
|
|
10
|
+
function normalizePermissionMode(value) {
|
|
11
|
+
const mode = String(value || 'default');
|
|
12
|
+
return PERMISSION_MODES.has(mode) ? mode : 'default';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function textFromContent(content) {
|
|
16
|
+
if (typeof content === 'string') return content;
|
|
17
|
+
if (!Array.isArray(content)) return '';
|
|
18
|
+
return content
|
|
19
|
+
.map(item => {
|
|
20
|
+
if (!item || typeof item !== 'object') return '';
|
|
21
|
+
if (item.type === 'text' && typeof item.text === 'string') return item.text;
|
|
22
|
+
if (item.type === 'tool_result') {
|
|
23
|
+
if (typeof item.content === 'string') return item.content;
|
|
24
|
+
if (Array.isArray(item.content)) return textFromContent(item.content);
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
})
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class AsyncMessageQueue {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.items = [];
|
|
35
|
+
this.waiters = [];
|
|
36
|
+
this.closed = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
push(item) {
|
|
40
|
+
if (this.closed) return false;
|
|
41
|
+
const waiter = this.waiters.shift();
|
|
42
|
+
if (waiter) waiter({ value: item, done: false });
|
|
43
|
+
else this.items.push(item);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
close() {
|
|
48
|
+
this.closed = true;
|
|
49
|
+
while (this.waiters.length > 0) {
|
|
50
|
+
this.waiters.shift()({ value: undefined, done: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
[Symbol.asyncIterator]() {
|
|
55
|
+
return {
|
|
56
|
+
next: () => {
|
|
57
|
+
if (this.items.length > 0) {
|
|
58
|
+
return Promise.resolve({ value: this.items.shift(), done: false });
|
|
59
|
+
}
|
|
60
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
61
|
+
return new Promise(resolve => this.waiters.push(resolve));
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class ClaudeStructuredSession extends EventEmitter {
|
|
68
|
+
constructor({ id, tool, workingDir, name, logger, options = {} }) {
|
|
69
|
+
super();
|
|
70
|
+
this.id = id;
|
|
71
|
+
this.tool = tool;
|
|
72
|
+
this.name = name || tool.displayName;
|
|
73
|
+
this.workingDir = workingDir;
|
|
74
|
+
this.logger = logger || console;
|
|
75
|
+
this.kind = 'claude-structured';
|
|
76
|
+
this.startTime = Date.now();
|
|
77
|
+
this.running = true;
|
|
78
|
+
this.status = 'idle';
|
|
79
|
+
this.messages = [];
|
|
80
|
+
this.pendingPermissions = new Map();
|
|
81
|
+
this.pendingInput = '';
|
|
82
|
+
this.inputSeq = 0;
|
|
83
|
+
this.hasUnreadCompletion = false;
|
|
84
|
+
this.completionReadInputSeq = 0;
|
|
85
|
+
this.timedInputs = new Map();
|
|
86
|
+
this.abortController = null;
|
|
87
|
+
this.inputQueue = null;
|
|
88
|
+
this.query = null;
|
|
89
|
+
this.runnerStarted = false;
|
|
90
|
+
this.abortRequested = false;
|
|
91
|
+
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
92
|
+
this.model = normalizeModel(options.model);
|
|
93
|
+
this.effort = normalizeEffort(options.effort);
|
|
94
|
+
this.allowedTools = new Set();
|
|
95
|
+
this.allowedBashLiterals = new Set();
|
|
96
|
+
this.allowedBashPrefixes = new Set();
|
|
97
|
+
this.resumeSessionId = options.resume || null;
|
|
98
|
+
this.claudeSessionId = options.resume || null;
|
|
99
|
+
this.activeOptionSignature = null;
|
|
100
|
+
this.contextRemaining = null;
|
|
101
|
+
this.latestUsage = null;
|
|
102
|
+
|
|
103
|
+
// Compatibility with existing session-scoped Git/file APIs.
|
|
104
|
+
this.ptyManager = {
|
|
105
|
+
workingDir,
|
|
106
|
+
isRunning: () => this.isRunning(),
|
|
107
|
+
write: data => this.write(data),
|
|
108
|
+
kill: () => this.kill(),
|
|
109
|
+
resize: () => {},
|
|
110
|
+
redraw: () => false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
toListItem() {
|
|
115
|
+
return {
|
|
116
|
+
id: this.id,
|
|
117
|
+
name: this.name,
|
|
118
|
+
tool: this.tool.displayName,
|
|
119
|
+
startTime: this.startTime,
|
|
120
|
+
toolKey: this.tool.key,
|
|
121
|
+
workingDirectory: this.workingDir,
|
|
122
|
+
hasUnreadCompletion: Boolean(this.hasUnreadCompletion),
|
|
123
|
+
timedInputCount: Array.from(this.timedInputs.values()).filter(item => item.sendAt > Date.now()).length,
|
|
124
|
+
mode: 'structured'
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
snapshot() {
|
|
129
|
+
return {
|
|
130
|
+
id: this.id,
|
|
131
|
+
name: this.name,
|
|
132
|
+
tool: this.tool.displayName,
|
|
133
|
+
toolKey: this.tool.key,
|
|
134
|
+
status: this.status,
|
|
135
|
+
state: this.getControlState(),
|
|
136
|
+
messages: this.messages,
|
|
137
|
+
pendingPermissions: Array.from(this.pendingPermissions.values()).map(item => item.public)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getControlState() {
|
|
142
|
+
return {
|
|
143
|
+
permissionMode: this.permissionMode,
|
|
144
|
+
model: this.model,
|
|
145
|
+
effort: this.effort,
|
|
146
|
+
status: this.status,
|
|
147
|
+
claudeSessionId: this.claudeSessionId || null,
|
|
148
|
+
resumeSessionId: this.resumeSessionId || null,
|
|
149
|
+
contextRemaining: this.contextRemaining,
|
|
150
|
+
latestUsage: this.latestUsage,
|
|
151
|
+
canAbort: this.status === 'thinking',
|
|
152
|
+
pendingPermissionCount: this.pendingPermissions.size
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getHistory() {
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const message of this.messages) {
|
|
159
|
+
if (message.kind === 'user') lines.push(`User: ${message.text}`);
|
|
160
|
+
if (message.kind === 'assistant') lines.push(`Claude: ${message.text}`);
|
|
161
|
+
if (message.kind === 'tool') lines.push(`Tool ${message.name}: ${message.summary}`);
|
|
162
|
+
if (message.kind === 'event') lines.push(message.text);
|
|
163
|
+
}
|
|
164
|
+
const text = lines.join('\n\n');
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
sessionId: this.id,
|
|
168
|
+
sessionName: this.name,
|
|
169
|
+
tool: this.tool.displayName,
|
|
170
|
+
historyMode: 'structured',
|
|
171
|
+
text,
|
|
172
|
+
updatedAt: Date.now(),
|
|
173
|
+
truncated: false,
|
|
174
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
175
|
+
lines: text ? text.split('\n').length : 0
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getCatchupOutput() {
|
|
180
|
+
return {
|
|
181
|
+
source: 'claude-structured',
|
|
182
|
+
items: this.messages.length,
|
|
183
|
+
data: ''
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
write(data) {
|
|
188
|
+
if (!this.running) return false;
|
|
189
|
+
const text = String(data || '');
|
|
190
|
+
if (!text) return true;
|
|
191
|
+
|
|
192
|
+
this.pendingInput += text.replace(/\r/g, '\n');
|
|
193
|
+
if (!this.pendingInput.includes('\n')) return true;
|
|
194
|
+
|
|
195
|
+
const parts = this.pendingInput.split('\n');
|
|
196
|
+
this.pendingInput = parts.pop() || '';
|
|
197
|
+
const prompt = parts.join('\n').trim();
|
|
198
|
+
if (prompt) this.sendUserMessage(prompt);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
sendUserMessage(text) {
|
|
203
|
+
if (!this.running) return false;
|
|
204
|
+
const prompt = String(text || '').trim();
|
|
205
|
+
if (!prompt) return false;
|
|
206
|
+
this.hasUnreadCompletion = false;
|
|
207
|
+
this.appendMessage({ kind: 'user', text: prompt });
|
|
208
|
+
|
|
209
|
+
const sdkMessage = {
|
|
210
|
+
type: 'user',
|
|
211
|
+
parent_tool_use_id: null,
|
|
212
|
+
message: {
|
|
213
|
+
role: 'user',
|
|
214
|
+
content: prompt
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (!this.runnerStarted) {
|
|
219
|
+
this.startRunner(sdkMessage);
|
|
220
|
+
} else if (this.inputQueue) {
|
|
221
|
+
this.inputQueue.push(sdkMessage);
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
createMessageItem(message) {
|
|
227
|
+
return {
|
|
228
|
+
id: crypto.randomUUID(),
|
|
229
|
+
createdAt: Date.now(),
|
|
230
|
+
...message
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
updateSettings(settings = {}) {
|
|
235
|
+
const next = {
|
|
236
|
+
permissionMode: settings.permissionMode === undefined ? this.permissionMode : normalizePermissionMode(settings.permissionMode),
|
|
237
|
+
model: settings.model === undefined ? this.model : normalizeModel(settings.model),
|
|
238
|
+
effort: settings.effort === undefined ? this.effort : normalizeEffort(settings.effort)
|
|
239
|
+
};
|
|
240
|
+
const changed = next.permissionMode !== this.permissionMode
|
|
241
|
+
|| next.model !== this.model
|
|
242
|
+
|| next.effort !== this.effort;
|
|
243
|
+
this.permissionMode = next.permissionMode;
|
|
244
|
+
this.model = next.model;
|
|
245
|
+
this.effort = next.effort;
|
|
246
|
+
|
|
247
|
+
if (changed) {
|
|
248
|
+
if (this.query && typeof this.query.setPermissionMode === 'function') {
|
|
249
|
+
Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
|
|
250
|
+
this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (this.runnerStarted && this.status === 'idle' && this.activeOptionSignature !== this.getOptionSignature()) {
|
|
254
|
+
this.resetRunnerForNextTurn();
|
|
255
|
+
}
|
|
256
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
257
|
+
}
|
|
258
|
+
return this.getControlState();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
selectResumeSession(resumeSessionId, historyMessages = null) {
|
|
262
|
+
const id = String(resumeSessionId || '').trim();
|
|
263
|
+
if (!id) return false;
|
|
264
|
+
if (this.status === 'thinking') this.abort('Switching resume target');
|
|
265
|
+
this.resumeSessionId = id;
|
|
266
|
+
this.claudeSessionId = id;
|
|
267
|
+
this.resetRunnerForNextTurn();
|
|
268
|
+
const eventMessage = this.createMessageItem({
|
|
269
|
+
kind: 'event',
|
|
270
|
+
level: 'info',
|
|
271
|
+
text: `Resume target selected: ${id}`
|
|
272
|
+
});
|
|
273
|
+
if (Array.isArray(historyMessages)) {
|
|
274
|
+
this.messages = [...historyMessages, eventMessage];
|
|
275
|
+
this.emitEvent({ type: 'history-reset', messages: this.messages });
|
|
276
|
+
} else {
|
|
277
|
+
this.messages.push(eventMessage);
|
|
278
|
+
this.emitEvent({ type: 'message', message: eventMessage });
|
|
279
|
+
}
|
|
280
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
abort(reason = 'Aborted by user') {
|
|
285
|
+
if (!this.runnerStarted && this.status !== 'thinking') return false;
|
|
286
|
+
this.abortRequested = true;
|
|
287
|
+
this.inputQueue?.close();
|
|
288
|
+
this.query?.close?.();
|
|
289
|
+
this.abortController?.abort();
|
|
290
|
+
this.runnerStarted = false;
|
|
291
|
+
this.inputQueue = null;
|
|
292
|
+
this.query = null;
|
|
293
|
+
this.abortController = null;
|
|
294
|
+
this.setStatus('idle');
|
|
295
|
+
this.appendMessage({ kind: 'event', level: 'info', text: reason });
|
|
296
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
resetRunnerForNextTurn() {
|
|
301
|
+
this.inputQueue?.close();
|
|
302
|
+
this.query?.close?.();
|
|
303
|
+
this.runnerStarted = false;
|
|
304
|
+
this.inputQueue = null;
|
|
305
|
+
this.query = null;
|
|
306
|
+
this.abortController = null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
finishRunner() {
|
|
310
|
+
this.runnerStarted = false;
|
|
311
|
+
this.inputQueue?.close();
|
|
312
|
+
this.inputQueue = null;
|
|
313
|
+
this.query = null;
|
|
314
|
+
this.abortController = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
respondPermission(id, approved, action = null) {
|
|
318
|
+
const pending = this.pendingPermissions.get(id);
|
|
319
|
+
if (!pending) return false;
|
|
320
|
+
this.pendingPermissions.delete(id);
|
|
321
|
+
const normalizedAction = this.normalizePermissionAction(action, approved);
|
|
322
|
+
const allowedTools = this.getAllowedToolsForAction(normalizedAction, pending);
|
|
323
|
+
const nextMode = this.getPermissionModeForAction(normalizedAction);
|
|
324
|
+
if (allowedTools.length > 0) this.addAllowedTools(allowedTools);
|
|
325
|
+
if (nextMode) {
|
|
326
|
+
this.permissionMode = nextMode;
|
|
327
|
+
if (this.query && typeof this.query.setPermissionMode === 'function') {
|
|
328
|
+
Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
|
|
329
|
+
this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const publicRequest = {
|
|
334
|
+
...pending.public,
|
|
335
|
+
status: approved ? 'approved' : 'denied',
|
|
336
|
+
action: normalizedAction,
|
|
337
|
+
mode: nextMode || undefined,
|
|
338
|
+
allowedTools: allowedTools.length > 0 ? allowedTools : undefined
|
|
339
|
+
};
|
|
340
|
+
this.emitEvent({ type: 'permission-updated', request: publicRequest });
|
|
341
|
+
pending.resolve(approved
|
|
342
|
+
? {
|
|
343
|
+
behavior: 'allow',
|
|
344
|
+
updatedInput: pending.input || {},
|
|
345
|
+
updatedPermissions: this.getPermissionUpdatesForAction(normalizedAction, pending),
|
|
346
|
+
toolUseID: pending.toolUseID,
|
|
347
|
+
decisionClassification: normalizedAction === 'allow-once' ? 'user_temporary' : 'user_permanent'
|
|
348
|
+
}
|
|
349
|
+
: { behavior: 'deny', message: DENY_PERMISSION_MESSAGE, interrupt: true, toolUseID: pending.toolUseID, decisionClassification: 'user_reject' });
|
|
350
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
normalizePermissionAction(action, approved) {
|
|
355
|
+
if (!approved) return 'deny';
|
|
356
|
+
const value = String(action || '').trim();
|
|
357
|
+
if (['allow-once', 'allow-tool', 'allow-edits', 'bypass'].includes(value)) return value;
|
|
358
|
+
return 'allow-once';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
getPermissionModeForAction(action) {
|
|
362
|
+
if (action === 'allow-edits') return 'acceptEdits';
|
|
363
|
+
if (action === 'bypass') return 'bypassPermissions';
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
getSdkPermissionMode() {
|
|
368
|
+
// Claude CLI refuses --dangerously-skip-permissions under root/sudo. Glad
|
|
369
|
+
// keeps bypass as local state and auto-allows through canUseTool instead.
|
|
370
|
+
if (this.permissionMode === 'bypassPermissions') return 'default';
|
|
371
|
+
return this.permissionMode;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
getAllowedToolsForAction(action, pending) {
|
|
375
|
+
if (action !== 'allow-tool') return [];
|
|
376
|
+
const toolName = pending.toolName || pending.public.toolName;
|
|
377
|
+
if (!toolName) return [];
|
|
378
|
+
if (toolName === 'Bash') {
|
|
379
|
+
const command = pending.input && typeof pending.input.command === 'string'
|
|
380
|
+
? pending.input.command
|
|
381
|
+
: '';
|
|
382
|
+
return command ? [`Bash(${command})`] : ['Bash'];
|
|
383
|
+
}
|
|
384
|
+
return [toolName];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
getPermissionUpdatesForAction(action, pending) {
|
|
388
|
+
const mode = this.getPermissionModeForAction(action);
|
|
389
|
+
const allowedTools = this.getAllowedToolsForAction(action, pending);
|
|
390
|
+
const updates = [];
|
|
391
|
+
if (allowedTools.length > 0) {
|
|
392
|
+
updates.push({
|
|
393
|
+
type: 'addRules',
|
|
394
|
+
rules: allowedTools.map(tool => this.permissionRuleFromTool(tool)),
|
|
395
|
+
behavior: 'allow',
|
|
396
|
+
destination: 'session'
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (mode === 'acceptEdits') {
|
|
400
|
+
updates.push({
|
|
401
|
+
type: 'addRules',
|
|
402
|
+
rules: Array.from(EDIT_TOOLS).map(toolName => ({ toolName })),
|
|
403
|
+
behavior: 'allow',
|
|
404
|
+
destination: 'session'
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return updates.length > 0 ? updates : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
permissionRuleFromTool(tool) {
|
|
411
|
+
const match = String(tool || '').match(/^Bash\(([\s\S]*)\)$/);
|
|
412
|
+
if (match) return { toolName: 'Bash', ruleContent: match[1] };
|
|
413
|
+
return { toolName: String(tool || '') };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
addAllowedTools(tools) {
|
|
417
|
+
for (const tool of tools) {
|
|
418
|
+
if (tool === 'Bash') {
|
|
419
|
+
this.allowedTools.add(tool);
|
|
420
|
+
} else if (tool.startsWith('Bash(')) {
|
|
421
|
+
this.parseBashPermission(tool);
|
|
422
|
+
} else {
|
|
423
|
+
this.allowedTools.add(tool);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
parseBashPermission(permission) {
|
|
429
|
+
const match = String(permission || '').match(/^Bash\(([\s\S]*)\)$/);
|
|
430
|
+
if (!match) return;
|
|
431
|
+
const command = match[1];
|
|
432
|
+
if (command.endsWith(':*')) {
|
|
433
|
+
this.allowedBashPrefixes.add(command.slice(0, -2));
|
|
434
|
+
} else {
|
|
435
|
+
this.allowedBashLiterals.add(command);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
isToolAllowed(toolName, input) {
|
|
440
|
+
if (toolName === 'Bash') {
|
|
441
|
+
if (this.allowedTools.has('Bash')) return true;
|
|
442
|
+
const command = input && typeof input.command === 'string' ? input.command : '';
|
|
443
|
+
if (command && this.allowedBashLiterals.has(command)) return true;
|
|
444
|
+
for (const prefix of this.allowedBashPrefixes) {
|
|
445
|
+
if (command.startsWith(prefix)) return true;
|
|
446
|
+
}
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
return this.allowedTools.has(toolName);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
shouldAutoAllowTool(toolName, input) {
|
|
453
|
+
if (this.isToolAllowed(toolName, input)) return true;
|
|
454
|
+
if (this.permissionMode === 'bypassPermissions' && !EXIT_PLAN_TOOLS.has(toolName)) return true;
|
|
455
|
+
if (this.permissionMode === 'acceptEdits' && EDIT_TOOLS.has(toolName)) return true;
|
|
456
|
+
if (this.permissionMode === 'plan' && !this.isDangerousTool(toolName)) return true;
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
isDangerousTool(toolName) {
|
|
461
|
+
return toolName === 'Bash' || EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
markCompletionRead() {
|
|
465
|
+
this.hasUnreadCompletion = false;
|
|
466
|
+
this.completionReadInputSeq = this.inputSeq || 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async startRunner(initialMessage) {
|
|
470
|
+
this.runnerStarted = true;
|
|
471
|
+
this.inputQueue = new AsyncMessageQueue();
|
|
472
|
+
this.inputQueue.push(initialMessage);
|
|
473
|
+
this.abortController = new AbortController();
|
|
474
|
+
this.abortRequested = false;
|
|
475
|
+
this.activeOptionSignature = this.getOptionSignature();
|
|
476
|
+
this.setStatus('thinking');
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
480
|
+
const resolvedModel = resolveClaudeModel(this.model, process.env);
|
|
481
|
+
const options = {
|
|
482
|
+
cwd: this.workingDir,
|
|
483
|
+
resume: this.resumeSessionId || undefined,
|
|
484
|
+
permissionMode: this.getSdkPermissionMode(),
|
|
485
|
+
allowDangerouslySkipPermissions: false,
|
|
486
|
+
effort: this.effort,
|
|
487
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
488
|
+
env: {
|
|
489
|
+
...process.env,
|
|
490
|
+
CLAUDE_AGENT_SDK_CLIENT_APP: 'glad-web'
|
|
491
|
+
},
|
|
492
|
+
abortController: this.abortController,
|
|
493
|
+
canUseTool: (toolName, input, options) => this.requestPermission(toolName, input, options)
|
|
494
|
+
};
|
|
495
|
+
if (resolvedModel) options.model = resolvedModel;
|
|
496
|
+
this.query = sdk.query({
|
|
497
|
+
prompt: this.inputQueue,
|
|
498
|
+
options
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
for await (const message of this.query) {
|
|
502
|
+
this.handleSdkMessage(message);
|
|
503
|
+
}
|
|
504
|
+
this.finishRunner();
|
|
505
|
+
this.setStatus('idle');
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (!this.running) return;
|
|
508
|
+
if (this.abortRequested) {
|
|
509
|
+
this.finishRunner();
|
|
510
|
+
this.setStatus('idle');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.appendMessage({
|
|
514
|
+
kind: 'event',
|
|
515
|
+
level: 'error',
|
|
516
|
+
text: `Claude session error: ${error && error.message ? error.message : String(error)}`
|
|
517
|
+
});
|
|
518
|
+
this.setStatus('error');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
getOptionSignature() {
|
|
523
|
+
return JSON.stringify({
|
|
524
|
+
permissionMode: this.permissionMode,
|
|
525
|
+
model: this.model,
|
|
526
|
+
effort: this.effort,
|
|
527
|
+
resume: this.resumeSessionId || null
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
requestPermission(toolName, input, options = {}) {
|
|
532
|
+
if (toolName !== 'AskUserQuestion' && this.shouldAutoAllowTool(toolName, input)) {
|
|
533
|
+
return Promise.resolve({
|
|
534
|
+
behavior: 'allow',
|
|
535
|
+
updatedInput: input || {},
|
|
536
|
+
toolUseID: options.toolUseID,
|
|
537
|
+
decisionClassification: 'user_permanent'
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
const id = crypto.randomUUID();
|
|
541
|
+
const request = {
|
|
542
|
+
id,
|
|
543
|
+
toolName,
|
|
544
|
+
title: options.title || `${toolName} requires approval`,
|
|
545
|
+
displayName: options.displayName || '',
|
|
546
|
+
description: options.description || '',
|
|
547
|
+
reason: options.decisionReason || options.description || '',
|
|
548
|
+
blockedPath: options.blockedPath || null,
|
|
549
|
+
canAllowTool: Boolean(toolName && !EDIT_TOOLS.has(toolName) && !EXIT_PLAN_TOOLS.has(toolName)),
|
|
550
|
+
canAllowEdits: EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName),
|
|
551
|
+
canBypass: EXIT_PLAN_TOOLS.has(toolName),
|
|
552
|
+
input,
|
|
553
|
+
createdAt: Date.now(),
|
|
554
|
+
status: 'pending'
|
|
555
|
+
};
|
|
556
|
+
this.emitEvent({ type: 'permission-request', request });
|
|
557
|
+
return new Promise(resolve => {
|
|
558
|
+
this.pendingPermissions.set(id, {
|
|
559
|
+
public: request,
|
|
560
|
+
resolve,
|
|
561
|
+
input,
|
|
562
|
+
toolName,
|
|
563
|
+
toolUseID: options.toolUseID
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
handleSdkMessage(message) {
|
|
569
|
+
if (!message || typeof message !== 'object') return;
|
|
570
|
+
|
|
571
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
572
|
+
this.claudeSessionId = message.session_id || this.claudeSessionId;
|
|
573
|
+
this.resumeSessionId = this.claudeSessionId || this.resumeSessionId;
|
|
574
|
+
if (message.model) this.model = String(message.model);
|
|
575
|
+
this.appendMessage({
|
|
576
|
+
kind: 'event',
|
|
577
|
+
level: 'info',
|
|
578
|
+
text: `Claude ready${message.model ? ` (${message.model})` : ''}`
|
|
579
|
+
});
|
|
580
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (message.type === 'assistant') {
|
|
585
|
+
const usage = message.message && message.message.usage;
|
|
586
|
+
if (usage) this.updateLatestUsageFromClaudeUsage(usage, message.message.model);
|
|
587
|
+
const content = message.message && message.message.content;
|
|
588
|
+
const text = textFromContent(content).trim();
|
|
589
|
+
const toolBlocks = Array.isArray(content)
|
|
590
|
+
? content.filter(item => item && item.type === 'tool_use')
|
|
591
|
+
: [];
|
|
592
|
+
if (text) {
|
|
593
|
+
this.appendMessage({ kind: 'assistant', text, raw: message });
|
|
594
|
+
}
|
|
595
|
+
for (const block of toolBlocks) {
|
|
596
|
+
this.appendMessage({
|
|
597
|
+
kind: 'tool',
|
|
598
|
+
name: block.name || 'tool',
|
|
599
|
+
summary: this.summarizeToolInput(block.input),
|
|
600
|
+
input: block.input,
|
|
601
|
+
toolUseId: block.id
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
this.setStatus('thinking');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (message.type === 'user') {
|
|
609
|
+
const content = message.message && message.message.content;
|
|
610
|
+
if (Array.isArray(content)) {
|
|
611
|
+
for (const item of content) {
|
|
612
|
+
if (item && item.type === 'tool_result') {
|
|
613
|
+
this.appendMessage({
|
|
614
|
+
kind: 'tool-result',
|
|
615
|
+
toolUseId: item.tool_use_id,
|
|
616
|
+
text: textFromContent([item]).trim(),
|
|
617
|
+
isError: Boolean(item.is_error)
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (message.type === 'result') {
|
|
626
|
+
this.updateUsageFromResult(message);
|
|
627
|
+
this.setStatus('idle');
|
|
628
|
+
if (this.inputSeq > this.completionReadInputSeq) {
|
|
629
|
+
this.hasUnreadCompletion = true;
|
|
630
|
+
}
|
|
631
|
+
this.emit('complete');
|
|
632
|
+
if (this.activeOptionSignature !== this.getOptionSignature()) {
|
|
633
|
+
this.resetRunnerForNextTurn();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
updateUsageFromResult(message) {
|
|
639
|
+
const usage = message && (message.usage || message.total_usage || message.result && message.result.usage);
|
|
640
|
+
const context = usage && (usage.context_remaining || usage.contextRemaining || usage.remaining_context || usage.remainingContext);
|
|
641
|
+
this.contextRemaining = typeof context === 'number' ? context : null;
|
|
642
|
+
if (usage) this.updateLatestUsageFromClaudeUsage(usage, message.model || message.result && message.result.model);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
updateLatestUsageFromClaudeUsage(usage, model = null) {
|
|
646
|
+
const inputTokens = Number(usage.input_tokens || usage.inputTokens || 0);
|
|
647
|
+
const outputTokens = Number(usage.output_tokens || usage.outputTokens || 0);
|
|
648
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens || usage.cacheCreationInputTokens || 0);
|
|
649
|
+
const cacheRead = Number(usage.cache_read_input_tokens || usage.cacheReadInputTokens || 0);
|
|
650
|
+
const contextSize = inputTokens + cacheCreation + cacheRead;
|
|
651
|
+
this.latestUsage = {
|
|
652
|
+
inputTokens,
|
|
653
|
+
outputTokens,
|
|
654
|
+
cacheCreation,
|
|
655
|
+
cacheRead,
|
|
656
|
+
totalTokens: inputTokens + outputTokens + cacheCreation + cacheRead,
|
|
657
|
+
contextSize,
|
|
658
|
+
model: model || null,
|
|
659
|
+
updatedAt: Date.now()
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
summarizeToolInput(input) {
|
|
664
|
+
if (!input || typeof input !== 'object') return '';
|
|
665
|
+
if (typeof input.command === 'string') return input.command;
|
|
666
|
+
if (typeof input.file_path === 'string') return input.file_path;
|
|
667
|
+
if (typeof input.path === 'string') return input.path;
|
|
668
|
+
const serialized = JSON.stringify(input);
|
|
669
|
+
return serialized.length > 240 ? serialized.slice(0, 240) + '...' : serialized;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
appendMessage(message) {
|
|
673
|
+
const item = this.createMessageItem(message);
|
|
674
|
+
this.messages.push(item);
|
|
675
|
+
this.emitEvent({ type: 'message', message: item });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
setStatus(status) {
|
|
679
|
+
if (this.status === status) return;
|
|
680
|
+
this.status = status;
|
|
681
|
+
this.emitEvent({ type: 'status', status });
|
|
682
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
emitEvent(event) {
|
|
686
|
+
this.emit('event', event);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
isRunning() {
|
|
690
|
+
return this.running;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
kill() {
|
|
694
|
+
this.running = false;
|
|
695
|
+
this.inputQueue?.close();
|
|
696
|
+
this.query?.close?.();
|
|
697
|
+
this.abortController?.abort();
|
|
698
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
699
|
+
pending.resolve({ behavior: 'deny', message: 'Session ended', interrupt: true, toolUseID: pending.toolUseID });
|
|
700
|
+
}
|
|
701
|
+
this.pendingPermissions.clear();
|
|
702
|
+
this.setStatus('stopped');
|
|
703
|
+
this.emit('exit', { exitCode: 0 });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
module.exports = ClaudeStructuredSession;
|