@myrialabs/clopen 0.0.8 → 0.1.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.
Files changed (49) hide show
  1. package/backend/index.ts +13 -1
  2. package/backend/lib/chat/stream-manager.ts +130 -10
  3. package/backend/lib/database/queries/message-queries.ts +47 -0
  4. package/backend/lib/engine/adapters/claude/stream.ts +65 -1
  5. package/backend/lib/engine/adapters/opencode/message-converter.ts +35 -77
  6. package/backend/lib/engine/types.ts +6 -0
  7. package/backend/lib/files/file-operations.ts +2 -2
  8. package/backend/lib/files/file-reading.ts +2 -2
  9. package/backend/lib/files/path-browsing.ts +2 -2
  10. package/backend/lib/terminal/pty-session-manager.ts +1 -1
  11. package/backend/lib/terminal/shell-utils.ts +4 -4
  12. package/backend/ws/chat/background.ts +3 -0
  13. package/backend/ws/chat/stream.ts +43 -1
  14. package/bin/clopen.ts +10 -0
  15. package/bun.lock +259 -381
  16. package/frontend/lib/components/chat/ChatInterface.svelte +8 -1
  17. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +20 -0
  18. package/frontend/lib/components/chat/formatters/TextMessage.svelte +3 -15
  19. package/frontend/lib/components/chat/formatters/Tools.svelte +15 -8
  20. package/frontend/lib/components/chat/input/ChatInput.svelte +70 -21
  21. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +23 -11
  22. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +1 -1
  23. package/frontend/lib/components/chat/message/ChatMessage.svelte +13 -1
  24. package/frontend/lib/components/chat/message/ChatMessages.svelte +2 -2
  25. package/frontend/lib/components/chat/message/DateSeparator.svelte +1 -1
  26. package/frontend/lib/components/chat/message/MessageBubble.svelte +2 -2
  27. package/frontend/lib/components/chat/message/MessageHeader.svelte +14 -12
  28. package/frontend/lib/components/chat/tools/AgentTool.svelte +95 -0
  29. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +396 -0
  30. package/frontend/lib/components/chat/tools/BashTool.svelte +9 -4
  31. package/frontend/lib/components/chat/tools/EnterPlanModeTool.svelte +24 -0
  32. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +4 -7
  33. package/frontend/lib/components/chat/tools/{KillShellTool.svelte → TaskStopTool.svelte} +6 -6
  34. package/frontend/lib/components/chat/tools/index.ts +5 -2
  35. package/frontend/lib/components/checkpoint/TimelineModal.svelte +7 -2
  36. package/frontend/lib/components/history/HistoryModal.svelte +13 -5
  37. package/frontend/lib/components/workspace/DesktopNavigator.svelte +2 -1
  38. package/frontend/lib/components/workspace/MobileNavigator.svelte +2 -1
  39. package/frontend/lib/services/chat/chat.service.ts +146 -12
  40. package/frontend/lib/stores/core/app.svelte.ts +77 -0
  41. package/frontend/lib/utils/chat/message-grouper.ts +94 -12
  42. package/frontend/lib/utils/chat/message-processor.ts +37 -4
  43. package/frontend/lib/utils/chat/tool-handler.ts +96 -5
  44. package/package.json +4 -4
  45. package/shared/constants/engines.ts +1 -1
  46. package/shared/types/database/schema.ts +1 -0
  47. package/shared/types/messaging/index.ts +15 -13
  48. package/shared/types/messaging/tool.ts +185 -361
  49. package/shared/utils/message-formatter.ts +1 -0
package/backend/index.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  #!/usr/bin/env bun
2
+
3
+ // Runtime guard — Bun only, reject Node.js and Deno
4
+ if (typeof globalThis.Bun === 'undefined') {
5
+ console.error('\x1b[31mError: Clopen requires Bun runtime.\x1b[0m');
6
+ console.error('Node.js and Deno are not supported.');
7
+ console.error('Install Bun: https://bun.sh');
8
+ process.exit(1);
9
+ }
10
+
2
11
  // MUST be first import — cleans process.env before any other module reads it
3
12
  import { SERVER_ENV } from './lib/shared/env';
4
13
 
@@ -72,7 +81,10 @@ if (!isDevelopment) {
72
81
  if (filePath.startsWith(distDir)) {
73
82
  try {
74
83
  if (statSync(filePath).isFile()) {
75
- return new Response(Bun.file(filePath));
84
+ const file = Bun.file(filePath);
85
+ return new Response(file, {
86
+ headers: { 'Content-Type': file.type || 'application/octet-stream' }
87
+ });
76
88
  }
77
89
  } catch {}
78
90
  }
@@ -438,33 +438,121 @@ class StreamManager extends EventEmitter {
438
438
  continue;
439
439
  }
440
440
 
441
- // Handle compact boundary messages
441
+ // Handle compact boundary messages — save to DB and show in chat UI
442
442
  if (message.type === 'system' && message.subtype === 'compact_boundary') {
443
443
  const compactMessage = message as SDKCompactBoundaryMessage;
444
444
  streamState.hasCompactBoundary = true;
445
+ const compactTimestamp = new Date().toISOString();
446
+
447
+ // Save to DB so compact boundary persists across refresh
448
+ let savedCompactId: string | undefined;
449
+ let savedCompactParentId: string | null = null;
450
+ if (chatSessionId) {
451
+ const saved = await this.saveMessage(
452
+ message,
453
+ chatSessionId,
454
+ compactTimestamp,
455
+ requestData.senderId,
456
+ requestData.senderName
457
+ );
458
+ savedCompactId = saved?.id;
459
+ savedCompactParentId = saved?.parent_message_id || null;
460
+ }
445
461
 
446
462
  streamState.messages.push({
447
463
  processId: streamState.processId,
448
464
  message,
449
- timestamp: new Date().toISOString(),
465
+ timestamp: compactTimestamp,
466
+ message_id: savedCompactId,
467
+ parent_message_id: savedCompactParentId,
450
468
  compactBoundary: {
451
469
  trigger: compactMessage.compact_metadata.trigger,
452
470
  preTokens: compactMessage.compact_metadata.pre_tokens
453
471
  }
454
472
  });
455
473
 
456
- this.emitStreamEvent(streamState, 'notification', {
457
- notification: {
458
- type: 'info',
459
- title: 'Conversation Compacted',
460
- message: `Conversation history has been compacted (${compactMessage.compact_metadata.trigger} trigger).`,
461
- icon: 'lucide:layers'
462
- },
463
- timestamp: new Date().toISOString()
474
+ // Emit as chat:message so it shows in the chat UI
475
+ this.emitStreamEvent(streamState, 'message', {
476
+ processId: streamState.processId,
477
+ message,
478
+ timestamp: compactTimestamp,
479
+ message_id: savedCompactId,
480
+ parent_message_id: savedCompactParentId,
481
+ sender_id: requestData.senderId,
482
+ sender_name: requestData.senderName
464
483
  });
484
+
485
+ continue;
486
+ }
487
+
488
+ // ──────────────────────────────────────────────────────────────
489
+ // Filter non-conversation SDK message types
490
+ // These are transient/metadata events that should NOT be saved
491
+ // to the database. Some are converted to notifications.
492
+ // ──────────────────────────────────────────────────────────────
493
+
494
+ // Handle rate_limit_event — convert to notification, don't save
495
+ if (message.type === 'rate_limit_event') {
496
+ const rateLimitMsg = message as any;
497
+ const info = rateLimitMsg.rate_limit_info;
498
+ if (info?.status === 'rejected' || info?.status === 'allowed_warning') {
499
+ const isRejected = info.status === 'rejected';
500
+ const resetTime = info.resetsAt
501
+ ? new Date(info.resetsAt * 1000).toLocaleTimeString()
502
+ : 'unknown';
503
+ this.emitStreamEvent(streamState, 'notification', {
504
+ notification: {
505
+ type: isRejected ? 'error' : 'warning',
506
+ title: isRejected ? 'Rate Limit Reached' : 'Rate Limit Warning',
507
+ message: isRejected
508
+ ? `Rate limit exceeded. Resets at ${resetTime}.`
509
+ : `Approaching rate limit (${Math.round((info.utilization || 0) * 100)}% used). Resets at ${resetTime}.`,
510
+ icon: isRejected ? 'lucide:ban' : 'lucide:alert-triangle'
511
+ },
512
+ timestamp: new Date().toISOString()
513
+ });
514
+ }
465
515
  continue;
466
516
  }
467
517
 
518
+ // Handle result messages — extract useful info, don't save to DB
519
+ if (message.type === 'result') {
520
+ const resultMsg = message as any;
521
+ if (resultMsg.subtype !== 'success' && resultMsg.errors?.length) {
522
+ debug.warn('chat', `SDK result error: ${resultMsg.subtype}`, resultMsg.errors);
523
+ }
524
+ continue;
525
+ }
526
+
527
+ // Skip all other system subtypes that aren't conversation content
528
+ // (init and compact_boundary are already handled above)
529
+ if (message.type === 'system') {
530
+ const subtype = (message as any).subtype;
531
+ // Compact boundary is handled above — this catches remaining subtypes:
532
+ // status, hook_started, hook_progress, hook_response,
533
+ // task_notification, task_started, task_progress,
534
+ // files_persisted, elicitation_complete, local_command_output
535
+ if (subtype !== 'compact_boundary') {
536
+ debug.log('chat', `[SM] Skipping system message subtype: ${subtype}`);
537
+ continue;
538
+ }
539
+ }
540
+
541
+ // Skip transient metadata events (not conversation content)
542
+ if (
543
+ message.type === 'tool_progress' ||
544
+ message.type === 'auth_status' ||
545
+ message.type === 'tool_use_summary' ||
546
+ message.type === 'prompt_suggestion'
547
+ ) {
548
+ debug.log('chat', `[SM] Skipping transient message type: ${message.type}`);
549
+ continue;
550
+ }
551
+
552
+ // ──────────────────────────────────────────────────────────────
553
+ // Handle partial messages (streaming events)
554
+ // ──────────────────────────────────────────────────────────────
555
+
468
556
  // Handle partial messages (streaming events)
469
557
  if (message.type === 'stream_event') {
470
558
  const partialMessage = message as SDKPartialAssistantMessage;
@@ -837,6 +925,38 @@ class StreamManager extends EventEmitter {
837
925
  /**
838
926
  * Cancel an active stream
839
927
  */
928
+ /**
929
+ * Resolve a pending AskUserQuestion for an active stream.
930
+ * Unblocks the engine's canUseTool callback so the SDK can continue.
931
+ */
932
+ resolveUserAnswer(chatSessionId: string, projectId: string | undefined, toolUseId: string, answers: Record<string, string>): boolean {
933
+ // Find the active stream for this session
934
+ const sessionKey = this.getSessionKey(projectId, chatSessionId);
935
+ const streamId = this.sessionStreams.get(sessionKey);
936
+ if (!streamId) {
937
+ debug.warn('chat', 'resolveUserAnswer: No stream found for session');
938
+ return false;
939
+ }
940
+
941
+ const streamState = this.activeStreams.get(streamId);
942
+ if (!streamState || streamState.status !== 'active') {
943
+ debug.warn('chat', 'resolveUserAnswer: Stream not active');
944
+ return false;
945
+ }
946
+
947
+ // Get the engine for this project
948
+ const pid = streamState.projectId || 'default';
949
+ const engine = getProjectEngine(pid, streamState.engine);
950
+
951
+ if (!engine.resolveUserAnswer) {
952
+ debug.warn('chat', 'resolveUserAnswer: Engine does not support resolveUserAnswer');
953
+ return false;
954
+ }
955
+
956
+ debug.log('chat', 'Resolving AskUserQuestion answer:', { toolUseId, answers });
957
+ return engine.resolveUserAnswer(toolUseId, answers);
958
+ }
959
+
840
960
  async cancelStream(streamId: string): Promise<boolean> {
841
961
  const streamState = this.activeStreams.get(streamId);
842
962
  if (!streamState || streamState.status !== 'active') {
@@ -437,6 +437,53 @@ export const messageQueries = {
437
437
  return candidateRoot;
438
438
  },
439
439
 
440
+ /**
441
+ * Mark messages with unanswered tool_use blocks as interrupted.
442
+ * Called when stream ends (complete/error/cancel) to persist the interrupted state.
443
+ * Adds metadata.interrupted = true to the sdk_message JSON at the message level.
444
+ */
445
+ markInterruptedMessages(sessionId: string): void {
446
+ const db = getDatabase();
447
+
448
+ // Get all visible messages for the session
449
+ const messages = db.prepare(`
450
+ SELECT id, sdk_message FROM messages
451
+ WHERE session_id = ? AND (is_deleted IS NULL OR is_deleted = 0)
452
+ ORDER BY timestamp ASC
453
+ `).all(sessionId) as { id: string; sdk_message: string }[];
454
+
455
+ // Collect all tool_use_ids that have a matching tool_result
456
+ const answeredToolIds = new Set<string>();
457
+ for (const msg of messages) {
458
+ const sdk = JSON.parse(msg.sdk_message);
459
+ if (sdk.type !== 'user' || !sdk.message?.content) continue;
460
+ const content = Array.isArray(sdk.message.content) ? sdk.message.content : [];
461
+ for (const item of content) {
462
+ if (item.type === 'tool_result' && item.tool_use_id) {
463
+ answeredToolIds.add(item.tool_use_id);
464
+ }
465
+ }
466
+ }
467
+
468
+ // Find assistant messages with unanswered tool_use blocks and mark them
469
+ const updateStmt = db.prepare(`UPDATE messages SET sdk_message = ? WHERE id = ?`);
470
+
471
+ for (const msg of messages) {
472
+ const sdk = JSON.parse(msg.sdk_message);
473
+ if (sdk.type !== 'assistant' || !sdk.message?.content) continue;
474
+ const content = Array.isArray(sdk.message.content) ? sdk.message.content : [];
475
+
476
+ const hasUnansweredTool = content.some(
477
+ (item: any) => item.type === 'tool_use' && item.id && !answeredToolIds.has(item.id)
478
+ );
479
+
480
+ if (hasUnansweredTool && !sdk.metadata?.interrupted) {
481
+ sdk.metadata = { ...sdk.metadata, interrupted: true };
482
+ updateStmt.run(JSON.stringify(sdk), msg.id);
483
+ }
484
+ }
485
+ },
486
+
440
487
  /**
441
488
  * Group orphaned messages by their branch root
442
489
  * Returns map of branchRootId -> [orphaned messages]
@@ -7,7 +7,7 @@
7
7
 
8
8
 
9
9
  import { query, type SDKMessage, type EngineSDKMessage, type Options, type Query, type SDKUserMessage } from '$shared/types/messaging';
10
- import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
10
+ import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
11
11
  import { normalizePath } from './path-utils';
12
12
  import { setupEnvironmentOnce, getEngineEnv } from './environment';
13
13
  import { handleStreamError } from './error-handler';
@@ -18,6 +18,12 @@ import { CLAUDE_CODE_MODELS } from '$shared/constants/engines';
18
18
 
19
19
  import { debug } from '$shared/utils/logger';
20
20
 
21
+ /** Pending AskUserQuestion resolver — stored while SDK is blocked waiting for user input */
22
+ interface PendingUserAnswer {
23
+ resolve: (result: PermissionResult) => void;
24
+ input: Record<string, unknown>;
25
+ }
26
+
21
27
  /** Type guard for AsyncIterable */
22
28
  function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
23
29
  return value != null && typeof value === 'object' && Symbol.asyncIterator in value;
@@ -28,6 +34,7 @@ export class ClaudeCodeEngine implements AIEngine {
28
34
  private _isInitialized = false;
29
35
  private activeController: AbortController | null = null;
30
36
  private activeQuery: Query | null = null;
37
+ private pendingUserAnswers = new Map<string, PendingUserAnswer>();
31
38
 
32
39
  get isInitialized(): boolean {
33
40
  return this._isInitialized;
@@ -49,6 +56,7 @@ export class ClaudeCodeEngine implements AIEngine {
49
56
 
50
57
  async dispose(): Promise<void> {
51
58
  await this.cancel();
59
+ this.pendingUserAnswers.clear();
52
60
  this._isInitialized = false;
53
61
  }
54
62
 
@@ -99,6 +107,35 @@ export class ClaudeCodeEngine implements AIEngine {
99
107
  systemPrompt: { type: "preset", preset: "claude_code" },
100
108
  settingSources: ["user", "project", "local"],
101
109
  forkSession: true,
110
+ // Custom permission handler: blocks on AskUserQuestion until user answers,
111
+ // auto-allows everything else. Works alongside bypassPermissions.
112
+ canUseTool: async (_toolName, input, options) => {
113
+ if (_toolName === 'AskUserQuestion') {
114
+ debug.log('engine', `AskUserQuestion detected (toolUseID: ${options.toolUseID}), waiting for user input...`);
115
+ return new Promise<PermissionResult>((resolve) => {
116
+ // Handle abort (stream cancelled while waiting)
117
+ if (options.signal.aborted) {
118
+ resolve({ behavior: 'deny', message: 'Cancelled' });
119
+ return;
120
+ }
121
+ const onAbort = () => {
122
+ this.pendingUserAnswers.delete(options.toolUseID);
123
+ resolve({ behavior: 'deny', message: 'Cancelled' });
124
+ };
125
+ options.signal.addEventListener('abort', onAbort, { once: true });
126
+
127
+ this.pendingUserAnswers.set(options.toolUseID, {
128
+ resolve: (result: PermissionResult) => {
129
+ options.signal.removeEventListener('abort', onAbort);
130
+ resolve(result);
131
+ },
132
+ input
133
+ });
134
+ });
135
+ }
136
+ // Auto-allow all other tools
137
+ return { behavior: 'allow' as const, updatedInput: input };
138
+ },
102
139
  ...(model && { model }),
103
140
  ...(resume && { resume }),
104
141
  ...(maxTurns && { maxTurns }),
@@ -155,6 +192,8 @@ export class ClaudeCodeEngine implements AIEngine {
155
192
  this.activeController = null;
156
193
  }
157
194
  this.activeQuery = null;
195
+ // Reject all pending user answer promises (abort signal handles this, but clean up the map)
196
+ this.pendingUserAnswers.clear();
158
197
  }
159
198
 
160
199
  /**
@@ -174,4 +213,29 @@ export class ClaudeCodeEngine implements AIEngine {
174
213
  await this.activeQuery.setPermissionMode(mode);
175
214
  }
176
215
  }
216
+
217
+ /**
218
+ * Resolve a pending AskUserQuestion by providing the user's answers.
219
+ * This unblocks the canUseTool callback, allowing the SDK to continue.
220
+ */
221
+ resolveUserAnswer(toolUseId: string, answers: Record<string, string>): boolean {
222
+ const pending = this.pendingUserAnswers.get(toolUseId);
223
+ if (!pending) {
224
+ debug.warn('engine', 'resolveUserAnswer: No pending question for toolUseId:', toolUseId);
225
+ return false;
226
+ }
227
+
228
+ debug.log('engine', `Resolving AskUserQuestion (toolUseID: ${toolUseId})`);
229
+
230
+ pending.resolve({
231
+ behavior: 'allow',
232
+ updatedInput: {
233
+ ...pending.input,
234
+ answers
235
+ }
236
+ });
237
+
238
+ this.pendingUserAnswers.delete(toolUseId);
239
+ return true;
240
+ }
177
241
  }
@@ -22,14 +22,10 @@ import type {
22
22
  GrepInput,
23
23
  WebFetchInput,
24
24
  WebSearchInput,
25
- AgentInput,
26
- NotebookEditInput,
27
- KillShellInput,
25
+ AskUserQuestionInput,
28
26
  ListMcpResourcesInput,
29
27
  ReadMcpResourceInput,
30
- TaskOutputInput,
31
28
  TodoWriteInput,
32
- ExitPlanModeInput,
33
29
  } from '@anthropic-ai/claude-agent-sdk/sdk-tools';
34
30
 
35
31
  // ============================================================
@@ -67,13 +63,17 @@ export function getToolInput(toolPart: ToolPart): OCToolInput {
67
63
  return input;
68
64
  }
69
65
 
70
- /** All Claude Code tool input types */
71
- type ClaudeToolInput =
66
+ /**
67
+ * Normalized tool input types.
68
+ * Only includes tools that exist in OpenCode SDK:
69
+ * bash, read, edit, write, glob, grep, webfetch, websearch,
70
+ * question, todowrite, todoread, patch, list, skill, lsp
71
+ */
72
+ type NormalizedToolInput =
72
73
  | BashInput | FileReadInput | FileEditInput | FileWriteInput
73
74
  | GlobInput | GrepInput | WebFetchInput | WebSearchInput
74
- | AgentInput | NotebookEditInput | KillShellInput
75
- | ListMcpResourcesInput | ReadMcpResourceInput
76
- | TaskOutputInput | TodoWriteInput | ExitPlanModeInput;
75
+ | AskUserQuestionInput | TodoWriteInput
76
+ | ListMcpResourcesInput | ReadMcpResourceInput;
77
77
 
78
78
  /** Text content block */
79
79
  interface TextContentBlock {
@@ -86,7 +86,7 @@ interface ToolUseContentBlock {
86
86
  type: 'tool_use';
87
87
  id: string;
88
88
  name: string;
89
- input: ClaudeToolInput;
89
+ input: NormalizedToolInput;
90
90
  $result?: ToolResult;
91
91
  }
92
92
 
@@ -97,34 +97,42 @@ type ContentBlock = TextContentBlock | ToolUseContentBlock;
97
97
  // ============================================================
98
98
 
99
99
  /**
100
- * OpenCode tool names → Claude Code tool names
100
+ * OpenCode tool names → Claude Code tool names (for UI rendering)
101
+ *
102
+ * Only maps tools that exist in OpenCode SDK:
103
+ * bash, read, edit, write, glob, grep, webfetch, websearch,
104
+ * question, todowrite, todoread, patch, list, skill, lsp
101
105
  *
102
- * OpenCode tool names (from Go source):
103
- * bash, view, edit, write, glob, grep, fetch, ls, patch, diagnostics, sourcegraph
106
+ * @see https://opencode.ai/docs/tools
104
107
  */
105
108
  const TOOL_NAME_MAP: Record<string, string> = {
109
+ // File operations
106
110
  'bash': 'Bash',
107
111
  'view': 'Read',
108
112
  'read': 'Read',
109
113
  'write': 'Write',
110
114
  'edit': 'Edit',
115
+ 'patch': 'Patch',
116
+ // Search & discovery
111
117
  'glob': 'Glob',
112
118
  'grep': 'Grep',
119
+ 'list': 'List',
120
+ // Web
113
121
  'fetch': 'WebFetch',
114
122
  'web_fetch': 'WebFetch',
115
123
  'webfetch': 'WebFetch',
116
124
  'web_search': 'WebSearch',
117
125
  'websearch': 'WebSearch',
118
- 'task': 'Task',
126
+ // Task management
119
127
  'todo_write': 'TodoWrite',
120
128
  'todowrite': 'TodoWrite',
121
129
  'todoread': 'TodoWrite',
122
- 'notebook_edit': 'NotebookEdit',
123
- 'notebookedit': 'NotebookEdit',
124
- 'exit_plan_mode': 'ExitPlanMode',
125
- 'exitplanmode': 'ExitPlanMode',
126
- 'kill_shell': 'KillShell',
127
- 'killshell': 'KillShell',
130
+ // User interaction
131
+ 'question': 'AskUserQuestion',
132
+ // Code intelligence & utilities
133
+ 'skill': 'Skill',
134
+ 'lsp': 'Lsp',
135
+ // MCP (custom servers)
128
136
  'list_mcp_resources': 'ListMcpResources',
129
137
  'read_mcp_resource': 'ReadMcpResource',
130
138
  };
@@ -282,40 +290,9 @@ function normalizeWebSearchInput(raw: OCToolInput): WebSearchInput {
282
290
  return result;
283
291
  }
284
292
 
285
- function normalizeAgentInput(raw: OCToolInput): AgentInput {
286
- const result: AgentInput = {
287
- description: str(raw, 'description', 'description'),
288
- prompt: str(raw, 'prompt', 'prompt'),
289
- subagent_type: str(raw, 'subagent_type', 'subagentType'),
290
- };
291
- const model = optStr(raw, 'model', 'model') as AgentInput['model'];
292
- if (model != null) result.model = model;
293
- const resume = optStr(raw, 'resume', 'resume');
294
- if (resume != null) result.resume = resume;
295
- const runInBackground = optBool(raw, 'run_in_background', 'runInBackground');
296
- if (runInBackground != null) result.run_in_background = runInBackground;
297
- const maxTurns = optNum(raw, 'max_turns', 'maxTurns');
298
- if (maxTurns != null) result.max_turns = maxTurns;
299
- return result;
300
- }
301
-
302
- function normalizeNotebookEditInput(raw: OCToolInput): NotebookEditInput {
303
- const result: NotebookEditInput = {
304
- notebook_path: str(raw, 'notebook_path', 'notebookPath'),
305
- new_source: str(raw, 'new_source', 'newSource'),
306
- };
307
- const cellId = optStr(raw, 'cell_id', 'cellId');
308
- if (cellId != null) result.cell_id = cellId;
309
- const cellType = optStr(raw, 'cell_type', 'cellType') as NotebookEditInput['cell_type'];
310
- if (cellType != null) result.cell_type = cellType;
311
- const editMode = optStr(raw, 'edit_mode', 'editMode') as NotebookEditInput['edit_mode'];
312
- if (editMode != null) result.edit_mode = editMode;
313
- return result;
314
- }
315
-
316
- function normalizeKillShellInput(raw: OCToolInput): KillShellInput {
293
+ function normalizeAskUserQuestionInput(raw: OCToolInput): AskUserQuestionInput {
317
294
  return {
318
- shell_id: str(raw, 'shell_id', 'shellId'),
295
+ questions: (raw.questions ?? []) as AskUserQuestionInput['questions'],
319
296
  };
320
297
  }
321
298
 
@@ -333,27 +310,12 @@ function normalizeReadMcpResourceInput(raw: OCToolInput): ReadMcpResourceInput {
333
310
  };
334
311
  }
335
312
 
336
- function normalizeTaskOutputInput(raw: OCToolInput): TaskOutputInput {
337
- return {
338
- task_id: str(raw, 'task_id', 'taskId'),
339
- block: (raw.block ?? true) as boolean,
340
- timeout: (raw.timeout ?? 30000) as number,
341
- };
342
- }
343
-
344
313
  function normalizeTodoWriteInput(raw: OCToolInput): TodoWriteInput {
345
314
  return {
346
315
  todos: (raw.todos ?? []) as TodoWriteInput['todos'],
347
316
  };
348
317
  }
349
318
 
350
- function normalizeExitPlanModeInput(raw: OCToolInput): ExitPlanModeInput {
351
- const result: ExitPlanModeInput = {};
352
- const allowedPrompts = raw.allowedPrompts ?? raw.allowed_prompts;
353
- if (allowedPrompts != null) result.allowedPrompts = allowedPrompts as ExitPlanModeInput['allowedPrompts'];
354
- return result;
355
- }
356
-
357
319
  // ============================================================
358
320
  // Normalizer Dispatcher
359
321
  // ============================================================
@@ -362,10 +324,10 @@ function normalizeExitPlanModeInput(raw: OCToolInput): ExitPlanModeInput {
362
324
  * Normalize OpenCode tool input → Claude Code tool input format.
363
325
  * Handles camelCase → snake_case conversion and field name differences.
364
326
  */
365
- function normalizeToolInput(claudeToolName: string, raw: OCToolInput): ClaudeToolInput {
327
+ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): NormalizedToolInput {
366
328
  // Custom MCP tools (mcp__*) — pass input through as-is
367
329
  if (claudeToolName.startsWith('mcp__')) {
368
- return raw as ClaudeToolInput;
330
+ return raw as NormalizedToolInput;
369
331
  }
370
332
 
371
333
  switch (claudeToolName) {
@@ -377,21 +339,17 @@ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): ClaudeToo
377
339
  case 'Grep': return normalizeGrepInput(raw);
378
340
  case 'WebFetch': return normalizeWebFetchInput(raw);
379
341
  case 'WebSearch': return normalizeWebSearchInput(raw);
380
- case 'Task': return normalizeAgentInput(raw);
381
- case 'NotebookEdit': return normalizeNotebookEditInput(raw);
382
- case 'KillShell': return normalizeKillShellInput(raw);
342
+ case 'AskUserQuestion': return normalizeAskUserQuestionInput(raw);
383
343
  case 'ListMcpResources': return normalizeListMcpResourcesInput(raw);
384
344
  case 'ReadMcpResource': return normalizeReadMcpResourceInput(raw);
385
- case 'TaskOutput': return normalizeTaskOutputInput(raw);
386
345
  case 'TodoWrite': return normalizeTodoWriteInput(raw);
387
- case 'ExitPlanMode': return normalizeExitPlanModeInput(raw);
388
346
  default: {
389
347
  // Unknown tool: generic camelCase → snake_case key normalization
390
348
  const normalized: Record<string, string | number | boolean> = {};
391
349
  for (const [key, value] of Object.entries(raw)) {
392
350
  normalized[camelToSnake(key)] = value as string | number | boolean;
393
351
  }
394
- return normalized as ClaudeToolInput;
352
+ return normalized as NormalizedToolInput;
395
353
  }
396
354
  }
397
355
  }
@@ -55,4 +55,10 @@ export interface AIEngine {
55
55
 
56
56
  /** Return the list of models this engine supports */
57
57
  getAvailableModels(): Promise<EngineModel[]>;
58
+
59
+ /**
60
+ * Resolve a pending AskUserQuestion by providing the user's answers.
61
+ * Unblocks the canUseTool callback so the SDK can continue.
62
+ */
63
+ resolveUserAnswer?(toolUseId: string, answers: Record<string, string>): boolean;
58
64
  }
@@ -10,9 +10,9 @@ const { spawn } = Bun;
10
10
  async function readdir(path: string): Promise<string[]> {
11
11
  let proc;
12
12
  if (process.platform === 'win32') {
13
- proc = Bun.spawn(['cmd', '/c', 'dir', '/b', path], { stdout: 'pipe', stderr: 'ignore' });
13
+ proc = Bun.spawn(['cmd', '/c', 'dir', '/b', '/a', path], { stdout: 'pipe', stderr: 'ignore' });
14
14
  } else {
15
- proc = Bun.spawn(['ls', '-1', path], { stdout: 'pipe', stderr: 'ignore' });
15
+ proc = Bun.spawn(['ls', '-1A', path], { stdout: 'pipe', stderr: 'ignore' });
16
16
  }
17
17
  const result = await new Response(proc.stdout).text();
18
18
  // Split and clean up, removing \r characters for Windows compatibility
@@ -7,9 +7,9 @@ import { debug } from '$shared/utils/logger';
7
7
  async function readdir(path: string): Promise<string[]> {
8
8
  let proc;
9
9
  if (process.platform === 'win32') {
10
- proc = Bun.spawn(['cmd', '/c', 'dir', '/b', path], { stdout: 'pipe', stderr: 'ignore' });
10
+ proc = Bun.spawn(['cmd', '/c', 'dir', '/b', '/a', path], { stdout: 'pipe', stderr: 'ignore' });
11
11
  } else {
12
- proc = Bun.spawn(['ls', '-1', path], { stdout: 'pipe', stderr: 'ignore' });
12
+ proc = Bun.spawn(['ls', '-1A', path], { stdout: 'pipe', stderr: 'ignore' });
13
13
  }
14
14
  const result = await new Response(proc.stdout).text();
15
15
  // Split and clean up, removing \r characters for Windows compatibility
@@ -6,9 +6,9 @@ import { debug } from '$shared/utils/logger';
6
6
  async function readdir(path: string): Promise<string[]> {
7
7
  let proc;
8
8
  if (process.platform === 'win32') {
9
- proc = Bun.spawn(['cmd', '/c', 'dir', '/b', path], { stdout: 'pipe', stderr: 'ignore' });
9
+ proc = Bun.spawn(['cmd', '/c', 'dir', '/b', '/a', path], { stdout: 'pipe', stderr: 'ignore' });
10
10
  } else {
11
- proc = Bun.spawn(['ls', '-1', path], { stdout: 'pipe', stderr: 'ignore' });
11
+ proc = Bun.spawn(['ls', '-1A', path], { stdout: 'pipe', stderr: 'ignore' });
12
12
  }
13
13
  const result = await new Response(proc.stdout).text();
14
14
  // Split and clean up, removing \r characters for Windows compatibility
@@ -61,7 +61,7 @@ class PtySessionManager {
61
61
 
62
62
  if (isWindows) {
63
63
  // Windows: Use PowerShell in interactive mode
64
- shell = 'powershell';
64
+ shell = 'powershell.exe';
65
65
  shellArgs = ['-NoLogo']; // Interactive mode, no -Command
66
66
  } else {
67
67
  // Unix: Use user's default shell or bash
@@ -102,7 +102,7 @@ export async function getShellConfig(preferGitBash = false): Promise<{
102
102
  // For Windows, always use PowerShell as the primary shell
103
103
  // PowerShell is available on all modern Windows systems
104
104
  return {
105
- shell: 'powershell',
105
+ shell: 'powershell.exe',
106
106
  args: (command: string) => ['-NoProfile', '-Command', command],
107
107
  name: 'PowerShell',
108
108
  isUnixLike: false
@@ -189,7 +189,7 @@ export function createPty(shell: string, args: string[], cwd: string, terminalSi
189
189
 
190
190
  if (isWindows) {
191
191
  // Windows: Always use PowerShell
192
- if (shell === 'powershell') {
192
+ if (shell === 'powershell' || shell === 'powershell.exe') {
193
193
  // Extract the actual command from args
194
194
  let actualCommand = args.join(' ');
195
195
  if (args.length >= 2 && (args[0] === '-Command' || args[0] === '-NoProfile')) {
@@ -201,7 +201,7 @@ export function createPty(shell: string, args: string[], cwd: string, terminalSi
201
201
  actualCommand = args[args.length - 1];
202
202
  }
203
203
  }
204
- return spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', actualCommand], {
204
+ return spawn('powershell.exe', ['-NoProfile', '-NoLogo', '-Command', actualCommand], {
205
205
  name: 'xterm-256color',
206
206
  cols,
207
207
  rows,
@@ -211,7 +211,7 @@ export function createPty(shell: string, args: string[], cwd: string, terminalSi
211
211
  }
212
212
 
213
213
  // Default to PowerShell if shell is not recognized
214
- return spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', args.join(' ') || 'Write-Host "Terminal ready"'], {
214
+ return spawn('powershell.exe', ['-NoProfile', '-NoLogo', '-Command', args.join(' ') || 'Write-Host "Terminal ready"'], {
215
215
  name: 'xterm-256color',
216
216
  cols,
217
217
  rows,