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,200 @@
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 { parseArchivedTasks, parseTaskCapsule, parseTaskList } from '../task-parser.mjs';
7
+
8
+ describe('task-parser', () => {
9
+ let baseDir;
10
+
11
+ before(() => {
12
+ baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tp-test-'));
13
+
14
+ // task-alpha — fully populated, latest updatedAt
15
+ const dirA = path.join(baseDir, 'task-alpha');
16
+ fs.mkdirSync(dirA, { recursive: true });
17
+ fs.writeFileSync(path.join(dirA, 'STATE.json'), JSON.stringify({
18
+ schemaVersion: 1,
19
+ taskId: 'task-alpha',
20
+ status: 'active',
21
+ mode: 'wf',
22
+ tier: 'standard',
23
+ phase: 'wave-1-synthesis',
24
+ gate: 'E-GATE',
25
+ updatedAt: '2026-07-29T08:00:00.000Z',
26
+ activeQuestion: null,
27
+ nextAction: 'Proceed to dispatch',
28
+ acceptance: [{ id: 'AC-001', text: 'Task A acceptance', status: 'passed' }],
29
+ links: { dependsOn: ['task-beta'], blocks: [], related: [] }
30
+ }));
31
+ fs.writeFileSync(path.join(dirA, 'PLAN.md'), '# Plan A');
32
+ fs.writeFileSync(path.join(dirA, 'PROGRESS.md'), '# Progress A');
33
+
34
+ // task-beta — completed, no plan/progress files
35
+ const dirB = path.join(baseDir, 'task-beta');
36
+ fs.mkdirSync(dirB, { recursive: true });
37
+ fs.writeFileSync(path.join(dirB, 'STATE.json'), JSON.stringify({
38
+ schemaVersion: 1,
39
+ taskId: 'task-beta',
40
+ status: 'completed',
41
+ mode: 'direct',
42
+ tier: 'none',
43
+ phase: 'closed',
44
+ gate: null,
45
+ updatedAt: '2026-07-28T12:00:00.000Z',
46
+ activeQuestion: null,
47
+ nextAction: null,
48
+ acceptance: [],
49
+ links: { dependsOn: [], blocks: ['task-alpha'], related: [] }
50
+ }));
51
+
52
+ // task-gamma — earliest updatedAt, has active question
53
+ const dirC = path.join(baseDir, 'task-gamma');
54
+ fs.mkdirSync(dirC, { recursive: true });
55
+ fs.writeFileSync(path.join(dirC, 'STATE.json'), JSON.stringify({
56
+ schemaVersion: 2,
57
+ taskId: 'task-gamma',
58
+ status: 'pending',
59
+ mode: 'direct',
60
+ tier: 'none',
61
+ phase: 'intake',
62
+ gate: null,
63
+ updatedAt: '2026-07-27T10:00:00.000Z',
64
+ activeQuestion: 'What to build?',
65
+ nextAction: 'Define goal',
66
+ acceptance: [],
67
+ links: { dependsOn: [], blocks: [], related: ['task-alpha'] }
68
+ }));
69
+
70
+ // not-a-capsule — directory with no STATE.json
71
+ const dirNoState = path.join(baseDir, 'not-a-capsule');
72
+ fs.mkdirSync(dirNoState, { recursive: true });
73
+ fs.writeFileSync(path.join(dirNoState, 'some-file.txt'), 'hello');
74
+
75
+ // task-bad-json — malformed STATE.json
76
+ const dirBadJson = path.join(baseDir, 'task-bad-json');
77
+ fs.mkdirSync(dirBadJson, { recursive: true });
78
+ fs.writeFileSync(path.join(dirBadJson, 'STATE.json'), '{ invalid json }');
79
+
80
+ // task-no-schema — STATE.json missing schemaVersion
81
+ const dirNoSchema = path.join(baseDir, 'task-no-schema');
82
+ fs.mkdirSync(dirNoSchema, { recursive: true });
83
+ fs.writeFileSync(path.join(dirNoSchema, 'STATE.json'), JSON.stringify({
84
+ taskId: 'task-no-schema',
85
+ status: 'active',
86
+ phase: 'unknown'
87
+ }));
88
+ });
89
+
90
+ after(() => {
91
+ fs.rmSync(baseDir, { recursive: true, force: true });
92
+ });
93
+
94
+ test('parseTaskCapsule parses a valid task capsule directory with STATE.json', () => {
95
+ const result = parseTaskCapsule(path.join(baseDir, 'task-alpha'));
96
+
97
+ assert.notEqual(result, null);
98
+ assert.equal(result.taskId, 'task-alpha');
99
+ assert.equal(result.status, 'active');
100
+ assert.equal(result.phase, 'wave-1-synthesis');
101
+ assert.equal(result.gate, 'E-GATE');
102
+ assert.equal(result.updatedAt, '2026-07-29T08:00:00.000Z');
103
+ assert.equal(result.activeQuestion, null);
104
+ assert.ok(Array.isArray(result.acceptance));
105
+ assert.equal(result.acceptance.length, 1);
106
+ assert.equal(result.acceptance[0].id, 'AC-001');
107
+ assert.equal(result.acceptance[0].status, 'passed');
108
+ assert.deepEqual(result.dependsOn, ['task-beta']);
109
+ assert.deepEqual(result.blocks, []);
110
+ assert.equal(result.hasPlan, true);
111
+ assert.equal(result.hasProgress, true);
112
+ });
113
+
114
+ test('parseTaskCapsule returns null for directory without STATE.json', () => {
115
+ const result = parseTaskCapsule(path.join(baseDir, 'not-a-capsule'));
116
+ assert.equal(result, null);
117
+ });
118
+
119
+ test('parseTaskCapsule throws on malformed JSON in STATE.json', () => {
120
+ assert.throws(
121
+ () => parseTaskCapsule(path.join(baseDir, 'task-bad-json')),
122
+ /malformed|JSON|parse|SyntaxError/i
123
+ );
124
+ });
125
+
126
+ test('parseTaskCapsule throws on missing schemaVersion field', () => {
127
+ assert.throws(
128
+ () => parseTaskCapsule(path.join(baseDir, 'task-no-schema')),
129
+ /schemaVersion/i
130
+ );
131
+ });
132
+
133
+ test('parseTaskList returns capsules sorted by updatedAt desc', () => {
134
+ const results = parseTaskList(baseDir);
135
+
136
+ assert.ok(results.length > 1);
137
+ for (let i = 1; i < results.length; i++) {
138
+ const prev = new Date(results[i - 1].updatedAt).getTime();
139
+ const curr = new Date(results[i].updatedAt).getTime();
140
+ assert.ok(prev >= curr, `Expected ${results[i - 1].taskId} (${results[i - 1].updatedAt}) >= ${results[i].taskId} (${results[i].updatedAt})`);
141
+ }
142
+
143
+ // Strict order: task-alpha (T+08:00), task-beta (T-1d 12:00), task-gamma (T-2d 10:00)
144
+ assert.equal(results[0].taskId, 'task-alpha');
145
+ assert.equal(results[1].taskId, 'task-beta');
146
+ assert.equal(results[2].taskId, 'task-gamma');
147
+ });
148
+
149
+ test('parseTaskList skips non-capsule directories', () => {
150
+ const results = parseTaskList(baseDir);
151
+ const taskIds = results.map(r => r.taskId);
152
+
153
+ assert.ok(taskIds.includes('task-alpha'));
154
+ assert.ok(taskIds.includes('task-beta'));
155
+ assert.ok(taskIds.includes('task-gamma'));
156
+ assert.ok(!taskIds.includes('not-a-capsule'));
157
+ });
158
+
159
+ test('parseTaskList handles completed capsule without PLAN.md or PROGRESS.md', () => {
160
+ const results = parseTaskList(baseDir);
161
+ const beta = results.find(r => r.taskId === 'task-beta');
162
+
163
+ assert.ok(beta);
164
+ assert.equal(beta.status, 'completed');
165
+ assert.equal(beta.phase, 'closed');
166
+ assert.equal(beta.hasPlan, false);
167
+ assert.equal(beta.hasProgress, false);
168
+ });
169
+
170
+ test('parseTaskList includes capsule with activeQuestion as string', () => {
171
+ const results = parseTaskList(baseDir);
172
+ const gamma = results.find(r => r.taskId === 'task-gamma');
173
+
174
+ assert.ok(gamma);
175
+ assert.equal(gamma.activeQuestion, 'What to build?');
176
+ assert.equal(gamma.gate, null);
177
+ assert.deepEqual(gamma.dependsOn, []);
178
+ assert.deepEqual(gamma.blocks, []);
179
+ });
180
+
181
+ test('parseArchivedTasks reads YYYY/MM/DD archive layout', () => {
182
+ const archiveDir = path.join(baseDir, '_archive', '2026', '07', '30', 'task-archived-alpha');
183
+ fs.mkdirSync(archiveDir, { recursive: true });
184
+ fs.writeFileSync(path.join(archiveDir, 'STATE.json'), JSON.stringify({
185
+ schemaVersion: 1,
186
+ taskId: 'task-archived-alpha',
187
+ status: 'archived',
188
+ phase: 'archived',
189
+ updatedAt: '2026-07-30T10:00:00.000Z',
190
+ defaultRuntime: 'codex',
191
+ }));
192
+
193
+ const archived = parseArchivedTasks(baseDir);
194
+ const match = archived.find(task => task.taskId === 'task-archived-alpha');
195
+ assert.ok(match);
196
+ assert.equal(match.archivedYear, '2026');
197
+ assert.equal(match.archivedPath, '2026/07/30');
198
+ assert.equal(match.defaultRuntime, 'codex');
199
+ });
200
+ });
@@ -0,0 +1,138 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ appendSessionEvent,
8
+ appendTerminalData,
9
+ globTerminalEvents,
10
+ listTerminalSessions,
11
+ persistSession,
12
+ readTerminalRange,
13
+ recordInputRequest,
14
+ } from '../terminal-store.mjs';
15
+
16
+ function makeSession() {
17
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'terminal-store-'));
18
+ const session = {
19
+ taskId: 'task-alpha',
20
+ sessionId: 'session-abc',
21
+ runtime: 'codex',
22
+ status: 'running',
23
+ attachMode: false,
24
+ updatedAt: '2026-07-30T00:00:00.000Z',
25
+ };
26
+ return { projectRoot, session };
27
+ }
28
+
29
+ test('persistSession writes task-scoped STATE.json', () => {
30
+ const { projectRoot, session } = makeSession();
31
+ try {
32
+ persistSession(projectRoot, session);
33
+ const statePath = path.join(projectRoot, 'Harness', 'tasks', 'task-alpha', 'sessions', 'session-abc', 'STATE.json');
34
+ assert.ok(fs.existsSync(statePath));
35
+ assert.equal(JSON.parse(fs.readFileSync(statePath, 'utf8')).runtime, 'codex');
36
+ } finally {
37
+ fs.rmSync(projectRoot, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('persistSession writes unbound sessions under Harness/a2a/sessions', () => {
42
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'terminal-store-'));
43
+ const session = {
44
+ taskId: null,
45
+ sessionId: 'session-unbound',
46
+ runtime: 'opencode',
47
+ status: 'running',
48
+ attachMode: true,
49
+ updatedAt: '2026-07-30T00:00:00.000Z',
50
+ };
51
+ try {
52
+ persistSession(projectRoot, session);
53
+ appendTerminalData(projectRoot, session, 'hello-a2a');
54
+ const statePath = path.join(projectRoot, 'Harness', 'a2a', 'sessions', 'session-unbound', 'STATE.json');
55
+ assert.ok(fs.existsSync(statePath));
56
+ assert.equal(JSON.parse(fs.readFileSync(statePath, 'utf8')).taskId, null);
57
+ const range = readTerminalRange(projectRoot, { sessionId: 'session-unbound', tail: 1 });
58
+ assert.equal(range.taskId, null);
59
+ assert.equal(range.entries[0].data, 'hello-a2a');
60
+ } finally {
61
+ fs.rmSync(projectRoot, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test('appendTerminalData creates monotonic seq and bounded range reads', () => {
66
+ const { projectRoot, session } = makeSession();
67
+ try {
68
+ persistSession(projectRoot, session);
69
+ appendTerminalData(projectRoot, session, 'one');
70
+ appendTerminalData(projectRoot, session, 'two');
71
+ const range = readTerminalRange(projectRoot, { sessionId: 'session-abc', fromSeq: 2, tail: 10 });
72
+ assert.equal(range.entries.length, 1);
73
+ assert.equal(range.entries[0].seq, 2);
74
+ assert.equal(range.entries[0].data, 'two');
75
+ } finally {
76
+ fs.rmSync(projectRoot, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test('globTerminalEvents reads event and terminal files by glob', () => {
81
+ const { projectRoot, session } = makeSession();
82
+ try {
83
+ persistSession(projectRoot, session);
84
+ appendSessionEvent(projectRoot, session, { type: 'session.running' });
85
+ appendTerminalData(projectRoot, session, 'hello');
86
+ const rows = globTerminalEvents(projectRoot, { pattern: 'Harness/tasks/**/sessions/**/events.jsonl' });
87
+ assert.equal(rows.length, 1);
88
+ assert.equal(rows[0].type, 'session.running');
89
+ } finally {
90
+ fs.rmSync(projectRoot, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('globTerminalEvents reads Harness/a2a session events', () => {
95
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'terminal-store-'));
96
+ const session = {
97
+ taskId: null,
98
+ sessionId: 'session-a2a-glob',
99
+ runtime: 'claude',
100
+ status: 'running',
101
+ attachMode: true,
102
+ updatedAt: '2026-07-30T00:00:00.000Z',
103
+ };
104
+ try {
105
+ persistSession(projectRoot, session);
106
+ appendSessionEvent(projectRoot, session, { type: 'session.running' });
107
+ const rows = globTerminalEvents(projectRoot, { pattern: 'Harness/a2a/sessions/**/events.jsonl' });
108
+ assert.equal(rows.length, 1);
109
+ assert.equal(rows[0].taskId, null);
110
+ } finally {
111
+ fs.rmSync(projectRoot, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test('recordInputRequest is attach-gated', () => {
116
+ const { projectRoot, session } = makeSession();
117
+ try {
118
+ persistSession(projectRoot, session);
119
+ assert.throws(() => recordInputRequest(projectRoot, 'session-abc', 'hello'), /watch mode/);
120
+ persistSession(projectRoot, { ...session, attachMode: true });
121
+ const event = recordInputRequest(projectRoot, 'session-abc', 'hello');
122
+ assert.equal(event.type, 'terminal.input.requested');
123
+ } finally {
124
+ fs.rmSync(projectRoot, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('listTerminalSessions returns persisted sessions', () => {
129
+ const { projectRoot, session } = makeSession();
130
+ try {
131
+ persistSession(projectRoot, session);
132
+ const sessions = listTerminalSessions(projectRoot);
133
+ assert.equal(sessions.length, 1);
134
+ assert.equal(sessions[0].sessionId, 'session-abc');
135
+ } finally {
136
+ fs.rmSync(projectRoot, { recursive: true, force: true });
137
+ }
138
+ });
@@ -0,0 +1,48 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import crypto from 'node:crypto';
4
+ import { generateToken, validateToken } from '../token.mjs';
5
+
6
+ test('generateToken returns a string of at least 32 hex characters', () => {
7
+ const token = generateToken();
8
+ assert.equal(typeof token, 'string');
9
+ assert.ok(token.length >= 32);
10
+ assert.ok(/^[0-9a-f]+$/.test(token));
11
+ });
12
+
13
+ test('two consecutive generateToken calls produce different tokens', () => {
14
+ const a = generateToken();
15
+ const b = generateToken();
16
+ assert.notEqual(a, b);
17
+ });
18
+
19
+ test('validateToken returns true for a matching token', () => {
20
+ const token = generateToken();
21
+ assert.equal(validateToken(token, token), true);
22
+ });
23
+
24
+ test('validateToken returns false for a non-matching token', () => {
25
+ const token = generateToken();
26
+ assert.equal(validateToken(token, token + 'x'), false);
27
+ assert.equal(validateToken('x' + token, token), false);
28
+ });
29
+
30
+ test('validateToken returns false for empty expected token', () => {
31
+ const token = generateToken();
32
+ assert.equal(validateToken(token, ''), false);
33
+ assert.equal(validateToken(token, null), false);
34
+ assert.equal(validateToken(token, undefined), false);
35
+ });
36
+
37
+ test('validateToken is timing-safe: equal-length comparison produces no early exit on mismatch', () => {
38
+ // Verify that both same-length mismatches and length-mismatches return false.
39
+ // This confirms there is no length-based early return that leaks information.
40
+ const a = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
41
+ const b = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
42
+ const c = 'cccccccccccccccccccccccccccccccc';
43
+ assert.equal(validateToken(a, b), false);
44
+ assert.equal(validateToken(a, c), false);
45
+ assert.equal(validateToken(b, c), false);
46
+ // Same-length match still works
47
+ assert.equal(validateToken(a, a), true);
48
+ });