@logicseek/hello-cc 0.1.3 → 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,37 +4,84 @@ 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';
35
+ import { readPackageMeta } from '../lib/package-meta.mjs';
36
+ import {
37
+ removeGuidanceBlocks as removeGuidanceBlocksForRoot,
38
+ writeGuidance as writeGuidanceForRoot
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';
13
68
 
14
69
  // Lazy-load lib modules (they may import node-pty which needs to be optional)
15
70
  const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'lib');
16
71
  async function loadDiscover() { return import(path.join(_libDir, 'discover.mjs')); }
17
72
  async function loadSetup() { return import(path.join(_libDir, 'setup.mjs')); }
18
73
 
19
- const VERSION = '0.1.2';
74
+ const PACKAGE_META = readPackageMeta(path.resolve(fileURLToPath(import.meta.url), '..', '..'));
75
+ const VERSION = PACKAGE_META.version;
20
76
  const PRODUCT_NAME = 'hello-cc';
21
77
  const CLI_NAME = 'hcc';
22
- const NPM_PACKAGE_NAME = '@logicseek/hello-cc';
78
+ const NPM_PACKAGE_NAME = PACKAGE_META.name;
23
79
  const DEFAULT_LOCK_TTL = 900;
24
80
  const ACTIVE_PEER_TTL = 600;
25
- const DB_SCHEMA_VERSION = 4;
26
81
  // Directory under .hello-cc/ for optional external PTY buffer files.
27
82
  const BUFS_DIR_NAME = 'bufs';
28
83
  const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
29
84
 
30
- class CliError extends Error {
31
- constructor(code, message, extra = {}) {
32
- super(message);
33
- this.code = code;
34
- this.extra = extra;
35
- }
36
- }
37
-
38
85
  function now() {
39
86
  return Math.floor(Date.now() / 1000);
40
87
  }
@@ -72,86 +119,6 @@ function detectBranch(cwd) {
72
119
  return runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) || '';
73
120
  }
74
121
 
75
- function splitGlobalArgs(argv) {
76
- const global = { json: false, root: null, db: null };
77
- const rest = [];
78
- for (let i = 0; i < argv.length; i += 1) {
79
- const arg = argv[i];
80
- if (arg === '--json') {
81
- global.json = true;
82
- } else if (arg === '--root') {
83
- global.root = argv[++i];
84
- } else if (arg.startsWith('--root=')) {
85
- global.root = arg.slice('--root='.length);
86
- } else if (arg === '--db') {
87
- global.db = argv[++i];
88
- } else if (arg.startsWith('--db=')) {
89
- global.db = arg.slice('--db='.length);
90
- } else {
91
- rest.push(arg);
92
- }
93
- }
94
- return { global, rest };
95
- }
96
-
97
- function parseOpts(args, spec = {}) {
98
- const booleans = new Set(spec.booleans || []);
99
- const arrays = new Set(spec.arrays || []);
100
- const opts = { _: [] };
101
- for (let i = 0; i < args.length; i += 1) {
102
- const arg = args[i];
103
- if (!arg.startsWith('--') || arg === '--') {
104
- opts._.push(arg);
105
- continue;
106
- }
107
- let key;
108
- let value;
109
- const eq = arg.indexOf('=');
110
- if (eq >= 0) {
111
- key = arg.slice(2, eq);
112
- value = arg.slice(eq + 1);
113
- } else {
114
- key = arg.slice(2);
115
- if (booleans.has(key)) {
116
- value = true;
117
- } else {
118
- value = args[++i];
119
- }
120
- }
121
- if (!key) throw new CliError('BAD_ARGS', `Invalid option: ${arg}`);
122
- if (value === undefined) throw new CliError('BAD_ARGS', `Missing value for --${key}`);
123
- if (arrays.has(key)) {
124
- if (!opts[key]) opts[key] = [];
125
- opts[key].push(value);
126
- } else {
127
- opts[key] = value;
128
- }
129
- }
130
- return opts;
131
- }
132
-
133
- function wantsHelp(args) {
134
- const stop = args.indexOf('--');
135
- const scan = stop >= 0 ? args.slice(0, stop) : args;
136
- const index = scan.findIndex((arg) => arg === '--help' || arg === '-h');
137
- if (index < 0) return false;
138
- return index <= 1 || !String(scan[index - 1] || '').startsWith('--');
139
- }
140
-
141
- function validateOpts(command, opts, allowed = []) {
142
- const allowedSet = new Set(['_', ...allowed]);
143
- for (const key of Object.keys(opts)) {
144
- if (!allowedSet.has(key)) throw new CliError('BAD_ARGS', `${command}: unknown option --${key}`);
145
- }
146
- if (opts._?.length) throw new CliError('BAD_ARGS', `${command}: unexpected argument ${opts._[0]}`);
147
- }
148
-
149
- function required(opts, key, envName = null) {
150
- const value = opts[key] || (envName ? process.env[envName] : null);
151
- if (!value) throw new CliError('BAD_ARGS', `Missing --${key}${envName ? ` or $${envName}` : ''}`);
152
- return value;
153
- }
154
-
155
122
  function sanitizePeerPart(value, fallback = 'peer') {
156
123
  const text = String(value || '').toLowerCase()
157
124
  .replace(/[^a-z0-9._-]/g, '-')
@@ -360,55 +327,13 @@ function autoPeerDefaults(ctx, kindHint = 'shell', status = 'working') {
360
327
  };
361
328
  }
362
329
 
363
- function intOpt(opts, key, fallback = null) {
364
- if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
365
- const value = Number.parseInt(String(opts[key]), 10);
366
- if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
367
- return value;
368
- }
369
-
370
330
  function createContext(global) {
371
331
  const cwd = process.cwd();
372
332
  const root = detectRoot(cwd, global.root);
373
- 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));
374
334
  return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
375
335
  }
376
336
 
377
- function runtimePath(ctx) {
378
- return path.join(ctx.root, '.hello-cc', 'runtime.json');
379
- }
380
-
381
- function webLogPath(ctx) {
382
- return path.join(ctx.root, '.hello-cc', 'web.log');
383
- }
384
-
385
- function globalStateDir() {
386
- return path.join(os.homedir(), '.hello-cc');
387
- }
388
-
389
- function globalRuntimePath() {
390
- return path.join(globalStateDir(), 'runtime.json');
391
- }
392
-
393
- function globalWebTokenPath() {
394
- return path.join(globalStateDir(), 'web-token');
395
- }
396
-
397
- function projectRegistryPath() {
398
- return path.join(globalStateDir(), 'projects.json');
399
- }
400
-
401
- function contextForProject(root, dbPath = null, base = {}) {
402
- const resolvedRoot = path.resolve(root);
403
- return {
404
- cwd: base.cwd || resolvedRoot,
405
- root: resolvedRoot,
406
- dbPath: path.resolve(dbPath || path.join(resolvedRoot, '.hello-cc', 'mesh.db')),
407
- json: Boolean(base.json),
408
- explicitRoot: true
409
- };
410
- }
411
-
412
337
  function projectRecord(ctx) {
413
338
  return {
414
339
  root: ctx.root,
@@ -428,7 +353,7 @@ function readProjectRegistry() {
428
353
  .filter((p) => p && typeof p.root === 'string')
429
354
  .map((p) => ({
430
355
  root: path.resolve(p.root),
431
- db: path.resolve(p.db || path.join(p.root, '.hello-cc', 'mesh.db')),
356
+ db: path.resolve(p.db || projectDbPath(p.root)),
432
357
  name: String(p.name || path.basename(p.root) || p.root),
433
358
  last_seen_at: Number.parseInt(p.last_seen_at || '0', 10) || 0
434
359
  }));
@@ -445,7 +370,7 @@ function writeProjectRegistry(projects) {
445
370
  if (!project?.root) continue;
446
371
  unique.set(path.resolve(project.root), {
447
372
  root: path.resolve(project.root),
448
- db: path.resolve(project.db || path.join(project.root, '.hello-cc', 'mesh.db')),
373
+ db: path.resolve(project.db || projectDbPath(project.root)),
449
374
  name: String(project.name || path.basename(project.root) || project.root),
450
375
  last_seen_at: Number.parseInt(project.last_seen_at || '0', 10) || 0
451
376
  });
@@ -485,15 +410,6 @@ function writeGlobalRuntime(runtime) {
485
410
  return file;
486
411
  }
487
412
 
488
- function runtimeConnectHost(host) {
489
- if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
490
- return host;
491
- }
492
-
493
- function runtimeBaseUrl(host, port) {
494
- return `http://${runtimeConnectHost(host)}:${port}`;
495
- }
496
-
497
413
  function writeRuntime(ctx, runtime) {
498
414
  const file = runtimePath(ctx);
499
415
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -536,7 +452,7 @@ function readRuntimeFile(ctx) {
536
452
 
537
453
  async function probeRuntime(runtime) {
538
454
  if (!runtime?.base_url) return false;
539
- const url = new URL('/api/runtime', runtime.base_url);
455
+ const url = runtimeApiUrl(runtime, '/api/runtime');
540
456
  const headers = {};
541
457
  if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
542
458
  try {
@@ -589,99 +505,10 @@ function clearRuntime(ctx, pid = process.pid) {
589
505
  }
590
506
  }
591
507
 
592
- function shellQuoteArg(value) {
593
- const text = String(value);
594
- if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
595
- return `'${text.replace(/'/g, `'\\''`)}'`;
596
- }
597
-
598
508
  function shellCommand(args) {
599
509
  return args.map(shellQuoteArg).join(' ');
600
510
  }
601
511
 
602
- function runtimeUrlQuery(runtime, projectRoot = null) {
603
- const parts = [];
604
- if (runtime.token) parts.push(`token=${encodeURIComponent(runtime.token)}`);
605
- if (projectRoot) parts.push(`project=${encodeURIComponent(projectRoot)}`);
606
- return parts.length ? `?${parts.join('&')}` : '';
607
- }
608
-
609
- function readStoredWebToken() {
610
- const file = globalWebTokenPath();
611
- if (!fs.existsSync(file)) return '';
612
- try {
613
- return fs.readFileSync(file, 'utf8').trim();
614
- } catch {
615
- return '';
616
- }
617
- }
618
-
619
- function writeStoredWebToken(token) {
620
- if (!token) return '';
621
- const file = globalWebTokenPath();
622
- fs.mkdirSync(path.dirname(file), { recursive: true });
623
- fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
624
- fs.chmodSync(file, 0o600);
625
- return file;
626
- }
627
-
628
- function makeWebToken(opts) {
629
- validateWebTokenOpts(opts);
630
- if (opts['no-token']) return '';
631
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
632
- if (explicitToken) {
633
- writeStoredWebToken(explicitToken);
634
- return explicitToken;
635
- }
636
- const stored = readStoredWebToken();
637
- if (stored) return stored;
638
- const generated = randomBytes(24).toString('base64url');
639
- writeStoredWebToken(generated);
640
- return generated;
641
- }
642
-
643
- function validateWebTokenOpts(opts) {
644
- if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) {
645
- throw new CliError('BAD_ARGS', '--no-token cannot be combined with --token or HCC_WEB_TOKEN');
646
- }
647
- }
648
-
649
- function expectedWebHost(opts) {
650
- return opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
651
- }
652
-
653
- function webRuntimeMatchesRequest(runtime, opts) {
654
- if (!runtime) return false;
655
- if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
656
- const expectedHost = expectedWebHost(opts);
657
- if (runtime.host !== expectedHost) return false;
658
- const expectedPort = intOpt(opts, 'port', 8787);
659
- if (opts.port !== undefined && runtime.port !== expectedPort) return false;
660
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
661
- if (opts['no-token']) return !runtime.token;
662
- if (explicitToken) return runtime.token === explicitToken;
663
- return Boolean(runtime.token);
664
- }
665
-
666
- function rememberRuntimeToken(runtime, opts) {
667
- if (!runtime?.token || opts['no-token']) return;
668
- const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
669
- if (explicitToken && runtime.token !== explicitToken) return;
670
- writeStoredWebToken(runtime.token);
671
- }
672
-
673
- function publicRuntimeUrl(runtime, projectRoot = null) {
674
- const host = runtime.host === '0.0.0.0' || runtime.host === '::'
675
- ? '<machine-ip>'
676
- : runtime.host || runtimeConnectHost(runtime.host || '127.0.0.1');
677
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
678
- }
679
-
680
- function localRuntimeUrl(runtime, projectRoot = null) {
681
- const host = runtimeConnectHost(runtime.host || '127.0.0.1');
682
- return `http://${host}:${runtime.port}/${runtimeUrlQuery(runtime, projectRoot)}`;
683
- }
684
-
685
512
  function tailFile(file, maxBytes = 12000) {
686
513
  try {
687
514
  const stat = fs.statSync(file);
@@ -1419,7 +1246,7 @@ function parseCodexCommandArgs(args) {
1419
1246
 
1420
1247
  async function runtimeRequest(ctx, method, route, body = null, runtime = null) {
1421
1248
  const rt = runtime || readRuntime(ctx);
1422
- const url = new URL(route, rt.base_url);
1249
+ const url = runtimeApiUrl(rt, route);
1423
1250
  const headers = { 'Content-Type': 'application/json' };
1424
1251
  headers['X-HCC-Root'] = ctx.root;
1425
1252
  headers['X-HCC-DB'] = ctx.dbPath;
@@ -1453,38 +1280,6 @@ async function runtimeRequest(ctx, method, route, body = null, runtime = null) {
1453
1280
  return json || {};
1454
1281
  }
1455
1282
 
1456
- const sleepBuffer = new SharedArrayBuffer(4);
1457
- const sleepView = new Int32Array(sleepBuffer);
1458
-
1459
- function sleepSync(ms) {
1460
- Atomics.wait(sleepView, 0, 0, ms);
1461
- }
1462
-
1463
- function isSqliteBusy(err) {
1464
- const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
1465
- return err?.errcode === 5 ||
1466
- err?.errcode === 6 ||
1467
- err?.errcode === 261 ||
1468
- /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
1469
- }
1470
-
1471
- function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
1472
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1473
- try {
1474
- db.exec(sql);
1475
- return true;
1476
- } catch (err) {
1477
- if (!isSqliteBusy(err)) throw err;
1478
- if (attempt === attempts - 1) {
1479
- if (ignoreBusy) return false;
1480
- throw err;
1481
- }
1482
- sleepSync(delayMs);
1483
- }
1484
- }
1485
- return false;
1486
- }
1487
-
1488
1283
  let projectMigrationFanoutDepth = 0;
1489
1284
  const migratedRegisteredProjectDbs = new Set();
1490
1285
 
@@ -1494,7 +1289,7 @@ function connect(ctx) {
1494
1289
  db.exec('PRAGMA busy_timeout = 5000;');
1495
1290
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1496
1291
  db.exec('PRAGMA foreign_keys = ON;');
1497
- initSchema(db);
1292
+ initSchema(db, { beforePostMigrationIndexes: dedupePeerBindings });
1498
1293
  migrateRegisteredProjectDbs(ctx);
1499
1294
  return db;
1500
1295
  }
@@ -1519,7 +1314,7 @@ function migrateRegisteredProjectDbs(ctx) {
1519
1314
  db.exec('PRAGMA busy_timeout = 5000;');
1520
1315
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
1521
1316
  db.exec('PRAGMA foreign_keys = ON;');
1522
- initSchema(db);
1317
+ initSchema(db, { beforePostMigrationIndexes: dedupePeerBindings });
1523
1318
  migratedRegisteredProjectDbs.add(cacheKey);
1524
1319
  } catch (err) {
1525
1320
  throw new CliError('REGISTERED_DB_MIGRATION_FAILED', `Failed to migrate registered project database ${dbPath}: ${err?.message || err}`, {
@@ -1535,252 +1330,6 @@ function migrateRegisteredProjectDbs(ctx) {
1535
1330
  }
1536
1331
  }
1537
1332
 
1538
- function initSchema(db) {
1539
- const existingVersion = readSchemaVersion(db);
1540
- if (existingVersion > DB_SCHEMA_VERSION) {
1541
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1542
- db_schema_version: existingVersion,
1543
- supported_schema_version: DB_SCHEMA_VERSION
1544
- });
1545
- }
1546
- createBaseSchema(db);
1547
- runSchemaMigrations(db);
1548
- dedupePeerBindings(db);
1549
- execWithBusyRetry(db, `
1550
- CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
1551
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
1552
- ON peer_bindings(provider, provider_session_id)
1553
- WHERE provider_session_id IS NOT NULL;
1554
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
1555
- ON peer_bindings(provider, provider_session_name)
1556
- WHERE provider_session_name IS NOT NULL;
1557
- CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
1558
- ON peer_bindings(runtime_target)
1559
- WHERE runtime_target IS NOT NULL;
1560
- `);
1561
- }
1562
-
1563
- function createBaseSchema(db) {
1564
- execWithBusyRetry(db, `
1565
- CREATE TABLE IF NOT EXISTS meta (
1566
- key TEXT PRIMARY KEY,
1567
- value TEXT NOT NULL
1568
- );
1569
-
1570
- CREATE TABLE IF NOT EXISTS schema_migrations (
1571
- version INTEGER PRIMARY KEY,
1572
- name TEXT NOT NULL,
1573
- applied_at INTEGER NOT NULL
1574
- );
1575
-
1576
- CREATE TABLE IF NOT EXISTS peers (
1577
- id TEXT PRIMARY KEY,
1578
- kind TEXT NOT NULL,
1579
- role TEXT,
1580
- worktree TEXT,
1581
- branch TEXT,
1582
- pid INTEGER,
1583
- status TEXT NOT NULL DEFAULT 'idle',
1584
- capabilities TEXT,
1585
- created_at INTEGER NOT NULL,
1586
- last_seen_at INTEGER NOT NULL
1587
- );
1588
-
1589
- CREATE TABLE IF NOT EXISTS peer_bindings (
1590
- peer TEXT PRIMARY KEY,
1591
- provider TEXT NOT NULL,
1592
- provider_session_id TEXT,
1593
- provider_session_name TEXT,
1594
- resume_mode TEXT NOT NULL DEFAULT 'new',
1595
- resume_arg TEXT,
1596
- command TEXT,
1597
- transport TEXT NOT NULL,
1598
- runtime_session_id TEXT,
1599
- runtime_target TEXT,
1600
- created_at INTEGER NOT NULL,
1601
- updated_at INTEGER NOT NULL,
1602
- FOREIGN KEY (peer) REFERENCES peers(id) ON DELETE CASCADE
1603
- );
1604
-
1605
- CREATE TABLE IF NOT EXISTS tasks (
1606
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1607
- title TEXT NOT NULL,
1608
- body TEXT,
1609
- status TEXT NOT NULL DEFAULT 'pending',
1610
- assignee TEXT,
1611
- owner TEXT,
1612
- parent_id INTEGER,
1613
- team_role TEXT,
1614
- priority INTEGER NOT NULL DEFAULT 100,
1615
- created_by TEXT,
1616
- claimed_at INTEGER,
1617
- completed_at INTEGER,
1618
- created_at INTEGER NOT NULL,
1619
- updated_at INTEGER NOT NULL
1620
- );
1621
-
1622
- CREATE TABLE IF NOT EXISTS messages (
1623
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1624
- sender TEXT NOT NULL,
1625
- recipient TEXT,
1626
- task_id INTEGER,
1627
- kind TEXT NOT NULL DEFAULT 'note',
1628
- body TEXT NOT NULL,
1629
- reply_to INTEGER,
1630
- thread_id INTEGER,
1631
- created_at INTEGER NOT NULL
1632
- );
1633
-
1634
- CREATE TABLE IF NOT EXISTS message_reads (
1635
- message_id INTEGER NOT NULL,
1636
- peer TEXT NOT NULL,
1637
- read_at INTEGER NOT NULL,
1638
- PRIMARY KEY (message_id, peer),
1639
- FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
1640
- );
1641
-
1642
- CREATE TABLE IF NOT EXISTS locks (
1643
- resource TEXT PRIMARY KEY,
1644
- owner TEXT NOT NULL,
1645
- task_id INTEGER,
1646
- reason TEXT,
1647
- expires_at INTEGER NOT NULL,
1648
- created_at INTEGER NOT NULL
1649
- );
1650
-
1651
- CREATE TABLE IF NOT EXISTS handoffs (
1652
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1653
- task_id INTEGER,
1654
- from_peer TEXT NOT NULL,
1655
- to_peer TEXT,
1656
- summary TEXT NOT NULL,
1657
- changed_files TEXT,
1658
- tests TEXT,
1659
- risks TEXT,
1660
- created_at INTEGER NOT NULL
1661
- );
1662
-
1663
- CREATE TABLE IF NOT EXISTS events (
1664
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1665
- type TEXT NOT NULL,
1666
- actor TEXT,
1667
- task_id INTEGER,
1668
- payload TEXT,
1669
- created_at INTEGER NOT NULL
1670
- );
1671
-
1672
- CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority, id);
1673
- CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
1674
- CREATE INDEX IF NOT EXISTS idx_messages_recipient_id ON messages(recipient, id);
1675
- CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
1676
- CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
1677
- `);
1678
- }
1679
-
1680
- function tableColumns(db, tableName) {
1681
- return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
1682
- }
1683
-
1684
- function tableExists(db, tableName) {
1685
- return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
1686
- }
1687
-
1688
- function readSchemaVersion(db) {
1689
- const metaExists = tableExists(db, 'meta');
1690
- const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
1691
- const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
1692
- const migrationVersion = tableExists(db, 'schema_migrations')
1693
- ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
1694
- : 0;
1695
- return Math.max(metaVersion, pragmaVersion, migrationVersion);
1696
- }
1697
-
1698
- function writeSchemaVersion(db, version) {
1699
- db.prepare(`
1700
- INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1701
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
1702
- `).run(String(version));
1703
- db.exec(`PRAGMA user_version = ${Number(version)}`);
1704
- }
1705
-
1706
- function recordSchemaMigration(db, version, name) {
1707
- db.prepare(`
1708
- INSERT INTO schema_migrations(version, name, applied_at)
1709
- VALUES (?, ?, ?)
1710
- ON CONFLICT(version) DO NOTHING
1711
- `).run(version, name, now());
1712
- }
1713
-
1714
- function addColumnIfMissing(db, tableName, columnName, ddl) {
1715
- const columns = tableColumns(db, tableName);
1716
- if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
1717
- }
1718
-
1719
- function runSchemaMigrations(db) {
1720
- const migrations = [
1721
- {
1722
- version: 1,
1723
- name: 'baseline',
1724
- up() {
1725
- // createBaseSchema already created the baseline tables.
1726
- }
1727
- },
1728
- {
1729
- version: 2,
1730
- name: 'peer binding runtime targets',
1731
- up(database) {
1732
- addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
1733
- }
1734
- },
1735
- {
1736
- version: 3,
1737
- name: 'threaded messages',
1738
- up(database) {
1739
- addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
1740
- addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
1741
- database.exec(`
1742
- UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
1743
- CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
1744
- `);
1745
- }
1746
- },
1747
- {
1748
- version: 4,
1749
- name: 'team task hierarchy',
1750
- up(database) {
1751
- addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
1752
- addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
1753
- database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
1754
- }
1755
- }
1756
- ];
1757
- let version = readSchemaVersion(db);
1758
- if (version > DB_SCHEMA_VERSION) {
1759
- throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
1760
- db_schema_version: version,
1761
- supported_schema_version: DB_SCHEMA_VERSION
1762
- });
1763
- }
1764
- const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
1765
- tx(db, () => {
1766
- for (const migration of migrations) {
1767
- if (version >= migration.version && applied.has(migration.version)) continue;
1768
- migration.up(db);
1769
- recordSchemaMigration(db, migration.version, migration.name);
1770
- applied.add(migration.version);
1771
- version = migration.version;
1772
- writeSchemaVersion(db, version);
1773
- }
1774
- if (version === 0) {
1775
- version = DB_SCHEMA_VERSION;
1776
- }
1777
- for (const migration of migrations) {
1778
- if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
1779
- }
1780
- writeSchemaVersion(db, version);
1781
- });
1782
- }
1783
-
1784
1333
  function peerBindingRuntimeRank(row) {
1785
1334
  if (row?.runtime_target && row.transport === 'tmux') return 50;
1786
1335
  if (row?.runtime_target) return 40;
@@ -1873,22 +1422,6 @@ function dedupePeerBindings(db) {
1873
1422
  }
1874
1423
  }
1875
1424
 
1876
- function tx(db, fn) {
1877
- execWithBusyRetry(db, 'BEGIN IMMEDIATE;');
1878
- try {
1879
- const result = fn();
1880
- db.exec('COMMIT;');
1881
- return result;
1882
- } catch (err) {
1883
- try {
1884
- db.exec('ROLLBACK;');
1885
- } catch {
1886
- // Ignore rollback failures; the original error is more useful.
1887
- }
1888
- throw err;
1889
- }
1890
- }
1891
-
1892
1425
  function addEvent(db, type, actor, taskId, payload) {
1893
1426
  db.prepare(`
1894
1427
  INSERT INTO events(type, actor, task_id, payload, created_at)
@@ -2239,11 +1772,6 @@ function parseEventPayload(row) {
2239
1772
  }
2240
1773
  }
2241
1774
 
2242
- function compactText(value, limit = 160) {
2243
- const text = String(value || '').replace(/\s+/g, ' ').trim();
2244
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
2245
- }
2246
-
2247
1775
  function uniqueList(values) {
2248
1776
  return [...new Set(values.filter(Boolean).map(String))];
2249
1777
  }
@@ -2287,6 +1815,7 @@ const TIMELINE_EVENT_ALLOW = new Set([
2287
1815
  'task.blocked',
2288
1816
  'task.done',
2289
1817
  'task.abandoned',
1818
+ 'task.takeover',
2290
1819
  'team.started',
2291
1820
  'lock.acquired',
2292
1821
  'lock.released',
@@ -2388,7 +1917,7 @@ function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = []
2388
1917
  peers: uniqueList([lock.owner]),
2389
1918
  task_id: lock.task_id || null,
2390
1919
  kind: 'active',
2391
- title: `lock ${lock.resource}`,
1920
+ title: `lock ${lockLabel(lock)}`,
2392
1921
  text: compactText(lock.reason || `owner=${lock.owner}`),
2393
1922
  direction: 'project'
2394
1923
  };
@@ -2436,6 +1965,51 @@ function makeAction(kind, argv, reason, mutates = true, extra = {}) {
2436
1965
  };
2437
1966
  }
2438
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
+
2439
2013
  function looksLikeMultiTask(task) {
2440
2014
  if (!task) return false;
2441
2015
  const text = `${task.title || ''}\n${task.body || ''}`;
@@ -2457,6 +2031,63 @@ function selectCurrentTask(tasks, peerId) {
2457
2031
  return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
2458
2032
  }
2459
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
+
2460
2091
  function summarizeTask(task) {
2461
2092
  if (!task) return null;
2462
2093
  return {
@@ -2467,8 +2098,151 @@ function summarizeTask(task) {
2467
2098
  assignee: task.assignee || null,
2468
2099
  parent_id: task.parent_id || null,
2469
2100
  team_role: task.team_role || null,
2470
- 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
+ }
2471
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
+ });
2472
2246
  }
2473
2247
 
2474
2248
  function deriveAutomation(snapshot, peer = null, opts = {}) {
@@ -2479,11 +2253,18 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2479
2253
  const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
2480
2254
  const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
2481
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);
2482
2261
  const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
2483
2262
  const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
2484
2263
  const requestedLocks = resources.map((resource) => {
2485
- const lock = (snapshot.locks || []).find((row) => row.resource === resource && Number(row.expires_at || 0) > t);
2486
- return { resource, lock };
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 };
2487
2268
  });
2488
2269
  const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
2489
2270
  const missingLock = requestedLocks.find((entry) => !entry.lock);
@@ -2501,14 +2282,14 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2501
2282
  known: Boolean(peerRow),
2502
2283
  active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= ACTIVE_PEER_TTL),
2503
2284
  age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
2504
- } : null,
2505
- current_task: summarizeTask(ownedTask),
2506
- phase: 'idle',
2507
- next_action: makeAction('none', [], 'no immediate coordination action', false),
2508
- actions: [],
2509
- finish_actions: [],
2510
- warnings
2511
- };
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
+ };
2512
2293
 
2513
2294
  const orderedUnread = unread
2514
2295
  .filter((message) => message.sender !== peerId)
@@ -2529,15 +2310,41 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2529
2310
  if (!ownedTask && assignedTasks.length) {
2530
2311
  const task = assignedTasks[0];
2531
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
+ ));
2532
2322
  } else if (!ownedTask && availableTasks.length) {
2533
2323
  taskActions.push(makeAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
2534
2324
  }
2535
2325
 
2536
2326
  if (ownedTask) {
2537
- if (conflict) {
2538
- lockActions.push(makeAction('msg.send', ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${conflict.resource}; it is locked by ${conflict.lock.owner}.`], `${conflict.resource} is locked by ${conflict.lock.owner}`, true, { task_id: ownedTask.id, resource: conflict.resource, lock_owner: conflict.lock.owner }));
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
+ ));
2539
2340
  } else if (missingLock) {
2540
- lockActions.push(makeAction('lock.acquire', ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), '--resource', missingLock.resource, '--ttl', String(DEFAULT_LOCK_TTL), '--reason', '<work>'], `task #${ownedTask.id} needs lock for ${missingLock.resource}`, true, { task_id: ownedTask.id, resource: missingLock.resource }));
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
+ ));
2541
2348
  }
2542
2349
  if (ownedTask.status === 'claimed') {
2543
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 }));
@@ -2551,7 +2358,7 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2551
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 }));
2552
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 }));
2553
2360
  for (const lock of ownLocks) {
2554
- automation.finish_actions.push(makeAction('lock.release', ['lock', 'release', '--peer', peerId, '--resource', lock.resource], `release ${lock.resource}`, true, { task_id: lock.task_id || null, resource: lock.resource }));
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) }));
2555
2362
  }
2556
2363
  }
2557
2364
  automation.actions.push(...lockActions, ...messageActions, ...taskActions);
@@ -2566,6 +2373,7 @@ function deriveAutomation(snapshot, peer = null, opts = {}) {
2566
2373
  : automation.next_action);
2567
2374
  if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
2568
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';
2569
2377
  else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
2570
2378
  else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
2571
2379
  else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
@@ -2620,6 +2428,8 @@ function formatOpenTaskLine(task) {
2620
2428
  const parts = [`#${task.id}`, task.status];
2621
2429
  if (task.owner) parts.push(`owner=${task.owner}`);
2622
2430
  if (task.assignee) parts.push(`assignee=${task.assignee}`);
2431
+ const ownerState = taskOwnerStateText(task);
2432
+ if (ownerState) parts.push(`owner_state=${ownerState}`);
2623
2433
  return `${parts.join(' ')}: ${task.title}`;
2624
2434
  }
2625
2435
 
@@ -2644,14 +2454,15 @@ function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
2644
2454
  ORDER BY last_seen_at DESC, id ASC
2645
2455
  LIMIT 200
2646
2456
  `).all(t);
2647
- const tasks = queryOpenTasks(db, 200);
2457
+ const taskRows = queryOpenTasks(db, 200);
2648
2458
  const locks = db.prepare(`
2649
- SELECT resource, owner, task_id, reason, expires_at, created_at
2459
+ SELECT resource, base_resource, scope, owner, task_id, reason, expires_at, created_at
2650
2460
  FROM locks
2651
2461
  WHERE expires_at > ?
2652
2462
  ORDER BY resource ASC
2653
2463
  LIMIT 200
2654
2464
  `).all(t);
2465
+ const tasks = annotateTasksWithLiveness(taskRows, peers, locks, t);
2655
2466
  const messages = peer
2656
2467
  ? queryInbox(db, peer, false, 50)
2657
2468
  : db.prepare(`
@@ -2742,7 +2553,7 @@ function buildHookCoordinationContext(db, ctx, peerId) {
2742
2553
  if (snapshot.locks.length > 0) {
2743
2554
  parts.push('[hello-cc active locks]');
2744
2555
  parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
2745
- `- ${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}` : ''}`));
2746
2557
  } else {
2747
2558
  parts.push('[hello-cc active locks]\n(none)');
2748
2559
  }
@@ -2764,153 +2575,23 @@ function ackMessages(db, peerId, messages) {
2764
2575
  }
2765
2576
  }
2766
2577
 
2767
- function formatJson(ok, dataOrError) {
2768
- if (ok) return JSON.stringify({ ok: true, data: dataOrError }, null, 2);
2769
- return JSON.stringify({ ok: false, error: dataOrError }, null, 2);
2578
+ function writeGuidance(ctx) {
2579
+ return writeGuidanceForRoot(ctx.root);
2770
2580
  }
2771
2581
 
2772
- function printResult(ctx, data, render) {
2773
- if (ctx.json) {
2774
- console.log(formatJson(true, data));
2775
- } else {
2776
- const output = render ? render(data) : String(data ?? '');
2777
- if (output) console.log(output);
2778
- }
2582
+ function removeGuidanceBlocks(ctx) {
2583
+ return removeGuidanceBlocksForRoot(ctx.root);
2779
2584
  }
2780
2585
 
2781
- function shellExports(values) {
2782
- return Object.entries(values)
2783
- .map(([key, value]) => `export ${key}=${shellQuoteArg(value)}`)
2784
- .join('\n');
2785
- }
2786
-
2787
- function table(rows, columns) {
2788
- if (!rows.length) return '(none)';
2789
- const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
2790
- const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
2791
- const sep = widths.map((w) => '-'.repeat(w)).join(' ');
2792
- const body = rows.map((row) => columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
2793
- return [header, sep, ...body].join('\n');
2794
- }
2795
-
2796
- function writeGuidance(ctx) {
2797
- const guidePath = path.join(ctx.root, '.hello-cc', 'HCC.md');
2798
- fs.mkdirSync(path.dirname(guidePath), { recursive: true });
2799
- const content = `# hello-cc Coordination Rules
2800
-
2801
- This project may be edited by multiple Claude Code and Codex CLI sessions.
2802
-
2803
- Use \`hcc\` as the source of truth for cross-session coordination. If the user
2804
- asks what other sessions are doing, do not answer from generic model knowledge.
2805
- Run the project-local commands below and summarize the result.
2806
-
2807
- Each terminal session must use a stable peer id, for example codex-a,
2808
- codex-b, claude-a, claude-b, or gpu-runner-a.
2809
-
2810
- Status checks:
2811
- - \`hcc peers\` shows known Claude/Codex/shell sessions.
2812
- - \`hcc task list\` shows all open tasks. Tasks stay visible until \`done\` or
2813
- \`abandoned\`; they are not read/unread items.
2814
- - \`hcc msg inbox\` shows unread messages for this session.
2815
- - \`hcc lock list\` shows active advisory locks.
2816
- - \`hcc status\` summarizes peers, tasks, locks, inbox, and recent events.
2817
- - \`hcc team status --task N\` summarizes explicit subtasks for a parent task.
2818
-
2819
- Read-only work:
2820
- - Reading files, searching, inspecting \`git diff\` / \`git status\`, reviewing
2821
- uncommitted changes, and running non-mutating checks such as \`node --check\`
2822
- do not require locks.
2823
- - For read-only review, do not acquire file locks. If another live peer already
2824
- holds write locks on changed files, report that the review is only a current
2825
- snapshot and not a final commit-ready verdict.
2826
- - Use tasks, messages, events, or handoffs to make read-only review visible;
2827
- do not use advisory locks to reserve files you are only reading.
2828
-
2829
- Review and monitoring:
2830
- - Reviewing another peer's work is a read-only activity. You may inspect current
2831
- diffs, task state, messages, handoffs, and non-mutating checks while that peer
2832
- keeps its write locks.
2833
- - If you find a concrete issue in another peer's task, proactively send that
2834
- peer an \`hcc msg\` with the task id, affected file or behavior, why it is a
2835
- problem, and a suggested fix or verification step.
2836
- - Continue monitoring until the peer fixes the issue, records a follow-up task,
2837
- or hands off. Do not silently treat a snapshot review as final approval.
2838
-
2839
- Before mutating work:
2840
- - Register with hcc.
2841
- - Read current status with \`hcc status\`, \`hcc state\`, \`hcc task list\`, and \`hcc msg inbox\`.
2842
- - If \`hcc state\` shows a current task for this peer, continue that task until
2843
- handoff, done, or blocked instead of claiming a new task.
2844
- - Claim one task before editing or mutating shared state when this peer has no
2845
- current task.
2846
- - If work needs multiple peers, use \`hcc team plan\` and \`hcc team start\`
2847
- explicitly; do not create or claim hidden extra tasks.
2848
-
2849
- Before editing or mutating shared resources:
2850
- - Acquire an advisory lock for the file, directory, module, or shared resource.
2851
- - If another live peer holds the lock, message that peer instead of editing.
2852
- - Acquire locks only before writes or shared-resource mutation, including file
2853
- edits, formatting, staging/committing through \`.git/index\`, intentional DB
2854
- data/schema changes, starting/stopping/restarting shared runtimes, or taking
2855
- over tmux/session/port resources.
2856
- - Commit-readiness checks are read-only until staging begins. Do not lock source
2857
- files for review. Before committing, ensure no live peer holds write locks on
2858
- the files to be committed, then lock \`.git/index\` only while staging and
2859
- committing.
2860
-
2861
- During work:
2862
- - Keep changes scoped to the claimed task.
2863
- - Send progress messages when another session needs context.
2864
-
2865
- Before stopping:
2866
- - Mark the task done or blocked.
2867
- - Create a handoff with changed files, tests, and remaining risks.
2868
- - Release locks you no longer need.
2869
- `;
2870
- fs.writeFileSync(guidePath, content);
2871
- const clause = `\n<!-- hello-cc:start -->\n\n${content}\n<!-- hello-cc:end -->\n`;
2872
- for (const file of ['CLAUDE.md', 'AGENTS.md']) {
2873
- const target = path.join(ctx.root, file);
2874
- if (!fs.existsSync(target)) {
2875
- fs.writeFileSync(target, clause.trimStart());
2876
- continue;
2877
- }
2878
- const existing = fs.readFileSync(target, 'utf8');
2879
- const updated = existing.includes('<!-- hello-cc:start -->')
2880
- ? existing.replace(/<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->/, clause.trim())
2881
- : `${existing.trimEnd()}\n${clause}`;
2882
- fs.writeFileSync(target, updated);
2883
- }
2884
- return guidePath;
2885
- }
2886
-
2887
- function removeGuidanceBlocks(ctx) {
2888
- const changed = [];
2889
- for (const file of ['CLAUDE.md', 'AGENTS.md']) {
2890
- const target = path.join(ctx.root, file);
2891
- if (!fs.existsSync(target)) continue;
2892
- const existing = fs.readFileSync(target, 'utf8');
2893
- const updated = existing
2894
- .replace(/\n?<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->\n?/g, '\n')
2895
- .replace(/\n{3,}/g, '\n\n')
2896
- .trimEnd();
2897
- if (updated !== existing) {
2898
- fs.writeFileSync(target, updated ? `${updated}\n` : '');
2899
- changed.push(target);
2900
- }
2901
- }
2902
- return changed;
2903
- }
2904
-
2905
- function normalizeListText(value, fallback = []) {
2906
- if (value === undefined || value === null || value === '') return JSON.stringify(fallback);
2907
- const text = String(value);
2908
- try {
2909
- JSON.parse(text);
2910
- return text;
2911
- } catch {
2912
- return JSON.stringify(text.split(',').map((item) => item.trim()).filter(Boolean));
2913
- }
2586
+ function normalizeListText(value, fallback = []) {
2587
+ if (value === undefined || value === null || value === '') return JSON.stringify(fallback);
2588
+ const text = String(value);
2589
+ try {
2590
+ JSON.parse(text);
2591
+ return text;
2592
+ } catch {
2593
+ return JSON.stringify(text.split(',').map((item) => item.trim()).filter(Boolean));
2594
+ }
2914
2595
  }
2915
2596
 
2916
2597
  function changedFiles(cwd) {
@@ -3069,6 +2750,7 @@ async function cmdTask(ctx, args) {
3069
2750
  if (sub === 'create') return taskCreate(ctx, args.slice(1));
3070
2751
  if (sub === 'list') return taskList(ctx, args.slice(1));
3071
2752
  if (sub === 'claim') return taskClaim(ctx, args.slice(1));
2753
+ if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
3072
2754
  if (sub === 'next') return taskNext(ctx, args.slice(1));
3073
2755
  if (sub === 'update') return taskUpdate(ctx, args.slice(1));
3074
2756
  if (sub === 'done') return taskDone(ctx, args.slice(1));
@@ -3136,12 +2818,20 @@ async function taskList(ctx, args) {
3136
2818
  } else {
3137
2819
  rows = queryOpenTasks(db, limit);
3138
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);
3139
2828
  printResult(ctx, rows, (data) => table(data, [
3140
2829
  { label: 'id', value: (r) => `#${r.id}` },
3141
2830
  { label: 'status', value: (r) => r.status },
3142
2831
  { label: 'prio', value: (r) => r.priority },
3143
2832
  { label: 'assignee', value: (r) => r.assignee || '' },
3144
2833
  { label: 'owner', value: (r) => r.owner || '' },
2834
+ { label: 'owner_state', value: (r) => taskOwnerStateText(r) },
3145
2835
  { label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
3146
2836
  { label: 'role', value: (r) => r.team_role || '' },
3147
2837
  { label: 'title', value: (r) => r.title }
@@ -3149,86 +2839,45 @@ async function taskList(ctx, args) {
3149
2839
  }
3150
2840
 
3151
2841
  async function taskClaim(ctx, args) {
3152
- 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);
3153
2854
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3154
2855
  const peer = identity.id;
3155
2856
  const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
3156
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 });
3157
2861
  const db = connect(ctx);
3158
2862
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3159
- const task = tx(db, () => {
3160
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
3161
- if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
3162
- if (row.owner && row.owner !== peer && !opts.force) {
3163
- throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
3164
- }
3165
- if (row.assignee && row.assignee !== peer && !opts.force) {
3166
- throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
3167
- }
3168
- if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !opts.force) {
3169
- throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
3170
- }
3171
- const t = now();
3172
- db.prepare(`
3173
- UPDATE tasks
3174
- SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
3175
- WHERE id = ?
3176
- `).run(peer, t, t, id);
3177
- addEvent(db, 'task.claimed', peer, id, { previous_owner: row.owner, force: Boolean(opts.force) });
3178
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
3179
- });
3180
- 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}`);
3181
2865
  }
3182
2866
 
3183
2867
  async function taskNext(ctx, args) {
3184
2868
  const opts = parseOpts(args, { booleans: ['force'] });
3185
2869
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3186
2870
  const peer = identity.id;
2871
+ const count = positiveIntOpt(opts, 'count', 1, { max: 50 });
3187
2872
  const db = connect(ctx);
3188
2873
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3189
- const task = tx(db, () => {
3190
- if (!opts.force) {
3191
- const current = db.prepare(`
3192
- SELECT * FROM tasks
3193
- WHERE owner = ?
3194
- AND status IN ('claimed', 'running', 'review', 'blocked')
3195
- ORDER BY
3196
- CASE status
3197
- WHEN 'running' THEN 0
3198
- WHEN 'claimed' THEN 1
3199
- WHEN 'review' THEN 2
3200
- WHEN 'blocked' THEN 3
3201
- ELSE 4
3202
- END,
3203
- priority ASC,
3204
- id ASC
3205
- LIMIT 1
3206
- `).get(peer);
3207
- if (current) return { ...current, current: true };
3208
- }
3209
- const row = db.prepare(`
3210
- SELECT * FROM tasks
3211
- WHERE status = 'pending'
3212
- AND owner IS NULL
3213
- AND (assignee IS NULL OR assignee = ?)
3214
- ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
3215
- LIMIT 1
3216
- `).get(peer, peer);
3217
- if (!row) return null;
3218
- const t = now();
3219
- db.prepare(`
3220
- UPDATE tasks
3221
- SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
3222
- WHERE id = ? AND owner IS NULL AND status = 'pending'
3223
- `).run(peer, t, t, row.id);
3224
- addEvent(db, 'task.claimed', peer, row.id, { next: true });
3225
- return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
3226
- });
3227
- 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) => {
3228
2876
  if (!data) return 'no pending task';
3229
- return data.current
3230
- ? `current task #${data.id}: ${data.title} (${data.status})`
3231
- : `claimed task #${data.id}: ${data.title}`;
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}`;
3232
2881
  });
3233
2882
  }
3234
2883
 
@@ -3846,7 +3495,7 @@ async function lockAcquire(ctx, args) {
3846
3495
  const opts = parseOpts(args);
3847
3496
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3848
3497
  const peer = identity.id;
3849
- const resource = required(opts, 'resource');
3498
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3850
3499
  const taskId = intOpt(opts, 'task', null);
3851
3500
  const ttl = intOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
3852
3501
  const reason = opts.reason || '';
@@ -3854,66 +3503,73 @@ async function lockAcquire(ctx, args) {
3854
3503
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3855
3504
  const lock = tx(db, () => {
3856
3505
  const t = now();
3857
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3858
- if (existing && existing.expires_at > t && existing.owner !== peer) {
3859
- throw new CliError('LOCK_HELD', `Resource is locked by ${existing.owner}`, {
3860
- resource,
3861
- owner: existing.owner,
3862
- 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)
3863
3516
  });
3864
3517
  }
3518
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
3865
3519
  db.prepare(`
3866
- INSERT INTO locks(resource, owner, task_id, reason, expires_at, created_at)
3867
- VALUES (?, ?, ?, ?, ?, ?)
3520
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at)
3521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3868
3522
  ON CONFLICT(resource) DO UPDATE SET
3523
+ base_resource = excluded.base_resource,
3524
+ scope = excluded.scope,
3869
3525
  owner = excluded.owner,
3870
3526
  task_id = excluded.task_id,
3871
3527
  reason = excluded.reason,
3872
3528
  expires_at = excluded.expires_at
3873
- `).run(resource, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
3874
- addEvent(db, 'lock.acquired', peer, taskId, { resource, ttl, previous_owner: existing ? existing.owner : null });
3875
- 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);
3876
3532
  });
3877
- 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)}`);
3878
3534
  }
3879
3535
 
3880
3536
  async function lockRelease(ctx, args) {
3881
3537
  const opts = parseOpts(args, { booleans: ['force'] });
3882
3538
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3883
3539
  const peer = identity.id;
3884
- const resource = required(opts, 'resource');
3540
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3885
3541
  const db = connect(ctx);
3886
3542
  touchCurrentPeer(db, ctx, identity, null, 'shell');
3887
3543
  const result = tx(db, () => {
3888
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3889
- 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 };
3890
3546
  if (existing.owner !== peer && !opts.force) {
3891
3547
  throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
3892
3548
  }
3893
- db.prepare('DELETE FROM locks WHERE resource = ?').run(resource);
3894
- addEvent(db, 'lock.released', peer, existing.task_id || null, { resource, force: Boolean(opts.force) });
3895
- 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 };
3896
3552
  });
3897
- 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)}`);
3898
3554
  }
3899
3555
 
3900
3556
  async function lockRenew(ctx, args) {
3901
3557
  const opts = parseOpts(args);
3902
3558
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
3903
3559
  const peer = identity.id;
3904
- const resource = required(opts, 'resource');
3560
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
3905
3561
  const ttl = intOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
3906
3562
  const db = connect(ctx);
3907
3563
  touchCurrentPeer(db, ctx, identity, 'working', 'shell');
3908
3564
  const lock = tx(db, () => {
3909
- const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(resource);
3910
- 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)}`);
3911
3567
  if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
3912
- db.prepare('UPDATE locks SET expires_at = ? WHERE resource = ?').run(now() + ttl, resource);
3913
- addEvent(db, 'lock.renewed', peer, existing.task_id || null, { resource, ttl });
3914
- 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);
3915
3571
  });
3916
- 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)}`);
3917
3573
  }
3918
3574
 
3919
3575
  async function lockList(ctx, args) {
@@ -3923,7 +3579,8 @@ async function lockList(ctx, args) {
3923
3579
  ? db.prepare('SELECT * FROM locks ORDER BY resource ASC').all()
3924
3580
  : db.prepare('SELECT * FROM locks WHERE expires_at > ? ORDER BY resource ASC').all(now());
3925
3581
  printResult(ctx, rows, (data) => table(data, [
3926
- { label: 'resource', value: (r) => r.resource },
3582
+ { label: 'resource', value: (r) => lockBaseResource(r) },
3583
+ { label: 'scope', value: (r) => lockScope(r) },
3927
3584
  { label: 'owner', value: (r) => r.owner },
3928
3585
  { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
3929
3586
  { label: 'expires', value: (r) => iso(r.expires_at) },
@@ -4009,36 +3666,46 @@ async function cmdStatus(ctx, args) {
4009
3666
  const opts = parseOpts(args);
4010
3667
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
4011
3668
  const peer = identity.id;
3669
+ const data = statusSummary(ctx, peer, identity);
3670
+ printResult(ctx, data, (s) => renderStatusSummary(s, peer));
3671
+ }
3672
+
3673
+ function statusSummary(ctx, peer = null, identity = null) {
4012
3674
  const db = connect(ctx);
4013
- touchCurrentPeer(db, ctx, identity, null, 'shell');
4014
- const t = now();
4015
- const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - ACTIVE_PEER_TTL).n;
4016
- const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - ACTIVE_PEER_TTL).n;
4017
- const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
4018
- const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
4019
- const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
4020
- const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
4021
- const data = { root: ctx.root, db: ctx.dbPath, active_peers: activePeers, stale_peers: stalePeers, tasks: taskRows, active_locks: locks, unread, recent_events: recent };
4022
- printResult(ctx, data, (s) => {
4023
- const taskSummary = s.tasks.length ? s.tasks.map((r) => `${r.status}:${r.n}`).join(', ') : 'none';
4024
- return [
4025
- `root: ${s.root}`,
4026
- `db: ${s.db}`,
4027
- `peers: active=${s.active_peers}, stale=${s.stale_peers}`,
4028
- `tasks: ${taskSummary}`,
4029
- `locks: active=${s.active_locks}`,
4030
- peer ? `inbox(${peer}): unread=${s.unread}` : null,
4031
- '',
4032
- 'recent events:',
4033
- table(s.recent_events, [
4034
- { label: 'id', value: (r) => `#${r.id}` },
4035
- { label: 'type', value: (r) => r.type },
4036
- { label: 'actor', value: (r) => r.actor || '' },
4037
- { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
4038
- { label: 'time', value: (r) => iso(r.created_at) }
4039
- ])
4040
- ].filter((line) => line !== null).join('\n');
4041
- });
3675
+ try {
3676
+ if (identity) touchCurrentPeer(db, ctx, identity, null, 'shell');
3677
+ const t = now();
3678
+ const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - ACTIVE_PEER_TTL).n;
3679
+ const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - ACTIVE_PEER_TTL).n;
3680
+ const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
3681
+ const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
3682
+ const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
3683
+ const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
3684
+ return { root: ctx.root, db: ctx.dbPath, active_peers: activePeers, stale_peers: stalePeers, tasks: taskRows, active_locks: locks, unread, recent_events: recent };
3685
+ } finally {
3686
+ db.close();
3687
+ }
3688
+ }
3689
+
3690
+ function renderStatusSummary(s, peer = null) {
3691
+ const taskSummary = s.tasks.length ? s.tasks.map((r) => `${r.status}:${r.n}`).join(', ') : 'none';
3692
+ return [
3693
+ `root: ${s.root}`,
3694
+ `db: ${s.db}`,
3695
+ `peers: active=${s.active_peers}, stale=${s.stale_peers}`,
3696
+ `tasks: ${taskSummary}`,
3697
+ `locks: active=${s.active_locks}`,
3698
+ peer ? `inbox(${peer}): unread=${s.unread}` : null,
3699
+ '',
3700
+ 'recent events:',
3701
+ table(s.recent_events, [
3702
+ { label: 'id', value: (r) => `#${r.id}` },
3703
+ { label: 'type', value: (r) => r.type },
3704
+ { label: 'actor', value: (r) => r.actor || '' },
3705
+ { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
3706
+ { label: 'time', value: (r) => iso(r.created_at) }
3707
+ ])
3708
+ ].filter((line) => line !== null).join('\n');
4042
3709
  }
4043
3710
 
4044
3711
  function normalizeStateResources(values) {
@@ -4052,7 +3719,7 @@ async function cmdState(ctx, args) {
4052
3719
  const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
4053
3720
  const peer = identity.id;
4054
3721
  const resources = normalizeStateResources(opts.resource || opts.resources || []);
4055
- 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 });
4056
3723
  printResult(ctx, snapshot, (data) => {
4057
3724
  const automation = data.automation;
4058
3725
  const lines = [
@@ -4103,75 +3770,22 @@ Coordination rules:
4103
3770
  - Claim exactly one task before editing.
4104
3771
  - If state shows a current task for ${peer}, continue that task before claiming another pending task.
4105
3772
  - Before editing a file, directory, module, or shared test resource, run:
4106
- ${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>
4107
3774
  - If a lock is held by another peer, message that peer instead of editing:
4108
3775
  ${cmd} msg send --from ${peer} --to <peer-id> --body "<question>"
4109
3776
  - Report progress or requests through msg send.
4110
3777
  - Before stopping, run tests, create a handoff, and release locks:
4111
3778
  ${cmd} handoff create --from ${peer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
4112
3779
  ${cmd} task done --peer ${peer} --id <task-id> --summary "<done summary>"
4113
- ${cmd} lock release --peer ${peer} --resource <path-or-module>
3780
+ ${cmd} lock release --peer ${peer} --resource <path-or-module> [--scope <scope>]
4114
3781
  `;
4115
3782
  printResult(ctx, { prompt: text }, () => text);
4116
3783
  }
4117
3784
 
4118
- function readRequestBody(req, limit = 1024 * 1024) {
4119
- return new Promise((resolve, reject) => {
4120
- let body = '';
4121
- req.on('data', (chunk) => {
4122
- body += chunk;
4123
- if (body.length > limit) {
4124
- reject(new CliError('REQUEST_TOO_LARGE', 'Request body is too large'));
4125
- req.destroy();
4126
- }
4127
- });
4128
- req.on('end', () => resolve(body));
4129
- req.on('error', reject);
4130
- });
4131
- }
4132
-
4133
- async function readJsonRequest(req) {
4134
- const body = await readRequestBody(req);
4135
- if (!body.trim()) return {};
4136
- return JSON.parse(body);
4137
- }
4138
-
4139
- function sendHttp(res, status, contentType, body) {
4140
- res.writeHead(status, {
4141
- 'Content-Type': contentType,
4142
- 'Cache-Control': 'no-store'
4143
- });
4144
- res.end(body);
4145
- }
4146
-
4147
- function sendJson(res, status, body) {
4148
- sendHttp(res, status, 'application/json; charset=utf-8', JSON.stringify(body, null, 2));
4149
- }
4150
-
4151
- function sendFile(res, filePath, contentType) {
4152
- try {
4153
- const body = fs.readFileSync(filePath);
4154
- res.writeHead(200, {
4155
- 'Content-Type': contentType,
4156
- 'Cache-Control': 'no-store'
4157
- });
4158
- res.end(body);
4159
- } catch {
4160
- sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Asset not found' } });
4161
- }
4162
- }
4163
-
4164
3785
  function isLoopbackHost(host) {
4165
3786
  return host === '127.0.0.1' || host === 'localhost' || host === '::1';
4166
3787
  }
4167
3788
 
4168
- function authOk(url, req, token) {
4169
- if (!token) return true;
4170
- const queryToken = url.searchParams.get('token');
4171
- const auth = req.headers.authorization || '';
4172
- return queryToken === token || auth === `Bearer ${token}`;
4173
- }
4174
-
4175
3789
  function defaultSessionCommand(kind) {
4176
3790
  if (kind === 'codex') return 'codex';
4177
3791
  if (kind === 'claude') return 'claude';
@@ -4478,1558 +4092,263 @@ function statusSnapshot(ctx, peer = null, opts = {}) {
4478
4092
  }
4479
4093
  }
4480
4094
 
4481
- function webIndexHtml() {
4482
- return `<!doctype html>
4483
- <html lang="en">
4484
- <head>
4485
- <meta charset="utf-8">
4486
- <meta name="viewport" content="width=device-width, initial-scale=1">
4487
- <title>hello-cc</title>
4488
- <link rel="stylesheet" href="/assets/xterm.css">
4489
- <style>
4490
- :root {
4491
- color-scheme: dark;
4492
- --bg: #101214;
4493
- --panel: #181b1f;
4494
- --panel-2: #20242a;
4495
- --border: #303640;
4496
- --text: #eef2f6;
4497
- --muted: #a3adba;
4498
- --accent: #40c4aa;
4499
- --warn: #f2bb4f;
4500
- --danger: #ff6b6b;
4501
- --ok: #75d17c;
4502
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
4503
- --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
4504
- }
4505
- * { box-sizing: border-box; }
4506
- body {
4507
- margin: 0;
4508
- min-height: 100vh;
4509
- background: var(--bg);
4510
- color: var(--text);
4511
- font-family: var(--sans);
4512
- overflow: hidden;
4513
- }
4514
- button, input, select {
4515
- font: inherit;
4516
- }
4517
- button {
4518
- border: 1px solid var(--border);
4519
- background: var(--panel-2);
4520
- color: var(--text);
4521
- height: 32px;
4522
- border-radius: 6px;
4523
- padding: 0 10px;
4524
- cursor: pointer;
4525
- }
4526
- button:hover { border-color: var(--accent); }
4527
- button.primary { background: #1b5f54; border-color: #267f71; }
4528
- button.danger { background: #5d252a; border-color: #8c333d; }
4529
- input, select {
4530
- width: 100%;
4531
- height: 32px;
4532
- border: 1px solid var(--border);
4533
- background: #0d0f12;
4534
- color: var(--text);
4535
- border-radius: 6px;
4536
- padding: 0 9px;
4537
- min-width: 0;
4538
- }
4539
- label {
4540
- display: grid;
4541
- gap: 4px;
4542
- color: var(--muted);
4543
- font-size: 12px;
4544
- }
4545
- .app {
4546
- position: relative;
4547
- height: 100vh;
4548
- display: grid;
4549
- --left-width: 320px;
4550
- --right-width: 360px;
4551
- grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
4552
- min-width: 640px;
4553
- transition: grid-template-columns .18s ease;
4554
- }
4555
- .app.resizing { transition: none; }
4556
- .app.left-collapsed { grid-template-columns: 0 minmax(0, 1fr) var(--right-width); }
4557
- .app.right-collapsed { grid-template-columns: var(--left-width) minmax(0, 1fr) 0; }
4558
- .app.left-collapsed.right-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
4559
- /* Hide sidebar borders when collapsed so no 1px seam remains. */
4560
- .app.left-collapsed .sidebar { border-right-width: 0; }
4561
- .app.right-collapsed .inspector { border-left-width: 0; }
4562
- .edge-resizer {
4563
- position: absolute;
4564
- top: 0;
4565
- bottom: 0;
4566
- z-index: 55;
4567
- width: 12px;
4568
- cursor: col-resize;
4569
- touch-action: none;
4570
- user-select: none;
4571
- background: transparent;
4572
- transition: left .18s ease, right .18s ease, background .12s;
4573
- }
4574
- .edge-resizer:hover,
4575
- .app.resizing .edge-resizer {
4576
- background: rgba(126, 231, 215, .08);
4577
- }
4578
- .app.resizing .edge-resizer { transition: none; }
4579
- .edge-resizer-left { left: var(--left-width); transform: translateX(-50%); }
4580
- .edge-resizer-right { right: var(--right-width); transform: translateX(50%); }
4581
- .app.left-collapsed .edge-resizer-left { left: 0; }
4582
- .app.right-collapsed .edge-resizer-right { right: 0; }
4583
- /* Small collapse handles centered vertically on each divider border.
4584
- They are children of .app (no overflow clip) and track the column edge. */
4585
- .edge-toggle {
4586
- position: absolute;
4587
- top: 50%;
4588
- z-index: 60;
4589
- width: 16px;
4590
- height: 44px;
4591
- padding: 0;
4592
- display: flex;
4593
- align-items: center;
4594
- justify-content: center;
4595
- font-size: 11px;
4596
- line-height: 1;
4597
- color: var(--muted);
4598
- background: var(--panel);
4599
- border: 1px solid var(--border);
4600
- border-radius: 6px;
4601
- cursor: col-resize;
4602
- touch-action: none;
4603
- user-select: none;
4604
- transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
4605
- }
4606
- .app.resizing .edge-toggle { transition: none; }
4607
- .edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
4608
- .edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
4609
- .edge-right { right: var(--right-width); transform: translate(50%, -50%); }
4610
- .app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
4611
- .app.right-collapsed .edge-right { right: 0; transform: translate(0, -50%); }
4612
- .sidebar, .inspector {
4613
- min-height: 0;
4614
- min-width: 0;
4615
- max-width: 100%;
4616
- overflow: hidden;
4617
- background: var(--panel);
4618
- border-right: 1px solid var(--border);
4619
- display: grid;
4620
- grid-template-rows: auto auto auto auto 1fr;
4621
- }
4622
- .inspector {
4623
- border-right: 0;
4624
- border-left: 1px solid var(--border);
4625
- grid-template-rows: auto 1fr;
4626
- }
4627
- .sidebar > *, .inspector > * {
4628
- min-width: 0;
4629
- max-width: 100%;
4630
- }
4631
- .brand {
4632
- padding: 14px;
4633
- border-bottom: 1px solid var(--border);
4634
- display: flex;
4635
- align-items: center;
4636
- justify-content: space-between;
4637
- gap: 10px;
4638
- }
4639
- .brand > div {
4640
- min-width: 0;
4641
- }
4642
- .brand strong { font-size: 15px; }
4643
- .brand span, .path {
4644
- color: var(--muted);
4645
- font-family: var(--mono);
4646
- font-size: 11px;
4647
- overflow: hidden;
4648
- text-overflow: ellipsis;
4649
- white-space: nowrap;
4650
- }
4651
- #connState {
4652
- flex: 0 0 92px;
4653
- max-width: 92px;
4654
- text-align: right;
4655
- }
4656
- .form {
4657
- padding: 12px;
4658
- display: grid;
4659
- gap: 9px;
4660
- border-bottom: 1px solid var(--border);
4661
- }
4662
- .grid2 {
4663
- display: grid;
4664
- grid-template-columns: 1fr 1fr;
4665
- gap: 8px;
4666
- }
4667
- .start-row {
4668
- display: grid;
4669
- grid-template-columns: 1fr auto;
4670
- gap: 8px;
4671
- align-items: end;
4672
- }
4673
- .start-options {
4674
- display: grid;
4675
- grid-template-columns: 1fr 1fr;
4676
- gap: 8px;
4677
- }
4678
- .start-options label[data-resume-field] {
4679
- grid-column: 1 / -1;
4680
- }
4681
- .session-header {
4682
- border-bottom: 1px solid var(--border);
4683
- padding: 8px 10px;
4684
- display: flex;
4685
- align-items: center;
4686
- justify-content: space-between;
4687
- gap: 8px;
4688
- min-height: 48px;
4689
- }
4690
- .session-header strong {
4691
- font-size: 13px;
4692
- font-weight: 600;
4693
- }
4694
- .session-header label {
4695
- width: 118px;
4696
- }
4697
- .sessions, .state {
4698
- min-height: 0;
4699
- overflow-y: auto;
4700
- overflow-x: hidden;
4701
- padding: 10px;
4702
- display: grid;
4703
- grid-template-columns: minmax(0, 1fr);
4704
- align-content: start;
4705
- gap: 8px;
4706
- scrollbar-width: thin;
4707
- scrollbar-color: #3a3f4a transparent;
4708
- }
4709
- .sessions::-webkit-scrollbar, .state::-webkit-scrollbar { width: 8px; }
4710
- .sessions::-webkit-scrollbar-track, .state::-webkit-scrollbar-track { background: transparent; }
4711
- .sessions::-webkit-scrollbar-thumb, .state::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
4712
- .session {
4713
- border: 1px solid var(--border);
4714
- background: #111418;
4715
- border-radius: 8px;
4716
- padding: 9px;
4717
- display: grid;
4718
- grid-template-columns: minmax(0, 1fr);
4719
- gap: 7px;
4720
- cursor: pointer;
4721
- }
4722
- .session.active { border-color: var(--accent); }
4723
- .row {
4724
- display: flex;
4725
- align-items: center;
4726
- justify-content: space-between;
4727
- gap: 8px;
4728
- min-width: 0;
4729
- }
4730
- .row strong {
4731
- overflow: hidden;
4732
- text-overflow: ellipsis;
4733
- white-space: nowrap;
4734
- }
4735
- .badge {
4736
- font-family: var(--mono);
4737
- font-size: 11px;
4738
- border: 1px solid var(--border);
4739
- color: var(--muted);
4740
- padding: 2px 6px;
4741
- border-radius: 999px;
4742
- white-space: nowrap;
4743
- }
4744
- .badge.running { color: var(--ok); border-color: #3b7b44; }
4745
- .badge.working, .badge.busy { color: var(--warn); border-color: #6b5a20; }
4746
- .badge.stale, .badge.detached, .badge.idle { color: var(--muted); border-color: var(--border); }
4747
- .badge.exited { color: var(--danger); border-color: #87434a; }
4748
- .main {
4749
- min-height: 0;
4750
- min-width: 0;
4751
- overflow: hidden;
4752
- display: grid;
4753
- grid-template-rows: auto 1fr;
4754
- background: #0b0d10;
4755
- }
4756
- .toolbar {
4757
- min-height: 48px;
4758
- border-bottom: 1px solid var(--border);
4759
- display: flex;
4760
- align-items: center;
4761
- gap: 8px;
4762
- padding: 8px 10px;
4763
- overflow: hidden;
4764
- }
4765
- .toolbar .title {
4766
- flex: 1 1 auto;
4767
- min-width: 0;
4768
- display: grid;
4769
- gap: 2px;
4770
- }
4771
- .toolbar .title strong {
4772
- overflow: hidden;
4773
- text-overflow: ellipsis;
4774
- white-space: nowrap;
4775
- }
4776
- .quick {
4777
- display: flex;
4778
- gap: 8px;
4779
- flex-wrap: nowrap;
4780
- align-items: center;
4781
- flex: 0 0 auto;
4782
- }
4783
- /* Toolbar edge toggles for collapsing the side panels. */
4784
- .icon-btn {
4785
- flex: 0 0 auto;
4786
- width: 30px;
4787
- height: 30px;
4788
- padding: 0;
4789
- display: inline-flex;
4790
- align-items: center;
4791
- justify-content: center;
4792
- font-size: 15px;
4793
- line-height: 1;
4794
- color: var(--muted);
4795
- background: transparent;
4796
- border: 1px solid var(--border);
4797
- border-radius: 7px;
4798
- cursor: pointer;
4799
- }
4800
- .icon-btn:hover { color: var(--text); border-color: var(--accent); }
4801
- /* Compact actions dropdown so the top bar stays a single tidy row. */
4802
- .menu-wrap { position: relative; display: inline-flex; }
4803
- .menu-btn { white-space: nowrap; }
4804
- .menu {
4805
- position: fixed;
4806
- z-index: 1000;
4807
- min-width: 150px;
4808
- padding: 5px;
4809
- display: grid;
4810
- gap: 2px;
4811
- background: var(--panel);
4812
- border: 1px solid var(--border);
4813
- border-radius: 8px;
4814
- box-shadow: 0 8px 24px rgba(0,0,0,.45);
4815
- }
4816
- .menu[hidden] { display: none; }
4817
- .menu button {
4818
- width: 100%;
4819
- text-align: left;
4820
- border: 0;
4821
- background: transparent;
4822
- padding: 7px 9px;
4823
- border-radius: 6px;
4824
- color: var(--text);
4825
- }
4826
- .menu button:hover { background: #1b1f26; }
4827
- #terminal {
4828
- min-height: 0;
4829
- overflow: hidden;
4830
- padding: 8px;
4831
- }
4832
- #terminal .xterm {
4833
- cursor: default;
4834
- }
4835
- /* The terminal mirrors a tmux pane; keep xterm's own hidden helper textarea
4836
- off-screen, but DO show the rendered block cursor (positioned from tmux). */
4837
- #terminal .xterm-helper-textarea {
4838
- caret-color: transparent !important;
4839
- color: transparent !important;
4840
- background: transparent !important;
4841
- left: -10000px !important;
4842
- top: 0 !important;
4843
- width: 1px !important;
4844
- height: 1px !important;
4845
- opacity: 0 !important;
4846
- }
4847
- .card {
4848
- border: 1px solid var(--border);
4849
- border-radius: 8px;
4850
- background: #111418;
4851
- overflow: hidden;
4852
- }
4853
- .card.state-card {
4854
- min-height: 0;
4855
- max-height: min(34vh, 280px);
4856
- display: grid;
4857
- grid-template-rows: auto minmax(0, 1fr);
4858
- }
4859
- .card h2 {
4860
- margin: 0;
4861
- padding: 8px 10px;
4862
- border-bottom: 1px solid var(--border);
4863
- font-size: 13px;
4864
- font-weight: 600;
4865
- display: flex;
4866
- justify-content: space-between;
4867
- align-items: center;
4868
- gap: 8px;
4869
- }
4870
- .card .body {
4871
- padding: 8px 10px;
4872
- display: grid;
4873
- gap: 6px;
4874
- }
4875
- .state-card .body {
4876
- min-height: 0;
4877
- max-height: min(28vh, 228px);
4878
- overflow-y: auto;
4879
- overflow-x: hidden;
4880
- align-content: start;
4881
- scrollbar-width: thin;
4882
- scrollbar-color: #3a3f4a transparent;
4883
- }
4884
- .state-card .body::-webkit-scrollbar { width: 8px; }
4885
- .state-card .body::-webkit-scrollbar-track { background: transparent; }
4886
- .state-card .body::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
4887
- .item {
4888
- display: grid;
4889
- gap: 2px;
4890
- font-size: 12px;
4891
- color: var(--muted);
4892
- border-bottom: 1px solid #242932;
4893
- padding-bottom: 6px;
4894
- }
4895
- .item:last-child { border-bottom: 0; padding-bottom: 0; }
4896
- .item strong { color: var(--text); font-size: 12px; }
4897
- .item span { overflow-wrap: anywhere; }
4898
- .mono { font-family: var(--mono); }
4899
- .empty { color: var(--muted); font-size: 12px; }
4900
- .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; }
4901
- #terminal { display: flex; flex-direction: column; }
4902
- </style>
4903
- </head>
4904
- <body>
4905
- <div class="app">
4906
- <aside class="sidebar">
4907
- <div class="brand">
4908
- <div>
4909
- <strong>hello-cc</strong>
4910
- <div class="path" id="rootPath"></div>
4911
- </div>
4912
- <span id="connState" data-i18n="conn.offline">offline</span>
4913
- </div>
4914
- <div class="form" style="padding-top:10px;padding-bottom:10px">
4915
- <div class="grid2">
4916
- <label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
4917
- <label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
4918
- </div>
4919
- <label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
4920
- <button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
4921
- </div>
4922
- <form class="form" id="startForm">
4923
- <div class="start-row">
4924
- <label><span data-i18n="newSession">New session</span><select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell" data-i18n="kind.shell">shell</option></select></label>
4925
- <button class="primary" type="submit" data-i18n="start">Start</button>
4926
- </div>
4927
- <div class="start-options">
4928
- <label><span data-i18n="mode">Mode</span><select id="startMode"><option value="new" data-i18n="mode.new">new</option><option value="resume" data-i18n="mode.resume">resume</option><option value="last" data-i18n="mode.last">last</option><option value="continue" data-i18n="mode.continue">continue</option></select></label>
4929
- <label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
4930
- <label data-resume-field data-resume-custom style="display:none"><span data-i18n="sessionId">Session id</span><input id="resumeArg" data-i18n-placeholder="sessionIdPlaceholder" placeholder="session id or name"></label>
4931
- </div>
4932
- </form>
4933
- <div class="session-header">
4934
- <strong data-i18n="sessions">Sessions</strong>
4935
- <label><span data-i18n="view">View</span><select id="sessionKindFilter"><option value="all" data-i18n="kind.all">all</option><option value="claude">claude</option><option value="codex">codex</option><option value="shell" data-i18n="kind.shell">shell</option><option value="other" data-i18n="kind.other">other</option></select></label>
4936
- </div>
4937
- <div class="sessions" id="sessions"></div>
4938
- </aside>
4939
-
4940
- <main class="main">
4941
- <div class="toolbar">
4942
- <div class="title">
4943
- <strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
4944
- <span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
4945
- </div>
4946
- <div class="quick" id="quickBar">
4947
- <div class="menu-wrap">
4948
- <button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
4949
- <div class="menu" id="actionsMenu" hidden>
4950
- <button data-send="register" data-i18n="action.register">register</button>
4951
- <button data-send="inbox" data-i18n="action.inbox">inbox</button>
4952
- <button data-send="next" data-i18n="action.nextTask">next task</button>
4953
- <button data-send="status" data-i18n="action.status">status</button>
4954
- <button data-send="heartbeat" data-i18n="action.heartbeat">heartbeat</button>
4955
- </div>
4956
- </div>
4957
- <button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
4958
- </div>
4959
- </div>
4960
- <div id="terminal" style="min-height:0;flex:1"></div>
4961
- <div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
4962
- </main>
4963
-
4964
- <aside class="inspector">
4965
- <div class="brand">
4966
- <strong data-i18n="projectState">Project State</strong>
4967
- <button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
4968
- </div>
4969
- <div class="state" id="state"></div>
4970
- </aside>
4971
-
4972
- <button class="edge-toggle edge-left" id="toggleLeft" type="button" data-i18n-title="collapseSidebar" data-i18n-aria="toggleLeftSidebar" title="Collapse sidebar" aria-label="Toggle left sidebar">⟨</button>
4973
- <button class="edge-toggle edge-right" id="toggleRight" type="button" data-i18n-title="collapseStatePanel" data-i18n-aria="toggleRightPanel" title="Collapse state panel" aria-label="Toggle right panel">⟩</button>
4974
- <div class="edge-resizer edge-resizer-left" id="resizeLeft" role="separator" aria-orientation="vertical" data-i18n-aria="resizeLeftSidebar" data-i18n-title="resizeLeftSidebar" aria-label="Resize left sidebar" title="Resize left sidebar"></div>
4975
- <div class="edge-resizer edge-resizer-right" id="resizeRight" role="separator" aria-orientation="vertical" data-i18n-aria="resizeRightPanel" data-i18n-title="resizeRightPanel" aria-label="Resize right panel" title="Resize right panel"></div>
4976
- </div>
4977
-
4978
- <script src="/assets/xterm.js"></script>
4979
- <script>
4980
- const initialParams = new URLSearchParams(location.search);
4981
- const token = initialParams.get('token') || '';
4982
- const headers = token ? { Authorization: 'Bearer ' + token } : {};
4983
- let currentProject = initialParams.get('project') || initialParams.get('root') || '';
4984
- let projects = [];
4985
- let sessionKindFilter = initialParams.get('kind') || 'all';
4986
- let sessions = []; // managed (PTY) sessions
4987
- let detected = []; // coordination-only peers (from hooks/watcher)
4988
- let resumableCache = []; // provider sessions available to resume (from /api/resumable)
4989
- let active = null; // active managed session id
4990
- let activeDetected = null; // active detected peer id
4991
- let activeType = 'managed'; // 'managed' | 'detected'
4992
- let activePeerTtl = 600;
4993
- let lastStateNow = 0;
4994
- let lastStateRoot = '';
4995
- let ws = null;
4996
- let wsReconnectTimer = null;
4997
- const i18n = {
4998
- en: {
4999
- language: 'Language',
5000
- project: 'Project',
5001
- projectPath: 'Project path',
5002
- projectPathPlaceholder: '/path/to/project',
5003
- registerProject: 'Register Project',
5004
- newSession: 'New session',
5005
- start: 'Start',
5006
- mode: 'Mode',
5007
- 'mode.new': 'new',
5008
- 'mode.resume': 'resume',
5009
- 'mode.last': 'last',
5010
- 'mode.continue': 'continue',
5011
- session: 'Session',
5012
- sessionId: 'Session id',
5013
- sessionIdPlaceholder: 'session id or name',
5014
- sessions: 'Sessions',
5015
- view: 'View',
5016
- 'kind.all': 'all',
5017
- 'kind.shell': 'shell',
5018
- 'kind.other': 'other',
5019
- noSessionSelected: 'No session selected',
5020
- startOrSelect: 'Start or select a session from the left panel',
5021
- actions: 'Actions',
5022
- 'action.register': 'register',
5023
- 'action.inbox': 'inbox',
5024
- 'action.nextTask': 'next task',
5025
- 'action.status': 'status',
5026
- 'action.heartbeat': 'heartbeat',
5027
- stop: 'stop',
5028
- projectState: 'Project State',
5029
- refresh: 'Refresh',
5030
- managed: 'Managed',
5031
- detected: 'Detected',
5032
- running: 'running',
5033
- noActiveSessions: 'No active sessions.',
5034
- startOneAbove: 'Start one above',
5035
- orRunTerminal: 'or run in any terminal:',
5036
- noDetectedPeers: 'No detected peers.',
5037
- viewFilter: 'View filter',
5038
- noTasks: 'No tasks.',
5039
- noPeers: 'No peers.',
5040
- noActiveLocks: 'No active locks.',
5041
- noMessages: 'No messages.',
5042
- noTimelineItems: 'No timeline items.',
5043
- nextAction: 'Next Action',
5044
- timeline: 'Timeline',
5045
- messages: 'Messages',
5046
- peers: 'Peers',
5047
- tasks: 'Tasks',
5048
- locks: 'Locks',
5049
- owner: 'owner',
5050
- assignee: 'assignee',
5051
- task: 'task',
5052
- all: 'all',
5053
- next: 'next',
5054
- finish: 'finish',
5055
- warnings: 'warnings',
5056
- noImmediateAction: 'No immediate coordination action',
5057
- detectedSession: 'Detected Session',
5058
- sendMessage: 'Send Message',
5059
- messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
5060
- messageBodyPlaceholder: 'Message body...',
5061
- send: 'Send',
5062
- peer: 'peer',
5063
- kind: 'kind',
5064
- status: 'status',
5065
- cwd: 'cwd',
5066
- pid: 'pid',
5067
- unknown: 'unknown',
5068
- lastSeen: 'last seen',
5069
- secondsAgo: 's ago',
5070
- age: 'age',
5071
- branch: 'branch',
5072
- runtime: 'runtime',
5073
- thread: 'thread',
5074
- reply: 'reply',
5075
- 'status.active': 'active',
5076
- 'status.stale': 'stale',
5077
- 'status.unknown': 'unknown',
5078
- 'status.running': 'running',
5079
- 'status.idle': 'idle',
5080
- 'status.exited': 'exited',
5081
- 'status.claimed': 'claimed',
5082
- 'status.review': 'review',
5083
- 'status.blocked': 'blocked',
5084
- 'status.done': 'done',
5085
- 'status.abandoned': 'abandoned',
5086
- 'status.pending': 'pending',
5087
- 'conn.offline': 'offline',
5088
- 'conn.online': 'online',
5089
- 'conn.attached': 'attached',
5090
- 'conn.reconnecting': 'reconnecting...',
5091
- 'conn.coordinationOnly': 'coordination only',
5092
- 'conn.error': 'error',
5093
- show: 'Show',
5094
- collapse: 'Collapse',
5095
- sidebar: 'sidebar',
5096
- statePanel: 'state panel',
5097
- dragToResize: 'drag to resize',
5098
- collapseSidebar: 'Collapse sidebar',
5099
- collapseStatePanel: 'Collapse state panel',
5100
- toggleLeftSidebar: 'Toggle left sidebar',
5101
- toggleRightPanel: 'Toggle right panel',
5102
- resizeLeftSidebar: 'Resize left sidebar',
5103
- resizeRightPanel: 'Resize right panel',
5104
- customSession: 'custom session id...'
5105
- },
5106
- zh: {
5107
- language: '语言',
5108
- project: '项目',
5109
- projectPath: '项目路径',
5110
- projectPathPlaceholder: '/项目/路径',
5111
- registerProject: '注册项目',
5112
- newSession: '新会话',
5113
- start: '启动',
5114
- mode: '模式',
5115
- 'mode.new': '新建',
5116
- 'mode.resume': '恢复',
5117
- 'mode.last': '最近',
5118
- 'mode.continue': '继续',
5119
- session: '会话',
5120
- sessionId: '会话 ID',
5121
- sessionIdPlaceholder: '会话 ID 或名称',
5122
- sessions: '会话',
5123
- view: '视图',
5124
- 'kind.all': '全部',
5125
- 'kind.shell': 'shell',
5126
- 'kind.other': '其他',
5127
- noSessionSelected: '未选择会话',
5128
- startOrSelect: '从左侧面板启动或选择一个会话',
5129
- actions: '操作',
5130
- 'action.register': '注册',
5131
- 'action.inbox': '收件箱',
5132
- 'action.nextTask': '下个任务',
5133
- 'action.status': '状态',
5134
- 'action.heartbeat': '心跳',
5135
- stop: '停止',
5136
- projectState: '项目状态',
5137
- refresh: '刷新',
5138
- managed: '托管',
5139
- detected: '检测到',
5140
- running: '运行中',
5141
- noActiveSessions: '没有活动会话。',
5142
- startOneAbove: '在上方启动一个会话',
5143
- orRunTerminal: '或在任意终端运行:',
5144
- noDetectedPeers: '没有检测到的协作方。',
5145
- viewFilter: '视图过滤',
5146
- noTasks: '没有任务。',
5147
- noPeers: '没有协作方。',
5148
- noActiveLocks: '没有活动锁。',
5149
- noMessages: '没有消息。',
5150
- noTimelineItems: '没有时间线条目。',
5151
- nextAction: '下一步',
5152
- timeline: '时间线',
5153
- messages: '消息',
5154
- peers: '协作方',
5155
- tasks: '任务',
5156
- locks: '锁',
5157
- owner: '负责人',
5158
- assignee: '执行者',
5159
- task: '任务',
5160
- all: '全部',
5161
- next: '下一步',
5162
- finish: '收尾',
5163
- warnings: '警告',
5164
- noImmediateAction: '当前没有需要立即处理的协作动作',
5165
- detectedSession: '检测到的会话',
5166
- sendMessage: '发送消息',
5167
- messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
5168
- messageBodyPlaceholder: '消息内容...',
5169
- send: '发送',
5170
- peer: '协作方',
5171
- kind: '类型',
5172
- status: '状态',
5173
- cwd: '工作目录',
5174
- pid: 'PID',
5175
- unknown: '未知',
5176
- lastSeen: '最后出现',
5177
- secondsAgo: ' 秒前',
5178
- age: '时长',
5179
- branch: '分支',
5180
- runtime: '运行时',
5181
- thread: '线程',
5182
- reply: '回复',
5183
- 'status.active': '活跃',
5184
- 'status.stale': '过期',
5185
- 'status.unknown': '未知',
5186
- 'status.running': '运行中',
5187
- 'status.idle': '空闲',
5188
- 'status.exited': '已退出',
5189
- 'status.claimed': '已认领',
5190
- 'status.review': '审查中',
5191
- 'status.blocked': '阻塞',
5192
- 'status.done': '完成',
5193
- 'status.abandoned': '已放弃',
5194
- 'status.pending': '待处理',
5195
- 'conn.offline': '离线',
5196
- 'conn.online': '在线',
5197
- 'conn.attached': '已连接',
5198
- 'conn.reconnecting': '重连中...',
5199
- 'conn.coordinationOnly': '仅协作',
5200
- 'conn.error': '错误',
5201
- show: '显示',
5202
- collapse: '折叠',
5203
- sidebar: '侧栏',
5204
- statePanel: '状态面板',
5205
- dragToResize: '拖动调整大小',
5206
- collapseSidebar: '折叠侧栏',
5207
- collapseStatePanel: '折叠状态面板',
5208
- toggleLeftSidebar: '切换左侧栏',
5209
- toggleRightPanel: '切换右侧面板',
5210
- resizeLeftSidebar: '调整左侧栏宽度',
5211
- resizeRightPanel: '调整右侧面板宽度',
5212
- customSession: '自定义会话 ID...'
5213
- }
5214
- };
5215
- let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
5216
- if (!i18n[lang]) lang = 'en';
5217
-
5218
- function tr(key, fallback = '') {
5219
- return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
5220
- }
5221
-
5222
- function setText(id, key) {
5223
- const el = document.getElementById(id);
5224
- if (el) el.textContent = tr(key);
5225
- }
5226
-
5227
- function connText(key) {
5228
- const el = document.getElementById('connState');
5229
- if (el) {
5230
- el.dataset.stateKey = key;
5231
- el.textContent = tr('conn.' + key);
5232
- }
5233
- }
5234
-
5235
- function applyLanguage() {
5236
- document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
5237
- localStorage.setItem('hcc.lang', lang);
5238
- const sel = document.getElementById('langSelect');
5239
- if (sel) sel.value = lang;
5240
- document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
5241
- document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
5242
- document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
5243
- document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
5244
- if (!active && !activeDetected) {
5245
- setText('activeTitle', 'noSessionSelected');
5246
- setText('activeMeta', 'startOrSelect');
5247
- }
5248
- const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
5249
- connText(stateKey);
5250
- syncStartModeOptions();
5251
- renderSections();
5252
- if (activeType === 'detected' && activeDetected) {
5253
- const draft = document.getElementById('detMsg')?.value || '';
5254
- const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
5255
- document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
5256
- renderDetectedPanel(peer);
5257
- const detMsg = document.getElementById('detMsg');
5258
- if (detMsg && draft) detMsg.value = draft;
5259
- } else if (activeType === 'managed' && active) {
5260
- const meta = sessions.find((s) => s.id === active);
5261
- if (meta) {
5262
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5263
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5264
- }
5265
- }
5266
- if (lastStateRoot) refreshCurrentState().catch(console.error);
5267
- syncToggleIcons();
5268
- }
5269
-
5270
- function requestQuery(extra = {}) {
5271
- const params = new URLSearchParams();
5272
- if (token) params.set('token', token);
5273
- if (currentProject) params.set('root', currentProject);
5274
- for (const [key, value] of Object.entries(extra)) {
5275
- if (value !== undefined && value !== null && value !== '') params.set(key, value);
5276
- }
5277
- const text = params.toString();
5278
- return text ? '?' + text : '';
5279
- }
5280
-
5281
- function updateLocationProject() {
5282
- const params = new URLSearchParams(location.search);
5283
- if (token) params.set('token', token);
5284
- if (currentProject) params.set('project', currentProject);
5285
- if (sessionKindFilter && sessionKindFilter !== 'all') params.set('kind', sessionKindFilter);
5286
- history.replaceState(null, '', location.pathname + '?' + params.toString());
5287
- }
5288
-
5289
- const term = new Terminal({
5290
- cursorBlink: true,
5291
- cursorInactiveStyle: 'outline',
5292
- cursorStyle: 'bar',
5293
- convertEol: true,
5294
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
5295
- fontSize: 13,
5296
- theme: { background: '#0b0d10', foreground: '#eef2f6', cursor: '#7dd3fc', cursorAccent: '#0b0d10' }
5297
- });
5298
- term.open(document.getElementById('terminal'));
5299
-
5300
- // Accurate terminal sizing via character measurement
5301
- function measureCharSize() {
5302
- const el = document.getElementById('terminal');
5303
- const canvas = document.createElement('canvas');
5304
- const ctx2d = canvas.getContext('2d');
5305
- ctx2d.font = '13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
5306
- const m = ctx2d.measureText('M');
5307
- return { w: m.width || 8, h: 17 };
5308
- }
5309
-
5310
- function resizeTerm() {
5311
- const el = document.getElementById('terminal');
5312
- const { w, h } = measureCharSize();
5313
- const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
5314
- const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
5315
- term.resize(cols, rows);
5316
- if (ws && ws.readyState === WebSocket.OPEN) {
5317
- ws.send(JSON.stringify({ type: 'resize', cols, rows }));
5318
- }
5319
- }
5320
- window.addEventListener('resize', resizeTerm);
5321
- setTimeout(resizeTerm, 80);
5322
-
5323
- term.onData((data) => {
5324
- if (ws && ws.readyState === WebSocket.OPEN) {
5325
- ws.send(JSON.stringify({ type: 'input', data }));
5326
- }
5327
- });
5328
-
5329
- async function api(path, options = {}) {
5330
- const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
5331
- ...options,
5332
- headers: { 'Content-Type': 'application/json', ...headers, ...(options.headers || {}) }
5333
- });
5334
- const json = await res.json();
5335
- if (!res.ok) throw new Error(json.error?.message || json.message || 'request failed');
5336
- return json;
5337
- }
5338
-
5339
- function renderProjects() {
5340
- const select = document.getElementById('projectSelect');
5341
- select.innerHTML = projects.map((p) =>
5342
- '<option value="' + esc(p.root) + '">' + esc((p.name || p.root) + ' · ' + p.root) + '</option>'
5343
- ).join('');
5344
- if (currentProject) select.value = currentProject;
5345
- document.getElementById('sessionKindFilter').value = sessionKindFilter;
5346
- }
5347
-
5348
- function kindMatches(item) {
5349
- const kind = ['claude', 'codex', 'shell'].includes(item.kind) ? item.kind : 'other';
5350
- return sessionKindFilter === 'all' || kind === sessionKindFilter;
5351
- }
5352
-
5353
- function syncStartModeOptions() {
5354
- const kind = document.getElementById('kind').value;
5355
- const modeSelect = document.getElementById('startMode');
5356
- const current = modeSelect.value;
5357
- const modes = kind === 'claude'
5358
- ? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
5359
- : kind === 'codex'
5360
- ? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
5361
- : [['new', 'new']];
5362
- modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
5363
- modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
5364
- const isResume = modeSelect.value === 'resume';
5365
- document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
5366
- if (isResume) loadResumable();
5367
- else document.querySelector('[data-resume-custom]').style.display = 'none';
5368
- }
5369
-
5370
- // Fetch provider sessions hcc knows about and fill the resume dropdown.
5371
- async function loadResumable() {
5372
- try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
5373
- catch { resumableCache = []; }
5374
- populateResumeSelect();
5375
- }
5376
- function populateResumeSelect() {
5377
- const kind = document.getElementById('kind').value;
5378
- const sel = document.getElementById('resumeSelect');
5379
- const prev = sel.value;
5380
- const items = resumableCache.filter((r) => r.provider === kind);
5381
- const opts = items.map((r) => {
5382
- const resume = r.resume || r.session_id || r.session_name || '';
5383
- const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
5384
- const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
5385
- return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
5386
- });
5387
- opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
5388
- sel.innerHTML = opts.join('');
5389
- if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
5390
- toggleResumeCustom();
5391
- }
5392
- function toggleResumeCustom() {
5393
- const sel = document.getElementById('resumeSelect');
5394
- document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
5395
- }
5396
-
5397
- async function loadProjects() {
5398
- const data = await api('/api/projects');
5399
- projects = data.projects || [];
5400
- if (!currentProject) currentProject = data.current?.root || projects[0]?.root || '';
5401
- renderProjects();
5402
- updateLocationProject();
5403
- }
5404
-
5405
- async function switchProject(root) {
5406
- currentProject = root;
5407
- updateLocationProject();
5408
- active = null;
5409
- activeDetected = null;
5410
- activeType = 'managed';
5411
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
5412
- term.reset();
5413
- document.getElementById('activeTitle').textContent = tr('noSessionSelected');
5414
- document.getElementById('activeMeta').textContent = tr('startOrSelect');
5415
- await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
5416
- const first = sessions.find(s => s.status === 'running');
5417
- if (first) connectManaged(first.id);
5418
- }
5419
-
5420
- function esc(text) {
5421
- return String(text ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
5422
- }
5423
-
5424
- function fmtTime(ts) {
5425
- if (!ts) return '';
5426
- return new Date(ts * 1000).toLocaleTimeString();
5427
- }
5428
-
5429
- function badgeClass(status) {
5430
- return String(status || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
5431
- }
5432
-
5433
- function fmtAge(age) {
5434
- const n = Number(age);
5435
- if (!Number.isFinite(n)) return '?';
5436
- return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
5437
- }
5438
-
5439
- function sessionPeerId(session) {
5440
- return session?.peer_id || session?.id || '';
5441
- }
5442
-
5443
- function sessionRuntimeNote(session) {
5444
- const peerId = sessionPeerId(session);
5445
- return session?.id && peerId && session.id !== peerId ? tr('runtime') + '=' + session.id + ' · ' : '';
5446
- }
5447
-
5448
- function sessionMetaText(session) {
5449
- return sessionRuntimeNote(session) + (session.command || '') + (session.pane ? ' · ' + session.pane : '') + ' · ' + (session.cwd || '');
5450
- }
5451
-
5452
- function statusText(status) {
5453
- const value = String(status || 'unknown');
5454
- return tr('status.' + value.toLowerCase(), value);
5455
- }
5456
-
5457
- function managedPeerId(id) {
5458
- return sessionPeerId(sessions.find((s) => s.id === id)) || id;
5459
- }
5460
-
5461
- function peerIsActive(peer, basisNow = lastStateNow) {
5462
- const age = Number(peer?.age_sec);
5463
- if (Number.isFinite(age)) return age <= activePeerTtl;
5464
- const seen = Number(peer?.last_seen_at || 0);
5465
- const t = Number(basisNow || 0) || Math.floor(Date.now() / 1000);
5466
- return seen > 0 && (t - seen) <= activePeerTtl;
5467
- }
5468
-
5469
- function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
5470
- const activity = peer?.status || 'unknown';
5471
- const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
5472
- const age = fmtAge(peer?.age_sec);
5473
- const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
5474
- if (runtime) {
5475
- return {
5476
- label: runtime.status || 'running',
5477
- detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5478
- };
5479
- }
5480
- if (liveness === 'stale') {
5481
- return {
5482
- label: 'stale',
5483
- detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
5484
- };
5485
- }
5486
- return {
5487
- label: activity,
5488
- detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5489
- };
5490
- }
5491
-
5492
- function bodyPinned(el) {
5493
- if (!el) return false;
5494
- return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
5495
- }
5496
-
5497
- function renderTimelineItem(item) {
5498
- const meta = [
5499
- item.source + ':' + item.source_id,
5500
- item.task_id ? tr('task') + ' #' + item.task_id : '',
5501
- item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
5502
- item.direction || '',
5503
- fmtTime(item.ts)
5504
- ].filter(Boolean).join(' · ');
5505
- return \`
5506
- <div class="item timeline-item">
5507
- <strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
5508
- <span>\${esc(meta)}</span>
5509
- \${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
5510
- </div>\`;
5511
- }
5512
-
5513
- document.getElementById('projectSelect').addEventListener('change', (event) => {
5514
- switchProject(event.target.value).catch(console.error);
5515
- });
5516
- document.getElementById('langSelect').addEventListener('change', (event) => {
5517
- lang = event.target.value === 'zh' ? 'zh' : 'en';
5518
- applyLanguage();
5519
- });
5520
- document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
5521
- sessionKindFilter = event.target.value || 'all';
5522
- updateLocationProject();
5523
- renderSections();
5524
- });
5525
- document.getElementById('addProjectBtn').addEventListener('click', async () => {
5526
- const input = document.getElementById('projectPath');
5527
- const root = input.value.trim();
5528
- if (!root) return;
5529
- await api('/api/projects', { method: 'POST', body: JSON.stringify({ root }) });
5530
- input.value = '';
5531
- await loadProjects();
5532
- await switchProject(root);
5533
- });
5534
-
5535
- // ── Sessions rendering (managed + detected) ──────────────────────────
5536
- function renderSections() {
5537
- const box = document.getElementById('sessions');
5538
- const visibleSessions = sessions.filter(kindMatches);
5539
- const visibleDetected = detected.filter(kindMatches);
5540
- const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
5541
- const manHtml = visibleSessions.length
5542
- ? visibleSessions.map((s) => {
5543
- const peerId = sessionPeerId(s);
5544
- return \`
5545
- <div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
5546
- <div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
5547
- <div class="row"><span class="badge">\${esc(s.kind)}</span><span class="badge \${s.type === 'external' || s.type === 'tmux' ? 'warn' : ''}">\${s.type === 'external' ? 'external' : s.type === 'tmux' ? 'tmux' : 'pty'}</span></div>
5548
- <div class="path">\${esc(sessionRuntimeNote(s) + (s.command || ''))}</div>
5549
- </div>\`;
5550
- }).join('')
5551
- : '<div class="empty">' + esc(tr('noActiveSessions')) + filterNote + '<br><br>' + esc(tr('startOneAbove')) + '<br>' + esc(tr('orRunTerminal')) + '<br><code>hcc peer start X -- claude</code></div>';
5552
-
5553
- const detHtml = visibleDetected.length
5554
- ? visibleDetected.map((p) => {
5555
- const state = peerStateView(p);
5556
- return \`
5557
- <div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
5558
- <div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span></div>
5559
- <div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span></div>
5560
- <div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
5561
- </div>\`;
5562
- }).join('')
5563
- : '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
5564
-
5565
- const savedScroll = box.scrollTop;
5566
- box.innerHTML = \`
5567
- <div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
5568
- \${manHtml}
5569
- <div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
5570
- \${detHtml}
5571
- \`;
5572
- box.scrollTop = savedScroll;
5573
- box.querySelectorAll('.session[data-type="managed"]').forEach((el) => {
5574
- el.addEventListener('click', () => connectManaged(el.dataset.id));
5575
- });
5576
- box.querySelectorAll('.session[data-type="detected"]').forEach((el) => {
5577
- el.addEventListener('click', () => connectDetected(el.dataset.id));
5578
- });
5579
- }
5580
-
5581
- async function refreshSessions() {
5582
- const data = await api('/api/sessions');
5583
- sessions = data.sessions || [];
5584
- renderSections();
5585
- if (active && activeType === 'managed') {
5586
- const meta = sessions.find((s) => s.id === active);
5587
- if (meta) {
5588
- document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
5589
- document.getElementById('activeMeta').textContent = sessionMetaText(meta);
5590
- }
5591
- }
5592
- connText('online');
5593
- }
5594
-
5595
- async function refreshDetected() {
5596
- try {
5597
- const data = await api('/api/detected');
5598
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
5599
- lastStateNow = Number(data.now || lastStateNow);
5600
- detected = data.detected || [];
5601
- renderSections();
5602
- } catch {}
5603
- }
5604
-
5605
- // ── Project state panel ───────────────────────────────────────────────
5606
- function renderState(data) {
5607
- const stateRoot = data.root || '';
5608
- document.getElementById('rootPath').textContent = stateRoot;
5609
- activePeerTtl = Number(data.active_peer_ttl || activePeerTtl);
5610
- lastStateNow = Number(data.now || lastStateNow);
5611
- const state = document.getElementById('state');
5612
- const preserveScroll = lastStateRoot === stateRoot;
5613
- const savedStateScroll = preserveScroll ? state.scrollTop : 0;
5614
- const savedCardScroll = preserveScroll
5615
- ? new Map(
5616
- [...state.querySelectorAll('.state-card[data-section]')].map((card) => [
5617
- card.dataset.section,
5618
- {
5619
- top: card.querySelector('.body')?.scrollTop || 0,
5620
- pinned: bodyPinned(card.querySelector('.body'))
5621
- }
5622
- ])
5623
- )
5624
- : new Map();
5625
- const runtimeById = new Map();
5626
- for (const session of sessions || []) {
5627
- runtimeById.set(session.id, session);
5628
- const peerId = sessionPeerId(session);
5629
- if (peerId) runtimeById.set(peerId, session);
5630
- }
5631
- const tasksData = data.tasks || [];
5632
- const peersData = data.peers || [];
5633
- const locksData = data.locks || [];
5634
- const messagesData = data.messages || [];
5635
- const timelineData = data.timeline || [];
5636
- const automation = data.automation || {};
5637
- const nextAction = automation.next_action || {};
5638
- const tasks = tasksData.map((t) => \`
5639
- <div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}</span></div>
5640
- \`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
5641
- const peers = peersData.map((a) => {
5642
- const peerRuntime = runtimeById.get(a.id);
5643
- const peerState = peerStateView(a, peerRuntime, data.now);
5644
- return \`
5645
- <div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
5646
- \`;
5647
- }).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
5648
- const locks = locksData.map((l) => \`
5649
- <div class="item"><strong>\${esc(l.resource)}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
5650
- \`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
5651
- const messages = messagesData.map((m) => \`
5652
- <div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
5653
- \`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
5654
- const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
5655
- const actionLines = [
5656
- '<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
5657
- nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
5658
- (automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
5659
- (automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
5660
- ].filter(Boolean).join('');
5661
- state.innerHTML = \`
5662
- <div class="card state-card" data-section="automation"><h2>\${esc(tr('nextAction'))} <span class="badge">\${esc(statusText(automation.phase || 'idle'))}</span></h2><div class="body">\${actionLines}</div></div>
5663
- <div class="card state-card" data-section="timeline"><h2>\${esc(tr('timeline'))} <span class="badge">\${timelineData.length}</span></h2><div class="body">\${timeline}</div></div>
5664
- <div class="card state-card" data-section="messages"><h2>\${esc(tr('messages'))} <span class="badge">\${messagesData.length}</span></h2><div class="body">\${messages}</div></div>
5665
- <div class="card state-card" data-section="peers"><h2>\${esc(tr('peers'))} <span class="badge">\${peersData.length}</span></h2><div class="body">\${peers}</div></div>
5666
- <div class="card state-card" data-section="tasks"><h2>\${esc(tr('tasks'))} <span class="badge">\${tasksData.length}</span></h2><div class="body">\${tasks}</div></div>
5667
- <div class="card state-card" data-section="locks"><h2>\${esc(tr('locks'))} <span class="badge">\${locksData.length}</span></h2><div class="body">\${locks}</div></div>
5668
- \`;
5669
- state.scrollTop = savedStateScroll;
5670
- for (const [section, saved] of savedCardScroll) {
5671
- const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
5672
- if (!body) continue;
5673
- if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
5674
- else body.scrollTop = saved.top;
5675
- }
5676
- lastStateRoot = stateRoot;
5677
- }
5678
-
5679
- async function refreshState() {
5680
- const peer = active ? managedPeerId(active) : null;
5681
- const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
5682
- const data = await api(p);
5683
- renderState(data);
5684
- }
5685
-
5686
- async function refreshDetectedState() {
5687
- if (!activeDetected) return;
5688
- const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
5689
- renderState(data);
5690
- }
4095
+ function webPeerRegister(projectCtx, peer, input = {}) {
4096
+ const db = connect(projectCtx);
4097
+ const row = {
4098
+ id: peer,
4099
+ kind: input.kind || 'other',
4100
+ role: input.role || 'peer',
4101
+ worktree: path.resolve(input.worktree || projectCtx.cwd),
4102
+ branch: input.branch || detectBranch(projectCtx.cwd),
4103
+ pid: intOpt(input, 'pid', process.ppid),
4104
+ status: input.status || 'idle',
4105
+ capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
4106
+ };
4107
+ try {
4108
+ upsertPeer(db, row);
4109
+ addEvent(db, 'peer.registered', peer, null, { ...row, source: 'web' });
4110
+ } finally {
4111
+ db.close();
4112
+ }
4113
+ return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
4114
+ }
5691
4115
 
5692
- async function refreshCurrentState() {
5693
- if (activeType === 'detected') return refreshDetectedState();
5694
- return refreshState();
5695
- }
4116
+ function webPeerHeartbeat(projectCtx, peer, input = {}) {
4117
+ const ttl = intOpt(input, 'ttl', DEFAULT_LOCK_TTL);
4118
+ const status = input.status || null;
4119
+ const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
4120
+ const db = connect(projectCtx);
4121
+ const t = now();
4122
+ let renewed = 0;
4123
+ try {
4124
+ touchPeer(db, peer, status);
4125
+ if (renewLocks) {
4126
+ renewed = db.prepare(`
4127
+ UPDATE locks SET expires_at = ?
4128
+ WHERE owner = ? AND expires_at > ?
4129
+ `).run(t + ttl, peer, t).changes;
4130
+ }
4131
+ addEvent(db, 'peer.heartbeat', peer, null, { status, renewed, source: 'web' });
4132
+ } finally {
4133
+ db.close();
4134
+ }
4135
+ return {
4136
+ peer,
4137
+ status,
4138
+ renewed,
4139
+ summary: `heartbeat ${peer}${renewed ? `, renewed locks: ${renewed}` : ''}`
4140
+ };
4141
+ }
5696
4142
 
5697
- // ── Connect to managed (PTY) session ─────────────────────────────────
5698
- function connectManaged(id) {
5699
- const meta = sessions.find((s) => s.id === id);
5700
- const peerId = sessionPeerId(meta) || id;
5701
- active = id;
5702
- activeDetected = null;
5703
- activeType = 'managed';
5704
- renderSections();
5705
- document.getElementById('activeTitle').textContent = peerId;
5706
- document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
5707
- document.getElementById('terminal').style.display = '';
5708
- document.getElementById('detectedPanel').style.display = 'none';
5709
- document.getElementById('quickBar').style.display = '';
5710
-
5711
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); }
5712
- term.reset();
5713
- openWs(id);
5714
- refreshState().catch(console.error);
4143
+ function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {}) {
4144
+ return tx(db, () => {
4145
+ if (!force) {
4146
+ const current = db.prepare(`
4147
+ SELECT * FROM tasks
4148
+ WHERE owner = ?
4149
+ AND status IN ('claimed', 'running', 'review', 'blocked')
4150
+ ORDER BY
4151
+ CASE status
4152
+ WHEN 'running' THEN 0
4153
+ WHEN 'claimed' THEN 1
4154
+ WHEN 'review' THEN 2
4155
+ WHEN 'blocked' THEN 3
4156
+ ELSE 4
4157
+ END,
4158
+ priority ASC,
4159
+ id ASC
4160
+ LIMIT 1
4161
+ `).get(peer);
4162
+ if (current) return { current: { ...current, current: true }, tasks: [] };
5715
4163
  }
4164
+ const rows = db.prepare(`
4165
+ SELECT * FROM tasks
4166
+ WHERE status = 'pending'
4167
+ AND owner IS NULL
4168
+ AND (assignee IS NULL OR assignee = ?)
4169
+ ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
4170
+ LIMIT ?
4171
+ `).all(peer, peer, count);
4172
+ if (!rows.length) return { current: null, tasks: [] };
4173
+ const t = now();
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 };
4186
+ });
4187
+ }
5716
4188
 
5717
- function openWs(id) {
5718
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
5719
- ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
5720
- ws.onopen = () => {
5721
- resizeTerm();
5722
- connText('attached');
5723
- };
5724
- ws.onmessage = (event) => {
5725
- const msg = JSON.parse(event.data);
5726
- // Check before any reset whether the viewport is pinned at the bottom
5727
- const pinned = (() => {
5728
- try { const b = term.buffer.active; return b.viewportY >= b.baseY; } catch { return true; }
5729
- })();
5730
- // The server streams the tmux pane's raw output, so xterm renders
5731
- // incrementally (no reset/redraw → no flicker) and the program's own
5732
- // escape sequences carry the cursor.
5733
- if (msg.type === 'snapshot') { term.reset(); term.write(msg.data || '', () => { term.scrollToBottom(); }); }
5734
- if (msg.type === 'data') { term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
5735
- if (msg.type === 'replace') { term.reset(); term.write(msg.data || '', pinned ? () => { term.scrollToBottom(); } : undefined); }
5736
- if (msg.type === 'exit') { refreshSessions().catch(console.error); }
5737
- };
5738
- ws.onclose = () => {
5739
- connText('reconnecting');
5740
- // Auto-reconnect if session is still in the list and running
5741
- wsReconnectTimer = setTimeout(() => {
5742
- const s = sessions.find((s) => s.id === id);
5743
- if (s && s.status === 'running' && active === id) openWs(id);
5744
- else connText('online');
5745
- }, 2000);
5746
- };
5747
- }
4189
+ function webPeerTaskNext(projectCtx, peer, input = {}) {
4190
+ const db = connect(projectCtx);
4191
+ try {
4192
+ touchPeer(db, peer, 'working');
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' };
4197
+ return {
4198
+ peer,
4199
+ task,
4200
+ tasks: result.tasks,
4201
+ current: Boolean(result.current),
4202
+ summary: result.current
4203
+ ? `current task #${task.id}: ${task.title} (${task.status})`
4204
+ : result.tasks.length === 1
4205
+ ? `claimed task #${task.id}: ${task.title}`
4206
+ : `claimed ${result.tasks.length} tasks`
4207
+ };
4208
+ } finally {
4209
+ db.close();
4210
+ }
4211
+ }
5748
4212
 
5749
- // ── Connect to detected (coordination-only) peer ──────────────────────
5750
- function connectDetected(id) {
5751
- const peer = detected.find((p) => p.id === id);
5752
- active = null;
5753
- activeDetected = id;
5754
- activeType = 'detected';
5755
- if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
5756
- renderSections();
5757
- document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
5758
- document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
5759
- document.getElementById('terminal').style.display = 'none';
5760
- document.getElementById('detectedPanel').style.display = '';
5761
- document.getElementById('quickBar').style.display = 'none';
5762
- connText('coordinationOnly');
5763
- renderDetectedPanel(peer || { id });
5764
- refreshDetectedState().catch(console.error);
5765
- }
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
+ }
5766
4228
 
5767
- function renderDetectedPanel(peer) {
5768
- const dp = document.getElementById('detectedPanel');
5769
- dp.innerHTML = \`
5770
- <div style="padding:16px;display:grid;gap:12px">
5771
- <div class="card">
5772
- <h2>\${esc(tr('detectedSession'))}</h2>
5773
- <div class="body">
5774
- <div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
5775
- <div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
5776
- <div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
5777
- <div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
5778
- <div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
5779
- <div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
5780
- </div>
5781
- </div>
5782
- <div class="card">
5783
- <h2>\${esc(tr('sendMessage'))}</h2>
5784
- <div class="body" style="gap:8px">
5785
- <div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
5786
- <textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="\${esc(tr('messageBodyPlaceholder'))}"></textarea>
5787
- <button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
5788
- </div>
5789
- </div>
5790
- </div>
5791
- \`;
5792
- document.getElementById('sendDetMsg').addEventListener('click', async () => {
5793
- const body = document.getElementById('detMsg').value.trim();
5794
- if (!body) return;
5795
- await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
5796
- method: 'POST',
5797
- body: JSON.stringify({ body, from: 'web' })
5798
- });
5799
- document.getElementById('detMsg').value = '';
5800
- await refreshDetectedState();
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)
5801
4249
  });
5802
4250
  }
5803
-
5804
- // ── Helpers ───────────────────────────────────────────────────────────
5805
- function sendLine(text) {
5806
- if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
5807
- ws.send(JSON.stringify({ type: 'input', data: text + '\\r' }));
5808
- }
5809
-
5810
- // ── Start session form ────────────────────────────────────────────────
5811
- document.getElementById('kind').addEventListener('change', syncStartModeOptions);
5812
- document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
5813
- document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
5814
- syncStartModeOptions();
5815
-
5816
- document.getElementById('startForm').addEventListener('submit', async (event) => {
5817
- event.preventDefault();
5818
- const kind = document.getElementById('kind').value;
5819
- const mode = document.getElementById('startMode').value;
5820
- const sel = document.getElementById('resumeSelect');
5821
- const resume = (sel.value && sel.value !== '__custom__')
5822
- ? sel.value
5823
- : document.getElementById('resumeArg').value.trim();
5824
- if (mode === 'resume' && !resume) return;
5825
- const payload = { kind, mode };
5826
- if (mode === 'resume') payload.resume = resume;
5827
- const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
5828
- await refreshSessions();
5829
- connectManaged(data.session.id);
5830
- });
5831
-
5832
- document.querySelectorAll('[data-send]').forEach((button) => {
5833
- button.addEventListener('click', () => {
5834
- if (!active) return;
5835
- const session = sessions.find((s) => s.id === active) || { kind: 'other', role: 'peer' };
5836
- const peerId = sessionPeerId(session) || active;
5837
- const lines = {
5838
- register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
5839
- inbox: \`hcc msg inbox --peer \${peerId}\`,
5840
- next: \`hcc task next --peer \${peerId}\`,
5841
- status: \`hcc status --peer \${peerId}\`,
5842
- heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
5843
- };
5844
- sendLine(lines[button.dataset.send]);
5845
- closeActionsMenu();
5846
- });
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);
5847
4265
  });
4266
+ return { peer, lock, summary: `locked ${lockLabel(lock)} by ${lock.owner} until ${iso(lock.expires_at)}` };
4267
+ } finally {
4268
+ db.close();
4269
+ }
4270
+ }
5848
4271
 
5849
- // ── Actions dropdown (declutters the toolbar) ─────────────────────────
5850
- const actionsBtn = document.getElementById('actionsBtn');
5851
- const actionsMenu = document.getElementById('actionsMenu');
5852
- function closeActionsMenu() {
5853
- actionsMenu.hidden = true;
5854
- actionsBtn.setAttribute('aria-expanded', 'false');
5855
- }
5856
- actionsBtn.addEventListener('click', (e) => {
5857
- e.stopPropagation();
5858
- const open = actionsMenu.hidden;
5859
- actionsMenu.hidden = !open;
5860
- actionsBtn.setAttribute('aria-expanded', String(open));
5861
- if (open) {
5862
- // Position as a fixed layer so it escapes the toolbar/main overflow clip.
5863
- const r = actionsBtn.getBoundingClientRect();
5864
- actionsMenu.style.top = (r.bottom + 6) + 'px';
5865
- actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
5866
- }
5867
- });
5868
- document.addEventListener('click', (e) => {
5869
- if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
5870
- });
5871
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
5872
-
5873
- // ── Collapsible side panels ───────────────────────────────────────────
5874
- const appEl = document.querySelector('.app');
5875
- const toggleLeftBtn = document.getElementById('toggleLeft');
5876
- const toggleRightBtn = document.getElementById('toggleRight');
5877
- const resizeLeftHandle = document.getElementById('resizeLeft');
5878
- const resizeRightHandle = document.getElementById('resizeRight');
5879
- const sideDefaults = { left: 320, right: 360 };
5880
- const sideMin = { left: 220, right: 240 };
5881
- const sideMax = { left: 560, right: 640 };
5882
- const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
5883
- const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
5884
- function sideIsCollapsed(side) {
5885
- return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
5886
- }
5887
- function storedSideWidth(side) {
5888
- const raw = Number(localStorage.getItem(sideWidthKey[side]));
5889
- return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
5890
- }
5891
- function effectiveOppositeWidth(side) {
5892
- const opposite = side === 'left' ? 'right' : 'left';
5893
- return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
5894
- }
5895
- function clampSideWidth(side, width) {
5896
- const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
5897
- const opposite = effectiveOppositeWidth(side);
5898
- const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
5899
- return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
5900
- }
5901
- function readSideWidth(side) {
5902
- return clampSideWidth(side, storedSideWidth(side));
5903
- }
5904
- function setSideWidth(side, width, persist = true) {
5905
- const value = clampSideWidth(side, width);
5906
- appEl.style.setProperty('--' + side + '-width', value + 'px');
5907
- if (persist) localStorage.setItem(sideWidthKey[side], String(value));
5908
- return value;
5909
- }
5910
- function applySideWidths() {
5911
- setSideWidth('left', readSideWidth('left'), false);
5912
- setSideWidth('right', readSideWidth('right'), false);
5913
- }
5914
- function syncToggleIcons() {
5915
- const l = appEl.classList.contains('left-collapsed');
5916
- const r = appEl.classList.contains('right-collapsed');
5917
- toggleLeftBtn.textContent = l ? '⟩' : '⟨';
5918
- toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
5919
- toggleRightBtn.textContent = r ? '⟨' : '⟩';
5920
- toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
5921
- }
5922
- function applyCollapseState() {
5923
- appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
5924
- appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
5925
- applySideWidths();
5926
- syncToggleIcons();
5927
- }
5928
- function toggleSide(side) {
5929
- const cls = side + '-collapsed';
5930
- const on = appEl.classList.toggle(cls);
5931
- localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
5932
- applySideWidths();
5933
- syncToggleIcons();
5934
- // Refit the terminal after the grid transition so xterm uses the new width.
5935
- setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
5936
- }
5937
- function beginSideDrag(side, event) {
5938
- if (event.button !== undefined && event.button !== 0) return;
5939
- dragState.side = side;
5940
- dragState.startX = event.clientX;
5941
- dragState.startWidth = readSideWidth(side);
5942
- dragState.moved = false;
5943
- dragState.suppressClick = false;
5944
- appEl.classList.add('resizing');
5945
- event.currentTarget.setPointerCapture?.(event.pointerId);
5946
- event.preventDefault();
5947
- }
5948
- function moveSideDrag(event) {
5949
- if (!dragState.side) return;
5950
- const delta = event.clientX - dragState.startX;
5951
- if (!dragState.moved && Math.abs(delta) <= 3) return;
5952
- dragState.moved = true;
5953
- const width = dragState.side === 'left'
5954
- ? dragState.startWidth + delta
5955
- : dragState.startWidth - delta;
5956
- setSideWidth(dragState.side, width);
5957
- if (dragState.side === 'left') {
5958
- appEl.classList.remove('left-collapsed');
5959
- localStorage.setItem('hcc.collapse.left', '0');
5960
- } else {
5961
- appEl.classList.remove('right-collapsed');
5962
- localStorage.setItem('hcc.collapse.right', '0');
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 });
5963
4283
  }
5964
- syncToggleIcons();
5965
- try { resizeTerm(); } catch {}
5966
- event.preventDefault();
5967
- }
5968
- function endSideDrag(event) {
5969
- if (!dragState.side) return;
5970
- dragState.suppressClick = dragState.moved;
5971
- event.currentTarget.releasePointerCapture?.(event.pointerId);
5972
- dragState.side = null;
5973
- appEl.classList.remove('resizing');
5974
- setTimeout(() => { dragState.suppressClick = false; }, 0);
5975
- try { resizeTerm(); } catch {}
5976
- }
5977
- function bindSideHandle(handle, side, clickAction = null) {
5978
- handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
5979
- handle.addEventListener('pointermove', moveSideDrag);
5980
- handle.addEventListener('pointerup', endSideDrag);
5981
- handle.addEventListener('pointercancel', endSideDrag);
5982
- handle.addEventListener('click', (event) => {
5983
- if (dragState.suppressClick) {
5984
- event.preventDefault();
5985
- return;
5986
- }
5987
- if (clickAction) clickAction(side);
5988
- });
5989
- }
5990
- bindSideHandle(resizeLeftHandle, 'left');
5991
- bindSideHandle(resizeRightHandle, 'right');
5992
- bindSideHandle(toggleLeftBtn, 'left', toggleSide);
5993
- bindSideHandle(toggleRightBtn, 'right', toggleSide);
5994
- window.addEventListener('resize', () => {
5995
- applySideWidths();
5996
- setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
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 };
5997
4287
  });
5998
- applyCollapseState();
4288
+ return { peer, result, summary: result.released ? `released ${lockLabel(result)}` : `no lock for ${lockLabel(result)}` };
4289
+ } finally {
4290
+ db.close();
4291
+ }
4292
+ }
5999
4293
 
6000
- document.getElementById('stopBtn').addEventListener('click', async () => {
6001
- if (!active) return;
6002
- await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: '{}' });
6003
- await refreshSessions();
6004
- });
6005
- document.getElementById('refreshBtn').addEventListener('click', () => {
6006
- Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
6007
- });
4294
+ function webPeerInbox(projectCtx, peer, input = {}) {
4295
+ const db = connect(projectCtx);
4296
+ try {
4297
+ const messages = queryInbox(db, peer, Boolean(input.all), intOpt(input, 'limit', 20));
4298
+ return {
4299
+ peer,
4300
+ messages,
4301
+ summary: messages.length ? `${messages.length} message${messages.length === 1 ? '' : 's'}` : 'no messages'
4302
+ };
4303
+ } finally {
4304
+ db.close();
4305
+ }
4306
+ }
6008
4307
 
6009
- applyLanguage();
6010
- loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
6011
- // Auto-connect to first running managed session on load
6012
- if (!active && !activeDetected) {
6013
- const first = sessions.find(s => s.status === 'running');
6014
- if (first) connectManaged(first.id);
6015
- }
6016
- }).catch((err) => {
6017
- connText('error');
6018
- console.error(err);
4308
+ function webPeerAction(projectCtx, peer, action, input = {}) {
4309
+ const normalized = String(action || '').replace(/_/g, '-');
4310
+ if (!peer) throw new CliError('BAD_REQUEST', 'peer required');
4311
+ if (normalized === 'status') {
4312
+ const status = statusSummary(projectCtx, peer);
4313
+ return { ok: true, action: normalized, peer, summary: `active=${status.active_peers}, stale=${status.stale_peers}, locks=${status.active_locks}, unread=${status.unread ?? 0}`, data: status };
4314
+ }
4315
+ if (normalized === 'state') {
4316
+ const data = statusSnapshot(projectCtx, peer, {
4317
+ resources: normalizeStateResources(input.resource || input.resources || []),
4318
+ intent: input.intent || null,
4319
+ scope: input.scope || null
6019
4320
  });
6020
- // ── Auto-poll state ──────────────────────────────────────────────────
6021
- setInterval(() => {
6022
- refreshSessions().catch(console.error);
6023
- refreshDetected().catch(console.error);
6024
- if (activeType === 'detected') refreshDetectedState().catch(console.error);
6025
- else if (active) refreshState().catch(console.error);
6026
- }, 3000);
6027
- setInterval(() => {
6028
- loadProjects().catch(console.error);
6029
- }, 8000);
6030
- </script>
6031
- </body>
6032
- </html>`;
4321
+ return { ok: true, action: normalized, peer, summary: data.automation?.next_action?.reason || 'state loaded', data };
4322
+ }
4323
+ if (normalized === 'inbox') {
4324
+ const data = webPeerInbox(projectCtx, peer, input);
4325
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4326
+ }
4327
+ if (normalized === 'task-next') {
4328
+ const data = webPeerTaskNext(projectCtx, peer, input);
4329
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
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
+ }
4343
+ if (normalized === 'heartbeat') {
4344
+ const data = webPeerHeartbeat(projectCtx, peer, input);
4345
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4346
+ }
4347
+ if (normalized === 'register') {
4348
+ const data = webPeerRegister(projectCtx, peer, input);
4349
+ return { ok: true, action: normalized, peer, summary: data.summary, data };
4350
+ }
4351
+ throw new CliError('BAD_REQUEST', `Unknown peer action: ${action}`);
6033
4352
  }
6034
4353
 
6035
4354
  async function cmdWeb(ctx, args, startMeta = {}) {
@@ -6413,6 +4732,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6413
4732
  if (session.replaceTimer) clearTimeout(session.replaceTimer);
6414
4733
  session.replaceTimer = setTimeout(() => {
6415
4734
  session.replaceTimer = null;
4735
+ session.lastBroadcastTime = Date.now();
6416
4736
  broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
6417
4737
  }, 80);
6418
4738
  }
@@ -6426,6 +4746,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6426
4746
  const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
6427
4747
  const pipeFile = path.join(bufsDir, `tmux-${safePane}-${safeId}.pipe`);
6428
4748
  session.pipeFile = pipeFile;
4749
+ // Restored panes may still have pipe-pane writers from a previous runtime.
4750
+ // Disable first so tmux tears down the stale writer before this runtime
4751
+ // installs its own FIFO reader.
4752
+ try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
6429
4753
  // Capture the existing screen once for the initial paint; pipe-pane only
6430
4754
  // forwards output produced after it is enabled.
6431
4755
  try {
@@ -6481,6 +4805,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6481
4805
  const data = Buffer.concat(chunks).toString();
6482
4806
  session.buffer += data;
6483
4807
  if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
4808
+ session.lastBroadcastTime = Date.now();
6484
4809
  broadcast(session, { type: 'data', data });
6485
4810
  } catch {
6486
4811
  if (session.streamFd !== null && session.streamFd !== undefined) {
@@ -6489,11 +4814,26 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6489
4814
  }
6490
4815
  }
6491
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);
6492
4830
  }
6493
4831
 
6494
4832
  function stopTmuxStream(session) {
6495
4833
  if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
4834
+ if (session.replacePoller) { clearInterval(session.replacePoller); session.replacePoller = null; }
6496
4835
  if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
4836
+ if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
6497
4837
  // Turn piping back off for this pane (no command toggles it off).
6498
4838
  try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
6499
4839
  if (session.streamFd !== null && session.streamFd !== undefined) {
@@ -6555,6 +4895,8 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6555
4895
  streamPoller: null,
6556
4896
  streamFd: null,
6557
4897
  pipeFile: null,
4898
+ replacePoller: null,
4899
+ lastBroadcastTime: 0,
6558
4900
  exitPoller: null
6559
4901
  };
6560
4902
  sessions.set(key, session);
@@ -6612,11 +4954,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6612
4954
  try { fs.appendFileSync(session.inFile, data); } catch {}
6613
4955
  } else if (session.type === 'tmux') {
6614
4956
  tmuxSendLiteral(session.pane, data);
4957
+ scheduleTmuxInputRefresh(session);
6615
4958
  } else {
6616
4959
  session.pty.write(data);
6617
4960
  }
6618
4961
  }
6619
4962
 
4963
+ function scheduleTmuxInputRefresh(session) {
4964
+ if (session.type !== 'tmux' || !session.pane) return;
4965
+ if (session.inputRefreshTimer) return;
4966
+ session.inputRefreshTimer = setTimeout(() => {
4967
+ session.inputRefreshTimer = null;
4968
+ if (session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
4969
+ }, 80);
4970
+ }
4971
+
6620
4972
  function resizeSession(session, cols, rows) {
6621
4973
  if (session.type === 'external') {
6622
4974
  try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
@@ -6935,7 +5287,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6935
5287
  }
6936
5288
 
6937
5289
  const server = http.createServer(async (req, res) => {
6938
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
5290
+ const url = requestUrl(req);
6939
5291
  try {
6940
5292
  if (req.method === 'GET' && url.pathname === '/') {
6941
5293
  sendHttp(res, 200, 'text/html; charset=utf-8', webIndexHtml());
@@ -6986,10 +5338,33 @@ async function cmdWeb(ctx, args, startMeta = {}) {
6986
5338
  ]);
6987
5339
  sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer'), {
6988
5340
  resources,
6989
- intent: url.searchParams.get('intent') || null
5341
+ intent: url.searchParams.get('intent') || null,
5342
+ scope: url.searchParams.get('scope') || null
6990
5343
  }));
6991
5344
  return;
6992
5345
  }
5346
+ const peerActionMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/actions\/([^/]+)$/);
5347
+ if (peerActionMatch) {
5348
+ const peer = decodeURIComponent(peerActionMatch[1]);
5349
+ const action = decodeURIComponent(peerActionMatch[2]);
5350
+ const readOnly = ['status', 'state', 'inbox'].includes(action);
5351
+ if (readOnly && req.method !== 'GET') {
5352
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use GET for read-only peer actions' } });
5353
+ return;
5354
+ }
5355
+ if (!readOnly && req.method !== 'POST') {
5356
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use POST for mutating peer actions' } });
5357
+ return;
5358
+ }
5359
+ const input = readOnly
5360
+ ? {
5361
+ ...Object.fromEntries(url.searchParams.entries()),
5362
+ resource: url.searchParams.getAll('resource')
5363
+ }
5364
+ : await readJsonRequest(req);
5365
+ sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));
5366
+ return;
5367
+ }
6993
5368
  // Detected sessions: peers registered via hooks/watcher but without PTY
6994
5369
  if (req.method === 'GET' && url.pathname === '/api/detected') {
6995
5370
  const db = connect(reqCtx);
@@ -7150,7 +5525,17 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7150
5525
  if (session.wrapperPid) { try { process.kill(session.wrapperPid, 'SIGTERM'); } catch {} }
7151
5526
  if (session.pid && session.pid !== session.wrapperPid) { try { process.kill(session.pid, 'SIGTERM'); } catch {} }
7152
5527
  } else if (session.type === 'tmux') {
5528
+ let input = {};
5529
+ try { input = await readJsonRequest(req); } catch {}
7153
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
+ }
7154
5539
  } else {
7155
5540
  session.pty.kill();
7156
5541
  }
@@ -7177,6 +5562,46 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7177
5562
  sendJson(res, 200, { ok: true, id: msgId });
7178
5563
  return;
7179
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
+ }
7180
5605
  sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
7181
5606
  } catch (err) {
7182
5607
  const detail = err instanceof CliError || process.env.HCC_DEBUG ? err.message : 'internal server error';
@@ -7186,7 +5611,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7186
5611
 
7187
5612
  const wss = new WebSocketServer({ noServer: true });
7188
5613
  server.on('upgrade', (req, socket, head) => {
7189
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
5614
+ const url = requestUrl(req);
7190
5615
  if (!authOk(url, req, token)) {
7191
5616
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
7192
5617
  socket.destroy();
@@ -7287,12 +5712,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
7287
5712
  } finally {
7288
5713
  db.close();
7289
5714
  }
7290
- const shownHost = host === '0.0.0.0' ? '<machine-ip>' : host;
7291
- const url = `http://${shownHost}:${actualPort}/${runtimeUrlQuery(runtime, ctx.root)}`;
7292
5715
  console.log(`${PRODUCT_NAME} web listening on ${host}:${actualPort}`);
7293
5716
  console.log(`project: ${ctx.root}`);
7294
5717
  console.log(`database: ${ctx.dbPath}`);
7295
- console.log(`open: ${url}`);
5718
+ console.log(`open: ${publicRuntimeUrl(runtime, ctx.root)}`);
7296
5719
  }
7297
5720
 
7298
5721
  async function cmdRun(ctx, args) {
@@ -7571,8 +5994,9 @@ Usage:
7571
5994
  ${CLI_NAME} task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
7572
5995
  ${CLI_NAME} task create --title TEXT --parent N [--team-role ROLE]
7573
5996
  ${CLI_NAME} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
7574
- ${CLI_NAME} task claim [--peer ID] --id N
7575
- ${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]
7576
6000
  ${CLI_NAME} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
7577
6001
  ${CLI_NAME} task done [--peer ID] --id N --summary TEXT
7578
6002
 
@@ -7580,8 +6004,13 @@ Default task list shows all project tasks that are not done or abandoned.
7580
6004
  --peer is an explicit filter; HCC_PEER does not hide other open tasks.
7581
6005
  Messages use per-peer unread ack state; tasks do not.
7582
6006
  task next returns your existing claimed/running/review/blocked task before
7583
- claiming another pending task. Use --force only when intentionally taking
7584
- another pending task.
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.
7585
6014
 
7586
6015
  If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
7587
6016
  as a stable project-local peer.
@@ -7607,7 +6036,7 @@ function helpState() {
7607
6036
  console.log(`${CLI_NAME} state
7608
6037
 
7609
6038
  Usage:
7610
- ${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]
7611
6040
 
7612
6041
  Shows the current collaboration timeline plus a machine-readable coordination
7613
6042
  state machine. With --json, the response includes automation.next_action.argv so
@@ -7616,6 +6045,9 @@ automation.current_task shows the peer's active claimed/running/review/blocked
7616
6045
  task when one exists.
7617
6046
  If a current task looks splittable, automation may suggest hcc team plan; this
7618
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.
7619
6051
 
7620
6052
  State does not execute coordination actions: it does not ack messages, claim
7621
6053
  tasks, acquire locks, send messages, create handoffs, or mark tasks done. Opening
@@ -7745,10 +6177,13 @@ function helpLock() {
7745
6177
  console.log(`${CLI_NAME} lock
7746
6178
 
7747
6179
  Usage:
7748
- ${CLI_NAME} lock acquire [--peer ID] --resource PATH [--task N] [--ttl SEC] [--reason TEXT]
7749
- ${CLI_NAME} lock renew [--peer ID] --resource PATH [--ttl SEC]
7750
- ${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]
7751
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.
7752
6187
  `);
7753
6188
  }
7754
6189