@logicseek/hello-cc 0.1.4 → 0.1.5

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
@@ -4,17 +4,67 @@ import fs from 'node:fs';
4
4
  import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import process from 'node:process';
7
- import os from 'node:os';
8
- import { createHash, randomBytes } from 'node:crypto';
7
+ import { createHash } from 'node:crypto';
9
8
  import { spawn, spawnSync } from 'node:child_process';
10
9
  import { setTimeout as sleep } from 'node:timers/promises';
11
10
  import { URL, fileURLToPath } from 'node:url';
12
11
  import { DatabaseSync } from 'node:sqlite';
12
+ import { CliError } from '../lib/errors.mjs';
13
+ import {
14
+ DB_SCHEMA_VERSION,
15
+ execWithBusyRetry,
16
+ initSchema,
17
+ tx
18
+ } from '../lib/db-schema.mjs';
19
+ import {
20
+ intOpt,
21
+ parseOpts,
22
+ required,
23
+ splitGlobalArgs,
24
+ validateOpts,
25
+ wantsHelp
26
+ } from '../lib/cli-args.mjs';
27
+ import {
28
+ compactText,
29
+ formatJson,
30
+ printResult,
31
+ shellExports,
32
+ shellQuoteArg,
33
+ table
34
+ } from '../lib/format.mjs';
13
35
  import { readPackageMeta } from '../lib/package-meta.mjs';
14
36
  import {
15
37
  removeGuidanceBlocks as removeGuidanceBlocksForRoot,
16
38
  writeGuidance as writeGuidanceForRoot
17
39
  } from '../lib/guidance.mjs';
40
+ import {
41
+ contextForProject,
42
+ globalRuntimePath,
43
+ projectDbPath,
44
+ projectRegistryPath,
45
+ runtimePath,
46
+ webLogPath
47
+ } from '../lib/runtime-paths.mjs';
48
+ import {
49
+ expectedWebHost,
50
+ localRuntimeUrl,
51
+ makeWebToken,
52
+ publicRuntimeUrl,
53
+ rememberRuntimeToken,
54
+ requestUrl,
55
+ runtimeApiUrl,
56
+ runtimeBaseUrl,
57
+ validateWebTokenOpts,
58
+ webRuntimeMatchesRequest
59
+ } from '../lib/web-runtime.mjs';
60
+ import {
61
+ authOk,
62
+ readJsonRequest,
63
+ sendFile,
64
+ sendHttp,
65
+ sendJson
66
+ } from '../lib/web-http.mjs';
67
+ import { webIndexHtml } from '../lib/web-ui-template.mjs';
18
68
 
19
69
  // Lazy-load lib modules (they may import node-pty which needs to be optional)
20
70
  const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'lib');
@@ -28,19 +78,10 @@ const CLI_NAME = 'hcc';
28
78
  const NPM_PACKAGE_NAME = PACKAGE_META.name;
29
79
  const DEFAULT_LOCK_TTL = 900;
30
80
  const ACTIVE_PEER_TTL = 600;
31
- const DB_SCHEMA_VERSION = 4;
32
81
  // Directory under .hello-cc/ for optional external PTY buffer files.
33
82
  const BUFS_DIR_NAME = 'bufs';
34
83
  const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
35
84
 
36
- class CliError extends Error {
37
- constructor(code, message, extra = {}) {
38
- super(message);
39
- this.code = code;
40
- this.extra = extra;
41
- }
42
- }
43
-
44
85
  function now() {
45
86
  return Math.floor(Date.now() / 1000);
46
87
  }
@@ -78,86 +119,6 @@ function detectBranch(cwd) {
78
119
  return runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) || '';
79
120
  }
80
121
 
81
- function splitGlobalArgs(argv) {
82
- const global = { json: false, root: null, db: null };
83
- const rest = [];
84
- for (let i = 0; i < argv.length; i += 1) {
85
- const arg = argv[i];
86
- if (arg === '--json') {
87
- global.json = true;
88
- } else if (arg === '--root') {
89
- global.root = argv[++i];
90
- } else if (arg.startsWith('--root=')) {
91
- global.root = arg.slice('--root='.length);
92
- } else if (arg === '--db') {
93
- global.db = argv[++i];
94
- } else if (arg.startsWith('--db=')) {
95
- global.db = arg.slice('--db='.length);
96
- } else {
97
- rest.push(arg);
98
- }
99
- }
100
- return { global, rest };
101
- }
102
-
103
- function parseOpts(args, spec = {}) {
104
- const booleans = new Set(spec.booleans || []);
105
- const arrays = new Set(spec.arrays || []);
106
- const opts = { _: [] };
107
- for (let i = 0; i < args.length; i += 1) {
108
- const arg = args[i];
109
- if (!arg.startsWith('--') || arg === '--') {
110
- opts._.push(arg);
111
- continue;
112
- }
113
- let key;
114
- let value;
115
- const eq = arg.indexOf('=');
116
- if (eq >= 0) {
117
- key = arg.slice(2, eq);
118
- value = arg.slice(eq + 1);
119
- } else {
120
- key = arg.slice(2);
121
- if (booleans.has(key)) {
122
- value = true;
123
- } else {
124
- value = args[++i];
125
- }
126
- }
127
- if (!key) throw new CliError('BAD_ARGS', `Invalid option: ${arg}`);
128
- if (value === undefined) throw new CliError('BAD_ARGS', `Missing value for --${key}`);
129
- if (arrays.has(key)) {
130
- if (!opts[key]) opts[key] = [];
131
- opts[key].push(value);
132
- } else {
133
- opts[key] = value;
134
- }
135
- }
136
- return opts;
137
- }
138
-
139
- function wantsHelp(args) {
140
- const stop = args.indexOf('--');
141
- const scan = stop >= 0 ? args.slice(0, stop) : args;
142
- const index = scan.findIndex((arg) => arg === '--help' || arg === '-h');
143
- if (index < 0) return false;
144
- return index <= 1 || !String(scan[index - 1] || '').startsWith('--');
145
- }
146
-
147
- function validateOpts(command, opts, allowed = []) {
148
- const allowedSet = new Set(['_', ...allowed]);
149
- for (const key of Object.keys(opts)) {
150
- if (!allowedSet.has(key)) throw new CliError('BAD_ARGS', `${command}: unknown option --${key}`);
151
- }
152
- if (opts._?.length) throw new CliError('BAD_ARGS', `${command}: unexpected argument ${opts._[0]}`);
153
- }
154
-
155
- function required(opts, key, envName = null) {
156
- const value = opts[key] || (envName ? process.env[envName] : null);
157
- if (!value) throw new CliError('BAD_ARGS', `Missing --${key}${envName ? ` or $${envName}` : ''}`);
158
- return value;
159
- }
160
-
161
122
  function sanitizePeerPart(value, fallback = 'peer') {
162
123
  const text = String(value || '').toLowerCase()
163
124
  .replace(/[^a-z0-9._-]/g, '-')
@@ -366,55 +327,13 @@ function autoPeerDefaults(ctx, kindHint = 'shell', status = 'working') {
366
327
  };
367
328
  }
368
329
 
369
- function intOpt(opts, key, fallback = null) {
370
- if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
371
- const value = Number.parseInt(String(opts[key]), 10);
372
- if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
373
- return value;
374
- }
375
-
376
330
  function createContext(global) {
377
331
  const cwd = process.cwd();
378
332
  const root = detectRoot(cwd, global.root);
379
- const dbPath = path.resolve(global.db || process.env.HCC_DB || path.join(root, '.hello-cc', 'mesh.db'));
333
+ const dbPath = path.resolve(global.db || process.env.HCC_DB || projectDbPath(root));
380
334
  return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
381
335
  }
382
336
 
383
- function runtimePath(ctx) {
384
- return path.join(ctx.root, '.hello-cc', 'runtime.json');
385
- }
386
-
387
- function webLogPath(ctx) {
388
- return path.join(ctx.root, '.hello-cc', 'web.log');
389
- }
390
-
391
- function globalStateDir() {
392
- return path.join(os.homedir(), '.hello-cc');
393
- }
394
-
395
- function globalRuntimePath() {
396
- return path.join(globalStateDir(), 'runtime.json');
397
- }
398
-
399
- function globalWebTokenPath() {
400
- return path.join(globalStateDir(), 'web-token');
401
- }
402
-
403
- function projectRegistryPath() {
404
- return path.join(globalStateDir(), 'projects.json');
405
- }
406
-
407
- function contextForProject(root, dbPath = null, base = {}) {
408
- const resolvedRoot = path.resolve(root);
409
- return {
410
- cwd: base.cwd || resolvedRoot,
411
- root: resolvedRoot,
412
- dbPath: path.resolve(dbPath || path.join(resolvedRoot, '.hello-cc', 'mesh.db')),
413
- json: Boolean(base.json),
414
- explicitRoot: true
415
- };
416
- }
417
-
418
337
  function projectRecord(ctx) {
419
338
  return {
420
339
  root: ctx.root,
@@ -434,7 +353,7 @@ function readProjectRegistry() {
434
353
  .filter((p) => p && typeof p.root === 'string')
435
354
  .map((p) => ({
436
355
  root: path.resolve(p.root),
437
- db: path.resolve(p.db || path.join(p.root, '.hello-cc', 'mesh.db')),
356
+ db: path.resolve(p.db || projectDbPath(p.root)),
438
357
  name: String(p.name || path.basename(p.root) || p.root),
439
358
  last_seen_at: Number.parseInt(p.last_seen_at || '0', 10) || 0
440
359
  }));
@@ -451,7 +370,7 @@ function writeProjectRegistry(projects) {
451
370
  if (!project?.root) continue;
452
371
  unique.set(path.resolve(project.root), {
453
372
  root: path.resolve(project.root),
454
- db: path.resolve(project.db || path.join(project.root, '.hello-cc', 'mesh.db')),
373
+ db: path.resolve(project.db || projectDbPath(project.root)),
455
374
  name: String(project.name || path.basename(project.root) || project.root),
456
375
  last_seen_at: Number.parseInt(project.last_seen_at || '0', 10) || 0
457
376
  });
@@ -491,15 +410,6 @@ function writeGlobalRuntime(runtime) {
491
410
  return file;
492
411
  }
493
412
 
494
- function runtimeConnectHost(host) {
495
- if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
496
- return host;
497
- }
498
-
499
- function runtimeBaseUrl(host, port) {
500
- return `http://${runtimeConnectHost(host)}:${port}`;
501
- }
502
-
503
413
  function writeRuntime(ctx, runtime) {
504
414
  const file = runtimePath(ctx);
505
415
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -542,7 +452,7 @@ function readRuntimeFile(ctx) {
542
452
 
543
453
  async function probeRuntime(runtime) {
544
454
  if (!runtime?.base_url) return false;
545
- const url = new URL('/api/runtime', runtime.base_url);
455
+ const url = runtimeApiUrl(runtime, '/api/runtime');
546
456
  const headers = {};
547
457
  if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
548
458
  try {
@@ -595,99 +505,10 @@ function clearRuntime(ctx, pid = process.pid) {
595
505
  }
596
506
  }
597
507
 
598
- function shellQuoteArg(value) {
599
- const text = String(value);
600
- if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
601
- return `'${text.replace(/'/g, `'\\''`)}'`;
602
- }
603
-
604
508
  function shellCommand(args) {
605
509
  return args.map(shellQuoteArg).join(' ');
606
510
  }
607
511
 
608
- function runtimeUrlQuery(runtime, projectRoot = null) {
609
- const parts = [];
610
- if (runtime.token) parts.push(`token=${encodeURIComponent(runtime.token)}`);
611
- if (projectRoot) parts.push(`project=${encodeURIComponent(projectRoot)}`);
612
- return parts.length ? `?${parts.join('&')}` : '';
613
- }
614
-
615
- function readStoredWebToken() {
616
- const file = globalWebTokenPath();
617
- if (!fs.existsSync(file)) return '';
618
- try {
619
- return fs.readFileSync(file, 'utf8').trim();
620
- } catch {
621
- return '';
622
- }
623
- }
624
-
625
- function writeStoredWebToken(token) {
626
- if (!token) return '';
627
- const file = globalWebTokenPath();
628
- fs.mkdirSync(path.dirname(file), { recursive: true });
629
- fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
630
- fs.chmodSync(file, 0o600);
631
- return file;
632
- }
633
-
634
- function makeWebToken(opts) {
635
- validateWebTokenOpts(opts);
636
- if (opts['no-token']) return '';
637
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
638
- if (explicitToken) {
639
- writeStoredWebToken(explicitToken);
640
- return explicitToken;
641
- }
642
- const stored = readStoredWebToken();
643
- if (stored) return stored;
644
- const generated = randomBytes(24).toString('base64url');
645
- writeStoredWebToken(generated);
646
- return generated;
647
- }
648
-
649
- function validateWebTokenOpts(opts) {
650
- if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) {
651
- throw new CliError('BAD_ARGS', '--no-token cannot be combined with --token or HCC_WEB_TOKEN');
652
- }
653
- }
654
-
655
- function expectedWebHost(opts) {
656
- return opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
657
- }
658
-
659
- function webRuntimeMatchesRequest(runtime, opts) {
660
- if (!runtime) return false;
661
- if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
662
- const expectedHost = expectedWebHost(opts);
663
- if (runtime.host !== expectedHost) return false;
664
- const expectedPort = intOpt(opts, 'port', 8787);
665
- if (opts.port !== undefined && runtime.port !== expectedPort) return false;
666
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
667
- if (opts['no-token']) return !runtime.token;
668
- if (explicitToken) return runtime.token === explicitToken;
669
- return Boolean(runtime.token);
670
- }
671
-
672
- function rememberRuntimeToken(runtime, opts) {
673
- if (!runtime?.token || opts['no-token']) return;
674
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
675
- if (explicitToken && runtime.token !== explicitToken) return;
676
- writeStoredWebToken(runtime.token);
677
- }
678
-
679
- function publicRuntimeUrl(runtime, projectRoot = null) {
680
- const host = runtime.host === '0.0.0.0' || runtime.host === '::'
681
- ? '<machine-ip>'
682
- : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
683
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
684
- }
685
-
686
- function localRuntimeUrl(runtime, projectRoot = null) {
687
- const host = runtimeConnectHost(runtime.host || '127.0.0.1');
688
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
689
- }
690
-
691
512
  function tailFile(file, maxBytes = 12000) {
692
513
  try {
693
514
  const stat = fs.statSync(file);
@@ -1425,7 +1246,7 @@ function parseCodexCommandArgs(args) {
1425
1246
 
1426
1247
  async function runtimeRequest(ctx, method, route, body = null, runtime = null) {
1427
1248
  const rt = runtime || readRuntime(ctx);
1428
- const url = new URL(route, rt.base_url);
1249
+ const url = runtimeApiUrl(rt, route);
1429
1250
  const headers = { 'Content-Type': 'application/json' };
1430
1251
  headers['X-HCC-Root'] = ctx.root;
1431
1252
  headers['X-HCC-DB'] = ctx.dbPath;
@@ -1459,38 +1280,6 @@ async function runtimeRequest(ctx, method, route, body = null, runtime = null) {
1459
1280
  return json || {};
1460
1281
  }
1461
1282
 
1462
- const sleepBuffer = new SharedArrayBuffer(4);
1463
- const sleepView = new Int32Array(sleepBuffer);
1464
-
1465
- function sleepSync(ms) {
1466
- Atomics.wait(sleepView, 0, 0, ms);
1467
- }
1468
-
1469
- function isSqliteBusy(err) {
1470
- const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
1471
- return err?.errcode === 5 ||
1472
- err?.errcode === 6 ||
1473
- err?.errcode === 261 ||
1474
- /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
1475
- }
1476
-
1477
- function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
1478
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1479
- try {
1480
- db.exec(sql);
1481
- return true;
1482
- } catch (err) {
1483
- if (!isSqliteBusy(err)) throw err;
1484
- if (attempt === attempts - 1) {
1485
- if (ignoreBusy) return false;
1486
- throw err;
1487
- }
1488
- sleepSync(delayMs);
1489
- }
1490
- }
1491
- return false;
1492
- }
1493
-
1494
1283
  let projectMigrationFanoutDepth = 0;
1495
1284
  const migratedRegisteredProjectDbs = new Set();
1496
1285
 
@@ -1500,7 +1289,7 @@ function connect(ctx) {
1500
1289
  db.exec('PRAGMA busy_timeout = 5000;');
1501
1290
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1502
1291
  db.exec('PRAGMA foreign_keys = ON;');
1503
- initSchema(db);
1292
+ initSchema(db, { beforePostMigrationIndexes: dedupePeerBindings });
1504
1293
  migrateRegisteredProjectDbs(ctx);
1505
1294
  return db;
1506
1295
  }
@@ -1525,7 +1314,7 @@ function migrateRegisteredProjectDbs(ctx) {
1525
1314
  db.exec('PRAGMA busy_timeout = 5000;');
1526
1315
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1527
1316
  db.exec('PRAGMA foreign_keys = ON;');
1528
- initSchema(db);
1317
+ initSchema(db, { beforePostMigrationIndexes: dedupePeerBindings });
1529
1318
  migratedRegisteredProjectDbs.add(cacheKey);
1530
1319
  } catch (err) {
1531
1320
  throw new CliError('REGISTERED_DB_MIGRATION_FAILED', `Failed to migrate registered project database ${dbPath}: ${err?.message || err}`, {
@@ -1541,252 +1330,6 @@ function migrateRegisteredProjectDbs(ctx) {
1541
1330
  }
1542
1331
  }
1543
1332
 
1544
- function initSchema(db) {
1545
- const existingVersion = readSchemaVersion(db);
1546
- if (existingVersion > DB_SCHEMA_VERSION) {
1547
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1548
- db_schema_version: existingVersion,
1549
- supported_schema_version: DB_SCHEMA_VERSION
1550
- });
1551
- }
1552
- createBaseSchema(db);
1553
- runSchemaMigrations(db);
1554
- dedupePeerBindings(db);
1555
- execWithBusyRetry(db, `
1556
- CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
1557
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
1558
- ON peer_bindings(provider, provider_session_id)
1559
- WHERE provider_session_id IS NOT NULL;
1560
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
1561
- ON peer_bindings(provider, provider_session_name)
1562
- WHERE provider_session_name IS NOT NULL;
1563
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
1564
- ON peer_bindings(runtime_target)
1565
- WHERE runtime_target IS NOT NULL;
1566
- `);
1567
- }
1568
-
1569
- function createBaseSchema(db) {
1570
- execWithBusyRetry(db, `
1571
- CREATE TABLE IF NOT EXISTS meta (
1572
- key TEXT PRIMARY KEY,
1573
- value TEXT NOT NULL
1574
- );
1575
-
1576
- CREATE TABLE IF NOT EXISTS schema_migrations (
1577
- version INTEGER PRIMARY KEY,
1578
- name TEXT NOT NULL,
1579
- applied_at INTEGER NOT NULL
1580
- );
1581
-
1582
- CREATE TABLE IF NOT EXISTS peers (
1583
- id TEXT PRIMARY KEY,
1584
- kind TEXT NOT NULL,
1585
- role TEXT,
1586
- worktree TEXT,
1587
- branch TEXT,
1588
- pid INTEGER,
1589
- status TEXT NOT NULL DEFAULT 'idle',
1590
- capabilities TEXT,
1591
- created_at INTEGER NOT NULL,
1592
- last_seen_at INTEGER NOT NULL
1593
- );
1594
-
1595
- CREATE TABLE IF NOT EXISTS peer_bindings (
1596
- peer TEXT PRIMARY KEY,
1597
- provider TEXT NOT NULL,
1598
- provider_session_id TEXT,
1599
- provider_session_name TEXT,
1600
- resume_mode TEXT NOT NULL DEFAULT 'new',
1601
- resume_arg TEXT,
1602
- command TEXT,
1603
- transport TEXT NOT NULL,
1604
- runtime_session_id TEXT,
1605
- runtime_target TEXT,
1606
- created_at INTEGER NOT NULL,
1607
- updated_at INTEGER NOT NULL,
1608
- FOREIGN KEY (peer) REFERENCES peers(id) ON DELETE CASCADE
1609
- );
1610
-
1611
- CREATE TABLE IF NOT EXISTS tasks (
1612
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1613
- title TEXT NOT NULL,
1614
- body TEXT,
1615
- status TEXT NOT NULL DEFAULT 'pending',
1616
- assignee TEXT,
1617
- owner TEXT,
1618
- parent_id INTEGER,
1619
- team_role TEXT,
1620
- priority INTEGER NOT NULL DEFAULT 100,
1621
- created_by TEXT,
1622
- claimed_at INTEGER,
1623
- completed_at INTEGER,
1624
- created_at INTEGER NOT NULL,
1625
- updated_at INTEGER NOT NULL
1626
- );
1627
-
1628
- CREATE TABLE IF NOT EXISTS messages (
1629
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1630
- sender TEXT NOT NULL,
1631
- recipient TEXT,
1632
- task_id INTEGER,
1633
- kind TEXT NOT NULL DEFAULT 'note',
1634
- body TEXT NOT NULL,
1635
- reply_to INTEGER,
1636
- thread_id INTEGER,
1637
- created_at INTEGER NOT NULL
1638
- );
1639
-
1640
- CREATE TABLE IF NOT EXISTS message_reads (
1641
- message_id INTEGER NOT NULL,
1642
- peer TEXT NOT NULL,
1643
- read_at INTEGER NOT NULL,
1644
- PRIMARY KEY (message_id, peer),
1645
- FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
1646
- );
1647
-
1648
- CREATE TABLE IF NOT EXISTS locks (
1649
- resource TEXT PRIMARY KEY,
1650
- owner TEXT NOT NULL,
1651
- task_id INTEGER,
1652
- reason TEXT,
1653
- expires_at INTEGER NOT NULL,
1654
- created_at INTEGER NOT NULL
1655
- );
1656
-
1657
- CREATE TABLE IF NOT EXISTS handoffs (
1658
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1659
- task_id INTEGER,
1660
- from_peer TEXT NOT NULL,
1661
- to_peer TEXT,
1662
- summary TEXT NOT NULL,
1663
- changed_files TEXT,
1664
- tests TEXT,
1665
- risks TEXT,
1666
- created_at INTEGER NOT NULL
1667
- );
1668
-
1669
- CREATE TABLE IF NOT EXISTS events (
1670
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1671
- type TEXT NOT NULL,
1672
- actor TEXT,
1673
- task_id INTEGER,
1674
- payload TEXT,
1675
- created_at INTEGER NOT NULL
1676
- );
1677
-
1678
- CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority, id);
1679
- CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
1680
- CREATE INDEX IF NOT EXISTS idx_messages_recipient_id ON messages(recipient, id);
1681
- CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
1682
- CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
1683
- `);
1684
- }
1685
-
1686
- function tableColumns(db, tableName) {
1687
- return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
1688
- }
1689
-
1690
- function tableExists(db, tableName) {
1691
- return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
1692
- }
1693
-
1694
- function readSchemaVersion(db) {
1695
- const metaExists = tableExists(db, 'meta');
1696
- const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
1697
- const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
1698
- const migrationVersion = tableExists(db, 'schema_migrations')
1699
- ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
1700
- : 0;
1701
- return Math.max(metaVersion, pragmaVersion, migrationVersion);
1702
- }
1703
-
1704
- function writeSchemaVersion(db, version) {
1705
- db.prepare(`
1706
- INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1707
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
1708
- `).run(String(version));
1709
- db.exec(`PRAGMA user_version = ${Number(version)}`);
1710
- }
1711
-
1712
- function recordSchemaMigration(db, version, name) {
1713
- db.prepare(`
1714
- INSERT INTO schema_migrations(version, name, applied_at)
1715
- VALUES (?, ?, ?)
1716
- ON CONFLICT(version) DO NOTHING
1717
- `).run(version, name, now());
1718
- }
1719
-
1720
- function addColumnIfMissing(db, tableName, columnName, ddl) {
1721
- const columns = tableColumns(db, tableName);
1722
- if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
1723
- }
1724
-
1725
- function runSchemaMigrations(db) {
1726
- const migrations = [
1727
- {
1728
- version: 1,
1729
- name: 'baseline',
1730
- up() {
1731
- // createBaseSchema already created the baseline tables.
1732
- }
1733
- },
1734
- {
1735
- version: 2,
1736
- name: 'peer binding runtime targets',
1737
- up(database) {
1738
- addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
1739
- }
1740
- },
1741
- {
1742
- version: 3,
1743
- name: 'threaded messages',
1744
- up(database) {
1745
- addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
1746
- addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
1747
- database.exec(`
1748
- UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
1749
- CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
1750
- `);
1751
- }
1752
- },
1753
- {
1754
- version: 4,
1755
- name: 'team task hierarchy',
1756
- up(database) {
1757
- addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
1758
- addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
1759
- database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
1760
- }
1761
- }
1762
- ];
1763
- let version = readSchemaVersion(db);
1764
- if (version > DB_SCHEMA_VERSION) {
1765
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1766
- db_schema_version: version,
1767
- supported_schema_version: DB_SCHEMA_VERSION
1768
- });
1769
- }
1770
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
1771
- tx(db, () => {
1772
- for (const migration of migrations) {
1773
- if (version >= migration.version && applied.has(migration.version)) continue;
1774
- migration.up(db);
1775
- recordSchemaMigration(db, migration.version, migration.name);
1776
- applied.add(migration.version);
1777
- version = migration.version;
1778
- writeSchemaVersion(db, version);
1779
- }
1780
- if (version === 0) {
1781
- version = DB_SCHEMA_VERSION;
1782
- }
1783
- for (const migration of migrations) {
1784
- if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
1785
- }
1786
- writeSchemaVersion(db, version);
1787
- });
1788
- }
1789
-
1790
1333
  function peerBindingRuntimeRank(row) {
1791
1334
  if (row?.runtime_target && row.transport === 'tmux') return 50;
1792
1335
  if (row?.runtime_target) return 40;
@@ -1879,22 +1422,6 @@ function dedupePeerBindings(db) {
1879
1422
  }
1880
1423
  }
1881
1424
 
1882
- function tx(db, fn) {
1883
- execWithBusyRetry(db, 'BEGIN IMMEDIATE;');
1884
- try {
1885
- const result = fn();
1886
- db.exec('COMMIT;');
1887
- return result;
1888
- } catch (err) {
1889
- try {
1890
- db.exec('ROLLBACK;');
1891
- } catch {
1892
- // Ignore rollback failures; the original error is more useful.
1893
- }
1894
- throw err;
1895
- }
1896
- }
1897
-
1898
1425
  function addEvent(db, type, actor, taskId, payload) {
1899
1426
  db.prepare(`
1900
1427
  INSERT INTO events(type, actor, task_id, payload, created_at)
@@ -2245,11 +1772,6 @@ function parseEventPayload(row) {
2245
1772
  }
2246
1773
  }
2247
1774
 
2248
- function compactText(value, limit = 160) {
2249
- const text = String(value || '').replace(/\s+/g, ' ').trim();
2250
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
2251
- }
2252
-
2253
1775
  function uniqueList(values) {
2254
1776
  return [...new Set(values.filter(Boolean).map(String))];
2255
1777
  }
@@ -2293,6 +1815,7 @@ const TIMELINE_EVENT_ALLOW = new Set([
2293
1815
  'task.blocked',
2294
1816
  'task.done',
2295
1817
  'task.abandoned',
1818
+ 'task.takeover',
2296
1819
  'team.started',
2297
1820
  'lock.acquired',
2298
1821
  'lock.released',
@@ -2394,7 +1917,7 @@ function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = []
2394
1917
  peers: uniqueList([lock.owner]),
2395
1918
  task_id: lock.task_id || null,
2396
1919
  kind: 'active',
2397
- title: `lock ${lock.resource}`,
1920
+ title: `lock ${lockLabel(lock)}`,
2398
1921
  text: compactText(lock.reason || `owner=${lock.owner}`),
2399
1922
  direction: 'project'
2400
1923
  };
@@ -2442,6 +1965,51 @@ function makeAction(kind, argv, reason, mutates = true, extra = {}) {
2442
1965
  };
2443
1966
  }
2444
1967
 
1968
+ const WHOLE_LOCK_SCOPE = '*';
1969
+
1970
+ function normalizeLockScope(scope) {
1971
+ const text = String(scope || '').trim();
1972
+ return text || WHOLE_LOCK_SCOPE;
1973
+ }
1974
+
1975
+ function scopedLockResource(resource, scope = WHOLE_LOCK_SCOPE) {
1976
+ const baseResource = String(resource || '').trim();
1977
+ const normalizedScope = normalizeLockScope(scope);
1978
+ if (!baseResource) throw new CliError('BAD_ARGS', 'Missing --resource');
1979
+ return {
1980
+ resource: normalizedScope === WHOLE_LOCK_SCOPE
1981
+ ? baseResource
1982
+ : `scoped:${Buffer.from(JSON.stringify([baseResource, normalizedScope]), 'utf8').toString('base64url')}`,
1983
+ base_resource: baseResource,
1984
+ scope: normalizedScope
1985
+ };
1986
+ }
1987
+
1988
+ function lockBaseResource(lock) {
1989
+ return lock?.base_resource || lock?.resource || '';
1990
+ }
1991
+
1992
+ function lockScope(lock) {
1993
+ return normalizeLockScope(lock?.scope);
1994
+ }
1995
+
1996
+ function lockLabel(lock) {
1997
+ const base = lockBaseResource(lock);
1998
+ const scope = lockScope(lock);
1999
+ return scope === WHOLE_LOCK_SCOPE ? base : `${base} [${scope}]`;
2000
+ }
2001
+
2002
+ function lockArgv(resource, scope) {
2003
+ const argv = ['--resource', resource];
2004
+ if (normalizeLockScope(scope) !== WHOLE_LOCK_SCOPE) argv.push('--scope', normalizeLockScope(scope));
2005
+ return argv;
2006
+ }
2007
+
2008
+ function locksConflict(a, b) {
2009
+ return lockBaseResource(a) === lockBaseResource(b) &&
2010
+ (lockScope(a) === WHOLE_LOCK_SCOPE || lockScope(b) === WHOLE_LOCK_SCOPE || lockScope(a) === lockScope(b));
2011
+ }
2012
+
2445
2013
  function looksLikeMultiTask(task) {
2446
2014
  if (!task) return false;
2447
2015
  const text = `${task.title || ''}\n${task.body || ''}`;
@@ -2463,6 +2031,63 @@ function selectCurrentTask(tasks, peerId) {
2463
2031
  return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
2464
2032
  }
2465
2033
 
2034
+ function taskRelatedLocks(task, locks) {
2035
+ const taskId = Number(task?.id || 0);
2036
+ const owner = task?.owner || '';
2037
+ return (locks || []).filter((lock) => {
2038
+ if (taskId && Number(lock.task_id || 0) === taskId) return true;
2039
+ return Boolean(owner && lock.owner === owner);
2040
+ });
2041
+ }
2042
+
2043
+ function taskOwnerLiveness(task, peers, locks, t = now()) {
2044
+ const owner = task?.owner || null;
2045
+ const relatedLocks = taskRelatedLocks(task, locks);
2046
+ if (!owner) {
2047
+ return {
2048
+ owner_known: false,
2049
+ owner_active: null,
2050
+ owner_stale: false,
2051
+ owner_age_sec: null,
2052
+ related_lock_count: relatedLocks.length,
2053
+ takeover_ready: false
2054
+ };
2055
+ }
2056
+ const ownerRow = (peers || []).find((row) => row.id === owner) || null;
2057
+ const ownerAge = ownerRow
2058
+ ? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
2059
+ : null;
2060
+ const ownerActive = Boolean(ownerRow && Number.isFinite(ownerAge) && ownerAge <= ACTIVE_PEER_TTL);
2061
+ const ownerStale = !ownerActive;
2062
+ const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
2063
+ return {
2064
+ owner_known: Boolean(ownerRow),
2065
+ owner_active: ownerActive,
2066
+ owner_stale: ownerStale,
2067
+ owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
2068
+ related_lock_count: relatedLocks.length,
2069
+ takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
2070
+ };
2071
+ }
2072
+
2073
+ function annotateTasksWithLiveness(tasks, peers, locks, t = now()) {
2074
+ return (tasks || []).map((task) => ({
2075
+ ...task,
2076
+ ...taskOwnerLiveness(task, peers, locks, t)
2077
+ }));
2078
+ }
2079
+
2080
+ function taskOwnerStateText(task) {
2081
+ if (!task?.owner) return '';
2082
+ if (task.owner_stale) {
2083
+ if (task.takeover_ready) return 'stale/no-lock';
2084
+ const locks = Number(task.related_lock_count || 0);
2085
+ return locks ? `stale/locks=${locks}` : 'stale';
2086
+ }
2087
+ if (task.owner_active) return 'active';
2088
+ return '';
2089
+ }
2090
+
2466
2091
  function summarizeTask(task) {
2467
2092
  if (!task) return null;
2468
2093
  return {
@@ -2473,8 +2098,151 @@ function summarizeTask(task) {
2473
2098
  assignee: task.assignee || null,
2474
2099
  parent_id: task.parent_id || null,
2475
2100
  team_role: task.team_role || null,
2476
- priority: task.priority
2101
+ priority: task.priority,
2102
+ owner_active: task.owner_active ?? null,
2103
+ owner_stale: Boolean(task.owner_stale),
2104
+ owner_age_sec: task.owner_age_sec ?? null,
2105
+ related_lock_count: Number(task.related_lock_count || 0),
2106
+ takeover_ready: Boolean(task.takeover_ready)
2107
+ };
2108
+ }
2109
+
2110
+ function parseTaskIds(opts) {
2111
+ const values = [];
2112
+ const addValue = (value) => {
2113
+ if (Array.isArray(value)) {
2114
+ for (const item of value) addValue(item);
2115
+ return;
2116
+ }
2117
+ if (value === undefined || value === null || value === '') return;
2118
+ for (const part of String(value).split(',')) {
2119
+ const text = part.trim();
2120
+ if (text) values.push(text);
2121
+ }
2477
2122
  };
2123
+ addValue(opts.id);
2124
+ addValue(opts.ids);
2125
+ addValue(opts._ || []);
2126
+ const seen = new Set();
2127
+ const ids = [];
2128
+ for (const value of values) {
2129
+ if (!/^\d+$/.test(value)) throw new CliError('BAD_ARGS', `task id must be an integer: ${value}`);
2130
+ const id = Number.parseInt(value, 10);
2131
+ if (!seen.has(id)) {
2132
+ seen.add(id);
2133
+ ids.push(id);
2134
+ }
2135
+ }
2136
+ if (!ids.length) throw new CliError('BAD_ARGS', 'Missing --id');
2137
+ return ids;
2138
+ }
2139
+
2140
+ function positiveIntOpt(opts, key, fallback, { max = 50 } = {}) {
2141
+ const value = intOpt(opts, key, fallback);
2142
+ if (value < 1) throw new CliError('BAD_ARGS', `--${key} must be >= 1`);
2143
+ if (value > max) throw new CliError('BAD_ARGS', `--${key} must be <= ${max}`);
2144
+ return value;
2145
+ }
2146
+
2147
+ function taskRowsText(tasks, verb = 'claimed') {
2148
+ const rows = Array.isArray(tasks) ? tasks : [tasks].filter(Boolean);
2149
+ if (!rows.length) return 'no pending task';
2150
+ return rows.map((task) => `${verb} task #${task.id}: ${task.title}`).join('\n');
2151
+ }
2152
+
2153
+ function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
2154
+ return tx(db, () => {
2155
+ const tasks = [];
2156
+ for (const id of ids) {
2157
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
2158
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
2159
+ if (row.owner && row.owner !== peer && !force) {
2160
+ throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
2161
+ }
2162
+ if (row.assignee && row.assignee !== peer && !force) {
2163
+ throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
2164
+ }
2165
+ if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !force) {
2166
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
2167
+ }
2168
+ const t = now();
2169
+ db.prepare(`
2170
+ UPDATE tasks
2171
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
2172
+ WHERE id = ?
2173
+ `).run(peer, t, t, id);
2174
+ addEvent(db, 'task.claimed', peer, id, {
2175
+ previous_owner: row.owner,
2176
+ force,
2177
+ ...(source ? { source } : {})
2178
+ });
2179
+ tasks.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(id));
2180
+ }
2181
+ return tasks;
2182
+ });
2183
+ }
2184
+
2185
+ function takeoverPolicyDetails(db, row, peer, { policy = 'any', staleAfter = ACTIVE_PEER_TTL } = {}) {
2186
+ const normalized = String(policy || 'any').toLowerCase();
2187
+ const allowed = new Set(['any', 'blocked', 'stale', 'blocked-or-stale']);
2188
+ if (!allowed.has(normalized)) {
2189
+ throw new CliError('BAD_ARGS', `Unsupported takeover policy: ${policy}`);
2190
+ }
2191
+ const previousOwner = row.owner || null;
2192
+ const blocked = row.status === 'blocked';
2193
+ const ownerRow = previousOwner ? db.prepare('SELECT id, last_seen_at FROM peers WHERE id = ?').get(previousOwner) : null;
2194
+ const ownerAge = ownerRow ? now() - Number(ownerRow.last_seen_at || 0) : null;
2195
+ const ownerStale = Boolean(previousOwner && previousOwner !== peer && (!ownerRow || ownerAge > staleAfter));
2196
+ const alreadyOwner = previousOwner === peer;
2197
+ const ok = alreadyOwner ||
2198
+ normalized === 'any' ||
2199
+ (normalized === 'blocked' && blocked) ||
2200
+ (normalized === 'stale' && ownerStale) ||
2201
+ (normalized === 'blocked-or-stale' && (blocked || ownerStale));
2202
+ return { policy: normalized, blocked, owner_stale: ownerStale, owner_age_sec: ownerAge, stale_after_sec: staleAfter, ok };
2203
+ }
2204
+
2205
+ function takeOverTaskForPeer(db, peer, id, { reason, policy = 'any', staleAfter = ACTIVE_PEER_TTL, source = null } = {}) {
2206
+ return tx(db, () => {
2207
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
2208
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
2209
+ if (['done', 'abandoned'].includes(row.status)) {
2210
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
2211
+ }
2212
+ const policyDetails = takeoverPolicyDetails(db, row, peer, { policy, staleAfter });
2213
+ if (!policyDetails.ok) {
2214
+ throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
2215
+ task_id: id,
2216
+ status: row.status,
2217
+ owner: row.owner || null,
2218
+ policy: policyDetails.policy,
2219
+ owner_stale: policyDetails.owner_stale,
2220
+ owner_age_sec: policyDetails.owner_age_sec,
2221
+ stale_after_sec: policyDetails.stale_after_sec
2222
+ });
2223
+ }
2224
+ const previousOwner = row.owner || null;
2225
+ const previousAssignee = row.assignee || null;
2226
+ const t = now();
2227
+ db.prepare(`
2228
+ UPDATE tasks
2229
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
2230
+ WHERE id = ?
2231
+ `).run(peer, t, t, id);
2232
+ addEvent(db, 'task.takeover', peer, id, {
2233
+ previous_owner: previousOwner,
2234
+ previous_assignee: previousAssignee,
2235
+ reason,
2236
+ policy: policyDetails.policy,
2237
+ owner_stale: policyDetails.owner_stale,
2238
+ stale_after_sec: staleAfter,
2239
+ ...(source ? { source } : {})
2240
+ });
2241
+ if (previousOwner && previousOwner !== peer) {
2242
+ sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
2243
+ }
2244
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
2245
+ });
2478
2246
  }
2479
2247
 
2480
2248
  function deriveAutomation(snapshot, peer = null, opts = {}) {
@@ -2485,11 +2253,18 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2485
2253
  const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
2486
2254
  const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
2487
2255
  const ownedTask = selectCurrentTask(openTasks, peerId);
2256
+ const takeoverReadyTasks = peerId
2257
+ ? openTasks.filter((task) => task.owner && task.owner !== peerId && task.takeover_ready)
2258
+ : [];
2259
+ const intent = String(opts.intent || 'work').toLowerCase();
2260
+ const scope = normalizeLockScope(opts.scope || opts.lock_scope);
2488
2261
  const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
2489
2262
  const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
2490
2263
  const requestedLocks = resources.map((resource) => {
2491
- const lock = (snapshot.locks || []).find((row) => row.resource === resource && Number(row.expires_at || 0) > t);
2492
- return { resource, lock };
2264
+ const requested = scopedLockResource(resource, scope);
2265
+ const lock = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && locksConflict(row, requested));
2266
+ const exact = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope);
2267
+ return { ...requested, lock, exact };
2493
2268
  });
2494
2269
  const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
2495
2270
  const missingLock = requestedLocks.find((entry) => !entry.lock);
@@ -2507,14 +2282,14 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2507
2282
  known: Boolean(peerRow),
2508
2283
  active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= ACTIVE_PEER_TTL),
2509
2284
  age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
2510
- } : null,
2511
- current_task: summarizeTask(ownedTask),
2512
- phase: 'idle',
2513
- next_action: makeAction('none', [], 'no immediate coordination action', false),
2514
- actions: [],
2515
- finish_actions: [],
2516
- warnings
2517
- };
2285
+ } : null,
2286
+ current_task: summarizeTask(ownedTask),
2287
+ phase: 'idle',
2288
+ next_action: makeAction('none', [], 'no immediate coordination action', false),
2289
+ actions: [],
2290
+ finish_actions: [],
2291
+ warnings
2292
+ };
2518
2293
 
2519
2294
  const orderedUnread = unread
2520
2295
  .filter((message) => message.sender !== peerId)
@@ -2535,15 +2310,41 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2535
2310
  if (!ownedTask && assignedTasks.length) {
2536
2311
  const task = assignedTasks[0];
2537
2312
  taskActions.push(makeAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
2313
+ } else if (!ownedTask && takeoverReadyTasks.length) {
2314
+ const task = takeoverReadyTasks[0];
2315
+ taskActions.push(makeAction(
2316
+ 'task.takeover',
2317
+ ['task', 'takeover', '--peer', peerId, '--id', String(task.id), '--reason', 'owner stale and no active related locks', '--policy', 'stale'],
2318
+ `task #${task.id} owner ${task.owner} is stale and has no active related locks`,
2319
+ true,
2320
+ { task_id: task.id, owner: task.owner, owner_age_sec: task.owner_age_sec ?? null }
2321
+ ));
2538
2322
  } else if (!ownedTask && availableTasks.length) {
2539
2323
  taskActions.push(makeAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
2540
2324
  }
2541
2325
 
2542
2326
  if (ownedTask) {
2543
- if (conflict) {
2544
- 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 }));
2327
+ const readOnlyIntent = ['read', 'review', 'inspect'].includes(intent);
2328
+ if (readOnlyIntent && resources.length) {
2329
+ warnings.push(`intent=${intent} is read-only; do not acquire file locks for snapshot inspection`);
2330
+ } else if (conflict) {
2331
+ const requestedLabel = lockLabel(conflict);
2332
+ const heldLabel = lockLabel(conflict.lock);
2333
+ lockActions.push(makeAction(
2334
+ 'msg.send',
2335
+ ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${requestedLabel}; ${heldLabel} is locked by ${conflict.lock.owner}. If our edits are separate, split to scoped locks before final tests/commit.`],
2336
+ `${requestedLabel} conflicts with ${heldLabel} held by ${conflict.lock.owner}`,
2337
+ true,
2338
+ { task_id: ownedTask.id, resource: conflict.base_resource, scope: conflict.scope, lock_owner: conflict.lock.owner, lock_resource: conflict.lock.resource, lock_scope: lockScope(conflict.lock) }
2339
+ ));
2545
2340
  } else if (missingLock) {
2546
- 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 }));
2341
+ lockActions.push(makeAction(
2342
+ 'lock.acquire',
2343
+ ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), ...lockArgv(missingLock.base_resource, missingLock.scope), '--ttl', String(DEFAULT_LOCK_TTL), '--reason', '<work>'],
2344
+ `task #${ownedTask.id} needs ${lockLabel(missingLock)} lock`,
2345
+ true,
2346
+ { task_id: ownedTask.id, resource: missingLock.base_resource, scope: missingLock.scope }
2347
+ ));
2547
2348
  }
2548
2349
  if (ownedTask.status === 'claimed') {
2549
2350
  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 }));
@@ -2557,7 +2358,7 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2557
2358
  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 }));
2558
2359
  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 }));
2559
2360
  for (const lock of ownLocks) {
2560
- 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 }));
2361
+ automation.finish_actions.push(makeAction('lock.release', ['lock', 'release', '--peer', peerId, ...lockArgv(lockBaseResource(lock), lockScope(lock))], `release ${lockLabel(lock)}`, true, { task_id: lock.task_id || null, resource: lockBaseResource(lock), scope: lockScope(lock) }));
2561
2362
  }
2562
2363
  }
2563
2364
  automation.actions.push(...lockActions, ...messageActions, ...taskActions);
@@ -2572,6 +2373,7 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2572
2373
  : automation.next_action);
2573
2374
  if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
2574
2375
  else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
2376
+ else if (automation.next_action.kind === 'task.takeover') automation.phase = 'takeover_task';
2575
2377
  else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
2576
2378
  else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
2577
2379
  else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
@@ -2626,6 +2428,8 @@ function formatOpenTaskLine(task) {
2626
2428
  const parts = [`#${task.id}`, task.status];
2627
2429
  if (task.owner) parts.push(`owner=${task.owner}`);
2628
2430
  if (task.assignee) parts.push(`assignee=${task.assignee}`);
2431
+ const ownerState = taskOwnerStateText(task);
2432
+ if (ownerState) parts.push(`owner_state=${ownerState}`);
2629
2433
  return `${parts.join(' ')}: ${task.title}`;
2630
2434
  }
2631
2435
 
@@ -2650,14 +2454,15 @@ function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
2650
2454
  ORDER BY last_seen_at DESC, id ASC
2651
2455
  LIMIT 200
2652
2456
  `).all(t);
2653
- const tasks = queryOpenTasks(db, 200);
2457
+ const taskRows = queryOpenTasks(db, 200);
2654
2458
  const locks = db.prepare(`
2655
- SELECT resource, owner, task_id, reason, expires_at, created_at
2459
+ SELECT resource, base_resource, scope, owner, task_id, reason, expires_at, created_at
2656
2460
  FROM locks
2657
2461
  WHERE expires_at > ?
2658
2462
  ORDER BY resource ASC
2659
2463
  LIMIT 200
2660
2464
  `).all(t);
2465
+ const tasks = annotateTasksWithLiveness(taskRows, peers, locks, t);
2661
2466
  const messages = peer
2662
2467
  ? queryInbox(db, peer, false, 50)
2663
2468
  : db.prepare(`
@@ -2748,7 +2553,7 @@ function buildHookCoordinationContext(db, ctx, peerId) {
2748
2553
  if (snapshot.locks.length > 0) {
2749
2554
  parts.push('[hello-cc active locks]');
2750
2555
  parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
2751
- `- ${lock.resource} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
2556
+ `- ${lockLabel(lock)} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
2752
2557
  } else {
2753
2558
  parts.push('[hello-cc active locks]\n(none)');
2754
2559
  }
@@ -2770,37 +2575,8 @@ function ackMessages(db, peerId, messages) {
2770
2575
  }
2771
2576
  }
2772
2577
 
2773
- function formatJson(ok, dataOrError) {
2774
- if (ok) return JSON.stringify({ ok: true, data: dataOrError }, null, 2);
2775
- return JSON.stringify({ ok: false, error: dataOrError }, null, 2);
2776
- }
2777
-
2778
- function printResult(ctx, data, render) {
2779
- if (ctx.json) {
2780
- console.log(formatJson(true, data));
2781
- } else {
2782
- const output = render ? render(data) : String(data ?? '');
2783
- if (output) console.log(output);
2784
- }
2785
- }
2786
-
2787
- function shellExports(values) {
2788
- return Object.entries(values)
2789
- .map(([key, value]) => `export ${key}=${shellQuoteArg(value)}`)
2790
- .join('\n');
2791
- }
2792
-
2793
- function table(rows, columns) {
2794
- if (!rows.length) return '(none)';
2795
- const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
2796
- const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
2797
- const sep = widths.map((w) => '-'.repeat(w)).join(' ');
2798
- const body = rows.map((row) => columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
2799
- return [header, sep, ...body].join('\n');
2800
- }
2801
-
2802
- function writeGuidance(ctx) {
2803
- return writeGuidanceForRoot(ctx.root);
2578
+ function writeGuidance(ctx) {
2579
+ return writeGuidanceForRoot(ctx.root);
2804
2580
  }
2805
2581
 
2806
2582
  function removeGuidanceBlocks(ctx) {
@@ -2974,6 +2750,7 @@ async function cmdTask(ctx, args) {
2974
2750
  if (sub === 'create') return taskCreate(ctx, args.slice(1));
2975
2751
  if (sub === 'list') return taskList(ctx, args.slice(1));
2976
2752
  if (sub === 'claim') return taskClaim(ctx, args.slice(1));
2753
+ if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
2977
2754
  if (sub === 'next') return taskNext(ctx, args.slice(1));
2978
2755
  if (sub === 'update') return taskUpdate(ctx, args.slice(1));
2979
2756
  if (sub === 'done') return taskDone(ctx, args.slice(1));
@@ -3041,12 +2818,20 @@ async function taskList(ctx, args) {
3041
2818
  } else {
3042
2819
  rows = queryOpenTasks(db, limit);
3043
2820
  }
2821
+ const t = now();
2822
+ const peers = db.prepare(`
2823
+ SELECT id, last_seen_at, (? - last_seen_at) AS age_sec
2824
+ FROM peers
2825
+ `).all(t);
2826
+ const locks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
2827
+ rows = annotateTasksWithLiveness(rows, peers, locks, t);
3044
2828
  printResult(ctx, rows, (data) => table(data, [
3045
2829
  { label: 'id', value: (r) => `#${r.id}` },
3046
2830
  { label: 'status', value: (r) => r.status },
3047
2831
  { label: 'prio', value: (r) => r.priority },
3048
2832
  { label: 'assignee', value: (r) => r.assignee || '' },
3049
2833
  { label: 'owner', value: (r) => r.owner || '' },
2834
+ { label: 'owner_state', value: (r) => taskOwnerStateText(r) },
3050
2835
  { label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
3051
2836
  { label: 'role', value: (r) => r.team_role || '' },
3052
2837
  { label: 'title', value: (r) => r.title }
@@ -3054,49 +2839,45 @@ async function taskList(ctx, args) {
3054
2839
  }
3055
2840
 
3056
2841
  async function taskClaim(ctx, args) {
3057
- const opts = parseOpts(args, { booleans: ['force'] });
2842
+ const opts = parseOpts(args, { booleans: ['force'], arrays: ['id', 'ids'] });
2843
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
2844
+ const peer = identity.id;
2845
+ const ids = parseTaskIds(opts);
2846
+ const db = connect(ctx);
2847
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
2848
+ const tasks = claimTaskRowsForPeer(db, peer, ids, { force: Boolean(opts.force) });
2849
+ printResult(ctx, ids.length === 1 ? tasks[0] : tasks, (data) => taskRowsText(Array.isArray(data) ? data : [data], 'claimed'));
2850
+ }
2851
+
2852
+ async function taskTakeover(ctx, args) {
2853
+ const opts = parseOpts(args);
3058
2854
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3059
2855
  const peer = identity.id;
3060
2856
  const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
3061
2857
  if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
2858
+ const reason = required(opts, 'reason');
2859
+ const policy = opts.policy || 'any';
2860
+ const staleAfter = positiveIntOpt(opts, 'stale-after', ACTIVE_PEER_TTL, { max: 86400 * 30 });
3062
2861
  const db = connect(ctx);
3063
2862
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3064
- const task = tx(db, () => {
3065
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
3066
- if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
3067
- if (row.owner && row.owner !== peer && !opts.force) {
3068
- throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
3069
- }
3070
- if (row.assignee && row.assignee !== peer && !opts.force) {
3071
- throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
3072
- }
3073
- if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !opts.force) {
3074
- throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
3075
- }
3076
- const t = now();
3077
- db.prepare(`
3078
- UPDATE tasks
3079
- SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
3080
- WHERE id = ?
3081
- `).run(peer, t, t, id);
3082
- addEvent(db, 'task.claimed', peer, id, { previous_owner: row.owner, force: Boolean(opts.force) });
3083
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
3084
- });
3085
- printResult(ctx, task, (data) => `claimed task #${data.id}: ${data.title}`);
2863
+ const task = takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter });
2864
+ printResult(ctx, task, (data) => `took over task #${data.id}: ${data.title}`);
3086
2865
  }
3087
2866
 
3088
2867
  async function taskNext(ctx, args) {
3089
2868
  const opts = parseOpts(args, { booleans: ['force'] });
3090
2869
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3091
2870
  const peer = identity.id;
2871
+ const count = positiveIntOpt(opts, 'count', 1, { max: 50 });
3092
2872
  const db = connect(ctx);
3093
2873
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3094
- const task = claimNextTaskForPeer(db, peer, Boolean(opts.force));
3095
- printResult(ctx, task, (data) => {
2874
+ const result = claimNextTasksForPeer(db, peer, { force: Boolean(opts.force), count });
2875
+ printResult(ctx, count === 1 ? (result.current || result.tasks[0] || null) : result, (data) => {
3096
2876
  if (!data) return 'no pending task';
3097
- return data.current
3098
- ? `current task #${data.id}: ${data.title} (${data.status})`
3099
- : `claimed task #${data.id}: ${data.title}`;
2877
+ if (data.current === true) return `current task #${data.id}: ${data.title} (${data.status})`;
2878
+ if (data.current) return `current task #${data.current.id}: ${data.current.title} (${data.current.status})`;
2879
+ if (data.tasks) return data.tasks.length ? taskRowsText(data.tasks, 'claimed') : 'no pending task';
2880
+ return `claimed task #${data.id}: ${data.title}`;
3100
2881
  });
3101
2882
  }
3102
2883
 
@@ -3714,7 +3495,7 @@ async function lockAcquire(ctx, args) {
3714
3495
  const opts = parseOpts(args);
3715
3496
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3716
3497
  const peer = identity.id;
3717
- const resource = required(opts, 'resource');
3498
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3718
3499
  const taskId = intOpt(opts, 'task', null);
3719
3500
  const ttl = intOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
3720
3501
  const reason = opts.reason || '';
@@ -3722,66 +3503,73 @@ async function lockAcquire(ctx, args) {
3722
3503
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3723
3504
  const lock = tx(db, () => {
3724
3505
  const t = now();
3725
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3726
- if (existing && existing.expires_at > t && existing.owner !== peer) {
3727
- throw new CliError('LOCK_HELD', `Resource is locked by ${existing.owner}`, {
3728
- resource,
3729
- owner: existing.owner,
3730
- expires_at: iso(existing.expires_at)
3506
+ const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
3507
+ const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
3508
+ if (conflict) {
3509
+ throw new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
3510
+ resource: requested.base_resource,
3511
+ scope: requested.scope,
3512
+ lock_resource: conflict.resource,
3513
+ lock_scope: lockScope(conflict),
3514
+ owner: conflict.owner,
3515
+ expires_at: iso(conflict.expires_at)
3731
3516
  });
3732
3517
  }
3518
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3733
3519
  db.prepare(`
3734
- INSERT INTO locks(resource, owner, task_id, reason, expires_at, created_at)
3735
- VALUES (?, ?, ?, ?, ?, ?)
3520
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
3521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3736
3522
  ON CONFLICT(resource) DO UPDATE SET
3523
+ base_resource = excluded.base_resource,
3524
+ scope = excluded.scope,
3737
3525
  owner = excluded.owner,
3738
3526
  task_id = excluded.task_id,
3739
3527
  reason = excluded.reason,
3740
3528
  expires_at = excluded.expires_at
3741
- `).run(resource, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
3742
- addEvent(db, 'lock.acquired', peer, taskId, { resource, ttl, previous_owner: existing ? existing.owner : null });
3743
- return db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3529
+ `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
3530
+ addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
3531
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3744
3532
  });
3745
- printResult(ctx, lock, (data) => `locked ${data.resource} by ${data.owner} until ${iso(data.expires_at)}`);
3533
+ printResult(ctx, lock, (data) => `locked ${lockLabel(data)} by ${data.owner} until ${iso(data.expires_at)}`);
3746
3534
  }
3747
3535
 
3748
3536
  async function lockRelease(ctx, args) {
3749
3537
  const opts = parseOpts(args, { booleans: ['force'] });
3750
3538
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3751
3539
  const peer = identity.id;
3752
- const resource = required(opts, 'resource');
3540
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3753
3541
  const db = connect(ctx);
3754
3542
  touchCurrentPeer(db, ctx, identity, null, 'shell');
3755
3543
  const result = tx(db, () => {
3756
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3757
- if (!existing) return { released: false, resource };
3544
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3545
+ if (!existing) return { released: false, ...requested };
3758
3546
  if (existing.owner !== peer && !opts.force) {
3759
3547
  throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
3760
3548
  }
3761
- db.prepare('DELETE FROM locks WHERE resource = ?').run(resource);
3762
- addEvent(db, 'lock.released', peer, existing.task_id || null, { resource, force: Boolean(opts.force) });
3763
- return { released: true, resource };
3549
+ db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
3550
+ addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force: Boolean(opts.force) });
3551
+ return { released: true, ...requested };
3764
3552
  });
3765
- printResult(ctx, result, (data) => data.released ? `released ${data.resource}` : `no lock for ${data.resource}`);
3553
+ printResult(ctx, result, (data) => data.released ? `released ${lockLabel(data)}` : `no lock for ${lockLabel(data)}`);
3766
3554
  }
3767
3555
 
3768
3556
  async function lockRenew(ctx, args) {
3769
3557
  const opts = parseOpts(args);
3770
3558
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3771
3559
  const peer = identity.id;
3772
- const resource = required(opts, 'resource');
3560
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3773
3561
  const ttl = intOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
3774
3562
  const db = connect(ctx);
3775
3563
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3776
3564
  const lock = tx(db, () => {
3777
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3778
- if (!existing) throw new CliError('NOT_FOUND', `No lock for ${resource}`);
3565
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3566
+ if (!existing) throw new CliError('NOT_FOUND', `No lock for ${lockLabel(requested)}`);
3779
3567
  if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
3780
- db.prepare('UPDATE locks SET expires_at = ? WHERE resource = ?').run(now() + ttl, resource);
3781
- addEvent(db, 'lock.renewed', peer, existing.task_id || null, { resource, ttl });
3782
- return db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3568
+ db.prepare('UPDATE locks SET expires_at = ? WHERE resource = ?').run(now() + ttl, requested.resource);
3569
+ addEvent(db, 'lock.renewed', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl });
3570
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3783
3571
  });
3784
- printResult(ctx, lock, (data) => `renewed ${data.resource} until ${iso(data.expires_at)}`);
3572
+ printResult(ctx, lock, (data) => `renewed ${lockLabel(data)} until ${iso(data.expires_at)}`);
3785
3573
  }
3786
3574
 
3787
3575
  async function lockList(ctx, args) {
@@ -3791,7 +3579,8 @@ async function lockList(ctx, args) {
3791
3579
  ? db.prepare('SELECT * FROM locks ORDER BY resource ASC').all()
3792
3580
  : db.prepare('SELECT * FROM locks WHERE expires_at > ? ORDER BY resource ASC').all(now());
3793
3581
  printResult(ctx, rows, (data) => table(data, [
3794
- { label: 'resource', value: (r) => r.resource },
3582
+ { label: 'resource', value: (r) => lockBaseResource(r) },
3583
+ { label: 'scope', value: (r) => lockScope(r) },
3795
3584
  { label: 'owner', value: (r) => r.owner },
3796
3585
  { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
3797
3586
  { label: 'expires', value: (r) => iso(r.expires_at) },
@@ -3930,7 +3719,7 @@ async function cmdState(ctx, args) {
3930
3719
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3931
3720
  const peer = identity.id;
3932
3721
  const resources = normalizeStateResources(opts.resource || opts.resources || []);
3933
- const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null });
3722
+ const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null, scope: opts.scope || null });
3934
3723
  printResult(ctx, snapshot, (data) => {
3935
3724
  const automation = data.automation;
3936
3725
  const lines = [
@@ -3981,75 +3770,22 @@ Coordination rules:
3981
3770
  - Claim exactly one task before editing.
3982
3771
  - If state shows a current task for ${peer}, continue that task before claiming another pending task.
3983
3772
  - Before editing a file, directory, module, or shared test resource, run:
3984
- ${cmd} lock acquire --peer ${peer} --resource <path-or-module> --task <task-id>
3773
+ ${cmd} lock acquire --peer ${peer} --resource <path-or-module> [--scope <scope>] --task <task-id>
3985
3774
  - If a lock is held by another peer, message that peer instead of editing:
3986
3775
  ${cmd} msg send --from ${peer} --to <peer-id> --body "<question>"
3987
3776
  - Report progress or requests through msg send.
3988
3777
  - Before stopping, run tests, create a handoff, and release locks:
3989
3778
  ${cmd} handoff create --from ${peer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
3990
3779
  ${cmd} task done --peer ${peer} --id <task-id> --summary "<done summary>"
3991
- ${cmd} lock release --peer ${peer} --resource <path-or-module>
3780
+ ${cmd} lock release --peer ${peer} --resource <path-or-module> [--scope <scope>]
3992
3781
  `;
3993
3782
  printResult(ctx, { prompt: text }, () => text);
3994
3783
  }
3995
3784
 
3996
- function readRequestBody(req, limit = 1024 * 1024) {
3997
- return new Promise((resolve, reject) => {
3998
- let body = '';
3999
- req.on('data', (chunk) => {
4000
- body += chunk;
4001
- if (body.length > limit) {
4002
- reject(new CliError('REQUEST_TOO_LARGE', 'Request body is too large'));
4003
- req.destroy();
4004
- }
4005
- });
4006
- req.on('end', () => resolve(body));
4007
- req.on('error', reject);
4008
- });
4009
- }
4010
-
4011
- async function readJsonRequest(req) {
4012
- const body = await readRequestBody(req);
4013
- if (!body.trim()) return {};
4014
- return JSON.parse(body);
4015
- }
4016
-
4017
- function sendHttp(res, status, contentType, body) {
4018
- res.writeHead(status, {
4019
- 'Content-Type': contentType,
4020
- 'Cache-Control': 'no-store'
4021
- });
4022
- res.end(body);
4023
- }
4024
-
4025
- function sendJson(res, status, body) {
4026
- sendHttp(res, status, 'application/json; charset=utf-8', JSON.stringify(body, null, 2));
4027
- }
4028
-
4029
- function sendFile(res, filePath, contentType) {
4030
- try {
4031
- const body = fs.readFileSync(filePath);
4032
- res.writeHead(200, {
4033
- 'Content-Type': contentType,
4034
- 'Cache-Control': 'no-store'
4035
- });
4036
- res.end(body);
4037
- } catch {
4038
- sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Asset not found' } });
4039
- }
4040
- }
4041
-
4042
3785
  function isLoopbackHost(host) {
4043
3786
  return host === '127.0.0.1' || host === 'localhost' || host === '::1';
4044
3787
  }
4045
3788
 
4046
- function authOk(url, req, token) {
4047
- if (!token) return true;
4048
- const queryToken = url.searchParams.get('token');
4049
- const auth = req.headers.authorization || '';
4050
- return queryToken === token || auth === `Bearer ${token}`;
4051
- }
4052
-
4053
3789
  function defaultSessionCommand(kind) {
4054
3790
  if (kind === 'codex') return 'codex';
4055
3791
  if (kind === 'claude') return 'claude';
@@ -4404,7 +4140,7 @@ function webPeerHeartbeat(projectCtx, peer, input = {}) {
4404
4140
  };
4405
4141
  }
4406
4142
 
4407
- function claimNextTaskForPeer(db, peer, force = false) {
4143
+ function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {}) {
4408
4144
  return tx(db, () => {
4409
4145
  if (!force) {
4410
4146
  const current = db.prepare(`
@@ -4423,25 +4159,30 @@ function claimNextTaskForPeer(db, peer, force = false) {
4423
4159
  id ASC
4424
4160
  LIMIT 1
4425
4161
  `).get(peer);
4426
- if (current) return { ...current, current: true };
4162
+ if (current) return { current: { ...current, current: true }, tasks: [] };
4427
4163
  }
4428
- const row = db.prepare(`
4164
+ const rows = db.prepare(`
4429
4165
  SELECT * FROM tasks
4430
4166
  WHERE status = 'pending'
4431
4167
  AND owner IS NULL
4432
4168
  AND (assignee IS NULL OR assignee = ?)
4433
4169
  ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
4434
- LIMIT 1
4435
- `).get(peer, peer);
4436
- if (!row) return null;
4170
+ LIMIT ?
4171
+ `).all(peer, peer, count);
4172
+ if (!rows.length) return { current: null, tasks: [] };
4437
4173
  const t = now();
4438
- db.prepare(`
4439
- UPDATE tasks
4440
- SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
4441
- WHERE id = ? AND owner IS NULL AND status = 'pending'
4442
- `).run(peer, t, t, row.id);
4443
- addEvent(db, 'task.claimed', peer, row.id, { next: true });
4444
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
4174
+ const claimed = [];
4175
+ for (const row of rows) {
4176
+ const changes = db.prepare(`
4177
+ UPDATE tasks
4178
+ SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
4179
+ WHERE id = ? AND owner IS NULL AND status = 'pending'
4180
+ `).run(peer, t, t, row.id).changes;
4181
+ if (!changes) continue;
4182
+ addEvent(db, 'task.claimed', peer, row.id, { next: true, count });
4183
+ claimed.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id));
4184
+ }
4185
+ return { current: null, tasks: claimed };
4445
4186
  });
4446
4187
  }
4447
4188
 
@@ -4449,21 +4190,107 @@ function webPeerTaskNext(projectCtx, peer, input = {}) {
4449
4190
  const db = connect(projectCtx);
4450
4191
  try {
4451
4192
  touchPeer(db, peer, 'working');
4452
- const task = claimNextTaskForPeer(db, peer, Boolean(input.force));
4453
- if (!task) return { peer, task: null, summary: 'no pending task' };
4193
+ const count = positiveIntOpt(input, 'count', 1, { max: 50 });
4194
+ const result = claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count });
4195
+ const task = result.current || result.tasks[0] || null;
4196
+ if (!task) return { peer, task: null, tasks: [], summary: 'no pending task' };
4454
4197
  return {
4455
4198
  peer,
4456
4199
  task,
4457
- current: Boolean(task.current),
4458
- summary: task.current
4200
+ tasks: result.tasks,
4201
+ current: Boolean(result.current),
4202
+ summary: result.current
4459
4203
  ? `current task #${task.id}: ${task.title} (${task.status})`
4460
- : `claimed task #${task.id}: ${task.title}`
4204
+ : result.tasks.length === 1
4205
+ ? `claimed task #${task.id}: ${task.title}`
4206
+ : `claimed ${result.tasks.length} tasks`
4461
4207
  };
4462
4208
  } finally {
4463
4209
  db.close();
4464
4210
  }
4465
4211
  }
4466
4212
 
4213
+ function webPeerTaskTakeover(projectCtx, peer, input = {}) {
4214
+ const id = intOpt(input, 'id', intOpt({ id: input.task }, 'id'));
4215
+ if (!id) throw new CliError('BAD_REQUEST', 'task id required');
4216
+ const reason = required(input, 'reason');
4217
+ const policy = input.policy || 'any';
4218
+ const staleAfter = positiveIntOpt(input, 'stale-after', intOpt(input, 'stale_after', ACTIVE_PEER_TTL), { max: 86400 * 30 });
4219
+ const db = connect(projectCtx);
4220
+ try {
4221
+ touchPeer(db, peer, 'working');
4222
+ const task = takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: 'web' });
4223
+ return { peer, task, summary: `took over task #${task.id}: ${task.title}` };
4224
+ } finally {
4225
+ db.close();
4226
+ }
4227
+ }
4228
+
4229
+ function webPeerLockAcquire(projectCtx, peer, input = {}) {
4230
+ const requested = scopedLockResource(required(input, 'resource'), input.scope);
4231
+ const ttl = intOpt(input, 'ttl', DEFAULT_LOCK_TTL);
4232
+ const taskId = intOpt(input, 'task', intOpt(input, 'task_id', null));
4233
+ const reason = input.reason || '';
4234
+ const db = connect(projectCtx);
4235
+ try {
4236
+ touchPeer(db, peer, 'working');
4237
+ const lock = tx(db, () => {
4238
+ const t = now();
4239
+ const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
4240
+ const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
4241
+ if (conflict) {
4242
+ throw new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
4243
+ resource: requested.base_resource,
4244
+ scope: requested.scope,
4245
+ lock_resource: conflict.resource,
4246
+ lock_scope: lockScope(conflict),
4247
+ owner: conflict.owner,
4248
+ expires_at: iso(conflict.expires_at)
4249
+ });
4250
+ }
4251
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
4252
+ db.prepare(`
4253
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
4254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4255
+ ON CONFLICT(resource) DO UPDATE SET
4256
+ base_resource = excluded.base_resource,
4257
+ scope = excluded.scope,
4258
+ owner = excluded.owner,
4259
+ task_id = excluded.task_id,
4260
+ reason = excluded.reason,
4261
+ expires_at = excluded.expires_at
4262
+ `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
4263
+ addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null, source: 'web' });
4264
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
4265
+ });
4266
+ return { peer, lock, summary: `locked ${lockLabel(lock)} by ${lock.owner} until ${iso(lock.expires_at)}` };
4267
+ } finally {
4268
+ db.close();
4269
+ }
4270
+ }
4271
+
4272
+ function webPeerLockRelease(projectCtx, peer, input = {}) {
4273
+ const requested = scopedLockResource(required(input, 'resource'), input.scope);
4274
+ const force = Boolean(input.force);
4275
+ const db = connect(projectCtx);
4276
+ try {
4277
+ touchPeer(db, peer, null);
4278
+ const result = tx(db, () => {
4279
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
4280
+ if (!existing) return { released: false, ...requested };
4281
+ if (existing.owner !== peer && !force) {
4282
+ throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
4283
+ }
4284
+ db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
4285
+ addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force, source: 'web' });
4286
+ return { released: true, ...requested };
4287
+ });
4288
+ return { peer, result, summary: result.released ? `released ${lockLabel(result)}` : `no lock for ${lockLabel(result)}` };
4289
+ } finally {
4290
+ db.close();
4291
+ }
4292
+ }
4293
+
4467
4294
  function webPeerInbox(projectCtx, peer, input = {}) {
4468
4295
  const db = connect(projectCtx);
4469
4296
  try {
@@ -4488,7 +4315,8 @@ function webPeerAction(projectCtx, peer, action, input = {}) {
4488
4315
  if (normalized === 'state') {
4489
4316
  const data = statusSnapshot(projectCtx, peer, {
4490
4317
  resources: normalizeStateResources(input.resource || input.resources || []),
4491
- intent: input.intent || null
4318
+ intent: input.intent || null,
4319
+ scope: input.scope || null
4492
4320
  });
4493
4321
  return { ok: true, action: normalized, peer, summary: data.automation?.next_action?.reason || 'state loaded', data };
4494
4322
  }
@@ -4500,6 +4328,18 @@ function webPeerAction(projectCtx, peer, action, input = {}) {
4500
4328
  const data = webPeerTaskNext(projectCtx, peer, input);
4501
4329
  return { ok: true, action: normalized, peer, summary: data.summary, data };
4502
4330
  }
4331
+ if (normalized === 'task-takeover') {
4332
+ const data = webPeerTaskTakeover(projectCtx, peer, input);
4333
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4334
+ }
4335
+ if (normalized === 'lock-acquire') {
4336
+ const data = webPeerLockAcquire(projectCtx, peer, input);
4337
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4338
+ }
4339
+ if (normalized === 'lock-release') {
4340
+ const data = webPeerLockRelease(projectCtx, peer, input);
4341
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4342
+ }
4503
4343
  if (normalized === 'heartbeat') {
4504
4344
  const data = webPeerHeartbeat(projectCtx, peer, input);
4505
4345
  return { ok: true, action: normalized, peer, summary: data.summary, data };
@@ -4511,1779 +4351,6 @@ function webPeerAction(projectCtx, peer, action, input = {}) {
4511
4351
  throw new CliError('BAD_REQUEST', `Unknown peer action: ${action}`);
4512
4352
  }
4513
4353
 
4514
- function webIndexHtml() {
4515
- return `<!doctype html>
4516
- <html lang="en">
4517
- <head>
4518
- <meta charset="utf-8">
4519
- <meta name="viewport" content="width=device-width, initial-scale=1">
4520
- <title>hello-cc</title>
4521
- <link rel="stylesheet" href="/assets/xterm.css">
4522
- <style>
4523
- :root {
4524
- color-scheme: dark;
4525
- --bg: #101214;
4526
- --panel: #181b1f;
4527
- --panel-2: #20242a;
4528
- --border: #303640;
4529
- --text: #eef2f6;
4530
- --muted: #a3adba;
4531
- --accent: #40c4aa;
4532
- --warn: #f2bb4f;
4533
- --danger: #ff6b6b;
4534
- --ok: #75d17c;
4535
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
4536
- --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
4537
- }
4538
- * { box-sizing: border-box; }
4539
- body {
4540
- margin: 0;
4541
- min-height: 100vh;
4542
- background: var(--bg);
4543
- color: var(--text);
4544
- font-family: var(--sans);
4545
- overflow: hidden;
4546
- }
4547
- button, input, select {
4548
- font: inherit;
4549
- }
4550
- button {
4551
- border: 1px solid var(--border);
4552
- background: var(--panel-2);
4553
- color: var(--text);
4554
- height: 32px;
4555
- border-radius: 6px;
4556
- padding: 0 10px;
4557
- cursor: pointer;
4558
- }
4559
- button:hover { border-color: var(--accent); }
4560
- button.primary { background: #1b5f54; border-color: #267f71; }
4561
- button.danger { background: #5d252a; border-color: #8c333d; }
4562
- input, select {
4563
- width: 100%;
4564
- height: 32px;
4565
- border: 1px solid var(--border);
4566
- background: #0d0f12;
4567
- color: var(--text);
4568
- border-radius: 6px;
4569
- padding: 0 9px;
4570
- min-width: 0;
4571
- }
4572
- label {
4573
- display: grid;
4574
- gap: 4px;
4575
- color: var(--muted);
4576
- font-size: 12px;
4577
- }
4578
- .app {
4579
- position: relative;
4580
- height: 100vh;
4581
- display: grid;
4582
- --left-width: 320px;
4583
- --right-width: 360px;
4584
- grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
4585
- min-width: 640px;
4586
- transition: grid-template-columns .18s ease;
4587
- }
4588
- .app.resizing { transition: none; }
4589
- .app.left-collapsed { grid-template-columns: 0 minmax(0, 1fr) var(--right-width); }
4590
- .app.right-collapsed { grid-template-columns: var(--left-width) minmax(0, 1fr) 0; }
4591
- .app.left-collapsed.right-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
4592
- /* Hide sidebar borders when collapsed so no 1px seam remains. */
4593
- .app.left-collapsed .sidebar { border-right-width: 0; }
4594
- .app.right-collapsed .inspector { border-left-width: 0; }
4595
- .edge-resizer {
4596
- position: absolute;
4597
- top: 0;
4598
- bottom: 0;
4599
- z-index: 55;
4600
- width: 12px;
4601
- cursor: col-resize;
4602
- touch-action: none;
4603
- user-select: none;
4604
- background: transparent;
4605
- transition: left .18s ease, right .18s ease, background .12s;
4606
- }
4607
- .edge-resizer:hover,
4608
- .app.resizing .edge-resizer {
4609
- background: rgba(126, 231, 215, .08);
4610
- }
4611
- .app.resizing .edge-resizer { transition: none; }
4612
- .edge-resizer-left { left: var(--left-width); transform: translateX(-50%); }
4613
- .edge-resizer-right { right: var(--right-width); transform: translateX(50%); }
4614
- .app.left-collapsed .edge-resizer-left { left: 0; }
4615
- .app.right-collapsed .edge-resizer-right { right: 0; }
4616
- /* Small collapse handles centered vertically on each divider border.
4617
- They are children of .app (no overflow clip) and track the column edge. */
4618
- .edge-toggle {
4619
- position: absolute;
4620
- top: 50%;
4621
- z-index: 60;
4622
- width: 16px;
4623
- height: 44px;
4624
- padding: 0;
4625
- display: flex;
4626
- align-items: center;
4627
- justify-content: center;
4628
- font-size: 11px;
4629
- line-height: 1;
4630
- color: var(--muted);
4631
- background: var(--panel);
4632
- border: 1px solid var(--border);
4633
- border-radius: 6px;
4634
- cursor: col-resize;
4635
- touch-action: none;
4636
- user-select: none;
4637
- transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
4638
- }
4639
- .app.resizing .edge-toggle { transition: none; }
4640
- .edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
4641
- .edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
4642
- .edge-right { right: var(--right-width); transform: translate(50%, -50%); }
4643
- .app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
4644
- .app.right-collapsed .edge-right { right: 0; transform: translate(0, -50%); }
4645
- .sidebar, .inspector {
4646
- min-height: 0;
4647
- min-width: 0;
4648
- max-width: 100%;
4649
- overflow: hidden;
4650
- background: var(--panel);
4651
- border-right: 1px solid var(--border);
4652
- display: grid;
4653
- grid-template-rows: auto auto auto auto 1fr;
4654
- }
4655
- .inspector {
4656
- border-right: 0;
4657
- border-left: 1px solid var(--border);
4658
- grid-template-rows: auto 1fr;
4659
- }
4660
- .sidebar > *, .inspector > * {
4661
- min-width: 0;
4662
- max-width: 100%;
4663
- }
4664
- .brand {
4665
- padding: 14px;
4666
- border-bottom: 1px solid var(--border);
4667
- display: flex;
4668
- align-items: center;
4669
- justify-content: space-between;
4670
- gap: 10px;
4671
- }
4672
- .brand > div {
4673
- min-width: 0;
4674
- }
4675
- .brand strong { font-size: 15px; }
4676
- .brand span, .path {
4677
- color: var(--muted);
4678
- font-family: var(--mono);
4679
- font-size: 11px;
4680
- overflow: hidden;
4681
- text-overflow: ellipsis;
4682
- white-space: nowrap;
4683
- }
4684
- #connState {
4685
- flex: 0 0 92px;
4686
- max-width: 92px;
4687
- text-align: right;
4688
- }
4689
- .form {
4690
- padding: 12px;
4691
- display: grid;
4692
- gap: 9px;
4693
- border-bottom: 1px solid var(--border);
4694
- }
4695
- .grid2 {
4696
- display: grid;
4697
- grid-template-columns: 1fr 1fr;
4698
- gap: 8px;
4699
- }
4700
- .start-row {
4701
- display: grid;
4702
- grid-template-columns: 1fr auto;
4703
- gap: 8px;
4704
- align-items: end;
4705
- }
4706
- .start-options {
4707
- display: grid;
4708
- grid-template-columns: 1fr 1fr;
4709
- gap: 8px;
4710
- }
4711
- .start-options label[data-resume-field] {
4712
- grid-column: 1 / -1;
4713
- }
4714
- .session-header {
4715
- border-bottom: 1px solid var(--border);
4716
- padding: 8px 10px;
4717
- display: flex;
4718
- align-items: center;
4719
- justify-content: space-between;
4720
- gap: 8px;
4721
- min-height: 48px;
4722
- }
4723
- .session-header strong {
4724
- font-size: 13px;
4725
- font-weight: 600;
4726
- }
4727
- .session-header label {
4728
- width: 118px;
4729
- }
4730
- .sessions, .state {
4731
- min-height: 0;
4732
- overflow-y: auto;
4733
- overflow-x: hidden;
4734
- padding: 10px;
4735
- display: grid;
4736
- grid-template-columns: minmax(0, 1fr);
4737
- align-content: start;
4738
- gap: 8px;
4739
- scrollbar-width: thin;
4740
- scrollbar-color: #3a3f4a transparent;
4741
- }
4742
- .sessions::-webkit-scrollbar, .state::-webkit-scrollbar { width: 8px; }
4743
- .sessions::-webkit-scrollbar-track, .state::-webkit-scrollbar-track { background: transparent; }
4744
- .sessions::-webkit-scrollbar-thumb, .state::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
4745
- .session {
4746
- border: 1px solid var(--border);
4747
- background: #111418;
4748
- border-radius: 8px;
4749
- padding: 9px;
4750
- display: grid;
4751
- grid-template-columns: minmax(0, 1fr);
4752
- gap: 7px;
4753
- cursor: pointer;
4754
- }
4755
- .session.active { border-color: var(--accent); }
4756
- .row {
4757
- display: flex;
4758
- align-items: center;
4759
- justify-content: space-between;
4760
- gap: 8px;
4761
- min-width: 0;
4762
- }
4763
- .row strong {
4764
- overflow: hidden;
4765
- text-overflow: ellipsis;
4766
- white-space: nowrap;
4767
- }
4768
- .badge {
4769
- font-family: var(--mono);
4770
- font-size: 11px;
4771
- border: 1px solid var(--border);
4772
- color: var(--muted);
4773
- padding: 2px 6px;
4774
- border-radius: 999px;
4775
- white-space: nowrap;
4776
- }
4777
- .badge.running { color: var(--ok); border-color: #3b7b44; }
4778
- .badge.working, .badge.busy { color: var(--warn); border-color: #6b5a20; }
4779
- .badge.stale, .badge.detached, .badge.idle { color: var(--muted); border-color: var(--border); }
4780
- .badge.exited { color: var(--danger); border-color: #87434a; }
4781
- .main {
4782
- min-height: 0;
4783
- min-width: 0;
4784
- overflow: hidden;
4785
- display: grid;
4786
- grid-template-rows: auto 1fr;
4787
- background: #0b0d10;
4788
- }
4789
- .toolbar {
4790
- min-height: 48px;
4791
- border-bottom: 1px solid var(--border);
4792
- display: flex;
4793
- align-items: center;
4794
- gap: 8px;
4795
- padding: 8px 10px;
4796
- overflow: hidden;
4797
- }
4798
- .toolbar .title {
4799
- flex: 1 1 auto;
4800
- min-width: 0;
4801
- display: grid;
4802
- gap: 2px;
4803
- }
4804
- .toolbar .title strong {
4805
- overflow: hidden;
4806
- text-overflow: ellipsis;
4807
- white-space: nowrap;
4808
- }
4809
- .quick {
4810
- display: flex;
4811
- gap: 8px;
4812
- flex-wrap: nowrap;
4813
- align-items: center;
4814
- flex: 0 0 auto;
4815
- }
4816
- /* Toolbar edge toggles for collapsing the side panels. */
4817
- .icon-btn {
4818
- flex: 0 0 auto;
4819
- width: 30px;
4820
- height: 30px;
4821
- padding: 0;
4822
- display: inline-flex;
4823
- align-items: center;
4824
- justify-content: center;
4825
- font-size: 15px;
4826
- line-height: 1;
4827
- color: var(--muted);
4828
- background: transparent;
4829
- border: 1px solid var(--border);
4830
- border-radius: 7px;
4831
- cursor: pointer;
4832
- }
4833
- .icon-btn:hover { color: var(--text); border-color: var(--accent); }
4834
- /* Compact actions dropdown so the top bar stays a single tidy row. */
4835
- .menu-wrap { position: relative; display: inline-flex; }
4836
- .menu-btn { white-space: nowrap; }
4837
- .menu {
4838
- position: fixed;
4839
- z-index: 1000;
4840
- min-width: 150px;
4841
- padding: 5px;
4842
- display: grid;
4843
- gap: 2px;
4844
- background: var(--panel);
4845
- border: 1px solid var(--border);
4846
- border-radius: 8px;
4847
- box-shadow: 0 8px 24px rgba(0,0,0,.45);
4848
- }
4849
- .menu[hidden] { display: none; }
4850
- .menu button {
4851
- width: 100%;
4852
- text-align: left;
4853
- border: 0;
4854
- background: transparent;
4855
- padding: 7px 9px;
4856
- border-radius: 6px;
4857
- color: var(--text);
4858
- }
4859
- .menu button:hover { background: #1b1f26; }
4860
- .menu .divider {
4861
- height: 1px;
4862
- margin: 4px 2px;
4863
- background: var(--border);
4864
- }
4865
- .action-result {
4866
- position: fixed;
4867
- right: 16px;
4868
- top: 64px;
4869
- z-index: 1200;
4870
- width: min(520px, calc(100vw - 32px));
4871
- max-height: min(68vh, 640px);
4872
- display: grid;
4873
- grid-template-rows: auto minmax(0, 1fr);
4874
- background: var(--panel);
4875
- border: 1px solid var(--border);
4876
- border-radius: 8px;
4877
- box-shadow: 0 12px 32px rgba(0,0,0,.5);
4878
- overflow: hidden;
4879
- }
4880
- .action-result[hidden] { display: none; }
4881
- .action-result header {
4882
- display: flex;
4883
- align-items: center;
4884
- justify-content: space-between;
4885
- gap: 10px;
4886
- padding: 9px 10px;
4887
- border-bottom: 1px solid var(--border);
4888
- }
4889
- .action-result header strong { font-size: 13px; }
4890
- .action-result pre {
4891
- margin: 0;
4892
- padding: 10px;
4893
- overflow: auto;
4894
- white-space: pre-wrap;
4895
- word-break: break-word;
4896
- font-family: var(--mono);
4897
- font-size: 11px;
4898
- color: var(--text);
4899
- background: #0d0f12;
4900
- }
4901
- #terminal {
4902
- min-height: 0;
4903
- overflow: hidden;
4904
- padding: 8px;
4905
- }
4906
- #terminal .xterm {
4907
- cursor: default;
4908
- }
4909
- /* The terminal mirrors a tmux pane; keep xterm's own hidden helper textarea
4910
- off-screen, but DO show the rendered block cursor (positioned from tmux). */
4911
- #terminal .xterm-helper-textarea {
4912
- caret-color: transparent !important;
4913
- color: transparent !important;
4914
- background: transparent !important;
4915
- left: -10000px !important;
4916
- top: 0 !important;
4917
- width: 1px !important;
4918
- height: 1px !important;
4919
- opacity: 0 !important;
4920
- }
4921
- .card {
4922
- border: 1px solid var(--border);
4923
- border-radius: 8px;
4924
- background: #111418;
4925
- overflow: hidden;
4926
- }
4927
- .card.state-card {
4928
- min-height: 0;
4929
- max-height: min(34vh, 280px);
4930
- display: grid;
4931
- grid-template-rows: auto minmax(0, 1fr);
4932
- }
4933
- .card.state-card.state-card-collapsed {
4934
- grid-template-rows: auto 0;
4935
- }
4936
- .card h2 {
4937
- margin: 0;
4938
- padding: 8px 10px;
4939
- border-bottom: 1px solid var(--border);
4940
- font-size: 13px;
4941
- font-weight: 600;
4942
- display: flex;
4943
- justify-content: space-between;
4944
- align-items: center;
4945
- gap: 8px;
4946
- }
4947
- .state-card-toggle {
4948
- width: 100%;
4949
- min-width: 0;
4950
- border: 0;
4951
- border-bottom: 1px solid var(--border);
4952
- border-radius: 0;
4953
- background: transparent;
4954
- color: inherit;
4955
- padding: 8px 10px;
4956
- display: flex;
4957
- align-items: center;
4958
- justify-content: space-between;
4959
- gap: 8px;
4960
- cursor: pointer;
4961
- text-align: left;
4962
- }
4963
- .state-card-toggle:hover { background: #151922; }
4964
- .state-card-toggle-title {
4965
- min-width: 0;
4966
- display: flex;
4967
- align-items: center;
4968
- gap: 8px;
4969
- overflow: hidden;
4970
- }
4971
- .state-card-toggle-title strong {
4972
- overflow: hidden;
4973
- text-overflow: ellipsis;
4974
- white-space: nowrap;
4975
- }
4976
- .state-card-chevron {
4977
- flex: 0 0 auto;
4978
- color: var(--muted);
4979
- font-size: 12px;
4980
- line-height: 1;
4981
- }
4982
- .state-card.state-card-collapsed .state-card-chevron {
4983
- transform: rotate(-90deg);
4984
- }
4985
- .card .body {
4986
- padding: 8px 10px;
4987
- display: grid;
4988
- gap: 6px;
4989
- }
4990
- .state-card .body {
4991
- min-height: 0;
4992
- max-height: min(28vh, 228px);
4993
- overflow-y: auto;
4994
- overflow-x: hidden;
4995
- align-content: start;
4996
- scrollbar-width: thin;
4997
- scrollbar-color: #3a3f4a transparent;
4998
- }
4999
- .state-card.state-card-collapsed .body {
5000
- display: none;
5001
- }
5002
- .state-card .body::-webkit-scrollbar { width: 8px; }
5003
- .state-card .body::-webkit-scrollbar-track { background: transparent; }
5004
- .state-card .body::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
5005
- .item {
5006
- display: grid;
5007
- gap: 2px;
5008
- font-size: 12px;
5009
- color: var(--muted);
5010
- border-bottom: 1px solid #242932;
5011
- padding-bottom: 6px;
5012
- }
5013
- .item:last-child { border-bottom: 0; padding-bottom: 0; }
5014
- .item strong { color: var(--text); font-size: 12px; }
5015
- .item span { overflow-wrap: anywhere; }
5016
- .mono { font-family: var(--mono); }
5017
- .empty { color: var(--muted); font-size: 12px; }
5018
- .sec-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 6px 10px 2px; display: flex; align-items: center; gap: 6px; }
5019
- #terminal { display: flex; flex-direction: column; }
5020
- </style>
5021
- </head>
5022
- <body>
5023
- <div class="app">
5024
- <aside class="sidebar">
5025
- <div class="brand">
5026
- <div>
5027
- <strong>hello-cc</strong>
5028
- <div class="path" id="rootPath"></div>
5029
- </div>
5030
- <span id="connState" data-i18n="conn.offline">offline</span>
5031
- </div>
5032
- <div class="form" style="padding-top:10px;padding-bottom:10px">
5033
- <div class="grid2">
5034
- <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
5035
- <label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
5036
- </div>
5037
- <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
5038
- <button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
5039
- </div>
5040
- <form class="form" id="startForm">
5041
- <div class="start-row">
5042
- <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>
5043
- <button class="primary" type="submit" data-i18n="start">Start</button>
5044
- </div>
5045
- <div class="start-options">
5046
- <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>
5047
- <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
5048
- <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>
5049
- </div>
5050
- </form>
5051
- <div class="session-header">
5052
- <strong data-i18n="sessions">Sessions</strong>
5053
- <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>
5054
- </div>
5055
- <div class="sessions" id="sessions"></div>
5056
- </aside>
5057
-
5058
- <main class="main">
5059
- <div class="toolbar">
5060
- <div class="title">
5061
- <strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
5062
- <span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
5063
- </div>
5064
- <div class="quick" id="quickBar">
5065
- <div class="menu-wrap">
5066
- <button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
5067
- <div class="menu" id="actionsMenu" hidden>
5068
- <button data-action="state" data-i18n="action.state">state</button>
5069
- <button data-action="status" data-i18n="action.status">status</button>
5070
- <button data-action="inbox" data-i18n="action.inbox">inbox</button>
5071
- <button data-action="task-next" data-i18n="action.claimNextTask">claim next task</button>
5072
- <button data-action="heartbeat" data-i18n="action.renewHeartbeat">renew heartbeat</button>
5073
- <div class="divider" role="separator"></div>
5074
- <button data-action="register" data-i18n="action.reregister">re-register peer</button>
5075
- <button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
5076
- </div>
5077
- </div>
5078
- <button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
5079
- </div>
5080
- </div>
5081
- <div id="terminal" style="min-height:0;flex:1"></div>
5082
- <div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
5083
- </main>
5084
-
5085
- <section class="action-result" id="actionResult" hidden aria-live="polite">
5086
- <header>
5087
- <strong id="actionResultTitle"></strong>
5088
- <button id="actionResultClose" type="button" aria-label="Close">×</button>
5089
- </header>
5090
- <pre id="actionResultBody"></pre>
5091
- </section>
5092
-
5093
- <aside class="inspector">
5094
- <div class="brand">
5095
- <strong data-i18n="projectState">Project State</strong>
5096
- <button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
5097
- </div>
5098
- <div class="state" id="state"></div>
5099
- </aside>
5100
-
5101
- <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>
5102
- <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>
5103
- <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>
5104
- <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>
5105
- </div>
5106
-
5107
- <script src="/assets/xterm.js"></script>
5108
- <script>
5109
- const initialParams = new URLSearchParams(location.search);
5110
- const token = initialParams.get('token') || '';
5111
- const headers = token ? { Authorization: 'Bearer ' + token } : {};
5112
- let currentProject = initialParams.get('project') || initialParams.get('root') || '';
5113
- let projects = [];
5114
- let sessionKindFilter = initialParams.get('kind') || 'all';
5115
- let sessions = []; // managed (PTY) sessions
5116
- let detected = []; // coordination-only peers (from hooks/watcher)
5117
- let resumableCache = []; // provider sessions available to resume (from /api/resumable)
5118
- let active = null; // active managed session id
5119
- let activeDetected = null; // active detected peer id
5120
- let activeType = 'managed'; // 'managed' | 'detected'
5121
- let activePeerTtl = 600;
5122
- let lastStateNow = 0;
5123
- let lastStateRoot = '';
5124
- let ws = null;
5125
- let wsReconnectTimer = null;
5126
- const i18n = {
5127
- en: {
5128
- language: 'Language',
5129
- project: 'Project',
5130
- projectPath: 'Project path',
5131
- projectPathPlaceholder: '/path/to/project',
5132
- registerProject: 'Register Project',
5133
- newSession: 'New session',
5134
- start: 'Start',
5135
- mode: 'Mode',
5136
- 'mode.new': 'new',
5137
- 'mode.resume': 'resume',
5138
- 'mode.last': 'last',
5139
- 'mode.continue': 'continue',
5140
- session: 'Session',
5141
- sessionId: 'Session id',
5142
- sessionIdPlaceholder: 'session id or name',
5143
- sessions: 'Sessions',
5144
- view: 'View',
5145
- 'kind.all': 'all',
5146
- 'kind.shell': 'shell',
5147
- 'kind.other': 'other',
5148
- noSessionSelected: 'No session selected',
5149
- startOrSelect: 'Start or select a session from the left panel',
5150
- actions: 'Actions',
5151
- 'action.state': 'state',
5152
- 'action.inbox': 'inbox',
5153
- 'action.claimNextTask': 'claim next task',
5154
- 'action.status': 'status',
5155
- 'action.renewHeartbeat': 'renew heartbeat',
5156
- 'action.reregister': 're-register peer',
5157
- 'action.runStatusTerminal': 'run status in terminal',
5158
- actionResult: 'Action Result',
5159
- stop: 'stop',
5160
- projectState: 'Project State',
5161
- refresh: 'Refresh',
5162
- managed: 'Managed',
5163
- detected: 'Detected',
5164
- running: 'running',
5165
- noActiveSessions: 'No active sessions.',
5166
- startOneAbove: 'Start one above',
5167
- orRunTerminal: 'or run in any terminal:',
5168
- noDetectedPeers: 'No detected peers.',
5169
- viewFilter: 'View filter',
5170
- noTasks: 'No tasks.',
5171
- noPeers: 'No peers.',
5172
- noActiveLocks: 'No active locks.',
5173
- noMessages: 'No messages.',
5174
- noTimelineItems: 'No timeline items.',
5175
- nextAction: 'Next Action',
5176
- timeline: 'Timeline',
5177
- messages: 'Messages',
5178
- peers: 'Peers',
5179
- tasks: 'Tasks',
5180
- locks: 'Locks',
5181
- owner: 'owner',
5182
- assignee: 'assignee',
5183
- task: 'task',
5184
- all: 'all',
5185
- next: 'next',
5186
- finish: 'finish',
5187
- warnings: 'warnings',
5188
- noImmediateAction: 'No immediate coordination action',
5189
- detectedSession: 'Detected Session',
5190
- sendMessage: 'Send Message',
5191
- messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
5192
- messageBodyPlaceholder: 'Message body...',
5193
- send: 'Send',
5194
- peer: 'peer',
5195
- kind: 'kind',
5196
- status: 'status',
5197
- cwd: 'cwd',
5198
- pid: 'pid',
5199
- unknown: 'unknown',
5200
- lastSeen: 'last seen',
5201
- secondsAgo: 's ago',
5202
- age: 'age',
5203
- branch: 'branch',
5204
- runtime: 'runtime',
5205
- thread: 'thread',
5206
- reply: 'reply',
5207
- 'status.active': 'active',
5208
- 'status.stale': 'stale',
5209
- 'status.unknown': 'unknown',
5210
- 'status.running': 'running',
5211
- 'status.idle': 'idle',
5212
- 'status.exited': 'exited',
5213
- 'status.claimed': 'claimed',
5214
- 'status.review': 'review',
5215
- 'status.blocked': 'blocked',
5216
- 'status.done': 'done',
5217
- 'status.abandoned': 'abandoned',
5218
- 'status.pending': 'pending',
5219
- 'conn.offline': 'offline',
5220
- 'conn.online': 'online',
5221
- 'conn.attached': 'attached',
5222
- 'conn.reconnecting': 'reconnecting...',
5223
- 'conn.coordinationOnly': 'coordination only',
5224
- 'conn.error': 'error',
5225
- show: 'Show',
5226
- collapse: 'Collapse',
5227
- sidebar: 'sidebar',
5228
- statePanel: 'state panel',
5229
- dragToResize: 'drag to resize',
5230
- collapseSidebar: 'Collapse sidebar',
5231
- collapseStatePanel: 'Collapse state panel',
5232
- toggleLeftSidebar: 'Toggle left sidebar',
5233
- toggleRightPanel: 'Toggle right panel',
5234
- resizeLeftSidebar: 'Resize left sidebar',
5235
- resizeRightPanel: 'Resize right panel',
5236
- customSession: 'custom session id...'
5237
- },
5238
- zh: {
5239
- language: '语言',
5240
- project: '项目',
5241
- projectPath: '项目路径',
5242
- projectPathPlaceholder: '/项目/路径',
5243
- registerProject: '注册项目',
5244
- newSession: '新会话',
5245
- start: '启动',
5246
- mode: '模式',
5247
- 'mode.new': '新建',
5248
- 'mode.resume': '恢复',
5249
- 'mode.last': '最近',
5250
- 'mode.continue': '继续',
5251
- session: '会话',
5252
- sessionId: '会话 ID',
5253
- sessionIdPlaceholder: '会话 ID 或名称',
5254
- sessions: '会话',
5255
- view: '视图',
5256
- 'kind.all': '全部',
5257
- 'kind.shell': 'shell',
5258
- 'kind.other': '其他',
5259
- noSessionSelected: '未选择会话',
5260
- startOrSelect: '从左侧面板启动或选择一个会话',
5261
- actions: '操作',
5262
- 'action.state': '状态详情',
5263
- 'action.inbox': '收件箱',
5264
- 'action.claimNextTask': '认领下个任务',
5265
- 'action.status': '状态',
5266
- 'action.renewHeartbeat': '续期心跳',
5267
- 'action.reregister': '重新注册协作方',
5268
- 'action.runStatusTerminal': '在终端运行状态命令',
5269
- actionResult: '操作结果',
5270
- stop: '停止',
5271
- projectState: '项目状态',
5272
- refresh: '刷新',
5273
- managed: '托管',
5274
- detected: '检测到',
5275
- running: '运行中',
5276
- noActiveSessions: '没有活动会话。',
5277
- startOneAbove: '在上方启动一个会话',
5278
- orRunTerminal: '或在任意终端运行:',
5279
- noDetectedPeers: '没有检测到的协作方。',
5280
- viewFilter: '视图过滤',
5281
- noTasks: '没有任务。',
5282
- noPeers: '没有协作方。',
5283
- noActiveLocks: '没有活动锁。',
5284
- noMessages: '没有消息。',
5285
- noTimelineItems: '没有时间线条目。',
5286
- nextAction: '下一步',
5287
- timeline: '时间线',
5288
- messages: '消息',
5289
- peers: '协作方',
5290
- tasks: '任务',
5291
- locks: '锁',
5292
- owner: '负责人',
5293
- assignee: '执行者',
5294
- task: '任务',
5295
- all: '全部',
5296
- next: '下一步',
5297
- finish: '收尾',
5298
- warnings: '警告',
5299
- noImmediateAction: '当前没有需要立即处理的协作动作',
5300
- detectedSession: '检测到的会话',
5301
- sendMessage: '发送消息',
5302
- messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
5303
- messageBodyPlaceholder: '消息内容...',
5304
- send: '发送',
5305
- peer: '协作方',
5306
- kind: '类型',
5307
- status: '状态',
5308
- cwd: '工作目录',
5309
- pid: 'PID',
5310
- unknown: '未知',
5311
- lastSeen: '最后出现',
5312
- secondsAgo: ' 秒前',
5313
- age: '时长',
5314
- branch: '分支',
5315
- runtime: '运行时',
5316
- thread: '线程',
5317
- reply: '回复',
5318
- 'status.active': '活跃',
5319
- 'status.stale': '过期',
5320
- 'status.unknown': '未知',
5321
- 'status.running': '运行中',
5322
- 'status.idle': '空闲',
5323
- 'status.exited': '已退出',
5324
- 'status.claimed': '已认领',
5325
- 'status.review': '审查中',
5326
- 'status.blocked': '阻塞',
5327
- 'status.done': '完成',
5328
- 'status.abandoned': '已放弃',
5329
- 'status.pending': '待处理',
5330
- 'conn.offline': '离线',
5331
- 'conn.online': '在线',
5332
- 'conn.attached': '已连接',
5333
- 'conn.reconnecting': '重连中...',
5334
- 'conn.coordinationOnly': '仅协作',
5335
- 'conn.error': '错误',
5336
- show: '显示',
5337
- collapse: '折叠',
5338
- sidebar: '侧栏',
5339
- statePanel: '状态面板',
5340
- dragToResize: '拖动调整大小',
5341
- collapseSidebar: '折叠侧栏',
5342
- collapseStatePanel: '折叠状态面板',
5343
- toggleLeftSidebar: '切换左侧栏',
5344
- toggleRightPanel: '切换右侧面板',
5345
- resizeLeftSidebar: '调整左侧栏宽度',
5346
- resizeRightPanel: '调整右侧面板宽度',
5347
- customSession: '自定义会话 ID...'
5348
- }
5349
- };
5350
- let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
5351
- if (!i18n[lang]) lang = 'en';
5352
-
5353
- function tr(key, fallback = '') {
5354
- return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
5355
- }
5356
-
5357
- function setText(id, key) {
5358
- const el = document.getElementById(id);
5359
- if (el) el.textContent = tr(key);
5360
- }
5361
-
5362
- function connText(key) {
5363
- const el = document.getElementById('connState');
5364
- if (el) {
5365
- el.dataset.stateKey = key;
5366
- el.textContent = tr('conn.' + key);
5367
- }
5368
- }
5369
-
5370
- function applyLanguage() {
5371
- document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
5372
- localStorage.setItem('hcc.lang', lang);
5373
- const sel = document.getElementById('langSelect');
5374
- if (sel) sel.value = lang;
5375
- document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
5376
- document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
5377
- document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
5378
- document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
5379
- if (!active && !activeDetected) {
5380
- setText('activeTitle', 'noSessionSelected');
5381
- setText('activeMeta', 'startOrSelect');
5382
- }
5383
- const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
5384
- connText(stateKey);
5385
- syncStartModeOptions();
5386
- renderSections();
5387
- if (activeType === 'detected' && activeDetected) {
5388
- const draft = document.getElementById('detMsg')?.value || '';
5389
- const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
5390
- document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
5391
- renderDetectedPanel(peer);
5392
- const detMsg = document.getElementById('detMsg');
5393
- if (detMsg && draft) detMsg.value = draft;
5394
- } else if (activeType === 'managed' && active) {
5395
- const meta = sessions.find((s) => s.id === active);
5396
- if (meta) {
5397
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5398
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5399
- }
5400
- }
5401
- if (lastStateRoot) refreshCurrentState().catch(console.error);
5402
- syncToggleIcons();
5403
- }
5404
-
5405
- function requestQuery(extra = {}) {
5406
- const params = new URLSearchParams();
5407
- if (token) params.set('token', token);
5408
- if (currentProject) params.set('root', currentProject);
5409
- for (const [key, value] of Object.entries(extra)) {
5410
- if (value !== undefined && value !== null && value !== '') params.set(key, value);
5411
- }
5412
- const text = params.toString();
5413
- return text ? '?' + text : '';
5414
- }
5415
-
5416
- function updateLocationProject() {
5417
- const params = new URLSearchParams(location.search);
5418
- if (token) params.set('token', token);
5419
- if (currentProject) params.set('project', currentProject);
5420
- if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
5421
- history.replaceState(null, '', location.pathname + '?' + params.toString());
5422
- }
5423
-
5424
- const term = new Terminal({
5425
- cursorBlink: true,
5426
- cursorInactiveStyle: 'outline',
5427
- cursorStyle: 'bar',
5428
- convertEol: true,
5429
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
5430
- fontSize: 13,
5431
- theme: { background: '#0b0d10', foreground: '#eef2f6', cursor: '#7dd3fc', cursorAccent: '#0b0d10' }
5432
- });
5433
- term.open(document.getElementById('terminal'));
5434
-
5435
- // Accurate terminal sizing via character measurement
5436
- function measureCharSize() {
5437
- const el = document.getElementById('terminal');
5438
- const canvas = document.createElement('canvas');
5439
- const ctx2d = canvas.getContext('2d');
5440
- ctx2d.font = '13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
5441
- const m = ctx2d.measureText('M');
5442
- return { w: m.width || 8, h: 17 };
5443
- }
5444
-
5445
- function resizeTerm() {
5446
- const el = document.getElementById('terminal');
5447
- const { w, h } = measureCharSize();
5448
- const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
5449
- const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
5450
- term.resize(cols, rows);
5451
- if (ws && ws.readyState === WebSocket.OPEN) {
5452
- ws.send(JSON.stringify({ type: 'resize', cols, rows }));
5453
- }
5454
- }
5455
- window.addEventListener('resize', resizeTerm);
5456
- setTimeout(resizeTerm, 80);
5457
-
5458
- term.onData((data) => {
5459
- if (ws && ws.readyState === WebSocket.OPEN) {
5460
- ws.send(JSON.stringify({ type: 'input', data }));
5461
- }
5462
- });
5463
-
5464
- async function api(path, options = {}) {
5465
- const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
5466
- ...options,
5467
- headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
5468
- });
5469
- const json = await res.json();
5470
- if (!res.ok) throw new Error(json.error?.message || json.message || 'request failed');
5471
- return json;
5472
- }
5473
-
5474
- function renderProjects() {
5475
- const select = document.getElementById('projectSelect');
5476
- select.innerHTML = projects.map((p) =>
5477
- '<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
5478
- ).join('');
5479
- if (currentProject) select.value = currentProject;
5480
- document.getElementById('sessionKindFilter').value = sessionKindFilter;
5481
- }
5482
-
5483
- function kindMatches(item) {
5484
- const kind = ['claude', 'codex', 'shell'].includes(item.kind) ? item.kind : 'other';
5485
- return sessionKindFilter === 'all' || kind === sessionKindFilter;
5486
- }
5487
-
5488
- function syncStartModeOptions() {
5489
- const kind = document.getElementById('kind').value;
5490
- const modeSelect = document.getElementById('startMode');
5491
- const current = modeSelect.value;
5492
- const modes = kind === 'claude'
5493
- ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
5494
- : kind === 'codex'
5495
- ? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
5496
- : [['new', 'new']];
5497
- modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
5498
- modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
5499
- const isResume = modeSelect.value === 'resume';
5500
- document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
5501
- if (isResume) loadResumable();
5502
- else document.querySelector('[data-resume-custom]').style.display = 'none';
5503
- }
5504
-
5505
- // Fetch provider sessions hcc knows about and fill the resume dropdown.
5506
- async function loadResumable() {
5507
- try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
5508
- catch { resumableCache = []; }
5509
- populateResumeSelect();
5510
- }
5511
- function populateResumeSelect() {
5512
- const kind = document.getElementById('kind').value;
5513
- const sel = document.getElementById('resumeSelect');
5514
- const prev = sel.value;
5515
- const items = resumableCache.filter((r) => r.provider === kind);
5516
- const opts = items.map((r) => {
5517
- const resume = r.resume || r.session_id || r.session_name || '';
5518
- const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
5519
- const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
5520
- return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
5521
- });
5522
- opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
5523
- sel.innerHTML = opts.join('');
5524
- if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
5525
- toggleResumeCustom();
5526
- }
5527
- function toggleResumeCustom() {
5528
- const sel = document.getElementById('resumeSelect');
5529
- document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
5530
- }
5531
-
5532
- async function loadProjects() {
5533
- const data = await api('/api/projects');
5534
- projects = data.projects || [];
5535
- if (!currentProject) currentProject = data.current?.root || projects[0]?.root || '';
5536
- renderProjects();
5537
- updateLocationProject();
5538
- }
5539
-
5540
- async function switchProject(root) {
5541
- currentProject = root;
5542
- updateLocationProject();
5543
- active = null;
5544
- activeDetected = null;
5545
- activeType = 'managed';
5546
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
5547
- term.reset();
5548
- document.getElementById('activeTitle').textContent = tr('noSessionSelected');
5549
- document.getElementById('activeMeta').textContent = tr('startOrSelect');
5550
- await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
5551
- const first = sessions.find(s => s.status === 'running');
5552
- if (first) connectManaged(first.id);
5553
- }
5554
-
5555
- function esc(text) {
5556
- return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
5557
- }
5558
-
5559
- function fmtTime(ts) {
5560
- if (!ts) return '';
5561
- return new Date(ts * 1000).toLocaleTimeString();
5562
- }
5563
-
5564
- function badgeClass(status) {
5565
- return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
5566
- }
5567
-
5568
- function fmtAge(age) {
5569
- const n = Number(age);
5570
- if (!Number.isFinite(n)) return '?';
5571
- return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
5572
- }
5573
-
5574
- function sessionPeerId(session) {
5575
- return session?.peer_id || session?.id || '';
5576
- }
5577
-
5578
- function sessionRuntimeNote(session) {
5579
- const peerId = sessionPeerId(session);
5580
- return session?.id && peerId && session.id !== peerId ? tr('runtime') + '=' + session.id + ' · ' : '';
5581
- }
5582
-
5583
- function sessionMetaText(session) {
5584
- return sessionRuntimeNote(session) + (session.command || '') + (session.pane ? ' · ' + session.pane : '') + ' · ' + (session.cwd || '');
5585
- }
5586
-
5587
- function statusText(status) {
5588
- const value = String(status || 'unknown');
5589
- return tr('status.' + value.toLowerCase(), value);
5590
- }
5591
-
5592
- function managedPeerId(id) {
5593
- return sessionPeerId(sessions.find((s) => s.id === id)) || id;
5594
- }
5595
-
5596
- function peerIsActive(peer, basisNow = lastStateNow) {
5597
- const age = Number(peer?.age_sec);
5598
- if (Number.isFinite(age)) return age <= activePeerTtl;
5599
- const seen = Number(peer?.last_seen_at || 0);
5600
- const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
5601
- return seen > 0 && (t - seen) <= activePeerTtl;
5602
- }
5603
-
5604
- function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
5605
- const activity = peer?.status || 'unknown';
5606
- const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
5607
- const age = fmtAge(peer?.age_sec);
5608
- const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
5609
- if (runtime) {
5610
- return {
5611
- label: runtime.status || 'running',
5612
- detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5613
- };
5614
- }
5615
- if (liveness === 'stale') {
5616
- return {
5617
- label: 'stale',
5618
- detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
5619
- };
5620
- }
5621
- return {
5622
- label: activity,
5623
- detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5624
- };
5625
- }
5626
-
5627
- function bodyPinned(el) {
5628
- if (!el) return false;
5629
- return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
5630
- }
5631
-
5632
- function stateCardCollapsed(section) {
5633
- return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
5634
- }
5635
-
5636
- function stateCardHtml(section, title, count, bodyHtml) {
5637
- const collapsed = stateCardCollapsed(section);
5638
- return \`
5639
- <div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
5640
- <button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
5641
- <span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
5642
- <span class="state-card-chevron">⌄</span>
5643
- </button>
5644
- <div class="body">\${bodyHtml}</div>
5645
- </div>\`;
5646
- }
5647
-
5648
- function bindStateCardToggles() {
5649
- document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
5650
- button.addEventListener('click', () => {
5651
- const card = button.closest('.state-card[data-section]');
5652
- if (!card) return;
5653
- const section = card.dataset.section || '';
5654
- const collapsed = !card.classList.contains('state-card-collapsed');
5655
- card.classList.toggle('state-card-collapsed', collapsed);
5656
- button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
5657
- localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
5658
- });
5659
- });
5660
- }
5661
-
5662
- function renderTimelineItem(item) {
5663
- const meta = [
5664
- item.source + ':' + item.source_id,
5665
- item.task_id ? tr('task') + ' #' + item.task_id : '',
5666
- item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
5667
- item.direction || '',
5668
- fmtTime(item.ts)
5669
- ].filter(Boolean).join(' · ');
5670
- return \`
5671
- <div class="item timeline-item">
5672
- <strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
5673
- <span>\${esc(meta)}</span>
5674
- \${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
5675
- </div>\`;
5676
- }
5677
-
5678
- document.getElementById('projectSelect').addEventListener('change', (event) => {
5679
- switchProject(event.target.value).catch(console.error);
5680
- });
5681
- document.getElementById('langSelect').addEventListener('change', (event) => {
5682
- lang = event.target.value === 'zh' ? 'zh' : 'en';
5683
- applyLanguage();
5684
- });
5685
- document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
5686
- sessionKindFilter = event.target.value || 'all';
5687
- updateLocationProject();
5688
- renderSections();
5689
- });
5690
- document.getElementById('addProjectBtn').addEventListener('click', async () => {
5691
- const input = document.getElementById('projectPath');
5692
- const root = input.value.trim();
5693
- if (!root) return;
5694
- await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
5695
- input.value = '';
5696
- await loadProjects();
5697
- await switchProject(root);
5698
- });
5699
-
5700
- // ── Sessions rendering (managed + detected) ──────────────────────────
5701
- function renderSections() {
5702
- const box = document.getElementById('sessions');
5703
- const visibleSessions = sessions.filter(kindMatches);
5704
- const visibleDetected = detected.filter(kindMatches);
5705
- const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
5706
- const manHtml = visibleSessions.length
5707
- ? visibleSessions.map((s) => {
5708
- const peerId = sessionPeerId(s);
5709
- return \`
5710
- <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
5711
- <div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
5712
- <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>
5713
- <div class="path">\${esc(sessionRuntimeNote(s) + (s.command || ''))}</div>
5714
- </div>\`;
5715
- }).join('')
5716
- : '<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>';
5717
-
5718
- const detHtml = visibleDetected.length
5719
- ? visibleDetected.map((p) => {
5720
- const state = peerStateView(p);
5721
- return \`
5722
- <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
5723
- <div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span></div>
5724
- <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>
5725
- <div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
5726
- </div>\`;
5727
- }).join('')
5728
- : '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
5729
-
5730
- const savedScroll = box.scrollTop;
5731
- box.innerHTML = \`
5732
- <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
5733
- \${manHtml}
5734
- <div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
5735
- \${detHtml}
5736
- \`;
5737
- box.scrollTop = savedScroll;
5738
- box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
5739
- el.addEventListener('click', () => connectManaged(el.dataset.id));
5740
- });
5741
- box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
5742
- el.addEventListener('click', () => connectDetected(el.dataset.id));
5743
- });
5744
- }
5745
-
5746
- async function refreshSessions() {
5747
- const data = await api('/api/sessions');
5748
- sessions = data.sessions || [];
5749
- renderSections();
5750
- if (active && activeType === 'managed') {
5751
- const meta = sessions.find((s) => s.id === active);
5752
- if (meta) {
5753
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5754
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5755
- }
5756
- }
5757
- connText('online');
5758
- }
5759
-
5760
- async function refreshDetected() {
5761
- try {
5762
- const data = await api('/api/detected');
5763
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
5764
- lastStateNow = Number(data.now || lastStateNow);
5765
- detected = data.detected || [];
5766
- renderSections();
5767
- } catch {}
5768
- }
5769
-
5770
- // ── Project state panel ───────────────────────────────────────────────
5771
- function renderState(data) {
5772
- const stateRoot = data.root || '';
5773
- document.getElementById('rootPath').textContent = stateRoot;
5774
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
5775
- lastStateNow = Number(data.now || lastStateNow);
5776
- const state = document.getElementById('state');
5777
- const preserveScroll = lastStateRoot === stateRoot;
5778
- const savedStateScroll = preserveScroll ? state.scrollTop : 0;
5779
- const savedCardScroll = preserveScroll
5780
- ? new Map(
5781
- [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
5782
- card.dataset.section,
5783
- {
5784
- top: card.querySelector('.body')?.scrollTop || 0,
5785
- pinned: bodyPinned(card.querySelector('.body'))
5786
- }
5787
- ])
5788
- )
5789
- : new Map();
5790
- const runtimeById = new Map();
5791
- for (const session of sessions || []) {
5792
- runtimeById.set(session.id, session);
5793
- const peerId = sessionPeerId(session);
5794
- if (peerId) runtimeById.set(peerId, session);
5795
- }
5796
- const tasksData = data.tasks || [];
5797
- const peersData = data.peers || [];
5798
- const locksData = data.locks || [];
5799
- const messagesData = data.messages || [];
5800
- const timelineData = data.timeline || [];
5801
- const automation = data.automation || {};
5802
- const nextAction = automation.next_action || {};
5803
- const tasks = tasksData.map((t) => \`
5804
- <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>
5805
- \`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
5806
- const peers = peersData.map((a) => {
5807
- const peerRuntime = runtimeById.get(a.id);
5808
- const peerState = peerStateView(a, peerRuntime, data.now);
5809
- return \`
5810
- <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>
5811
- \`;
5812
- }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
5813
- const locks = locksData.map((l) => \`
5814
- <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>
5815
- \`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
5816
- const messages = messagesData.map((m) => \`
5817
- <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>
5818
- \`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
5819
- const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
5820
- const actionLines = [
5821
- '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
5822
- nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
5823
- (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
5824
- (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
5825
- ].filter(Boolean).join('');
5826
- state.innerHTML = [
5827
- stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
5828
- stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
5829
- stateCardHtml('messages', tr('messages'), messagesData.length, messages),
5830
- stateCardHtml('peers', tr('peers'), peersData.length, peers),
5831
- stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
5832
- stateCardHtml('locks', tr('locks'), locksData.length, locks)
5833
- ].join('');
5834
- state.scrollTop = savedStateScroll;
5835
- for (const [section, saved] of savedCardScroll) {
5836
- const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
5837
- if (!body) continue;
5838
- if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
5839
- else body.scrollTop = saved.top;
5840
- }
5841
- bindStateCardToggles();
5842
- lastStateRoot = stateRoot;
5843
- }
5844
-
5845
- async function refreshState() {
5846
- const peer = active ? managedPeerId(active) : null;
5847
- const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
5848
- const data = await api(p);
5849
- renderState(data);
5850
- }
5851
-
5852
- async function refreshDetectedState() {
5853
- if (!activeDetected) return;
5854
- const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
5855
- renderState(data);
5856
- }
5857
-
5858
- async function refreshCurrentState() {
5859
- if (activeType === 'detected') return refreshDetectedState();
5860
- return refreshState();
5861
- }
5862
-
5863
- // ── Connect to managed (PTY) session ─────────────────────────────────
5864
- function connectManaged(id) {
5865
- const meta = sessions.find((s) => s.id === id);
5866
- const peerId = sessionPeerId(meta) || id;
5867
- active = id;
5868
- activeDetected = null;
5869
- activeType = 'managed';
5870
- renderSections();
5871
- document.getElementById('activeTitle').textContent = peerId;
5872
- document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
5873
- document.getElementById('terminal').style.display = '';
5874
- document.getElementById('detectedPanel').style.display = 'none';
5875
- document.getElementById('quickBar').style.display = '';
5876
-
5877
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); }
5878
- term.reset();
5879
- openWs(id);
5880
- refreshState().catch(console.error);
5881
- }
5882
-
5883
- function openWs(id) {
5884
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
5885
- ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
5886
- ws.onopen = () => {
5887
- resizeTerm();
5888
- connText('attached');
5889
- };
5890
- ws.onmessage = (event) => {
5891
- const msg = JSON.parse(event.data);
5892
- // Check before any reset whether the viewport is pinned at the bottom
5893
- const pinned = (() => {
5894
- try { const b = term.buffer.active; return b.viewportY >= b.baseY; } catch { return true; }
5895
- })();
5896
- // The server streams the tmux pane's raw output, so xterm renders
5897
- // incrementally (no reset/redraw → no flicker) and the program's own
5898
- // escape sequences carry the cursor.
5899
- if (msg.type === 'snapshot') { term.reset(); term.write(msg.data || '', () => { term.scrollToBottom(); }); }
5900
- if (msg.type === 'data') { term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
5901
- if (msg.type === 'replace') { term.reset(); term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
5902
- if (msg.type === 'exit') { refreshSessions().catch(console.error); }
5903
- };
5904
- ws.onclose = () => {
5905
- connText('reconnecting');
5906
- // Auto-reconnect if session is still in the list and running
5907
- wsReconnectTimer = setTimeout(() => {
5908
- const s = sessions.find((s) => s.id === id);
5909
- if (s && s.status === 'running' && active === id) openWs(id);
5910
- else connText('online');
5911
- }, 2000);
5912
- };
5913
- }
5914
-
5915
- // ── Connect to detected (coordination-only) peer ──────────────────────
5916
- function connectDetected(id) {
5917
- const peer = detected.find((p) => p.id === id);
5918
- active = null;
5919
- activeDetected = id;
5920
- activeType = 'detected';
5921
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
5922
- renderSections();
5923
- document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
5924
- document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
5925
- document.getElementById('terminal').style.display = 'none';
5926
- document.getElementById('detectedPanel').style.display = '';
5927
- document.getElementById('quickBar').style.display = 'none';
5928
- connText('coordinationOnly');
5929
- renderDetectedPanel(peer || { id });
5930
- refreshDetectedState().catch(console.error);
5931
- }
5932
-
5933
- function renderDetectedPanel(peer) {
5934
- const dp = document.getElementById('detectedPanel');
5935
- dp.innerHTML = \`
5936
- <div style="padding:16px;display:grid;gap:12px">
5937
- <div class="card">
5938
- <h2>\${esc(tr('detectedSession'))}</h2>
5939
- <div class="body">
5940
- <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
5941
- <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
5942
- <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
5943
- <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
5944
- <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
5945
- <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
5946
- </div>
5947
- </div>
5948
- <div class="card">
5949
- <h2>\${esc(tr('sendMessage'))}</h2>
5950
- <div class="body" style="gap:8px">
5951
- <div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
5952
- <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>
5953
- <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
5954
- </div>
5955
- </div>
5956
- </div>
5957
- \`;
5958
- document.getElementById('sendDetMsg').addEventListener('click', async () => {
5959
- const body = document.getElementById('detMsg').value.trim();
5960
- if (!body) return;
5961
- await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
5962
- method: 'POST',
5963
- body: JSON.stringify({ body, from: 'web' })
5964
- });
5965
- document.getElementById('detMsg').value = '';
5966
- await refreshDetectedState();
5967
- });
5968
- }
5969
-
5970
- // ── Helpers ───────────────────────────────────────────────────────────
5971
- function sendLine(text) {
5972
- if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
5973
- ws.send(JSON.stringify({ type: 'input', data: text + '\\r' }));
5974
- }
5975
-
5976
- // ── Start session form ────────────────────────────────────────────────
5977
- document.getElementById('kind').addEventListener('change', syncStartModeOptions);
5978
- document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
5979
- document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
5980
- syncStartModeOptions();
5981
-
5982
- document.getElementById('startForm').addEventListener('submit', async (event) => {
5983
- event.preventDefault();
5984
- const kind = document.getElementById('kind').value;
5985
- const mode = document.getElementById('startMode').value;
5986
- const sel = document.getElementById('resumeSelect');
5987
- const resume = (sel.value && sel.value !== '__custom__')
5988
- ? sel.value
5989
- : document.getElementById('resumeArg').value.trim();
5990
- if (mode === 'resume' && !resume) return;
5991
- const payload = { kind, mode };
5992
- if (mode === 'resume') payload.resume = resume;
5993
- const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
5994
- await refreshSessions();
5995
- connectManaged(data.session.id);
5996
- });
5997
-
5998
- function activePeerInfo() {
5999
- if (!active) return null;
6000
- const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
6001
- return {
6002
- session,
6003
- peerId: sessionPeerId(session) || active
6004
- };
6005
- }
6006
-
6007
- function terminalCommandForAction(action, info) {
6008
- const session = info.session || {};
6009
- const peerId = info.peerId;
6010
- const lines = {
6011
- register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
6012
- inbox: \`hcc msg inbox --peer \${peerId}\`,
6013
- 'task-next': \`hcc task next --peer \${peerId}\`,
6014
- state: \`hcc state --peer \${peerId}\`,
6015
- status: \`hcc status --peer \${peerId}\`,
6016
- heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
6017
- };
6018
- return lines[action] || lines.status;
6019
- }
6020
-
6021
- function formatActionResult(result) {
6022
- if (!result) return '';
6023
- const data = result.data || {};
6024
- if (result.action === 'status') {
6025
- const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
6026
- return [
6027
- data.root,
6028
- 'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
6029
- 'tasks ' + tasks,
6030
- 'locks active=' + data.active_locks,
6031
- 'unread ' + (data.unread ?? 0)
6032
- ].filter(Boolean).join('\\n');
6033
- }
6034
- if (result.action === 'state') {
6035
- const automation = data.automation || {};
6036
- const next = automation.next_action || {};
6037
- return [
6038
- 'phase: ' + (automation.phase || 'idle'),
6039
- 'next: ' + (next.command || next.kind || 'none'),
6040
- 'why: ' + (next.reason || tr('noImmediateAction')),
6041
- ...(automation.warnings || []).map((w) => 'warning: ' + w)
6042
- ].join('\\n');
6043
- }
6044
- if (result.action === 'inbox') {
6045
- const messages = data.messages || [];
6046
- return messages.length
6047
- ? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
6048
- : tr('noMessages');
6049
- }
6050
- if (result.action === 'task-next') {
6051
- return data.task
6052
- ? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
6053
- : result.summary;
6054
- }
6055
- return result.summary || JSON.stringify(result, null, 2);
6056
- }
6057
-
6058
- function showActionResult(result) {
6059
- const panel = document.getElementById('actionResult');
6060
- document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
6061
- document.getElementById('actionResultBody').textContent = formatActionResult(result);
6062
- panel.hidden = false;
6063
- }
6064
-
6065
- async function runPeerAction(action) {
6066
- const info = activePeerInfo();
6067
- if (!info) return;
6068
- const payload = action === 'register'
6069
- ? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
6070
- : action === 'heartbeat'
6071
- ? { renew_locks: true }
6072
- : {};
6073
- const readOnly = ['status', 'state', 'inbox'].includes(action);
6074
- const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
6075
- ? {}
6076
- : { method: 'POST', body: JSON.stringify(payload) });
6077
- showActionResult(result);
6078
- await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
6079
- }
6080
-
6081
- document.querySelectorAll('[data-action]').forEach((button) => {
6082
- button.addEventListener('click', () => {
6083
- closeActionsMenu();
6084
- runPeerAction(button.dataset.action).catch((err) => {
6085
- showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
6086
- });
6087
- });
6088
- });
6089
- document.querySelectorAll('[data-terminal-action]').forEach((button) => {
6090
- button.addEventListener('click', () => {
6091
- const info = activePeerInfo();
6092
- if (!info) return;
6093
- sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
6094
- closeActionsMenu();
6095
- });
6096
- });
6097
- document.getElementById('actionResultClose').addEventListener('click', () => {
6098
- document.getElementById('actionResult').hidden = true;
6099
- });
6100
-
6101
- // ── Actions dropdown (declutters the toolbar) ─────────────────────────
6102
- const actionsBtn = document.getElementById('actionsBtn');
6103
- const actionsMenu = document.getElementById('actionsMenu');
6104
- function closeActionsMenu() {
6105
- actionsMenu.hidden = true;
6106
- actionsBtn.setAttribute('aria-expanded', 'false');
6107
- }
6108
- actionsBtn.addEventListener('click', (e) => {
6109
- e.stopPropagation();
6110
- const open = actionsMenu.hidden;
6111
- actionsMenu.hidden = !open;
6112
- actionsBtn.setAttribute('aria-expanded', String(open));
6113
- if (open) {
6114
- // Position as a fixed layer so it escapes the toolbar/main overflow clip.
6115
- const r = actionsBtn.getBoundingClientRect();
6116
- actionsMenu.style.top = (r.bottom + 6) + 'px';
6117
- actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
6118
- }
6119
- });
6120
- document.addEventListener('click', (e) => {
6121
- if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
6122
- });
6123
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
6124
-
6125
- // ── Collapsible side panels ───────────────────────────────────────────
6126
- const appEl = document.querySelector('.app');
6127
- const toggleLeftBtn = document.getElementById('toggleLeft');
6128
- const toggleRightBtn = document.getElementById('toggleRight');
6129
- const resizeLeftHandle = document.getElementById('resizeLeft');
6130
- const resizeRightHandle = document.getElementById('resizeRight');
6131
- const sideDefaults = { left: 320, right: 360 };
6132
- const sideMin = { left: 220, right: 240 };
6133
- const sideMax = { left: 560, right: 640 };
6134
- const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
6135
- const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
6136
- function sideIsCollapsed(side) {
6137
- return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
6138
- }
6139
- function storedSideWidth(side) {
6140
- const raw = Number(localStorage.getItem(sideWidthKey[side]));
6141
- return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
6142
- }
6143
- function effectiveOppositeWidth(side) {
6144
- const opposite = side === 'left' ? 'right' : 'left';
6145
- return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
6146
- }
6147
- function clampSideWidth(side, width) {
6148
- const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
6149
- const opposite = effectiveOppositeWidth(side);
6150
- const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
6151
- return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
6152
- }
6153
- function readSideWidth(side) {
6154
- return clampSideWidth(side, storedSideWidth(side));
6155
- }
6156
- function setSideWidth(side, width, persist = true) {
6157
- const value = clampSideWidth(side, width);
6158
- appEl.style.setProperty('--' + side + '-width', value + 'px');
6159
- if (persist) localStorage.setItem(sideWidthKey[side], String(value));
6160
- return value;
6161
- }
6162
- function applySideWidths() {
6163
- setSideWidth('left', readSideWidth('left'), false);
6164
- setSideWidth('right', readSideWidth('right'), false);
6165
- }
6166
- function syncToggleIcons() {
6167
- const l = appEl.classList.contains('left-collapsed');
6168
- const r = appEl.classList.contains('right-collapsed');
6169
- toggleLeftBtn.textContent = l ? '⟩' : '⟨';
6170
- toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
6171
- toggleRightBtn.textContent = r ? '⟨' : '⟩';
6172
- toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
6173
- }
6174
- function applyCollapseState() {
6175
- appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
6176
- appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
6177
- applySideWidths();
6178
- syncToggleIcons();
6179
- }
6180
- function toggleSide(side) {
6181
- const cls = side + '-collapsed';
6182
- const on = appEl.classList.toggle(cls);
6183
- localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
6184
- applySideWidths();
6185
- syncToggleIcons();
6186
- // Refit the terminal after the grid transition so xterm uses the new width.
6187
- setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
6188
- }
6189
- function beginSideDrag(side, event) {
6190
- if (event.button !== undefined && event.button !== 0) return;
6191
- dragState.side = side;
6192
- dragState.startX = event.clientX;
6193
- dragState.startWidth = readSideWidth(side);
6194
- dragState.moved = false;
6195
- dragState.suppressClick = false;
6196
- appEl.classList.add('resizing');
6197
- event.currentTarget.setPointerCapture?.(event.pointerId);
6198
- event.preventDefault();
6199
- }
6200
- function moveSideDrag(event) {
6201
- if (!dragState.side) return;
6202
- const delta = event.clientX - dragState.startX;
6203
- if (!dragState.moved && Math.abs(delta) <= 3) return;
6204
- dragState.moved = true;
6205
- const width = dragState.side === 'left'
6206
- ? dragState.startWidth + delta
6207
- : dragState.startWidth - delta;
6208
- setSideWidth(dragState.side, width);
6209
- if (dragState.side === 'left') {
6210
- appEl.classList.remove('left-collapsed');
6211
- localStorage.setItem('hcc.collapse.left', '0');
6212
- } else {
6213
- appEl.classList.remove('right-collapsed');
6214
- localStorage.setItem('hcc.collapse.right', '0');
6215
- }
6216
- syncToggleIcons();
6217
- try { resizeTerm(); } catch {}
6218
- event.preventDefault();
6219
- }
6220
- function endSideDrag(event) {
6221
- if (!dragState.side) return;
6222
- dragState.suppressClick = dragState.moved;
6223
- event.currentTarget.releasePointerCapture?.(event.pointerId);
6224
- dragState.side = null;
6225
- appEl.classList.remove('resizing');
6226
- setTimeout(() => { dragState.suppressClick = false; }, 0);
6227
- try { resizeTerm(); } catch {}
6228
- }
6229
- function bindSideHandle(handle, side, clickAction = null) {
6230
- handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
6231
- handle.addEventListener('pointermove', moveSideDrag);
6232
- handle.addEventListener('pointerup', endSideDrag);
6233
- handle.addEventListener('pointercancel', endSideDrag);
6234
- handle.addEventListener('click', (event) => {
6235
- if (dragState.suppressClick) {
6236
- event.preventDefault();
6237
- return;
6238
- }
6239
- if (clickAction) clickAction(side);
6240
- });
6241
- }
6242
- bindSideHandle(resizeLeftHandle, 'left');
6243
- bindSideHandle(resizeRightHandle, 'right');
6244
- bindSideHandle(toggleLeftBtn, 'left', toggleSide);
6245
- bindSideHandle(toggleRightBtn, 'right', toggleSide);
6246
- window.addEventListener('resize', () => {
6247
- applySideWidths();
6248
- setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
6249
- });
6250
- applyCollapseState();
6251
-
6252
- document.getElementById('stopBtn').addEventListener('click', async () => {
6253
- if (!active) return;
6254
- await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: '{}' });
6255
- await refreshSessions();
6256
- });
6257
- document.getElementById('refreshBtn').addEventListener('click', () => {
6258
- Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
6259
- });
6260
-
6261
- applyLanguage();
6262
- loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
6263
- // Auto-connect to first running managed session on load
6264
- if (!active && !activeDetected) {
6265
- const first = sessions.find(s => s.status === 'running');
6266
- if (first) connectManaged(first.id);
6267
- }
6268
- }).catch((err) => {
6269
- connText('error');
6270
- console.error(err);
6271
- });
6272
- // ── Auto-poll state ──────────────────────────────────────────────────
6273
- setInterval(() => {
6274
- refreshSessions().catch(console.error);
6275
- refreshDetected().catch(console.error);
6276
- if (activeType === 'detected') refreshDetectedState().catch(console.error);
6277
- else if (active) refreshState().catch(console.error);
6278
- }, 3000);
6279
- setInterval(() => {
6280
- loadProjects().catch(console.error);
6281
- }, 8000);
6282
- </script>
6283
- </body>
6284
- </html>`;
6285
- }
6286
-
6287
4354
  async function cmdWeb(ctx, args, startMeta = {}) {
6288
4355
  if (args[0] === '--help' || args[0] === '-h') return helpWeb();
6289
4356
  if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
@@ -6665,6 +4732,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6665
4732
  if (session.replaceTimer) clearTimeout(session.replaceTimer);
6666
4733
  session.replaceTimer = setTimeout(() => {
6667
4734
  session.replaceTimer = null;
4735
+ session.lastBroadcastTime = Date.now();
6668
4736
  broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
6669
4737
  }, 80);
6670
4738
  }
@@ -6737,6 +4805,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6737
4805
  const data = Buffer.concat(chunks).toString();
6738
4806
  session.buffer += data;
6739
4807
  if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
4808
+ session.lastBroadcastTime = Date.now();
6740
4809
  broadcast(session, { type: 'data', data });
6741
4810
  } catch {
6742
4811
  if (session.streamFd !== null && session.streamFd !== undefined) {
@@ -6745,10 +4814,24 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6745
4814
  }
6746
4815
  }
6747
4816
  }, 40);
4817
+
4818
+ // Fallback replace poller: if the FIFO produces no data for N seconds
4819
+ // (e.g. pipe-pane output is fully buffered), send a fresh capture-pane
4820
+ // snapshot so the browser stays current. This also recovers from any
4821
+ // silent FIFO-read failures on restored panes.
4822
+ session.lastBroadcastTime = Date.now();
4823
+ session.replacePoller = setInterval(() => {
4824
+ if (session.status !== 'running') return;
4825
+ if (Date.now() - (session.lastBroadcastTime || 0) > 4000) {
4826
+ session.lastBroadcastTime = Date.now();
4827
+ broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
4828
+ }
4829
+ }, 1600);
6748
4830
  }
6749
4831
 
6750
4832
  function stopTmuxStream(session) {
6751
4833
  if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
4834
+ if (session.replacePoller) { clearInterval(session.replacePoller); session.replacePoller = null; }
6752
4835
  if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
6753
4836
  if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
6754
4837
  // Turn piping back off for this pane (no command toggles it off).
@@ -6812,6 +4895,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6812
4895
  streamPoller: null,
6813
4896
  streamFd: null,
6814
4897
  pipeFile: null,
4898
+ replacePoller: null,
4899
+ lastBroadcastTime: 0,
6815
4900
  exitPoller: null
6816
4901
  };
6817
4902
  sessions.set(key, session);
@@ -7202,7 +5287,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7202
5287
  }
7203
5288
 
7204
5289
  const server = http.createServer(async (req, res) => {
7205
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
5290
+ const url = requestUrl(req);
7206
5291
  try {
7207
5292
  if (req.method === 'GET' && url.pathname === '/') {
7208
5293
  sendHttp(res, 200, 'text/html; charset=utf-8', webIndexHtml());
@@ -7253,7 +5338,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7253
5338
  ]);
7254
5339
  sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer'), {
7255
5340
  resources,
7256
- intent: url.searchParams.get('intent') || null
5341
+ intent: url.searchParams.get('intent') || null,
5342
+ scope: url.searchParams.get('scope') || null
7257
5343
  }));
7258
5344
  return;
7259
5345
  }
@@ -7439,7 +5525,17 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7439
5525
  if (session.wrapperPid) { try { process.kill(session.wrapperPid, 'SIGTERM'); } catch {} }
7440
5526
  if (session.pid && session.pid !== session.wrapperPid) { try { process.kill(session.pid, 'SIGTERM'); } catch {} }
7441
5527
  } else if (session.type === 'tmux') {
5528
+ let input = {};
5529
+ try { input = await readJsonRequest(req); } catch {}
7442
5530
  detachTmuxSession(session, 'detached');
5531
+ if (input.kill_tmux) {
5532
+ try {
5533
+ const sessName = runTmux(['display-message', '-p', '-t', session.pane, '#{session_name}']).trim();
5534
+ if (sessName) tmuxKillSession(sessName);
5535
+ } catch {
5536
+ // Pane or session already gone; no fallback needed.
5537
+ }
5538
+ }
7443
5539
  } else {
7444
5540
  session.pty.kill();
7445
5541
  }
@@ -7466,6 +5562,46 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7466
5562
  sendJson(res, 200, { ok: true, id: msgId });
7467
5563
  return;
7468
5564
  }
5565
+ const detectedStopMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/stop$/);
5566
+ if (req.method === 'POST' && detectedStopMatch) {
5567
+ const peerId = decodeURIComponent(detectedStopMatch[1]);
5568
+ let input = {};
5569
+ try { input = await readJsonRequest(req); } catch {}
5570
+ const db = connect(reqCtx);
5571
+ try {
5572
+ const now_ = now();
5573
+ db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now_, peerId);
5574
+ const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peerId);
5575
+ if (binding && input.kill_tmux && binding.runtime_target) {
5576
+ try {
5577
+ const sessName = runTmux(['display-message', '-p', '-t', binding.runtime_target, '#{session_name}']).trim();
5578
+ if (sessName) tmuxKillSession(sessName);
5579
+ } catch {
5580
+ // Session already gone.
5581
+ }
5582
+ }
5583
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now_, peerId);
5584
+ addEvent(db, 'peer.stopped', 'web', null, { peer: peerId });
5585
+ } finally {
5586
+ db.close();
5587
+ }
5588
+ sendJson(res, 200, { ok: true, peer: peerId, status: 'exited' });
5589
+ return;
5590
+ }
5591
+ const detectedRestartMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/restart$/);
5592
+ if (req.method === 'POST' && detectedRestartMatch) {
5593
+ const peerId = decodeURIComponent(detectedRestartMatch[1]);
5594
+ const db = connect(reqCtx);
5595
+ try {
5596
+ const now_ = now();
5597
+ db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('running', now_, peerId);
5598
+ addEvent(db, 'peer.restarted', 'web', null, { peer: peerId });
5599
+ } finally {
5600
+ db.close();
5601
+ }
5602
+ sendJson(res, 200, { ok: true, peer: peerId, status: 'running' });
5603
+ return;
5604
+ }
7469
5605
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
7470
5606
  } catch (err) {
7471
5607
  const detail = err instanceof CliError || process.env.HCC_DEBUG ? err.message : 'internal server error';
@@ -7475,7 +5611,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7475
5611
 
7476
5612
  const wss = new WebSocketServer({ noServer: true });
7477
5613
  server.on('upgrade', (req, socket, head) => {
7478
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
5614
+ const url = requestUrl(req);
7479
5615
  if (!authOk(url, req, token)) {
7480
5616
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
7481
5617
  socket.destroy();
@@ -7576,12 +5712,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7576
5712
  } finally {
7577
5713
  db.close();
7578
5714
  }
7579
- const shownHost = host === '0.0.0.0' ? '<machine-ip>' : host;
7580
- const url = `http://${shownHost}:${actualPort}/${runtimeUrlQuery(runtime, ctx.root)}`;
7581
5715
  console.log(`${PRODUCT_NAME} web listening on ${host}:${actualPort}`);
7582
5716
  console.log(`project: ${ctx.root}`);
7583
5717
  console.log(`database: ${ctx.dbPath}`);
7584
- console.log(`open: ${url}`);
5718
+ console.log(`open: ${publicRuntimeUrl(runtime, ctx.root)}`);
7585
5719
  }
7586
5720
 
7587
5721
  async function cmdRun(ctx, args) {
@@ -7860,8 +5994,9 @@ Usage:
7860
5994
  ${CLI_NAME} task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
7861
5995
  ${CLI_NAME} task create --title TEXT --parent N [--team-role ROLE]
7862
5996
  ${CLI_NAME} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
7863
- ${CLI_NAME} task claim [--peer ID] --id N
7864
- ${CLI_NAME} task next [--peer ID] [--force]
5997
+ ${CLI_NAME} task claim [--peer ID] --id N[,N] [--id N] [--ids N,N] [--force]
5998
+ ${CLI_NAME} task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--stale-after SECONDS]
5999
+ ${CLI_NAME} task next [--peer ID] [--force] [--count N]
7865
6000
  ${CLI_NAME} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
7866
6001
  ${CLI_NAME} task done [--peer ID] --id N --summary TEXT
7867
6002
 
@@ -7869,8 +6004,13 @@ Default task list shows all project tasks that are not done or abandoned.
7869
6004
  --peer is an explicit filter; HCC_PEER does not hide other open tasks.
7870
6005
  Messages use per-peer unread ack state; tasks do not.
7871
6006
  task next returns your existing claimed/running/review/blocked task before
7872
- claiming another pending task. Use --force only when intentionally taking
7873
- another pending task.
6007
+ claiming another pending task. Use --force when intentionally taking additional
6008
+ pending tasks; combine it with --count N for explicit batch claims.
6009
+ Use task takeover when explicitly taking over a non-complete task from another
6010
+ owner; it records the previous owner, requires a reason, and notifies them.
6011
+ Use --policy blocked, stale, or blocked-or-stale to require an auditable
6012
+ precondition before takeover. The default policy is any for backward
6013
+ compatibility.
7874
6014
 
7875
6015
  If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
7876
6016
  as a stable project-local peer.
@@ -7896,7 +6036,7 @@ function helpState() {
7896
6036
  console.log(`${CLI_NAME} state
7897
6037
 
7898
6038
  Usage:
7899
- ${CLI_NAME} state [--peer ID] [--resource PATH] [--intent work|stop|finish]
6039
+ ${CLI_NAME} state [--peer ID] [--resource PATH] [--scope SCOPE] [--intent read|review|work|write|stop|finish]
7900
6040
 
7901
6041
  Shows the current collaboration timeline plus a machine-readable coordination
7902
6042
  state machine. With --json, the response includes automation.next_action.argv so
@@ -7905,6 +6045,9 @@ automation.current_task shows the peer's active claimed/running/review/blocked
7905
6045
  task when one exists.
7906
6046
  If a current task looks splittable, automation may suggest hcc team plan; this
7907
6047
  is read-only until hcc team start is run explicitly.
6048
+ With --intent read or --intent review, state treats resources as snapshot
6049
+ inspection and does not suggest acquiring file locks. With write/work intents,
6050
+ --scope lets agents coordinate independent regions of the same resource.
7908
6051
 
7909
6052
  State does not execute coordination actions: it does not ack messages, claim
7910
6053
  tasks, acquire locks, send messages, create handoffs, or mark tasks done. Opening
@@ -8034,10 +6177,13 @@ function helpLock() {
8034
6177
  console.log(`${CLI_NAME} lock
8035
6178
 
8036
6179
  Usage:
8037
- ${CLI_NAME} lock acquire [--peer ID] --resource PATH [--task N] [--ttl SEC] [--reason TEXT]
8038
- ${CLI_NAME} lock renew [--peer ID] --resource PATH [--ttl SEC]
8039
- ${CLI_NAME} lock release [--peer ID] --resource PATH [--force]
6180
+ ${CLI_NAME} lock acquire [--peer ID] --resource PATH [--scope SCOPE] [--task N] [--ttl SEC] [--reason TEXT]
6181
+ ${CLI_NAME} lock renew [--peer ID] --resource PATH [--scope SCOPE] [--ttl SEC]
6182
+ ${CLI_NAME} lock release [--peer ID] --resource PATH [--scope SCOPE] [--force]
8040
6183
  ${CLI_NAME} lock list [--all]
6184
+
6185
+ Omit --scope to lock the whole resource. Different scopes on the same resource
6186
+ can be held concurrently, but a whole-resource lock conflicts with every scope.
8041
6187
  `);
8042
6188
  }
8043
6189