glad-web 1.0.46 → 2.0.2

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.
Files changed (74) hide show
  1. package/README.md +4 -192
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/glad.cjs +56 -0
  4. package/package.json +19 -61
  5. package/README.zh-CN.md +0 -198
  6. package/assets/logo.svg +0 -43
  7. package/bin/cli.js +0 -65
  8. package/lib/ai-tools/demo/enhanced-demo.js +0 -625
  9. package/lib/ai-tools/demo/index.js +0 -24
  10. package/lib/ai-tools/demo/responses.js +0 -88
  11. package/lib/ai-tools/detector.js +0 -76
  12. package/lib/ai-tools/registry.js +0 -300
  13. package/lib/claude/cli-usage.js +0 -95
  14. package/lib/claude/config.js +0 -82
  15. package/lib/claude/structured-session.js +0 -884
  16. package/lib/claude/transcript-repository.js +0 -216
  17. package/lib/codex/image-store.js +0 -174
  18. package/lib/codex/structured-session.js +0 -1590
  19. package/lib/commands/config.js +0 -78
  20. package/lib/commands/tools.js +0 -128
  21. package/lib/commands/web.js +0 -605
  22. package/lib/config/constants.js +0 -17
  23. package/lib/config/manager.js +0 -108
  24. package/lib/git/service.js +0 -83
  25. package/lib/notifications/message-formatter.js +0 -94
  26. package/lib/notifications/notification-service.js +0 -143
  27. package/lib/notifications/serverchan-client.js +0 -58
  28. package/lib/notifications/serverchan-settings-store.js +0 -115
  29. package/lib/schedule/job-runner.js +0 -162
  30. package/lib/schedule/job-store.js +0 -167
  31. package/lib/schedule/key-sequences.js +0 -49
  32. package/lib/schedule/scheduler-service.js +0 -39
  33. package/lib/server/routes/notifications.js +0 -52
  34. package/lib/server/routes/providers.js +0 -114
  35. package/lib/server/routes/schedules.js +0 -54
  36. package/lib/server/routes/skillhub.js +0 -104
  37. package/lib/server/routes/usage.js +0 -23
  38. package/lib/server/routes/workspace.js +0 -77
  39. package/lib/session/buffer.js +0 -102
  40. package/lib/session/file-attachment-store.js +0 -168
  41. package/lib/session/pty-manager.js +0 -255
  42. package/lib/session/rendered-history.js +0 -225
  43. package/lib/session/session-manager.js +0 -1032
  44. package/lib/session/text-history.js +0 -274
  45. package/lib/skillhub/client.js +0 -121
  46. package/lib/skillhub/settings-store.js +0 -168
  47. package/lib/skillhub/skill-installer.js +0 -320
  48. package/lib/usage/ccusage-runner.js +0 -128
  49. package/lib/usage/source-catalog.js +0 -26
  50. package/lib/usage/usage-service.js +0 -226
  51. package/lib/utils/logger.js +0 -74
  52. package/lib/utils/pid.js +0 -67
  53. package/lib/utils/validation.js +0 -53
  54. package/lib/web/bootstrap.js +0 -34
  55. package/lib/web/claude.js +0 -1150
  56. package/lib/web/codex.js +0 -1045
  57. package/lib/web/composer.js +0 -493
  58. package/lib/web/core.js +0 -385
  59. package/lib/web/git.js +0 -535
  60. package/lib/web/gitgraph.js +0 -293
  61. package/lib/web/index.html +0 -547
  62. package/lib/web/layout.js +0 -69
  63. package/lib/web/notifications.js +0 -164
  64. package/lib/web/schedules.js +0 -245
  65. package/lib/web/session.js +0 -361
  66. package/lib/web/shell.js +0 -74
  67. package/lib/web/skillhub.js +0 -197
  68. package/lib/web/styles.css +0 -932
  69. package/lib/web/terminal-scroll.js +0 -81
  70. package/lib/web/theme.js +0 -60
  71. package/lib/web/timed-inputs.js +0 -216
  72. package/lib/web/usage.js +0 -323
  73. package/lib/workspace/service.js +0 -77
  74. package/scripts/check-syntax.js +0 -26
@@ -1,884 +0,0 @@
1
- const { EventEmitter } = require('events');
2
- const crypto = require('crypto');
3
- const { normalizeEffort, normalizeModel, resolveClaudeModel } = require('./config');
4
- const { parseClaudeContextOutput, parseClaudeUsageOutput } = require('./cli-usage');
5
-
6
- const PERMISSION_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan']);
7
- const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
8
- const EXIT_PLAN_TOOLS = new Set(['exit_plan_mode', 'ExitPlanMode']);
9
- 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.";
10
- const LOCAL_COMMAND_TIMEOUT_MS = 30_000;
11
-
12
- function normalizePermissionMode(value) {
13
- const mode = String(value || 'default');
14
- return PERMISSION_MODES.has(mode) ? mode : 'default';
15
- }
16
-
17
- function textFromContent(content) {
18
- if (typeof content === 'string') return content;
19
- if (!Array.isArray(content)) return '';
20
- return content
21
- .map(item => {
22
- if (!item || typeof item !== 'object') return '';
23
- if (item.type === 'text' && typeof item.text === 'string') return item.text;
24
- if (item.type === 'tool_result') {
25
- if (typeof item.content === 'string') return item.content;
26
- if (Array.isArray(item.content)) return textFromContent(item.content);
27
- }
28
- return '';
29
- })
30
- .filter(Boolean)
31
- .join('\n');
32
- }
33
-
34
- class AsyncMessageQueue {
35
- constructor() {
36
- this.items = [];
37
- this.waiters = [];
38
- this.closed = false;
39
- }
40
-
41
- push(item) {
42
- if (this.closed) return false;
43
- const waiter = this.waiters.shift();
44
- if (waiter) waiter({ value: item, done: false });
45
- else this.items.push(item);
46
- return true;
47
- }
48
-
49
- close() {
50
- this.closed = true;
51
- while (this.waiters.length > 0) {
52
- this.waiters.shift()({ value: undefined, done: true });
53
- }
54
- }
55
-
56
- [Symbol.asyncIterator]() {
57
- return {
58
- next: () => {
59
- if (this.items.length > 0) {
60
- return Promise.resolve({ value: this.items.shift(), done: false });
61
- }
62
- if (this.closed) return Promise.resolve({ value: undefined, done: true });
63
- return new Promise(resolve => this.waiters.push(resolve));
64
- }
65
- };
66
- }
67
- }
68
-
69
- class ClaudeStructuredSession extends EventEmitter {
70
- constructor({
71
- id,
72
- tool,
73
- workingDir,
74
- name,
75
- logger,
76
- options = {},
77
- localCommandRunner = null
78
- }) {
79
- super();
80
- this.id = id;
81
- this.tool = tool;
82
- this.name = name || tool.displayName;
83
- this.workingDir = workingDir;
84
- this.logger = logger || console;
85
- this.kind = 'claude-structured';
86
- this.startTime = Date.now();
87
- this.running = true;
88
- this.status = 'idle';
89
- this.messages = [];
90
- this.pendingPermissions = new Map();
91
- this.pendingInput = '';
92
- this.inputSeq = 0;
93
- this.hasUnreadCompletion = false;
94
- this.completionReadInputSeq = 0;
95
- this.timedInputs = new Map();
96
- this.abortController = null;
97
- this.inputQueue = null;
98
- this.query = null;
99
- this.runnerStarted = false;
100
- this.runnerReadyPromise = null;
101
- this.runnerInitializationAnnounced = false;
102
- this.abortRequested = false;
103
- this.localCommandRunner = localCommandRunner;
104
- this.localCommandChain = Promise.resolve();
105
- this.pendingLocalCommand = null;
106
- this.permissionMode = normalizePermissionMode(options.permissionMode);
107
- this.model = normalizeModel(options.model);
108
- this.effort = normalizeEffort(options.effort);
109
- this.allowedTools = new Set();
110
- this.allowedBashLiterals = new Set();
111
- this.allowedBashPrefixes = new Set();
112
- this.resumeSessionId = options.resume || null;
113
- this.claudeSessionId = options.resume || null;
114
- this.activeOptionSignature = null;
115
- this.turnQueue = [];
116
-
117
- // Compatibility with existing session-scoped Git/file APIs.
118
- this.ptyManager = {
119
- workingDir,
120
- isRunning: () => this.isRunning(),
121
- write: data => this.write(data),
122
- kill: () => this.kill(),
123
- resize: () => {},
124
- redraw: () => false
125
- };
126
- }
127
-
128
- toListItem() {
129
- return {
130
- id: this.id,
131
- name: this.name,
132
- tool: this.tool.displayName,
133
- startTime: this.startTime,
134
- toolKey: this.tool.key,
135
- workingDirectory: this.workingDir,
136
- hasUnreadCompletion: Boolean(this.hasUnreadCompletion),
137
- timedInputCount: Array.from(this.timedInputs.values()).filter(item => item.sendAt > Date.now()).length,
138
- mode: 'structured'
139
- };
140
- }
141
-
142
- snapshot() {
143
- return {
144
- id: this.id,
145
- name: this.name,
146
- tool: this.tool.displayName,
147
- toolKey: this.tool.key,
148
- status: this.status,
149
- state: this.getControlState(),
150
- messages: this.messages,
151
- pendingPermissions: Array.from(this.pendingPermissions.values()).map(item => item.public)
152
- };
153
- }
154
-
155
- getControlState() {
156
- return {
157
- permissionMode: this.permissionMode,
158
- model: this.model,
159
- effort: this.effort,
160
- status: this.status,
161
- claudeSessionId: this.claudeSessionId || null,
162
- resumeSessionId: this.resumeSessionId || null,
163
- canAbort: this.status === 'thinking',
164
- pendingPermissionCount: this.pendingPermissions.size
165
- };
166
- }
167
-
168
- getHistory() {
169
- const lines = [];
170
- for (const message of this.messages) {
171
- if (message.kind === 'user') lines.push(`User: ${message.text}`);
172
- if (message.kind === 'assistant') lines.push(`Claude: ${message.text}`);
173
- if (message.kind === 'tool') lines.push(`Tool ${message.name}: ${message.summary}`);
174
- if (message.kind === 'event') lines.push(message.text);
175
- }
176
- const text = lines.join('\n\n');
177
- return {
178
- success: true,
179
- sessionId: this.id,
180
- sessionName: this.name,
181
- tool: this.tool.displayName,
182
- historyMode: 'structured',
183
- text,
184
- updatedAt: Date.now(),
185
- truncated: false,
186
- bytes: Buffer.byteLength(text, 'utf8'),
187
- lines: text ? text.split('\n').length : 0
188
- };
189
- }
190
-
191
- getCatchupOutput() {
192
- return {
193
- source: 'claude-structured',
194
- items: this.messages.length,
195
- data: ''
196
- };
197
- }
198
-
199
- write(data) {
200
- if (!this.running) return false;
201
- const text = String(data || '');
202
- if (!text) return true;
203
-
204
- this.pendingInput += text.replace(/\r/g, '\n');
205
- if (!this.pendingInput.includes('\n')) return true;
206
-
207
- const parts = this.pendingInput.split('\n');
208
- this.pendingInput = parts.pop() || '';
209
- const prompt = parts.join('\n').trim();
210
- if (prompt) this.sendUserMessage(prompt);
211
- return true;
212
- }
213
-
214
- sendUserMessage(text, attachments = [], options = {}) {
215
- if (!this.running) return false;
216
- const prompt = String(text || '').trim();
217
- const agentPrompt = String(options.agentText ?? prompt).trim();
218
- const images = Array.isArray(attachments) ? attachments.filter(item => item?.data && item?.mediaType) : [];
219
- const displayAttachments = Array.isArray(options.displayAttachments) ? options.displayAttachments : [];
220
- if (!agentPrompt && images.length === 0) return false;
221
- this.hasUnreadCompletion = false;
222
- const turn = this.beginTurn(prompt, [
223
- ...images.map(item => ({ name: item.name, size: item.size })),
224
- ...displayAttachments
225
- ]);
226
- this.setStatus('thinking');
227
-
228
- const content = images.length > 0 ? [
229
- ...(agentPrompt ? [{ type: 'text', text: agentPrompt }] : []),
230
- ...images.map(item => ({
231
- type: 'image',
232
- source: { type: 'base64', media_type: item.mediaType, data: item.data }
233
- }))
234
- ] : agentPrompt;
235
-
236
- const sdkMessage = {
237
- type: 'user',
238
- parent_tool_use_id: null,
239
- message: {
240
- role: 'user',
241
- content
242
- }
243
- };
244
-
245
- if (!this.runnerStarted) {
246
- this.startRunner(sdkMessage);
247
- } else if (this.inputQueue) {
248
- this.inputQueue.push(sdkMessage);
249
- }
250
- return true;
251
- }
252
-
253
- beginTurn(prompt, attachments = []) {
254
- const turn = { id: crypto.randomUUID(), startedAt: Date.now() };
255
- this.turnQueue.push(turn);
256
- this.appendMessage({ kind: 'turn-start', turnId: turn.id, createdAt: turn.startedAt });
257
- this.appendMessage({ kind: 'user', text: prompt, attachments, turnId: turn.id, createdAt: turn.startedAt });
258
- return turn;
259
- }
260
-
261
- currentTurn() {
262
- return this.turnQueue[0] || null;
263
- }
264
-
265
- completeCurrentTurn(status = 'completed', reportedDurationMs = null) {
266
- const turn = this.turnQueue.shift();
267
- if (!turn) return null;
268
- const completedAt = Date.now();
269
- return this.appendMessage({
270
- kind: 'turn-end',
271
- turnId: turn.id,
272
- turnStatus: status,
273
- durationMs: Number.isFinite(Number(reportedDurationMs))
274
- ? Math.max(0, Number(reportedDurationMs))
275
- : Math.max(0, completedAt - turn.startedAt),
276
- createdAt: completedAt
277
- });
278
- }
279
-
280
- sealTurns(status = 'cancelled') {
281
- while (this.turnQueue.length > 0) this.completeCurrentTurn(status);
282
- }
283
-
284
- createMessageItem(message) {
285
- return {
286
- id: crypto.randomUUID(),
287
- createdAt: Date.now(),
288
- ...message
289
- };
290
- }
291
-
292
- updateSettings(settings = {}) {
293
- const next = {
294
- permissionMode: settings.permissionMode === undefined ? this.permissionMode : normalizePermissionMode(settings.permissionMode),
295
- model: settings.model === undefined ? this.model : normalizeModel(settings.model),
296
- effort: settings.effort === undefined ? this.effort : normalizeEffort(settings.effort)
297
- };
298
- const changed = next.permissionMode !== this.permissionMode
299
- || next.model !== this.model
300
- || next.effort !== this.effort;
301
- this.permissionMode = next.permissionMode;
302
- this.model = next.model;
303
- this.effort = next.effort;
304
-
305
- if (changed) {
306
- if (this.query && typeof this.query.setPermissionMode === 'function') {
307
- Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
308
- this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
309
- });
310
- }
311
- if (this.runnerStarted && this.status === 'idle' && this.activeOptionSignature !== this.getOptionSignature()) {
312
- this.resetRunnerForNextTurn();
313
- }
314
- this.emitEvent({ type: 'state', state: this.getControlState() });
315
- }
316
- return this.getControlState();
317
- }
318
-
319
- selectResumeSession(resumeSessionId, historyMessages = null) {
320
- const id = String(resumeSessionId || '').trim();
321
- if (!id) return false;
322
- if (this.status === 'thinking') this.abort('Switching resume target');
323
- this.resumeSessionId = id;
324
- this.claudeSessionId = id;
325
- this.resetRunnerForNextTurn();
326
- const eventMessage = this.createMessageItem({
327
- kind: 'event',
328
- level: 'info',
329
- text: `Resume target selected: ${id}`
330
- });
331
- if (Array.isArray(historyMessages)) {
332
- this.messages = [...historyMessages, eventMessage];
333
- this.emitEvent({ type: 'history-reset', messages: this.messages });
334
- } else {
335
- this.messages.push(eventMessage);
336
- this.emitEvent({ type: 'message', message: eventMessage });
337
- }
338
- this.emitEvent({ type: 'state', state: this.getControlState() });
339
- return true;
340
- }
341
-
342
- abort(reason = 'Aborted by user') {
343
- if (!this.runnerStarted && this.status !== 'thinking') return false;
344
- this.abortRequested = true;
345
- this.inputQueue?.close();
346
- this.query?.close?.();
347
- this.abortController?.abort();
348
- this.rejectPendingLocalCommand(new Error(reason));
349
- this.runnerStarted = false;
350
- this.runnerReadyPromise = null;
351
- this.runnerInitializationAnnounced = false;
352
- this.inputQueue = null;
353
- this.query = null;
354
- this.abortController = null;
355
- this.sealTurns('cancelled');
356
- this.setStatus('idle');
357
- this.appendMessage({ kind: 'event', level: 'info', text: reason });
358
- this.emitEvent({ type: 'state', state: this.getControlState() });
359
- return true;
360
- }
361
-
362
- resetRunnerForNextTurn() {
363
- this.inputQueue?.close();
364
- this.query?.close?.();
365
- this.rejectPendingLocalCommand(new Error('Claude CLI session was reset'));
366
- this.runnerStarted = false;
367
- this.runnerReadyPromise = null;
368
- this.runnerInitializationAnnounced = false;
369
- this.inputQueue = null;
370
- this.query = null;
371
- this.abortController = null;
372
- this.turnQueue = [];
373
- }
374
-
375
- finishRunner() {
376
- this.rejectPendingLocalCommand(new Error('Claude CLI session ended before returning the command output'));
377
- this.runnerStarted = false;
378
- this.runnerReadyPromise = null;
379
- this.runnerInitializationAnnounced = false;
380
- this.inputQueue?.close();
381
- this.inputQueue = null;
382
- this.query = null;
383
- this.abortController = null;
384
- }
385
-
386
- respondPermission(id, approved, action = null) {
387
- const pending = this.pendingPermissions.get(id);
388
- if (!pending) return false;
389
- this.pendingPermissions.delete(id);
390
- const normalizedAction = this.normalizePermissionAction(action, approved);
391
- const allowedTools = this.getAllowedToolsForAction(normalizedAction, pending);
392
- const nextMode = this.getPermissionModeForAction(normalizedAction);
393
- if (allowedTools.length > 0) this.addAllowedTools(allowedTools);
394
- if (nextMode) {
395
- this.permissionMode = nextMode;
396
- if (this.query && typeof this.query.setPermissionMode === 'function') {
397
- Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
398
- this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
399
- });
400
- }
401
- }
402
- const publicRequest = {
403
- ...pending.public,
404
- status: approved ? 'approved' : 'denied',
405
- action: normalizedAction,
406
- mode: nextMode || undefined,
407
- allowedTools: allowedTools.length > 0 ? allowedTools : undefined
408
- };
409
- this.emitEvent({ type: 'permission-updated', request: publicRequest });
410
- pending.resolve(approved
411
- ? {
412
- behavior: 'allow',
413
- updatedInput: pending.input || {},
414
- updatedPermissions: this.getPermissionUpdatesForAction(normalizedAction, pending),
415
- toolUseID: pending.toolUseID,
416
- decisionClassification: normalizedAction === 'allow-once' ? 'user_temporary' : 'user_permanent'
417
- }
418
- : { behavior: 'deny', message: DENY_PERMISSION_MESSAGE, interrupt: true, toolUseID: pending.toolUseID, decisionClassification: 'user_reject' });
419
- this.emitEvent({ type: 'state', state: this.getControlState() });
420
- return true;
421
- }
422
-
423
- normalizePermissionAction(action, approved) {
424
- if (!approved) return 'deny';
425
- const value = String(action || '').trim();
426
- if (['allow-once', 'allow-tool', 'allow-edits', 'bypass'].includes(value)) return value;
427
- return 'allow-once';
428
- }
429
-
430
- getPermissionModeForAction(action) {
431
- if (action === 'allow-edits') return 'acceptEdits';
432
- if (action === 'bypass') return 'bypassPermissions';
433
- return null;
434
- }
435
-
436
- getSdkPermissionMode() {
437
- // Claude CLI refuses --dangerously-skip-permissions under root/sudo. Glad
438
- // keeps bypass as local state and auto-allows through canUseTool instead.
439
- if (this.permissionMode === 'bypassPermissions') return 'default';
440
- return this.permissionMode;
441
- }
442
-
443
- getAllowedToolsForAction(action, pending) {
444
- if (action !== 'allow-tool') return [];
445
- const toolName = pending.toolName || pending.public.toolName;
446
- if (!toolName) return [];
447
- if (toolName === 'Bash') {
448
- const command = pending.input && typeof pending.input.command === 'string'
449
- ? pending.input.command
450
- : '';
451
- return command ? [`Bash(${command})`] : ['Bash'];
452
- }
453
- return [toolName];
454
- }
455
-
456
- getPermissionUpdatesForAction(action, pending) {
457
- const mode = this.getPermissionModeForAction(action);
458
- const allowedTools = this.getAllowedToolsForAction(action, pending);
459
- const updates = [];
460
- if (allowedTools.length > 0) {
461
- updates.push({
462
- type: 'addRules',
463
- rules: allowedTools.map(tool => this.permissionRuleFromTool(tool)),
464
- behavior: 'allow',
465
- destination: 'session'
466
- });
467
- }
468
- if (mode === 'acceptEdits') {
469
- updates.push({
470
- type: 'addRules',
471
- rules: Array.from(EDIT_TOOLS).map(toolName => ({ toolName })),
472
- behavior: 'allow',
473
- destination: 'session'
474
- });
475
- }
476
- return updates.length > 0 ? updates : undefined;
477
- }
478
-
479
- permissionRuleFromTool(tool) {
480
- const match = String(tool || '').match(/^Bash\(([\s\S]*)\)$/);
481
- if (match) return { toolName: 'Bash', ruleContent: match[1] };
482
- return { toolName: String(tool || '') };
483
- }
484
-
485
- addAllowedTools(tools) {
486
- for (const tool of tools) {
487
- if (tool === 'Bash') {
488
- this.allowedTools.add(tool);
489
- } else if (tool.startsWith('Bash(')) {
490
- this.parseBashPermission(tool);
491
- } else {
492
- this.allowedTools.add(tool);
493
- }
494
- }
495
- }
496
-
497
- parseBashPermission(permission) {
498
- const match = String(permission || '').match(/^Bash\(([\s\S]*)\)$/);
499
- if (!match) return;
500
- const command = match[1];
501
- if (command.endsWith(':*')) {
502
- this.allowedBashPrefixes.add(command.slice(0, -2));
503
- } else {
504
- this.allowedBashLiterals.add(command);
505
- }
506
- }
507
-
508
- isToolAllowed(toolName, input) {
509
- if (toolName === 'Bash') {
510
- if (this.allowedTools.has('Bash')) return true;
511
- const command = input && typeof input.command === 'string' ? input.command : '';
512
- if (command && this.allowedBashLiterals.has(command)) return true;
513
- for (const prefix of this.allowedBashPrefixes) {
514
- if (command.startsWith(prefix)) return true;
515
- }
516
- return false;
517
- }
518
- return this.allowedTools.has(toolName);
519
- }
520
-
521
- shouldAutoAllowTool(toolName, input) {
522
- if (this.isToolAllowed(toolName, input)) return true;
523
- if (this.permissionMode === 'bypassPermissions' && !EXIT_PLAN_TOOLS.has(toolName)) return true;
524
- if (this.permissionMode === 'acceptEdits' && EDIT_TOOLS.has(toolName)) return true;
525
- if (this.permissionMode === 'plan' && !this.isDangerousTool(toolName)) return true;
526
- return false;
527
- }
528
-
529
- isDangerousTool(toolName) {
530
- return toolName === 'Bash' || EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName);
531
- }
532
-
533
- markCompletionRead() {
534
- this.hasUnreadCompletion = false;
535
- this.completionReadInputSeq = this.inputSeq || 0;
536
- }
537
-
538
- startRunner(initialMessage = null) {
539
- if (this.runnerStarted) {
540
- if (initialMessage && this.inputQueue) this.inputQueue.push(initialMessage);
541
- return this.runnerReadyPromise || Promise.resolve(this.query);
542
- }
543
- this.runnerStarted = true;
544
- this.runnerInitializationAnnounced = false;
545
- this.inputQueue = new AsyncMessageQueue();
546
- if (initialMessage) this.inputQueue.push(initialMessage);
547
- this.abortController = new AbortController();
548
- this.abortRequested = false;
549
- this.activeOptionSignature = this.getOptionSignature();
550
- if (initialMessage) this.setStatus('thinking');
551
-
552
- let resolveReady;
553
- this.runnerReadyPromise = new Promise(resolve => { resolveReady = resolve; });
554
- void this.runRunner(resolveReady);
555
- return this.runnerReadyPromise;
556
- }
557
-
558
- async runRunner(resolveReady) {
559
- try {
560
- const sdk = await import('@anthropic-ai/claude-agent-sdk');
561
- const resolvedModel = resolveClaudeModel(this.model, process.env);
562
- const options = {
563
- cwd: this.workingDir,
564
- resume: this.resumeSessionId || undefined,
565
- permissionMode: this.getSdkPermissionMode(),
566
- allowDangerouslySkipPermissions: false,
567
- effort: this.effort,
568
- tools: { type: 'preset', preset: 'claude_code' },
569
- env: {
570
- ...process.env,
571
- CLAUDE_AGENT_SDK_CLIENT_APP: 'glad-web'
572
- },
573
- abortController: this.abortController,
574
- canUseTool: (toolName, input, options) => this.requestPermission(toolName, input, options)
575
- };
576
- if (resolvedModel) options.model = resolvedModel;
577
- this.query = sdk.query({
578
- prompt: this.inputQueue,
579
- options
580
- });
581
- if (typeof this.query.initializationResult === 'function') {
582
- await this.query.initializationResult();
583
- }
584
- resolveReady(this.query);
585
-
586
- for await (const message of this.query) {
587
- this.handleSdkMessage(message);
588
- }
589
- this.finishRunner();
590
- this.sealTurns('completed');
591
- this.setStatus('idle');
592
- } catch (error) {
593
- resolveReady(null);
594
- const wasAborted = this.abortRequested;
595
- this.inputQueue?.close();
596
- this.query?.close?.();
597
- this.finishRunner();
598
- if (!this.running) return;
599
- if (wasAborted) {
600
- this.setStatus('idle');
601
- return;
602
- }
603
- this.appendMessage({
604
- kind: 'event',
605
- level: 'error',
606
- text: `Claude session error: ${error && error.message ? error.message : String(error)}`
607
- });
608
- this.sealTurns('failed');
609
- this.setStatus('error');
610
- }
611
- }
612
-
613
- async showUsage() {
614
- try {
615
- const output = await this.runLocalCommand('/usage');
616
- const usage = { source: 'claude-cli-command', session: parseClaudeUsageOutput(output), fetchedAt: Date.now() };
617
- this.appendMessage({ kind: 'usage', title: 'Claude usage', usage });
618
- return true;
619
- } catch (error) {
620
- this.appendMessage({
621
- kind: 'usage',
622
- title: 'Claude usage',
623
- error: error && error.message ? error.message : String(error)
624
- });
625
- return false;
626
- }
627
- }
628
-
629
- async showContext() {
630
- try {
631
- const output = await this.runLocalCommand('/context');
632
- this.appendMessage({ kind: 'context', title: 'Claude context', context: parseClaudeContextOutput(output) });
633
- return true;
634
- } catch (error) {
635
- this.appendMessage({
636
- kind: 'context',
637
- title: 'Claude context',
638
- error: error && error.message ? error.message : String(error)
639
- });
640
- return false;
641
- }
642
- }
643
-
644
- runLocalCommand(command) {
645
- const task = this.localCommandChain.then(() => this.executeLocalCommand(command));
646
- this.localCommandChain = task.catch(() => {});
647
- return task;
648
- }
649
-
650
- async executeLocalCommand(command) {
651
- if (this.status === 'thinking') throw new Error('Wait for Claude to finish before running a local command');
652
- if (this.localCommandRunner) return this.localCommandRunner(command);
653
-
654
- const query = await this.startRunner();
655
- if (!query || !this.inputQueue) throw new Error('Claude CLI session is not available');
656
- if (this.pendingLocalCommand) throw new Error('Another Claude local command is already running');
657
-
658
- return new Promise((resolve, reject) => {
659
- const timeout = setTimeout(() => {
660
- if (this.pendingLocalCommand?.command !== command) return;
661
- this.pendingLocalCommand = null;
662
- reject(new Error(`Claude ${command} command timed out`));
663
- }, LOCAL_COMMAND_TIMEOUT_MS);
664
- this.pendingLocalCommand = {
665
- command,
666
- resolve: output => {
667
- clearTimeout(timeout);
668
- this.pendingLocalCommand = null;
669
- resolve(output);
670
- },
671
- reject: error => {
672
- clearTimeout(timeout);
673
- this.pendingLocalCommand = null;
674
- reject(error);
675
- }
676
- };
677
- this.inputQueue.push({
678
- type: 'user',
679
- parent_tool_use_id: null,
680
- message: { role: 'user', content: command }
681
- });
682
- });
683
- }
684
-
685
- rejectPendingLocalCommand(error) {
686
- this.pendingLocalCommand?.reject(error);
687
- }
688
-
689
- getOptionSignature() {
690
- return JSON.stringify({
691
- permissionMode: this.permissionMode,
692
- model: this.model,
693
- effort: this.effort,
694
- resume: this.resumeSessionId || null
695
- });
696
- }
697
-
698
- requestPermission(toolName, input, options = {}) {
699
- if (toolName !== 'AskUserQuestion' && this.shouldAutoAllowTool(toolName, input)) {
700
- return Promise.resolve({
701
- behavior: 'allow',
702
- updatedInput: input || {},
703
- toolUseID: options.toolUseID,
704
- decisionClassification: 'user_permanent'
705
- });
706
- }
707
- const id = crypto.randomUUID();
708
- const request = {
709
- id,
710
- toolName,
711
- title: options.title || `${toolName} requires approval`,
712
- displayName: options.displayName || '',
713
- description: options.description || '',
714
- reason: options.decisionReason || options.description || '',
715
- blockedPath: options.blockedPath || null,
716
- canAllowTool: Boolean(toolName && !EDIT_TOOLS.has(toolName) && !EXIT_PLAN_TOOLS.has(toolName)),
717
- canAllowEdits: EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName),
718
- canBypass: EXIT_PLAN_TOOLS.has(toolName),
719
- input,
720
- toolUseId: options.toolUseID || null,
721
- createdAt: Date.now(),
722
- status: 'pending'
723
- };
724
- this.emitEvent({ type: 'permission-request', request });
725
- return new Promise(resolve => {
726
- this.pendingPermissions.set(id, {
727
- public: request,
728
- resolve,
729
- input,
730
- toolName,
731
- toolUseID: options.toolUseID
732
- });
733
- });
734
- }
735
-
736
- handleSdkMessage(message) {
737
- if (!message || typeof message !== 'object') return;
738
-
739
- if (message.type === 'system' && message.subtype === 'init') {
740
- const signatureBeforeInit = this.getOptionSignature();
741
- this.claudeSessionId = message.session_id || this.claudeSessionId;
742
- this.resumeSessionId = this.claudeSessionId || this.resumeSessionId;
743
- if (message.model) this.model = String(message.model);
744
- // Resolving runtime session/model values is not a user settings change
745
- // and must not recycle the live runner after the turn.
746
- if (this.activeOptionSignature === signatureBeforeInit) {
747
- this.activeOptionSignature = this.getOptionSignature();
748
- }
749
- if (!this.runnerInitializationAnnounced) {
750
- this.runnerInitializationAnnounced = true;
751
- this.appendMessage({
752
- kind: 'event',
753
- level: 'info',
754
- text: `Claude ready${message.model ? ` (${message.model})` : ''}`
755
- });
756
- }
757
- this.emitEvent({ type: 'state', state: this.getControlState() });
758
- return;
759
- }
760
-
761
- if (message.type === 'system' && message.subtype === 'local_command_output') {
762
- if (this.pendingLocalCommand) {
763
- this.pendingLocalCommand.resolve(String(message.content || ''));
764
- }
765
- return;
766
- }
767
-
768
- if (this.pendingLocalCommand && message.type === 'assistant') return;
769
-
770
- if (this.pendingLocalCommand && message.type === 'result') {
771
- const output = typeof message.result === 'string' ? message.result : '';
772
- if (message.is_error || !output) {
773
- this.pendingLocalCommand.reject(new Error(output || `Claude ${this.pendingLocalCommand.command} command failed`));
774
- } else {
775
- this.pendingLocalCommand.resolve(output);
776
- }
777
- this.setStatus('idle');
778
- return;
779
- }
780
-
781
- if (message.type === 'assistant') {
782
- const turn = this.currentTurn();
783
- const content = message.message && message.message.content;
784
- const text = textFromContent(content).trim();
785
- const toolBlocks = Array.isArray(content)
786
- ? content.filter(item => item && item.type === 'tool_use')
787
- : [];
788
- if (text) {
789
- this.appendMessage({ kind: 'assistant', text, raw: message, turnId: turn?.id || null });
790
- }
791
- for (const block of toolBlocks) {
792
- this.appendMessage({
793
- kind: 'tool',
794
- name: block.name || 'tool',
795
- summary: this.summarizeToolInput(block.input),
796
- input: block.input,
797
- toolUseId: block.id,
798
- turnId: turn?.id || null,
799
- startedAtMs: Date.now()
800
- });
801
- }
802
- this.setStatus('thinking');
803
- return;
804
- }
805
-
806
- if (message.type === 'user') {
807
- const turn = this.currentTurn();
808
- const content = message.message && message.message.content;
809
- if (Array.isArray(content)) {
810
- for (const item of content) {
811
- if (item && item.type === 'tool_result') {
812
- this.appendMessage({
813
- kind: 'tool-result',
814
- toolUseId: item.tool_use_id,
815
- text: textFromContent([item]).trim(),
816
- isError: Boolean(item.is_error),
817
- turnId: turn?.id || null,
818
- completedAtMs: Date.now()
819
- });
820
- }
821
- }
822
- }
823
- return;
824
- }
825
-
826
- if (message.type === 'result') {
827
- this.completeCurrentTurn(message.is_error || message.subtype !== 'success' ? 'failed' : 'completed', message.duration_ms);
828
- this.setStatus(this.turnQueue.length > 0 ? 'thinking' : 'idle');
829
- if (this.inputSeq > this.completionReadInputSeq) {
830
- this.hasUnreadCompletion = true;
831
- }
832
- this.emit('complete');
833
- if (this.activeOptionSignature !== this.getOptionSignature()) {
834
- this.resetRunnerForNextTurn();
835
- }
836
- }
837
- }
838
-
839
- summarizeToolInput(input) {
840
- if (!input || typeof input !== 'object') return '';
841
- if (typeof input.command === 'string') return input.command;
842
- if (typeof input.file_path === 'string') return input.file_path;
843
- if (typeof input.path === 'string') return input.path;
844
- const serialized = JSON.stringify(input);
845
- return serialized.length > 240 ? serialized.slice(0, 240) + '...' : serialized;
846
- }
847
-
848
- appendMessage(message) {
849
- const item = this.createMessageItem(message);
850
- this.messages.push(item);
851
- this.emitEvent({ type: 'message', message: item });
852
- return item;
853
- }
854
-
855
- setStatus(status) {
856
- if (this.status === status) return;
857
- this.status = status;
858
- this.emitEvent({ type: 'status', status });
859
- this.emitEvent({ type: 'state', state: this.getControlState() });
860
- }
861
-
862
- emitEvent(event) {
863
- this.emit('event', event);
864
- }
865
-
866
- isRunning() {
867
- return this.running;
868
- }
869
-
870
- kill() {
871
- this.running = false;
872
- this.inputQueue?.close();
873
- this.query?.close?.();
874
- this.abortController?.abort();
875
- for (const pending of this.pendingPermissions.values()) {
876
- pending.resolve({ behavior: 'deny', message: 'Session ended', interrupt: true, toolUseID: pending.toolUseID });
877
- }
878
- this.pendingPermissions.clear();
879
- this.setStatus('stopped');
880
- this.emit('exit', { exitCode: 0 });
881
- }
882
- }
883
-
884
- module.exports = ClaudeStructuredSession;