@logicseek/hello-cc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +462 -0
- package/README.zh-CN.md +409 -0
- package/bin/hcc.mjs +6355 -0
- package/docs/design.md +263 -0
- package/docs/implementation.md +345 -0
- package/lib/discover.mjs +477 -0
- package/lib/setup.mjs +766 -0
- package/package.json +54 -0
- package/scripts/regression.mjs +1221 -0
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
13
|
+
const hccBin = path.join(repoRoot, 'bin', 'hcc.mjs');
|
|
14
|
+
const testId = `${process.pid}-${Date.now()}`;
|
|
15
|
+
const root = path.join(os.tmpdir(), `hcc-reg-root-${testId}`);
|
|
16
|
+
const home = path.join(os.tmpdir(), `hcc-reg-home-${testId}`);
|
|
17
|
+
const fakeBin = path.join(os.tmpdir(), `hcc-reg-bin-${testId}`);
|
|
18
|
+
const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
|
|
19
|
+
const tmuxSession = `hcc-reg-${process.pid}`;
|
|
20
|
+
const port = 22000 + (process.pid % 10000);
|
|
21
|
+
|
|
22
|
+
let tmuxStarted = false;
|
|
23
|
+
let runtimePid = null;
|
|
24
|
+
const managedTmuxSessions = new Set();
|
|
25
|
+
|
|
26
|
+
const env = {
|
|
27
|
+
...process.env,
|
|
28
|
+
HOME: home,
|
|
29
|
+
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`
|
|
30
|
+
};
|
|
31
|
+
for (const key of Object.keys(env)) {
|
|
32
|
+
if (key.startsWith('HCC_')) delete env[key];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function log(message) {
|
|
36
|
+
process.stdout.write(`${message}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fail(message) {
|
|
40
|
+
throw new Error(message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function commandText(command, args) {
|
|
44
|
+
return [command, ...args].join(' ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sh(value) {
|
|
48
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(command, args, options = {}) {
|
|
52
|
+
const result = spawnSync(command, args, {
|
|
53
|
+
cwd: options.cwd || repoRoot,
|
|
54
|
+
env: options.env || env,
|
|
55
|
+
input: options.input,
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
stdio: options.stdio || [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
58
|
+
});
|
|
59
|
+
if (result.status !== 0) {
|
|
60
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
61
|
+
fail(`${commandText(command, args)} failed${output ? `\n${output}` : ''}`);
|
|
62
|
+
}
|
|
63
|
+
return result.stdout || '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runMaybe(command, args, options = {}) {
|
|
67
|
+
return spawnSync(command, args, {
|
|
68
|
+
cwd: options.cwd || repoRoot,
|
|
69
|
+
env: options.env || env,
|
|
70
|
+
input: options.input,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
stdio: options.stdio || [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hcc(args, options = {}) {
|
|
77
|
+
return run(process.execPath, [hccBin, '--root', root, ...args], options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hccJson(args, options = {}) {
|
|
81
|
+
const output = hcc(['--json', ...args], options);
|
|
82
|
+
const parsed = JSON.parse(output);
|
|
83
|
+
if (!parsed.ok) fail(`hcc json command failed: ${output}`);
|
|
84
|
+
return parsed.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hccMaybe(args, options = {}) {
|
|
88
|
+
return runMaybe(process.execPath, [hccBin, '--root', root, ...args], options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hccFrom(args, cwd, options = {}) {
|
|
92
|
+
return run(process.execPath, [hccBin, ...args], { ...options, cwd });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hccFromMaybe(args, cwd, options = {}) {
|
|
96
|
+
return runMaybe(process.execPath, [hccBin, ...args], { ...options, cwd });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withMeshDb(fn) {
|
|
100
|
+
const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'));
|
|
101
|
+
try {
|
|
102
|
+
return fn(db);
|
|
103
|
+
} finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function providerBindingRows(provider, sessionName) {
|
|
109
|
+
return withMeshDb((db) => db.prepare(`
|
|
110
|
+
SELECT peer, provider, provider_session_id, provider_session_name, transport, runtime_target
|
|
111
|
+
FROM peer_bindings
|
|
112
|
+
WHERE provider = ? AND provider_session_name = ?
|
|
113
|
+
ORDER BY peer
|
|
114
|
+
`).all(provider, sessionName));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function peerBindingIndexNames(dbPath = path.join(root, '.hello-cc', 'mesh.db')) {
|
|
118
|
+
const db = new DatabaseSync(dbPath);
|
|
119
|
+
try {
|
|
120
|
+
return new Set(db.prepare(`
|
|
121
|
+
SELECT name
|
|
122
|
+
FROM sqlite_master
|
|
123
|
+
WHERE type = 'index' AND tbl_name = 'peer_bindings'
|
|
124
|
+
`).all().map((row) => row.name));
|
|
125
|
+
} finally {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertPeerBindingIndexes(dbPath) {
|
|
131
|
+
const indexes = peerBindingIndexNames(dbPath);
|
|
132
|
+
for (const name of [
|
|
133
|
+
'idx_peer_bindings_provider_session_id_unique',
|
|
134
|
+
'idx_peer_bindings_provider_session_name_unique',
|
|
135
|
+
'idx_peer_bindings_runtime_target_unique'
|
|
136
|
+
]) {
|
|
137
|
+
if (!indexes.has(name)) fail(`missing peer binding uniqueness index: ${name}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function insertStaleProviderBinding(peer, provider, sessionName) {
|
|
142
|
+
withMeshDb((db) => {
|
|
143
|
+
const t = Math.floor(Date.now() / 1000) - 3600;
|
|
144
|
+
db.prepare(`
|
|
145
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
146
|
+
VALUES (?, ?, 'peer', ?, '', NULL, 'idle', 'regression-stale', ?, ?)
|
|
147
|
+
ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at
|
|
148
|
+
`).run(peer, provider, root, t, t);
|
|
149
|
+
db.prepare(`
|
|
150
|
+
INSERT INTO peer_bindings(
|
|
151
|
+
peer, provider, provider_session_id, provider_session_name, resume_mode,
|
|
152
|
+
resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
|
|
153
|
+
)
|
|
154
|
+
VALUES (?, ?, NULL, ?, 'detected', NULL, NULL, 'detected', ?, NULL, ?, ?)
|
|
155
|
+
ON CONFLICT(peer) DO UPDATE SET
|
|
156
|
+
provider = excluded.provider,
|
|
157
|
+
provider_session_name = excluded.provider_session_name,
|
|
158
|
+
transport = excluded.transport,
|
|
159
|
+
runtime_target = excluded.runtime_target,
|
|
160
|
+
updated_at = excluded.updated_at
|
|
161
|
+
`).run(peer, provider, sessionName, peer, t, t);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget) {
|
|
166
|
+
withMeshDb((db) => {
|
|
167
|
+
const t = Math.floor(Date.now() / 1000) - 3600;
|
|
168
|
+
db.prepare(`
|
|
169
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
170
|
+
VALUES (?, ?, 'peer', ?, '', NULL, 'idle', 'regression-runtime', ?, ?)
|
|
171
|
+
ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at
|
|
172
|
+
`).run(peer, provider, root, t, t);
|
|
173
|
+
db.prepare(`
|
|
174
|
+
INSERT INTO peer_bindings(
|
|
175
|
+
peer, provider, provider_session_id, provider_session_name, resume_mode,
|
|
176
|
+
resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
|
|
177
|
+
)
|
|
178
|
+
VALUES (?, ?, NULL, ?, 'resume', ?, ?, 'tmux', ?, ?, ?, ?)
|
|
179
|
+
`).run(peer, provider, sessionName, sessionName, `${provider} resume ${sessionName}`, peer, runtimeTarget, t, t);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertSqliteUniqueFailure(label, fn) {
|
|
184
|
+
try {
|
|
185
|
+
fn();
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (String(err?.message || err).includes('UNIQUE constraint failed')) return;
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
fail(`${label} did not fail with a SQLite UNIQUE constraint`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function cleanupBindingPeers(prefix) {
|
|
194
|
+
withMeshDb((db) => {
|
|
195
|
+
db.prepare('DELETE FROM peer_bindings WHERE peer LIKE ?').run(`${prefix}%`);
|
|
196
|
+
db.prepare('DELETE FROM peers WHERE id LIKE ?').run(`${prefix}%`);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertPeerBindingUniqueConstraints() {
|
|
201
|
+
assertPeerBindingIndexes();
|
|
202
|
+
const providerPrefix = 'unique-provider-';
|
|
203
|
+
const runtimePrefix = 'unique-runtime-';
|
|
204
|
+
cleanupBindingPeers(providerPrefix);
|
|
205
|
+
cleanupBindingPeers(runtimePrefix);
|
|
206
|
+
|
|
207
|
+
insertStaleProviderBinding(`${providerPrefix}a`, 'codex', 'unique-provider-session');
|
|
208
|
+
assertSqliteUniqueFailure('duplicate provider session binding', () => {
|
|
209
|
+
insertStaleProviderBinding(`${providerPrefix}b`, 'codex', 'unique-provider-session');
|
|
210
|
+
});
|
|
211
|
+
const providerRows = providerBindingRows('codex', 'unique-provider-session');
|
|
212
|
+
if (providerRows.length !== 1 || providerRows[0].peer !== `${providerPrefix}a`) {
|
|
213
|
+
fail(`provider session unique constraint left unexpected rows:\n${JSON.stringify(providerRows, null, 2)}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
insertRuntimeTargetBinding(`${runtimePrefix}a`, 'claude', 'unique-runtime-session-a', '%unique-runtime-target');
|
|
217
|
+
assertSqliteUniqueFailure('duplicate runtime target binding', () => {
|
|
218
|
+
insertRuntimeTargetBinding(`${runtimePrefix}b`, 'codex', 'unique-runtime-session-b', '%unique-runtime-target');
|
|
219
|
+
});
|
|
220
|
+
const runtimeRows = withMeshDb((db) => db.prepare(`
|
|
221
|
+
SELECT peer, provider, provider_session_name, transport, runtime_target
|
|
222
|
+
FROM peer_bindings
|
|
223
|
+
WHERE runtime_target = '%unique-runtime-target'
|
|
224
|
+
ORDER BY peer
|
|
225
|
+
`).all());
|
|
226
|
+
if (runtimeRows.length !== 1 || runtimeRows[0].peer !== `${runtimePrefix}a`) {
|
|
227
|
+
fail(`runtime target unique constraint left unexpected rows:\n${JSON.stringify(runtimeRows, null, 2)}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
cleanupBindingPeers(providerPrefix);
|
|
231
|
+
cleanupBindingPeers(runtimePrefix);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createLegacyBindingDb(dbPath) {
|
|
235
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
236
|
+
const db = new DatabaseSync(dbPath);
|
|
237
|
+
try {
|
|
238
|
+
const oldTime = Math.floor(Date.now() / 1000) - 3600;
|
|
239
|
+
const newTime = oldTime + 60;
|
|
240
|
+
db.exec(`
|
|
241
|
+
CREATE TABLE peer_bindings (
|
|
242
|
+
peer TEXT PRIMARY KEY,
|
|
243
|
+
provider TEXT NOT NULL,
|
|
244
|
+
provider_session_id TEXT,
|
|
245
|
+
provider_session_name TEXT,
|
|
246
|
+
resume_mode TEXT NOT NULL DEFAULT 'new',
|
|
247
|
+
resume_arg TEXT,
|
|
248
|
+
command TEXT,
|
|
249
|
+
transport TEXT NOT NULL,
|
|
250
|
+
runtime_session_id TEXT,
|
|
251
|
+
runtime_target TEXT,
|
|
252
|
+
created_at INTEGER NOT NULL,
|
|
253
|
+
updated_at INTEGER NOT NULL
|
|
254
|
+
);
|
|
255
|
+
`);
|
|
256
|
+
const insert = db.prepare(`
|
|
257
|
+
INSERT INTO peer_bindings(
|
|
258
|
+
peer, provider, provider_session_id, provider_session_name, resume_mode,
|
|
259
|
+
resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
|
|
260
|
+
)
|
|
261
|
+
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
262
|
+
`);
|
|
263
|
+
insert.run(
|
|
264
|
+
'legacy-provider-detected', 'claude', 'legacy-provider-session', 'detected',
|
|
265
|
+
null, null, 'detected', 'legacy-provider-detected', null, oldTime, oldTime
|
|
266
|
+
);
|
|
267
|
+
insert.run(
|
|
268
|
+
'legacy-provider-live', 'claude', 'legacy-provider-session', 'resume',
|
|
269
|
+
'legacy-provider-session', 'claude --resume legacy-provider-session', 'tmux',
|
|
270
|
+
'legacy-provider-live', '%legacy-provider-pane', oldTime, newTime
|
|
271
|
+
);
|
|
272
|
+
insert.run(
|
|
273
|
+
'legacy-runtime-old', 'codex', 'legacy-runtime-old-session', 'resume',
|
|
274
|
+
'legacy-runtime-old-session', 'codex resume legacy-runtime-old-session', 'tmux',
|
|
275
|
+
'legacy-runtime-old', '%legacy-runtime-pane', oldTime, oldTime
|
|
276
|
+
);
|
|
277
|
+
insert.run(
|
|
278
|
+
'legacy-runtime-new', 'shell', 'legacy-runtime-new-session', 'command',
|
|
279
|
+
null, 'bash', 'tmux', 'legacy-runtime-new', '%legacy-runtime-pane', oldTime, newTime
|
|
280
|
+
);
|
|
281
|
+
} finally {
|
|
282
|
+
db.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function assertLegacyBindingRepair() {
|
|
287
|
+
const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-root-${testId}`);
|
|
288
|
+
const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
|
|
289
|
+
try {
|
|
290
|
+
createLegacyBindingDb(legacyDb);
|
|
291
|
+
run(process.execPath, [hccBin, '--root', legacyRoot, 'status', '--peer', 'legacy-check'], { env });
|
|
292
|
+
assertPeerBindingIndexes(legacyDb);
|
|
293
|
+
const db = new DatabaseSync(legacyDb);
|
|
294
|
+
try {
|
|
295
|
+
const providerRows = db.prepare(`
|
|
296
|
+
SELECT peer, transport, runtime_target
|
|
297
|
+
FROM peer_bindings
|
|
298
|
+
WHERE provider = 'claude' AND provider_session_name = 'legacy-provider-session'
|
|
299
|
+
ORDER BY peer
|
|
300
|
+
`).all();
|
|
301
|
+
if (providerRows.length !== 1 || providerRows[0].peer !== 'legacy-provider-live') {
|
|
302
|
+
fail(`legacy provider duplicate was not repaired:\n${JSON.stringify(providerRows, null, 2)}`);
|
|
303
|
+
}
|
|
304
|
+
const runtimeRows = db.prepare(`
|
|
305
|
+
SELECT peer, transport, runtime_target
|
|
306
|
+
FROM peer_bindings
|
|
307
|
+
WHERE runtime_target = '%legacy-runtime-pane'
|
|
308
|
+
ORDER BY peer
|
|
309
|
+
`).all();
|
|
310
|
+
if (runtimeRows.length !== 1 || runtimeRows[0].peer !== 'legacy-runtime-new') {
|
|
311
|
+
fail(`legacy runtime duplicate was not repaired:\n${JSON.stringify(runtimeRows, null, 2)}`);
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
db.close();
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
try { fs.rmSync(legacyRoot, { recursive: true, force: true }); } catch {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function tmuxAvailable() {
|
|
322
|
+
return runMaybe('tmux', ['-V']).status === 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function trackTmuxPane(pane) {
|
|
326
|
+
if (!pane) return;
|
|
327
|
+
const result = runMaybe('tmux', ['display-message', '-p', '-t', pane, '#S']);
|
|
328
|
+
if (result.status === 0) managedTmuxSessions.add(result.stdout.trim());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function parsePane(output) {
|
|
332
|
+
const match = String(output).match(/\bpane=(%\d+)/);
|
|
333
|
+
if (!match) fail(`cannot parse tmux pane from:\n${output}`);
|
|
334
|
+
trackTmuxPane(match[1]);
|
|
335
|
+
return match[1];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function envWithoutPeer(extra = {}) {
|
|
339
|
+
const next = { ...env, ...extra };
|
|
340
|
+
delete next.HCC_PEER;
|
|
341
|
+
delete next.HCC_ROOT;
|
|
342
|
+
delete next.HCC_DB;
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function envWithoutProvider(extra = {}) {
|
|
347
|
+
const next = envWithoutPeer();
|
|
348
|
+
for (const key of Object.keys(next)) {
|
|
349
|
+
if (key.startsWith('ANTHROPIC_') || key.startsWith('CLAUDE_CODE_')) delete next[key];
|
|
350
|
+
}
|
|
351
|
+
return { ...next, ...extra };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function envAsCodex(extra = {}) {
|
|
355
|
+
const next = envWithoutPeer({
|
|
356
|
+
CODEX_MANAGED_BY_NPM: '1',
|
|
357
|
+
CODEX_THREAD_ID: `codex-thread-${testId}`,
|
|
358
|
+
...extra
|
|
359
|
+
});
|
|
360
|
+
delete next.CLAUDE_CODE_SESSION_ID;
|
|
361
|
+
delete next.CLAUDECODE;
|
|
362
|
+
return next;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function parseSentPeer(output) {
|
|
366
|
+
const match = String(output).match(/^sent message #\d+ (.+) -> /m);
|
|
367
|
+
if (!match) fail(`cannot parse sender from: ${output}`);
|
|
368
|
+
return match[1];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function hasTask(rows, taskId) {
|
|
372
|
+
return rows.some((row) => String(row.id) === String(taskId));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function hasInstalledHook(config, event) {
|
|
376
|
+
const entries = config?.hooks?.[event];
|
|
377
|
+
if (!Array.isArray(entries)) return false;
|
|
378
|
+
return entries.some((entry) =>
|
|
379
|
+
Array.isArray(entry?.hooks) &&
|
|
380
|
+
entry.hooks.some((hook) => /\bhcc\.mjs\b.*\bhook\b/.test(String(hook?.command || '')))
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function hookContext(output, expectedEvent) {
|
|
385
|
+
let parsed;
|
|
386
|
+
try {
|
|
387
|
+
parsed = JSON.parse(output);
|
|
388
|
+
} catch {
|
|
389
|
+
fail(`hook output is not JSON:\n${output}`);
|
|
390
|
+
}
|
|
391
|
+
const specific = parsed?.hookSpecificOutput;
|
|
392
|
+
if (specific?.hookEventName !== expectedEvent) {
|
|
393
|
+
fail(`hookEventName mismatch for ${expectedEvent}:\n${output}`);
|
|
394
|
+
}
|
|
395
|
+
return String(specific.additionalContext || '');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function ensureFile(file, expected = null) {
|
|
399
|
+
if (!fs.existsSync(file)) fail(`missing file: ${file}`);
|
|
400
|
+
if (expected !== null) {
|
|
401
|
+
const actual = fs.readFileSync(file, 'utf8').trim();
|
|
402
|
+
if (actual !== expected) fail(`unexpected content in ${file}: ${actual}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function waitFor(check, label, timeoutMs = 10000) {
|
|
407
|
+
const deadline = Date.now() + timeoutMs;
|
|
408
|
+
while (Date.now() < deadline) {
|
|
409
|
+
if (await check()) return;
|
|
410
|
+
await sleep(100);
|
|
411
|
+
}
|
|
412
|
+
fail(`timed out waiting for ${label}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function waitForFile(file, expected, label = file) {
|
|
416
|
+
await waitFor(() => fs.existsSync(file), label);
|
|
417
|
+
ensureFile(file, expected);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function waitForFileContent(file, expected, label = file) {
|
|
421
|
+
await waitFor(() => {
|
|
422
|
+
if (!fs.existsSync(file)) return false;
|
|
423
|
+
return fs.readFileSync(file, 'utf8').trim() === expected;
|
|
424
|
+
}, label);
|
|
425
|
+
ensureFile(file, expected);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function waitForFileLineCount(file, expected, label = file) {
|
|
429
|
+
await waitFor(() => {
|
|
430
|
+
if (!fs.existsSync(file)) return false;
|
|
431
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
432
|
+
return lines.length === expected;
|
|
433
|
+
}, label);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function waitRuntime() {
|
|
437
|
+
const runtimeFile = path.join(root, '.hello-cc', 'runtime.json');
|
|
438
|
+
await waitFor(async () => {
|
|
439
|
+
if (!fs.existsSync(runtimeFile)) return false;
|
|
440
|
+
try {
|
|
441
|
+
const runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
|
|
442
|
+
const response = await fetch(`${runtime.base_url}/api/runtime`);
|
|
443
|
+
return response.ok;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}, 'runtime');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function expectWebSocketMarker(peer, marker) {
|
|
451
|
+
await new Promise((resolve, reject) => {
|
|
452
|
+
let sawSnapshot = false;
|
|
453
|
+
let sawMarker = false;
|
|
454
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/terminal/${encodeURIComponent(peer)}`);
|
|
455
|
+
const timer = setTimeout(() => reject(new Error(`${peer} websocket timeout`)), 5000);
|
|
456
|
+
ws.on('open', () => {
|
|
457
|
+
const result = hccMaybe(['inject', peer, `echo ${marker}`]);
|
|
458
|
+
if (result.status !== 0) reject(new Error(result.stderr || result.stdout || 'inject failed'));
|
|
459
|
+
});
|
|
460
|
+
ws.on('message', (raw) => {
|
|
461
|
+
const msg = JSON.parse(String(raw));
|
|
462
|
+
if (msg.type === 'snapshot') sawSnapshot = true;
|
|
463
|
+
if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
|
|
464
|
+
sawMarker = true;
|
|
465
|
+
}
|
|
466
|
+
if (sawSnapshot && sawMarker) {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
ws.close();
|
|
469
|
+
resolve();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
ws.on('error', reject);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function tmuxStreamNodes() {
|
|
477
|
+
const dir = path.join(root, '.hello-cc', 'bufs');
|
|
478
|
+
if (!fs.existsSync(dir)) return [];
|
|
479
|
+
return fs.readdirSync(dir)
|
|
480
|
+
.filter((name) => name.startsWith('tmux-') && name.endsWith('.pipe'))
|
|
481
|
+
.map((name) => path.join(dir, name));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function expectBoundedTmuxStream(label) {
|
|
485
|
+
let nodes = [];
|
|
486
|
+
await waitFor(() => {
|
|
487
|
+
nodes = tmuxStreamNodes();
|
|
488
|
+
return nodes.some((file) => {
|
|
489
|
+
try { return fs.lstatSync(file).isFIFO(); } catch { return false; }
|
|
490
|
+
});
|
|
491
|
+
}, label);
|
|
492
|
+
const bad = nodes.filter((file) => {
|
|
493
|
+
try {
|
|
494
|
+
const stat = fs.lstatSync(file);
|
|
495
|
+
return !stat.isFIFO() || stat.size !== 0;
|
|
496
|
+
} catch {
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
if (bad.length) fail(`tmux stream used growable regular files:\n${bad.join('\n')}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function writeFakeTools() {
|
|
504
|
+
fs.mkdirSync(fakeBin, { recursive: true });
|
|
505
|
+
for (const name of ['claude', 'codex']) {
|
|
506
|
+
const file = path.join(fakeBin, name);
|
|
507
|
+
fs.writeFileSync(file, `#!/usr/bin/env bash\necho fake-${name} "$@"\nif [ -n "\${HCC_FAKE_LOG:-}" ]; then echo fake-${name} "$@" >> "$HCC_FAKE_LOG"; fi\nif [ "\${HCC_FAKE_STAY_ALIVE:-}" = "1" ]; then exec bash --noprofile --norc; fi\n`, { mode: 0o755 });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function startRuntime(options = {}) {
|
|
512
|
+
const runtimeEnv = options.env || env;
|
|
513
|
+
const upOutput = hcc(['up', '--no-discover', '--no-guidance'], { env: runtimeEnv });
|
|
514
|
+
if (!upOutput.includes('local coordination ready')) fail(`hcc up did not report local coordination:\n${upOutput}`);
|
|
515
|
+
if (fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) fail('hcc up should not start web runtime');
|
|
516
|
+
|
|
517
|
+
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], { env: runtimeEnv });
|
|
518
|
+
const match = output.match(/^pid:\s*(\d+)/m);
|
|
519
|
+
if (!match) fail(`hcc web did not print background pid:\n${output}`);
|
|
520
|
+
runtimePid = Number.parseInt(match[1], 10);
|
|
521
|
+
if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function stopRuntime() {
|
|
525
|
+
if (!runtimePid) return;
|
|
526
|
+
hccMaybe(['down']);
|
|
527
|
+
await waitFor(() => {
|
|
528
|
+
try {
|
|
529
|
+
process.kill(runtimePid, 0);
|
|
530
|
+
return false;
|
|
531
|
+
} catch {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
}, 'runtime process exit', 5000).catch(() => {
|
|
535
|
+
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
536
|
+
});
|
|
537
|
+
runtimePid = null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function cleanup() {
|
|
541
|
+
try { hccMaybe(['down']); } catch {}
|
|
542
|
+
if (runtimePid) {
|
|
543
|
+
try { process.kill(runtimePid, 'SIGTERM'); } catch {}
|
|
544
|
+
}
|
|
545
|
+
if (tmuxStarted) {
|
|
546
|
+
runMaybe('tmux', ['kill-session', '-t', tmuxSession]);
|
|
547
|
+
}
|
|
548
|
+
for (const session of managedTmuxSessions) {
|
|
549
|
+
runMaybe('tmux', ['kill-session', '-t', session]);
|
|
550
|
+
}
|
|
551
|
+
for (const dir of [root, home, fakeBin, outDir]) {
|
|
552
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function setupRegression() {
|
|
557
|
+
log('[1/12] web bootstrap/hooks/shims');
|
|
558
|
+
writeFakeTools();
|
|
559
|
+
fs.mkdirSync(root, { recursive: true });
|
|
560
|
+
fs.mkdirSync(home, { recursive: true });
|
|
561
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
562
|
+
fs.writeFileSync(path.join(home, '.profile'), 'if [ "$BASH" ]; then . "$HOME/.bashrc"; fi\n');
|
|
563
|
+
fs.writeFileSync(path.join(home, '.bashrc'), '# regression rc\n[ -z "$PS1" ] && return\nexport PATH="/late:$PATH"\n');
|
|
564
|
+
const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
565
|
+
const match = output.match(/^pid:\s*(\d+)/m);
|
|
566
|
+
if (!match) fail(`hcc web did not print background pid during bootstrap:\n${output}`);
|
|
567
|
+
runtimePid = Number.parseInt(match[1], 10);
|
|
568
|
+
await waitRuntime();
|
|
569
|
+
ensureFile(path.join(root, '.hello-cc', 'mesh.db'));
|
|
570
|
+
if (fs.existsSync(path.join(root, '.hello-cc', 'HCC.md'))) fail('web --no-guidance should not write HCC.md');
|
|
571
|
+
if (fs.existsSync(path.join(root, 'CLAUDE.md'))) fail('web --no-guidance should not write CLAUDE.md');
|
|
572
|
+
if (fs.existsSync(path.join(root, 'AGENTS.md'))) fail('web --no-guidance should not write AGENTS.md');
|
|
573
|
+
ensureFile(path.join(home, '.claude', 'settings.json'));
|
|
574
|
+
ensureFile(path.join(home, '.codex', 'hooks.json'));
|
|
575
|
+
const claudeHooks = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8'));
|
|
576
|
+
const codexHooks = JSON.parse(fs.readFileSync(path.join(home, '.codex', 'hooks.json'), 'utf8'));
|
|
577
|
+
for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse', 'PreToolUse']) {
|
|
578
|
+
if (!hasInstalledHook(claudeHooks, event)) fail(`Claude hook missing ${event}`);
|
|
579
|
+
}
|
|
580
|
+
for (const event of ['SessionStart', 'UserPromptSubmit', 'Stop', 'PreToolUse']) {
|
|
581
|
+
if (!hasInstalledHook(codexHooks, event)) fail(`Codex hook missing ${event}`);
|
|
582
|
+
}
|
|
583
|
+
ensureFile(path.join(home, '.hcc-shims', 'claude'));
|
|
584
|
+
ensureFile(path.join(home, '.hcc-shims', 'codex'));
|
|
585
|
+
const bashrc = fs.readFileSync(path.join(home, '.bashrc'), 'utf8');
|
|
586
|
+
const shimIndex = bashrc.indexOf('.hcc-shims');
|
|
587
|
+
const guardIndex = bashrc.indexOf('[ -z "$PS1" ] && return');
|
|
588
|
+
if (shimIndex < 0 || guardIndex < 0 || shimIndex > guardIndex) {
|
|
589
|
+
fail(`shim PATH was not installed before bash early return:\n${bashrc}`);
|
|
590
|
+
}
|
|
591
|
+
const latePathIndex = bashrc.indexOf('export PATH="/late:$PATH"');
|
|
592
|
+
const lastShimIndex = bashrc.lastIndexOf('.hcc-shims');
|
|
593
|
+
if (latePathIndex < 0 || lastShimIndex < latePathIndex) {
|
|
594
|
+
fail(`shim PATH was not reasserted after late PATH edits:\n${bashrc}`);
|
|
595
|
+
}
|
|
596
|
+
const nonInteractiveCodex = run('bash', ['-lc', 'command -v codex'], { env }).trim();
|
|
597
|
+
if (nonInteractiveCodex !== path.join(home, '.hcc-shims', 'codex')) {
|
|
598
|
+
fail(`non-interactive bash did not resolve codex shim: ${nonInteractiveCodex}`);
|
|
599
|
+
}
|
|
600
|
+
const interactiveCodex = run('bash', ['-ic', 'command -v codex'], { env }).trim();
|
|
601
|
+
if (interactiveCodex !== path.join(home, '.hcc-shims', 'codex')) {
|
|
602
|
+
fail(`interactive bash did not keep codex shim first: ${interactiveCodex}`);
|
|
603
|
+
}
|
|
604
|
+
if (!hcc(['install-hooks', '--status']).includes('claude=yes codex=yes')) fail('hooks not installed');
|
|
605
|
+
if (!hcc(['shim', 'status']).includes('shims installed')) fail('shims not installed');
|
|
606
|
+
const staleShim = path.join(home, '.hcc-shims', 'claude');
|
|
607
|
+
fs.writeFileSync(staleShim, '#!/usr/bin/env bash\necho stale --web-managed\n', { mode: 0o755 });
|
|
608
|
+
const ensured = hccMaybe(['shim', 'ensure', 'claude', staleShim]);
|
|
609
|
+
if (ensured.status !== 75) {
|
|
610
|
+
fail(`shim ensure did not request re-exec for stale shim:\n${ensured.stdout}\n${ensured.stderr}`);
|
|
611
|
+
}
|
|
612
|
+
const refreshedShim = fs.readFileSync(staleShim, 'utf8');
|
|
613
|
+
if (!refreshedShim.includes('shim ensure "claude"') || refreshedShim.includes('stale --web-managed')) {
|
|
614
|
+
fail(`shim ensure did not refresh stale shim:\n${refreshedShim}`);
|
|
615
|
+
}
|
|
616
|
+
await stopRuntime();
|
|
617
|
+
assertPeerBindingUniqueConstraints();
|
|
618
|
+
assertLegacyBindingRepair();
|
|
619
|
+
|
|
620
|
+
const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
621
|
+
const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
622
|
+
if (!noTokenMatch) fail(`no-token web did not print background pid:\n${noTokenOutput}`);
|
|
623
|
+
runtimePid = Number.parseInt(noTokenMatch[1], 10);
|
|
624
|
+
if (noTokenOutput.includes('token=') || noTokenOutput.includes('token was generated')) {
|
|
625
|
+
fail(`default web output unexpectedly required token:\n${noTokenOutput}`);
|
|
626
|
+
}
|
|
627
|
+
await waitRuntime();
|
|
628
|
+
const noTokenRuntime = JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
|
|
629
|
+
if (noTokenRuntime.token || Object.hasOwn(noTokenRuntime, 'token_generated')) {
|
|
630
|
+
fail(`default web runtime unexpectedly stored token data:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
|
|
631
|
+
}
|
|
632
|
+
const noTokenResponse = await fetch(`${noTokenRuntime.base_url}/api/runtime`);
|
|
633
|
+
if (!noTokenResponse.ok) fail(`default web API required token: ${noTokenResponse.status}`);
|
|
634
|
+
await stopRuntime();
|
|
635
|
+
|
|
636
|
+
const childDir = path.join(root, 'packages', 'child');
|
|
637
|
+
fs.mkdirSync(childDir, { recursive: true });
|
|
638
|
+
const childFindRoot = hccFrom(['find-root'], childDir).trim();
|
|
639
|
+
if (childFindRoot !== childDir) fail(`child find-root mismatch: ${childFindRoot} !== ${childDir}`);
|
|
640
|
+
const childStatus = hccFrom(['status'], childDir);
|
|
641
|
+
if (!childStatus.includes(`root: ${childDir}`) || !childStatus.includes(`db: ${path.join(childDir, '.hello-cc', 'mesh.db')}`)) {
|
|
642
|
+
fail(`child command did not stay on current path:\n${childStatus}`);
|
|
643
|
+
}
|
|
644
|
+
ensureFile(path.join(childDir, '.hello-cc', 'mesh.db'));
|
|
645
|
+
const explicitChildRoot = hccFrom(['--root', root, 'find-root'], childDir).trim();
|
|
646
|
+
if (explicitChildRoot !== root) fail(`explicit child find-root mismatch: ${explicitChildRoot} !== ${root}`);
|
|
647
|
+
const explicitChildStatus = hccFrom(['--root', root, 'status'], childDir);
|
|
648
|
+
if (!explicitChildStatus.includes(`root: ${root}`) || !explicitChildStatus.includes(`db: ${path.join(root, '.hello-cc', 'mesh.db')}`)) {
|
|
649
|
+
fail(`explicit child command did not use requested root:\n${explicitChildStatus}`);
|
|
650
|
+
}
|
|
651
|
+
const childHookPayload = JSON.stringify({ session_id: 'child-hook-session', cwd: childDir, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
|
|
652
|
+
hccFrom(['hook', 'userpromptsubmit'], childDir, { input: childHookPayload, env: envWithoutPeer({ CODEX_THREAD_ID: 'child-hook-thread' }) });
|
|
653
|
+
const registryFile = path.join(home, '.hello-cc', 'projects.json');
|
|
654
|
+
ensureFile(registryFile);
|
|
655
|
+
const registry = JSON.parse(fs.readFileSync(registryFile, 'utf8'));
|
|
656
|
+
if (!(registry.projects || []).some((p) => p.root === childDir)) {
|
|
657
|
+
fail(`hook project was not auto-registered:\n${JSON.stringify(registry, null, 2)}`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const joinOut = hcc(['join', '--peer', 'join-a', '--kind', 'codex']);
|
|
661
|
+
if (!joinOut.includes('export HCC_PEER=join-a')) fail(`bad join output: ${joinOut}`);
|
|
662
|
+
const envOut = hcc(['env', '--peer', 'env-a']);
|
|
663
|
+
if (!envOut.includes('export HCC_PEER=env-a')) fail(`bad env output: ${envOut}`);
|
|
664
|
+
run('bash', ['-lc', [
|
|
665
|
+
`eval "$(${sh(process.execPath)} ${sh(hccBin)} --root ${sh(root)} join --peer eval-a --kind codex)"`,
|
|
666
|
+
'test "$HCC_PEER" = eval-a',
|
|
667
|
+
`${sh(process.execPath)} ${sh(hccBin)} --root ${sh(root)} msg send --from "$HCC_PEER" --to "$HCC_PEER" --body eval-ok >/dev/null`,
|
|
668
|
+
`${sh(process.execPath)} ${sh(hccBin)} --root ${sh(root)} msg inbox --peer "$HCC_PEER" --all | grep -q eval-ok`
|
|
669
|
+
].join('; ')]);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function dbWorkflow() {
|
|
673
|
+
log('[3/12] db workflow');
|
|
674
|
+
hcc(['register', '--peer', 'human', '--kind', 'human', '--role', 'operator']);
|
|
675
|
+
const created = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'full regression task', '--body', 'exercise hcc bus']);
|
|
676
|
+
const taskMatch = created.match(/created task #(\d+):/);
|
|
677
|
+
if (!taskMatch) fail(`cannot parse task id: ${created}`);
|
|
678
|
+
const taskId = taskMatch[1];
|
|
679
|
+
hcc(['task', 'claim', '--peer', 'codex-a', '--id', taskId]);
|
|
680
|
+
hcc(['task', 'update', '--peer', 'codex-a', '--id', taskId, '--status', 'running', '--summary', 'started']);
|
|
681
|
+
const runningTasksForOtherPeer = hccJson(['task', 'list'], { env: { ...env, HCC_PEER: 'claude-a' } });
|
|
682
|
+
if (!hasTask(runningTasksForOtherPeer, taskId)) {
|
|
683
|
+
fail(`running task hidden from default list for another peer: #${taskId}`);
|
|
684
|
+
}
|
|
685
|
+
hcc(['lock', 'acquire', '--peer', 'codex-a', '--task', taskId, '--resource', 'src/router', '--ttl', '60', '--reason', 'regression']);
|
|
686
|
+
hcc(['lock', 'renew', '--peer', 'codex-a', '--resource', 'src/router', '--ttl', '60']);
|
|
687
|
+
hcc(['msg', 'send', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--body', 'please review']);
|
|
688
|
+
const inbox = hcc(['msg', 'inbox', '--peer', 'claude-a', '--wait', '0']);
|
|
689
|
+
if (!inbox.includes('please review')) fail('inbox did not include message');
|
|
690
|
+
const msgMatch = inbox.match(/^#(\d+)/m);
|
|
691
|
+
if (!msgMatch) fail(`cannot parse message id: ${inbox}`);
|
|
692
|
+
hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgMatch[1]]);
|
|
693
|
+
hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
|
|
694
|
+
if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
|
|
695
|
+
if (!hcc(['status', '--peer', 'codex-a']).includes('codex-a')) fail('status missing peer');
|
|
696
|
+
hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/router']);
|
|
697
|
+
hcc(['task', 'done', '--peer', 'codex-a', '--id', taskId, '--summary', 'done']);
|
|
698
|
+
const doneDefaultTasks = hccJson(['task', 'list'], { env: { ...env, HCC_PEER: 'claude-a' } });
|
|
699
|
+
if (hasTask(doneDefaultTasks, taskId)) fail(`done task still shown in default list: #${taskId}`);
|
|
700
|
+
if (!hasTask(hccJson(['task', 'list', '--all']), taskId)) fail(`done task missing from --all list: #${taskId}`);
|
|
701
|
+
if (!hasTask(hccJson(['task', 'list', '--status', 'done']), taskId)) fail(`done task missing from --status done list: #${taskId}`);
|
|
702
|
+
|
|
703
|
+
const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
|
|
704
|
+
const abandonedMatch = abandoned.match(/created task #(\d+):/);
|
|
705
|
+
if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
|
|
706
|
+
const abandonedTaskId = abandonedMatch[1];
|
|
707
|
+
hcc(['task', 'update', '--peer', 'human', '--id', abandonedTaskId, '--status', 'abandoned', '--summary', 'not needed']);
|
|
708
|
+
if (hasTask(hccJson(['task', 'list']), abandonedTaskId)) {
|
|
709
|
+
fail(`abandoned task still shown in default list: #${abandonedTaskId}`);
|
|
710
|
+
}
|
|
711
|
+
if (!hasTask(hccJson(['task', 'list', '--status', 'abandoned']), abandonedTaskId)) {
|
|
712
|
+
fail(`abandoned task missing from --status abandoned list: #${abandonedTaskId}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const hookTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-hook', '--title', 'hook visible task']);
|
|
716
|
+
const hookTaskMatch = hookTask.match(/created task #(\d+):/);
|
|
717
|
+
if (!hookTaskMatch) fail(`cannot parse hook task id: ${hookTask}`);
|
|
718
|
+
const hookTaskId = hookTaskMatch[1];
|
|
719
|
+
hcc(['task', 'claim', '--peer', 'codex-hook', '--id', hookTaskId]);
|
|
720
|
+
hcc(['task', 'update', '--peer', 'codex-hook', '--id', hookTaskId, '--status', 'running', '--summary', 'hook visible']);
|
|
721
|
+
hcc(['msg', 'send', '--from', 'human', '--to', 'claude-hook', '--task', hookTaskId, '--body', 'hook-only-message']);
|
|
722
|
+
const hookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'UserPromptSubmit', prompt: 'status?' });
|
|
723
|
+
const hookEnv = { ...env, HCC_PEER: 'claude-hook' };
|
|
724
|
+
const firstHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
|
|
725
|
+
if (!firstHook.includes('[hello-cc coordination]') || !firstHook.includes('[hello-cc open tasks]') || !firstHook.includes(`#${hookTaskId} running`)) {
|
|
726
|
+
fail(`UserPromptSubmit hook did not include open task snapshot:\n${firstHook}`);
|
|
727
|
+
}
|
|
728
|
+
if (!firstHook.includes('[hello-cc known peers]') || !firstHook.includes('do not say sessions are isolated')) {
|
|
729
|
+
fail(`UserPromptSubmit hook missing strong cross-session instruction:\n${firstHook}`);
|
|
730
|
+
}
|
|
731
|
+
if (!firstHook.includes('hcc task list') || !firstHook.includes('hook-only-message')) {
|
|
732
|
+
fail(`UserPromptSubmit hook missing instructions or unread message:\n${firstHook}`);
|
|
733
|
+
}
|
|
734
|
+
const secondHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
|
|
735
|
+
if (!secondHook.includes(`#${hookTaskId} running`)) {
|
|
736
|
+
fail(`UserPromptSubmit hook stopped showing open task after first read:\n${secondHook}`);
|
|
737
|
+
}
|
|
738
|
+
if (secondHook.includes('hook-only-message')) {
|
|
739
|
+
fail(`UserPromptSubmit hook repeated acked unread message:\n${secondHook}`);
|
|
740
|
+
}
|
|
741
|
+
const sessionHookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'SessionStart', source: 'resume' });
|
|
742
|
+
const sessionHook = hookContext(hcc(['hook', 'sessionstart'], { env: hookEnv, input: sessionHookPayload }), 'SessionStart');
|
|
743
|
+
if (!sessionHook.includes(`#${hookTaskId} running`) || sessionHook.includes('hook-only-message')) {
|
|
744
|
+
fail(`SessionStart hook context wrong:\n${sessionHook}`);
|
|
745
|
+
}
|
|
746
|
+
hcc(['task', 'done', '--peer', 'codex-hook', '--id', hookTaskId, '--summary', 'hook regression done']);
|
|
747
|
+
hcc(['event', 'tail', '--limit', '5']);
|
|
748
|
+
|
|
749
|
+
const autoEnv = envWithoutPeer();
|
|
750
|
+
const autoSent = hcc(['msg', 'send', '--to', 'all', '--body', 'auto-join-ok'], { env: autoEnv });
|
|
751
|
+
const autoPeer = parseSentPeer(autoSent);
|
|
752
|
+
const autoPeers = hcc(['peers']);
|
|
753
|
+
if (!autoPeers.includes(autoPeer)) fail(`auto peer missing from peers: ${autoPeer}\n${autoPeers}`);
|
|
754
|
+
const autoInbox = hcc(['msg', 'inbox', '--all'], { env: autoEnv });
|
|
755
|
+
if (!autoInbox.includes('auto-join-ok')) fail(`auto inbox missing message:\n${autoInbox}`);
|
|
756
|
+
const autoTask = hcc(['task', 'create', '--title', 'auto join task', '--body', 'auto workflow'], { env: autoEnv });
|
|
757
|
+
const autoTaskMatch = autoTask.match(/created task #(\d+):/);
|
|
758
|
+
if (!autoTaskMatch) fail(`cannot parse auto task id: ${autoTask}`);
|
|
759
|
+
const autoTaskId = autoTaskMatch[1];
|
|
760
|
+
hcc(['task', 'claim', '--id', autoTaskId], { env: autoEnv });
|
|
761
|
+
hcc(['task', 'update', '--id', autoTaskId, '--status', 'running', '--summary', 'auto running'], { env: autoEnv });
|
|
762
|
+
hcc(['lock', 'acquire', '--resource', 'auto/resource', '--task', autoTaskId, '--ttl', '60'], { env: autoEnv });
|
|
763
|
+
hcc(['lock', 'renew', '--resource', 'auto/resource', '--ttl', '60'], { env: autoEnv });
|
|
764
|
+
hcc(['handoff', 'create', '--summary', 'auto handoff', '--tests', 'auto test', '--risks', 'none'], { env: autoEnv });
|
|
765
|
+
hcc(['lock', 'release', '--resource', 'auto/resource'], { env: autoEnv });
|
|
766
|
+
hcc(['task', 'done', '--id', autoTaskId, '--summary', 'auto done'], { env: autoEnv });
|
|
767
|
+
const autoEvents = hcc(['event', 'tail', '--limit', '50']);
|
|
768
|
+
if (!autoEvents.includes('peer.auto_joined') || !autoEvents.includes(autoPeer)) {
|
|
769
|
+
fail(`auto join event missing for ${autoPeer}:\n${autoEvents}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const codexEnv = envAsCodex();
|
|
773
|
+
const codexPeer = parseSentPeer(hcc(['msg', 'send', '--to', 'all', '--body', 'auto-codex-ok'], { env: codexEnv }));
|
|
774
|
+
if (!codexPeer.startsWith('codex-')) fail(`CODEX_THREAD_ID did not produce codex peer: ${codexPeer}`);
|
|
775
|
+
if (!hcc(['msg', 'inbox', '--all'], { env: codexEnv }).includes('auto-codex-ok')) {
|
|
776
|
+
fail('codex auto peer inbox did not include message');
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function multiProjectWebWorkflow() {
|
|
781
|
+
log('[4/12] multi-project web');
|
|
782
|
+
const otherRoot = path.join(root, 'second-project');
|
|
783
|
+
fs.mkdirSync(otherRoot, { recursive: true });
|
|
784
|
+
const output = hccFrom(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], otherRoot);
|
|
785
|
+
if (!output.includes('web already running in background')) fail(`second project did not reuse global web:\n${output}`);
|
|
786
|
+
if (!output.includes(`project: ${otherRoot}`)) fail(`second project output did not show its root:\n${output}`);
|
|
787
|
+
if (!output.includes(encodeURIComponent(otherRoot))) fail(`second project URL did not include project query:\n${output}`);
|
|
788
|
+
|
|
789
|
+
const otherRuntimeFile = path.join(otherRoot, '.hello-cc', 'runtime.json');
|
|
790
|
+
ensureFile(otherRuntimeFile);
|
|
791
|
+
const otherRuntime = JSON.parse(fs.readFileSync(otherRuntimeFile, 'utf8'));
|
|
792
|
+
if (otherRuntime.pid !== runtimePid || otherRuntime.port !== port) {
|
|
793
|
+
fail(`second project runtime did not point at global runtime:\n${JSON.stringify(otherRuntime, null, 2)}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const projectsResponse = await fetch(`http://127.0.0.1:${port}/api/projects?root=${encodeURIComponent(otherRoot)}`);
|
|
797
|
+
const projects = await projectsResponse.json();
|
|
798
|
+
if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
|
|
799
|
+
const roots = new Set((projects.projects || []).map((p) => p.root));
|
|
800
|
+
if (!roots.has(root) || !roots.has(otherRoot)) {
|
|
801
|
+
fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const detectedResponse = await fetch(`http://127.0.0.1:${port}/api/detected?root=${encodeURIComponent(root)}`);
|
|
805
|
+
const detectedJson = await detectedResponse.json();
|
|
806
|
+
if (!detectedResponse.ok || typeof detectedJson.active_peer_ttl !== 'number' || typeof detectedJson.now !== 'number' || !Array.isArray(detectedJson.detected)) {
|
|
807
|
+
fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const htmlResponse = await fetch(`http://127.0.0.1:${port}/`);
|
|
811
|
+
const html = await htmlResponse.text();
|
|
812
|
+
for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
|
|
813
|
+
if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
814
|
+
}
|
|
815
|
+
if (!html.includes('Register Project') || !html.includes('New session') || !html.includes('<strong>Sessions</strong>') || !html.includes('<label>View<select')) {
|
|
816
|
+
fail('web form missing simplified project/session controls');
|
|
817
|
+
}
|
|
818
|
+
if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
|
|
819
|
+
fail('web state panel missing scrollable peer state UI');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!tmuxAvailable()) return;
|
|
823
|
+
const started = hccFrom(['peer', 'start', 'other-shell', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], otherRoot);
|
|
824
|
+
parsePane(started);
|
|
825
|
+
const rootList = hcc(['peer', 'list']);
|
|
826
|
+
const otherList = hccFrom(['peer', 'list'], otherRoot);
|
|
827
|
+
if (rootList.includes('other-shell')) fail(`root project saw second project session:\n${rootList}`);
|
|
828
|
+
if (!otherList.includes('other-shell')) fail(`second project did not see its session:\n${otherList}`);
|
|
829
|
+
|
|
830
|
+
const rootSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(root)}`)).json();
|
|
831
|
+
const otherSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`)).json();
|
|
832
|
+
if ((rootSessions.sessions || []).some((s) => s.id === 'other-shell')) {
|
|
833
|
+
fail(`root API saw second project session:\n${JSON.stringify(rootSessions)}`);
|
|
834
|
+
}
|
|
835
|
+
if (!(otherSessions.sessions || []).some((s) => s.id === 'other-shell')) {
|
|
836
|
+
fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const startAuto = async () => {
|
|
840
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`, {
|
|
841
|
+
method: 'POST',
|
|
842
|
+
headers: { 'Content-Type': 'application/json' },
|
|
843
|
+
body: JSON.stringify({ kind: 'shell', command: 'bash --noprofile --norc' })
|
|
844
|
+
});
|
|
845
|
+
const json = await response.json();
|
|
846
|
+
if (!response.ok) fail(`auto web session start failed: ${JSON.stringify(json)}`);
|
|
847
|
+
return json.session;
|
|
848
|
+
};
|
|
849
|
+
const autoOne = await startAuto();
|
|
850
|
+
const autoTwo = await startAuto();
|
|
851
|
+
if (!autoOne.id.startsWith('shell-') || !autoTwo.id.startsWith('shell-') || autoOne.id === autoTwo.id) {
|
|
852
|
+
fail(`auto web session ids were not unique: ${autoOne.id}, ${autoTwo.id}`);
|
|
853
|
+
}
|
|
854
|
+
await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoOne.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
|
|
855
|
+
await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoTwo.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
|
|
856
|
+
hccFromMaybe(['peer', 'stop', 'other-shell'], otherRoot);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function tmuxBackedStartWorkflow() {
|
|
860
|
+
if (!tmuxAvailable()) {
|
|
861
|
+
log('[5/12] tmux-backed start skipped (tmux not installed)');
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
log('[5/12] tmux-backed start + websocket + restore');
|
|
866
|
+
const file = path.join(outDir, 'pty-ok');
|
|
867
|
+
const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
|
|
868
|
+
const pane = parsePane(started);
|
|
869
|
+
const list = hcc(['peer', 'list']);
|
|
870
|
+
if (!list.includes('shell-a') || !list.includes('tmux')) fail(`tmux-backed peer missing from list:\n${list}`);
|
|
871
|
+
hcc(['inject', 'shell-a', `echo PTY_OK > ${file}`]);
|
|
872
|
+
await waitForFile(file, 'PTY_OK', 'pty injection');
|
|
873
|
+
await expectWebSocketMarker('shell-a', 'WS_PTY_OK');
|
|
874
|
+
await expectBoundedTmuxStream('tmux-backed FIFO stream');
|
|
875
|
+
|
|
876
|
+
await stopRuntime();
|
|
877
|
+
run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
878
|
+
startRuntime();
|
|
879
|
+
await waitRuntime();
|
|
880
|
+
await waitFor(() => hcc(['peer', 'list']).includes('shell-a'), 'tmux-backed peer restore');
|
|
881
|
+
const restoredFile = path.join(outDir, 'pty-restored-ok');
|
|
882
|
+
hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
|
|
883
|
+
await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
|
|
884
|
+
|
|
885
|
+
const canonicalSession = 'canonical-session';
|
|
886
|
+
insertStaleProviderBinding('claude-stale-canonical', 'claude', canonicalSession);
|
|
887
|
+
const canonicalStarted = hcc(['peer', 'start', 'claude-live-canonical', '--kind', 'claude', '--resume', canonicalSession], {
|
|
888
|
+
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
889
|
+
});
|
|
890
|
+
const canonicalPane = parsePane(canonicalStarted);
|
|
891
|
+
const canonicalRows = providerBindingRows('claude', canonicalSession);
|
|
892
|
+
if (canonicalRows.length !== 1 ||
|
|
893
|
+
canonicalRows[0].peer !== 'claude-live-canonical' ||
|
|
894
|
+
canonicalRows[0].transport !== 'tmux' ||
|
|
895
|
+
canonicalRows[0].runtime_target !== canonicalPane) {
|
|
896
|
+
fail(`stale provider binding was not migrated to live tmux peer:\n${JSON.stringify(canonicalRows, null, 2)}`);
|
|
897
|
+
}
|
|
898
|
+
hcc(['peer', 'stop', 'claude-live-canonical']);
|
|
899
|
+
|
|
900
|
+
const forceSession = 'force-canonical-session';
|
|
901
|
+
const forceFirst = hcc(['peer', 'start', 'claude-force-a', '--kind', 'claude', '--resume', forceSession], {
|
|
902
|
+
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
903
|
+
});
|
|
904
|
+
parsePane(forceFirst);
|
|
905
|
+
const forceConflict = hccMaybe(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession], {
|
|
906
|
+
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
907
|
+
});
|
|
908
|
+
if (forceConflict.status === 0 || !String(forceConflict.stderr || forceConflict.stdout).includes('already bound to claude-force-a')) {
|
|
909
|
+
fail(`provider session duplicate start was not rejected:\n${forceConflict.stdout}\n${forceConflict.stderr}`);
|
|
910
|
+
}
|
|
911
|
+
const forceSecond = hcc(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession, '--force'], {
|
|
912
|
+
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
913
|
+
});
|
|
914
|
+
const forcePane = parsePane(forceSecond);
|
|
915
|
+
const forceRows = providerBindingRows('claude', forceSession);
|
|
916
|
+
if (forceRows.length !== 1 ||
|
|
917
|
+
forceRows[0].peer !== 'claude-force-b' ||
|
|
918
|
+
forceRows[0].transport !== 'tmux' ||
|
|
919
|
+
forceRows[0].runtime_target !== forcePane) {
|
|
920
|
+
fail(`--force did not move provider binding to replacement tmux peer:\n${JSON.stringify(forceRows, null, 2)}`);
|
|
921
|
+
}
|
|
922
|
+
hccMaybe(['peer', 'stop', 'claude-force-a']);
|
|
923
|
+
hcc(['peer', 'stop', 'claude-force-b']);
|
|
924
|
+
|
|
925
|
+
await stopRuntime();
|
|
926
|
+
startRuntime({ env: envWithoutProvider({ HCC_REG_VALUE: 'runtime-old', ANTHROPIC_BASE_URL: 'runtime-old-url' }) });
|
|
927
|
+
await waitRuntime();
|
|
928
|
+
const envFile = path.join(outDir, 'caller-env-ok');
|
|
929
|
+
parsePane(hcc(['peer', 'start', 'env-a', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], {
|
|
930
|
+
env: envWithoutProvider({ HCC_REG_VALUE: 'caller-new' })
|
|
931
|
+
}));
|
|
932
|
+
hcc(['inject', 'env-a', `printf '%s|%s\\n' "$HCC_REG_VALUE" "\${ANTHROPIC_BASE_URL:-}" > ${envFile}`]);
|
|
933
|
+
await waitForFile(envFile, 'caller-new|', 'caller env propagation');
|
|
934
|
+
hcc(['peer', 'stop', 'env-a']);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function shimTmuxWorkflow() {
|
|
938
|
+
if (!tmuxAvailable()) {
|
|
939
|
+
log('[6/12] shim tmux-backed launch skipped (tmux not installed)');
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
log('[6/12] shim tmux-backed launch');
|
|
944
|
+
const shim = path.join(home, '.hcc-shims', 'claude');
|
|
945
|
+
const codexShim = path.join(home, '.hcc-shims', 'codex');
|
|
946
|
+
const claudeVersion = run(shim, ['--version'], { cwd: root, env });
|
|
947
|
+
if (!claudeVersion.includes('fake-claude --version') || claudeVersion.includes('started ')) {
|
|
948
|
+
fail(`claude shim did not pass through --version:\n${claudeVersion}`);
|
|
949
|
+
}
|
|
950
|
+
const claudePrint = run(shim, ['--print', 'hello'], { cwd: root, env });
|
|
951
|
+
if (!claudePrint.includes('fake-claude --print hello') || claudePrint.includes('started ')) {
|
|
952
|
+
fail(`claude shim did not pass through --print:\n${claudePrint}`);
|
|
953
|
+
}
|
|
954
|
+
const codexVersion = run(codexShim, ['--version'], { cwd: root, env });
|
|
955
|
+
if (!codexVersion.includes('fake-codex --version') || codexVersion.includes('started ')) {
|
|
956
|
+
fail(`codex shim did not pass through --version:\n${codexVersion}`);
|
|
957
|
+
}
|
|
958
|
+
const codexExec = run(codexShim, ['exec', 'hello'], { cwd: root, env });
|
|
959
|
+
if (!codexExec.includes('fake-codex exec hello') || codexExec.includes('started ')) {
|
|
960
|
+
fail(`codex shim did not pass through exec:\n${codexExec}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const shimEnv = {
|
|
964
|
+
...env,
|
|
965
|
+
HCC_SHIM_NO_ATTACH: '1',
|
|
966
|
+
HCC_FAKE_STAY_ALIVE: '1',
|
|
967
|
+
HCC_NO_AUTO_INSTALL_TMUX: '1',
|
|
968
|
+
HCC_REG_VALUE: 'shim-first'
|
|
969
|
+
};
|
|
970
|
+
const output = run(shim, ['--resume', 'shim-regression-session'], { cwd: root, env: shimEnv });
|
|
971
|
+
const peerMatch = output.match(/started\s+(\S+)\s+\(/);
|
|
972
|
+
if (!peerMatch) fail(`shim did not start a peer:\n${output}`);
|
|
973
|
+
const peer = peerMatch[1];
|
|
974
|
+
const pane = parsePane(output);
|
|
975
|
+
const list = hcc(['peer', 'list']);
|
|
976
|
+
if (!list.includes(peer) || !list.includes('tmux') || !list.includes(pane)) {
|
|
977
|
+
fail(`shim peer missing from list:\n${list}`);
|
|
978
|
+
}
|
|
979
|
+
const hookPreservePayload = JSON.stringify({
|
|
980
|
+
session_id: 'hook-preserve-session',
|
|
981
|
+
cwd: root,
|
|
982
|
+
hook_event_name: 'UserPromptSubmit',
|
|
983
|
+
prompt: 'keep tmux binding'
|
|
984
|
+
});
|
|
985
|
+
hcc(['hook', 'userpromptsubmit'], {
|
|
986
|
+
env: { ...env, HCC_PEER: peer, CLAUDE_CODE_SESSION_ID: 'hook-preserve-session' },
|
|
987
|
+
input: hookPreservePayload
|
|
988
|
+
});
|
|
989
|
+
const afterHookRows = hccJson(['peer', 'list']);
|
|
990
|
+
const afterHookPeer = afterHookRows.find((row) => row.id === peer);
|
|
991
|
+
if (!afterHookPeer?.binding || afterHookPeer.binding.transport !== 'tmux' || afterHookPeer.binding.runtime_target !== pane) {
|
|
992
|
+
fail(`Claude hook overwrote tmux binding:\n${JSON.stringify(afterHookPeer, null, 2)}`);
|
|
993
|
+
}
|
|
994
|
+
hcc(['hook', 'userpromptsubmit'], {
|
|
995
|
+
env: envWithoutPeer({ CLAUDE_CODE_SESSION_ID: 'hook-preserve-session' }),
|
|
996
|
+
input: hookPreservePayload
|
|
997
|
+
});
|
|
998
|
+
const hookBindingRows = providerBindingRows('claude', 'hook-preserve-session');
|
|
999
|
+
if (hookBindingRows.length !== 1 ||
|
|
1000
|
+
hookBindingRows[0].peer !== peer ||
|
|
1001
|
+
hookBindingRows[0].transport !== 'tmux' ||
|
|
1002
|
+
hookBindingRows[0].runtime_target !== pane) {
|
|
1003
|
+
fail(`Claude hook did not canonicalize provider session to tmux peer:\n${JSON.stringify(hookBindingRows, null, 2)}`);
|
|
1004
|
+
}
|
|
1005
|
+
const file = path.join(outDir, 'shim-ok');
|
|
1006
|
+
hcc(['inject', peer, `echo SHIM_OK > ${file}`]);
|
|
1007
|
+
await waitForFile(file, 'SHIM_OK', 'shim tmux injection');
|
|
1008
|
+
await expectWebSocketMarker(peer, 'WS_SHIM_OK');
|
|
1009
|
+
await expectBoundedTmuxStream('shim tmux FIFO stream');
|
|
1010
|
+
|
|
1011
|
+
const firstEnvFile = path.join(outDir, 'shim-env-first');
|
|
1012
|
+
hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${firstEnvFile}`]);
|
|
1013
|
+
await waitForFile(firstEnvFile, 'shim-first', 'shim first env');
|
|
1014
|
+
|
|
1015
|
+
const restarted = run(shim, ['--resume', 'shim-regression-session'], {
|
|
1016
|
+
cwd: root,
|
|
1017
|
+
env: { ...shimEnv, HCC_REG_VALUE: 'shim-second' }
|
|
1018
|
+
});
|
|
1019
|
+
parsePane(restarted);
|
|
1020
|
+
const secondEnvFile = path.join(outDir, 'shim-env-second');
|
|
1021
|
+
hcc(['inject', peer, `printf '%s\\n' "$HCC_REG_VALUE" > ${secondEnvFile}`]);
|
|
1022
|
+
await waitForFile(secondEnvFile, 'shim-second', 'shim env restart');
|
|
1023
|
+
const reentryFile = path.join(outDir, 'shim-reentry');
|
|
1024
|
+
hcc(['inject', peer, `HCC_FAKE_STAY_ALIVE=0 HCC_REG_VALUE=shim-third ${sh(shim)} --resume shim-regression-session > ${sh(reentryFile)} 2>&1`]);
|
|
1025
|
+
await waitForFileContent(reentryFile, 'fake-claude --resume shim-regression-session', 'shim tmux pane re-entry');
|
|
1026
|
+
hcc(['peer', 'stop', peer]);
|
|
1027
|
+
|
|
1028
|
+
const exitedResume = 'shim-exited-session';
|
|
1029
|
+
const exitedLog = path.join(outDir, 'shim-exited-log');
|
|
1030
|
+
const exitedEnv = {
|
|
1031
|
+
...shimEnv,
|
|
1032
|
+
HCC_FAKE_STAY_ALIVE: '0',
|
|
1033
|
+
HCC_FAKE_LOG: exitedLog
|
|
1034
|
+
};
|
|
1035
|
+
const exitedFirst = run(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
|
|
1036
|
+
const exitedPeerMatch = exitedFirst.match(/started\s+(\S+)\s+\(/);
|
|
1037
|
+
if (!exitedPeerMatch) fail(`exited shim did not start a peer:\n${exitedFirst}`);
|
|
1038
|
+
const exitedPeer = exitedPeerMatch[1];
|
|
1039
|
+
parsePane(exitedFirst);
|
|
1040
|
+
await waitForFileLineCount(exitedLog, 1, 'shim exited first provider launch');
|
|
1041
|
+
const fallbackFile = path.join(outDir, 'shim-exited-fallback');
|
|
1042
|
+
hcc(['inject', exitedPeer, `printf '%s\\n' fallback > ${sh(fallbackFile)}`]);
|
|
1043
|
+
await waitForFile(fallbackFile, 'fallback', 'shim exited fallback shell');
|
|
1044
|
+
const exitedSecond = run(shim, ['--resume', exitedResume], { cwd: root, env: exitedEnv });
|
|
1045
|
+
parsePane(exitedSecond);
|
|
1046
|
+
await waitForFileLineCount(exitedLog, 2, 'shim exited resume relaunch');
|
|
1047
|
+
hcc(['peer', 'stop', exitedPeer]);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function tmuxWorkflow() {
|
|
1051
|
+
const tmuxVersion = runMaybe('tmux', ['-V']);
|
|
1052
|
+
if (tmuxVersion.status !== 0) {
|
|
1053
|
+
log('[7/12] tmux skipped (tmux not installed)');
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
log('[7/12] tmux attach + websocket + force');
|
|
1058
|
+
run('tmux', ['new-session', '-d', '-s', tmuxSession, '-c', os.tmpdir(), 'bash --noprofile --norc']);
|
|
1059
|
+
tmuxStarted = true;
|
|
1060
|
+
const pane = run('tmux', ['display-message', '-p', '-t', `${tmuxSession}:0.0`, '#{pane_id}']).trim();
|
|
1061
|
+
const file = path.join(outDir, 'tmux-ok');
|
|
1062
|
+
hcc(['peer', 'attach', 'tmux-a', '--kind', 'shell', '--pane', pane]);
|
|
1063
|
+
hcc(['inject', 'tmux-a', `echo TMUX_OK > ${file}`]);
|
|
1064
|
+
await waitForFile(file, 'TMUX_OK', 'tmux injection');
|
|
1065
|
+
await expectWebSocketMarker('tmux-a', 'WS_TMUX_OK');
|
|
1066
|
+
await expectBoundedTmuxStream('attached tmux FIFO stream');
|
|
1067
|
+
const conflict = hccMaybe(['peer', 'attach', 'tmux-b', '--kind', 'shell', '--pane', pane]);
|
|
1068
|
+
if (conflict.status === 0 || !String(conflict.stderr || conflict.stdout).includes('already attached to tmux-a')) {
|
|
1069
|
+
fail('tmux duplicate attach did not fail as expected');
|
|
1070
|
+
}
|
|
1071
|
+
hcc(['peer', 'attach', 'tmux-b', '--kind', 'shell', '--pane', pane, '--force']);
|
|
1072
|
+
if (!hcc(['peer', 'list']).includes('tmux-b')) fail('tmux force attach missing');
|
|
1073
|
+
hcc(['peer', 'stop', 'tmux-b']);
|
|
1074
|
+
run('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}']);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function askBroadcastWorkflow() {
|
|
1078
|
+
if (!tmuxAvailable()) {
|
|
1079
|
+
log('[8/12] ask/broadcast injection skipped (tmux not installed)');
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
log('[8/12] ask/broadcast injection');
|
|
1084
|
+
const askFile = path.join(outDir, 'ask-ok');
|
|
1085
|
+
parsePane(hcc(['peer', 'start', 'shell-b', '--kind', 'shell', '--', 'bash']));
|
|
1086
|
+
hcc(['ask', 'shell-b', `echo ASK_OK > ${askFile}`, '--from', 'human', '--inject']);
|
|
1087
|
+
await waitForFile(askFile, 'ASK_OK', 'ask injection');
|
|
1088
|
+
if (!hcc(['msg', 'inbox', '--peer', 'shell-b', '--all']).includes('ASK_OK')) fail('ask durable message missing');
|
|
1089
|
+
const broadcastFile = path.join(outDir, 'broadcast-ok');
|
|
1090
|
+
hcc(['broadcast', `echo BROADCAST_OK > ${broadcastFile}`, '--from', 'human', '--inject']);
|
|
1091
|
+
await waitForFile(broadcastFile, 'BROADCAST_OK', 'broadcast injection');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function downGcPackWorkflow() {
|
|
1095
|
+
log('[9/12] down/gc/pack');
|
|
1096
|
+
hccMaybe(['peer', 'stop', 'shell-a']);
|
|
1097
|
+
hccMaybe(['peer', 'stop', 'shell-b']);
|
|
1098
|
+
await stopRuntime();
|
|
1099
|
+
await waitFor(() => !fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), 'runtime cleanup', 5000);
|
|
1100
|
+
hcc(['gc', '--older-than', '0', '--yes']);
|
|
1101
|
+
run('npm', ['pack', '--dry-run']);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function oldNameScan() {
|
|
1105
|
+
log('[10/12] old-name scan');
|
|
1106
|
+
const oldNamePattern = [
|
|
1107
|
+
'agent' + 'mesh',
|
|
1108
|
+
'Agent' + 'mesh',
|
|
1109
|
+
'AGENT' + 'MESH',
|
|
1110
|
+
'\\.' + 'agent' + 'mesh',
|
|
1111
|
+
'bin/' + 'agent' + 'mesh',
|
|
1112
|
+
'HCC_' + 'AGENT',
|
|
1113
|
+
'ACTIVE_' + 'AGENT_' + 'TTL',
|
|
1114
|
+
'--' + 'agent\\b'
|
|
1115
|
+
].join('|');
|
|
1116
|
+
const rg = runMaybe('rg', [
|
|
1117
|
+
'-n',
|
|
1118
|
+
'--glob', '!node_modules/**',
|
|
1119
|
+
'--glob', '!*.tgz',
|
|
1120
|
+
'--',
|
|
1121
|
+
oldNamePattern,
|
|
1122
|
+
'.'
|
|
1123
|
+
]);
|
|
1124
|
+
if (rg.status === 0) fail(`old names found:\n${rg.stdout}`);
|
|
1125
|
+
if (rg.status !== 1) fail(`rg failed:\n${rg.stderr || rg.stdout}`);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function syntaxAndHelp() {
|
|
1129
|
+
log('[11/12] syntax/help');
|
|
1130
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
|
|
1131
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
|
|
1132
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'discover.mjs')]);
|
|
1133
|
+
const mainHelp = run(process.execPath, [hccBin, '--help']);
|
|
1134
|
+
if (mainHelp.includes('setup') || mainHelp.includes('uninstall') || mainHelp.includes('--web-managed')) {
|
|
1135
|
+
fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
|
|
1136
|
+
}
|
|
1137
|
+
const runHelp = run(process.execPath, [hccBin, 'run', '--help']);
|
|
1138
|
+
if (runHelp.includes('--web-managed')) fail(`run help exposes removed --web-managed:\n${runHelp}`);
|
|
1139
|
+
if (!run(process.execPath, [hccBin, 'peer', '--help']).includes('peer attach')) fail('peer attach missing from help');
|
|
1140
|
+
const removed = runMaybe(process.execPath, [hccBin, '--root', root, 'run', '--peer', 'bad', '--kind', 'shell', '--web-managed', '--', 'bash']);
|
|
1141
|
+
if (removed.status === 0 || !String(removed.stderr || removed.stdout).includes('unknown option --web-managed')) {
|
|
1142
|
+
fail(`run --web-managed was not rejected:\n${removed.stdout}\n${removed.stderr}`);
|
|
1143
|
+
}
|
|
1144
|
+
run('npm', ['run', 'smoke']);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function uninstallWorkflow() {
|
|
1148
|
+
log('[12/12] maintenance cleanup');
|
|
1149
|
+
const uninstallRoot = path.join(os.tmpdir(), `hcc-reg-uninstall-root-${testId}`);
|
|
1150
|
+
const uninstallHome = path.join(os.tmpdir(), `hcc-reg-uninstall-home-${testId}`);
|
|
1151
|
+
fs.mkdirSync(uninstallRoot, { recursive: true });
|
|
1152
|
+
fs.mkdirSync(uninstallHome, { recursive: true });
|
|
1153
|
+
const uninstallEnv = {
|
|
1154
|
+
...env,
|
|
1155
|
+
HOME: uninstallHome,
|
|
1156
|
+
PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
|
|
1160
|
+
ensureFile(path.join(uninstallRoot, '.hello-cc', 'mesh.db'));
|
|
1161
|
+
ensureFile(path.join(uninstallRoot, 'CLAUDE.md'));
|
|
1162
|
+
ensureFile(path.join(uninstallRoot, 'AGENTS.md'));
|
|
1163
|
+
ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
|
|
1164
|
+
ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
|
|
1165
|
+
ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
|
|
1166
|
+
|
|
1167
|
+
const kept = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall'], { env: uninstallEnv });
|
|
1168
|
+
if (!kept.includes('project data kept')) fail(`uninstall did not keep project data by default:\n${kept}`);
|
|
1169
|
+
if (!fs.existsSync(path.join(uninstallRoot, '.hello-cc', 'mesh.db'))) fail('default uninstall removed project db');
|
|
1170
|
+
if (fs.existsSync(path.join(uninstallHome, '.hcc-shims', 'claude'))) fail('default uninstall did not remove shim');
|
|
1171
|
+
if (!run(process.execPath, [hccBin, '--root', uninstallRoot, 'install-hooks', '--status'], { env: uninstallEnv }).includes('claude=no codex=no')) {
|
|
1172
|
+
fail('default uninstall did not remove hooks');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
|
|
1176
|
+
const refused = runMaybe(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall', '--purge'], { env: uninstallEnv });
|
|
1177
|
+
if (refused.status === 0 || !String(refused.stderr || refused.stdout).includes('without --yes')) {
|
|
1178
|
+
fail(`purge without --yes was not refused:\n${refused.stdout}\n${refused.stderr}`);
|
|
1179
|
+
}
|
|
1180
|
+
const purged = run(process.execPath, [hccBin, '--root', uninstallRoot, 'uninstall', '--purge', '--yes'], { env: uninstallEnv });
|
|
1181
|
+
if (!purged.includes('project data removed')) fail(`purge output wrong:\n${purged}`);
|
|
1182
|
+
if (fs.existsSync(path.join(uninstallRoot, '.hello-cc'))) fail('purge did not remove .hello-cc');
|
|
1183
|
+
for (const file of ['CLAUDE.md', 'AGENTS.md']) {
|
|
1184
|
+
const p = path.join(uninstallRoot, file);
|
|
1185
|
+
const text = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
1186
|
+
if (text.includes('hello-cc:start')) fail(`${file} still has hello-cc block after purge`);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
try { fs.rmSync(uninstallRoot, { recursive: true, force: true }); } catch {}
|
|
1190
|
+
try { fs.rmSync(uninstallHome, { recursive: true, force: true }); } catch {}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function main() {
|
|
1194
|
+
process.once('SIGINT', () => { cleanup(); process.exit(130); });
|
|
1195
|
+
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
1196
|
+
|
|
1197
|
+
await setupRegression();
|
|
1198
|
+
log('[2/12] runtime');
|
|
1199
|
+
startRuntime();
|
|
1200
|
+
await waitRuntime();
|
|
1201
|
+
hcc(['peer', 'list']);
|
|
1202
|
+
await dbWorkflow();
|
|
1203
|
+
await multiProjectWebWorkflow();
|
|
1204
|
+
await tmuxBackedStartWorkflow();
|
|
1205
|
+
await shimTmuxWorkflow();
|
|
1206
|
+
await tmuxWorkflow();
|
|
1207
|
+
await askBroadcastWorkflow();
|
|
1208
|
+
await downGcPackWorkflow();
|
|
1209
|
+
oldNameScan();
|
|
1210
|
+
syntaxAndHelp();
|
|
1211
|
+
uninstallWorkflow();
|
|
1212
|
+
log('FULL_REGRESSION_OK');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
main().catch((err) => {
|
|
1216
|
+
cleanup();
|
|
1217
|
+
process.stderr.write(`${err.stack || err.message}\n`);
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}).finally(() => {
|
|
1220
|
+
cleanup();
|
|
1221
|
+
});
|