@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
6
  import { dirname } from 'node:path';
@@ -8,6 +9,7 @@ import { machineFile } from '../../../bin/machine-layout.js';
8
9
  import { setAgentBrowserHostAttached } from '../../cli/headless/mcp-config.js';
9
10
  import { getBrowserDriverState } from '../browser/driver-state.js';
10
11
  import { toBrowserPreflightPayload } from '../browser/preflight.js';
12
+ import { getChainEngine } from '../chain/chain-engine.js';
11
13
  import { CollabSessionRegistry } from '../collab/registry.js';
12
14
  import { COLLAB_EVENT_BUFFER_MAX_AGE_MS, COLLAB_EVENT_BUFFER_MAX_BYTES, COLLAB_EVENT_BUFFER_MAX_EVENTS, } from '../collab/types.js';
13
15
  import { respawnInstance } from '../instances.js';
@@ -16,6 +18,7 @@ import { parseDeclaredEnvironment, resolveCliEnvironment } from '../platform-url
16
18
  import { getAgentScheduler } from '../schedule/agent-scheduler.js';
17
19
  import { getQualityScheduler } from '../schedule/quality-scheduler.js';
18
20
  import { getScheduler } from '../schedule/scheduler.js';
21
+ import { holdAppToolCallsFor, resumeAppToolCallsFor } from '../sdk/app-tools.js';
19
22
  import { attachSdkBrowserTransport } from '../sdk/browser-transport.js';
20
23
  import { teardownSdkForViewer, teardownSdkOnTransportClose } from '../sdk/revoke.js';
21
24
  import { captureException } from '../sentry.js';
@@ -28,6 +31,7 @@ import { resolvePendingQuestion } from './ask-user-question-bridge.js';
28
31
  import { AutocompleteService } from './autocomplete.js';
29
32
  import { activeBrowserAgentTarget } from './browser-agent-runtime.js';
30
33
  import { attachBrowserHostBroadcast, broadcastAgentTabState, browserHostPolicy, handleBrowserHostMessage, onViewerDisconnected as onBrowserViewerDisconnected, onWsContextClosed as onBrowserWsContextClosed, } from './browser-handlers.js';
34
+ import { moveViewerToContext } from './browser-viewer-lifecycle.js';
31
35
  import { addressToViewer } from './browser-viewer-registry.js';
32
36
  import { handleCollabMessage } from './collab-handlers.js';
33
37
  import { createCommitSuggestionService, GIT_VIEW_TAB_ID, isAnyExecutionActive } from './commit-suggestion-service.js';
@@ -42,7 +46,7 @@ import { FileUploadHandler } from './file-upload-handler.js';
42
46
  import { handleGitMessage } from './git-handlers.js';
43
47
  import { GitHeadWatcher } from './git-head-watcher.js';
44
48
  import { hasScopeBinding, resolveScopeDir } from './handler-context.js';
45
- import { MsgIdTracker } from './msg-id-tracker.js';
49
+ import { MsgIdTracker, MUTATION_SCOPE, mutationMsgId, ReplyRecorder } from './msg-id-tracker.js';
46
50
  import { maybeStartAutoImplement } from './plan-execution-handlers.js';
47
51
  import { handlePlanMessage } from './plan-handlers.js';
48
52
  import { executorCache } from './plan-helpers.js';
@@ -64,7 +68,9 @@ import { buildSnippetListResultData, handleSnippetMessage } from './snippet-hand
64
68
  import { SeqEventBuffer, SeqEventBufferRegistry, TabEventBufferRegistry } from './tab-event-buffer.js';
65
69
  import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSetTabEngine, handleSyncTabMeta } from './tab-handlers.js';
66
70
  import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
71
+ import { parseStreamInterest, ViewerInterestRegistry } from './viewer-interest.js';
67
72
  const log = debugLog('handler');
73
+ export const VIEWER_DETACH_GRACE_MS = 60_000;
68
74
  export class WebSocketImproviseHandler {
69
75
  sessions = new Map();
70
76
  connections = new Map();
@@ -87,12 +93,14 @@ export class WebSocketImproviseHandler {
87
93
  snippetExpander = null;
88
94
  snippetWatcher = null;
89
95
  tabEventBuffers = new TabEventBufferRegistry();
96
+ viewerInterest = new ViewerInterestRegistry();
90
97
  chatTimelines = new ChatTimelineRegistry();
91
98
  pmEventBuffers = new SeqEventBufferRegistry();
92
99
  pmTimelines = new PmTimelineRegistry();
93
100
  collabEventBuffers = new SeqEventBufferRegistry(() => new SeqEventBuffer(COLLAB_EVENT_BUFFER_MAX_EVENTS, COLLAB_EVENT_BUFFER_MAX_AGE_MS, Date.now, COLLAB_EVENT_BUFFER_MAX_BYTES));
94
101
  collabSessions = new CollabSessionRegistry(this);
95
102
  msgIdTracker = new MsgIdTracker();
103
+ replyRecorders = new AsyncLocalStorage();
96
104
  commitSuggestions = null;
97
105
  instanceRegistry;
98
106
  shutdownInProgress = false;
@@ -104,6 +112,9 @@ export class WebSocketImproviseHandler {
104
112
  browserDriverStateDispose = null;
105
113
  currentWorkingDir = null;
106
114
  sessionRoles = new SessionRoleDirectory();
115
+ contextViewers = new Map();
116
+ detachedContexts = new Map();
117
+ deferredCloses = new Map();
107
118
  constructor(instanceRegistry = null, browserHost = null) {
108
119
  this.frecencyPath = machineFile('frecency');
109
120
  const frecencyData = this.loadFrecencyData();
@@ -283,6 +294,7 @@ export class WebSocketImproviseHandler {
283
294
  }
284
295
  if (workingDir) {
285
296
  getScheduler(workingDir, this).start();
297
+ getChainEngine(workingDir, this).start();
286
298
  getAgentScheduler(workingDir, this).start();
287
299
  getQualityScheduler(workingDir).start();
288
300
  }
@@ -316,16 +328,59 @@ export class WebSocketImproviseHandler {
316
328
  const viewerId = msg._viewer ?? ws._viewerId;
317
329
  delete msg._viewer;
318
330
  this.sessionRoles.noteMessage(viewerId, role, ws, { explicitViewerStamp, messageType: msg.type });
319
- await this.dispatchMessage(ws, msg, tabId, workingDir, role, viewerId);
331
+ const msgId = mutationMsgId(msg);
332
+ if (msgId === undefined) {
333
+ await this.dispatchMessage(ws, msg, tabId, workingDir, role, viewerId);
334
+ }
335
+ else {
336
+ await this.dispatchMutationOnce(ws, msg, msgId, viewerId, () => this.dispatchMessage(ws, msg, tabId, workingDir, role, viewerId));
337
+ }
320
338
  }
321
339
  catch (error) {
322
- console.error('[WebSocketImproviseHandler] Error handling message:', error);
323
- captureException(error, { context: 'websocket.handleMessage' });
324
- this.send(ws, {
325
- type: 'error',
326
- data: { message: error instanceof Error ? error.message : String(error) }
340
+ this.replyWithHandlerError(ws, error);
341
+ }
342
+ }
343
+ replyWithHandlerError(ws, error) {
344
+ console.error('[WebSocketImproviseHandler] Error handling message:', error);
345
+ captureException(error, { context: 'websocket.handleMessage' });
346
+ this.send(ws, {
347
+ type: 'error',
348
+ data: { message: error instanceof Error ? error.message : String(error) }
349
+ });
350
+ }
351
+ async dispatchMutationOnce(ws, msg, msgId, viewerId, dispatch) {
352
+ const repeat = this.msgIdTracker.admit(MUTATION_SCOPE, msgId);
353
+ if (repeat) {
354
+ this.answerRepeatedMutation(ws, msg, msgId, repeat, viewerId);
355
+ return;
356
+ }
357
+ const recorder = new ReplyRecorder(ws, msg.type);
358
+ try {
359
+ await this.replyRecorders.run(recorder, async () => {
360
+ try {
361
+ await dispatch();
362
+ }
363
+ catch (error) {
364
+ this.replyWithHandlerError(ws, error);
365
+ }
327
366
  });
328
367
  }
368
+ finally {
369
+ this.msgIdTracker.settle(MUTATION_SCOPE, msgId, recorder.close());
370
+ }
371
+ }
372
+ answerRepeatedMutation(ws, msg, msgId, repeat, viewerId) {
373
+ console.log(`[WebSocketImproviseHandler] ${msg.type} duplicate msgId=${msgId} — ${repeat.replies ? 'replaying its result' : 'ignored'}`);
374
+ if (repeat.replies) {
375
+ for (const frame of repeat.replies)
376
+ this.deliver(ws, frame);
377
+ return;
378
+ }
379
+ this.send(ws, addressToViewer({
380
+ type: 'duplicateIgnored',
381
+ ...(msg.tabId ? { tabId: msg.tabId } : {}),
382
+ data: { msgId, type: msg.type, ...(repeat.pending ? { pending: true } : {}) },
383
+ }, ws, viewerId));
329
384
  }
330
385
  static DISPATCH = {
331
386
  execute: 'session', cancel: 'session', getHistory: 'session', new: 'session', approve: 'session', reject: 'session', queueEnqueuePrompt: 'session', queueRemovePrompt: 'session', queueClearPrompts: 'session',
@@ -337,13 +392,13 @@ export class WebSocketImproviseHandler {
337
392
  listDirectory: 'fileExplorer', writeFile: 'fileExplorer', createFile: 'fileExplorer', createDirectory: 'fileExplorer', deleteFile: 'fileExplorer', renameFile: 'fileExplorer', notifyFileOpened: 'fileExplorer', searchFileContents: 'fileExplorer', cancelSearch: 'fileExplorer', findDefinition: 'fileExplorer',
338
393
  collabJoin: 'collab', collabLeave: 'collab', collabPush: 'collab', collabCursor: 'collab', collabFlush: 'collab',
339
394
  gitStatus: 'git', gitStage: 'git', gitUnstage: 'git', gitCommit: 'git', gitCommitWithAI: 'git', gitPush: 'git', gitPull: 'git', gitLog: 'git', gitDiscoverRepos: 'git', gitSetDirectory: 'git', gitGetRemoteInfo: 'git', gitCreatePR: 'git', gitGeneratePRDescription: 'git', gitListBranches: 'git', gitCheckout: 'git', gitCreateBranch: 'git', gitDeleteBranch: 'git', gitDiff: 'git', gitShowCommit: 'git', gitCommitDiff: 'git', gitListTags: 'git', gitCreateTag: 'git', gitPushTag: 'git', gitWorktreeList: 'git', gitWorktreeCreate: 'git', gitWorktreeCreateAndAssign: 'git', gitWorktreeRemove: 'git', tabWorktreeSwitch: 'git', tabRepoWorktreeSwitch: 'git', gitWorktreePush: 'git', gitWorktreeCreatePR: 'git', gitMergePreview: 'git', gitWorktreeMerge: 'git', gitMergeAbort: 'git', gitMergeComplete: 'git', gitMergeStashPop: 'git', gitMergeDiscardBlockers: 'git',
340
- planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planRestoreBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planExportBoard: 'plan', planImportBoard: 'plan', planDownloadBoard: 'plan', planInstantiateTemplate: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan', queueEnqueueComposerPrompt: 'plan', queueRemoveComposerPrompt: 'plan', queueClearComposerPrompts: 'plan',
395
+ planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planUpdateIssue: 'plan', planReviewDecision: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planRestoreBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planExportBoard: 'plan', planImportBoard: 'plan', planDownloadBoard: 'plan', planInstantiateTemplate: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan', queueEnqueueComposerPrompt: 'plan', queueRemoveComposerPrompt: 'plan', queueClearComposerPrompts: 'plan',
341
396
  getPmTimeline: 'plan',
342
397
  planScheduleCreate: 'schedule', planScheduleUpdate: 'schedule', planScheduleDelete: 'schedule', planScheduleList: 'schedule', planScheduleValidateSource: 'schedule',
343
398
  agentScheduleCreate: 'agentSchedule', agentScheduleUpdate: 'agentSchedule', agentScheduleDelete: 'agentSchedule', agentScheduleList: 'agentSchedule',
344
399
  fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
345
400
  fileDownloadStart: 'fileDownload', fileDownloadCancel: 'fileDownload',
346
- sdkConnect: 'sdk', sdkRequest: 'sdk', sdkAgentRun: 'sdk', sdkPmWrite: 'sdk', sdkPmExecute: 'sdk', sdkPmSchedule: 'sdk', sdkFilesWrite: 'sdk', sdkStorageWrite: 'sdk', sdkCancel: 'sdk', sdkRevoke: 'sdk', sdkConsentDecision: 'sdk', sdkAgentSession: 'sdk', sdkAgentInteract: 'sdk', sdkGitWrite: 'sdk', sdkTerminalWrite: 'sdk', sdkToolsServe: 'sdk', sdkQualityWrite: 'sdk', sdkBrowserWrite: 'sdk', sdkInferenceRun: 'sdk', sdkEndpointsWrite: 'sdk',
401
+ sdkConnect: 'sdk', sdkRequest: 'sdk', sdkAgentRun: 'sdk', sdkPmWrite: 'sdk', sdkPmExecute: 'sdk', sdkPmSchedule: 'sdk', sdkPmChain: 'sdk', sdkFilesWrite: 'sdk', sdkStorageWrite: 'sdk', sdkCancel: 'sdk', sdkRevoke: 'sdk', sdkConsentDecision: 'sdk', sdkAgentSession: 'sdk', sdkAgentInteract: 'sdk', sdkGitWrite: 'sdk', sdkTerminalWrite: 'sdk', sdkToolsServe: 'sdk', sdkQualityWrite: 'sdk', sdkBrowserWrite: 'sdk', sdkInferenceRun: 'sdk', sdkEndpointsWrite: 'sdk',
347
402
  appInstallLocal: 'apps', appServeStatus: 'apps', appStop: 'apps', appUpdate: 'apps', appGreenlightDecision: 'apps', appSpaceList: 'apps', appSpaceAdd: 'apps', appSpaceRemove: 'apps', appSpaceConfigure: 'apps',
348
403
  browser_preflight: 'browser', browser_snapshot: 'browser', browser_resume_driver: 'browser',
349
404
  browser_navigate: 'browser', browser_back: 'browser', browser_forward: 'browser', browser_reload: 'browser', browser_stop: 'browser',
@@ -411,7 +466,7 @@ export class WebSocketImproviseHandler {
411
466
  this.sendPermissionDenied(ws, msg.type);
412
467
  return;
413
468
  }
414
- return handleTerminalMessage(this, ws, msg, tabId, workingDir);
469
+ return handleTerminalMessage(this, ws, msg, tabId, workingDir, viewerId);
415
470
  case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, effectiveDir, role);
416
471
  case 'collab': return handleCollabMessage(this, ws, msg, effectiveDir, role, viewerId);
417
472
  case 'git':
@@ -420,7 +475,7 @@ export class WebSocketImproviseHandler {
420
475
  return;
421
476
  }
422
477
  return handleGitMessage(this, ws, msg, tabId, workingDir);
423
- case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, role);
478
+ case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, role, viewerId);
424
479
  case 'schedule': return handleScheduleMessage(this, ws, msg, tabId, workingDir, role);
425
480
  case 'agentSchedule': return handleAgentScheduleMessage(this, ws, msg, workingDir, role);
426
481
  case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, effectiveDir, role);
@@ -549,6 +604,12 @@ export class WebSocketImproviseHandler {
549
604
  if (can(role, 'editSettings'))
550
605
  handleSetTabEngine(this, ws, msg, tabId, workingDir);
551
606
  return true;
607
+ case 'viewerInterest': {
608
+ const interest = parseStreamInterest(msg.data?.interest);
609
+ if (interest)
610
+ this.viewerInterest.declare(ws, interest);
611
+ return true;
612
+ }
552
613
  default:
553
614
  return false;
554
615
  }
@@ -772,12 +833,25 @@ export class WebSocketImproviseHandler {
772
833
  }
773
834
  }
774
835
  handleClose(ws) {
836
+ const deferred = this.deferredCloses.get(ws);
837
+ if (deferred) {
838
+ clearTimeout(deferred);
839
+ this.deferredCloses.delete(ws);
840
+ }
841
+ this.contextViewers.delete(ws);
842
+ const detached = this.detachedContexts.get(ws);
843
+ if (detached) {
844
+ clearTimeout(detached.timer);
845
+ this.detachedContexts.delete(ws);
846
+ this.killDetachedSearches(detached);
847
+ }
775
848
  const tabMap = this.connections.get(ws);
776
849
  if (tabMap) {
777
850
  this.cleanupConnectionResources(tabMap);
778
851
  }
779
852
  this.connections.delete(ws);
780
853
  this.allConnections.delete(ws);
854
+ this.viewerInterest.forget(ws);
781
855
  cleanupTerminalSubscribers(this, ws);
782
856
  this.sessionRoles.dropWs(ws);
783
857
  if (ws._viewerId) {
@@ -802,34 +876,90 @@ export class WebSocketImproviseHandler {
802
876
  }
803
877
  }
804
878
  handleTransportDetach(ws) {
805
- const tabMap = this.connections.get(ws);
806
- if (tabMap) {
807
- for (const tabId of tabMap.keys()) {
808
- const searchProcess = this.activeSearches.get(tabId);
809
- if (searchProcess) {
810
- try {
811
- searchProcess.kill();
812
- }
813
- catch { }
814
- this.activeSearches.delete(tabId);
815
- }
816
- }
879
+ if (this.detachedContexts.has(ws))
880
+ return;
881
+ const searches = new Map();
882
+ for (const tabId of this.connections.get(ws)?.keys() ?? []) {
883
+ const search = this.activeSearches.get(tabId);
884
+ if (search)
885
+ searches.set(tabId, search);
817
886
  }
818
887
  this.connections.delete(ws);
819
888
  this.allConnections.delete(ws);
889
+ this.viewerInterest.forget(ws);
820
890
  cleanupTerminalSubscribers(this, ws);
821
891
  this.sessionRoles.dropWs(ws);
892
+ this.collabSessions.onTransportDetach();
893
+ for (const viewerId of this.contextViewers.get(ws) ?? [])
894
+ holdAppToolCallsFor(viewerId);
895
+ this.contextViewers.delete(ws);
896
+ const timer = setTimeout(() => this.expireDetachedContext(ws), VIEWER_DETACH_GRACE_MS);
897
+ timer.unref?.();
898
+ this.detachedContexts.set(ws, { searches, resumed: false, timer });
899
+ }
900
+ expireDetachedContext(ws) {
901
+ const detached = this.detachedContexts.get(ws);
902
+ if (!detached)
903
+ return;
904
+ this.detachedContexts.delete(ws);
905
+ if (!detached.resumed)
906
+ this.killDetachedSearches(detached);
822
907
  if (this.browserHost) {
823
908
  try {
824
909
  onBrowserWsContextClosed(this.browserHost, ws);
825
910
  }
826
911
  catch (err) {
827
- captureException(err, { context: 'websocket.handleTransportDetach.browserDetach' });
912
+ captureException(err, { context: 'websocket.detachGrace.browserRelease' });
828
913
  }
829
914
  }
830
- this.collabSessions.onTransportDetach();
915
+ }
916
+ killDetachedSearches(detached) {
917
+ for (const [tabId, search] of detached.searches) {
918
+ if (this.activeSearches.get(tabId) !== search)
919
+ continue;
920
+ try {
921
+ search.kill();
922
+ }
923
+ catch { }
924
+ this.activeSearches.delete(tabId);
925
+ }
926
+ }
927
+ handleViewerConnected(ws, viewerId) {
928
+ let known = this.contextViewers.get(ws);
929
+ if (!known) {
930
+ known = new Set();
931
+ this.contextViewers.set(ws, known);
932
+ }
933
+ known.add(viewerId);
934
+ this.handleWebConnected();
935
+ for (const from of this.detachedContexts.keys())
936
+ moveViewerToContext(viewerId, from, ws);
937
+ resumeAppToolCallsFor(viewerId);
938
+ }
939
+ handleWebConnected() {
940
+ if (this.deferredCloses.size > 0) {
941
+ console.log('[handler] A viewer is back — the pending web_disconnected teardown is cancelled');
942
+ }
943
+ for (const timer of this.deferredCloses.values())
944
+ clearTimeout(timer);
945
+ this.deferredCloses.clear();
946
+ for (const detached of this.detachedContexts.values())
947
+ detached.resumed = true;
948
+ }
949
+ handleWebDisconnected(ws, onClosed) {
950
+ if (this.deferredCloses.has(ws))
951
+ return;
952
+ const timer = setTimeout(() => {
953
+ this.deferredCloses.delete(ws);
954
+ this.handleClose(ws);
955
+ onClosed?.();
956
+ }, VIEWER_DETACH_GRACE_MS);
957
+ timer.unref?.();
958
+ this.deferredCloses.set(ws, timer);
831
959
  }
832
960
  handleBrowserViewerDisconnected(viewerId) {
961
+ for (const known of this.contextViewers.values())
962
+ known.delete(viewerId);
833
963
  teardownSdkForViewer(viewerId);
834
964
  this.sessionRoles.dropViewer(viewerId);
835
965
  this.collabSessions.dropViewer(viewerId);
@@ -952,23 +1082,45 @@ export class WebSocketImproviseHandler {
952
1082
  }
953
1083
  }
954
1084
  send(ws, response) {
955
- try {
956
- ws.send(JSON.stringify(response));
957
- }
958
- catch (error) {
959
- console.error('[WebSocketImproviseHandler] Error sending message:', error);
960
- }
1085
+ this.write(ws, response, this.replyRecorders.getStore());
961
1086
  }
962
1087
  broadcastToOthers(sender, response) {
963
1088
  for (const ws of this.allConnections) {
964
1089
  if (ws !== sender) {
965
- this.send(ws, response);
1090
+ this.write(ws, response);
966
1091
  }
967
1092
  }
968
1093
  }
969
1094
  broadcastToAll(response) {
970
1095
  for (const ws of this.allConnections) {
971
- this.send(ws, response);
1096
+ this.write(ws, response);
1097
+ }
1098
+ }
1099
+ broadcastStream(response) {
1100
+ for (const ws of this.allConnections) {
1101
+ if (!this.viewerInterest.wantsStreams(ws))
1102
+ continue;
1103
+ this.write(ws, response);
1104
+ }
1105
+ }
1106
+ write(ws, response, recorder) {
1107
+ let frame;
1108
+ try {
1109
+ frame = JSON.stringify(response);
1110
+ }
1111
+ catch (error) {
1112
+ console.error('[WebSocketImproviseHandler] Error sending message:', error);
1113
+ return;
1114
+ }
1115
+ recorder?.record(ws, response.type, frame, response._target);
1116
+ this.deliver(ws, frame);
1117
+ }
1118
+ deliver(ws, frame) {
1119
+ try {
1120
+ ws.send(frame);
1121
+ }
1122
+ catch (error) {
1123
+ console.error('[WebSocketImproviseHandler] Error sending message:', error);
972
1124
  }
973
1125
  }
974
1126
  cleanupSession(sessionId) {
@@ -2,27 +2,89 @@
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  const DEFAULT_TTL_MS = 15 * 60 * 1000;
4
4
  const DEFAULT_MAX_PER_TAB = 512;
5
+ const DEFAULT_MAX_MUTATIONS = 4096;
6
+ export const IDEMPOTENT_MUTATION_TYPES = new Set([
7
+ 'writeFile', 'createFile', 'createDirectory', 'deleteFile', 'renameFile',
8
+ 'fileUploadStart', 'fileUploadChunk', 'fileUploadComplete', 'fileUploadCancel',
9
+ 'gitStage', 'gitUnstage', 'gitCommit', 'gitCommitWithAI', 'gitPush', 'gitPull', 'gitSetDirectory',
10
+ 'gitCheckout', 'gitCreateBranch', 'gitDeleteBranch', 'gitCreatePR', 'gitCreateTag', 'gitPushTag',
11
+ 'gitWorktreeCreate', 'gitWorktreeCreateAndAssign', 'gitWorktreeRemove', 'gitWorktreePush', 'gitWorktreeCreatePR',
12
+ 'gitWorktreeMerge', 'gitMergeAbort', 'gitMergeComplete', 'gitMergeStashPop', 'gitMergeDiscardBlockers',
13
+ 'tabWorktreeSwitch', 'tabRepoWorktreeSwitch',
14
+ 'cancel', 'approve', 'reject',
15
+ 'removeTab',
16
+ 'askUserQuestionResponse',
17
+ 'planReviewDecision',
18
+ 'sdkPmWrite', 'sdkFilesWrite', 'sdkStorageWrite', 'sdkGitWrite', 'sdkTerminalWrite', 'sdkQualityWrite',
19
+ 'sdkBrowserWrite', 'sdkEndpointsWrite',
20
+ 'sdkPmChain',
21
+ ]);
22
+ export const MUTATION_SCOPE = '\u0000mutations';
23
+ const MAX_MSG_ID_LENGTH = 128;
24
+ export function mutationMsgId(msg) {
25
+ if (!IDEMPOTENT_MUTATION_TYPES.has(msg.type))
26
+ return undefined;
27
+ const msgId = msg.data?.msgId;
28
+ return typeof msgId === 'string' && msgId.length > 0 && msgId.length <= MAX_MSG_ID_LENGTH ? msgId : undefined;
29
+ }
30
+ const MAX_CACHED_REPLIES = 8;
31
+ const MAX_CACHED_REPLY_BYTES = 4 * 1024;
32
+ export class ReplyRecorder {
33
+ requester;
34
+ requestType;
35
+ frames = [];
36
+ bytes = 0;
37
+ overflowed = false;
38
+ open = true;
39
+ constructor(requester, requestType) {
40
+ this.requester = requester;
41
+ this.requestType = requestType;
42
+ }
43
+ record(to, replyType, frame, target) {
44
+ if (!this.open || this.overflowed || to !== this.requester || target !== undefined)
45
+ return;
46
+ if (this.requestType.startsWith('sdk') ? replyType !== 'sdkResponse' : replyType.startsWith('sdk'))
47
+ return;
48
+ this.bytes += frame.length;
49
+ if (this.frames.length >= MAX_CACHED_REPLIES || this.bytes > MAX_CACHED_REPLY_BYTES) {
50
+ this.overflowed = true;
51
+ return;
52
+ }
53
+ this.frames.push(frame);
54
+ }
55
+ close() {
56
+ this.open = false;
57
+ return this.overflowed || this.frames.length === 0 ? null : this.frames;
58
+ }
59
+ }
5
60
  export class MsgIdTracker {
6
61
  ttlMs;
7
62
  maxPerTab;
8
63
  now;
64
+ maxMutations;
9
65
  tabs = new Map();
10
- constructor(ttlMs = DEFAULT_TTL_MS, maxPerTab = DEFAULT_MAX_PER_TAB, now = Date.now) {
66
+ constructor(ttlMs = DEFAULT_TTL_MS, maxPerTab = DEFAULT_MAX_PER_TAB, now = Date.now, maxMutations = DEFAULT_MAX_MUTATIONS) {
11
67
  this.ttlMs = ttlMs;
12
68
  this.maxPerTab = maxPerTab;
13
69
  this.now = now;
70
+ this.maxMutations = maxMutations;
14
71
  }
15
72
  recordIfFirst(tabId, msgId) {
16
- const entry = this.tabs.get(tabId) ?? { ids: new Map() };
17
- this.evictExpired(entry);
18
- if (entry.ids.has(msgId)) {
19
- entry.ids.set(msgId, this.now());
20
- return false;
21
- }
22
- entry.ids.set(msgId, this.now());
23
- this.enforceSizeCap(entry);
24
- this.tabs.set(tabId, entry);
25
- return true;
73
+ return this.seeIfFirst(tabId, msgId, this.maxPerTab) === null;
74
+ }
75
+ admit(scope, msgId) {
76
+ const seen = this.seeIfFirst(scope, msgId, this.maxMutations, true);
77
+ if (seen === null)
78
+ return null;
79
+ return { replies: seen.replies ?? null, pending: seen.pending === true };
80
+ }
81
+ settle(scope, msgId, replies) {
82
+ const seen = this.tabs.get(scope)?.ids.get(msgId);
83
+ if (!seen)
84
+ return;
85
+ seen.pending = false;
86
+ seen.replies = replies;
87
+ seen.at = this.now();
26
88
  }
27
89
  forget(tabId) {
28
90
  this.tabs.delete(tabId);
@@ -30,19 +92,32 @@ export class MsgIdTracker {
30
92
  size(tabId) {
31
93
  return this.tabs.get(tabId)?.ids.size ?? 0;
32
94
  }
95
+ seeIfFirst(tabId, msgId, cap, pending = false) {
96
+ const entry = this.tabs.get(tabId) ?? { ids: new Map() };
97
+ this.evictExpired(entry);
98
+ const seen = entry.ids.get(msgId);
99
+ if (seen) {
100
+ seen.at = this.now();
101
+ return seen;
102
+ }
103
+ entry.ids.set(msgId, pending ? { at: this.now(), pending } : { at: this.now() });
104
+ this.enforceSizeCap(entry, cap);
105
+ this.tabs.set(tabId, entry);
106
+ return null;
107
+ }
33
108
  evictExpired(entry) {
34
109
  const cutoff = this.now() - this.ttlMs;
35
- for (const [id, ts] of entry.ids) {
36
- if (ts < cutoff)
110
+ for (const [id, seen] of entry.ids) {
111
+ if (seen.at < cutoff && !seen.pending)
37
112
  entry.ids.delete(id);
38
113
  }
39
114
  }
40
- enforceSizeCap(entry) {
41
- while (entry.ids.size > this.maxPerTab) {
42
- const firstKey = entry.ids.keys().next().value;
43
- if (firstKey === undefined)
44
- break;
45
- entry.ids.delete(firstKey);
115
+ enforceSizeCap(entry, cap) {
116
+ for (const [id, seen] of entry.ids) {
117
+ if (entry.ids.size <= cap)
118
+ return;
119
+ if (!seen.pending)
120
+ entry.ids.delete(id);
46
121
  }
47
122
  }
48
123
  }
@@ -1,10 +1,13 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { sendAgentPush } from '../agent-push.js';
3
4
  import { handlePlanPrompt, parseModelDirective, parseRunModel } from '../plan/composer.js';
5
+ import { resolveReadyToWork } from '../plan/dependency-resolver.js';
4
6
  import { parseBoardDirectory, parsePlanDirectory, resolvePmDir } from '../plan/parser.js';
5
7
  import { PM_BOARD_ISSUE_ID, PM_COMPOSER_ISSUE_ID, pmTimelineIssueId } from '../plan/pm-timeline.js';
8
+ import { broadcastPlanStateIfChanged } from '../plan/watcher.js';
6
9
  import { scopeExecutionDir } from './handler-context.js';
7
- import { denyIfViewOnly, executorCache, getExecutor } from './plan-helpers.js';
10
+ import { denyIfViewOnly, executorCache, getExecutor, notifyChainExecutorStatus, notifyChainRunEnded, } from './plan-helpers.js';
8
11
  import { broadcastPmEvent, pmTimelineKey } from './pm-timeline-broadcast.js';
9
12
  import { mergePreUploadedAttachments } from './session-handlers.js';
10
13
  export function handlePrompt(ctx, ws, msg, workingDir, permission) {
@@ -42,6 +45,7 @@ export function wireExecutorEvents(executor, ctx, workingDir, boardId, agentTabI
42
45
  const timelineKey = (issueId) => pmTimelineKey(boardId ?? null, pmTimelineIssueId(issueId));
43
46
  executor.on('statusChanged', (status) => {
44
47
  ctx.broadcastToAll({ type: 'planExecutionProgress', data: { status, ...tag } });
48
+ notifyChainExecutorStatus(workingDir, status);
45
49
  });
46
50
  executor.on('issueStarted', (issue) => {
47
51
  ctx.broadcastToAll({
@@ -80,7 +84,7 @@ export function wireExecutorEvents(executor, ctx, workingDir, boardId, agentTabI
80
84
  ctx.broadcastToAll({ type: 'planExecutionMetrics', data: executor.getMetrics() });
81
85
  const fullState = parsePlanDirectory(workingDir);
82
86
  if (fullState) {
83
- ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
87
+ broadcastPlanStateIfChanged(ctx, workingDir, fullState);
84
88
  }
85
89
  });
86
90
  executor.on('issueError', (data) => {
@@ -110,22 +114,34 @@ export function wireExecutorEvents(executor, ctx, workingDir, boardId, agentTabI
110
114
  executor.on('stateUpdated', () => {
111
115
  const fullState = parsePlanDirectory(workingDir);
112
116
  if (fullState) {
113
- ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
117
+ broadcastPlanStateIfChanged(ctx, workingDir, fullState);
114
118
  }
115
119
  });
116
120
  executor.on('reviewProgress', (data) => {
117
121
  broadcastPmEvent(ctx, timelineKey(data.issueId), 'planReviewProgress', { ...data, ...tag });
122
+ if (data.status === 'awaiting_approval') {
123
+ sendAgentPush({
124
+ event: 'approval_needed',
125
+ tabId: `${boardId ?? 'pm'}::${data.issueId}`,
126
+ workingDirectory: workingDir,
127
+ });
128
+ }
129
+ });
130
+ executor.on('issueAbandoned', (data) => {
131
+ broadcastPmEvent(ctx, timelineKey(data.issueId), 'planIssueAbandoned', { ...data, ...tag });
118
132
  });
119
133
  executor.on('complete', (reason) => {
120
134
  ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, ...tag, metrics: executor.getMetrics() } });
121
135
  ctx.commitSuggestions?.notifyExecutionSettled(settleDir);
136
+ notifyChainRunEnded(workingDir, boardId ?? null, 'complete');
122
137
  });
123
138
  executor.on('error', (error) => {
124
139
  ctx.broadcastToAll({ type: 'planExecutionError', data: { error, ...tag } });
125
140
  ctx.commitSuggestions?.notifyExecutionSettled(settleDir);
141
+ notifyChainRunEnded(workingDir, boardId ?? null, 'error');
126
142
  });
127
143
  }
128
- function broadcastBoardRunPrompt(ctx, boardId, prompt) {
144
+ export function broadcastBoardRunPrompt(ctx, boardId, prompt) {
129
145
  broadcastPmEvent(ctx, pmTimelineKey(boardId, PM_BOARD_ISSUE_ID), 'planExecutionPrompt', {
130
146
  issueId: PM_BOARD_ISSUE_ID,
131
147
  prompt,
@@ -139,12 +155,24 @@ export function maybeStartAutoImplement(ctx, workingDir, boardId) {
139
155
  const boardState = parseBoardDirectory(pmDir, boardId);
140
156
  if (!boardState?.board.autoImplement)
141
157
  return;
158
+ startBoardRunIfIdle(ctx, workingDir, boardId, `Auto-implement board ${boardId}`);
159
+ }
160
+ export function maybeContinueAfterApproval(ctx, workingDir, boardId) {
161
+ const pmDir = resolvePmDir(workingDir);
162
+ if (!pmDir)
163
+ return;
164
+ const boardState = parseBoardDirectory(pmDir, boardId);
165
+ if (!boardState || resolveReadyToWork(boardState.issues).length === 0)
166
+ return;
167
+ startBoardRunIfIdle(ctx, workingDir, boardId, `Continue board ${boardId} after an approval`);
168
+ }
169
+ function startBoardRunIfIdle(ctx, workingDir, boardId, launchPrompt) {
142
170
  const executor = getExecutor(workingDir);
143
171
  if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting')
144
172
  return;
145
173
  const executionDir = scopeExecutionDir(ctx, boardId);
146
174
  wireExecutorEvents(executor, ctx, workingDir, boardId, undefined, executionDir);
147
- broadcastBoardRunPrompt(ctx, boardId, `Auto-implement board ${boardId}`);
175
+ broadcastBoardRunPrompt(ctx, boardId, launchPrompt);
148
176
  ctx.broadcastToAll({ type: 'planExecutionStarted', data: { status: 'executing', boardId } });
149
177
  executor.startBoard(boardId, executionDir).catch(error => {
150
178
  ctx.broadcastToAll({
@@ -4,12 +4,12 @@ import { can } from '@minionry/roles';
4
4
  import { dispatchByType } from './dispatch-util.js';
5
5
  import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleExportBoard as handlePublishExportBoard, handleReorderBoards, handleRestoreBoard, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
6
6
  import { handleExecute, handleExecuteEpic, handleGetPmTimeline, handlePause, handlePrompt, handleResume, handleStop } from './plan-execution-handlers.js';
7
- import { handleDeleteIssue, handleGetIssue, handleGetMilestone, handleGetSprint, handleListIssues, handlePlanInit, handleScaffold, handleUpdateIssue } from './plan-issue-handlers.js';
7
+ import { handleDeleteIssue, handleGetIssue, handleGetMilestone, handleGetSprint, handleListIssues, handlePlanInit, handleReviewDecision, handleScaffold, handleUpdateIssue } from './plan-issue-handlers.js';
8
8
  import { handleActivateSprint, handleCompleteSprint, handleCreateSprint, handleGetSprintArtifacts } from './plan-sprint-handlers.js';
9
9
  import { handleDownloadBoard, handleImportBoard, handleInstantiateTemplate, handleExportBoard as handleTransferExportBoard } from './plan-transfer-handlers.js';
10
10
  import { handleQueueClearComposerPrompts, handleQueueEnqueueComposerPrompt, handleQueueRemoveComposerPrompt } from './prompt-queue-handlers.js';
11
11
  export { cleanupPlanResources } from './plan-helpers.js';
12
- export function buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate) {
12
+ export function buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate, decidedBy = { role: 'owner' }) {
13
13
  return {
14
14
  planInit: () => handlePlanInit(ctx, ws, workingDir),
15
15
  planGetState: () => handlePlanInit(ctx, ws, workingDir),
@@ -17,7 +17,8 @@ export function buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate) {
17
17
  planGetIssue: () => handleGetIssue(ctx, ws, msg, workingDir),
18
18
  planGetSprint: () => handleGetSprint(ctx, ws, msg, workingDir),
19
19
  planGetMilestone: () => handleGetMilestone(ctx, ws, msg, workingDir),
20
- planUpdateIssue: () => handleUpdateIssue(ctx, ws, msg, workingDir, editPlanGate),
20
+ planUpdateIssue: () => handleUpdateIssue(ctx, ws, msg, workingDir, editPlanGate, decidedBy),
21
+ planReviewDecision: () => handleReviewDecision(ctx, ws, msg, workingDir, editPlanGate, decidedBy),
21
22
  planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, editPlanGate),
22
23
  planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, editPlanGate),
23
24
  planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, editPlanGate),
@@ -52,9 +53,13 @@ export function buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate) {
52
53
  queueClearComposerPrompts: () => handleQueueClearComposerPrompts(ctx, ws, msg, workingDir, editPlanGate),
53
54
  };
54
55
  }
55
- export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, role) {
56
+ export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, role,
57
+ viewerId) {
56
58
  const editPlanGate = can(role, 'editPlan') ? undefined : 'view';
57
- const handlers = buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate);
59
+ const handlers = buildPlanHandlers(ctx, ws, msg, workingDir, editPlanGate, {
60
+ role,
61
+ ...(viewerId ? { viewerId } : {}),
62
+ });
58
63
  try {
59
64
  dispatchByType(handlers, 'plan', msg.type, (type) => {
60
65
  ctx.send(ws, { type: 'planError', data: { error: `Unknown plan message type: ${type}` } });