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,150 @@
|
|
|
1
|
+
import test, { before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { startServer, stopServer } from '../server.mjs';
|
|
8
|
+
import { SessionRegistry } from '../session-registry.mjs';
|
|
9
|
+
|
|
10
|
+
function fetchJson(baseUrl, token, route) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const url = new URL(route, baseUrl);
|
|
13
|
+
if (token !== undefined) url.searchParams.set('token', token);
|
|
14
|
+
http.get(url.toString(), (res) => {
|
|
15
|
+
let body = '';
|
|
16
|
+
res.on('data', c => body += c);
|
|
17
|
+
res.on('end', () => {
|
|
18
|
+
try { resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) }); }
|
|
19
|
+
catch { resolve({ status: res.statusCode, headers: res.headers, body }); }
|
|
20
|
+
});
|
|
21
|
+
}).on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function authGet(baseUrl, token, route) {
|
|
26
|
+
const url = new URL(route, baseUrl);
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
http.get({ hostname: url.hostname, port: url.port, path: url.pathname + url.search,
|
|
29
|
+
headers: { Authorization: `Bearer ${token}` } }, (res) => {
|
|
30
|
+
let body = '';
|
|
31
|
+
res.on('data', c => body += c);
|
|
32
|
+
res.on('end', () => {
|
|
33
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
|
34
|
+
catch { resolve({ status: res.statusCode, body }); }
|
|
35
|
+
});
|
|
36
|
+
}).on('error', reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function postJson(baseUrl, token, route, payload = {}) {
|
|
41
|
+
const url = new URL(route, baseUrl);
|
|
42
|
+
const body = JSON.stringify(payload);
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const req = http.request({
|
|
45
|
+
hostname: url.hostname,
|
|
46
|
+
port: url.port,
|
|
47
|
+
path: url.pathname + url.search,
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${token}`,
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
'Content-Length': Buffer.byteLength(body),
|
|
53
|
+
},
|
|
54
|
+
}, (res) => {
|
|
55
|
+
let responseBody = '';
|
|
56
|
+
res.on('data', c => responseBody += c);
|
|
57
|
+
res.on('end', () => {
|
|
58
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(responseBody) }); }
|
|
59
|
+
catch { resolve({ status: res.statusCode, body: responseBody }); }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
req.on('error', reject);
|
|
63
|
+
req.end(body);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let server, baseUrl, token, tempRoot;
|
|
68
|
+
|
|
69
|
+
before(async () => {
|
|
70
|
+
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-ui-test-'));
|
|
71
|
+
const tasksDir = path.join(tempRoot, 'Harness', 'tasks');
|
|
72
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
73
|
+
const ta = path.join(tasksDir, 'task-alpha');
|
|
74
|
+
fs.mkdirSync(ta);
|
|
75
|
+
fs.writeFileSync(path.join(ta, 'STATE.json'), JSON.stringify({
|
|
76
|
+
schemaVersion: 1, taskId: 'task-alpha', status: 'active', phase: 'intake',
|
|
77
|
+
updatedAt: '2026-07-29T10:00:00.000Z',
|
|
78
|
+
acceptance: [{ id: 'AC-001', text: 'Alpha', status: 'pending' }],
|
|
79
|
+
links: { dependsOn: [], blocks: [] }
|
|
80
|
+
}));
|
|
81
|
+
fs.writeFileSync(path.join(ta, 'PLAN.md'), '# Plan');
|
|
82
|
+
fs.writeFileSync(path.join(ta, 'PROGRESS.md'), '# Progress');
|
|
83
|
+
|
|
84
|
+
const tb = path.join(tasksDir, 'task-beta');
|
|
85
|
+
fs.mkdirSync(tb);
|
|
86
|
+
fs.writeFileSync(path.join(tb, 'STATE.json'), JSON.stringify({
|
|
87
|
+
schemaVersion: 1, taskId: 'task-beta', status: 'active', phase: 'implementation',
|
|
88
|
+
updatedAt: '2026-07-29T09:00:00.000Z',
|
|
89
|
+
acceptance: [{ id: 'AC-002', text: 'Beta', status: 'passed' }],
|
|
90
|
+
links: { dependsOn: ['task-alpha'], blocks: [] }
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const hd = path.join(tempRoot, 'Harness');
|
|
94
|
+
fs.writeFileSync(path.join(hd, 'settings.json'), JSON.stringify({ ui: { language: 'zh' } }));
|
|
95
|
+
fs.writeFileSync(path.join(hd, '.harness-version'), JSON.stringify({ version: '0.8.19' }));
|
|
96
|
+
|
|
97
|
+
const r = await startServer({ projectRoot: tempRoot, host: '127.0.0.1', port: 0 });
|
|
98
|
+
server = r.server; token = r.token; baseUrl = `http://127.0.0.1:${r.port}`;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
after(async () => {
|
|
102
|
+
if (server) await stopServer(server);
|
|
103
|
+
if (tempRoot) fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('binds to 127.0.0.1', () => { const a = server.address(); assert.equal(a.address, '127.0.0.1'); });
|
|
107
|
+
test('port > 0 with port=0', () => { assert.ok(server.address().port > 0); });
|
|
108
|
+
test('health returns 200', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/health'); assert.equal(status, 200); assert.equal(body.status, 'ok'); });
|
|
109
|
+
test('health auth via Bearer', async () => { const { status, body } = await authGet(baseUrl, token, '/api/health'); assert.equal(status, 200); assert.equal(body.status, 'ok'); });
|
|
110
|
+
test('no token -> 401', async () => { const { status, body } = await fetchJson(baseUrl, undefined, '/api/tasks'); assert.equal(status, 401); assert.equal(body.error.code, 'UNAUTHORIZED'); });
|
|
111
|
+
test('bad token -> 403', async () => { const { status } = await fetchJson(baseUrl, 'bad-token-1234567890abcdef', '/api/tasks'); assert.equal(status, 403); });
|
|
112
|
+
test('project returns root + version', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/project'); assert.equal(status, 200); assert.equal(body.root, tempRoot); assert.equal(body.version, '0.8.19'); });
|
|
113
|
+
test('tasks returns array sorted desc', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/tasks'); assert.equal(status, 200); assert.ok(body.length >= 2); assert.equal(body[0].taskId, 'task-alpha'); });
|
|
114
|
+
test('tasks/:id returns single', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/tasks/task-beta'); assert.equal(status, 200); assert.equal(body.taskId, 'task-beta'); });
|
|
115
|
+
test('tasks/:id missing -> 404', async () => { const { status } = await fetchJson(baseUrl, token, '/api/tasks/task-nonexist'); assert.equal(status, 404); });
|
|
116
|
+
test('tasks with invalid chars -> 400', async () => { const { status } = await fetchJson(baseUrl, token, '/api/tasks/task%00inject'); assert.equal(status, 400); });
|
|
117
|
+
test('settings merged', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/settings'); assert.equal(status, 200); assert.equal(body.server.host, '127.0.0.1'); assert.equal(body.ui.language, 'zh'); });
|
|
118
|
+
test('sessions empty when no registry', async () => { const { status, body } = await fetchJson(baseUrl, token, '/api/sessions'); assert.equal(status, 200); assert.ok(Array.isArray(body)); assert.equal(body.length, 0); });
|
|
119
|
+
test('error envelope format', async () => { const { status, body } = await fetchJson(baseUrl, undefined, '/api/tasks'); assert.equal(status, 401); assert.ok(body.error.code); assert.ok(body.error.message); });
|
|
120
|
+
test('content-type json', async () => { const { status, headers } = await fetchJson(baseUrl, token, '/api/health'); assert.equal(status, 200); assert.match(headers['content-type'], /application\/json/); });
|
|
121
|
+
test('unknown route 404', async () => { const { status } = await fetchJson(baseUrl, token, '/api/nope'); assert.equal(status, 404); });
|
|
122
|
+
|
|
123
|
+
test('session stop is idempotent and removes session from live list', async () => {
|
|
124
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-ui-stop-test-'));
|
|
125
|
+
fs.mkdirSync(path.join(root, 'Harness', 'tasks'), { recursive: true });
|
|
126
|
+
const registry = new SessionRegistry();
|
|
127
|
+
const started = await startServer({ projectRoot: root, host: '127.0.0.1', port: 0, sessionRegistry: registry });
|
|
128
|
+
const localBaseUrl = `http://127.0.0.1:${started.port}`;
|
|
129
|
+
try {
|
|
130
|
+
const session = registry.create({ runtime: 'claude' });
|
|
131
|
+
registry.update(session.sessionId, { status: 'running' });
|
|
132
|
+
|
|
133
|
+
const first = await postJson(localBaseUrl, started.token, `/api/sessions/${session.sessionId}/stop`);
|
|
134
|
+
assert.equal(first.status, 200);
|
|
135
|
+
assert.equal(first.body.ok, true);
|
|
136
|
+
assert.equal(first.body.saved.status, 'saved');
|
|
137
|
+
|
|
138
|
+
const live = await fetchJson(localBaseUrl, started.token, '/api/sessions');
|
|
139
|
+
assert.equal(live.status, 200);
|
|
140
|
+
assert.deepEqual(live.body, []);
|
|
141
|
+
|
|
142
|
+
const second = await postJson(localBaseUrl, started.token, `/api/sessions/${session.sessionId}/stop`);
|
|
143
|
+
assert.equal(second.status, 200);
|
|
144
|
+
assert.equal(second.body.ok, true);
|
|
145
|
+
assert.equal(second.body.alreadyStopped, true);
|
|
146
|
+
} finally {
|
|
147
|
+
await stopServer(started.server);
|
|
148
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { SessionRegistry, ALLOWED_RUNTIMES } from '../session-registry.mjs';
|
|
4
|
+
import { RUNTIME_DEFINITIONS } from '../runtime-detector.mjs';
|
|
5
|
+
|
|
6
|
+
test('ALLOWED_RUNTIMES contains supported and detectable CLI agents', () => {
|
|
7
|
+
assert.deepEqual([...ALLOWED_RUNTIMES].sort(), RUNTIME_DEFINITIONS.map(runtime => runtime.id).sort());
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('create with valid runtime claude succeeds', () => {
|
|
11
|
+
const reg = new SessionRegistry();
|
|
12
|
+
const session = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp/proj' });
|
|
13
|
+
assert.ok(session.sessionId);
|
|
14
|
+
assert.equal(session.taskId, 'task-xyz');
|
|
15
|
+
assert.equal(session.runtime, 'claude');
|
|
16
|
+
assert.equal(session.subagentMode, 'wf-subagents');
|
|
17
|
+
assert.equal(session.workflowMode, null);
|
|
18
|
+
assert.equal(session.status, 'starting');
|
|
19
|
+
assert.equal(session.cols, 120);
|
|
20
|
+
assert.equal(session.rows, 32);
|
|
21
|
+
assert.equal(session.pid, null);
|
|
22
|
+
assert.equal(session.exitCode, null);
|
|
23
|
+
assert.equal(session.wsClientCount, 0);
|
|
24
|
+
assert.equal(typeof session.startedAt, 'string');
|
|
25
|
+
assert.equal(typeof session.updatedAt, 'string');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('create can start an unbound terminal agent session', () => {
|
|
29
|
+
const reg = new SessionRegistry();
|
|
30
|
+
const session = reg.create({ runtime: 'opencode', cols: 100, rows: 30, model: 'sonnet', provider: 'anthropic' });
|
|
31
|
+
assert.ok(session.sessionId);
|
|
32
|
+
assert.equal(session.taskId, null);
|
|
33
|
+
assert.equal(session.runtime, 'opencode');
|
|
34
|
+
assert.equal(session.model, 'sonnet');
|
|
35
|
+
assert.equal(session.provider, 'anthropic');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('create can mark a wf CEO terminal session', () => {
|
|
39
|
+
const reg = new SessionRegistry();
|
|
40
|
+
const session = reg.create({
|
|
41
|
+
runtime: 'codex',
|
|
42
|
+
role: 'CEO',
|
|
43
|
+
workflowMode: 'wf',
|
|
44
|
+
ceoPrompt: 'run the workflow',
|
|
45
|
+
subagentMode: 'built-in-subagents',
|
|
46
|
+
});
|
|
47
|
+
assert.equal(session.role, 'CEO');
|
|
48
|
+
assert.equal(session.workflowMode, 'wf');
|
|
49
|
+
assert.equal(session.ceoPrompt, 'run the workflow');
|
|
50
|
+
assert.equal(session.subagentMode, 'built-in-subagents');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('create with invalid runtime fake-peer throws', () => {
|
|
54
|
+
const reg = new SessionRegistry();
|
|
55
|
+
assert.throws(() => reg.create({ taskId: 'task-xyz', runtime: 'fake-peer' }), {
|
|
56
|
+
name: 'Error',
|
|
57
|
+
message: /Invalid runtime.*fake-peer/,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('create with codex succeeds', () => {
|
|
62
|
+
const reg = new SessionRegistry();
|
|
63
|
+
const session = reg.create({ taskId: 'task-abc', runtime: 'codex', cols: 80, rows: 24, projectRoot: '/tmp' });
|
|
64
|
+
assert.equal(session.runtime, 'codex');
|
|
65
|
+
assert.ok(session.sessionId);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('create with opencode succeeds', () => {
|
|
69
|
+
const reg = new SessionRegistry();
|
|
70
|
+
const session = reg.create({ taskId: 'task-abc', runtime: 'opencode', cols: 80, rows: 24, projectRoot: '/tmp' });
|
|
71
|
+
assert.equal(session.runtime, 'opencode');
|
|
72
|
+
assert.ok(session.sessionId);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('create with cc succeeds', () => {
|
|
76
|
+
const reg = new SessionRegistry();
|
|
77
|
+
const session = reg.create({ taskId: 'task-abc', runtime: 'cc', cols: 80, rows: 24, projectRoot: '/tmp' });
|
|
78
|
+
assert.equal(session.runtime, 'cc');
|
|
79
|
+
assert.ok(session.sessionId);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('get returns correct session', () => {
|
|
83
|
+
const reg = new SessionRegistry();
|
|
84
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
85
|
+
const retrieved = reg.get(created.sessionId);
|
|
86
|
+
assert.deepEqual(retrieved, created);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('get returns undefined for unknown sessionId', () => {
|
|
90
|
+
const reg = new SessionRegistry();
|
|
91
|
+
assert.equal(reg.get('nonexistent'), undefined);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('getAll returns array', () => {
|
|
95
|
+
const reg = new SessionRegistry();
|
|
96
|
+
reg.create({ taskId: 'task-a', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
97
|
+
reg.create({ taskId: 'task-b', runtime: 'codex', cols: 80, rows: 24, projectRoot: '/tmp' });
|
|
98
|
+
const all = reg.getAll();
|
|
99
|
+
assert.equal(all.length, 2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('getAllForTask filters correctly', () => {
|
|
103
|
+
const reg = new SessionRegistry();
|
|
104
|
+
const s1 = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp/a' });
|
|
105
|
+
const s2 = reg.create({ taskId: 'task-xyz', runtime: 'codex', cols: 80, rows: 24, projectRoot: '/tmp/a' });
|
|
106
|
+
reg.create({ taskId: 'task-other', runtime: 'claude', cols: 100, rows: 40, projectRoot: '/tmp/b' });
|
|
107
|
+
|
|
108
|
+
const filtered = reg.getAllForTask('task-xyz');
|
|
109
|
+
assert.equal(filtered.length, 2);
|
|
110
|
+
assert.ok(filtered.some(s => s.sessionId === s1.sessionId));
|
|
111
|
+
assert.ok(filtered.some(s => s.sessionId === s2.sessionId));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('update merges status and cols/rows', () => {
|
|
115
|
+
const reg = new SessionRegistry();
|
|
116
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
117
|
+
|
|
118
|
+
reg.update(created.sessionId, { status: 'running', cols: 100, rows: 40 });
|
|
119
|
+
const updated = reg.get(created.sessionId);
|
|
120
|
+
|
|
121
|
+
assert.equal(updated.status, 'running');
|
|
122
|
+
assert.equal(updated.cols, 100);
|
|
123
|
+
assert.equal(updated.rows, 40);
|
|
124
|
+
assert.equal(updated.taskId, 'task-xyz'); // unchanged
|
|
125
|
+
assert.equal(updated.runtime, 'claude'); // unchanged
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('update merges pid and exitCode', () => {
|
|
129
|
+
const reg = new SessionRegistry();
|
|
130
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
131
|
+
|
|
132
|
+
reg.update(created.sessionId, { pid: 12345 });
|
|
133
|
+
assert.equal(reg.get(created.sessionId).pid, 12345);
|
|
134
|
+
|
|
135
|
+
reg.update(created.sessionId, { status: 'exited', exitCode: 0 });
|
|
136
|
+
assert.equal(reg.get(created.sessionId).status, 'exited');
|
|
137
|
+
assert.equal(reg.get(created.sessionId).exitCode, 0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('update merges wsClientCount', () => {
|
|
141
|
+
const reg = new SessionRegistry();
|
|
142
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
143
|
+
reg.update(created.sessionId, { wsClientCount: 3 });
|
|
144
|
+
assert.equal(reg.get(created.sessionId).wsClientCount, 3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('update sets updatedAt timestamp', () => {
|
|
148
|
+
const reg = new SessionRegistry();
|
|
149
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
150
|
+
const before = created.updatedAt;
|
|
151
|
+
|
|
152
|
+
// Small delay to ensure different timestamp
|
|
153
|
+
const updated = reg.get(created.sessionId);
|
|
154
|
+
reg.update(created.sessionId, { status: 'running' });
|
|
155
|
+
const after = reg.get(created.sessionId).updatedAt;
|
|
156
|
+
assert.ok(after >= before);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('count returns correct number', () => {
|
|
160
|
+
const reg = new SessionRegistry();
|
|
161
|
+
assert.equal(reg.count(), 0);
|
|
162
|
+
reg.create({ taskId: 'task-a', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
163
|
+
assert.equal(reg.count(), 1);
|
|
164
|
+
reg.create({ taskId: 'task-b', runtime: 'opencode', cols: 100, rows: 40, projectRoot: '/tmp' });
|
|
165
|
+
assert.equal(reg.count(), 2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('remove then get returns undefined', () => {
|
|
169
|
+
const reg = new SessionRegistry();
|
|
170
|
+
const created = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
171
|
+
assert.equal(reg.count(), 1);
|
|
172
|
+
|
|
173
|
+
reg.remove(created.sessionId);
|
|
174
|
+
assert.equal(reg.get(created.sessionId), undefined);
|
|
175
|
+
assert.equal(reg.count(), 0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('stop removes web session from live registry and returns saved state', () => {
|
|
179
|
+
const reg = new SessionRegistry();
|
|
180
|
+
const created = reg.create({ runtime: 'claude' });
|
|
181
|
+
const stopped = reg.stop(created.sessionId);
|
|
182
|
+
assert.equal(stopped.sessionId, created.sessionId);
|
|
183
|
+
assert.equal(stopped.status, 'saved');
|
|
184
|
+
assert.equal(reg.get(created.sessionId), undefined);
|
|
185
|
+
assert.equal(reg.count(), 0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('withLock serializes updates for the same session', async () => {
|
|
189
|
+
const reg = new SessionRegistry();
|
|
190
|
+
const created = reg.create({ runtime: 'claude' });
|
|
191
|
+
const events = [];
|
|
192
|
+
|
|
193
|
+
await Promise.all([
|
|
194
|
+
reg.withLock(created.sessionId, async () => {
|
|
195
|
+
events.push('a:start');
|
|
196
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
197
|
+
events.push('a:end');
|
|
198
|
+
}),
|
|
199
|
+
reg.withLock(created.sessionId, async () => {
|
|
200
|
+
events.push('b:start');
|
|
201
|
+
events.push('b:end');
|
|
202
|
+
}),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
assert.deepEqual(events, ['a:start', 'a:end', 'b:start', 'b:end']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('remove non-existent session does nothing', () => {
|
|
209
|
+
const reg = new SessionRegistry();
|
|
210
|
+
reg.remove('nonexistent');
|
|
211
|
+
assert.equal(reg.count(), 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('two concurrent sessions do not interfere', () => {
|
|
215
|
+
const reg = new SessionRegistry();
|
|
216
|
+
const s1 = reg.create({ taskId: 'task-a', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/proj-a' });
|
|
217
|
+
const s2 = reg.create({ taskId: 'task-b', runtime: 'codex', cols: 80, rows: 24, projectRoot: '/proj-b' });
|
|
218
|
+
|
|
219
|
+
assert.notEqual(s1.sessionId, s2.sessionId);
|
|
220
|
+
assert.equal(s1.taskId, 'task-a');
|
|
221
|
+
assert.equal(s2.taskId, 'task-b');
|
|
222
|
+
|
|
223
|
+
reg.update(s1.sessionId, { status: 'running' });
|
|
224
|
+
assert.equal(reg.get(s1.sessionId).status, 'running');
|
|
225
|
+
assert.equal(reg.get(s2.sessionId).status, 'starting'); // unchanged
|
|
226
|
+
|
|
227
|
+
reg.remove(s1.sessionId);
|
|
228
|
+
assert.equal(reg.get(s1.sessionId), undefined);
|
|
229
|
+
assert.equal(reg.get(s2.sessionId).taskId, 'task-b'); // s2 intact
|
|
230
|
+
assert.equal(reg.count(), 1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('create returns session with peerId derived from runtime', () => {
|
|
234
|
+
const reg = new SessionRegistry();
|
|
235
|
+
const session = reg.create({ taskId: 'task-xyz', runtime: 'claude', cols: 120, rows: 32, projectRoot: '/tmp' });
|
|
236
|
+
assert.ok(session.peerId);
|
|
237
|
+
assert.ok(session.peerId.startsWith('claude-'));
|
|
238
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import test, { describe, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { loadSettings, resolveSettings } from '../settings.mjs';
|
|
7
|
+
import { RUNTIME_DEFINITIONS } from '../runtime-detector.mjs';
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
server: { host: '127.0.0.1', port: 0 },
|
|
11
|
+
terminal: { enabled: false, attachMode: false, defaultRuntime: 'codex' },
|
|
12
|
+
peers: { allowlist: RUNTIME_DEFINITIONS.map(runtime => runtime.id) },
|
|
13
|
+
ui: { theme: 'auto', language: 'en', reducedMotion: false },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('settings', () => {
|
|
17
|
+
let baseDir;
|
|
18
|
+
|
|
19
|
+
before(() => {
|
|
20
|
+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'st-test-'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(() => {
|
|
24
|
+
fs.rmSync(baseDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('loadSettings returns defaults when no settings.json exists', () => {
|
|
28
|
+
const settings = loadSettings(baseDir);
|
|
29
|
+
assert.deepEqual(settings, DEFAULTS);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('project settings override defaults', () => {
|
|
33
|
+
const projectDir = path.join(baseDir, 'override-test');
|
|
34
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
35
|
+
fs.mkdirSync(path.join(projectDir, 'Harness'), { recursive: true });
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
path.join(projectDir, 'Harness', 'settings.json'),
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
server: { port: 3456 },
|
|
40
|
+
ui: { language: 'zh-CN' },
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const settings = loadSettings(projectDir);
|
|
45
|
+
|
|
46
|
+
assert.equal(settings.server.host, '127.0.0.1'); // from defaults
|
|
47
|
+
assert.equal(settings.server.port, 3456); // from project
|
|
48
|
+
assert.equal(settings.ui.language, 'zh-CN'); // from project
|
|
49
|
+
assert.equal(settings.ui.theme, 'auto'); // from defaults
|
|
50
|
+
assert.equal(settings.terminal.enabled, false); // from defaults
|
|
51
|
+
assert.deepEqual(settings.peers.allowlist, RUNTIME_DEFINITIONS.map(runtime => runtime.id)); // from defaults
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('unknown keys are silently ignored in project settings', () => {
|
|
55
|
+
const projectDir = path.join(baseDir, 'unknown-keys');
|
|
56
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
57
|
+
fs.mkdirSync(path.join(projectDir, 'Harness'), { recursive: true });
|
|
58
|
+
fs.writeFileSync(
|
|
59
|
+
path.join(projectDir, 'Harness', 'settings.json'),
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
unknownKey: 'should-be-ignored',
|
|
62
|
+
nestedUnknown: { inner: 'also-ignored' },
|
|
63
|
+
server: { host: '0.0.0.0' },
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const settings = loadSettings(projectDir);
|
|
68
|
+
|
|
69
|
+
assert.equal(settings.server.host, '0.0.0.0');
|
|
70
|
+
assert.equal(settings.server.port, 0);
|
|
71
|
+
// unknown keys should not appear
|
|
72
|
+
assert.equal(Object.hasOwn(settings, 'unknownKey'), false);
|
|
73
|
+
assert.equal(Object.hasOwn(settings, 'nestedUnknown'), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('missing required keys get default values', () => {
|
|
77
|
+
const projectDir = path.join(baseDir, 'missing-keys');
|
|
78
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
79
|
+
fs.mkdirSync(path.join(projectDir, 'Harness'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
path.join(projectDir, 'Harness', 'settings.json'),
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
server: { host: '0.0.0.0' },
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const settings = loadSettings(projectDir);
|
|
88
|
+
|
|
89
|
+
assert.equal(settings.server.host, '0.0.0.0');
|
|
90
|
+
assert.equal(settings.server.port, 0); // default, not provided
|
|
91
|
+
assert.equal(settings.terminal.enabled, false); // default, not provided
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('nested settings merge correctly (not full replace)', () => {
|
|
95
|
+
const projectDir = path.join(baseDir, 'nested-merge');
|
|
96
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
97
|
+
fs.mkdirSync(path.join(projectDir, 'Harness'), { recursive: true });
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
path.join(projectDir, 'Harness', 'settings.json'),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
server: { port: 8080 },
|
|
102
|
+
peers: { allowlist: ['claude'] },
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const settings = loadSettings(projectDir);
|
|
107
|
+
|
|
108
|
+
// server.host came from defaults, server.port from project
|
|
109
|
+
assert.equal(settings.server.host, '127.0.0.1');
|
|
110
|
+
assert.equal(settings.server.port, 8080);
|
|
111
|
+
// peers.allowlist replaced (not merged)
|
|
112
|
+
assert.deepEqual(settings.peers.allowlist, ['claude']);
|
|
113
|
+
// ui fully from defaults
|
|
114
|
+
assert.equal(settings.ui.theme, 'auto');
|
|
115
|
+
assert.equal(settings.ui.language, 'en');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('resolveSettings returns the merged result from loadSettings', () => {
|
|
119
|
+
const projectDir = path.join(baseDir, 'resolve-test');
|
|
120
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
121
|
+
fs.mkdirSync(path.join(projectDir, 'Harness'), { recursive: true });
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(projectDir, 'Harness', 'settings.json'),
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
terminal: { enabled: true },
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// resolveSettings takes a precedence object (project root overrides)
|
|
130
|
+
const settings = resolveSettings({ projectRoot: projectDir });
|
|
131
|
+
|
|
132
|
+
assert.equal(settings.terminal.enabled, true);
|
|
133
|
+
assert.equal(settings.server.host, '127.0.0.1');
|
|
134
|
+
});
|
|
135
|
+
});
|