@yeaft/webchat-agent 0.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.
package/sdk/query.js ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Main query implementation for Claude Code SDK
3
+ * Handles spawning Claude process and managing message streams
4
+ */
5
+
6
+ import { spawn } from 'child_process';
7
+ import { createInterface } from 'readline';
8
+ import { Stream } from './stream.js';
9
+ import { AbortError } from './types.js';
10
+ import { getCleanEnv, logDebug, streamToStdin, resolveClaudeCommand } from './utils.js';
11
+
12
+
13
+ /**
14
+ * Query class manages Claude Code process interaction
15
+ * Implements AsyncIterableIterator for streaming messages
16
+ */
17
+ export class Query {
18
+ constructor(childStdin, childStdout, processExitPromise, canCallTool) {
19
+ this.pendingControlResponses = new Map();
20
+ this.cancelControllers = new Map();
21
+ this.inputStream = new Stream();
22
+ this.childStdin = childStdin;
23
+ this.childStdout = childStdout;
24
+ this.processExitPromise = processExitPromise;
25
+ this.canCallTool = canCallTool;
26
+ this.claudeSessionId = null;
27
+
28
+ this.readMessages();
29
+ this.sdkMessages = this.readSdkMessages();
30
+ }
31
+
32
+ /**
33
+ * Get the Claude session ID
34
+ */
35
+ getSessionId() {
36
+ return this.claudeSessionId;
37
+ }
38
+
39
+ /**
40
+ * Set an error on the stream
41
+ */
42
+ setError(error) {
43
+ this.inputStream.error(error);
44
+ }
45
+
46
+ /**
47
+ * AsyncIterableIterator implementation
48
+ */
49
+ next(...args) {
50
+ return this.sdkMessages.next(...args);
51
+ }
52
+
53
+ return(value) {
54
+ if (this.sdkMessages.return) {
55
+ return this.sdkMessages.return(value);
56
+ }
57
+ return Promise.resolve({ done: true, value: undefined });
58
+ }
59
+
60
+ throw(e) {
61
+ if (this.sdkMessages.throw) {
62
+ return this.sdkMessages.throw(e);
63
+ }
64
+ return Promise.reject(e);
65
+ }
66
+
67
+ [Symbol.asyncIterator]() {
68
+ return this.sdkMessages;
69
+ }
70
+
71
+ /**
72
+ * Read messages from Claude process stdout
73
+ */
74
+ async readMessages() {
75
+ const rl = createInterface({ input: this.childStdout });
76
+
77
+ try {
78
+ for await (const line of rl) {
79
+ if (line.trim()) {
80
+ try {
81
+ const message = JSON.parse(line);
82
+
83
+ // Capture session ID from system messages
84
+ if (message.type === 'system' && message.session_id) {
85
+ this.claudeSessionId = message.session_id;
86
+ logDebug(`Session ID captured: ${this.claudeSessionId}`);
87
+ }
88
+
89
+ if (message.type === 'control_response') {
90
+ const handler = this.pendingControlResponses.get(message.response.request_id);
91
+ if (handler) {
92
+ handler(message.response);
93
+ }
94
+ continue;
95
+ } else if (message.type === 'control_request') {
96
+ await this.handleControlRequest(message);
97
+ continue;
98
+ } else if (message.type === 'control_cancel_request') {
99
+ this.handleControlCancelRequest(message);
100
+ continue;
101
+ }
102
+
103
+ this.inputStream.enqueue(message);
104
+ } catch (e) {
105
+ logDebug(`Non-JSON line: ${line.substring(0, 100)}`);
106
+ }
107
+ }
108
+ }
109
+ await this.processExitPromise;
110
+ } catch (error) {
111
+ this.inputStream.error(error);
112
+ } finally {
113
+ this.inputStream.done();
114
+ this.cleanupControllers();
115
+ rl.close();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Async generator for SDK messages
121
+ */
122
+ async *readSdkMessages() {
123
+ for await (const message of this.inputStream) {
124
+ yield message;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Send interrupt request to Claude
130
+ */
131
+ async interrupt() {
132
+ if (!this.childStdin) {
133
+ throw new Error('Interrupt requires --input-format stream-json');
134
+ }
135
+
136
+ await this.request({
137
+ subtype: 'interrupt'
138
+ }, this.childStdin);
139
+ }
140
+
141
+ /**
142
+ * Send a user message
143
+ */
144
+ sendMessage(content) {
145
+ if (!this.childStdin) {
146
+ throw new Error('sendMessage requires --input-format stream-json');
147
+ }
148
+
149
+ const msg = {
150
+ type: 'user',
151
+ message: {
152
+ role: 'user',
153
+ content: typeof content === 'string' ? content : content
154
+ }
155
+ };
156
+
157
+ this.childStdin.write(JSON.stringify(msg) + '\n');
158
+ }
159
+
160
+ /**
161
+ * Send control request to Claude process
162
+ */
163
+ request(request, childStdin) {
164
+ const requestId = Math.random().toString(36).substring(2, 15);
165
+ const sdkRequest = {
166
+ request_id: requestId,
167
+ type: 'control_request',
168
+ request
169
+ };
170
+
171
+ return new Promise((resolve, reject) => {
172
+ this.pendingControlResponses.set(requestId, (response) => {
173
+ if (response.subtype === 'success') {
174
+ resolve(response);
175
+ } else {
176
+ reject(new Error(response.error));
177
+ }
178
+ });
179
+
180
+ childStdin.write(JSON.stringify(sdkRequest) + '\n');
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Handle incoming control requests for tool permissions
186
+ */
187
+ async handleControlRequest(request) {
188
+ if (!this.childStdin) {
189
+ logDebug('Cannot handle control request - no stdin available');
190
+ return;
191
+ }
192
+
193
+ const controller = new AbortController();
194
+ this.cancelControllers.set(request.request_id, controller);
195
+
196
+ try {
197
+ const response = await this.processControlRequest(request, controller.signal);
198
+ const controlResponse = {
199
+ type: 'control_response',
200
+ response: {
201
+ subtype: 'success',
202
+ request_id: request.request_id,
203
+ response
204
+ }
205
+ };
206
+ this.childStdin.write(JSON.stringify(controlResponse) + '\n');
207
+ } catch (error) {
208
+ const controlErrorResponse = {
209
+ type: 'control_response',
210
+ response: {
211
+ subtype: 'error',
212
+ request_id: request.request_id,
213
+ error: error instanceof Error ? error.message : String(error)
214
+ }
215
+ };
216
+ this.childStdin.write(JSON.stringify(controlErrorResponse) + '\n');
217
+ } finally {
218
+ this.cancelControllers.delete(request.request_id);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Handle control cancel requests
224
+ */
225
+ handleControlCancelRequest(request) {
226
+ const controller = this.cancelControllers.get(request.request_id);
227
+ if (controller) {
228
+ controller.abort();
229
+ this.cancelControllers.delete(request.request_id);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Process control requests based on subtype
235
+ */
236
+ async processControlRequest(request, signal) {
237
+ if (request.request.subtype === 'can_use_tool') {
238
+ if (!this.canCallTool) {
239
+ throw new Error('canCallTool callback is not provided.');
240
+ }
241
+ return this.canCallTool(request.request.tool_name, request.request.input, {
242
+ signal
243
+ });
244
+ }
245
+
246
+ throw new Error('Unsupported control request subtype: ' + request.request.subtype);
247
+ }
248
+
249
+ /**
250
+ * Cleanup method to abort all pending control requests
251
+ */
252
+ cleanupControllers() {
253
+ for (const [requestId, controller] of this.cancelControllers.entries()) {
254
+ controller.abort();
255
+ this.cancelControllers.delete(requestId);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Main query function to interact with Claude Code
262
+ * @param {object} config - Configuration object
263
+ * @param {string|AsyncIterable} config.prompt - The prompt or async iterable of messages
264
+ * @param {object} config.options - Query options
265
+ * @returns {Query} Query instance
266
+ */
267
+ export function query(config) {
268
+ const {
269
+ prompt,
270
+ options: {
271
+ allowedTools = [],
272
+ appendSystemPrompt,
273
+ customSystemPrompt,
274
+ cwd,
275
+ disallowedTools = [],
276
+ maxTurns,
277
+ permissionMode = 'default',
278
+ continue: continueConversation,
279
+ resume,
280
+ model,
281
+ canCallTool,
282
+ abort
283
+ } = {}
284
+ } = config;
285
+
286
+ // Build command arguments
287
+ const args = ['--output-format', 'stream-json', '--verbose'];
288
+
289
+ if (customSystemPrompt) args.push('--system-prompt', customSystemPrompt);
290
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt);
291
+ if (maxTurns) args.push('--max-turns', maxTurns.toString());
292
+ if (model) args.push('--model', model);
293
+ if (canCallTool) {
294
+ if (typeof prompt === 'string') {
295
+ throw new Error('canCallTool callback requires --input-format stream-json. Please set prompt as an AsyncIterable.');
296
+ }
297
+ args.push('--permission-prompt-tool', 'stdio');
298
+ }
299
+ if (continueConversation) args.push('--continue');
300
+ if (resume) args.push('--resume', resume);
301
+ if (allowedTools.length > 0) args.push('--allowedTools', ...allowedTools);
302
+ if (disallowedTools.length > 0) args.push('--disallowedTools', ...disallowedTools);
303
+ if (permissionMode) args.push('--permission-mode', permissionMode);
304
+
305
+ // Handle prompt input
306
+ if (typeof prompt === 'string') {
307
+ args.push('--print', prompt.trim());
308
+ } else {
309
+ args.push('--input-format', 'stream-json');
310
+ }
311
+
312
+ const { command: claudeCommand, prefixArgs, spawnOpts } = resolveClaudeCommand();
313
+ const spawnEnv = getCleanEnv();
314
+
315
+ console.log(`[SDK] Spawning Claude Code:`);
316
+ console.log(`[SDK] command: ${claudeCommand}`);
317
+ if (prefixArgs.length) console.log(`[SDK] entrypoint: ${prefixArgs[0]}`);
318
+ console.log(`[SDK] args: ${args.join(' ')}`);
319
+ console.log(`[SDK] cwd: ${cwd}`);
320
+ if (spawnOpts.shell) console.log(`[SDK] shell: ${spawnOpts.shell}`);
321
+ logDebug(`Spawning Claude Code: ${claudeCommand} ${[...prefixArgs, ...args].join(' ')}`);
322
+
323
+ const child = spawn(claudeCommand, [...prefixArgs, ...args], {
324
+ cwd,
325
+ stdio: ['pipe', 'pipe', 'pipe'],
326
+ signal: abort,
327
+ env: spawnEnv,
328
+ windowsHide: true,
329
+ ...spawnOpts,
330
+ });
331
+
332
+ // Handle stdin
333
+ let childStdin = null;
334
+ if (typeof prompt === 'string') {
335
+ child.stdin.end();
336
+ } else {
337
+ streamToStdin(prompt, child.stdin, abort);
338
+ childStdin = child.stdin;
339
+ }
340
+
341
+ // Handle stderr - always capture for debugging
342
+ let stderrBuffer = '';
343
+ child.stderr.on('data', (data) => {
344
+ const text = data.toString();
345
+ stderrBuffer += text;
346
+ if (process.env.DEBUG) {
347
+ console.error('Claude Code stderr:', text);
348
+ }
349
+ });
350
+
351
+ // Setup cleanup
352
+ const cleanup = () => {
353
+ if (!child.killed) {
354
+ child.kill();
355
+ }
356
+ };
357
+
358
+ abort?.addEventListener('abort', cleanup);
359
+ process.on('exit', cleanup);
360
+
361
+ // Handle process exit
362
+ const processExitPromise = new Promise((resolve) => {
363
+ child.on('close', (code) => {
364
+ if (abort?.aborted) {
365
+ queryInstance.setError(new AbortError('Claude Code process aborted by user'));
366
+ }
367
+ if (code !== 0) {
368
+ const errorMsg = stderrBuffer ? `Claude Code process exited with code ${code}: ${stderrBuffer.trim()}` : `Claude Code process exited with code ${code}`;
369
+ console.error('[SDK] Process error:', errorMsg);
370
+ queryInstance.setError(new Error(errorMsg));
371
+ } else {
372
+ resolve();
373
+ }
374
+ });
375
+ });
376
+
377
+ // Create query instance
378
+ const queryInstance = new Query(childStdin, child.stdout, processExitPromise, canCallTool);
379
+
380
+ // Handle process errors
381
+ child.on('error', (error) => {
382
+ if (abort?.aborted) {
383
+ queryInstance.setError(new AbortError('Claude Code process aborted by user'));
384
+ } else {
385
+ queryInstance.setError(new Error(`Failed to spawn Claude Code process: ${error.message}`));
386
+ }
387
+ });
388
+
389
+ // Cleanup on exit
390
+ processExitPromise.finally(() => {
391
+ cleanup();
392
+ abort?.removeEventListener('abort', cleanup);
393
+ });
394
+
395
+ return queryInstance;
396
+ }
package/sdk/stream.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Stream implementation for handling async message streams
3
+ * Provides an async iterable interface for processing SDK messages
4
+ */
5
+
6
+ /**
7
+ * Generic async stream implementation
8
+ * Handles queuing, error propagation, and proper cleanup
9
+ */
10
+ export class Stream {
11
+ constructor(returned) {
12
+ this.queue = [];
13
+ this.readResolve = undefined;
14
+ this.readReject = undefined;
15
+ this.isDone = false;
16
+ this.hasError = undefined;
17
+ this.started = false;
18
+ this.returned = returned;
19
+ }
20
+
21
+ /**
22
+ * Implements async iterable protocol
23
+ */
24
+ [Symbol.asyncIterator]() {
25
+ if (this.started) {
26
+ throw new Error('Stream can only be iterated once');
27
+ }
28
+ this.started = true;
29
+ return this;
30
+ }
31
+
32
+ /**
33
+ * Gets the next value from the stream
34
+ */
35
+ async next() {
36
+ // Return queued items first
37
+ if (this.queue.length > 0) {
38
+ return Promise.resolve({
39
+ done: false,
40
+ value: this.queue.shift()
41
+ });
42
+ }
43
+
44
+ // Check terminal states
45
+ if (this.isDone) {
46
+ return Promise.resolve({ done: true, value: undefined });
47
+ }
48
+
49
+ if (this.hasError) {
50
+ return Promise.reject(this.hasError);
51
+ }
52
+
53
+ // Wait for new data
54
+ return new Promise((resolve, reject) => {
55
+ this.readResolve = resolve;
56
+ this.readReject = reject;
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Adds a value to the stream
62
+ */
63
+ enqueue(value) {
64
+ if (this.readResolve) {
65
+ // Direct delivery to waiting consumer
66
+ const resolve = this.readResolve;
67
+ this.readResolve = undefined;
68
+ this.readReject = undefined;
69
+ resolve({ done: false, value });
70
+ } else {
71
+ // Queue for later consumption
72
+ this.queue.push(value);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Marks the stream as complete
78
+ */
79
+ done() {
80
+ this.isDone = true;
81
+ if (this.readResolve) {
82
+ const resolve = this.readResolve;
83
+ this.readResolve = undefined;
84
+ this.readReject = undefined;
85
+ resolve({ done: true, value: undefined });
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Propagates an error through the stream
91
+ */
92
+ error(error) {
93
+ this.hasError = error;
94
+ if (this.readReject) {
95
+ const reject = this.readReject;
96
+ this.readResolve = undefined;
97
+ this.readReject = undefined;
98
+ reject(error);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Implements async iterator cleanup
104
+ */
105
+ async return() {
106
+ this.isDone = true;
107
+ if (this.returned) {
108
+ this.returned();
109
+ }
110
+ return Promise.resolve({ done: true, value: undefined });
111
+ }
112
+ }
package/sdk/types.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error classes for Claude Code SDK integration
3
+ */
4
+
5
+ /**
6
+ * Abort error for cancelled operations
7
+ */
8
+ export class AbortError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'AbortError';
12
+ }
13
+ }
package/sdk/utils.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Utility functions for Claude Code SDK integration
3
+ * Path resolution, environment setup, and platform compatibility
4
+ */
5
+
6
+ import { platform, homedir } from 'os';
7
+ import { join, dirname } from 'path';
8
+ import { existsSync, readFileSync } from 'fs';
9
+ import { execSync } from 'child_process';
10
+
11
+ /**
12
+ * Log debug message
13
+ */
14
+ export function logDebug(message) {
15
+ if (process.env.DEBUG) {
16
+ console.log('[SDK Debug]', message);
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Build the full PATH string with common bin directories included.
22
+ * Used by both getDefaultClaudeCodePath() and getCleanEnv().
23
+ */
24
+ function getEnhancedPath() {
25
+ if (isWindows()) {
26
+ const systemPaths = [
27
+ 'C:\\Windows\\system32',
28
+ 'C:\\Windows',
29
+ 'C:\\Windows\\System32\\Wbem'
30
+ ];
31
+ const currentPath = process.env.PATH || process.env.Path || '';
32
+ const pathParts = currentPath.split(';').filter(p => p);
33
+ for (const sp of systemPaths) {
34
+ if (!pathParts.some(p => p.toLowerCase() === sp.toLowerCase())) {
35
+ pathParts.push(sp);
36
+ }
37
+ }
38
+ return pathParts.join(';');
39
+ } else {
40
+ const unixPaths = [
41
+ '/usr/local/bin',
42
+ '/usr/bin',
43
+ '/bin',
44
+ '/usr/sbin',
45
+ join(homedir(), '.local', 'bin'),
46
+ join(homedir(), '.npm-global', 'bin'),
47
+ ];
48
+ if (platform() === 'darwin') {
49
+ unixPaths.push('/opt/homebrew/bin');
50
+ }
51
+ // Include the directory where the current node binary lives
52
+ // This catches nvm/fnm/volta managed node installs and their global bins
53
+ const nodeBinDir = join(process.execPath, '..');
54
+ unixPaths.push(nodeBinDir);
55
+
56
+ const currentPath = process.env.PATH || '';
57
+ const pathParts = currentPath.split(':').filter(p => p);
58
+ for (const sp of unixPaths) {
59
+ if (!pathParts.includes(sp)) {
60
+ pathParts.push(sp);
61
+ }
62
+ }
63
+ return pathParts.join(':');
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get default path to Claude Code executable
69
+ * Tries CLAUDE_PATH env var first, then checks common install locations,
70
+ * then auto-discovers via which/where with enhanced PATH.
71
+ */
72
+ export function getDefaultClaudeCodePath() {
73
+ if (process.env.CLAUDE_PATH) return process.env.CLAUDE_PATH;
74
+
75
+ // Check common locations first (fast, no subprocess)
76
+ if (!isWindows()) {
77
+ const candidates = [
78
+ '/usr/local/bin/claude',
79
+ join(homedir(), '.local', 'bin', 'claude'),
80
+ join(homedir(), '.npm-global', 'bin', 'claude'),
81
+ // nvm/fnm/volta: claude installed globally lives next to node
82
+ join(process.execPath, '..', 'claude'),
83
+ ];
84
+ if (platform() === 'darwin') {
85
+ candidates.push('/opt/homebrew/bin/claude');
86
+ }
87
+ for (const c of candidates) {
88
+ if (existsSync(c)) return c;
89
+ }
90
+ }
91
+
92
+ // Try which/where with enhanced PATH (catches nvm, custom installs, etc.)
93
+ try {
94
+ const enhancedPath = getEnhancedPath();
95
+ const cmd = isWindows() ? 'where claude' : 'which claude';
96
+ const output = execSync(cmd, {
97
+ stdio: ['pipe', 'pipe', 'pipe'],
98
+ timeout: 5000,
99
+ env: { ...process.env, PATH: enhancedPath }
100
+ }).toString().trim();
101
+ const lines = output.split('\n').map(l => l.trim()).filter(Boolean);
102
+
103
+ if (isWindows() && lines.length > 1) {
104
+ // On Windows, `where` may return multiple matches. Prefer .cmd/.exe over
105
+ // the extensionless Unix shell script that npm also creates.
106
+ const preferred = lines.find(l => /\.(cmd|exe)$/i.test(l));
107
+ if (preferred) return preferred;
108
+ }
109
+
110
+ if (lines[0]) return lines[0];
111
+ } catch {}
112
+
113
+ // Fallback: bare command, hope it's on PATH
114
+ return 'claude';
115
+ }
116
+
117
+ /**
118
+ * Create a clean environment
119
+ * Ensures necessary environment variables and PATH entries are present
120
+ */
121
+ export function getCleanEnv() {
122
+ const env = { ...process.env };
123
+
124
+ if (isWindows()) {
125
+ if (!env.COMSPEC) {
126
+ env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe';
127
+ }
128
+ if (!env.SystemRoot) {
129
+ env.SystemRoot = 'C:\\Windows';
130
+ }
131
+ }
132
+
133
+ env.PATH = getEnhancedPath();
134
+ return env;
135
+ }
136
+
137
+ /**
138
+ * Stream async messages to stdin
139
+ */
140
+ export async function streamToStdin(stream, stdin, abort) {
141
+ for await (const message of stream) {
142
+ if (abort?.aborted) break;
143
+ stdin.write(JSON.stringify(message) + '\n');
144
+ }
145
+ stdin.end();
146
+ }
147
+
148
+ /**
149
+ * Check if running on Windows
150
+ */
151
+ export function isWindows() {
152
+ return platform() === 'win32';
153
+ }
154
+
155
+ /**
156
+ * Resolve Claude executable into { command, prefixArgs, spawnOpts } for spawn().
157
+ * On Windows (npm install): parses .cmd wrapper to find cli.js, then calls node directly.
158
+ * This avoids cmd.exe flash and PowerShell script execution policy issues.
159
+ */
160
+ export function resolveClaudeCommand() {
161
+ const execPath = getDefaultClaudeCodePath();
162
+
163
+ if (isWindows() && execPath.toLowerCase().endsWith('.cmd')) {
164
+ // npm 生成的 .cmd 内容固定格式,核心行是:
165
+ // "%_prog%" "%dp0%\node_modules\@anthropic-ai\claude-code\cli.js" %*
166
+ // 解析出 cli.js 的相对路径,拼成绝对路径后用 node 直接调用
167
+ try {
168
+ const cmdContent = readFileSync(execPath, 'utf-8');
169
+ const match = cmdContent.match(/%dp0%\\(.+?\.js)"/i) ||
170
+ cmdContent.match(/%dp0%\\(.+?\.js)/i);
171
+ if (match) {
172
+ const cliJsPath = join(dirname(execPath), match[1]);
173
+ if (existsSync(cliJsPath)) {
174
+ return {
175
+ command: process.execPath, // node
176
+ prefixArgs: [cliJsPath],
177
+ spawnOpts: {},
178
+ };
179
+ }
180
+ }
181
+ } catch {}
182
+ // 解析失败时 fallback: 用 powershell 执行 .ps1
183
+ const ps1Path = execPath.slice(0, -4) + '.ps1';
184
+ if (existsSync(ps1Path)) {
185
+ return {
186
+ command: 'powershell.exe',
187
+ prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1Path],
188
+ spawnOpts: {},
189
+ };
190
+ }
191
+ }
192
+
193
+ return { command: execPath, prefixArgs: [], spawnOpts: {} };
194
+ }