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.
@@ -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;