create-harness-vibe-coding 0.8.18 → 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.
Files changed (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -2
  4. package/src/index.js +383 -267
  5. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  6. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  7. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  8. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  9. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  10. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  11. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  12. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  13. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  14. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  15. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  16. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  17. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  18. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  19. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  20. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  21. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  22. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  23. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  24. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  25. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  26. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  27. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  28. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  29. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  30. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  31. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  32. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  33. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  34. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  35. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  36. package/src/ui/dist/index.html +18 -0
  37. package/src/ui/index.html +17 -0
  38. package/src/ui/package.json +33 -0
  39. package/src/ui/pnpm-lock.yaml +1582 -0
  40. package/src/ui/pnpm-workspace.yaml +2 -0
  41. package/src/ui/src/App.tsx +86 -0
  42. package/src/ui/src/api.ts +93 -0
  43. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  44. package/src/ui/src/components/Footer.tsx +69 -0
  45. package/src/ui/src/components/Header.tsx +175 -0
  46. package/src/ui/src/components/LoadingView.tsx +22 -0
  47. package/src/ui/src/components/RolesRoute.tsx +169 -0
  48. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  49. package/src/ui/src/components/TaskList.tsx +388 -0
  50. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  51. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  52. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  53. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  54. package/src/ui/src/index.css +281 -0
  55. package/src/ui/src/main.tsx +11 -0
  56. package/src/ui/src/types.ts +108 -0
  57. package/src/ui/tsconfig.json +21 -0
  58. package/src/ui/vite.config.ts +19 -0
  59. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  60. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  61. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  62. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  63. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  64. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  65. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  66. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  67. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  68. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  69. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  70. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  71. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  72. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  73. package/src/wf-ui-server/a2a-store.mjs +296 -0
  74. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  75. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  76. package/src/wf-ui-server/runtime-config.mjs +153 -0
  77. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  78. package/src/wf-ui-server/security.mjs +67 -0
  79. package/src/wf-ui-server/server.mjs +849 -0
  80. package/src/wf-ui-server/session-registry.mjs +204 -0
  81. package/src/wf-ui-server/settings.mjs +100 -0
  82. package/src/wf-ui-server/task-parser.mjs +133 -0
  83. package/src/wf-ui-server/terminal-store.mjs +225 -0
  84. package/src/wf-ui-server/token.mjs +41 -0
  85. package/src/wf-ui-server/ws-events.mjs +354 -0
  86. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  87. package/templates/common/.claude/commands/wf-help.md +1 -0
  88. package/templates/common/.claude/commands/wf-task-list.md +2 -0
  89. package/templates/common/.claude/commands/wf-ui.md +26 -0
  90. package/templates/common/.claude/commands/wf-update.md +54 -9
  91. package/templates/common/.claude/rules/ecc/common.md +1 -1
  92. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  93. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  94. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  95. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  96. package/templates/common/.harness-version +63 -39
  97. package/templates/common/.opencode/commands/wf-help.md +1 -0
  98. package/templates/common/.opencode/commands/wf-task-list.md +2 -0
  99. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  100. package/templates/common/.opencode/commands/wf-update.md +54 -9
  101. package/templates/common/CLAUDE.md +1 -1
  102. package/templates/common/Harness/MEMORY.md +2 -0
  103. package/templates/common/Harness/README.md +6 -4
  104. package/templates/common/Harness/a2a/role-graph.json +79 -0
  105. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  106. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  107. package/templates/common/Harness/ownership.manifest.json +57 -2
  108. package/templates/common/Harness/scripts/README.md +134 -0
  109. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  110. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  111. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  112. package/templates/common/Harness/scripts/task-state.mjs +556 -23
  113. package/templates/common/Harness/scripts/validate-harness.mjs +229 -19
  114. package/templates/common/Harness/scripts/wf-remove.mjs +8 -3
  115. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  116. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  117. package/templates/common/Harness/settings.json +35 -0
  118. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +7 -2
  119. package/templates/common/Harness/specs/runtime/command-surface.json +14 -0
  120. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  121. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  122. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  123. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  124. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  125. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  126. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  127. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  128. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,383 @@
1
+ import test, { before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import net from 'node:net';
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+
9
+ import { startServer, stopServer } from '../server.mjs';
10
+ import { attachTerminalWs } from '../ws-terminal.mjs';
11
+ import { SessionRegistry } from '../session-registry.mjs';
12
+
13
+ // ── Minimal WebSocket frame helpers ──
14
+
15
+ /**
16
+ * Parse a single unfragmented text frame from server->client data.
17
+ * Server frames are unmasked. Returns null if incomplete.
18
+ */
19
+ function tryParseFrame(buf) {
20
+ if (buf.length < 2) return null;
21
+ const opcode = buf[0] & 0x0f;
22
+ const masked = (buf[1] & 0x80) !== 0;
23
+ let payloadLen = buf[1] & 0x7f;
24
+ let offset = 2;
25
+
26
+ if (payloadLen === 126) {
27
+ if (buf.length < 4) return null;
28
+ payloadLen = buf.readUInt16BE(2);
29
+ offset = 4;
30
+ } else if (payloadLen === 127) {
31
+ if (buf.length < 10) return null;
32
+ payloadLen = Number(buf.readBigUInt64BE(2));
33
+ offset = 10;
34
+ }
35
+
36
+ // For testing we only handle unmasked server frames
37
+ if (masked) return null;
38
+ if (buf.length < offset + payloadLen) return null;
39
+
40
+ return {
41
+ opcode,
42
+ payload: buf.slice(offset, offset + payloadLen).toString('utf8'),
43
+ totalLen: offset + payloadLen,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Build a masked WebSocket text frame (client -> server).
49
+ */
50
+ function sendMaskedFrame(sock, text) {
51
+ const data = Buffer.from(text, 'utf8');
52
+ const maskKey = crypto.randomBytes(4);
53
+ const masked = Buffer.allocUnsafe(data.length);
54
+ for (let i = 0; i < data.length; i++) {
55
+ masked[i] = data[i] ^ maskKey[i % 4];
56
+ }
57
+ const len = data.length;
58
+ let header;
59
+ if (len < 126) {
60
+ header = Buffer.alloc(6);
61
+ header[0] = 0x81; // FIN + text
62
+ header[1] = 0x80 | len; // masked
63
+ maskKey.copy(header, 2);
64
+ sock.write(Buffer.concat([header, masked]));
65
+ } else if (len < 65536) {
66
+ header = Buffer.alloc(8);
67
+ header[0] = 0x81;
68
+ header[1] = 0x80 | 126;
69
+ header.writeUInt16BE(len, 2);
70
+ maskKey.copy(header, 4);
71
+ sock.write(Buffer.concat([header, masked]));
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Send a WebSocket close frame (client -> server) to gracefully close.
77
+ */
78
+ function sendWsClose(sock) {
79
+ const maskKey = crypto.randomBytes(4);
80
+ const payload = Buffer.alloc(2);
81
+ payload.writeUInt16BE(1000, 0);
82
+ const masked = Buffer.allocUnsafe(2);
83
+ for (let i = 0; i < 2; i++) {
84
+ masked[i] = payload[i] ^ maskKey[i % 4];
85
+ }
86
+ const header = Buffer.alloc(6);
87
+ header[0] = 0x88;
88
+ header[1] = 0x80 | 2;
89
+ maskKey.copy(header, 2);
90
+ sock.write(Buffer.concat([header, masked]));
91
+ }
92
+
93
+ /**
94
+ * Connect to /ws/terminal/:sessionId using raw TCP.
95
+ * Returns { sock, readMessage, close } for 101 upgrade,
96
+ * or { status, body } for non-101 responses.
97
+ */
98
+ async function wsConnectTerminal(port, sessionId, tokenValue) {
99
+ return new Promise((resolve, reject) => {
100
+ const sock = net.createConnection({ host: '127.0.0.1', port }, () => {
101
+ const wsKey = 'dGhlIHNhbXBsZSBub25jZQ==';
102
+ const q = tokenValue !== undefined && tokenValue !== null
103
+ ? 'token=' + encodeURIComponent(tokenValue) : '';
104
+ sock.write(
105
+ `GET /ws/terminal/${sessionId}?${q} HTTP/1.1\r\n` +
106
+ 'Host: 127.0.0.1\r\n' +
107
+ 'Upgrade: websocket\r\n' +
108
+ 'Connection: Upgrade\r\n' +
109
+ 'Sec-WebSocket-Key: ' + wsKey + '\r\n' +
110
+ 'Sec-WebSocket-Version: 13\r\n\r\n'
111
+ );
112
+ });
113
+
114
+ let resolved = false;
115
+ const resolveOnce = (v) => { if (!resolved) { resolved = true; resolve(v); } };
116
+
117
+ const timeout = setTimeout(() => {
118
+ sock.destroy();
119
+ resolveOnce({ status: 0, body: 'timeout' });
120
+ }, 10000);
121
+
122
+ let frameBuf = Buffer.alloc(0);
123
+ let httpHeaderDone = false;
124
+
125
+ sock.on('data', (chunk) => {
126
+ if (!httpHeaderDone) {
127
+ frameBuf = Buffer.concat([frameBuf, chunk]);
128
+ const headerEnd = frameBuf.indexOf('\r\n\r\n');
129
+ if (headerEnd === -1) return;
130
+
131
+ const header = frameBuf.slice(0, headerEnd).toString();
132
+ const rest = frameBuf.slice(headerEnd + 4);
133
+ const statusCode = parseInt(header.split(' ')[1] || '0', 10);
134
+
135
+ clearTimeout(timeout);
136
+
137
+ if (statusCode === 101) {
138
+ httpHeaderDone = true;
139
+ frameBuf = rest;
140
+
141
+ const conn = { sock, close() { sendWsClose(sock); } };
142
+
143
+ conn.readMessage = (ms = 10000) => new Promise((res, rej) => {
144
+ function consumeBuffer() {
145
+ const frame = tryParseFrame(frameBuf);
146
+ if (frame) {
147
+ if (frame.opcode === 0x8) {
148
+ sock.destroy();
149
+ return rej(new Error('Connection closed'));
150
+ }
151
+ if (frame.opcode === 0x1) {
152
+ frameBuf = frameBuf.slice(frame.totalLen);
153
+ try { res(JSON.parse(frame.payload)); } catch (e) { rej(e); }
154
+ return true;
155
+ }
156
+ frameBuf = frameBuf.slice(frame.totalLen);
157
+ return false;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ if (consumeBuffer()) return;
163
+
164
+ const tid = setTimeout(() => rej(new Error('timeout')), ms);
165
+ const onData = () => consumeBuffer() && cleanup();
166
+ const onClose = () => { cleanup(); rej(new Error('Connection closed')); };
167
+ const cleanup = () => {
168
+ clearTimeout(tid);
169
+ sock.off('data', onData);
170
+ sock.off('close', onClose);
171
+ };
172
+ sock.on('data', onData);
173
+ sock.on('close', onClose);
174
+ });
175
+
176
+ resolveOnce(conn);
177
+ } else {
178
+ sock.destroy();
179
+ resolveOnce({ status: statusCode, body: rest.toString() });
180
+ }
181
+ } else {
182
+ frameBuf = Buffer.concat([frameBuf, chunk]);
183
+ }
184
+ });
185
+
186
+ sock.on('close', () => {
187
+ clearTimeout(timeout);
188
+ resolveOnce({ status: 0, body: 'closed' });
189
+ });
190
+ sock.on('error', () => {});
191
+ });
192
+ }
193
+
194
+ // ── Fixture setup ──
195
+
196
+ let server;
197
+ let token;
198
+ let port;
199
+ let registry;
200
+ let wsHandle;
201
+ let tempRoot;
202
+ let sessionA;
203
+ let sessionB;
204
+
205
+ before(async () => {
206
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-ui-term-'));
207
+ const tasksDir = path.join(tempRoot, 'Harness', 'tasks');
208
+ fs.mkdirSync(tasksDir, { recursive: true });
209
+
210
+ const r = await startServer({ projectRoot: tempRoot, host: '127.0.0.1', port: 0 });
211
+ server = r.server;
212
+ token = r.token;
213
+ port = r.port;
214
+
215
+ registry = new SessionRegistry();
216
+ sessionA = registry.create({ taskId: 'task-a', runtime: 'claude', cols: 120, rows: 32 });
217
+ sessionB = registry.create({ taskId: 'task-b', runtime: 'codex', cols: 120, rows: 32 });
218
+
219
+ const origLog = console.log;
220
+ console.log = () => {};
221
+
222
+ wsHandle = attachTerminalWs(server, token, registry);
223
+
224
+ console.log = origLog;
225
+ });
226
+
227
+ after(async () => {
228
+ if (wsHandle) await wsHandle.close();
229
+ if (server) await stopServer(server);
230
+ if (tempRoot) {
231
+ try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch {}
232
+ }
233
+ });
234
+
235
+ // ── Tests ──
236
+
237
+ test('connects with valid token, receives session:state', async () => {
238
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, token);
239
+ assert.ok(conn.sock, 'Should establish WS connection');
240
+ assert.ok(conn.sock.writable, 'Socket should be writable');
241
+ assert.ok(typeof conn.readMessage === 'function');
242
+
243
+ const msg = await conn.readMessage(3000);
244
+ assert.equal(msg.type, 'session:state');
245
+ assert.equal(msg.sessionId, sessionA.sessionId);
246
+ assert.equal(msg.state, 'starting');
247
+
248
+ conn.close();
249
+ });
250
+
251
+ test('connection with invalid token gets rejected', async () => {
252
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, 'bad-token-1234567890abcdef');
253
+ assert.equal(conn.status, 401, 'Invalid token should get 401');
254
+ });
255
+
256
+ test('connection to nonexistent sessionId gets rejected', async () => {
257
+ const conn = await wsConnectTerminal(port, 'nonexistent-session-id', token);
258
+ assert.equal(conn.status, 404, 'Nonexistent session should get 404');
259
+ });
260
+
261
+ test('connection with no token gets rejected', async () => {
262
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, undefined);
263
+ assert.equal(conn.status, 401, 'No token should get 401');
264
+ });
265
+
266
+ test('output isolation between sessions', async () => {
267
+ const connA = await wsConnectTerminal(port, sessionA.sessionId, token);
268
+ const handshakeA = await connA.readMessage(3000);
269
+ assert.equal(handshakeA.type, 'session:state');
270
+
271
+ const connB = await wsConnectTerminal(port, sessionB.sessionId, token);
272
+ const handshakeB = await connB.readMessage(3000);
273
+ assert.equal(handshakeB.type, 'session:state');
274
+
275
+ // Broadcast only to session A
276
+ wsHandle.broadcastToSession(sessionA.sessionId, {
277
+ type: 'pty:data',
278
+ sessionId: sessionA.sessionId,
279
+ data: 'hello-A',
280
+ });
281
+
282
+ // Session B should NOT receive the message
283
+ await assert.rejects(
284
+ () => connB.readMessage(2000),
285
+ { message: 'timeout' },
286
+ 'Session B should not receive pty:data from session A',
287
+ );
288
+
289
+ // Session A should receive it
290
+ const msgA = await connA.readMessage(3000);
291
+ assert.equal(msgA.type, 'pty:data');
292
+ assert.equal(msgA.sessionId, sessionA.sessionId);
293
+
294
+ connA.close();
295
+ connB.close();
296
+ });
297
+
298
+ test('pty:resize updates session cols/rows in registry', async () => {
299
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, token);
300
+ await conn.readMessage(3000); // drain handshake
301
+
302
+ sendMaskedFrame(conn.sock, JSON.stringify({ type: 'pty:resize', cols: 100, rows: 40 }));
303
+
304
+ await new Promise(r => setTimeout(r, 200));
305
+
306
+ const updated = registry.get(sessionA.sessionId);
307
+ assert.equal(updated.cols, 100);
308
+ assert.equal(updated.rows, 40);
309
+
310
+ conn.close();
311
+ });
312
+
313
+ test('watch mode: pty:input returns error message, data NOT forwarded', async () => {
314
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, token);
315
+ await conn.readMessage(3000); // drain
316
+
317
+ sendMaskedFrame(conn.sock, JSON.stringify({ type: 'pty:input', data: 'echo hello' }));
318
+
319
+ const msg = await conn.readMessage(3000);
320
+ assert.equal(msg.type, 'session:error');
321
+ assert.ok(msg.message.includes('watch mode'), `message should mention watch mode, got: ${msg.message}`);
322
+
323
+ conn.close();
324
+ });
325
+
326
+ test('attach mode toggle: after session.attachMode = true, pty:input returns confirmation', async () => {
327
+ registry.update(sessionA.sessionId, { attachMode: true });
328
+
329
+ const conn = await wsConnectTerminal(port, sessionA.sessionId, token);
330
+ await conn.readMessage(3000); // drain
331
+
332
+ sendMaskedFrame(conn.sock, JSON.stringify({ type: 'pty:input', data: 'echo hello' }));
333
+
334
+ const msg = await conn.readMessage(3000);
335
+ // Either pty:data confirmation (if a PTY is attached) or session:error about no PTY (if not)
336
+ assert.ok(msg.type === 'pty:data' || (msg.type === 'session:error' && msg.message.includes('PTY')),
337
+ `Expected pty:data or session:error about PTY, got type=${msg.type} message=${msg.message}`);
338
+
339
+ conn.close();
340
+ });
341
+
342
+ test('pty:resize on session A does not affect session B cols/rows', async () => {
343
+ const connA = await wsConnectTerminal(port, sessionA.sessionId, token);
344
+ await connA.readMessage(3000);
345
+
346
+ const connB = await wsConnectTerminal(port, sessionB.sessionId, token);
347
+ await connB.readMessage(3000);
348
+
349
+ sendMaskedFrame(connA.sock, JSON.stringify({ type: 'pty:resize', cols: 80, rows: 24 }));
350
+
351
+ await new Promise(r => setTimeout(r, 200));
352
+
353
+ const updatedA = registry.get(sessionA.sessionId);
354
+ assert.equal(updatedA.cols, 80);
355
+ assert.equal(updatedA.rows, 24);
356
+
357
+ const updatedB = registry.get(sessionB.sessionId);
358
+ assert.equal(updatedB.cols, 120);
359
+ assert.equal(updatedB.rows, 32);
360
+
361
+ connA.close();
362
+ connB.close();
363
+ });
364
+
365
+ test('disconnect decrements wsClientCount', async () => {
366
+ // Use a session that has had no prior WS connections in this test suite
367
+ const freshSession = registry.create({ taskId: 'task-disconnect', runtime: 'claude', cols: 120, rows: 32 });
368
+
369
+ const initial = freshSession.wsClientCount;
370
+ assert.equal(initial, 0, 'fresh session should start with wsClientCount=0');
371
+
372
+ const conn = await wsConnectTerminal(port, freshSession.sessionId, token);
373
+ await conn.readMessage(3000);
374
+
375
+ const afterConnect = registry.get(freshSession.sessionId).wsClientCount;
376
+ assert.equal(afterConnect, 1, 'wsClientCount should be 1 after connect');
377
+
378
+ conn.close();
379
+ await new Promise(r => setTimeout(r, 500));
380
+
381
+ const afterClose = registry.get(freshSession.sessionId).wsClientCount;
382
+ assert.equal(afterClose, 0, 'wsClientCount should return to 0 after disconnect');
383
+ });
@@ -0,0 +1,296 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseTaskCapsule, parseTaskList } from './task-parser.mjs';
4
+ import { RUNTIME_DEFINITIONS } from './runtime-detector.mjs';
5
+ import { listTerminalSessions } from './terminal-store.mjs';
6
+
7
+ const DEFAULT_ROLE_GRAPH = {
8
+ schemaVersion: 1,
9
+ rootAgentId: 'ceo',
10
+ agents: [
11
+ {
12
+ agentId: 'ceo',
13
+ role: 'CEO Leader',
14
+ level: 0,
15
+ kind: 'controller',
16
+ skills: ['wf', 'subagent-orchestrator', 'terminal-control'],
17
+ permissions: ['plan', 'dispatch', 'read-terminals', 'send-terminal-input'],
18
+ },
19
+ {
20
+ agentId: 'product-manager',
21
+ role: 'Product Manager',
22
+ level: 1,
23
+ kind: 'planner',
24
+ skills: ['product-shape', 'acceptance'],
25
+ permissions: ['read-state', 'define-ac'],
26
+ },
27
+ {
28
+ agentId: 'architect',
29
+ role: 'Architect',
30
+ level: 1,
31
+ kind: 'architecture',
32
+ skills: ['architecture', 'contracts'],
33
+ permissions: ['read-code', 'define-contracts'],
34
+ },
35
+ {
36
+ agentId: 'backend-expert',
37
+ role: 'Backend Expert',
38
+ level: 1,
39
+ kind: 'implementer',
40
+ skills: ['runtime-detection', 'terminal-control', 'a2a-files'],
41
+ permissions: ['read-code', 'write-backend', 'terminal-io'],
42
+ },
43
+ {
44
+ agentId: 'frontend-expert',
45
+ role: 'Frontend Expert',
46
+ level: 1,
47
+ kind: 'implementer',
48
+ skills: ['ui-control-plane'],
49
+ permissions: ['read-code', 'write-ui'],
50
+ },
51
+ {
52
+ agentId: 'reviewer',
53
+ role: 'Reviewer',
54
+ level: 1,
55
+ kind: 'review',
56
+ skills: ['wf-review'],
57
+ permissions: ['read-code', 'read-evidence'],
58
+ },
59
+ {
60
+ agentId: 'verifier',
61
+ role: 'Verifier',
62
+ level: 1,
63
+ kind: 'validation',
64
+ skills: ['wf-browser', 'test-runner'],
65
+ permissions: ['run-tests', 'read-evidence'],
66
+ },
67
+ {
68
+ agentId: 'terminal-controller',
69
+ role: 'Terminal Controller',
70
+ level: 1,
71
+ kind: 'runtime',
72
+ skills: ['terminal-control'],
73
+ permissions: ['read-terminals', 'send-terminal-input'],
74
+ },
75
+ ],
76
+ edges: [
77
+ { from: 'ceo', to: 'product-manager', relation: 'defines' },
78
+ { from: 'ceo', to: 'architect', relation: 'routes' },
79
+ { from: 'ceo', to: 'backend-expert', relation: 'dispatches' },
80
+ { from: 'ceo', to: 'frontend-expert', relation: 'dispatches' },
81
+ { from: 'ceo', to: 'reviewer', relation: 'requests-review' },
82
+ { from: 'ceo', to: 'verifier', relation: 'requests-evidence' },
83
+ { from: 'ceo', to: 'terminal-controller', relation: 'controls' },
84
+ ],
85
+ };
86
+
87
+ const DEFAULT_TERMINAL_CONTROL_SKILL = {
88
+ schemaVersion: 1,
89
+ skillId: 'terminal-control',
90
+ name: 'Terminal Control',
91
+ source: 'Harness/a2a/skills/terminal-control.json',
92
+ description: 'Bounded A2A tools for listing terminal sessions, reading output ranges, globbing terminal events, and attach-gated input forwarding.',
93
+ tools: [
94
+ { name: 'list-sessions', reads: ['Harness/tasks/**/sessions/**/STATE.json', 'Harness/a2a/sessions/**/STATE.json'] },
95
+ { name: 'read-range', reads: ['Harness/tasks/**/sessions/**/terminal.jsonl', 'Harness/a2a/sessions/**/terminal.jsonl'], args: ['session', 'from', 'to', 'tail'] },
96
+ { name: 'glob-events', reads: ['Harness/tasks/**/sessions/**/events.jsonl', 'Harness/a2a/sessions/**/events.jsonl'], args: ['pattern', 'since', 'limit'] },
97
+ { name: 'send-input', writes: ['Harness/tasks/**/sessions/**/input-requests.jsonl', 'Harness/a2a/sessions/**/input-requests.jsonl'], args: ['session', 'text'] },
98
+ ],
99
+ defaultMode: 'watch',
100
+ inputPolicy: 'attach-mode-required',
101
+ };
102
+
103
+ const BUILT_IN_WORKFLOWS = [
104
+ {
105
+ id: 'wf-standard',
106
+ command: '/wf',
107
+ label: 'WF Standard',
108
+ description: 'CEO-led workflow with A2A terminal orchestration and acceptance gates.',
109
+ defaultCeoPrompt: 'Act as the Harness /wf CEO. Create a concise plan, choose terminal agents only when useful, coordinate A2A READ/WRITE/GLOB evidence, and keep task binding optional unless explicitly needed.',
110
+ },
111
+ {
112
+ id: 'wf-max',
113
+ command: '/wf-max',
114
+ label: 'WF Max',
115
+ description: 'Deeper CEO workflow for broader architecture and verification passes.',
116
+ defaultCeoPrompt: 'Act as the Harness /wf-max CEO. Expand exploration only where it improves decisions, then coordinate terminal agents through Harness A2A evidence.',
117
+ },
118
+ {
119
+ id: 'wf-browser',
120
+ command: '/wf-browser',
121
+ label: 'WF Browser',
122
+ description: 'Browser verification workflow with terminal agents and E2E evidence.',
123
+ defaultCeoPrompt: 'Act as the Harness /wf-browser CEO. Coordinate implementation terminals and browser verification, then report evidence.',
124
+ },
125
+ {
126
+ id: 'wf-review',
127
+ command: '/wf-review',
128
+ label: 'WF Review',
129
+ description: 'Independent review workflow for risks, regressions, and missing tests.',
130
+ defaultCeoPrompt: 'Act as the Harness /wf-review CEO. Inspect the current changes, ask terminal peers for independent findings when useful, and return prioritized review evidence.',
131
+ },
132
+ ];
133
+
134
+ function a2aRoot(projectRoot) {
135
+ return path.join(projectRoot, 'Harness', 'a2a');
136
+ }
137
+
138
+ function writeJsonIfMissing(filePath, data) {
139
+ if (fs.existsSync(filePath)) return;
140
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
141
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
142
+ }
143
+
144
+ function readJson(filePath, fallback) {
145
+ try {
146
+ if (!fs.existsSync(filePath)) return fallback;
147
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
148
+ } catch {
149
+ return fallback;
150
+ }
151
+ }
152
+
153
+ export function ensureA2aDefaults(projectRoot) {
154
+ const root = a2aRoot(projectRoot);
155
+ writeJsonIfMissing(path.join(root, 'role-graph.json'), DEFAULT_ROLE_GRAPH);
156
+ writeJsonIfMissing(path.join(root, 'runtime-registry.json'), {
157
+ schemaVersion: 1,
158
+ source: 'backend-runtime-detector',
159
+ runtimes: RUNTIME_DEFINITIONS.map((runtime) => runtime.id),
160
+ });
161
+ writeJsonIfMissing(path.join(root, 'workflows.json'), {
162
+ schemaVersion: 1,
163
+ workflows: BUILT_IN_WORKFLOWS,
164
+ });
165
+ writeJsonIfMissing(path.join(root, 'skills', 'terminal-control.json'), DEFAULT_TERMINAL_CONTROL_SKILL);
166
+ return root;
167
+ }
168
+
169
+ export function loadRoleGraph(projectRoot) {
170
+ ensureA2aDefaults(projectRoot);
171
+ return readJson(path.join(a2aRoot(projectRoot), 'role-graph.json'), DEFAULT_ROLE_GRAPH);
172
+ }
173
+
174
+ export function loadA2aSkills(projectRoot) {
175
+ ensureA2aDefaults(projectRoot);
176
+ const skillsRoot = path.join(a2aRoot(projectRoot), 'skills');
177
+ let entries = [];
178
+ try {
179
+ entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
180
+ } catch {
181
+ return [];
182
+ }
183
+ return entries
184
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
185
+ .map((entry) => readJson(path.join(skillsRoot, entry.name), null))
186
+ .filter(Boolean);
187
+ }
188
+
189
+ export function loadBuiltInWorkflows(projectRoot) {
190
+ ensureA2aDefaults(projectRoot);
191
+ const registry = readJson(path.join(a2aRoot(projectRoot), 'workflows.json'), { workflows: BUILT_IN_WORKFLOWS });
192
+ return Array.isArray(registry.workflows) ? registry.workflows : BUILT_IN_WORKFLOWS;
193
+ }
194
+
195
+ function readActiveTaskId(projectRoot) {
196
+ const progressPath = path.join(projectRoot, 'Harness', 'PROGRESS.md');
197
+ try {
198
+ const content = fs.readFileSync(progressPath, 'utf8');
199
+ const marker = content.match(/## Active Task\s+^- ([^\r\n]+)/m);
200
+ if (marker) return marker[1].trim();
201
+ } catch {
202
+ return null;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ function activeTask(projectRoot) {
208
+ const tasksRoot = path.join(projectRoot, 'Harness', 'tasks');
209
+ const activeTaskId = readActiveTaskId(projectRoot);
210
+ if (activeTaskId) {
211
+ const capsule = parseTaskCapsule(path.join(tasksRoot, activeTaskId));
212
+ if (capsule) return capsule;
213
+ }
214
+ return parseTaskList(tasksRoot)[0] || null;
215
+ }
216
+
217
+ export function buildWorkflowSnapshot(projectRoot, sessionRegistry) {
218
+ const roleGraph = loadRoleGraph(projectRoot);
219
+ const task = activeTask(projectRoot);
220
+ const memorySessions = sessionRegistry && typeof sessionRegistry.getAll === 'function'
221
+ ? sessionRegistry.getAll()
222
+ : [];
223
+ const diskSessions = listTerminalSessions(projectRoot);
224
+ const bySessionId = new Map();
225
+ for (const session of diskSessions) bySessionId.set(session.sessionId, session);
226
+ for (const session of memorySessions) {
227
+ bySessionId.set(session.sessionId, { ...bySessionId.get(session.sessionId), ...session });
228
+ }
229
+ const sessions = [...bySessionId.values()]
230
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
231
+
232
+ const roleNodes = roleGraph.agents.map((agent) => ({
233
+ id: agent.agentId,
234
+ label: agent.role,
235
+ kind: agent.kind,
236
+ level: agent.level,
237
+ status: agent.agentId === 'ceo' ? (task?.status || 'idle') : 'ready',
238
+ skills: agent.skills || [],
239
+ permissions: agent.permissions || [],
240
+ }));
241
+
242
+ const nodes = [...roleNodes];
243
+ const edges = [...roleGraph.edges];
244
+ for (const session of sessions) {
245
+ const nodeId = `session-${session.sessionId}`;
246
+ const isWorkflowCeo = session.workflowMode === 'wf' && String(session.role || '').toLowerCase().includes('ceo');
247
+ nodes.push({
248
+ id: nodeId,
249
+ label: isWorkflowCeo ? `${session.runtime} /wf CEO` : `${session.runtime} terminal`,
250
+ kind: 'terminal-session',
251
+ level: isWorkflowCeo ? 1 : 2,
252
+ status: session.status,
253
+ sessionId: session.sessionId,
254
+ taskId: session.taskId,
255
+ peerId: session.peerId,
256
+ runtime: session.runtime,
257
+ model: session.model,
258
+ provider: session.provider,
259
+ subagentMode: session.subagentMode,
260
+ workflowMode: session.workflowMode,
261
+ objective: session.objective,
262
+ });
263
+ edges.push({
264
+ from: isWorkflowCeo ? 'ceo' : 'terminal-controller',
265
+ to: nodeId,
266
+ relation: isWorkflowCeo ? 'runs-wf-terminal' : 'owns-session',
267
+ });
268
+ }
269
+
270
+ return {
271
+ schemaVersion: 1,
272
+ workflowId: task ? `workflow-${task.taskId}` : 'workflow-none',
273
+ taskId: task?.taskId || null,
274
+ mode: task?.mode || null,
275
+ phase: task?.phase || null,
276
+ gate: task?.gate || null,
277
+ rootAgentId: roleGraph.rootAgentId,
278
+ subagentModes: [
279
+ { id: 'built-in-subagents', label: 'Built-in Subagents' },
280
+ { id: 'wf-subagents', label: 'WF Subagents' },
281
+ ],
282
+ availableWorkflows: loadBuiltInWorkflows(projectRoot),
283
+ roles: {
284
+ nodes: roleNodes,
285
+ edges: roleGraph.edges,
286
+ },
287
+ queues: task ? {
288
+ dependsOn: task.dependsOn,
289
+ blocks: task.blocks,
290
+ acceptance: task.acceptance,
291
+ } : null,
292
+ nodes,
293
+ edges,
294
+ sessions,
295
+ };
296
+ }