@myrialabs/clopen 0.0.7 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/index.ts +28 -10
- package/backend/lib/chat/stream-manager.ts +130 -10
- package/backend/lib/database/queries/message-queries.ts +47 -0
- package/backend/lib/engine/adapters/claude/stream.ts +65 -1
- package/backend/lib/engine/adapters/opencode/message-converter.ts +35 -77
- package/backend/lib/engine/types.ts +6 -0
- package/backend/lib/files/file-operations.ts +2 -2
- package/backend/lib/files/file-reading.ts +2 -2
- package/backend/lib/files/path-browsing.ts +2 -2
- package/backend/lib/terminal/pty-session-manager.ts +1 -1
- package/backend/lib/terminal/shell-utils.ts +4 -4
- package/backend/lib/terminal/stream-manager.ts +6 -3
- package/backend/ws/chat/background.ts +3 -0
- package/backend/ws/chat/stream.ts +43 -1
- package/backend/ws/terminal/session.ts +48 -0
- package/bin/clopen.ts +10 -0
- package/bun.lock +258 -383
- package/frontend/lib/components/chat/ChatInterface.svelte +8 -1
- package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +20 -0
- package/frontend/lib/components/chat/formatters/TextMessage.svelte +3 -15
- package/frontend/lib/components/chat/formatters/Tools.svelte +15 -8
- package/frontend/lib/components/chat/input/ChatInput.svelte +70 -21
- package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +23 -11
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +1 -1
- package/frontend/lib/components/chat/message/ChatMessage.svelte +13 -1
- package/frontend/lib/components/chat/message/ChatMessages.svelte +2 -2
- package/frontend/lib/components/chat/message/DateSeparator.svelte +1 -1
- package/frontend/lib/components/chat/message/MessageBubble.svelte +2 -2
- package/frontend/lib/components/chat/message/MessageHeader.svelte +14 -12
- package/frontend/lib/components/chat/tools/AgentTool.svelte +95 -0
- package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +396 -0
- package/frontend/lib/components/chat/tools/BashTool.svelte +9 -4
- package/frontend/lib/components/chat/tools/EnterPlanModeTool.svelte +24 -0
- package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +4 -7
- package/frontend/lib/components/chat/tools/{KillShellTool.svelte → TaskStopTool.svelte} +6 -6
- package/frontend/lib/components/chat/tools/index.ts +5 -2
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +7 -2
- package/frontend/lib/components/history/HistoryModal.svelte +13 -5
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +2 -1
- package/frontend/lib/components/workspace/MobileNavigator.svelte +2 -1
- package/frontend/lib/services/chat/chat.service.ts +146 -12
- package/frontend/lib/services/terminal/project.service.ts +65 -10
- package/frontend/lib/services/terminal/terminal.service.ts +19 -0
- package/frontend/lib/stores/core/app.svelte.ts +77 -0
- package/frontend/lib/stores/features/terminal.svelte.ts +10 -0
- package/frontend/lib/utils/chat/message-grouper.ts +94 -12
- package/frontend/lib/utils/chat/message-processor.ts +37 -4
- package/frontend/lib/utils/chat/tool-handler.ts +96 -5
- package/package.json +4 -5
- package/shared/constants/engines.ts +1 -1
- package/shared/types/database/schema.ts +1 -0
- package/shared/types/messaging/index.ts +15 -13
- package/shared/types/messaging/tool.ts +185 -361
- 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
|
|
|
@@ -14,6 +23,7 @@ import { debug } from '$shared/utils/logger';
|
|
|
14
23
|
import { findAvailablePort } from './lib/shared/port-utils';
|
|
15
24
|
import { networkInterfaces } from 'os';
|
|
16
25
|
import { resolve } from 'node:path';
|
|
26
|
+
import { statSync } from 'node:fs';
|
|
17
27
|
|
|
18
28
|
// Import WebSocket router
|
|
19
29
|
import { wsRouter } from './ws';
|
|
@@ -57,19 +67,27 @@ const app = new Elysia()
|
|
|
57
67
|
.use(wsRouter.asPlugin('/ws'));
|
|
58
68
|
|
|
59
69
|
if (!isDevelopment) {
|
|
60
|
-
// Production: serve static files
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assets: 'dist',
|
|
65
|
-
prefix: '/',
|
|
66
|
-
}));
|
|
67
|
-
|
|
68
|
-
// SPA fallback: serve index.html for any unmatched route (client-side routing)
|
|
70
|
+
// Production: serve static files manually instead of @elysiajs/static.
|
|
71
|
+
// The static plugin tries to serve directories (like /) as files via Bun.file(),
|
|
72
|
+
// which hangs on some devices/platforms. Using statSync to verify the path is
|
|
73
|
+
// an actual file before serving avoids this issue.
|
|
69
74
|
const distDir = resolve(process.cwd(), 'dist');
|
|
70
75
|
const indexHtml = await Bun.file(resolve(distDir, 'index.html')).text();
|
|
71
76
|
|
|
72
|
-
app.
|
|
77
|
+
app.all('/*', ({ path }) => {
|
|
78
|
+
// Serve static files from dist/
|
|
79
|
+
if (path !== '/' && !path.includes('..')) {
|
|
80
|
+
const filePath = resolve(distDir, path.slice(1));
|
|
81
|
+
if (filePath.startsWith(distDir)) {
|
|
82
|
+
try {
|
|
83
|
+
if (statSync(filePath).isFile()) {
|
|
84
|
+
return new Response(Bun.file(filePath));
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// SPA fallback: serve cached index.html
|
|
73
91
|
return new Response(indexHtml, {
|
|
74
92
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
75
93
|
});
|
|
@@ -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:
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
|
|
126
|
+
// Task management
|
|
119
127
|
'todo_write': 'TodoWrite',
|
|
120
128
|
'todowrite': 'TodoWrite',
|
|
121
129
|
'todoread': 'TodoWrite',
|
|
122
|
-
|
|
123
|
-
'
|
|
124
|
-
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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):
|
|
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
|
|
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 '
|
|
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
|
|
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', '-
|
|
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', '-
|
|
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', '-
|
|
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
|