@logicseek/hello-cc 0.1.2 → 0.1.3

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.
@@ -107,7 +107,9 @@ function withMeshDb(fn) {
107
107
 
108
108
  function providerBindingRows(provider, sessionName) {
109
109
  return withMeshDb((db) => db.prepare(`
110
- SELECT peer, provider, provider_session_id, provider_session_name, transport, runtime_target
110
+ SELECT
111
+ peer, provider, provider_session_id, provider_session_name,
112
+ resume_mode, resume_arg, command, transport, runtime_target
111
113
  FROM peer_bindings
112
114
  WHERE provider = ? AND provider_session_name = ?
113
115
  ORDER BY peer
@@ -180,6 +182,42 @@ function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget)
180
182
  });
181
183
  }
182
184
 
185
+ function moveRuntimeBindingPeer(fromPeer, toPeer) {
186
+ withMeshDb((db) => {
187
+ const peer = db.prepare('SELECT * FROM peers WHERE id = ?').get(fromPeer);
188
+ if (!peer) fail(`cannot move missing peer ${fromPeer}`);
189
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(fromPeer);
190
+ if (!binding) fail(`cannot move missing peer binding ${fromPeer}`);
191
+ const t = Math.floor(Date.now() / 1000);
192
+ db.prepare(`
193
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195
+ ON CONFLICT(id) DO UPDATE SET
196
+ kind = excluded.kind,
197
+ role = excluded.role,
198
+ worktree = excluded.worktree,
199
+ branch = excluded.branch,
200
+ pid = excluded.pid,
201
+ status = excluded.status,
202
+ capabilities = excluded.capabilities,
203
+ last_seen_at = excluded.last_seen_at
204
+ `).run(
205
+ toPeer,
206
+ peer.kind,
207
+ peer.role,
208
+ peer.worktree,
209
+ peer.branch,
210
+ peer.pid,
211
+ peer.status,
212
+ peer.capabilities,
213
+ t,
214
+ t
215
+ );
216
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(toPeer);
217
+ db.prepare('UPDATE peer_bindings SET peer = ?, updated_at = ? WHERE peer = ?').run(toPeer, t, fromPeer);
218
+ });
219
+ }
220
+
183
221
  function assertSqliteUniqueFailure(label, fn) {
184
222
  try {
185
223
  fn();
@@ -283,6 +321,227 @@ function createLegacyBindingDb(dbPath) {
283
321
  }
284
322
  }
285
323
 
324
+ function createLegacySchemaDb(dbPath) {
325
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
326
+ const db = new DatabaseSync(dbPath);
327
+ try {
328
+ const t = Math.floor(Date.now() / 1000) - 3600;
329
+ db.exec(`
330
+ CREATE TABLE meta (
331
+ key TEXT PRIMARY KEY,
332
+ value TEXT NOT NULL
333
+ );
334
+ INSERT INTO meta(key, value) VALUES ('schema_version', '1');
335
+
336
+ CREATE TABLE peers (
337
+ id TEXT PRIMARY KEY,
338
+ kind TEXT NOT NULL,
339
+ role TEXT,
340
+ worktree TEXT,
341
+ branch TEXT,
342
+ pid INTEGER,
343
+ status TEXT NOT NULL DEFAULT 'idle',
344
+ capabilities TEXT,
345
+ created_at INTEGER NOT NULL,
346
+ last_seen_at INTEGER NOT NULL
347
+ );
348
+
349
+ CREATE TABLE peer_bindings (
350
+ peer TEXT PRIMARY KEY,
351
+ provider TEXT NOT NULL,
352
+ provider_session_id TEXT,
353
+ provider_session_name TEXT,
354
+ resume_mode TEXT NOT NULL DEFAULT 'new',
355
+ resume_arg TEXT,
356
+ command TEXT,
357
+ transport TEXT NOT NULL,
358
+ runtime_session_id TEXT,
359
+ created_at INTEGER NOT NULL,
360
+ updated_at INTEGER NOT NULL
361
+ );
362
+
363
+ CREATE TABLE tasks (
364
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
365
+ title TEXT NOT NULL,
366
+ body TEXT,
367
+ status TEXT NOT NULL DEFAULT 'pending',
368
+ assignee TEXT,
369
+ owner TEXT,
370
+ priority INTEGER NOT NULL DEFAULT 100,
371
+ created_by TEXT,
372
+ claimed_at INTEGER,
373
+ completed_at INTEGER,
374
+ created_at INTEGER NOT NULL,
375
+ updated_at INTEGER NOT NULL
376
+ );
377
+
378
+ CREATE TABLE messages (
379
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
380
+ sender TEXT NOT NULL,
381
+ recipient TEXT,
382
+ task_id INTEGER,
383
+ kind TEXT NOT NULL DEFAULT 'note',
384
+ body TEXT NOT NULL,
385
+ created_at INTEGER NOT NULL
386
+ );
387
+
388
+ CREATE TABLE message_reads (
389
+ message_id INTEGER NOT NULL,
390
+ peer TEXT NOT NULL,
391
+ read_at INTEGER NOT NULL,
392
+ PRIMARY KEY (message_id, peer)
393
+ );
394
+
395
+ CREATE TABLE locks (
396
+ resource TEXT PRIMARY KEY,
397
+ owner TEXT NOT NULL,
398
+ task_id INTEGER,
399
+ reason TEXT,
400
+ expires_at INTEGER NOT NULL,
401
+ created_at INTEGER NOT NULL
402
+ );
403
+
404
+ CREATE TABLE handoffs (
405
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
406
+ task_id INTEGER,
407
+ from_peer TEXT NOT NULL,
408
+ to_peer TEXT,
409
+ summary TEXT NOT NULL,
410
+ changed_files TEXT,
411
+ tests TEXT,
412
+ risks TEXT,
413
+ created_at INTEGER NOT NULL
414
+ );
415
+
416
+ CREATE TABLE events (
417
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
418
+ type TEXT NOT NULL,
419
+ actor TEXT,
420
+ task_id INTEGER,
421
+ payload TEXT,
422
+ created_at INTEGER NOT NULL
423
+ );
424
+
425
+ CREATE INDEX idx_tasks_status_priority ON tasks(status, priority, id);
426
+ CREATE INDEX idx_tasks_owner ON tasks(owner);
427
+ CREATE INDEX idx_messages_recipient_id ON messages(recipient, id);
428
+ CREATE INDEX idx_events_id ON events(id);
429
+ CREATE INDEX idx_locks_expires ON locks(expires_at);
430
+
431
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
432
+ VALUES ('legacy-peer', 'codex', 'peer', '${root.replace(/'/g, "''")}', '', NULL, 'idle', 'legacy', ${t}, ${t});
433
+ INSERT INTO peer_bindings(peer, provider, provider_session_id, provider_session_name, resume_mode, resume_arg, command, transport, runtime_session_id, created_at, updated_at)
434
+ VALUES ('legacy-peer', 'codex', NULL, 'legacy-session', 'detected', NULL, NULL, 'detected', 'legacy-peer', ${t}, ${t});
435
+ INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
436
+ VALUES ('legacy-peer', 'all', NULL, 'note', 'legacy-message', ${t});
437
+ `);
438
+ } finally {
439
+ db.close();
440
+ }
441
+ }
442
+
443
+ function assertLegacySchemaMigration() {
444
+ const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-schema-root-${testId}`);
445
+ const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
446
+ try {
447
+ createLegacySchemaDb(legacyDb);
448
+ run(process.execPath, [hccBin, '--root', legacyRoot, 'status', '--peer', 'legacy-check'], { env });
449
+ const db = new DatabaseSync(legacyDb);
450
+ try {
451
+ const peerBindingColumns = new Set(db.prepare('PRAGMA table_info(peer_bindings)').all().map((row) => row.name));
452
+ if (!peerBindingColumns.has('runtime_target')) fail('legacy migration did not add peer_bindings.runtime_target');
453
+ const messageColumns = new Set(db.prepare('PRAGMA table_info(messages)').all().map((row) => row.name));
454
+ for (const column of ['reply_to', 'thread_id']) {
455
+ if (!messageColumns.has(column)) fail(`legacy migration did not add messages.${column}`);
456
+ }
457
+ const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
458
+ for (const column of ['parent_id', 'team_role']) {
459
+ if (!taskColumns.has(column)) fail(`legacy migration did not add tasks.${column}`);
460
+ }
461
+ const message = db.prepare('SELECT id, thread_id FROM messages WHERE body = ?').get('legacy-message');
462
+ if (!message || message.thread_id !== message.id) {
463
+ fail(`legacy message thread_id not backfilled:\n${JSON.stringify(message, null, 2)}`);
464
+ }
465
+ const metaVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
466
+ const pragmaVersion = db.prepare('PRAGMA user_version').get().user_version;
467
+ if (metaVersion !== '4' || pragmaVersion !== 4) {
468
+ fail(`schema version not synchronized: meta=${metaVersion} pragma=${pragmaVersion}`);
469
+ }
470
+ const migrations = db.prepare('SELECT version, name FROM schema_migrations ORDER BY version').all();
471
+ if (migrations.length !== 4 || migrations[3].version !== 4 || migrations[3].name !== 'team task hierarchy') {
472
+ fail(`schema migrations history wrong:\n${JSON.stringify(migrations, null, 2)}`);
473
+ }
474
+ } finally {
475
+ db.close();
476
+ }
477
+ } finally {
478
+ try { fs.rmSync(legacyRoot, { recursive: true, force: true }); } catch {}
479
+ }
480
+ }
481
+
482
+ function assertRegisteredProjectDbMigration() {
483
+ const otherRoot = path.join(os.tmpdir(), `hcc-reg-registered-legacy-root-${testId}`);
484
+ const otherDb = path.join(otherRoot, '.hello-cc', 'mesh.db');
485
+ const registryFile = path.join(home, '.hello-cc', 'projects.json');
486
+ try {
487
+ createLegacySchemaDb(otherDb);
488
+ fs.mkdirSync(path.dirname(registryFile), { recursive: true });
489
+ fs.writeFileSync(registryFile, JSON.stringify({
490
+ projects: [
491
+ { root, db: path.join(root, '.hello-cc', 'mesh.db'), name: 'current', last_seen_at: 2 },
492
+ { root: otherRoot, db: otherDb, name: 'registered-legacy', last_seen_at: 1 }
493
+ ]
494
+ }, null, 2));
495
+ hcc(['status', '--peer', 'registered-migration-check']);
496
+ const db = new DatabaseSync(otherDb);
497
+ try {
498
+ const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
499
+ if (!taskColumns.has('parent_id') || !taskColumns.has('team_role')) {
500
+ fail(`registered project DB was not migrated:\n${JSON.stringify([...taskColumns], null, 2)}`);
501
+ }
502
+ const version = db.prepare('PRAGMA user_version').get().user_version;
503
+ if (version !== 4) fail(`registered project DB user_version wrong: ${version}`);
504
+ } finally {
505
+ db.close();
506
+ }
507
+ } finally {
508
+ try { fs.rmSync(otherRoot, { recursive: true, force: true }); } catch {}
509
+ }
510
+ }
511
+
512
+ function assertFutureSchemaMigrationHistoryRejected() {
513
+ const futureRoot = path.join(os.tmpdir(), `hcc-reg-future-schema-root-${testId}`);
514
+ const futureDb = path.join(futureRoot, '.hello-cc', 'mesh.db');
515
+ try {
516
+ fs.mkdirSync(path.dirname(futureDb), { recursive: true });
517
+ const db = new DatabaseSync(futureDb);
518
+ try {
519
+ db.exec(`
520
+ CREATE TABLE meta (
521
+ key TEXT PRIMARY KEY,
522
+ value TEXT NOT NULL
523
+ );
524
+ INSERT INTO meta(key, value) VALUES ('schema_version', '4');
525
+ CREATE TABLE schema_migrations (
526
+ version INTEGER PRIMARY KEY,
527
+ name TEXT NOT NULL,
528
+ applied_at INTEGER NOT NULL
529
+ );
530
+ INSERT INTO schema_migrations(version, name, applied_at)
531
+ VALUES (999, 'future migration', 1);
532
+ `);
533
+ } finally {
534
+ db.close();
535
+ }
536
+ const result = runMaybe(process.execPath, [hccBin, '--root', futureRoot, 'status', '--peer', 'future-schema-check'], { env });
537
+ if (result.status === 0 || !`${result.stdout}\n${result.stderr}`.includes('Database schema version 999 is newer than this hcc (4)')) {
538
+ fail(`future schema migration history was not rejected:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
539
+ }
540
+ } finally {
541
+ try { fs.rmSync(futureRoot, { recursive: true, force: true }); } catch {}
542
+ }
543
+ }
544
+
286
545
  function assertLegacyBindingRepair() {
287
546
  const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-root-${testId}`);
288
547
  const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
@@ -403,6 +662,59 @@ function ensureFile(file, expected = null) {
403
662
  }
404
663
  }
405
664
 
665
+ function assertGuidanceLockPolicy(file) {
666
+ const text = fs.readFileSync(file, 'utf8');
667
+ for (const expected of [
668
+ 'Read-only work:',
669
+ 'do not require locks',
670
+ 'For read-only review, do not acquire file locks',
671
+ 'not a final commit-ready verdict',
672
+ 'Review and monitoring:',
673
+ "Reviewing another peer's work is a read-only activity",
674
+ 'proactively send that',
675
+ 'affected file or behavior',
676
+ 'Do not silently treat a snapshot review as final approval',
677
+ 'Before mutating work:',
678
+ 'Before editing or mutating shared resources:',
679
+ 'Commit-readiness checks are read-only until staging begins',
680
+ 'lock `.git/index` only while staging and'
681
+ ]) {
682
+ if (!text.includes(expected)) fail(`guidance lock policy missing ${expected} in ${file}`);
683
+ }
684
+ }
685
+
686
+ function runtimeUrl(runtime, route, params = {}) {
687
+ const url = new URL(route, runtime.base_url || `http://127.0.0.1:${port}`);
688
+ if (runtime.token) url.searchParams.set('token', runtime.token);
689
+ for (const [key, value] of Object.entries(params)) {
690
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
691
+ }
692
+ return url.toString();
693
+ }
694
+
695
+ function currentRuntime() {
696
+ return JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
697
+ }
698
+
699
+ function currentRuntimeUrl(route, params = {}) {
700
+ return runtimeUrl(currentRuntime(), route, params);
701
+ }
702
+
703
+ function runtimeFetch(route, options = {}, params = {}) {
704
+ const runtime = currentRuntime();
705
+ const headers = { ...(options.headers || {}) };
706
+ if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
707
+ return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
708
+ }
709
+
710
+ function runtimeWsUrl(peer) {
711
+ const runtime = currentRuntime();
712
+ const url = new URL(`/ws/terminal/${encodeURIComponent(peer)}`, runtime.base_url || `http://127.0.0.1:${port}`);
713
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
714
+ if (runtime.token) url.searchParams.set('token', runtime.token);
715
+ return url.toString();
716
+ }
717
+
406
718
  async function waitFor(check, label, timeoutMs = 10000) {
407
719
  const deadline = Date.now() + timeoutMs;
408
720
  while (Date.now() < deadline) {
@@ -438,8 +750,7 @@ async function waitRuntime() {
438
750
  await waitFor(async () => {
439
751
  if (!fs.existsSync(runtimeFile)) return false;
440
752
  try {
441
- const runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
442
- const response = await fetch(`${runtime.base_url}/api/runtime`);
753
+ const response = await runtimeFetch('/api/runtime');
443
754
  return response.ok;
444
755
  } catch {
445
756
  return false;
@@ -451,7 +762,7 @@ async function expectWebSocketMarker(peer, marker) {
451
762
  await new Promise((resolve, reject) => {
452
763
  let sawSnapshot = false;
453
764
  let sawMarker = false;
454
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/terminal/${encodeURIComponent(peer)}`);
765
+ const ws = new WebSocket(runtimeWsUrl(peer));
455
766
  const timer = setTimeout(() => reject(new Error(`${peer} websocket timeout`)), 5000);
456
767
  ws.on('open', () => {
457
768
  const result = hccMaybe(['inject', peer, `echo ${marker}`]);
@@ -476,7 +787,7 @@ async function expectWebSocketMarker(peer, marker) {
476
787
  async function expectResizeReplaceSnapshot(peer, marker) {
477
788
  await new Promise((resolve, reject) => {
478
789
  let sawSnapshot = false;
479
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/terminal/${encodeURIComponent(peer)}`);
790
+ const ws = new WebSocket(runtimeWsUrl(peer));
480
791
  const timer = setTimeout(() => reject(new Error(`${peer} resize replace timeout`)), 5000);
481
792
  ws.on('open', () => {
482
793
  const result = hccMaybe(['inject', peer, `echo ${marker}`]);
@@ -640,24 +951,92 @@ async function setupRegression() {
640
951
  }
641
952
  await stopRuntime();
642
953
  assertPeerBindingUniqueConstraints();
954
+ assertLegacySchemaMigration();
955
+ assertRegisteredProjectDbMigration();
956
+ assertFutureSchemaMigrationHistoryRejected();
643
957
  assertLegacyBindingRepair();
644
958
 
645
- const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
959
+ const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
960
+ const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
961
+ if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
962
+ runtimePid = Number.parseInt(tokenMatch[1], 10);
963
+ if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
964
+ fail(`default web output did not include remote token URL:\n${tokenOutput}`);
965
+ }
966
+ await waitRuntime();
967
+ const tokenRuntime = currentRuntime();
968
+ if (tokenRuntime.host !== '0.0.0.0' || !tokenRuntime.token || tokenRuntime.token.length < 24) {
969
+ fail(`default web runtime did not store remote token data:\n${JSON.stringify(tokenRuntime, null, 2)}`);
970
+ }
971
+ const unauthorizedResponse = await fetch(`${tokenRuntime.base_url}/api/runtime`);
972
+ if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
973
+ const tokenResponse = await runtimeFetch('/api/runtime');
974
+ if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
975
+ const tokenFile = path.join(home, '.hello-cc', 'web-token');
976
+ ensureFile(tokenFile, tokenRuntime.token);
977
+ fs.rmSync(tokenFile, { force: true });
978
+ const existingTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
979
+ if (!existingTokenOutput.includes('web already running in background')) {
980
+ fail(`web did not reuse existing token runtime:\n${existingTokenOutput}`);
981
+ }
982
+ ensureFile(tokenFile, tokenRuntime.token);
983
+ await stopRuntime();
984
+
985
+ const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
986
+ const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
987
+ if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
988
+ runtimePid = Number.parseInt(stableTokenMatch[1], 10);
989
+ await waitRuntime();
990
+ const stableTokenRuntime = currentRuntime();
991
+ if (stableTokenRuntime.token !== tokenRuntime.token) {
992
+ fail(`default web token changed across restart:\nfirst=${tokenRuntime.token}\nsecond=${stableTokenRuntime.token}`);
993
+ }
994
+ await stopRuntime();
995
+
996
+ const fixedToken = `fixed-token-${testId}`;
997
+ const fixedTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance'], {
998
+ env: { ...env, HCC_WEB_TOKEN: fixedToken }
999
+ });
1000
+ const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
1001
+ if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
1002
+ runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
1003
+ await waitRuntime();
1004
+ const fixedTokenRuntime = currentRuntime();
1005
+ if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
1006
+ fail(`explicit stable token was not used:\n${fixedTokenOutput}\n${JSON.stringify(fixedTokenRuntime, null, 2)}`);
1007
+ }
1008
+ await stopRuntime();
1009
+
1010
+ const persistedFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
1011
+ const persistedFixedMatch = persistedFixedOutput.match(/^pid:\s*(\d+)/m);
1012
+ if (!persistedFixedMatch) fail(`persisted fixed-token web did not print background pid:\n${persistedFixedOutput}`);
1013
+ runtimePid = Number.parseInt(persistedFixedMatch[1], 10);
1014
+ await waitRuntime();
1015
+ const persistedFixedRuntime = currentRuntime();
1016
+ if (persistedFixedRuntime.token !== fixedToken) {
1017
+ fail(`explicit stable token was not persisted:\n${JSON.stringify(persistedFixedRuntime, null, 2)}`);
1018
+ }
1019
+ await stopRuntime();
1020
+
1021
+ const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
646
1022
  const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
647
- if (!noTokenMatch) fail(`no-token web did not print background pid:\n${noTokenOutput}`);
1023
+ if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
648
1024
  runtimePid = Number.parseInt(noTokenMatch[1], 10);
649
- if (noTokenOutput.includes('token=') || noTokenOutput.includes('token was generated')) {
650
- fail(`default web output unexpectedly required token:\n${noTokenOutput}`);
1025
+ if (noTokenOutput.includes('token=')) {
1026
+ fail(`explicit no-token web output included token:\n${noTokenOutput}`);
651
1027
  }
652
1028
  await waitRuntime();
653
- const noTokenRuntime = JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
654
- if (noTokenRuntime.token || Object.hasOwn(noTokenRuntime, 'token_generated')) {
655
- fail(`default web runtime unexpectedly stored token data:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
656
- }
1029
+ const noTokenRuntime = currentRuntime();
1030
+ if (noTokenRuntime.token) fail(`explicit no-token runtime stored token:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
657
1031
  const noTokenResponse = await fetch(`${noTokenRuntime.base_url}/api/runtime`);
658
- if (!noTokenResponse.ok) fail(`default web API required token: ${noTokenResponse.status}`);
1032
+ if (!noTokenResponse.ok) fail(`explicit no-token web API required token: ${noTokenResponse.status}`);
659
1033
  await stopRuntime();
660
1034
 
1035
+ const conflictingToken = hccMaybe(['web', '--local', '--port', String(port), '--token', 'abc', '--no-token', '--no-discover', '--no-guidance']);
1036
+ if (conflictingToken.status === 0 || !String(conflictingToken.stderr || conflictingToken.stdout).includes('--no-token cannot be combined')) {
1037
+ fail(`web accepted conflicting --token and --no-token:\n${conflictingToken.stdout}\n${conflictingToken.stderr}`);
1038
+ }
1039
+
661
1040
  const childDir = path.join(root, 'packages', 'child');
662
1041
  fs.mkdirSync(childDir, { recursive: true });
663
1042
  const childFindRoot = hccFrom(['find-root'], childDir).trim();
@@ -714,9 +1093,71 @@ async function dbWorkflow() {
714
1093
  if (!inbox.includes('please review')) fail('inbox did not include message');
715
1094
  const msgMatch = inbox.match(/^#(\d+)/m);
716
1095
  if (!msgMatch) fail(`cannot parse message id: ${inbox}`);
717
- hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgMatch[1]]);
1096
+ const msgId = msgMatch[1];
1097
+ const reply = hcc(['msg', 'reply', '--from', 'claude-a', '--id', msgId, '--body', 'review reply']);
1098
+ const replyMatch = reply.match(/sent reply #(\d+) to #(\d+)/);
1099
+ if (!replyMatch || replyMatch[2] !== msgId) fail(`cannot parse reply output: ${reply}`);
1100
+ const thread = hcc(['msg', 'thread', '--id', msgId]);
1101
+ if (!thread.includes(`thread #${msgId}`) || !thread.includes('please review') || !thread.includes('review reply')) {
1102
+ fail(`thread output missing original or reply:\n${thread}`);
1103
+ }
1104
+ const replyInbox = hcc(['msg', 'inbox', '--peer', 'codex-a']);
1105
+ if (!replyInbox.includes('review reply') || !replyInbox.includes(`#${msgId}`)) {
1106
+ fail(`reply did not reach original sender inbox with thread context:\n${replyInbox}`);
1107
+ }
1108
+ const stateBeforeAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/router']);
1109
+ if (stateBeforeAck.automation?.schema_version !== 1 || stateBeforeAck.automation.phase !== 'reply_message') {
1110
+ fail(`state automation did not prioritize unread reply:\n${JSON.stringify(stateBeforeAck, null, 2)}`);
1111
+ }
1112
+ if (stateBeforeAck.automation.next_action.kind !== 'msg.reply' || !stateBeforeAck.automation.next_action.argv.includes(replyMatch[1])) {
1113
+ fail(`state next action did not target unread reply:\n${JSON.stringify(stateBeforeAck.automation, null, 2)}`);
1114
+ }
1115
+ const timelineIds = new Set((stateBeforeAck.timeline || []).map((item) => item.id));
1116
+ if (!timelineIds.has(`message:${msgId}`) || !timelineIds.has(`message:${replyMatch[1]}`)) {
1117
+ fail(`state timeline missing message thread entries:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
1118
+ }
1119
+ if ([...(stateBeforeAck.timeline || [])].some((item) => item.kind === 'message.sent' || item.kind === 'message.ack')) {
1120
+ fail(`state timeline includes noisy message events:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
1121
+ }
1122
+ hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgId]);
1123
+ hcc(['msg', 'ack', '--peer', 'codex-a', '--id', replyMatch[1]]);
1124
+ const stateAfterAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/new-lock']);
1125
+ if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
1126
+ fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
1127
+ }
1128
+ const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
1129
+ const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
1130
+ if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
1131
+ const queuedTaskId = queuedTaskMatch[1];
1132
+ const busyState = hccJson(['state', '--peer', 'codex-a']);
1133
+ if (String(busyState.automation.current_task?.id) !== String(taskId)) {
1134
+ fail(`state did not preserve current task while another task was assigned:\n${JSON.stringify(busyState.automation, null, 2)}`);
1135
+ }
1136
+ if (['task.claim', 'task.next', 'msg.inbox'].includes(busyState.automation.next_action.kind)) {
1137
+ fail(`state let a new assigned task interrupt current work:\n${JSON.stringify(busyState.automation, null, 2)}`);
1138
+ }
1139
+ const busyHookPayload = JSON.stringify({ session_id: 'codex-busy-session', cwd: root, hook_event_name: 'UserPromptSubmit', prompt: 'new user prompt while busy' });
1140
+ const busyHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: { ...env, HCC_PEER: 'codex-a' }, input: busyHookPayload }), 'UserPromptSubmit');
1141
+ if (!busyHook.includes('[hello-cc current task]') || !busyHook.includes(`#${taskId} running`)) {
1142
+ fail(`hook did not preserve current task while another task was assigned:\n${busyHook}`);
1143
+ }
1144
+ if (busyHook.includes(`hcc task claim --peer codex-a --id ${queuedTaskId}`)) {
1145
+ fail(`hook suggested claiming a new task while current task was active:\n${busyHook}`);
1146
+ }
1147
+ const nextAgain = hcc(['task', 'next', '--peer', 'codex-a']);
1148
+ if (!nextAgain.includes(`current task #${taskId}`)) {
1149
+ fail(`task next did not preserve current task:\n${nextAgain}`);
1150
+ }
1151
+ hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
718
1152
  hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
719
1153
  if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
1154
+ const stateAfterHandoff = hccJson(['state', '--peer', 'claude-a']);
1155
+ if (!(stateAfterHandoff.timeline || []).some((item) => item.id.startsWith('handoff:') && item.text.includes('handoff summary'))) {
1156
+ fail(`state timeline missing handoff item:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
1157
+ }
1158
+ if ((stateAfterHandoff.timeline || []).some((item) => item.kind === 'handoff.created')) {
1159
+ fail(`state timeline includes noisy handoff event:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
1160
+ }
720
1161
  if (!hcc(['status', '--peer', 'codex-a']).includes('codex-a')) fail('status missing peer');
721
1162
  hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/router']);
722
1163
  hcc(['task', 'done', '--peer', 'codex-a', '--id', taskId, '--summary', 'done']);
@@ -725,6 +1166,77 @@ async function dbWorkflow() {
725
1166
  if (!hasTask(hccJson(['task', 'list', '--all']), taskId)) fail(`done task missing from --all list: #${taskId}`);
726
1167
  if (!hasTask(hccJson(['task', 'list', '--status', 'done']), taskId)) fail(`done task missing from --status done list: #${taskId}`);
727
1168
 
1169
+ const teamParent = hcc([
1170
+ 'task', 'create',
1171
+ '--from', 'human',
1172
+ '--to', 'codex-team-captain',
1173
+ '--title', 'parallel release cleanup',
1174
+ '--body', '- Update docs\n- Add regression\n- Verify migration'
1175
+ ]);
1176
+ const teamParentMatch = teamParent.match(/created task #(\d+):/);
1177
+ if (!teamParentMatch) fail(`cannot parse team parent id: ${teamParent}`);
1178
+ const teamParentId = teamParentMatch[1];
1179
+ hcc(['task', 'claim', '--peer', 'codex-team-captain', '--id', teamParentId]);
1180
+ hcc(['task', 'update', '--peer', 'codex-team-captain', '--id', teamParentId, '--status', 'running', '--summary', 'team captain']);
1181
+ const teamState = hccJson(['state', '--peer', 'codex-team-captain']);
1182
+ if (teamState.automation.phase !== 'team_plan' || teamState.automation.next_action.kind !== 'team.plan') {
1183
+ fail(`state did not suggest team plan for splittable task:\n${JSON.stringify(teamState.automation, null, 2)}`);
1184
+ }
1185
+ const teamPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
1186
+ if (!teamPlan.includes('team plan for task') || !teamPlan.includes('Update docs')) {
1187
+ fail(`team plan output wrong:\n${teamPlan}`);
1188
+ }
1189
+ const kindWorkerPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Docs slot', '--item', 'tests:Tests slot', '--workers', 'codex:2']);
1190
+ if (!kindWorkerPlan.includes(`codex-team-${teamParentId}-1`) || !kindWorkerPlan.includes(`codex-team-${teamParentId}-2`)) {
1191
+ fail(`team workers kind-count syntax was not expanded:\n${kindWorkerPlan}`);
1192
+ }
1193
+ const childrenBeforeStart = withMeshDb((db) => db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE parent_id = ?').get(teamParentId).n);
1194
+ if (childrenBeforeStart !== 0) fail(`team plan created children: ${childrenBeforeStart}`);
1195
+ const teamStart = hccJson(['team', 'start', '--from', 'codex-team-captain', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
1196
+ if (teamStart.children.length !== 2) {
1197
+ fail(`team start created wrong child count:\n${JSON.stringify(teamStart, null, 2)}`);
1198
+ }
1199
+ const teamRows = withMeshDb((db) => db.prepare(`
1200
+ SELECT id, title, assignee, parent_id, team_role
1201
+ FROM tasks
1202
+ WHERE parent_id = ?
1203
+ ORDER BY id
1204
+ `).all(teamParentId));
1205
+ if (teamRows.length !== 2 ||
1206
+ teamRows[0].assignee !== 'codex-worker-a' ||
1207
+ teamRows[1].assignee !== 'claude-worker-a' ||
1208
+ teamRows.some((row) => String(row.parent_id) !== String(teamParentId) || !row.team_role)) {
1209
+ fail(`team child rows wrong:\n${JSON.stringify(teamRows, null, 2)}`);
1210
+ }
1211
+ const duplicateTeamStart = hccMaybe(['team', 'start', '--from-task', teamParentId]);
1212
+ if (duplicateTeamStart.status === 0 || !String(duplicateTeamStart.stderr || duplicateTeamStart.stdout).includes('already has')) {
1213
+ fail(`duplicate team start was not rejected:\n${duplicateTeamStart.stdout}\n${duplicateTeamStart.stderr}`);
1214
+ }
1215
+ const teamStatus = hcc(['team', 'status', '--task', teamParentId]);
1216
+ if (!teamStatus.includes('subtasks: 2') || !teamStatus.includes('codex-worker-a')) {
1217
+ fail(`team status output wrong:\n${teamStatus}`);
1218
+ }
1219
+ for (const row of teamRows) {
1220
+ hcc(['task', 'update', '--peer', 'human', '--force', '--id', String(row.id), '--status', 'abandoned', '--summary', 'team regression cleanup']);
1221
+ }
1222
+ hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
1223
+
1224
+ const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
1225
+ const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
1226
+ if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
1227
+ const conflictTaskId = conflictTaskMatch[1];
1228
+ hcc(['task', 'claim', '--peer', 'codex-b', '--id', conflictTaskId]);
1229
+ hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'src/conflict', '--ttl', '60', '--reason', 'held by other peer']);
1230
+ const conflictState = hccJson(['state', '--peer', 'codex-b', '--resource', 'src/conflict']);
1231
+ if (conflictState.automation.phase !== 'coordinate_lock' || conflictState.automation.next_action.kind !== 'msg.send') {
1232
+ fail(`state automation did not suggest lock coordination:\n${JSON.stringify(conflictState.automation, null, 2)}`);
1233
+ }
1234
+ if (conflictState.automation.next_action.argv.includes('--force')) {
1235
+ fail(`state automation suggested forcing a lock:\n${JSON.stringify(conflictState.automation, null, 2)}`);
1236
+ }
1237
+ hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
1238
+ hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
1239
+
728
1240
  const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
729
1241
  const abandonedMatch = abandoned.match(/created task #(\d+):/);
730
1242
  if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
@@ -753,9 +1265,12 @@ async function dbWorkflow() {
753
1265
  if (!firstHook.includes('[hello-cc known peers]') || !firstHook.includes('do not say sessions are isolated')) {
754
1266
  fail(`UserPromptSubmit hook missing strong cross-session instruction:\n${firstHook}`);
755
1267
  }
756
- if (!firstHook.includes('hcc task list') || !firstHook.includes('hook-only-message')) {
1268
+ if (!firstHook.includes('hcc task list') || !firstHook.includes('hcc msg reply --id <message-id>') || !firstHook.includes('hook-only-message')) {
757
1269
  fail(`UserPromptSubmit hook missing instructions or unread message:\n${firstHook}`);
758
1270
  }
1271
+ if (!firstHook.includes('[hello-cc next action]') || !firstHook.includes('phase: reply_message') || !firstHook.includes('hcc msg reply')) {
1272
+ fail(`UserPromptSubmit hook missing executable next action:\n${firstHook}`);
1273
+ }
759
1274
  const secondHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
760
1275
  if (!secondHook.includes(`#${hookTaskId} running`)) {
761
1276
  fail(`UserPromptSubmit hook stopped showing open task after first read:\n${secondHook}`);
@@ -763,6 +1278,9 @@ async function dbWorkflow() {
763
1278
  if (secondHook.includes('hook-only-message')) {
764
1279
  fail(`UserPromptSubmit hook repeated acked unread message:\n${secondHook}`);
765
1280
  }
1281
+ if (!secondHook.includes('[hello-cc next action]')) {
1282
+ fail(`UserPromptSubmit hook dropped next action after ack:\n${secondHook}`);
1283
+ }
766
1284
  const sessionHookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'SessionStart', source: 'resume' });
767
1285
  const sessionHook = hookContext(hcc(['hook', 'sessionstart'], { env: hookEnv, input: sessionHookPayload }), 'SessionStart');
768
1286
  if (!sessionHook.includes(`#${hookTaskId} running`) || sessionHook.includes('hook-only-message')) {
@@ -784,12 +1302,22 @@ async function dbWorkflow() {
784
1302
  const autoTaskId = autoTaskMatch[1];
785
1303
  hcc(['task', 'claim', '--id', autoTaskId], { env: autoEnv });
786
1304
  hcc(['task', 'update', '--id', autoTaskId, '--status', 'running', '--summary', 'auto running'], { env: autoEnv });
1305
+ const autoState = hccJson(['state'], { env: autoEnv });
1306
+ if (autoState.automation.peer?.id !== autoPeer) {
1307
+ fail(`auto state used wrong peer: ${autoPeer}\n${JSON.stringify(autoState.automation, null, 2)}`);
1308
+ }
1309
+ if (String(autoState.automation.current_task?.id) !== String(autoTaskId)) {
1310
+ fail(`auto state lost current task #${autoTaskId}:\n${JSON.stringify(autoState.automation, null, 2)}`);
1311
+ }
1312
+ if (autoState.automation.next_action.kind === 'task.next') {
1313
+ fail(`auto state suggested task.next while current task was active:\n${JSON.stringify(autoState.automation, null, 2)}`);
1314
+ }
787
1315
  hcc(['lock', 'acquire', '--resource', 'auto/resource', '--task', autoTaskId, '--ttl', '60'], { env: autoEnv });
788
1316
  hcc(['lock', 'renew', '--resource', 'auto/resource', '--ttl', '60'], { env: autoEnv });
789
1317
  hcc(['handoff', 'create', '--summary', 'auto handoff', '--tests', 'auto test', '--risks', 'none'], { env: autoEnv });
790
1318
  hcc(['lock', 'release', '--resource', 'auto/resource'], { env: autoEnv });
791
1319
  hcc(['task', 'done', '--id', autoTaskId, '--summary', 'auto done'], { env: autoEnv });
792
- const autoEvents = hcc(['event', 'tail', '--limit', '50']);
1320
+ const autoEvents = hcc(['event', 'tail', '--limit', '200']);
793
1321
  if (!autoEvents.includes('peer.auto_joined') || !autoEvents.includes(autoPeer)) {
794
1322
  fail(`auto join event missing for ${autoPeer}:\n${autoEvents}`);
795
1323
  }
@@ -818,7 +1346,7 @@ async function multiProjectWebWorkflow() {
818
1346
  fail(`second project runtime did not point at global runtime:\n${JSON.stringify(otherRuntime, null, 2)}`);
819
1347
  }
820
1348
 
821
- const projectsResponse = await fetch(`http://127.0.0.1:${port}/api/projects?root=${encodeURIComponent(otherRoot)}`);
1349
+ const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
822
1350
  const projects = await projectsResponse.json();
823
1351
  if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
824
1352
  const roots = new Set((projects.projects || []).map((p) => p.root));
@@ -826,23 +1354,97 @@ async function multiProjectWebWorkflow() {
826
1354
  fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
827
1355
  }
828
1356
 
829
- const detectedResponse = await fetch(`http://127.0.0.1:${port}/api/detected?root=${encodeURIComponent(root)}`);
1357
+ const detectedResponse = await runtimeFetch('/api/detected', {}, { root });
830
1358
  const detectedJson = await detectedResponse.json();
831
1359
  if (!detectedResponse.ok || typeof detectedJson.active_peer_ttl !== 'number' || typeof detectedJson.now !== 'number' || !Array.isArray(detectedJson.detected)) {
832
1360
  fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
833
1361
  }
834
1362
 
835
- const htmlResponse = await fetch(`http://127.0.0.1:${port}/`);
1363
+ const htmlResponse = await fetch(currentRuntimeUrl('/'));
836
1364
  const html = await htmlResponse.text();
837
1365
  for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
838
1366
  if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
839
1367
  }
840
- if (!html.includes('Register Project') || !html.includes('New session') || !html.includes('<strong>Sessions</strong>') || !html.includes('<label>View<select')) {
841
- fail('web form missing simplified project/session controls');
1368
+ for (const expected of [
1369
+ 'id="projectSelect"',
1370
+ 'id="projectPath"',
1371
+ 'id="addProjectBtn"',
1372
+ 'id="startForm"',
1373
+ 'id="kind"',
1374
+ 'id="sessionKindFilter"',
1375
+ 'id="sessions"'
1376
+ ]) {
1377
+ if (!html.includes(expected)) fail(`web form missing simplified project/session control: ${expected}`);
1378
+ }
1379
+ for (const expected of [
1380
+ 'id="langSelect"',
1381
+ "localStorage.getItem('hcc.lang')",
1382
+ "localStorage.setItem('hcc.lang', lang)",
1383
+ "document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'",
1384
+ "zh: {",
1385
+ "language: '语言'",
1386
+ "projectState: '项目状态'",
1387
+ "noSessionSelected: '未选择会话'",
1388
+ "sendMessage: '发送消息'",
1389
+ "peers: '协作方'",
1390
+ "noPeers: '没有协作方。'",
1391
+ "'status.active': '活跃'",
1392
+ "data-i18n=\"language\"",
1393
+ "data-i18n-placeholder=\"projectPathPlaceholder\"",
1394
+ "data-i18n-title=\"collapseSidebar\"",
1395
+ "function applyLanguage()",
1396
+ "function tr(key",
1397
+ "function statusText(status)",
1398
+ "function sessionMetaText(session)",
1399
+ "connText('attached')",
1400
+ "connText('coordinationOnly')",
1401
+ "tr('noActiveSessions')",
1402
+ "tr('nextAction')",
1403
+ "tr('detectedSession')",
1404
+ "tr('messageBodyPlaceholder')",
1405
+ "activeType === 'detected' && activeDetected",
1406
+ "const draft = document.getElementById('detMsg')?.value || ''",
1407
+ "statusText(state.label)",
1408
+ "statusText(automation.phase || 'idle')",
1409
+ "tr('runtime')"
1410
+ ]) {
1411
+ if (!html.includes(expected)) fail(`web UI missing i18n support: ${expected}`);
1412
+ }
1413
+ if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
1414
+ fail('web form missing provider resume controls');
1415
+ }
1416
+ for (const expected of [
1417
+ '--left-width',
1418
+ '--right-width',
1419
+ 'hcc.sidebar.left.width',
1420
+ 'hcc.sidebar.right.width',
1421
+ 'class="edge-resizer edge-resizer-left"',
1422
+ 'class="edge-resizer edge-resizer-right"',
1423
+ 'id="resizeLeft"',
1424
+ 'id="resizeRight"',
1425
+ 'role="separator"',
1426
+ 'aria-orientation="vertical"',
1427
+ 'bindSideHandle(resizeLeftHandle',
1428
+ 'bindSideHandle(resizeRightHandle',
1429
+ 'bindSideHandle(toggleLeftBtn',
1430
+ 'bindSideHandle(toggleRightBtn',
1431
+ 'setPointerCapture',
1432
+ 'sideIsCollapsed(opposite) ? 0',
1433
+ 'Math.abs(delta) <= 3',
1434
+ "localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
1435
+ 'cursor: col-resize'
1436
+ ]) {
1437
+ if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
842
1438
  }
843
1439
  if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('data-section="peers"')) {
844
1440
  fail('web state panel missing scrollable peer state UI');
845
1441
  }
1442
+ if (!html.includes('data-section="timeline"') || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
1443
+ fail('web state panel missing collaboration timeline or refresh routing');
1444
+ }
1445
+ if (!html.includes('data-section="messages"') || !html.includes('messagesData.length')) {
1446
+ fail('web state panel missing dedicated messages card');
1447
+ }
846
1448
 
847
1449
  if (!tmuxAvailable()) return;
848
1450
  const started = hccFrom(['peer', 'start', 'other-shell', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], otherRoot);
@@ -852,8 +1454,8 @@ async function multiProjectWebWorkflow() {
852
1454
  if (rootList.includes('other-shell')) fail(`root project saw second project session:\n${rootList}`);
853
1455
  if (!otherList.includes('other-shell')) fail(`second project did not see its session:\n${otherList}`);
854
1456
 
855
- const rootSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(root)}`)).json();
856
- const otherSessions = await (await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`)).json();
1457
+ const rootSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
1458
+ const otherSessions = await (await runtimeFetch('/api/sessions', {}, { root: otherRoot })).json();
857
1459
  if ((rootSessions.sessions || []).some((s) => s.id === 'other-shell')) {
858
1460
  fail(`root API saw second project session:\n${JSON.stringify(rootSessions)}`);
859
1461
  }
@@ -861,12 +1463,112 @@ async function multiProjectWebWorkflow() {
861
1463
  fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
862
1464
  }
863
1465
 
1466
+ const startProvider = async (payload) => {
1467
+ const response = await runtimeFetch('/api/sessions', {
1468
+ method: 'POST',
1469
+ headers: { 'Content-Type': 'application/json' },
1470
+ body: JSON.stringify({
1471
+ env: {
1472
+ HOME: home,
1473
+ PATH: env.PATH,
1474
+ SHELL: process.env.SHELL || 'bash',
1475
+ HCC_FAKE_STAY_ALIVE: '1'
1476
+ },
1477
+ ...payload
1478
+ })
1479
+ }, { root });
1480
+ const json = await response.json();
1481
+ if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
1482
+ return json.session;
1483
+ };
1484
+ const stopSession = async (id) => {
1485
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
1486
+ };
1487
+
1488
+ const claudeResumeName = `web-claude-resume-${testId}`;
1489
+ const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
1490
+ const expectedClaudePeer = `claude-${claudeResumeName.slice(0, 8)}`;
1491
+ if (!claudeResume.command.includes(`claude --resume ${claudeResumeName}`)) {
1492
+ fail(`web claude resume command wrong:\n${JSON.stringify(claudeResume, null, 2)}`);
1493
+ }
1494
+ if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
1495
+ fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
1496
+ }
1497
+ const claudeRows = providerBindingRows('claude', claudeResumeName);
1498
+ if (claudeRows.length !== 1 ||
1499
+ claudeRows[0].peer !== claudeResume.peer_id ||
1500
+ claudeRows[0].resume_mode !== 'resume' ||
1501
+ claudeRows[0].resume_arg !== claudeResumeName ||
1502
+ claudeRows[0].transport !== 'tmux' ||
1503
+ !claudeRows[0].runtime_target) {
1504
+ fail(`web claude resume binding wrong:\n${JSON.stringify(claudeRows, null, 2)}`);
1505
+ }
1506
+ await stopSession(claudeResume.id);
1507
+
1508
+ const codexResumeName = `web-codex-resume-${testId}`;
1509
+ const codexResume = await startProvider({ kind: 'codex', mode: 'resume', resume: codexResumeName });
1510
+ const expectedCodexPeer = `codex-${codexResumeName.slice(0, 8)}`;
1511
+ if (!codexResume.command.includes(`codex resume ${codexResumeName}`)) {
1512
+ fail(`web codex resume command wrong:\n${JSON.stringify(codexResume, null, 2)}`);
1513
+ }
1514
+ if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
1515
+ fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
1516
+ }
1517
+ const codexRows = providerBindingRows('codex', codexResumeName);
1518
+ if (codexRows.length !== 1 ||
1519
+ codexRows[0].peer !== codexResume.peer_id ||
1520
+ codexRows[0].resume_mode !== 'resume' ||
1521
+ codexRows[0].resume_arg !== codexResumeName ||
1522
+ codexRows[0].transport !== 'tmux' ||
1523
+ !codexRows[0].runtime_target) {
1524
+ fail(`web codex resume binding wrong:\n${JSON.stringify(codexRows, null, 2)}`);
1525
+ }
1526
+ await stopSession(codexResume.id);
1527
+ const codexRowsAfterStop = providerBindingRows('codex', codexResumeName);
1528
+ if (codexRowsAfterStop.length !== 1 || codexRowsAfterStop[0].peer !== expectedCodexPeer) {
1529
+ fail(`web codex resume binding was not stable after stop:\n${JSON.stringify(codexRowsAfterStop, null, 2)}`);
1530
+ }
1531
+
1532
+ const resumableResponse = await runtimeFetch('/api/resumable', {}, { root });
1533
+ const resumableJson = await resumableResponse.json();
1534
+ if (!resumableResponse.ok) fail(`resumable API failed: ${JSON.stringify(resumableJson)}`);
1535
+ const resumableRows = resumableJson.resumable || [];
1536
+ const claudeResumable = resumableRows.find((row) => row.provider === 'claude' && row.resume === claudeResumeName);
1537
+ const codexResumable = resumableRows.find((row) => row.provider === 'codex' && row.resume === codexResumeName);
1538
+ if (!claudeResumable || claudeResumable.session_name !== claudeResumeName || claudeResumable.session_id !== null) {
1539
+ fail(`resumable API omitted named claude resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
1540
+ }
1541
+ if (!codexResumable || codexResumable.session_name !== codexResumeName || codexResumable.session_id !== null) {
1542
+ fail(`resumable API omitted named codex resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
1543
+ }
1544
+
1545
+ const codexLast = await startProvider({ kind: 'codex', mode: 'last' });
1546
+ if (codexLast.command !== 'codex resume --last') {
1547
+ fail(`web codex last command wrong:\n${JSON.stringify(codexLast, null, 2)}`);
1548
+ }
1549
+ await stopSession(codexLast.id);
1550
+
1551
+ const claudeContinue = await startProvider({ kind: 'claude', mode: 'continue' });
1552
+ if (claudeContinue.command !== 'claude --continue') {
1553
+ fail(`web claude continue command wrong:\n${JSON.stringify(claudeContinue, null, 2)}`);
1554
+ }
1555
+ await stopSession(claudeContinue.id);
1556
+
1557
+ const badShellResume = await runtimeFetch('/api/sessions', {
1558
+ method: 'POST',
1559
+ headers: { 'Content-Type': 'application/json' },
1560
+ body: JSON.stringify({ kind: 'shell', mode: 'resume', resume: 'not-supported' })
1561
+ }, { root });
1562
+ if (badShellResume.ok) {
1563
+ fail('web shell resume was accepted');
1564
+ }
1565
+
864
1566
  const startAuto = async () => {
865
- const response = await fetch(`http://127.0.0.1:${port}/api/sessions?root=${encodeURIComponent(otherRoot)}`, {
1567
+ const response = await runtimeFetch('/api/sessions', {
866
1568
  method: 'POST',
867
1569
  headers: { 'Content-Type': 'application/json' },
868
1570
  body: JSON.stringify({ kind: 'shell', command: 'bash --noprofile --norc' })
869
- });
1571
+ }, { root: otherRoot });
870
1572
  const json = await response.json();
871
1573
  if (!response.ok) fail(`auto web session start failed: ${JSON.stringify(json)}`);
872
1574
  return json.session;
@@ -876,8 +1578,8 @@ async function multiProjectWebWorkflow() {
876
1578
  if (!autoOne.id.startsWith('shell-') || !autoTwo.id.startsWith('shell-') || autoOne.id === autoTwo.id) {
877
1579
  fail(`auto web session ids were not unique: ${autoOne.id}, ${autoTwo.id}`);
878
1580
  }
879
- await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoOne.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
880
- await fetch(`http://127.0.0.1:${port}/api/sessions/${encodeURIComponent(autoTwo.id)}/stop?root=${encodeURIComponent(otherRoot)}`, { method: 'POST' });
1581
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(autoOne.id)}/stop`, { method: 'POST' }, { root: otherRoot });
1582
+ await runtimeFetch(`/api/sessions/${encodeURIComponent(autoTwo.id)}/stop`, { method: 'POST' }, { root: otherRoot });
881
1583
  hccFromMaybe(['peer', 'stop', 'other-shell'], otherRoot);
882
1584
  }
883
1585
 
@@ -908,6 +1610,23 @@ async function tmuxBackedStartWorkflow() {
908
1610
  hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
909
1611
  await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
910
1612
 
1613
+ const aliasPeer = 'shell-canonical-alias';
1614
+ parsePane(hcc(['peer', 'start', 'shell-runtime-alias', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
1615
+ moveRuntimeBindingPeer('shell-runtime-alias', aliasPeer);
1616
+ const aliasSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
1617
+ const aliasSession = (aliasSessions.sessions || []).find((session) => session.id === 'shell-runtime-alias');
1618
+ if (!aliasSession || aliasSession.peer_id !== aliasPeer) {
1619
+ fail(`sessions API did not expose canonical peer id for runtime alias:\n${JSON.stringify(aliasSessions, null, 2)}`);
1620
+ }
1621
+ const aliasDetected = await (await runtimeFetch('/api/detected', {}, { root })).json();
1622
+ if ((aliasDetected.detected || []).some((peer) => peer.id === aliasPeer || peer.id === 'shell-runtime-alias')) {
1623
+ fail(`detected API showed managed runtime/canonical duplicate:\n${JSON.stringify(aliasDetected, null, 2)}`);
1624
+ }
1625
+ const aliasFile = path.join(outDir, 'runtime-alias-ok');
1626
+ hcc(['inject', aliasPeer, `echo RUNTIME_ALIAS_OK > ${aliasFile}`]);
1627
+ await waitForFile(aliasFile, 'RUNTIME_ALIAS_OK', 'canonical peer injection to runtime alias');
1628
+ hcc(['peer', 'stop', aliasPeer]);
1629
+
911
1630
  const canonicalSession = 'canonical-session';
912
1631
  insertStaleProviderBinding('claude-stale-canonical', 'claude', canonicalSession);
913
1632
  const canonicalStarted = hcc(['peer', 'start', 'claude-live-canonical', '--kind', 'claude', '--resume', canonicalSession], {
@@ -1126,15 +1845,40 @@ async function downGcPackWorkflow() {
1126
1845
  hcc(['gc', '--older-than', '0', '--yes']);
1127
1846
  const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
1128
1847
  const files = new Set(pack[0]?.files?.map((entry) => entry.path) || []);
1129
- for (const file of ['assets/logo.svg', 'CHANGELOG.md', 'docs/commands.md', 'docs/commands.zh-CN.md']) {
1848
+ for (const file of ['assets/logo.svg', 'CHANGELOG.md', 'docs/commands.md', 'docs/commands.zh-CN.md', 'scripts/github-release.mjs']) {
1130
1849
  if (!files.has(file)) fail(`npm package missing ${file}`);
1131
1850
  }
1132
1851
  const releaseCheck = run('npm', ['run', 'release:check']);
1133
- if (!releaseCheck.includes('release notes ok: 0.1.2')) fail(`release check output wrong:\n${releaseCheck}`);
1852
+ if (!releaseCheck.includes('release notes ok: 0.1.3')) fail(`release check output wrong:\n${releaseCheck}`);
1134
1853
  const releaseNotes = run('npm', ['run', 'release:notes']);
1135
- if (!releaseNotes.includes('## 0.1.2') || !releaseNotes.includes('### Summary')) {
1854
+ if (!releaseNotes.includes('## 0.1.3') || !releaseNotes.includes('### Summary')) {
1136
1855
  fail(`release notes output wrong:\n${releaseNotes}`);
1137
1856
  }
1857
+ const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', '0.1.3']));
1858
+ if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== 'v0.1.3' || githubRelease.body_length < 100) {
1859
+ fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
1860
+ }
1861
+ const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
1862
+ const docsIndexZh = fs.readFileSync(path.join(repoRoot, 'docs', 'README.zh-CN.md'), 'utf8');
1863
+ if (!docsIndex.includes('github-release.yml') || !docsIndex.includes('workflow_dispatch') || !docsIndexZh.includes('github-release.yml') || !docsIndexZh.includes('workflow_dispatch')) {
1864
+ fail('docs index missing GitHub Release publishing command');
1865
+ }
1866
+ const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'github-release.yml'), 'utf8');
1867
+ for (const expected of [
1868
+ 'Publish GitHub Release',
1869
+ "tags:\n - 'v*'",
1870
+ 'workflow_dispatch:',
1871
+ 'contents: write',
1872
+ 'actions/checkout@v4',
1873
+ 'actions/setup-node@v4',
1874
+ 'node-version: 24',
1875
+ 'npm run release:check',
1876
+ 'npm run release:github:dry-run',
1877
+ 'npm run release:github',
1878
+ 'GITHUB_TOKEN: ${{ github.token }}'
1879
+ ]) {
1880
+ if (!workflow.includes(expected)) fail(`github release workflow missing ${expected}`);
1881
+ }
1138
1882
  }
1139
1883
 
1140
1884
  function oldNameScan() {
@@ -1173,9 +1917,33 @@ function syntaxAndHelp() {
1173
1917
  if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
1174
1918
  fail(`main help missing update command:\n${mainHelp}`);
1175
1919
  }
1920
+ if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
1921
+ fail(`main help missing state command:\n${mainHelp}`);
1922
+ }
1923
+ if (!mainHelp.includes(' team <subcommand> Plan, start, and inspect explicit task teams')) {
1924
+ fail(`main help missing team command:\n${mainHelp}`);
1925
+ }
1176
1926
  if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
1177
1927
  fail(`main help missing uninstall command:\n${mainHelp}`);
1178
1928
  }
1929
+ const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
1930
+ if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
1931
+ fail(`msg help missing reply/thread commands:\n${msgHelp}`);
1932
+ }
1933
+ const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
1934
+ if (!taskHelp.includes('task next [--peer ID] [--force]') ||
1935
+ !taskHelp.includes('existing claimed/running/review/blocked task') ||
1936
+ !taskHelp.includes('task create --title TEXT --parent N')) {
1937
+ fail(`task help missing current-task task next semantics:\n${taskHelp}`);
1938
+ }
1939
+ const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
1940
+ if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
1941
+ fail(`team help missing expected content:\n${teamHelp}`);
1942
+ }
1943
+ const stateHelp = run(process.execPath, [hccBin, 'state', '--help']);
1944
+ if (!stateHelp.includes('hcc state') || !stateHelp.includes('automation.next_action.argv') || !stateHelp.includes('automation.current_task') || !stateHelp.includes('hcc team plan')) {
1945
+ fail(`state help missing expected content:\n${stateHelp}`);
1946
+ }
1179
1947
  const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
1180
1948
  if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
1181
1949
  fail(`update help missing expected content:\n${updateHelp}`);
@@ -1200,7 +1968,7 @@ function syntaxAndHelp() {
1200
1968
  if (runHelp.includes('--web-managed')) fail(`run help exposes removed --web-managed:\n${runHelp}`);
1201
1969
  const subcommandHelpCases = [
1202
1970
  ['task', 'done', 'hcc task done'],
1203
- ['msg', 'ack', 'hcc msg ack'],
1971
+ ['msg', 'reply', 'hcc msg reply'],
1204
1972
  ['peer', 'attach', 'peer attach'],
1205
1973
  ['lock', 'release', 'hcc lock release'],
1206
1974
  ['handoff', 'create', 'hcc handoff create'],
@@ -1233,8 +2001,12 @@ function uninstallWorkflow() {
1233
2001
 
1234
2002
  run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
1235
2003
  ensureFile(path.join(uninstallRoot, '.hello-cc', 'mesh.db'));
2004
+ ensureFile(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
1236
2005
  ensureFile(path.join(uninstallRoot, 'CLAUDE.md'));
1237
2006
  ensureFile(path.join(uninstallRoot, 'AGENTS.md'));
2007
+ assertGuidanceLockPolicy(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
2008
+ assertGuidanceLockPolicy(path.join(uninstallRoot, 'CLAUDE.md'));
2009
+ assertGuidanceLockPolicy(path.join(uninstallRoot, 'AGENTS.md'));
1238
2010
  ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
1239
2011
  ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
1240
2012
  ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));