gsd-unsupervised 1.0.0

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 (83) hide show
  1. package/README.md +263 -0
  2. package/bin/gsd-unsupervised +3 -0
  3. package/bin/start-daemon.sh +12 -0
  4. package/bin/unsupervised-gsd +2 -0
  5. package/dist/agent-runner.d.ts +26 -0
  6. package/dist/agent-runner.js +111 -0
  7. package/dist/agent-runner.spawn.test.d.ts +1 -0
  8. package/dist/agent-runner.spawn.test.js +128 -0
  9. package/dist/agent-runner.test.d.ts +1 -0
  10. package/dist/agent-runner.test.js +26 -0
  11. package/dist/bootstrap/wsl-bootstrap.d.ts +11 -0
  12. package/dist/bootstrap/wsl-bootstrap.js +14 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +172 -0
  15. package/dist/config/paths.d.ts +8 -0
  16. package/dist/config/paths.js +36 -0
  17. package/dist/config/wsl.d.ts +4 -0
  18. package/dist/config/wsl.js +43 -0
  19. package/dist/config.d.ts +79 -0
  20. package/dist/config.js +95 -0
  21. package/dist/config.test.d.ts +1 -0
  22. package/dist/config.test.js +27 -0
  23. package/dist/cursor-agent.d.ts +17 -0
  24. package/dist/cursor-agent.invoker.test.d.ts +1 -0
  25. package/dist/cursor-agent.invoker.test.js +150 -0
  26. package/dist/cursor-agent.js +156 -0
  27. package/dist/cursor-agent.test.d.ts +1 -0
  28. package/dist/cursor-agent.test.js +60 -0
  29. package/dist/daemon.d.ts +17 -0
  30. package/dist/daemon.js +374 -0
  31. package/dist/git.d.ts +23 -0
  32. package/dist/git.js +76 -0
  33. package/dist/goals.d.ts +34 -0
  34. package/dist/goals.js +148 -0
  35. package/dist/gsd-state.d.ts +49 -0
  36. package/dist/gsd-state.js +76 -0
  37. package/dist/init-wizard.d.ts +5 -0
  38. package/dist/init-wizard.js +96 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.js +103 -0
  41. package/dist/lifecycle.test.d.ts +1 -0
  42. package/dist/lifecycle.test.js +116 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.js +31 -0
  45. package/dist/notifier.d.ts +6 -0
  46. package/dist/notifier.js +37 -0
  47. package/dist/orchestrator.d.ts +35 -0
  48. package/dist/orchestrator.js +791 -0
  49. package/dist/resource-governor.d.ts +54 -0
  50. package/dist/resource-governor.js +57 -0
  51. package/dist/resource-governor.test.d.ts +1 -0
  52. package/dist/resource-governor.test.js +33 -0
  53. package/dist/resume-pointer.d.ts +36 -0
  54. package/dist/resume-pointer.js +116 -0
  55. package/dist/roadmap-parser.d.ts +24 -0
  56. package/dist/roadmap-parser.js +105 -0
  57. package/dist/roadmap-parser.test.d.ts +1 -0
  58. package/dist/roadmap-parser.test.js +57 -0
  59. package/dist/session-log.d.ts +53 -0
  60. package/dist/session-log.js +92 -0
  61. package/dist/session-log.test.d.ts +1 -0
  62. package/dist/session-log.test.js +146 -0
  63. package/dist/state-index.d.ts +5 -0
  64. package/dist/state-index.js +31 -0
  65. package/dist/state-parser.d.ts +13 -0
  66. package/dist/state-parser.js +82 -0
  67. package/dist/state-parser.test.d.ts +1 -0
  68. package/dist/state-parser.test.js +228 -0
  69. package/dist/state-types.d.ts +20 -0
  70. package/dist/state-types.js +1 -0
  71. package/dist/state-watcher.d.ts +49 -0
  72. package/dist/state-watcher.js +148 -0
  73. package/dist/status-server.d.ts +112 -0
  74. package/dist/status-server.js +379 -0
  75. package/dist/status-server.test.d.ts +1 -0
  76. package/dist/status-server.test.js +206 -0
  77. package/dist/stream-events.d.ts +423 -0
  78. package/dist/stream-events.js +87 -0
  79. package/dist/stream-events.test.d.ts +1 -0
  80. package/dist/stream-events.test.js +304 -0
  81. package/dist/todos-api.d.ts +5 -0
  82. package/dist/todos-api.js +35 -0
  83. package/package.json +54 -0
@@ -0,0 +1,148 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import chokidar from 'chokidar';
3
+ import { readStateFile } from './state-index.js';
4
+ /**
5
+ * Watches STATE.md for changes, parses content, and emits typed progress events.
6
+ * Used by the daemon and dashboard for real-time progress visibility.
7
+ */
8
+ export class StateWatcher extends EventEmitter {
9
+ stateMdPath;
10
+ debounceMs;
11
+ logger;
12
+ watcher = null;
13
+ debounceTimer = null;
14
+ lastSnapshot = null;
15
+ goalCompleteEmitted = false;
16
+ constructor(options) {
17
+ super();
18
+ const { stateMdPath, debounceMs = 500, logger } = options;
19
+ this.stateMdPath = stateMdPath;
20
+ this.debounceMs = debounceMs;
21
+ this.logger = logger;
22
+ }
23
+ start() {
24
+ if (this.watcher) {
25
+ return;
26
+ }
27
+ this.watcher = chokidar.watch(this.stateMdPath, {
28
+ persistent: true,
29
+ ignoreInitial: false,
30
+ });
31
+ const onFileEvent = () => {
32
+ if (this.debounceTimer) {
33
+ clearTimeout(this.debounceTimer);
34
+ }
35
+ this.debounceTimer = setTimeout(() => {
36
+ this.debounceTimer = null;
37
+ this.handleChange();
38
+ }, this.debounceMs);
39
+ };
40
+ this.watcher.on('add', onFileEvent);
41
+ this.watcher.on('change', onFileEvent);
42
+ this.watcher.on('error', (err) => {
43
+ this.logger.warn({ err }, 'StateWatcher file error');
44
+ });
45
+ }
46
+ async handleChange() {
47
+ let snapshot = null;
48
+ try {
49
+ snapshot = await readStateFile(this.stateMdPath, this.logger);
50
+ }
51
+ catch (err) {
52
+ this.logger.warn({ err }, 'StateWatcher read failed, keeping last snapshot');
53
+ return;
54
+ }
55
+ if (snapshot === null) {
56
+ this.logger.debug('STATE.md missing or unparseable');
57
+ return;
58
+ }
59
+ const previous = this.lastSnapshot;
60
+ if (previous !== null) {
61
+ const isNoop = previous.phaseNumber === snapshot.phaseNumber &&
62
+ previous.totalPhases === snapshot.totalPhases &&
63
+ previous.phaseName === snapshot.phaseName &&
64
+ previous.planNumber === snapshot.planNumber &&
65
+ previous.totalPlans === snapshot.totalPlans &&
66
+ previous.status === snapshot.status &&
67
+ (previous.progressPercent ?? null) === (snapshot.progressPercent ?? null) &&
68
+ (previous.gitSha ?? null) === (snapshot.gitSha ?? null);
69
+ if (isNoop) {
70
+ this.logger.debug({
71
+ phase: snapshot.phaseNumber,
72
+ plan: snapshot.planNumber,
73
+ status: snapshot.status,
74
+ }, 'state_noop');
75
+ return;
76
+ }
77
+ }
78
+ if (previous === null) {
79
+ this.emit('ready', snapshot);
80
+ this.logger.info({ path: this.stateMdPath }, 'STATE.md first detected');
81
+ }
82
+ this.emit('state_changed', { previous, current: snapshot });
83
+ this.logger.info({
84
+ phase: snapshot.phaseNumber,
85
+ plan: snapshot.planNumber,
86
+ status: snapshot.status,
87
+ progressPercent: snapshot.progressPercent,
88
+ }, 'state_changed');
89
+ if (previous !== null) {
90
+ if (snapshot.phaseNumber > previous.phaseNumber) {
91
+ const phaseName = snapshot.phaseName;
92
+ this.emit('phase_advanced', {
93
+ fromPhase: previous.phaseNumber,
94
+ toPhase: snapshot.phaseNumber,
95
+ phaseName,
96
+ });
97
+ this.logger.info({ fromPhase: previous.phaseNumber, toPhase: snapshot.phaseNumber, phaseName }, 'phase_advanced');
98
+ }
99
+ if (snapshot.phaseNumber === previous.phaseNumber &&
100
+ snapshot.planNumber > previous.planNumber) {
101
+ this.emit('plan_advanced', {
102
+ phaseNumber: snapshot.phaseNumber,
103
+ fromPlan: previous.planNumber,
104
+ toPlan: snapshot.planNumber,
105
+ });
106
+ this.logger.info({
107
+ phaseNumber: snapshot.phaseNumber,
108
+ fromPlan: previous.planNumber,
109
+ toPlan: snapshot.planNumber,
110
+ }, 'plan_advanced');
111
+ }
112
+ const statusComplete = /complete/i.test(snapshot.status);
113
+ const prevComplete = /complete/i.test(previous.status);
114
+ if (statusComplete &&
115
+ (previous.phaseNumber !== snapshot.phaseNumber || !prevComplete)) {
116
+ this.emit('phase_completed', {
117
+ phaseNumber: snapshot.phaseNumber,
118
+ phaseName: snapshot.phaseName,
119
+ });
120
+ this.logger.info({ phaseNumber: snapshot.phaseNumber, phaseName: snapshot.phaseName }, 'phase_completed');
121
+ }
122
+ }
123
+ if (snapshot.phaseNumber === snapshot.totalPhases &&
124
+ /complete/i.test(snapshot.status)) {
125
+ if (!this.goalCompleteEmitted) {
126
+ this.goalCompleteEmitted = true;
127
+ const progressPercent = snapshot.progressPercent ?? 100;
128
+ this.emit('goal_completed', { progressPercent });
129
+ this.logger.info({ progressPercent }, 'goal_completed');
130
+ }
131
+ }
132
+ this.lastSnapshot = snapshot;
133
+ }
134
+ stop() {
135
+ if (this.debounceTimer) {
136
+ clearTimeout(this.debounceTimer);
137
+ this.debounceTimer = null;
138
+ }
139
+ if (this.watcher) {
140
+ this.watcher.close();
141
+ this.watcher = null;
142
+ }
143
+ this.removeAllListeners();
144
+ }
145
+ getLastSnapshot() {
146
+ return this.lastSnapshot;
147
+ }
148
+ }
@@ -0,0 +1,112 @@
1
+ /** Schema for .planning/config.json parallelization slice (exposed via /api/config). */
2
+ export interface PlanningConfig {
3
+ mode?: string;
4
+ depth?: string;
5
+ parallelization?: {
6
+ enabled?: boolean;
7
+ plan_level?: boolean;
8
+ task_level?: boolean;
9
+ skip_checkpoints?: boolean;
10
+ max_concurrent_agents?: number;
11
+ min_plans_for_parallel?: number;
12
+ };
13
+ gates?: Record<string, boolean>;
14
+ safety?: Record<string, boolean>;
15
+ }
16
+ export interface StatusPayload {
17
+ running: boolean;
18
+ currentGoal?: string;
19
+ phaseNumber?: number;
20
+ planNumber?: number;
21
+ heartbeat?: string;
22
+ }
23
+ /** Dashboard-oriented rich payload for GET /api/status. */
24
+ export interface DashboardStatusPayload {
25
+ /** Legacy-compatible minimal fields. */
26
+ running: boolean;
27
+ currentGoal?: string;
28
+ phaseNumber?: number;
29
+ planNumber?: number;
30
+ heartbeat?: string;
31
+ /** Current agent session ID (from last session log entry). */
32
+ currentAgentId?: string | null;
33
+ /** Current phase/plan from STATE.md. */
34
+ stateSnapshot?: {
35
+ phaseNumber: number;
36
+ totalPhases: number;
37
+ phaseName: string;
38
+ planNumber: number;
39
+ totalPlans: number;
40
+ status: string;
41
+ lastActivity: string;
42
+ progressPercent: number | null;
43
+ } | null;
44
+ /** Last N session log entries (rolling window). */
45
+ sessionLogEntries?: Array<{
46
+ timestamp: string;
47
+ goalTitle: string;
48
+ phase: string;
49
+ phaseNumber?: number;
50
+ planNumber?: number;
51
+ sessionId: string | null;
52
+ status: string;
53
+ }>;
54
+ /** Last N git commits (hash, message, timestamp). */
55
+ gitFeed?: Array<{
56
+ hash: string;
57
+ message: string;
58
+ timestamp: string;
59
+ }>;
60
+ /** Placeholder for token/cost tracking (populated later). */
61
+ tokens?: {
62
+ prompt?: number;
63
+ completion?: number;
64
+ total?: number;
65
+ };
66
+ /** Placeholder for cost tracking (populated later). */
67
+ cost?: {
68
+ amount?: number;
69
+ currency?: string;
70
+ };
71
+ /** Current system load information (best-effort). */
72
+ systemLoad?: import('./resource-governor.js').LoadInfo & {
73
+ maxCpuFraction?: number;
74
+ maxMemoryFraction?: number;
75
+ };
76
+ }
77
+ /** Optional webhook: add goals/todos via API or Twilio inbound. */
78
+ export interface WebhookOptions {
79
+ goalsPath: string;
80
+ workspaceRoot: string;
81
+ /** Callback when a goal is added (persisted to goals.md by route). */
82
+ onQueueGoal: (goal: import('./goals.js').Goal) => void;
83
+ /** Titles of goals currently being executed (for dedup on reload). */
84
+ getRunningTitles: () => string[];
85
+ /** Create a todo file; returns path. */
86
+ addTodo: (title: string, area?: string) => Promise<string>;
87
+ }
88
+ export interface StatusServerOptions {
89
+ stateMdPath: string;
90
+ sessionLogPath: string;
91
+ workspaceRoot: string;
92
+ /** Path to .planning/config.json for GET/POST /api/config (optional). */
93
+ planningConfigPath?: string;
94
+ /** Max session log entries in dashboard payload (default 20). */
95
+ sessionLogLimit?: number;
96
+ /** Max git commits in feed (default 10). */
97
+ gitFeedLimit?: number;
98
+ /** When set, enables POST /api/goals, POST /api/todos, POST /webhook/twilio. */
99
+ webhook?: WebhookOptions;
100
+ }
101
+ export declare function readPlanningConfig(path: string): Promise<PlanningConfig>;
102
+ /**
103
+ * Creates an Express-based HTTP server that serves:
104
+ * - GET /: dashboard HTML (when options are provided) or legacy JSON.
105
+ * - GET /status: legacy JSON status (same shape as before).
106
+ * - GET /api/status: rich dashboard JSON (agent, goal, phase/plan, state snapshot, session log window, git feed, token/cost placeholders).
107
+ * When options are provided, / serves the dashboard and /api/status is enabled; otherwise / and /status return legacy JSON.
108
+ */
109
+ export declare function createStatusServer(port: number, getStatus: () => StatusPayload, options?: StatusServerOptions): {
110
+ server: import('node:http').Server;
111
+ close: () => Promise<void>;
112
+ };
@@ -0,0 +1,379 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import express from 'express';
4
+ import { appendPendingGoal } from './goals.js';
5
+ function escapeTwiML(s) {
6
+ return s
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;');
11
+ }
12
+ import { readStateMd } from './state-parser.js';
13
+ import { readSessionLog } from './session-log.js';
14
+ import { getRecentCommits } from './git.js';
15
+ import { currentLoadInfo } from './resource-governor.js';
16
+ const DEFAULT_PLANNING_CONFIG = {
17
+ mode: 'interactive',
18
+ depth: 'standard',
19
+ parallelization: {
20
+ enabled: false,
21
+ plan_level: false,
22
+ task_level: false,
23
+ skip_checkpoints: false,
24
+ max_concurrent_agents: 3,
25
+ min_plans_for_parallel: 2,
26
+ },
27
+ };
28
+ export async function readPlanningConfig(path) {
29
+ if (!existsSync(path))
30
+ return { ...DEFAULT_PLANNING_CONFIG };
31
+ try {
32
+ const raw = await readFile(path, 'utf-8');
33
+ const data = JSON.parse(raw);
34
+ return {
35
+ ...DEFAULT_PLANNING_CONFIG,
36
+ ...data,
37
+ parallelization: { ...DEFAULT_PLANNING_CONFIG.parallelization, ...data.parallelization },
38
+ };
39
+ }
40
+ catch {
41
+ return { ...DEFAULT_PLANNING_CONFIG };
42
+ }
43
+ }
44
+ async function writePlanningConfig(path, config) {
45
+ await writeFile(path, JSON.stringify(config, null, 2), 'utf-8');
46
+ }
47
+ /** Returns inline HTML for the dashboard (mobile-first, no build). */
48
+ function getDashboardHtml() {
49
+ return `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="utf-8" />
53
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
54
+ <title>GSD Autopilot</title>
55
+ <style>
56
+ :root { --bg: #0f0f12; --card: #1a1a1f; --text: #e4e4e7; --muted: #71717a; --accent: #22c55e; --border: #27272a; }
57
+ @media (prefers-color-scheme: light) {
58
+ :root { --bg: #fafafa; --card: #fff; --text: #18181b; --muted: #71717a; --accent: #16a34a; --border: #e4e4e7; }
59
+ }
60
+ * { box-sizing: border-box; }
61
+ body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
62
+ .container { max-width: 720px; margin: 0 auto; padding: 1rem; }
63
+ header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin-bottom: 1.25rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
64
+ h1 { margin: 0; font-size: 1.25rem; font-weight: 600; }
65
+ .badge { font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 9999px; background: var(--card); color: var(--muted); }
66
+ .badge.running { background: rgba(34,197,94,0.2); color: var(--accent); }
67
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
68
+ .card h2 { margin: 0 0 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--muted); }
69
+ .progress-wrap { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin-top: 0.5rem; }
70
+ .progress-bar { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s ease; }
71
+ .git-feed { font-size: 0.8125rem; }
72
+ .git-feed li { list-style: none; padding: 0.35rem 0; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 0.15rem; }
73
+ .git-feed li:last-child { border-bottom: 0; }
74
+ .git-hash { font-family: ui-monospace, monospace; color: var(--muted); font-size: 0.75rem; }
75
+ .metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem; }
76
+ .metrics span { font-size: 0.8125rem; color: var(--muted); }
77
+ .toggle-wrap { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
78
+ .toggle { position: relative; width: 44px; height: 24px; background: var(--border); border-radius: 9999px; cursor: pointer; border: none; }
79
+ .toggle.on { background: var(--accent); }
80
+ .toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
81
+ .toggle.on::after { transform: translateX(20px); }
82
+ .toggle-label { font-size: 0.875rem; }
83
+ .error-msg { font-size: 0.8125rem; color: #ef4444; margin-top: 0.25rem; }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div class="container">
88
+ <header>
89
+ <h1>GSD Autopilot</h1>
90
+ <span class="badge" id="status-badge">—</span>
91
+ <span class="badge" id="agent-badge">Agent: —</span>
92
+ </header>
93
+ <section class="card" id="goal-card">
94
+ <h2>Current goal</h2>
95
+ <p id="goal-title" style="margin:0;">—</p>
96
+ <div class="progress-wrap"><div class="progress-bar" id="progress-bar" style="width:0%"></div></div>
97
+ <p id="phase-plan" style="margin:0.5rem 0 0;font-size:0.8125rem;color:var(--muted)">—</p>
98
+ </section>
99
+ <section class="card">
100
+ <h2>Recent commits</h2>
101
+ <ul class="git-feed" id="git-feed"></ul>
102
+ </section>
103
+ <section class="card">
104
+ <h2>Tokens / cost</h2>
105
+ <div class="metrics" id="metrics"><span>—</span></div>
106
+ </section>
107
+ <section class="card" id="config-section">
108
+ <h2>Execution mode</h2>
109
+ <div class="toggle-wrap">
110
+ <button type="button" class="toggle" id="mode-toggle" aria-label="Parallel mode"></button>
111
+ <span class="toggle-label" id="mode-label">Sequential</span>
112
+ </div>
113
+ <p class="error-msg" id="config-error" style="display:none"></p>
114
+ </section>
115
+ </div>
116
+ <script>
117
+ const API = '/api/status';
118
+ const CONFIG_API = '/api/config';
119
+ const REFRESH_MS = 10000;
120
+
121
+ function render(data) {
122
+ document.getElementById('status-badge').textContent = data.running ? 'Running' : 'Stopped';
123
+ document.getElementById('status-badge').className = 'badge' + (data.running ? ' running' : '');
124
+ document.getElementById('agent-badge').textContent = 'Agent: ' + (data.currentAgentId || '—');
125
+ document.getElementById('goal-title').textContent = data.currentGoal || '—';
126
+ const snap = data.stateSnapshot || null;
127
+ const pct = snap && snap.progressPercent != null ? snap.progressPercent : 0;
128
+ document.getElementById('progress-bar').style.width = pct + '%';
129
+ document.getElementById('phase-plan').textContent = snap
130
+ ? 'Phase ' + snap.phaseNumber + '/' + snap.totalPhases + ' · Plan ' + snap.planNumber + '/' + snap.totalPlans + ' — ' + snap.status
131
+ : '—';
132
+ const feed = document.getElementById('git-feed');
133
+ feed.innerHTML = (data.gitFeed || []).map(function(c) {
134
+ return '<li><span class="git-hash">' + c.hash + '</span> ' + escapeHtml(c.message) + ' <span style="color:var(--muted)">' + c.timestamp + '</span></li>';
135
+ }).join('') || '<li>No commits</li>';
136
+ const t = data.tokens || {};
137
+ const cost = data.cost || {};
138
+ document.getElementById('metrics').innerHTML = ('<span>Tokens: ' + (t.total ?? '—') + '</span><span>Cost: ' + (cost.amount != null ? cost.amount + ' ' + (cost.currency || '') : '—') + '</span>');
139
+ }
140
+
141
+ function escapeHtml(s) {
142
+ const div = document.createElement('div');
143
+ div.textContent = s;
144
+ return div.innerHTML;
145
+ }
146
+
147
+ function renderConfig(parallel) {
148
+ var t = document.getElementById('mode-toggle');
149
+ var l = document.getElementById('mode-label');
150
+ t.classList.toggle('on', !!parallel);
151
+ l.textContent = parallel ? 'Parallel' : 'Sequential';
152
+ }
153
+
154
+ function fetchStatus() {
155
+ fetch(API).then(function(r) { return r.json(); }).then(render).catch(function() {});
156
+ }
157
+
158
+ function fetchConfig() {
159
+ fetch(CONFIG_API).then(function(r) { return r.json(); }).then(function(c) {
160
+ renderConfig(c.parallelization && c.parallelization.enabled);
161
+ }).catch(function() {});
162
+ }
163
+
164
+ document.getElementById('mode-toggle').addEventListener('click', function() {
165
+ var errEl = document.getElementById('config-error');
166
+ errEl.style.display = 'none';
167
+ fetch(CONFIG_API).then(function(r) { return r.json(); }).then(function(c) {
168
+ var next = !(c.parallelization && c.parallelization.enabled);
169
+ return fetch(CONFIG_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parallelization: Object.assign({}, c.parallelization || {}, { enabled: next }) }) });
170
+ }).then(function(r) {
171
+ if (!r.ok) throw new Error(r.statusText);
172
+ return r.json();
173
+ }).then(function(c) {
174
+ renderConfig(c.parallelization && c.parallelization.enabled);
175
+ }).catch(function(e) {
176
+ errEl.textContent = 'Update failed: ' + e.message;
177
+ errEl.style.display = 'block';
178
+ });
179
+ });
180
+
181
+ fetchStatus();
182
+ fetchConfig();
183
+ setInterval(fetchStatus, REFRESH_MS);
184
+ </script>
185
+ </body>
186
+ </html>`;
187
+ }
188
+ /**
189
+ * Creates an Express-based HTTP server that serves:
190
+ * - GET /: dashboard HTML (when options are provided) or legacy JSON.
191
+ * - GET /status: legacy JSON status (same shape as before).
192
+ * - GET /api/status: rich dashboard JSON (agent, goal, phase/plan, state snapshot, session log window, git feed, token/cost placeholders).
193
+ * When options are provided, / serves the dashboard and /api/status is enabled; otherwise / and /status return legacy JSON.
194
+ */
195
+ export function createStatusServer(port, getStatus, options) {
196
+ const app = express();
197
+ const sessionLogLimit = options?.sessionLogLimit ?? 20;
198
+ const gitFeedLimit = options?.gitFeedLimit ?? 10;
199
+ const planningConfigPath = options?.planningConfigPath;
200
+ app.use(express.json());
201
+ app.use(express.urlencoded({ extended: true }));
202
+ if (planningConfigPath) {
203
+ app.get('/api/config', async (_req, res) => {
204
+ try {
205
+ const config = await readPlanningConfig(planningConfigPath);
206
+ res.json(config);
207
+ }
208
+ catch (err) {
209
+ res.status(500).json({ error: String(err) });
210
+ }
211
+ });
212
+ app.post('/api/config', async (req, res) => {
213
+ try {
214
+ const body = req.body;
215
+ if (!body || typeof body !== 'object') {
216
+ res.status(400).json({ error: 'Invalid JSON body' });
217
+ return;
218
+ }
219
+ const current = await readPlanningConfig(planningConfigPath);
220
+ const nextParallelization = body.parallelization;
221
+ if (nextParallelization !== undefined) {
222
+ if (typeof nextParallelization !== 'object' || nextParallelization === null) {
223
+ res.status(400).json({ error: 'parallelization must be an object' });
224
+ return;
225
+ }
226
+ const merged = {
227
+ ...current.parallelization,
228
+ ...nextParallelization,
229
+ };
230
+ if (merged.enabled !== undefined && typeof merged.enabled !== 'boolean') {
231
+ res.status(400).json({ error: 'parallelization.enabled must be a boolean' });
232
+ return;
233
+ }
234
+ current.parallelization = merged;
235
+ }
236
+ await writePlanningConfig(planningConfigPath, current);
237
+ res.json(current);
238
+ }
239
+ catch (err) {
240
+ res.status(500).json({ error: String(err) });
241
+ }
242
+ });
243
+ }
244
+ if (options?.webhook) {
245
+ const wh = options.webhook;
246
+ app.post('/api/goals', async (req, res) => {
247
+ try {
248
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
249
+ if (!title) {
250
+ res.status(400).json({ error: 'Missing or invalid title' });
251
+ return;
252
+ }
253
+ const priority = typeof req.body.priority === 'number' && Number.isFinite(req.body.priority)
254
+ ? req.body.priority
255
+ : undefined;
256
+ await appendPendingGoal(wh.goalsPath, title, priority);
257
+ const goal = {
258
+ title,
259
+ status: 'pending',
260
+ raw: `- [ ] ${title}${priority != null ? ` [priority:${priority}]` : ''}`,
261
+ priority,
262
+ };
263
+ wh.onQueueGoal(goal);
264
+ res.json({ ok: true, title });
265
+ }
266
+ catch (err) {
267
+ res.status(500).json({ error: String(err) });
268
+ }
269
+ });
270
+ app.post('/api/todos', async (req, res) => {
271
+ try {
272
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
273
+ if (!title) {
274
+ res.status(400).json({ error: 'Missing or invalid title' });
275
+ return;
276
+ }
277
+ const area = typeof req.body.area === 'string' ? req.body.area.trim() || 'general' : 'general';
278
+ const filePath = await wh.addTodo(title, area);
279
+ res.json({ ok: true, title, path: filePath });
280
+ }
281
+ catch (err) {
282
+ res.status(500).json({ error: String(err) });
283
+ }
284
+ });
285
+ app.post('/webhook/twilio', async (req, res) => {
286
+ const body = typeof req.body?.Body === 'string' ? req.body.Body.trim() : '';
287
+ const lower = body.toLowerCase();
288
+ let reply = '';
289
+ try {
290
+ if (lower.startsWith('add ') || lower.startsWith('goal ')) {
291
+ const title = body.slice(body.toLowerCase().indexOf(' ') + 1).trim();
292
+ if (title) {
293
+ await appendPendingGoal(wh.goalsPath, title);
294
+ const goal = {
295
+ title,
296
+ status: 'pending',
297
+ raw: `- [ ] ${title}`,
298
+ };
299
+ wh.onQueueGoal(goal);
300
+ reply = `Added goal: ${title}`;
301
+ }
302
+ else {
303
+ reply = 'Usage: add <goal title> or goal <goal title>';
304
+ }
305
+ }
306
+ else if (lower.startsWith('todo ')) {
307
+ const title = body.slice(5).trim();
308
+ if (title) {
309
+ await wh.addTodo(title);
310
+ reply = `Added todo: ${title}`;
311
+ }
312
+ else {
313
+ reply = 'Usage: todo <task description>';
314
+ }
315
+ }
316
+ else {
317
+ reply =
318
+ 'Send: add <goal> | goal <goal> | todo <task>. Example: add Complete Phase 4';
319
+ }
320
+ }
321
+ catch (err) {
322
+ reply = `Error: ${String(err)}`;
323
+ }
324
+ res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeTwiML(reply)}</Message></Response>`);
325
+ });
326
+ }
327
+ /** Dashboard at GET / when rich options provided; legacy JSON at GET /status. */
328
+ if (options) {
329
+ app.get('/', (_req, res) => {
330
+ res.type('html').send(getDashboardHtml());
331
+ });
332
+ }
333
+ else {
334
+ app.get('/', (_req, res) => {
335
+ res.json(getStatus());
336
+ });
337
+ }
338
+ app.get('/status', (_req, res) => {
339
+ res.json(getStatus());
340
+ });
341
+ /** Dashboard API: rich payload. */
342
+ app.get('/api/status', async (_req, res) => {
343
+ const legacy = getStatus();
344
+ const payload = {
345
+ ...legacy,
346
+ tokens: {},
347
+ cost: {},
348
+ systemLoad: currentLoadInfo(),
349
+ };
350
+ if (options) {
351
+ const [stateSnapshot, sessionLogEntries, gitFeed] = await Promise.all([
352
+ readStateMd(options.stateMdPath),
353
+ readSessionLog(options.sessionLogPath).then((entries) => entries.slice(-sessionLogLimit).reverse()),
354
+ getRecentCommits(options.workspaceRoot, gitFeedLimit),
355
+ ]);
356
+ payload.stateSnapshot = stateSnapshot ?? undefined;
357
+ payload.sessionLogEntries = sessionLogEntries.map((e) => ({
358
+ timestamp: e.timestamp,
359
+ goalTitle: e.goalTitle,
360
+ phase: e.phase,
361
+ phaseNumber: e.phaseNumber,
362
+ planNumber: e.planNumber,
363
+ sessionId: e.sessionId,
364
+ status: e.status,
365
+ }));
366
+ payload.gitFeed = gitFeed;
367
+ const lastEntry = sessionLogEntries[0];
368
+ if (lastEntry) {
369
+ payload.currentAgentId = lastEntry.sessionId;
370
+ }
371
+ }
372
+ res.json(payload);
373
+ });
374
+ const server = app.listen(port);
375
+ const close = () => new Promise((resolve, reject) => {
376
+ server.close((err) => (err ? reject(err) : resolve()));
377
+ });
378
+ return { server, close };
379
+ }
@@ -0,0 +1 @@
1
+ export {};