@yemi33/minions 0.1.2086 → 0.1.2088

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/dashboard.js CHANGED
@@ -73,11 +73,6 @@ const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
73
73
 
74
74
  const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
75
75
  let CONFIG = queries.getConfig();
76
- // Mirror cli.js: clear persisted statusWorkItemsRetentionDays=7 + the matching
77
- // meetings field (prior default 7) so the new default (0, no trim) reaches the
78
- // /api/status slimmers without needing an engine bounce first.
79
- try { shared.applyStatusWorkItemsRetentionMigration(CONFIG); } catch { /* best-effort */ }
80
- try { shared.applyStatusMeetingsRetentionMigration(CONFIG); } catch { /* best-effort */ }
81
76
  let PROJECTS = _getProjects(CONFIG);
82
77
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
83
78
  const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
@@ -100,8 +95,6 @@ function ensureConfiguredProjectStateFiles() {
100
95
 
101
96
  function reloadConfig() {
102
97
  CONFIG = queries.getConfig();
103
- try { shared.applyStatusWorkItemsRetentionMigration(CONFIG); } catch { /* best-effort */ }
104
- try { shared.applyStatusMeetingsRetentionMigration(CONFIG); } catch { /* best-effort */ }
105
98
  PROJECTS = _getProjects(CONFIG);
106
99
  ensureConfiguredProjectStateFiles();
107
100
  }
@@ -1744,6 +1737,14 @@ function invalidateStatusCache(_opts) {
1744
1737
  function _buildStatusFastState() {
1745
1738
  // reloadConfig is called by both sync and async callers before this — kept
1746
1739
  // outside so the async path can yield between reload and rebuild.
1740
+ //
1741
+ // Issue #2949 — the heavy slices (agents/workItems/pullRequests/dispatch/
1742
+ // metrics/inbox/notes/engineLog/watches/meetings/qaRuns/qaSessions/
1743
+ // meetingsTotal) all moved to dedicated /api/<x> endpoints or /state/<path>
1744
+ // static-file passthrough. They're not in this snapshot anymore — every
1745
+ // page now fetches its own dedicated endpoint with input-mtime ETag, so
1746
+ // there's no outer cache that can hold a fresh disk write hostage.
1747
+ // /api/status is now just the small live engine/throttle envelope.
1747
1748
  const engineState = getEngineState();
1748
1749
  const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
1749
1750
  // W-mpof1xxe000ac689 — Two-signal liveness check. heartbeat (15s cadence,
@@ -1752,309 +1753,38 @@ function _buildStatusFastState() {
1752
1753
  // heartbeat writer silently dies but tickInner keeps running, the heartbeat
1753
1754
  // ages past 120s while lastTickAt stays fresh — the dashboard used to lie
1754
1755
  // ("STALE") on a provably-ticking engine. Engine is alive iff ANY signal is
1755
- // fresh; only when BOTH age past threshold do we trip the badge. The tick
1756
- // threshold is floored by ENGINE_HEARTBEAT_STALE_MS so a misconfigured 5s
1757
- // tickInterval cannot trip the boolean in 10s.
1756
+ // fresh; only when BOTH age past threshold do we trip the badge.
1758
1757
  const tickInterval = Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval;
1759
1758
  const tickStaleThresholdMs = Math.max(ENGINE_HEARTBEAT_STALE_MS, 2 * tickInterval);
1760
1759
  const tickAge = engineState.lastTickAt ? Date.now() - engineState.lastTickAt : Infinity;
1761
1760
  return {
1762
- agents: getAgents(),
1763
- inbox: getInbox(),
1764
- notes: getNotesWithMeta(),
1765
- pullRequests: getPullRequests(),
1766
1761
  engine: {
1767
1762
  ...engineState,
1768
1763
  worktreeCount: _countWorktrees(),
1769
- // Snapshot-time staleness. Frozen with the snapshot; the client renders
1770
- // off these instead of recomputing Date.now() - heartbeat against a
1771
- // possibly-cached payload (false-positive banner regression #2754).
1772
1764
  heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
1773
1765
  heartbeatStale: !!(engineState.heartbeat
1774
1766
  && hbAge > ENGINE_HEARTBEAT_STALE_MS
1775
1767
  && tickAge > tickStaleThresholdMs),
1776
- // W-mpnc4u8c001d9d6c — Surface the tick cadence so the client's
1777
- // #engine-quick-stats "Next tick in Xs" countdown reads the SAME value
1778
- // the engine actually uses (Settings parity rule). Paired with
1779
- // control.lastTickAt (above) stamped at the start of every tickInner.
1780
1768
  tickInterval: tickInterval,
1781
1769
  },
1782
1770
  adoThrottle: ado.getAdoThrottleState(),
1783
1771
  ghThrottle: gh.getGhThrottleState(),
1784
- dispatch: getDispatchQueue(),
1785
- engineLog: getEngineLog(),
1786
- metrics: getMetrics(),
1787
- workItems: _slimWorkItemsForStatus(getWorkItems()),
1788
- watches: watchesMod.getWatches(),
1789
- meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
1790
- // Top-level full meeting count (NOT slim slice length). Surfaced so the
1791
- // sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
1792
- // still fires when ANY meeting — including old/archived ones dropped from
1793
- // the slim slice by statusMeetingsRetentionDays — gains a new round.
1794
- // Without this, completing the third round of an archived meeting would
1795
- // silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
1796
- meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
1797
- // QA runs — surfaced for the sidebar activity-dot counter and any future
1798
- // CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
1799
- // (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
1800
- // so a new run lights the dot within one /api/status poll cycle (~4 s).
1801
- // Uses the unsorted summary helper rather than listRuns({limit:50}) — the
1802
- // latter reads + sorts the FULL qa-runs.json on every fast-state rebuild
1803
- // and would charge O(N log N) to the /api/status hot path. The summary
1804
- // helper returns { total, sig } without sorting; that's all the sidebar
1805
- // counter needs to detect new runs and status flips.
1806
- qaRuns: _safeStatusSlice('qaRuns', () => qaRunsMod.summarizeRunsForStatus(), { total: 0, sig: '' }),
1807
- // QA sessions — same role as qaRuns above. The sidebar activity-dot
1808
- // for QA Sessions polls this slice for the cheap { total, sig } summary
1809
- // so a new session or a state transition lights the dot within one
1810
- // /api/status cycle. summarizeSessionsForStatus is unsorted (mirrors
1811
- // qaRunsMod.summarizeRunsForStatus) so this stays O(N) and doesn't
1812
- // charge sort cost to the /api/status hot path.
1813
- qaSessions: _safeStatusSlice('qaSessions', () => {
1814
- const qaSessions = require('./engine/qa-sessions');
1815
- return qaSessions.summarizeSessionsForStatus();
1816
- }, { total: 0, sig: '' }),
1817
1772
  };
1818
1773
  }
1819
1774
 
1820
- // Run a status-slice producer with rate-limited error logging. The lazy-
1821
- // require IIFEs this replaces silently degraded to fallback values if the
1822
- // underlying module had a syntax error or got renamed — sidebar dots went
1823
- // dark with no diagnostic. We keep returning the fallback so the rest of
1824
- // the status payload survives, but we log once per minute per slice so a
1825
- // persistent error doesn't spam the engine log AND isn't invisible.
1826
- const _statusSliceErrorLastLogged = new Map();
1827
- function _safeStatusSlice(name, fn, fallback) {
1828
- try {
1829
- return fn();
1830
- } catch (e) {
1831
- const now = Date.now();
1832
- const last = _statusSliceErrorLastLogged.get(name) || 0;
1833
- if (now - last > 60000) {
1834
- _statusSliceErrorLastLogged.set(name, now);
1835
- console.error('[status] slice ' + name + ' failed: ' + (e && e.message || e));
1836
- }
1837
- return fallback;
1838
- }
1839
- }
1840
-
1841
- // ── /api/status workItems slimming (W-mphejzmj000718bf) ─────────────────────
1842
- // Project each work item onto a narrow shape that omits the large free-text
1843
- // fields (description, full acceptanceCriteria, full references) before
1844
- // shipping on /api/status. The dashboard never renders description/AC/
1845
- // references-detail off the cached slice — `wiRow` only needs counts +
1846
- // status/badge fields, and `openWorkItemDetail` fetches the full record on
1847
- // demand via GET /api/work-items/<id>. This slim projection is the bulk of
1848
- // the payload savings (~3MB → <500KB typical) and runs unconditionally.
1849
- //
1850
- // engine.statusWorkItemsRetentionDays is an optional second-tier filter that
1851
- // drops terminal (done/failed/cancelled) items older than N days. Default 0
1852
- // = no trim (legacy behavior, full list shipped). Set to a positive integer
1853
- // to opt into the date-based trim. Active items (pending/dispatched/queued)
1854
- // are ALWAYS kept regardless of age.
1855
- const _ACTIVE_WI_STATUSES_FOR_STATUS = new Set(['pending', 'dispatched', 'queued', 'paused', 'decomposed']);
1856
- const _TERMINAL_WI_STATUSES_FOR_STATUS = new Set(['done', 'failed', 'cancelled']);
1857
- function _resolveStatusWorkItemsRetentionDays() {
1858
- const raw = CONFIG?.engine?.statusWorkItemsRetentionDays;
1859
- if (raw === 0 || raw === '0') return 0;
1860
- const n = Number(raw);
1861
- if (Number.isFinite(n) && n >= 0) return n;
1862
- return shared.ENGINE_DEFAULTS.statusWorkItemsRetentionDays;
1863
- }
1864
- function _slimWorkItemForStatus(item) {
1865
- const slim = {
1866
- id: item.id,
1867
- title: item.title,
1868
- status: item.status,
1869
- type: item.type,
1870
- priority: item.priority,
1871
- _source: item._source,
1872
- created: item.created,
1873
- };
1874
- if (item.dispatched_to !== undefined) slim.dispatched_to = item.dispatched_to;
1875
- if (item.agent !== undefined) slim.agent = item.agent;
1876
- if (item._pr !== undefined) slim._pr = item._pr;
1877
- if (item._prUrl !== undefined) slim._prUrl = item._prUrl;
1878
- if (item._skipReason !== undefined) slim._skipReason = item._skipReason;
1879
- if (item._blockedBy !== undefined) slim._blockedBy = item._blockedBy;
1880
- if (item._humanFeedback !== undefined) slim._humanFeedback = item._humanFeedback;
1881
- if (Array.isArray(item.completedAgents)) slim.completedAgents = item.completedAgents;
1882
- if (item.failReason !== undefined) slim.failReason = item.failReason;
1883
- if (item.branchStrategy !== undefined) slim.branchStrategy = item.branchStrategy;
1884
- if (item.scope !== undefined) slim.scope = item.scope;
1885
- if (item.completedAt !== undefined) slim.completedAt = item.completedAt;
1886
- if (item.dispatched_at !== undefined) slim.dispatched_at = item.dispatched_at;
1887
- if (item._reopened !== undefined) slim._reopened = item._reopened;
1888
- if (item._pendingReason !== undefined) slim._pendingReason = item._pendingReason;
1889
- if (item._managedSpawnPartial !== undefined) slim._managedSpawnPartial = item._managedSpawnPartial;
1890
- if (item._securityFlag !== undefined) slim._securityFlag = item._securityFlag;
1891
- if (item._artifacts !== undefined) slim._artifacts = item._artifacts;
1892
- // Cross-slice fields read by derivePlanStatus / render-prd / refresh.js
1893
- // (sourcePlan, planFile, itemType, project, parent_id). Without these the
1894
- // plan-status derivation and decomposed-children rollup regress.
1895
- if (item.sourcePlan !== undefined) slim.sourcePlan = item.sourcePlan;
1896
- if (item.planFile !== undefined) slim.planFile = item.planFile;
1897
- if (item.itemType !== undefined) slim.itemType = item.itemType;
1898
- if (item.project !== undefined) slim.project = item.project;
1899
- if (item.parent_id !== undefined) slim.parent_id = item.parent_id;
1900
- // PR follow-up subobject is small and drives the +N chip + row badge.
1901
- if (item.meta && item.meta.pr_followup) slim.meta = { pr_followup: item.meta.pr_followup };
1902
- // Counts only — the modal fetches full arrays on demand via /api/work-items/<id>.
1903
- slim.referencesCount = Array.isArray(item.references) ? item.references.length : 0;
1904
- slim.acceptanceCriteriaCount = Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0;
1905
- return slim;
1906
- }
1907
- function _slimWorkItemsForStatus(items) {
1908
- if (!Array.isArray(items)) return items;
1909
- const retentionDays = _resolveStatusWorkItemsRetentionDays();
1910
- if (retentionDays <= 0) {
1911
- // Trimming disabled — keep full list but still flatten via slim shape
1912
- // so wire format is consistent (renderers never have to handle the old
1913
- // heavy fields). Set retentionDays to a huge value if you want EVERY
1914
- // terminal item, not just recent ones — 0 means "skip the date filter".
1915
- return items.map(_slimWorkItemForStatus);
1916
- }
1917
- const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1918
- const surviving = [];
1919
- for (const item of items) {
1920
- if (!item) continue;
1921
- const status = item.status || 'pending';
1922
- if (_TERMINAL_WI_STATUSES_FOR_STATUS.has(status)) {
1923
- const ts = item.completedAt || item.dispatched_at || item.created || '';
1924
- const tsMs = ts ? Date.parse(ts) : NaN;
1925
- // Drop only when we have a parseable timestamp and it's beyond the
1926
- // window. Items with missing/unparseable timestamps stay visible —
1927
- // we'd rather over-include than silently hide them.
1928
- if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
1929
- continue;
1930
- }
1931
- } else if (!_ACTIVE_WI_STATUSES_FOR_STATUS.has(status)) {
1932
- // Unknown status — keep, so a future status value isn't silently
1933
- // hidden until the constant set is updated.
1934
- }
1935
- surviving.push(_slimWorkItemForStatus(item));
1936
- }
1937
- return surviving;
1938
- }
1939
-
1940
- // ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
1941
- // Mirrors the workItems slim above (PR #2816). Meetings were the second
1942
- // largest /api/status slice — live measurement: 22 meetings / 4.3MB
1943
- // (60% of the 7.2MB payload) before slimming. The list renderer in
1944
- // dashboard/js/render-meetings.js:renderMeetings only needs:
1945
- // - id, title, status, round, participants, agenda(short), createdAt,
1946
- // completedAt
1947
- // - per-participant booleans of findings/debate (used to pick the
1948
- // ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
1949
- // The detail modal calls `/api/meetings/:id` which serves the full record
1950
- // (findings.content + debate.content + conclusion + transcript bodies), so
1951
- // dropping those bodies from the slice is safe. The slim projection alone
1952
- // delivers the bulk of the payload savings and always runs.
1953
- //
1954
- // engine.statusMeetingsRetentionDays is an optional second-tier filter that
1955
- // drops terminal (completed/archived) meetings older than N days. Default 0
1956
- // = no trim (legacy behavior, full list shipped — but still slim-shaped).
1957
- // Active meetings (investigating/debating/concluding) are ALWAYS kept.
1958
- const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
1959
- const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
1960
- function _resolveStatusMeetingsRetentionDays() {
1961
- const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
1962
- if (raw === 0 || raw === '0') return 0;
1963
- const n = Number(raw);
1964
- if (Number.isFinite(n) && n >= 0) return n;
1965
- return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
1966
- }
1967
- function _slimMeetingForStatus(meeting) {
1968
- // Reduce findings/debate objects to {agentId: true} sentinels — the list
1969
- // renderer only checks `m.findings?.[p]` for truthiness when picking the
1970
- // participant-badge icon. Keeping just the keys preserves that contract
1971
- // while dropping the per-round agent transcript bodies (~95KB+ each).
1972
- const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
1973
- ? Object.keys(meeting.findings) : [];
1974
- const debateKeys = meeting.debate && typeof meeting.debate === 'object'
1975
- ? Object.keys(meeting.debate) : [];
1976
- const findings = {};
1977
- for (const k of findingsKeys) findings[k] = true;
1978
- const debate = {};
1979
- for (const k of debateKeys) debate[k] = true;
1980
- const slim = {
1981
- id: meeting.id,
1982
- title: meeting.title,
1983
- status: meeting.status,
1984
- round: meeting.round,
1985
- participants: Array.isArray(meeting.participants) ? meeting.participants : [],
1986
- agenda: meeting.agenda,
1987
- createdAt: meeting.createdAt,
1988
- findings,
1989
- debate,
1990
- };
1991
- if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
1992
- if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
1993
- if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
1994
- return slim;
1995
- }
1996
- function _slimMeetingsForStatus(meetings) {
1997
- if (!Array.isArray(meetings)) return meetings;
1998
- const retentionDays = _resolveStatusMeetingsRetentionDays();
1999
- if (retentionDays <= 0) {
2000
- // Trimming disabled — keep full list but still flatten via slim shape
2001
- // so wire format is consistent and the heavy bodies never ship via
2002
- // /api/status regardless of operator config.
2003
- return meetings.map(_slimMeetingForStatus);
2004
- }
2005
- const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
2006
- const surviving = [];
2007
- for (const meeting of meetings) {
2008
- if (!meeting) continue;
2009
- const status = meeting.status || 'investigating';
2010
- if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
2011
- const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
2012
- const tsMs = ts ? Date.parse(ts) : NaN;
2013
- // Drop only when we have a parseable timestamp and it's beyond the
2014
- // window. Meetings with missing/unparseable timestamps stay visible —
2015
- // we'd rather over-include than silently hide them.
2016
- if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
2017
- continue;
2018
- }
2019
- } else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
2020
- // Unknown status — keep, so a future round name isn't silently
2021
- // hidden until the constant set is updated.
2022
- }
2023
- surviving.push(_slimMeetingForStatus(meeting));
2024
- }
2025
- return surviving;
2026
- }
2027
- // Count meetings on disk without rehydrating bodies — backs the sidebar
2028
- // activity dot signature so new rounds in trimmed/archived meetings still
2029
- // flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
2030
- function _countMeetingsForStatus() {
2031
- const meetings = meetingMod.getMeetings();
2032
- return Array.isArray(meetings) ? meetings.length : 0;
2033
- }
2034
-
2035
1775
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
2036
1776
  function _buildStatusSlowState() {
2037
- const prdInfo = getPrdInfo();
1777
+ // Issue #2949 — heavy slices moved to dedicated endpoints (see header on
1778
+ // _buildStatusFastState). prdProgress + prd → /api/prd, verifyGuides →
1779
+ // /api/verify-guides, archivedPrds → /api/archived-prds, schedules →
1780
+ // /api/schedules, pipelines → /api/pipelines, pinned → /api/pinned.
1781
+ // What's left here is genuine "shape of the install" state that has no
1782
+ // staleness problem: projects + git status (server-only compute), runtime
1783
+ // capability roll-ups (skills + mcpServers), config-derived autoMode +
1784
+ // initialized + installId, and the version banner.
2038
1785
  return {
2039
- prdProgress: prdInfo.progress,
2040
- prd: prdInfo.status,
2041
- verifyGuides: getVerifyGuides(),
2042
- archivedPrds: getArchivedPrds(),
2043
1786
  skills: getSkills(),
2044
1787
  mcpServers: getMcpServers(),
2045
- schedules: (() => {
2046
- const scheds = CONFIG.schedules || [];
2047
- const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
2048
- return scheds.map(s => {
2049
- const runEntry = runs[s.id];
2050
- // Backward compat: runEntry can be a string (old format) or object (new format with back-references)
2051
- const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
2052
- const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
2053
- return { ...s, _lastRun, ...extra };
2054
- });
2055
- })(),
2056
- pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
2057
- pinned: (() => { try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; } })(),
2058
1788
  projects: PROJECTS.map(p => {
2059
1789
  const status = getProjectGitStatus(p.localPath, p.mainBranch);
2060
1790
  const mainBranch = p.mainBranch || null;
@@ -2357,6 +2087,331 @@ function getStatusJson() {
2357
2087
  return _statusCacheJson;
2358
2088
  }
2359
2089
 
2090
+ // ── /state/<path> — raw state-file passthrough ────────────────────────────
2091
+ // Static-file handler that serves files from under MINIONS_DIR directly,
2092
+ // bypassing the /api/status assembled-snapshot cache. Per-page polls hit
2093
+ // this instead of /api/status so a stale outer cache cannot hold a fresh
2094
+ // disk write hostage. ETag = mtimeMs + size → 304 when unchanged.
2095
+ //
2096
+ // Safety:
2097
+ // 1. shared.sanitizePath rejects null bytes / .. / absolute paths and
2098
+ // asserts the resolved target stays under MINIONS_DIR.
2099
+ // 2. Top-level dir must be in STATE_READ_ALLOWED_DIRS — no exposing
2100
+ // config.json, .install-id, source code, .gitignored secrets.
2101
+ // 3. STATE_READ_DENIED_PATTERNS deny-lists sensitive files INSIDE
2102
+ // allowed dirs (SQLite db, .backup sidecars, live-output.log,
2103
+ // session.json — any of which leak credentials or runtime state).
2104
+ // 4. Symlinks are rejected outright (no follow → realpath round-trip).
2105
+ // 5. Directory paths return a JSON listing [{name,size,mtimeMs,isDir}],
2106
+ // one level deep — needed for inbox/ and notes/inbox/ enumeration.
2107
+ const STATE_READ_ALLOWED_DIRS = new Set([
2108
+ 'projects', 'engine', 'prd', 'notes', 'plans',
2109
+ 'pipelines', 'knowledge', 'agents', 'watches.d',
2110
+ 'meetings',
2111
+ ]);
2112
+ // Single-file allowances for state files that live at MINIONS_DIR root
2113
+ // rather than inside an allowlisted directory. Currently: notes.md (consolidated
2114
+ // human + agent notes; renderer needs raw markdown). Keep this tight.
2115
+ const STATE_READ_ALLOWED_FILES = new Set([
2116
+ 'notes.md',
2117
+ ]);
2118
+ // Sensitive session-bearing JSON filenames that must NEVER be served via
2119
+ // /state/<path>. Exact basename match (not a broad regex) so we don't
2120
+ // silently swallow legitimate user filenames like plans/redis-sessions.md
2121
+ // or meetings/MTG-debugging-sessions-*.json. (Round-2 review finding #9.)
2122
+ const STATE_READ_DENIED_BASENAMES = new Set([
2123
+ 'session.json',
2124
+ 'sessions.json',
2125
+ 'cc-session.json',
2126
+ 'cc-sessions.json',
2127
+ 'qa-sessions.json',
2128
+ 'qa-sessions-history.json',
2129
+ 'session-state.json',
2130
+ 'session-token.json',
2131
+ 'keep-pids.json',
2132
+ // doc-sessions.json holds user-authored doc-chat conversation state
2133
+ // (questions + document context + LLM responses). Was previously denied
2134
+ // via a broad *sessions?.json regex that round-2 #9 narrowed; restoring
2135
+ // explicit coverage here. (Round-3 review finding #2.)
2136
+ 'doc-sessions.json',
2137
+ ]);
2138
+ const STATE_READ_DENIED_PATTERNS = [
2139
+ /(^|[\\/])state\.db($|-wal$|-shm$)/i,
2140
+ /\.backup$/i,
2141
+ // Engine writes several stdout-bearing log shapes under agents/<id>/:
2142
+ // - live-output.log (tail-followed during the run)
2143
+ // - output.log (legacy post-run dump)
2144
+ // - output-<agent>-<type>-<id>.log (per-dispatch named log written by
2145
+ // lifecycle.js — typically the largest single byte source)
2146
+ // - live-output-prev.log (rotation backup of the previous run)
2147
+ // All contain agent stdout that can leak credentials echoed in error
2148
+ // stack traces and untrusted PR-comment content. (Round-3 review #1.)
2149
+ // `-[^\\/]*` (zero-or-more, NOT one-or-more) so degenerate `output-.log`
2150
+ // and `live-output-.log` variants are also caught — a malformed dispatch
2151
+ // template resolving the id to empty would otherwise slip past.
2152
+ // (Round-4 #4.)
2153
+ /(^|[\\/])(live-)?output(-prev|-[^\\/]*)?\.log$/i,
2154
+ /(^|[\\/])\.env($|\.)/i,
2155
+ ];
2156
+ function _isStateReadPathDenied(rel) {
2157
+ if (STATE_READ_DENIED_PATTERNS.some(re => re.test(rel))) return true;
2158
+ // Basename comparison — extract the trailing segment and compare against
2159
+ // the known-sensitive set. Forward and backslash both supported so
2160
+ // Windows-shaped rel paths are caught.
2161
+ const base = rel.split(/[\\/]/).pop() || '';
2162
+ return STATE_READ_DENIED_BASENAMES.has(base.toLowerCase());
2163
+ }
2164
+ function _stateReadContentType(ext) {
2165
+ switch (ext.toLowerCase()) {
2166
+ case '.json': return 'application/json; charset=utf-8';
2167
+ case '.md': return 'text/markdown; charset=utf-8';
2168
+ case '.txt': case '.log': return 'text/plain; charset=utf-8';
2169
+ default: return 'application/octet-stream';
2170
+ }
2171
+ }
2172
+ // ── Dedicated fresh-JSON endpoint pattern ─────────────────────────────────
2173
+ // Companion to /state/<path>. /state/ is "serve a single file verbatim";
2174
+ // serveFreshJson is "run server-side enrichment, ETag off input mtimes."
2175
+ //
2176
+ // Used for slices whose payload joins multiple files (agents derived from
2177
+ // config + dispatch + inbox + steering, metrics derived from metrics.json
2178
+ // + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
2179
+ // Replicating those joins in browser JS would be brittle, and shipping the
2180
+ // fully-enriched result through /api/status pays the staleness tax that
2181
+ // issue #2949 documented. This pattern bypasses both:
2182
+ //
2183
+ // 1. Caller supplies an array of input file paths.
2184
+ // 2. We statSync each one and take MAX(mtimeMs). That's the ETag.
2185
+ // 3. If the client's If-None-Match matches → 304 with no body.
2186
+ // 4. Otherwise call `builder()` to assemble the response from scratch
2187
+ // (no outer cache layer to go stale).
2188
+ // 5. Stamp ETag + Content-Type and send.
2189
+ //
2190
+ // Cost per request when ETag matches: N statSync calls (microseconds).
2191
+ // Cost on a miss: same N statSync + the builder, which is exactly the
2192
+ // existing queries.getAgents() / getMetrics() / etc. that /api/status
2193
+ // already pays. Net change: same compute, no caching, always-fresh.
2194
+ function _maxInputMtimeMs(inputs) {
2195
+ let max = 0;
2196
+ for (const fp of inputs) {
2197
+ try {
2198
+ // Use lstatSync so a broken Windows junction (common after DevBox
2199
+ // swap) or a circular symlink doesn't block the event loop on a
2200
+ // ~30 s OS timeout trying to follow the link. Engine never writes
2201
+ // symlinks into state dirs in normal operation, so an lstat that
2202
+ // detects a symlink is treated as "skip" — the input is effectively
2203
+ // not tracked. (Round-2 review finding #7.)
2204
+ const s = fs.lstatSync(fp);
2205
+ if (s.isSymbolicLink()) continue;
2206
+ if (s.mtimeMs > max) max = s.mtimeMs;
2207
+ // Directory inputs: dir mtime only advances on add/remove of
2208
+ // immediate entries, not on in-place file edits. Walk two levels
2209
+ // down so editing prd/<plan>.json in place (depth 1) and
2210
+ // agents/<id>/live-output.log (depth 2) both bust the ETag.
2211
+ // Steering edits at agents/<id>/inbox/steering-*.md (depth 3)
2212
+ // currently rely on add/remove cadence rather than per-file content
2213
+ // mtime — accept this lag rather than walking unbounded.
2214
+ // (Review finding #6.)
2215
+ if (s.isDirectory()) {
2216
+ let level1;
2217
+ try { level1 = fs.readdirSync(fp); } catch { level1 = []; }
2218
+ for (const name of level1) {
2219
+ const child = path.join(fp, name);
2220
+ let cs;
2221
+ try { cs = fs.lstatSync(child); } catch { continue; }
2222
+ if (cs.isSymbolicLink()) continue;
2223
+ if (cs.mtimeMs > max) max = cs.mtimeMs;
2224
+ if (cs.isDirectory()) {
2225
+ let level2;
2226
+ try { level2 = fs.readdirSync(child); } catch { level2 = []; }
2227
+ for (const inner of level2) {
2228
+ try {
2229
+ const is = fs.lstatSync(path.join(child, inner));
2230
+ if (is.isSymbolicLink()) continue;
2231
+ if (is.mtimeMs > max) max = is.mtimeMs;
2232
+ } catch { /* skip unreadable */ }
2233
+ }
2234
+ }
2235
+ }
2236
+ }
2237
+ } catch {
2238
+ // Missing input = not yet on disk. Don't bust the ETag — a builder
2239
+ // that legitimately depends on the file will surface its own empty
2240
+ // state, and the ETag will move once the file appears.
2241
+ }
2242
+ }
2243
+ return Math.floor(max);
2244
+ }
2245
+ function serveFreshJson(req, res, opts) {
2246
+ const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
2247
+ const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
2248
+ const tag = opts && opts.tag ? String(opts.tag) : 'v';
2249
+ if (!builder) {
2250
+ res.statusCode = 500;
2251
+ res.setHeader('Content-Type', 'application/json');
2252
+ res.end(JSON.stringify({ error: 'serveFreshJson: builder is required' }));
2253
+ return;
2254
+ }
2255
+ const mtime = _maxInputMtimeMs(inputs);
2256
+ const etag = '"' + tag + '-' + mtime + '"';
2257
+ res.setHeader('ETag', etag);
2258
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2259
+ if (req && req.headers && req.headers['if-none-match'] === etag) {
2260
+ res.statusCode = 304;
2261
+ res.end();
2262
+ return;
2263
+ }
2264
+ let payload;
2265
+ try {
2266
+ payload = builder();
2267
+ } catch (e) {
2268
+ res.statusCode = 500;
2269
+ res.setHeader('Content-Type', 'application/json');
2270
+ res.end(JSON.stringify({ error: e && e.message || String(e) }));
2271
+ return;
2272
+ }
2273
+ res.statusCode = 200;
2274
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
2275
+ res.end(JSON.stringify(payload));
2276
+ }
2277
+
2278
+ function handleStateRead(req, res) {
2279
+ const urlPath = req.url.split('?')[0];
2280
+ const rel = decodeURIComponent(urlPath.slice('/state/'.length));
2281
+ if (!rel) {
2282
+ res.statusCode = 400;
2283
+ res.setHeader('Content-Type', 'application/json');
2284
+ res.end(JSON.stringify({ error: 'path required' }));
2285
+ return;
2286
+ }
2287
+ // Top-level allowlist — first path segment must match a permitted
2288
+ // directory OR (when there is no further path) a permitted root file.
2289
+ // STRICT-CASE comparison: case-folding the allowlist would create
2290
+ // Linux/Windows divergence (Windows NTFS resolves /state/PROJECTS/... to
2291
+ // the real lowercase dir and serves 200; Linux ext4 returns 404 since
2292
+ // sanitizePath preserves the literal-case path that lstat would then
2293
+ // miss). yemi33 PR Tests runs Windows-only — case-folding lets a
2294
+ // platform-divergent path land without CI signal. URLs in this codebase
2295
+ // are canonically lowercase; reject mixed-case to keep parity.
2296
+ // (Round-4 #5 — reverts the round-3 case-insensitive change.)
2297
+ const segments = rel.split(/[\\/]/);
2298
+ const top = segments[0] || '';
2299
+ const isSingleSegment = segments.length === 1;
2300
+ if (!STATE_READ_ALLOWED_DIRS.has(top) &&
2301
+ !(isSingleSegment && STATE_READ_ALLOWED_FILES.has(top))) {
2302
+ res.statusCode = 403;
2303
+ res.setHeader('Content-Type', 'application/json');
2304
+ res.end(JSON.stringify({ error: 'forbidden' }));
2305
+ return;
2306
+ }
2307
+ if (_isStateReadPathDenied(rel)) {
2308
+ res.statusCode = 403;
2309
+ res.setHeader('Content-Type', 'application/json');
2310
+ res.end(JSON.stringify({ error: 'forbidden file' }));
2311
+ return;
2312
+ }
2313
+ let resolved;
2314
+ try {
2315
+ resolved = shared.sanitizePath(rel, MINIONS_DIR);
2316
+ } catch (e) {
2317
+ res.statusCode = 403;
2318
+ res.setHeader('Content-Type', 'application/json');
2319
+ res.end(JSON.stringify({ error: e.message }));
2320
+ return;
2321
+ }
2322
+ let lstat;
2323
+ try { lstat = fs.lstatSync(resolved); }
2324
+ catch {
2325
+ res.statusCode = 404;
2326
+ res.setHeader('Content-Type', 'application/json');
2327
+ res.end(JSON.stringify({ error: 'not found' }));
2328
+ return;
2329
+ }
2330
+ // Reject symlinks outright — even if they point inside MINIONS_DIR, they
2331
+ // can be swapped after the allowlist check (TOCTOU) to escape. Engine
2332
+ // never writes symlinks into state dirs in normal operation.
2333
+ if (lstat.isSymbolicLink()) {
2334
+ res.statusCode = 403;
2335
+ res.setHeader('Content-Type', 'application/json');
2336
+ res.end(JSON.stringify({ error: 'symlinks not served' }));
2337
+ return;
2338
+ }
2339
+ if (lstat.isDirectory()) {
2340
+ // One-level directory listing for enumeration (inbox, archive, etc.).
2341
+ // Per-entry stat lives inside the inner try-catch so a single bad entry
2342
+ // doesn't fail the whole listing — bad entry just gets dropped.
2343
+ const etag = '"dir-' + Math.floor(lstat.mtimeMs) + '"';
2344
+ res.setHeader('ETag', etag);
2345
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2346
+ if (req.headers['if-none-match'] === etag) {
2347
+ res.statusCode = 304;
2348
+ res.end();
2349
+ return;
2350
+ }
2351
+ let entries;
2352
+ try {
2353
+ entries = fs.readdirSync(resolved).map(name => {
2354
+ // Re-apply the deny-list to entry names so the listing doesn't
2355
+ // leak the existence of state.db / *.backup / live-output.log /
2356
+ // session.json / .env* even though the per-file fetch for them
2357
+ // would 403. Deny patterns match against the relative path,
2358
+ // mirroring the request-time check above. (Review finding #8.)
2359
+ const childRel = (rel + '/' + name).replace(/\\/g, '/');
2360
+ if (_isStateReadPathDenied(childRel)) return null;
2361
+ try {
2362
+ const s = fs.lstatSync(path.join(resolved, name));
2363
+ if (s.isSymbolicLink()) return null;
2364
+ return {
2365
+ name,
2366
+ size: s.size,
2367
+ mtimeMs: Math.floor(s.mtimeMs),
2368
+ isDir: s.isDirectory(),
2369
+ };
2370
+ } catch { return null; }
2371
+ }).filter(Boolean);
2372
+ } catch (e) {
2373
+ res.statusCode = 500;
2374
+ res.setHeader('Content-Type', 'application/json');
2375
+ res.end(JSON.stringify({ error: e.message }));
2376
+ return;
2377
+ }
2378
+ res.statusCode = 200;
2379
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
2380
+ res.end(JSON.stringify({ path: rel.replace(/\\/g, '/'), entries }));
2381
+ return;
2382
+ }
2383
+ // File path: mtime+size ETag, stream the bytes.
2384
+ const etag = '"' + Math.floor(lstat.mtimeMs) + '-' + lstat.size + '"';
2385
+ res.setHeader('ETag', etag);
2386
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2387
+ if (req.headers['if-none-match'] === etag) {
2388
+ res.statusCode = 304;
2389
+ res.end();
2390
+ return;
2391
+ }
2392
+ res.statusCode = 200;
2393
+ res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
2394
+ try {
2395
+ const stream = fs.createReadStream(resolved);
2396
+ stream.on('error', (err) => {
2397
+ if (!res.headersSent) {
2398
+ res.statusCode = 500;
2399
+ res.setHeader('Content-Type', 'application/json');
2400
+ res.end(JSON.stringify({ error: err.message }));
2401
+ } else if (!res.writableEnded) {
2402
+ res.end();
2403
+ }
2404
+ });
2405
+ stream.pipe(res);
2406
+ } catch (e) {
2407
+ if (!res.headersSent) {
2408
+ res.statusCode = 500;
2409
+ res.setHeader('Content-Type', 'application/json');
2410
+ res.end(JSON.stringify({ error: e.message }));
2411
+ }
2412
+ }
2413
+ }
2414
+
2360
2415
  // Top-level /api/status request handler (W-mpehsyhv0017085a). Extracted from
2361
2416
  // the inline route handler so unit tests can call it with mock req/res and so
2362
2417
  // production routing has a single source of truth. The inline route delegates
@@ -2386,7 +2441,6 @@ async function _handleStatusRequest(req, res) {
2386
2441
  if (_ifNoneMatchHasEtag(inm, currentEtag)) {
2387
2442
  res.setHeader('ETag', currentEtag);
2388
2443
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2389
- res.setHeader('Access-Control-Allow-Origin', '*');
2390
2444
  res.statusCode = 304;
2391
2445
  res.end();
2392
2446
  return;
@@ -2395,7 +2449,6 @@ async function _handleStatusRequest(req, res) {
2395
2449
  // Use pre-serialized JSON and pre-computed gzip buffer — zero per-request compression
2396
2450
  const json = getStatusJson();
2397
2451
  res.setHeader('Content-Type', 'application/json');
2398
- res.setHeader('Access-Control-Allow-Origin', '*');
2399
2452
  res.setHeader('ETag', currentEtag);
2400
2453
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
2401
2454
  res.statusCode = 200;
@@ -4715,9 +4768,12 @@ function checkRateLimit(key, maxPerMinute) {
4715
4768
  function jsonReply(res, code, data, req) {
4716
4769
  res.setHeader('Content-Type', 'application/json');
4717
4770
  // Access-Control-Allow-Origin is set ONCE by the server dispatcher prelude:
4718
- // `*` for GET/HEAD (read-only), never for mutating responses (Origin gate
4719
- // already blocked cross-origin POSTs). Setting it here would reopen the
4720
- // cross-origin write path.
4771
+ // echoed `Origin` for GET/HEAD ONLY when the origin is the dashboard's own
4772
+ // served origin or an entry in `config.engine.allowedDashboardOrigins`;
4773
+ // never on mutating responses (Origin gate already blocked cross-origin
4774
+ // POSTs). Handlers must NOT set ACAO themselves — doing so would either
4775
+ // override the prelude's narrowed value back to `*` (P-bfa2c-cors-wildcard
4776
+ // regression) or re-open the cross-origin write path.
4721
4777
  res.statusCode = code;
4722
4778
  const json = JSON.stringify(data);
4723
4779
  const ae = req && req.headers && req.headers['accept-encoding'] || '';
@@ -4870,11 +4926,21 @@ const server = http.createServer(async (req, res) => {
4870
4926
  }
4871
4927
  }
4872
4928
 
4873
- // GET: permit cross-origin reads for external monitoring tools (curl, uptime
4874
- // checks). Mutating responses deliberately do NOT set ACAO — cross-origin
4875
- // browsers cannot use them anyway (Origin check blocks that path).
4929
+ // GET/HEAD: narrow CORS. Echo the request's `Origin` in
4930
+ // `Access-Control-Allow-Origin` ONLY if it matches the dashboard's own
4931
+ // served origin (`http://localhost:7331`) or an entry in
4932
+ // `config.engine.allowedDashboardOrigins` (default []). When no `Origin`
4933
+ // header is present (curl, uptime monitors, Node http.request without
4934
+ // Origin), do not emit ACAO at all — preserves the local-tooling path.
4935
+ // The previous wildcard (`*`) let any cross-origin browser page issue
4936
+ // `fetch` to http://localhost:7331/api/* and read the JSON response,
4937
+ // exposing operator-private state (config, work items, agent
4938
+ // transcripts). See P-bfa2c-cors-wildcard + docs/security.md.
4876
4939
  if (req.method === 'GET' || req.method === 'HEAD') {
4877
- res.setHeader('Access-Control-Allow-Origin', '*');
4940
+ if (_rawOrigin && shared.isAllowedDashboardOrigin(_rawOrigin, CONFIG)) {
4941
+ res.setHeader('Access-Control-Allow-Origin', _rawOrigin);
4942
+ res.setHeader('Vary', 'Origin');
4943
+ }
4878
4944
  }
4879
4945
 
4880
4946
  // ── Route Handler Functions ───────────────────────────────────────────────
@@ -6017,7 +6083,6 @@ const server = http.createServer(async (req, res) => {
6017
6083
  const livePath = path.join(agentDir, 'live-output.log');
6018
6084
  const prevPath = path.join(agentDir, 'live-output-prev.log');
6019
6085
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
6020
- res.setHeader('Access-Control-Allow-Origin', '*');
6021
6086
  const params = new URL(req.url, 'http://localhost').searchParams;
6022
6087
  const rawTail = parseInt(params.get('tail'));
6023
6088
  if (params.has('tail') && isNaN(rawTail)) return jsonReply(res, 400, { error: 'tail must be a number' });
@@ -6080,7 +6145,6 @@ const server = http.createServer(async (req, res) => {
6080
6145
  const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'output.log');
6081
6146
  const content = safeRead(outputPath);
6082
6147
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
6083
- res.setHeader('Access-Control-Allow-Origin', '*');
6084
6148
  res.end(content || 'No output log found for this agent.');
6085
6149
  return;
6086
6150
  }
@@ -6088,7 +6152,6 @@ const server = http.createServer(async (req, res) => {
6088
6152
  async function handleNotesFull(req, res) {
6089
6153
  const content = safeRead(path.join(MINIONS_DIR, 'notes.md'));
6090
6154
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
6091
- res.setHeader('Access-Control-Allow-Origin', '*');
6092
6155
  res.end(content || 'No notes file found.');
6093
6156
  return;
6094
6157
  }
@@ -6155,7 +6218,6 @@ const server = http.createServer(async (req, res) => {
6155
6218
  const content = safeRead(path.join(kbCatDir, file));
6156
6219
  if (content === null) return jsonReply(res, 404, { error: 'not found' });
6157
6220
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
6158
- res.setHeader('Access-Control-Allow-Origin', '*');
6159
6221
  res.end(content);
6160
6222
  return;
6161
6223
  }
@@ -6361,7 +6423,6 @@ const server = http.createServer(async (req, res) => {
6361
6423
  if (!content) return jsonReply(res, 404, { error: 'not found' });
6362
6424
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
6363
6425
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
6364
- res.setHeader('Access-Control-Allow-Origin', '*');
6365
6426
  res.end(content);
6366
6427
  return;
6367
6428
  }
@@ -6383,7 +6444,6 @@ const server = http.createServer(async (req, res) => {
6383
6444
  const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
6384
6445
  res.setHeader('Content-Type', contentType + '; charset=utf-8');
6385
6446
  res.setHeader('Cache-Control', 'no-cache');
6386
- res.setHeader('Access-Control-Allow-Origin', '*');
6387
6447
  res.end(content);
6388
6448
  return;
6389
6449
  }
@@ -7390,7 +7450,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7390
7450
  const skillPath = _resolveSkillReadPath({ file, dir, source, config: CONFIG });
7391
7451
  const content = skillPath ? (safeRead(skillPath) || '') : '';
7392
7452
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
7393
- res.setHeader('Access-Control-Allow-Origin', '*');
7394
7453
  res.end(content || 'Skill not found.');
7395
7454
  return;
7396
7455
  }
@@ -8657,20 +8716,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8657
8716
  }
8658
8717
  }
8659
8718
 
8660
- async function handleSchedulesList(req, res) {
8661
- reloadConfig();
8662
- const schedules = CONFIG.schedules || [];
8663
- const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
8664
- const result = schedules.map(s => {
8665
- const runEntry = runs[s.id];
8666
- // Backward compat: runEntry can be a string (old format) or object (new format with back-references)
8667
- const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
8668
- const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
8669
- return { ...s, _lastRun, ...extra };
8670
- });
8671
- return jsonReply(res, 200, { schedules: result });
8672
- }
8673
-
8674
8719
  async function handleSchedulesCreate(req, res) {
8675
8720
  const body = await readBody(req);
8676
8721
  let { id, cron, title, type, project, agent, description, priority, enabled } = body;
@@ -9058,35 +9103,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9058
9103
  }
9059
9104
  // String fields
9060
9105
  if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
9061
- // W-mphejzmj000718bf: /api/status workItems retention window. Handled
9062
- // outside the numericFields loop because `Number(0) || D[key]`
9063
- // collapses the "disable trim" value (0) back to the default — we
9064
- // want 0 to actually persist as "skip the date filter".
9065
- if (e.statusWorkItemsRetentionDays !== undefined) {
9066
- const raw = e.statusWorkItemsRetentionDays;
9067
- if (raw === '' || raw === null) {
9068
- _deleteEngineConfig('statusWorkItemsRetentionDays');
9069
- } else {
9070
- let val = Number(raw);
9071
- if (!Number.isFinite(val) || val < 0) val = D.statusWorkItemsRetentionDays;
9072
- if (val > 365) { _clamped.push(`statusWorkItemsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
9073
- _setEngineConfig('statusWorkItemsRetentionDays', val);
9074
- }
9075
- }
9076
- // W-mphlrxx6000a8760: /api/status meetings retention window. Same
9077
- // shape as statusWorkItemsRetentionDays — 0 must persist literally
9078
- // (disables trim), so handled outside the numericFields loop.
9079
- if (e.statusMeetingsRetentionDays !== undefined) {
9080
- const raw = e.statusMeetingsRetentionDays;
9081
- if (raw === '' || raw === null) {
9082
- _deleteEngineConfig('statusMeetingsRetentionDays');
9083
- } else {
9084
- let val = Number(raw);
9085
- if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
9086
- if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
9087
- _setEngineConfig('statusMeetingsRetentionDays', val);
9088
- }
9089
- }
9090
9106
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
9091
9107
  // the override (engine falls back to gh/git/os resolution); any other
9092
9108
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -9540,7 +9556,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9540
9556
 
9541
9557
  async function handleAgentDetail(req, res, match) {
9542
9558
  res.setHeader('Content-Type', 'application/json');
9543
- res.setHeader('Access-Control-Allow-Origin', '*');
9544
9559
  try {
9545
9560
  res.end(JSON.stringify(getAgentDetail(match[1])));
9546
9561
  } catch (e) {
@@ -10165,7 +10180,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10165
10180
  opts.status = statusRaw;
10166
10181
  }
10167
10182
  const runs = qa.listRuns(opts);
10168
- res.setHeader('Access-Control-Allow-Origin', '*');
10169
10183
  return jsonReply(res, 200, { runs, generatedAt: new Date().toISOString() }, req);
10170
10184
  } catch (e) {
10171
10185
  return jsonReply(res, 500, { error: e.message }, req);
@@ -10181,7 +10195,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10181
10195
  }
10182
10196
  const run = qa.getRun(id);
10183
10197
  if (!run) return jsonReply(res, 404, { error: 'qa run not found' }, req);
10184
- res.setHeader('Access-Control-Allow-Origin', '*');
10185
10198
  return jsonReply(res, 200, { run }, req);
10186
10199
  } catch (e) {
10187
10200
  return jsonReply(res, 500, { error: e.message }, req);
@@ -10225,7 +10238,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10225
10238
  if (!stat.isFile()) return jsonReply(res, 404, { error: 'artifact not found' }, req);
10226
10239
  res.setHeader('Content-Type', _qaArtifactContentType(file));
10227
10240
  res.setHeader('Content-Length', String(stat.size));
10228
- res.setHeader('Access-Control-Allow-Origin', '*');
10229
10241
  res.statusCode = 200;
10230
10242
  fs.createReadStream(real).pipe(res);
10231
10243
  return;
@@ -10633,6 +10645,192 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10633
10645
 
10634
10646
  // Status & health
10635
10647
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
10648
+ // Raw state-file passthrough (no /api/ prefix — this serves files, not API
10649
+ // responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
10650
+ { method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
10651
+ // ── Dedicated fresh-JSON endpoints (issue #2949) ───────────────────────
10652
+ // Each runs the existing server-side enrichment fresh on every request,
10653
+ // ETag computed from input file mtimes — no outer cache, no staleness.
10654
+ // Pages call these instead of relying on /api/status's assembled snapshot.
10655
+ { method: 'GET', path: '/api/agents', desc: 'Live agent roster with status/lastAction/runtime/inbox/charter — derived from config + dispatch + agents dir', handler: (req, res) => {
10656
+ return serveFreshJson(req, res, {
10657
+ tag: 'agents',
10658
+ inputs: [
10659
+ CONFIG_PATH,
10660
+ path.join(ENGINE_DIR, 'dispatch.json'),
10661
+ INBOX_DIR,
10662
+ AGENTS_DIR,
10663
+ ],
10664
+ builder: () => getAgents(),
10665
+ });
10666
+ }},
10667
+ { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items (per-project files joined + dispatch/PR cross-reference) — fresh on every request', handler: (req, res) => {
10668
+ const config = queries.getConfig();
10669
+ const projects = config.projects || [];
10670
+ const inputs = [
10671
+ CONFIG_PATH,
10672
+ path.join(ENGINE_DIR, 'dispatch.json'),
10673
+ ];
10674
+ for (const p of projects) {
10675
+ if (p && p.name) {
10676
+ inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
10677
+ inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
10678
+ }
10679
+ }
10680
+ return serveFreshJson(req, res, {
10681
+ tag: 'work-items',
10682
+ inputs,
10683
+ builder: () => getWorkItems(),
10684
+ });
10685
+ }},
10686
+ { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests (per-project files joined + url backfill + _project stamp)', handler: (req, res) => {
10687
+ const config = queries.getConfig();
10688
+ const projects = config.projects || [];
10689
+ const inputs = [CONFIG_PATH];
10690
+ for (const p of projects) {
10691
+ if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
10692
+ }
10693
+ return serveFreshJson(req, res, {
10694
+ tag: 'pull-requests',
10695
+ inputs,
10696
+ builder: () => queries.getPullRequests(),
10697
+ });
10698
+ }},
10699
+ { method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
10700
+ return serveFreshJson(req, res, {
10701
+ tag: 'dispatch',
10702
+ inputs: [
10703
+ path.join(ENGINE_DIR, 'dispatch.json'),
10704
+ path.join(ENGINE_DIR, 'metrics.json'),
10705
+ ],
10706
+ builder: () => getDispatchQueue(),
10707
+ });
10708
+ }},
10709
+ { method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
10710
+ const config = queries.getConfig();
10711
+ const projects = config.projects || [];
10712
+ const inputs = [
10713
+ path.join(ENGINE_DIR, 'metrics.json'),
10714
+ path.join(ENGINE_DIR, 'dispatch.json'),
10715
+ ];
10716
+ for (const p of projects) {
10717
+ if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
10718
+ }
10719
+ return serveFreshJson(req, res, {
10720
+ tag: 'metrics',
10721
+ inputs,
10722
+ builder: () => getMetrics(),
10723
+ });
10724
+ }},
10725
+ { method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from prd/*.json × per-project work-items × pull-requests', handler: (req, res) => {
10726
+ const config = queries.getConfig();
10727
+ const projects = config.projects || [];
10728
+ const inputs = [
10729
+ CONFIG_PATH,
10730
+ PRD_DIR,
10731
+ path.join(PRD_DIR, 'archive'),
10732
+ // Plans dir — getPrdInfo statSyncs plans/<plan.source_plan> to
10733
+ // compute the `planStale` flag; edits to plans/*.md need to bust
10734
+ // the ETag so the Regenerate prompt surfaces. (Review finding #7.)
10735
+ path.join(MINIONS_DIR, 'plans'),
10736
+ // Central work-items.json at MINIONS_DIR root — getPrdInfo
10737
+ // cross-references plan items lacking a project scope. (Review #7.)
10738
+ path.join(MINIONS_DIR, 'work-items.json'),
10739
+ ];
10740
+ for (const p of projects) {
10741
+ if (p && p.name) {
10742
+ inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
10743
+ inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
10744
+ }
10745
+ }
10746
+ return serveFreshJson(req, res, {
10747
+ tag: 'prd',
10748
+ inputs,
10749
+ builder: () => getPrdInfo(),
10750
+ });
10751
+ }},
10752
+ { method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
10753
+ return serveFreshJson(req, res, {
10754
+ tag: 'archived-prds',
10755
+ inputs: [path.join(PRD_DIR, 'archive')],
10756
+ builder: () => getArchivedPrds(),
10757
+ });
10758
+ }},
10759
+ { method: 'GET', path: '/api/verify-guides', desc: 'List of generated verify-guide files under prd/guides/', handler: (req, res) => {
10760
+ return serveFreshJson(req, res, {
10761
+ tag: 'verify-guides',
10762
+ inputs: [path.join(PRD_DIR, 'guides')],
10763
+ builder: () => getVerifyGuides(),
10764
+ });
10765
+ }},
10766
+ { method: 'GET', path: '/api/pinned', desc: 'Parsed pinned-notes entries from pinned.md (prepended to every agent prompt)', handler: (req, res) => {
10767
+ return serveFreshJson(req, res, {
10768
+ tag: 'pinned',
10769
+ inputs: [PINNED_PATH],
10770
+ builder: () => {
10771
+ try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; }
10772
+ },
10773
+ });
10774
+ }},
10775
+ { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with schedule-runs.json overlay (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
10776
+ return serveFreshJson(req, res, {
10777
+ tag: 'schedules',
10778
+ inputs: [CONFIG_PATH, path.join(ENGINE_DIR, 'schedule-runs.json')],
10779
+ builder: () => {
10780
+ // Read config.json directly via queries.getConfig() rather than
10781
+ // calling reloadConfig() — the latter cascades into
10782
+ // ensureConfiguredProjectStateFiles() which acquires
10783
+ // mutateJsonFileLocked on every project's work-items.json AND
10784
+ // pull-requests.json, putting the schedules sidebar poll on the
10785
+ // critical path of every engine PR/WI writer. The builder only
10786
+ // needs config.schedules; getConfig is a pure disk read.
10787
+ // (Round-2 review finding #3.)
10788
+ const cfg = queries.getConfig() || {};
10789
+ const scheds = cfg.schedules || [];
10790
+ const runs = shared.safeJson(path.join(ENGINE_DIR, 'schedule-runs.json')) || {};
10791
+ return scheds.map(s => {
10792
+ const runEntry = runs[s.id];
10793
+ const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
10794
+ const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
10795
+ return { ...s, _lastRun, ...extra };
10796
+ });
10797
+ },
10798
+ });
10799
+ }},
10800
+ { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 runs each from pipeline-runs.json', handler: (req, res) => {
10801
+ return serveFreshJson(req, res, {
10802
+ tag: 'pipelines',
10803
+ inputs: [
10804
+ path.join(MINIONS_DIR, 'pipelines'),
10805
+ path.join(ENGINE_DIR, 'pipeline-runs.json'),
10806
+ ],
10807
+ builder: () => {
10808
+ try {
10809
+ const pl = require('./engine/pipeline');
10810
+ return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) }));
10811
+ } catch {
10812
+ return [];
10813
+ }
10814
+ },
10815
+ });
10816
+ }},
10817
+ { method: 'GET', path: '/api/qa-runs-summary', desc: 'Sidebar-counter summary {total, sig} for QA runs (sidebar activity-dot signal)', handler: (req, res) => {
10818
+ return serveFreshJson(req, res, {
10819
+ tag: 'qa-runs-summary',
10820
+ inputs: [path.join(ENGINE_DIR, 'qa-runs.json')],
10821
+ builder: () => qaRunsMod.summarizeRunsForStatus(),
10822
+ });
10823
+ }},
10824
+ { method: 'GET', path: '/api/meetings-total', desc: 'Total meeting count (sidebar activity-dot signal — cheaper than fetching /api/meetings)', handler: (req, res) => {
10825
+ return serveFreshJson(req, res, {
10826
+ tag: 'meetings-total',
10827
+ inputs: [path.join(MINIONS_DIR, 'meetings')],
10828
+ builder: () => {
10829
+ const meetings = meetingMod.getMeetings();
10830
+ return { total: Array.isArray(meetings) ? meetings.length : 0 };
10831
+ },
10832
+ });
10833
+ }},
10636
10834
  { method: 'GET', path: '/api/browser-presence', desc: 'Whether a dashboard browser tab was recently active', handler: (req, res) => {
10637
10835
  return jsonReply(res, 200, _getDashboardBrowserPresence(), req);
10638
10836
  }},
@@ -11107,7 +11305,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11107
11305
 
11108
11306
  // Schedules
11109
11307
  { method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
11110
- { method: 'GET', path: '/api/schedules', desc: 'Return schedules from config + last-run times', handler: handleSchedulesList },
11111
11308
  { method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: 'cron, title, id?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesCreate },
11112
11309
  { method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
11113
11310
  { method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
@@ -11579,6 +11776,15 @@ module.exports = {
11579
11776
  refreshStatusAsync,
11580
11777
  handleStatus: _handleStatusRequest,
11581
11778
  invalidateStatusCache,
11779
+ // Raw state-file passthrough — exported for direct unit testing.
11780
+ handleStateRead,
11781
+ STATE_READ_ALLOWED_DIRS,
11782
+ STATE_READ_ALLOWED_FILES,
11783
+ STATE_READ_DENIED_PATTERNS,
11784
+ STATE_READ_DENIED_BASENAMES,
11785
+ // Dedicated fresh-JSON endpoint helper (issue #2949).
11786
+ serveFreshJson,
11787
+ _maxInputMtimeMs,
11582
11788
  _getStatusCacheVersion,
11583
11789
  _setStatusRefreshHook,
11584
11790
  _resetStatusCacheForTesting,