create-harness-vibe-coding 0.8.17 → 0.8.19
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 +22 -0
- package/README-CN.md +2 -0
- package/README.md +3 -0
- package/package.json +5 -2
- package/src/generator.js +613 -97
- package/src/index.js +556 -309
- package/src/prompts.js +18 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
- package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
- package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
- package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
- package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
- package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
- package/src/ui/dist/index.html +18 -0
- package/src/ui/index.html +17 -0
- package/src/ui/package.json +33 -0
- package/src/ui/pnpm-lock.yaml +1582 -0
- package/src/ui/pnpm-workspace.yaml +2 -0
- package/src/ui/src/App.tsx +86 -0
- package/src/ui/src/api.ts +93 -0
- package/src/ui/src/components/AgentsRoute.tsx +503 -0
- package/src/ui/src/components/Footer.tsx +69 -0
- package/src/ui/src/components/Header.tsx +175 -0
- package/src/ui/src/components/LoadingView.tsx +22 -0
- package/src/ui/src/components/RolesRoute.tsx +169 -0
- package/src/ui/src/components/SettingsRoute.tsx +166 -0
- package/src/ui/src/components/TaskList.tsx +388 -0
- package/src/ui/src/components/TerminalDrawer.tsx +611 -0
- package/src/ui/src/components/WorkflowRoute.tsx +670 -0
- package/src/ui/src/hooks/useReducedMotion.ts +17 -0
- package/src/ui/src/hooks/useServerConnection.ts +114 -0
- package/src/ui/src/index.css +281 -0
- package/src/ui/src/main.tsx +11 -0
- package/src/ui/src/types.ts +108 -0
- package/src/ui/tsconfig.json +21 -0
- package/src/ui/vite.config.ts +19 -0
- package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
- package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
- package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
- package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
- package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
- package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
- package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
- package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
- package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
- package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
- package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
- package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
- package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
- package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
- package/src/wf-ui-server/a2a-store.mjs +296 -0
- package/src/wf-ui-server/peer-capsule.mjs +213 -0
- package/src/wf-ui-server/pty-adapter.mjs +155 -0
- package/src/wf-ui-server/runtime-config.mjs +153 -0
- package/src/wf-ui-server/runtime-detector.mjs +374 -0
- package/src/wf-ui-server/security.mjs +67 -0
- package/src/wf-ui-server/server.mjs +849 -0
- package/src/wf-ui-server/session-registry.mjs +204 -0
- package/src/wf-ui-server/settings.mjs +100 -0
- package/src/wf-ui-server/task-parser.mjs +133 -0
- package/src/wf-ui-server/terminal-store.mjs +225 -0
- package/src/wf-ui-server/token.mjs +41 -0
- package/src/wf-ui-server/ws-events.mjs +354 -0
- package/src/wf-ui-server/ws-terminal.mjs +473 -0
- package/templates/common/.claude/commands/wf-command-create.md +58 -0
- package/templates/common/.claude/commands/wf-help.md +5 -0
- package/templates/common/.claude/commands/wf-task-archive.md +26 -0
- package/templates/common/.claude/commands/wf-task-list.md +26 -0
- package/templates/common/.claude/commands/wf-task-record.md +24 -0
- package/templates/common/.claude/commands/wf-ui.md +26 -0
- package/templates/common/.claude/commands/wf-update.md +54 -9
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
- package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
- package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
- package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
- package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
- package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
- package/templates/common/.harness-version +105 -47
- package/templates/common/.opencode/commands/wf-command-create.md +61 -0
- package/templates/common/.opencode/commands/wf-help.md +5 -0
- package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
- package/templates/common/.opencode/commands/wf-task-list.md +29 -0
- package/templates/common/.opencode/commands/wf-task-record.md +27 -0
- package/templates/common/.opencode/commands/wf-ui.md +26 -0
- package/templates/common/.opencode/commands/wf-update.md +54 -9
- package/templates/common/CLAUDE.md +8 -6
- package/templates/common/Harness/MEMORY.md +11 -0
- package/templates/common/Harness/README.md +21 -38
- package/templates/common/Harness/a2a/role-graph.json +79 -0
- package/templates/common/Harness/a2a/runtime-registry.json +5 -0
- package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
- package/templates/common/Harness/ownership.manifest.json +142 -2
- package/templates/common/Harness/scripts/README.md +134 -0
- package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
- package/templates/common/Harness/scripts/context-budget.mjs +1 -1
- package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
- package/templates/common/Harness/scripts/task-state.mjs +949 -26
- package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
- package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
- package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
- package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
- package/templates/common/Harness/settings.json +35 -0
- package/templates/common/Harness/specs/guides/SETUP.md +8 -0
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
- package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
- package/templates/common/Harness/specs/runtime/subagents.md +6 -0
- package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
- package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
- package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
- package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
- package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
- package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
- package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
- package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
- package/templates/common/Harness/tasks/_template/STATE.json +6 -0
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
- package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { canonicalizeProjectPath, validateTaskId } from './security.mjs';
|
|
7
|
+
import { parseTaskList, parseTaskCapsule, parseArchivedTasks, readTaskFile } from './task-parser.mjs';
|
|
8
|
+
import { loadSettings } from './settings.mjs';
|
|
9
|
+
import { generateToken, validateToken } from './token.mjs';
|
|
10
|
+
import { detectRuntimesCached, resolveRuntimeResumeArgs } from './runtime-detector.mjs';
|
|
11
|
+
import { spawnPty } from './pty-adapter.mjs';
|
|
12
|
+
import { killPtyProcess, registerPtyProcess, unregisterPtyProcess, writePtyInput } from './ws-terminal.mjs';
|
|
13
|
+
import {
|
|
14
|
+
ensureA2aDefaults,
|
|
15
|
+
loadA2aSkills,
|
|
16
|
+
buildWorkflowSnapshot,
|
|
17
|
+
loadBuiltInWorkflows,
|
|
18
|
+
loadRoleGraph,
|
|
19
|
+
} from './a2a-store.mjs';
|
|
20
|
+
import { readRuntimeConfig, writeRuntimeConfig } from './runtime-config.mjs';
|
|
21
|
+
import {
|
|
22
|
+
appendSessionEvent,
|
|
23
|
+
appendTerminalData,
|
|
24
|
+
globTerminalEvents,
|
|
25
|
+
listTerminalSessions,
|
|
26
|
+
persistSession,
|
|
27
|
+
readTerminalRange,
|
|
28
|
+
recordInputRequest,
|
|
29
|
+
} from './terminal-store.mjs';
|
|
30
|
+
|
|
31
|
+
const MIME = {
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.css': 'text/css; charset=utf-8',
|
|
34
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
35
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
36
|
+
'.json': 'application/json',
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.ico': 'image/x-icon',
|
|
40
|
+
'.woff2': 'font/woff2',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const SERVER_VERSION = '0.8.19';
|
|
44
|
+
|
|
45
|
+
// ── In-memory debug state (frontend posts, CLI reads) ──
|
|
46
|
+
let debugState = {
|
|
47
|
+
connected: false,
|
|
48
|
+
route: null,
|
|
49
|
+
tasks: null,
|
|
50
|
+
taskCount: 0,
|
|
51
|
+
errors: [],
|
|
52
|
+
events: [],
|
|
53
|
+
lastUpdate: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the UI dist directory.
|
|
58
|
+
*/
|
|
59
|
+
function resolveUiDist() {
|
|
60
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
61
|
+
const pkgDist = path.resolve(__dirname, '..', '..', 'dist', 'wf-ui');
|
|
62
|
+
if (fs.existsSync(pkgDist)) return pkgDist;
|
|
63
|
+
const devDist = path.resolve(__dirname, '..', 'ui', 'dist');
|
|
64
|
+
if (fs.existsSync(devDist)) return devDist;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serveStatic(res, filePath) {
|
|
69
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
70
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
71
|
+
const cacheControl = filePath.includes(`${path.sep}assets${path.sep}`)
|
|
72
|
+
? 'public, max-age=31536000, immutable'
|
|
73
|
+
: 'no-cache';
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(filePath);
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
'Content-Type': mime,
|
|
78
|
+
'Cache-Control': cacheControl,
|
|
79
|
+
});
|
|
80
|
+
res.end(content);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sendJson(res, statusCode, data) {
|
|
88
|
+
const body = JSON.stringify(data);
|
|
89
|
+
res.writeHead(statusCode, {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'Access-Control-Allow-Origin': '*',
|
|
92
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
93
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
94
|
+
});
|
|
95
|
+
res.end(body);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendError(res, statusCode, code, message, details) {
|
|
99
|
+
const err = { error: { code, message } };
|
|
100
|
+
if (details !== undefined) err.error.details = details;
|
|
101
|
+
sendJson(res, statusCode, err);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tokenFromRequest(req, url) {
|
|
105
|
+
const queryToken = url.searchParams.get('token');
|
|
106
|
+
if (queryToken) return queryToken;
|
|
107
|
+
const auth = req.headers.authorization || '';
|
|
108
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
109
|
+
return match ? match[1] : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readJsonBody(req) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
let body = '';
|
|
115
|
+
req.on('data', chunk => { body += chunk; });
|
|
116
|
+
req.on('end', () => {
|
|
117
|
+
try { resolve(body ? JSON.parse(body) : {}); }
|
|
118
|
+
catch (err) { reject(err); }
|
|
119
|
+
});
|
|
120
|
+
req.on('error', reject);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mergeSessions(memorySessions, diskSessions) {
|
|
125
|
+
const byId = new Map();
|
|
126
|
+
for (const session of diskSessions) byId.set(session.sessionId, session);
|
|
127
|
+
for (const session of memorySessions) byId.set(session.sessionId, { ...byId.get(session.sessionId), ...session });
|
|
128
|
+
return [...byId.values()].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isLiveSession(session) {
|
|
132
|
+
return session?.status === 'running' || session?.status === 'starting';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function withResumeMetadata(session) {
|
|
136
|
+
const runtimeInfo = detectRuntimesCached({ includeMissing: true })
|
|
137
|
+
.find((candidate) => candidate.id === session.runtime);
|
|
138
|
+
const resumeArgs = resolveRuntimeResumeArgs(session.runtime, { agentSessionId: session.agentSessionId });
|
|
139
|
+
const command = runtimeInfo?.command || session.runtime;
|
|
140
|
+
return {
|
|
141
|
+
...session,
|
|
142
|
+
resumeSupported: resumeArgs.length > 0,
|
|
143
|
+
resumeArgs,
|
|
144
|
+
resumeCommand: resumeArgs.length > 0 ? [command, ...resumeArgs].join(' ') : null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createServer({ projectRoot, sessionRegistry: sr, token: expectedToken, terminalHub = null }) {
|
|
149
|
+
const absRoot = canonicalizeProjectPath(projectRoot);
|
|
150
|
+
const startTime = Date.now();
|
|
151
|
+
ensureA2aDefaults(absRoot);
|
|
152
|
+
|
|
153
|
+
const server = http.createServer((req, res) => {
|
|
154
|
+
if (req.method === 'OPTIONS') {
|
|
155
|
+
res.writeHead(204, {
|
|
156
|
+
'Access-Control-Allow-Origin': '*',
|
|
157
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
158
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
159
|
+
});
|
|
160
|
+
res.end();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
|
|
165
|
+
const pathname = url.pathname;
|
|
166
|
+
|
|
167
|
+
if (expectedToken && pathname.startsWith('/api/')) {
|
|
168
|
+
const presented = tokenFromRequest(req, url);
|
|
169
|
+
if (!presented) return sendError(res, 401, 'UNAUTHORIZED', 'Missing token');
|
|
170
|
+
if (!validateToken(presented, expectedToken)) return sendError(res, 403, 'FORBIDDEN', 'Invalid token');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── GET /api/health ──
|
|
174
|
+
if (req.method === 'GET' && pathname === '/api/health') {
|
|
175
|
+
return sendJson(res, 200, {
|
|
176
|
+
status: 'ok',
|
|
177
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
178
|
+
version: SERVER_VERSION,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── GET /api/project ──
|
|
183
|
+
if (req.method === 'GET' && pathname === '/api/project') {
|
|
184
|
+
const tasksRoot = path.join(absRoot, 'Harness', 'tasks');
|
|
185
|
+
let taskCount = 0;
|
|
186
|
+
try {
|
|
187
|
+
if (fs.existsSync(tasksRoot)) {
|
|
188
|
+
const dirs = fs.readdirSync(tasksRoot, { withFileTypes: true });
|
|
189
|
+
taskCount = dirs.filter(d => d.isDirectory() && !d.name.startsWith('_')).length;
|
|
190
|
+
}
|
|
191
|
+
} catch { /* fallback */ }
|
|
192
|
+
let version = 'unknown';
|
|
193
|
+
try {
|
|
194
|
+
const vp = path.join(absRoot, 'Harness', '.harness-version');
|
|
195
|
+
if (fs.existsSync(vp)) {
|
|
196
|
+
const v = JSON.parse(fs.readFileSync(vp, 'utf8'));
|
|
197
|
+
version = v.version || 'unknown';
|
|
198
|
+
}
|
|
199
|
+
} catch { /* fallback */ }
|
|
200
|
+
return sendJson(res, 200, { root: absRoot, version, taskCount });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── GET /api/tasks ──
|
|
204
|
+
if (req.method === 'GET' && pathname === '/api/tasks') {
|
|
205
|
+
const tasksRoot = path.join(absRoot, 'Harness', 'tasks');
|
|
206
|
+
const tasks = parseTaskList(tasksRoot);
|
|
207
|
+
return sendJson(res, 200, tasks);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── GET /api/tasks/archived ──
|
|
211
|
+
if (req.method === 'GET' && pathname === '/api/tasks/archived') {
|
|
212
|
+
const tasksRoot = path.join(absRoot, 'Harness', 'tasks');
|
|
213
|
+
return sendJson(res, 200, parseArchivedTasks(tasksRoot));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── GET /api/tasks/:taskId ──
|
|
217
|
+
const tasksMatch = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
|
218
|
+
if (req.method === 'GET' && tasksMatch) {
|
|
219
|
+
const taskId = tasksMatch[1];
|
|
220
|
+
if (!validateTaskId(taskId)) {
|
|
221
|
+
return sendError(res, 400, 'BAD_REQUEST', 'Invalid task ID');
|
|
222
|
+
}
|
|
223
|
+
const taskDir = path.join(absRoot, 'Harness', 'tasks', taskId);
|
|
224
|
+
try {
|
|
225
|
+
const capsule = parseTaskCapsule(taskDir);
|
|
226
|
+
if (capsule === null) {
|
|
227
|
+
return sendError(res, 404, 'NOT_FOUND', `Task "${taskId}" not found`);
|
|
228
|
+
}
|
|
229
|
+
return sendJson(res, 200, capsule);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return sendError(res, 500, 'INTERNAL_ERROR', 'Failed to read task capsule', err.message);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const taskArchiveMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/archive$/);
|
|
236
|
+
if (req.method === 'POST' && taskArchiveMatch) {
|
|
237
|
+
const taskId = taskArchiveMatch[1];
|
|
238
|
+
if (!validateTaskId(taskId)) return sendError(res, 400, 'BAD_REQUEST', 'Invalid task ID');
|
|
239
|
+
try {
|
|
240
|
+
return sendJson(res, 200, archiveTask(absRoot, taskId));
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── GET /api/settings ──
|
|
247
|
+
if (req.method === 'GET' && pathname === '/api/settings') {
|
|
248
|
+
const settings = loadSettings(absRoot);
|
|
249
|
+
return sendJson(res, 200, settings);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (req.method === 'GET' && pathname === '/api/runtimes') {
|
|
253
|
+
return sendJson(res, 200, detectRuntimesCached({
|
|
254
|
+
includeMissing: url.searchParams.get('all') === '1',
|
|
255
|
+
refresh: url.searchParams.get('refresh') === '1',
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const runtimeConfigMatch = pathname.match(/^\/api\/runtimes\/([^/]+)\/config$/);
|
|
260
|
+
if (req.method === 'GET' && runtimeConfigMatch) {
|
|
261
|
+
try {
|
|
262
|
+
return sendJson(res, 200, readRuntimeConfig(absRoot, runtimeConfigMatch[1]));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (req.method === 'POST' && runtimeConfigMatch) {
|
|
269
|
+
readJsonBody(req).then((payload) => {
|
|
270
|
+
try {
|
|
271
|
+
const result = writeRuntimeConfig(absRoot, runtimeConfigMatch[1], payload);
|
|
272
|
+
sendJson(res, 200, result);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
275
|
+
}
|
|
276
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (req.method === 'GET' && pathname === '/api/workflow') {
|
|
281
|
+
return sendJson(res, 200, buildWorkflowSnapshot(absRoot, sr));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (req.method === 'GET' && pathname === '/api/workflows') {
|
|
285
|
+
return sendJson(res, 200, loadBuiltInWorkflows(absRoot));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (req.method === 'GET' && pathname === '/api/roles') {
|
|
289
|
+
return sendJson(res, 200, loadRoleGraph(absRoot));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (req.method === 'POST' && pathname === '/api/workflow/run') {
|
|
293
|
+
if (!sr || typeof sr.create !== 'function') {
|
|
294
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
295
|
+
}
|
|
296
|
+
readJsonBody(req).then(async (payload) => {
|
|
297
|
+
try {
|
|
298
|
+
const session = await createWorkflowSession(sr, absRoot, payload, terminalHub);
|
|
299
|
+
sendJson(res, 201, session);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
302
|
+
}
|
|
303
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (req.method === 'GET' && pathname === '/api/a2a/skills') {
|
|
308
|
+
return sendJson(res, 200, loadA2aSkills(absRoot));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (req.method === 'GET' && pathname === '/api/terminals') {
|
|
312
|
+
return sendJson(res, 200, listTerminalSessions(absRoot));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (req.method === 'GET' && pathname === '/api/terminals/events') {
|
|
316
|
+
return sendJson(res, 200, globTerminalEvents(absRoot, {
|
|
317
|
+
pattern: url.searchParams.get('glob') || '**/*',
|
|
318
|
+
since: url.searchParams.get('since') || undefined,
|
|
319
|
+
limit: Number(url.searchParams.get('limit') || 200),
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const rangeMatch = pathname.match(/^\/api\/terminals\/([^/]+)\/range$/);
|
|
324
|
+
if (req.method === 'GET' && rangeMatch) {
|
|
325
|
+
return sendJson(res, 200, readTerminalRange(absRoot, {
|
|
326
|
+
sessionId: rangeMatch[1],
|
|
327
|
+
fromSeq: url.searchParams.has('fromSeq') ? Number(url.searchParams.get('fromSeq')) : undefined,
|
|
328
|
+
toSeq: url.searchParams.has('toSeq') ? Number(url.searchParams.get('toSeq')) : undefined,
|
|
329
|
+
tail: url.searchParams.has('tail') ? Number(url.searchParams.get('tail')) : undefined,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── GET /api/sessions ──
|
|
334
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
335
|
+
const memorySessions = sr && typeof sr.getAll === 'function' ? sr.getAll() : [];
|
|
336
|
+
const includeAll = url.searchParams.get('all') === '1';
|
|
337
|
+
const sessions = includeAll
|
|
338
|
+
? mergeSessions(memorySessions, listTerminalSessions(absRoot)).map(withResumeMetadata)
|
|
339
|
+
: memorySessions.filter(isLiveSession).map(withResumeMetadata);
|
|
340
|
+
return sendJson(res, 200, sessions);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── POST /api/sessions — create a new session ──
|
|
344
|
+
if (req.method === 'POST' && pathname === '/api/sessions') {
|
|
345
|
+
if (!sr || typeof sr.create !== 'function') {
|
|
346
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
347
|
+
}
|
|
348
|
+
readJsonBody(req).then(async (payload) => {
|
|
349
|
+
try {
|
|
350
|
+
const session = await createRuntimeSession(sr, absRoot, payload, terminalHub);
|
|
351
|
+
sendJson(res, 201, session);
|
|
352
|
+
} catch (e) {
|
|
353
|
+
sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
354
|
+
}
|
|
355
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const taskSessionMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/sessions$/);
|
|
360
|
+
if (req.method === 'POST' && taskSessionMatch) {
|
|
361
|
+
if (!sr || typeof sr.create !== 'function') {
|
|
362
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
363
|
+
}
|
|
364
|
+
const routeTaskId = taskSessionMatch[1];
|
|
365
|
+
if (!validateTaskId(routeTaskId)) return sendError(res, 400, 'BAD_REQUEST', 'Invalid task ID');
|
|
366
|
+
readJsonBody(req).then(async (payload) => {
|
|
367
|
+
try {
|
|
368
|
+
const session = await createRuntimeSession(sr, absRoot, { ...payload, taskId: routeTaskId }, terminalHub);
|
|
369
|
+
sendJson(res, 201, session);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
372
|
+
}
|
|
373
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── POST /api/sessions/:sessionId/stop ──
|
|
378
|
+
const stopMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
|
|
379
|
+
if (req.method === 'POST' && stopMatch) {
|
|
380
|
+
if (!sr || typeof sr.stop !== 'function') {
|
|
381
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
382
|
+
}
|
|
383
|
+
const sessionId = stopMatch[1];
|
|
384
|
+
const runStop = typeof sr.withLock === 'function'
|
|
385
|
+
? sr.withLock(sessionId, () => stopRuntimeSession(sr, absRoot, sessionId, terminalHub))
|
|
386
|
+
: Promise.resolve(stopRuntimeSession(sr, absRoot, sessionId, terminalHub));
|
|
387
|
+
runStop
|
|
388
|
+
.then(result => sendJson(res, 200, result))
|
|
389
|
+
.catch(e => sendError(res, 500, 'INTERNAL_ERROR', e.message));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const attachMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/attach-mode$/);
|
|
394
|
+
if (req.method === 'POST' && attachMatch) {
|
|
395
|
+
if (!sr || typeof sr.get !== 'function') {
|
|
396
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
397
|
+
}
|
|
398
|
+
const session = sr.get(attachMatch[1]);
|
|
399
|
+
if (!session) return sendError(res, 404, 'NOT_FOUND', 'Session not found');
|
|
400
|
+
readJsonBody(req).then((payload) => {
|
|
401
|
+
const attachMode = Boolean(payload.attachMode);
|
|
402
|
+
sr.update(session.sessionId, { attachMode });
|
|
403
|
+
const updated = sr.get(session.sessionId);
|
|
404
|
+
persistSession(absRoot, updated);
|
|
405
|
+
appendSessionEvent(absRoot, updated, { type: 'session.attach-mode', attachMode });
|
|
406
|
+
sendJson(res, 200, updated);
|
|
407
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const inputMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
|
|
412
|
+
if (req.method === 'POST' && inputMatch) {
|
|
413
|
+
if (!sr || typeof sr.get !== 'function') {
|
|
414
|
+
return sendError(res, 501, 'NOT_IMPLEMENTED', 'Session registry not available');
|
|
415
|
+
}
|
|
416
|
+
const session = sr.get(inputMatch[1]);
|
|
417
|
+
if (!session) return sendError(res, 404, 'NOT_FOUND', 'Session not found');
|
|
418
|
+
readJsonBody(req).then((payload) => {
|
|
419
|
+
const data = String(payload.data || payload.text || '');
|
|
420
|
+
if (!session.attachMode) return sendError(res, 409, 'WATCH_MODE', 'Input rejected: session is in watch mode');
|
|
421
|
+
if (!writePtyInput(session.sessionId, data)) return sendError(res, 409, 'NO_PTY', 'No PTY process attached to this session');
|
|
422
|
+
recordInputRequest(absRoot, session.sessionId, data);
|
|
423
|
+
appendTerminalData(absRoot, session, data, 'stdin');
|
|
424
|
+
return sendJson(res, 200, { ok: true, sessionId: session.sessionId });
|
|
425
|
+
}).catch(e => sendError(res, 400, 'BAD_REQUEST', e.message));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── GET /api/tasks/:taskId/file/:filename ──
|
|
430
|
+
const fileMatch = pathname.match(/^\/api\/tasks\/([^/]+)\/file\/([^/]+)$/);
|
|
431
|
+
if (req.method === 'GET' && fileMatch) {
|
|
432
|
+
const fileTaskId = fileMatch[1];
|
|
433
|
+
const filename = fileMatch[2];
|
|
434
|
+
if (!validateTaskId(fileTaskId)) return sendError(res, 400, 'BAD_REQUEST', 'Invalid task ID');
|
|
435
|
+
const taskDir = findTaskDirectory(absRoot, fileTaskId);
|
|
436
|
+
if (!taskDir || !fs.existsSync(taskDir)) return sendError(res, 404, 'NOT_FOUND', `Task "${fileTaskId}" not found`);
|
|
437
|
+
const fileData = readTaskFile(taskDir, filename);
|
|
438
|
+
if (!fileData) return sendError(res, 404, 'NOT_FOUND', `File "${filename}" not found`);
|
|
439
|
+
return sendJson(res, 200, fileData);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── POST /api/settings — write project settings ──
|
|
443
|
+
if (req.method === 'POST' && pathname === '/api/settings') {
|
|
444
|
+
let body = '';
|
|
445
|
+
req.on('data', chunk => { body += chunk; });
|
|
446
|
+
req.on('end', () => {
|
|
447
|
+
try {
|
|
448
|
+
const newSettings = JSON.parse(body);
|
|
449
|
+
const current = loadSettings(absRoot);
|
|
450
|
+
const merged = { ...current, ...newSettings };
|
|
451
|
+
const settingsPath = path.join(absRoot, 'Harness', 'settings.json');
|
|
452
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
453
|
+
fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2));
|
|
454
|
+
sendJson(res, 200, merged);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
sendError(res, 400, 'BAD_REQUEST', e.message);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── POST /api/debug/report — frontend posts UI state ──
|
|
463
|
+
if (req.method === 'POST' && pathname === '/api/debug/report') {
|
|
464
|
+
let body = '';
|
|
465
|
+
req.on('data', chunk => { body += chunk; });
|
|
466
|
+
req.on('end', () => {
|
|
467
|
+
try {
|
|
468
|
+
const report = JSON.parse(body);
|
|
469
|
+
debugState = {
|
|
470
|
+
...debugState,
|
|
471
|
+
...report,
|
|
472
|
+
lastUpdate: new Date().toISOString(),
|
|
473
|
+
};
|
|
474
|
+
sendJson(res, 200, { ok: true });
|
|
475
|
+
} catch (e) {
|
|
476
|
+
sendError(res, 400, 'BAD_REQUEST', 'Invalid JSON');
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── GET /api/debug/state — read latest frontend UI state ──
|
|
483
|
+
if (req.method === 'GET' && pathname === '/api/debug/state') {
|
|
484
|
+
const tasksRoot = path.join(absRoot, 'Harness', 'tasks');
|
|
485
|
+
let tasks = [];
|
|
486
|
+
try { tasks = parseTaskList(tasksRoot); } catch { /* */ }
|
|
487
|
+
return sendJson(res, 200, {
|
|
488
|
+
frontend: debugState,
|
|
489
|
+
server: {
|
|
490
|
+
version: SERVER_VERSION,
|
|
491
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
492
|
+
projectRoot: absRoot,
|
|
493
|
+
tasksOnDisk: tasks.length,
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Static assets ──
|
|
499
|
+
if (req.method === 'GET') {
|
|
500
|
+
const uiDist = resolveUiDist();
|
|
501
|
+
if (uiDist) {
|
|
502
|
+
// /assets/* and /favicon.ico
|
|
503
|
+
if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') {
|
|
504
|
+
const assetPath = path.join(uiDist, pathname);
|
|
505
|
+
if (serveStatic(res, assetPath)) return;
|
|
506
|
+
}
|
|
507
|
+
// SPA fallback: all other GET → index.html
|
|
508
|
+
if (!pathname.startsWith('/api/') && !pathname.startsWith('/ws/')) {
|
|
509
|
+
const indexPath = path.join(uiDist, 'index.html');
|
|
510
|
+
if (serveStatic(res, indexPath)) return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return sendError(res, 404, 'NOT_FOUND', `No route matches ${req.method} ${pathname}`);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return server;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function optionalTaskId(value) {
|
|
522
|
+
const taskId = String(value || '').trim();
|
|
523
|
+
if (!taskId) return null;
|
|
524
|
+
if (!validateTaskId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
|
|
525
|
+
return taskId;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function cleanString(value, fallback = '') {
|
|
529
|
+
const text = String(value || '').trim();
|
|
530
|
+
return text || fallback;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function writeJsonAtomic(filePath, data) {
|
|
534
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
535
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
536
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
537
|
+
fs.renameSync(tmp, filePath);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function findTaskDirectory(absRoot, taskId) {
|
|
541
|
+
const tasksRoot = path.join(absRoot, 'Harness', 'tasks');
|
|
542
|
+
const activeDir = path.join(tasksRoot, taskId);
|
|
543
|
+
if (fs.existsSync(activeDir)) return activeDir;
|
|
544
|
+
|
|
545
|
+
const archiveRoot = path.join(tasksRoot, '_archive');
|
|
546
|
+
if (!fs.existsSync(archiveRoot)) return null;
|
|
547
|
+
const stack = [archiveRoot];
|
|
548
|
+
while (stack.length > 0) {
|
|
549
|
+
const current = stack.pop();
|
|
550
|
+
let entries = [];
|
|
551
|
+
try { entries = fs.readdirSync(current, { withFileTypes: true }); }
|
|
552
|
+
catch { continue; }
|
|
553
|
+
for (const entry of entries) {
|
|
554
|
+
if (!entry.isDirectory()) continue;
|
|
555
|
+
const dir = path.join(current, entry.name);
|
|
556
|
+
if (entry.name === taskId && fs.existsSync(path.join(dir, 'STATE.json'))) return dir;
|
|
557
|
+
stack.push(dir);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function readTaskState(absRoot, taskId) {
|
|
564
|
+
const taskDir = taskId ? findTaskDirectory(absRoot, taskId) : null;
|
|
565
|
+
if (!taskDir) return null;
|
|
566
|
+
const statePath = path.join(taskDir, 'STATE.json');
|
|
567
|
+
try {
|
|
568
|
+
if (!fs.existsSync(statePath)) return null;
|
|
569
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function resolveTaskRuntime(absRoot, taskId, requestedRuntime) {
|
|
576
|
+
if (requestedRuntime) return requestedRuntime;
|
|
577
|
+
const taskState = readTaskState(absRoot, taskId);
|
|
578
|
+
const settings = loadSettings(absRoot);
|
|
579
|
+
return cleanString(
|
|
580
|
+
taskState?.defaultRuntime ||
|
|
581
|
+
taskState?.defaultAgentRuntime ||
|
|
582
|
+
taskState?.agentRuntime ||
|
|
583
|
+
taskState?.cliAgent ||
|
|
584
|
+
settings?.terminal?.defaultRuntime,
|
|
585
|
+
'codex',
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function rememberTaskRuntime(absRoot, taskId, runtime) {
|
|
590
|
+
if (!taskId || !runtime) return;
|
|
591
|
+
const taskDir = findTaskDirectory(absRoot, taskId);
|
|
592
|
+
if (!taskDir || taskDir.includes(`${path.sep}_archive${path.sep}`)) return;
|
|
593
|
+
const statePath = path.join(taskDir, 'STATE.json');
|
|
594
|
+
try {
|
|
595
|
+
if (!fs.existsSync(statePath)) return;
|
|
596
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
597
|
+
const next = {
|
|
598
|
+
...state,
|
|
599
|
+
defaultRuntime: runtime,
|
|
600
|
+
defaultAgentRuntime: runtime,
|
|
601
|
+
updatedAt: new Date().toISOString(),
|
|
602
|
+
};
|
|
603
|
+
writeJsonAtomic(statePath, next);
|
|
604
|
+
} catch {
|
|
605
|
+
// Runtime binding is recovery metadata; session launch remains authoritative.
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function archiveTask(absRoot, taskId) {
|
|
610
|
+
const scriptPath = path.join(absRoot, 'Harness', 'scripts', 'task-state.mjs');
|
|
611
|
+
if (!fs.existsSync(scriptPath)) {
|
|
612
|
+
throw new Error('Harness/scripts/task-state.mjs is not available for archive');
|
|
613
|
+
}
|
|
614
|
+
const result = spawnSync(process.execPath, [scriptPath, 'archive', '--apply', '--task', taskId, '--json'], {
|
|
615
|
+
cwd: absRoot,
|
|
616
|
+
encoding: 'utf8',
|
|
617
|
+
windowsHide: true,
|
|
618
|
+
});
|
|
619
|
+
let payload = null;
|
|
620
|
+
try { payload = result.stdout ? JSON.parse(result.stdout) : null; } catch { /* keep fallback */ }
|
|
621
|
+
if (result.status !== 0 || payload?.ok === false) {
|
|
622
|
+
const message = payload?.errors?.join('; ') || result.stderr || result.stdout || `Archive failed for ${taskId}`;
|
|
623
|
+
throw new Error(message.trim());
|
|
624
|
+
}
|
|
625
|
+
return payload || { ok: true, command: 'archive', taskId };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function stopRuntimeSession(sr, absRoot, sessionId, terminalHub = null) {
|
|
629
|
+
const session = sr.get(sessionId);
|
|
630
|
+
if (!session) {
|
|
631
|
+
killPtyProcess(sessionId);
|
|
632
|
+
return { ok: true, killed: false, saved: null, alreadyStopped: true };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const killed = killPtyProcess(sessionId);
|
|
636
|
+
const stopped = sr.stop(sessionId);
|
|
637
|
+
if (!stopped) return { ok: true, killed, saved: null, alreadyStopped: true };
|
|
638
|
+
const updated = withResumeMetadata({
|
|
639
|
+
...stopped,
|
|
640
|
+
status: 'saved',
|
|
641
|
+
killed,
|
|
642
|
+
wsClientCount: 0,
|
|
643
|
+
stoppedAt: new Date().toISOString(),
|
|
644
|
+
updatedAt: new Date().toISOString(),
|
|
645
|
+
});
|
|
646
|
+
persistSession(absRoot, updated);
|
|
647
|
+
appendSessionEvent(absRoot, updated, { type: 'session.stopped', killed });
|
|
648
|
+
terminalHub?.broadcastToSession?.(updated.sessionId, {
|
|
649
|
+
type: 'session:state',
|
|
650
|
+
sessionId: updated.sessionId,
|
|
651
|
+
state: updated.status || 'saved',
|
|
652
|
+
});
|
|
653
|
+
return { ok: true, killed, saved: updated };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function workflowCommandForRuntime(runtime, command) {
|
|
657
|
+
if (runtime === 'codex' && command.startsWith('/')) return `$${command.slice(1)}`;
|
|
658
|
+
return command;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function workflowInitialInput(runtime, workflow, ceoPrompt) {
|
|
662
|
+
const command = workflowCommandForRuntime(runtime, workflow.command || '/wf');
|
|
663
|
+
return `${command}\n${ceoPrompt.trim()}\n`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function createWorkflowSession(sr, absRoot, payload, terminalHub = null) {
|
|
667
|
+
const workflows = loadBuiltInWorkflows(absRoot);
|
|
668
|
+
const selectedWorkflow = workflows.find((workflow) =>
|
|
669
|
+
workflow.id === payload.workflowId || workflow.command === payload.workflowCommand
|
|
670
|
+
) || workflows[0];
|
|
671
|
+
const detected = detectRuntimesCached();
|
|
672
|
+
const runtime = cleanString(payload.runtime, detected[0]?.id || '');
|
|
673
|
+
if (!runtime) throw new Error('No detected runtime is available to start /wf');
|
|
674
|
+
const ceoPrompt = cleanString(payload.ceoPrompt, selectedWorkflow.defaultCeoPrompt || 'Run Harness /wf mode as the CEO agent.');
|
|
675
|
+
|
|
676
|
+
return createRuntimeSession(sr, absRoot, {
|
|
677
|
+
...payload,
|
|
678
|
+
runtime,
|
|
679
|
+
role: 'CEO',
|
|
680
|
+
objective: `${selectedWorkflow.label || 'WF'} CEO terminal`,
|
|
681
|
+
workflowMode: 'wf',
|
|
682
|
+
workflowId: selectedWorkflow.id,
|
|
683
|
+
workflowCommand: selectedWorkflow.command || '/wf',
|
|
684
|
+
ceoPrompt,
|
|
685
|
+
initialInput: workflowInitialInput(runtime, selectedWorkflow, ceoPrompt),
|
|
686
|
+
}, terminalHub);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function createRuntimeSession(sr, absRoot, payload, terminalHub = null) {
|
|
690
|
+
const taskId = optionalTaskId(payload.taskId);
|
|
691
|
+
const runtime = resolveTaskRuntime(absRoot, taskId, cleanString(payload.runtime));
|
|
692
|
+
if (!runtime) throw new Error('Runtime is required');
|
|
693
|
+
const role = cleanString(payload.role, 'terminal-agent');
|
|
694
|
+
const objective = cleanString(payload.objective, 'Harness terminal agent');
|
|
695
|
+
const subagentMode = cleanString(payload.subagentMode, 'wf-subagents');
|
|
696
|
+
const workflowMode = cleanString(payload.workflowMode, '');
|
|
697
|
+
const model = cleanString(payload.model, '');
|
|
698
|
+
const provider = cleanString(payload.provider, '');
|
|
699
|
+
const ceoPrompt = cleanString(payload.ceoPrompt, '');
|
|
700
|
+
const runtimeInfo = detectRuntimesCached({ includeMissing: true }).find((candidate) => candidate.id === runtime);
|
|
701
|
+
if (!runtimeInfo) throw new Error(`Unknown runtime: ${runtime}`);
|
|
702
|
+
if (!runtimeInfo.path) throw new Error(`${runtimeInfo.label || runtime} is not detected on PATH`);
|
|
703
|
+
rememberTaskRuntime(absRoot, taskId, runtime);
|
|
704
|
+
|
|
705
|
+
const session = sr.create({
|
|
706
|
+
runtime,
|
|
707
|
+
taskId,
|
|
708
|
+
role,
|
|
709
|
+
objective,
|
|
710
|
+
projectRoot: absRoot,
|
|
711
|
+
subagentMode,
|
|
712
|
+
workflowMode: workflowMode || null,
|
|
713
|
+
model,
|
|
714
|
+
provider,
|
|
715
|
+
ceoPrompt,
|
|
716
|
+
});
|
|
717
|
+
persistSession(absRoot, session);
|
|
718
|
+
appendSessionEvent(absRoot, session, {
|
|
719
|
+
type: 'session.created',
|
|
720
|
+
runtime,
|
|
721
|
+
role: session.role,
|
|
722
|
+
taskId,
|
|
723
|
+
workflowMode: workflowMode || null,
|
|
724
|
+
subagentMode,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
if (!runtimeInfo.launchable) {
|
|
728
|
+
sr.update(session.sessionId, {
|
|
729
|
+
status: 'blocked',
|
|
730
|
+
blockedReason: runtimeInfo.blockedReason || 'runtime-not-launchable',
|
|
731
|
+
});
|
|
732
|
+
const updated = sr.get(session.sessionId);
|
|
733
|
+
persistSession(absRoot, updated);
|
|
734
|
+
appendSessionEvent(absRoot, updated, { type: 'session.blocked', reason: updated.blockedReason });
|
|
735
|
+
return updated;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const spawned = await spawnPty({
|
|
739
|
+
runtime,
|
|
740
|
+
taskId,
|
|
741
|
+
peerId: session.peerId,
|
|
742
|
+
sessionId: session.sessionId,
|
|
743
|
+
command: runtimeInfo.path || runtimeInfo.command,
|
|
744
|
+
model,
|
|
745
|
+
projectRoot: absRoot,
|
|
746
|
+
cols: session.cols,
|
|
747
|
+
rows: session.rows,
|
|
748
|
+
onData: (data) => {
|
|
749
|
+
const current = sr.get(session.sessionId);
|
|
750
|
+
if (!current) return;
|
|
751
|
+
appendTerminalData(absRoot, current, data, 'stdout');
|
|
752
|
+
terminalHub?.broadcastToSession?.(session.sessionId, {
|
|
753
|
+
type: 'pty:data',
|
|
754
|
+
sessionId: session.sessionId,
|
|
755
|
+
data,
|
|
756
|
+
});
|
|
757
|
+
},
|
|
758
|
+
onExit: ({ exitCode, signal }) => {
|
|
759
|
+
const handleExit = () => {
|
|
760
|
+
if (!sr.get(session.sessionId)) {
|
|
761
|
+
unregisterPtyProcess(session.sessionId);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
sr.update(session.sessionId, { status: 'exited', exitCode });
|
|
765
|
+
const current = sr.get(session.sessionId);
|
|
766
|
+
persistSession(absRoot, current, { signal });
|
|
767
|
+
appendSessionEvent(absRoot, current, { type: 'session.exited', exitCode, signal });
|
|
768
|
+
terminalHub?.broadcastToSession?.(session.sessionId, {
|
|
769
|
+
type: 'session:state',
|
|
770
|
+
sessionId: session.sessionId,
|
|
771
|
+
state: 'exited',
|
|
772
|
+
});
|
|
773
|
+
unregisterPtyProcess(session.sessionId);
|
|
774
|
+
};
|
|
775
|
+
if (typeof sr.withLock === 'function') {
|
|
776
|
+
sr.withLock(session.sessionId, handleExit).catch(() => unregisterPtyProcess(session.sessionId));
|
|
777
|
+
} else {
|
|
778
|
+
handleExit();
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
if (spawned.blocked) {
|
|
784
|
+
sr.update(session.sessionId, { status: 'blocked', blockedReason: spawned.reason, blockedHint: spawned.hint });
|
|
785
|
+
const updated = sr.get(session.sessionId);
|
|
786
|
+
persistSession(absRoot, updated, { hint: spawned.hint, details: spawned.details || [] });
|
|
787
|
+
appendSessionEvent(absRoot, updated, {
|
|
788
|
+
type: 'session.blocked',
|
|
789
|
+
reason: spawned.reason,
|
|
790
|
+
hint: spawned.hint,
|
|
791
|
+
details: spawned.details || [],
|
|
792
|
+
});
|
|
793
|
+
return updated;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
sr.update(session.sessionId, { status: 'running', pid: spawned.pid, ptyProvider: spawned.ptyProvider || null });
|
|
797
|
+
const updated = sr.get(session.sessionId);
|
|
798
|
+
persistSession(absRoot, updated);
|
|
799
|
+
appendSessionEvent(absRoot, updated, { type: 'session.running', pid: spawned.pid, ptyProvider: spawned.ptyProvider || null });
|
|
800
|
+
registerPtyProcess(session.sessionId, spawned.ptyProcess);
|
|
801
|
+
if (payload.initialInput) {
|
|
802
|
+
const input = String(payload.initialInput);
|
|
803
|
+
writePtyInput(session.sessionId, input);
|
|
804
|
+
appendTerminalData(absRoot, updated, input, 'stdin');
|
|
805
|
+
appendSessionEvent(absRoot, updated, { type: 'terminal.input.injected', bytes: input.length });
|
|
806
|
+
}
|
|
807
|
+
return updated;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function startServer(opts = {}) {
|
|
811
|
+
const projectRoot = canonicalizeProjectPath(opts.projectRoot || process.cwd());
|
|
812
|
+
const host = opts.host || '127.0.0.1';
|
|
813
|
+
const port = opts.port !== undefined ? opts.port : 0;
|
|
814
|
+
const token = opts.token || generateToken();
|
|
815
|
+
|
|
816
|
+
const server = createServer({
|
|
817
|
+
projectRoot,
|
|
818
|
+
sessionRegistry: opts.sessionRegistry,
|
|
819
|
+
token,
|
|
820
|
+
terminalHub: opts.terminalHub || null,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
return new Promise((resolve, reject) => {
|
|
824
|
+
server.listen(port, host, () => {
|
|
825
|
+
const addr = server.address();
|
|
826
|
+
const actualPort = addr.port;
|
|
827
|
+
resolve({
|
|
828
|
+
server,
|
|
829
|
+
port: actualPort,
|
|
830
|
+
token,
|
|
831
|
+
url: `http://${host}:${actualPort}/?token=${encodeURIComponent(token)}`,
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
server.on('error', reject);
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function stopServer(server) {
|
|
839
|
+
return new Promise((resolve, reject) => {
|
|
840
|
+
if (!server || !server.listening) return resolve();
|
|
841
|
+
server.close((err) => {
|
|
842
|
+
if (err) return reject(err);
|
|
843
|
+
resolve();
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Expose debug state for external readers (WS modules, etc.) */
|
|
849
|
+
export function getDebugState() { return debugState; }
|