@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.
package/bin/hcc.mjs CHANGED
@@ -5,7 +5,7 @@ import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
7
  import os from 'node:os';
8
- import { createHash } from 'node:crypto';
8
+ import { createHash, randomBytes } from 'node:crypto';
9
9
  import { spawn, spawnSync } from 'node:child_process';
10
10
  import { setTimeout as sleep } from 'node:timers/promises';
11
11
  import { URL, fileURLToPath } from 'node:url';
@@ -22,6 +22,7 @@ const CLI_NAME = 'hcc';
22
22
  const NPM_PACKAGE_NAME = '@logicseek/hello-cc';
23
23
  const DEFAULT_LOCK_TTL = 900;
24
24
  const ACTIVE_PEER_TTL = 600;
25
+ const DB_SCHEMA_VERSION = 4;
25
26
  // Directory under .hello-cc/ for optional external PTY buffer files.
26
27
  const BUFS_DIR_NAME = 'bufs';
27
28
  const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
@@ -163,6 +164,10 @@ function shortHash(value) {
163
164
  return createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
164
165
  }
165
166
 
167
+ function providerSessionPeerId(kind, providerId) {
168
+ return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
169
+ }
170
+
166
171
  function currentTtyName() {
167
172
  if (process.env.TTY) return process.env.TTY;
168
173
  const result = spawnSync('tty', [], {
@@ -385,6 +390,10 @@ function globalRuntimePath() {
385
390
  return path.join(globalStateDir(), 'runtime.json');
386
391
  }
387
392
 
393
+ function globalWebTokenPath() {
394
+ return path.join(globalStateDir(), 'web-token');
395
+ }
396
+
388
397
  function projectRegistryPath() {
389
398
  return path.join(globalStateDir(), 'projects.json');
390
399
  }
@@ -597,6 +606,70 @@ function runtimeUrlQuery(runtime, projectRoot = null) {
597
606
  return parts.length ? `?${parts.join('&')}` : '';
598
607
  }
599
608
 
609
+ function readStoredWebToken() {
610
+ const file = globalWebTokenPath();
611
+ if (!fs.existsSync(file)) return '';
612
+ try {
613
+ return fs.readFileSync(file, 'utf8').trim();
614
+ } catch {
615
+ return '';
616
+ }
617
+ }
618
+
619
+ function writeStoredWebToken(token) {
620
+ if (!token) return '';
621
+ const file = globalWebTokenPath();
622
+ fs.mkdirSync(path.dirname(file), { recursive: true });
623
+ fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
624
+ fs.chmodSync(file, 0o600);
625
+ return file;
626
+ }
627
+
628
+ function makeWebToken(opts) {
629
+ validateWebTokenOpts(opts);
630
+ if (opts['no-token']) return '';
631
+ const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
632
+ if (explicitToken) {
633
+ writeStoredWebToken(explicitToken);
634
+ return explicitToken;
635
+ }
636
+ const stored = readStoredWebToken();
637
+ if (stored) return stored;
638
+ const generated = randomBytes(24).toString('base64url');
639
+ writeStoredWebToken(generated);
640
+ return generated;
641
+ }
642
+
643
+ function validateWebTokenOpts(opts) {
644
+ if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) {
645
+ throw new CliError('BAD_ARGS', '--no-token cannot be combined with --token or HCC_WEB_TOKEN');
646
+ }
647
+ }
648
+
649
+ function expectedWebHost(opts) {
650
+ return opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
651
+ }
652
+
653
+ function webRuntimeMatchesRequest(runtime, opts) {
654
+ if (!runtime) return false;
655
+ if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
656
+ const expectedHost = expectedWebHost(opts);
657
+ if (runtime.host !== expectedHost) return false;
658
+ const expectedPort = intOpt(opts, 'port', 8787);
659
+ if (opts.port !== undefined && runtime.port !== expectedPort) return false;
660
+ const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
661
+ if (opts['no-token']) return !runtime.token;
662
+ if (explicitToken) return runtime.token === explicitToken;
663
+ return Boolean(runtime.token);
664
+ }
665
+
666
+ function rememberRuntimeToken(runtime, opts) {
667
+ if (!runtime?.token || opts['no-token']) return;
668
+ const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
669
+ if (explicitToken && runtime.token !== explicitToken) return;
670
+ writeStoredWebToken(runtime.token);
671
+ }
672
+
600
673
  function publicRuntimeUrl(runtime, projectRoot = null) {
601
674
  const host = runtime.host === '0.0.0.0' || runtime.host === '::'
602
675
  ? '<machine-ip>'
@@ -915,6 +988,13 @@ function tmuxSendKeys(pane, keys) {
915
988
 
916
989
  function tmuxSendRawLiteral(pane, text) {
917
990
  if (!text) return;
991
+ // A tmux argument consisting solely of ';' is swallowed as a command
992
+ // separator even after '--', so a typed lone semicolon would vanish. Paste
993
+ // such runs through a buffer (which is parsed as data, not command args).
994
+ if (/^;+$/.test(text)) {
995
+ tmuxPasteBuffer(pane, text, { raw: true });
996
+ return;
997
+ }
918
998
  runTmux(['send-keys', '-t', pane, '-l', '--', text]);
919
999
  }
920
1000
 
@@ -1036,18 +1116,26 @@ function tmuxSendLiteral(pane, text) {
1036
1116
  }
1037
1117
  if (current) chunks.push({ type: 'literal', text: current });
1038
1118
 
1119
+ // Typed characters must arrive as real key presses, not clipboard pastes:
1120
+ // send-keys -l is ~3x cheaper than the load-buffer/paste-buffer/delete-buffer
1121
+ // cycle (one tmux spawn vs three) and the target program sees keystrokes
1122
+ // rather than paste events — which bracketed-paste-aware TUIs (claude, codex)
1123
+ // otherwise mishandle. paste-buffer is reserved for genuine bracketed pastes.
1124
+ // Exit copy-mode once up front so send-keys isn't interpreted as copy commands.
1125
+ if (tmuxInCopyMode(pane)) tmuxExitCopyMode(pane);
1126
+
1039
1127
  let pendingText = '';
1040
1128
  for (const chunk of chunks) {
1041
1129
  if (chunk.type === 'literal') {
1042
1130
  pendingText += chunk.text;
1043
1131
  } else {
1044
- if (pendingText) { tmuxPasteBuffer(pane, pendingText, { raw: true }); pendingText = ''; }
1132
+ if (pendingText) { tmuxSendRawLiteral(pane, pendingText); pendingText = ''; }
1045
1133
  if (chunk.type === 'key') tmuxSendKeys(pane, [chunk.key]);
1046
1134
  else if (chunk.type === 'paste') tmuxPasteBuffer(pane, chunk.text, { bracketed: true, raw: true });
1047
1135
  else tmuxSendRawLiteral(pane, chunk.text);
1048
1136
  }
1049
1137
  }
1050
- if (pendingText) tmuxPasteBuffer(pane, pendingText, { raw: true });
1138
+ if (pendingText) tmuxSendRawLiteral(pane, pendingText);
1051
1139
  }
1052
1140
 
1053
1141
  function inferPeerKind(id, explicitKind, firstCommand) {
@@ -1397,6 +1485,9 @@ function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy =
1397
1485
  return false;
1398
1486
  }
1399
1487
 
1488
+ let projectMigrationFanoutDepth = 0;
1489
+ const migratedRegisteredProjectDbs = new Set();
1490
+
1400
1491
  function connect(ctx) {
1401
1492
  fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
1402
1493
  const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
@@ -1404,16 +1495,84 @@ function connect(ctx) {
1404
1495
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1405
1496
  db.exec('PRAGMA foreign_keys = ON;');
1406
1497
  initSchema(db);
1498
+ migrateRegisteredProjectDbs(ctx);
1407
1499
  return db;
1408
1500
  }
1409
1501
 
1502
+ function migrateRegisteredProjectDbs(ctx) {
1503
+ if (projectMigrationFanoutDepth > 0) return;
1504
+ projectMigrationFanoutDepth += 1;
1505
+ try {
1506
+ const currentDb = path.resolve(ctx.dbPath);
1507
+ const seen = new Set([currentDb]);
1508
+ for (const project of readProjectRegistry()) {
1509
+ const root = path.resolve(project.root);
1510
+ const dbPath = path.resolve(project.db || path.join(root, '.hello-cc', 'mesh.db'));
1511
+ if (seen.has(dbPath)) continue;
1512
+ seen.add(dbPath);
1513
+ const cacheKey = `${dbPath}:${DB_SCHEMA_VERSION}`;
1514
+ if (migratedRegisteredProjectDbs.has(cacheKey)) continue;
1515
+ if (!fs.existsSync(root) || !fs.existsSync(dbPath)) continue;
1516
+ let db = null;
1517
+ try {
1518
+ db = new DatabaseSync(dbPath, { timeout: 5000 });
1519
+ db.exec('PRAGMA busy_timeout = 5000;');
1520
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1521
+ db.exec('PRAGMA foreign_keys = ON;');
1522
+ initSchema(db);
1523
+ migratedRegisteredProjectDbs.add(cacheKey);
1524
+ } catch (err) {
1525
+ throw new CliError('REGISTERED_DB_MIGRATION_FAILED', `Failed to migrate registered project database ${dbPath}: ${err?.message || err}`, {
1526
+ db: dbPath,
1527
+ code: err instanceof CliError ? err.code : undefined
1528
+ });
1529
+ } finally {
1530
+ try { db?.close(); } catch {}
1531
+ }
1532
+ }
1533
+ } finally {
1534
+ projectMigrationFanoutDepth -= 1;
1535
+ }
1536
+ }
1537
+
1410
1538
  function initSchema(db) {
1539
+ const existingVersion = readSchemaVersion(db);
1540
+ if (existingVersion > DB_SCHEMA_VERSION) {
1541
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1542
+ db_schema_version: existingVersion,
1543
+ supported_schema_version: DB_SCHEMA_VERSION
1544
+ });
1545
+ }
1546
+ createBaseSchema(db);
1547
+ runSchemaMigrations(db);
1548
+ dedupePeerBindings(db);
1549
+ execWithBusyRetry(db, `
1550
+ CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
1551
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
1552
+ ON peer_bindings(provider, provider_session_id)
1553
+ WHERE provider_session_id IS NOT NULL;
1554
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
1555
+ ON peer_bindings(provider, provider_session_name)
1556
+ WHERE provider_session_name IS NOT NULL;
1557
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
1558
+ ON peer_bindings(runtime_target)
1559
+ WHERE runtime_target IS NOT NULL;
1560
+ `);
1561
+ }
1562
+
1563
+ function createBaseSchema(db) {
1411
1564
  execWithBusyRetry(db, `
1412
1565
  CREATE TABLE IF NOT EXISTS meta (
1413
1566
  key TEXT PRIMARY KEY,
1414
1567
  value TEXT NOT NULL
1415
1568
  );
1416
1569
 
1570
+ CREATE TABLE IF NOT EXISTS schema_migrations (
1571
+ version INTEGER PRIMARY KEY,
1572
+ name TEXT NOT NULL,
1573
+ applied_at INTEGER NOT NULL
1574
+ );
1575
+
1417
1576
  CREATE TABLE IF NOT EXISTS peers (
1418
1577
  id TEXT PRIMARY KEY,
1419
1578
  kind TEXT NOT NULL,
@@ -1450,6 +1609,8 @@ function initSchema(db) {
1450
1609
  status TEXT NOT NULL DEFAULT 'pending',
1451
1610
  assignee TEXT,
1452
1611
  owner TEXT,
1612
+ parent_id INTEGER,
1613
+ team_role TEXT,
1453
1614
  priority INTEGER NOT NULL DEFAULT 100,
1454
1615
  created_by TEXT,
1455
1616
  claimed_at INTEGER,
@@ -1465,6 +1626,8 @@ function initSchema(db) {
1465
1626
  task_id INTEGER,
1466
1627
  kind TEXT NOT NULL DEFAULT 'note',
1467
1628
  body TEXT NOT NULL,
1629
+ reply_to INTEGER,
1630
+ thread_id INTEGER,
1468
1631
  created_at INTEGER NOT NULL
1469
1632
  );
1470
1633
 
@@ -1512,28 +1675,110 @@ function initSchema(db) {
1512
1675
  CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
1513
1676
  CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
1514
1677
  `);
1678
+ }
1679
+
1680
+ function tableColumns(db, tableName) {
1681
+ return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
1682
+ }
1683
+
1684
+ function tableExists(db, tableName) {
1685
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
1686
+ }
1687
+
1688
+ function readSchemaVersion(db) {
1689
+ const metaExists = tableExists(db, 'meta');
1690
+ const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
1691
+ const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
1692
+ const migrationVersion = tableExists(db, 'schema_migrations')
1693
+ ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
1694
+ : 0;
1695
+ return Math.max(metaVersion, pragmaVersion, migrationVersion);
1696
+ }
1697
+
1698
+ function writeSchemaVersion(db, version) {
1515
1699
  db.prepare(`
1516
- INSERT INTO meta(key, value) VALUES ('schema_version', '1')
1517
- ON CONFLICT(key) DO NOTHING
1518
- `).run();
1700
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1701
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
1702
+ `).run(String(version));
1703
+ db.exec(`PRAGMA user_version = ${Number(version)}`);
1704
+ }
1519
1705
 
1520
- const peerBindingColumns = new Set(db.prepare('PRAGMA table_info(peer_bindings)').all().map((col) => col.name));
1521
- if (!peerBindingColumns.has('runtime_target')) {
1522
- db.exec('ALTER TABLE peer_bindings ADD COLUMN runtime_target TEXT');
1706
+ function recordSchemaMigration(db, version, name) {
1707
+ db.prepare(`
1708
+ INSERT INTO schema_migrations(version, name, applied_at)
1709
+ VALUES (?, ?, ?)
1710
+ ON CONFLICT(version) DO NOTHING
1711
+ `).run(version, name, now());
1712
+ }
1713
+
1714
+ function addColumnIfMissing(db, tableName, columnName, ddl) {
1715
+ const columns = tableColumns(db, tableName);
1716
+ if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
1717
+ }
1718
+
1719
+ function runSchemaMigrations(db) {
1720
+ const migrations = [
1721
+ {
1722
+ version: 1,
1723
+ name: 'baseline',
1724
+ up() {
1725
+ // createBaseSchema already created the baseline tables.
1726
+ }
1727
+ },
1728
+ {
1729
+ version: 2,
1730
+ name: 'peer binding runtime targets',
1731
+ up(database) {
1732
+ addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
1733
+ }
1734
+ },
1735
+ {
1736
+ version: 3,
1737
+ name: 'threaded messages',
1738
+ up(database) {
1739
+ addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
1740
+ addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
1741
+ database.exec(`
1742
+ UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
1743
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
1744
+ `);
1745
+ }
1746
+ },
1747
+ {
1748
+ version: 4,
1749
+ name: 'team task hierarchy',
1750
+ up(database) {
1751
+ addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
1752
+ addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
1753
+ database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
1754
+ }
1755
+ }
1756
+ ];
1757
+ let version = readSchemaVersion(db);
1758
+ if (version > DB_SCHEMA_VERSION) {
1759
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1760
+ db_schema_version: version,
1761
+ supported_schema_version: DB_SCHEMA_VERSION
1762
+ });
1523
1763
  }
1524
- dedupePeerBindings(db);
1525
- execWithBusyRetry(db, `
1526
- CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
1527
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
1528
- ON peer_bindings(provider, provider_session_id)
1529
- WHERE provider_session_id IS NOT NULL;
1530
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
1531
- ON peer_bindings(provider, provider_session_name)
1532
- WHERE provider_session_name IS NOT NULL;
1533
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
1534
- ON peer_bindings(runtime_target)
1535
- WHERE runtime_target IS NOT NULL;
1536
- `);
1764
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
1765
+ tx(db, () => {
1766
+ for (const migration of migrations) {
1767
+ if (version >= migration.version && applied.has(migration.version)) continue;
1768
+ migration.up(db);
1769
+ recordSchemaMigration(db, migration.version, migration.name);
1770
+ applied.add(migration.version);
1771
+ version = migration.version;
1772
+ writeSchemaVersion(db, version);
1773
+ }
1774
+ if (version === 0) {
1775
+ version = DB_SCHEMA_VERSION;
1776
+ }
1777
+ for (const migration of migrations) {
1778
+ if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
1779
+ }
1780
+ writeSchemaVersion(db, version);
1781
+ });
1537
1782
  }
1538
1783
 
1539
1784
  function peerBindingRuntimeRank(row) {
@@ -1900,23 +2145,32 @@ function touchCurrentPeer(db, ctx, resolved, status = null, kindHint = 'shell')
1900
2145
  });
1901
2146
  }
1902
2147
 
1903
- function sendMessage(db, sender, recipient, taskId, kind, body) {
2148
+ function sendMessage(db, sender, recipient, taskId, kind, body, meta = {}) {
2149
+ const replyTo = meta.reply_to || null;
2150
+ const threadId = meta.thread_id || null;
1904
2151
  const info = db.prepare(`
1905
- INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
1906
- VALUES (?, ?, ?, ?, ?, ?)
1907
- `).run(sender, recipient || 'all', taskId || null, kind || 'note', body, now());
2152
+ INSERT INTO messages(sender, recipient, task_id, kind, body, reply_to, thread_id, created_at)
2153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2154
+ `).run(sender, recipient || 'all', taskId || null, kind || 'note', body, replyTo, threadId, now());
2155
+ const messageId = Number(info.lastInsertRowid);
2156
+ if (!threadId) {
2157
+ db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(messageId, messageId);
2158
+ }
1908
2159
  addEvent(db, 'message.sent', sender, taskId || null, {
1909
- message_id: Number(info.lastInsertRowid),
2160
+ message_id: messageId,
1910
2161
  recipient: recipient || 'all',
1911
- kind: kind || 'note'
2162
+ kind: kind || 'note',
2163
+ reply_to: replyTo,
2164
+ thread_id: threadId || messageId
1912
2165
  });
1913
- return Number(info.lastInsertRowid);
2166
+ return messageId;
1914
2167
  }
1915
2168
 
1916
2169
  function queryInbox(db, peer, includeAll, limit) {
1917
2170
  return db.prepare(`
1918
2171
  SELECT
1919
- m.id, m.sender, m.recipient, m.task_id, m.kind, m.body, m.created_at, r.read_at
2172
+ m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
2173
+ m.reply_to, m.thread_id, m.created_at, r.read_at
1920
2174
  FROM messages m
1921
2175
  LEFT JOIN message_reads r
1922
2176
  ON r.message_id = m.id AND r.peer = ?
@@ -1928,6 +2182,417 @@ function queryInbox(db, peer, includeAll, limit) {
1928
2182
  `).all(peer, peer, includeAll ? 1 : 0, limit);
1929
2183
  }
1930
2184
 
2185
+ function queryTimelineMessages(db, peer, limit) {
2186
+ if (!peer) {
2187
+ return db.prepare(`
2188
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
2189
+ FROM messages
2190
+ ORDER BY id DESC
2191
+ LIMIT ?
2192
+ `).all(limit).reverse();
2193
+ }
2194
+ return db.prepare(`
2195
+ SELECT
2196
+ m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
2197
+ m.reply_to, m.thread_id, m.created_at, r.read_at
2198
+ FROM messages m
2199
+ LEFT JOIN message_reads r
2200
+ ON r.message_id = m.id AND r.peer = ?
2201
+ WHERE
2202
+ m.sender = ?
2203
+ OR m.recipient IS NULL
2204
+ OR m.recipient = ''
2205
+ OR m.recipient = 'all'
2206
+ OR m.recipient = ?
2207
+ ORDER BY m.id DESC
2208
+ LIMIT ?
2209
+ `).all(peer, peer, peer, limit).reverse();
2210
+ }
2211
+
2212
+ function getMessage(db, id) {
2213
+ return db.prepare(`
2214
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
2215
+ FROM messages
2216
+ WHERE id = ?
2217
+ `).get(id);
2218
+ }
2219
+
2220
+ function queryMessageThread(db, messageId, limit) {
2221
+ const message = getMessage(db, messageId);
2222
+ if (!message) throw new CliError('NOT_FOUND', `Message #${messageId} does not exist`);
2223
+ const threadId = message.thread_id || message.id;
2224
+ const rows = db.prepare(`
2225
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
2226
+ FROM messages
2227
+ WHERE id = ? OR thread_id = ?
2228
+ ORDER BY id ASC
2229
+ LIMIT ?
2230
+ `).all(message.id, threadId, limit);
2231
+ return { message, thread_id: threadId, messages: rows };
2232
+ }
2233
+
2234
+ function parseEventPayload(row) {
2235
+ try {
2236
+ return row?.payload ? JSON.parse(row.payload) : {};
2237
+ } catch {
2238
+ return {};
2239
+ }
2240
+ }
2241
+
2242
+ function compactText(value, limit = 160) {
2243
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
2244
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
2245
+ }
2246
+
2247
+ function uniqueList(values) {
2248
+ return [...new Set(values.filter(Boolean).map(String))];
2249
+ }
2250
+
2251
+ function messageParticipants(message) {
2252
+ const participants = [message.sender];
2253
+ if (message.recipient && !['all', ''].includes(message.recipient)) participants.push(message.recipient);
2254
+ return uniqueList(participants);
2255
+ }
2256
+
2257
+ function taskParticipants(task) {
2258
+ return uniqueList([task.created_by, task.owner, task.assignee]);
2259
+ }
2260
+
2261
+ function payloadParticipants(payload) {
2262
+ const values = [];
2263
+ for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
2264
+ if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
2265
+ }
2266
+ return uniqueList(values);
2267
+ }
2268
+
2269
+ function peerMatchesTimelineItem(item, peer) {
2270
+ if (!peer) return true;
2271
+ if (item.broadcast) return true;
2272
+ return (item.peers || []).includes(peer);
2273
+ }
2274
+
2275
+ function shouldHideTimelineMessage(message) {
2276
+ if (message.kind === 'task' && /^Task #\d+ assigned: /.test(message.body || '')) return true;
2277
+ if (message.kind === 'handoff' && /^Handoff #\d+: /.test(message.body || '')) return true;
2278
+ return false;
2279
+ }
2280
+
2281
+ const TIMELINE_EVENT_ALLOW = new Set([
2282
+ 'task.created',
2283
+ 'task.claimed',
2284
+ 'task.pending',
2285
+ 'task.running',
2286
+ 'task.review',
2287
+ 'task.blocked',
2288
+ 'task.done',
2289
+ 'task.abandoned',
2290
+ 'team.started',
2291
+ 'lock.acquired',
2292
+ 'lock.released',
2293
+ 'peer.registered',
2294
+ 'peer.joined',
2295
+ 'peer.auto_joined',
2296
+ 'peer.stopped',
2297
+ 'provider.session.merged',
2298
+ 'web.session.started',
2299
+ 'web.session.exited',
2300
+ 'tmux.session.attached',
2301
+ 'tmux.session.detached',
2302
+ 'tmux.session.exited',
2303
+ 'run.session.started',
2304
+ 'run.session.exited'
2305
+ ]);
2306
+
2307
+ function shouldHideTimelineEvent(event) {
2308
+ if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
2309
+ if (event.type && event.type.startsWith('hook.')) return true;
2310
+ if (event.type && event.type.startsWith('web.session.input')) return true;
2311
+ return !TIMELINE_EVENT_ALLOW.has(event.type);
2312
+ }
2313
+
2314
+ function timelineDirection(message, peer) {
2315
+ if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
2316
+ if (message.sender === peer && message.recipient === peer) return 'self';
2317
+ if (message.sender === peer) return 'out';
2318
+ if (message.recipient === 'all') return 'broadcast';
2319
+ if (message.recipient === peer) return 'in';
2320
+ return 'project';
2321
+ }
2322
+
2323
+ function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
2324
+ const items = [];
2325
+ for (const message of messages) {
2326
+ if (shouldHideTimelineMessage(message)) continue;
2327
+ const item = {
2328
+ id: `message:${message.id}`,
2329
+ source: 'message',
2330
+ source_id: message.id,
2331
+ ts: message.created_at,
2332
+ actor: message.sender,
2333
+ peers: messageParticipants(message),
2334
+ task_id: message.task_id || null,
2335
+ kind: message.kind || 'note',
2336
+ title: `${message.sender} -> ${message.recipient || 'all'}`,
2337
+ text: compactText(message.body),
2338
+ unread: message.read_at === null || message.read_at === undefined,
2339
+ direction: timelineDirection(message, peer),
2340
+ thread_id: message.thread_id || message.id,
2341
+ reply_to: message.reply_to || null,
2342
+ broadcast: !message.recipient || message.recipient === 'all'
2343
+ };
2344
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
2345
+ }
2346
+ for (const handoff of handoffs) {
2347
+ const item = {
2348
+ id: `handoff:${handoff.id}`,
2349
+ source: 'handoff',
2350
+ source_id: handoff.id,
2351
+ ts: handoff.created_at,
2352
+ actor: handoff.from_peer,
2353
+ peers: uniqueList([handoff.from_peer, handoff.to_peer]),
2354
+ task_id: handoff.task_id || null,
2355
+ kind: 'handoff',
2356
+ title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
2357
+ text: compactText(handoff.summary),
2358
+ direction: 'project',
2359
+ broadcast: !handoff.to_peer,
2360
+ tests: handoff.tests || '',
2361
+ risks: handoff.risks || ''
2362
+ };
2363
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
2364
+ }
2365
+ for (const task of tasks) {
2366
+ const item = {
2367
+ id: `task:${task.id}`,
2368
+ source: 'task',
2369
+ source_id: task.id,
2370
+ ts: task.updated_at || task.created_at,
2371
+ actor: task.owner || task.created_by || task.assignee || '',
2372
+ peers: taskParticipants(task),
2373
+ task_id: task.id,
2374
+ kind: task.status,
2375
+ title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
2376
+ text: compactText(task.title),
2377
+ direction: 'project'
2378
+ };
2379
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
2380
+ }
2381
+ for (const lock of locks) {
2382
+ const item = {
2383
+ id: `lock:${lock.resource}`,
2384
+ source: 'lock',
2385
+ source_id: lock.resource,
2386
+ ts: lock.created_at,
2387
+ actor: lock.owner,
2388
+ peers: uniqueList([lock.owner]),
2389
+ task_id: lock.task_id || null,
2390
+ kind: 'active',
2391
+ title: `lock ${lock.resource}`,
2392
+ text: compactText(lock.reason || `owner=${lock.owner}`),
2393
+ direction: 'project'
2394
+ };
2395
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
2396
+ }
2397
+ for (const event of events) {
2398
+ if (shouldHideTimelineEvent(event)) continue;
2399
+ const payload = parseEventPayload(event);
2400
+ const item = {
2401
+ id: `event:${event.id}`,
2402
+ source: 'event',
2403
+ source_id: event.id,
2404
+ ts: event.created_at,
2405
+ actor: event.actor || '',
2406
+ peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
2407
+ task_id: event.task_id || null,
2408
+ kind: event.type,
2409
+ title: event.type,
2410
+ text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
2411
+ direction: 'project'
2412
+ };
2413
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
2414
+ }
2415
+ const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
2416
+ items.sort((a, b) =>
2417
+ (a.ts || 0) - (b.ts || 0) ||
2418
+ (order[a.source] || 99) - (order[b.source] || 99) ||
2419
+ String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
2420
+ return items.slice(-120);
2421
+ }
2422
+
2423
+ function actionCommand(argv) {
2424
+ if (!argv?.length) return '';
2425
+ return [CLI_NAME, ...argv].map(shellQuoteArg).join(' ');
2426
+ }
2427
+
2428
+ function makeAction(kind, argv, reason, mutates = true, extra = {}) {
2429
+ return {
2430
+ kind,
2431
+ reason,
2432
+ mutates,
2433
+ argv,
2434
+ command: actionCommand(argv),
2435
+ ...extra
2436
+ };
2437
+ }
2438
+
2439
+ function looksLikeMultiTask(task) {
2440
+ if (!task) return false;
2441
+ const text = `${task.title || ''}\n${task.body || ''}`;
2442
+ const bullets = text.split('\n').filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S+/.test(line)).length;
2443
+ const separators = (text.match(/[,,;;、]/g) || []).length;
2444
+ return bullets >= 2 || separators >= 3 || /多任务|并行|团队|分工|several tasks|multiple tasks|parallel|team/i.test(text);
2445
+ }
2446
+
2447
+ function selectCurrentTask(tasks, peerId) {
2448
+ if (!peerId) return null;
2449
+ const statusRank = { running: 0, claimed: 1, review: 2, blocked: 3 };
2450
+ const openTasks = (tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
2451
+ const ownedTasks = openTasks
2452
+ .filter((task) => task.owner === peerId)
2453
+ .sort((a, b) =>
2454
+ (statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9) ||
2455
+ Number(a.priority || 0) - Number(b.priority || 0) ||
2456
+ Number(a.id || 0) - Number(b.id || 0));
2457
+ return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
2458
+ }
2459
+
2460
+ function summarizeTask(task) {
2461
+ if (!task) return null;
2462
+ return {
2463
+ id: task.id,
2464
+ title: task.title,
2465
+ status: task.status,
2466
+ owner: task.owner || null,
2467
+ assignee: task.assignee || null,
2468
+ parent_id: task.parent_id || null,
2469
+ team_role: task.team_role || null,
2470
+ priority: task.priority
2471
+ };
2472
+ }
2473
+
2474
+ function deriveAutomation(snapshot, peer = null, opts = {}) {
2475
+ const peerId = peer || '';
2476
+ const t = Number(snapshot.now || now());
2477
+ const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
2478
+ const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
2479
+ const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
2480
+ const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
2481
+ const ownedTask = selectCurrentTask(openTasks, peerId);
2482
+ const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
2483
+ const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
2484
+ const requestedLocks = resources.map((resource) => {
2485
+ const lock = (snapshot.locks || []).find((row) => row.resource === resource && Number(row.expires_at || 0) > t);
2486
+ return { resource, lock };
2487
+ });
2488
+ const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
2489
+ const missingLock = requestedLocks.find((entry) => !entry.lock);
2490
+ const unread = peerId ? (snapshot.messages || []).filter((message) =>
2491
+ message.read_at === null || message.read_at === undefined) : [];
2492
+ const warnings = [];
2493
+ if (peerId && !peerRow) warnings.push(`peer ${peerId} is not registered in this project`);
2494
+ const lockActions = [];
2495
+ const messageActions = [];
2496
+ const taskActions = [];
2497
+ const automation = {
2498
+ schema_version: 1,
2499
+ peer: peerId ? {
2500
+ id: peerId,
2501
+ known: Boolean(peerRow),
2502
+ active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= ACTIVE_PEER_TTL),
2503
+ age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
2504
+ } : null,
2505
+ current_task: summarizeTask(ownedTask),
2506
+ phase: 'idle',
2507
+ next_action: makeAction('none', [], 'no immediate coordination action', false),
2508
+ actions: [],
2509
+ finish_actions: [],
2510
+ warnings
2511
+ };
2512
+
2513
+ const orderedUnread = unread
2514
+ .filter((message) => message.sender !== peerId)
2515
+ .filter((message) => !shouldHideTimelineMessage(message))
2516
+ .sort((a, b) => (a.kind === 'task' ? 1 : 0) - (b.kind === 'task' ? 1 : 0) || a.id - b.id);
2517
+ for (const message of orderedUnread) {
2518
+ if (ownedTask && message.kind === 'task') continue;
2519
+ const kind = message.kind === 'task' ? 'msg.inbox' : 'msg.reply';
2520
+ const argv = kind === 'msg.reply'
2521
+ ? ['msg', 'reply', '--from', peerId, '--id', String(message.id), '--body', '<answer>']
2522
+ : ['msg', 'inbox', '--peer', peerId];
2523
+ messageActions.push(makeAction(kind, argv, `unread message #${message.id} from ${message.sender}`, kind !== 'msg.inbox', {
2524
+ message_id: message.id,
2525
+ task_id: message.task_id || null
2526
+ }));
2527
+ }
2528
+
2529
+ if (!ownedTask && assignedTasks.length) {
2530
+ const task = assignedTasks[0];
2531
+ taskActions.push(makeAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
2532
+ } else if (!ownedTask && availableTasks.length) {
2533
+ taskActions.push(makeAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
2534
+ }
2535
+
2536
+ if (ownedTask) {
2537
+ if (conflict) {
2538
+ lockActions.push(makeAction('msg.send', ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${conflict.resource}; it is locked by ${conflict.lock.owner}.`], `${conflict.resource} is locked by ${conflict.lock.owner}`, true, { task_id: ownedTask.id, resource: conflict.resource, lock_owner: conflict.lock.owner }));
2539
+ } else if (missingLock) {
2540
+ lockActions.push(makeAction('lock.acquire', ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), '--resource', missingLock.resource, '--ttl', String(DEFAULT_LOCK_TTL), '--reason', '<work>'], `task #${ownedTask.id} needs lock for ${missingLock.resource}`, true, { task_id: ownedTask.id, resource: missingLock.resource }));
2541
+ }
2542
+ if (ownedTask.status === 'claimed') {
2543
+ taskActions.push(makeAction('task.update', ['task', 'update', '--peer', peerId, '--id', String(ownedTask.id), '--status', 'running', '--summary', '<started>'], `task #${ownedTask.id} is claimed but not running`, true, { task_id: ownedTask.id }));
2544
+ }
2545
+ if (!ownedTask.parent_id && looksLikeMultiTask(ownedTask)) {
2546
+ const childCount = openTasks.filter((task) => task.parent_id === ownedTask.id && !['done', 'abandoned'].includes(task.status)).length;
2547
+ if (!childCount) {
2548
+ taskActions.push(makeAction('team.plan', ['team', 'plan', '--from-task', String(ownedTask.id)], `task #${ownedTask.id} looks splittable; plan explicit team subtasks`, false, { task_id: ownedTask.id }));
2549
+ }
2550
+ }
2551
+ automation.finish_actions.push(makeAction('handoff.create', ['handoff', 'create', '--from', peerId, '--task', String(ownedTask.id), '--summary', '<summary>', '--tests', '<tests>', '--risks', '<risks>'], `handoff task #${ownedTask.id} before stopping`, true, { task_id: ownedTask.id }));
2552
+ automation.finish_actions.push(makeAction('task.done', ['task', 'done', '--peer', peerId, '--id', String(ownedTask.id), '--summary', '<summary>'], `mark task #${ownedTask.id} done after handoff`, true, { task_id: ownedTask.id }));
2553
+ for (const lock of ownLocks) {
2554
+ automation.finish_actions.push(makeAction('lock.release', ['lock', 'release', '--peer', peerId, '--resource', lock.resource], `release ${lock.resource}`, true, { task_id: lock.task_id || null, resource: lock.resource }));
2555
+ }
2556
+ }
2557
+ automation.actions.push(...lockActions, ...messageActions, ...taskActions);
2558
+
2559
+ if (opts.intent === 'finish' || opts.intent === 'stop') {
2560
+ automation.phase = ownedTask ? 'handoff' : 'idle';
2561
+ automation.next_action = automation.finish_actions[0] || automation.next_action;
2562
+ return automation;
2563
+ }
2564
+ automation.next_action = automation.actions[0] || (ownedTask
2565
+ ? makeAction('none', [], `continue task #${ownedTask.id}`, false, { task_id: ownedTask.id })
2566
+ : automation.next_action);
2567
+ if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
2568
+ else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
2569
+ else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
2570
+ else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
2571
+ else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
2572
+ else if (ownedTask) automation.phase = ownedTask.status === 'review' ? 'handoff' : 'work';
2573
+ return automation;
2574
+ }
2575
+
2576
+ function renderAutomationContext(automation) {
2577
+ if (!automation) return '';
2578
+ const lines = [
2579
+ '[hello-cc next action]',
2580
+ `phase: ${automation.phase}`,
2581
+ automation.current_task ? `current_task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
2582
+ `next: ${automation.next_action.command || automation.next_action.kind}`,
2583
+ `why: ${automation.next_action.reason}`
2584
+ ].filter(Boolean);
2585
+ if (automation.finish_actions?.length) {
2586
+ lines.push('finish:');
2587
+ for (const action of automation.finish_actions.slice(0, 4)) lines.push(`- ${action.command}`);
2588
+ }
2589
+ if (automation.warnings?.length) {
2590
+ lines.push('warnings:');
2591
+ for (const warning of automation.warnings.slice(0, 4)) lines.push(`- ${warning}`);
2592
+ }
2593
+ return lines.join('\n');
2594
+ }
2595
+
1931
2596
  function queryOpenTasks(db, limit, peer = null) {
1932
2597
  const sql = `
1933
2598
  SELECT * FROM tasks
@@ -1970,29 +2635,81 @@ function formatHookEventName(hookType) {
1970
2635
  return known[compact] || String(hookType || 'unknown');
1971
2636
  }
1972
2637
 
1973
- function buildHookCoordinationContext(db, peerId) {
1974
- const openTasks = queryOpenTasks(db, 8);
1975
- const unread = queryInbox(db, peerId, false, 5);
2638
+ function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
1976
2639
  const t = now();
1977
2640
  const peers = db.prepare(`
1978
- SELECT id, kind, status, last_seen_at
2641
+ SELECT id, kind, role, status, worktree, branch, pid, capabilities,
2642
+ created_at, last_seen_at, (? - last_seen_at) AS age_sec
1979
2643
  FROM peers
1980
2644
  ORDER BY last_seen_at DESC, id ASC
1981
- LIMIT 8
1982
- `).all();
2645
+ LIMIT 200
2646
+ `).all(t);
2647
+ const tasks = queryOpenTasks(db, 200);
2648
+ const locks = db.prepare(`
2649
+ SELECT resource, owner, task_id, reason, expires_at, created_at
2650
+ FROM locks
2651
+ WHERE expires_at > ?
2652
+ ORDER BY resource ASC
2653
+ LIMIT 200
2654
+ `).all(t);
2655
+ const messages = peer
2656
+ ? queryInbox(db, peer, false, 50)
2657
+ : db.prepare(`
2658
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
2659
+ FROM messages
2660
+ ORDER BY id DESC
2661
+ LIMIT 50
2662
+ `).all().reverse();
2663
+ const timelineMessages = queryTimelineMessages(db, peer, 80);
2664
+ const handoffs = db.prepare(`
2665
+ SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
2666
+ FROM handoffs
2667
+ ORDER BY id DESC
2668
+ LIMIT 50
2669
+ `).all().reverse();
2670
+ const events = db.prepare(`
2671
+ SELECT id, type, actor, task_id, payload, created_at
2672
+ FROM events
2673
+ ORDER BY id DESC
2674
+ LIMIT 80
2675
+ `).all().reverse();
2676
+ const snapshot = {
2677
+ root: ctx.root,
2678
+ db: ctx.dbPath,
2679
+ now: t,
2680
+ active_peer_ttl: ACTIVE_PEER_TTL,
2681
+ peers,
2682
+ tasks,
2683
+ locks,
2684
+ messages,
2685
+ handoffs,
2686
+ events
2687
+ };
2688
+ snapshot.timeline = timelineFromRows({ messages: timelineMessages, handoffs, tasks, locks, events }, peer);
2689
+ snapshot.automation = deriveAutomation(snapshot, peer, opts);
2690
+ return snapshot;
2691
+ }
2692
+
2693
+ function buildHookCoordinationContext(db, ctx, peerId) {
2694
+ const snapshot = collectStateSnapshot(db, ctx, peerId);
2695
+ const openTasks = snapshot.tasks.slice(0, 8);
2696
+ const unread = snapshot.messages.slice(0, 5);
2697
+ const peers = snapshot.peers.slice(0, 8);
1983
2698
  const parts = [
1984
2699
  '[hello-cc coordination]',
1985
2700
  `peer: ${peerId}`,
1986
2701
  'This is live project coordination context injected by hello-cc.',
1987
2702
  'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
1988
- 'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
1989
- 'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.'
2703
+ 'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc state, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
2704
+ 'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.',
2705
+ 'If you already own a current task, continue that task until handoff/done/blocked; do not claim a different task just because a new prompt arrived.',
2706
+ 'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
1990
2707
  ];
1991
2708
 
1992
2709
  if (peers.length > 0) {
1993
2710
  parts.push('[hello-cc known peers]');
1994
2711
  parts.push(...peers.map((peer) => {
1995
- const age = Math.max(0, t - Number(peer.last_seen_at || 0));
2712
+ const age = Math.max(0, snapshot.now - Number(peer.last_seen_at || 0));
1996
2713
  const active = age <= ACTIVE_PEER_TTL ? 'active' : 'stale';
1997
2714
  return `- ${peer.id} ${peer.kind || 'other'} ${peer.status || 'idle'} ${active}`;
1998
2715
  }));
@@ -2000,6 +2717,12 @@ function buildHookCoordinationContext(db, peerId) {
2000
2717
  parts.push('[hello-cc known peers]\n(none)');
2001
2718
  }
2002
2719
 
2720
+ if (snapshot.automation.current_task) {
2721
+ const task = snapshot.automation.current_task;
2722
+ parts.push('[hello-cc current task]');
2723
+ parts.push(`#${task.id} ${task.status}: ${task.title}`);
2724
+ }
2725
+
2003
2726
  if (openTasks.length > 0) {
2004
2727
  parts.push('[hello-cc open tasks]');
2005
2728
  parts.push(...openTasks.map((task) => `- ${formatOpenTaskLine(task)}`));
@@ -2010,12 +2733,22 @@ function buildHookCoordinationContext(db, peerId) {
2010
2733
  if (unread.length > 0) {
2011
2734
  parts.push('[hello-cc unread messages]');
2012
2735
  parts.push(...unread.map((m) =>
2013
- `- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}: ${m.body}`
2736
+ `- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}${m.reply_to ? ` reply #${m.reply_to}` : ''}: ${m.body}`
2014
2737
  ));
2015
2738
  } else {
2016
2739
  parts.push('[hello-cc unread messages]\n(none)');
2017
2740
  }
2018
2741
 
2742
+ if (snapshot.locks.length > 0) {
2743
+ parts.push('[hello-cc active locks]');
2744
+ parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
2745
+ `- ${lock.resource} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
2746
+ } else {
2747
+ parts.push('[hello-cc active locks]\n(none)');
2748
+ }
2749
+
2750
+ parts.push(renderAutomationContext(snapshot.automation));
2751
+
2019
2752
  return { text: parts.join('\n'), messages: unread };
2020
2753
  }
2021
2754
 
@@ -2081,15 +2814,49 @@ Status checks:
2081
2814
  - \`hcc msg inbox\` shows unread messages for this session.
2082
2815
  - \`hcc lock list\` shows active advisory locks.
2083
2816
  - \`hcc status\` summarizes peers, tasks, locks, inbox, and recent events.
2084
-
2085
- Before work:
2817
+ - \`hcc team status --task N\` summarizes explicit subtasks for a parent task.
2818
+
2819
+ Read-only work:
2820
+ - Reading files, searching, inspecting \`git diff\` / \`git status\`, reviewing
2821
+ uncommitted changes, and running non-mutating checks such as \`node --check\`
2822
+ do not require locks.
2823
+ - For read-only review, do not acquire file locks. If another live peer already
2824
+ holds write locks on changed files, report that the review is only a current
2825
+ snapshot and not a final commit-ready verdict.
2826
+ - Use tasks, messages, events, or handoffs to make read-only review visible;
2827
+ do not use advisory locks to reserve files you are only reading.
2828
+
2829
+ Review and monitoring:
2830
+ - Reviewing another peer's work is a read-only activity. You may inspect current
2831
+ diffs, task state, messages, handoffs, and non-mutating checks while that peer
2832
+ keeps its write locks.
2833
+ - If you find a concrete issue in another peer's task, proactively send that
2834
+ peer an \`hcc msg\` with the task id, affected file or behavior, why it is a
2835
+ problem, and a suggested fix or verification step.
2836
+ - Continue monitoring until the peer fixes the issue, records a follow-up task,
2837
+ or hands off. Do not silently treat a snapshot review as final approval.
2838
+
2839
+ Before mutating work:
2086
2840
  - Register with hcc.
2087
- - Read current status with \`hcc status\`, \`hcc task list\`, and \`hcc msg inbox\`.
2088
- - Claim one task before editing.
2089
-
2090
- Before editing:
2841
+ - Read current status with \`hcc status\`, \`hcc state\`, \`hcc task list\`, and \`hcc msg inbox\`.
2842
+ - If \`hcc state\` shows a current task for this peer, continue that task until
2843
+ handoff, done, or blocked instead of claiming a new task.
2844
+ - Claim one task before editing or mutating shared state when this peer has no
2845
+ current task.
2846
+ - If work needs multiple peers, use \`hcc team plan\` and \`hcc team start\`
2847
+ explicitly; do not create or claim hidden extra tasks.
2848
+
2849
+ Before editing or mutating shared resources:
2091
2850
  - Acquire an advisory lock for the file, directory, module, or shared resource.
2092
2851
  - If another live peer holds the lock, message that peer instead of editing.
2852
+ - Acquire locks only before writes or shared-resource mutation, including file
2853
+ edits, formatting, staging/committing through \`.git/index\`, intentional DB
2854
+ data/schema changes, starting/stopping/restarting shared runtimes, or taking
2855
+ over tmux/session/port resources.
2856
+ - Commit-readiness checks are read-only until staging begins. Do not lock source
2857
+ files for review. Before committing, ensure no live peer holds write locks on
2858
+ the files to be committed, then lock \`.git/index\` only while staging and
2859
+ committing.
2093
2860
 
2094
2861
  During work:
2095
2862
  - Keep changes scoped to the claimed task.
@@ -2316,22 +3083,28 @@ async function taskCreate(ctx, args) {
2316
3083
  const createdBy = identity.id;
2317
3084
  const assignee = opts.to || opts.assignee || null;
2318
3085
  const priority = intOpt(opts, 'priority', 100);
3086
+ const parentId = intOpt(opts, 'parent', null);
3087
+ const teamRole = opts.role || opts['team-role'] || null;
2319
3088
  const db = connect(ctx);
2320
3089
  touchCurrentPeer(db, ctx, identity, null, 'shell');
2321
3090
  const t = now();
2322
3091
  const id = tx(db, () => {
3092
+ if (parentId && !db.prepare('SELECT id FROM tasks WHERE id = ?').get(parentId)) {
3093
+ throw new CliError('NOT_FOUND', `Parent task #${parentId} does not exist`);
3094
+ }
2323
3095
  const info = db.prepare(`
2324
- INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
2325
- VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?)
2326
- `).run(title, body, assignee, priority, createdBy, t, t);
3096
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
3097
+ VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
3098
+ `).run(title, body, assignee, parentId, teamRole, priority, createdBy, t, t);
2327
3099
  const taskId = Number(info.lastInsertRowid);
2328
- addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority });
3100
+ addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority, parent_id: parentId, team_role: teamRole });
2329
3101
  if (assignee) {
2330
3102
  sendMessage(db, createdBy, assignee, taskId, 'task', `Task #${taskId} assigned: ${title}`);
2331
3103
  }
2332
3104
  return taskId;
2333
3105
  });
2334
- printResult(ctx, { id, title, assignee, priority }, (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}`);
3106
+ printResult(ctx, { id, title, assignee, priority, parent_id: parentId, team_role: teamRole },
3107
+ (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
2335
3108
  }
2336
3109
 
2337
3110
  async function taskList(ctx, args) {
@@ -2369,6 +3142,8 @@ async function taskList(ctx, args) {
2369
3142
  { label: 'prio', value: (r) => r.priority },
2370
3143
  { label: 'assignee', value: (r) => r.assignee || '' },
2371
3144
  { label: 'owner', value: (r) => r.owner || '' },
3145
+ { label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
3146
+ { label: 'role', value: (r) => r.team_role || '' },
2372
3147
  { label: 'title', value: (r) => r.title }
2373
3148
  ]));
2374
3149
  }
@@ -2406,12 +3181,31 @@ async function taskClaim(ctx, args) {
2406
3181
  }
2407
3182
 
2408
3183
  async function taskNext(ctx, args) {
2409
- const opts = parseOpts(args);
3184
+ const opts = parseOpts(args, { booleans: ['force'] });
2410
3185
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
2411
3186
  const peer = identity.id;
2412
3187
  const db = connect(ctx);
2413
3188
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
2414
3189
  const task = tx(db, () => {
3190
+ if (!opts.force) {
3191
+ const current = db.prepare(`
3192
+ SELECT * FROM tasks
3193
+ WHERE owner = ?
3194
+ AND status IN ('claimed', 'running', 'review', 'blocked')
3195
+ ORDER BY
3196
+ CASE status
3197
+ WHEN 'running' THEN 0
3198
+ WHEN 'claimed' THEN 1
3199
+ WHEN 'review' THEN 2
3200
+ WHEN 'blocked' THEN 3
3201
+ ELSE 4
3202
+ END,
3203
+ priority ASC,
3204
+ id ASC
3205
+ LIMIT 1
3206
+ `).get(peer);
3207
+ if (current) return { ...current, current: true };
3208
+ }
2415
3209
  const row = db.prepare(`
2416
3210
  SELECT * FROM tasks
2417
3211
  WHERE status = 'pending'
@@ -2430,7 +3224,12 @@ async function taskNext(ctx, args) {
2430
3224
  addEvent(db, 'task.claimed', peer, row.id, { next: true });
2431
3225
  return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
2432
3226
  });
2433
- printResult(ctx, task, (data) => data ? `claimed task #${data.id}: ${data.title}` : 'no pending task');
3227
+ printResult(ctx, task, (data) => {
3228
+ if (!data) return 'no pending task';
3229
+ return data.current
3230
+ ? `current task #${data.id}: ${data.title} (${data.status})`
3231
+ : `claimed task #${data.id}: ${data.title}`;
3232
+ });
2434
3233
  }
2435
3234
 
2436
3235
  async function taskUpdate(ctx, args) {
@@ -2464,14 +3263,223 @@ async function taskUpdate(ctx, args) {
2464
3263
  }
2465
3264
  return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
2466
3265
  });
2467
- printResult(ctx, task, (data) => `task #${data.id} -> ${data.status}`);
3266
+ printResult(ctx, task, (data) => `task #${data.id} -> ${data.status}`);
3267
+ }
3268
+
3269
+ async function taskDone(ctx, args) {
3270
+ const opts = parseOpts(args, { booleans: ['force'] });
3271
+ opts.status = 'done';
3272
+ if (opts.summary && !opts.body) opts.body = opts.summary;
3273
+ return taskUpdate(ctx, args.concat(['--status', 'done']));
3274
+ }
3275
+
3276
+ function splitCsvList(value) {
3277
+ if (Array.isArray(value)) return value.flatMap((item) => splitCsvList(item));
3278
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
3279
+ }
3280
+
3281
+ function parseTeamItems(opts) {
3282
+ const rawItems = [
3283
+ ...splitCsvList(opts.item || []),
3284
+ ...splitCsvList(opts.items || [])
3285
+ ];
3286
+ return rawItems.map((raw, index) => {
3287
+ const parts = String(raw).split(':').map((part) => part.trim());
3288
+ let assignee = null;
3289
+ let role = null;
3290
+ let title = raw.trim();
3291
+ if (parts.length >= 3) {
3292
+ assignee = parts.shift() || null;
3293
+ role = parts.shift() || null;
3294
+ title = parts.join(':').trim();
3295
+ } else if (parts.length === 2) {
3296
+ role = parts[0] || null;
3297
+ title = parts[1] || title;
3298
+ }
3299
+ if (!title) title = `subtask ${index + 1}`;
3300
+ return { title, role: role || `worker-${index + 1}`, assignee };
3301
+ });
3302
+ }
3303
+
3304
+ function inferTeamItems(task, opts) {
3305
+ const explicit = parseTeamItems(opts);
3306
+ if (explicit.length) return explicit;
3307
+ const count = Math.max(1, intOpt(opts, 'count', 3));
3308
+ const baseTitle = task?.title || 'team task';
3309
+ return Array.from({ length: count }, (_, index) => ({
3310
+ title: `${baseTitle} / subtask ${index + 1}`,
3311
+ role: `worker-${index + 1}`,
3312
+ assignee: null
3313
+ }));
3314
+ }
3315
+
3316
+ function expandTeamWorkers(workers, parentId) {
3317
+ const expanded = [];
3318
+ for (const token of splitCsvList(workers || [])) {
3319
+ const match = token.match(/^([A-Za-z][A-Za-z0-9._-]*):([1-9][0-9]*)$/);
3320
+ if (!match) {
3321
+ expanded.push(token);
3322
+ continue;
3323
+ }
3324
+ const kind = sanitizePeerPart(match[1], 'peer');
3325
+ const count = Number.parseInt(match[2], 10);
3326
+ for (let i = 1; i <= count; i += 1) expanded.push(`${kind}-team-${parentId}-${i}`);
3327
+ }
3328
+ return expanded;
3329
+ }
3330
+
3331
+ function assignTeamWorkers(items, workers, parentId) {
3332
+ const workerList = expandTeamWorkers(workers, parentId);
3333
+ if (!workerList.length) return items;
3334
+ return items.map((item, index) => ({
3335
+ ...item,
3336
+ assignee: item.assignee || workerList[index % workerList.length]
3337
+ }));
3338
+ }
3339
+
3340
+ function teamChildren(db, parentId) {
3341
+ return db.prepare(`
3342
+ SELECT *
3343
+ FROM tasks
3344
+ WHERE parent_id = ?
3345
+ ORDER BY priority ASC, id ASC
3346
+ `).all(parentId);
3347
+ }
3348
+
3349
+ function taskById(db, id) {
3350
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
3351
+ }
3352
+
3353
+ function teamSummary(db, parentId) {
3354
+ const parent = taskById(db, parentId);
3355
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
3356
+ const children = teamChildren(db, parentId);
3357
+ const handoffs = db.prepare(`
3358
+ SELECT *
3359
+ FROM handoffs
3360
+ WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
3361
+ ORDER BY id ASC
3362
+ `).all(parentId, parentId);
3363
+ const counts = {};
3364
+ for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
3365
+ return { parent, children, handoffs, counts };
3366
+ }
3367
+
3368
+ async function cmdTeam(ctx, args) {
3369
+ const sub = args[0];
3370
+ if (!sub || wantsHelp(args)) return helpTeam();
3371
+ if (sub === 'plan') return teamPlan(ctx, args.slice(1));
3372
+ if (sub === 'start') return teamStart(ctx, args.slice(1));
3373
+ if (sub === 'status') return teamStatus(ctx, args.slice(1));
3374
+ throw new CliError('BAD_ARGS', `Unknown team command: ${sub}`);
3375
+ }
3376
+
3377
+ async function teamPlan(ctx, args) {
3378
+ const opts = parseOpts(args, { arrays: ['item'] });
3379
+ const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
3380
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
3381
+ const db = connect(ctx);
3382
+ const parent = taskById(db, parentId);
3383
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
3384
+ const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
3385
+ const data = { parent, items };
3386
+ printResult(ctx, data, (plan) => {
3387
+ const lines = [`team plan for task #${plan.parent.id}: ${plan.parent.title}`];
3388
+ plan.items.forEach((item, index) => {
3389
+ lines.push(`${index + 1}. ${item.role}: ${item.title}${item.assignee ? ` -> ${item.assignee}` : ''}`);
3390
+ });
3391
+ lines.push('', `start: ${CLI_NAME} team start --from-task ${plan.parent.id} ${plan.items.map((item) => `--item ${shellQuoteArg(`${item.assignee ? `${item.assignee}:` : ''}${item.role}:${item.title}`)}`).join(' ')}`.trim());
3392
+ return lines.join('\n');
3393
+ });
3394
+ }
3395
+
3396
+ async function teamStart(ctx, args) {
3397
+ const opts = parseOpts(args, { arrays: ['item'], booleans: ['force'] });
3398
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
3399
+ const actor = identity.id;
3400
+ const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
3401
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
3402
+ const priorityBase = intOpt(opts, 'priority', 100);
3403
+ const db = connect(ctx);
3404
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
3405
+ const result = tx(db, () => {
3406
+ const parent = taskById(db, parentId);
3407
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
3408
+ const existing = teamChildren(db, parentId);
3409
+ if (existing.length && !opts.force) {
3410
+ throw new CliError('TEAM_EXISTS', `Task #${parentId} already has ${existing.length} team subtask(s); use --force to add more`, {
3411
+ parent_id: parentId,
3412
+ children: existing.map((task) => task.id)
3413
+ });
3414
+ }
3415
+ const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
3416
+ const t = now();
3417
+ const children = [];
3418
+ items.forEach((item, index) => {
3419
+ const info = db.prepare(`
3420
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
3421
+ VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
3422
+ `).run(
3423
+ item.title,
3424
+ opts.body || `Subtask for #${parentId}: ${parent.title}`,
3425
+ item.assignee || null,
3426
+ parentId,
3427
+ item.role || `worker-${index + 1}`,
3428
+ priorityBase + index,
3429
+ actor,
3430
+ t,
3431
+ t
3432
+ );
3433
+ const taskId = Number(info.lastInsertRowid);
3434
+ addEvent(db, 'task.created', actor, taskId, {
3435
+ title: item.title,
3436
+ assignee: item.assignee || null,
3437
+ priority: priorityBase + index,
3438
+ parent_id: parentId,
3439
+ team_role: item.role || null
3440
+ });
3441
+ if (item.assignee) sendMessage(db, actor, item.assignee, taskId, 'task', `Task #${taskId} assigned: ${item.title}`);
3442
+ children.push(taskById(db, taskId));
3443
+ });
3444
+ addEvent(db, 'team.started', actor, parentId, {
3445
+ child_tasks: children.map((task) => task.id),
3446
+ workers: expandTeamWorkers(opts.workers || [], parentId)
3447
+ });
3448
+ return { parent, children };
3449
+ });
3450
+ printResult(ctx, result, (data) => {
3451
+ const lines = [`started team for task #${data.parent.id}: ${data.children.length} subtask${data.children.length === 1 ? '' : 's'}`];
3452
+ for (const child of data.children) {
3453
+ lines.push(`- #${child.id} ${child.team_role || 'worker'}${child.assignee ? ` -> ${child.assignee}` : ''}: ${child.title}`);
3454
+ }
3455
+ return lines.join('\n');
3456
+ });
2468
3457
  }
2469
3458
 
2470
- async function taskDone(ctx, args) {
2471
- const opts = parseOpts(args, { booleans: ['force'] });
2472
- opts.status = 'done';
2473
- if (opts.summary && !opts.body) opts.body = opts.summary;
2474
- return taskUpdate(ctx, args.concat(['--status', 'done']));
3459
+ async function teamStatus(ctx, args) {
3460
+ const opts = parseOpts(args);
3461
+ const parentId = intOpt(opts, 'task', intOpt(opts, 'from-task', intOpt({ task: opts._[0] }, 'task')));
3462
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --task');
3463
+ const db = connect(ctx);
3464
+ const data = teamSummary(db, parentId);
3465
+ printResult(ctx, data, (summary) => {
3466
+ const countText = Object.entries(summary.counts).map(([status, count]) => `${status}:${count}`).join(', ') || 'none';
3467
+ const lines = [
3468
+ `team task #${summary.parent.id}: ${summary.parent.title}`,
3469
+ `parent status: ${summary.parent.status}`,
3470
+ `subtasks: ${summary.children.length} (${countText})`
3471
+ ];
3472
+ for (const child of summary.children) {
3473
+ lines.push(`- #${child.id} ${child.status} ${child.team_role || 'worker'}${child.owner ? ` owner=${child.owner}` : ''}${child.assignee ? ` assignee=${child.assignee}` : ''}: ${child.title}`);
3474
+ }
3475
+ if (summary.handoffs.length) {
3476
+ lines.push('handoffs:');
3477
+ for (const handoff of summary.handoffs.slice(-8)) {
3478
+ lines.push(`- #${handoff.id} task #${handoff.task_id || ''} ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}: ${handoff.summary}`);
3479
+ }
3480
+ }
3481
+ return lines.join('\n');
3482
+ });
2475
3483
  }
2476
3484
 
2477
3485
  async function cmdMsg(ctx, args) {
@@ -2480,6 +3488,8 @@ async function cmdMsg(ctx, args) {
2480
3488
  if (sub === 'send') return msgSend(ctx, args.slice(1));
2481
3489
  if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
2482
3490
  if (sub === 'ack') return msgAck(ctx, args.slice(1));
3491
+ if (sub === 'reply') return msgReply(ctx, args.slice(1));
3492
+ if (sub === 'thread') return msgThread(ctx, args.slice(1));
2483
3493
  throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
2484
3494
  }
2485
3495
 
@@ -2494,7 +3504,8 @@ async function msgSend(ctx, args) {
2494
3504
  const db = connect(ctx);
2495
3505
  touchCurrentPeer(db, ctx, identity, null, 'shell');
2496
3506
  const id = sendMessage(db, sender, recipient, taskId, kind, body);
2497
- printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body }, (data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
3507
+ printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, reply_to: null, thread_id: id },
3508
+ (data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
2498
3509
  }
2499
3510
 
2500
3511
  async function msgInbox(ctx, args) {
@@ -2517,6 +3528,8 @@ async function msgInbox(ctx, args) {
2517
3528
  { label: 'from', value: (r) => r.sender },
2518
3529
  { label: 'kind', value: (r) => r.kind },
2519
3530
  { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
3531
+ { label: 'reply', value: (r) => r.reply_to ? `#${r.reply_to}` : '' },
3532
+ { label: 'thread', value: (r) => r.thread_id ? `#${r.thread_id}` : '' },
2520
3533
  { label: 'time', value: (r) => iso(r.created_at) },
2521
3534
  { label: 'body', value: (r) => r.body }
2522
3535
  ]));
@@ -2532,13 +3545,73 @@ async function msgAck(ctx, args) {
2532
3545
  touchCurrentPeer(db, ctx, identity, null, 'shell');
2533
3546
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
2534
3547
  if (!message) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
3548
+ ackMessage(db, peer, message);
3549
+ printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
3550
+ }
3551
+
3552
+ function ackMessage(db, peer, message) {
2535
3553
  db.prepare(`
2536
3554
  INSERT INTO message_reads(message_id, peer, read_at)
2537
3555
  VALUES (?, ?, ?)
2538
3556
  ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
2539
- `).run(id, peer, now());
2540
- addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: id });
2541
- printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
3557
+ `).run(message.id, peer, now());
3558
+ addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: message.id });
3559
+ }
3560
+
3561
+ async function msgReply(ctx, args) {
3562
+ const opts = parseOpts(args);
3563
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
3564
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
3565
+ const body = required(opts, 'body');
3566
+ const db = connect(ctx);
3567
+ const original = getMessage(db, id);
3568
+ if (!original) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
3569
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
3570
+ const sender = identity.id;
3571
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
3572
+ const recipient = opts.to || original.sender;
3573
+ const taskId = intOpt(opts, 'task', original.task_id || null);
3574
+ const kind = opts.kind || 'reply';
3575
+ const threadId = original.thread_id || original.id;
3576
+ const replyId = sendMessage(db, sender, recipient, taskId, kind, body, {
3577
+ reply_to: original.id,
3578
+ thread_id: threadId
3579
+ });
3580
+ ackMessage(db, sender, original);
3581
+ printResult(ctx, {
3582
+ id: replyId,
3583
+ sender,
3584
+ recipient,
3585
+ task_id: taskId,
3586
+ kind,
3587
+ body,
3588
+ reply_to: original.id,
3589
+ thread_id: threadId
3590
+ }, (data) => `sent reply #${data.id} to #${data.reply_to} ${data.sender} -> ${data.recipient}`);
3591
+ }
3592
+
3593
+ async function msgThread(ctx, args) {
3594
+ const opts = parseOpts(args);
3595
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
3596
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
3597
+ const limit = intOpt(opts, 'limit', 50);
3598
+ const db = connect(ctx);
3599
+ const data = queryMessageThread(db, id, limit);
3600
+ printResult(ctx, data, (thread) => {
3601
+ const lines = [`thread #${thread.thread_id} (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`];
3602
+ for (const message of thread.messages) {
3603
+ const parts = [
3604
+ `#${message.id}`,
3605
+ `${message.sender} -> ${message.recipient || 'all'}`,
3606
+ message.task_id ? `task #${message.task_id}` : '',
3607
+ message.reply_to ? `reply #${message.reply_to}` : '',
3608
+ message.kind || 'note',
3609
+ iso(message.created_at)
3610
+ ].filter(Boolean).join(' ');
3611
+ lines.push(`${parts}\n ${message.body}`);
3612
+ }
3613
+ return lines.join('\n');
3614
+ });
2542
3615
  }
2543
3616
 
2544
3617
  async function cmdAsk(ctx, args) {
@@ -2626,9 +3699,13 @@ async function peerList(ctx, args) {
2626
3699
  } finally {
2627
3700
  db.close();
2628
3701
  }
2629
- const rows = (data.sessions || []).map((session) => ({ ...session, binding: bindings.get(session.id) || null }));
3702
+ const rows = (data.sessions || []).map((session) => ({
3703
+ ...session,
3704
+ binding: bindings.get(session.peer_id || session.id) || bindings.get(session.id) || null
3705
+ }));
2630
3706
  printResult(ctx, rows, (items) => table(items, [
2631
3707
  { label: 'id', value: (r) => r.id },
3708
+ { label: 'peer', value: (r) => r.peer_id && r.peer_id !== r.id ? r.peer_id : '' },
2632
3709
  { label: 'kind', value: (r) => r.kind },
2633
3710
  { label: 'role', value: (r) => r.role || '' },
2634
3711
  { label: 'status', value: (r) => r.status },
@@ -2964,6 +4041,46 @@ async function cmdStatus(ctx, args) {
2964
4041
  });
2965
4042
  }
2966
4043
 
4044
+ function normalizeStateResources(values) {
4045
+ const list = Array.isArray(values) ? values : [values];
4046
+ return uniqueList(list.flatMap((value) => String(value || '').split(',').map((part) => part.trim())));
4047
+ }
4048
+
4049
+ async function cmdState(ctx, args) {
4050
+ if (wantsHelp(args)) return helpState();
4051
+ const opts = parseOpts(args, { arrays: ['resource'] });
4052
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
4053
+ const peer = identity.id;
4054
+ const resources = normalizeStateResources(opts.resource || opts.resources || []);
4055
+ const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null });
4056
+ printResult(ctx, snapshot, (data) => {
4057
+ const automation = data.automation;
4058
+ const lines = [
4059
+ `root: ${data.root}`,
4060
+ `peer: ${peer}`,
4061
+ automation.current_task ? `current task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
4062
+ `phase: ${automation.phase}`,
4063
+ `next: ${automation.next_action.command || automation.next_action.kind}`,
4064
+ `why: ${automation.next_action.reason}`
4065
+ ].filter(Boolean);
4066
+ if (automation.finish_actions.length) {
4067
+ lines.push('', 'finish actions:');
4068
+ lines.push(...automation.finish_actions.map((action) => `- ${action.command}`));
4069
+ }
4070
+ if (automation.warnings.length) {
4071
+ lines.push('', 'warnings:');
4072
+ lines.push(...automation.warnings.map((warning) => `- ${warning}`));
4073
+ }
4074
+ if (data.timeline.length) {
4075
+ lines.push('', 'timeline:');
4076
+ for (const item of data.timeline.slice(-8)) {
4077
+ lines.push(`- ${iso(item.ts)} ${item.source}:${item.source_id} ${item.title}${item.text ? ` — ${item.text}` : ''}`);
4078
+ }
4079
+ }
4080
+ return lines.join('\n');
4081
+ });
4082
+ }
4083
+
2967
4084
  async function cmdPrompt(ctx, args) {
2968
4085
  const opts = parseOpts(args);
2969
4086
  const peer = required(opts, 'peer', 'HCC_PEER');
@@ -2978,11 +4095,13 @@ project uses a flat peer mesh: there is no required main/worker hierarchy.
2978
4095
  Run these commands before changing files:
2979
4096
 
2980
4097
  ${cmd} register --peer ${peer} --kind ${kind} --role ${role}
4098
+ ${cmd} state --peer ${peer}
2981
4099
  ${cmd} msg inbox --peer ${peer}
2982
4100
  ${cmd} task next --peer ${peer}
2983
4101
 
2984
4102
  Coordination rules:
2985
4103
  - Claim exactly one task before editing.
4104
+ - If state shows a current task for ${peer}, continue that task before claiming another pending task.
2986
4105
  - Before editing a file, directory, module, or shared test resource, run:
2987
4106
  ${cmd} lock acquire --peer ${peer} --resource <path-or-module> --task <task-id>
2988
4107
  - If a lock is held by another peer, message that peer instead of editing:
@@ -3203,19 +4322,25 @@ async function prepareLocalBus(ctx, opts = {}) {
3203
4322
  }
3204
4323
 
3205
4324
  async function startWebBackground(ctx, args) {
3206
- const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
3207
- validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
4325
+ const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
4326
+ validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
4327
+ validateWebTokenOpts(opts);
3208
4328
  ensureTmuxAvailable({ autoInstall: true });
3209
4329
  const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
3210
4330
  registerProject(ctx);
3211
4331
 
3212
4332
  const existing = await readHealthyGlobalRuntime();
3213
4333
  if (existing) {
3214
- try {
3215
- await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
3216
- } catch {}
3217
- writeRuntime(ctx, { ...existing, root: ctx.root, db: ctx.dbPath, project_root: ctx.root, global_runtime: true });
3218
- return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
4334
+ if (webRuntimeMatchesRequest(existing, opts)) {
4335
+ rememberRuntimeToken(existing, opts);
4336
+ try {
4337
+ await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
4338
+ } catch {}
4339
+ writeRuntime(ctx, { ...existing, root: ctx.root, db: ctx.dbPath, project_root: ctx.root, global_runtime: true });
4340
+ return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
4341
+ }
4342
+ try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
4343
+ await sleep(250);
3219
4344
  }
3220
4345
 
3221
4346
  try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
@@ -3340,57 +4465,10 @@ async function cmdDown(ctx, args) {
3340
4465
  printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
3341
4466
  }
3342
4467
 
3343
- function statusSnapshot(ctx, peer = null) {
4468
+ function statusSnapshot(ctx, peer = null, opts = {}) {
3344
4469
  const db = connect(ctx);
3345
4470
  try {
3346
- const t = now();
3347
- const peers = db.prepare(`
3348
- SELECT id, kind, role, status, worktree, branch, pid, capabilities,
3349
- created_at, last_seen_at, (? - last_seen_at) AS age_sec
3350
- FROM peers
3351
- ORDER BY last_seen_at DESC, id ASC
3352
- LIMIT 200
3353
- `).all(t);
3354
- const tasks = queryOpenTasks(db, 200);
3355
- const locks = db.prepare(`
3356
- SELECT resource, owner, task_id, reason, expires_at, created_at
3357
- FROM locks
3358
- WHERE expires_at > ?
3359
- ORDER BY resource ASC
3360
- LIMIT 200
3361
- `).all(t);
3362
- const messages = peer
3363
- ? queryInbox(db, peer, false, 50)
3364
- : db.prepare(`
3365
- SELECT id, sender, recipient, task_id, kind, body, created_at
3366
- FROM messages
3367
- ORDER BY id DESC
3368
- LIMIT 50
3369
- `).all().reverse();
3370
- const handoffs = db.prepare(`
3371
- SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
3372
- FROM handoffs
3373
- ORDER BY id DESC
3374
- LIMIT 50
3375
- `).all().reverse();
3376
- const events = db.prepare(`
3377
- SELECT id, type, actor, task_id, payload, created_at
3378
- FROM events
3379
- ORDER BY id DESC
3380
- LIMIT 80
3381
- `).all().reverse();
3382
- return {
3383
- root: ctx.root,
3384
- db: ctx.dbPath,
3385
- now: t,
3386
- active_peer_ttl: ACTIVE_PEER_TTL,
3387
- peers,
3388
- tasks,
3389
- locks,
3390
- messages,
3391
- handoffs,
3392
- events
3393
- };
4471
+ return collectStateSnapshot(db, ctx, peer, opts);
3394
4472
  } finally {
3395
4473
  try {
3396
4474
  db.close();
@@ -3465,11 +4543,72 @@ function webIndexHtml() {
3465
4543
  font-size: 12px;
3466
4544
  }
3467
4545
  .app {
4546
+ position: relative;
3468
4547
  height: 100vh;
3469
4548
  display: grid;
3470
- grid-template-columns: 320px 1fr 360px;
3471
- min-width: 980px;
3472
- }
4549
+ --left-width: 320px;
4550
+ --right-width: 360px;
4551
+ grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
4552
+ min-width: 640px;
4553
+ transition: grid-template-columns .18s ease;
4554
+ }
4555
+ .app.resizing { transition: none; }
4556
+ .app.left-collapsed { grid-template-columns: 0 minmax(0, 1fr) var(--right-width); }
4557
+ .app.right-collapsed { grid-template-columns: var(--left-width) minmax(0, 1fr) 0; }
4558
+ .app.left-collapsed.right-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
4559
+ /* Hide sidebar borders when collapsed so no 1px seam remains. */
4560
+ .app.left-collapsed .sidebar { border-right-width: 0; }
4561
+ .app.right-collapsed .inspector { border-left-width: 0; }
4562
+ .edge-resizer {
4563
+ position: absolute;
4564
+ top: 0;
4565
+ bottom: 0;
4566
+ z-index: 55;
4567
+ width: 12px;
4568
+ cursor: col-resize;
4569
+ touch-action: none;
4570
+ user-select: none;
4571
+ background: transparent;
4572
+ transition: left .18s ease, right .18s ease, background .12s;
4573
+ }
4574
+ .edge-resizer:hover,
4575
+ .app.resizing .edge-resizer {
4576
+ background: rgba(126, 231, 215, .08);
4577
+ }
4578
+ .app.resizing .edge-resizer { transition: none; }
4579
+ .edge-resizer-left { left: var(--left-width); transform: translateX(-50%); }
4580
+ .edge-resizer-right { right: var(--right-width); transform: translateX(50%); }
4581
+ .app.left-collapsed .edge-resizer-left { left: 0; }
4582
+ .app.right-collapsed .edge-resizer-right { right: 0; }
4583
+ /* Small collapse handles centered vertically on each divider border.
4584
+ They are children of .app (no overflow clip) and track the column edge. */
4585
+ .edge-toggle {
4586
+ position: absolute;
4587
+ top: 50%;
4588
+ z-index: 60;
4589
+ width: 16px;
4590
+ height: 44px;
4591
+ padding: 0;
4592
+ display: flex;
4593
+ align-items: center;
4594
+ justify-content: center;
4595
+ font-size: 11px;
4596
+ line-height: 1;
4597
+ color: var(--muted);
4598
+ background: var(--panel);
4599
+ border: 1px solid var(--border);
4600
+ border-radius: 6px;
4601
+ cursor: col-resize;
4602
+ touch-action: none;
4603
+ user-select: none;
4604
+ transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
4605
+ }
4606
+ .app.resizing .edge-toggle { transition: none; }
4607
+ .edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
4608
+ .edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
4609
+ .edge-right { right: var(--right-width); transform: translate(50%, -50%); }
4610
+ .app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
4611
+ .app.right-collapsed .edge-right { right: 0; transform: translate(0, -50%); }
3473
4612
  .sidebar, .inspector {
3474
4613
  min-height: 0;
3475
4614
  min-width: 0;
@@ -3531,6 +4670,14 @@ function webIndexHtml() {
3531
4670
  gap: 8px;
3532
4671
  align-items: end;
3533
4672
  }
4673
+ .start-options {
4674
+ display: grid;
4675
+ grid-template-columns: 1fr 1fr;
4676
+ gap: 8px;
4677
+ }
4678
+ .start-options label[data-resume-field] {
4679
+ grid-column: 1 / -1;
4680
+ }
3534
4681
  .session-header {
3535
4682
  border-bottom: 1px solid var(--border);
3536
4683
  padding: 8px 10px;
@@ -3616,17 +4763,67 @@ function webIndexHtml() {
3616
4763
  overflow: hidden;
3617
4764
  }
3618
4765
  .toolbar .title {
3619
- min-width: 180px;
4766
+ flex: 1 1 auto;
4767
+ min-width: 0;
3620
4768
  display: grid;
3621
4769
  gap: 2px;
3622
4770
  }
4771
+ .toolbar .title strong {
4772
+ overflow: hidden;
4773
+ text-overflow: ellipsis;
4774
+ white-space: nowrap;
4775
+ }
3623
4776
  .quick {
3624
4777
  display: flex;
3625
- gap: 6px;
3626
- flex-wrap: wrap;
3627
- justify-content: flex-end;
4778
+ gap: 8px;
4779
+ flex-wrap: nowrap;
4780
+ align-items: center;
4781
+ flex: 0 0 auto;
4782
+ }
4783
+ /* Toolbar edge toggles for collapsing the side panels. */
4784
+ .icon-btn {
4785
+ flex: 0 0 auto;
4786
+ width: 30px;
4787
+ height: 30px;
4788
+ padding: 0;
4789
+ display: inline-flex;
4790
+ align-items: center;
4791
+ justify-content: center;
4792
+ font-size: 15px;
4793
+ line-height: 1;
4794
+ color: var(--muted);
4795
+ background: transparent;
4796
+ border: 1px solid var(--border);
4797
+ border-radius: 7px;
4798
+ cursor: pointer;
4799
+ }
4800
+ .icon-btn:hover { color: var(--text); border-color: var(--accent); }
4801
+ /* Compact actions dropdown so the top bar stays a single tidy row. */
4802
+ .menu-wrap { position: relative; display: inline-flex; }
4803
+ .menu-btn { white-space: nowrap; }
4804
+ .menu {
4805
+ position: fixed;
4806
+ z-index: 1000;
4807
+ min-width: 150px;
4808
+ padding: 5px;
4809
+ display: grid;
4810
+ gap: 2px;
4811
+ background: var(--panel);
4812
+ border: 1px solid var(--border);
4813
+ border-radius: 8px;
4814
+ box-shadow: 0 8px 24px rgba(0,0,0,.45);
4815
+ }
4816
+ .menu[hidden] { display: none; }
4817
+ .menu button {
3628
4818
  width: 100%;
4819
+ text-align: left;
4820
+ border: 0;
4821
+ background: transparent;
4822
+ padding: 7px 9px;
4823
+ border-radius: 6px;
4824
+ color: var(--text);
3629
4825
  }
4826
+ .menu button:hover { background: #1b1f26; }
3630
4827
  #terminal {
3631
4828
  min-height: 0;
3632
4829
  overflow: hidden;
@@ -3712,22 +4909,30 @@ function webIndexHtml() {
3712
4909
  <strong>hello-cc</strong>
3713
4910
  <div class="path" id="rootPath"></div>
3714
4911
  </div>
3715
- <span id="connState">offline</span>
4912
+ <span id="connState" data-i18n="conn.offline">offline</span>
3716
4913
  </div>
3717
4914
  <div class="form" style="padding-top:10px;padding-bottom:10px">
3718
- <label>Project<select id="projectSelect"></select></label>
3719
- <label>Project path<input id="projectPath" placeholder="/path/to/project"></label>
3720
- <button id="addProjectBtn" type="button">Register Project</button>
4915
+ <div class="grid2">
4916
+ <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
4917
+ <label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
4918
+ </div>
4919
+ <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
4920
+ <button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
3721
4921
  </div>
3722
4922
  <form class="form" id="startForm">
3723
4923
  <div class="start-row">
3724
- <label>New session<select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell">shell</option></select></label>
3725
- <button class="primary" type="submit">Start</button>
4924
+ <label><span data-i18n="newSession">New session</span><select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell" data-i18n="kind.shell">shell</option></select></label>
4925
+ <button class="primary" type="submit" data-i18n="start">Start</button>
4926
+ </div>
4927
+ <div class="start-options">
4928
+ <label><span data-i18n="mode">Mode</span><select id="startMode"><option value="new" data-i18n="mode.new">new</option><option value="resume" data-i18n="mode.resume">resume</option><option value="last" data-i18n="mode.last">last</option><option value="continue" data-i18n="mode.continue">continue</option></select></label>
4929
+ <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
4930
+ <label data-resume-field data-resume-custom style="display:none"><span data-i18n="sessionId">Session id</span><input id="resumeArg" data-i18n-placeholder="sessionIdPlaceholder" placeholder="session id or name"></label>
3726
4931
  </div>
3727
4932
  </form>
3728
4933
  <div class="session-header">
3729
- <strong>Sessions</strong>
3730
- <label>View<select id="sessionKindFilter"><option value="all">all</option><option value="claude">claude</option><option value="codex">codex</option><option value="shell">shell</option><option value="other">other</option></select></label>
4934
+ <strong data-i18n="sessions">Sessions</strong>
4935
+ <label><span data-i18n="view">View</span><select id="sessionKindFilter"><option value="all" data-i18n="kind.all">all</option><option value="claude">claude</option><option value="codex">codex</option><option value="shell" data-i18n="kind.shell">shell</option><option value="other" data-i18n="kind.other">other</option></select></label>
3731
4936
  </div>
3732
4937
  <div class="sessions" id="sessions"></div>
3733
4938
  </aside>
@@ -3735,16 +4940,21 @@ function webIndexHtml() {
3735
4940
  <main class="main">
3736
4941
  <div class="toolbar">
3737
4942
  <div class="title">
3738
- <strong id="activeTitle">No session selected</strong>
3739
- <span class="path" id="activeMeta">Start or select a session from the left panel</span>
4943
+ <strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
4944
+ <span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
3740
4945
  </div>
3741
4946
  <div class="quick" id="quickBar">
3742
- <button data-send="register">register</button>
3743
- <button data-send="inbox">inbox</button>
3744
- <button data-send="next">next task</button>
3745
- <button data-send="status">status</button>
3746
- <button data-send="heartbeat">heartbeat</button>
3747
- <button class="danger" id="stopBtn" type="button">stop</button>
4947
+ <div class="menu-wrap">
4948
+ <button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
4949
+ <div class="menu" id="actionsMenu" hidden>
4950
+ <button data-send="register" data-i18n="action.register">register</button>
4951
+ <button data-send="inbox" data-i18n="action.inbox">inbox</button>
4952
+ <button data-send="next" data-i18n="action.nextTask">next task</button>
4953
+ <button data-send="status" data-i18n="action.status">status</button>
4954
+ <button data-send="heartbeat" data-i18n="action.heartbeat">heartbeat</button>
4955
+ </div>
4956
+ </div>
4957
+ <button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
3748
4958
  </div>
3749
4959
  </div>
3750
4960
  <div id="terminal" style="min-height:0;flex:1"></div>
@@ -3753,11 +4963,16 @@ function webIndexHtml() {
3753
4963
 
3754
4964
  <aside class="inspector">
3755
4965
  <div class="brand">
3756
- <strong>Project State</strong>
3757
- <button id="refreshBtn" type="button">Refresh</button>
4966
+ <strong data-i18n="projectState">Project State</strong>
4967
+ <button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
3758
4968
  </div>
3759
4969
  <div class="state" id="state"></div>
3760
4970
  </aside>
4971
+
4972
+ <button class="edge-toggle edge-left" id="toggleLeft" type="button" data-i18n-title="collapseSidebar" data-i18n-aria="toggleLeftSidebar" title="Collapse sidebar" aria-label="Toggle left sidebar">⟨</button>
4973
+ <button class="edge-toggle edge-right" id="toggleRight" type="button" data-i18n-title="collapseStatePanel" data-i18n-aria="toggleRightPanel" title="Collapse state panel" aria-label="Toggle right panel">⟩</button>
4974
+ <div class="edge-resizer edge-resizer-left" id="resizeLeft" role="separator" aria-orientation="vertical" data-i18n-aria="resizeLeftSidebar" data-i18n-title="resizeLeftSidebar" aria-label="Resize left sidebar" title="Resize left sidebar"></div>
4975
+ <div class="edge-resizer edge-resizer-right" id="resizeRight" role="separator" aria-orientation="vertical" data-i18n-aria="resizeRightPanel" data-i18n-title="resizeRightPanel" aria-label="Resize right panel" title="Resize right panel"></div>
3761
4976
  </div>
3762
4977
 
3763
4978
  <script src="/assets/xterm.js"></script>
@@ -3770,6 +4985,7 @@ function webIndexHtml() {
3770
4985
  let sessionKindFilter = initialParams.get('kind') || 'all';
3771
4986
  let sessions = []; // managed (PTY) sessions
3772
4987
  let detected = []; // coordination-only peers (from hooks/watcher)
4988
+ let resumableCache = []; // provider sessions available to resume (from /api/resumable)
3773
4989
  let active = null; // active managed session id
3774
4990
  let activeDetected = null; // active detected peer id
3775
4991
  let activeType = 'managed'; // 'managed' | 'detected'
@@ -3778,6 +4994,278 @@ function webIndexHtml() {
3778
4994
  let lastStateRoot = '';
3779
4995
  let ws = null;
3780
4996
  let wsReconnectTimer = null;
4997
+ const i18n = {
4998
+ en: {
4999
+ language: 'Language',
5000
+ project: 'Project',
5001
+ projectPath: 'Project path',
5002
+ projectPathPlaceholder: '/path/to/project',
5003
+ registerProject: 'Register Project',
5004
+ newSession: 'New session',
5005
+ start: 'Start',
5006
+ mode: 'Mode',
5007
+ 'mode.new': 'new',
5008
+ 'mode.resume': 'resume',
5009
+ 'mode.last': 'last',
5010
+ 'mode.continue': 'continue',
5011
+ session: 'Session',
5012
+ sessionId: 'Session id',
5013
+ sessionIdPlaceholder: 'session id or name',
5014
+ sessions: 'Sessions',
5015
+ view: 'View',
5016
+ 'kind.all': 'all',
5017
+ 'kind.shell': 'shell',
5018
+ 'kind.other': 'other',
5019
+ noSessionSelected: 'No session selected',
5020
+ startOrSelect: 'Start or select a session from the left panel',
5021
+ actions: 'Actions',
5022
+ 'action.register': 'register',
5023
+ 'action.inbox': 'inbox',
5024
+ 'action.nextTask': 'next task',
5025
+ 'action.status': 'status',
5026
+ 'action.heartbeat': 'heartbeat',
5027
+ stop: 'stop',
5028
+ projectState: 'Project State',
5029
+ refresh: 'Refresh',
5030
+ managed: 'Managed',
5031
+ detected: 'Detected',
5032
+ running: 'running',
5033
+ noActiveSessions: 'No active sessions.',
5034
+ startOneAbove: 'Start one above',
5035
+ orRunTerminal: 'or run in any terminal:',
5036
+ noDetectedPeers: 'No detected peers.',
5037
+ viewFilter: 'View filter',
5038
+ noTasks: 'No tasks.',
5039
+ noPeers: 'No peers.',
5040
+ noActiveLocks: 'No active locks.',
5041
+ noMessages: 'No messages.',
5042
+ noTimelineItems: 'No timeline items.',
5043
+ nextAction: 'Next Action',
5044
+ timeline: 'Timeline',
5045
+ messages: 'Messages',
5046
+ peers: 'Peers',
5047
+ tasks: 'Tasks',
5048
+ locks: 'Locks',
5049
+ owner: 'owner',
5050
+ assignee: 'assignee',
5051
+ task: 'task',
5052
+ all: 'all',
5053
+ next: 'next',
5054
+ finish: 'finish',
5055
+ warnings: 'warnings',
5056
+ noImmediateAction: 'No immediate coordination action',
5057
+ detectedSession: 'Detected Session',
5058
+ sendMessage: 'Send Message',
5059
+ messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
5060
+ messageBodyPlaceholder: 'Message body...',
5061
+ send: 'Send',
5062
+ peer: 'peer',
5063
+ kind: 'kind',
5064
+ status: 'status',
5065
+ cwd: 'cwd',
5066
+ pid: 'pid',
5067
+ unknown: 'unknown',
5068
+ lastSeen: 'last seen',
5069
+ secondsAgo: 's ago',
5070
+ age: 'age',
5071
+ branch: 'branch',
5072
+ runtime: 'runtime',
5073
+ thread: 'thread',
5074
+ reply: 'reply',
5075
+ 'status.active': 'active',
5076
+ 'status.stale': 'stale',
5077
+ 'status.unknown': 'unknown',
5078
+ 'status.running': 'running',
5079
+ 'status.idle': 'idle',
5080
+ 'status.exited': 'exited',
5081
+ 'status.claimed': 'claimed',
5082
+ 'status.review': 'review',
5083
+ 'status.blocked': 'blocked',
5084
+ 'status.done': 'done',
5085
+ 'status.abandoned': 'abandoned',
5086
+ 'status.pending': 'pending',
5087
+ 'conn.offline': 'offline',
5088
+ 'conn.online': 'online',
5089
+ 'conn.attached': 'attached',
5090
+ 'conn.reconnecting': 'reconnecting...',
5091
+ 'conn.coordinationOnly': 'coordination only',
5092
+ 'conn.error': 'error',
5093
+ show: 'Show',
5094
+ collapse: 'Collapse',
5095
+ sidebar: 'sidebar',
5096
+ statePanel: 'state panel',
5097
+ dragToResize: 'drag to resize',
5098
+ collapseSidebar: 'Collapse sidebar',
5099
+ collapseStatePanel: 'Collapse state panel',
5100
+ toggleLeftSidebar: 'Toggle left sidebar',
5101
+ toggleRightPanel: 'Toggle right panel',
5102
+ resizeLeftSidebar: 'Resize left sidebar',
5103
+ resizeRightPanel: 'Resize right panel',
5104
+ customSession: 'custom session id...'
5105
+ },
5106
+ zh: {
5107
+ language: '语言',
5108
+ project: '项目',
5109
+ projectPath: '项目路径',
5110
+ projectPathPlaceholder: '/项目/路径',
5111
+ registerProject: '注册项目',
5112
+ newSession: '新会话',
5113
+ start: '启动',
5114
+ mode: '模式',
5115
+ 'mode.new': '新建',
5116
+ 'mode.resume': '恢复',
5117
+ 'mode.last': '最近',
5118
+ 'mode.continue': '继续',
5119
+ session: '会话',
5120
+ sessionId: '会话 ID',
5121
+ sessionIdPlaceholder: '会话 ID 或名称',
5122
+ sessions: '会话',
5123
+ view: '视图',
5124
+ 'kind.all': '全部',
5125
+ 'kind.shell': 'shell',
5126
+ 'kind.other': '其他',
5127
+ noSessionSelected: '未选择会话',
5128
+ startOrSelect: '从左侧面板启动或选择一个会话',
5129
+ actions: '操作',
5130
+ 'action.register': '注册',
5131
+ 'action.inbox': '收件箱',
5132
+ 'action.nextTask': '下个任务',
5133
+ 'action.status': '状态',
5134
+ 'action.heartbeat': '心跳',
5135
+ stop: '停止',
5136
+ projectState: '项目状态',
5137
+ refresh: '刷新',
5138
+ managed: '托管',
5139
+ detected: '检测到',
5140
+ running: '运行中',
5141
+ noActiveSessions: '没有活动会话。',
5142
+ startOneAbove: '在上方启动一个会话',
5143
+ orRunTerminal: '或在任意终端运行:',
5144
+ noDetectedPeers: '没有检测到的协作方。',
5145
+ viewFilter: '视图过滤',
5146
+ noTasks: '没有任务。',
5147
+ noPeers: '没有协作方。',
5148
+ noActiveLocks: '没有活动锁。',
5149
+ noMessages: '没有消息。',
5150
+ noTimelineItems: '没有时间线条目。',
5151
+ nextAction: '下一步',
5152
+ timeline: '时间线',
5153
+ messages: '消息',
5154
+ peers: '协作方',
5155
+ tasks: '任务',
5156
+ locks: '锁',
5157
+ owner: '负责人',
5158
+ assignee: '执行者',
5159
+ task: '任务',
5160
+ all: '全部',
5161
+ next: '下一步',
5162
+ finish: '收尾',
5163
+ warnings: '警告',
5164
+ noImmediateAction: '当前没有需要立即处理的协作动作',
5165
+ detectedSession: '检测到的会话',
5166
+ sendMessage: '发送消息',
5167
+ messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
5168
+ messageBodyPlaceholder: '消息内容...',
5169
+ send: '发送',
5170
+ peer: '协作方',
5171
+ kind: '类型',
5172
+ status: '状态',
5173
+ cwd: '工作目录',
5174
+ pid: 'PID',
5175
+ unknown: '未知',
5176
+ lastSeen: '最后出现',
5177
+ secondsAgo: ' 秒前',
5178
+ age: '时长',
5179
+ branch: '分支',
5180
+ runtime: '运行时',
5181
+ thread: '线程',
5182
+ reply: '回复',
5183
+ 'status.active': '活跃',
5184
+ 'status.stale': '过期',
5185
+ 'status.unknown': '未知',
5186
+ 'status.running': '运行中',
5187
+ 'status.idle': '空闲',
5188
+ 'status.exited': '已退出',
5189
+ 'status.claimed': '已认领',
5190
+ 'status.review': '审查中',
5191
+ 'status.blocked': '阻塞',
5192
+ 'status.done': '完成',
5193
+ 'status.abandoned': '已放弃',
5194
+ 'status.pending': '待处理',
5195
+ 'conn.offline': '离线',
5196
+ 'conn.online': '在线',
5197
+ 'conn.attached': '已连接',
5198
+ 'conn.reconnecting': '重连中...',
5199
+ 'conn.coordinationOnly': '仅协作',
5200
+ 'conn.error': '错误',
5201
+ show: '显示',
5202
+ collapse: '折叠',
5203
+ sidebar: '侧栏',
5204
+ statePanel: '状态面板',
5205
+ dragToResize: '拖动调整大小',
5206
+ collapseSidebar: '折叠侧栏',
5207
+ collapseStatePanel: '折叠状态面板',
5208
+ toggleLeftSidebar: '切换左侧栏',
5209
+ toggleRightPanel: '切换右侧面板',
5210
+ resizeLeftSidebar: '调整左侧栏宽度',
5211
+ resizeRightPanel: '调整右侧面板宽度',
5212
+ customSession: '自定义会话 ID...'
5213
+ }
5214
+ };
5215
+ let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
5216
+ if (!i18n[lang]) lang = 'en';
5217
+
5218
+ function tr(key, fallback = '') {
5219
+ return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
5220
+ }
5221
+
5222
+ function setText(id, key) {
5223
+ const el = document.getElementById(id);
5224
+ if (el) el.textContent = tr(key);
5225
+ }
5226
+
5227
+ function connText(key) {
5228
+ const el = document.getElementById('connState');
5229
+ if (el) {
5230
+ el.dataset.stateKey = key;
5231
+ el.textContent = tr('conn.' + key);
5232
+ }
5233
+ }
5234
+
5235
+ function applyLanguage() {
5236
+ document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
5237
+ localStorage.setItem('hcc.lang', lang);
5238
+ const sel = document.getElementById('langSelect');
5239
+ if (sel) sel.value = lang;
5240
+ document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
5241
+ document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
5242
+ document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
5243
+ document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
5244
+ if (!active && !activeDetected) {
5245
+ setText('activeTitle', 'noSessionSelected');
5246
+ setText('activeMeta', 'startOrSelect');
5247
+ }
5248
+ const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
5249
+ connText(stateKey);
5250
+ syncStartModeOptions();
5251
+ renderSections();
5252
+ if (activeType === 'detected' && activeDetected) {
5253
+ const draft = document.getElementById('detMsg')?.value || '';
5254
+ const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
5255
+ document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
5256
+ renderDetectedPanel(peer);
5257
+ const detMsg = document.getElementById('detMsg');
5258
+ if (detMsg && draft) detMsg.value = draft;
5259
+ } else if (activeType === 'managed' && active) {
5260
+ const meta = sessions.find((s) => s.id === active);
5261
+ if (meta) {
5262
+ document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5263
+ document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5264
+ }
5265
+ }
5266
+ if (lastStateRoot) refreshCurrentState().catch(console.error);
5267
+ syncToggleIcons();
5268
+ }
3781
5269
 
3782
5270
  function requestQuery(extra = {}) {
3783
5271
  const params = new URLSearchParams();
@@ -3862,6 +5350,50 @@ function webIndexHtml() {
3862
5350
  return sessionKindFilter === 'all' || kind === sessionKindFilter;
3863
5351
  }
3864
5352
 
5353
+ function syncStartModeOptions() {
5354
+ const kind = document.getElementById('kind').value;
5355
+ const modeSelect = document.getElementById('startMode');
5356
+ const current = modeSelect.value;
5357
+ const modes = kind === 'claude'
5358
+ ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
5359
+ : kind === 'codex'
5360
+ ? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
5361
+ : [['new', 'new']];
5362
+ modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
5363
+ modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
5364
+ const isResume = modeSelect.value === 'resume';
5365
+ document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
5366
+ if (isResume) loadResumable();
5367
+ else document.querySelector('[data-resume-custom]').style.display = 'none';
5368
+ }
5369
+
5370
+ // Fetch provider sessions hcc knows about and fill the resume dropdown.
5371
+ async function loadResumable() {
5372
+ try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
5373
+ catch { resumableCache = []; }
5374
+ populateResumeSelect();
5375
+ }
5376
+ function populateResumeSelect() {
5377
+ const kind = document.getElementById('kind').value;
5378
+ const sel = document.getElementById('resumeSelect');
5379
+ const prev = sel.value;
5380
+ const items = resumableCache.filter((r) => r.provider === kind);
5381
+ const opts = items.map((r) => {
5382
+ const resume = r.resume || r.session_id || r.session_name || '';
5383
+ const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
5384
+ const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
5385
+ return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
5386
+ });
5387
+ opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
5388
+ sel.innerHTML = opts.join('');
5389
+ if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
5390
+ toggleResumeCustom();
5391
+ }
5392
+ function toggleResumeCustom() {
5393
+ const sel = document.getElementById('resumeSelect');
5394
+ document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
5395
+ }
5396
+
3865
5397
  async function loadProjects() {
3866
5398
  const data = await api('/api/projects');
3867
5399
  projects = data.projects || [];
@@ -3878,8 +5410,8 @@ function webIndexHtml() {
3878
5410
  activeType = 'managed';
3879
5411
  if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
3880
5412
  term.reset();
3881
- document.getElementById('activeTitle').textContent = 'No session selected';
3882
- document.getElementById('activeMeta').textContent = 'Start or select a session from the left panel';
5413
+ document.getElementById('activeTitle').textContent = tr('noSessionSelected');
5414
+ document.getElementById('activeMeta').textContent = tr('startOrSelect');
3883
5415
  await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
3884
5416
  const first = sessions.find(s => s.status === 'running');
3885
5417
  if (first) connectManaged(first.id);
@@ -3900,7 +5432,30 @@ function webIndexHtml() {
3900
5432
 
3901
5433
  function fmtAge(age) {
3902
5434
  const n = Number(age);
3903
- return Number.isFinite(n) ? Math.max(0, Math.round(n)) + 's' : '?';
5435
+ if (!Number.isFinite(n)) return '?';
5436
+ return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
5437
+ }
5438
+
5439
+ function sessionPeerId(session) {
5440
+ return session?.peer_id || session?.id || '';
5441
+ }
5442
+
5443
+ function sessionRuntimeNote(session) {
5444
+ const peerId = sessionPeerId(session);
5445
+ return session?.id && peerId && session.id !== peerId ? tr('runtime') + '=' + session.id + ' · ' : '';
5446
+ }
5447
+
5448
+ function sessionMetaText(session) {
5449
+ return sessionRuntimeNote(session) + (session.command || '') + (session.pane ? ' · ' + session.pane : '') + ' · ' + (session.cwd || '');
5450
+ }
5451
+
5452
+ function statusText(status) {
5453
+ const value = String(status || 'unknown');
5454
+ return tr('status.' + value.toLowerCase(), value);
5455
+ }
5456
+
5457
+ function managedPeerId(id) {
5458
+ return sessionPeerId(sessions.find((s) => s.id === id)) || id;
3904
5459
  }
3905
5460
 
3906
5461
  function peerIsActive(peer, basisNow = lastStateNow) {
@@ -3911,32 +5466,57 @@ function webIndexHtml() {
3911
5466
  return seen > 0 && (t - seen) <= activePeerTtl;
3912
5467
  }
3913
5468
 
3914
- function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
5469
+ function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
3915
5470
  const activity = peer?.status || 'unknown';
3916
5471
  const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
3917
5472
  const age = fmtAge(peer?.age_sec);
3918
- const branch = peer?.branch ? ' branch=' + peer.branch : '';
5473
+ const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
3919
5474
  if (runtime) {
3920
5475
  return {
3921
5476
  label: runtime.status || 'running',
3922
- detail: 'peer=' + activity + ' ' + liveness + ' age=' + age + branch
5477
+ detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
3923
5478
  };
3924
5479
  }
3925
5480
  if (liveness === 'stale') {
3926
5481
  return {
3927
5482
  label: 'stale',
3928
- detail: 'last=' + activity + ' age=' + age + branch
5483
+ detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
3929
5484
  };
3930
5485
  }
3931
- return {
3932
- label: activity,
3933
- detail: liveness + ' age=' + age + branch
3934
- };
3935
- }
5486
+ return {
5487
+ label: activity,
5488
+ detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5489
+ };
5490
+ }
5491
+
5492
+ function bodyPinned(el) {
5493
+ if (!el) return false;
5494
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
5495
+ }
5496
+
5497
+ function renderTimelineItem(item) {
5498
+ const meta = [
5499
+ item.source + ':' + item.source_id,
5500
+ item.task_id ? tr('task') + ' #' + item.task_id : '',
5501
+ item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
5502
+ item.direction || '',
5503
+ fmtTime(item.ts)
5504
+ ].filter(Boolean).join(' · ');
5505
+ return \`
5506
+ <div class="item timeline-item">
5507
+ <strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
5508
+ <span>\${esc(meta)}</span>
5509
+ \${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
5510
+ </div>\`;
5511
+ }
3936
5512
 
3937
5513
  document.getElementById('projectSelect').addEventListener('change', (event) => {
3938
5514
  switchProject(event.target.value).catch(console.error);
3939
5515
  });
5516
+ document.getElementById('langSelect').addEventListener('change', (event) => {
5517
+ lang = event.target.value === 'zh' ? 'zh' : 'en';
5518
+ applyLanguage();
5519
+ });
3940
5520
  document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
3941
5521
  sessionKindFilter = event.target.value || 'all';
3942
5522
  updateLocationProject();
@@ -3957,33 +5537,36 @@ function webIndexHtml() {
3957
5537
  const box = document.getElementById('sessions');
3958
5538
  const visibleSessions = sessions.filter(kindMatches);
3959
5539
  const visibleDetected = detected.filter(kindMatches);
3960
- const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>View filter: ' + esc(sessionKindFilter);
5540
+ const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
3961
5541
  const manHtml = visibleSessions.length
3962
- ? visibleSessions.map((s) => \`
5542
+ ? visibleSessions.map((s) => {
5543
+ const peerId = sessionPeerId(s);
5544
+ return \`
3963
5545
  <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
3964
- <div class="row"><strong>\${esc(s.id)}</strong><span class="badge \${esc(s.status)}">\${esc(s.status)}</span></div>
5546
+ <div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
3965
5547
  <div class="row"><span class="badge">\${esc(s.kind)}</span><span class="badge \${s.type === 'external' || s.type === 'tmux' ? 'warn' : ''}">\${s.type === 'external' ? 'external' : s.type === 'tmux' ? 'tmux' : 'pty'}</span></div>
3966
- <div class="path">\${esc(s.command)}</div>
3967
- </div>\`).join('')
3968
- : '<div class="empty">No active sessions.' + filterNote + '<br><br>Start one above<br>or run in any terminal:<br><code>hcc peer start X -- claude</code></div>';
5548
+ <div class="path">\${esc(sessionRuntimeNote(s) + (s.command || ''))}</div>
5549
+ </div>\`;
5550
+ }).join('')
5551
+ : '<div class="empty">' + esc(tr('noActiveSessions')) + filterNote + '<br><br>' + esc(tr('startOneAbove')) + '<br>' + esc(tr('orRunTerminal')) + '<br><code>hcc peer start X -- claude</code></div>';
3969
5552
 
3970
5553
  const detHtml = visibleDetected.length
3971
5554
  ? visibleDetected.map((p) => {
3972
5555
  const state = peerStateView(p);
3973
5556
  return \`
3974
5557
  <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
3975
- <div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">detected</span></div>
3976
- <div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(state.label)}</span></div>
5558
+ <div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span></div>
5559
+ <div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span></div>
3977
5560
  <div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
3978
5561
  </div>\`;
3979
5562
  }).join('')
3980
- : '<div class="empty">No detected peers.' + filterNote + '</div>';
5563
+ : '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
3981
5564
 
3982
5565
  const savedScroll = box.scrollTop;
3983
5566
  box.innerHTML = \`
3984
- <div class="sec-label">Managed <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} running</span></div>
5567
+ <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
3985
5568
  \${manHtml}
3986
- <div class="sec-label" style="margin-top:10px">Detected <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
5569
+ <div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
3987
5570
  \${detHtml}
3988
5571
  \`;
3989
5572
  box.scrollTop = savedScroll;
@@ -3999,7 +5582,14 @@ function webIndexHtml() {
3999
5582
  const data = await api('/api/sessions');
4000
5583
  sessions = data.sessions || [];
4001
5584
  renderSections();
4002
- document.getElementById('connState').textContent = 'online';
5585
+ if (active && activeType === 'managed') {
5586
+ const meta = sessions.find((s) => s.id === active);
5587
+ if (meta) {
5588
+ document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5589
+ document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5590
+ }
5591
+ }
5592
+ connText('online');
4003
5593
  }
4004
5594
 
4005
5595
  async function refreshDetected() {
@@ -4020,76 +5610,100 @@ function webIndexHtml() {
4020
5610
  lastStateNow = Number(data.now || lastStateNow);
4021
5611
  const state = document.getElementById('state');
4022
5612
  const preserveScroll = lastStateRoot === stateRoot;
4023
- const savedStateScroll = preserveScroll ? state.scrollTop : 0;
4024
- const savedCardScroll = preserveScroll
4025
- ? new Map(
4026
- [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
4027
- card.dataset.section,
4028
- card.querySelector('.body')?.scrollTop || 0
4029
- ])
4030
- )
4031
- : new Map();
4032
- const runtimeById = new Map((sessions || []).map((s) => [s.id, s]));
4033
- const tasksData = data.tasks || [];
4034
- const peersData = data.peers || [];
4035
- const locksData = data.locks || [];
4036
- const messagesData = data.messages || [];
4037
- const eventsData = data.events || [];
5613
+ const savedStateScroll = preserveScroll ? state.scrollTop : 0;
5614
+ const savedCardScroll = preserveScroll
5615
+ ? new Map(
5616
+ [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
5617
+ card.dataset.section,
5618
+ {
5619
+ top: card.querySelector('.body')?.scrollTop || 0,
5620
+ pinned: bodyPinned(card.querySelector('.body'))
5621
+ }
5622
+ ])
5623
+ )
5624
+ : new Map();
5625
+ const runtimeById = new Map();
5626
+ for (const session of sessions || []) {
5627
+ runtimeById.set(session.id, session);
5628
+ const peerId = sessionPeerId(session);
5629
+ if (peerId) runtimeById.set(peerId, session);
5630
+ }
5631
+ const tasksData = data.tasks || [];
5632
+ const peersData = data.peers || [];
5633
+ const locksData = data.locks || [];
5634
+ const messagesData = data.messages || [];
5635
+ const timelineData = data.timeline || [];
5636
+ const automation = data.automation || {};
5637
+ const nextAction = automation.next_action || {};
4038
5638
  const tasks = tasksData.map((t) => \`
4039
- <div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(t.status)} owner=\${esc(t.owner || '')} assignee=\${esc(t.assignee || '')}</span></div>
4040
- \`).join('') || '<div class="empty">No tasks.</div>';
4041
- const peers = peersData.map((a) => {
5639
+ <div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}</span></div>
5640
+ \`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
5641
+ const peers = peersData.map((a) => {
4042
5642
  const peerRuntime = runtimeById.get(a.id);
4043
5643
  const peerState = peerStateView(a, peerRuntime, data.now);
4044
5644
  return \`
4045
- <div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(peerState.label)}</span></strong><span>\${esc(peerState.detail)}</span></div>
5645
+ <div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
4046
5646
  \`;
4047
- }).join('') || '<div class="empty">No peers.</div>';
4048
- const locks = locksData.map((l) => \`
4049
- <div class="item"><strong>\${esc(l.resource)}</strong><span>owner=\${esc(l.owner)} task=\${l.task_id ? '#' + l.task_id : ''}</span></div>
4050
- \`).join('') || '<div class="empty">No active locks.</div>';
4051
- const messages = messagesData.map((m) => \`
4052
- <div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || 'all')}</strong><span>\${esc(m.body)}</span></div>
4053
- \`).join('') || '<div class="empty">No messages.</div>';
4054
- const events = eventsData.map((e) => \`
4055
- <div class="item"><strong>#\${e.id} \${esc(e.type)}</strong><span>\${esc(e.actor || '')} \${e.task_id ? '#' + e.task_id : ''} \${fmtTime(e.created_at)}</span></div>
4056
- \`).join('') || '<div class="empty">No events.</div>';
4057
- state.innerHTML = \`
4058
- <div class="card state-card" data-section="peers"><h2>Peers <span class="badge">\${peersData.length}</span></h2><div class="body">\${peers}</div></div>
4059
- <div class="card state-card" data-section="tasks"><h2>Tasks <span class="badge">\${tasksData.length}</span></h2><div class="body">\${tasks}</div></div>
4060
- <div class="card state-card" data-section="locks"><h2>Locks <span class="badge">\${locksData.length}</span></h2><div class="body">\${locks}</div></div>
4061
- <div class="card state-card" data-section="messages"><h2>Messages <span class="badge">\${messagesData.length}</span></h2><div class="body">\${messages}</div></div>
4062
- <div class="card state-card" data-section="events"><h2>Events <span class="badge">\${eventsData.length}</span></h2><div class="body">\${events}</div></div>
5647
+ }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
5648
+ const locks = locksData.map((l) => \`
5649
+ <div class="item"><strong>\${esc(l.resource)}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
5650
+ \`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
5651
+ const messages = messagesData.map((m) => \`
5652
+ <div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
5653
+ \`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
5654
+ const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
5655
+ const actionLines = [
5656
+ '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
5657
+ nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
5658
+ (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
5659
+ (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
5660
+ ].filter(Boolean).join('');
5661
+ state.innerHTML = \`
5662
+ <div class="card state-card" data-section="automation"><h2>\${esc(tr('nextAction'))} <span class="badge">\${esc(statusText(automation.phase || 'idle'))}</span></h2><div class="body">\${actionLines}</div></div>
5663
+ <div class="card state-card" data-section="timeline"><h2>\${esc(tr('timeline'))} <span class="badge">\${timelineData.length}</span></h2><div class="body">\${timeline}</div></div>
5664
+ <div class="card state-card" data-section="messages"><h2>\${esc(tr('messages'))} <span class="badge">\${messagesData.length}</span></h2><div class="body">\${messages}</div></div>
5665
+ <div class="card state-card" data-section="peers"><h2>\${esc(tr('peers'))} <span class="badge">\${peersData.length}</span></h2><div class="body">\${peers}</div></div>
5666
+ <div class="card state-card" data-section="tasks"><h2>\${esc(tr('tasks'))} <span class="badge">\${tasksData.length}</span></h2><div class="body">\${tasks}</div></div>
5667
+ <div class="card state-card" data-section="locks"><h2>\${esc(tr('locks'))} <span class="badge">\${locksData.length}</span></h2><div class="body">\${locks}</div></div>
4063
5668
  \`;
4064
- state.scrollTop = savedStateScroll;
4065
- for (const [section, top] of savedCardScroll) {
4066
- const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
4067
- if (body) body.scrollTop = top;
5669
+ state.scrollTop = savedStateScroll;
5670
+ for (const [section, saved] of savedCardScroll) {
5671
+ const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
5672
+ if (!body) continue;
5673
+ if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
5674
+ else body.scrollTop = saved.top;
5675
+ }
5676
+ lastStateRoot = stateRoot;
4068
5677
  }
4069
- lastStateRoot = stateRoot;
4070
- }
4071
5678
 
4072
5679
  async function refreshState() {
4073
- const p = active ? '/api/state?peer=' + encodeURIComponent(active) : '/api/state';
5680
+ const peer = active ? managedPeerId(active) : null;
5681
+ const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
4074
5682
  const data = await api(p);
4075
5683
  renderState(data);
4076
5684
  }
4077
5685
 
4078
- async function refreshDetectedState() {
4079
- if (!activeDetected) return;
4080
- const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
4081
- renderState(data);
4082
- }
5686
+ async function refreshDetectedState() {
5687
+ if (!activeDetected) return;
5688
+ const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
5689
+ renderState(data);
5690
+ }
5691
+
5692
+ async function refreshCurrentState() {
5693
+ if (activeType === 'detected') return refreshDetectedState();
5694
+ return refreshState();
5695
+ }
4083
5696
 
4084
5697
  // ── Connect to managed (PTY) session ─────────────────────────────────
4085
5698
  function connectManaged(id) {
4086
5699
  const meta = sessions.find((s) => s.id === id);
5700
+ const peerId = sessionPeerId(meta) || id;
4087
5701
  active = id;
4088
5702
  activeDetected = null;
4089
5703
  activeType = 'managed';
4090
5704
  renderSections();
4091
- document.getElementById('activeTitle').textContent = id;
4092
- document.getElementById('activeMeta').textContent = meta ? meta.command + (meta.pane ? ' · ' + meta.pane : '') + ' · ' + (meta.cwd || '') : '';
5705
+ document.getElementById('activeTitle').textContent = peerId;
5706
+ document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
4093
5707
  document.getElementById('terminal').style.display = '';
4094
5708
  document.getElementById('detectedPanel').style.display = 'none';
4095
5709
  document.getElementById('quickBar').style.display = '';
@@ -4105,7 +5719,7 @@ function webIndexHtml() {
4105
5719
  ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
4106
5720
  ws.onopen = () => {
4107
5721
  resizeTerm();
4108
- document.getElementById('connState').textContent = 'attached';
5722
+ connText('attached');
4109
5723
  };
4110
5724
  ws.onmessage = (event) => {
4111
5725
  const msg = JSON.parse(event.data);
@@ -4122,12 +5736,12 @@ function webIndexHtml() {
4122
5736
  if (msg.type === 'exit') { refreshSessions().catch(console.error); }
4123
5737
  };
4124
5738
  ws.onclose = () => {
4125
- document.getElementById('connState').textContent = 'reconnecting...';
5739
+ connText('reconnecting');
4126
5740
  // Auto-reconnect if session is still in the list and running
4127
5741
  wsReconnectTimer = setTimeout(() => {
4128
5742
  const s = sessions.find((s) => s.id === id);
4129
5743
  if (s && s.status === 'running' && active === id) openWs(id);
4130
- else document.getElementById('connState').textContent = 'online';
5744
+ else connText('online');
4131
5745
  }, 2000);
4132
5746
  };
4133
5747
  }
@@ -4140,12 +5754,12 @@ function webIndexHtml() {
4140
5754
  activeType = 'detected';
4141
5755
  if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
4142
5756
  renderSections();
4143
- document.getElementById('activeTitle').textContent = id + ' (detected)';
5757
+ document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
4144
5758
  document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
4145
5759
  document.getElementById('terminal').style.display = 'none';
4146
5760
  document.getElementById('detectedPanel').style.display = '';
4147
5761
  document.getElementById('quickBar').style.display = 'none';
4148
- document.getElementById('connState').textContent = 'coordination only';
5762
+ connText('coordinationOnly');
4149
5763
  renderDetectedPanel(peer || { id });
4150
5764
  refreshDetectedState().catch(console.error);
4151
5765
  }
@@ -4155,22 +5769,22 @@ function webIndexHtml() {
4155
5769
  dp.innerHTML = \`
4156
5770
  <div style="padding:16px;display:grid;gap:12px">
4157
5771
  <div class="card">
4158
- <h2>Detected Session</h2>
5772
+ <h2>\${esc(tr('detectedSession'))}</h2>
4159
5773
  <div class="body">
4160
- <div class="item"><strong>peer</strong><span class="mono">\${esc(peer.id)}</span></div>
4161
- <div class="item"><strong>kind</strong><span>\${esc(peer.kind || '')}</span></div>
4162
- <div class="item"><strong>status</strong><span>\${esc(peer.status || '')}</span></div>
4163
- <div class="item"><strong>cwd</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
4164
- <div class="item"><strong>pid</strong><span>\${esc(peer.pid || 'unknown')}</span></div>
4165
- <div class="item"><strong>last seen</strong><span>\${peer.age_sec != null ? peer.age_sec + 's ago' : ''}</span></div>
5774
+ <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
5775
+ <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
5776
+ <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
5777
+ <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
5778
+ <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
5779
+ <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
4166
5780
  </div>
4167
5781
  </div>
4168
5782
  <div class="card">
4169
- <h2>Send Message</h2>
5783
+ <h2>\${esc(tr('sendMessage'))}</h2>
4170
5784
  <div class="body" style="gap:8px">
4171
- <div style="font-size:12px;color:var(--muted)">Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.</div>
4172
- <textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="Message body..."></textarea>
4173
- <button class="primary" id="sendDetMsg">Send</button>
5785
+ <div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
5786
+ <textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="\${esc(tr('messageBodyPlaceholder'))}"></textarea>
5787
+ <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
4174
5788
  </div>
4175
5789
  </div>
4176
5790
  </div>
@@ -4181,10 +5795,11 @@ function webIndexHtml() {
4181
5795
  await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
4182
5796
  method: 'POST',
4183
5797
  body: JSON.stringify({ body, from: 'web' })
5798
+ });
5799
+ document.getElementById('detMsg').value = '';
5800
+ await refreshDetectedState();
4184
5801
  });
4185
- document.getElementById('detMsg').value = '';
4186
- });
4187
- }
5802
+ }
4188
5803
 
4189
5804
  // ── Helpers ───────────────────────────────────────────────────────────
4190
5805
  function sendLine(text) {
@@ -4193,11 +5808,22 @@ function webIndexHtml() {
4193
5808
  }
4194
5809
 
4195
5810
  // ── Start session form ────────────────────────────────────────────────
5811
+ document.getElementById('kind').addEventListener('change', syncStartModeOptions);
5812
+ document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
5813
+ document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
5814
+ syncStartModeOptions();
5815
+
4196
5816
  document.getElementById('startForm').addEventListener('submit', async (event) => {
4197
5817
  event.preventDefault();
4198
- const payload = {
4199
- kind: document.getElementById('kind').value
4200
- };
5818
+ const kind = document.getElementById('kind').value;
5819
+ const mode = document.getElementById('startMode').value;
5820
+ const sel = document.getElementById('resumeSelect');
5821
+ const resume = (sel.value && sel.value !== '__custom__')
5822
+ ? sel.value
5823
+ : document.getElementById('resumeArg').value.trim();
5824
+ if (mode === 'resume' && !resume) return;
5825
+ const payload = { kind, mode };
5826
+ if (mode === 'resume') payload.resume = resume;
4201
5827
  const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
4202
5828
  await refreshSessions();
4203
5829
  connectManaged(data.session.id);
@@ -4207,26 +5833,180 @@ function webIndexHtml() {
4207
5833
  button.addEventListener('click', () => {
4208
5834
  if (!active) return;
4209
5835
  const session = sessions.find((s) => s.id === active) || { kind: 'other', role: 'peer' };
5836
+ const peerId = sessionPeerId(session) || active;
4210
5837
  const lines = {
4211
- register: \`hcc register --peer \${active} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
4212
- inbox: \`hcc msg inbox --peer \${active}\`,
4213
- next: \`hcc task next --peer \${active}\`,
4214
- status: \`hcc status --peer \${active}\`,
4215
- heartbeat:\`hcc heartbeat --peer \${active} --renew-locks\`
5838
+ register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
5839
+ inbox: \`hcc msg inbox --peer \${peerId}\`,
5840
+ next: \`hcc task next --peer \${peerId}\`,
5841
+ status: \`hcc status --peer \${peerId}\`,
5842
+ heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
4216
5843
  };
4217
5844
  sendLine(lines[button.dataset.send]);
5845
+ closeActionsMenu();
5846
+ });
5847
+ });
5848
+
5849
+ // ── Actions dropdown (declutters the toolbar) ─────────────────────────
5850
+ const actionsBtn = document.getElementById('actionsBtn');
5851
+ const actionsMenu = document.getElementById('actionsMenu');
5852
+ function closeActionsMenu() {
5853
+ actionsMenu.hidden = true;
5854
+ actionsBtn.setAttribute('aria-expanded', 'false');
5855
+ }
5856
+ actionsBtn.addEventListener('click', (e) => {
5857
+ e.stopPropagation();
5858
+ const open = actionsMenu.hidden;
5859
+ actionsMenu.hidden = !open;
5860
+ actionsBtn.setAttribute('aria-expanded', String(open));
5861
+ if (open) {
5862
+ // Position as a fixed layer so it escapes the toolbar/main overflow clip.
5863
+ const r = actionsBtn.getBoundingClientRect();
5864
+ actionsMenu.style.top = (r.bottom + 6) + 'px';
5865
+ actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
5866
+ }
5867
+ });
5868
+ document.addEventListener('click', (e) => {
5869
+ if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
5870
+ });
5871
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
5872
+
5873
+ // ── Collapsible side panels ───────────────────────────────────────────
5874
+ const appEl = document.querySelector('.app');
5875
+ const toggleLeftBtn = document.getElementById('toggleLeft');
5876
+ const toggleRightBtn = document.getElementById('toggleRight');
5877
+ const resizeLeftHandle = document.getElementById('resizeLeft');
5878
+ const resizeRightHandle = document.getElementById('resizeRight');
5879
+ const sideDefaults = { left: 320, right: 360 };
5880
+ const sideMin = { left: 220, right: 240 };
5881
+ const sideMax = { left: 560, right: 640 };
5882
+ const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
5883
+ const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
5884
+ function sideIsCollapsed(side) {
5885
+ return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
5886
+ }
5887
+ function storedSideWidth(side) {
5888
+ const raw = Number(localStorage.getItem(sideWidthKey[side]));
5889
+ return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
5890
+ }
5891
+ function effectiveOppositeWidth(side) {
5892
+ const opposite = side === 'left' ? 'right' : 'left';
5893
+ return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
5894
+ }
5895
+ function clampSideWidth(side, width) {
5896
+ const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
5897
+ const opposite = effectiveOppositeWidth(side);
5898
+ const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
5899
+ return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
5900
+ }
5901
+ function readSideWidth(side) {
5902
+ return clampSideWidth(side, storedSideWidth(side));
5903
+ }
5904
+ function setSideWidth(side, width, persist = true) {
5905
+ const value = clampSideWidth(side, width);
5906
+ appEl.style.setProperty('--' + side + '-width', value + 'px');
5907
+ if (persist) localStorage.setItem(sideWidthKey[side], String(value));
5908
+ return value;
5909
+ }
5910
+ function applySideWidths() {
5911
+ setSideWidth('left', readSideWidth('left'), false);
5912
+ setSideWidth('right', readSideWidth('right'), false);
5913
+ }
5914
+ function syncToggleIcons() {
5915
+ const l = appEl.classList.contains('left-collapsed');
5916
+ const r = appEl.classList.contains('right-collapsed');
5917
+ toggleLeftBtn.textContent = l ? '⟩' : '⟨';
5918
+ toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
5919
+ toggleRightBtn.textContent = r ? '⟨' : '⟩';
5920
+ toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
5921
+ }
5922
+ function applyCollapseState() {
5923
+ appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
5924
+ appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
5925
+ applySideWidths();
5926
+ syncToggleIcons();
5927
+ }
5928
+ function toggleSide(side) {
5929
+ const cls = side + '-collapsed';
5930
+ const on = appEl.classList.toggle(cls);
5931
+ localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
5932
+ applySideWidths();
5933
+ syncToggleIcons();
5934
+ // Refit the terminal after the grid transition so xterm uses the new width.
5935
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
5936
+ }
5937
+ function beginSideDrag(side, event) {
5938
+ if (event.button !== undefined && event.button !== 0) return;
5939
+ dragState.side = side;
5940
+ dragState.startX = event.clientX;
5941
+ dragState.startWidth = readSideWidth(side);
5942
+ dragState.moved = false;
5943
+ dragState.suppressClick = false;
5944
+ appEl.classList.add('resizing');
5945
+ event.currentTarget.setPointerCapture?.(event.pointerId);
5946
+ event.preventDefault();
5947
+ }
5948
+ function moveSideDrag(event) {
5949
+ if (!dragState.side) return;
5950
+ const delta = event.clientX - dragState.startX;
5951
+ if (!dragState.moved && Math.abs(delta) <= 3) return;
5952
+ dragState.moved = true;
5953
+ const width = dragState.side === 'left'
5954
+ ? dragState.startWidth + delta
5955
+ : dragState.startWidth - delta;
5956
+ setSideWidth(dragState.side, width);
5957
+ if (dragState.side === 'left') {
5958
+ appEl.classList.remove('left-collapsed');
5959
+ localStorage.setItem('hcc.collapse.left', '0');
5960
+ } else {
5961
+ appEl.classList.remove('right-collapsed');
5962
+ localStorage.setItem('hcc.collapse.right', '0');
5963
+ }
5964
+ syncToggleIcons();
5965
+ try { resizeTerm(); } catch {}
5966
+ event.preventDefault();
5967
+ }
5968
+ function endSideDrag(event) {
5969
+ if (!dragState.side) return;
5970
+ dragState.suppressClick = dragState.moved;
5971
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
5972
+ dragState.side = null;
5973
+ appEl.classList.remove('resizing');
5974
+ setTimeout(() => { dragState.suppressClick = false; }, 0);
5975
+ try { resizeTerm(); } catch {}
5976
+ }
5977
+ function bindSideHandle(handle, side, clickAction = null) {
5978
+ handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
5979
+ handle.addEventListener('pointermove', moveSideDrag);
5980
+ handle.addEventListener('pointerup', endSideDrag);
5981
+ handle.addEventListener('pointercancel', endSideDrag);
5982
+ handle.addEventListener('click', (event) => {
5983
+ if (dragState.suppressClick) {
5984
+ event.preventDefault();
5985
+ return;
5986
+ }
5987
+ if (clickAction) clickAction(side);
4218
5988
  });
5989
+ }
5990
+ bindSideHandle(resizeLeftHandle, 'left');
5991
+ bindSideHandle(resizeRightHandle, 'right');
5992
+ bindSideHandle(toggleLeftBtn, 'left', toggleSide);
5993
+ bindSideHandle(toggleRightBtn, 'right', toggleSide);
5994
+ window.addEventListener('resize', () => {
5995
+ applySideWidths();
5996
+ setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
4219
5997
  });
5998
+ applyCollapseState();
4220
5999
 
4221
6000
  document.getElementById('stopBtn').addEventListener('click', async () => {
4222
6001
  if (!active) return;
4223
6002
  await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: '{}' });
4224
6003
  await refreshSessions();
4225
6004
  });
4226
- document.getElementById('refreshBtn').addEventListener('click', () => {
4227
- Promise.all([refreshSessions(), refreshDetected(), refreshState()]).catch(console.error);
4228
- });
6005
+ document.getElementById('refreshBtn').addEventListener('click', () => {
6006
+ Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
6007
+ });
4229
6008
 
6009
+ applyLanguage();
4230
6010
  loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
4231
6011
  // Auto-connect to first running managed session on load
4232
6012
  if (!active && !activeDetected) {
@@ -4234,7 +6014,7 @@ function webIndexHtml() {
4234
6014
  if (first) connectManaged(first.id);
4235
6015
  }
4236
6016
  }).catch((err) => {
4237
- document.getElementById('connState').textContent = 'error';
6017
+ connText('error');
4238
6018
  console.error(err);
4239
6019
  });
4240
6020
  // ── Auto-poll state ──────────────────────────────────────────────────
@@ -4255,12 +6035,12 @@ function webIndexHtml() {
4255
6035
  async function cmdWeb(ctx, args, startMeta = {}) {
4256
6036
  if (args[0] === '--help' || args[0] === '-h') return helpWeb();
4257
6037
  if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
4258
- const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
4259
- validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
4260
- const host = opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
6038
+ const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
6039
+ validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
6040
+ validateWebTokenOpts(opts);
6041
+ const host = expectedWebHost(opts);
4261
6042
  const port = intOpt(opts, 'port', 8787);
4262
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
4263
- const token = explicitToken;
6043
+ const token = makeWebToken(opts);
4264
6044
  ensureTmuxAvailable({ autoInstall: false });
4265
6045
  const ptyModule = await import('node-pty');
4266
6046
  const { WebSocketServer } = await import('ws');
@@ -4306,8 +6086,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4306
6086
  return [...sessions.values()].filter((session) => session.root === projectCtx.root);
4307
6087
  }
4308
6088
 
4309
- function getSession(projectCtx, id) {
4310
- return sessions.get(sessionKey(projectCtx, id));
6089
+ function getSession(projectCtx, id, db = null) {
6090
+ const direct = sessions.get(sessionKey(projectCtx, id));
6091
+ if (direct) return direct;
6092
+ for (const session of sessionsForProject(projectCtx)) {
6093
+ if (session.peerId === id) return session;
6094
+ }
6095
+ if (db) {
6096
+ for (const session of sessionsForProject(projectCtx)) {
6097
+ if (resolveSessionPeerId(db, session) === id) return session;
6098
+ }
6099
+ }
6100
+ return null;
4311
6101
  }
4312
6102
 
4313
6103
  function knownPeerIds(projectCtx) {
@@ -4350,6 +6140,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4350
6140
 
4351
6141
  const session = {
4352
6142
  id,
6143
+ peerId: id,
4353
6144
  root: pctx.root,
4354
6145
  ctx: pctx,
4355
6146
  kind: meta.kind || 'external',
@@ -4478,9 +6269,58 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4478
6269
  const autoAttachPoller = setInterval(scanAndAttachDetectedPeers, 5000);
4479
6270
 
4480
6271
  // ── Serialize + broadcast helpers ─────────────────────────────────────────
4481
- function serializeSession(session) {
6272
+ function resolveSessionPeerId(db, session) {
6273
+ if (!session) return null;
6274
+ if (!db) return session.peerId || session.id || null;
6275
+
6276
+ if (session.type === 'tmux' && session.pane) {
6277
+ const byTarget = db.prepare(`
6278
+ SELECT peer
6279
+ FROM peer_bindings
6280
+ WHERE runtime_target = ?
6281
+ ORDER BY updated_at DESC, created_at DESC
6282
+ LIMIT 1
6283
+ `).get(session.pane);
6284
+ if (byTarget?.peer) {
6285
+ session.peerId = byTarget.peer;
6286
+ return byTarget.peer;
6287
+ }
6288
+ }
6289
+
6290
+ if (session.id) {
6291
+ const byRuntime = db.prepare(`
6292
+ SELECT peer
6293
+ FROM peer_bindings
6294
+ WHERE runtime_session_id = ?
6295
+ ORDER BY updated_at DESC, created_at DESC
6296
+ LIMIT 1
6297
+ `).get(session.id);
6298
+ if (byRuntime?.peer) {
6299
+ session.peerId = byRuntime.peer;
6300
+ return byRuntime.peer;
6301
+ }
6302
+
6303
+ const byPeer = db.prepare(`
6304
+ SELECT peer
6305
+ FROM peer_bindings
6306
+ WHERE peer = ?
6307
+ LIMIT 1
6308
+ `).get(session.id);
6309
+ if (byPeer?.peer) {
6310
+ session.peerId = byPeer.peer;
6311
+ return byPeer.peer;
6312
+ }
6313
+ }
6314
+
6315
+ session.peerId = session.peerId || session.id || null;
6316
+ return session.peerId;
6317
+ }
6318
+
6319
+ function serializeSession(session, db = null) {
6320
+ const peerId = resolveSessionPeerId(db, session);
4482
6321
  return {
4483
6322
  id: session.id,
6323
+ peer_id: peerId,
4484
6324
  kind: session.kind,
4485
6325
  role: session.role,
4486
6326
  command: session.command,
@@ -4526,9 +6366,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4526
6366
  sessions.delete(sessionKey(pctx, session.id));
4527
6367
  const db = connect(pctx);
4528
6368
  try {
4529
- db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run(status, now(), session.id);
4530
- db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now(), session.id);
4531
- addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', session.id, null, {
6369
+ const t = now();
6370
+ const peerId = resolveSessionPeerId(db, session) || session.id;
6371
+ db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id IN (?, ?)').run(status, t, session.id, peerId);
6372
+ db.prepare(`
6373
+ UPDATE peer_bindings
6374
+ SET runtime_target = NULL, updated_at = ?
6375
+ WHERE peer IN (?, ?)
6376
+ OR runtime_session_id = ?
6377
+ OR (? IS NOT NULL AND runtime_target = ?)
6378
+ `).run(t, session.id, peerId, session.id, session.pane || null, session.pane || null);
6379
+ addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, {
6380
+ runtime_session_id: session.id,
4532
6381
  pane: session.pane
4533
6382
  });
4534
6383
  } finally {
@@ -4688,6 +6537,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4688
6537
  const captured = tmuxCapturePane(info.pane);
4689
6538
  const session = {
4690
6539
  id,
6540
+ peerId: id,
4691
6541
  root: pctx.root,
4692
6542
  ctx: pctx,
4693
6543
  kind,
@@ -4737,7 +6587,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4737
6587
  capabilities: 'tmux'
4738
6588
  });
4739
6589
  const binding = input.binding || {};
4740
- upsertCanonicalPeerBinding(db, {
6590
+ const canonical = upsertCanonicalPeerBinding(db, {
4741
6591
  peer: id,
4742
6592
  provider: binding.provider || kind,
4743
6593
  provider_session_id: binding.provider_session_id || null,
@@ -4749,6 +6599,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4749
6599
  runtime_session_id: id,
4750
6600
  runtime_target: info.pane
4751
6601
  }, Boolean(input.force));
6602
+ session.peerId = canonical.peer;
4752
6603
  addEvent(db, 'tmux.session.attached', id, null, { pane: info.pane, command, cwd, pid: info.pid });
4753
6604
  } finally {
4754
6605
  db.close();
@@ -4959,6 +6810,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4959
6810
  });
4960
6811
  const session = {
4961
6812
  id,
6813
+ peerId: id,
4962
6814
  root: pctx.root,
4963
6815
  ctx: pctx,
4964
6816
  kind,
@@ -4986,7 +6838,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4986
6838
  status: 'running',
4987
6839
  capabilities: 'web-pty'
4988
6840
  });
4989
- upsertCanonicalPeerBinding(db, {
6841
+ const canonical = upsertCanonicalPeerBinding(db, {
4990
6842
  peer: id,
4991
6843
  provider: input.binding?.provider || kind,
4992
6844
  provider_session_id: input.binding?.provider_session_id || null,
@@ -4997,6 +6849,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
4997
6849
  transport: 'web-pty',
4998
6850
  runtime_session_id: id
4999
6851
  }, Boolean(input.force));
6852
+ session.peerId = canonical.peer;
5000
6853
  addEvent(db, 'web.session.started', id, null, { command, cwd, pid: child.pid });
5001
6854
  } finally {
5002
6855
  db.close();
@@ -5021,9 +6874,60 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5021
6874
  return session;
5022
6875
  }
5023
6876
 
6877
+ function webSessionBuildOptions(input) {
6878
+ const mode = input.mode || 'new';
6879
+ if (mode === 'new') return {};
6880
+ if (mode === 'resume') {
6881
+ const resume = String(input.resume || '').trim();
6882
+ if (!resume) throw new CliError('BAD_REQUEST', 'resume session required');
6883
+ return { resume };
6884
+ }
6885
+ if (mode === 'last') return { last: true };
6886
+ if (mode === 'continue') return { continue: true };
6887
+ throw new CliError('BAD_REQUEST', `Unsupported session mode: ${mode}`);
6888
+ }
6889
+
6890
+ function webSessionPeerId(projectCtx, kind, opts, input) {
6891
+ if (input.id) return input.id;
6892
+ if (opts.resume) return providerSessionPeerId(kind, opts.resume);
6893
+ return nextProjectSessionId(projectCtx, kind);
6894
+ }
6895
+
6896
+ function normalizeWebSessionInput(input) {
6897
+ const pctx = input.projectCtx || ctx;
6898
+ const kind = input.kind || 'shell';
6899
+ if (!['claude', 'codex', 'shell'].includes(kind)) {
6900
+ throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
6901
+ }
6902
+ if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };
6903
+
6904
+ const opts = webSessionBuildOptions(input);
6905
+ if (kind === 'shell' && hasResumeOpts(opts)) {
6906
+ throw new CliError('BAD_REQUEST', 'Resume modes are only supported for codex and claude sessions');
6907
+ }
6908
+ if (kind !== 'claude' && opts.continue) {
6909
+ throw new CliError('BAD_REQUEST', 'continue is only supported for claude sessions');
6910
+ }
6911
+ if (kind !== 'codex' && opts.last) {
6912
+ throw new CliError('BAD_REQUEST', 'last is only supported for codex sessions');
6913
+ }
6914
+
6915
+ const id = webSessionPeerId(pctx, kind, opts, input);
6916
+ const built = buildPeerCommand(id, kind, opts, []);
6917
+ return {
6918
+ ...input,
6919
+ id,
6920
+ kind,
6921
+ command: built.command,
6922
+ binding: built.binding,
6923
+ projectCtx: pctx
6924
+ };
6925
+ }
6926
+
5024
6927
  function startSession(input) {
5025
- if (input.backend === 'pty') return startPtySession(input);
5026
- return startTmuxManagedSession(input);
6928
+ const normalized = normalizeWebSessionInput(input);
6929
+ if (normalized.backend === 'pty') return startPtySession(normalized);
6930
+ return startTmuxManagedSession(normalized);
5027
6931
  }
5028
6932
 
5029
6933
  for (const projectCtx of projectContexts.values()) {
@@ -5076,13 +6980,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5076
6980
  return;
5077
6981
  }
5078
6982
  if (req.method === 'GET' && url.pathname === '/api/state') {
5079
- sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer')));
6983
+ const resources = normalizeStateResources([
6984
+ ...url.searchParams.getAll('resource'),
6985
+ url.searchParams.get('resources') || ''
6986
+ ]);
6987
+ sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer'), {
6988
+ resources,
6989
+ intent: url.searchParams.get('intent') || null
6990
+ }));
5080
6991
  return;
5081
6992
  }
5082
6993
  // Detected sessions: peers registered via hooks/watcher but without PTY
5083
6994
  if (req.method === 'GET' && url.pathname === '/api/detected') {
5084
6995
  const db = connect(reqCtx);
5085
6996
  let detected = [];
6997
+ const managedIds = new Set();
5086
6998
  const t = now();
5087
6999
  try {
5088
7000
  detected = db.prepare(`
@@ -5092,11 +7004,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5092
7004
  ORDER BY last_seen_at DESC, id ASC
5093
7005
  LIMIT 100
5094
7006
  `).all(t);
7007
+ for (const session of sessionsForProject(reqCtx)) {
7008
+ managedIds.add(session.id);
7009
+ const peerId = resolveSessionPeerId(db, session);
7010
+ if (peerId) managedIds.add(peerId);
7011
+ }
5095
7012
  } finally {
5096
7013
  db.close();
5097
7014
  }
5098
7015
  // Exclude peers that are already in the managed sessions Map
5099
- const managedIds = new Set(sessionsForProject(reqCtx).map((s) => s.id));
5100
7016
  sendJson(res, 200, {
5101
7017
  now: t,
5102
7018
  active_peer_ttl: ACTIVE_PEER_TTL,
@@ -5104,6 +7020,44 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5104
7020
  });
5105
7021
  return;
5106
7022
  }
7023
+ if (req.method === 'GET' && url.pathname === '/api/resumable') {
7024
+ // Provider sessions hcc has seen (via hooks/detection) that carry a real
7025
+ // provider session id or resumable provider session name.
7026
+ const db = connect(reqCtx);
7027
+ let rows = [];
7028
+ try {
7029
+ rows = db.prepare(`
7030
+ SELECT b.provider, b.provider_session_id, b.provider_session_name, b.peer,
7031
+ p.last_seen_at
7032
+ FROM peer_bindings b
7033
+ LEFT JOIN peers p ON p.id = b.peer
7034
+ WHERE (b.provider_session_id IS NOT NULL AND b.provider_session_id != '')
7035
+ OR (b.provider_session_name IS NOT NULL AND b.provider_session_name != '')
7036
+ ORDER BY p.last_seen_at DESC, b.updated_at DESC
7037
+ `).all();
7038
+ } finally {
7039
+ db.close();
7040
+ }
7041
+ const seen = new Set();
7042
+ const resumable = [];
7043
+ for (const r of rows) {
7044
+ const resumeValue = r.provider_session_id || r.provider_session_name || '';
7045
+ if (!resumeValue) continue;
7046
+ const key = `${r.provider}:${resumeValue}`;
7047
+ if (seen.has(key)) continue;
7048
+ seen.add(key);
7049
+ resumable.push({
7050
+ provider: r.provider,
7051
+ session_id: r.provider_session_id,
7052
+ session_name: r.provider_session_name || null,
7053
+ resume: resumeValue,
7054
+ name: r.provider_session_name || null,
7055
+ peer: r.peer
7056
+ });
7057
+ }
7058
+ sendJson(res, 200, { resumable });
7059
+ return;
7060
+ }
5107
7061
  if (req.method === 'GET' && url.pathname === '/api/runtime') {
5108
7062
  sendJson(res, 200, {
5109
7063
  product: PRODUCT_NAME,
@@ -5122,7 +7076,14 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5122
7076
  return;
5123
7077
  }
5124
7078
  if (req.method === 'GET' && url.pathname === '/api/sessions') {
5125
- sendJson(res, 200, { sessions: sessionsForProject(reqCtx).map(serializeSession) });
7079
+ const db = connect(reqCtx);
7080
+ try {
7081
+ sendJson(res, 200, {
7082
+ sessions: sessionsForProject(reqCtx).map((session) => serializeSession(session, db))
7083
+ });
7084
+ } finally {
7085
+ db.close();
7086
+ }
5126
7087
  return;
5127
7088
  }
5128
7089
  if (req.method === 'POST' && url.pathname === '/api/sessions') {
@@ -5140,7 +7101,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5140
7101
  const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
5141
7102
  if (req.method === 'POST' && inputMatch) {
5142
7103
  const id = decodeURIComponent(inputMatch[1]);
5143
- const session = getSession(reqCtx, id);
7104
+ const lookupDb = connect(reqCtx);
7105
+ let session;
7106
+ try {
7107
+ session = getSession(reqCtx, id, lookupDb);
7108
+ } finally {
7109
+ lookupDb.close();
7110
+ }
5144
7111
  if (!session) {
5145
7112
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
5146
7113
  return;
@@ -5165,7 +7132,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5165
7132
  const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
5166
7133
  if (req.method === 'POST' && stopMatch) {
5167
7134
  const id = decodeURIComponent(stopMatch[1]);
5168
- const session = getSession(reqCtx, id);
7135
+ const lookupDb = connect(reqCtx);
7136
+ let session;
7137
+ try {
7138
+ session = getSession(reqCtx, id, lookupDb);
7139
+ } finally {
7140
+ lookupDb.close();
7141
+ }
5169
7142
  if (!session) {
5170
7143
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
5171
7144
  return;
@@ -5227,7 +7200,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
5227
7200
  }
5228
7201
  const reqCtx = projectFromRequest(req, url);
5229
7202
  const id = decodeURIComponent(match[1]);
5230
- const session = getSession(reqCtx, id);
7203
+ const lookupDb = connect(reqCtx);
7204
+ let session;
7205
+ try {
7206
+ session = getSession(reqCtx, id, lookupDb);
7207
+ } finally {
7208
+ lookupDb.close();
7209
+ }
5231
7210
  if (!session) {
5232
7211
  socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
5233
7212
  socket.destroy();
@@ -5557,6 +7536,7 @@ Commands:
5557
7536
  heartbeat [--peer ID] Mark the current peer alive, optionally renew locks
5558
7537
  peers List known peers
5559
7538
  status [--peer ID] Show project coordination state
7539
+ state [--peer ID] Show timeline and next coordination action
5560
7540
  scan [--register] Detect existing Claude/Codex sessions
5561
7541
  prompt --peer ID Print copy/paste session instructions
5562
7542
  run --peer ID -- COMMAND Register a peer and run a command in this terminal
@@ -5565,6 +7545,7 @@ Commands:
5565
7545
  ask PEER MESSAGE Send a direct work request to one peer
5566
7546
  broadcast MESSAGE Send a work request to all peers
5567
7547
  task <subcommand> Create, list, claim, update, finish tasks
7548
+ team <subcommand> Plan, start, and inspect explicit task teams
5568
7549
  msg <subcommand> Send, read, and ack messages
5569
7550
  lock <subcommand> Acquire, renew, release, and list advisory locks
5570
7551
  handoff <subcommand> Create and list handoffs
@@ -5579,7 +7560,7 @@ Environment:
5579
7560
  HCC_ROOT Override project root
5580
7561
  HCC_DB Override database path
5581
7562
  HCC_PEER Default peer id; inferred automatically when absent
5582
- HCC_WEB_TOKEN Optional web access token
7563
+ HCC_WEB_TOKEN Replace and save the stable web access token
5583
7564
  `);
5584
7565
  }
5585
7566
 
@@ -5588,21 +7569,60 @@ function helpTask() {
5588
7569
 
5589
7570
  Usage:
5590
7571
  ${CLI_NAME} task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
7572
+ ${CLI_NAME} task create --title TEXT --parent N [--team-role ROLE]
5591
7573
  ${CLI_NAME} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
5592
7574
  ${CLI_NAME} task claim [--peer ID] --id N
5593
- ${CLI_NAME} task next [--peer ID]
7575
+ ${CLI_NAME} task next [--peer ID] [--force]
5594
7576
  ${CLI_NAME} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
5595
7577
  ${CLI_NAME} task done [--peer ID] --id N --summary TEXT
5596
7578
 
5597
7579
  Default task list shows all project tasks that are not done or abandoned.
5598
7580
  --peer is an explicit filter; HCC_PEER does not hide other open tasks.
5599
7581
  Messages use per-peer unread ack state; tasks do not.
7582
+ task next returns your existing claimed/running/review/blocked task before
7583
+ claiming another pending task. Use --force only when intentionally taking
7584
+ another pending task.
5600
7585
 
5601
7586
  If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
5602
7587
  as a stable project-local peer.
5603
7588
  `);
5604
7589
  }
5605
7590
 
7591
+ function helpTeam() {
7592
+ console.log(`${CLI_NAME} team
7593
+
7594
+ Usage:
7595
+ ${CLI_NAME} team plan --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1]
7596
+ ${CLI_NAME} team start --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1] [--force]
7597
+ ${CLI_NAME} team status --task N
7598
+
7599
+ team plan is read-only. team start creates explicit child tasks under the parent
7600
+ task and optionally assigns them to workers. It does not silently spawn model
7601
+ processes or override the current-task rule.
7602
+ workers may be explicit peer ids or kind counts such as codex:2,claude:1.
7603
+ `);
7604
+ }
7605
+
7606
+ function helpState() {
7607
+ console.log(`${CLI_NAME} state
7608
+
7609
+ Usage:
7610
+ ${CLI_NAME} state [--peer ID] [--resource PATH] [--intent work|stop|finish]
7611
+
7612
+ Shows the current collaboration timeline plus a machine-readable coordination
7613
+ state machine. With --json, the response includes automation.next_action.argv so
7614
+ agents can execute the suggested hcc command explicitly and leave an audit trail.
7615
+ automation.current_task shows the peer's active claimed/running/review/blocked
7616
+ task when one exists.
7617
+ If a current task looks splittable, automation may suggest hcc team plan; this
7618
+ is read-only until hcc team start is run explicitly.
7619
+
7620
+ State does not execute coordination actions: it does not ack messages, claim
7621
+ tasks, acquire locks, send messages, create handoffs, or mark tasks done. Opening
7622
+ state may still perform normal SQLite schema maintenance for known project DBs.
7623
+ `);
7624
+ }
7625
+
5606
7626
  function helpJoin() {
5607
7627
  console.log(`${CLI_NAME} join
5608
7628
 
@@ -5640,8 +7660,11 @@ Usage:
5640
7660
  ${CLI_NAME} msg send [--from ID] [--to ID|all] --body TEXT [--task N] [--kind note|task|handoff]
5641
7661
  ${CLI_NAME} msg inbox [--peer ID] [--wait SEC] [--all] [--limit N]
5642
7662
  ${CLI_NAME} msg ack [--peer ID] --id N
7663
+ ${CLI_NAME} msg reply [--from ID] --id N --body TEXT [--to ID] [--kind reply]
7664
+ ${CLI_NAME} msg thread --id N [--limit N]
5643
7665
 
5644
7666
  If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal.
7667
+ Use msg reply when answering a message so the response stays in the same thread.
5645
7668
  `);
5646
7669
  }
5647
7670
 
@@ -5813,19 +7836,22 @@ function helpWeb() {
5813
7836
  console.log(`${CLI_NAME} web
5814
7837
 
5815
7838
  Usage:
5816
- ${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-discover] [--no-guidance]
7839
+ ${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-token] [--no-discover] [--no-guidance]
5817
7840
 
5818
7841
  Examples:
5819
7842
  ${CLI_NAME} web
5820
- HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --host 0.0.0.0 --port 8787
7843
+ HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --port 8787
7844
+ ${CLI_NAME} web --local
5821
7845
 
5822
7846
  This is the default one-command entrypoint. It prepares local coordination,
5823
7847
  installs Claude/Codex hooks and shims, ensures tmux is available, starts the
5824
7848
  browser terminal console as a background runtime, prints the URL, PID, runtime
5825
7849
  file, and log file, then returns the terminal to you.
5826
7850
 
5827
- Web token auth is disabled by default. Pass --token or set HCC_WEB_TOKEN to
5828
- require a token.
7851
+ By default, the web runtime listens on 0.0.0.0 and uses a saved token,
7852
+ generating one on first use. Use HCC_WEB_TOKEN or --token to replace the saved
7853
+ token, --local to bind only to 127.0.0.1, or --no-token only for a trusted
7854
+ local/test environment.
5829
7855
 
5830
7856
  After hcc web, plain claude/codex commands started from a new shell are wrapped
5831
7857
  as local tmux-backed terminals. Existing ordinary terminals can communicate
@@ -5905,7 +7931,7 @@ async function cmdHook(ctx, args) {
5905
7931
  if (!peerId) {
5906
7932
  resumeId = providerSession.resumeId || readParentResumeId(kind);
5907
7933
  if (resumeId) {
5908
- peerId = `${kind}-${sanitizePeerPart(resumeId.slice(0, 8), shortHash(resumeId))}`;
7934
+ peerId = providerSessionPeerId(kind, resumeId);
5909
7935
  }
5910
7936
  }
5911
7937
  if (!peerId) {
@@ -5962,7 +7988,7 @@ async function cmdHook(ctx, args) {
5962
7988
  addEvent(db, `hook.${hookKey}`, peerId, null, { session_id: sessionId, cwd: hookCwd });
5963
7989
 
5964
7990
  if (['sessionstart', 'userpromptsubmit'].includes(hookKey)) {
5965
- const snapshot = buildHookCoordinationContext(db, peerId);
7991
+ const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
5966
7992
  ackMessages(db, peerId, snapshot.messages);
5967
7993
  process.stdout.write(JSON.stringify({
5968
7994
  hookSpecificOutput: {
@@ -5971,7 +7997,7 @@ async function cmdHook(ctx, args) {
5971
7997
  }
5972
7998
  }) + '\n');
5973
7999
  } else if (['posttooluse', 'stop'].includes(hookKey)) {
5974
- const snapshot = buildHookCoordinationContext(db, peerId);
8000
+ const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
5975
8001
  if (snapshot.messages.length > 0) {
5976
8002
  ackMessages(db, peerId, snapshot.messages);
5977
8003
  process.stdout.write(JSON.stringify({
@@ -6404,6 +8430,7 @@ async function dispatch(ctx, rest) {
6404
8430
  if (command === 'heartbeat') return cmdHeartbeat(ctx, args);
6405
8431
  if (command === 'peers') return cmdPeers(ctx, args);
6406
8432
  if (command === 'status') return cmdStatus(ctx, args);
8433
+ if (command === 'state') return cmdState(ctx, args);
6407
8434
  if (command === 'prompt') return cmdPrompt(ctx, args);
6408
8435
  if (command === 'run') return cmdRun(ctx, args);
6409
8436
  if (command === 'peer') return cmdPeer(ctx, args);
@@ -6411,6 +8438,7 @@ async function dispatch(ctx, rest) {
6411
8438
  if (command === 'ask') return cmdAsk(ctx, args);
6412
8439
  if (command === 'broadcast') return cmdBroadcast(ctx, args);
6413
8440
  if (command === 'task') return cmdTask(ctx, args);
8441
+ if (command === 'team') return cmdTeam(ctx, args);
6414
8442
  if (command === 'msg') return cmdMsg(ctx, args);
6415
8443
  if (command === 'lock') return cmdLock(ctx, args);
6416
8444
  if (command === 'handoff') return cmdHandoff(ctx, args);