@exreve/exk 1.0.37 → 1.0.39

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.
@@ -101,9 +101,17 @@ function extractToolName(toolResult) {
101
101
  }
102
102
  if (toolResult.stdout !== undefined || toolResult.stderr !== undefined)
103
103
  return 'Bash';
104
- // SDK 0.2.x: content-only results from nested tool calls (no stdout/stderr wrapper)
105
- if (toolResult.content && typeof toolResult.content === 'string' && toolResult.type === 'text')
104
+ // send_file tool: content is JSON with _type marker detect before Bash fallback
105
+ if (toolResult.content && typeof toolResult.content === 'string' && toolResult.type === 'text') {
106
+ try {
107
+ const parsed = JSON.parse(toolResult.content);
108
+ if (parsed._type === 'send_file')
109
+ return 'send_file';
110
+ }
111
+ catch { /* not JSON, fall through */ }
112
+ // SDK 0.2.x: content-only results from nested tool calls (no stdout/stderr wrapper)
106
113
  return 'Bash';
114
+ }
107
115
  return 'unknown';
108
116
  }
109
117
  // Look up tool name from the most recent assistant message's tool_use blocks by tool_use_id
package/dist/index.js CHANGED
@@ -1545,17 +1545,17 @@ async function runDaemon(foreground = false, email) {
1545
1545
  console.log(`[CLI] Emitted error app:control:response`);
1546
1546
  }
1547
1547
  });
1548
- // Handle folder transfer — this device is the source (sender)
1549
- socket.on('transfer:send', (data) => {
1548
+ // Handle folder transfer — this device is the source (pack & upload)
1549
+ socket.on('transfer:pack', (data) => {
1550
1550
  if (foreground) {
1551
- console.log(`[transfer] Send request: ${data.sourcePath} → device ${data.destDeviceId.slice(0, 8)}`);
1551
+ console.log(`[transfer] Pack request: ${data.sourcePath} (${data.selectedItems?.length || 0} items) → device ${data.destDeviceId.slice(0, 8)}`);
1552
1552
  }
1553
1553
  startSending(socket, data, foreground);
1554
1554
  });
1555
- // Handle folder transfer — this device is the destination (receiver)
1556
- socket.on('transfer:receive', (data) => {
1555
+ // Handle folder transfer — this device is the destination (download & extract)
1556
+ socket.on('transfer:pull', (data) => {
1557
1557
  if (foreground) {
1558
- console.log(`[transfer] Receive request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath}`);
1558
+ console.log(`[transfer] Pull request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath} (${(data.totalBytes / (1024 * 1024)).toFixed(1)} MB)`);
1559
1559
  }
1560
1560
  startReceiving(socket, data, foreground);
1561
1561
  });
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Module MCP Server
3
3
  *
4
- * Provides built-in tools like analyze_image for vision capabilities.
4
+ * Provides built-in tools like analyze_image for vision capabilities
5
+ * and send_file for displaying files to the user in chat.
5
6
  */
6
7
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
7
8
  import { z } from 'zod';
@@ -9,19 +10,54 @@ import * as fs from 'fs';
9
10
  import * as path from 'path';
10
11
  import * as os from 'os';
11
12
  import { getOpenrouterApiKey } from './agentSession.js';
13
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
14
+ /** Comprehensive MIME type map for file extension detection */
15
+ const MIME_MAP = {
16
+ // Images
17
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
18
+ gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
19
+ svg: 'image/svg+xml', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff',
20
+ avif: 'image/avif',
21
+ // Audio
22
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
23
+ m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac',
24
+ wma: 'audio/x-ms-wma', opus: 'audio/opus',
25
+ // Video
26
+ mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
27
+ avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv',
28
+ m4v: 'video/mp4', '3gp': 'video/3gpp',
29
+ // Documents
30
+ pdf: 'application/pdf',
31
+ // Text / Code
32
+ txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
33
+ json: 'application/json', xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml',
34
+ toml: 'text/plain', html: 'text/html', htm: 'text/html',
35
+ css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
36
+ js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
37
+ ts: 'text/typescript', tsx: 'text/typescript',
38
+ jsx: 'text/javascript', py: 'text/x-python', rs: 'text/x-rust',
39
+ go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
40
+ h: 'text/x-c', hpp: 'text/x-c++', rb: 'text/x-ruby', php: 'text/x-php',
41
+ sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
42
+ sql: 'text/x-sql', graphql: 'text/graphql', vue: 'text/x-vue',
43
+ svelte: 'text/x-svelte', dart: 'text/x-dart', swift: 'text/x-swift',
44
+ kt: 'text/x-kotlin', scala: 'text/x-scala', lua: 'text/x-lua',
45
+ r: 'text/x-r', dockerfile: 'text/x-dockerfile',
46
+ };
12
47
  /**
13
- * Convert a file to a data URI for vision API consumption
48
+ * Get MIME type from file extension
49
+ */
50
+ function getMimeType(filePath) {
51
+ const ext = path.extname(filePath).toLowerCase().replace('.', '');
52
+ return MIME_MAP[ext] || 'application/octet-stream';
53
+ }
54
+ /**
55
+ * Convert a file to a data URI (base64 encoded)
14
56
  */
15
57
  function fileToDataUri(filePath) {
16
58
  try {
17
59
  const buf = fs.readFileSync(filePath);
18
- const ext = path.extname(filePath).toLowerCase().replace('.', '');
19
- const mimeMap = {
20
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
21
- gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
22
- svg: 'image/svg+xml',
23
- };
24
- const mime = mimeMap[ext] || 'application/octet-stream';
60
+ const mime = getMimeType(filePath);
25
61
  return `data:${mime};base64,${buf.toString('base64')}`;
26
62
  }
27
63
  catch {
@@ -78,12 +114,76 @@ function createAnalyzeImageTool(attachmentDir) {
78
114
  });
79
115
  }
80
116
  /**
81
- * Create the MCP server with built-in tools (always includes analyze_image)
117
+ * Create the send_file tool for displaying files to the user in chat.
118
+ * Supports images, audio, video, PDFs, code, and other files.
119
+ */
120
+ function createSendFileTool(attachmentDir) {
121
+ const workDir = attachmentDir || os.tmpdir();
122
+ return tool('send_file', 'Send a file to the user for display in chat. Supports images (shown inline), audio/video (with player), PDFs, code files (syntax highlighted), and other files (download link). Use file_path for local files or data for base64-encoded content.', {
123
+ file_path: z.string().optional().describe('Path to a local file on this device (absolute or relative to project directory)'),
124
+ data: z.string().optional().describe('Base64-encoded file content (without data: prefix)'),
125
+ mime_type: z.string().optional().describe('MIME type of the file (required when using data, auto-detected from file_path)'),
126
+ filename: z.string().optional().describe('Display name for the file (auto-detected from file_path)'),
127
+ }, async (args) => {
128
+ try {
129
+ let dataUri;
130
+ let mimeType;
131
+ let fileName;
132
+ let fileSize;
133
+ if (args.file_path) {
134
+ // Read from local file
135
+ const filePath = path.resolve(workDir, args.file_path);
136
+ if (!fs.existsSync(filePath)) {
137
+ return { content: [{ type: 'text', text: `Error: File not found: ${args.file_path}` }], isError: true };
138
+ }
139
+ const stat = fs.statSync(filePath);
140
+ fileSize = stat.size;
141
+ if (fileSize > MAX_FILE_SIZE) {
142
+ return { content: [{ type: 'text', text: `Error: File too large (${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
143
+ }
144
+ const buf = fs.readFileSync(filePath);
145
+ mimeType = args.mime_type || getMimeType(filePath);
146
+ fileName = args.filename || path.basename(filePath);
147
+ dataUri = `data:${mimeType};base64,${buf.toString('base64')}`;
148
+ }
149
+ else if (args.data) {
150
+ // Use provided base64 data
151
+ mimeType = args.mime_type || 'application/octet-stream';
152
+ fileName = args.filename || 'file';
153
+ const rawBase64 = args.data.replace(/^data:[^;]+;base64,/, '');
154
+ fileSize = Math.floor(rawBase64.length * 0.75);
155
+ if (fileSize > MAX_FILE_SIZE) {
156
+ return { content: [{ type: 'text', text: `Error: Data too large (~${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
157
+ }
158
+ dataUri = `data:${mimeType};base64,${rawBase64}`;
159
+ }
160
+ else {
161
+ return { content: [{ type: 'text', text: 'Error: Either file_path or data must be provided.' }], isError: true };
162
+ }
163
+ // Return structured result that the frontend will detect
164
+ const result = JSON.stringify({
165
+ _type: 'send_file',
166
+ data: dataUri,
167
+ mime_type: mimeType,
168
+ filename: fileName,
169
+ size: fileSize,
170
+ });
171
+ return { content: [{ type: 'text', text: result }] };
172
+ }
173
+ catch (error) {
174
+ return { content: [{ type: 'text', text: `Error sending file: ${error.message}` }], isError: true };
175
+ }
176
+ });
177
+ }
178
+ /**
179
+ * Create the MCP server with built-in tools
82
180
  */
83
181
  export function createModuleMcpServer(config) {
84
182
  const tools = [];
85
183
  // Always add analyze_image tool (uses OpenRouter key from ai-config via backend)
86
184
  tools.push(createAnalyzeImageTool(config.attachmentDir));
185
+ // Add send_file tool for displaying files to the user in chat
186
+ tools.push(createSendFileTool(config.attachmentDir));
87
187
  const server = createSdkMcpServer({
88
188
  name: 'claude-voice-modules',
89
189
  version: '1.0.0',
@@ -4,26 +4,30 @@ import fsSync from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { createHash } from 'crypto';
7
+ import { Readable, Transform } from 'stream';
7
8
  // ============ Transfer Service (CLI side) ============
8
- // Handles both sending and receiving folders via tar streaming.
9
- // Binary chunks flow over Socket.IO no HTTP endpoints needed.
10
- const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
9
+ // Two-phase transfer via HTTP:
10
+ // Phase 1 (source): tar selected items HTTP POST to backend
11
+ // Phase 2 (dest): HTTP GET from backend tar extract
12
+ // Socket.IO carries control events + progress/log.
11
13
  // ---- Sender ----
12
14
  export function startSending(socket, transfer, foreground) {
13
- const { transferId, sourcePath } = transfer;
15
+ const { transferId, sourcePath, selectedItems } = transfer;
14
16
  const startTime = Date.now();
17
+ const apiUrl = getApiUrl(socket);
15
18
  const log = (message) => {
16
19
  if (foreground)
17
20
  console.log(`[transfer:send] ${message}`);
18
21
  socket.emit('transfer:log', { transferId, side: 'source', level: 'info', message });
19
22
  };
20
- const sendProgress = (bytesTransferred, totalBytes) => {
23
+ const sendProgress = (phase, bytesTransferred, totalBytes) => {
21
24
  const elapsed = (Date.now() - startTime) / 1000;
22
25
  const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
23
26
  const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
24
27
  socket.emit('transfer:progress', {
25
28
  transferId,
26
29
  side: 'source',
30
+ phase,
27
31
  bytesTransferred,
28
32
  totalBytes,
29
33
  speed: Math.round(speed),
@@ -34,55 +38,41 @@ export function startSending(socket, transfer, foreground) {
34
38
  console.error(`[transfer:send] Error: ${message}`);
35
39
  socket.emit('transfer:error', { transferId, side: 'source', message });
36
40
  };
37
- log(`Starting to tar and send: ${sourcePath}`);
38
- // Estimate folder size first with a quick du
41
+ const items = selectedItems || ['.'];
42
+ log(`Packing ${items.length} item(s) from ${sourcePath}`);
43
+ // Estimate size first
39
44
  let totalBytes = 0;
40
- try {
41
- const duResult = spawn('du', ['-sb', sourcePath], { timeout: 30000 });
42
- let duOutput = '';
43
- duResult.stdout.on('data', (d) => { duOutput += d.toString(); });
44
- duResult.on('close', (code) => {
45
- if (code === 0) {
46
- const match = duOutput.match(/^(\d+)/);
45
+ const sizeEstimate = spawn('du', ['-sb', ...items.map(i => path.join(sourcePath, i))], { cwd: sourcePath });
46
+ let duOutput = '';
47
+ sizeEstimate.stdout.on('data', (d) => { duOutput += d.toString(); });
48
+ sizeEstimate.on('close', (code) => {
49
+ if (code === 0) {
50
+ const lines = duOutput.trim().split('\n');
51
+ for (const line of lines) {
52
+ const match = line.match(/^(\d+)/);
47
53
  if (match)
48
- totalBytes = parseInt(match[1], 10);
54
+ totalBytes += parseInt(match[1], 10);
49
55
  }
50
- log(`Estimated size: ${formatBytes(totalBytes)}`);
51
- doSend();
52
- });
53
- duResult.on('error', () => doSend());
54
- }
55
- catch {
56
- doSend();
57
- }
58
- function doSend() {
59
- // Spawn tar to stream the folder
60
- const tar = spawn('tar', ['cf', '-', '-C', sourcePath, '.']);
56
+ }
57
+ log(`Estimated size: ${formatBytes(totalBytes)}`);
58
+ doPackAndUpload();
59
+ });
60
+ sizeEstimate.on('error', () => doPackAndUpload());
61
+ function doPackAndUpload() {
62
+ // Create tar with selected items
63
+ const tarArgs = ['cf', '-', '-C', sourcePath, ...items];
64
+ const tar = spawn('tar', tarArgs);
61
65
  const hash = createHash('sha256');
62
66
  let bytesTransferred = 0;
63
- let seq = 0;
64
67
  let lastProgressTime = Date.now();
65
- tar.stdout.on('data', (chunk) => {
66
- hash.update(chunk);
67
- bytesTransferred += chunk.length;
68
- // Emit binary chunk
69
- socket.emit('transfer:chunk', {
70
- transferId,
71
- seq,
72
- data: chunk,
73
- size: chunk.length,
74
- });
75
- seq++;
76
- // Throttle progress updates to every 500ms
77
- if (Date.now() - lastProgressTime > 500) {
78
- sendProgress(bytesTransferred, totalBytes || bytesTransferred);
79
- lastProgressTime = Date.now();
80
- }
81
- });
68
+ let fileCount = 0;
69
+ // Count files as we go (approximate from tar output)
82
70
  tar.stderr.on('data', (data) => {
83
71
  const msg = data.toString().trim();
84
72
  if (msg)
85
73
  log(`tar: ${msg}`);
74
+ // Count lines in tar stderr as approximate file count
75
+ fileCount += msg.split('\n').length;
86
76
  });
87
77
  tar.on('error', (err) => {
88
78
  sendError(`tar process error: ${err.message}`);
@@ -92,19 +82,53 @@ export function startSending(socket, transfer, foreground) {
92
82
  sendError(`tar exited with code ${code}`);
93
83
  return;
94
84
  }
95
- const duration = Date.now() - startTime;
85
+ log(`Packing complete. Final size: ${formatBytes(bytesTransferred)}`);
86
+ });
87
+ // Stream tar output through hash, then HTTP POST
88
+ const uploadUrl = `${apiUrl}/transfer/${transferId}/upload`;
89
+ // Pipe tar stdout → hash transform → HTTP upload
90
+ const hashTransform = new Transform({
91
+ transform(chunk, _encoding, callback) {
92
+ hash.update(chunk);
93
+ bytesTransferred += chunk.length;
94
+ if (Date.now() - lastProgressTime > 500) {
95
+ sendProgress('uploading', bytesTransferred, totalBytes || bytesTransferred);
96
+ lastProgressTime = Date.now();
97
+ }
98
+ this.push(chunk);
99
+ callback();
100
+ },
101
+ });
102
+ // Use fetch with streaming body for the upload
103
+ const tarStream = tar.stdout.pipe(hashTransform);
104
+ // Node 18+ supports ReadableStream in fetch body
105
+ const nodeStream = Readable.toWeb(tarStream);
106
+ fetch(uploadUrl, {
107
+ method: 'POST',
108
+ body: nodeStream,
109
+ headers: {
110
+ 'Content-Type': 'application/x-tar',
111
+ 'Transfer-Encoding': 'chunked',
112
+ },
113
+ duplex: 'half',
114
+ }).then(async (res) => {
115
+ if (!res.ok) {
116
+ const text = await res.text();
117
+ sendError(`Upload failed: HTTP ${res.status} ${text}`);
118
+ return;
119
+ }
96
120
  const sha256 = hash.digest('hex');
97
- // Final progress
98
- sendProgress(bytesTransferred, bytesTransferred);
99
- // We don't send transfer:complete here — the dest does that after verification
100
- // Instead, we send a source:done so the dest knows the stream is over
101
- socket.emit('transfer:source:done', {
121
+ const duration = Date.now() - startTime;
122
+ // Notify backend that upload is done (only after HTTP confirms success)
123
+ socket.emit('transfer:uploaded', {
102
124
  transferId,
103
125
  totalBytes: bytesTransferred,
104
126
  sha256,
105
- duration,
127
+ fileCount,
106
128
  });
107
- log(`Sent ${formatBytes(bytesTransferred)} in ${(duration / 1000).toFixed(1)}s (${seq} chunks)`);
129
+ log(`Upload complete: ${formatBytes(bytesTransferred)} in ${(duration / 1000).toFixed(1)}s (sha256: ${sha256.slice(0, 12)}...)`);
130
+ }).catch((err) => {
131
+ sendError(`Upload network error: ${err.message}`);
108
132
  });
109
133
  // Handle cancellation
110
134
  const cancelHandler = (data) => {
@@ -121,18 +145,22 @@ export function startSending(socket, transfer, foreground) {
121
145
  export function startReceiving(socket, transfer, foreground) {
122
146
  const { transferId, destPath } = transfer;
123
147
  const startTime = Date.now();
148
+ const apiUrl = getApiUrl(socket);
149
+ const expectedBytes = transfer.totalBytes || 0;
150
+ const expectedSha256 = transfer.sha256 || '';
124
151
  const log = (message) => {
125
152
  if (foreground)
126
153
  console.log(`[transfer:recv] ${message}`);
127
154
  socket.emit('transfer:log', { transferId, side: 'dest', level: 'info', message });
128
155
  };
129
- const sendProgress = (bytesTransferred, totalBytes) => {
156
+ const sendProgress = (phase, bytesTransferred, totalBytes) => {
130
157
  const elapsed = (Date.now() - startTime) / 1000;
131
158
  const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
132
159
  const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
133
160
  socket.emit('transfer:progress', {
134
161
  transferId,
135
162
  side: 'dest',
163
+ phase,
136
164
  bytesTransferred,
137
165
  totalBytes,
138
166
  speed: Math.round(speed),
@@ -144,103 +172,109 @@ export function startReceiving(socket, transfer, foreground) {
144
172
  socket.emit('transfer:error', { transferId, side: 'dest', message });
145
173
  };
146
174
  const tmpFile = path.join(os.tmpdir(), `ttc-transfer-${transferId}.tar`);
147
- log(`Ready to receive into: ${destPath}`);
148
- // Ensure dest directory exists
149
- mkdir(destPath, { recursive: true }).then(() => {
150
- const writeStream = fsSync.createWriteStream(tmpFile);
151
- const hash = createHash('sha256');
152
- let bytesReceived = 0;
153
- let totalExpected = 0;
154
- let lastProgressTime = Date.now();
155
- let sourceSha256 = '';
156
- // Handle incoming chunks
157
- const chunkHandler = (data) => {
158
- if (data.transferId !== transferId)
175
+ log(`Downloading from backend...`);
176
+ mkdir(destPath, { recursive: true }).then(async () => {
177
+ try {
178
+ const downloadUrl = `${apiUrl}/transfer/${transferId}/download`;
179
+ const res = await fetch(downloadUrl);
180
+ if (!res.ok) {
181
+ sendError(`Download failed: HTTP ${res.status}`);
159
182
  return;
160
- const chunk = Buffer.isBuffer(data.data) ? data.data : Buffer.from(data.data);
161
- writeStream.write(chunk);
162
- hash.update(chunk);
163
- bytesReceived += chunk.length;
164
- if (Date.now() - lastProgressTime > 500) {
165
- sendProgress(bytesReceived, totalExpected || bytesReceived);
166
- lastProgressTime = Date.now();
167
183
  }
168
- };
169
- socket.on('transfer:chunk', chunkHandler);
170
- // Handle source:done — source finished sending
171
- const sourceDoneHandler = (data) => {
172
- if (data.transferId !== transferId)
184
+ if (!res.body) {
185
+ sendError('Download failed: no response body');
173
186
  return;
174
- totalExpected = data.totalBytes;
175
- sourceSha256 = data.sha256;
176
- log(`Source finished. Received ${formatBytes(bytesReceived)} / ${formatBytes(totalExpected)}`);
177
- // Finish writing and verify
178
- writeStream.end(async () => {
179
- try {
180
- const receivedHash = hash.digest('hex');
181
- // Verify hash
182
- if (receivedHash !== sourceSha256) {
183
- sendError(`Hash mismatch! Expected ${sourceSha256.slice(0, 16)}... got ${receivedHash.slice(0, 16)}...`);
184
- try {
185
- await unlink(tmpFile);
186
- }
187
- catch { }
188
- return;
189
- }
190
- log(`Hash verified ✓. Extracting to ${destPath}...`);
191
- // Extract tar
192
- const extractProc = spawn('tar', ['xf', tmpFile, '-C', destPath]);
193
- extractProc.on('close', async (code) => {
194
- // Cleanup temp file
195
- try {
196
- await unlink(tmpFile);
197
- }
198
- catch { }
199
- if (code !== 0) {
200
- sendError(`tar extraction failed with code ${code}`);
201
- return;
202
- }
203
- const duration = Date.now() - startTime;
204
- // Notify completion
205
- socket.emit('transfer:complete', {
206
- transferId,
207
- totalBytes: bytesReceived,
208
- sha256: receivedHash,
209
- duration,
210
- });
211
- log(`Complete! ${formatBytes(bytesReceived)} extracted to ${destPath} in ${(duration / 1000).toFixed(1)}s`);
212
- // Cleanup listeners
213
- socket.off('transfer:chunk', chunkHandler);
214
- socket.off('transfer:source:done', sourceDoneHandler);
215
- });
216
- extractProc.on('error', (err) => {
217
- sendError(`tar extraction error: ${err.message}`);
218
- });
187
+ }
188
+ // Stream download to temp file while hashing
189
+ const writeStream = fsSync.createWriteStream(tmpFile);
190
+ const hash = createHash('sha256');
191
+ let bytesReceived = 0;
192
+ let lastProgressTime = Date.now();
193
+ const reader = res.body.getReader();
194
+ // Read chunks from the response stream
195
+ while (true) {
196
+ const { done, value } = await reader.read();
197
+ if (done)
198
+ break;
199
+ writeStream.write(value);
200
+ hash.update(value);
201
+ bytesReceived += value.length;
202
+ if (Date.now() - lastProgressTime > 500) {
203
+ sendProgress('downloading', bytesReceived, expectedBytes || bytesReceived);
204
+ lastProgressTime = Date.now();
219
205
  }
220
- catch (err) {
221
- sendError(`Post-processing error: ${err.message}`);
206
+ }
207
+ writeStream.end();
208
+ log(`Downloaded ${formatBytes(bytesReceived)}. Verifying...`);
209
+ // Verify hash
210
+ const receivedHash = hash.digest('hex');
211
+ if (expectedSha256 && receivedHash !== expectedSha256) {
212
+ sendError(`Hash mismatch! Expected ${expectedSha256.slice(0, 16)}... got ${receivedHash.slice(0, 16)}...`);
213
+ try {
214
+ await unlink(tmpFile);
222
215
  }
216
+ catch { }
217
+ return;
218
+ }
219
+ log(`Hash verified ✓. Extracting to ${destPath}...`);
220
+ sendProgress('extracting', bytesReceived, bytesReceived);
221
+ // Extract tar
222
+ const extractProc = spawn('tar', ['xf', tmpFile, '-C', destPath]);
223
+ await new Promise((resolve, reject) => {
224
+ extractProc.on('close', (code) => {
225
+ if (code !== 0)
226
+ reject(new Error(`tar extraction failed with code ${code}`));
227
+ else
228
+ resolve();
229
+ });
230
+ extractProc.on('error', reject);
223
231
  });
224
- };
225
- socket.on('transfer:source:done', sourceDoneHandler);
226
- // Handle cancellation
227
- const cancelHandler = (data) => {
228
- if (data.transferId === transferId) {
229
- writeStream.destroy();
230
- socket.off('transfer:chunk', chunkHandler);
231
- socket.off('transfer:source:done', sourceDoneHandler);
232
- socket.off('transfer:cancel', cancelHandler);
233
- log('Transfer cancelled');
234
- // Cleanup temp file
235
- unlink(tmpFile).catch(() => { });
232
+ // Cleanup temp file
233
+ try {
234
+ await unlink(tmpFile);
236
235
  }
237
- };
238
- socket.on('transfer:cancel', cancelHandler);
236
+ catch { }
237
+ const duration = Date.now() - startTime;
238
+ socket.emit('transfer:complete', {
239
+ transferId,
240
+ totalBytes: bytesReceived,
241
+ sha256: receivedHash,
242
+ duration,
243
+ fileCount: 0,
244
+ });
245
+ log(`Complete! ${formatBytes(bytesReceived)} extracted to ${destPath} in ${(duration / 1000).toFixed(1)}s`);
246
+ }
247
+ catch (err) {
248
+ sendError(`Download/extract error: ${err.message}`);
249
+ try {
250
+ await unlink(tmpFile);
251
+ }
252
+ catch { }
253
+ }
239
254
  }).catch((err) => {
240
255
  sendError(`Failed to create destination directory: ${err.message}`);
241
256
  });
242
257
  }
243
258
  // ---- Helpers ----
259
+ function getApiUrl(socket) {
260
+ // Extract the base URL from the socket connection
261
+ const io = socket.io;
262
+ const opts = io.opts;
263
+ if (opts?.hostname) {
264
+ const protocol = opts.secure !== false ? 'https' : 'http';
265
+ const port = opts.port ? `:${opts.port}` : '';
266
+ return `${protocol}://${opts.hostname}${port}`;
267
+ }
268
+ // Fallback: read from config
269
+ try {
270
+ const configPath = path.join(os.homedir(), '.talk-to-code', 'config.json');
271
+ const config = JSON.parse(fsSync.readFileSync(configPath, 'utf-8'));
272
+ return config.apiUrl || 'https://api.talk-to-code.com';
273
+ }
274
+ catch {
275
+ return 'https://api.talk-to-code.com';
276
+ }
277
+ }
244
278
  function formatBytes(bytes) {
245
279
  if (bytes === 0)
246
280
  return '0 B';
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exreve/exk",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "description": "exk - Control Claude CLI with voice and programmable interfaces",
5
5
  "type": "module",
6
6
  "bin": {