mstro-app 0.5.5 → 0.5.6

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 (70) hide show
  1. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker-process.js +9 -1
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/cli/headless/mcp-config.d.ts +22 -5
  5. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  6. package/dist/server/cli/headless/mcp-config.js +7 -5
  7. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +19 -0
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/types.d.ts +16 -1
  12. package/dist/server/cli/headless/types.d.ts.map +1 -1
  13. package/dist/server/cli/improvisation-session-manager.d.ts +6 -0
  14. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  15. package/dist/server/cli/improvisation-session-manager.js +8 -0
  16. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  17. package/dist/server/cli/improvisation-types.d.ts +7 -0
  18. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  19. package/dist/server/cli/improvisation-types.js.map +1 -1
  20. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
  21. package/dist/server/cli/retry/retry-runner-factory.js +1 -0
  22. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
  23. package/dist/server/index.js +8 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/mcp/server.js +52 -0
  26. package/dist/server/mcp/server.js.map +1 -1
  27. package/dist/server/routes/index.d.ts +1 -0
  28. package/dist/server/routes/index.d.ts.map +1 -1
  29. package/dist/server/routes/index.js +1 -0
  30. package/dist/server/routes/index.js.map +1 -1
  31. package/dist/server/routes/internal.d.ts +16 -0
  32. package/dist/server/routes/internal.d.ts.map +1 -0
  33. package/dist/server/routes/internal.js +94 -0
  34. package/dist/server/routes/internal.js.map +1 -0
  35. package/dist/server/services/runtime-info.d.ts +3 -0
  36. package/dist/server/services/runtime-info.d.ts.map +1 -0
  37. package/dist/server/services/runtime-info.js +21 -0
  38. package/dist/server/services/runtime-info.js.map +1 -0
  39. package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
  40. package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
  41. package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
  42. package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
  43. package/dist/server/services/websocket/handler.d.ts +8 -0
  44. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  45. package/dist/server/services/websocket/handler.js +30 -0
  46. package/dist/server/services/websocket/handler.js.map +1 -1
  47. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  48. package/dist/server/services/websocket/session-handlers.js +3 -0
  49. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  50. package/dist/server/services/websocket/types.d.ts +39 -2
  51. package/dist/server/services/websocket/types.d.ts.map +1 -1
  52. package/dist/server/services/websocket/types.js +2 -2
  53. package/dist/server/services/websocket/types.js.map +1 -1
  54. package/package.json +1 -1
  55. package/server/cli/headless/claude-invoker-process.ts +9 -1
  56. package/server/cli/headless/mcp-config.ts +30 -5
  57. package/server/cli/headless/runner.ts +21 -0
  58. package/server/cli/headless/types.ts +16 -1
  59. package/server/cli/improvisation-session-manager.ts +9 -0
  60. package/server/cli/improvisation-types.ts +7 -0
  61. package/server/cli/retry/retry-runner-factory.ts +1 -0
  62. package/server/index.ts +8 -0
  63. package/server/mcp/server.ts +57 -0
  64. package/server/routes/index.ts +1 -0
  65. package/server/routes/internal.ts +112 -0
  66. package/server/services/runtime-info.ts +24 -0
  67. package/server/services/websocket/ask-user-question-bridge.ts +148 -0
  68. package/server/services/websocket/handler.ts +30 -0
  69. package/server/services/websocket/session-handlers.ts +3 -0
  70. package/server/services/websocket/types.ts +48 -2
@@ -0,0 +1,112 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * Internal Routes
5
+ *
6
+ * HTTP endpoints used by sibling subprocesses (like the MCP bouncer) to talk
7
+ * back to the running CLI server. NOT mounted under `/api/*` — these are gated
8
+ * by the per-process bouncer secret instead of the user's session token.
9
+ *
10
+ * Currently a single endpoint:
11
+ * POST /internal/ask-user-question
12
+ * Bouncer pauses Claude on AskUserQuestion; this blocks until the web
13
+ * user answers, then returns the answers Claude needs to continue.
14
+ */
15
+
16
+ import { Hono } from 'hono'
17
+ import {
18
+ isValidBouncerSecret,
19
+ registerPendingQuestion,
20
+ } from '../services/websocket/ask-user-question-bridge.js'
21
+ import type { HandlerContext } from '../services/websocket/handler-context.js'
22
+ import { broadcastTabEvent } from '../services/websocket/tab-broadcast.js'
23
+ import type {
24
+ AskUserQuestionItem,
25
+ AskUserQuestionPayload,
26
+ } from '../services/websocket/types.js'
27
+
28
+ interface AskUserQuestionRequestBody {
29
+ toolUseId?: unknown
30
+ tabId?: unknown
31
+ questions?: unknown
32
+ /** Override default 15min timeout (ms). Optional. */
33
+ timeoutMs?: unknown
34
+ }
35
+
36
+ /** Narrow an unknown into AskUserQuestionItem[] without throwing. */
37
+ function parseQuestions(value: unknown): AskUserQuestionItem[] | null {
38
+ if (!Array.isArray(value)) return null
39
+ const out: AskUserQuestionItem[] = []
40
+ for (const raw of value) {
41
+ if (!raw || typeof raw !== 'object') return null
42
+ const r = raw as Record<string, unknown>
43
+ if (typeof r.question !== 'string' || typeof r.header !== 'string') return null
44
+ if (!Array.isArray(r.options)) return null
45
+ const options = r.options.map((o) => {
46
+ if (!o || typeof o !== 'object') return null
47
+ const oo = o as Record<string, unknown>
48
+ if (typeof oo.label !== 'string') return null
49
+ return {
50
+ label: oo.label,
51
+ description: typeof oo.description === 'string' ? oo.description : '',
52
+ preview: typeof oo.preview === 'string' ? oo.preview : undefined,
53
+ }
54
+ })
55
+ if (options.some((o) => o === null)) return null
56
+ out.push({
57
+ question: r.question,
58
+ header: r.header,
59
+ options: options as AskUserQuestionItem['options'],
60
+ multiSelect: r.multiSelect === true,
61
+ })
62
+ }
63
+ return out
64
+ }
65
+
66
+ export function createInternalRoutes(ctx: HandlerContext): Hono {
67
+ const app = new Hono()
68
+
69
+ app.post('/ask-user-question', async (c) => {
70
+ const secret = c.req.header('x-mstro-bouncer-secret')
71
+ if (!isValidBouncerSecret(secret)) {
72
+ return c.json({ error: 'Forbidden' }, 403)
73
+ }
74
+
75
+ let body: AskUserQuestionRequestBody
76
+ try {
77
+ body = (await c.req.json()) as AskUserQuestionRequestBody
78
+ } catch {
79
+ return c.json({ error: 'Invalid JSON' }, 400)
80
+ }
81
+
82
+ const toolUseId = typeof body.toolUseId === 'string' ? body.toolUseId : ''
83
+ const tabId = typeof body.tabId === 'string' ? body.tabId : ''
84
+ const questions = parseQuestions(body.questions)
85
+ if (!toolUseId || !tabId || !questions || questions.length === 0) {
86
+ return c.json({ error: 'toolUseId, tabId, and non-empty questions[] are required' }, 400)
87
+ }
88
+
89
+ const timeoutMs =
90
+ typeof body.timeoutMs === 'number' && body.timeoutMs > 0 ? body.timeoutMs : undefined
91
+
92
+ const payload: AskUserQuestionPayload = { toolUseId, questions }
93
+ broadcastTabEvent(ctx, tabId, 'askUserQuestion', payload)
94
+
95
+ try {
96
+ const answers = await registerPendingQuestion({ toolUseId, tabId, timeoutMs })
97
+ return c.json({ answers })
98
+ } catch (err) {
99
+ const reason = err instanceof Error ? err.message : 'cancelled'
100
+ // Tell every web client to dismiss the card so users don't keep poking
101
+ // an already-dead question.
102
+ broadcastTabEvent(ctx, tabId, 'askUserQuestionDismissed', {
103
+ toolUseId,
104
+ reason: reason === 'timeout' ? 'timeout' : 'cancelled',
105
+ })
106
+ const status = reason === 'timeout' ? 504 : 410
107
+ return c.json({ error: reason }, status)
108
+ }
109
+ })
110
+
111
+ return app
112
+ }
@@ -0,0 +1,24 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * Runtime Info
5
+ *
6
+ * Holds process-wide singletons that are known after server startup but
7
+ * needed by code paths (e.g. the headless runner / MCP config generator)
8
+ * that don't have a natural reference to the Hono app or instance registry.
9
+ *
10
+ * Specifically: the port the CLI server is bound to. We need it so the MCP
11
+ * bouncer subprocess can call back into us via HTTP for AskUserQuestion.
12
+ *
13
+ * Set once at server startup (see `server/index.ts`).
14
+ */
15
+
16
+ let currentPort: number | undefined;
17
+
18
+ export function setCurrentMstroPort(port: number): void {
19
+ currentPort = port;
20
+ }
21
+
22
+ export function getCurrentMstroPort(): number | undefined {
23
+ return currentPort;
24
+ }
@@ -0,0 +1,148 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * AskUserQuestion Bridge
5
+ *
6
+ * Bridges the MCP bouncer subprocess (which receives Claude's AskUserQuestion
7
+ * tool calls) and the web client (which collects the user's answers).
8
+ *
9
+ * Flow:
10
+ * Claude → MCP bouncer (subprocess)
11
+ * → POST /internal/ask-user-question (this CLI server)
12
+ * → registerPendingQuestion() stores a resolver
13
+ * → broadcastTabEvent('askUserQuestion', …) pushes the question to web
14
+ * → web user answers → WS `askUserQuestionResponse`
15
+ * → resolvePendingQuestion() resolves the awaited promise
16
+ * → HTTP response to bouncer with answers
17
+ * → bouncer returns { behavior: "allow", updatedInput: { questions, answers } }
18
+ *
19
+ * Ownership of state: pending questions live only here, in-process. The
20
+ * registry is keyed by `toolUseId` (Claude's per-call id) which guarantees
21
+ * uniqueness across tabs and sessions.
22
+ *
23
+ * Timeouts: questions auto-reject after `DEFAULT_TIMEOUT_MS`. The bouncer's
24
+ * HTTP call gets a 504 and returns a deny to Claude rather than blocking the
25
+ * Claude turn forever.
26
+ */
27
+
28
+ import { randomUUID } from 'node:crypto';
29
+
30
+ /** Default per-question timeout (15 minutes). */
31
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
32
+
33
+ interface PendingQuestion {
34
+ toolUseId: string;
35
+ tabId: string;
36
+ resolve: (answers: Record<string, string>) => void;
37
+ reject: (reason: 'timeout' | 'cancelled' | 'session-ended') => void;
38
+ timer: ReturnType<typeof setTimeout>;
39
+ createdAt: number;
40
+ }
41
+
42
+ const pending = new Map<string, PendingQuestion>();
43
+
44
+ /** Per-process secret the MCP bouncer must echo to authenticate.
45
+ * Generated once at server start, passed to bouncers via env var. */
46
+ const bouncerSharedSecret = randomUUID();
47
+
48
+ /** Get the per-process bouncer secret (passed via env var to bouncer subprocesses). */
49
+ export function getBouncerSecret(): string {
50
+ return bouncerSharedSecret;
51
+ }
52
+
53
+ /** Validate a secret claimed by an inbound /internal request. */
54
+ export function isValidBouncerSecret(secret: string | undefined | null): boolean {
55
+ if (!secret) return false;
56
+ return secret === bouncerSharedSecret;
57
+ }
58
+
59
+ export interface RegisterPendingQuestionOptions {
60
+ toolUseId: string;
61
+ tabId: string;
62
+ timeoutMs?: number;
63
+ }
64
+
65
+ /**
66
+ * Register a pending question. The returned promise resolves when
67
+ * `resolvePendingQuestion` is called for the same toolUseId, or rejects on
68
+ * timeout / cancellation.
69
+ */
70
+ export function registerPendingQuestion(
71
+ opts: RegisterPendingQuestionOptions,
72
+ ): Promise<Record<string, string>> {
73
+ const { toolUseId, tabId, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
74
+
75
+ // Defensive: reject any prior pending entry for this id (shouldn't happen
76
+ // but a process restart or duplicate POST shouldn't leak handlers).
77
+ const existing = pending.get(toolUseId);
78
+ if (existing) {
79
+ clearTimeout(existing.timer);
80
+ existing.reject('cancelled');
81
+ pending.delete(toolUseId);
82
+ }
83
+
84
+ return new Promise<Record<string, string>>((resolve, reject) => {
85
+ const timer = setTimeout(() => {
86
+ const entry = pending.get(toolUseId);
87
+ if (!entry) return;
88
+ pending.delete(toolUseId);
89
+ entry.reject('timeout');
90
+ }, timeoutMs);
91
+
92
+ pending.set(toolUseId, {
93
+ toolUseId,
94
+ tabId,
95
+ resolve,
96
+ reject: (reason) => reject(new Error(reason)),
97
+ timer,
98
+ createdAt: Date.now(),
99
+ });
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Resolve a pending question with the user's answers. Returns true if a
105
+ * pending entry was found and resolved; false if there was no matching
106
+ * pending question (already answered, timed out, or unknown id).
107
+ */
108
+ export function resolvePendingQuestion(
109
+ toolUseId: string,
110
+ answers: Record<string, string>,
111
+ ): boolean {
112
+ const entry = pending.get(toolUseId);
113
+ if (!entry) return false;
114
+ pending.delete(toolUseId);
115
+ clearTimeout(entry.timer);
116
+ entry.resolve(answers);
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Cancel all pending questions for a given tab. Used when a tab is removed,
122
+ * a session is reset, or an orchestra disconnects. Returns the toolUseIds
123
+ * that were cancelled so callers can broadcast `askUserQuestionDismissed`.
124
+ */
125
+ export function cancelPendingQuestionsForTab(
126
+ tabId: string,
127
+ reason: 'cancelled' | 'session-ended' = 'cancelled',
128
+ ): string[] {
129
+ const cancelled: string[] = [];
130
+ for (const [id, entry] of pending) {
131
+ if (entry.tabId !== tabId) continue;
132
+ pending.delete(id);
133
+ clearTimeout(entry.timer);
134
+ entry.reject(reason);
135
+ cancelled.push(id);
136
+ }
137
+ return cancelled;
138
+ }
139
+
140
+ /** Look up the tab that owns a pending question, or undefined. */
141
+ export function getPendingQuestionTab(toolUseId: string): string | undefined {
142
+ return pending.get(toolUseId)?.tabId;
143
+ }
144
+
145
+ /** Diagnostic: how many questions are currently waiting on user input. */
146
+ export function pendingQuestionCount(): number {
147
+ return pending.size;
148
+ }
@@ -15,6 +15,7 @@ import type { ImprovisationSessionManager } from '../../cli/improvisation-sessio
15
15
  import type { InstanceRegistry } from '../instances.js';
16
16
  import { captureException } from '../sentry.js';
17
17
  import { getPTYManager } from '../terminal/pty-manager.js';
18
+ import { resolvePendingQuestion } from './ask-user-question-bridge.js';
18
19
  import { AutocompleteService } from './autocomplete.js';
19
20
  import { FileDownloadHandler } from './file-download-handler.js';
20
21
  import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
@@ -217,6 +218,9 @@ export class WebSocketImproviseHandler implements HandlerContext {
217
218
  return handleListSkills(this, ws, workingDir);
218
219
  case 'shutdownInstance':
219
220
  return this.handleShutdownInstance(ws, permission);
221
+ case 'askUserQuestionResponse':
222
+ if (permission === 'view') return;
223
+ return this.handleAskUserQuestionResponse(msg, tabId);
220
224
  }
221
225
 
222
226
  // Dispatch table lookup for domain handlers
@@ -391,6 +395,32 @@ export class WebSocketImproviseHandler implements HandlerContext {
391
395
  this.sessions.delete(sessionId);
392
396
  }
393
397
 
398
+ /**
399
+ * Resolve a pending AskUserQuestion call with the user's answers. The
400
+ * bouncer subprocess is awaiting on the bridge promise; calling
401
+ * `resolvePendingQuestion` releases it so Claude resumes with the answers.
402
+ * Stale toolUseIds are no-ops — likely the question already timed out or
403
+ * was cancelled, and a stale web client is replaying its submission.
404
+ */
405
+ private handleAskUserQuestionResponse(msg: WebSocketMessage, tabId: string): void {
406
+ const data = msg.data as { toolUseId?: unknown; answers?: unknown } | undefined;
407
+ const toolUseId = typeof data?.toolUseId === 'string' ? data.toolUseId : '';
408
+ const answersIn = data?.answers;
409
+ if (!toolUseId) return;
410
+
411
+ // Only accept a flat string-string map; coerce safely so a malformed
412
+ // payload doesn't crash the bridge.
413
+ const answers: Record<string, string> = {};
414
+ if (answersIn && typeof answersIn === 'object' && !Array.isArray(answersIn)) {
415
+ for (const [k, v] of Object.entries(answersIn as Record<string, unknown>)) {
416
+ if (typeof v === 'string') answers[k] = v;
417
+ }
418
+ }
419
+
420
+ void tabId; // tabId is informational; the toolUseId is the unique key
421
+ resolvePendingQuestion(toolUseId, answers);
422
+ }
423
+
394
424
  /**
395
425
  * Handle a `shutdownInstance` control message from a web client.
396
426
  *
@@ -248,6 +248,9 @@ export function buildOutputHistory(session: ImprovisationSessionManager): Array<
248
248
  */
249
249
  export function setupSessionListeners(ctx: HandlerContext, session: ImprovisationSessionManager, _ws: WSContext, tabId: string): void {
250
250
  session.removeAllListeners();
251
+ // Bind tabId before listeners — the headless runner reads it at executePrompt
252
+ // time to wire AskUserQuestion routing back to this tab's web clients.
253
+ session.setTabId(tabId);
251
254
  const engine = resolveEngineForSession(session);
252
255
 
253
256
  session.on('onHistoryPersisted', () => {
@@ -19,7 +19,7 @@ export interface WSContext {
19
19
  _ws?: unknown
20
20
  }
21
21
 
22
- const CoreMessages = ['execute', 'cancel', 'getHistory', 'getSessions', 'getSessionsCount', 'deleteSession', 'getSessionById', 'clearHistory', 'searchHistory', 'new', 'autocomplete', 'readFile', 'ping', 'initTab', 'resumeSession', 'approve', 'reject', 'recordSelection', 'requestNotificationSummary'] as const;
22
+ const CoreMessages = ['execute', 'cancel', 'getHistory', 'getSessions', 'getSessionsCount', 'deleteSession', 'getSessionById', 'clearHistory', 'searchHistory', 'new', 'autocomplete', 'readFile', 'ping', 'initTab', 'resumeSession', 'approve', 'reject', 'recordSelection', 'requestNotificationSummary', 'askUserQuestionResponse'] as const;
23
23
 
24
24
  const TerminalMessages = ['terminalInit', 'terminalReconnect', 'terminalList', 'terminalInput', 'terminalResize', 'terminalClose'] as const;
25
25
 
@@ -108,7 +108,7 @@ export interface WebSocketMessage {
108
108
  _permission?: 'view';
109
109
  }
110
110
 
111
- const CoreResponseMessages = ['output', 'thinking', 'movementStart', 'movementComplete', 'movementError', 'sessionUpdate', 'history', 'sessions', 'sessionsCount', 'sessionDeleted', 'sessionData', 'historyCleared', 'searchResults', 'newSession', 'autocomplete', 'fileContent', 'error', 'pong', 'tabInitialized', 'approvalRequired', 'toolUse', 'streamingTokens', 'notificationSummary', 'executeAck', 'clientOffline', 'clientAuthExpired'] as const;
111
+ const CoreResponseMessages = ['output', 'thinking', 'movementStart', 'movementComplete', 'movementError', 'sessionUpdate', 'history', 'sessions', 'sessionsCount', 'sessionDeleted', 'sessionData', 'historyCleared', 'searchResults', 'newSession', 'autocomplete', 'fileContent', 'error', 'pong', 'tabInitialized', 'approvalRequired', 'toolUse', 'streamingTokens', 'notificationSummary', 'executeAck', 'clientOffline', 'clientAuthExpired', 'askUserQuestion', 'askUserQuestionDismissed'] as const;
112
112
 
113
113
  const TerminalResponseMessages = ['terminalOutput', 'terminalReady', 'terminalExit', 'terminalError', 'terminalList', 'terminalScrollback', 'terminalCreated', 'terminalClosed'] as const;
114
114
 
@@ -193,6 +193,52 @@ export interface ConnectionData {
193
193
  workingDir: string;
194
194
  }
195
195
 
196
+ // ============================================================================
197
+ // AskUserQuestion Types
198
+ // ============================================================================
199
+
200
+ /**
201
+ * Single option Claude offers in an AskUserQuestion call. Mirrors the
202
+ * Claude Agent SDK shape so we can pass-through with no translation.
203
+ */
204
+ export interface AskUserQuestionOption {
205
+ label: string;
206
+ description: string;
207
+ /** Optional HTML/Markdown preview snippet (when previewFormat is set). */
208
+ preview?: string;
209
+ }
210
+
211
+ /**
212
+ * One question in an AskUserQuestion call. Per Claude SDK: each call
213
+ * carries 1–4 questions, each question 2–4 options. Header is a short
214
+ * label (≤12 chars) used for compact display.
215
+ */
216
+ export interface AskUserQuestionItem {
217
+ question: string;
218
+ header: string;
219
+ options: AskUserQuestionOption[];
220
+ multiSelect: boolean;
221
+ }
222
+
223
+ /** Outbound payload (cli → web) when Claude pauses on an AskUserQuestion. */
224
+ export interface AskUserQuestionPayload {
225
+ toolUseId: string;
226
+ questions: AskUserQuestionItem[];
227
+ }
228
+
229
+ /** Outbound payload (cli → web) when a pending question is no longer answerable. */
230
+ export interface AskUserQuestionDismissedPayload {
231
+ toolUseId: string;
232
+ reason: 'timeout' | 'cancelled' | 'session-ended';
233
+ }
234
+
235
+ /** Inbound payload (web → cli) carrying the user's selections. */
236
+ export interface AskUserQuestionResponseData {
237
+ toolUseId: string;
238
+ /** Map of question text → selected label(s) (joined with ", " for multi-select). */
239
+ answers: Record<string, string>;
240
+ }
241
+
196
242
  export interface SkillEntry {
197
243
  name: string;
198
244
  displayName: string;