@sumicom/quicksave 0.5.2 → 0.6.1
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/dist/ai/asyncQueue.d.ts +17 -0
- package/dist/ai/asyncQueue.d.ts.map +1 -0
- package/dist/ai/asyncQueue.js +49 -0
- package/dist/ai/asyncQueue.js.map +1 -0
- package/dist/ai/asyncQueue.test.d.ts +2 -0
- package/dist/ai/asyncQueue.test.d.ts.map +1 -0
- package/dist/ai/asyncQueue.test.js +56 -0
- package/dist/ai/asyncQueue.test.js.map +1 -0
- package/dist/ai/cardBuilder.d.ts +73 -0
- package/dist/ai/cardBuilder.d.ts.map +1 -0
- package/dist/ai/cardBuilder.js +595 -0
- package/dist/ai/cardBuilder.js.map +1 -0
- package/dist/ai/claudeCliProvider.d.ts +19 -0
- package/dist/ai/claudeCliProvider.d.ts.map +1 -0
- package/dist/ai/claudeCliProvider.js +466 -0
- package/dist/ai/claudeCliProvider.js.map +1 -0
- package/dist/ai/claudeSdkProvider.d.ts +18 -0
- package/dist/ai/claudeSdkProvider.d.ts.map +1 -0
- package/dist/ai/claudeSdkProvider.js +418 -0
- package/dist/ai/claudeSdkProvider.js.map +1 -0
- package/dist/ai/commitSummary.d.ts +4 -0
- package/dist/ai/commitSummary.d.ts.map +1 -1
- package/dist/ai/commitSummary.js +80 -40
- package/dist/ai/commitSummary.js.map +1 -1
- package/dist/ai/provider.d.ts +54 -0
- package/dist/ai/provider.d.ts.map +1 -0
- package/dist/ai/provider.js +2 -0
- package/dist/ai/provider.js.map +1 -0
- package/dist/ai/sandboxMcp.d.ts +12 -0
- package/dist/ai/sandboxMcp.d.ts.map +1 -0
- package/dist/ai/sandboxMcp.js +12 -0
- package/dist/ai/sandboxMcp.js.map +1 -0
- package/dist/ai/sandboxMcpStdio.d.ts +16 -0
- package/dist/ai/sandboxMcpStdio.d.ts.map +1 -0
- package/dist/ai/sandboxMcpStdio.js +127 -0
- package/dist/ai/sandboxMcpStdio.js.map +1 -0
- package/dist/ai/sessionManager.d.ts +100 -0
- package/dist/ai/sessionManager.d.ts.map +1 -0
- package/dist/ai/sessionManager.js +595 -0
- package/dist/ai/sessionManager.js.map +1 -0
- package/dist/ai/sessionRegistry.d.ts +28 -0
- package/dist/ai/sessionRegistry.d.ts.map +1 -0
- package/dist/ai/sessionRegistry.js +148 -0
- package/dist/ai/sessionRegistry.js.map +1 -0
- package/dist/connection/connection.d.ts +24 -6
- package/dist/connection/connection.d.ts.map +1 -1
- package/dist/connection/connection.js +85 -13
- package/dist/connection/connection.js.map +1 -1
- package/dist/connection/pubsub.d.ts +41 -0
- package/dist/connection/pubsub.d.ts.map +1 -0
- package/dist/connection/pubsub.js +101 -0
- package/dist/connection/pubsub.js.map +1 -0
- package/dist/connection/pubsub.test.d.ts +2 -0
- package/dist/connection/pubsub.test.d.ts.map +1 -0
- package/dist/connection/pubsub.test.js +96 -0
- package/dist/connection/pubsub.test.js.map +1 -0
- package/dist/git/operations.d.ts +6 -1
- package/dist/git/operations.d.ts.map +1 -1
- package/dist/git/operations.js +31 -2
- package/dist/git/operations.js.map +1 -1
- package/dist/handlers/messageHandler.d.ts +25 -5
- package/dist/handlers/messageHandler.d.ts.map +1 -1
- package/dist/handlers/messageHandler.js +266 -29
- package/dist/handlers/messageHandler.js.map +1 -1
- package/dist/handlers/messageHandler.test.js +1 -1
- package/dist/index.js +192 -41
- package/dist/index.js.map +1 -1
- package/dist/service/ensureDaemon.d.ts.map +1 -1
- package/dist/service/ensureDaemon.js +15 -2
- package/dist/service/ensureDaemon.js.map +1 -1
- package/dist/service/ipc.test.js +6 -6
- package/dist/service/ipcClient.js +1 -1
- package/dist/service/run.d.ts.map +1 -1
- package/dist/service/run.js +120 -11
- package/dist/service/run.js.map +1 -1
- package/dist/service/singleton.d.ts +4 -0
- package/dist/service/singleton.d.ts.map +1 -1
- package/dist/service/singleton.js +28 -2
- package/dist/service/singleton.js.map +1 -1
- package/dist/service/singleton.test.js +1 -1
- package/dist/service/types.d.ts +29 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service/types.js +11 -0
- package/dist/service/types.js.map +1 -1
- package/package.json +5 -3
- package/dist/ai/claudeCodeService.d.ts +0 -111
- package/dist/ai/claudeCodeService.d.ts.map +0 -1
- package/dist/ai/claudeCodeService.js +0 -764
- package/dist/ai/claudeCodeService.js.map +0 -1
|
@@ -1,764 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from 'events';
|
|
2
|
-
import { createReadStream } from 'fs';
|
|
3
|
-
import { readdir, stat } from 'fs/promises';
|
|
4
|
-
import { createInterface } from 'readline';
|
|
5
|
-
import { join } from 'path';
|
|
6
|
-
import { homedir } from 'os';
|
|
7
|
-
import { unstable_v2_createSession, unstable_v2_resumeSession, listSessions, getSessionMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
-
const TOOL_RESULT_TRUNCATE_LENGTH = 500;
|
|
9
|
-
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
10
|
-
/** Tools auto-approved at each permission level (no user prompt).
|
|
11
|
-
* Read/Glob/Grep are always auto-approved at SDK level (allowedTools).
|
|
12
|
-
* Tools NOT listed here go through canUseTool → permission prompt.
|
|
13
|
-
*
|
|
14
|
-
* Risk tiers:
|
|
15
|
-
* - Safe: Edit, Write, NotebookEdit, TodoWrite, Agent, EnterWorktree, ExitWorktree
|
|
16
|
-
* - Network: WebFetch, WebSearch
|
|
17
|
-
* - Execute: Bash
|
|
18
|
-
* - Code/Control: Skill, ToolSearch, Config
|
|
19
|
-
* - Schedule: CronCreate, CronDelete (arbitrary scheduled execution)
|
|
20
|
-
* - Remote: RemoteTrigger (triggers remote agents)
|
|
21
|
-
* - Workflow: EnterPlanMode, ExitPlanMode (ExitPlanMode has its own interactive UI)
|
|
22
|
-
*/
|
|
23
|
-
const AUTO_APPROVE = {
|
|
24
|
-
bypassPermissions: new Set([
|
|
25
|
-
// Safe
|
|
26
|
-
'Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree',
|
|
27
|
-
// Network
|
|
28
|
-
'WebFetch', 'WebSearch',
|
|
29
|
-
// Execute
|
|
30
|
-
'Bash',
|
|
31
|
-
// Code/Control
|
|
32
|
-
'Skill', 'ToolSearch', 'Config',
|
|
33
|
-
// Schedule + Remote
|
|
34
|
-
'CronCreate', 'CronDelete', 'CronList', 'RemoteTrigger',
|
|
35
|
-
// Workflow
|
|
36
|
-
'EnterPlanMode', 'ExitPlanMode',
|
|
37
|
-
// Background tasks
|
|
38
|
-
'TaskOutput', 'TaskStop',
|
|
39
|
-
]),
|
|
40
|
-
acceptEdits: new Set(['Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree']),
|
|
41
|
-
default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent']),
|
|
42
|
-
plan: new Set(),
|
|
43
|
-
};
|
|
44
|
-
/** Extract readable text from tool_result content (which may be a string or array of blocks). */
|
|
45
|
-
function extractToolResultText(content) {
|
|
46
|
-
if (typeof content === 'string')
|
|
47
|
-
return content;
|
|
48
|
-
if (Array.isArray(content)) {
|
|
49
|
-
return content
|
|
50
|
-
.filter((b) => b.type === 'text')
|
|
51
|
-
.map((b) => b.text || '')
|
|
52
|
-
.join('\n');
|
|
53
|
-
}
|
|
54
|
-
return JSON.stringify(content);
|
|
55
|
-
}
|
|
56
|
-
// ─── Direct JSONL reader ──────────────────────────────────────────────────────
|
|
57
|
-
// getSessionMessages() from the SDK follows the parentUuid chain, which stops
|
|
58
|
-
// at each compact_boundary. Reading the file directly lets us surface all
|
|
59
|
-
// messages across every compaction epoch in a single flat list.
|
|
60
|
-
//
|
|
61
|
-
// Set to false to fall back to the SDK reader (only shows current epoch).
|
|
62
|
-
const READ_THROUGH_COMPACT_BOUNDARY = false;
|
|
63
|
-
function projectDirName(cwd) {
|
|
64
|
-
// Mirrors SDK logic: replace non-alphanumeric chars with '-'.
|
|
65
|
-
// Paths under 200 chars use the raw replacement; longer ones get a hash
|
|
66
|
-
// suffix — but that edge case is unlikely for normal project paths.
|
|
67
|
-
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
68
|
-
}
|
|
69
|
-
function jsonlPath(sessionId, cwd) {
|
|
70
|
-
return join(homedir(), '.claude', 'projects', projectDirName(cwd), `${sessionId}.jsonl`);
|
|
71
|
-
}
|
|
72
|
-
async function readAllJSONLMessages(sessionId, cwd) {
|
|
73
|
-
const filePath = jsonlPath(sessionId, cwd);
|
|
74
|
-
const entries = [];
|
|
75
|
-
await new Promise((resolve, reject) => {
|
|
76
|
-
const rl = createInterface({
|
|
77
|
-
input: createReadStream(filePath),
|
|
78
|
-
crlfDelay: Infinity,
|
|
79
|
-
});
|
|
80
|
-
rl.on('line', (line) => {
|
|
81
|
-
if (!line.trim())
|
|
82
|
-
return;
|
|
83
|
-
try {
|
|
84
|
-
const obj = JSON.parse(line);
|
|
85
|
-
const t = obj.type;
|
|
86
|
-
// Keep user/assistant turns and compact_boundary markers; skip progress/queue-op
|
|
87
|
-
if (t === 'user' || t === 'assistant' || (t === 'system' && obj.subtype === 'compact_boundary')) {
|
|
88
|
-
entries.push(obj);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
catch { /* malformed line — skip */ }
|
|
92
|
-
});
|
|
93
|
-
rl.on('close', resolve);
|
|
94
|
-
rl.on('error', reject);
|
|
95
|
-
});
|
|
96
|
-
return entries;
|
|
97
|
-
}
|
|
98
|
-
export class ClaudeCodeService extends EventEmitter {
|
|
99
|
-
sessions = new Map();
|
|
100
|
-
sessionPermissions = new Map(); // persists across active/inactive
|
|
101
|
-
pendingInputRequests = new Map();
|
|
102
|
-
requestCounter = 0;
|
|
103
|
-
preferences = {
|
|
104
|
-
model: DEFAULT_MODEL,
|
|
105
|
-
};
|
|
106
|
-
constructor() {
|
|
107
|
-
super();
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Initialize preferences from the most recently used session's JSONL.
|
|
111
|
-
* Reads the last assistant message's model field.
|
|
112
|
-
*/
|
|
113
|
-
async initPreferences() {
|
|
114
|
-
try {
|
|
115
|
-
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
116
|
-
const projectDirs = await readdir(claudeDir);
|
|
117
|
-
let mostRecentFile = null;
|
|
118
|
-
let mostRecentMtime = 0;
|
|
119
|
-
for (const projectDir of projectDirs) {
|
|
120
|
-
const projectPath = join(claudeDir, projectDir);
|
|
121
|
-
try {
|
|
122
|
-
const files = await readdir(projectPath);
|
|
123
|
-
for (const file of files) {
|
|
124
|
-
if (!file.endsWith('.jsonl'))
|
|
125
|
-
continue;
|
|
126
|
-
const filePath = join(projectPath, file);
|
|
127
|
-
const fileStat = await stat(filePath);
|
|
128
|
-
if (fileStat.mtimeMs > mostRecentMtime) {
|
|
129
|
-
mostRecentMtime = fileStat.mtimeMs;
|
|
130
|
-
mostRecentFile = filePath;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
catch { /* skip unreadable dirs */ }
|
|
135
|
-
}
|
|
136
|
-
if (!mostRecentFile)
|
|
137
|
-
return;
|
|
138
|
-
const assistantEntries = await new Promise((resolve, reject) => {
|
|
139
|
-
const results = [];
|
|
140
|
-
const rl = createInterface({ input: createReadStream(mostRecentFile), crlfDelay: Infinity });
|
|
141
|
-
rl.on('line', (line) => {
|
|
142
|
-
if (!line.trim())
|
|
143
|
-
return;
|
|
144
|
-
try {
|
|
145
|
-
const obj = JSON.parse(line);
|
|
146
|
-
if (obj.type === 'assistant')
|
|
147
|
-
results.push(obj);
|
|
148
|
-
}
|
|
149
|
-
catch { /* malformed line */ }
|
|
150
|
-
});
|
|
151
|
-
rl.on('close', () => resolve(results));
|
|
152
|
-
rl.on('error', reject);
|
|
153
|
-
});
|
|
154
|
-
for (let i = assistantEntries.length - 1; i >= 0; i--) {
|
|
155
|
-
const model = assistantEntries[i].message?.model;
|
|
156
|
-
if (model) {
|
|
157
|
-
this.preferences.model = model;
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
catch { /* use defaults */ }
|
|
163
|
-
}
|
|
164
|
-
getPreferences() {
|
|
165
|
-
return { ...this.preferences };
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Update preferences. Returns the applied preferences (may differ if validation fails).
|
|
169
|
-
* Emits 'preferences-updated' if any value actually changed.
|
|
170
|
-
*/
|
|
171
|
-
setPreferences(prefs) {
|
|
172
|
-
const next = { ...this.preferences };
|
|
173
|
-
let changed = false;
|
|
174
|
-
if (prefs.model !== undefined && prefs.model !== this.preferences.model) {
|
|
175
|
-
next.model = prefs.model;
|
|
176
|
-
changed = true;
|
|
177
|
-
}
|
|
178
|
-
if (changed) {
|
|
179
|
-
this.preferences = next;
|
|
180
|
-
this.emit('preferences-updated', this.preferences);
|
|
181
|
-
}
|
|
182
|
-
return { ...this.preferences };
|
|
183
|
-
}
|
|
184
|
-
/** Build and emit a session-updated event with current state. */
|
|
185
|
-
emitSessionUpdate(sessionId) {
|
|
186
|
-
const ps = this.sessions.get(sessionId);
|
|
187
|
-
const hasPendingInput = Array.from(this.pendingInputRequests.values())
|
|
188
|
-
.some((p) => p.request.sessionId === sessionId);
|
|
189
|
-
this.emit('session-updated', {
|
|
190
|
-
sessionId,
|
|
191
|
-
isActive: !!ps,
|
|
192
|
-
isStreaming: ps?.streaming ?? false,
|
|
193
|
-
hasPendingInput,
|
|
194
|
-
permissionMode: ps?.permissionLevel,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
async listAvailableSessions(cwd) {
|
|
198
|
-
const sessions = await listSessions({ dir: cwd, limit: 50 });
|
|
199
|
-
// Enrich with live state + detect pending from JSONL
|
|
200
|
-
const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
|
|
201
|
-
// Check JSONL for sessions not in memory (cold pending detection)
|
|
202
|
-
const enriched = await Promise.all(sessions.map(async (s) => {
|
|
203
|
-
const isActive = this.sessions.has(s.sessionId);
|
|
204
|
-
const isStreaming = this.sessions.get(s.sessionId)?.streaming ?? false;
|
|
205
|
-
let hasPendingInput = pendingSessionIds.has(s.sessionId);
|
|
206
|
-
// If not already known as pending from memory, check JSONL tail
|
|
207
|
-
if (!hasPendingInput) {
|
|
208
|
-
hasPendingInput = await this.detectPendingFromJSONL(s.sessionId, cwd);
|
|
209
|
-
}
|
|
210
|
-
return {
|
|
211
|
-
sessionId: s.sessionId,
|
|
212
|
-
summary: s.summary,
|
|
213
|
-
lastModified: s.lastModified,
|
|
214
|
-
createdAt: s.createdAt,
|
|
215
|
-
cwd: s.cwd,
|
|
216
|
-
gitBranch: s.gitBranch,
|
|
217
|
-
isActive,
|
|
218
|
-
isStreaming,
|
|
219
|
-
hasPendingInput,
|
|
220
|
-
permissionMode: this.sessions.get(s.sessionId)?.permissionLevel ?? this.sessionPermissions.get(s.sessionId),
|
|
221
|
-
};
|
|
222
|
-
}));
|
|
223
|
-
// Sort: pending first, then active, then by lastModified
|
|
224
|
-
enriched.sort((a, b) => {
|
|
225
|
-
if (a.hasPendingInput !== b.hasPendingInput)
|
|
226
|
-
return a.hasPendingInput ? -1 : 1;
|
|
227
|
-
if (a.isActive !== b.isActive)
|
|
228
|
-
return a.isActive ? -1 : 1;
|
|
229
|
-
return b.lastModified - a.lastModified;
|
|
230
|
-
});
|
|
231
|
-
return enriched;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Check if a session's last message in the SDK JSONL is an unanswered tool_use.
|
|
235
|
-
* Reads only the last few messages to keep it fast.
|
|
236
|
-
*/
|
|
237
|
-
async detectPendingFromJSONL(sessionId, cwd) {
|
|
238
|
-
try {
|
|
239
|
-
const allMessages = READ_THROUGH_COMPACT_BOUNDARY
|
|
240
|
-
? await readAllJSONLMessages(sessionId, cwd)
|
|
241
|
-
: await getSessionMessages(sessionId, { dir: cwd });
|
|
242
|
-
if (allMessages.length === 0)
|
|
243
|
-
return false;
|
|
244
|
-
const last = allMessages[allMessages.length - 1];
|
|
245
|
-
// Last message is assistant with a tool_use block and no following user/tool_result
|
|
246
|
-
if (last.type !== 'assistant')
|
|
247
|
-
return false;
|
|
248
|
-
const content = last.message?.content;
|
|
249
|
-
if (!Array.isArray(content))
|
|
250
|
-
return false;
|
|
251
|
-
return content.some((block) => block.type === 'tool_use');
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
async getMessages(sessionId, cwd, offset = 0, limit = 50) {
|
|
258
|
-
const allMessages = READ_THROUGH_COMPACT_BOUNDARY
|
|
259
|
-
? await readAllJSONLMessages(sessionId, cwd)
|
|
260
|
-
: await getSessionMessages(sessionId, { dir: cwd });
|
|
261
|
-
const total = allMessages.length;
|
|
262
|
-
const tailStart = Math.max(0, total - offset - limit);
|
|
263
|
-
const tailEnd = Math.max(0, total - offset);
|
|
264
|
-
const sliced = allMessages.slice(tailStart, tailEnd);
|
|
265
|
-
const messages = sliced.flatMap((msg, i) => {
|
|
266
|
-
// compact_boundary system entries — render as a divider
|
|
267
|
-
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
|
|
268
|
-
return [{
|
|
269
|
-
index: tailStart + i,
|
|
270
|
-
role: 'system',
|
|
271
|
-
content: 'Context compacted',
|
|
272
|
-
}];
|
|
273
|
-
}
|
|
274
|
-
const role = msg.type;
|
|
275
|
-
const rawMessage = msg.message;
|
|
276
|
-
const expanded = [];
|
|
277
|
-
if (rawMessage?.content) {
|
|
278
|
-
if (typeof rawMessage.content === 'string') {
|
|
279
|
-
expanded.push({ index: tailStart + i, role, content: rawMessage.content });
|
|
280
|
-
}
|
|
281
|
-
else if (Array.isArray(rawMessage.content)) {
|
|
282
|
-
const textParts = [];
|
|
283
|
-
for (const block of rawMessage.content) {
|
|
284
|
-
if (block.type === 'text') {
|
|
285
|
-
textParts.push(block.text);
|
|
286
|
-
}
|
|
287
|
-
else if (block.type === 'tool_use') {
|
|
288
|
-
expanded.push({
|
|
289
|
-
index: tailStart + i,
|
|
290
|
-
role,
|
|
291
|
-
content: '',
|
|
292
|
-
toolName: block.name,
|
|
293
|
-
toolInput: JSON.stringify(block.input),
|
|
294
|
-
toolUseId: block.id,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
else if (block.type === 'tool_result') {
|
|
298
|
-
const resultStr = extractToolResultText(block.content);
|
|
299
|
-
const truncated = resultStr.length > TOOL_RESULT_TRUNCATE_LENGTH;
|
|
300
|
-
expanded.push({
|
|
301
|
-
index: tailStart + i,
|
|
302
|
-
role,
|
|
303
|
-
content: '',
|
|
304
|
-
toolResult: truncated
|
|
305
|
-
? resultStr.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]'
|
|
306
|
-
: resultStr,
|
|
307
|
-
toolResultForId: block.tool_use_id,
|
|
308
|
-
truncated,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (textParts.length > 0) {
|
|
313
|
-
expanded.unshift({ index: tailStart + i, role, content: textParts.join('\n') });
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (expanded.length === 0) {
|
|
318
|
-
expanded.push({ index: tailStart + i, role, content: '' });
|
|
319
|
-
}
|
|
320
|
-
return expanded;
|
|
321
|
-
});
|
|
322
|
-
return {
|
|
323
|
-
messages,
|
|
324
|
-
total,
|
|
325
|
-
hasMore: tailStart > 0,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Create a V2 session with the given cwd.
|
|
330
|
-
* V2 SDKSessionOptions doesn't expose `cwd`, so we temporarily change
|
|
331
|
-
* process.cwd() around the synchronous createSession() call.
|
|
332
|
-
*/
|
|
333
|
-
createSessionWithCwd(cwd, sessionId, opts) {
|
|
334
|
-
const originalCwd = process.cwd();
|
|
335
|
-
try {
|
|
336
|
-
process.chdir(cwd);
|
|
337
|
-
// allowedTools = SDK auto-approve list (bypasses canUseTool entirely).
|
|
338
|
-
// Only list tools that should NEVER prompt at ANY permission level.
|
|
339
|
-
// Everything else goes through permissionMode → canUseTool for dynamic control.
|
|
340
|
-
const sessionOpts = {
|
|
341
|
-
model: opts.model ?? DEFAULT_MODEL,
|
|
342
|
-
allowedTools: opts.allowedTools ?? ['Read', 'Glob', 'Grep'],
|
|
343
|
-
permissionMode: 'default',
|
|
344
|
-
settingSources: ['user', 'project', 'local'],
|
|
345
|
-
canUseTool: async (toolName, input, options) => {
|
|
346
|
-
const resolvedSessionId = sessionId ?? 'unknown';
|
|
347
|
-
// Check runtime permission level — auto-approve if tool is in the allow set
|
|
348
|
-
const ps = resolvedSessionId !== 'unknown' ? this.sessions.get(resolvedSessionId) : undefined;
|
|
349
|
-
const level = ps?.permissionLevel ?? 'acceptEdits';
|
|
350
|
-
if (AUTO_APPROVE[level].has(toolName)) {
|
|
351
|
-
return { behavior: 'allow', updatedInput: input };
|
|
352
|
-
}
|
|
353
|
-
const requestId = `perm-${++this.requestCounter}`;
|
|
354
|
-
// AskUserQuestion: forward as question type with options
|
|
355
|
-
const isQuestion = toolName === 'AskUserQuestion';
|
|
356
|
-
const questions = isQuestion ? input.questions : undefined;
|
|
357
|
-
// Forward request to PWA
|
|
358
|
-
const request = {
|
|
359
|
-
sessionId: resolvedSessionId,
|
|
360
|
-
requestId,
|
|
361
|
-
inputType: isQuestion ? 'question' : 'permission',
|
|
362
|
-
title: isQuestion
|
|
363
|
-
? questions?.[0]?.question ?? 'Question from Claude'
|
|
364
|
-
: (options.title ?? `Allow ${toolName}?`),
|
|
365
|
-
message: isQuestion
|
|
366
|
-
? undefined
|
|
367
|
-
: (options.description ?? JSON.stringify(input).slice(0, 500)),
|
|
368
|
-
toolName,
|
|
369
|
-
toolInput: input,
|
|
370
|
-
// Include structured options for question type
|
|
371
|
-
...(isQuestion && questions ? {
|
|
372
|
-
options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
|
|
373
|
-
key: opt.label,
|
|
374
|
-
label: opt.label,
|
|
375
|
-
description: opt.description,
|
|
376
|
-
}))),
|
|
377
|
-
} : {}),
|
|
378
|
-
};
|
|
379
|
-
this.emit('user-input-request', request);
|
|
380
|
-
this.emitSessionUpdate(resolvedSessionId);
|
|
381
|
-
// Wait for explicit user response (no timeout — user must act)
|
|
382
|
-
const response = await this.waitForUserInput(requestId, request, options.signal);
|
|
383
|
-
if (response.action === 'deny') {
|
|
384
|
-
return { behavior: 'deny', message: 'User denied permission' };
|
|
385
|
-
}
|
|
386
|
-
// For AskUserQuestion, inject user's answer into the tool input
|
|
387
|
-
if (isQuestion && response.response) {
|
|
388
|
-
const answers = {};
|
|
389
|
-
if (questions?.[0]?.question) {
|
|
390
|
-
answers[questions[0].question] = response.response;
|
|
391
|
-
}
|
|
392
|
-
return {
|
|
393
|
-
behavior: 'allow',
|
|
394
|
-
updatedInput: { ...input, answers },
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
return { behavior: 'allow', updatedInput: input };
|
|
398
|
-
},
|
|
399
|
-
};
|
|
400
|
-
if (opts.resumeSessionId) {
|
|
401
|
-
return unstable_v2_resumeSession(opts.resumeSessionId, sessionOpts);
|
|
402
|
-
}
|
|
403
|
-
return unstable_v2_createSession(sessionOpts);
|
|
404
|
-
}
|
|
405
|
-
finally {
|
|
406
|
-
process.chdir(originalCwd);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
waitForUserInput(requestId, request, signal) {
|
|
410
|
-
return new Promise((resolve) => {
|
|
411
|
-
this.pendingInputRequests.set(requestId, { resolve, request });
|
|
412
|
-
// Only auto-resolve if the SDK itself aborts (e.g. session closed)
|
|
413
|
-
signal?.addEventListener('abort', () => {
|
|
414
|
-
this.pendingInputRequests.delete(requestId);
|
|
415
|
-
resolve({ sessionId: '', requestId, action: 'allow' });
|
|
416
|
-
}, { once: true });
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Called by message handler when PWA sends a user input response.
|
|
421
|
-
*/
|
|
422
|
-
resolveUserInput(response) {
|
|
423
|
-
const pending = this.pendingInputRequests.get(response.requestId);
|
|
424
|
-
if (!pending)
|
|
425
|
-
return false;
|
|
426
|
-
this.pendingInputRequests.delete(response.requestId);
|
|
427
|
-
pending.resolve(response);
|
|
428
|
-
this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
|
|
429
|
-
this.emitSessionUpdate(pending.request.sessionId);
|
|
430
|
-
return true;
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Get all pending user input requests (for re-sending to a reconnected client).
|
|
434
|
-
*/
|
|
435
|
-
getPendingInputRequests() {
|
|
436
|
-
return Array.from(this.pendingInputRequests.values()).map((p) => p.request);
|
|
437
|
-
}
|
|
438
|
-
async startSession(opts) {
|
|
439
|
-
const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
440
|
-
const level = validModes.includes(opts.permissionMode)
|
|
441
|
-
? opts.permissionMode : 'acceptEdits';
|
|
442
|
-
const session = this.createSessionWithCwd(opts.cwd, null, {
|
|
443
|
-
allowedTools: opts.allowedTools,
|
|
444
|
-
model: opts.model,
|
|
445
|
-
permissionMode: opts.permissionMode,
|
|
446
|
-
});
|
|
447
|
-
const prompt = opts.systemPrompt
|
|
448
|
-
? `[System context: ${opts.systemPrompt}]\n\n${opts.prompt}`
|
|
449
|
-
: opts.prompt;
|
|
450
|
-
await session.send(prompt);
|
|
451
|
-
this.emit('stream', {
|
|
452
|
-
sessionId: '', streamId: opts.streamId,
|
|
453
|
-
eventType: 'user_message',
|
|
454
|
-
content: opts.prompt,
|
|
455
|
-
});
|
|
456
|
-
const sessionId = await new Promise((resolve) => {
|
|
457
|
-
this.consumeStream(session, opts.streamId, (id) => {
|
|
458
|
-
this.sessions.set(id, {
|
|
459
|
-
session,
|
|
460
|
-
sessionId: id,
|
|
461
|
-
cwd: opts.cwd,
|
|
462
|
-
streaming: true,
|
|
463
|
-
cancelStreaming: null,
|
|
464
|
-
permissionLevel: level,
|
|
465
|
-
});
|
|
466
|
-
this.sessionPermissions.set(id, level);
|
|
467
|
-
this.emitSessionUpdate(id);
|
|
468
|
-
resolve(id);
|
|
469
|
-
});
|
|
470
|
-
});
|
|
471
|
-
return sessionId;
|
|
472
|
-
}
|
|
473
|
-
async resumeSession(opts) {
|
|
474
|
-
const existing = this.sessions.get(opts.sessionId);
|
|
475
|
-
if (existing) {
|
|
476
|
-
console.log(`[v2] hot resume session=${opts.sessionId}`);
|
|
477
|
-
existing.streaming = true;
|
|
478
|
-
this.emitSessionUpdate(opts.sessionId);
|
|
479
|
-
this.emit('stream', {
|
|
480
|
-
sessionId: opts.sessionId, streamId: opts.streamId,
|
|
481
|
-
eventType: 'user_message',
|
|
482
|
-
content: opts.prompt,
|
|
483
|
-
});
|
|
484
|
-
await existing.session.send(opts.prompt);
|
|
485
|
-
this.consumeStream(existing.session, opts.streamId, () => {
|
|
486
|
-
// Session ID already captured
|
|
487
|
-
});
|
|
488
|
-
return opts.sessionId;
|
|
489
|
-
}
|
|
490
|
-
console.log(`[v2] cold resume session=${opts.sessionId}`);
|
|
491
|
-
const session = this.createSessionWithCwd(opts.cwd, opts.sessionId, {
|
|
492
|
-
resumeSessionId: opts.sessionId,
|
|
493
|
-
});
|
|
494
|
-
this.emit('stream', {
|
|
495
|
-
sessionId: opts.sessionId, streamId: opts.streamId,
|
|
496
|
-
eventType: 'user_message',
|
|
497
|
-
content: opts.prompt,
|
|
498
|
-
});
|
|
499
|
-
await session.send(opts.prompt);
|
|
500
|
-
const actualSessionId = await new Promise((resolve) => {
|
|
501
|
-
this.consumeStream(session, opts.streamId, (id) => {
|
|
502
|
-
if (id !== opts.sessionId) {
|
|
503
|
-
this.emit('stream', {
|
|
504
|
-
sessionId: id, streamId: opts.streamId,
|
|
505
|
-
eventType: 'system',
|
|
506
|
-
content: `Warning: SDK created new session ${id} instead of resuming ${opts.sessionId}`,
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
const restoredLevel = this.sessionPermissions.get(opts.sessionId) ?? 'acceptEdits';
|
|
510
|
-
this.sessions.set(id, {
|
|
511
|
-
session,
|
|
512
|
-
sessionId: id,
|
|
513
|
-
cwd: opts.cwd,
|
|
514
|
-
streaming: true,
|
|
515
|
-
cancelStreaming: null,
|
|
516
|
-
permissionLevel: restoredLevel,
|
|
517
|
-
});
|
|
518
|
-
this.sessionPermissions.set(id, restoredLevel);
|
|
519
|
-
this.emitSessionUpdate(id);
|
|
520
|
-
resolve(id);
|
|
521
|
-
});
|
|
522
|
-
});
|
|
523
|
-
return actualSessionId;
|
|
524
|
-
}
|
|
525
|
-
cancelSession(sessionId) {
|
|
526
|
-
const ps = this.sessions.get(sessionId);
|
|
527
|
-
if (!ps)
|
|
528
|
-
return false;
|
|
529
|
-
// Stop streaming but keep the session process alive
|
|
530
|
-
if (ps.cancelStreaming) {
|
|
531
|
-
ps.cancelStreaming();
|
|
532
|
-
}
|
|
533
|
-
ps.streaming = false;
|
|
534
|
-
return true;
|
|
535
|
-
}
|
|
536
|
-
setPermissionLevel(sessionId, level) {
|
|
537
|
-
const ps = this.sessions.get(sessionId);
|
|
538
|
-
if (ps) {
|
|
539
|
-
ps.permissionLevel = level;
|
|
540
|
-
}
|
|
541
|
-
// Persist across active/inactive lifecycle
|
|
542
|
-
this.sessionPermissions.set(sessionId, level);
|
|
543
|
-
// Always broadcast so all clients sync, even for inactive sessions
|
|
544
|
-
this.emit('session-updated', {
|
|
545
|
-
sessionId,
|
|
546
|
-
isActive: !!ps,
|
|
547
|
-
isStreaming: ps?.streaming ?? false,
|
|
548
|
-
hasPendingInput: ps ? Array.from(this.pendingInputRequests.values()).some((p) => p.request.sessionId === sessionId) : false,
|
|
549
|
-
permissionMode: level,
|
|
550
|
-
});
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
553
|
-
getPermissionLevel(sessionId) {
|
|
554
|
-
return this.sessions.get(sessionId)?.permissionLevel ?? 'acceptEdits';
|
|
555
|
-
}
|
|
556
|
-
getActiveSessions() {
|
|
557
|
-
const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map((p) => p.request.sessionId));
|
|
558
|
-
return Array.from(this.sessions.entries()).map(([id, ps]) => ({
|
|
559
|
-
sessionId: id,
|
|
560
|
-
cwd: ps.cwd,
|
|
561
|
-
isStreaming: ps.streaming,
|
|
562
|
-
hasPendingInput: pendingSessionIds.has(id),
|
|
563
|
-
permissionMode: ps.permissionLevel,
|
|
564
|
-
}));
|
|
565
|
-
}
|
|
566
|
-
closeSession(sessionId) {
|
|
567
|
-
const ps = this.sessions.get(sessionId);
|
|
568
|
-
if (!ps)
|
|
569
|
-
return false;
|
|
570
|
-
// Stop streaming if active
|
|
571
|
-
if (ps.cancelStreaming) {
|
|
572
|
-
ps.cancelStreaming();
|
|
573
|
-
}
|
|
574
|
-
// Terminate the subprocess
|
|
575
|
-
ps.session.close();
|
|
576
|
-
this.sessions.delete(sessionId);
|
|
577
|
-
this.emitSessionUpdate(sessionId);
|
|
578
|
-
return true;
|
|
579
|
-
}
|
|
580
|
-
isStreaming(sessionId) {
|
|
581
|
-
return this.sessions.get(sessionId)?.streaming ?? false;
|
|
582
|
-
}
|
|
583
|
-
isOpen(sessionId) {
|
|
584
|
-
return this.sessions.has(sessionId);
|
|
585
|
-
}
|
|
586
|
-
getActiveSessionCount() {
|
|
587
|
-
return this.sessions.size;
|
|
588
|
-
}
|
|
589
|
-
cleanup() {
|
|
590
|
-
// Force-resolve any pending inputs on daemon shutdown
|
|
591
|
-
for (const [requestId, pending] of this.pendingInputRequests) {
|
|
592
|
-
pending.resolve({ sessionId: '', requestId, action: 'allow' });
|
|
593
|
-
}
|
|
594
|
-
this.pendingInputRequests.clear();
|
|
595
|
-
for (const [, ps] of this.sessions) {
|
|
596
|
-
try {
|
|
597
|
-
ps.session.close();
|
|
598
|
-
}
|
|
599
|
-
catch {
|
|
600
|
-
// Ignore errors during cleanup
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
this.sessions.clear();
|
|
604
|
-
}
|
|
605
|
-
async consumeStream(session, streamId, onSessionId) {
|
|
606
|
-
let textBuffer = '';
|
|
607
|
-
let bufferTimer = null;
|
|
608
|
-
let capturedSessionId = null;
|
|
609
|
-
let cancelled = false;
|
|
610
|
-
const emitStream = (event) => {
|
|
611
|
-
this.emit('stream', { ...event, sessionId: capturedSessionId ?? '', streamId });
|
|
612
|
-
};
|
|
613
|
-
const flushText = () => {
|
|
614
|
-
if (textBuffer) {
|
|
615
|
-
emitStream({ eventType: 'assistant_text', content: textBuffer });
|
|
616
|
-
textBuffer = '';
|
|
617
|
-
}
|
|
618
|
-
if (bufferTimer) {
|
|
619
|
-
clearTimeout(bufferTimer);
|
|
620
|
-
bufferTimer = null;
|
|
621
|
-
}
|
|
622
|
-
};
|
|
623
|
-
const markStreamingDone = () => {
|
|
624
|
-
if (capturedSessionId) {
|
|
625
|
-
const ps = this.sessions.get(capturedSessionId);
|
|
626
|
-
if (ps) {
|
|
627
|
-
ps.streaming = false;
|
|
628
|
-
ps.cancelStreaming = null;
|
|
629
|
-
}
|
|
630
|
-
this.emitSessionUpdate(capturedSessionId);
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
const bufferText = (text) => {
|
|
634
|
-
textBuffer += text;
|
|
635
|
-
if (!bufferTimer) {
|
|
636
|
-
bufferTimer = setTimeout(flushText, 150);
|
|
637
|
-
}
|
|
638
|
-
if (textBuffer.length > 2048) {
|
|
639
|
-
flushText();
|
|
640
|
-
}
|
|
641
|
-
};
|
|
642
|
-
// Wire up cancel function for this streaming turn
|
|
643
|
-
const setCancelFn = () => {
|
|
644
|
-
if (capturedSessionId) {
|
|
645
|
-
const ps = this.sessions.get(capturedSessionId);
|
|
646
|
-
if (ps) {
|
|
647
|
-
ps.cancelStreaming = () => { cancelled = true; };
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
try {
|
|
652
|
-
for await (const message of session.stream()) {
|
|
653
|
-
if (cancelled)
|
|
654
|
-
break;
|
|
655
|
-
// Capture session ID from init message
|
|
656
|
-
if (message.type === 'system' && message.subtype === 'init') {
|
|
657
|
-
capturedSessionId = message.session_id;
|
|
658
|
-
console.log(`[stream] init session=${message.session_id}`);
|
|
659
|
-
onSessionId(message.session_id);
|
|
660
|
-
setCancelFn();
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
// Session state changed — 'idle' means turn is over
|
|
664
|
-
if (message.type === 'system' && message.subtype === 'session_state_changed') {
|
|
665
|
-
const state = message.state;
|
|
666
|
-
if (state === 'idle') {
|
|
667
|
-
flushText();
|
|
668
|
-
console.log(`[stream] session_state_changed=idle session=${capturedSessionId}`);
|
|
669
|
-
// Don't call onEnd here — the result message will follow
|
|
670
|
-
}
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
// Streaming partial events
|
|
674
|
-
if (message.type === 'stream_event') {
|
|
675
|
-
const event = message.event;
|
|
676
|
-
if (event?.type === 'content_block_delta') {
|
|
677
|
-
const delta = event.delta;
|
|
678
|
-
if (delta?.type === 'text_delta' && delta.text) {
|
|
679
|
-
bufferText(delta.text);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
// Complete assistant messages
|
|
685
|
-
if (message.type === 'assistant') {
|
|
686
|
-
flushText();
|
|
687
|
-
const betaMessage = message.message;
|
|
688
|
-
if (betaMessage?.content) {
|
|
689
|
-
for (const block of betaMessage.content) {
|
|
690
|
-
if (block.type === 'text' && block.text) {
|
|
691
|
-
emitStream({
|
|
692
|
-
eventType: 'assistant_text',
|
|
693
|
-
content: block.text,
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
else if (block.type === 'tool_use') {
|
|
697
|
-
const toolInput = JSON.stringify(block.input);
|
|
698
|
-
emitStream({
|
|
699
|
-
eventType: 'tool_use',
|
|
700
|
-
content: '',
|
|
701
|
-
toolName: block.name,
|
|
702
|
-
toolInput,
|
|
703
|
-
toolUseId: block.id,
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
|
-
// User messages contain tool results
|
|
711
|
-
if (message.type === 'user') {
|
|
712
|
-
const userMsg = message.message;
|
|
713
|
-
if (userMsg?.content && Array.isArray(userMsg.content)) {
|
|
714
|
-
for (const block of userMsg.content) {
|
|
715
|
-
if (block.type === 'tool_result') {
|
|
716
|
-
const resultContent = extractToolResultText(block.content);
|
|
717
|
-
const truncated = resultContent.length > TOOL_RESULT_TRUNCATE_LENGTH
|
|
718
|
-
? resultContent.slice(0, TOOL_RESULT_TRUNCATE_LENGTH) + ' [truncated]'
|
|
719
|
-
: resultContent;
|
|
720
|
-
emitStream({
|
|
721
|
-
eventType: 'tool_result',
|
|
722
|
-
content: truncated,
|
|
723
|
-
toolResultForId: block.tool_use_id,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
continue;
|
|
729
|
-
}
|
|
730
|
-
// Final result — turn is complete but session stays alive
|
|
731
|
-
if (message.type === 'result') {
|
|
732
|
-
flushText();
|
|
733
|
-
const result = message;
|
|
734
|
-
console.log(`[stream] result session=${capturedSessionId} subtype=${result.subtype} cost=$${result.total_cost_usd?.toFixed(4) ?? '?'}`);
|
|
735
|
-
markStreamingDone();
|
|
736
|
-
this.emit('stream:end', {
|
|
737
|
-
sessionId: capturedSessionId ?? '', streamId,
|
|
738
|
-
success: result.subtype === 'success',
|
|
739
|
-
error: result.subtype !== 'success'
|
|
740
|
-
? (result.errors?.join('; ') || `Session ended: ${result.subtype}`)
|
|
741
|
-
: undefined,
|
|
742
|
-
totalCostUsd: result.total_cost_usd,
|
|
743
|
-
tokenUsage: result.usage
|
|
744
|
-
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
745
|
-
: undefined,
|
|
746
|
-
});
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
flushText();
|
|
751
|
-
console.log(`[stream] ended without result session=${capturedSessionId} (cancel or unexpected)`);
|
|
752
|
-
markStreamingDone();
|
|
753
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: true });
|
|
754
|
-
}
|
|
755
|
-
catch (error) {
|
|
756
|
-
flushText();
|
|
757
|
-
console.error(`[stream] error session=${capturedSessionId}:`, error);
|
|
758
|
-
markStreamingDone();
|
|
759
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
760
|
-
this.emit('stream:end', { sessionId: capturedSessionId ?? '', streamId, success: false, error: msg });
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
//# sourceMappingURL=claudeCodeService.js.map
|