mstro-app 0.4.47 → 0.4.51

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 (69) 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 +3 -0
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  5. package/dist/server/cli/headless/runner.js +1 -0
  6. package/dist/server/cli/headless/runner.js.map +1 -1
  7. package/dist/server/cli/headless/types.d.ts +7 -1
  8. package/dist/server/cli/headless/types.d.ts.map +1 -1
  9. package/dist/server/cli/improvisation-retry.d.ts +1 -1
  10. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  11. package/dist/server/cli/improvisation-retry.js +3 -1
  12. package/dist/server/cli/improvisation-retry.js.map +1 -1
  13. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.js +5 -1
  15. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  16. package/dist/server/cli/improvisation-types.d.ts +6 -0
  17. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  18. package/dist/server/cli/improvisation-types.js.map +1 -1
  19. package/dist/server/services/plan/composer.d.ts.map +1 -1
  20. package/dist/server/services/plan/composer.js +9 -7
  21. package/dist/server/services/plan/composer.js.map +1 -1
  22. package/dist/server/services/settings.d.ts +17 -0
  23. package/dist/server/services/settings.d.ts.map +1 -1
  24. package/dist/server/services/settings.js +18 -2
  25. package/dist/server/services/settings.js.map +1 -1
  26. package/dist/server/services/websocket/file-upload-handler.d.ts +6 -2
  27. package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -1
  28. package/dist/server/services/websocket/file-upload-handler.js +127 -46
  29. package/dist/server/services/websocket/file-upload-handler.js.map +1 -1
  30. package/dist/server/services/websocket/handler.js +4 -4
  31. package/dist/server/services/websocket/handler.js.map +1 -1
  32. package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
  33. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  34. package/dist/server/services/websocket/plan-board-handlers.js +34 -0
  35. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  36. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  37. package/dist/server/services/websocket/plan-handlers.js +2 -1
  38. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  39. package/dist/server/services/websocket/session-handlers.js +2 -2
  40. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  41. package/dist/server/services/websocket/session-initialization.js +4 -4
  42. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  43. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  44. package/dist/server/services/websocket/settings-handlers.js +4 -1
  45. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  46. package/dist/server/services/websocket/tab-handlers.js +2 -2
  47. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  48. package/dist/server/services/websocket/types.d.ts +1 -1
  49. package/dist/server/services/websocket/types.d.ts.map +1 -1
  50. package/dist/server/services/websocket/types.js +1 -1
  51. package/dist/server/services/websocket/types.js.map +1 -1
  52. package/package.json +1 -1
  53. package/server/cli/headless/claude-invoker-process.ts +4 -0
  54. package/server/cli/headless/runner.ts +1 -0
  55. package/server/cli/headless/types.ts +7 -1
  56. package/server/cli/improvisation-retry.ts +3 -0
  57. package/server/cli/improvisation-session-manager.ts +5 -1
  58. package/server/cli/improvisation-types.ts +6 -0
  59. package/server/services/plan/composer.ts +10 -7
  60. package/server/services/settings.ts +29 -2
  61. package/server/services/websocket/file-upload-handler.ts +159 -51
  62. package/server/services/websocket/handler.ts +4 -4
  63. package/server/services/websocket/plan-board-handlers.ts +42 -0
  64. package/server/services/websocket/plan-handlers.ts +2 -1
  65. package/server/services/websocket/session-handlers.ts +2 -2
  66. package/server/services/websocket/session-initialization.ts +4 -4
  67. package/server/services/websocket/settings-handlers.ts +4 -1
  68. package/server/services/websocket/tab-handlers.ts +2 -2
  69. package/server/services/websocket/types.ts +1 -1
@@ -89,7 +89,7 @@ function prepareAttachmentPrompt(
89
89
  (warning) => {
90
90
  ctx.broadcastToAll({
91
91
  type: 'planPromptProgress',
92
- data: { message: warning },
92
+ data: { message: warning, boardId: effectiveBoardId ?? null },
93
93
  });
94
94
  },
95
95
  );
@@ -287,10 +287,12 @@ User request: ${userPrompt}`;
287
287
  const { prompt: finalPrompt, imageAttachments, sessionId: attachmentSessionId } =
288
288
  prepareAttachmentPrompt(ctx, enrichedPrompt, attachments, workingDir, cc.effectiveBoardId);
289
289
 
290
+ const streamBoardId = cc.effectiveBoardId ?? null;
291
+
290
292
  try {
291
293
  ctx.broadcastToAll({
292
294
  type: 'planPromptProgress',
293
- data: { message: 'Starting project planning...' },
295
+ data: { message: 'Starting project planning...', boardId: streamBoardId },
294
296
  });
295
297
 
296
298
  const runner = new ResilientRunner({
@@ -305,7 +307,7 @@ User request: ${userPrompt}`;
305
307
  outputCallback: (text: string) => {
306
308
  ctx.send(ws, {
307
309
  type: 'planPromptStreaming',
308
- data: { token: text },
310
+ data: { token: text, boardId: streamBoardId },
309
311
  });
310
312
  },
311
313
  toolUseCallback: (() => {
@@ -315,7 +317,7 @@ User request: ${userPrompt}`;
315
317
  if (message) {
316
318
  ctx.broadcastToAll({
317
319
  type: 'planPromptProgress',
318
- data: { message },
320
+ data: { message, boardId: streamBoardId },
319
321
  });
320
322
  }
321
323
  };
@@ -326,14 +328,14 @@ User request: ${userPrompt}`;
326
328
 
327
329
  ctx.broadcastToAll({
328
330
  type: 'planPromptProgress',
329
- data: { message: 'Claude is planning your project...' },
331
+ data: { message: 'Claude is planning your project...', boardId: streamBoardId },
330
332
  });
331
333
 
332
334
  const result = await runner.run();
333
335
 
334
336
  ctx.broadcastToAll({
335
337
  type: 'planPromptProgress',
336
- data: { message: 'Finalizing project plan...' },
338
+ data: { message: 'Finalizing project plan...', boardId: streamBoardId },
337
339
  });
338
340
 
339
341
  ctx.send(ws, {
@@ -342,6 +344,7 @@ User request: ${userPrompt}`;
342
344
  response: result.completed ? 'Prompt executed successfully.' : (result.error || 'Unknown error'),
343
345
  success: result.completed,
344
346
  error: result.error || null,
347
+ boardId: streamBoardId,
345
348
  },
346
349
  });
347
350
 
@@ -353,7 +356,7 @@ User request: ${userPrompt}`;
353
356
  } catch (error) {
354
357
  ctx.send(ws, {
355
358
  type: 'planError',
356
- data: { error: error instanceof Error ? error.message : String(error) },
359
+ data: { error: error instanceof Error ? error.message : String(error), boardId: streamBoardId },
357
360
  });
358
361
  } finally {
359
362
  cleanupAttachments(workingDir, attachmentSessionId);
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Structure:
10
10
  * {
11
- * "model": "opus"
11
+ * "model": "opus",
12
+ * "effortLevel": "auto"
12
13
  * }
13
14
  */
14
15
 
@@ -26,12 +27,22 @@ export interface MstroSettings {
26
27
  * - Any other string is passed as --model <value>
27
28
  */
28
29
  model: string
30
+ /**
31
+ * Effort level for main execution, passed to Claude Code as --effort.
32
+ * - 'auto' means don't pass --effort (let Claude Code use its per-model default:
33
+ * xhigh on Opus 4.7, high on Sonnet 4.6).
34
+ * - Any other string is passed as --effort <value>. Claude Code silently falls
35
+ * back to the highest supported level when a model doesn't support the value
36
+ * (e.g. xhigh → high on Sonnet 4.6). Haiku ignores it entirely.
37
+ */
38
+ effortLevel: string
29
39
  /** Per-repo preferred PR base branch, keyed by normalized remote URL */
30
40
  prBaseBranches?: Record<string, string>
31
41
  }
32
42
 
33
43
  const DEFAULT_SETTINGS: MstroSettings = {
34
- model: 'opus'
44
+ model: 'opus',
45
+ effortLevel: 'auto'
35
46
  }
36
47
 
37
48
  /**
@@ -90,6 +101,22 @@ export function setModel(model: string): void {
90
101
  saveSettings(settings)
91
102
  }
92
103
 
104
+ /**
105
+ * Get the current effort level setting
106
+ */
107
+ export function getEffortLevel(): string {
108
+ return getSettings().effortLevel
109
+ }
110
+
111
+ /**
112
+ * Update just the effort level setting
113
+ */
114
+ export function setEffortLevel(effortLevel: string): void {
115
+ const settings = getSettings()
116
+ settings.effortLevel = effortLevel
117
+ saveSettings(settings)
118
+ }
119
+
93
120
  /** Normalize a remote URL into a stable key (e.g. "github.com/owner/repo") */
94
121
  function normalizeRemoteUrl(remoteUrl: string): string {
95
122
  return remoteUrl
@@ -4,13 +4,16 @@
4
4
  /**
5
5
  * Chunked File Upload Handler
6
6
  *
7
- * Receives files in chunks over WebSocket from remote web clients,
8
- * writes them to .mstro/tmp/attachments/{tabId}/, and sends progress acks back.
7
+ * Receives files in chunks over WebSocket from remote web clients.
8
+ * - When `targetPath` is provided: streams to <workingDir>/<targetPath> (drag-drop into file tree).
9
+ * - Otherwise: streams to .mstro/tmp/attachments/{tabId}/ (prompt attachments).
9
10
  */
10
11
 
11
12
  import type { WriteStream } from 'node:fs';
12
13
  import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
13
- import { join } from 'node:path';
14
+ import { dirname, join } from 'node:path';
15
+ import { containsDangerousPatterns, validatePathWithinWorkingDir } from '../pathUtils.js';
16
+ import type { HandlerContext } from './handler-context.js';
14
17
  import type { WebSocketResponse, WSContext } from './types.js';
15
18
 
16
19
  interface UploadState {
@@ -22,6 +25,8 @@ interface UploadState {
22
25
  totalChunks: number;
23
26
  receivedChunks: number;
24
27
  filePath: string;
28
+ /** Path relative to workingDir (only set when destination is the file tree, not the attachments dir) */
29
+ targetRelPath?: string;
25
30
  stream: WriteStream;
26
31
  lastActivity: number;
27
32
  }
@@ -44,7 +49,10 @@ export class FileUploadHandler {
44
49
  private completedUploads = new Map<string, CompletedUpload[]>(); // tabId -> completed uploads
45
50
  private cleanupInterval: ReturnType<typeof setInterval>;
46
51
 
47
- constructor(private workingDir: string) {
52
+ constructor(
53
+ private workingDir: string,
54
+ private ctx?: HandlerContext,
55
+ ) {
48
56
  // Periodically clean up stale uploads
49
57
  this.cleanupInterval = setInterval(() => this.cleanupStaleUploads(), 30_000);
50
58
  }
@@ -65,59 +73,48 @@ export class FileUploadHandler {
65
73
  ws: WSContext,
66
74
  send: (ws: WSContext, response: WebSocketResponse) => void,
67
75
  tabId: string,
68
- data: { uploadId: string; fileName: string; fileSize: number; mimeType: string; isImage: boolean; totalChunks: number }
76
+ data: { uploadId: string; fileName: string; fileSize: number; mimeType: string; isImage: boolean; totalChunks: number; targetPath?: string; overwrite?: boolean },
77
+ permission?: 'view',
69
78
  ): void {
70
- const { uploadId, fileName, fileSize, mimeType, isImage, totalChunks } = data;
79
+ const { uploadId, fileName, fileSize, mimeType, isImage, totalChunks, targetPath, overwrite } = data;
80
+ const sendError = (error: string) => send(ws, {
81
+ type: 'fileUploadError' as WebSocketResponse['type'],
82
+ tabId,
83
+ data: { uploadId, error },
84
+ });
71
85
 
72
- // Validate file size
73
86
  if (fileSize > MAX_FILE_SIZE) {
74
- send(ws, {
75
- type: 'fileUploadError' as WebSocketResponse['type'],
76
- tabId,
77
- data: { uploadId, error: `File too large: ${(fileSize / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` }
78
- });
87
+ sendError(`File too large: ${(fileSize / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`);
79
88
  return;
80
89
  }
81
90
 
82
- // Create attachment directory
83
- const attachDir = join(this.workingDir, '.mstro', 'tmp', 'attachments', tabId);
84
- if (!existsSync(attachDir)) {
85
- mkdirSync(attachDir, { recursive: true });
86
- }
91
+ const resolved = targetPath !== undefined
92
+ ? resolveFileTreeTarget({ targetPath, fileName, workingDir: this.workingDir, permission, overwrite })
93
+ : resolveAttachmentTarget({ workingDir: this.workingDir, tabId, fileName });
87
94
 
88
- // Handle duplicate file names
89
- let targetFileName = fileName;
90
- let counter = 1;
91
- while (existsSync(join(attachDir, targetFileName))) {
92
- const ext = fileName.lastIndexOf('.') !== -1 ? fileName.slice(fileName.lastIndexOf('.')) : '';
93
- const base = fileName.lastIndexOf('.') !== -1 ? fileName.slice(0, fileName.lastIndexOf('.')) : fileName;
94
- targetFileName = `${base}-${counter}${ext}`;
95
- counter++;
95
+ if (!resolved.ok) {
96
+ sendError(resolved.error);
97
+ return;
96
98
  }
97
99
 
98
- const filePath = join(attachDir, targetFileName);
99
- const stream = createWriteStream(filePath);
100
-
101
- const uploadState: UploadState = {
100
+ this.activeUploads.set(uploadId, {
102
101
  uploadId,
103
- fileName: targetFileName,
102
+ fileName: resolved.fileName,
104
103
  fileSize,
105
104
  mimeType,
106
105
  isImage,
107
106
  totalChunks,
108
107
  receivedChunks: 0,
109
- filePath,
110
- stream,
108
+ filePath: resolved.filePath,
109
+ targetRelPath: resolved.targetRelPath,
110
+ stream: createWriteStream(resolved.filePath),
111
111
  lastActivity: Date.now(),
112
- };
113
-
114
- this.activeUploads.set(uploadId, uploadState);
112
+ });
115
113
 
116
- // Send ack for start
117
114
  send(ws, {
118
115
  type: 'fileUploadAck' as WebSocketResponse['type'],
119
116
  tabId,
120
- data: { uploadId, chunkIndex: -1, status: 'ok' }
117
+ data: { uploadId, chunkIndex: -1, status: 'ok' },
121
118
  });
122
119
  }
123
120
 
@@ -183,27 +180,50 @@ export class FileUploadHandler {
183
180
  // Verify file was written
184
181
  try {
185
182
  const stat = statSync(upload.filePath);
186
- const completed: CompletedUpload = {
187
- uploadId,
188
- fileName: upload.fileName,
189
- filePath: upload.filePath,
190
- isImage: upload.isImage,
191
- mimeType: upload.mimeType,
192
- fileSize: stat.size,
193
- };
194
-
195
- // Store completed upload for this tab
196
- const tabUploads = this.completedUploads.get(tabId) || [];
197
- tabUploads.push(completed);
198
- this.completedUploads.set(tabId, tabUploads);
183
+
184
+ // Only stash as a pending prompt attachment for the attachment-path case.
185
+ // File-tree drops land at the final destination directly.
186
+ if (upload.targetRelPath === undefined) {
187
+ const completed: CompletedUpload = {
188
+ uploadId,
189
+ fileName: upload.fileName,
190
+ filePath: upload.filePath,
191
+ isImage: upload.isImage,
192
+ mimeType: upload.mimeType,
193
+ fileSize: stat.size,
194
+ };
195
+ const tabUploads = this.completedUploads.get(tabId) || [];
196
+ tabUploads.push(completed);
197
+ this.completedUploads.set(tabId, tabUploads);
198
+ }
199
199
 
200
200
  this.activeUploads.delete(uploadId);
201
201
 
202
202
  send(ws, {
203
203
  type: 'fileUploadReady' as WebSocketResponse['type'],
204
204
  tabId,
205
- data: { uploadId, filePath: upload.filePath, fileName: upload.fileName }
205
+ data: {
206
+ uploadId,
207
+ filePath: upload.filePath,
208
+ fileName: upload.fileName,
209
+ ...(upload.targetRelPath !== undefined ? { targetPath: upload.targetRelPath } : {}),
210
+ },
206
211
  });
212
+
213
+ // Broadcast fileCreated so all connected clients (including the uploader)
214
+ // refresh their file tree. The final path may differ from the requested
215
+ // targetPath due to counter-based disambiguation, so we broadcast the resolved path.
216
+ if (upload.targetRelPath !== undefined && this.ctx) {
217
+ this.ctx.broadcastToAll({
218
+ type: 'fileCreated' as WebSocketResponse['type'],
219
+ data: {
220
+ path: upload.targetRelPath,
221
+ name: upload.fileName,
222
+ size: stat.size,
223
+ modifiedAt: new Date().toISOString(),
224
+ },
225
+ });
226
+ }
207
227
  } catch (err) {
208
228
  const errorMsg = err instanceof Error ? err.message : String(err);
209
229
  send(ws, {
@@ -257,3 +277,91 @@ export class FileUploadHandler {
257
277
  }
258
278
  }
259
279
  }
280
+
281
+ type ResolveResult =
282
+ | { ok: true; filePath: string; fileName: string; targetRelPath?: string }
283
+ | { ok: false; error: string };
284
+
285
+ function splitExt(fileName: string): { base: string; ext: string } {
286
+ const dot = fileName.lastIndexOf('.');
287
+ if (dot <= 0) return { base: fileName, ext: '' };
288
+ return { base: fileName.slice(0, dot), ext: fileName.slice(dot) };
289
+ }
290
+
291
+ function ensureDir(dir: string): void {
292
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
293
+ }
294
+
295
+ function resolveFileTreeTarget(opts: {
296
+ targetPath: string;
297
+ fileName: string;
298
+ workingDir: string;
299
+ permission?: 'view';
300
+ overwrite?: boolean;
301
+ }): ResolveResult {
302
+ const { targetPath, fileName, workingDir, permission, overwrite } = opts;
303
+
304
+ if (permission === 'view') {
305
+ return { ok: false, error: 'View-only sessions cannot upload files' };
306
+ }
307
+ if (containsDangerousPatterns(targetPath) || containsDangerousPatterns(fileName)) {
308
+ return { ok: false, error: 'Invalid characters in target path' };
309
+ }
310
+
311
+ const validation = validatePathWithinWorkingDir(targetPath, workingDir);
312
+ if (!validation.valid) {
313
+ return { ok: false, error: validation.error ?? 'Invalid target path' };
314
+ }
315
+
316
+ const resolvedPath = validation.resolvedPath;
317
+ const parent = dirname(resolvedPath);
318
+ try {
319
+ ensureDir(parent);
320
+ } catch (err) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ return { ok: false, error: `Failed to create parent directory: ${msg}` };
323
+ }
324
+
325
+ const lastSlash = targetPath.lastIndexOf('/');
326
+ const relDir = lastSlash >= 0 ? targetPath.slice(0, lastSlash) : '';
327
+
328
+ let finalName = fileName;
329
+ let finalAbs = resolvedPath;
330
+ if (existsSync(finalAbs) && !overwrite) {
331
+ const { base, ext } = splitExt(fileName);
332
+ let counter = 1;
333
+ do {
334
+ finalName = `${base} (${counter})${ext}`;
335
+ finalAbs = join(parent, finalName);
336
+ counter++;
337
+ } while (existsSync(finalAbs));
338
+ }
339
+
340
+ const finalRel = relDir ? `${relDir}/${finalName}` : finalName;
341
+ return { ok: true, filePath: finalAbs, fileName: finalName, targetRelPath: finalRel };
342
+ }
343
+
344
+ function resolveAttachmentTarget(opts: {
345
+ workingDir: string;
346
+ tabId: string;
347
+ fileName: string;
348
+ }): ResolveResult {
349
+ const { workingDir, tabId, fileName } = opts;
350
+ const attachDir = join(workingDir, '.mstro', 'tmp', 'attachments', tabId);
351
+ try {
352
+ ensureDir(attachDir);
353
+ } catch (err) {
354
+ const msg = err instanceof Error ? err.message : String(err);
355
+ return { ok: false, error: `Failed to create attachment directory: ${msg}` };
356
+ }
357
+
358
+ let finalName = fileName;
359
+ let counter = 1;
360
+ while (existsSync(join(attachDir, finalName))) {
361
+ const { base, ext } = splitExt(fileName);
362
+ finalName = `${base}-${counter}${ext}`;
363
+ counter++;
364
+ }
365
+
366
+ return { ok: true, filePath: join(attachDir, finalName), fileName: finalName };
367
+ }
@@ -220,20 +220,20 @@ export class WebSocketImproviseHandler implements HandlerContext {
220
220
  case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
221
221
  case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
222
222
  case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
223
- case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
223
+ case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir, permission);
224
224
  }
225
225
  }
226
226
 
227
- private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
227
+ private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
228
228
  if (!this.fileUploadHandler) {
229
- this.fileUploadHandler = new FileUploadHandler(workingDir);
229
+ this.fileUploadHandler = new FileUploadHandler(workingDir, this);
230
230
  }
231
231
  const handler = this.fileUploadHandler;
232
232
  const send = this.send.bind(this);
233
233
 
234
234
  switch (msg.type) {
235
235
  case 'fileUploadStart':
236
- handler.handleUploadStart(ws, send, tabId, msg.data);
236
+ handler.handleUploadStart(ws, send, tabId, msg.data, permission);
237
237
  break;
238
238
  case 'fileUploadChunk':
239
239
  handler.handleUploadChunk(ws, send, tabId, msg.data);
@@ -209,6 +209,48 @@ export function handleArchiveBoard(
209
209
  }
210
210
  }
211
211
 
212
+ export function handleRestoreBoard(
213
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
214
+ workingDir: string, permission?: 'view',
215
+ ): void {
216
+ if (denyIfViewOnly(ctx, ws, permission)) return;
217
+
218
+ const boardId = msg.data?.boardId;
219
+ if (!boardId) {
220
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
221
+ return;
222
+ }
223
+
224
+ const pmDir = resolvePmDir(workingDir);
225
+ if (!pmDir) return;
226
+
227
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
228
+ if (!existsSync(boardMdPath)) {
229
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
230
+ return;
231
+ }
232
+
233
+ let content = readFileSync(boardMdPath, 'utf-8');
234
+ content = replaceFrontMatterField(content, 'status', 'active');
235
+ writeFileSync(boardMdPath, content, 'utf-8');
236
+
237
+ const workspacePath = join(pmDir, 'workspace.json');
238
+ if (existsSync(workspacePath)) {
239
+ const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
240
+ if (!workspace.boardOrder.includes(boardId)) {
241
+ workspace.boardOrder.push(boardId);
242
+ }
243
+ workspace.activeBoardId = boardId;
244
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
245
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
246
+ }
247
+
248
+ const boardState = parseBoardDirectory(pmDir, boardId);
249
+ if (boardState) {
250
+ ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
251
+ }
252
+ }
253
+
212
254
  export function handleGetBoard(
213
255
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
214
256
  workingDir: string,
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { HandlerContext } from './handler-context.js';
12
- import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
12
+ import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleRestoreBoard, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
13
13
  import { handleExecute, handleExecuteEpic, handlePause, handlePrompt, handleResume, handleStop } from './plan-execution-handlers.js';
14
14
  import { handleCreateIssue, handleDeleteIssue, handleGetIssue, handleGetMilestone, handleGetSprint, handleListIssues, handlePlanInit, handleScaffold, handleUpdateIssue } from './plan-issue-handlers.js';
15
15
  import { handleActivateSprint, handleCompleteSprint, handleCreateSprint, handleGetSprintArtifacts } from './plan-sprint-handlers.js';
@@ -47,6 +47,7 @@ export function handlePlanMessage(
47
47
  planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
48
48
  planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
49
49
  planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
50
+ planRestoreBoard: () => handleRestoreBoard(ctx, ws, msg, workingDir, permission),
50
51
  planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
51
52
  planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
52
53
  planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  import type { FileAttachment, ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
5
- import { getModel } from '../settings.js';
5
+ import { getEffortLevel, getModel } from '../settings.js';
6
6
  import type { HandlerContext } from './handler-context.js';
7
7
  import { runQualityScan } from './quality-service.js';
8
8
  import { resolveSkillPrompt } from './skill-handlers.js';
@@ -240,7 +240,7 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
240
240
  case 'new': {
241
241
  const oldSession = requireSession(ctx, ws, tabId);
242
242
  const oldSessionId = oldSession.getSessionInfo().sessionId;
243
- const newSession = oldSession.startNewSession({ model: getModel() });
243
+ const newSession = oldSession.startNewSession({ model: getModel(), effortLevel: getEffortLevel() });
244
244
  oldSession.destroy();
245
245
  ctx.sessions.delete(oldSessionId);
246
246
  setupSessionListeners(ctx, newSession, ws, tabId);
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
5
- import { getModel } from '../settings.js';
5
+ import { getEffortLevel, getModel } from '../settings.js';
6
6
  import type { HandlerContext } from './handler-context.js';
7
7
  import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
8
8
  import type { SessionRegistry } from './session-registry.js';
@@ -78,7 +78,7 @@ export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: s
78
78
  }
79
79
 
80
80
  // 3. Create new session
81
- const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
81
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel(), effortLevel: getEffortLevel() });
82
82
  setupSessionListeners(ctx, session, ws, tabId);
83
83
 
84
84
  const sessionId = session.getSessionInfo().sessionId;
@@ -134,10 +134,10 @@ export async function resumeHistoricalSession(
134
134
  let isNewSession = false;
135
135
 
136
136
  try {
137
- session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel() });
137
+ session = ImprovisationSessionManager.resumeFromHistory(workingDir, historicalSessionId, { model: getModel(), effortLevel: getEffortLevel() });
138
138
  } catch (error: unknown) {
139
139
  console.warn(`[WebSocketImproviseHandler] Could not resume session ${historicalSessionId}: ${error instanceof Error ? error.message : String(error)}. Creating new session.`);
140
- session = new ImprovisationSessionManager({ workingDir, model: getModel() });
140
+ session = new ImprovisationSessionManager({ workingDir, model: getModel(), effortLevel: getEffortLevel() });
141
141
  isNewSession = true;
142
142
  }
143
143
 
@@ -4,7 +4,7 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { getSettings, setModel } from '../settings.js';
7
+ import { getSettings, setEffortLevel, setModel } from '../settings.js';
8
8
  import type { HandlerContext } from './handler-context.js';
9
9
  import type { WebSocketMessage, WSContext } from './types.js';
10
10
 
@@ -16,6 +16,9 @@ export function handleUpdateSettings(ctx: HandlerContext, _ws: WSContext, msg: W
16
16
  if (msg.data?.model !== undefined) {
17
17
  setModel(msg.data.model);
18
18
  }
19
+ if (msg.data?.effortLevel !== undefined) {
20
+ setEffortLevel(msg.data.effortLevel);
21
+ }
19
22
  ctx.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
20
23
  }
21
24
 
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
5
- import { getModel } from '../settings.js';
5
+ import { getEffortLevel, getModel } from '../settings.js';
6
6
  import type { HandlerContext } from './handler-context.js';
7
7
  import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
8
8
  import type { WebSocketMessage, WSContext } from './types.js';
@@ -130,7 +130,7 @@ export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workin
130
130
  return;
131
131
  }
132
132
 
133
- const session = new ImprovisationSessionManager({ workingDir, model: getModel() });
133
+ const session = new ImprovisationSessionManager({ workingDir, model: getModel(), effortLevel: getEffortLevel() });
134
134
  setupSessionListeners(ctx, session, ws, tabId);
135
135
 
136
136
  const sessionId = session.getSessionInfo().sessionId;
@@ -48,7 +48,7 @@ const FileUploadMessages = ['fileUploadStart', 'fileUploadChunk', 'fileUploadCom
48
48
 
49
49
  const PlanMessages = ['planInit', 'planGetState', 'planListIssues', 'planGetIssue', 'planGetSprint', 'planGetMilestone', 'planCreateIssue', 'planUpdateIssue', 'planDeleteIssue', 'planScaffold', 'planPrompt', 'planExecute', 'planExecuteEpic', 'planPause', 'planStop', 'planResume'] as const;
50
50
 
51
- const PlanBoardMessages = ['planCreateBoard', 'planUpdateBoard', 'planArchiveBoard', 'planGetBoard', 'planGetBoardState', 'planReorderBoards', 'planSetActiveBoard', 'planGetBoardArtifacts'] as const;
51
+ const PlanBoardMessages = ['planCreateBoard', 'planUpdateBoard', 'planArchiveBoard', 'planRestoreBoard', 'planGetBoard', 'planGetBoardState', 'planReorderBoards', 'planSetActiveBoard', 'planGetBoardArtifacts'] as const;
52
52
 
53
53
  const PlanSprintMessages = ['planCreateSprint', 'planActivateSprint', 'planCompleteSprint', 'planGetSprintArtifacts'] as const;
54
54