@logicseek/hello-cc 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -0
- package/README.md +31 -6
- package/README.zh-CN.md +31 -7
- package/assets/logo.svg +84 -0
- package/bin/hcc.mjs +2388 -290
- package/docs/README.md +24 -0
- package/docs/README.zh-CN.md +24 -0
- package/docs/commands.md +135 -0
- package/docs/commands.zh-CN.md +126 -0
- package/docs/design.md +12 -4
- package/docs/guide.md +83 -71
- package/docs/guide.zh-CN.md +74 -68
- package/docs/implementation.md +21 -1
- package/package.json +7 -1
- package/scripts/github-release.mjs +190 -0
- package/scripts/regression.mjs +852 -31
- package/scripts/release-notes.mjs +112 -0
package/bin/hcc.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import http from 'node:http';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
-
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
11
|
import { URL, fileURLToPath } from 'node:url';
|
|
@@ -16,11 +16,13 @@ const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'lib');
|
|
|
16
16
|
async function loadDiscover() { return import(path.join(_libDir, 'discover.mjs')); }
|
|
17
17
|
async function loadSetup() { return import(path.join(_libDir, 'setup.mjs')); }
|
|
18
18
|
|
|
19
|
-
const VERSION = '0.1.
|
|
19
|
+
const VERSION = '0.1.2';
|
|
20
20
|
const PRODUCT_NAME = 'hello-cc';
|
|
21
21
|
const CLI_NAME = 'hcc';
|
|
22
|
+
const NPM_PACKAGE_NAME = '@logicseek/hello-cc';
|
|
22
23
|
const DEFAULT_LOCK_TTL = 900;
|
|
23
24
|
const ACTIVE_PEER_TTL = 600;
|
|
25
|
+
const DB_SCHEMA_VERSION = 4;
|
|
24
26
|
// Directory under .hello-cc/ for optional external PTY buffer files.
|
|
25
27
|
const BUFS_DIR_NAME = 'bufs';
|
|
26
28
|
const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
@@ -128,6 +130,14 @@ function parseOpts(args, spec = {}) {
|
|
|
128
130
|
return opts;
|
|
129
131
|
}
|
|
130
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
|
+
|
|
131
141
|
function validateOpts(command, opts, allowed = []) {
|
|
132
142
|
const allowedSet = new Set(['_', ...allowed]);
|
|
133
143
|
for (const key of Object.keys(opts)) {
|
|
@@ -154,6 +164,10 @@ function shortHash(value) {
|
|
|
154
164
|
return createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
function providerSessionPeerId(kind, providerId) {
|
|
168
|
+
return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
function currentTtyName() {
|
|
158
172
|
if (process.env.TTY) return process.env.TTY;
|
|
159
173
|
const result = spawnSync('tty', [], {
|
|
@@ -376,6 +390,10 @@ function globalRuntimePath() {
|
|
|
376
390
|
return path.join(globalStateDir(), 'runtime.json');
|
|
377
391
|
}
|
|
378
392
|
|
|
393
|
+
function globalWebTokenPath() {
|
|
394
|
+
return path.join(globalStateDir(), 'web-token');
|
|
395
|
+
}
|
|
396
|
+
|
|
379
397
|
function projectRegistryPath() {
|
|
380
398
|
return path.join(globalStateDir(), 'projects.json');
|
|
381
399
|
}
|
|
@@ -588,6 +606,70 @@ function runtimeUrlQuery(runtime, projectRoot = null) {
|
|
|
588
606
|
return parts.length ? `?${parts.join('&')}` : '';
|
|
589
607
|
}
|
|
590
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
|
+
|
|
591
673
|
function publicRuntimeUrl(runtime, projectRoot = null) {
|
|
592
674
|
const host = runtime.host === '0.0.0.0' || runtime.host === '::'
|
|
593
675
|
? '<machine-ip>'
|
|
@@ -906,6 +988,13 @@ function tmuxSendKeys(pane, keys) {
|
|
|
906
988
|
|
|
907
989
|
function tmuxSendRawLiteral(pane, text) {
|
|
908
990
|
if (!text) return;
|
|
991
|
+
// A tmux argument consisting solely of ';' is swallowed as a command
|
|
992
|
+
// separator even after '--', so a typed lone semicolon would vanish. Paste
|
|
993
|
+
// such runs through a buffer (which is parsed as data, not command args).
|
|
994
|
+
if (/^;+$/.test(text)) {
|
|
995
|
+
tmuxPasteBuffer(pane, text, { raw: true });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
909
998
|
runTmux(['send-keys', '-t', pane, '-l', '--', text]);
|
|
910
999
|
}
|
|
911
1000
|
|
|
@@ -1027,18 +1116,26 @@ function tmuxSendLiteral(pane, text) {
|
|
|
1027
1116
|
}
|
|
1028
1117
|
if (current) chunks.push({ type: 'literal', text: current });
|
|
1029
1118
|
|
|
1119
|
+
// Typed characters must arrive as real key presses, not clipboard pastes:
|
|
1120
|
+
// send-keys -l is ~3x cheaper than the load-buffer/paste-buffer/delete-buffer
|
|
1121
|
+
// cycle (one tmux spawn vs three) and the target program sees keystrokes
|
|
1122
|
+
// rather than paste events — which bracketed-paste-aware TUIs (claude, codex)
|
|
1123
|
+
// otherwise mishandle. paste-buffer is reserved for genuine bracketed pastes.
|
|
1124
|
+
// Exit copy-mode once up front so send-keys isn't interpreted as copy commands.
|
|
1125
|
+
if (tmuxInCopyMode(pane)) tmuxExitCopyMode(pane);
|
|
1126
|
+
|
|
1030
1127
|
let pendingText = '';
|
|
1031
1128
|
for (const chunk of chunks) {
|
|
1032
1129
|
if (chunk.type === 'literal') {
|
|
1033
1130
|
pendingText += chunk.text;
|
|
1034
1131
|
} else {
|
|
1035
|
-
if (pendingText) {
|
|
1132
|
+
if (pendingText) { tmuxSendRawLiteral(pane, pendingText); pendingText = ''; }
|
|
1036
1133
|
if (chunk.type === 'key') tmuxSendKeys(pane, [chunk.key]);
|
|
1037
1134
|
else if (chunk.type === 'paste') tmuxPasteBuffer(pane, chunk.text, { bracketed: true, raw: true });
|
|
1038
1135
|
else tmuxSendRawLiteral(pane, chunk.text);
|
|
1039
1136
|
}
|
|
1040
1137
|
}
|
|
1041
|
-
if (pendingText)
|
|
1138
|
+
if (pendingText) tmuxSendRawLiteral(pane, pendingText);
|
|
1042
1139
|
}
|
|
1043
1140
|
|
|
1044
1141
|
function inferPeerKind(id, explicitKind, firstCommand) {
|
|
@@ -1388,6 +1485,9 @@ function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy =
|
|
|
1388
1485
|
return false;
|
|
1389
1486
|
}
|
|
1390
1487
|
|
|
1488
|
+
let projectMigrationFanoutDepth = 0;
|
|
1489
|
+
const migratedRegisteredProjectDbs = new Set();
|
|
1490
|
+
|
|
1391
1491
|
function connect(ctx) {
|
|
1392
1492
|
fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
|
|
1393
1493
|
const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
|
|
@@ -1395,16 +1495,84 @@ function connect(ctx) {
|
|
|
1395
1495
|
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
1396
1496
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
1397
1497
|
initSchema(db);
|
|
1498
|
+
migrateRegisteredProjectDbs(ctx);
|
|
1398
1499
|
return db;
|
|
1399
1500
|
}
|
|
1400
1501
|
|
|
1502
|
+
function migrateRegisteredProjectDbs(ctx) {
|
|
1503
|
+
if (projectMigrationFanoutDepth > 0) return;
|
|
1504
|
+
projectMigrationFanoutDepth += 1;
|
|
1505
|
+
try {
|
|
1506
|
+
const currentDb = path.resolve(ctx.dbPath);
|
|
1507
|
+
const seen = new Set([currentDb]);
|
|
1508
|
+
for (const project of readProjectRegistry()) {
|
|
1509
|
+
const root = path.resolve(project.root);
|
|
1510
|
+
const dbPath = path.resolve(project.db || path.join(root, '.hello-cc', 'mesh.db'));
|
|
1511
|
+
if (seen.has(dbPath)) continue;
|
|
1512
|
+
seen.add(dbPath);
|
|
1513
|
+
const cacheKey = `${dbPath}:${DB_SCHEMA_VERSION}`;
|
|
1514
|
+
if (migratedRegisteredProjectDbs.has(cacheKey)) continue;
|
|
1515
|
+
if (!fs.existsSync(root) || !fs.existsSync(dbPath)) continue;
|
|
1516
|
+
let db = null;
|
|
1517
|
+
try {
|
|
1518
|
+
db = new DatabaseSync(dbPath, { timeout: 5000 });
|
|
1519
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
1520
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
1521
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
1522
|
+
initSchema(db);
|
|
1523
|
+
migratedRegisteredProjectDbs.add(cacheKey);
|
|
1524
|
+
} catch (err) {
|
|
1525
|
+
throw new CliError('REGISTERED_DB_MIGRATION_FAILED', `Failed to migrate registered project database ${dbPath}: ${err?.message || err}`, {
|
|
1526
|
+
db: dbPath,
|
|
1527
|
+
code: err instanceof CliError ? err.code : undefined
|
|
1528
|
+
});
|
|
1529
|
+
} finally {
|
|
1530
|
+
try { db?.close(); } catch {}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
} finally {
|
|
1534
|
+
projectMigrationFanoutDepth -= 1;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1401
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) {
|
|
1402
1564
|
execWithBusyRetry(db, `
|
|
1403
1565
|
CREATE TABLE IF NOT EXISTS meta (
|
|
1404
1566
|
key TEXT PRIMARY KEY,
|
|
1405
1567
|
value TEXT NOT NULL
|
|
1406
1568
|
);
|
|
1407
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
|
+
|
|
1408
1576
|
CREATE TABLE IF NOT EXISTS peers (
|
|
1409
1577
|
id TEXT PRIMARY KEY,
|
|
1410
1578
|
kind TEXT NOT NULL,
|
|
@@ -1441,6 +1609,8 @@ function initSchema(db) {
|
|
|
1441
1609
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
1442
1610
|
assignee TEXT,
|
|
1443
1611
|
owner TEXT,
|
|
1612
|
+
parent_id INTEGER,
|
|
1613
|
+
team_role TEXT,
|
|
1444
1614
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
1445
1615
|
created_by TEXT,
|
|
1446
1616
|
claimed_at INTEGER,
|
|
@@ -1456,6 +1626,8 @@ function initSchema(db) {
|
|
|
1456
1626
|
task_id INTEGER,
|
|
1457
1627
|
kind TEXT NOT NULL DEFAULT 'note',
|
|
1458
1628
|
body TEXT NOT NULL,
|
|
1629
|
+
reply_to INTEGER,
|
|
1630
|
+
thread_id INTEGER,
|
|
1459
1631
|
created_at INTEGER NOT NULL
|
|
1460
1632
|
);
|
|
1461
1633
|
|
|
@@ -1503,28 +1675,110 @@ function initSchema(db) {
|
|
|
1503
1675
|
CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
|
|
1504
1676
|
CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
|
|
1505
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) {
|
|
1506
1699
|
db.prepare(`
|
|
1507
|
-
INSERT INTO meta(key, value) VALUES ('schema_version',
|
|
1508
|
-
ON CONFLICT(key) DO
|
|
1509
|
-
`).run();
|
|
1700
|
+
INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
1701
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
1702
|
+
`).run(String(version));
|
|
1703
|
+
db.exec(`PRAGMA user_version = ${Number(version)}`);
|
|
1704
|
+
}
|
|
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
|
+
}
|
|
1510
1718
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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
|
+
});
|
|
1514
1763
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
+
});
|
|
1528
1782
|
}
|
|
1529
1783
|
|
|
1530
1784
|
function peerBindingRuntimeRank(row) {
|
|
@@ -1891,23 +2145,32 @@ function touchCurrentPeer(db, ctx, resolved, status = null, kindHint = 'shell')
|
|
|
1891
2145
|
});
|
|
1892
2146
|
}
|
|
1893
2147
|
|
|
1894
|
-
function sendMessage(db, sender, recipient, taskId, kind, body) {
|
|
2148
|
+
function sendMessage(db, sender, recipient, taskId, kind, body, meta = {}) {
|
|
2149
|
+
const replyTo = meta.reply_to || null;
|
|
2150
|
+
const threadId = meta.thread_id || null;
|
|
1895
2151
|
const info = db.prepare(`
|
|
1896
|
-
INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
|
|
1897
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1898
|
-
`).run(sender, recipient || 'all', taskId || null, kind || 'note', body, now());
|
|
2152
|
+
INSERT INTO messages(sender, recipient, task_id, kind, body, reply_to, thread_id, created_at)
|
|
2153
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2154
|
+
`).run(sender, recipient || 'all', taskId || null, kind || 'note', body, replyTo, threadId, now());
|
|
2155
|
+
const messageId = Number(info.lastInsertRowid);
|
|
2156
|
+
if (!threadId) {
|
|
2157
|
+
db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(messageId, messageId);
|
|
2158
|
+
}
|
|
1899
2159
|
addEvent(db, 'message.sent', sender, taskId || null, {
|
|
1900
|
-
message_id:
|
|
2160
|
+
message_id: messageId,
|
|
1901
2161
|
recipient: recipient || 'all',
|
|
1902
|
-
kind: kind || 'note'
|
|
2162
|
+
kind: kind || 'note',
|
|
2163
|
+
reply_to: replyTo,
|
|
2164
|
+
thread_id: threadId || messageId
|
|
1903
2165
|
});
|
|
1904
|
-
return
|
|
2166
|
+
return messageId;
|
|
1905
2167
|
}
|
|
1906
2168
|
|
|
1907
2169
|
function queryInbox(db, peer, includeAll, limit) {
|
|
1908
2170
|
return db.prepare(`
|
|
1909
2171
|
SELECT
|
|
1910
|
-
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2172
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2173
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
1911
2174
|
FROM messages m
|
|
1912
2175
|
LEFT JOIN message_reads r
|
|
1913
2176
|
ON r.message_id = m.id AND r.peer = ?
|
|
@@ -1919,6 +2182,417 @@ function queryInbox(db, peer, includeAll, limit) {
|
|
|
1919
2182
|
`).all(peer, peer, includeAll ? 1 : 0, limit);
|
|
1920
2183
|
}
|
|
1921
2184
|
|
|
2185
|
+
function queryTimelineMessages(db, peer, limit) {
|
|
2186
|
+
if (!peer) {
|
|
2187
|
+
return db.prepare(`
|
|
2188
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2189
|
+
FROM messages
|
|
2190
|
+
ORDER BY id DESC
|
|
2191
|
+
LIMIT ?
|
|
2192
|
+
`).all(limit).reverse();
|
|
2193
|
+
}
|
|
2194
|
+
return db.prepare(`
|
|
2195
|
+
SELECT
|
|
2196
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2197
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
2198
|
+
FROM messages m
|
|
2199
|
+
LEFT JOIN message_reads r
|
|
2200
|
+
ON r.message_id = m.id AND r.peer = ?
|
|
2201
|
+
WHERE
|
|
2202
|
+
m.sender = ?
|
|
2203
|
+
OR m.recipient IS NULL
|
|
2204
|
+
OR m.recipient = ''
|
|
2205
|
+
OR m.recipient = 'all'
|
|
2206
|
+
OR m.recipient = ?
|
|
2207
|
+
ORDER BY m.id DESC
|
|
2208
|
+
LIMIT ?
|
|
2209
|
+
`).all(peer, peer, peer, limit).reverse();
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
function getMessage(db, id) {
|
|
2213
|
+
return db.prepare(`
|
|
2214
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2215
|
+
FROM messages
|
|
2216
|
+
WHERE id = ?
|
|
2217
|
+
`).get(id);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function queryMessageThread(db, messageId, limit) {
|
|
2221
|
+
const message = getMessage(db, messageId);
|
|
2222
|
+
if (!message) throw new CliError('NOT_FOUND', `Message #${messageId} does not exist`);
|
|
2223
|
+
const threadId = message.thread_id || message.id;
|
|
2224
|
+
const rows = db.prepare(`
|
|
2225
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2226
|
+
FROM messages
|
|
2227
|
+
WHERE id = ? OR thread_id = ?
|
|
2228
|
+
ORDER BY id ASC
|
|
2229
|
+
LIMIT ?
|
|
2230
|
+
`).all(message.id, threadId, limit);
|
|
2231
|
+
return { message, thread_id: threadId, messages: rows };
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function parseEventPayload(row) {
|
|
2235
|
+
try {
|
|
2236
|
+
return row?.payload ? JSON.parse(row.payload) : {};
|
|
2237
|
+
} catch {
|
|
2238
|
+
return {};
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function compactText(value, limit = 160) {
|
|
2243
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
2244
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
function uniqueList(values) {
|
|
2248
|
+
return [...new Set(values.filter(Boolean).map(String))];
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function messageParticipants(message) {
|
|
2252
|
+
const participants = [message.sender];
|
|
2253
|
+
if (message.recipient && !['all', ''].includes(message.recipient)) participants.push(message.recipient);
|
|
2254
|
+
return uniqueList(participants);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function taskParticipants(task) {
|
|
2258
|
+
return uniqueList([task.created_by, task.owner, task.assignee]);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
function payloadParticipants(payload) {
|
|
2262
|
+
const values = [];
|
|
2263
|
+
for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
|
|
2264
|
+
if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
|
|
2265
|
+
}
|
|
2266
|
+
return uniqueList(values);
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function peerMatchesTimelineItem(item, peer) {
|
|
2270
|
+
if (!peer) return true;
|
|
2271
|
+
if (item.broadcast) return true;
|
|
2272
|
+
return (item.peers || []).includes(peer);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function shouldHideTimelineMessage(message) {
|
|
2276
|
+
if (message.kind === 'task' && /^Task #\d+ assigned: /.test(message.body || '')) return true;
|
|
2277
|
+
if (message.kind === 'handoff' && /^Handoff #\d+: /.test(message.body || '')) return true;
|
|
2278
|
+
return false;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
const TIMELINE_EVENT_ALLOW = new Set([
|
|
2282
|
+
'task.created',
|
|
2283
|
+
'task.claimed',
|
|
2284
|
+
'task.pending',
|
|
2285
|
+
'task.running',
|
|
2286
|
+
'task.review',
|
|
2287
|
+
'task.blocked',
|
|
2288
|
+
'task.done',
|
|
2289
|
+
'task.abandoned',
|
|
2290
|
+
'team.started',
|
|
2291
|
+
'lock.acquired',
|
|
2292
|
+
'lock.released',
|
|
2293
|
+
'peer.registered',
|
|
2294
|
+
'peer.joined',
|
|
2295
|
+
'peer.auto_joined',
|
|
2296
|
+
'peer.stopped',
|
|
2297
|
+
'provider.session.merged',
|
|
2298
|
+
'web.session.started',
|
|
2299
|
+
'web.session.exited',
|
|
2300
|
+
'tmux.session.attached',
|
|
2301
|
+
'tmux.session.detached',
|
|
2302
|
+
'tmux.session.exited',
|
|
2303
|
+
'run.session.started',
|
|
2304
|
+
'run.session.exited'
|
|
2305
|
+
]);
|
|
2306
|
+
|
|
2307
|
+
function shouldHideTimelineEvent(event) {
|
|
2308
|
+
if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
|
|
2309
|
+
if (event.type && event.type.startsWith('hook.')) return true;
|
|
2310
|
+
if (event.type && event.type.startsWith('web.session.input')) return true;
|
|
2311
|
+
return !TIMELINE_EVENT_ALLOW.has(event.type);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function timelineDirection(message, peer) {
|
|
2315
|
+
if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
|
|
2316
|
+
if (message.sender === peer && message.recipient === peer) return 'self';
|
|
2317
|
+
if (message.sender === peer) return 'out';
|
|
2318
|
+
if (message.recipient === 'all') return 'broadcast';
|
|
2319
|
+
if (message.recipient === peer) return 'in';
|
|
2320
|
+
return 'project';
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
|
|
2324
|
+
const items = [];
|
|
2325
|
+
for (const message of messages) {
|
|
2326
|
+
if (shouldHideTimelineMessage(message)) continue;
|
|
2327
|
+
const item = {
|
|
2328
|
+
id: `message:${message.id}`,
|
|
2329
|
+
source: 'message',
|
|
2330
|
+
source_id: message.id,
|
|
2331
|
+
ts: message.created_at,
|
|
2332
|
+
actor: message.sender,
|
|
2333
|
+
peers: messageParticipants(message),
|
|
2334
|
+
task_id: message.task_id || null,
|
|
2335
|
+
kind: message.kind || 'note',
|
|
2336
|
+
title: `${message.sender} -> ${message.recipient || 'all'}`,
|
|
2337
|
+
text: compactText(message.body),
|
|
2338
|
+
unread: message.read_at === null || message.read_at === undefined,
|
|
2339
|
+
direction: timelineDirection(message, peer),
|
|
2340
|
+
thread_id: message.thread_id || message.id,
|
|
2341
|
+
reply_to: message.reply_to || null,
|
|
2342
|
+
broadcast: !message.recipient || message.recipient === 'all'
|
|
2343
|
+
};
|
|
2344
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2345
|
+
}
|
|
2346
|
+
for (const handoff of handoffs) {
|
|
2347
|
+
const item = {
|
|
2348
|
+
id: `handoff:${handoff.id}`,
|
|
2349
|
+
source: 'handoff',
|
|
2350
|
+
source_id: handoff.id,
|
|
2351
|
+
ts: handoff.created_at,
|
|
2352
|
+
actor: handoff.from_peer,
|
|
2353
|
+
peers: uniqueList([handoff.from_peer, handoff.to_peer]),
|
|
2354
|
+
task_id: handoff.task_id || null,
|
|
2355
|
+
kind: 'handoff',
|
|
2356
|
+
title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
|
|
2357
|
+
text: compactText(handoff.summary),
|
|
2358
|
+
direction: 'project',
|
|
2359
|
+
broadcast: !handoff.to_peer,
|
|
2360
|
+
tests: handoff.tests || '',
|
|
2361
|
+
risks: handoff.risks || ''
|
|
2362
|
+
};
|
|
2363
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2364
|
+
}
|
|
2365
|
+
for (const task of tasks) {
|
|
2366
|
+
const item = {
|
|
2367
|
+
id: `task:${task.id}`,
|
|
2368
|
+
source: 'task',
|
|
2369
|
+
source_id: task.id,
|
|
2370
|
+
ts: task.updated_at || task.created_at,
|
|
2371
|
+
actor: task.owner || task.created_by || task.assignee || '',
|
|
2372
|
+
peers: taskParticipants(task),
|
|
2373
|
+
task_id: task.id,
|
|
2374
|
+
kind: task.status,
|
|
2375
|
+
title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
|
|
2376
|
+
text: compactText(task.title),
|
|
2377
|
+
direction: 'project'
|
|
2378
|
+
};
|
|
2379
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2380
|
+
}
|
|
2381
|
+
for (const lock of locks) {
|
|
2382
|
+
const item = {
|
|
2383
|
+
id: `lock:${lock.resource}`,
|
|
2384
|
+
source: 'lock',
|
|
2385
|
+
source_id: lock.resource,
|
|
2386
|
+
ts: lock.created_at,
|
|
2387
|
+
actor: lock.owner,
|
|
2388
|
+
peers: uniqueList([lock.owner]),
|
|
2389
|
+
task_id: lock.task_id || null,
|
|
2390
|
+
kind: 'active',
|
|
2391
|
+
title: `lock ${lock.resource}`,
|
|
2392
|
+
text: compactText(lock.reason || `owner=${lock.owner}`),
|
|
2393
|
+
direction: 'project'
|
|
2394
|
+
};
|
|
2395
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2396
|
+
}
|
|
2397
|
+
for (const event of events) {
|
|
2398
|
+
if (shouldHideTimelineEvent(event)) continue;
|
|
2399
|
+
const payload = parseEventPayload(event);
|
|
2400
|
+
const item = {
|
|
2401
|
+
id: `event:${event.id}`,
|
|
2402
|
+
source: 'event',
|
|
2403
|
+
source_id: event.id,
|
|
2404
|
+
ts: event.created_at,
|
|
2405
|
+
actor: event.actor || '',
|
|
2406
|
+
peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
|
|
2407
|
+
task_id: event.task_id || null,
|
|
2408
|
+
kind: event.type,
|
|
2409
|
+
title: event.type,
|
|
2410
|
+
text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
|
|
2411
|
+
direction: 'project'
|
|
2412
|
+
};
|
|
2413
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2414
|
+
}
|
|
2415
|
+
const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
|
|
2416
|
+
items.sort((a, b) =>
|
|
2417
|
+
(a.ts || 0) - (b.ts || 0) ||
|
|
2418
|
+
(order[a.source] || 99) - (order[b.source] || 99) ||
|
|
2419
|
+
String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
|
|
2420
|
+
return items.slice(-120);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function actionCommand(argv) {
|
|
2424
|
+
if (!argv?.length) return '';
|
|
2425
|
+
return [CLI_NAME, ...argv].map(shellQuoteArg).join(' ');
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function makeAction(kind, argv, reason, mutates = true, extra = {}) {
|
|
2429
|
+
return {
|
|
2430
|
+
kind,
|
|
2431
|
+
reason,
|
|
2432
|
+
mutates,
|
|
2433
|
+
argv,
|
|
2434
|
+
command: actionCommand(argv),
|
|
2435
|
+
...extra
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function looksLikeMultiTask(task) {
|
|
2440
|
+
if (!task) return false;
|
|
2441
|
+
const text = `${task.title || ''}\n${task.body || ''}`;
|
|
2442
|
+
const bullets = text.split('\n').filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S+/.test(line)).length;
|
|
2443
|
+
const separators = (text.match(/[,,;;、]/g) || []).length;
|
|
2444
|
+
return bullets >= 2 || separators >= 3 || /多任务|并行|团队|分工|several tasks|multiple tasks|parallel|team/i.test(text);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
function selectCurrentTask(tasks, peerId) {
|
|
2448
|
+
if (!peerId) return null;
|
|
2449
|
+
const statusRank = { running: 0, claimed: 1, review: 2, blocked: 3 };
|
|
2450
|
+
const openTasks = (tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
2451
|
+
const ownedTasks = openTasks
|
|
2452
|
+
.filter((task) => task.owner === peerId)
|
|
2453
|
+
.sort((a, b) =>
|
|
2454
|
+
(statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9) ||
|
|
2455
|
+
Number(a.priority || 0) - Number(b.priority || 0) ||
|
|
2456
|
+
Number(a.id || 0) - Number(b.id || 0));
|
|
2457
|
+
return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function summarizeTask(task) {
|
|
2461
|
+
if (!task) return null;
|
|
2462
|
+
return {
|
|
2463
|
+
id: task.id,
|
|
2464
|
+
title: task.title,
|
|
2465
|
+
status: task.status,
|
|
2466
|
+
owner: task.owner || null,
|
|
2467
|
+
assignee: task.assignee || null,
|
|
2468
|
+
parent_id: task.parent_id || null,
|
|
2469
|
+
team_role: task.team_role || null,
|
|
2470
|
+
priority: task.priority
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
function deriveAutomation(snapshot, peer = null, opts = {}) {
|
|
2475
|
+
const peerId = peer || '';
|
|
2476
|
+
const t = Number(snapshot.now || now());
|
|
2477
|
+
const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
|
|
2478
|
+
const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
2479
|
+
const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
|
|
2480
|
+
const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
|
|
2481
|
+
const ownedTask = selectCurrentTask(openTasks, peerId);
|
|
2482
|
+
const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
|
|
2483
|
+
const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
|
|
2484
|
+
const requestedLocks = resources.map((resource) => {
|
|
2485
|
+
const lock = (snapshot.locks || []).find((row) => row.resource === resource && Number(row.expires_at || 0) > t);
|
|
2486
|
+
return { resource, lock };
|
|
2487
|
+
});
|
|
2488
|
+
const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
|
|
2489
|
+
const missingLock = requestedLocks.find((entry) => !entry.lock);
|
|
2490
|
+
const unread = peerId ? (snapshot.messages || []).filter((message) =>
|
|
2491
|
+
message.read_at === null || message.read_at === undefined) : [];
|
|
2492
|
+
const warnings = [];
|
|
2493
|
+
if (peerId && !peerRow) warnings.push(`peer ${peerId} is not registered in this project`);
|
|
2494
|
+
const lockActions = [];
|
|
2495
|
+
const messageActions = [];
|
|
2496
|
+
const taskActions = [];
|
|
2497
|
+
const automation = {
|
|
2498
|
+
schema_version: 1,
|
|
2499
|
+
peer: peerId ? {
|
|
2500
|
+
id: peerId,
|
|
2501
|
+
known: Boolean(peerRow),
|
|
2502
|
+
active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= ACTIVE_PEER_TTL),
|
|
2503
|
+
age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
|
|
2504
|
+
} : null,
|
|
2505
|
+
current_task: summarizeTask(ownedTask),
|
|
2506
|
+
phase: 'idle',
|
|
2507
|
+
next_action: makeAction('none', [], 'no immediate coordination action', false),
|
|
2508
|
+
actions: [],
|
|
2509
|
+
finish_actions: [],
|
|
2510
|
+
warnings
|
|
2511
|
+
};
|
|
2512
|
+
|
|
2513
|
+
const orderedUnread = unread
|
|
2514
|
+
.filter((message) => message.sender !== peerId)
|
|
2515
|
+
.filter((message) => !shouldHideTimelineMessage(message))
|
|
2516
|
+
.sort((a, b) => (a.kind === 'task' ? 1 : 0) - (b.kind === 'task' ? 1 : 0) || a.id - b.id);
|
|
2517
|
+
for (const message of orderedUnread) {
|
|
2518
|
+
if (ownedTask && message.kind === 'task') continue;
|
|
2519
|
+
const kind = message.kind === 'task' ? 'msg.inbox' : 'msg.reply';
|
|
2520
|
+
const argv = kind === 'msg.reply'
|
|
2521
|
+
? ['msg', 'reply', '--from', peerId, '--id', String(message.id), '--body', '<answer>']
|
|
2522
|
+
: ['msg', 'inbox', '--peer', peerId];
|
|
2523
|
+
messageActions.push(makeAction(kind, argv, `unread message #${message.id} from ${message.sender}`, kind !== 'msg.inbox', {
|
|
2524
|
+
message_id: message.id,
|
|
2525
|
+
task_id: message.task_id || null
|
|
2526
|
+
}));
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if (!ownedTask && assignedTasks.length) {
|
|
2530
|
+
const task = assignedTasks[0];
|
|
2531
|
+
taskActions.push(makeAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
|
|
2532
|
+
} else if (!ownedTask && availableTasks.length) {
|
|
2533
|
+
taskActions.push(makeAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
if (ownedTask) {
|
|
2537
|
+
if (conflict) {
|
|
2538
|
+
lockActions.push(makeAction('msg.send', ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${conflict.resource}; it is locked by ${conflict.lock.owner}.`], `${conflict.resource} is locked by ${conflict.lock.owner}`, true, { task_id: ownedTask.id, resource: conflict.resource, lock_owner: conflict.lock.owner }));
|
|
2539
|
+
} else if (missingLock) {
|
|
2540
|
+
lockActions.push(makeAction('lock.acquire', ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), '--resource', missingLock.resource, '--ttl', String(DEFAULT_LOCK_TTL), '--reason', '<work>'], `task #${ownedTask.id} needs lock for ${missingLock.resource}`, true, { task_id: ownedTask.id, resource: missingLock.resource }));
|
|
2541
|
+
}
|
|
2542
|
+
if (ownedTask.status === 'claimed') {
|
|
2543
|
+
taskActions.push(makeAction('task.update', ['task', 'update', '--peer', peerId, '--id', String(ownedTask.id), '--status', 'running', '--summary', '<started>'], `task #${ownedTask.id} is claimed but not running`, true, { task_id: ownedTask.id }));
|
|
2544
|
+
}
|
|
2545
|
+
if (!ownedTask.parent_id && looksLikeMultiTask(ownedTask)) {
|
|
2546
|
+
const childCount = openTasks.filter((task) => task.parent_id === ownedTask.id && !['done', 'abandoned'].includes(task.status)).length;
|
|
2547
|
+
if (!childCount) {
|
|
2548
|
+
taskActions.push(makeAction('team.plan', ['team', 'plan', '--from-task', String(ownedTask.id)], `task #${ownedTask.id} looks splittable; plan explicit team subtasks`, false, { task_id: ownedTask.id }));
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
automation.finish_actions.push(makeAction('handoff.create', ['handoff', 'create', '--from', peerId, '--task', String(ownedTask.id), '--summary', '<summary>', '--tests', '<tests>', '--risks', '<risks>'], `handoff task #${ownedTask.id} before stopping`, true, { task_id: ownedTask.id }));
|
|
2552
|
+
automation.finish_actions.push(makeAction('task.done', ['task', 'done', '--peer', peerId, '--id', String(ownedTask.id), '--summary', '<summary>'], `mark task #${ownedTask.id} done after handoff`, true, { task_id: ownedTask.id }));
|
|
2553
|
+
for (const lock of ownLocks) {
|
|
2554
|
+
automation.finish_actions.push(makeAction('lock.release', ['lock', 'release', '--peer', peerId, '--resource', lock.resource], `release ${lock.resource}`, true, { task_id: lock.task_id || null, resource: lock.resource }));
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
automation.actions.push(...lockActions, ...messageActions, ...taskActions);
|
|
2558
|
+
|
|
2559
|
+
if (opts.intent === 'finish' || opts.intent === 'stop') {
|
|
2560
|
+
automation.phase = ownedTask ? 'handoff' : 'idle';
|
|
2561
|
+
automation.next_action = automation.finish_actions[0] || automation.next_action;
|
|
2562
|
+
return automation;
|
|
2563
|
+
}
|
|
2564
|
+
automation.next_action = automation.actions[0] || (ownedTask
|
|
2565
|
+
? makeAction('none', [], `continue task #${ownedTask.id}`, false, { task_id: ownedTask.id })
|
|
2566
|
+
: automation.next_action);
|
|
2567
|
+
if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
|
|
2568
|
+
else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
|
|
2569
|
+
else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
|
|
2570
|
+
else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
|
|
2571
|
+
else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
|
|
2572
|
+
else if (ownedTask) automation.phase = ownedTask.status === 'review' ? 'handoff' : 'work';
|
|
2573
|
+
return automation;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function renderAutomationContext(automation) {
|
|
2577
|
+
if (!automation) return '';
|
|
2578
|
+
const lines = [
|
|
2579
|
+
'[hello-cc next action]',
|
|
2580
|
+
`phase: ${automation.phase}`,
|
|
2581
|
+
automation.current_task ? `current_task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
|
|
2582
|
+
`next: ${automation.next_action.command || automation.next_action.kind}`,
|
|
2583
|
+
`why: ${automation.next_action.reason}`
|
|
2584
|
+
].filter(Boolean);
|
|
2585
|
+
if (automation.finish_actions?.length) {
|
|
2586
|
+
lines.push('finish:');
|
|
2587
|
+
for (const action of automation.finish_actions.slice(0, 4)) lines.push(`- ${action.command}`);
|
|
2588
|
+
}
|
|
2589
|
+
if (automation.warnings?.length) {
|
|
2590
|
+
lines.push('warnings:');
|
|
2591
|
+
for (const warning of automation.warnings.slice(0, 4)) lines.push(`- ${warning}`);
|
|
2592
|
+
}
|
|
2593
|
+
return lines.join('\n');
|
|
2594
|
+
}
|
|
2595
|
+
|
|
1922
2596
|
function queryOpenTasks(db, limit, peer = null) {
|
|
1923
2597
|
const sql = `
|
|
1924
2598
|
SELECT * FROM tasks
|
|
@@ -1961,29 +2635,81 @@ function formatHookEventName(hookType) {
|
|
|
1961
2635
|
return known[compact] || String(hookType || 'unknown');
|
|
1962
2636
|
}
|
|
1963
2637
|
|
|
1964
|
-
function
|
|
1965
|
-
const openTasks = queryOpenTasks(db, 8);
|
|
1966
|
-
const unread = queryInbox(db, peerId, false, 5);
|
|
2638
|
+
function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
|
|
1967
2639
|
const t = now();
|
|
1968
2640
|
const peers = db.prepare(`
|
|
1969
|
-
SELECT id, kind, status,
|
|
2641
|
+
SELECT id, kind, role, status, worktree, branch, pid, capabilities,
|
|
2642
|
+
created_at, last_seen_at, (? - last_seen_at) AS age_sec
|
|
1970
2643
|
FROM peers
|
|
1971
2644
|
ORDER BY last_seen_at DESC, id ASC
|
|
1972
|
-
LIMIT
|
|
1973
|
-
`).all();
|
|
2645
|
+
LIMIT 200
|
|
2646
|
+
`).all(t);
|
|
2647
|
+
const tasks = queryOpenTasks(db, 200);
|
|
2648
|
+
const locks = db.prepare(`
|
|
2649
|
+
SELECT resource, owner, task_id, reason, expires_at, created_at
|
|
2650
|
+
FROM locks
|
|
2651
|
+
WHERE expires_at > ?
|
|
2652
|
+
ORDER BY resource ASC
|
|
2653
|
+
LIMIT 200
|
|
2654
|
+
`).all(t);
|
|
2655
|
+
const messages = peer
|
|
2656
|
+
? queryInbox(db, peer, false, 50)
|
|
2657
|
+
: db.prepare(`
|
|
2658
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2659
|
+
FROM messages
|
|
2660
|
+
ORDER BY id DESC
|
|
2661
|
+
LIMIT 50
|
|
2662
|
+
`).all().reverse();
|
|
2663
|
+
const timelineMessages = queryTimelineMessages(db, peer, 80);
|
|
2664
|
+
const handoffs = db.prepare(`
|
|
2665
|
+
SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
|
|
2666
|
+
FROM handoffs
|
|
2667
|
+
ORDER BY id DESC
|
|
2668
|
+
LIMIT 50
|
|
2669
|
+
`).all().reverse();
|
|
2670
|
+
const events = db.prepare(`
|
|
2671
|
+
SELECT id, type, actor, task_id, payload, created_at
|
|
2672
|
+
FROM events
|
|
2673
|
+
ORDER BY id DESC
|
|
2674
|
+
LIMIT 80
|
|
2675
|
+
`).all().reverse();
|
|
2676
|
+
const snapshot = {
|
|
2677
|
+
root: ctx.root,
|
|
2678
|
+
db: ctx.dbPath,
|
|
2679
|
+
now: t,
|
|
2680
|
+
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
2681
|
+
peers,
|
|
2682
|
+
tasks,
|
|
2683
|
+
locks,
|
|
2684
|
+
messages,
|
|
2685
|
+
handoffs,
|
|
2686
|
+
events
|
|
2687
|
+
};
|
|
2688
|
+
snapshot.timeline = timelineFromRows({ messages: timelineMessages, handoffs, tasks, locks, events }, peer);
|
|
2689
|
+
snapshot.automation = deriveAutomation(snapshot, peer, opts);
|
|
2690
|
+
return snapshot;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function buildHookCoordinationContext(db, ctx, peerId) {
|
|
2694
|
+
const snapshot = collectStateSnapshot(db, ctx, peerId);
|
|
2695
|
+
const openTasks = snapshot.tasks.slice(0, 8);
|
|
2696
|
+
const unread = snapshot.messages.slice(0, 5);
|
|
2697
|
+
const peers = snapshot.peers.slice(0, 8);
|
|
1974
2698
|
const parts = [
|
|
1975
2699
|
'[hello-cc coordination]',
|
|
1976
2700
|
`peer: ${peerId}`,
|
|
1977
2701
|
'This is live project coordination context injected by hello-cc.',
|
|
1978
2702
|
'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
|
|
1979
|
-
'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
|
|
1980
|
-
'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.'
|
|
2703
|
+
'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc state, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
|
|
2704
|
+
'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.',
|
|
2705
|
+
'If you already own a current task, continue that task until handoff/done/blocked; do not claim a different task just because a new prompt arrived.',
|
|
2706
|
+
'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
|
|
1981
2707
|
];
|
|
1982
2708
|
|
|
1983
2709
|
if (peers.length > 0) {
|
|
1984
2710
|
parts.push('[hello-cc known peers]');
|
|
1985
2711
|
parts.push(...peers.map((peer) => {
|
|
1986
|
-
const age = Math.max(0,
|
|
2712
|
+
const age = Math.max(0, snapshot.now - Number(peer.last_seen_at || 0));
|
|
1987
2713
|
const active = age <= ACTIVE_PEER_TTL ? 'active' : 'stale';
|
|
1988
2714
|
return `- ${peer.id} ${peer.kind || 'other'} ${peer.status || 'idle'} ${active}`;
|
|
1989
2715
|
}));
|
|
@@ -1991,6 +2717,12 @@ function buildHookCoordinationContext(db, peerId) {
|
|
|
1991
2717
|
parts.push('[hello-cc known peers]\n(none)');
|
|
1992
2718
|
}
|
|
1993
2719
|
|
|
2720
|
+
if (snapshot.automation.current_task) {
|
|
2721
|
+
const task = snapshot.automation.current_task;
|
|
2722
|
+
parts.push('[hello-cc current task]');
|
|
2723
|
+
parts.push(`#${task.id} ${task.status}: ${task.title}`);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
1994
2726
|
if (openTasks.length > 0) {
|
|
1995
2727
|
parts.push('[hello-cc open tasks]');
|
|
1996
2728
|
parts.push(...openTasks.map((task) => `- ${formatOpenTaskLine(task)}`));
|
|
@@ -2001,12 +2733,22 @@ function buildHookCoordinationContext(db, peerId) {
|
|
|
2001
2733
|
if (unread.length > 0) {
|
|
2002
2734
|
parts.push('[hello-cc unread messages]');
|
|
2003
2735
|
parts.push(...unread.map((m) =>
|
|
2004
|
-
`- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}: ${m.body}`
|
|
2736
|
+
`- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}${m.reply_to ? ` reply #${m.reply_to}` : ''}: ${m.body}`
|
|
2005
2737
|
));
|
|
2006
2738
|
} else {
|
|
2007
2739
|
parts.push('[hello-cc unread messages]\n(none)');
|
|
2008
2740
|
}
|
|
2009
2741
|
|
|
2742
|
+
if (snapshot.locks.length > 0) {
|
|
2743
|
+
parts.push('[hello-cc active locks]');
|
|
2744
|
+
parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
|
|
2745
|
+
`- ${lock.resource} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
|
|
2746
|
+
} else {
|
|
2747
|
+
parts.push('[hello-cc active locks]\n(none)');
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
parts.push(renderAutomationContext(snapshot.automation));
|
|
2751
|
+
|
|
2010
2752
|
return { text: parts.join('\n'), messages: unread };
|
|
2011
2753
|
}
|
|
2012
2754
|
|
|
@@ -2072,15 +2814,49 @@ Status checks:
|
|
|
2072
2814
|
- \`hcc msg inbox\` shows unread messages for this session.
|
|
2073
2815
|
- \`hcc lock list\` shows active advisory locks.
|
|
2074
2816
|
- \`hcc status\` summarizes peers, tasks, locks, inbox, and recent events.
|
|
2075
|
-
|
|
2076
|
-
|
|
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:
|
|
2077
2840
|
- Register with hcc.
|
|
2078
|
-
- Read current status with \`hcc status\`, \`hcc task list\`, and \`hcc msg inbox\`.
|
|
2079
|
-
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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:
|
|
2082
2850
|
- Acquire an advisory lock for the file, directory, module, or shared resource.
|
|
2083
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.
|
|
2084
2860
|
|
|
2085
2861
|
During work:
|
|
2086
2862
|
- Keep changes scoped to the claimed task.
|
|
@@ -2289,7 +3065,7 @@ async function cmdPeers(ctx, args) {
|
|
|
2289
3065
|
|
|
2290
3066
|
async function cmdTask(ctx, args) {
|
|
2291
3067
|
const sub = args[0];
|
|
2292
|
-
if (!sub ||
|
|
3068
|
+
if (!sub || wantsHelp(args)) return helpTask();
|
|
2293
3069
|
if (sub === 'create') return taskCreate(ctx, args.slice(1));
|
|
2294
3070
|
if (sub === 'list') return taskList(ctx, args.slice(1));
|
|
2295
3071
|
if (sub === 'claim') return taskClaim(ctx, args.slice(1));
|
|
@@ -2307,22 +3083,28 @@ async function taskCreate(ctx, args) {
|
|
|
2307
3083
|
const createdBy = identity.id;
|
|
2308
3084
|
const assignee = opts.to || opts.assignee || null;
|
|
2309
3085
|
const priority = intOpt(opts, 'priority', 100);
|
|
3086
|
+
const parentId = intOpt(opts, 'parent', null);
|
|
3087
|
+
const teamRole = opts.role || opts['team-role'] || null;
|
|
2310
3088
|
const db = connect(ctx);
|
|
2311
3089
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
2312
3090
|
const t = now();
|
|
2313
3091
|
const id = tx(db, () => {
|
|
3092
|
+
if (parentId && !db.prepare('SELECT id FROM tasks WHERE id = ?').get(parentId)) {
|
|
3093
|
+
throw new CliError('NOT_FOUND', `Parent task #${parentId} does not exist`);
|
|
3094
|
+
}
|
|
2314
3095
|
const info = db.prepare(`
|
|
2315
|
-
INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
|
|
2316
|
-
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?)
|
|
2317
|
-
`).run(title, body, assignee, priority, createdBy, t, t);
|
|
3096
|
+
INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
3097
|
+
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
|
|
3098
|
+
`).run(title, body, assignee, parentId, teamRole, priority, createdBy, t, t);
|
|
2318
3099
|
const taskId = Number(info.lastInsertRowid);
|
|
2319
|
-
addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority });
|
|
3100
|
+
addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority, parent_id: parentId, team_role: teamRole });
|
|
2320
3101
|
if (assignee) {
|
|
2321
3102
|
sendMessage(db, createdBy, assignee, taskId, 'task', `Task #${taskId} assigned: ${title}`);
|
|
2322
3103
|
}
|
|
2323
3104
|
return taskId;
|
|
2324
3105
|
});
|
|
2325
|
-
printResult(ctx, { id, title, assignee, priority
|
|
3106
|
+
printResult(ctx, { id, title, assignee, priority, parent_id: parentId, team_role: teamRole },
|
|
3107
|
+
(data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
|
|
2326
3108
|
}
|
|
2327
3109
|
|
|
2328
3110
|
async function taskList(ctx, args) {
|
|
@@ -2360,6 +3142,8 @@ async function taskList(ctx, args) {
|
|
|
2360
3142
|
{ label: 'prio', value: (r) => r.priority },
|
|
2361
3143
|
{ label: 'assignee', value: (r) => r.assignee || '' },
|
|
2362
3144
|
{ label: 'owner', value: (r) => r.owner || '' },
|
|
3145
|
+
{ label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
|
|
3146
|
+
{ label: 'role', value: (r) => r.team_role || '' },
|
|
2363
3147
|
{ label: 'title', value: (r) => r.title }
|
|
2364
3148
|
]));
|
|
2365
3149
|
}
|
|
@@ -2397,12 +3181,31 @@ async function taskClaim(ctx, args) {
|
|
|
2397
3181
|
}
|
|
2398
3182
|
|
|
2399
3183
|
async function taskNext(ctx, args) {
|
|
2400
|
-
const opts = parseOpts(args);
|
|
3184
|
+
const opts = parseOpts(args, { booleans: ['force'] });
|
|
2401
3185
|
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
2402
3186
|
const peer = identity.id;
|
|
2403
3187
|
const db = connect(ctx);
|
|
2404
3188
|
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
2405
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
|
+
}
|
|
2406
3209
|
const row = db.prepare(`
|
|
2407
3210
|
SELECT * FROM tasks
|
|
2408
3211
|
WHERE status = 'pending'
|
|
@@ -2421,7 +3224,12 @@ async function taskNext(ctx, args) {
|
|
|
2421
3224
|
addEvent(db, 'task.claimed', peer, row.id, { next: true });
|
|
2422
3225
|
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
|
|
2423
3226
|
});
|
|
2424
|
-
printResult(ctx, task, (data) =>
|
|
3227
|
+
printResult(ctx, task, (data) => {
|
|
3228
|
+
if (!data) return 'no pending task';
|
|
3229
|
+
return data.current
|
|
3230
|
+
? `current task #${data.id}: ${data.title} (${data.status})`
|
|
3231
|
+
: `claimed task #${data.id}: ${data.title}`;
|
|
3232
|
+
});
|
|
2425
3233
|
}
|
|
2426
3234
|
|
|
2427
3235
|
async function taskUpdate(ctx, args) {
|
|
@@ -2465,12 +3273,223 @@ async function taskDone(ctx, args) {
|
|
|
2465
3273
|
return taskUpdate(ctx, args.concat(['--status', 'done']));
|
|
2466
3274
|
}
|
|
2467
3275
|
|
|
3276
|
+
function splitCsvList(value) {
|
|
3277
|
+
if (Array.isArray(value)) return value.flatMap((item) => splitCsvList(item));
|
|
3278
|
+
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
function parseTeamItems(opts) {
|
|
3282
|
+
const rawItems = [
|
|
3283
|
+
...splitCsvList(opts.item || []),
|
|
3284
|
+
...splitCsvList(opts.items || [])
|
|
3285
|
+
];
|
|
3286
|
+
return rawItems.map((raw, index) => {
|
|
3287
|
+
const parts = String(raw).split(':').map((part) => part.trim());
|
|
3288
|
+
let assignee = null;
|
|
3289
|
+
let role = null;
|
|
3290
|
+
let title = raw.trim();
|
|
3291
|
+
if (parts.length >= 3) {
|
|
3292
|
+
assignee = parts.shift() || null;
|
|
3293
|
+
role = parts.shift() || null;
|
|
3294
|
+
title = parts.join(':').trim();
|
|
3295
|
+
} else if (parts.length === 2) {
|
|
3296
|
+
role = parts[0] || null;
|
|
3297
|
+
title = parts[1] || title;
|
|
3298
|
+
}
|
|
3299
|
+
if (!title) title = `subtask ${index + 1}`;
|
|
3300
|
+
return { title, role: role || `worker-${index + 1}`, assignee };
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function inferTeamItems(task, opts) {
|
|
3305
|
+
const explicit = parseTeamItems(opts);
|
|
3306
|
+
if (explicit.length) return explicit;
|
|
3307
|
+
const count = Math.max(1, intOpt(opts, 'count', 3));
|
|
3308
|
+
const baseTitle = task?.title || 'team task';
|
|
3309
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
3310
|
+
title: `${baseTitle} / subtask ${index + 1}`,
|
|
3311
|
+
role: `worker-${index + 1}`,
|
|
3312
|
+
assignee: null
|
|
3313
|
+
}));
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
function expandTeamWorkers(workers, parentId) {
|
|
3317
|
+
const expanded = [];
|
|
3318
|
+
for (const token of splitCsvList(workers || [])) {
|
|
3319
|
+
const match = token.match(/^([A-Za-z][A-Za-z0-9._-]*):([1-9][0-9]*)$/);
|
|
3320
|
+
if (!match) {
|
|
3321
|
+
expanded.push(token);
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
const kind = sanitizePeerPart(match[1], 'peer');
|
|
3325
|
+
const count = Number.parseInt(match[2], 10);
|
|
3326
|
+
for (let i = 1; i <= count; i += 1) expanded.push(`${kind}-team-${parentId}-${i}`);
|
|
3327
|
+
}
|
|
3328
|
+
return expanded;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function assignTeamWorkers(items, workers, parentId) {
|
|
3332
|
+
const workerList = expandTeamWorkers(workers, parentId);
|
|
3333
|
+
if (!workerList.length) return items;
|
|
3334
|
+
return items.map((item, index) => ({
|
|
3335
|
+
...item,
|
|
3336
|
+
assignee: item.assignee || workerList[index % workerList.length]
|
|
3337
|
+
}));
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
function teamChildren(db, parentId) {
|
|
3341
|
+
return db.prepare(`
|
|
3342
|
+
SELECT *
|
|
3343
|
+
FROM tasks
|
|
3344
|
+
WHERE parent_id = ?
|
|
3345
|
+
ORDER BY priority ASC, id ASC
|
|
3346
|
+
`).all(parentId);
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
function taskById(db, id) {
|
|
3350
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
function teamSummary(db, parentId) {
|
|
3354
|
+
const parent = taskById(db, parentId);
|
|
3355
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3356
|
+
const children = teamChildren(db, parentId);
|
|
3357
|
+
const handoffs = db.prepare(`
|
|
3358
|
+
SELECT *
|
|
3359
|
+
FROM handoffs
|
|
3360
|
+
WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
|
|
3361
|
+
ORDER BY id ASC
|
|
3362
|
+
`).all(parentId, parentId);
|
|
3363
|
+
const counts = {};
|
|
3364
|
+
for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
|
|
3365
|
+
return { parent, children, handoffs, counts };
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
async function cmdTeam(ctx, args) {
|
|
3369
|
+
const sub = args[0];
|
|
3370
|
+
if (!sub || wantsHelp(args)) return helpTeam();
|
|
3371
|
+
if (sub === 'plan') return teamPlan(ctx, args.slice(1));
|
|
3372
|
+
if (sub === 'start') return teamStart(ctx, args.slice(1));
|
|
3373
|
+
if (sub === 'status') return teamStatus(ctx, args.slice(1));
|
|
3374
|
+
throw new CliError('BAD_ARGS', `Unknown team command: ${sub}`);
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
async function teamPlan(ctx, args) {
|
|
3378
|
+
const opts = parseOpts(args, { arrays: ['item'] });
|
|
3379
|
+
const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
|
|
3380
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
|
|
3381
|
+
const db = connect(ctx);
|
|
3382
|
+
const parent = taskById(db, parentId);
|
|
3383
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3384
|
+
const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
|
|
3385
|
+
const data = { parent, items };
|
|
3386
|
+
printResult(ctx, data, (plan) => {
|
|
3387
|
+
const lines = [`team plan for task #${plan.parent.id}: ${plan.parent.title}`];
|
|
3388
|
+
plan.items.forEach((item, index) => {
|
|
3389
|
+
lines.push(`${index + 1}. ${item.role}: ${item.title}${item.assignee ? ` -> ${item.assignee}` : ''}`);
|
|
3390
|
+
});
|
|
3391
|
+
lines.push('', `start: ${CLI_NAME} team start --from-task ${plan.parent.id} ${plan.items.map((item) => `--item ${shellQuoteArg(`${item.assignee ? `${item.assignee}:` : ''}${item.role}:${item.title}`)}`).join(' ')}`.trim());
|
|
3392
|
+
return lines.join('\n');
|
|
3393
|
+
});
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
async function teamStart(ctx, args) {
|
|
3397
|
+
const opts = parseOpts(args, { arrays: ['item'], booleans: ['force'] });
|
|
3398
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
3399
|
+
const actor = identity.id;
|
|
3400
|
+
const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
|
|
3401
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
|
|
3402
|
+
const priorityBase = intOpt(opts, 'priority', 100);
|
|
3403
|
+
const db = connect(ctx);
|
|
3404
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3405
|
+
const result = tx(db, () => {
|
|
3406
|
+
const parent = taskById(db, parentId);
|
|
3407
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3408
|
+
const existing = teamChildren(db, parentId);
|
|
3409
|
+
if (existing.length && !opts.force) {
|
|
3410
|
+
throw new CliError('TEAM_EXISTS', `Task #${parentId} already has ${existing.length} team subtask(s); use --force to add more`, {
|
|
3411
|
+
parent_id: parentId,
|
|
3412
|
+
children: existing.map((task) => task.id)
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
|
|
3416
|
+
const t = now();
|
|
3417
|
+
const children = [];
|
|
3418
|
+
items.forEach((item, index) => {
|
|
3419
|
+
const info = db.prepare(`
|
|
3420
|
+
INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
3421
|
+
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
|
|
3422
|
+
`).run(
|
|
3423
|
+
item.title,
|
|
3424
|
+
opts.body || `Subtask for #${parentId}: ${parent.title}`,
|
|
3425
|
+
item.assignee || null,
|
|
3426
|
+
parentId,
|
|
3427
|
+
item.role || `worker-${index + 1}`,
|
|
3428
|
+
priorityBase + index,
|
|
3429
|
+
actor,
|
|
3430
|
+
t,
|
|
3431
|
+
t
|
|
3432
|
+
);
|
|
3433
|
+
const taskId = Number(info.lastInsertRowid);
|
|
3434
|
+
addEvent(db, 'task.created', actor, taskId, {
|
|
3435
|
+
title: item.title,
|
|
3436
|
+
assignee: item.assignee || null,
|
|
3437
|
+
priority: priorityBase + index,
|
|
3438
|
+
parent_id: parentId,
|
|
3439
|
+
team_role: item.role || null
|
|
3440
|
+
});
|
|
3441
|
+
if (item.assignee) sendMessage(db, actor, item.assignee, taskId, 'task', `Task #${taskId} assigned: ${item.title}`);
|
|
3442
|
+
children.push(taskById(db, taskId));
|
|
3443
|
+
});
|
|
3444
|
+
addEvent(db, 'team.started', actor, parentId, {
|
|
3445
|
+
child_tasks: children.map((task) => task.id),
|
|
3446
|
+
workers: expandTeamWorkers(opts.workers || [], parentId)
|
|
3447
|
+
});
|
|
3448
|
+
return { parent, children };
|
|
3449
|
+
});
|
|
3450
|
+
printResult(ctx, result, (data) => {
|
|
3451
|
+
const lines = [`started team for task #${data.parent.id}: ${data.children.length} subtask${data.children.length === 1 ? '' : 's'}`];
|
|
3452
|
+
for (const child of data.children) {
|
|
3453
|
+
lines.push(`- #${child.id} ${child.team_role || 'worker'}${child.assignee ? ` -> ${child.assignee}` : ''}: ${child.title}`);
|
|
3454
|
+
}
|
|
3455
|
+
return lines.join('\n');
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
async function teamStatus(ctx, args) {
|
|
3460
|
+
const opts = parseOpts(args);
|
|
3461
|
+
const parentId = intOpt(opts, 'task', intOpt(opts, 'from-task', intOpt({ task: opts._[0] }, 'task')));
|
|
3462
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --task');
|
|
3463
|
+
const db = connect(ctx);
|
|
3464
|
+
const data = teamSummary(db, parentId);
|
|
3465
|
+
printResult(ctx, data, (summary) => {
|
|
3466
|
+
const countText = Object.entries(summary.counts).map(([status, count]) => `${status}:${count}`).join(', ') || 'none';
|
|
3467
|
+
const lines = [
|
|
3468
|
+
`team task #${summary.parent.id}: ${summary.parent.title}`,
|
|
3469
|
+
`parent status: ${summary.parent.status}`,
|
|
3470
|
+
`subtasks: ${summary.children.length} (${countText})`
|
|
3471
|
+
];
|
|
3472
|
+
for (const child of summary.children) {
|
|
3473
|
+
lines.push(`- #${child.id} ${child.status} ${child.team_role || 'worker'}${child.owner ? ` owner=${child.owner}` : ''}${child.assignee ? ` assignee=${child.assignee}` : ''}: ${child.title}`);
|
|
3474
|
+
}
|
|
3475
|
+
if (summary.handoffs.length) {
|
|
3476
|
+
lines.push('handoffs:');
|
|
3477
|
+
for (const handoff of summary.handoffs.slice(-8)) {
|
|
3478
|
+
lines.push(`- #${handoff.id} task #${handoff.task_id || ''} ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}: ${handoff.summary}`);
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
return lines.join('\n');
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
|
|
2468
3485
|
async function cmdMsg(ctx, args) {
|
|
2469
3486
|
const sub = args[0];
|
|
2470
|
-
if (!sub ||
|
|
3487
|
+
if (!sub || wantsHelp(args)) return helpMsg();
|
|
2471
3488
|
if (sub === 'send') return msgSend(ctx, args.slice(1));
|
|
2472
3489
|
if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
|
|
2473
3490
|
if (sub === 'ack') return msgAck(ctx, args.slice(1));
|
|
3491
|
+
if (sub === 'reply') return msgReply(ctx, args.slice(1));
|
|
3492
|
+
if (sub === 'thread') return msgThread(ctx, args.slice(1));
|
|
2474
3493
|
throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
|
|
2475
3494
|
}
|
|
2476
3495
|
|
|
@@ -2485,7 +3504,8 @@ async function msgSend(ctx, args) {
|
|
|
2485
3504
|
const db = connect(ctx);
|
|
2486
3505
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
2487
3506
|
const id = sendMessage(db, sender, recipient, taskId, kind, body);
|
|
2488
|
-
printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body
|
|
3507
|
+
printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, reply_to: null, thread_id: id },
|
|
3508
|
+
(data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
|
|
2489
3509
|
}
|
|
2490
3510
|
|
|
2491
3511
|
async function msgInbox(ctx, args) {
|
|
@@ -2508,28 +3528,90 @@ async function msgInbox(ctx, args) {
|
|
|
2508
3528
|
{ label: 'from', value: (r) => r.sender },
|
|
2509
3529
|
{ label: 'kind', value: (r) => r.kind },
|
|
2510
3530
|
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
3531
|
+
{ label: 'reply', value: (r) => r.reply_to ? `#${r.reply_to}` : '' },
|
|
3532
|
+
{ label: 'thread', value: (r) => r.thread_id ? `#${r.thread_id}` : '' },
|
|
2511
3533
|
{ label: 'time', value: (r) => iso(r.created_at) },
|
|
2512
3534
|
{ label: 'body', value: (r) => r.body }
|
|
2513
3535
|
]));
|
|
2514
3536
|
}
|
|
2515
3537
|
|
|
2516
|
-
async function msgAck(ctx, args) {
|
|
3538
|
+
async function msgAck(ctx, args) {
|
|
3539
|
+
const opts = parseOpts(args);
|
|
3540
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
3541
|
+
const peer = identity.id;
|
|
3542
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
3543
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
3544
|
+
const db = connect(ctx);
|
|
3545
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3546
|
+
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
|
3547
|
+
if (!message) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
3548
|
+
ackMessage(db, peer, message);
|
|
3549
|
+
printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
function ackMessage(db, peer, message) {
|
|
3553
|
+
db.prepare(`
|
|
3554
|
+
INSERT INTO message_reads(message_id, peer, read_at)
|
|
3555
|
+
VALUES (?, ?, ?)
|
|
3556
|
+
ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
|
|
3557
|
+
`).run(message.id, peer, now());
|
|
3558
|
+
addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: message.id });
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
async function msgReply(ctx, args) {
|
|
3562
|
+
const opts = parseOpts(args);
|
|
3563
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
3564
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
3565
|
+
const body = required(opts, 'body');
|
|
3566
|
+
const db = connect(ctx);
|
|
3567
|
+
const original = getMessage(db, id);
|
|
3568
|
+
if (!original) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
3569
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
3570
|
+
const sender = identity.id;
|
|
3571
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3572
|
+
const recipient = opts.to || original.sender;
|
|
3573
|
+
const taskId = intOpt(opts, 'task', original.task_id || null);
|
|
3574
|
+
const kind = opts.kind || 'reply';
|
|
3575
|
+
const threadId = original.thread_id || original.id;
|
|
3576
|
+
const replyId = sendMessage(db, sender, recipient, taskId, kind, body, {
|
|
3577
|
+
reply_to: original.id,
|
|
3578
|
+
thread_id: threadId
|
|
3579
|
+
});
|
|
3580
|
+
ackMessage(db, sender, original);
|
|
3581
|
+
printResult(ctx, {
|
|
3582
|
+
id: replyId,
|
|
3583
|
+
sender,
|
|
3584
|
+
recipient,
|
|
3585
|
+
task_id: taskId,
|
|
3586
|
+
kind,
|
|
3587
|
+
body,
|
|
3588
|
+
reply_to: original.id,
|
|
3589
|
+
thread_id: threadId
|
|
3590
|
+
}, (data) => `sent reply #${data.id} to #${data.reply_to} ${data.sender} -> ${data.recipient}`);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
async function msgThread(ctx, args) {
|
|
2517
3594
|
const opts = parseOpts(args);
|
|
2518
|
-
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
2519
|
-
const peer = identity.id;
|
|
2520
3595
|
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
2521
3596
|
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
3597
|
+
const limit = intOpt(opts, 'limit', 50);
|
|
2522
3598
|
const db = connect(ctx);
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
3599
|
+
const data = queryMessageThread(db, id, limit);
|
|
3600
|
+
printResult(ctx, data, (thread) => {
|
|
3601
|
+
const lines = [`thread #${thread.thread_id} (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`];
|
|
3602
|
+
for (const message of thread.messages) {
|
|
3603
|
+
const parts = [
|
|
3604
|
+
`#${message.id}`,
|
|
3605
|
+
`${message.sender} -> ${message.recipient || 'all'}`,
|
|
3606
|
+
message.task_id ? `task #${message.task_id}` : '',
|
|
3607
|
+
message.reply_to ? `reply #${message.reply_to}` : '',
|
|
3608
|
+
message.kind || 'note',
|
|
3609
|
+
iso(message.created_at)
|
|
3610
|
+
].filter(Boolean).join(' ');
|
|
3611
|
+
lines.push(`${parts}\n ${message.body}`);
|
|
3612
|
+
}
|
|
3613
|
+
return lines.join('\n');
|
|
3614
|
+
});
|
|
2533
3615
|
}
|
|
2534
3616
|
|
|
2535
3617
|
async function cmdAsk(ctx, args) {
|
|
@@ -2598,7 +3680,7 @@ async function cmdInject(ctx, args) {
|
|
|
2598
3680
|
|
|
2599
3681
|
async function cmdPeer(ctx, args) {
|
|
2600
3682
|
const sub = args[0];
|
|
2601
|
-
if (!sub ||
|
|
3683
|
+
if (!sub || wantsHelp(args)) return helpPeer();
|
|
2602
3684
|
if (sub === 'list') return peerList(ctx, args.slice(1));
|
|
2603
3685
|
if (sub === 'start') return peerStart(ctx, args.slice(1));
|
|
2604
3686
|
if (sub === 'attach') return peerAttach(ctx, args.slice(1));
|
|
@@ -2617,9 +3699,13 @@ async function peerList(ctx, args) {
|
|
|
2617
3699
|
} finally {
|
|
2618
3700
|
db.close();
|
|
2619
3701
|
}
|
|
2620
|
-
const rows = (data.sessions || []).map((session) => ({
|
|
3702
|
+
const rows = (data.sessions || []).map((session) => ({
|
|
3703
|
+
...session,
|
|
3704
|
+
binding: bindings.get(session.peer_id || session.id) || bindings.get(session.id) || null
|
|
3705
|
+
}));
|
|
2621
3706
|
printResult(ctx, rows, (items) => table(items, [
|
|
2622
3707
|
{ label: 'id', value: (r) => r.id },
|
|
3708
|
+
{ label: 'peer', value: (r) => r.peer_id && r.peer_id !== r.id ? r.peer_id : '' },
|
|
2623
3709
|
{ label: 'kind', value: (r) => r.kind },
|
|
2624
3710
|
{ label: 'role', value: (r) => r.role || '' },
|
|
2625
3711
|
{ label: 'status', value: (r) => r.status },
|
|
@@ -2748,7 +3834,7 @@ async function peerStop(ctx, args) {
|
|
|
2748
3834
|
|
|
2749
3835
|
async function cmdLock(ctx, args) {
|
|
2750
3836
|
const sub = args[0];
|
|
2751
|
-
if (!sub ||
|
|
3837
|
+
if (!sub || wantsHelp(args)) return helpLock();
|
|
2752
3838
|
if (sub === 'acquire') return lockAcquire(ctx, args.slice(1));
|
|
2753
3839
|
if (sub === 'release') return lockRelease(ctx, args.slice(1));
|
|
2754
3840
|
if (sub === 'renew') return lockRenew(ctx, args.slice(1));
|
|
@@ -2847,7 +3933,7 @@ async function lockList(ctx, args) {
|
|
|
2847
3933
|
|
|
2848
3934
|
async function cmdHandoff(ctx, args) {
|
|
2849
3935
|
const sub = args[0];
|
|
2850
|
-
if (!sub ||
|
|
3936
|
+
if (!sub || wantsHelp(args)) return helpHandoff();
|
|
2851
3937
|
if (sub === 'create') return handoffCreate(ctx, args.slice(1));
|
|
2852
3938
|
if (sub === 'list') return handoffList(ctx, args.slice(1));
|
|
2853
3939
|
throw new CliError('BAD_ARGS', `Unknown handoff command: ${sub}`);
|
|
@@ -2900,7 +3986,7 @@ async function handoffList(ctx, args) {
|
|
|
2900
3986
|
|
|
2901
3987
|
async function cmdEvent(ctx, args) {
|
|
2902
3988
|
const sub = args[0];
|
|
2903
|
-
if (!sub ||
|
|
3989
|
+
if (!sub || wantsHelp(args)) return helpEvent();
|
|
2904
3990
|
if (sub === 'tail') return eventTail(ctx, args.slice(1));
|
|
2905
3991
|
throw new CliError('BAD_ARGS', `Unknown event command: ${sub}`);
|
|
2906
3992
|
}
|
|
@@ -2955,6 +4041,46 @@ async function cmdStatus(ctx, args) {
|
|
|
2955
4041
|
});
|
|
2956
4042
|
}
|
|
2957
4043
|
|
|
4044
|
+
function normalizeStateResources(values) {
|
|
4045
|
+
const list = Array.isArray(values) ? values : [values];
|
|
4046
|
+
return uniqueList(list.flatMap((value) => String(value || '').split(',').map((part) => part.trim())));
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
async function cmdState(ctx, args) {
|
|
4050
|
+
if (wantsHelp(args)) return helpState();
|
|
4051
|
+
const opts = parseOpts(args, { arrays: ['resource'] });
|
|
4052
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
4053
|
+
const peer = identity.id;
|
|
4054
|
+
const resources = normalizeStateResources(opts.resource || opts.resources || []);
|
|
4055
|
+
const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null });
|
|
4056
|
+
printResult(ctx, snapshot, (data) => {
|
|
4057
|
+
const automation = data.automation;
|
|
4058
|
+
const lines = [
|
|
4059
|
+
`root: ${data.root}`,
|
|
4060
|
+
`peer: ${peer}`,
|
|
4061
|
+
automation.current_task ? `current task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
|
|
4062
|
+
`phase: ${automation.phase}`,
|
|
4063
|
+
`next: ${automation.next_action.command || automation.next_action.kind}`,
|
|
4064
|
+
`why: ${automation.next_action.reason}`
|
|
4065
|
+
].filter(Boolean);
|
|
4066
|
+
if (automation.finish_actions.length) {
|
|
4067
|
+
lines.push('', 'finish actions:');
|
|
4068
|
+
lines.push(...automation.finish_actions.map((action) => `- ${action.command}`));
|
|
4069
|
+
}
|
|
4070
|
+
if (automation.warnings.length) {
|
|
4071
|
+
lines.push('', 'warnings:');
|
|
4072
|
+
lines.push(...automation.warnings.map((warning) => `- ${warning}`));
|
|
4073
|
+
}
|
|
4074
|
+
if (data.timeline.length) {
|
|
4075
|
+
lines.push('', 'timeline:');
|
|
4076
|
+
for (const item of data.timeline.slice(-8)) {
|
|
4077
|
+
lines.push(`- ${iso(item.ts)} ${item.source}:${item.source_id} ${item.title}${item.text ? ` — ${item.text}` : ''}`);
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
return lines.join('\n');
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
|
|
2958
4084
|
async function cmdPrompt(ctx, args) {
|
|
2959
4085
|
const opts = parseOpts(args);
|
|
2960
4086
|
const peer = required(opts, 'peer', 'HCC_PEER');
|
|
@@ -2969,11 +4095,13 @@ project uses a flat peer mesh: there is no required main/worker hierarchy.
|
|
|
2969
4095
|
Run these commands before changing files:
|
|
2970
4096
|
|
|
2971
4097
|
${cmd} register --peer ${peer} --kind ${kind} --role ${role}
|
|
4098
|
+
${cmd} state --peer ${peer}
|
|
2972
4099
|
${cmd} msg inbox --peer ${peer}
|
|
2973
4100
|
${cmd} task next --peer ${peer}
|
|
2974
4101
|
|
|
2975
4102
|
Coordination rules:
|
|
2976
4103
|
- Claim exactly one task before editing.
|
|
4104
|
+
- If state shows a current task for ${peer}, continue that task before claiming another pending task.
|
|
2977
4105
|
- Before editing a file, directory, module, or shared test resource, run:
|
|
2978
4106
|
${cmd} lock acquire --peer ${peer} --resource <path-or-module> --task <task-id>
|
|
2979
4107
|
- If a lock is held by another peer, message that peer instead of editing:
|
|
@@ -3194,19 +4322,25 @@ async function prepareLocalBus(ctx, opts = {}) {
|
|
|
3194
4322
|
}
|
|
3195
4323
|
|
|
3196
4324
|
async function startWebBackground(ctx, args) {
|
|
3197
|
-
const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
|
|
3198
|
-
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
|
|
4325
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
|
|
4326
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
|
|
4327
|
+
validateWebTokenOpts(opts);
|
|
3199
4328
|
ensureTmuxAvailable({ autoInstall: true });
|
|
3200
4329
|
const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
|
|
3201
4330
|
registerProject(ctx);
|
|
3202
4331
|
|
|
3203
4332
|
const existing = await readHealthyGlobalRuntime();
|
|
3204
4333
|
if (existing) {
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
4334
|
+
if (webRuntimeMatchesRequest(existing, opts)) {
|
|
4335
|
+
rememberRuntimeToken(existing, opts);
|
|
4336
|
+
try {
|
|
4337
|
+
await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
|
|
4338
|
+
} catch {}
|
|
4339
|
+
writeRuntime(ctx, { ...existing, root: ctx.root, db: ctx.dbPath, project_root: ctx.root, global_runtime: true });
|
|
4340
|
+
return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
|
|
4341
|
+
}
|
|
4342
|
+
try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
|
|
4343
|
+
await sleep(250);
|
|
3210
4344
|
}
|
|
3211
4345
|
|
|
3212
4346
|
try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
|
|
@@ -3331,57 +4465,10 @@ async function cmdDown(ctx, args) {
|
|
|
3331
4465
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
3332
4466
|
}
|
|
3333
4467
|
|
|
3334
|
-
function statusSnapshot(ctx, peer = null) {
|
|
4468
|
+
function statusSnapshot(ctx, peer = null, opts = {}) {
|
|
3335
4469
|
const db = connect(ctx);
|
|
3336
4470
|
try {
|
|
3337
|
-
|
|
3338
|
-
const peers = db.prepare(`
|
|
3339
|
-
SELECT id, kind, role, status, worktree, branch, pid, capabilities,
|
|
3340
|
-
created_at, last_seen_at, (? - last_seen_at) AS age_sec
|
|
3341
|
-
FROM peers
|
|
3342
|
-
ORDER BY last_seen_at DESC, id ASC
|
|
3343
|
-
LIMIT 200
|
|
3344
|
-
`).all(t);
|
|
3345
|
-
const tasks = queryOpenTasks(db, 200);
|
|
3346
|
-
const locks = db.prepare(`
|
|
3347
|
-
SELECT resource, owner, task_id, reason, expires_at, created_at
|
|
3348
|
-
FROM locks
|
|
3349
|
-
WHERE expires_at > ?
|
|
3350
|
-
ORDER BY resource ASC
|
|
3351
|
-
LIMIT 200
|
|
3352
|
-
`).all(t);
|
|
3353
|
-
const messages = peer
|
|
3354
|
-
? queryInbox(db, peer, false, 50)
|
|
3355
|
-
: db.prepare(`
|
|
3356
|
-
SELECT id, sender, recipient, task_id, kind, body, created_at
|
|
3357
|
-
FROM messages
|
|
3358
|
-
ORDER BY id DESC
|
|
3359
|
-
LIMIT 50
|
|
3360
|
-
`).all().reverse();
|
|
3361
|
-
const handoffs = db.prepare(`
|
|
3362
|
-
SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
|
|
3363
|
-
FROM handoffs
|
|
3364
|
-
ORDER BY id DESC
|
|
3365
|
-
LIMIT 50
|
|
3366
|
-
`).all().reverse();
|
|
3367
|
-
const events = db.prepare(`
|
|
3368
|
-
SELECT id, type, actor, task_id, payload, created_at
|
|
3369
|
-
FROM events
|
|
3370
|
-
ORDER BY id DESC
|
|
3371
|
-
LIMIT 80
|
|
3372
|
-
`).all().reverse();
|
|
3373
|
-
return {
|
|
3374
|
-
root: ctx.root,
|
|
3375
|
-
db: ctx.dbPath,
|
|
3376
|
-
now: t,
|
|
3377
|
-
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
3378
|
-
peers,
|
|
3379
|
-
tasks,
|
|
3380
|
-
locks,
|
|
3381
|
-
messages,
|
|
3382
|
-
handoffs,
|
|
3383
|
-
events
|
|
3384
|
-
};
|
|
4471
|
+
return collectStateSnapshot(db, ctx, peer, opts);
|
|
3385
4472
|
} finally {
|
|
3386
4473
|
try {
|
|
3387
4474
|
db.close();
|
|
@@ -3456,11 +4543,72 @@ function webIndexHtml() {
|
|
|
3456
4543
|
font-size: 12px;
|
|
3457
4544
|
}
|
|
3458
4545
|
.app {
|
|
4546
|
+
position: relative;
|
|
3459
4547
|
height: 100vh;
|
|
3460
4548
|
display: grid;
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
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%); }
|
|
3464
4612
|
.sidebar, .inspector {
|
|
3465
4613
|
min-height: 0;
|
|
3466
4614
|
min-width: 0;
|
|
@@ -3522,6 +4670,14 @@ function webIndexHtml() {
|
|
|
3522
4670
|
gap: 8px;
|
|
3523
4671
|
align-items: end;
|
|
3524
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
|
+
}
|
|
3525
4681
|
.session-header {
|
|
3526
4682
|
border-bottom: 1px solid var(--border);
|
|
3527
4683
|
padding: 8px 10px;
|
|
@@ -3607,17 +4763,67 @@ function webIndexHtml() {
|
|
|
3607
4763
|
overflow: hidden;
|
|
3608
4764
|
}
|
|
3609
4765
|
.toolbar .title {
|
|
3610
|
-
|
|
4766
|
+
flex: 1 1 auto;
|
|
4767
|
+
min-width: 0;
|
|
3611
4768
|
display: grid;
|
|
3612
4769
|
gap: 2px;
|
|
3613
4770
|
}
|
|
4771
|
+
.toolbar .title strong {
|
|
4772
|
+
overflow: hidden;
|
|
4773
|
+
text-overflow: ellipsis;
|
|
4774
|
+
white-space: nowrap;
|
|
4775
|
+
}
|
|
3614
4776
|
.quick {
|
|
3615
4777
|
display: flex;
|
|
3616
|
-
gap:
|
|
3617
|
-
flex-wrap:
|
|
3618
|
-
|
|
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 {
|
|
3619
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);
|
|
3620
4825
|
}
|
|
4826
|
+
.menu button:hover { background: #1b1f26; }
|
|
3621
4827
|
#terminal {
|
|
3622
4828
|
min-height: 0;
|
|
3623
4829
|
overflow: hidden;
|
|
@@ -3703,22 +4909,30 @@ function webIndexHtml() {
|
|
|
3703
4909
|
<strong>hello-cc</strong>
|
|
3704
4910
|
<div class="path" id="rootPath"></div>
|
|
3705
4911
|
</div>
|
|
3706
|
-
<span id="connState">offline</span>
|
|
4912
|
+
<span id="connState" data-i18n="conn.offline">offline</span>
|
|
3707
4913
|
</div>
|
|
3708
4914
|
<div class="form" style="padding-top:10px;padding-bottom:10px">
|
|
3709
|
-
<
|
|
3710
|
-
|
|
3711
|
-
|
|
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>
|
|
3712
4921
|
</div>
|
|
3713
4922
|
<form class="form" id="startForm">
|
|
3714
4923
|
<div class="start-row">
|
|
3715
|
-
<label>New session
|
|
3716
|
-
<button class="primary" type="submit">Start</button>
|
|
4924
|
+
<label><span data-i18n="newSession">New session</span><select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell" data-i18n="kind.shell">shell</option></select></label>
|
|
4925
|
+
<button class="primary" type="submit" data-i18n="start">Start</button>
|
|
4926
|
+
</div>
|
|
4927
|
+
<div class="start-options">
|
|
4928
|
+
<label><span data-i18n="mode">Mode</span><select id="startMode"><option value="new" data-i18n="mode.new">new</option><option value="resume" data-i18n="mode.resume">resume</option><option value="last" data-i18n="mode.last">last</option><option value="continue" data-i18n="mode.continue">continue</option></select></label>
|
|
4929
|
+
<label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
|
|
4930
|
+
<label data-resume-field data-resume-custom style="display:none"><span data-i18n="sessionId">Session id</span><input id="resumeArg" data-i18n-placeholder="sessionIdPlaceholder" placeholder="session id or name"></label>
|
|
3717
4931
|
</div>
|
|
3718
4932
|
</form>
|
|
3719
4933
|
<div class="session-header">
|
|
3720
|
-
<strong>Sessions</strong>
|
|
3721
|
-
<label>View
|
|
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>
|
|
3722
4936
|
</div>
|
|
3723
4937
|
<div class="sessions" id="sessions"></div>
|
|
3724
4938
|
</aside>
|
|
@@ -3726,16 +4940,21 @@ function webIndexHtml() {
|
|
|
3726
4940
|
<main class="main">
|
|
3727
4941
|
<div class="toolbar">
|
|
3728
4942
|
<div class="title">
|
|
3729
|
-
<strong id="activeTitle">No session selected</strong>
|
|
3730
|
-
<span class="path" id="activeMeta">Start or select a session from the left panel</span>
|
|
4943
|
+
<strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
|
|
4944
|
+
<span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
|
|
3731
4945
|
</div>
|
|
3732
4946
|
<div class="quick" id="quickBar">
|
|
3733
|
-
<
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
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>
|
|
3739
4958
|
</div>
|
|
3740
4959
|
</div>
|
|
3741
4960
|
<div id="terminal" style="min-height:0;flex:1"></div>
|
|
@@ -3744,11 +4963,16 @@ function webIndexHtml() {
|
|
|
3744
4963
|
|
|
3745
4964
|
<aside class="inspector">
|
|
3746
4965
|
<div class="brand">
|
|
3747
|
-
<strong>Project State</strong>
|
|
3748
|
-
<button id="refreshBtn" type="button">Refresh</button>
|
|
4966
|
+
<strong data-i18n="projectState">Project State</strong>
|
|
4967
|
+
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
|
3749
4968
|
</div>
|
|
3750
4969
|
<div class="state" id="state"></div>
|
|
3751
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>
|
|
3752
4976
|
</div>
|
|
3753
4977
|
|
|
3754
4978
|
<script src="/assets/xterm.js"></script>
|
|
@@ -3761,6 +4985,7 @@ function webIndexHtml() {
|
|
|
3761
4985
|
let sessionKindFilter = initialParams.get('kind') || 'all';
|
|
3762
4986
|
let sessions = []; // managed (PTY) sessions
|
|
3763
4987
|
let detected = []; // coordination-only peers (from hooks/watcher)
|
|
4988
|
+
let resumableCache = []; // provider sessions available to resume (from /api/resumable)
|
|
3764
4989
|
let active = null; // active managed session id
|
|
3765
4990
|
let activeDetected = null; // active detected peer id
|
|
3766
4991
|
let activeType = 'managed'; // 'managed' | 'detected'
|
|
@@ -3769,6 +4994,278 @@ function webIndexHtml() {
|
|
|
3769
4994
|
let lastStateRoot = '';
|
|
3770
4995
|
let ws = null;
|
|
3771
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
|
+
}
|
|
3772
5269
|
|
|
3773
5270
|
function requestQuery(extra = {}) {
|
|
3774
5271
|
const params = new URLSearchParams();
|
|
@@ -3853,6 +5350,50 @@ function webIndexHtml() {
|
|
|
3853
5350
|
return sessionKindFilter === 'all' || kind === sessionKindFilter;
|
|
3854
5351
|
}
|
|
3855
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
|
+
|
|
3856
5397
|
async function loadProjects() {
|
|
3857
5398
|
const data = await api('/api/projects');
|
|
3858
5399
|
projects = data.projects || [];
|
|
@@ -3869,8 +5410,8 @@ function webIndexHtml() {
|
|
|
3869
5410
|
activeType = 'managed';
|
|
3870
5411
|
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
3871
5412
|
term.reset();
|
|
3872
|
-
document.getElementById('activeTitle').textContent = '
|
|
3873
|
-
document.getElementById('activeMeta').textContent = '
|
|
5413
|
+
document.getElementById('activeTitle').textContent = tr('noSessionSelected');
|
|
5414
|
+
document.getElementById('activeMeta').textContent = tr('startOrSelect');
|
|
3874
5415
|
await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
|
|
3875
5416
|
const first = sessions.find(s => s.status === 'running');
|
|
3876
5417
|
if (first) connectManaged(first.id);
|
|
@@ -3891,7 +5432,30 @@ function webIndexHtml() {
|
|
|
3891
5432
|
|
|
3892
5433
|
function fmtAge(age) {
|
|
3893
5434
|
const n = Number(age);
|
|
3894
|
-
|
|
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;
|
|
3895
5459
|
}
|
|
3896
5460
|
|
|
3897
5461
|
function peerIsActive(peer, basisNow = lastStateNow) {
|
|
@@ -3902,32 +5466,57 @@ function webIndexHtml() {
|
|
|
3902
5466
|
return seen > 0 && (t - seen) <= activePeerTtl;
|
|
3903
5467
|
}
|
|
3904
5468
|
|
|
3905
|
-
|
|
5469
|
+
function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
|
|
3906
5470
|
const activity = peer?.status || 'unknown';
|
|
3907
5471
|
const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
|
|
3908
5472
|
const age = fmtAge(peer?.age_sec);
|
|
3909
|
-
const branch = peer?.branch ? ' branch=' + peer.branch : '';
|
|
5473
|
+
const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
|
|
3910
5474
|
if (runtime) {
|
|
3911
5475
|
return {
|
|
3912
5476
|
label: runtime.status || 'running',
|
|
3913
|
-
detail: 'peer=' + activity + ' ' + liveness + ' age=' + age + branch
|
|
5477
|
+
detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
3914
5478
|
};
|
|
3915
5479
|
}
|
|
3916
5480
|
if (liveness === 'stale') {
|
|
3917
5481
|
return {
|
|
3918
5482
|
label: 'stale',
|
|
3919
|
-
detail: '
|
|
5483
|
+
detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
|
|
3920
5484
|
};
|
|
3921
5485
|
}
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
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
|
+
}
|
|
3927
5512
|
|
|
3928
5513
|
document.getElementById('projectSelect').addEventListener('change', (event) => {
|
|
3929
5514
|
switchProject(event.target.value).catch(console.error);
|
|
3930
5515
|
});
|
|
5516
|
+
document.getElementById('langSelect').addEventListener('change', (event) => {
|
|
5517
|
+
lang = event.target.value === 'zh' ? 'zh' : 'en';
|
|
5518
|
+
applyLanguage();
|
|
5519
|
+
});
|
|
3931
5520
|
document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
|
|
3932
5521
|
sessionKindFilter = event.target.value || 'all';
|
|
3933
5522
|
updateLocationProject();
|
|
@@ -3948,33 +5537,36 @@ function webIndexHtml() {
|
|
|
3948
5537
|
const box = document.getElementById('sessions');
|
|
3949
5538
|
const visibleSessions = sessions.filter(kindMatches);
|
|
3950
5539
|
const visibleDetected = detected.filter(kindMatches);
|
|
3951
|
-
const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>
|
|
5540
|
+
const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
|
|
3952
5541
|
const manHtml = visibleSessions.length
|
|
3953
|
-
? visibleSessions.map((s) =>
|
|
5542
|
+
? visibleSessions.map((s) => {
|
|
5543
|
+
const peerId = sessionPeerId(s);
|
|
5544
|
+
return \`
|
|
3954
5545
|
<div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
|
|
3955
|
-
<div class="row"><strong>\${esc(
|
|
5546
|
+
<div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
|
|
3956
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>
|
|
3957
|
-
<div class="path">\${esc(s.command)}</div>
|
|
3958
|
-
</div
|
|
3959
|
-
|
|
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>';
|
|
3960
5552
|
|
|
3961
5553
|
const detHtml = visibleDetected.length
|
|
3962
5554
|
? visibleDetected.map((p) => {
|
|
3963
5555
|
const state = peerStateView(p);
|
|
3964
5556
|
return \`
|
|
3965
5557
|
<div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
|
|
3966
|
-
<div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20"
|
|
3967
|
-
<div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(state.label)}</span></div>
|
|
5558
|
+
<div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span></div>
|
|
5559
|
+
<div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span></div>
|
|
3968
5560
|
<div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
|
|
3969
5561
|
</div>\`;
|
|
3970
5562
|
}).join('')
|
|
3971
|
-
: '<div class="empty">
|
|
5563
|
+
: '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
|
|
3972
5564
|
|
|
3973
5565
|
const savedScroll = box.scrollTop;
|
|
3974
5566
|
box.innerHTML = \`
|
|
3975
|
-
<div class="sec-label"
|
|
5567
|
+
<div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
|
|
3976
5568
|
\${manHtml}
|
|
3977
|
-
<div class="sec-label" style="margin-top:10px"
|
|
5569
|
+
<div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
|
|
3978
5570
|
\${detHtml}
|
|
3979
5571
|
\`;
|
|
3980
5572
|
box.scrollTop = savedScroll;
|
|
@@ -3990,7 +5582,14 @@ function webIndexHtml() {
|
|
|
3990
5582
|
const data = await api('/api/sessions');
|
|
3991
5583
|
sessions = data.sessions || [];
|
|
3992
5584
|
renderSections();
|
|
3993
|
-
|
|
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');
|
|
3994
5593
|
}
|
|
3995
5594
|
|
|
3996
5595
|
async function refreshDetected() {
|
|
@@ -4011,76 +5610,100 @@ function webIndexHtml() {
|
|
|
4011
5610
|
lastStateNow = Number(data.now || lastStateNow);
|
|
4012
5611
|
const state = document.getElementById('state');
|
|
4013
5612
|
const preserveScroll = lastStateRoot === stateRoot;
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
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 || {};
|
|
4029
5638
|
const tasks = tasksData.map((t) => \`
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
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) => {
|
|
4033
5642
|
const peerRuntime = runtimeById.get(a.id);
|
|
4034
5643
|
const peerState = peerStateView(a, peerRuntime, data.now);
|
|
4035
5644
|
return \`
|
|
4036
|
-
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(peerState.label)}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
5645
|
+
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
4037
5646
|
\`;
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
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>
|
|
4054
5668
|
\`;
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
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;
|
|
4059
5677
|
}
|
|
4060
|
-
lastStateRoot = stateRoot;
|
|
4061
|
-
}
|
|
4062
5678
|
|
|
4063
5679
|
async function refreshState() {
|
|
4064
|
-
const
|
|
5680
|
+
const peer = active ? managedPeerId(active) : null;
|
|
5681
|
+
const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
|
|
4065
5682
|
const data = await api(p);
|
|
4066
5683
|
renderState(data);
|
|
4067
5684
|
}
|
|
4068
5685
|
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
5686
|
+
async function refreshDetectedState() {
|
|
5687
|
+
if (!activeDetected) return;
|
|
5688
|
+
const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
|
|
5689
|
+
renderState(data);
|
|
5690
|
+
}
|
|
5691
|
+
|
|
5692
|
+
async function refreshCurrentState() {
|
|
5693
|
+
if (activeType === 'detected') return refreshDetectedState();
|
|
5694
|
+
return refreshState();
|
|
5695
|
+
}
|
|
4074
5696
|
|
|
4075
5697
|
// ── Connect to managed (PTY) session ─────────────────────────────────
|
|
4076
5698
|
function connectManaged(id) {
|
|
4077
5699
|
const meta = sessions.find((s) => s.id === id);
|
|
5700
|
+
const peerId = sessionPeerId(meta) || id;
|
|
4078
5701
|
active = id;
|
|
4079
5702
|
activeDetected = null;
|
|
4080
5703
|
activeType = 'managed';
|
|
4081
5704
|
renderSections();
|
|
4082
|
-
document.getElementById('activeTitle').textContent =
|
|
4083
|
-
document.getElementById('activeMeta').textContent = meta ?
|
|
5705
|
+
document.getElementById('activeTitle').textContent = peerId;
|
|
5706
|
+
document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
|
|
4084
5707
|
document.getElementById('terminal').style.display = '';
|
|
4085
5708
|
document.getElementById('detectedPanel').style.display = 'none';
|
|
4086
5709
|
document.getElementById('quickBar').style.display = '';
|
|
@@ -4096,7 +5719,7 @@ function webIndexHtml() {
|
|
|
4096
5719
|
ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
|
|
4097
5720
|
ws.onopen = () => {
|
|
4098
5721
|
resizeTerm();
|
|
4099
|
-
|
|
5722
|
+
connText('attached');
|
|
4100
5723
|
};
|
|
4101
5724
|
ws.onmessage = (event) => {
|
|
4102
5725
|
const msg = JSON.parse(event.data);
|
|
@@ -4113,12 +5736,12 @@ function webIndexHtml() {
|
|
|
4113
5736
|
if (msg.type === 'exit') { refreshSessions().catch(console.error); }
|
|
4114
5737
|
};
|
|
4115
5738
|
ws.onclose = () => {
|
|
4116
|
-
|
|
5739
|
+
connText('reconnecting');
|
|
4117
5740
|
// Auto-reconnect if session is still in the list and running
|
|
4118
5741
|
wsReconnectTimer = setTimeout(() => {
|
|
4119
5742
|
const s = sessions.find((s) => s.id === id);
|
|
4120
5743
|
if (s && s.status === 'running' && active === id) openWs(id);
|
|
4121
|
-
else
|
|
5744
|
+
else connText('online');
|
|
4122
5745
|
}, 2000);
|
|
4123
5746
|
};
|
|
4124
5747
|
}
|
|
@@ -4131,12 +5754,12 @@ function webIndexHtml() {
|
|
|
4131
5754
|
activeType = 'detected';
|
|
4132
5755
|
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
4133
5756
|
renderSections();
|
|
4134
|
-
document.getElementById('activeTitle').textContent = id + ' (detected)';
|
|
5757
|
+
document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
|
|
4135
5758
|
document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
|
|
4136
5759
|
document.getElementById('terminal').style.display = 'none';
|
|
4137
5760
|
document.getElementById('detectedPanel').style.display = '';
|
|
4138
5761
|
document.getElementById('quickBar').style.display = 'none';
|
|
4139
|
-
|
|
5762
|
+
connText('coordinationOnly');
|
|
4140
5763
|
renderDetectedPanel(peer || { id });
|
|
4141
5764
|
refreshDetectedState().catch(console.error);
|
|
4142
5765
|
}
|
|
@@ -4146,22 +5769,22 @@ function webIndexHtml() {
|
|
|
4146
5769
|
dp.innerHTML = \`
|
|
4147
5770
|
<div style="padding:16px;display:grid;gap:12px">
|
|
4148
5771
|
<div class="card">
|
|
4149
|
-
<h2
|
|
5772
|
+
<h2>\${esc(tr('detectedSession'))}</h2>
|
|
4150
5773
|
<div class="body">
|
|
4151
|
-
<div class="item"><strong
|
|
4152
|
-
<div class="item"><strong
|
|
4153
|
-
<div class="item"><strong
|
|
4154
|
-
<div class="item"><strong
|
|
4155
|
-
<div class="item"><strong
|
|
4156
|
-
<div class="item"><strong
|
|
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>
|
|
4157
5780
|
</div>
|
|
4158
5781
|
</div>
|
|
4159
5782
|
<div class="card">
|
|
4160
|
-
<h2
|
|
5783
|
+
<h2>\${esc(tr('sendMessage'))}</h2>
|
|
4161
5784
|
<div class="body" style="gap:8px">
|
|
4162
|
-
<div style="font-size:12px;color:var(--muted)"
|
|
4163
|
-
<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="
|
|
4164
|
-
<button class="primary" id="sendDetMsg"
|
|
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>
|
|
4165
5788
|
</div>
|
|
4166
5789
|
</div>
|
|
4167
5790
|
</div>
|
|
@@ -4172,10 +5795,11 @@ function webIndexHtml() {
|
|
|
4172
5795
|
await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
|
|
4173
5796
|
method: 'POST',
|
|
4174
5797
|
body: JSON.stringify({ body, from: 'web' })
|
|
5798
|
+
});
|
|
5799
|
+
document.getElementById('detMsg').value = '';
|
|
5800
|
+
await refreshDetectedState();
|
|
4175
5801
|
});
|
|
4176
|
-
|
|
4177
|
-
});
|
|
4178
|
-
}
|
|
5802
|
+
}
|
|
4179
5803
|
|
|
4180
5804
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
4181
5805
|
function sendLine(text) {
|
|
@@ -4184,11 +5808,22 @@ function webIndexHtml() {
|
|
|
4184
5808
|
}
|
|
4185
5809
|
|
|
4186
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
|
+
|
|
4187
5816
|
document.getElementById('startForm').addEventListener('submit', async (event) => {
|
|
4188
5817
|
event.preventDefault();
|
|
4189
|
-
const
|
|
4190
|
-
|
|
4191
|
-
|
|
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;
|
|
4192
5827
|
const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
|
|
4193
5828
|
await refreshSessions();
|
|
4194
5829
|
connectManaged(data.session.id);
|
|
@@ -4198,26 +5833,180 @@ function webIndexHtml() {
|
|
|
4198
5833
|
button.addEventListener('click', () => {
|
|
4199
5834
|
if (!active) return;
|
|
4200
5835
|
const session = sessions.find((s) => s.id === active) || { kind: 'other', role: 'peer' };
|
|
5836
|
+
const peerId = sessionPeerId(session) || active;
|
|
4201
5837
|
const lines = {
|
|
4202
|
-
register: \`hcc register --peer \${
|
|
4203
|
-
inbox: \`hcc msg inbox --peer \${
|
|
4204
|
-
next: \`hcc task next --peer \${
|
|
4205
|
-
status: \`hcc status --peer \${
|
|
4206
|
-
heartbeat:\`hcc heartbeat --peer \${
|
|
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\`
|
|
4207
5843
|
};
|
|
4208
5844
|
sendLine(lines[button.dataset.send]);
|
|
5845
|
+
closeActionsMenu();
|
|
5846
|
+
});
|
|
5847
|
+
});
|
|
5848
|
+
|
|
5849
|
+
// ── Actions dropdown (declutters the toolbar) ─────────────────────────
|
|
5850
|
+
const actionsBtn = document.getElementById('actionsBtn');
|
|
5851
|
+
const actionsMenu = document.getElementById('actionsMenu');
|
|
5852
|
+
function closeActionsMenu() {
|
|
5853
|
+
actionsMenu.hidden = true;
|
|
5854
|
+
actionsBtn.setAttribute('aria-expanded', 'false');
|
|
5855
|
+
}
|
|
5856
|
+
actionsBtn.addEventListener('click', (e) => {
|
|
5857
|
+
e.stopPropagation();
|
|
5858
|
+
const open = actionsMenu.hidden;
|
|
5859
|
+
actionsMenu.hidden = !open;
|
|
5860
|
+
actionsBtn.setAttribute('aria-expanded', String(open));
|
|
5861
|
+
if (open) {
|
|
5862
|
+
// Position as a fixed layer so it escapes the toolbar/main overflow clip.
|
|
5863
|
+
const r = actionsBtn.getBoundingClientRect();
|
|
5864
|
+
actionsMenu.style.top = (r.bottom + 6) + 'px';
|
|
5865
|
+
actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
|
|
5866
|
+
}
|
|
5867
|
+
});
|
|
5868
|
+
document.addEventListener('click', (e) => {
|
|
5869
|
+
if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
|
|
5870
|
+
});
|
|
5871
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
|
|
5872
|
+
|
|
5873
|
+
// ── Collapsible side panels ───────────────────────────────────────────
|
|
5874
|
+
const appEl = document.querySelector('.app');
|
|
5875
|
+
const toggleLeftBtn = document.getElementById('toggleLeft');
|
|
5876
|
+
const toggleRightBtn = document.getElementById('toggleRight');
|
|
5877
|
+
const resizeLeftHandle = document.getElementById('resizeLeft');
|
|
5878
|
+
const resizeRightHandle = document.getElementById('resizeRight');
|
|
5879
|
+
const sideDefaults = { left: 320, right: 360 };
|
|
5880
|
+
const sideMin = { left: 220, right: 240 };
|
|
5881
|
+
const sideMax = { left: 560, right: 640 };
|
|
5882
|
+
const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
|
|
5883
|
+
const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
|
|
5884
|
+
function sideIsCollapsed(side) {
|
|
5885
|
+
return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
|
|
5886
|
+
}
|
|
5887
|
+
function storedSideWidth(side) {
|
|
5888
|
+
const raw = Number(localStorage.getItem(sideWidthKey[side]));
|
|
5889
|
+
return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
|
|
5890
|
+
}
|
|
5891
|
+
function effectiveOppositeWidth(side) {
|
|
5892
|
+
const opposite = side === 'left' ? 'right' : 'left';
|
|
5893
|
+
return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
|
|
5894
|
+
}
|
|
5895
|
+
function clampSideWidth(side, width) {
|
|
5896
|
+
const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
|
|
5897
|
+
const opposite = effectiveOppositeWidth(side);
|
|
5898
|
+
const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
|
|
5899
|
+
return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
|
|
5900
|
+
}
|
|
5901
|
+
function readSideWidth(side) {
|
|
5902
|
+
return clampSideWidth(side, storedSideWidth(side));
|
|
5903
|
+
}
|
|
5904
|
+
function setSideWidth(side, width, persist = true) {
|
|
5905
|
+
const value = clampSideWidth(side, width);
|
|
5906
|
+
appEl.style.setProperty('--' + side + '-width', value + 'px');
|
|
5907
|
+
if (persist) localStorage.setItem(sideWidthKey[side], String(value));
|
|
5908
|
+
return value;
|
|
5909
|
+
}
|
|
5910
|
+
function applySideWidths() {
|
|
5911
|
+
setSideWidth('left', readSideWidth('left'), false);
|
|
5912
|
+
setSideWidth('right', readSideWidth('right'), false);
|
|
5913
|
+
}
|
|
5914
|
+
function syncToggleIcons() {
|
|
5915
|
+
const l = appEl.classList.contains('left-collapsed');
|
|
5916
|
+
const r = appEl.classList.contains('right-collapsed');
|
|
5917
|
+
toggleLeftBtn.textContent = l ? '⟩' : '⟨';
|
|
5918
|
+
toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
|
|
5919
|
+
toggleRightBtn.textContent = r ? '⟨' : '⟩';
|
|
5920
|
+
toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
|
|
5921
|
+
}
|
|
5922
|
+
function applyCollapseState() {
|
|
5923
|
+
appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
|
|
5924
|
+
appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
|
|
5925
|
+
applySideWidths();
|
|
5926
|
+
syncToggleIcons();
|
|
5927
|
+
}
|
|
5928
|
+
function toggleSide(side) {
|
|
5929
|
+
const cls = side + '-collapsed';
|
|
5930
|
+
const on = appEl.classList.toggle(cls);
|
|
5931
|
+
localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
|
|
5932
|
+
applySideWidths();
|
|
5933
|
+
syncToggleIcons();
|
|
5934
|
+
// Refit the terminal after the grid transition so xterm uses the new width.
|
|
5935
|
+
setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
|
|
5936
|
+
}
|
|
5937
|
+
function beginSideDrag(side, event) {
|
|
5938
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
5939
|
+
dragState.side = side;
|
|
5940
|
+
dragState.startX = event.clientX;
|
|
5941
|
+
dragState.startWidth = readSideWidth(side);
|
|
5942
|
+
dragState.moved = false;
|
|
5943
|
+
dragState.suppressClick = false;
|
|
5944
|
+
appEl.classList.add('resizing');
|
|
5945
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
5946
|
+
event.preventDefault();
|
|
5947
|
+
}
|
|
5948
|
+
function moveSideDrag(event) {
|
|
5949
|
+
if (!dragState.side) return;
|
|
5950
|
+
const delta = event.clientX - dragState.startX;
|
|
5951
|
+
if (!dragState.moved && Math.abs(delta) <= 3) return;
|
|
5952
|
+
dragState.moved = true;
|
|
5953
|
+
const width = dragState.side === 'left'
|
|
5954
|
+
? dragState.startWidth + delta
|
|
5955
|
+
: dragState.startWidth - delta;
|
|
5956
|
+
setSideWidth(dragState.side, width);
|
|
5957
|
+
if (dragState.side === 'left') {
|
|
5958
|
+
appEl.classList.remove('left-collapsed');
|
|
5959
|
+
localStorage.setItem('hcc.collapse.left', '0');
|
|
5960
|
+
} else {
|
|
5961
|
+
appEl.classList.remove('right-collapsed');
|
|
5962
|
+
localStorage.setItem('hcc.collapse.right', '0');
|
|
5963
|
+
}
|
|
5964
|
+
syncToggleIcons();
|
|
5965
|
+
try { resizeTerm(); } catch {}
|
|
5966
|
+
event.preventDefault();
|
|
5967
|
+
}
|
|
5968
|
+
function endSideDrag(event) {
|
|
5969
|
+
if (!dragState.side) return;
|
|
5970
|
+
dragState.suppressClick = dragState.moved;
|
|
5971
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
5972
|
+
dragState.side = null;
|
|
5973
|
+
appEl.classList.remove('resizing');
|
|
5974
|
+
setTimeout(() => { dragState.suppressClick = false; }, 0);
|
|
5975
|
+
try { resizeTerm(); } catch {}
|
|
5976
|
+
}
|
|
5977
|
+
function bindSideHandle(handle, side, clickAction = null) {
|
|
5978
|
+
handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
|
|
5979
|
+
handle.addEventListener('pointermove', moveSideDrag);
|
|
5980
|
+
handle.addEventListener('pointerup', endSideDrag);
|
|
5981
|
+
handle.addEventListener('pointercancel', endSideDrag);
|
|
5982
|
+
handle.addEventListener('click', (event) => {
|
|
5983
|
+
if (dragState.suppressClick) {
|
|
5984
|
+
event.preventDefault();
|
|
5985
|
+
return;
|
|
5986
|
+
}
|
|
5987
|
+
if (clickAction) clickAction(side);
|
|
4209
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);
|
|
4210
5997
|
});
|
|
5998
|
+
applyCollapseState();
|
|
4211
5999
|
|
|
4212
6000
|
document.getElementById('stopBtn').addEventListener('click', async () => {
|
|
4213
6001
|
if (!active) return;
|
|
4214
6002
|
await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: '{}' });
|
|
4215
6003
|
await refreshSessions();
|
|
4216
6004
|
});
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
6005
|
+
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
6006
|
+
Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
|
|
6007
|
+
});
|
|
4220
6008
|
|
|
6009
|
+
applyLanguage();
|
|
4221
6010
|
loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
|
|
4222
6011
|
// Auto-connect to first running managed session on load
|
|
4223
6012
|
if (!active && !activeDetected) {
|
|
@@ -4225,7 +6014,7 @@ function webIndexHtml() {
|
|
|
4225
6014
|
if (first) connectManaged(first.id);
|
|
4226
6015
|
}
|
|
4227
6016
|
}).catch((err) => {
|
|
4228
|
-
|
|
6017
|
+
connText('error');
|
|
4229
6018
|
console.error(err);
|
|
4230
6019
|
});
|
|
4231
6020
|
// ── Auto-poll state ──────────────────────────────────────────────────
|
|
@@ -4246,12 +6035,12 @@ function webIndexHtml() {
|
|
|
4246
6035
|
async function cmdWeb(ctx, args, startMeta = {}) {
|
|
4247
6036
|
if (args[0] === '--help' || args[0] === '-h') return helpWeb();
|
|
4248
6037
|
if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
|
|
4249
|
-
const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
|
|
4250
|
-
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
|
|
4251
|
-
|
|
6038
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
|
|
6039
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
|
|
6040
|
+
validateWebTokenOpts(opts);
|
|
6041
|
+
const host = expectedWebHost(opts);
|
|
4252
6042
|
const port = intOpt(opts, 'port', 8787);
|
|
4253
|
-
const
|
|
4254
|
-
const token = explicitToken;
|
|
6043
|
+
const token = makeWebToken(opts);
|
|
4255
6044
|
ensureTmuxAvailable({ autoInstall: false });
|
|
4256
6045
|
const ptyModule = await import('node-pty');
|
|
4257
6046
|
const { WebSocketServer } = await import('ws');
|
|
@@ -4297,8 +6086,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4297
6086
|
return [...sessions.values()].filter((session) => session.root === projectCtx.root);
|
|
4298
6087
|
}
|
|
4299
6088
|
|
|
4300
|
-
function getSession(projectCtx, id) {
|
|
4301
|
-
|
|
6089
|
+
function getSession(projectCtx, id, db = null) {
|
|
6090
|
+
const direct = sessions.get(sessionKey(projectCtx, id));
|
|
6091
|
+
if (direct) return direct;
|
|
6092
|
+
for (const session of sessionsForProject(projectCtx)) {
|
|
6093
|
+
if (session.peerId === id) return session;
|
|
6094
|
+
}
|
|
6095
|
+
if (db) {
|
|
6096
|
+
for (const session of sessionsForProject(projectCtx)) {
|
|
6097
|
+
if (resolveSessionPeerId(db, session) === id) return session;
|
|
6098
|
+
}
|
|
6099
|
+
}
|
|
6100
|
+
return null;
|
|
4302
6101
|
}
|
|
4303
6102
|
|
|
4304
6103
|
function knownPeerIds(projectCtx) {
|
|
@@ -4341,6 +6140,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4341
6140
|
|
|
4342
6141
|
const session = {
|
|
4343
6142
|
id,
|
|
6143
|
+
peerId: id,
|
|
4344
6144
|
root: pctx.root,
|
|
4345
6145
|
ctx: pctx,
|
|
4346
6146
|
kind: meta.kind || 'external',
|
|
@@ -4469,9 +6269,58 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4469
6269
|
const autoAttachPoller = setInterval(scanAndAttachDetectedPeers, 5000);
|
|
4470
6270
|
|
|
4471
6271
|
// ── Serialize + broadcast helpers ─────────────────────────────────────────
|
|
4472
|
-
function
|
|
6272
|
+
function resolveSessionPeerId(db, session) {
|
|
6273
|
+
if (!session) return null;
|
|
6274
|
+
if (!db) return session.peerId || session.id || null;
|
|
6275
|
+
|
|
6276
|
+
if (session.type === 'tmux' && session.pane) {
|
|
6277
|
+
const byTarget = db.prepare(`
|
|
6278
|
+
SELECT peer
|
|
6279
|
+
FROM peer_bindings
|
|
6280
|
+
WHERE runtime_target = ?
|
|
6281
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
6282
|
+
LIMIT 1
|
|
6283
|
+
`).get(session.pane);
|
|
6284
|
+
if (byTarget?.peer) {
|
|
6285
|
+
session.peerId = byTarget.peer;
|
|
6286
|
+
return byTarget.peer;
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
if (session.id) {
|
|
6291
|
+
const byRuntime = db.prepare(`
|
|
6292
|
+
SELECT peer
|
|
6293
|
+
FROM peer_bindings
|
|
6294
|
+
WHERE runtime_session_id = ?
|
|
6295
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
6296
|
+
LIMIT 1
|
|
6297
|
+
`).get(session.id);
|
|
6298
|
+
if (byRuntime?.peer) {
|
|
6299
|
+
session.peerId = byRuntime.peer;
|
|
6300
|
+
return byRuntime.peer;
|
|
6301
|
+
}
|
|
6302
|
+
|
|
6303
|
+
const byPeer = db.prepare(`
|
|
6304
|
+
SELECT peer
|
|
6305
|
+
FROM peer_bindings
|
|
6306
|
+
WHERE peer = ?
|
|
6307
|
+
LIMIT 1
|
|
6308
|
+
`).get(session.id);
|
|
6309
|
+
if (byPeer?.peer) {
|
|
6310
|
+
session.peerId = byPeer.peer;
|
|
6311
|
+
return byPeer.peer;
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
|
|
6315
|
+
session.peerId = session.peerId || session.id || null;
|
|
6316
|
+
return session.peerId;
|
|
6317
|
+
}
|
|
6318
|
+
|
|
6319
|
+
function serializeSession(session, db = null) {
|
|
6320
|
+
const peerId = resolveSessionPeerId(db, session);
|
|
4473
6321
|
return {
|
|
4474
6322
|
id: session.id,
|
|
6323
|
+
peer_id: peerId,
|
|
4475
6324
|
kind: session.kind,
|
|
4476
6325
|
role: session.role,
|
|
4477
6326
|
command: session.command,
|
|
@@ -4517,9 +6366,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4517
6366
|
sessions.delete(sessionKey(pctx, session.id));
|
|
4518
6367
|
const db = connect(pctx);
|
|
4519
6368
|
try {
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
6369
|
+
const t = now();
|
|
6370
|
+
const peerId = resolveSessionPeerId(db, session) || session.id;
|
|
6371
|
+
db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id IN (?, ?)').run(status, t, session.id, peerId);
|
|
6372
|
+
db.prepare(`
|
|
6373
|
+
UPDATE peer_bindings
|
|
6374
|
+
SET runtime_target = NULL, updated_at = ?
|
|
6375
|
+
WHERE peer IN (?, ?)
|
|
6376
|
+
OR runtime_session_id = ?
|
|
6377
|
+
OR (? IS NOT NULL AND runtime_target = ?)
|
|
6378
|
+
`).run(t, session.id, peerId, session.id, session.pane || null, session.pane || null);
|
|
6379
|
+
addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, {
|
|
6380
|
+
runtime_session_id: session.id,
|
|
4523
6381
|
pane: session.pane
|
|
4524
6382
|
});
|
|
4525
6383
|
} finally {
|
|
@@ -4679,6 +6537,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4679
6537
|
const captured = tmuxCapturePane(info.pane);
|
|
4680
6538
|
const session = {
|
|
4681
6539
|
id,
|
|
6540
|
+
peerId: id,
|
|
4682
6541
|
root: pctx.root,
|
|
4683
6542
|
ctx: pctx,
|
|
4684
6543
|
kind,
|
|
@@ -4728,7 +6587,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4728
6587
|
capabilities: 'tmux'
|
|
4729
6588
|
});
|
|
4730
6589
|
const binding = input.binding || {};
|
|
4731
|
-
upsertCanonicalPeerBinding(db, {
|
|
6590
|
+
const canonical = upsertCanonicalPeerBinding(db, {
|
|
4732
6591
|
peer: id,
|
|
4733
6592
|
provider: binding.provider || kind,
|
|
4734
6593
|
provider_session_id: binding.provider_session_id || null,
|
|
@@ -4740,6 +6599,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4740
6599
|
runtime_session_id: id,
|
|
4741
6600
|
runtime_target: info.pane
|
|
4742
6601
|
}, Boolean(input.force));
|
|
6602
|
+
session.peerId = canonical.peer;
|
|
4743
6603
|
addEvent(db, 'tmux.session.attached', id, null, { pane: info.pane, command, cwd, pid: info.pid });
|
|
4744
6604
|
} finally {
|
|
4745
6605
|
db.close();
|
|
@@ -4950,6 +6810,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4950
6810
|
});
|
|
4951
6811
|
const session = {
|
|
4952
6812
|
id,
|
|
6813
|
+
peerId: id,
|
|
4953
6814
|
root: pctx.root,
|
|
4954
6815
|
ctx: pctx,
|
|
4955
6816
|
kind,
|
|
@@ -4977,7 +6838,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4977
6838
|
status: 'running',
|
|
4978
6839
|
capabilities: 'web-pty'
|
|
4979
6840
|
});
|
|
4980
|
-
upsertCanonicalPeerBinding(db, {
|
|
6841
|
+
const canonical = upsertCanonicalPeerBinding(db, {
|
|
4981
6842
|
peer: id,
|
|
4982
6843
|
provider: input.binding?.provider || kind,
|
|
4983
6844
|
provider_session_id: input.binding?.provider_session_id || null,
|
|
@@ -4988,6 +6849,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4988
6849
|
transport: 'web-pty',
|
|
4989
6850
|
runtime_session_id: id
|
|
4990
6851
|
}, Boolean(input.force));
|
|
6852
|
+
session.peerId = canonical.peer;
|
|
4991
6853
|
addEvent(db, 'web.session.started', id, null, { command, cwd, pid: child.pid });
|
|
4992
6854
|
} finally {
|
|
4993
6855
|
db.close();
|
|
@@ -5012,9 +6874,60 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5012
6874
|
return session;
|
|
5013
6875
|
}
|
|
5014
6876
|
|
|
6877
|
+
function webSessionBuildOptions(input) {
|
|
6878
|
+
const mode = input.mode || 'new';
|
|
6879
|
+
if (mode === 'new') return {};
|
|
6880
|
+
if (mode === 'resume') {
|
|
6881
|
+
const resume = String(input.resume || '').trim();
|
|
6882
|
+
if (!resume) throw new CliError('BAD_REQUEST', 'resume session required');
|
|
6883
|
+
return { resume };
|
|
6884
|
+
}
|
|
6885
|
+
if (mode === 'last') return { last: true };
|
|
6886
|
+
if (mode === 'continue') return { continue: true };
|
|
6887
|
+
throw new CliError('BAD_REQUEST', `Unsupported session mode: ${mode}`);
|
|
6888
|
+
}
|
|
6889
|
+
|
|
6890
|
+
function webSessionPeerId(projectCtx, kind, opts, input) {
|
|
6891
|
+
if (input.id) return input.id;
|
|
6892
|
+
if (opts.resume) return providerSessionPeerId(kind, opts.resume);
|
|
6893
|
+
return nextProjectSessionId(projectCtx, kind);
|
|
6894
|
+
}
|
|
6895
|
+
|
|
6896
|
+
function normalizeWebSessionInput(input) {
|
|
6897
|
+
const pctx = input.projectCtx || ctx;
|
|
6898
|
+
const kind = input.kind || 'shell';
|
|
6899
|
+
if (!['claude', 'codex', 'shell'].includes(kind)) {
|
|
6900
|
+
throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
|
|
6901
|
+
}
|
|
6902
|
+
if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };
|
|
6903
|
+
|
|
6904
|
+
const opts = webSessionBuildOptions(input);
|
|
6905
|
+
if (kind === 'shell' && hasResumeOpts(opts)) {
|
|
6906
|
+
throw new CliError('BAD_REQUEST', 'Resume modes are only supported for codex and claude sessions');
|
|
6907
|
+
}
|
|
6908
|
+
if (kind !== 'claude' && opts.continue) {
|
|
6909
|
+
throw new CliError('BAD_REQUEST', 'continue is only supported for claude sessions');
|
|
6910
|
+
}
|
|
6911
|
+
if (kind !== 'codex' && opts.last) {
|
|
6912
|
+
throw new CliError('BAD_REQUEST', 'last is only supported for codex sessions');
|
|
6913
|
+
}
|
|
6914
|
+
|
|
6915
|
+
const id = webSessionPeerId(pctx, kind, opts, input);
|
|
6916
|
+
const built = buildPeerCommand(id, kind, opts, []);
|
|
6917
|
+
return {
|
|
6918
|
+
...input,
|
|
6919
|
+
id,
|
|
6920
|
+
kind,
|
|
6921
|
+
command: built.command,
|
|
6922
|
+
binding: built.binding,
|
|
6923
|
+
projectCtx: pctx
|
|
6924
|
+
};
|
|
6925
|
+
}
|
|
6926
|
+
|
|
5015
6927
|
function startSession(input) {
|
|
5016
|
-
|
|
5017
|
-
return
|
|
6928
|
+
const normalized = normalizeWebSessionInput(input);
|
|
6929
|
+
if (normalized.backend === 'pty') return startPtySession(normalized);
|
|
6930
|
+
return startTmuxManagedSession(normalized);
|
|
5018
6931
|
}
|
|
5019
6932
|
|
|
5020
6933
|
for (const projectCtx of projectContexts.values()) {
|
|
@@ -5067,13 +6980,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5067
6980
|
return;
|
|
5068
6981
|
}
|
|
5069
6982
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
5070
|
-
|
|
6983
|
+
const resources = normalizeStateResources([
|
|
6984
|
+
...url.searchParams.getAll('resource'),
|
|
6985
|
+
url.searchParams.get('resources') || ''
|
|
6986
|
+
]);
|
|
6987
|
+
sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer'), {
|
|
6988
|
+
resources,
|
|
6989
|
+
intent: url.searchParams.get('intent') || null
|
|
6990
|
+
}));
|
|
5071
6991
|
return;
|
|
5072
6992
|
}
|
|
5073
6993
|
// Detected sessions: peers registered via hooks/watcher but without PTY
|
|
5074
6994
|
if (req.method === 'GET' && url.pathname === '/api/detected') {
|
|
5075
6995
|
const db = connect(reqCtx);
|
|
5076
6996
|
let detected = [];
|
|
6997
|
+
const managedIds = new Set();
|
|
5077
6998
|
const t = now();
|
|
5078
6999
|
try {
|
|
5079
7000
|
detected = db.prepare(`
|
|
@@ -5083,11 +7004,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5083
7004
|
ORDER BY last_seen_at DESC, id ASC
|
|
5084
7005
|
LIMIT 100
|
|
5085
7006
|
`).all(t);
|
|
7007
|
+
for (const session of sessionsForProject(reqCtx)) {
|
|
7008
|
+
managedIds.add(session.id);
|
|
7009
|
+
const peerId = resolveSessionPeerId(db, session);
|
|
7010
|
+
if (peerId) managedIds.add(peerId);
|
|
7011
|
+
}
|
|
5086
7012
|
} finally {
|
|
5087
7013
|
db.close();
|
|
5088
7014
|
}
|
|
5089
7015
|
// Exclude peers that are already in the managed sessions Map
|
|
5090
|
-
const managedIds = new Set(sessionsForProject(reqCtx).map((s) => s.id));
|
|
5091
7016
|
sendJson(res, 200, {
|
|
5092
7017
|
now: t,
|
|
5093
7018
|
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
@@ -5095,6 +7020,44 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5095
7020
|
});
|
|
5096
7021
|
return;
|
|
5097
7022
|
}
|
|
7023
|
+
if (req.method === 'GET' && url.pathname === '/api/resumable') {
|
|
7024
|
+
// Provider sessions hcc has seen (via hooks/detection) that carry a real
|
|
7025
|
+
// provider session id or resumable provider session name.
|
|
7026
|
+
const db = connect(reqCtx);
|
|
7027
|
+
let rows = [];
|
|
7028
|
+
try {
|
|
7029
|
+
rows = db.prepare(`
|
|
7030
|
+
SELECT b.provider, b.provider_session_id, b.provider_session_name, b.peer,
|
|
7031
|
+
p.last_seen_at
|
|
7032
|
+
FROM peer_bindings b
|
|
7033
|
+
LEFT JOIN peers p ON p.id = b.peer
|
|
7034
|
+
WHERE (b.provider_session_id IS NOT NULL AND b.provider_session_id != '')
|
|
7035
|
+
OR (b.provider_session_name IS NOT NULL AND b.provider_session_name != '')
|
|
7036
|
+
ORDER BY p.last_seen_at DESC, b.updated_at DESC
|
|
7037
|
+
`).all();
|
|
7038
|
+
} finally {
|
|
7039
|
+
db.close();
|
|
7040
|
+
}
|
|
7041
|
+
const seen = new Set();
|
|
7042
|
+
const resumable = [];
|
|
7043
|
+
for (const r of rows) {
|
|
7044
|
+
const resumeValue = r.provider_session_id || r.provider_session_name || '';
|
|
7045
|
+
if (!resumeValue) continue;
|
|
7046
|
+
const key = `${r.provider}:${resumeValue}`;
|
|
7047
|
+
if (seen.has(key)) continue;
|
|
7048
|
+
seen.add(key);
|
|
7049
|
+
resumable.push({
|
|
7050
|
+
provider: r.provider,
|
|
7051
|
+
session_id: r.provider_session_id,
|
|
7052
|
+
session_name: r.provider_session_name || null,
|
|
7053
|
+
resume: resumeValue,
|
|
7054
|
+
name: r.provider_session_name || null,
|
|
7055
|
+
peer: r.peer
|
|
7056
|
+
});
|
|
7057
|
+
}
|
|
7058
|
+
sendJson(res, 200, { resumable });
|
|
7059
|
+
return;
|
|
7060
|
+
}
|
|
5098
7061
|
if (req.method === 'GET' && url.pathname === '/api/runtime') {
|
|
5099
7062
|
sendJson(res, 200, {
|
|
5100
7063
|
product: PRODUCT_NAME,
|
|
@@ -5113,7 +7076,14 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5113
7076
|
return;
|
|
5114
7077
|
}
|
|
5115
7078
|
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
|
5116
|
-
|
|
7079
|
+
const db = connect(reqCtx);
|
|
7080
|
+
try {
|
|
7081
|
+
sendJson(res, 200, {
|
|
7082
|
+
sessions: sessionsForProject(reqCtx).map((session) => serializeSession(session, db))
|
|
7083
|
+
});
|
|
7084
|
+
} finally {
|
|
7085
|
+
db.close();
|
|
7086
|
+
}
|
|
5117
7087
|
return;
|
|
5118
7088
|
}
|
|
5119
7089
|
if (req.method === 'POST' && url.pathname === '/api/sessions') {
|
|
@@ -5131,7 +7101,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5131
7101
|
const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
|
|
5132
7102
|
if (req.method === 'POST' && inputMatch) {
|
|
5133
7103
|
const id = decodeURIComponent(inputMatch[1]);
|
|
5134
|
-
const
|
|
7104
|
+
const lookupDb = connect(reqCtx);
|
|
7105
|
+
let session;
|
|
7106
|
+
try {
|
|
7107
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7108
|
+
} finally {
|
|
7109
|
+
lookupDb.close();
|
|
7110
|
+
}
|
|
5135
7111
|
if (!session) {
|
|
5136
7112
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
5137
7113
|
return;
|
|
@@ -5156,7 +7132,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5156
7132
|
const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
|
|
5157
7133
|
if (req.method === 'POST' && stopMatch) {
|
|
5158
7134
|
const id = decodeURIComponent(stopMatch[1]);
|
|
5159
|
-
const
|
|
7135
|
+
const lookupDb = connect(reqCtx);
|
|
7136
|
+
let session;
|
|
7137
|
+
try {
|
|
7138
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7139
|
+
} finally {
|
|
7140
|
+
lookupDb.close();
|
|
7141
|
+
}
|
|
5160
7142
|
if (!session) {
|
|
5161
7143
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
5162
7144
|
return;
|
|
@@ -5218,7 +7200,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5218
7200
|
}
|
|
5219
7201
|
const reqCtx = projectFromRequest(req, url);
|
|
5220
7202
|
const id = decodeURIComponent(match[1]);
|
|
5221
|
-
const
|
|
7203
|
+
const lookupDb = connect(reqCtx);
|
|
7204
|
+
let session;
|
|
7205
|
+
try {
|
|
7206
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7207
|
+
} finally {
|
|
7208
|
+
lookupDb.close();
|
|
7209
|
+
}
|
|
5222
7210
|
if (!session) {
|
|
5223
7211
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
5224
7212
|
socket.destroy();
|
|
@@ -5539,6 +7527,8 @@ Commands:
|
|
|
5539
7527
|
web Start coordination, shims, tmux, and browser console
|
|
5540
7528
|
up Initialize local coordination only
|
|
5541
7529
|
down Stop the running hello-cc web runtime
|
|
7530
|
+
update Update the global npm install of hello-cc
|
|
7531
|
+
uninstall Remove hooks, shims, and optional project data
|
|
5542
7532
|
init Initialize .hello-cc/mesh.db and guidance
|
|
5543
7533
|
register --peer ID Register or update a peer session identity
|
|
5544
7534
|
join --peer ID Register this shell and print eval-able env
|
|
@@ -5546,6 +7536,7 @@ Commands:
|
|
|
5546
7536
|
heartbeat [--peer ID] Mark the current peer alive, optionally renew locks
|
|
5547
7537
|
peers List known peers
|
|
5548
7538
|
status [--peer ID] Show project coordination state
|
|
7539
|
+
state [--peer ID] Show timeline and next coordination action
|
|
5549
7540
|
scan [--register] Detect existing Claude/Codex sessions
|
|
5550
7541
|
prompt --peer ID Print copy/paste session instructions
|
|
5551
7542
|
run --peer ID -- COMMAND Register a peer and run a command in this terminal
|
|
@@ -5554,6 +7545,7 @@ Commands:
|
|
|
5554
7545
|
ask PEER MESSAGE Send a direct work request to one peer
|
|
5555
7546
|
broadcast MESSAGE Send a work request to all peers
|
|
5556
7547
|
task <subcommand> Create, list, claim, update, finish tasks
|
|
7548
|
+
team <subcommand> Plan, start, and inspect explicit task teams
|
|
5557
7549
|
msg <subcommand> Send, read, and ack messages
|
|
5558
7550
|
lock <subcommand> Acquire, renew, release, and list advisory locks
|
|
5559
7551
|
handoff <subcommand> Create and list handoffs
|
|
@@ -5568,7 +7560,7 @@ Environment:
|
|
|
5568
7560
|
HCC_ROOT Override project root
|
|
5569
7561
|
HCC_DB Override database path
|
|
5570
7562
|
HCC_PEER Default peer id; inferred automatically when absent
|
|
5571
|
-
HCC_WEB_TOKEN
|
|
7563
|
+
HCC_WEB_TOKEN Replace and save the stable web access token
|
|
5572
7564
|
`);
|
|
5573
7565
|
}
|
|
5574
7566
|
|
|
@@ -5577,21 +7569,60 @@ function helpTask() {
|
|
|
5577
7569
|
|
|
5578
7570
|
Usage:
|
|
5579
7571
|
${CLI_NAME} task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
|
|
7572
|
+
${CLI_NAME} task create --title TEXT --parent N [--team-role ROLE]
|
|
5580
7573
|
${CLI_NAME} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
|
|
5581
7574
|
${CLI_NAME} task claim [--peer ID] --id N
|
|
5582
|
-
${CLI_NAME} task next [--peer ID]
|
|
7575
|
+
${CLI_NAME} task next [--peer ID] [--force]
|
|
5583
7576
|
${CLI_NAME} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
|
|
5584
7577
|
${CLI_NAME} task done [--peer ID] --id N --summary TEXT
|
|
5585
7578
|
|
|
5586
7579
|
Default task list shows all project tasks that are not done or abandoned.
|
|
5587
7580
|
--peer is an explicit filter; HCC_PEER does not hide other open tasks.
|
|
5588
7581
|
Messages use per-peer unread ack state; tasks do not.
|
|
7582
|
+
task next returns your existing claimed/running/review/blocked task before
|
|
7583
|
+
claiming another pending task. Use --force only when intentionally taking
|
|
7584
|
+
another pending task.
|
|
5589
7585
|
|
|
5590
7586
|
If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
|
|
5591
7587
|
as a stable project-local peer.
|
|
5592
7588
|
`);
|
|
5593
7589
|
}
|
|
5594
7590
|
|
|
7591
|
+
function helpTeam() {
|
|
7592
|
+
console.log(`${CLI_NAME} team
|
|
7593
|
+
|
|
7594
|
+
Usage:
|
|
7595
|
+
${CLI_NAME} team plan --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1]
|
|
7596
|
+
${CLI_NAME} team start --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1] [--force]
|
|
7597
|
+
${CLI_NAME} team status --task N
|
|
7598
|
+
|
|
7599
|
+
team plan is read-only. team start creates explicit child tasks under the parent
|
|
7600
|
+
task and optionally assigns them to workers. It does not silently spawn model
|
|
7601
|
+
processes or override the current-task rule.
|
|
7602
|
+
workers may be explicit peer ids or kind counts such as codex:2,claude:1.
|
|
7603
|
+
`);
|
|
7604
|
+
}
|
|
7605
|
+
|
|
7606
|
+
function helpState() {
|
|
7607
|
+
console.log(`${CLI_NAME} state
|
|
7608
|
+
|
|
7609
|
+
Usage:
|
|
7610
|
+
${CLI_NAME} state [--peer ID] [--resource PATH] [--intent work|stop|finish]
|
|
7611
|
+
|
|
7612
|
+
Shows the current collaboration timeline plus a machine-readable coordination
|
|
7613
|
+
state machine. With --json, the response includes automation.next_action.argv so
|
|
7614
|
+
agents can execute the suggested hcc command explicitly and leave an audit trail.
|
|
7615
|
+
automation.current_task shows the peer's active claimed/running/review/blocked
|
|
7616
|
+
task when one exists.
|
|
7617
|
+
If a current task looks splittable, automation may suggest hcc team plan; this
|
|
7618
|
+
is read-only until hcc team start is run explicitly.
|
|
7619
|
+
|
|
7620
|
+
State does not execute coordination actions: it does not ack messages, claim
|
|
7621
|
+
tasks, acquire locks, send messages, create handoffs, or mark tasks done. Opening
|
|
7622
|
+
state may still perform normal SQLite schema maintenance for known project DBs.
|
|
7623
|
+
`);
|
|
7624
|
+
}
|
|
7625
|
+
|
|
5595
7626
|
function helpJoin() {
|
|
5596
7627
|
console.log(`${CLI_NAME} join
|
|
5597
7628
|
|
|
@@ -5629,8 +7660,11 @@ Usage:
|
|
|
5629
7660
|
${CLI_NAME} msg send [--from ID] [--to ID|all] --body TEXT [--task N] [--kind note|task|handoff]
|
|
5630
7661
|
${CLI_NAME} msg inbox [--peer ID] [--wait SEC] [--all] [--limit N]
|
|
5631
7662
|
${CLI_NAME} msg ack [--peer ID] --id N
|
|
7663
|
+
${CLI_NAME} msg reply [--from ID] --id N --body TEXT [--to ID] [--kind reply]
|
|
7664
|
+
${CLI_NAME} msg thread --id N [--limit N]
|
|
5632
7665
|
|
|
5633
7666
|
If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal.
|
|
7667
|
+
Use msg reply when answering a message so the response stays in the same thread.
|
|
5634
7668
|
`);
|
|
5635
7669
|
}
|
|
5636
7670
|
|
|
@@ -5780,23 +7814,44 @@ Stops the web runtime started by hcc web for this project.
|
|
|
5780
7814
|
`);
|
|
5781
7815
|
}
|
|
5782
7816
|
|
|
7817
|
+
function helpUpdate() {
|
|
7818
|
+
console.log(`${CLI_NAME} update
|
|
7819
|
+
|
|
7820
|
+
Usage:
|
|
7821
|
+
${CLI_NAME} update [--tag TAG] [--registry URL] [--dry-run]
|
|
7822
|
+
|
|
7823
|
+
Examples:
|
|
7824
|
+
${CLI_NAME} update
|
|
7825
|
+
${CLI_NAME} update --tag latest
|
|
7826
|
+
${CLI_NAME} update --dry-run
|
|
7827
|
+
|
|
7828
|
+
Updates the global npm install by running:
|
|
7829
|
+
npm install -g ${NPM_PACKAGE_NAME}@TAG
|
|
7830
|
+
|
|
7831
|
+
The default TAG is latest.
|
|
7832
|
+
`);
|
|
7833
|
+
}
|
|
7834
|
+
|
|
5783
7835
|
function helpWeb() {
|
|
5784
7836
|
console.log(`${CLI_NAME} web
|
|
5785
7837
|
|
|
5786
7838
|
Usage:
|
|
5787
|
-
${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-discover] [--no-guidance]
|
|
7839
|
+
${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-token] [--no-discover] [--no-guidance]
|
|
5788
7840
|
|
|
5789
7841
|
Examples:
|
|
5790
7842
|
${CLI_NAME} web
|
|
5791
|
-
HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --
|
|
7843
|
+
HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --port 8787
|
|
7844
|
+
${CLI_NAME} web --local
|
|
5792
7845
|
|
|
5793
7846
|
This is the default one-command entrypoint. It prepares local coordination,
|
|
5794
7847
|
installs Claude/Codex hooks and shims, ensures tmux is available, starts the
|
|
5795
7848
|
browser terminal console as a background runtime, prints the URL, PID, runtime
|
|
5796
7849
|
file, and log file, then returns the terminal to you.
|
|
5797
7850
|
|
|
5798
|
-
|
|
5799
|
-
|
|
7851
|
+
By default, the web runtime listens on 0.0.0.0 and uses a saved token,
|
|
7852
|
+
generating one on first use. Use HCC_WEB_TOKEN or --token to replace the saved
|
|
7853
|
+
token, --local to bind only to 127.0.0.1, or --no-token only for a trusted
|
|
7854
|
+
local/test environment.
|
|
5800
7855
|
|
|
5801
7856
|
After hcc web, plain claude/codex commands started from a new shell are wrapped
|
|
5802
7857
|
as local tmux-backed terminals. Existing ordinary terminals can communicate
|
|
@@ -5876,7 +7931,7 @@ async function cmdHook(ctx, args) {
|
|
|
5876
7931
|
if (!peerId) {
|
|
5877
7932
|
resumeId = providerSession.resumeId || readParentResumeId(kind);
|
|
5878
7933
|
if (resumeId) {
|
|
5879
|
-
peerId =
|
|
7934
|
+
peerId = providerSessionPeerId(kind, resumeId);
|
|
5880
7935
|
}
|
|
5881
7936
|
}
|
|
5882
7937
|
if (!peerId) {
|
|
@@ -5933,7 +7988,7 @@ async function cmdHook(ctx, args) {
|
|
|
5933
7988
|
addEvent(db, `hook.${hookKey}`, peerId, null, { session_id: sessionId, cwd: hookCwd });
|
|
5934
7989
|
|
|
5935
7990
|
if (['sessionstart', 'userpromptsubmit'].includes(hookKey)) {
|
|
5936
|
-
const snapshot = buildHookCoordinationContext(db, peerId);
|
|
7991
|
+
const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
|
|
5937
7992
|
ackMessages(db, peerId, snapshot.messages);
|
|
5938
7993
|
process.stdout.write(JSON.stringify({
|
|
5939
7994
|
hookSpecificOutput: {
|
|
@@ -5942,7 +7997,7 @@ async function cmdHook(ctx, args) {
|
|
|
5942
7997
|
}
|
|
5943
7998
|
}) + '\n');
|
|
5944
7999
|
} else if (['posttooluse', 'stop'].includes(hookKey)) {
|
|
5945
|
-
const snapshot = buildHookCoordinationContext(db, peerId);
|
|
8000
|
+
const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
|
|
5946
8001
|
if (snapshot.messages.length > 0) {
|
|
5947
8002
|
ackMessages(db, peerId, snapshot.messages);
|
|
5948
8003
|
process.stdout.write(JSON.stringify({
|
|
@@ -6103,6 +8158,46 @@ async function cmdSetup(ctx, args) {
|
|
|
6103
8158
|
log('\nDone. Run `hcc web` for the default coordinated terminal experience.');
|
|
6104
8159
|
}
|
|
6105
8160
|
|
|
8161
|
+
// ─── hcc update ──────────────────────────────────────────────────────────────
|
|
8162
|
+
|
|
8163
|
+
async function cmdUpdate(ctx, args) {
|
|
8164
|
+
if (args[0] === '--help' || args[0] === '-h') return helpUpdate();
|
|
8165
|
+
|
|
8166
|
+
const opts = parseOpts(args, { booleans: ['dry-run'] });
|
|
8167
|
+
validateOpts('update', opts, ['tag', 'registry', 'dry-run']);
|
|
8168
|
+
const tag = opts.tag || 'latest';
|
|
8169
|
+
if (!/^[A-Za-z0-9._+-]+$/.test(tag)) {
|
|
8170
|
+
throw new CliError('BAD_ARGS', '--tag must be an npm dist-tag or version');
|
|
8171
|
+
}
|
|
8172
|
+
|
|
8173
|
+
const packageSpec = `${NPM_PACKAGE_NAME}@${tag}`;
|
|
8174
|
+
const npmArgs = ['install', '-g', packageSpec];
|
|
8175
|
+
if (opts.registry) npmArgs.push('--registry', opts.registry);
|
|
8176
|
+
const command = shellCommand(['npm', ...npmArgs]);
|
|
8177
|
+
const data = { package: NPM_PACKAGE_NAME, tag, registry: opts.registry || null, command, dry_run: Boolean(opts['dry-run']) };
|
|
8178
|
+
|
|
8179
|
+
if (opts['dry-run']) {
|
|
8180
|
+
printResult(ctx, data, () => `would run: ${command}`);
|
|
8181
|
+
return;
|
|
8182
|
+
}
|
|
8183
|
+
|
|
8184
|
+
const result = spawnSync('npm', npmArgs, {
|
|
8185
|
+
encoding: ctx.json ? 'utf8' : undefined,
|
|
8186
|
+
stdio: ctx.json ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
8187
|
+
});
|
|
8188
|
+
if (result.error) {
|
|
8189
|
+
throw new CliError('UPDATE_FAILED', `npm update command failed: ${result.error.message}`);
|
|
8190
|
+
}
|
|
8191
|
+
if (result.status !== 0) {
|
|
8192
|
+
const output = ctx.json
|
|
8193
|
+
? [result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
|
8194
|
+
: '';
|
|
8195
|
+
throw new CliError('UPDATE_FAILED', output || `npm exited with status ${result.status}`);
|
|
8196
|
+
}
|
|
8197
|
+
|
|
8198
|
+
printResult(ctx, data, () => `updated ${packageSpec}`);
|
|
8199
|
+
}
|
|
8200
|
+
|
|
6106
8201
|
// ─── hcc uninstall ───────────────────────────────────────────────────────────
|
|
6107
8202
|
|
|
6108
8203
|
async function cmdUninstall(ctx, args) {
|
|
@@ -6326,6 +8421,7 @@ async function dispatch(ctx, rest) {
|
|
|
6326
8421
|
if (command === '--version' || command === 'version') return console.log(VERSION);
|
|
6327
8422
|
if (command === 'up') return cmdUp(ctx, args);
|
|
6328
8423
|
if (command === 'down') return cmdDown(ctx, args);
|
|
8424
|
+
if (command === 'update') return cmdUpdate(ctx, args);
|
|
6329
8425
|
if (command === 'uninstall') return cmdUninstall(ctx, args);
|
|
6330
8426
|
if (command === 'init') return cmdInit(ctx, args);
|
|
6331
8427
|
if (command === 'register') return cmdRegister(ctx, args);
|
|
@@ -6334,6 +8430,7 @@ async function dispatch(ctx, rest) {
|
|
|
6334
8430
|
if (command === 'heartbeat') return cmdHeartbeat(ctx, args);
|
|
6335
8431
|
if (command === 'peers') return cmdPeers(ctx, args);
|
|
6336
8432
|
if (command === 'status') return cmdStatus(ctx, args);
|
|
8433
|
+
if (command === 'state') return cmdState(ctx, args);
|
|
6337
8434
|
if (command === 'prompt') return cmdPrompt(ctx, args);
|
|
6338
8435
|
if (command === 'run') return cmdRun(ctx, args);
|
|
6339
8436
|
if (command === 'peer') return cmdPeer(ctx, args);
|
|
@@ -6341,6 +8438,7 @@ async function dispatch(ctx, rest) {
|
|
|
6341
8438
|
if (command === 'ask') return cmdAsk(ctx, args);
|
|
6342
8439
|
if (command === 'broadcast') return cmdBroadcast(ctx, args);
|
|
6343
8440
|
if (command === 'task') return cmdTask(ctx, args);
|
|
8441
|
+
if (command === 'team') return cmdTeam(ctx, args);
|
|
6344
8442
|
if (command === 'msg') return cmdMsg(ctx, args);
|
|
6345
8443
|
if (command === 'lock') return cmdLock(ctx, args);
|
|
6346
8444
|
if (command === 'handoff') return cmdHandoff(ctx, args);
|