tuna-agent 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +93 -0
  2. package/dist/__tests__/need-input-flow.test.d.ts +11 -0
  3. package/dist/__tests__/need-input-flow.test.js +646 -0
  4. package/dist/agents/claude-code-adapter.d.ts +13 -0
  5. package/dist/agents/claude-code-adapter.js +613 -0
  6. package/dist/agents/factory.d.ts +2 -0
  7. package/dist/agents/factory.js +12 -0
  8. package/dist/agents/openclaw-adapter.d.ts +18 -0
  9. package/dist/agents/openclaw-adapter.js +217 -0
  10. package/dist/agents/types.d.ts +31 -0
  11. package/dist/agents/types.js +1 -0
  12. package/dist/cli/commands/connect.d.ts +8 -0
  13. package/dist/cli/commands/connect.js +163 -0
  14. package/dist/cli/commands/start.d.ts +5 -0
  15. package/dist/cli/commands/start.js +54 -0
  16. package/dist/cli/commands/status.d.ts +1 -0
  17. package/dist/cli/commands/status.js +21 -0
  18. package/dist/cli/commands/stop.d.ts +1 -0
  19. package/dist/cli/commands/stop.js +23 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +32 -0
  22. package/dist/config/store.d.ts +29 -0
  23. package/dist/config/store.js +94 -0
  24. package/dist/daemon/index.d.ts +6 -0
  25. package/dist/daemon/index.js +576 -0
  26. package/dist/daemon/pm-state.d.ts +16 -0
  27. package/dist/daemon/pm-state.js +37 -0
  28. package/dist/daemon/ws-client.d.ts +107 -0
  29. package/dist/daemon/ws-client.js +293 -0
  30. package/dist/executor/task-runner.d.ts +30 -0
  31. package/dist/executor/task-runner.js +638 -0
  32. package/dist/pm/planner.d.ts +20 -0
  33. package/dist/pm/planner.js +375 -0
  34. package/dist/system/info.d.ts +18 -0
  35. package/dist/system/info.js +169 -0
  36. package/dist/types/index.d.ts +123 -0
  37. package/dist/types/index.js +1 -0
  38. package/dist/utils/claude-cli.d.ts +35 -0
  39. package/dist/utils/claude-cli.js +271 -0
  40. package/dist/utils/execution-helpers.d.ts +32 -0
  41. package/dist/utils/execution-helpers.js +177 -0
  42. package/dist/utils/image-download.d.ts +9 -0
  43. package/dist/utils/image-download.js +60 -0
  44. package/dist/utils/message-schemas.d.ts +69 -0
  45. package/dist/utils/message-schemas.js +80 -0
  46. package/dist/utils/pm-helpers.d.ts +5 -0
  47. package/dist/utils/pm-helpers.js +31 -0
  48. package/dist/utils/skill-scanner.d.ts +13 -0
  49. package/dist/utils/skill-scanner.js +91 -0
  50. package/dist/utils/validate-path.d.ts +10 -0
  51. package/dist/utils/validate-path.js +18 -0
  52. package/package.json +43 -0
@@ -0,0 +1,35 @@
1
+ export interface ClaudeCliOptions {
2
+ prompt: string;
3
+ cwd: string;
4
+ allowedTools?: string[];
5
+ disallowedTools?: string[];
6
+ systemPrompt?: string;
7
+ resumeSessionId?: string;
8
+ maxTurns?: number;
9
+ outputFormat?: 'json' | 'stream-json' | 'text';
10
+ onStreamLine?: (data: Record<string, unknown>) => void;
11
+ includePartialMessages?: boolean;
12
+ /** Skip MCP servers, built-in tools, and skills for faster startup */
13
+ lightweight?: boolean;
14
+ agentTeam?: boolean;
15
+ timeoutMs?: number;
16
+ signal?: AbortSignal;
17
+ /** Permission mode for Claude Code CLI */
18
+ permissionMode?: 'default' | 'plan' | 'bypassPermissions';
19
+ /** Callback invoked when Claude Code asks for tool approval. Return true to allow, false to deny. */
20
+ onPermissionRequest?: (tool: string, detail: string) => Promise<boolean>;
21
+ /** Local file paths to pass as input (e.g., images) via --input-file flags */
22
+ inputFiles?: string[];
23
+ }
24
+ export interface ClaudeCliResult {
25
+ result: string;
26
+ sessionId?: string;
27
+ costUsd?: number;
28
+ durationMs?: number;
29
+ numTurns?: number;
30
+ isError: boolean;
31
+ }
32
+ /**
33
+ * Spawn a Claude Code CLI session (`claude -p`) and return the result.
34
+ */
35
+ export declare function runClaude(options: ClaudeCliOptions): Promise<ClaudeCliResult>;
@@ -0,0 +1,271 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import { StringDecoder } from 'string_decoder';
3
+ /** Cached absolute path to `claude` binary, resolved once at first use */
4
+ let _claudeBinPath = null;
5
+ function getClaudeBinPath() {
6
+ if (_claudeBinPath)
7
+ return _claudeBinPath;
8
+ // Search common paths where claude CLI may be installed
9
+ const searchPaths = [
10
+ process.env.PATH,
11
+ '/opt/homebrew/bin',
12
+ '/usr/local/bin',
13
+ `${process.env.HOME}/.local/bin`,
14
+ `${process.env.HOME}/.npm-global/bin`,
15
+ '/usr/bin',
16
+ ].filter(Boolean).join(':');
17
+ try {
18
+ _claudeBinPath = execSync('which claude', {
19
+ encoding: 'utf-8',
20
+ env: { ...process.env, PATH: searchPaths },
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ }).trim();
23
+ console.log(`[claude-cli] Resolved claude binary: ${_claudeBinPath}`);
24
+ return _claudeBinPath;
25
+ }
26
+ catch {
27
+ // Fallback to bare 'claude' and let spawn try PATH
28
+ console.warn('[claude-cli] Could not resolve claude binary path, falling back to "claude"');
29
+ return 'claude';
30
+ }
31
+ }
32
+ // Permission prompt detection pattern:
33
+ // Claude Code outputs tool name, detail, and a y/n prompt on stderr when in default permission mode
34
+ const PERMISSION_PROMPT_REGEX = /(?:Allow|Approve|Permission).*?\b(Edit|Write|Bash)\b.*?([^\n]+)\n.*?\(y\/n\)/is;
35
+ const PERMISSION_YN_REGEX = /\(y\/n\)\s*$/;
36
+ const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
37
+ /**
38
+ * Detect permission prompts from Claude Code stderr and respond via stdin.
39
+ */
40
+ function handlePermissionPrompt(text, proc, onPermissionRequest) {
41
+ // Only process if this looks like a permission prompt (contains y/n)
42
+ if (!PERMISSION_YN_REGEX.test(text))
43
+ return;
44
+ // Extract tool and detail from the prompt text
45
+ const match = text.match(PERMISSION_PROMPT_REGEX);
46
+ const tool = match?.[1] ?? 'unknown';
47
+ const detail = match?.[2]?.trim() ?? text.trim();
48
+ // Set up auto-deny timeout
49
+ let timedOut = false;
50
+ const timer = setTimeout(() => {
51
+ timedOut = true;
52
+ console.warn(`[claude-cli] Permission request timed out after 5 minutes, auto-denying`);
53
+ proc.stdin?.write('n\n');
54
+ }, PERMISSION_TIMEOUT_MS);
55
+ onPermissionRequest(tool, detail)
56
+ .then((approved) => {
57
+ if (timedOut)
58
+ return;
59
+ clearTimeout(timer);
60
+ proc.stdin?.write(approved ? 'y\n' : 'n\n');
61
+ console.log(`[claude-cli] Permission ${approved ? 'approved' : 'denied'} for ${tool}: ${detail.substring(0, 80)}`);
62
+ })
63
+ .catch((err) => {
64
+ if (timedOut)
65
+ return;
66
+ clearTimeout(timer);
67
+ console.error(`[claude-cli] Permission callback error, auto-denying:`, err);
68
+ proc.stdin?.write('n\n');
69
+ });
70
+ }
71
+ /**
72
+ * Spawn a Claude Code CLI session (`claude -p`) and return the result.
73
+ */
74
+ export function runClaude(options) {
75
+ return new Promise((resolve, reject) => {
76
+ const format = options.outputFormat ?? 'json';
77
+ // Append image file paths to prompt so Claude Code reads them via Read tool
78
+ let prompt = options.prompt;
79
+ if (options.inputFiles?.length) {
80
+ const fileList = options.inputFiles.map(f => `- ${f}`).join('\n');
81
+ prompt += `\n\n[User attached ${options.inputFiles.length} image(s). Read these files to see the images:]\n${fileList}`;
82
+ }
83
+ const args = ['-p', prompt, '--output-format', format];
84
+ if (options.allowedTools?.length) {
85
+ args.push('--allowedTools', options.allowedTools.join(','));
86
+ }
87
+ if (options.disallowedTools?.length) {
88
+ args.push('--disallowedTools', options.disallowedTools.join(','));
89
+ }
90
+ if (options.systemPrompt) {
91
+ args.push('--append-system-prompt', options.systemPrompt);
92
+ }
93
+ if (options.maxTurns) {
94
+ args.push('--max-turns', String(options.maxTurns));
95
+ }
96
+ if (options.resumeSessionId) {
97
+ args.push('--resume', options.resumeSessionId);
98
+ }
99
+ if (options.lightweight) {
100
+ args.push('--strict-mcp-config'); // skip all MCP servers
101
+ args.push('--tools', ''); // skip built-in tools
102
+ args.push('--disable-slash-commands'); // skip skills/plugins
103
+ }
104
+ if (options.permissionMode) {
105
+ args.push('--permission-mode', options.permissionMode);
106
+ }
107
+ if (format === 'stream-json') {
108
+ args.push('--verbose');
109
+ if (options.includePartialMessages) {
110
+ args.push('--include-partial-messages');
111
+ }
112
+ }
113
+ const useInteractiveStdin = !!options.permissionMode && !!options.onPermissionRequest;
114
+ const claudeBin = getClaudeBinPath();
115
+ // Ensure PATH includes common bin dirs so shebang `#!/usr/bin/env node` resolves
116
+ const spawnPath = [
117
+ process.env.PATH,
118
+ '/opt/homebrew/bin',
119
+ '/usr/local/bin',
120
+ `${process.env.HOME}/.local/bin`,
121
+ `${process.env.HOME}/.nvm/versions/node/v20.10.0/bin`,
122
+ ].filter(Boolean).join(':');
123
+ const proc = spawn(claudeBin, args, {
124
+ cwd: options.cwd,
125
+ stdio: [useInteractiveStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
126
+ env: {
127
+ ...process.env,
128
+ HOME: process.env.HOME || '',
129
+ PATH: spawnPath,
130
+ ...(options.agentTeam ? {
131
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
132
+ CLAUDE_CODE_TEAMMATE_MODE: 'in-process',
133
+ } : {}),
134
+ },
135
+ });
136
+ // 30-minute timeout by default
137
+ const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000;
138
+ const timeoutTimer = setTimeout(() => {
139
+ console.error(`[claude-cli] Process timed out after ${timeoutMs / 1000}s, killing`);
140
+ proc.kill('SIGTERM');
141
+ setTimeout(() => { if (!proc.killed)
142
+ proc.kill('SIGKILL'); }, 5000);
143
+ }, timeoutMs);
144
+ // Abort signal — kill process when task is cancelled
145
+ if (options.signal) {
146
+ const onAbort = () => {
147
+ console.log('[claude-cli] Task cancelled — killing process');
148
+ proc.kill('SIGTERM');
149
+ setTimeout(() => { if (!proc.killed)
150
+ proc.kill('SIGKILL'); }, 5000);
151
+ };
152
+ if (options.signal.aborted) {
153
+ onAbort();
154
+ }
155
+ else {
156
+ options.signal.addEventListener('abort', onAbort, { once: true });
157
+ proc.on('close', () => options.signal.removeEventListener('abort', onAbort));
158
+ }
159
+ }
160
+ let stdout = '';
161
+ let stderr = '';
162
+ let buffer = '';
163
+ const spawnTime = Date.now();
164
+ let firstStdoutTime = 0;
165
+ let firstStreamEventTime = 0;
166
+ // Use StringDecoder to properly handle multi-byte UTF-8 chars split across chunks
167
+ const stdoutDecoder = new StringDecoder('utf8');
168
+ const stderrDecoder = new StringDecoder('utf8');
169
+ proc.stdout.on('data', (chunk) => {
170
+ const text = stdoutDecoder.write(chunk);
171
+ stdout += text;
172
+ if (!firstStdoutTime) {
173
+ firstStdoutTime = Date.now();
174
+ console.log(`[claude-cli] ⏱ first stdout: ${firstStdoutTime - spawnTime}ms after spawn`);
175
+ }
176
+ if (options.onStreamLine && format === 'stream-json') {
177
+ buffer += text;
178
+ const lines = buffer.split('\n');
179
+ buffer = lines.pop() ?? '';
180
+ for (const line of lines) {
181
+ if (!line.trim())
182
+ continue;
183
+ try {
184
+ const parsed = JSON.parse(line);
185
+ if (!firstStreamEventTime && parsed.type === 'stream_event') {
186
+ firstStreamEventTime = Date.now();
187
+ const evt = parsed.event;
188
+ console.log(`[claude-cli] ⏱ first stream_event (${evt?.type}): ${firstStreamEventTime - spawnTime}ms after spawn`);
189
+ }
190
+ options.onStreamLine(parsed);
191
+ }
192
+ catch {
193
+ // skip non-JSON lines
194
+ }
195
+ }
196
+ }
197
+ });
198
+ proc.stderr.on('data', (chunk) => {
199
+ const text = stderrDecoder.write(chunk);
200
+ stderr += text;
201
+ // Detect permission prompts from Claude Code on stderr
202
+ if (useInteractiveStdin) {
203
+ handlePermissionPrompt(text, proc, options.onPermissionRequest);
204
+ }
205
+ });
206
+ proc.on('close', (code) => {
207
+ clearTimeout(timeoutTimer);
208
+ const totalMs = Date.now() - spawnTime;
209
+ console.log(`[claude-cli] ⏱ process closed: ${totalMs}ms total (first stdout: ${firstStdoutTime ? firstStdoutTime - spawnTime : '?'}ms, first stream: ${firstStreamEventTime ? firstStreamEventTime - spawnTime : 'none'}ms)`);
210
+ // Flush remaining buffer
211
+ if (options.onStreamLine && buffer.trim()) {
212
+ try {
213
+ options.onStreamLine(JSON.parse(buffer));
214
+ }
215
+ catch { /* skip */ }
216
+ }
217
+ if (code !== 0 && !stdout.trim()) {
218
+ const isTimeout = code === null || code === 143;
219
+ const errMsg = isTimeout
220
+ ? `Task timed out after ${timeoutMs / 1000}s`
221
+ : `claude exited with code ${code}: ${stderr}`;
222
+ reject(new Error(errMsg));
223
+ return;
224
+ }
225
+ try {
226
+ if (format === 'stream-json') {
227
+ const lines = stdout.trim().split('\n');
228
+ for (let i = lines.length - 1; i >= 0; i--) {
229
+ try {
230
+ const data = JSON.parse(lines[i]);
231
+ if (data.type === 'result') {
232
+ resolve({
233
+ result: data.result ?? '',
234
+ sessionId: data.session_id,
235
+ costUsd: data.cost_usd,
236
+ durationMs: data.duration_ms,
237
+ numTurns: data.num_turns,
238
+ isError: data.is_error ?? data.subtype !== 'success',
239
+ });
240
+ return;
241
+ }
242
+ }
243
+ catch { /* skip */ }
244
+ }
245
+ reject(new Error('No result message found in stream output'));
246
+ }
247
+ else {
248
+ const data = JSON.parse(stdout.trim());
249
+ resolve({
250
+ result: data.result ?? '',
251
+ sessionId: data.session_id,
252
+ costUsd: data.cost_usd,
253
+ durationMs: data.duration_ms,
254
+ numTurns: data.num_turns,
255
+ isError: data.is_error ?? false,
256
+ });
257
+ }
258
+ }
259
+ catch {
260
+ resolve({
261
+ result: stdout.trim(),
262
+ isError: code !== 0,
263
+ });
264
+ }
265
+ });
266
+ proc.on('error', (err) => {
267
+ clearTimeout(timeoutTimer);
268
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
269
+ });
270
+ });
271
+ }
@@ -0,0 +1,32 @@
1
+ import type { AgentWebSocketClient } from '../daemon/ws-client.js';
2
+ import type { TaskAssignment, TaskPlan, SessionInfo, ExecutionCallbacks, InputResponse } from '../types/index.js';
3
+ /**
4
+ * Strip ALL markdown formatting → plain text (for session result).
5
+ */
6
+ export declare function stripMarkdown(text: string): string;
7
+ /**
8
+ * Simplify markdown for mobile (keep bold, italic, lists, tables - simplify code blocks).
9
+ */
10
+ export declare function simplifyMarkdown(text: string): string;
11
+ /**
12
+ * Convert SessionInfo to plain payload for WebSocket transmission.
13
+ */
14
+ export declare function sessionToPayload(session: SessionInfo): Record<string, unknown>;
15
+ /**
16
+ * Wait for user input via WebSocket. Rejects if task is cancelled.
17
+ */
18
+ export declare function waitForInput(taskId: string, resolvers: Map<string, (response: InputResponse) => void>): Promise<InputResponse>;
19
+ /**
20
+ * Build reusable execution callbacks for a plan.
21
+ * Used by both handleTask (claude-code-adapter) and resumePMChat (daemon).
22
+ */
23
+ export declare function buildExecutionCallbacks(taskId: string, plan: TaskPlan, ws: AgentWebSocketClient, resolvers: Map<string, (response: InputResponse) => void>, onPermissionRequest?: (subtaskId: string, tool: string, detail: string) => Promise<boolean>): ExecutionCallbacks;
24
+ /**
25
+ * Execute a plan and handle the post-execution summary.
26
+ * Returns the follow-up message if user wants to continue, or null if task is complete.
27
+ */
28
+ export declare function executePlanAndReport(task: TaskAssignment, plan: TaskPlan, ws: AgentWebSocketClient, resolvers: Map<string, (response: InputResponse) => void>, signal?: AbortSignal, confirmBeforeEdit?: boolean, onPermissionRequest?: (subtaskId: string, tool: string, detail: string) => Promise<boolean>): Promise<{
29
+ sessions: SessionInfo[];
30
+ status: 'done' | 'failed' | 'waiting_input';
31
+ followUpMessage?: string;
32
+ }>;
@@ -0,0 +1,177 @@
1
+ import { executeTaskWithPlan } from '../executor/task-runner.js';
2
+ /**
3
+ * Strip ALL markdown formatting → plain text (for session result).
4
+ */
5
+ export function stripMarkdown(text) {
6
+ return text
7
+ .replace(/\*\*(.+?)\*\*/g, '$1')
8
+ .replace(/\*(.+?)\*/g, '$1')
9
+ .replace(/`(.+?)`/g, '$1')
10
+ .replace(/^#+\s+/gm, '')
11
+ .replace(/^[-*+]\s+/gm, '')
12
+ .replace(/^\d+\.\s+/gm, '')
13
+ .replace(/\[(.+?)\]\(.+?\)/g, '$1')
14
+ .trim();
15
+ }
16
+ /**
17
+ * Simplify markdown for mobile (keep bold, italic, lists, tables - simplify code blocks).
18
+ */
19
+ export function simplifyMarkdown(text) {
20
+ return text
21
+ .replace(/#{4,}\s+/g, '### ')
22
+ .trim();
23
+ }
24
+ /**
25
+ * Convert SessionInfo to plain payload for WebSocket transmission.
26
+ */
27
+ export function sessionToPayload(session) {
28
+ return {
29
+ session_id: session.sessionId ?? `agent-${Date.now()}`,
30
+ subtask_id: session.subtaskId,
31
+ status: session.status,
32
+ result: session.result,
33
+ duration_ms: session.durationMs ?? 0,
34
+ };
35
+ }
36
+ /**
37
+ * Wait for user input via WebSocket. Rejects if task is cancelled.
38
+ */
39
+ export function waitForInput(taskId, resolvers) {
40
+ return new Promise((resolve, reject) => {
41
+ resolvers.set(taskId, (response) => {
42
+ if (response.text === '__TASK_CANCELLED__') {
43
+ reject(new Error('Task cancelled'));
44
+ }
45
+ else {
46
+ resolve(response);
47
+ }
48
+ });
49
+ });
50
+ }
51
+ function capitalize(str) {
52
+ return str.charAt(0).toUpperCase() + str.slice(1);
53
+ }
54
+ /**
55
+ * Build reusable execution callbacks for a plan.
56
+ * Used by both handleTask (claude-code-adapter) and resumePMChat (daemon).
57
+ */
58
+ export function buildExecutionCallbacks(taskId, plan, ws, resolvers, onPermissionRequest) {
59
+ return {
60
+ onSubtaskStart: async (subtaskId) => {
61
+ const subtask = plan.subtasks.find((s) => s.id === subtaskId);
62
+ if (subtask) {
63
+ ws.sendSubtaskStart(taskId, {
64
+ id: subtask.id,
65
+ role: subtask.role,
66
+ description: subtask.description,
67
+ });
68
+ await new Promise(resolve => setTimeout(resolve, 100));
69
+ ws.sendPMMessage(taskId, {
70
+ sender: 'system',
71
+ content: `${capitalize(subtask.role)} session started`,
72
+ });
73
+ }
74
+ },
75
+ onSubtaskLog: async (subtaskId, logData) => {
76
+ ws.sendProgress(taskId, 'subtask_log', { subtaskId, log: logData });
77
+ },
78
+ onSubtaskDone: async (subtaskId, session) => {
79
+ const subtask = plan.subtasks.find((s) => s.id === subtaskId);
80
+ const roleName = subtask ? capitalize(subtask.role) : 'Session';
81
+ const rawResult = session.result || '';
82
+ const plainResult = rawResult ? stripMarkdown(rawResult) : '';
83
+ ws.sendSubtaskDone(taskId, {
84
+ subtaskId,
85
+ status: session.status,
86
+ result: plainResult,
87
+ durationMs: session.durationMs,
88
+ sessionId: session.sessionId,
89
+ });
90
+ if (session.status === 'done') {
91
+ const durationMs = session.durationMs;
92
+ const durationStr = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : '';
93
+ const resultPreview = rawResult ? simplifyMarkdown(rawResult) : '';
94
+ await new Promise(resolve => setTimeout(resolve, 100));
95
+ ws.sendPMMessage(taskId, {
96
+ sender: 'pm',
97
+ content: `${roleName} completed successfully${durationStr ? ` in ${durationStr}` : ''}${resultPreview ? `\n\n${resultPreview}` : ''}`,
98
+ });
99
+ }
100
+ else if (session.status === 'failed') {
101
+ const errorMsg = rawResult ? simplifyMarkdown(rawResult).substring(0, 200) : 'Unknown error';
102
+ await new Promise(resolve => setTimeout(resolve, 100));
103
+ ws.sendPMMessage(taskId, {
104
+ sender: 'pm',
105
+ content: `${roleName} encountered an issue: ${errorMsg}`,
106
+ });
107
+ }
108
+ },
109
+ onSubtaskNeedsInput: async (subtaskId, question) => {
110
+ const subtask = plan.subtasks.find((s) => s.id === subtaskId);
111
+ const roleName = subtask ? capitalize(subtask.role) : 'A session';
112
+ ws.sendPMMessage(taskId, {
113
+ sender: 'pm',
114
+ content: `${roleName} is asking: ${question.question}`,
115
+ options: question.options,
116
+ context: question.context,
117
+ });
118
+ ws.sendNeedsInput(taskId, {
119
+ subtaskId,
120
+ question: question.question,
121
+ options: question.options,
122
+ context: question.context,
123
+ });
124
+ const { text: answer } = await waitForInput(taskId, resolvers);
125
+ ws.sendPMMessage(taskId, {
126
+ sender: 'pm',
127
+ content: `Got it! Passing this to ${roleName}...`,
128
+ });
129
+ return answer;
130
+ },
131
+ onLayerStart: async (layerIndex, totalLayers, subtaskIds) => {
132
+ ws.sendProgress(taskId, 'layer_start', { layerIndex, totalLayers, subtaskIds });
133
+ },
134
+ onPermissionRequest,
135
+ };
136
+ }
137
+ /**
138
+ * Execute a plan and handle the post-execution summary.
139
+ * Returns the follow-up message if user wants to continue, or null if task is complete.
140
+ */
141
+ export async function executePlanAndReport(task, plan, ws, resolvers, signal, confirmBeforeEdit, onPermissionRequest) {
142
+ const callbacks = buildExecutionCallbacks(task.id, plan, ws, resolvers, onPermissionRequest);
143
+ const { sessions, status } = await executeTaskWithPlan(task, plan, (event, data) => { ws.sendProgress(task.id, event, data); }, callbacks, signal, confirmBeforeEdit);
144
+ if (status === 'waiting_input') {
145
+ return { sessions, status };
146
+ }
147
+ const totalDuration = sessions.reduce((sum, s) => sum + (s.durationMs ?? 0), 0);
148
+ const durationStr = (totalDuration / 1000).toFixed(1);
149
+ // Announce result
150
+ await new Promise(resolve => setTimeout(resolve, 150));
151
+ if (status === 'done') {
152
+ ws.sendPMMessage(task.id, {
153
+ sender: 'pm',
154
+ content: `All sessions completed in ${durationStr}s. Review the results and let me know if you need anything else.`,
155
+ });
156
+ }
157
+ else {
158
+ const failedSession = sessions.find((s) => s.status === 'failed');
159
+ ws.sendPMMessage(task.id, {
160
+ sender: 'pm',
161
+ content: `Some sessions had issues: ${failedSession?.result?.substring(0, 150) || 'Unknown error'}. Let me know how to proceed.`,
162
+ });
163
+ }
164
+ // Ask user: continue or complete?
165
+ ws.sendNeedsInput(task.id, {
166
+ subtaskId: 'follow-up',
167
+ question: 'Would you like to continue with anything else?',
168
+ options: ['Complete Task'],
169
+ });
170
+ const { text: followUpAnswer } = await waitForInput(task.id, resolvers);
171
+ console.log(`[Execution] Follow-up answer: ${followUpAnswer.substring(0, 80)}`);
172
+ if (followUpAnswer.toLowerCase() === 'complete task' || followUpAnswer.toLowerCase() === 'done') {
173
+ return { sessions, status };
174
+ }
175
+ // User wants to continue
176
+ return { sessions, status, followUpMessage: followUpAnswer };
177
+ }
@@ -0,0 +1,9 @@
1
+ import type { ChatAttachment } from '../types/index.js';
2
+ /**
3
+ * Download attachments to a temp directory and return local file paths.
4
+ */
5
+ export declare function downloadAttachments(attachments: ChatAttachment[], taskId: string): Promise<string[]>;
6
+ /**
7
+ * Clean up temp directory for a task.
8
+ */
9
+ export declare function cleanupAttachments(taskId: string): void;
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import https from 'https';
5
+ import http from 'http';
6
+ /**
7
+ * Download attachments to a temp directory and return local file paths.
8
+ */
9
+ export async function downloadAttachments(attachments, taskId) {
10
+ if (!attachments || attachments.length === 0)
11
+ return [];
12
+ const tmpDir = path.join(os.tmpdir(), 'tuna-attachments', taskId);
13
+ fs.mkdirSync(tmpDir, { recursive: true });
14
+ const paths = [];
15
+ for (const att of attachments) {
16
+ const safeName = `${Date.now()}_${att.file_name.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
17
+ const localPath = path.join(tmpDir, safeName);
18
+ await downloadFile(att.cdn_url, localPath);
19
+ paths.push(localPath);
20
+ console.log(`[Download] ${att.file_name} → ${localPath}`);
21
+ }
22
+ return paths;
23
+ }
24
+ function downloadFile(url, dest) {
25
+ return new Promise((resolve, reject) => {
26
+ const mod = url.startsWith('https') ? https : http;
27
+ const file = fs.createWriteStream(dest);
28
+ mod.get(url, (response) => {
29
+ // Follow redirects
30
+ if ((response.statusCode === 301 || response.statusCode === 302) && response.headers.location) {
31
+ file.close();
32
+ fs.unlinkSync(dest);
33
+ downloadFile(response.headers.location, dest).then(resolve, reject);
34
+ return;
35
+ }
36
+ if (response.statusCode !== 200) {
37
+ file.close();
38
+ fs.unlinkSync(dest);
39
+ reject(new Error(`Download failed: HTTP ${response.statusCode}`));
40
+ return;
41
+ }
42
+ response.pipe(file);
43
+ file.on('finish', () => { file.close(); resolve(); });
44
+ file.on('error', (err) => { fs.unlinkSync(dest); reject(err); });
45
+ }).on('error', (err) => {
46
+ fs.unlinkSync(dest);
47
+ reject(err);
48
+ });
49
+ });
50
+ }
51
+ /**
52
+ * Clean up temp directory for a task.
53
+ */
54
+ export function cleanupAttachments(taskId) {
55
+ const tmpDir = path.join(os.tmpdir(), 'tuna-attachments', taskId);
56
+ try {
57
+ fs.rmSync(tmpDir, { recursive: true, force: true });
58
+ }
59
+ catch { /* ignore */ }
60
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ export declare const TaskAssignmentSchema: z.ZodObject<{
3
+ type: z.ZodLiteral<"task_assigned">;
4
+ task: z.ZodObject<{
5
+ id: z.ZodString;
6
+ description: z.ZodString;
7
+ repoPath: z.ZodDefault<z.ZodOptional<z.ZodString>>;
8
+ mode: z.ZodEnum<{
9
+ tuna: "tuna";
10
+ agent_team: "agent_team";
11
+ }>;
12
+ confirmBeforeEdit: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
13
+ attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
14
+ file_key: z.ZodString;
15
+ cdn_url: z.ZodString;
16
+ file_type: z.ZodString;
17
+ file_name: z.ZodString;
18
+ }, z.core.$strip>>>;
19
+ source: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
20
+ manual: "manual";
21
+ skill: "skill";
22
+ scheduled: "scheduled";
23
+ workflow: "workflow";
24
+ api: "api";
25
+ }>>>;
26
+ }, z.core.$strip>;
27
+ }, z.core.$strip>;
28
+ export declare const CommandMessageSchema: z.ZodObject<{
29
+ type: z.ZodLiteral<"command">;
30
+ command: z.ZodString;
31
+ workspace_path: z.ZodOptional<z.ZodString>;
32
+ skill_id: z.ZodOptional<z.ZodString>;
33
+ source_path: z.ZodOptional<z.ZodString>;
34
+ skill_name: z.ZodOptional<z.ZodString>;
35
+ }, z.core.$strip>;
36
+ export declare const TaskCancelledSchema: z.ZodObject<{
37
+ type: z.ZodLiteral<"task_cancelled">;
38
+ taskId: z.ZodString;
39
+ }, z.core.$strip>;
40
+ export declare const InputResponseSchema: z.ZodObject<{
41
+ type: z.ZodLiteral<"input_response">;
42
+ taskId: z.ZodString;
43
+ subtaskId: z.ZodOptional<z.ZodString>;
44
+ answer: z.ZodString;
45
+ attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
46
+ file_key: z.ZodString;
47
+ cdn_url: z.ZodString;
48
+ file_type: z.ZodString;
49
+ file_name: z.ZodString;
50
+ }, z.core.$strip>>>;
51
+ }, z.core.$strip>;
52
+ export declare const PermissionResponseSchema: z.ZodObject<{
53
+ type: z.ZodLiteral<"permission_response">;
54
+ taskId: z.ZodOptional<z.ZodString>;
55
+ requestId: z.ZodString;
56
+ approved: z.ZodBoolean;
57
+ }, z.core.$strip>;
58
+ export declare const SimpleMessageSchema: z.ZodObject<{
59
+ type: z.ZodEnum<{
60
+ error: "error";
61
+ connected: "connected";
62
+ pong: "pong";
63
+ }>;
64
+ }, z.core.$loose>;
65
+ /**
66
+ * Validate an incoming WebSocket message and return the parsed result.
67
+ * Returns null if validation fails.
68
+ */
69
+ export declare function validateMessage(msg: Record<string, unknown>): Record<string, unknown> | null;