glad-web 1.0.19 → 1.0.20
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 +516 -0
- package/lib/commands/web.js +70 -12
- package/lib/session/session-manager.js +317 -2
- package/lib/web/index.html +842 -2
- package/package.json +2 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const MODEL_ALIASES = new Set(['default', 'env', 'sonnet', 'opus', 'haiku']);
|
|
2
|
+
const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
3
|
+
|
|
4
|
+
function cleanEnvValue(value) {
|
|
5
|
+
const text = String(value || '').trim();
|
|
6
|
+
return text || null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readClaudeEnv(env = process.env) {
|
|
10
|
+
return {
|
|
11
|
+
anthropicModel: cleanEnvValue(env.ANTHROPIC_MODEL),
|
|
12
|
+
sonnetModel: cleanEnvValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
|
|
13
|
+
opusModel: cleanEnvValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
|
|
14
|
+
haikuModel: cleanEnvValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
|
|
15
|
+
subagentModel: cleanEnvValue(env.CLAUDE_CODE_SUBAGENT_MODEL),
|
|
16
|
+
effort: normalizeEffort(env.CLAUDE_CODE_EFFORT_LEVEL)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeModel(value, env = process.env) {
|
|
21
|
+
const model = String(value || '').trim();
|
|
22
|
+
if (model) return model;
|
|
23
|
+
return readClaudeEnv(env).anthropicModel ? 'env' : 'sonnet';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeEffort(value, env = process.env) {
|
|
27
|
+
const effort = String(value || '').trim().toLowerCase();
|
|
28
|
+
if (EFFORT_LEVELS.has(effort)) return effort;
|
|
29
|
+
const envEffort = String(env.CLAUDE_CODE_EFFORT_LEVEL || '').trim().toLowerCase();
|
|
30
|
+
return EFFORT_LEVELS.has(envEffort) ? envEffort : 'medium';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveClaudeModel(value, env = process.env) {
|
|
34
|
+
const selected = normalizeModel(value, env);
|
|
35
|
+
const config = readClaudeEnv(env);
|
|
36
|
+
if (selected === 'default') return undefined;
|
|
37
|
+
if (selected === 'env') return config.anthropicModel || undefined;
|
|
38
|
+
if (selected === 'sonnet') return config.sonnetModel || 'sonnet';
|
|
39
|
+
if (selected === 'opus') return config.opusModel || 'opus';
|
|
40
|
+
if (selected === 'haiku') return config.haikuModel || 'haiku';
|
|
41
|
+
return selected;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function modelOption(value, label, resolved, source) {
|
|
45
|
+
return {
|
|
46
|
+
value,
|
|
47
|
+
label,
|
|
48
|
+
resolved: resolved || null,
|
|
49
|
+
source
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getClaudeRuntimeConfig(env = process.env) {
|
|
54
|
+
const config = readClaudeEnv(env);
|
|
55
|
+
const options = [
|
|
56
|
+
modelOption('default', 'Default', null, 'Claude default')
|
|
57
|
+
];
|
|
58
|
+
if (config.anthropicModel) {
|
|
59
|
+
options.push(modelOption('env', 'Env', config.anthropicModel, 'ANTHROPIC_MODEL'));
|
|
60
|
+
}
|
|
61
|
+
options.push(
|
|
62
|
+
modelOption('sonnet', 'Sonnet', config.sonnetModel || 'sonnet', 'ANTHROPIC_DEFAULT_SONNET_MODEL'),
|
|
63
|
+
modelOption('opus', 'Opus', config.opusModel || 'opus', 'ANTHROPIC_DEFAULT_OPUS_MODEL'),
|
|
64
|
+
modelOption('haiku', 'Haiku', config.haikuModel || 'haiku', 'ANTHROPIC_DEFAULT_HAIKU_MODEL')
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
defaultModel: config.anthropicModel ? 'env' : 'sonnet',
|
|
69
|
+
defaultEffort: config.effort,
|
|
70
|
+
models: options,
|
|
71
|
+
env: config
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
EFFORT_LEVELS,
|
|
77
|
+
MODEL_ALIASES,
|
|
78
|
+
getClaudeRuntimeConfig,
|
|
79
|
+
normalizeEffort,
|
|
80
|
+
normalizeModel,
|
|
81
|
+
resolveClaudeModel
|
|
82
|
+
};
|
|
@@ -0,0 +1,516 @@
|
|
|
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
|
+
|
|
7
|
+
function normalizePermissionMode(value) {
|
|
8
|
+
const mode = String(value || 'default');
|
|
9
|
+
return PERMISSION_MODES.has(mode) ? mode : 'default';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function textFromContent(content) {
|
|
13
|
+
if (typeof content === 'string') return content;
|
|
14
|
+
if (!Array.isArray(content)) return '';
|
|
15
|
+
return content
|
|
16
|
+
.map(item => {
|
|
17
|
+
if (!item || typeof item !== 'object') return '';
|
|
18
|
+
if (item.type === 'text' && typeof item.text === 'string') return item.text;
|
|
19
|
+
if (item.type === 'tool_result') {
|
|
20
|
+
if (typeof item.content === 'string') return item.content;
|
|
21
|
+
if (Array.isArray(item.content)) return textFromContent(item.content);
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class AsyncMessageQueue {
|
|
30
|
+
constructor() {
|
|
31
|
+
this.items = [];
|
|
32
|
+
this.waiters = [];
|
|
33
|
+
this.closed = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
push(item) {
|
|
37
|
+
if (this.closed) return false;
|
|
38
|
+
const waiter = this.waiters.shift();
|
|
39
|
+
if (waiter) waiter({ value: item, done: false });
|
|
40
|
+
else this.items.push(item);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
close() {
|
|
45
|
+
this.closed = true;
|
|
46
|
+
while (this.waiters.length > 0) {
|
|
47
|
+
this.waiters.shift()({ value: undefined, done: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
[Symbol.asyncIterator]() {
|
|
52
|
+
return {
|
|
53
|
+
next: () => {
|
|
54
|
+
if (this.items.length > 0) {
|
|
55
|
+
return Promise.resolve({ value: this.items.shift(), done: false });
|
|
56
|
+
}
|
|
57
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
58
|
+
return new Promise(resolve => this.waiters.push(resolve));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class ClaudeStructuredSession extends EventEmitter {
|
|
65
|
+
constructor({ id, tool, workingDir, name, logger, options = {} }) {
|
|
66
|
+
super();
|
|
67
|
+
this.id = id;
|
|
68
|
+
this.tool = tool;
|
|
69
|
+
this.name = name || tool.displayName;
|
|
70
|
+
this.workingDir = workingDir;
|
|
71
|
+
this.logger = logger || console;
|
|
72
|
+
this.kind = 'claude-structured';
|
|
73
|
+
this.startTime = Date.now();
|
|
74
|
+
this.running = true;
|
|
75
|
+
this.status = 'idle';
|
|
76
|
+
this.messages = [];
|
|
77
|
+
this.pendingPermissions = new Map();
|
|
78
|
+
this.pendingInput = '';
|
|
79
|
+
this.inputSeq = 0;
|
|
80
|
+
this.hasUnreadCompletion = false;
|
|
81
|
+
this.completionReadInputSeq = 0;
|
|
82
|
+
this.timedInputs = new Map();
|
|
83
|
+
this.abortController = null;
|
|
84
|
+
this.inputQueue = null;
|
|
85
|
+
this.query = null;
|
|
86
|
+
this.runnerStarted = false;
|
|
87
|
+
this.abortRequested = false;
|
|
88
|
+
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
89
|
+
this.model = normalizeModel(options.model);
|
|
90
|
+
this.effort = normalizeEffort(options.effort);
|
|
91
|
+
this.resumeSessionId = options.resume || null;
|
|
92
|
+
this.claudeSessionId = options.resume || null;
|
|
93
|
+
this.activeOptionSignature = null;
|
|
94
|
+
this.contextRemaining = null;
|
|
95
|
+
|
|
96
|
+
// Compatibility with existing session-scoped Git/file APIs.
|
|
97
|
+
this.ptyManager = {
|
|
98
|
+
workingDir,
|
|
99
|
+
isRunning: () => this.isRunning(),
|
|
100
|
+
write: data => this.write(data),
|
|
101
|
+
kill: () => this.kill(),
|
|
102
|
+
resize: () => {},
|
|
103
|
+
redraw: () => false
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
toListItem() {
|
|
108
|
+
return {
|
|
109
|
+
id: this.id,
|
|
110
|
+
name: this.name,
|
|
111
|
+
tool: this.tool.displayName,
|
|
112
|
+
startTime: this.startTime,
|
|
113
|
+
toolKey: this.tool.key,
|
|
114
|
+
workingDirectory: this.workingDir,
|
|
115
|
+
hasUnreadCompletion: Boolean(this.hasUnreadCompletion),
|
|
116
|
+
timedInputCount: Array.from(this.timedInputs.values()).filter(item => item.sendAt > Date.now()).length,
|
|
117
|
+
mode: 'structured'
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
snapshot() {
|
|
122
|
+
return {
|
|
123
|
+
id: this.id,
|
|
124
|
+
name: this.name,
|
|
125
|
+
tool: this.tool.displayName,
|
|
126
|
+
toolKey: this.tool.key,
|
|
127
|
+
status: this.status,
|
|
128
|
+
state: this.getControlState(),
|
|
129
|
+
messages: this.messages,
|
|
130
|
+
pendingPermissions: Array.from(this.pendingPermissions.values()).map(item => item.public)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getControlState() {
|
|
135
|
+
return {
|
|
136
|
+
permissionMode: this.permissionMode,
|
|
137
|
+
model: this.model,
|
|
138
|
+
effort: this.effort,
|
|
139
|
+
status: this.status,
|
|
140
|
+
claudeSessionId: this.claudeSessionId || null,
|
|
141
|
+
resumeSessionId: this.resumeSessionId || null,
|
|
142
|
+
contextRemaining: this.contextRemaining,
|
|
143
|
+
canAbort: this.status === 'thinking',
|
|
144
|
+
pendingPermissionCount: this.pendingPermissions.size
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getHistory() {
|
|
149
|
+
const lines = [];
|
|
150
|
+
for (const message of this.messages) {
|
|
151
|
+
if (message.kind === 'user') lines.push(`User: ${message.text}`);
|
|
152
|
+
if (message.kind === 'assistant') lines.push(`Claude: ${message.text}`);
|
|
153
|
+
if (message.kind === 'tool') lines.push(`Tool ${message.name}: ${message.summary}`);
|
|
154
|
+
if (message.kind === 'event') lines.push(message.text);
|
|
155
|
+
}
|
|
156
|
+
const text = lines.join('\n\n');
|
|
157
|
+
return {
|
|
158
|
+
success: true,
|
|
159
|
+
sessionId: this.id,
|
|
160
|
+
sessionName: this.name,
|
|
161
|
+
tool: this.tool.displayName,
|
|
162
|
+
historyMode: 'structured',
|
|
163
|
+
text,
|
|
164
|
+
updatedAt: Date.now(),
|
|
165
|
+
truncated: false,
|
|
166
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
167
|
+
lines: text ? text.split('\n').length : 0
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getCatchupOutput() {
|
|
172
|
+
return {
|
|
173
|
+
source: 'claude-structured',
|
|
174
|
+
items: this.messages.length,
|
|
175
|
+
data: ''
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
write(data) {
|
|
180
|
+
if (!this.running) return false;
|
|
181
|
+
const text = String(data || '');
|
|
182
|
+
if (!text) return true;
|
|
183
|
+
|
|
184
|
+
this.pendingInput += text.replace(/\r/g, '\n');
|
|
185
|
+
if (!this.pendingInput.includes('\n')) return true;
|
|
186
|
+
|
|
187
|
+
const parts = this.pendingInput.split('\n');
|
|
188
|
+
this.pendingInput = parts.pop() || '';
|
|
189
|
+
const prompt = parts.join('\n').trim();
|
|
190
|
+
if (prompt) this.sendUserMessage(prompt);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
sendUserMessage(text) {
|
|
195
|
+
if (!this.running) return false;
|
|
196
|
+
const prompt = String(text || '').trim();
|
|
197
|
+
if (!prompt) return false;
|
|
198
|
+
this.hasUnreadCompletion = false;
|
|
199
|
+
this.appendMessage({ kind: 'user', text: prompt });
|
|
200
|
+
|
|
201
|
+
const sdkMessage = {
|
|
202
|
+
type: 'user',
|
|
203
|
+
parent_tool_use_id: null,
|
|
204
|
+
message: {
|
|
205
|
+
role: 'user',
|
|
206
|
+
content: prompt
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (!this.runnerStarted) {
|
|
211
|
+
this.startRunner(sdkMessage);
|
|
212
|
+
} else if (this.inputQueue) {
|
|
213
|
+
this.inputQueue.push(sdkMessage);
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
createMessageItem(message) {
|
|
219
|
+
return {
|
|
220
|
+
id: crypto.randomUUID(),
|
|
221
|
+
createdAt: Date.now(),
|
|
222
|
+
...message
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
updateSettings(settings = {}) {
|
|
227
|
+
const next = {
|
|
228
|
+
permissionMode: settings.permissionMode === undefined ? this.permissionMode : normalizePermissionMode(settings.permissionMode),
|
|
229
|
+
model: settings.model === undefined ? this.model : normalizeModel(settings.model),
|
|
230
|
+
effort: settings.effort === undefined ? this.effort : normalizeEffort(settings.effort)
|
|
231
|
+
};
|
|
232
|
+
const changed = next.permissionMode !== this.permissionMode
|
|
233
|
+
|| next.model !== this.model
|
|
234
|
+
|| next.effort !== this.effort;
|
|
235
|
+
this.permissionMode = next.permissionMode;
|
|
236
|
+
this.model = next.model;
|
|
237
|
+
this.effort = next.effort;
|
|
238
|
+
|
|
239
|
+
if (changed) {
|
|
240
|
+
if (this.query && typeof this.query.setPermissionMode === 'function') {
|
|
241
|
+
Promise.resolve(this.query.setPermissionMode(this.permissionMode)).catch(error => {
|
|
242
|
+
this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (this.runnerStarted && this.status === 'idle' && this.activeOptionSignature !== this.getOptionSignature()) {
|
|
246
|
+
this.resetRunnerForNextTurn();
|
|
247
|
+
}
|
|
248
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
249
|
+
}
|
|
250
|
+
return this.getControlState();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
selectResumeSession(resumeSessionId, historyMessages = null) {
|
|
254
|
+
const id = String(resumeSessionId || '').trim();
|
|
255
|
+
if (!id) return false;
|
|
256
|
+
if (this.status === 'thinking') this.abort('Switching resume target');
|
|
257
|
+
this.resumeSessionId = id;
|
|
258
|
+
this.claudeSessionId = id;
|
|
259
|
+
this.resetRunnerForNextTurn();
|
|
260
|
+
const eventMessage = this.createMessageItem({
|
|
261
|
+
kind: 'event',
|
|
262
|
+
level: 'info',
|
|
263
|
+
text: `Resume target selected: ${id}`
|
|
264
|
+
});
|
|
265
|
+
if (Array.isArray(historyMessages)) {
|
|
266
|
+
this.messages = [...historyMessages, eventMessage];
|
|
267
|
+
this.emitEvent({ type: 'history-reset', messages: this.messages });
|
|
268
|
+
} else {
|
|
269
|
+
this.messages.push(eventMessage);
|
|
270
|
+
this.emitEvent({ type: 'message', message: eventMessage });
|
|
271
|
+
}
|
|
272
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
abort(reason = 'Aborted by user') {
|
|
277
|
+
if (!this.runnerStarted && this.status !== 'thinking') return false;
|
|
278
|
+
this.abortRequested = true;
|
|
279
|
+
this.inputQueue?.close();
|
|
280
|
+
this.query?.close?.();
|
|
281
|
+
this.abortController?.abort();
|
|
282
|
+
this.runnerStarted = false;
|
|
283
|
+
this.inputQueue = null;
|
|
284
|
+
this.query = null;
|
|
285
|
+
this.abortController = null;
|
|
286
|
+
this.setStatus('idle');
|
|
287
|
+
this.appendMessage({ kind: 'event', level: 'info', text: reason });
|
|
288
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
resetRunnerForNextTurn() {
|
|
293
|
+
this.inputQueue?.close();
|
|
294
|
+
this.query?.close?.();
|
|
295
|
+
this.runnerStarted = false;
|
|
296
|
+
this.inputQueue = null;
|
|
297
|
+
this.query = null;
|
|
298
|
+
this.abortController = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
respondPermission(id, approved) {
|
|
302
|
+
const pending = this.pendingPermissions.get(id);
|
|
303
|
+
if (!pending) return false;
|
|
304
|
+
this.pendingPermissions.delete(id);
|
|
305
|
+
const publicRequest = { ...pending.public, status: approved ? 'approved' : 'denied' };
|
|
306
|
+
this.emitEvent({ type: 'permission-updated', request: publicRequest });
|
|
307
|
+
pending.resolve(approved
|
|
308
|
+
? { behavior: 'allow', toolUseID: pending.toolUseID, decisionClassification: 'user_temporary' }
|
|
309
|
+
: { behavior: 'deny', message: 'Denied by user', interrupt: true, toolUseID: pending.toolUseID, decisionClassification: 'user_reject' });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
markCompletionRead() {
|
|
314
|
+
this.hasUnreadCompletion = false;
|
|
315
|
+
this.completionReadInputSeq = this.inputSeq || 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async startRunner(initialMessage) {
|
|
319
|
+
this.runnerStarted = true;
|
|
320
|
+
this.inputQueue = new AsyncMessageQueue();
|
|
321
|
+
this.inputQueue.push(initialMessage);
|
|
322
|
+
this.abortController = new AbortController();
|
|
323
|
+
this.abortRequested = false;
|
|
324
|
+
this.activeOptionSignature = this.getOptionSignature();
|
|
325
|
+
this.setStatus('thinking');
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
329
|
+
const resolvedModel = resolveClaudeModel(this.model, process.env);
|
|
330
|
+
const options = {
|
|
331
|
+
cwd: this.workingDir,
|
|
332
|
+
resume: this.resumeSessionId || undefined,
|
|
333
|
+
permissionMode: this.permissionMode,
|
|
334
|
+
effort: this.effort,
|
|
335
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
336
|
+
env: {
|
|
337
|
+
...process.env,
|
|
338
|
+
CLAUDE_AGENT_SDK_CLIENT_APP: 'glad-web'
|
|
339
|
+
},
|
|
340
|
+
abortController: this.abortController,
|
|
341
|
+
canUseTool: (toolName, input, options) => this.requestPermission(toolName, input, options)
|
|
342
|
+
};
|
|
343
|
+
if (resolvedModel) options.model = resolvedModel;
|
|
344
|
+
this.query = sdk.query({
|
|
345
|
+
prompt: this.inputQueue,
|
|
346
|
+
options
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
for await (const message of this.query) {
|
|
350
|
+
this.handleSdkMessage(message);
|
|
351
|
+
}
|
|
352
|
+
this.setStatus('idle');
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (!this.running) return;
|
|
355
|
+
if (this.abortRequested) {
|
|
356
|
+
this.setStatus('idle');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.appendMessage({
|
|
360
|
+
kind: 'event',
|
|
361
|
+
level: 'error',
|
|
362
|
+
text: `Claude session error: ${error && error.message ? error.message : String(error)}`
|
|
363
|
+
});
|
|
364
|
+
this.setStatus('error');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
getOptionSignature() {
|
|
369
|
+
return JSON.stringify({
|
|
370
|
+
permissionMode: this.permissionMode,
|
|
371
|
+
model: this.model,
|
|
372
|
+
effort: this.effort,
|
|
373
|
+
resume: this.resumeSessionId || null
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
requestPermission(toolName, input, options = {}) {
|
|
378
|
+
const id = crypto.randomUUID();
|
|
379
|
+
const request = {
|
|
380
|
+
id,
|
|
381
|
+
toolName,
|
|
382
|
+
title: options.title || `${toolName} requires approval`,
|
|
383
|
+
reason: options.decisionReason || '',
|
|
384
|
+
input,
|
|
385
|
+
createdAt: Date.now(),
|
|
386
|
+
status: 'pending'
|
|
387
|
+
};
|
|
388
|
+
this.emitEvent({ type: 'permission-request', request });
|
|
389
|
+
return new Promise(resolve => {
|
|
390
|
+
this.pendingPermissions.set(id, {
|
|
391
|
+
public: request,
|
|
392
|
+
resolve,
|
|
393
|
+
toolUseID: options.toolUseID
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
handleSdkMessage(message) {
|
|
399
|
+
if (!message || typeof message !== 'object') return;
|
|
400
|
+
|
|
401
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
402
|
+
this.claudeSessionId = message.session_id || this.claudeSessionId;
|
|
403
|
+
this.resumeSessionId = this.claudeSessionId || this.resumeSessionId;
|
|
404
|
+
if (message.model) this.model = String(message.model);
|
|
405
|
+
this.appendMessage({
|
|
406
|
+
kind: 'event',
|
|
407
|
+
level: 'info',
|
|
408
|
+
text: `Claude ready${message.model ? ` (${message.model})` : ''}`
|
|
409
|
+
});
|
|
410
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (message.type === 'assistant') {
|
|
415
|
+
const content = message.message && message.message.content;
|
|
416
|
+
const text = textFromContent(content).trim();
|
|
417
|
+
const toolBlocks = Array.isArray(content)
|
|
418
|
+
? content.filter(item => item && item.type === 'tool_use')
|
|
419
|
+
: [];
|
|
420
|
+
if (text) {
|
|
421
|
+
this.appendMessage({ kind: 'assistant', text, raw: message });
|
|
422
|
+
}
|
|
423
|
+
for (const block of toolBlocks) {
|
|
424
|
+
this.appendMessage({
|
|
425
|
+
kind: 'tool',
|
|
426
|
+
name: block.name || 'tool',
|
|
427
|
+
summary: this.summarizeToolInput(block.input),
|
|
428
|
+
input: block.input,
|
|
429
|
+
toolUseId: block.id
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
this.setStatus('thinking');
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (message.type === 'user') {
|
|
437
|
+
const content = message.message && message.message.content;
|
|
438
|
+
if (Array.isArray(content)) {
|
|
439
|
+
for (const item of content) {
|
|
440
|
+
if (item && item.type === 'tool_result') {
|
|
441
|
+
this.appendMessage({
|
|
442
|
+
kind: 'tool-result',
|
|
443
|
+
toolUseId: item.tool_use_id,
|
|
444
|
+
text: textFromContent([item]).trim(),
|
|
445
|
+
isError: Boolean(item.is_error)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (message.type === 'result') {
|
|
454
|
+
this.updateUsageFromResult(message);
|
|
455
|
+
this.setStatus('idle');
|
|
456
|
+
if (this.inputSeq > this.completionReadInputSeq) {
|
|
457
|
+
this.hasUnreadCompletion = true;
|
|
458
|
+
}
|
|
459
|
+
this.emit('complete');
|
|
460
|
+
if (this.activeOptionSignature !== this.getOptionSignature()) {
|
|
461
|
+
this.resetRunnerForNextTurn();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
updateUsageFromResult(message) {
|
|
467
|
+
const usage = message && (message.usage || message.total_usage || message.result && message.result.usage);
|
|
468
|
+
const context = usage && (usage.context_remaining || usage.contextRemaining || usage.remaining_context || usage.remainingContext);
|
|
469
|
+
this.contextRemaining = typeof context === 'number' ? context : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
summarizeToolInput(input) {
|
|
473
|
+
if (!input || typeof input !== 'object') return '';
|
|
474
|
+
if (typeof input.command === 'string') return input.command;
|
|
475
|
+
if (typeof input.file_path === 'string') return input.file_path;
|
|
476
|
+
if (typeof input.path === 'string') return input.path;
|
|
477
|
+
const serialized = JSON.stringify(input);
|
|
478
|
+
return serialized.length > 240 ? serialized.slice(0, 240) + '...' : serialized;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
appendMessage(message) {
|
|
482
|
+
const item = this.createMessageItem(message);
|
|
483
|
+
this.messages.push(item);
|
|
484
|
+
this.emitEvent({ type: 'message', message: item });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
setStatus(status) {
|
|
488
|
+
if (this.status === status) return;
|
|
489
|
+
this.status = status;
|
|
490
|
+
this.emitEvent({ type: 'status', status });
|
|
491
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
emitEvent(event) {
|
|
495
|
+
this.emit('event', event);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
isRunning() {
|
|
499
|
+
return this.running;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
kill() {
|
|
503
|
+
this.running = false;
|
|
504
|
+
this.inputQueue?.close();
|
|
505
|
+
this.query?.close?.();
|
|
506
|
+
this.abortController?.abort();
|
|
507
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
508
|
+
pending.resolve({ behavior: 'deny', message: 'Session ended', interrupt: true, toolUseID: pending.toolUseID });
|
|
509
|
+
}
|
|
510
|
+
this.pendingPermissions.clear();
|
|
511
|
+
this.setStatus('stopped');
|
|
512
|
+
this.emit('exit', { exitCode: 0 });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
module.exports = ClaudeStructuredSession;
|