@yemi33/minions 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 (2026-03-26)
4
+
5
+ ### Engine
6
+ - engine.js
7
+ - engine/ado.js
8
+ - engine/cli.js
9
+ - engine/github.js
10
+ - engine/lifecycle.js
11
+ - engine/scheduler.js
12
+ - engine/shared.js
13
+
14
+ ### Dashboard
15
+ - dashboard.html
16
+ - dashboard.js
17
+
18
+ ### Playbooks
19
+ - decompose.md
20
+
21
+ ### Documentation
22
+ - README.md
23
+
24
+ ### Other
25
+ - CLAUDE.md
26
+ - TODO.md
27
+ - routing.md
28
+ - test/playwright/dashboard.spec.js
29
+
30
+ ## 0.1.8 (2026-03-25)
31
+
32
+ ### Engine
33
+ - engine.js
34
+ - engine/cli.js
35
+ - engine/preflight.js
36
+ - engine/shared.js
37
+ - engine/spawn-agent.js
38
+
39
+ ### Documentation
40
+ - README.md
41
+ - auto-discovery.md
42
+
43
+ ### Other
44
+ - CLAUDE.md
45
+ - bin/minions.js
46
+ - config.template.json
47
+ - test/unit.test.js
48
+
3
49
  ## 0.1.7 (2026-03-24)
4
50
 
5
51
  ### Documentation
package/README.md CHANGED
@@ -473,7 +473,7 @@ Engine behavior is controlled via `config.json`. Key settings:
473
473
  {
474
474
  "engine": {
475
475
  "tickInterval": 60000,
476
- "maxConcurrent": 3,
476
+ "maxConcurrent": 5,
477
477
  "agentTimeout": 18000000,
478
478
  "heartbeatTimeout": 300000,
479
479
  "maxTurns": 100,
@@ -487,7 +487,7 @@ Engine behavior is controlled via `config.json`. Key settings:
487
487
  | Setting | Default | Description |
488
488
  |---------|---------|-------------|
489
489
  | `tickInterval` | 60000 (1min) | Milliseconds between engine ticks |
490
- | `maxConcurrent` | 3 | Max agents running simultaneously |
490
+ | `maxConcurrent` | 5 | Max agents running simultaneously |
491
491
  | `agentTimeout` | 18000000 (5h) | Max total agent runtime |
492
492
  | `heartbeatTimeout` | 300000 (5min) | Kill agents silent longer than this |
493
493
  | `maxTurns` | 100 | Max Claude CLI turns per agent session |
@@ -497,6 +497,60 @@ Engine behavior is controlled via `config.json`. Key settings:
497
497
  | `worktreeRoot` | `../worktrees` | Where git worktrees are created |
498
498
  | `idleAlertMinutes` | 15 | Alert after no dispatch for this many minutes |
499
499
  | `restartGracePeriod` | 1200000 (20min) | Grace period for agent re-attachment after engine restart |
500
+ | `shutdownTimeout` | 300000 (5min) | Max wait for active agents during graceful shutdown (SIGTERM/SIGINT) |
501
+ | `allowTempAgents` | false | Spawn ephemeral agents when all permanent agents are busy |
502
+ | `autoDecompose` | true | Auto-decompose `implement:large` items into sub-tasks before dispatch |
503
+
504
+ ### Scheduled Tasks
505
+
506
+ Add recurring work via `config.schedules`:
507
+
508
+ ```json
509
+ {
510
+ "schedules": [
511
+ {
512
+ "id": "nightly-tests",
513
+ "cron": "0 2 *",
514
+ "type": "test",
515
+ "title": "Nightly test suite",
516
+ "project": "MyProject",
517
+ "agent": "dallas",
518
+ "enabled": true
519
+ }
520
+ ]
521
+ }
522
+ ```
523
+
524
+ Cron format is simplified 3-field: `minute hour dayOfWeek` (0=Sun..6=Sat). Supports `*`, `*/N`, and specific values. Examples:
525
+ - `0 2 *` — 2am daily
526
+ - `0 9 1` — 9am every Monday
527
+ - `*/30 * *` — every 30 minutes
528
+ - `0 9 1,3,5` — 9am Mon/Wed/Fri
529
+
530
+ ### Graceful Shutdown
531
+
532
+ The engine handles `SIGTERM` and `SIGINT` (Ctrl+C) gracefully:
533
+ 1. Stops accepting new work (enters `stopping` state)
534
+ 2. Waits for active agents to finish (up to `shutdownTimeout`, default 5 minutes)
535
+ 3. Exits cleanly
536
+
537
+ Active agents continue running as independent processes and will be re-attached on next engine start.
538
+
539
+ ### Task Decomposition
540
+
541
+ Work items with `complexity: "large"` or `estimated_complexity: "large"` are auto-decomposed before dispatch (controlled by `engine.autoDecompose`, default `true`). The engine dispatches a `decompose` agent that breaks the item into 2-5 smaller sub-tasks, each becoming an independent work item with dependency tracking.
542
+
543
+ ### Temporary Agents
544
+
545
+ Set `engine.allowTempAgents: true` to let the engine spawn ephemeral agents when all 5 permanent agents are busy. Temp agents:
546
+ - Get a `temp-{id}` identifier
547
+ - Use a minimal system prompt (no charter)
548
+ - Are auto-cleaned up after task completion
549
+ - Count toward `maxConcurrent` slots
550
+
551
+ ### Live Output Streaming
552
+
553
+ The dashboard streams agent output in real-time via Server-Sent Events (SSE) instead of polling. The `GET /api/agent/:id/live-stream` endpoint pushes output chunks as they're written. Falls back to 3-second polling if SSE is unavailable.
500
554
 
501
555
  ## Node.js Upgrade Caution
502
556
 
@@ -535,6 +589,8 @@ To move to a new machine: `npm install -g @yemi33/minions && minions init --forc
535
589
  ado.js <- ADO token management, PR polling, PR reconciliation
536
590
  llm.js <- callLLM() with session resume, trackEngineUsage()
537
591
  spawn-agent.js <- Agent spawn wrapper (resolves claude cli.js)
592
+ preflight.js <- Prerequisite checks (Node, Git, Claude CLI, API key)
593
+ scheduler.js <- Cron-style scheduled task discovery
538
594
  ado-mcp-wrapper.js <- ADO MCP authentication wrapper
539
595
  check-status.js <- Quick status check without full engine load
540
596
  control.json <- running/paused/stopped (runtime, generated)
@@ -542,6 +598,7 @@ To move to a new machine: `npm install -g @yemi33/minions && minions init --forc
542
598
  log.json <- Audit trail, capped at 500 (runtime, generated)
543
599
  metrics.json <- Per-agent quality metrics (runtime, generated)
544
600
  cooldowns.json <- Dispatch cooldown tracking (runtime, generated)
601
+ schedule-runs.json <- Last-run timestamps for scheduled tasks (runtime, generated)
545
602
  dashboard.js <- Web dashboard server
546
603
  dashboard.html <- Dashboard UI (single-file)
547
604
  config.json <- projects[], agents, engine, claude settings (generated by minions init)
@@ -566,6 +623,7 @@ To move to a new machine: `npm install -g @yemi33/minions && minions init --forc
566
623
  implement-shared.md <- Implement on a shared branch
567
624
  ask.md <- Answer a question about the codebase
568
625
  verify.md <- Plan verification: build, test, start webapp, testing guide
626
+ decompose.md <- Break large work items into 2-5 sub-tasks
569
627
  skills/ <- Agent-created reusable workflows (generated)
570
628
  agents/
571
629
  {name}/
package/bin/minions.js CHANGED
@@ -297,6 +297,13 @@ function init() {
297
297
  showChangelog(installedVersion);
298
298
  }
299
299
 
300
+ // Run preflight checks (warn only — don't block init)
301
+ try {
302
+ const { runPreflight, printPreflight } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
303
+ const { results } = runPreflight();
304
+ printPreflight(results, { label: 'Preflight checks' });
305
+ } catch {}
306
+
300
307
  // Auto-start on fresh install; force-upgrade restarts automatically.
301
308
  if (isUpgrade) {
302
309
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
@@ -315,7 +322,17 @@ function init() {
315
322
  });
316
323
  dashProc.unref();
317
324
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
318
- console.log(' Dashboard: http://localhost:7331\n');
325
+ console.log(' Dashboard: http://localhost:7331');
326
+
327
+ // Next steps guidance
328
+ console.log(`
329
+ Next steps:
330
+ minions work "Explore the codebase" Give your first task
331
+ minions status Check engine state
332
+ http://localhost:7331 Open the dashboard
333
+ minions doctor Verify everything is working
334
+ minions --help See all commands
335
+ `);
319
336
  }
320
337
 
321
338
  function copyDir(src, dest, excludeTop, alwaysUpdate, neverOverwrite, isUpgrade, actions, relPath = '') {
@@ -391,6 +408,16 @@ function showVersion() {
391
408
  } else {
392
409
  console.log(' Not installed yet. Run: minions init');
393
410
  }
411
+
412
+ // Check npm registry for latest version (best-effort, non-blocking)
413
+ try {
414
+ const latest = execSync('npm view @yemi33/minions version', { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim();
415
+ if (latest && latest !== pkg) {
416
+ console.log(`\n Latest on npm: ${latest}`);
417
+ console.log(' To update: npm update -g @yemi33/minions && minions init --force');
418
+ }
419
+ } catch {} // offline or npm not available — skip silently
420
+
394
421
  console.log('');
395
422
  }
396
423
 
@@ -428,12 +455,14 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
428
455
  minions init [--skip-scan] Bootstrap ~/.minions/ (first time)
429
456
  minions init --force Upgrade engine code + add new files (auto-skip scan)
430
457
  minions version Show installed vs package version
458
+ minions doctor Check prerequisites and runtime health
431
459
  minions add <project-dir> Link a project (interactive)
432
460
  minions remove <project-dir> Unlink a project
433
461
  minions list List linked projects
434
462
 
435
463
  Engine:
436
- minions start Start engine daemon
464
+ minions up Start engine + dashboard (use after reboot)
465
+ minions start Start engine daemon only
437
466
  minions stop Stop the engine
438
467
  minions status Show agents, projects, queue
439
468
  minions pause / resume Pause/resume dispatching
@@ -455,6 +484,26 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
455
484
  showVersion();
456
485
  } else if (cmd === 'add' || cmd === 'remove' || cmd === 'list') {
457
486
  delegate('minions.js', [cmd, ...rest]);
487
+ } else if (cmd === 'up' || cmd === 'restart') {
488
+ // Start both engine and dashboard — the go-to command after a reboot
489
+ ensureInstalled();
490
+ // Stop engine if running (ignore errors)
491
+ try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
492
+ const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start'], {
493
+ cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
494
+ });
495
+ engineProc.unref();
496
+ console.log(`\n Engine started (PID: ${engineProc.pid})`);
497
+ const dashProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'dashboard.js')], {
498
+ cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
499
+ });
500
+ dashProc.unref();
501
+ console.log(` Dashboard started (PID: ${dashProc.pid})`);
502
+ console.log(' Dashboard: http://localhost:7331\n');
503
+ } else if (cmd === 'doctor') {
504
+ ensureInstalled();
505
+ const { doctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
506
+ doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
458
507
  } else if (cmd === 'dash' || cmd === 'dashboard') {
459
508
  delegate('dashboard.js', rest);
460
509
  } else if (engineCmds.has(cmd)) {
@@ -1,28 +1,4 @@
1
1
  {
2
- "projects": [
3
- {
4
- "name": "YOUR_PROJECT_NAME",
5
- "localPath": "/path/to/your/project",
6
- "repoHost": "ado",
7
- "repositoryId": "YOUR_REPO_ID",
8
- "adoOrg": "YOUR_ORG",
9
- "adoProject": "YOUR_PROJECT",
10
- "repoName": "YOUR_REPO_NAME",
11
- "mainBranch": "main",
12
- "prUrlBase": "",
13
- "workSources": {
14
- "pullRequests": {
15
- "enabled": true,
16
- "path": ".minions/pull-requests.json",
17
- "cooldownMinutes": 30
18
- },
19
- "workItems": {
20
- "enabled": true,
21
- "path": ".minions/work-items.json",
22
- "cooldownMinutes": 0
23
- }
24
- }
25
- }
26
- ]
2
+ "projects": []
27
3
  }
28
4
 
package/dashboard.html CHANGED
@@ -917,7 +917,7 @@ function closeDetail() {
917
917
  document.getElementById('detail-overlay').classList.remove('open');
918
918
  document.getElementById('detail-panel').classList.remove('open');
919
919
  currentAgentId = null;
920
- stopLivePolling();
920
+ stopLiveStream();
921
921
  }
922
922
 
923
923
  function renderDetailTabs(detail) {
@@ -978,10 +978,10 @@ function renderDetailContent(detail, tab) {
978
978
  } else if (tab === 'live') {
979
979
  el.innerHTML = '<div class="section" id="live-output" style="max-height:60vh;overflow-y:auto;font-size:11px;line-height:1.6">Loading live output...</div>' +
980
980
  '<div style="margin-top:8px;display:flex;gap:8px;align-items:center">' +
981
- '<span class="pulse"></span><span style="font-size:11px;color:var(--green)">Auto-refreshing every 3s</span>' +
981
+ '<span class="pulse"></span><span id="live-status-label" style="font-size:11px;color:var(--green)">Streaming live</span>' +
982
982
  '<button class="pr-pager-btn" onclick="refreshLiveOutput()" style="font-size:10px">Refresh now</button>' +
983
983
  '</div>';
984
- startLivePolling();
984
+ startLiveStream(currentAgentId);
985
985
  } else if (tab === 'charter') {
986
986
  el.innerHTML = '<div class="section">' + escHtml(detail.charter || 'No charter found.') + '</div>';
987
987
  } else if (tab === 'history') {
@@ -1011,6 +1011,49 @@ function renderDetailContent(detail, tab) {
1011
1011
  }
1012
1012
 
1013
1013
  let livePollingInterval = null;
1014
+ let liveEventSource = null;
1015
+
1016
+ function startLiveStream(agentId) {
1017
+ stopLiveStream();
1018
+ if (!agentId) return;
1019
+
1020
+ const outputEl = document.getElementById('live-output');
1021
+ if (outputEl) outputEl.textContent = '';
1022
+
1023
+ liveEventSource = new EventSource('/api/agent/' + agentId + '/live-stream');
1024
+
1025
+ liveEventSource.onmessage = function(e) {
1026
+ try {
1027
+ const chunk = JSON.parse(e.data);
1028
+ const el = document.getElementById('live-output');
1029
+ if (el) {
1030
+ const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
1031
+ el.textContent += chunk;
1032
+ if (wasAtBottom) el.scrollTop = el.scrollHeight;
1033
+ }
1034
+ } catch {}
1035
+ };
1036
+
1037
+ liveEventSource.addEventListener('done', function() {
1038
+ stopLiveStream();
1039
+ });
1040
+
1041
+ liveEventSource.onerror = function() {
1042
+ // Fall back to polling on SSE error
1043
+ stopLiveStream();
1044
+ startLivePolling();
1045
+ const label = document.getElementById('live-status-label');
1046
+ if (label) label.textContent = 'Auto-refreshing every 3s';
1047
+ };
1048
+ }
1049
+
1050
+ function stopLiveStream() {
1051
+ if (liveEventSource) {
1052
+ liveEventSource.close();
1053
+ liveEventSource = null;
1054
+ }
1055
+ stopLivePolling();
1056
+ }
1014
1057
 
1015
1058
  function startLivePolling() {
1016
1059
  stopLivePolling();
@@ -2209,6 +2252,7 @@ function renderDispatch(dispatch) {
2209
2252
  '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
2210
2253
  '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
2211
2254
  '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
2255
+ (d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : '') +
2212
2256
  '</div>'
2213
2257
  ).join('') + '</div>';
2214
2258
  } else {
@@ -2516,6 +2560,7 @@ function wiRow(item) {
2516
2560
  '<td>' + typeBadge(item.type) + '</td>' +
2517
2561
  '<td>' + priBadge(item.priority) + '</td>' +
2518
2562
  '<td>' + statusBadge(item.status || 'pending') +
2563
+ (item._pendingReason ? ' <span style="font-size:9px;color:var(--muted);margin-left:4px" title="Pending reason: ' + escHtml(item._pendingReason) + '">' + escHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
2519
2564
  (item.status === 'failed' ? ' <button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-left:4px" onclick="event.stopPropagation();retryWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')">Retry</button>' : '') +
2520
2565
  '</td>' +
2521
2566
  '<td>' +
@@ -2527,6 +2572,7 @@ function wiRow(item) {
2527
2572
  '<td>' + prLink + '</td>' +
2528
2573
  '<td><span class="pr-date">' + shortTime(item.created) + '</span></td>' +
2529
2574
  '<td style="white-space:nowrap">' +
2575
+ ((item.status === 'pending' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
2530
2576
  ((item.status === 'done' || item.status === 'failed') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
2531
2577
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
2532
2578
  '</td>' +
@@ -2588,6 +2634,63 @@ async function retryWorkItem(id, source) {
2588
2634
  } catch (e) { alert('Retry error: ' + e.message); }
2589
2635
  }
2590
2636
 
2637
+ function editWorkItem(id, source) {
2638
+ const item = allWorkItems.find(i => i.id === id);
2639
+ if (!item) return;
2640
+ const types = ['implement', 'fix', 'review', 'plan', 'verify', 'investigate', 'refactor', 'test', 'docs'];
2641
+ const priorities = ['critical', 'high', 'medium', 'low'];
2642
+ const agentOpts = cmdAgents.map(a => '<option value="' + escHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escHtml(a.name) + '</option>').join('');
2643
+ const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
2644
+ const priOpts = priorities.map(p => '<option value="' + p + '"' + ((item.priority || 'medium') === p ? ' selected' : '') + '>' + p + '</option>').join('');
2645
+
2646
+ document.getElementById('modal-title').textContent = 'Edit Work Item ' + id;
2647
+ document.getElementById('modal-body').style.whiteSpace = 'normal';
2648
+ document.getElementById('modal-body').innerHTML =
2649
+ '<div style="display:flex;flex-direction:column;gap:12px;font-family:inherit">' +
2650
+ '<label style="color:var(--text);font-size:var(--text-md)">Title' +
2651
+ '<input id="wi-edit-title" value="' + escHtml(item.title || '') + '" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit">' +
2652
+ '</label>' +
2653
+ '<label style="color:var(--text);font-size:var(--text-md)">Description' +
2654
+ '<textarea id="wi-edit-desc" rows="3" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit;resize:vertical">' + escHtml(item.description || '') + '</textarea>' +
2655
+ '</label>' +
2656
+ '<div style="display:flex;gap:12px">' +
2657
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Type' +
2658
+ '<select id="wi-edit-type" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' + typeOpts + '</select>' +
2659
+ '</label>' +
2660
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Priority' +
2661
+ '<select id="wi-edit-priority" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)">' + priOpts + '</select>' +
2662
+ '</label>' +
2663
+ '<label style="color:var(--text);font-size:var(--text-md);flex:1">Agent' +
2664
+ '<select id="wi-edit-agent" style="display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md)"><option value="">Auto</option>' + agentOpts + '</select>' +
2665
+ '</label>' +
2666
+ '</div>' +
2667
+ '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">' +
2668
+ '<button onclick="closeModal()" class="pr-pager-btn" style="padding:6px 16px;font-size:var(--text-md)">Cancel</button>' +
2669
+ '<button onclick="submitWorkItemEdit(\'' + escHtml(id) + '\',\'' + escHtml(source || '') + '\')" style="padding:6px 16px;font-size:var(--text-md);background:var(--blue);color:#fff;border:none;border-radius:var(--radius-sm);cursor:pointer">Save</button>' +
2670
+ '</div>' +
2671
+ '</div>';
2672
+ document.getElementById('modal').classList.add('open');
2673
+ }
2674
+
2675
+ async function submitWorkItemEdit(id, source) {
2676
+ const title = document.getElementById('wi-edit-title').value.trim();
2677
+ const description = document.getElementById('wi-edit-desc').value;
2678
+ const type = document.getElementById('wi-edit-type').value;
2679
+ const priority = document.getElementById('wi-edit-priority').value;
2680
+ const agent = document.getElementById('wi-edit-agent').value;
2681
+ if (!title) { alert('Title is required'); return; }
2682
+ try {
2683
+ const res = await fetch('/api/work-items/update', {
2684
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2685
+ body: JSON.stringify({ id, source: source || undefined, title, description, type, priority, agent })
2686
+ });
2687
+ if (res.ok) { closeModal(); refresh(); showToast('cmd-toast', 'Work item updated', true); } else {
2688
+ const d = await res.json();
2689
+ alert('Update failed: ' + (d.error || 'unknown'));
2690
+ }
2691
+ } catch (e) { alert('Update error: ' + e.message); }
2692
+ }
2693
+
2591
2694
  async function deleteWorkItem(id, source) {
2592
2695
  if (!confirm('Delete work item ' + id + '? This will kill any running agent and remove all dispatch history.')) return;
2593
2696
  try {
package/dashboard.js CHANGED
@@ -852,6 +852,44 @@ const server = http.createServer(async (req, res) => {
852
852
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
853
853
  }
854
854
 
855
+ // POST /api/work-items/update — edit a pending/failed work item
856
+ if (req.method === 'POST' && req.url === '/api/work-items/update') {
857
+ try {
858
+ const body = await readBody(req);
859
+ const { id, source, title, description, type, priority, agent } = body;
860
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
861
+
862
+ let wiPath;
863
+ if (!source || source === 'central') {
864
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
865
+ } else {
866
+ const proj = PROJECTS.find(p => p.name === source);
867
+ if (proj) {
868
+ wiPath = shared.projectWorkItemsPath(proj);
869
+ }
870
+ }
871
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
872
+
873
+ const items = JSON.parse(safeRead(wiPath) || '[]');
874
+ const item = items.find(i => i.id === id);
875
+ if (!item) return jsonReply(res, 404, { error: 'item not found' });
876
+
877
+ if (item.status === 'dispatched') {
878
+ return jsonReply(res, 400, { error: 'Cannot edit dispatched items' });
879
+ }
880
+
881
+ if (title !== undefined) item.title = title;
882
+ if (description !== undefined) item.description = description;
883
+ if (type !== undefined) item.type = type;
884
+ if (priority !== undefined) item.priority = priority;
885
+ if (agent !== undefined) item.agent = agent || null;
886
+ item.updatedAt = new Date().toISOString();
887
+
888
+ safeWrite(wiPath, items);
889
+ return jsonReply(res, 200, { ok: true, item });
890
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
891
+ }
892
+
855
893
  // POST /api/notes — write to inbox so it flows through normal consolidation
856
894
  if (req.method === 'POST' && req.url === '/api/notes') {
857
895
  try {
@@ -1076,6 +1114,69 @@ const server = http.createServer(async (req, res) => {
1076
1114
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1077
1115
  }
1078
1116
 
1117
+ // GET /api/agent/:id/live-stream — SSE real-time live output streaming
1118
+ const liveStreamMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/);
1119
+ if (liveStreamMatch && req.method === 'GET') {
1120
+ const agentId = liveStreamMatch[1];
1121
+ const liveLogPath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
1122
+
1123
+ res.writeHead(200, {
1124
+ 'Content-Type': 'text/event-stream',
1125
+ 'Cache-Control': 'no-cache',
1126
+ 'Connection': 'keep-alive',
1127
+ 'Access-Control-Allow-Origin': '*',
1128
+ });
1129
+
1130
+ // Send initial content
1131
+ let offset = 0;
1132
+ try {
1133
+ const content = fs.readFileSync(liveLogPath, 'utf8');
1134
+ if (content.length > 0) {
1135
+ res.write(`data: ${JSON.stringify(content)}\n\n`);
1136
+ offset = Buffer.byteLength(content, 'utf8');
1137
+ }
1138
+ } catch {}
1139
+
1140
+ // Watch for changes using fs.watchFile (cross-platform, works on Windows)
1141
+ const watcher = () => {
1142
+ try {
1143
+ const stat = fs.statSync(liveLogPath);
1144
+ if (stat.size > offset) {
1145
+ const fd = fs.openSync(liveLogPath, 'r');
1146
+ const buf = Buffer.alloc(stat.size - offset);
1147
+ fs.readSync(fd, buf, 0, buf.length, offset);
1148
+ fs.closeSync(fd);
1149
+ offset = stat.size;
1150
+ const chunk = buf.toString('utf8');
1151
+ if (chunk) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
1152
+ }
1153
+ } catch {}
1154
+ };
1155
+
1156
+ fs.watchFile(liveLogPath, { interval: 500 }, watcher);
1157
+
1158
+ // Check if agent is still active (poll every 5s)
1159
+ const doneCheck = setInterval(() => {
1160
+ const dispatch = getDispatchQueue();
1161
+ const isActive = (dispatch.active || []).some(d => d.agent === agentId);
1162
+ if (!isActive) {
1163
+ watcher(); // flush final content
1164
+ res.write(`event: done\ndata: complete\n\n`);
1165
+ clearInterval(doneCheck);
1166
+ fs.unwatchFile(liveLogPath, watcher);
1167
+ res.end();
1168
+ }
1169
+ }, 5000);
1170
+
1171
+ // Cleanup on client disconnect
1172
+ req.on('close', () => {
1173
+ clearInterval(doneCheck);
1174
+ fs.unwatchFile(liveLogPath, watcher);
1175
+ });
1176
+
1177
+ return;
1178
+ }
1179
+
1079
1180
  // GET /api/agent/:id/live — tail live output for a working agent
1080
1181
  const liveMatch = req.url.match(/^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/);
1081
1182
  if (liveMatch && req.method === 'GET') {
@@ -196,7 +196,7 @@ Discovered items land in `engine/dispatch.json`:
196
196
  Each tick, the engine checks available slots:
197
197
 
198
198
  ```
199
- slotsAvailable = maxConcurrent (3) - activeCount
199
+ slotsAvailable = maxConcurrent (5) - activeCount
200
200
  ```
201
201
 
202
202
  It takes up to `slotsAvailable` items from pending and spawns them. Items are processed in discovery-priority order (fixes first, then reviews, then implements, then work-items).
@@ -378,7 +378,7 @@ All discovery behavior is controlled via `config.json`:
378
378
  {
379
379
  "engine": {
380
380
  "tickInterval": 60000, // ms between ticks
381
- "maxConcurrent": 3, // max agents running at once
381
+ "maxConcurrent": 5, // max agents running at once
382
382
  "agentTimeout": 18000000, // 5 hours — kill hung processes
383
383
  "heartbeatTimeout": 300000, // 5min — kill stale/silent agents
384
384
  "maxTurns": 100, // max claude CLI turns per agent
package/engine/ado.js CHANGED
@@ -192,6 +192,7 @@ async function pollPrStatus(config) {
192
192
  pr.buildStatus = buildStatus;
193
193
  if (buildFailReason) pr.buildFailReason = buildFailReason;
194
194
  else delete pr.buildFailReason;
195
+ if (buildStatus !== 'failing') delete pr._buildFailNotified;
195
196
  updated = true;
196
197
  }
197
198