@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
@@ -8,6 +8,23 @@ import { PlanWatcher } from '../plan/watcher.js';
8
8
  export const watcherCache = new Map();
9
9
  export const executorCache = new Map();
10
10
  export const schedulerCache = new Map();
11
+ export const chainEngineCache = new Map();
12
+ export function notifyChainRunEnded(workingDir, boardId, signal) {
13
+ try {
14
+ chainEngineCache.get(workingDir)?.notifyRunEnded(boardId, signal);
15
+ }
16
+ catch (error) {
17
+ console.error('[chain] notifyRunEnded failed:', error);
18
+ }
19
+ }
20
+ export function notifyChainExecutorStatus(workingDir, status) {
21
+ try {
22
+ chainEngineCache.get(workingDir)?.notifyExecutorStatus(status);
23
+ }
24
+ catch (error) {
25
+ console.error('[chain] notifyExecutorStatus failed:', error);
26
+ }
27
+ }
11
28
  export function resolvePlanPath(workingDir, relativePath) {
12
29
  const pmDir = resolvePmDir(workingDir);
13
30
  if (!pmDir)
@@ -68,6 +85,7 @@ labels: []
68
85
  | todo | ready | Refined and queued for an agent |
69
86
  | in_progress | started | An agent is working it now |
70
87
  | in_review | started | Agent finished; awaiting the AI review gate |
88
+ | awaiting_approval | started | Passed AI review; waiting for a person to approve |
71
89
  | done | completed | Passed review |
72
90
  | cancelled | cancelled | Will not be done |
73
91
  `;
@@ -128,6 +146,11 @@ export function cleanupPlanResources(workingDir) {
128
146
  scheduler.stop();
129
147
  schedulerCache.delete(workingDir);
130
148
  }
149
+ const chainEngine = chainEngineCache.get(workingDir);
150
+ if (chainEngine) {
151
+ chainEngine.stop();
152
+ chainEngineCache.delete(workingDir);
153
+ }
131
154
  }
132
155
  export function scaffoldPmDirectory(workingDir, name) {
133
156
  const planDir = defaultPmDir(workingDir);
@@ -1,13 +1,16 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
- import { basename } from 'node:path';
4
+ import { basename, relative, sep } from 'node:path';
5
+ import { resolveBoardDir, tryCompleteBoardIfDone } from '../plan/board-config.js';
5
6
  import { getPlanningStatus } from '../plan/composer.js';
6
7
  import { checkAllAcceptanceCriteria, replaceFrontMatterField } from '../plan/front-matter.js';
7
- import { parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists } from '../plan/parser.js';
8
+ import { NO_SPECS_DIR_ERROR, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
9
+ import { latestReviewVerdict, writeReviewApproval } from '../plan/review-approval.js';
8
10
  import { tryCompleteParentEpic } from '../plan/state-reconciler.js';
9
11
  import { hasScopeBinding, setScopeRepo, setScopeWorktree } from './handler-context.js';
10
- import { denyIfViewOnly, executorCache, formatYamlValue, getWatcher, resolvePlanPath, scaffoldPmDirectory } from './plan-helpers.js';
12
+ import { maybeContinueAfterApproval } from './plan-execution-handlers.js';
13
+ import { denyIfViewOnly, executorCache, formatYamlValue, getWatcher, notifyChainRunEnded, resolvePlanPath, scaffoldPmDirectory } from './plan-helpers.js';
11
14
  function restoreBoardScopes(ctx, boardWorktrees) {
12
15
  for (const [boardId, entry] of Object.entries(boardWorktrees)) {
13
16
  if (hasScopeBinding(ctx, boardId))
@@ -95,7 +98,35 @@ export function handleGetMilestone(ctx, ws, msg, workingDir) {
95
98
  }
96
99
  ctx.send(ws, { type: 'planMilestone', data: milestone });
97
100
  }
98
- export function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
101
+ function recordStatusOverrideIfParked(workingDir, path, patchedStatus, decidedBy) {
102
+ const current = typeof patchedStatus === 'string' ? parseSingleIssue(workingDir, path) : null;
103
+ if (!current || current.reviewGate !== 'required' || current.status !== 'awaiting_approval')
104
+ return null;
105
+ const pmDir = resolvePmDir(workingDir);
106
+ const boardId = current.path.match(/^boards\/([^/]+)\//)?.[1];
107
+ const boardDir = pmDir && boardId ? resolveBoardDir(pmDir, boardId) : null;
108
+ if (!boardDir)
109
+ return `Could not resolve the board for ${current.id} — nothing was changed`;
110
+ const decidedAt = new Date();
111
+ const record = {
112
+ issueId: current.id,
113
+ decision: patchedStatus === 'done' ? 'approve' : 'reject',
114
+ comment: null,
115
+ decidedAt: decidedAt.toISOString(),
116
+ decidedBy,
117
+ via: 'status_override',
118
+ verdictFile: latestReviewVerdict(boardDir, current.id)?.fileName ?? null,
119
+ };
120
+ try {
121
+ writeReviewApproval(boardDir, record, decidedAt.getTime());
122
+ return null;
123
+ }
124
+ catch (error) {
125
+ return `Could not record the decision for ${current.id}: ${errorText(error)} — nothing was changed`;
126
+ }
127
+ }
128
+ export function handleUpdateIssue(ctx, ws, msg, workingDir, permission,
129
+ decidedBy = { role: 'owner' }) {
99
130
  if (denyIfViewOnly(ctx, ws, permission))
100
131
  return;
101
132
  const { path, fields } = msg.data || {};
@@ -108,6 +139,11 @@ export function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
108
139
  ctx.send(ws, { type: 'planError', data: { error: `File not found: ${path}` } });
109
140
  return;
110
141
  }
142
+ const statusOverrideError = recordStatusOverrideIfParked(workingDir, path, fields.status, decidedBy);
143
+ if (statusOverrideError) {
144
+ ctx.send(ws, { type: 'planError', data: { error: statusOverrideError } });
145
+ return;
146
+ }
111
147
  if (!applyIssueFieldUpdates(fullPath, fields)) {
112
148
  ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
113
149
  return;
@@ -166,3 +202,124 @@ export function handleScaffold(ctx, ws, msg, workingDir, permission) {
166
202
  const fullState = parsePlanDirectory(workingDir);
167
203
  ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
168
204
  }
205
+ export function handleReviewDecision(ctx, ws, msg, workingDir, permission,
206
+ decidedBy = { role: 'owner' }) {
207
+ if (denyIfViewOnly(ctx, ws, permission))
208
+ return;
209
+ const request = parseReviewDecisionRequest(msg.data);
210
+ if (typeof request === 'string') {
211
+ ctx.send(ws, { type: 'planError', data: { error: request } });
212
+ return;
213
+ }
214
+ const target = resolveReviewDecisionTarget(workingDir, request);
215
+ if (typeof target === 'string') {
216
+ ctx.send(ws, { type: 'planError', data: { error: target } });
217
+ return;
218
+ }
219
+ const decidedAt = new Date();
220
+ const record = {
221
+ issueId: target.issue.id,
222
+ decision: request.decision,
223
+ comment: request.comment,
224
+ decidedAt: decidedAt.toISOString(),
225
+ decidedBy,
226
+ via: 'review_decision',
227
+ verdictFile: latestReviewVerdict(target.boardDir, target.issue.id)?.fileName ?? null,
228
+ };
229
+ try {
230
+ writeReviewApproval(target.boardDir, record, decidedAt.getTime());
231
+ }
232
+ catch (error) {
233
+ ctx.send(ws, {
234
+ type: 'planError',
235
+ data: { error: `Could not record the decision for ${target.issue.id}: ${errorText(error)} — nothing was changed` },
236
+ });
237
+ return;
238
+ }
239
+ if (!applyIssueFieldUpdates(target.fullPath, { status: request.decision === 'approve' ? 'done' : 'todo' })) {
240
+ ctx.send(ws, { type: 'planError', data: { error: `Invalid file format: ${target.relPath}` } });
241
+ return;
242
+ }
243
+ if (request.decision === 'reject' && request.comment) {
244
+ appendChangesRequested(target.fullPath, request.comment, decidedAt);
245
+ }
246
+ void settleReviewDecision(ctx, workingDir, request, target).catch((error) => {
247
+ ctx.send(ws, {
248
+ type: 'planError',
249
+ data: { error: `${target.issue.id} was ${request.decision === 'approve' ? 'approved' : 'sent back'} but the board could not be reconciled: ${errorText(error)}` },
250
+ });
251
+ });
252
+ }
253
+ function parseReviewDecisionRequest(data) {
254
+ const { boardId, path, decision, comment } = (data ?? {});
255
+ if (typeof boardId !== 'string' || boardId.length === 0)
256
+ return 'Board id required';
257
+ if (typeof path !== 'string' || path.length === 0)
258
+ return 'Issue path required';
259
+ if (decision !== 'approve' && decision !== 'reject')
260
+ return "Decision must be 'approve' or 'reject'";
261
+ const note = typeof comment === 'string' ? comment.trim() : '';
262
+ if (decision === 'reject' && note.length === 0)
263
+ return 'A comment is required when requesting changes';
264
+ return { boardId, path, decision, comment: note.length > 0 ? note : null };
265
+ }
266
+ function resolveReviewDecisionTarget(workingDir, request) {
267
+ const pmDir = resolvePmDir(workingDir);
268
+ if (!pmDir)
269
+ return NO_SPECS_DIR_ERROR;
270
+ const boardDir = resolveBoardDir(pmDir, request.boardId);
271
+ if (!boardDir)
272
+ return `Board not found: ${request.boardId}`;
273
+ const fullPath = resolvePlanPath(workingDir, request.path);
274
+ if (!fullPath || !isInsideDir(fullPath, boardDir)) {
275
+ return `Issue ${request.path} is not on board ${request.boardId}`;
276
+ }
277
+ const relPath = relative(pmDir, fullPath);
278
+ const issue = existsSync(fullPath) ? parseSingleIssue(workingDir, relPath) : null;
279
+ if (!issue)
280
+ return `Issue not found: ${relPath}`;
281
+ if (issue.reviewGate !== 'required') {
282
+ return `${issue.id} has no approval gate — its review_gate is '${issue.reviewGate}'`;
283
+ }
284
+ if (issue.status !== 'awaiting_approval') {
285
+ return `${issue.id} is not awaiting approval (status: ${issue.status})`;
286
+ }
287
+ return { pmDir, boardDir, fullPath, relPath, issue };
288
+ }
289
+ function isInsideDir(candidate, dir) {
290
+ return candidate.startsWith(`${dir}${sep}`);
291
+ }
292
+ async function settleReviewDecision(ctx, workingDir, request, target) {
293
+ const updated = parseSingleIssue(workingDir, target.relPath);
294
+ let epic = null;
295
+ if (request.decision === 'approve' && updated) {
296
+ const epicPath = tryCompleteParentEpic(workingDir, updated);
297
+ epic = epicPath ? parseSingleIssue(workingDir, epicPath) : null;
298
+ await tryCompleteBoardIfDone(target.pmDir, request.boardId, parseBoardDirectory(target.pmDir, request.boardId)?.issues ?? [], (message) => { console.warn(`[plan-review-decision] ${message}`); });
299
+ notifyChainRunEnded(workingDir, request.boardId, 'complete');
300
+ }
301
+ ctx.broadcastToAll({ type: 'planIssueUpdated', data: updated });
302
+ if (epic)
303
+ ctx.broadcastToAll({ type: 'planIssueUpdated', data: epic });
304
+ const fullState = parsePlanDirectory(workingDir);
305
+ if (fullState)
306
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
307
+ if (request.decision === 'approve')
308
+ maybeContinueAfterApproval(ctx, workingDir, request.boardId);
309
+ }
310
+ const ACTIVITY_HEADING_RE = /^## Activity[^\S\r\n]*$/m;
311
+ function appendChangesRequested(fullPath, comment, decidedAt) {
312
+ try {
313
+ const content = readFileSync(fullPath, 'utf-8');
314
+ const entry = `- Changes requested (${decidedAt.toISOString().split('T')[0]}): ${comment.replace(/\s*\n\s*/g, ' ')}`;
315
+ const heading = ACTIVITY_HEADING_RE.exec(content);
316
+ const cut = heading?.index === undefined ? -1 : heading.index + heading[0].length;
317
+ writeFileSync(fullPath, cut < 0
318
+ ? `${content}\n\n## Activity\n${entry}`
319
+ : `${content.slice(0, cut)}\n${entry}${content.slice(cut)}`, 'utf-8');
320
+ }
321
+ catch { }
322
+ }
323
+ function errorText(error) {
324
+ return error instanceof Error ? error.message : String(error);
325
+ }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { replaceFrontMatterField } from '../plan/front-matter.js';
6
6
  import { defaultPmDir, getNextSprintId, parsePlanDirectory, parseSingleSprint, parseSprintArtifacts, resolvePmDir } from '../plan/parser.js';
7
+ import { broadcastPlanStateIfChanged } from '../plan/watcher.js';
7
8
  import { denyIfViewOnly, resolvePlanPath } from './plan-helpers.js';
8
9
  function buildSprintMarkdown(id, title, goal, start, end, issueRefs) {
9
10
  const issuesYaml = issueRefs.length > 0
@@ -114,7 +115,7 @@ export function handleActivateSprint(ctx, ws, msg, workingDir, permission) {
114
115
  ctx.broadcastToAll({ type: 'planSprintUpdated', data: updatedSprint });
115
116
  const updatedState = parsePlanDirectory(workingDir);
116
117
  if (updatedState) {
117
- ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
118
+ broadcastPlanStateIfChanged(ctx, workingDir, updatedState);
118
119
  }
119
120
  }
120
121
  export function handleCompleteSprint(ctx, ws, msg, workingDir, permission) {
@@ -172,7 +173,7 @@ export function handleCompleteSprint(ctx, ws, msg, workingDir, permission) {
172
173
  ctx.broadcastToAll({ type: 'planSprintCompleted', data: updatedSprint });
173
174
  const updatedState = parsePlanDirectory(workingDir);
174
175
  if (updatedState) {
175
- ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
176
+ broadcastPlanStateIfChanged(ctx, workingDir, updatedState);
176
177
  }
177
178
  }
178
179
  export function handleGetSprintArtifacts(ctx, ws, msg, workingDir) {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { can } from '@minionry/roles';
4
4
  import { NO_SPECS_DIR_ERROR, resolvePmDir } from '../plan/parser.js';
5
- import { cleanupScheduleSource, isValidTiming, parseCreateSourceWire, parseUpdatePatch, resolveCreateSource, } from '../schedule/schedule-wire.js';
5
+ import { cleanupScheduleSource, isValidTiming, parseCreateSourceWire, parseMaxLatenessMs, parseUpdatePatch, resolveCreateSource, } from '../schedule/schedule-wire.js';
6
6
  import { getScheduler } from '../schedule/scheduler.js';
7
7
  import { previewTemplateSource } from '../schedule/template-source.js';
8
8
  import { dispatchByType } from './dispatch-util.js';
@@ -46,6 +46,11 @@ function handleScheduleCreate(ctx, ws, msg, workingDir, permission) {
46
46
  reply({ success: false, error: 'Invalid timing' });
47
47
  return;
48
48
  }
49
+ const maxLateness = parseMaxLatenessMs(data.maxLatenessMs);
50
+ if (!maxLateness.ok) {
51
+ reply({ success: false, error: 'Invalid maxLatenessMs (integer >= 60000)' });
52
+ return;
53
+ }
49
54
  const pmDir = resolvePmDir(workingDir);
50
55
  if (!pmDir) {
51
56
  reply({ success: false, error: NO_SPECS_DIR_ERROR });
@@ -70,6 +75,7 @@ function handleScheduleCreate(ctx, ws, msg, workingDir, permission) {
70
75
  input: data.input,
71
76
  timing: data.timing,
72
77
  enabled: data.enabled,
78
+ maxLatenessMs: maxLateness.value,
73
79
  });
74
80
  notifySchedulerAndBroadcast(ctx, workingDir);
75
81
  reply({
@@ -127,7 +133,7 @@ function handleScheduleUpdate(ctx, ws, msg, workingDir, permission) {
127
133
  }
128
134
  const safePatch = parseUpdatePatch(patch);
129
135
  if (!safePatch) {
130
- reply({ success: false, error: 'Invalid timing' });
136
+ reply({ success: false, error: 'Invalid timing or maxLatenessMs' });
131
137
  return;
132
138
  }
133
139
  const store = getScheduleStore(ctx, workingDir);
@@ -78,8 +78,12 @@ function createAppAgentRun(ctx, start) {
78
78
  }
79
79
  : {}),
80
80
  });
81
- if (model)
81
+ if (model) {
82
82
  session.applyOverrides({ engine: model.engine, model: model.value });
83
+ }
84
+ else if (selection) {
85
+ session.applyOverrides({ engine: selection.engine, ...(selection.model ? { model: selection.model } : {}) });
86
+ }
83
87
  setupSessionListeners(ctx, session, APP_HOST_WS, tabId);
84
88
  const sessionId = session.getSessionInfo().sessionId;
85
89
  ctx.sessions.set(sessionId, session);
@@ -0,0 +1,30 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { getAgentScheduler } from '../schedule/agent-scheduler.js';
4
+ import { setSdkAgentScheduleHost } from '../sdk/agent-schedule.js';
5
+ export function installSdkAgentScheduleHost(ctx) {
6
+ const host = {
7
+ getStore(workingDir) {
8
+ const engine = getAgentScheduler(workingDir, ctx);
9
+ engine.start();
10
+ return engine.getStore();
11
+ },
12
+ notifyAndBroadcast(workingDir) {
13
+ const engine = getAgentScheduler(workingDir, ctx);
14
+ engine.tick();
15
+ const store = engine.getStore();
16
+ if (store)
17
+ ctx.broadcastToAll({ type: 'agentSchedulesChanged', data: { schedules: store.list() } });
18
+ },
19
+ fireNow(workingDir, scheduleId) {
20
+ const engine = getAgentScheduler(workingDir, ctx);
21
+ engine.start();
22
+ const result = engine.fireNow(scheduleId);
23
+ const store = engine.getStore();
24
+ if (store)
25
+ ctx.broadcastToAll({ type: 'agentSchedulesChanged', data: { schedules: store.list() } });
26
+ return result;
27
+ },
28
+ };
29
+ setSdkAgentScheduleHost(host);
30
+ }
@@ -23,7 +23,7 @@ export function installSdkBrowserHost(ctx) {
23
23
  navigate: async ({ url, tabId }) => {
24
24
  await (await ensureReady(ctx)).navigate(url, tabId);
25
25
  },
26
- open: async ({ url, reuseOrigin }) => {
26
+ open: async ({ url, reuseOrigin, opener }) => {
27
27
  const live = await ensureReady(ctx);
28
28
  if (reuseOrigin) {
29
29
  const match = live.getTabsSnapshot().tabs.find((t) => sameOrigin(t.url, reuseOrigin));
@@ -32,7 +32,7 @@ export function installSdkBrowserHost(ctx) {
32
32
  return { tabId: match.id };
33
33
  }
34
34
  }
35
- const tab = await live.openTab(url);
35
+ const tab = await live.openTab(url, { kind: 'app', appId: opener.appId, origin: opener.origin });
36
36
  return { tabId: tab.id };
37
37
  },
38
38
  close: async ({ tabId }) => {
@@ -75,7 +75,8 @@ export function installSdkBrowserHost(ctx) {
75
75
  onEvent(event);
76
76
  },
77
77
  broadcastToAll: (response) => ctx.broadcastToAll(response),
78
- });
78
+ },
79
+ initiator ? { kind: 'app', appId: initiator.appId, origin: initiator.origin } : undefined);
79
80
  return {
80
81
  agentId: run.agentId,
81
82
  approve: (verdict) => resolveProposal(verdict),
@@ -130,11 +131,17 @@ async function runSdkPreflight(ctx) {
130
131
  }
131
132
  return toBrowserPreflightPayload(result, { chromiumInstalling: false });
132
133
  }
134
+ function withoutOpener(tab) {
135
+ const { opener, ...sdkTab } = tab;
136
+ void opener;
137
+ return sdkTab;
138
+ }
133
139
  function withOwners(payload) {
134
140
  const driverState = getBrowserDriverState();
135
141
  return {
136
142
  currentTabId: payload.currentTabId,
137
- tabs: payload.tabs.map((tab) => {
143
+ tabs: payload.tabs.map((nativeTab) => {
144
+ const tab = withoutOpener(nativeTab);
138
145
  const owner = driverState.getOwner(tab.id);
139
146
  if (!owner)
140
147
  return tab;
@@ -2,12 +2,15 @@
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { join } from 'node:path';
4
4
  import { parsePlanDirectory, resolvePmDir } from '../plan/parser.js';
5
+ import { broadcastPlanStateIfChanged } from '../plan/watcher.js';
5
6
  import { injectGatherIssue, writeRunInputFile } from '../schedule/run-bootstrap.js';
6
7
  import { resolveSdkConsent } from '../sdk/connect.js';
7
8
  import { runSdkPipeline } from '../sdk/pipeline.js';
8
9
  import { setSdkPmHost } from '../sdk/pm.js';
9
10
  import { handleChatToBoard } from './plan-board-handlers.js';
10
11
  import { executorCache, getWatcher } from './plan-helpers.js';
12
+ import { installSdkAgentScheduleHost } from './sdk-agent-schedule-host.js';
13
+ import { installSdkPmChainHost } from './sdk-pm-chain-host.js';
11
14
  import { installSdkPmExecutionHost } from './sdk-pm-execution-host.js';
12
15
  import { installSdkPmScheduleHost } from './sdk-pm-schedule-host.js';
13
16
  import { installSdkQualityScheduleHost } from './sdk-quality-schedule-host.js';
@@ -116,7 +119,7 @@ function installSdkPmHost(ctx) {
116
119
  getWatcher(workingDir, ctx).start();
117
120
  const fullState = parsePlanDirectory(workingDir);
118
121
  if (fullState)
119
- ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
122
+ broadcastPlanStateIfChanged(ctx, workingDir, fullState);
120
123
  },
121
124
  stopExecutorIfRunning: (workingDir, boardId) => {
122
125
  const executor = executorCache.get(workingDir);
@@ -191,7 +194,9 @@ export async function handleSdkMessage(ctx, ws, msg, tabId, workingDir, role, vi
191
194
  installSdkPmHost(ctx);
192
195
  installSdkPmExecutionHost(ctx);
193
196
  installSdkPmScheduleHost(ctx);
197
+ installSdkPmChainHost(ctx);
194
198
  installSdkQualityScheduleHost();
199
+ installSdkAgentScheduleHost(ctx);
195
200
  if (msg.type === 'sdkConsentDecision') {
196
201
  handleSdkConsentDecision(msg, role, viewerId);
197
202
  return;
@@ -0,0 +1,20 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { broadcastChainsChanged, getChainEngine } from '../chain/chain-engine.js';
4
+ import { setSdkPmChainHost } from '../sdk/pm-chain.js';
5
+ export function installSdkPmChainHost(ctx) {
6
+ const host = {
7
+ getStore(workingDir) {
8
+ const engine = getChainEngine(workingDir, ctx);
9
+ engine.start();
10
+ return engine.getStore();
11
+ },
12
+ notifyAndBroadcast(workingDir) {
13
+ const engine = getChainEngine(workingDir, ctx);
14
+ const store = engine.getStore();
15
+ if (store)
16
+ broadcastChainsChanged(ctx, store);
17
+ },
18
+ };
19
+ setSdkPmChainHost(host);
20
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { setSdkPmExecutionHost } from '../sdk/pm-execute.js';
4
4
  import { scopeExecutionDir } from './handler-context.js';
5
- import { wireExecutorEvents } from './plan-execution-handlers.js';
5
+ import { broadcastBoardRunPrompt, wireExecutorEvents } from './plan-execution-handlers.js';
6
6
  import { executorCache, getExecutor } from './plan-helpers.js';
7
7
  const currentRunByWorkingDir = new Map();
8
8
  function mintRunId() {
@@ -13,6 +13,25 @@ function clearCurrentRun(workingDir, runId) {
13
13
  if (current?.runId === runId)
14
14
  currentRunByWorkingDir.delete(workingDir);
15
15
  }
16
+ export function startFreshBoardRun(ctx, workingDir, boardId, options) {
17
+ const executor = getExecutor(workingDir);
18
+ const runId = mintRunId();
19
+ currentRunByWorkingDir.set(workingDir, { runId, boardId });
20
+ const executionDir = scopeExecutionDir(ctx, boardId);
21
+ wireExecutorEvents(executor, ctx, workingDir, boardId, undefined, executionDir);
22
+ executor.on('complete', () => clearCurrentRun(workingDir, runId));
23
+ executor.on('error', () => clearCurrentRun(workingDir, runId));
24
+ if (options?.launchPrompt)
25
+ broadcastBoardRunPrompt(ctx, boardId, options.launchPrompt);
26
+ ctx.broadcastToAll({ type: 'planExecutionStarted', data: { status: 'executing', boardId } });
27
+ executor.startBoard(boardId, executionDir).catch((error) => {
28
+ ctx.broadcastToAll({
29
+ type: 'planExecutionError',
30
+ data: { error: error instanceof Error ? error.message : String(error), boardId },
31
+ });
32
+ });
33
+ return { runId };
34
+ }
16
35
  export function installSdkPmExecutionHost(ctx) {
17
36
  const host = {
18
37
  attach(workingDir, boardId) {
@@ -28,19 +47,7 @@ export function installSdkPmExecutionHost(ctx) {
28
47
  if (alreadyRunningThisBoard) {
29
48
  return { runId: current.runId, on: (event, cb) => executor.on(event, cb), off: (event, cb) => executor.off(event, cb) };
30
49
  }
31
- const runId = mintRunId();
32
- currentRunByWorkingDir.set(workingDir, { runId, boardId });
33
- const executionDir = scopeExecutionDir(ctx, boardId);
34
- wireExecutorEvents(executor, ctx, workingDir, boardId, undefined, executionDir);
35
- executor.on('complete', () => clearCurrentRun(workingDir, runId));
36
- executor.on('error', () => clearCurrentRun(workingDir, runId));
37
- ctx.broadcastToAll({ type: 'planExecutionStarted', data: { status: 'executing', boardId } });
38
- executor.startBoard(boardId, executionDir).catch((error) => {
39
- ctx.broadcastToAll({
40
- type: 'planExecutionError',
41
- data: { error: error instanceof Error ? error.message : String(error), boardId },
42
- });
43
- });
50
+ const { runId } = startFreshBoardRun(ctx, workingDir, boardId);
44
51
  return { runId, on: (event, cb) => executor.on(event, cb), off: (event, cb) => executor.off(event, cb) };
45
52
  },
46
53
  pause(workingDir, boardId) {
@@ -16,6 +16,15 @@ export function installSdkPmScheduleHost(ctx) {
16
16
  if (store)
17
17
  ctx.broadcastToAll({ type: 'planSchedulesChanged', data: { schedules: store.list() } });
18
18
  },
19
+ fireNow(workingDir, scheduleId) {
20
+ const engine = getScheduler(workingDir, ctx);
21
+ engine.start();
22
+ const result = engine.fireNow(scheduleId);
23
+ const store = engine.getStore();
24
+ if (store)
25
+ ctx.broadcastToAll({ type: 'planSchedulesChanged', data: { schedules: store.list() } });
26
+ return result;
27
+ },
19
28
  };
20
29
  setSdkPmScheduleHost(host);
21
30
  }
@@ -23,6 +23,7 @@ export function notifySdkTerminalSessionOpened(terminalId) {
23
23
  }
24
24
  export function resetSdkTerminalNotifications() {
25
25
  sessionListeners.clear();
26
+ exitBroadcastCtx = null;
26
27
  if (managerExitCleanup) {
27
28
  managerExitCleanup();
28
29
  managerExitCleanup = null;
@@ -44,17 +45,23 @@ function summarizeSession(id) {
44
45
  };
45
46
  }
46
47
  let managerExitCleanup = null;
48
+ let exitBroadcastCtx = null;
47
49
  function wireManagerExitListener() {
48
50
  if (managerExitCleanup)
49
51
  return;
50
52
  const ptyManager = getPTYManager();
51
53
  const onExit = (terminalId) => {
54
+ const ownerLabel = ptyManager.getSessionInfo(terminalId)?.ownerLabel;
55
+ if (ownerLabel !== undefined) {
56
+ exitBroadcastCtx?.broadcastToAll({ type: 'terminalClosed', data: { terminalId, ownerLabel } });
57
+ }
52
58
  notifySessionChange({ kind: 'closed', id: terminalId });
53
59
  };
54
60
  ptyManager.on('exit', onExit);
55
61
  managerExitCleanup = () => ptyManager.off('exit', onExit);
56
62
  }
57
63
  export function installSdkTerminalHost(ctx) {
64
+ exitBroadcastCtx = ctx;
58
65
  wireManagerExitListener();
59
66
  const host = {
60
67
  async ensurePtyAvailable() {
@@ -1,13 +1,10 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { spawn } from 'node:child_process';
4
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { resolveClaudeCommand } from '../../engines/claude/claude-command.js';
4
+ import { resolveClaudeCommand, TOOL_LESS_CLAUDE_ARGS } from '../../engines/claude/claude-command.js';
7
5
  import { computeClassifierHealth } from '../../mcp/classifier/health.js';
8
6
  import { getModelCatalog } from '../model-catalog.js';
9
7
  import { clearBouncerClassifierOverride, getCurrentSpaceIdentity, getSettings, setBouncerClassifier, setBouncerClassifierOverride, setEffortLevel, setEngine, setFastMode, setMaxConcurrentSessions, setMaxUploadSizeMb, setModel, setQualityEffortLevel, setQualityEngine, setQualityFastMode, setQualityModel, setSubagentModel, setSubagentModelForce, } from '../settings.js';
10
- import { spacePath } from '../space-layout.js';
11
8
  import { createSystemPrompt, deleteSystemPromptProfile, listSystemPromptProfiles, readSystemPromptFile, saveSystemPromptFile, systemPromptsDir, } from '../system-prompts.js';
12
9
  import { isEngineId } from './types.js';
13
10
  function buildSystemPromptProfilesMessage() {
@@ -173,10 +170,6 @@ export async function handleGetModelCatalog(ctx, ws, msg) {
173
170
  }
174
171
  export async function generateNotificationSummary(ctx, ws, tabId, userPrompt, output, workingDir) {
175
172
  try {
176
- const tempDir = spacePath(workingDir, 'tmp');
177
- if (!existsSync(tempDir)) {
178
- mkdirSync(tempDir, { recursive: true });
179
- }
180
173
  let truncatedOutput = output;
181
174
  if (output.length > 4000) {
182
175
  const firstPart = output.slice(0, 2000);
@@ -201,19 +194,19 @@ Generate a notification summary following these rules:
201
194
  6. No emojis, no markdown, just plain text
202
195
 
203
196
  Respond with ONLY the summary text, nothing else.`;
204
- const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
205
- writeFileSync(promptFile, summaryPrompt);
206
197
  const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
207
198
  const args = [
208
199
  '--print',
209
200
  '--model', 'haiku',
210
- '--system-prompt', systemPrompt,
211
- promptFile
201
+ ...TOOL_LESS_CLAUDE_ARGS,
202
+ '--system-prompt', systemPrompt
212
203
  ];
213
204
  const claude = spawn(resolveClaudeCommand(), args, {
214
205
  cwd: workingDir,
215
- stdio: ['ignore', 'pipe', 'pipe']
206
+ stdio: ['pipe', 'pipe', 'pipe']
216
207
  });
208
+ claude.stdin?.on('error', () => { });
209
+ claude.stdin?.end(summaryPrompt);
217
210
  let stdout = '';
218
211
  let stderr = '';
219
212
  let responseSent = false;
@@ -234,11 +227,6 @@ Respond with ONLY the summary text, nothing else.`;
234
227
  stderr += data.toString();
235
228
  });
236
229
  claude.on('close', (code) => {
237
- try {
238
- unlinkSync(promptFile);
239
- }
240
- catch {
241
- }
242
230
  if (code === 0 && stdout.trim()) {
243
231
  sendSummaryOnce(stdout.trim().slice(0, 150));
244
232
  }
@@ -2,13 +2,17 @@
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { mirrorTabEventToSdk } from '../sdk/agents.js';
4
4
  import { isTimelineTabEvent } from '../timeline/index.js';
5
+ import { isHighFrequencyTabEvent } from './viewer-interest.js';
5
6
  export function broadcastTabEvent(ctx, tabId, type, data, engine) {
6
7
  const buffer = ctx.tabEventBuffers.getOrCreate(tabId);
7
8
  const seq = buffer.record(type, data);
8
9
  const envelope = { type, tabId, data, seq };
9
10
  if (engine)
10
11
  envelope.engine = engine;
11
- ctx.broadcastToAll(envelope);
12
+ if (isHighFrequencyTabEvent(type) && ctx.broadcastStream)
13
+ ctx.broadcastStream(envelope);
14
+ else
15
+ ctx.broadcastToAll(envelope);
12
16
  mirrorTabEventToSdk(tabId, type, data, (response) => ctx.broadcastToAll(response));
13
17
  assembleTabTimeline(ctx, tabId, type, data, engine);
14
18
  return seq;