@logicseek/hello-cc 0.1.2 → 0.1.4
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 +145 -3
- package/README.md +4 -1
- package/README.zh-CN.md +5 -1
- package/bin/hcc.mjs +2701 -384
- package/docs/README.md +5 -3
- package/docs/README.zh-CN.md +5 -3
- package/docs/commands.md +38 -7
- package/docs/commands.zh-CN.md +31 -6
- package/docs/design.md +12 -4
- package/docs/guide.md +55 -10
- package/docs/guide.zh-CN.md +47 -9
- package/docs/implementation.md +21 -1
- package/lib/guidance.mjs +115 -0
- package/lib/package-meta.mjs +24 -0
- package/lib/release-notes.mjs +40 -0
- package/package.json +3 -1
- package/scripts/github-release.mjs +152 -0
- package/scripts/regression.mjs +965 -33
- package/scripts/release-notes.mjs +6 -38
package/bin/hcc.mjs
CHANGED
|
@@ -5,23 +5,30 @@ 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';
|
|
12
12
|
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { readPackageMeta } from '../lib/package-meta.mjs';
|
|
14
|
+
import {
|
|
15
|
+
removeGuidanceBlocks as removeGuidanceBlocksForRoot,
|
|
16
|
+
writeGuidance as writeGuidanceForRoot
|
|
17
|
+
} from '../lib/guidance.mjs';
|
|
13
18
|
|
|
14
19
|
// Lazy-load lib modules (they may import node-pty which needs to be optional)
|
|
15
20
|
const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'lib');
|
|
16
21
|
async function loadDiscover() { return import(path.join(_libDir, 'discover.mjs')); }
|
|
17
22
|
async function loadSetup() { return import(path.join(_libDir, 'setup.mjs')); }
|
|
18
23
|
|
|
19
|
-
const
|
|
24
|
+
const PACKAGE_META = readPackageMeta(path.resolve(fileURLToPath(import.meta.url), '..', '..'));
|
|
25
|
+
const VERSION = PACKAGE_META.version;
|
|
20
26
|
const PRODUCT_NAME = 'hello-cc';
|
|
21
27
|
const CLI_NAME = 'hcc';
|
|
22
|
-
const NPM_PACKAGE_NAME =
|
|
28
|
+
const NPM_PACKAGE_NAME = PACKAGE_META.name;
|
|
23
29
|
const DEFAULT_LOCK_TTL = 900;
|
|
24
30
|
const ACTIVE_PEER_TTL = 600;
|
|
31
|
+
const DB_SCHEMA_VERSION = 4;
|
|
25
32
|
// Directory under .hello-cc/ for optional external PTY buffer files.
|
|
26
33
|
const BUFS_DIR_NAME = 'bufs';
|
|
27
34
|
const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
@@ -163,6 +170,10 @@ function shortHash(value) {
|
|
|
163
170
|
return createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
function providerSessionPeerId(kind, providerId) {
|
|
174
|
+
return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
function currentTtyName() {
|
|
167
178
|
if (process.env.TTY) return process.env.TTY;
|
|
168
179
|
const result = spawnSync('tty', [], {
|
|
@@ -385,6 +396,10 @@ function globalRuntimePath() {
|
|
|
385
396
|
return path.join(globalStateDir(), 'runtime.json');
|
|
386
397
|
}
|
|
387
398
|
|
|
399
|
+
function globalWebTokenPath() {
|
|
400
|
+
return path.join(globalStateDir(), 'web-token');
|
|
401
|
+
}
|
|
402
|
+
|
|
388
403
|
function projectRegistryPath() {
|
|
389
404
|
return path.join(globalStateDir(), 'projects.json');
|
|
390
405
|
}
|
|
@@ -597,6 +612,70 @@ function runtimeUrlQuery(runtime, projectRoot = null) {
|
|
|
597
612
|
return parts.length ? `?${parts.join('&')}` : '';
|
|
598
613
|
}
|
|
599
614
|
|
|
615
|
+
function readStoredWebToken() {
|
|
616
|
+
const file = globalWebTokenPath();
|
|
617
|
+
if (!fs.existsSync(file)) return '';
|
|
618
|
+
try {
|
|
619
|
+
return fs.readFileSync(file, 'utf8').trim();
|
|
620
|
+
} catch {
|
|
621
|
+
return '';
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function writeStoredWebToken(token) {
|
|
626
|
+
if (!token) return '';
|
|
627
|
+
const file = globalWebTokenPath();
|
|
628
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
629
|
+
fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
|
|
630
|
+
fs.chmodSync(file, 0o600);
|
|
631
|
+
return file;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function makeWebToken(opts) {
|
|
635
|
+
validateWebTokenOpts(opts);
|
|
636
|
+
if (opts['no-token']) return '';
|
|
637
|
+
const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
|
|
638
|
+
if (explicitToken) {
|
|
639
|
+
writeStoredWebToken(explicitToken);
|
|
640
|
+
return explicitToken;
|
|
641
|
+
}
|
|
642
|
+
const stored = readStoredWebToken();
|
|
643
|
+
if (stored) return stored;
|
|
644
|
+
const generated = randomBytes(24).toString('base64url');
|
|
645
|
+
writeStoredWebToken(generated);
|
|
646
|
+
return generated;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function validateWebTokenOpts(opts) {
|
|
650
|
+
if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) {
|
|
651
|
+
throw new CliError('BAD_ARGS', '--no-token cannot be combined with --token or HCC_WEB_TOKEN');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function expectedWebHost(opts) {
|
|
656
|
+
return opts.host || (opts.local ? '127.0.0.1' : '0.0.0.0');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function webRuntimeMatchesRequest(runtime, opts) {
|
|
660
|
+
if (!runtime) return false;
|
|
661
|
+
if (opts['no-token'] && (opts.token || process.env.HCC_WEB_TOKEN)) return false;
|
|
662
|
+
const expectedHost = expectedWebHost(opts);
|
|
663
|
+
if (runtime.host !== expectedHost) return false;
|
|
664
|
+
const expectedPort = intOpt(opts, 'port', 8787);
|
|
665
|
+
if (opts.port !== undefined && runtime.port !== expectedPort) return false;
|
|
666
|
+
const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
|
|
667
|
+
if (opts['no-token']) return !runtime.token;
|
|
668
|
+
if (explicitToken) return runtime.token === explicitToken;
|
|
669
|
+
return Boolean(runtime.token);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function rememberRuntimeToken(runtime, opts) {
|
|
673
|
+
if (!runtime?.token || opts['no-token']) return;
|
|
674
|
+
const explicitToken = opts.token || process.env.HCC_WEB_TOKEN || '';
|
|
675
|
+
if (explicitToken && runtime.token !== explicitToken) return;
|
|
676
|
+
writeStoredWebToken(runtime.token);
|
|
677
|
+
}
|
|
678
|
+
|
|
600
679
|
function publicRuntimeUrl(runtime, projectRoot = null) {
|
|
601
680
|
const host = runtime.host === '0.0.0.0' || runtime.host === '::'
|
|
602
681
|
? '<machine-ip>'
|
|
@@ -915,6 +994,13 @@ function tmuxSendKeys(pane, keys) {
|
|
|
915
994
|
|
|
916
995
|
function tmuxSendRawLiteral(pane, text) {
|
|
917
996
|
if (!text) return;
|
|
997
|
+
// A tmux argument consisting solely of ';' is swallowed as a command
|
|
998
|
+
// separator even after '--', so a typed lone semicolon would vanish. Paste
|
|
999
|
+
// such runs through a buffer (which is parsed as data, not command args).
|
|
1000
|
+
if (/^;+$/.test(text)) {
|
|
1001
|
+
tmuxPasteBuffer(pane, text, { raw: true });
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
918
1004
|
runTmux(['send-keys', '-t', pane, '-l', '--', text]);
|
|
919
1005
|
}
|
|
920
1006
|
|
|
@@ -1036,18 +1122,26 @@ function tmuxSendLiteral(pane, text) {
|
|
|
1036
1122
|
}
|
|
1037
1123
|
if (current) chunks.push({ type: 'literal', text: current });
|
|
1038
1124
|
|
|
1125
|
+
// Typed characters must arrive as real key presses, not clipboard pastes:
|
|
1126
|
+
// send-keys -l is ~3x cheaper than the load-buffer/paste-buffer/delete-buffer
|
|
1127
|
+
// cycle (one tmux spawn vs three) and the target program sees keystrokes
|
|
1128
|
+
// rather than paste events — which bracketed-paste-aware TUIs (claude, codex)
|
|
1129
|
+
// otherwise mishandle. paste-buffer is reserved for genuine bracketed pastes.
|
|
1130
|
+
// Exit copy-mode once up front so send-keys isn't interpreted as copy commands.
|
|
1131
|
+
if (tmuxInCopyMode(pane)) tmuxExitCopyMode(pane);
|
|
1132
|
+
|
|
1039
1133
|
let pendingText = '';
|
|
1040
1134
|
for (const chunk of chunks) {
|
|
1041
1135
|
if (chunk.type === 'literal') {
|
|
1042
1136
|
pendingText += chunk.text;
|
|
1043
1137
|
} else {
|
|
1044
|
-
if (pendingText) {
|
|
1138
|
+
if (pendingText) { tmuxSendRawLiteral(pane, pendingText); pendingText = ''; }
|
|
1045
1139
|
if (chunk.type === 'key') tmuxSendKeys(pane, [chunk.key]);
|
|
1046
1140
|
else if (chunk.type === 'paste') tmuxPasteBuffer(pane, chunk.text, { bracketed: true, raw: true });
|
|
1047
1141
|
else tmuxSendRawLiteral(pane, chunk.text);
|
|
1048
1142
|
}
|
|
1049
1143
|
}
|
|
1050
|
-
if (pendingText)
|
|
1144
|
+
if (pendingText) tmuxSendRawLiteral(pane, pendingText);
|
|
1051
1145
|
}
|
|
1052
1146
|
|
|
1053
1147
|
function inferPeerKind(id, explicitKind, firstCommand) {
|
|
@@ -1397,6 +1491,9 @@ function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy =
|
|
|
1397
1491
|
return false;
|
|
1398
1492
|
}
|
|
1399
1493
|
|
|
1494
|
+
let projectMigrationFanoutDepth = 0;
|
|
1495
|
+
const migratedRegisteredProjectDbs = new Set();
|
|
1496
|
+
|
|
1400
1497
|
function connect(ctx) {
|
|
1401
1498
|
fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
|
|
1402
1499
|
const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
|
|
@@ -1404,16 +1501,84 @@ function connect(ctx) {
|
|
|
1404
1501
|
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
1405
1502
|
db.exec('PRAGMA foreign_keys = ON;');
|
|
1406
1503
|
initSchema(db);
|
|
1504
|
+
migrateRegisteredProjectDbs(ctx);
|
|
1407
1505
|
return db;
|
|
1408
1506
|
}
|
|
1409
1507
|
|
|
1508
|
+
function migrateRegisteredProjectDbs(ctx) {
|
|
1509
|
+
if (projectMigrationFanoutDepth > 0) return;
|
|
1510
|
+
projectMigrationFanoutDepth += 1;
|
|
1511
|
+
try {
|
|
1512
|
+
const currentDb = path.resolve(ctx.dbPath);
|
|
1513
|
+
const seen = new Set([currentDb]);
|
|
1514
|
+
for (const project of readProjectRegistry()) {
|
|
1515
|
+
const root = path.resolve(project.root);
|
|
1516
|
+
const dbPath = path.resolve(project.db || path.join(root, '.hello-cc', 'mesh.db'));
|
|
1517
|
+
if (seen.has(dbPath)) continue;
|
|
1518
|
+
seen.add(dbPath);
|
|
1519
|
+
const cacheKey = `${dbPath}:${DB_SCHEMA_VERSION}`;
|
|
1520
|
+
if (migratedRegisteredProjectDbs.has(cacheKey)) continue;
|
|
1521
|
+
if (!fs.existsSync(root) || !fs.existsSync(dbPath)) continue;
|
|
1522
|
+
let db = null;
|
|
1523
|
+
try {
|
|
1524
|
+
db = new DatabaseSync(dbPath, { timeout: 5000 });
|
|
1525
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
1526
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
1527
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
1528
|
+
initSchema(db);
|
|
1529
|
+
migratedRegisteredProjectDbs.add(cacheKey);
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
throw new CliError('REGISTERED_DB_MIGRATION_FAILED', `Failed to migrate registered project database ${dbPath}: ${err?.message || err}`, {
|
|
1532
|
+
db: dbPath,
|
|
1533
|
+
code: err instanceof CliError ? err.code : undefined
|
|
1534
|
+
});
|
|
1535
|
+
} finally {
|
|
1536
|
+
try { db?.close(); } catch {}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
} finally {
|
|
1540
|
+
projectMigrationFanoutDepth -= 1;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1410
1544
|
function initSchema(db) {
|
|
1545
|
+
const existingVersion = readSchemaVersion(db);
|
|
1546
|
+
if (existingVersion > DB_SCHEMA_VERSION) {
|
|
1547
|
+
throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
|
|
1548
|
+
db_schema_version: existingVersion,
|
|
1549
|
+
supported_schema_version: DB_SCHEMA_VERSION
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
createBaseSchema(db);
|
|
1553
|
+
runSchemaMigrations(db);
|
|
1554
|
+
dedupePeerBindings(db);
|
|
1555
|
+
execWithBusyRetry(db, `
|
|
1556
|
+
CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
|
|
1557
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
|
|
1558
|
+
ON peer_bindings(provider, provider_session_id)
|
|
1559
|
+
WHERE provider_session_id IS NOT NULL;
|
|
1560
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
|
|
1561
|
+
ON peer_bindings(provider, provider_session_name)
|
|
1562
|
+
WHERE provider_session_name IS NOT NULL;
|
|
1563
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
|
|
1564
|
+
ON peer_bindings(runtime_target)
|
|
1565
|
+
WHERE runtime_target IS NOT NULL;
|
|
1566
|
+
`);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function createBaseSchema(db) {
|
|
1411
1570
|
execWithBusyRetry(db, `
|
|
1412
1571
|
CREATE TABLE IF NOT EXISTS meta (
|
|
1413
1572
|
key TEXT PRIMARY KEY,
|
|
1414
1573
|
value TEXT NOT NULL
|
|
1415
1574
|
);
|
|
1416
1575
|
|
|
1576
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
1577
|
+
version INTEGER PRIMARY KEY,
|
|
1578
|
+
name TEXT NOT NULL,
|
|
1579
|
+
applied_at INTEGER NOT NULL
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1417
1582
|
CREATE TABLE IF NOT EXISTS peers (
|
|
1418
1583
|
id TEXT PRIMARY KEY,
|
|
1419
1584
|
kind TEXT NOT NULL,
|
|
@@ -1450,6 +1615,8 @@ function initSchema(db) {
|
|
|
1450
1615
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
1451
1616
|
assignee TEXT,
|
|
1452
1617
|
owner TEXT,
|
|
1618
|
+
parent_id INTEGER,
|
|
1619
|
+
team_role TEXT,
|
|
1453
1620
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
1454
1621
|
created_by TEXT,
|
|
1455
1622
|
claimed_at INTEGER,
|
|
@@ -1465,6 +1632,8 @@ function initSchema(db) {
|
|
|
1465
1632
|
task_id INTEGER,
|
|
1466
1633
|
kind TEXT NOT NULL DEFAULT 'note',
|
|
1467
1634
|
body TEXT NOT NULL,
|
|
1635
|
+
reply_to INTEGER,
|
|
1636
|
+
thread_id INTEGER,
|
|
1468
1637
|
created_at INTEGER NOT NULL
|
|
1469
1638
|
);
|
|
1470
1639
|
|
|
@@ -1512,28 +1681,110 @@ function initSchema(db) {
|
|
|
1512
1681
|
CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
|
|
1513
1682
|
CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
|
|
1514
1683
|
`);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function tableColumns(db, tableName) {
|
|
1687
|
+
return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function tableExists(db, tableName) {
|
|
1691
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function readSchemaVersion(db) {
|
|
1695
|
+
const metaExists = tableExists(db, 'meta');
|
|
1696
|
+
const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
|
|
1697
|
+
const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
|
|
1698
|
+
const migrationVersion = tableExists(db, 'schema_migrations')
|
|
1699
|
+
? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
|
|
1700
|
+
: 0;
|
|
1701
|
+
return Math.max(metaVersion, pragmaVersion, migrationVersion);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function writeSchemaVersion(db, version) {
|
|
1705
|
+
db.prepare(`
|
|
1706
|
+
INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
1707
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
1708
|
+
`).run(String(version));
|
|
1709
|
+
db.exec(`PRAGMA user_version = ${Number(version)}`);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function recordSchemaMigration(db, version, name) {
|
|
1515
1713
|
db.prepare(`
|
|
1516
|
-
INSERT INTO
|
|
1517
|
-
|
|
1518
|
-
|
|
1714
|
+
INSERT INTO schema_migrations(version, name, applied_at)
|
|
1715
|
+
VALUES (?, ?, ?)
|
|
1716
|
+
ON CONFLICT(version) DO NOTHING
|
|
1717
|
+
`).run(version, name, now());
|
|
1718
|
+
}
|
|
1519
1719
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1720
|
+
function addColumnIfMissing(db, tableName, columnName, ddl) {
|
|
1721
|
+
const columns = tableColumns(db, tableName);
|
|
1722
|
+
if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function runSchemaMigrations(db) {
|
|
1726
|
+
const migrations = [
|
|
1727
|
+
{
|
|
1728
|
+
version: 1,
|
|
1729
|
+
name: 'baseline',
|
|
1730
|
+
up() {
|
|
1731
|
+
// createBaseSchema already created the baseline tables.
|
|
1732
|
+
}
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
version: 2,
|
|
1736
|
+
name: 'peer binding runtime targets',
|
|
1737
|
+
up(database) {
|
|
1738
|
+
addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
{
|
|
1742
|
+
version: 3,
|
|
1743
|
+
name: 'threaded messages',
|
|
1744
|
+
up(database) {
|
|
1745
|
+
addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
|
|
1746
|
+
addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
|
|
1747
|
+
database.exec(`
|
|
1748
|
+
UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
|
|
1749
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
|
|
1750
|
+
`);
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
version: 4,
|
|
1755
|
+
name: 'team task hierarchy',
|
|
1756
|
+
up(database) {
|
|
1757
|
+
addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
|
|
1758
|
+
addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
|
|
1759
|
+
database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
];
|
|
1763
|
+
let version = readSchemaVersion(db);
|
|
1764
|
+
if (version > DB_SCHEMA_VERSION) {
|
|
1765
|
+
throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
|
|
1766
|
+
db_schema_version: version,
|
|
1767
|
+
supported_schema_version: DB_SCHEMA_VERSION
|
|
1768
|
+
});
|
|
1523
1769
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1770
|
+
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
|
|
1771
|
+
tx(db, () => {
|
|
1772
|
+
for (const migration of migrations) {
|
|
1773
|
+
if (version >= migration.version && applied.has(migration.version)) continue;
|
|
1774
|
+
migration.up(db);
|
|
1775
|
+
recordSchemaMigration(db, migration.version, migration.name);
|
|
1776
|
+
applied.add(migration.version);
|
|
1777
|
+
version = migration.version;
|
|
1778
|
+
writeSchemaVersion(db, version);
|
|
1779
|
+
}
|
|
1780
|
+
if (version === 0) {
|
|
1781
|
+
version = DB_SCHEMA_VERSION;
|
|
1782
|
+
}
|
|
1783
|
+
for (const migration of migrations) {
|
|
1784
|
+
if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
|
|
1785
|
+
}
|
|
1786
|
+
writeSchemaVersion(db, version);
|
|
1787
|
+
});
|
|
1537
1788
|
}
|
|
1538
1789
|
|
|
1539
1790
|
function peerBindingRuntimeRank(row) {
|
|
@@ -1900,23 +2151,32 @@ function touchCurrentPeer(db, ctx, resolved, status = null, kindHint = 'shell')
|
|
|
1900
2151
|
});
|
|
1901
2152
|
}
|
|
1902
2153
|
|
|
1903
|
-
function sendMessage(db, sender, recipient, taskId, kind, body) {
|
|
2154
|
+
function sendMessage(db, sender, recipient, taskId, kind, body, meta = {}) {
|
|
2155
|
+
const replyTo = meta.reply_to || null;
|
|
2156
|
+
const threadId = meta.thread_id || null;
|
|
1904
2157
|
const info = db.prepare(`
|
|
1905
|
-
INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
|
|
1906
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1907
|
-
`).run(sender, recipient || 'all', taskId || null, kind || 'note', body, now());
|
|
2158
|
+
INSERT INTO messages(sender, recipient, task_id, kind, body, reply_to, thread_id, created_at)
|
|
2159
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2160
|
+
`).run(sender, recipient || 'all', taskId || null, kind || 'note', body, replyTo, threadId, now());
|
|
2161
|
+
const messageId = Number(info.lastInsertRowid);
|
|
2162
|
+
if (!threadId) {
|
|
2163
|
+
db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(messageId, messageId);
|
|
2164
|
+
}
|
|
1908
2165
|
addEvent(db, 'message.sent', sender, taskId || null, {
|
|
1909
|
-
message_id:
|
|
2166
|
+
message_id: messageId,
|
|
1910
2167
|
recipient: recipient || 'all',
|
|
1911
|
-
kind: kind || 'note'
|
|
2168
|
+
kind: kind || 'note',
|
|
2169
|
+
reply_to: replyTo,
|
|
2170
|
+
thread_id: threadId || messageId
|
|
1912
2171
|
});
|
|
1913
|
-
return
|
|
2172
|
+
return messageId;
|
|
1914
2173
|
}
|
|
1915
2174
|
|
|
1916
2175
|
function queryInbox(db, peer, includeAll, limit) {
|
|
1917
2176
|
return db.prepare(`
|
|
1918
2177
|
SELECT
|
|
1919
|
-
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2178
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2179
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
1920
2180
|
FROM messages m
|
|
1921
2181
|
LEFT JOIN message_reads r
|
|
1922
2182
|
ON r.message_id = m.id AND r.peer = ?
|
|
@@ -1928,6 +2188,417 @@ function queryInbox(db, peer, includeAll, limit) {
|
|
|
1928
2188
|
`).all(peer, peer, includeAll ? 1 : 0, limit);
|
|
1929
2189
|
}
|
|
1930
2190
|
|
|
2191
|
+
function queryTimelineMessages(db, peer, limit) {
|
|
2192
|
+
if (!peer) {
|
|
2193
|
+
return db.prepare(`
|
|
2194
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2195
|
+
FROM messages
|
|
2196
|
+
ORDER BY id DESC
|
|
2197
|
+
LIMIT ?
|
|
2198
|
+
`).all(limit).reverse();
|
|
2199
|
+
}
|
|
2200
|
+
return db.prepare(`
|
|
2201
|
+
SELECT
|
|
2202
|
+
m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
|
|
2203
|
+
m.reply_to, m.thread_id, m.created_at, r.read_at
|
|
2204
|
+
FROM messages m
|
|
2205
|
+
LEFT JOIN message_reads r
|
|
2206
|
+
ON r.message_id = m.id AND r.peer = ?
|
|
2207
|
+
WHERE
|
|
2208
|
+
m.sender = ?
|
|
2209
|
+
OR m.recipient IS NULL
|
|
2210
|
+
OR m.recipient = ''
|
|
2211
|
+
OR m.recipient = 'all'
|
|
2212
|
+
OR m.recipient = ?
|
|
2213
|
+
ORDER BY m.id DESC
|
|
2214
|
+
LIMIT ?
|
|
2215
|
+
`).all(peer, peer, peer, limit).reverse();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function getMessage(db, id) {
|
|
2219
|
+
return db.prepare(`
|
|
2220
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2221
|
+
FROM messages
|
|
2222
|
+
WHERE id = ?
|
|
2223
|
+
`).get(id);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function queryMessageThread(db, messageId, limit) {
|
|
2227
|
+
const message = getMessage(db, messageId);
|
|
2228
|
+
if (!message) throw new CliError('NOT_FOUND', `Message #${messageId} does not exist`);
|
|
2229
|
+
const threadId = message.thread_id || message.id;
|
|
2230
|
+
const rows = db.prepare(`
|
|
2231
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2232
|
+
FROM messages
|
|
2233
|
+
WHERE id = ? OR thread_id = ?
|
|
2234
|
+
ORDER BY id ASC
|
|
2235
|
+
LIMIT ?
|
|
2236
|
+
`).all(message.id, threadId, limit);
|
|
2237
|
+
return { message, thread_id: threadId, messages: rows };
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function parseEventPayload(row) {
|
|
2241
|
+
try {
|
|
2242
|
+
return row?.payload ? JSON.parse(row.payload) : {};
|
|
2243
|
+
} catch {
|
|
2244
|
+
return {};
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function compactText(value, limit = 160) {
|
|
2249
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
2250
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function uniqueList(values) {
|
|
2254
|
+
return [...new Set(values.filter(Boolean).map(String))];
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function messageParticipants(message) {
|
|
2258
|
+
const participants = [message.sender];
|
|
2259
|
+
if (message.recipient && !['all', ''].includes(message.recipient)) participants.push(message.recipient);
|
|
2260
|
+
return uniqueList(participants);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function taskParticipants(task) {
|
|
2264
|
+
return uniqueList([task.created_by, task.owner, task.assignee]);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function payloadParticipants(payload) {
|
|
2268
|
+
const values = [];
|
|
2269
|
+
for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
|
|
2270
|
+
if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
|
|
2271
|
+
}
|
|
2272
|
+
return uniqueList(values);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function peerMatchesTimelineItem(item, peer) {
|
|
2276
|
+
if (!peer) return true;
|
|
2277
|
+
if (item.broadcast) return true;
|
|
2278
|
+
return (item.peers || []).includes(peer);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function shouldHideTimelineMessage(message) {
|
|
2282
|
+
if (message.kind === 'task' && /^Task #\d+ assigned: /.test(message.body || '')) return true;
|
|
2283
|
+
if (message.kind === 'handoff' && /^Handoff #\d+: /.test(message.body || '')) return true;
|
|
2284
|
+
return false;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
const TIMELINE_EVENT_ALLOW = new Set([
|
|
2288
|
+
'task.created',
|
|
2289
|
+
'task.claimed',
|
|
2290
|
+
'task.pending',
|
|
2291
|
+
'task.running',
|
|
2292
|
+
'task.review',
|
|
2293
|
+
'task.blocked',
|
|
2294
|
+
'task.done',
|
|
2295
|
+
'task.abandoned',
|
|
2296
|
+
'team.started',
|
|
2297
|
+
'lock.acquired',
|
|
2298
|
+
'lock.released',
|
|
2299
|
+
'peer.registered',
|
|
2300
|
+
'peer.joined',
|
|
2301
|
+
'peer.auto_joined',
|
|
2302
|
+
'peer.stopped',
|
|
2303
|
+
'provider.session.merged',
|
|
2304
|
+
'web.session.started',
|
|
2305
|
+
'web.session.exited',
|
|
2306
|
+
'tmux.session.attached',
|
|
2307
|
+
'tmux.session.detached',
|
|
2308
|
+
'tmux.session.exited',
|
|
2309
|
+
'run.session.started',
|
|
2310
|
+
'run.session.exited'
|
|
2311
|
+
]);
|
|
2312
|
+
|
|
2313
|
+
function shouldHideTimelineEvent(event) {
|
|
2314
|
+
if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
|
|
2315
|
+
if (event.type && event.type.startsWith('hook.')) return true;
|
|
2316
|
+
if (event.type && event.type.startsWith('web.session.input')) return true;
|
|
2317
|
+
return !TIMELINE_EVENT_ALLOW.has(event.type);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function timelineDirection(message, peer) {
|
|
2321
|
+
if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
|
|
2322
|
+
if (message.sender === peer && message.recipient === peer) return 'self';
|
|
2323
|
+
if (message.sender === peer) return 'out';
|
|
2324
|
+
if (message.recipient === 'all') return 'broadcast';
|
|
2325
|
+
if (message.recipient === peer) return 'in';
|
|
2326
|
+
return 'project';
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
|
|
2330
|
+
const items = [];
|
|
2331
|
+
for (const message of messages) {
|
|
2332
|
+
if (shouldHideTimelineMessage(message)) continue;
|
|
2333
|
+
const item = {
|
|
2334
|
+
id: `message:${message.id}`,
|
|
2335
|
+
source: 'message',
|
|
2336
|
+
source_id: message.id,
|
|
2337
|
+
ts: message.created_at,
|
|
2338
|
+
actor: message.sender,
|
|
2339
|
+
peers: messageParticipants(message),
|
|
2340
|
+
task_id: message.task_id || null,
|
|
2341
|
+
kind: message.kind || 'note',
|
|
2342
|
+
title: `${message.sender} -> ${message.recipient || 'all'}`,
|
|
2343
|
+
text: compactText(message.body),
|
|
2344
|
+
unread: message.read_at === null || message.read_at === undefined,
|
|
2345
|
+
direction: timelineDirection(message, peer),
|
|
2346
|
+
thread_id: message.thread_id || message.id,
|
|
2347
|
+
reply_to: message.reply_to || null,
|
|
2348
|
+
broadcast: !message.recipient || message.recipient === 'all'
|
|
2349
|
+
};
|
|
2350
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2351
|
+
}
|
|
2352
|
+
for (const handoff of handoffs) {
|
|
2353
|
+
const item = {
|
|
2354
|
+
id: `handoff:${handoff.id}`,
|
|
2355
|
+
source: 'handoff',
|
|
2356
|
+
source_id: handoff.id,
|
|
2357
|
+
ts: handoff.created_at,
|
|
2358
|
+
actor: handoff.from_peer,
|
|
2359
|
+
peers: uniqueList([handoff.from_peer, handoff.to_peer]),
|
|
2360
|
+
task_id: handoff.task_id || null,
|
|
2361
|
+
kind: 'handoff',
|
|
2362
|
+
title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
|
|
2363
|
+
text: compactText(handoff.summary),
|
|
2364
|
+
direction: 'project',
|
|
2365
|
+
broadcast: !handoff.to_peer,
|
|
2366
|
+
tests: handoff.tests || '',
|
|
2367
|
+
risks: handoff.risks || ''
|
|
2368
|
+
};
|
|
2369
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2370
|
+
}
|
|
2371
|
+
for (const task of tasks) {
|
|
2372
|
+
const item = {
|
|
2373
|
+
id: `task:${task.id}`,
|
|
2374
|
+
source: 'task',
|
|
2375
|
+
source_id: task.id,
|
|
2376
|
+
ts: task.updated_at || task.created_at,
|
|
2377
|
+
actor: task.owner || task.created_by || task.assignee || '',
|
|
2378
|
+
peers: taskParticipants(task),
|
|
2379
|
+
task_id: task.id,
|
|
2380
|
+
kind: task.status,
|
|
2381
|
+
title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
|
|
2382
|
+
text: compactText(task.title),
|
|
2383
|
+
direction: 'project'
|
|
2384
|
+
};
|
|
2385
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2386
|
+
}
|
|
2387
|
+
for (const lock of locks) {
|
|
2388
|
+
const item = {
|
|
2389
|
+
id: `lock:${lock.resource}`,
|
|
2390
|
+
source: 'lock',
|
|
2391
|
+
source_id: lock.resource,
|
|
2392
|
+
ts: lock.created_at,
|
|
2393
|
+
actor: lock.owner,
|
|
2394
|
+
peers: uniqueList([lock.owner]),
|
|
2395
|
+
task_id: lock.task_id || null,
|
|
2396
|
+
kind: 'active',
|
|
2397
|
+
title: `lock ${lock.resource}`,
|
|
2398
|
+
text: compactText(lock.reason || `owner=${lock.owner}`),
|
|
2399
|
+
direction: 'project'
|
|
2400
|
+
};
|
|
2401
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2402
|
+
}
|
|
2403
|
+
for (const event of events) {
|
|
2404
|
+
if (shouldHideTimelineEvent(event)) continue;
|
|
2405
|
+
const payload = parseEventPayload(event);
|
|
2406
|
+
const item = {
|
|
2407
|
+
id: `event:${event.id}`,
|
|
2408
|
+
source: 'event',
|
|
2409
|
+
source_id: event.id,
|
|
2410
|
+
ts: event.created_at,
|
|
2411
|
+
actor: event.actor || '',
|
|
2412
|
+
peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
|
|
2413
|
+
task_id: event.task_id || null,
|
|
2414
|
+
kind: event.type,
|
|
2415
|
+
title: event.type,
|
|
2416
|
+
text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
|
|
2417
|
+
direction: 'project'
|
|
2418
|
+
};
|
|
2419
|
+
if (peerMatchesTimelineItem(item, peer)) items.push(item);
|
|
2420
|
+
}
|
|
2421
|
+
const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
|
|
2422
|
+
items.sort((a, b) =>
|
|
2423
|
+
(a.ts || 0) - (b.ts || 0) ||
|
|
2424
|
+
(order[a.source] || 99) - (order[b.source] || 99) ||
|
|
2425
|
+
String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
|
|
2426
|
+
return items.slice(-120);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function actionCommand(argv) {
|
|
2430
|
+
if (!argv?.length) return '';
|
|
2431
|
+
return [CLI_NAME, ...argv].map(shellQuoteArg).join(' ');
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function makeAction(kind, argv, reason, mutates = true, extra = {}) {
|
|
2435
|
+
return {
|
|
2436
|
+
kind,
|
|
2437
|
+
reason,
|
|
2438
|
+
mutates,
|
|
2439
|
+
argv,
|
|
2440
|
+
command: actionCommand(argv),
|
|
2441
|
+
...extra
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function looksLikeMultiTask(task) {
|
|
2446
|
+
if (!task) return false;
|
|
2447
|
+
const text = `${task.title || ''}\n${task.body || ''}`;
|
|
2448
|
+
const bullets = text.split('\n').filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S+/.test(line)).length;
|
|
2449
|
+
const separators = (text.match(/[,,;;、]/g) || []).length;
|
|
2450
|
+
return bullets >= 2 || separators >= 3 || /多任务|并行|团队|分工|several tasks|multiple tasks|parallel|team/i.test(text);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function selectCurrentTask(tasks, peerId) {
|
|
2454
|
+
if (!peerId) return null;
|
|
2455
|
+
const statusRank = { running: 0, claimed: 1, review: 2, blocked: 3 };
|
|
2456
|
+
const openTasks = (tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
2457
|
+
const ownedTasks = openTasks
|
|
2458
|
+
.filter((task) => task.owner === peerId)
|
|
2459
|
+
.sort((a, b) =>
|
|
2460
|
+
(statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9) ||
|
|
2461
|
+
Number(a.priority || 0) - Number(b.priority || 0) ||
|
|
2462
|
+
Number(a.id || 0) - Number(b.id || 0));
|
|
2463
|
+
return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function summarizeTask(task) {
|
|
2467
|
+
if (!task) return null;
|
|
2468
|
+
return {
|
|
2469
|
+
id: task.id,
|
|
2470
|
+
title: task.title,
|
|
2471
|
+
status: task.status,
|
|
2472
|
+
owner: task.owner || null,
|
|
2473
|
+
assignee: task.assignee || null,
|
|
2474
|
+
parent_id: task.parent_id || null,
|
|
2475
|
+
team_role: task.team_role || null,
|
|
2476
|
+
priority: task.priority
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function deriveAutomation(snapshot, peer = null, opts = {}) {
|
|
2481
|
+
const peerId = peer || '';
|
|
2482
|
+
const t = Number(snapshot.now || now());
|
|
2483
|
+
const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
|
|
2484
|
+
const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
|
|
2485
|
+
const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
|
|
2486
|
+
const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
|
|
2487
|
+
const ownedTask = selectCurrentTask(openTasks, peerId);
|
|
2488
|
+
const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
|
|
2489
|
+
const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
|
|
2490
|
+
const requestedLocks = resources.map((resource) => {
|
|
2491
|
+
const lock = (snapshot.locks || []).find((row) => row.resource === resource && Number(row.expires_at || 0) > t);
|
|
2492
|
+
return { resource, lock };
|
|
2493
|
+
});
|
|
2494
|
+
const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
|
|
2495
|
+
const missingLock = requestedLocks.find((entry) => !entry.lock);
|
|
2496
|
+
const unread = peerId ? (snapshot.messages || []).filter((message) =>
|
|
2497
|
+
message.read_at === null || message.read_at === undefined) : [];
|
|
2498
|
+
const warnings = [];
|
|
2499
|
+
if (peerId && !peerRow) warnings.push(`peer ${peerId} is not registered in this project`);
|
|
2500
|
+
const lockActions = [];
|
|
2501
|
+
const messageActions = [];
|
|
2502
|
+
const taskActions = [];
|
|
2503
|
+
const automation = {
|
|
2504
|
+
schema_version: 1,
|
|
2505
|
+
peer: peerId ? {
|
|
2506
|
+
id: peerId,
|
|
2507
|
+
known: Boolean(peerRow),
|
|
2508
|
+
active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= ACTIVE_PEER_TTL),
|
|
2509
|
+
age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
|
|
2510
|
+
} : null,
|
|
2511
|
+
current_task: summarizeTask(ownedTask),
|
|
2512
|
+
phase: 'idle',
|
|
2513
|
+
next_action: makeAction('none', [], 'no immediate coordination action', false),
|
|
2514
|
+
actions: [],
|
|
2515
|
+
finish_actions: [],
|
|
2516
|
+
warnings
|
|
2517
|
+
};
|
|
2518
|
+
|
|
2519
|
+
const orderedUnread = unread
|
|
2520
|
+
.filter((message) => message.sender !== peerId)
|
|
2521
|
+
.filter((message) => !shouldHideTimelineMessage(message))
|
|
2522
|
+
.sort((a, b) => (a.kind === 'task' ? 1 : 0) - (b.kind === 'task' ? 1 : 0) || a.id - b.id);
|
|
2523
|
+
for (const message of orderedUnread) {
|
|
2524
|
+
if (ownedTask && message.kind === 'task') continue;
|
|
2525
|
+
const kind = message.kind === 'task' ? 'msg.inbox' : 'msg.reply';
|
|
2526
|
+
const argv = kind === 'msg.reply'
|
|
2527
|
+
? ['msg', 'reply', '--from', peerId, '--id', String(message.id), '--body', '<answer>']
|
|
2528
|
+
: ['msg', 'inbox', '--peer', peerId];
|
|
2529
|
+
messageActions.push(makeAction(kind, argv, `unread message #${message.id} from ${message.sender}`, kind !== 'msg.inbox', {
|
|
2530
|
+
message_id: message.id,
|
|
2531
|
+
task_id: message.task_id || null
|
|
2532
|
+
}));
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
if (!ownedTask && assignedTasks.length) {
|
|
2536
|
+
const task = assignedTasks[0];
|
|
2537
|
+
taskActions.push(makeAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
|
|
2538
|
+
} else if (!ownedTask && availableTasks.length) {
|
|
2539
|
+
taskActions.push(makeAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
if (ownedTask) {
|
|
2543
|
+
if (conflict) {
|
|
2544
|
+
lockActions.push(makeAction('msg.send', ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${conflict.resource}; it is locked by ${conflict.lock.owner}.`], `${conflict.resource} is locked by ${conflict.lock.owner}`, true, { task_id: ownedTask.id, resource: conflict.resource, lock_owner: conflict.lock.owner }));
|
|
2545
|
+
} else if (missingLock) {
|
|
2546
|
+
lockActions.push(makeAction('lock.acquire', ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), '--resource', missingLock.resource, '--ttl', String(DEFAULT_LOCK_TTL), '--reason', '<work>'], `task #${ownedTask.id} needs lock for ${missingLock.resource}`, true, { task_id: ownedTask.id, resource: missingLock.resource }));
|
|
2547
|
+
}
|
|
2548
|
+
if (ownedTask.status === 'claimed') {
|
|
2549
|
+
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 }));
|
|
2550
|
+
}
|
|
2551
|
+
if (!ownedTask.parent_id && looksLikeMultiTask(ownedTask)) {
|
|
2552
|
+
const childCount = openTasks.filter((task) => task.parent_id === ownedTask.id && !['done', 'abandoned'].includes(task.status)).length;
|
|
2553
|
+
if (!childCount) {
|
|
2554
|
+
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 }));
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
automation.finish_actions.push(makeAction('handoff.create', ['handoff', 'create', '--from', peerId, '--task', String(ownedTask.id), '--summary', '<summary>', '--tests', '<tests>', '--risks', '<risks>'], `handoff task #${ownedTask.id} before stopping`, true, { task_id: ownedTask.id }));
|
|
2558
|
+
automation.finish_actions.push(makeAction('task.done', ['task', 'done', '--peer', peerId, '--id', String(ownedTask.id), '--summary', '<summary>'], `mark task #${ownedTask.id} done after handoff`, true, { task_id: ownedTask.id }));
|
|
2559
|
+
for (const lock of ownLocks) {
|
|
2560
|
+
automation.finish_actions.push(makeAction('lock.release', ['lock', 'release', '--peer', peerId, '--resource', lock.resource], `release ${lock.resource}`, true, { task_id: lock.task_id || null, resource: lock.resource }));
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
automation.actions.push(...lockActions, ...messageActions, ...taskActions);
|
|
2564
|
+
|
|
2565
|
+
if (opts.intent === 'finish' || opts.intent === 'stop') {
|
|
2566
|
+
automation.phase = ownedTask ? 'handoff' : 'idle';
|
|
2567
|
+
automation.next_action = automation.finish_actions[0] || automation.next_action;
|
|
2568
|
+
return automation;
|
|
2569
|
+
}
|
|
2570
|
+
automation.next_action = automation.actions[0] || (ownedTask
|
|
2571
|
+
? makeAction('none', [], `continue task #${ownedTask.id}`, false, { task_id: ownedTask.id })
|
|
2572
|
+
: automation.next_action);
|
|
2573
|
+
if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
|
|
2574
|
+
else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
|
|
2575
|
+
else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
|
|
2576
|
+
else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
|
|
2577
|
+
else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
|
|
2578
|
+
else if (ownedTask) automation.phase = ownedTask.status === 'review' ? 'handoff' : 'work';
|
|
2579
|
+
return automation;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function renderAutomationContext(automation) {
|
|
2583
|
+
if (!automation) return '';
|
|
2584
|
+
const lines = [
|
|
2585
|
+
'[hello-cc next action]',
|
|
2586
|
+
`phase: ${automation.phase}`,
|
|
2587
|
+
automation.current_task ? `current_task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
|
|
2588
|
+
`next: ${automation.next_action.command || automation.next_action.kind}`,
|
|
2589
|
+
`why: ${automation.next_action.reason}`
|
|
2590
|
+
].filter(Boolean);
|
|
2591
|
+
if (automation.finish_actions?.length) {
|
|
2592
|
+
lines.push('finish:');
|
|
2593
|
+
for (const action of automation.finish_actions.slice(0, 4)) lines.push(`- ${action.command}`);
|
|
2594
|
+
}
|
|
2595
|
+
if (automation.warnings?.length) {
|
|
2596
|
+
lines.push('warnings:');
|
|
2597
|
+
for (const warning of automation.warnings.slice(0, 4)) lines.push(`- ${warning}`);
|
|
2598
|
+
}
|
|
2599
|
+
return lines.join('\n');
|
|
2600
|
+
}
|
|
2601
|
+
|
|
1931
2602
|
function queryOpenTasks(db, limit, peer = null) {
|
|
1932
2603
|
const sql = `
|
|
1933
2604
|
SELECT * FROM tasks
|
|
@@ -1970,29 +2641,81 @@ function formatHookEventName(hookType) {
|
|
|
1970
2641
|
return known[compact] || String(hookType || 'unknown');
|
|
1971
2642
|
}
|
|
1972
2643
|
|
|
1973
|
-
function
|
|
1974
|
-
const openTasks = queryOpenTasks(db, 8);
|
|
1975
|
-
const unread = queryInbox(db, peerId, false, 5);
|
|
2644
|
+
function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
|
|
1976
2645
|
const t = now();
|
|
1977
2646
|
const peers = db.prepare(`
|
|
1978
|
-
SELECT id, kind, status,
|
|
2647
|
+
SELECT id, kind, role, status, worktree, branch, pid, capabilities,
|
|
2648
|
+
created_at, last_seen_at, (? - last_seen_at) AS age_sec
|
|
1979
2649
|
FROM peers
|
|
1980
2650
|
ORDER BY last_seen_at DESC, id ASC
|
|
1981
|
-
LIMIT
|
|
1982
|
-
`).all();
|
|
2651
|
+
LIMIT 200
|
|
2652
|
+
`).all(t);
|
|
2653
|
+
const tasks = queryOpenTasks(db, 200);
|
|
2654
|
+
const locks = db.prepare(`
|
|
2655
|
+
SELECT resource, owner, task_id, reason, expires_at, created_at
|
|
2656
|
+
FROM locks
|
|
2657
|
+
WHERE expires_at > ?
|
|
2658
|
+
ORDER BY resource ASC
|
|
2659
|
+
LIMIT 200
|
|
2660
|
+
`).all(t);
|
|
2661
|
+
const messages = peer
|
|
2662
|
+
? queryInbox(db, peer, false, 50)
|
|
2663
|
+
: db.prepare(`
|
|
2664
|
+
SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
|
|
2665
|
+
FROM messages
|
|
2666
|
+
ORDER BY id DESC
|
|
2667
|
+
LIMIT 50
|
|
2668
|
+
`).all().reverse();
|
|
2669
|
+
const timelineMessages = queryTimelineMessages(db, peer, 80);
|
|
2670
|
+
const handoffs = db.prepare(`
|
|
2671
|
+
SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
|
|
2672
|
+
FROM handoffs
|
|
2673
|
+
ORDER BY id DESC
|
|
2674
|
+
LIMIT 50
|
|
2675
|
+
`).all().reverse();
|
|
2676
|
+
const events = db.prepare(`
|
|
2677
|
+
SELECT id, type, actor, task_id, payload, created_at
|
|
2678
|
+
FROM events
|
|
2679
|
+
ORDER BY id DESC
|
|
2680
|
+
LIMIT 80
|
|
2681
|
+
`).all().reverse();
|
|
2682
|
+
const snapshot = {
|
|
2683
|
+
root: ctx.root,
|
|
2684
|
+
db: ctx.dbPath,
|
|
2685
|
+
now: t,
|
|
2686
|
+
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
2687
|
+
peers,
|
|
2688
|
+
tasks,
|
|
2689
|
+
locks,
|
|
2690
|
+
messages,
|
|
2691
|
+
handoffs,
|
|
2692
|
+
events
|
|
2693
|
+
};
|
|
2694
|
+
snapshot.timeline = timelineFromRows({ messages: timelineMessages, handoffs, tasks, locks, events }, peer);
|
|
2695
|
+
snapshot.automation = deriveAutomation(snapshot, peer, opts);
|
|
2696
|
+
return snapshot;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function buildHookCoordinationContext(db, ctx, peerId) {
|
|
2700
|
+
const snapshot = collectStateSnapshot(db, ctx, peerId);
|
|
2701
|
+
const openTasks = snapshot.tasks.slice(0, 8);
|
|
2702
|
+
const unread = snapshot.messages.slice(0, 5);
|
|
2703
|
+
const peers = snapshot.peers.slice(0, 8);
|
|
1983
2704
|
const parts = [
|
|
1984
2705
|
'[hello-cc coordination]',
|
|
1985
2706
|
`peer: ${peerId}`,
|
|
1986
2707
|
'This is live project coordination context injected by hello-cc.',
|
|
1987
2708
|
'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
|
|
1988
|
-
'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
|
|
1989
|
-
'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.'
|
|
2709
|
+
'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.',
|
|
2710
|
+
'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.',
|
|
2711
|
+
'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.',
|
|
2712
|
+
'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
|
|
1990
2713
|
];
|
|
1991
2714
|
|
|
1992
2715
|
if (peers.length > 0) {
|
|
1993
2716
|
parts.push('[hello-cc known peers]');
|
|
1994
2717
|
parts.push(...peers.map((peer) => {
|
|
1995
|
-
const age = Math.max(0,
|
|
2718
|
+
const age = Math.max(0, snapshot.now - Number(peer.last_seen_at || 0));
|
|
1996
2719
|
const active = age <= ACTIVE_PEER_TTL ? 'active' : 'stale';
|
|
1997
2720
|
return `- ${peer.id} ${peer.kind || 'other'} ${peer.status || 'idle'} ${active}`;
|
|
1998
2721
|
}));
|
|
@@ -2000,6 +2723,12 @@ function buildHookCoordinationContext(db, peerId) {
|
|
|
2000
2723
|
parts.push('[hello-cc known peers]\n(none)');
|
|
2001
2724
|
}
|
|
2002
2725
|
|
|
2726
|
+
if (snapshot.automation.current_task) {
|
|
2727
|
+
const task = snapshot.automation.current_task;
|
|
2728
|
+
parts.push('[hello-cc current task]');
|
|
2729
|
+
parts.push(`#${task.id} ${task.status}: ${task.title}`);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2003
2732
|
if (openTasks.length > 0) {
|
|
2004
2733
|
parts.push('[hello-cc open tasks]');
|
|
2005
2734
|
parts.push(...openTasks.map((task) => `- ${formatOpenTaskLine(task)}`));
|
|
@@ -2010,12 +2739,22 @@ function buildHookCoordinationContext(db, peerId) {
|
|
|
2010
2739
|
if (unread.length > 0) {
|
|
2011
2740
|
parts.push('[hello-cc unread messages]');
|
|
2012
2741
|
parts.push(...unread.map((m) =>
|
|
2013
|
-
`- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}: ${m.body}`
|
|
2742
|
+
`- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}${m.reply_to ? ` reply #${m.reply_to}` : ''}: ${m.body}`
|
|
2014
2743
|
));
|
|
2015
2744
|
} else {
|
|
2016
2745
|
parts.push('[hello-cc unread messages]\n(none)');
|
|
2017
2746
|
}
|
|
2018
2747
|
|
|
2748
|
+
if (snapshot.locks.length > 0) {
|
|
2749
|
+
parts.push('[hello-cc active locks]');
|
|
2750
|
+
parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
|
|
2751
|
+
`- ${lock.resource} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
|
|
2752
|
+
} else {
|
|
2753
|
+
parts.push('[hello-cc active locks]\n(none)');
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
parts.push(renderAutomationContext(snapshot.automation));
|
|
2757
|
+
|
|
2019
2758
|
return { text: parts.join('\n'), messages: unread };
|
|
2020
2759
|
}
|
|
2021
2760
|
|
|
@@ -2061,78 +2800,11 @@ function table(rows, columns) {
|
|
|
2061
2800
|
}
|
|
2062
2801
|
|
|
2063
2802
|
function writeGuidance(ctx) {
|
|
2064
|
-
|
|
2065
|
-
fs.mkdirSync(path.dirname(guidePath), { recursive: true });
|
|
2066
|
-
const content = `# hello-cc Coordination Rules
|
|
2067
|
-
|
|
2068
|
-
This project may be edited by multiple Claude Code and Codex CLI sessions.
|
|
2069
|
-
|
|
2070
|
-
Use \`hcc\` as the source of truth for cross-session coordination. If the user
|
|
2071
|
-
asks what other sessions are doing, do not answer from generic model knowledge.
|
|
2072
|
-
Run the project-local commands below and summarize the result.
|
|
2073
|
-
|
|
2074
|
-
Each terminal session must use a stable peer id, for example codex-a,
|
|
2075
|
-
codex-b, claude-a, claude-b, or gpu-runner-a.
|
|
2076
|
-
|
|
2077
|
-
Status checks:
|
|
2078
|
-
- \`hcc peers\` shows known Claude/Codex/shell sessions.
|
|
2079
|
-
- \`hcc task list\` shows all open tasks. Tasks stay visible until \`done\` or
|
|
2080
|
-
\`abandoned\`; they are not read/unread items.
|
|
2081
|
-
- \`hcc msg inbox\` shows unread messages for this session.
|
|
2082
|
-
- \`hcc lock list\` shows active advisory locks.
|
|
2083
|
-
- \`hcc status\` summarizes peers, tasks, locks, inbox, and recent events.
|
|
2084
|
-
|
|
2085
|
-
Before work:
|
|
2086
|
-
- Register with hcc.
|
|
2087
|
-
- Read current status with \`hcc status\`, \`hcc task list\`, and \`hcc msg inbox\`.
|
|
2088
|
-
- Claim one task before editing.
|
|
2089
|
-
|
|
2090
|
-
Before editing:
|
|
2091
|
-
- Acquire an advisory lock for the file, directory, module, or shared resource.
|
|
2092
|
-
- If another live peer holds the lock, message that peer instead of editing.
|
|
2093
|
-
|
|
2094
|
-
During work:
|
|
2095
|
-
- Keep changes scoped to the claimed task.
|
|
2096
|
-
- Send progress messages when another session needs context.
|
|
2097
|
-
|
|
2098
|
-
Before stopping:
|
|
2099
|
-
- Mark the task done or blocked.
|
|
2100
|
-
- Create a handoff with changed files, tests, and remaining risks.
|
|
2101
|
-
- Release locks you no longer need.
|
|
2102
|
-
`;
|
|
2103
|
-
fs.writeFileSync(guidePath, content);
|
|
2104
|
-
const clause = `\n<!-- hello-cc:start -->\n\n${content}\n<!-- hello-cc:end -->\n`;
|
|
2105
|
-
for (const file of ['CLAUDE.md', 'AGENTS.md']) {
|
|
2106
|
-
const target = path.join(ctx.root, file);
|
|
2107
|
-
if (!fs.existsSync(target)) {
|
|
2108
|
-
fs.writeFileSync(target, clause.trimStart());
|
|
2109
|
-
continue;
|
|
2110
|
-
}
|
|
2111
|
-
const existing = fs.readFileSync(target, 'utf8');
|
|
2112
|
-
const updated = existing.includes('<!-- hello-cc:start -->')
|
|
2113
|
-
? existing.replace(/<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->/, clause.trim())
|
|
2114
|
-
: `${existing.trimEnd()}\n${clause}`;
|
|
2115
|
-
fs.writeFileSync(target, updated);
|
|
2116
|
-
}
|
|
2117
|
-
return guidePath;
|
|
2803
|
+
return writeGuidanceForRoot(ctx.root);
|
|
2118
2804
|
}
|
|
2119
2805
|
|
|
2120
2806
|
function removeGuidanceBlocks(ctx) {
|
|
2121
|
-
|
|
2122
|
-
for (const file of ['CLAUDE.md', 'AGENTS.md']) {
|
|
2123
|
-
const target = path.join(ctx.root, file);
|
|
2124
|
-
if (!fs.existsSync(target)) continue;
|
|
2125
|
-
const existing = fs.readFileSync(target, 'utf8');
|
|
2126
|
-
const updated = existing
|
|
2127
|
-
.replace(/\n?<!-- hello-cc:start -->[\s\S]*?<!-- hello-cc:end -->\n?/g, '\n')
|
|
2128
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
2129
|
-
.trimEnd();
|
|
2130
|
-
if (updated !== existing) {
|
|
2131
|
-
fs.writeFileSync(target, updated ? `${updated}\n` : '');
|
|
2132
|
-
changed.push(target);
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
return changed;
|
|
2807
|
+
return removeGuidanceBlocksForRoot(ctx.root);
|
|
2136
2808
|
}
|
|
2137
2809
|
|
|
2138
2810
|
function normalizeListText(value, fallback = []) {
|
|
@@ -2316,22 +2988,28 @@ async function taskCreate(ctx, args) {
|
|
|
2316
2988
|
const createdBy = identity.id;
|
|
2317
2989
|
const assignee = opts.to || opts.assignee || null;
|
|
2318
2990
|
const priority = intOpt(opts, 'priority', 100);
|
|
2991
|
+
const parentId = intOpt(opts, 'parent', null);
|
|
2992
|
+
const teamRole = opts.role || opts['team-role'] || null;
|
|
2319
2993
|
const db = connect(ctx);
|
|
2320
2994
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
2321
2995
|
const t = now();
|
|
2322
2996
|
const id = tx(db, () => {
|
|
2997
|
+
if (parentId && !db.prepare('SELECT id FROM tasks WHERE id = ?').get(parentId)) {
|
|
2998
|
+
throw new CliError('NOT_FOUND', `Parent task #${parentId} does not exist`);
|
|
2999
|
+
}
|
|
2323
3000
|
const info = db.prepare(`
|
|
2324
|
-
INSERT INTO tasks(title, body, status, assignee, owner, priority, created_by, created_at, updated_at)
|
|
2325
|
-
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?)
|
|
2326
|
-
`).run(title, body, assignee, priority, createdBy, t, t);
|
|
3001
|
+
INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
3002
|
+
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
|
|
3003
|
+
`).run(title, body, assignee, parentId, teamRole, priority, createdBy, t, t);
|
|
2327
3004
|
const taskId = Number(info.lastInsertRowid);
|
|
2328
|
-
addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority });
|
|
3005
|
+
addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority, parent_id: parentId, team_role: teamRole });
|
|
2329
3006
|
if (assignee) {
|
|
2330
3007
|
sendMessage(db, createdBy, assignee, taskId, 'task', `Task #${taskId} assigned: ${title}`);
|
|
2331
3008
|
}
|
|
2332
3009
|
return taskId;
|
|
2333
3010
|
});
|
|
2334
|
-
printResult(ctx, { id, title, assignee, priority
|
|
3011
|
+
printResult(ctx, { id, title, assignee, priority, parent_id: parentId, team_role: teamRole },
|
|
3012
|
+
(data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
|
|
2335
3013
|
}
|
|
2336
3014
|
|
|
2337
3015
|
async function taskList(ctx, args) {
|
|
@@ -2369,6 +3047,8 @@ async function taskList(ctx, args) {
|
|
|
2369
3047
|
{ label: 'prio', value: (r) => r.priority },
|
|
2370
3048
|
{ label: 'assignee', value: (r) => r.assignee || '' },
|
|
2371
3049
|
{ label: 'owner', value: (r) => r.owner || '' },
|
|
3050
|
+
{ label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
|
|
3051
|
+
{ label: 'role', value: (r) => r.team_role || '' },
|
|
2372
3052
|
{ label: 'title', value: (r) => r.title }
|
|
2373
3053
|
]));
|
|
2374
3054
|
}
|
|
@@ -2406,31 +3086,18 @@ async function taskClaim(ctx, args) {
|
|
|
2406
3086
|
}
|
|
2407
3087
|
|
|
2408
3088
|
async function taskNext(ctx, args) {
|
|
2409
|
-
const opts = parseOpts(args);
|
|
3089
|
+
const opts = parseOpts(args, { booleans: ['force'] });
|
|
2410
3090
|
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
2411
3091
|
const peer = identity.id;
|
|
2412
3092
|
const db = connect(ctx);
|
|
2413
3093
|
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
2414
|
-
const task =
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
|
|
2421
|
-
LIMIT 1
|
|
2422
|
-
`).get(peer, peer);
|
|
2423
|
-
if (!row) return null;
|
|
2424
|
-
const t = now();
|
|
2425
|
-
db.prepare(`
|
|
2426
|
-
UPDATE tasks
|
|
2427
|
-
SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
|
|
2428
|
-
WHERE id = ? AND owner IS NULL AND status = 'pending'
|
|
2429
|
-
`).run(peer, t, t, row.id);
|
|
2430
|
-
addEvent(db, 'task.claimed', peer, row.id, { next: true });
|
|
2431
|
-
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
|
|
3094
|
+
const task = claimNextTaskForPeer(db, peer, Boolean(opts.force));
|
|
3095
|
+
printResult(ctx, task, (data) => {
|
|
3096
|
+
if (!data) return 'no pending task';
|
|
3097
|
+
return data.current
|
|
3098
|
+
? `current task #${data.id}: ${data.title} (${data.status})`
|
|
3099
|
+
: `claimed task #${data.id}: ${data.title}`;
|
|
2432
3100
|
});
|
|
2433
|
-
printResult(ctx, task, (data) => data ? `claimed task #${data.id}: ${data.title}` : 'no pending task');
|
|
2434
3101
|
}
|
|
2435
3102
|
|
|
2436
3103
|
async function taskUpdate(ctx, args) {
|
|
@@ -2474,33 +3141,245 @@ async function taskDone(ctx, args) {
|
|
|
2474
3141
|
return taskUpdate(ctx, args.concat(['--status', 'done']));
|
|
2475
3142
|
}
|
|
2476
3143
|
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
if (sub === 'send') return msgSend(ctx, args.slice(1));
|
|
2481
|
-
if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
|
|
2482
|
-
if (sub === 'ack') return msgAck(ctx, args.slice(1));
|
|
2483
|
-
throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
|
|
3144
|
+
function splitCsvList(value) {
|
|
3145
|
+
if (Array.isArray(value)) return value.flatMap((item) => splitCsvList(item));
|
|
3146
|
+
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
|
|
2484
3147
|
}
|
|
2485
3148
|
|
|
2486
|
-
|
|
2487
|
-
const
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
3149
|
+
function parseTeamItems(opts) {
|
|
3150
|
+
const rawItems = [
|
|
3151
|
+
...splitCsvList(opts.item || []),
|
|
3152
|
+
...splitCsvList(opts.items || [])
|
|
3153
|
+
];
|
|
3154
|
+
return rawItems.map((raw, index) => {
|
|
3155
|
+
const parts = String(raw).split(':').map((part) => part.trim());
|
|
3156
|
+
let assignee = null;
|
|
3157
|
+
let role = null;
|
|
3158
|
+
let title = raw.trim();
|
|
3159
|
+
if (parts.length >= 3) {
|
|
3160
|
+
assignee = parts.shift() || null;
|
|
3161
|
+
role = parts.shift() || null;
|
|
3162
|
+
title = parts.join(':').trim();
|
|
3163
|
+
} else if (parts.length === 2) {
|
|
3164
|
+
role = parts[0] || null;
|
|
3165
|
+
title = parts[1] || title;
|
|
3166
|
+
}
|
|
3167
|
+
if (!title) title = `subtask ${index + 1}`;
|
|
3168
|
+
return { title, role: role || `worker-${index + 1}`, assignee };
|
|
3169
|
+
});
|
|
2498
3170
|
}
|
|
2499
3171
|
|
|
2500
|
-
|
|
2501
|
-
const
|
|
2502
|
-
|
|
2503
|
-
const
|
|
3172
|
+
function inferTeamItems(task, opts) {
|
|
3173
|
+
const explicit = parseTeamItems(opts);
|
|
3174
|
+
if (explicit.length) return explicit;
|
|
3175
|
+
const count = Math.max(1, intOpt(opts, 'count', 3));
|
|
3176
|
+
const baseTitle = task?.title || 'team task';
|
|
3177
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
3178
|
+
title: `${baseTitle} / subtask ${index + 1}`,
|
|
3179
|
+
role: `worker-${index + 1}`,
|
|
3180
|
+
assignee: null
|
|
3181
|
+
}));
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
function expandTeamWorkers(workers, parentId) {
|
|
3185
|
+
const expanded = [];
|
|
3186
|
+
for (const token of splitCsvList(workers || [])) {
|
|
3187
|
+
const match = token.match(/^([A-Za-z][A-Za-z0-9._-]*):([1-9][0-9]*)$/);
|
|
3188
|
+
if (!match) {
|
|
3189
|
+
expanded.push(token);
|
|
3190
|
+
continue;
|
|
3191
|
+
}
|
|
3192
|
+
const kind = sanitizePeerPart(match[1], 'peer');
|
|
3193
|
+
const count = Number.parseInt(match[2], 10);
|
|
3194
|
+
for (let i = 1; i <= count; i += 1) expanded.push(`${kind}-team-${parentId}-${i}`);
|
|
3195
|
+
}
|
|
3196
|
+
return expanded;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function assignTeamWorkers(items, workers, parentId) {
|
|
3200
|
+
const workerList = expandTeamWorkers(workers, parentId);
|
|
3201
|
+
if (!workerList.length) return items;
|
|
3202
|
+
return items.map((item, index) => ({
|
|
3203
|
+
...item,
|
|
3204
|
+
assignee: item.assignee || workerList[index % workerList.length]
|
|
3205
|
+
}));
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
function teamChildren(db, parentId) {
|
|
3209
|
+
return db.prepare(`
|
|
3210
|
+
SELECT *
|
|
3211
|
+
FROM tasks
|
|
3212
|
+
WHERE parent_id = ?
|
|
3213
|
+
ORDER BY priority ASC, id ASC
|
|
3214
|
+
`).all(parentId);
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
function taskById(db, id) {
|
|
3218
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
function teamSummary(db, parentId) {
|
|
3222
|
+
const parent = taskById(db, parentId);
|
|
3223
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3224
|
+
const children = teamChildren(db, parentId);
|
|
3225
|
+
const handoffs = db.prepare(`
|
|
3226
|
+
SELECT *
|
|
3227
|
+
FROM handoffs
|
|
3228
|
+
WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
|
|
3229
|
+
ORDER BY id ASC
|
|
3230
|
+
`).all(parentId, parentId);
|
|
3231
|
+
const counts = {};
|
|
3232
|
+
for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
|
|
3233
|
+
return { parent, children, handoffs, counts };
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
async function cmdTeam(ctx, args) {
|
|
3237
|
+
const sub = args[0];
|
|
3238
|
+
if (!sub || wantsHelp(args)) return helpTeam();
|
|
3239
|
+
if (sub === 'plan') return teamPlan(ctx, args.slice(1));
|
|
3240
|
+
if (sub === 'start') return teamStart(ctx, args.slice(1));
|
|
3241
|
+
if (sub === 'status') return teamStatus(ctx, args.slice(1));
|
|
3242
|
+
throw new CliError('BAD_ARGS', `Unknown team command: ${sub}`);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
async function teamPlan(ctx, args) {
|
|
3246
|
+
const opts = parseOpts(args, { arrays: ['item'] });
|
|
3247
|
+
const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
|
|
3248
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
|
|
3249
|
+
const db = connect(ctx);
|
|
3250
|
+
const parent = taskById(db, parentId);
|
|
3251
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3252
|
+
const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
|
|
3253
|
+
const data = { parent, items };
|
|
3254
|
+
printResult(ctx, data, (plan) => {
|
|
3255
|
+
const lines = [`team plan for task #${plan.parent.id}: ${plan.parent.title}`];
|
|
3256
|
+
plan.items.forEach((item, index) => {
|
|
3257
|
+
lines.push(`${index + 1}. ${item.role}: ${item.title}${item.assignee ? ` -> ${item.assignee}` : ''}`);
|
|
3258
|
+
});
|
|
3259
|
+
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());
|
|
3260
|
+
return lines.join('\n');
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
async function teamStart(ctx, args) {
|
|
3265
|
+
const opts = parseOpts(args, { arrays: ['item'], booleans: ['force'] });
|
|
3266
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
3267
|
+
const actor = identity.id;
|
|
3268
|
+
const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
|
|
3269
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
|
|
3270
|
+
const priorityBase = intOpt(opts, 'priority', 100);
|
|
3271
|
+
const db = connect(ctx);
|
|
3272
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3273
|
+
const result = tx(db, () => {
|
|
3274
|
+
const parent = taskById(db, parentId);
|
|
3275
|
+
if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
|
|
3276
|
+
const existing = teamChildren(db, parentId);
|
|
3277
|
+
if (existing.length && !opts.force) {
|
|
3278
|
+
throw new CliError('TEAM_EXISTS', `Task #${parentId} already has ${existing.length} team subtask(s); use --force to add more`, {
|
|
3279
|
+
parent_id: parentId,
|
|
3280
|
+
children: existing.map((task) => task.id)
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
|
|
3284
|
+
const t = now();
|
|
3285
|
+
const children = [];
|
|
3286
|
+
items.forEach((item, index) => {
|
|
3287
|
+
const info = db.prepare(`
|
|
3288
|
+
INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
3289
|
+
VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
|
|
3290
|
+
`).run(
|
|
3291
|
+
item.title,
|
|
3292
|
+
opts.body || `Subtask for #${parentId}: ${parent.title}`,
|
|
3293
|
+
item.assignee || null,
|
|
3294
|
+
parentId,
|
|
3295
|
+
item.role || `worker-${index + 1}`,
|
|
3296
|
+
priorityBase + index,
|
|
3297
|
+
actor,
|
|
3298
|
+
t,
|
|
3299
|
+
t
|
|
3300
|
+
);
|
|
3301
|
+
const taskId = Number(info.lastInsertRowid);
|
|
3302
|
+
addEvent(db, 'task.created', actor, taskId, {
|
|
3303
|
+
title: item.title,
|
|
3304
|
+
assignee: item.assignee || null,
|
|
3305
|
+
priority: priorityBase + index,
|
|
3306
|
+
parent_id: parentId,
|
|
3307
|
+
team_role: item.role || null
|
|
3308
|
+
});
|
|
3309
|
+
if (item.assignee) sendMessage(db, actor, item.assignee, taskId, 'task', `Task #${taskId} assigned: ${item.title}`);
|
|
3310
|
+
children.push(taskById(db, taskId));
|
|
3311
|
+
});
|
|
3312
|
+
addEvent(db, 'team.started', actor, parentId, {
|
|
3313
|
+
child_tasks: children.map((task) => task.id),
|
|
3314
|
+
workers: expandTeamWorkers(opts.workers || [], parentId)
|
|
3315
|
+
});
|
|
3316
|
+
return { parent, children };
|
|
3317
|
+
});
|
|
3318
|
+
printResult(ctx, result, (data) => {
|
|
3319
|
+
const lines = [`started team for task #${data.parent.id}: ${data.children.length} subtask${data.children.length === 1 ? '' : 's'}`];
|
|
3320
|
+
for (const child of data.children) {
|
|
3321
|
+
lines.push(`- #${child.id} ${child.team_role || 'worker'}${child.assignee ? ` -> ${child.assignee}` : ''}: ${child.title}`);
|
|
3322
|
+
}
|
|
3323
|
+
return lines.join('\n');
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
async function teamStatus(ctx, args) {
|
|
3328
|
+
const opts = parseOpts(args);
|
|
3329
|
+
const parentId = intOpt(opts, 'task', intOpt(opts, 'from-task', intOpt({ task: opts._[0] }, 'task')));
|
|
3330
|
+
if (!parentId) throw new CliError('BAD_ARGS', 'Missing --task');
|
|
3331
|
+
const db = connect(ctx);
|
|
3332
|
+
const data = teamSummary(db, parentId);
|
|
3333
|
+
printResult(ctx, data, (summary) => {
|
|
3334
|
+
const countText = Object.entries(summary.counts).map(([status, count]) => `${status}:${count}`).join(', ') || 'none';
|
|
3335
|
+
const lines = [
|
|
3336
|
+
`team task #${summary.parent.id}: ${summary.parent.title}`,
|
|
3337
|
+
`parent status: ${summary.parent.status}`,
|
|
3338
|
+
`subtasks: ${summary.children.length} (${countText})`
|
|
3339
|
+
];
|
|
3340
|
+
for (const child of summary.children) {
|
|
3341
|
+
lines.push(`- #${child.id} ${child.status} ${child.team_role || 'worker'}${child.owner ? ` owner=${child.owner}` : ''}${child.assignee ? ` assignee=${child.assignee}` : ''}: ${child.title}`);
|
|
3342
|
+
}
|
|
3343
|
+
if (summary.handoffs.length) {
|
|
3344
|
+
lines.push('handoffs:');
|
|
3345
|
+
for (const handoff of summary.handoffs.slice(-8)) {
|
|
3346
|
+
lines.push(`- #${handoff.id} task #${handoff.task_id || ''} ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}: ${handoff.summary}`);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
return lines.join('\n');
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
async function cmdMsg(ctx, args) {
|
|
3354
|
+
const sub = args[0];
|
|
3355
|
+
if (!sub || wantsHelp(args)) return helpMsg();
|
|
3356
|
+
if (sub === 'send') return msgSend(ctx, args.slice(1));
|
|
3357
|
+
if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
|
|
3358
|
+
if (sub === 'ack') return msgAck(ctx, args.slice(1));
|
|
3359
|
+
if (sub === 'reply') return msgReply(ctx, args.slice(1));
|
|
3360
|
+
if (sub === 'thread') return msgThread(ctx, args.slice(1));
|
|
3361
|
+
throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
async function msgSend(ctx, args) {
|
|
3365
|
+
const opts = parseOpts(args);
|
|
3366
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
3367
|
+
const sender = identity.id;
|
|
3368
|
+
const recipient = opts.to || 'all';
|
|
3369
|
+
const body = required(opts, 'body');
|
|
3370
|
+
const taskId = intOpt(opts, 'task', null);
|
|
3371
|
+
const kind = opts.kind || 'note';
|
|
3372
|
+
const db = connect(ctx);
|
|
3373
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3374
|
+
const id = sendMessage(db, sender, recipient, taskId, kind, body);
|
|
3375
|
+
printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, reply_to: null, thread_id: id },
|
|
3376
|
+
(data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async function msgInbox(ctx, args) {
|
|
3380
|
+
const opts = parseOpts(args, { booleans: ['all'] });
|
|
3381
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
3382
|
+
const peer = identity.id;
|
|
2504
3383
|
const waitSec = intOpt(opts, 'wait', 0);
|
|
2505
3384
|
const limit = intOpt(opts, 'limit', 20);
|
|
2506
3385
|
const includeAll = Boolean(opts.all);
|
|
@@ -2517,6 +3396,8 @@ async function msgInbox(ctx, args) {
|
|
|
2517
3396
|
{ label: 'from', value: (r) => r.sender },
|
|
2518
3397
|
{ label: 'kind', value: (r) => r.kind },
|
|
2519
3398
|
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
3399
|
+
{ label: 'reply', value: (r) => r.reply_to ? `#${r.reply_to}` : '' },
|
|
3400
|
+
{ label: 'thread', value: (r) => r.thread_id ? `#${r.thread_id}` : '' },
|
|
2520
3401
|
{ label: 'time', value: (r) => iso(r.created_at) },
|
|
2521
3402
|
{ label: 'body', value: (r) => r.body }
|
|
2522
3403
|
]));
|
|
@@ -2532,13 +3413,73 @@ async function msgAck(ctx, args) {
|
|
|
2532
3413
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
2533
3414
|
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
|
2534
3415
|
if (!message) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
3416
|
+
ackMessage(db, peer, message);
|
|
3417
|
+
printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
function ackMessage(db, peer, message) {
|
|
2535
3421
|
db.prepare(`
|
|
2536
3422
|
INSERT INTO message_reads(message_id, peer, read_at)
|
|
2537
3423
|
VALUES (?, ?, ?)
|
|
2538
3424
|
ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
|
|
2539
|
-
`).run(id, peer, now());
|
|
2540
|
-
addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: id });
|
|
2541
|
-
|
|
3425
|
+
`).run(message.id, peer, now());
|
|
3426
|
+
addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: message.id });
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
async function msgReply(ctx, args) {
|
|
3430
|
+
const opts = parseOpts(args);
|
|
3431
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
3432
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
3433
|
+
const body = required(opts, 'body');
|
|
3434
|
+
const db = connect(ctx);
|
|
3435
|
+
const original = getMessage(db, id);
|
|
3436
|
+
if (!original) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
3437
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
3438
|
+
const sender = identity.id;
|
|
3439
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3440
|
+
const recipient = opts.to || original.sender;
|
|
3441
|
+
const taskId = intOpt(opts, 'task', original.task_id || null);
|
|
3442
|
+
const kind = opts.kind || 'reply';
|
|
3443
|
+
const threadId = original.thread_id || original.id;
|
|
3444
|
+
const replyId = sendMessage(db, sender, recipient, taskId, kind, body, {
|
|
3445
|
+
reply_to: original.id,
|
|
3446
|
+
thread_id: threadId
|
|
3447
|
+
});
|
|
3448
|
+
ackMessage(db, sender, original);
|
|
3449
|
+
printResult(ctx, {
|
|
3450
|
+
id: replyId,
|
|
3451
|
+
sender,
|
|
3452
|
+
recipient,
|
|
3453
|
+
task_id: taskId,
|
|
3454
|
+
kind,
|
|
3455
|
+
body,
|
|
3456
|
+
reply_to: original.id,
|
|
3457
|
+
thread_id: threadId
|
|
3458
|
+
}, (data) => `sent reply #${data.id} to #${data.reply_to} ${data.sender} -> ${data.recipient}`);
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
async function msgThread(ctx, args) {
|
|
3462
|
+
const opts = parseOpts(args);
|
|
3463
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
3464
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
3465
|
+
const limit = intOpt(opts, 'limit', 50);
|
|
3466
|
+
const db = connect(ctx);
|
|
3467
|
+
const data = queryMessageThread(db, id, limit);
|
|
3468
|
+
printResult(ctx, data, (thread) => {
|
|
3469
|
+
const lines = [`thread #${thread.thread_id} (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`];
|
|
3470
|
+
for (const message of thread.messages) {
|
|
3471
|
+
const parts = [
|
|
3472
|
+
`#${message.id}`,
|
|
3473
|
+
`${message.sender} -> ${message.recipient || 'all'}`,
|
|
3474
|
+
message.task_id ? `task #${message.task_id}` : '',
|
|
3475
|
+
message.reply_to ? `reply #${message.reply_to}` : '',
|
|
3476
|
+
message.kind || 'note',
|
|
3477
|
+
iso(message.created_at)
|
|
3478
|
+
].filter(Boolean).join(' ');
|
|
3479
|
+
lines.push(`${parts}\n ${message.body}`);
|
|
3480
|
+
}
|
|
3481
|
+
return lines.join('\n');
|
|
3482
|
+
});
|
|
2542
3483
|
}
|
|
2543
3484
|
|
|
2544
3485
|
async function cmdAsk(ctx, args) {
|
|
@@ -2626,9 +3567,13 @@ async function peerList(ctx, args) {
|
|
|
2626
3567
|
} finally {
|
|
2627
3568
|
db.close();
|
|
2628
3569
|
}
|
|
2629
|
-
const rows = (data.sessions || []).map((session) => ({
|
|
3570
|
+
const rows = (data.sessions || []).map((session) => ({
|
|
3571
|
+
...session,
|
|
3572
|
+
binding: bindings.get(session.peer_id || session.id) || bindings.get(session.id) || null
|
|
3573
|
+
}));
|
|
2630
3574
|
printResult(ctx, rows, (items) => table(items, [
|
|
2631
3575
|
{ label: 'id', value: (r) => r.id },
|
|
3576
|
+
{ label: 'peer', value: (r) => r.peer_id && r.peer_id !== r.id ? r.peer_id : '' },
|
|
2632
3577
|
{ label: 'kind', value: (r) => r.kind },
|
|
2633
3578
|
{ label: 'role', value: (r) => r.role || '' },
|
|
2634
3579
|
{ label: 'status', value: (r) => r.status },
|
|
@@ -2932,35 +3877,85 @@ async function cmdStatus(ctx, args) {
|
|
|
2932
3877
|
const opts = parseOpts(args);
|
|
2933
3878
|
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
2934
3879
|
const peer = identity.id;
|
|
3880
|
+
const data = statusSummary(ctx, peer, identity);
|
|
3881
|
+
printResult(ctx, data, (s) => renderStatusSummary(s, peer));
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
function statusSummary(ctx, peer = null, identity = null) {
|
|
2935
3885
|
const db = connect(ctx);
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
3886
|
+
try {
|
|
3887
|
+
if (identity) touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3888
|
+
const t = now();
|
|
3889
|
+
const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - ACTIVE_PEER_TTL).n;
|
|
3890
|
+
const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - ACTIVE_PEER_TTL).n;
|
|
3891
|
+
const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
|
|
3892
|
+
const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
|
|
3893
|
+
const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
|
|
3894
|
+
const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
|
|
3895
|
+
return { root: ctx.root, db: ctx.dbPath, active_peers: activePeers, stale_peers: stalePeers, tasks: taskRows, active_locks: locks, unread, recent_events: recent };
|
|
3896
|
+
} finally {
|
|
3897
|
+
db.close();
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
function renderStatusSummary(s, peer = null) {
|
|
3902
|
+
const taskSummary = s.tasks.length ? s.tasks.map((r) => `${r.status}:${r.n}`).join(', ') : 'none';
|
|
3903
|
+
return [
|
|
3904
|
+
`root: ${s.root}`,
|
|
3905
|
+
`db: ${s.db}`,
|
|
3906
|
+
`peers: active=${s.active_peers}, stale=${s.stale_peers}`,
|
|
3907
|
+
`tasks: ${taskSummary}`,
|
|
3908
|
+
`locks: active=${s.active_locks}`,
|
|
3909
|
+
peer ? `inbox(${peer}): unread=${s.unread}` : null,
|
|
3910
|
+
'',
|
|
3911
|
+
'recent events:',
|
|
3912
|
+
table(s.recent_events, [
|
|
3913
|
+
{ label: 'id', value: (r) => `#${r.id}` },
|
|
3914
|
+
{ label: 'type', value: (r) => r.type },
|
|
3915
|
+
{ label: 'actor', value: (r) => r.actor || '' },
|
|
3916
|
+
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
3917
|
+
{ label: 'time', value: (r) => iso(r.created_at) }
|
|
3918
|
+
])
|
|
3919
|
+
].filter((line) => line !== null).join('\n');
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
function normalizeStateResources(values) {
|
|
3923
|
+
const list = Array.isArray(values) ? values : [values];
|
|
3924
|
+
return uniqueList(list.flatMap((value) => String(value || '').split(',').map((part) => part.trim())));
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
async function cmdState(ctx, args) {
|
|
3928
|
+
if (wantsHelp(args)) return helpState();
|
|
3929
|
+
const opts = parseOpts(args, { arrays: ['resource'] });
|
|
3930
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
3931
|
+
const peer = identity.id;
|
|
3932
|
+
const resources = normalizeStateResources(opts.resource || opts.resources || []);
|
|
3933
|
+
const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null });
|
|
3934
|
+
printResult(ctx, snapshot, (data) => {
|
|
3935
|
+
const automation = data.automation;
|
|
3936
|
+
const lines = [
|
|
3937
|
+
`root: ${data.root}`,
|
|
3938
|
+
`peer: ${peer}`,
|
|
3939
|
+
automation.current_task ? `current task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
|
|
3940
|
+
`phase: ${automation.phase}`,
|
|
3941
|
+
`next: ${automation.next_action.command || automation.next_action.kind}`,
|
|
3942
|
+
`why: ${automation.next_action.reason}`
|
|
3943
|
+
].filter(Boolean);
|
|
3944
|
+
if (automation.finish_actions.length) {
|
|
3945
|
+
lines.push('', 'finish actions:');
|
|
3946
|
+
lines.push(...automation.finish_actions.map((action) => `- ${action.command}`));
|
|
3947
|
+
}
|
|
3948
|
+
if (automation.warnings.length) {
|
|
3949
|
+
lines.push('', 'warnings:');
|
|
3950
|
+
lines.push(...automation.warnings.map((warning) => `- ${warning}`));
|
|
3951
|
+
}
|
|
3952
|
+
if (data.timeline.length) {
|
|
3953
|
+
lines.push('', 'timeline:');
|
|
3954
|
+
for (const item of data.timeline.slice(-8)) {
|
|
3955
|
+
lines.push(`- ${iso(item.ts)} ${item.source}:${item.source_id} ${item.title}${item.text ? ` — ${item.text}` : ''}`);
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
return lines.join('\n');
|
|
2964
3959
|
});
|
|
2965
3960
|
}
|
|
2966
3961
|
|
|
@@ -2978,11 +3973,13 @@ project uses a flat peer mesh: there is no required main/worker hierarchy.
|
|
|
2978
3973
|
Run these commands before changing files:
|
|
2979
3974
|
|
|
2980
3975
|
${cmd} register --peer ${peer} --kind ${kind} --role ${role}
|
|
3976
|
+
${cmd} state --peer ${peer}
|
|
2981
3977
|
${cmd} msg inbox --peer ${peer}
|
|
2982
3978
|
${cmd} task next --peer ${peer}
|
|
2983
3979
|
|
|
2984
3980
|
Coordination rules:
|
|
2985
3981
|
- Claim exactly one task before editing.
|
|
3982
|
+
- If state shows a current task for ${peer}, continue that task before claiming another pending task.
|
|
2986
3983
|
- Before editing a file, directory, module, or shared test resource, run:
|
|
2987
3984
|
${cmd} lock acquire --peer ${peer} --resource <path-or-module> --task <task-id>
|
|
2988
3985
|
- If a lock is held by another peer, message that peer instead of editing:
|
|
@@ -3203,19 +4200,25 @@ async function prepareLocalBus(ctx, opts = {}) {
|
|
|
3203
4200
|
}
|
|
3204
4201
|
|
|
3205
4202
|
async function startWebBackground(ctx, args) {
|
|
3206
|
-
const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
|
|
3207
|
-
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
|
|
4203
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
|
|
4204
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
|
|
4205
|
+
validateWebTokenOpts(opts);
|
|
3208
4206
|
ensureTmuxAvailable({ autoInstall: true });
|
|
3209
4207
|
const setup = await prepareLocalBus(ctx, { ...opts, installShims: true });
|
|
3210
4208
|
registerProject(ctx);
|
|
3211
4209
|
|
|
3212
4210
|
const existing = await readHealthyGlobalRuntime();
|
|
3213
4211
|
if (existing) {
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
4212
|
+
if (webRuntimeMatchesRequest(existing, opts)) {
|
|
4213
|
+
rememberRuntimeToken(existing, opts);
|
|
4214
|
+
try {
|
|
4215
|
+
await runtimeRequest(ctx, 'POST', '/api/projects', { root: ctx.root, db: ctx.dbPath }, existing);
|
|
4216
|
+
} catch {}
|
|
4217
|
+
writeRuntime(ctx, { ...existing, root: ctx.root, db: ctx.dbPath, project_root: ctx.root, global_runtime: true });
|
|
4218
|
+
return printWebRuntime(ctx, existing, { already: true, logFile: webLogPath(ctx), setup });
|
|
4219
|
+
}
|
|
4220
|
+
try { await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing); } catch {}
|
|
4221
|
+
await sleep(250);
|
|
3219
4222
|
}
|
|
3220
4223
|
|
|
3221
4224
|
try { fs.rmSync(runtimePath(ctx), { force: true }); } catch {}
|
|
@@ -3340,57 +4343,10 @@ async function cmdDown(ctx, args) {
|
|
|
3340
4343
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
3341
4344
|
}
|
|
3342
4345
|
|
|
3343
|
-
function statusSnapshot(ctx, peer = null) {
|
|
4346
|
+
function statusSnapshot(ctx, peer = null, opts = {}) {
|
|
3344
4347
|
const db = connect(ctx);
|
|
3345
4348
|
try {
|
|
3346
|
-
|
|
3347
|
-
const peers = db.prepare(`
|
|
3348
|
-
SELECT id, kind, role, status, worktree, branch, pid, capabilities,
|
|
3349
|
-
created_at, last_seen_at, (? - last_seen_at) AS age_sec
|
|
3350
|
-
FROM peers
|
|
3351
|
-
ORDER BY last_seen_at DESC, id ASC
|
|
3352
|
-
LIMIT 200
|
|
3353
|
-
`).all(t);
|
|
3354
|
-
const tasks = queryOpenTasks(db, 200);
|
|
3355
|
-
const locks = db.prepare(`
|
|
3356
|
-
SELECT resource, owner, task_id, reason, expires_at, created_at
|
|
3357
|
-
FROM locks
|
|
3358
|
-
WHERE expires_at > ?
|
|
3359
|
-
ORDER BY resource ASC
|
|
3360
|
-
LIMIT 200
|
|
3361
|
-
`).all(t);
|
|
3362
|
-
const messages = peer
|
|
3363
|
-
? queryInbox(db, peer, false, 50)
|
|
3364
|
-
: db.prepare(`
|
|
3365
|
-
SELECT id, sender, recipient, task_id, kind, body, created_at
|
|
3366
|
-
FROM messages
|
|
3367
|
-
ORDER BY id DESC
|
|
3368
|
-
LIMIT 50
|
|
3369
|
-
`).all().reverse();
|
|
3370
|
-
const handoffs = db.prepare(`
|
|
3371
|
-
SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
|
|
3372
|
-
FROM handoffs
|
|
3373
|
-
ORDER BY id DESC
|
|
3374
|
-
LIMIT 50
|
|
3375
|
-
`).all().reverse();
|
|
3376
|
-
const events = db.prepare(`
|
|
3377
|
-
SELECT id, type, actor, task_id, payload, created_at
|
|
3378
|
-
FROM events
|
|
3379
|
-
ORDER BY id DESC
|
|
3380
|
-
LIMIT 80
|
|
3381
|
-
`).all().reverse();
|
|
3382
|
-
return {
|
|
3383
|
-
root: ctx.root,
|
|
3384
|
-
db: ctx.dbPath,
|
|
3385
|
-
now: t,
|
|
3386
|
-
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
3387
|
-
peers,
|
|
3388
|
-
tasks,
|
|
3389
|
-
locks,
|
|
3390
|
-
messages,
|
|
3391
|
-
handoffs,
|
|
3392
|
-
events
|
|
3393
|
-
};
|
|
4349
|
+
return collectStateSnapshot(db, ctx, peer, opts);
|
|
3394
4350
|
} finally {
|
|
3395
4351
|
try {
|
|
3396
4352
|
db.close();
|
|
@@ -3400,6 +4356,161 @@ function statusSnapshot(ctx, peer = null) {
|
|
|
3400
4356
|
}
|
|
3401
4357
|
}
|
|
3402
4358
|
|
|
4359
|
+
function webPeerRegister(projectCtx, peer, input = {}) {
|
|
4360
|
+
const db = connect(projectCtx);
|
|
4361
|
+
const row = {
|
|
4362
|
+
id: peer,
|
|
4363
|
+
kind: input.kind || 'other',
|
|
4364
|
+
role: input.role || 'peer',
|
|
4365
|
+
worktree: path.resolve(input.worktree || projectCtx.cwd),
|
|
4366
|
+
branch: input.branch || detectBranch(projectCtx.cwd),
|
|
4367
|
+
pid: intOpt(input, 'pid', process.ppid),
|
|
4368
|
+
status: input.status || 'idle',
|
|
4369
|
+
capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
|
|
4370
|
+
};
|
|
4371
|
+
try {
|
|
4372
|
+
upsertPeer(db, row);
|
|
4373
|
+
addEvent(db, 'peer.registered', peer, null, { ...row, source: 'web' });
|
|
4374
|
+
} finally {
|
|
4375
|
+
db.close();
|
|
4376
|
+
}
|
|
4377
|
+
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
function webPeerHeartbeat(projectCtx, peer, input = {}) {
|
|
4381
|
+
const ttl = intOpt(input, 'ttl', DEFAULT_LOCK_TTL);
|
|
4382
|
+
const status = input.status || null;
|
|
4383
|
+
const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
|
|
4384
|
+
const db = connect(projectCtx);
|
|
4385
|
+
const t = now();
|
|
4386
|
+
let renewed = 0;
|
|
4387
|
+
try {
|
|
4388
|
+
touchPeer(db, peer, status);
|
|
4389
|
+
if (renewLocks) {
|
|
4390
|
+
renewed = db.prepare(`
|
|
4391
|
+
UPDATE locks SET expires_at = ?
|
|
4392
|
+
WHERE owner = ? AND expires_at > ?
|
|
4393
|
+
`).run(t + ttl, peer, t).changes;
|
|
4394
|
+
}
|
|
4395
|
+
addEvent(db, 'peer.heartbeat', peer, null, { status, renewed, source: 'web' });
|
|
4396
|
+
} finally {
|
|
4397
|
+
db.close();
|
|
4398
|
+
}
|
|
4399
|
+
return {
|
|
4400
|
+
peer,
|
|
4401
|
+
status,
|
|
4402
|
+
renewed,
|
|
4403
|
+
summary: `heartbeat ${peer}${renewed ? `, renewed locks: ${renewed}` : ''}`
|
|
4404
|
+
};
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
function claimNextTaskForPeer(db, peer, force = false) {
|
|
4408
|
+
return tx(db, () => {
|
|
4409
|
+
if (!force) {
|
|
4410
|
+
const current = db.prepare(`
|
|
4411
|
+
SELECT * FROM tasks
|
|
4412
|
+
WHERE owner = ?
|
|
4413
|
+
AND status IN ('claimed', 'running', 'review', 'blocked')
|
|
4414
|
+
ORDER BY
|
|
4415
|
+
CASE status
|
|
4416
|
+
WHEN 'running' THEN 0
|
|
4417
|
+
WHEN 'claimed' THEN 1
|
|
4418
|
+
WHEN 'review' THEN 2
|
|
4419
|
+
WHEN 'blocked' THEN 3
|
|
4420
|
+
ELSE 4
|
|
4421
|
+
END,
|
|
4422
|
+
priority ASC,
|
|
4423
|
+
id ASC
|
|
4424
|
+
LIMIT 1
|
|
4425
|
+
`).get(peer);
|
|
4426
|
+
if (current) return { ...current, current: true };
|
|
4427
|
+
}
|
|
4428
|
+
const row = db.prepare(`
|
|
4429
|
+
SELECT * FROM tasks
|
|
4430
|
+
WHERE status = 'pending'
|
|
4431
|
+
AND owner IS NULL
|
|
4432
|
+
AND (assignee IS NULL OR assignee = ?)
|
|
4433
|
+
ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
|
|
4434
|
+
LIMIT 1
|
|
4435
|
+
`).get(peer, peer);
|
|
4436
|
+
if (!row) return null;
|
|
4437
|
+
const t = now();
|
|
4438
|
+
db.prepare(`
|
|
4439
|
+
UPDATE tasks
|
|
4440
|
+
SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
|
|
4441
|
+
WHERE id = ? AND owner IS NULL AND status = 'pending'
|
|
4442
|
+
`).run(peer, t, t, row.id);
|
|
4443
|
+
addEvent(db, 'task.claimed', peer, row.id, { next: true });
|
|
4444
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
|
|
4445
|
+
});
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
function webPeerTaskNext(projectCtx, peer, input = {}) {
|
|
4449
|
+
const db = connect(projectCtx);
|
|
4450
|
+
try {
|
|
4451
|
+
touchPeer(db, peer, 'working');
|
|
4452
|
+
const task = claimNextTaskForPeer(db, peer, Boolean(input.force));
|
|
4453
|
+
if (!task) return { peer, task: null, summary: 'no pending task' };
|
|
4454
|
+
return {
|
|
4455
|
+
peer,
|
|
4456
|
+
task,
|
|
4457
|
+
current: Boolean(task.current),
|
|
4458
|
+
summary: task.current
|
|
4459
|
+
? `current task #${task.id}: ${task.title} (${task.status})`
|
|
4460
|
+
: `claimed task #${task.id}: ${task.title}`
|
|
4461
|
+
};
|
|
4462
|
+
} finally {
|
|
4463
|
+
db.close();
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
function webPeerInbox(projectCtx, peer, input = {}) {
|
|
4468
|
+
const db = connect(projectCtx);
|
|
4469
|
+
try {
|
|
4470
|
+
const messages = queryInbox(db, peer, Boolean(input.all), intOpt(input, 'limit', 20));
|
|
4471
|
+
return {
|
|
4472
|
+
peer,
|
|
4473
|
+
messages,
|
|
4474
|
+
summary: messages.length ? `${messages.length} message${messages.length === 1 ? '' : 's'}` : 'no messages'
|
|
4475
|
+
};
|
|
4476
|
+
} finally {
|
|
4477
|
+
db.close();
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
function webPeerAction(projectCtx, peer, action, input = {}) {
|
|
4482
|
+
const normalized = String(action || '').replace(/_/g, '-');
|
|
4483
|
+
if (!peer) throw new CliError('BAD_REQUEST', 'peer required');
|
|
4484
|
+
if (normalized === 'status') {
|
|
4485
|
+
const status = statusSummary(projectCtx, peer);
|
|
4486
|
+
return { ok: true, action: normalized, peer, summary: `active=${status.active_peers}, stale=${status.stale_peers}, locks=${status.active_locks}, unread=${status.unread ?? 0}`, data: status };
|
|
4487
|
+
}
|
|
4488
|
+
if (normalized === 'state') {
|
|
4489
|
+
const data = statusSnapshot(projectCtx, peer, {
|
|
4490
|
+
resources: normalizeStateResources(input.resource || input.resources || []),
|
|
4491
|
+
intent: input.intent || null
|
|
4492
|
+
});
|
|
4493
|
+
return { ok: true, action: normalized, peer, summary: data.automation?.next_action?.reason || 'state loaded', data };
|
|
4494
|
+
}
|
|
4495
|
+
if (normalized === 'inbox') {
|
|
4496
|
+
const data = webPeerInbox(projectCtx, peer, input);
|
|
4497
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4498
|
+
}
|
|
4499
|
+
if (normalized === 'task-next') {
|
|
4500
|
+
const data = webPeerTaskNext(projectCtx, peer, input);
|
|
4501
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4502
|
+
}
|
|
4503
|
+
if (normalized === 'heartbeat') {
|
|
4504
|
+
const data = webPeerHeartbeat(projectCtx, peer, input);
|
|
4505
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4506
|
+
}
|
|
4507
|
+
if (normalized === 'register') {
|
|
4508
|
+
const data = webPeerRegister(projectCtx, peer, input);
|
|
4509
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4510
|
+
}
|
|
4511
|
+
throw new CliError('BAD_REQUEST', `Unknown peer action: ${action}`);
|
|
4512
|
+
}
|
|
4513
|
+
|
|
3403
4514
|
function webIndexHtml() {
|
|
3404
4515
|
return `<!doctype html>
|
|
3405
4516
|
<html lang="en">
|
|
@@ -3465,11 +4576,72 @@ function webIndexHtml() {
|
|
|
3465
4576
|
font-size: 12px;
|
|
3466
4577
|
}
|
|
3467
4578
|
.app {
|
|
4579
|
+
position: relative;
|
|
3468
4580
|
height: 100vh;
|
|
3469
4581
|
display: grid;
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
4582
|
+
--left-width: 320px;
|
|
4583
|
+
--right-width: 360px;
|
|
4584
|
+
grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
|
|
4585
|
+
min-width: 640px;
|
|
4586
|
+
transition: grid-template-columns .18s ease;
|
|
4587
|
+
}
|
|
4588
|
+
.app.resizing { transition: none; }
|
|
4589
|
+
.app.left-collapsed { grid-template-columns: 0 minmax(0, 1fr) var(--right-width); }
|
|
4590
|
+
.app.right-collapsed { grid-template-columns: var(--left-width) minmax(0, 1fr) 0; }
|
|
4591
|
+
.app.left-collapsed.right-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
|
|
4592
|
+
/* Hide sidebar borders when collapsed so no 1px seam remains. */
|
|
4593
|
+
.app.left-collapsed .sidebar { border-right-width: 0; }
|
|
4594
|
+
.app.right-collapsed .inspector { border-left-width: 0; }
|
|
4595
|
+
.edge-resizer {
|
|
4596
|
+
position: absolute;
|
|
4597
|
+
top: 0;
|
|
4598
|
+
bottom: 0;
|
|
4599
|
+
z-index: 55;
|
|
4600
|
+
width: 12px;
|
|
4601
|
+
cursor: col-resize;
|
|
4602
|
+
touch-action: none;
|
|
4603
|
+
user-select: none;
|
|
4604
|
+
background: transparent;
|
|
4605
|
+
transition: left .18s ease, right .18s ease, background .12s;
|
|
4606
|
+
}
|
|
4607
|
+
.edge-resizer:hover,
|
|
4608
|
+
.app.resizing .edge-resizer {
|
|
4609
|
+
background: rgba(126, 231, 215, .08);
|
|
4610
|
+
}
|
|
4611
|
+
.app.resizing .edge-resizer { transition: none; }
|
|
4612
|
+
.edge-resizer-left { left: var(--left-width); transform: translateX(-50%); }
|
|
4613
|
+
.edge-resizer-right { right: var(--right-width); transform: translateX(50%); }
|
|
4614
|
+
.app.left-collapsed .edge-resizer-left { left: 0; }
|
|
4615
|
+
.app.right-collapsed .edge-resizer-right { right: 0; }
|
|
4616
|
+
/* Small collapse handles centered vertically on each divider border.
|
|
4617
|
+
They are children of .app (no overflow clip) and track the column edge. */
|
|
4618
|
+
.edge-toggle {
|
|
4619
|
+
position: absolute;
|
|
4620
|
+
top: 50%;
|
|
4621
|
+
z-index: 60;
|
|
4622
|
+
width: 16px;
|
|
4623
|
+
height: 44px;
|
|
4624
|
+
padding: 0;
|
|
4625
|
+
display: flex;
|
|
4626
|
+
align-items: center;
|
|
4627
|
+
justify-content: center;
|
|
4628
|
+
font-size: 11px;
|
|
4629
|
+
line-height: 1;
|
|
4630
|
+
color: var(--muted);
|
|
4631
|
+
background: var(--panel);
|
|
4632
|
+
border: 1px solid var(--border);
|
|
4633
|
+
border-radius: 6px;
|
|
4634
|
+
cursor: col-resize;
|
|
4635
|
+
touch-action: none;
|
|
4636
|
+
user-select: none;
|
|
4637
|
+
transition: left .18s ease, right .18s ease, color .12s, border-color .12s, background .12s;
|
|
4638
|
+
}
|
|
4639
|
+
.app.resizing .edge-toggle { transition: none; }
|
|
4640
|
+
.edge-toggle:hover { color: var(--text); border-color: var(--accent); background: #1b1f26; }
|
|
4641
|
+
.edge-left { left: var(--left-width); transform: translate(-50%, -50%); }
|
|
4642
|
+
.edge-right { right: var(--right-width); transform: translate(50%, -50%); }
|
|
4643
|
+
.app.left-collapsed .edge-left { left: 0; transform: translate(0, -50%); }
|
|
4644
|
+
.app.right-collapsed .edge-right { right: 0; transform: translate(0, -50%); }
|
|
3473
4645
|
.sidebar, .inspector {
|
|
3474
4646
|
min-height: 0;
|
|
3475
4647
|
min-width: 0;
|
|
@@ -3531,6 +4703,14 @@ function webIndexHtml() {
|
|
|
3531
4703
|
gap: 8px;
|
|
3532
4704
|
align-items: end;
|
|
3533
4705
|
}
|
|
4706
|
+
.start-options {
|
|
4707
|
+
display: grid;
|
|
4708
|
+
grid-template-columns: 1fr 1fr;
|
|
4709
|
+
gap: 8px;
|
|
4710
|
+
}
|
|
4711
|
+
.start-options label[data-resume-field] {
|
|
4712
|
+
grid-column: 1 / -1;
|
|
4713
|
+
}
|
|
3534
4714
|
.session-header {
|
|
3535
4715
|
border-bottom: 1px solid var(--border);
|
|
3536
4716
|
padding: 8px 10px;
|
|
@@ -3616,16 +4796,107 @@ function webIndexHtml() {
|
|
|
3616
4796
|
overflow: hidden;
|
|
3617
4797
|
}
|
|
3618
4798
|
.toolbar .title {
|
|
3619
|
-
|
|
4799
|
+
flex: 1 1 auto;
|
|
4800
|
+
min-width: 0;
|
|
3620
4801
|
display: grid;
|
|
3621
4802
|
gap: 2px;
|
|
3622
4803
|
}
|
|
4804
|
+
.toolbar .title strong {
|
|
4805
|
+
overflow: hidden;
|
|
4806
|
+
text-overflow: ellipsis;
|
|
4807
|
+
white-space: nowrap;
|
|
4808
|
+
}
|
|
3623
4809
|
.quick {
|
|
3624
4810
|
display: flex;
|
|
3625
|
-
gap:
|
|
3626
|
-
flex-wrap:
|
|
3627
|
-
|
|
4811
|
+
gap: 8px;
|
|
4812
|
+
flex-wrap: nowrap;
|
|
4813
|
+
align-items: center;
|
|
4814
|
+
flex: 0 0 auto;
|
|
4815
|
+
}
|
|
4816
|
+
/* Toolbar edge toggles for collapsing the side panels. */
|
|
4817
|
+
.icon-btn {
|
|
4818
|
+
flex: 0 0 auto;
|
|
4819
|
+
width: 30px;
|
|
4820
|
+
height: 30px;
|
|
4821
|
+
padding: 0;
|
|
4822
|
+
display: inline-flex;
|
|
4823
|
+
align-items: center;
|
|
4824
|
+
justify-content: center;
|
|
4825
|
+
font-size: 15px;
|
|
4826
|
+
line-height: 1;
|
|
4827
|
+
color: var(--muted);
|
|
4828
|
+
background: transparent;
|
|
4829
|
+
border: 1px solid var(--border);
|
|
4830
|
+
border-radius: 7px;
|
|
4831
|
+
cursor: pointer;
|
|
4832
|
+
}
|
|
4833
|
+
.icon-btn:hover { color: var(--text); border-color: var(--accent); }
|
|
4834
|
+
/* Compact actions dropdown so the top bar stays a single tidy row. */
|
|
4835
|
+
.menu-wrap { position: relative; display: inline-flex; }
|
|
4836
|
+
.menu-btn { white-space: nowrap; }
|
|
4837
|
+
.menu {
|
|
4838
|
+
position: fixed;
|
|
4839
|
+
z-index: 1000;
|
|
4840
|
+
min-width: 150px;
|
|
4841
|
+
padding: 5px;
|
|
4842
|
+
display: grid;
|
|
4843
|
+
gap: 2px;
|
|
4844
|
+
background: var(--panel);
|
|
4845
|
+
border: 1px solid var(--border);
|
|
4846
|
+
border-radius: 8px;
|
|
4847
|
+
box-shadow: 0 8px 24px rgba(0,0,0,.45);
|
|
4848
|
+
}
|
|
4849
|
+
.menu[hidden] { display: none; }
|
|
4850
|
+
.menu button {
|
|
3628
4851
|
width: 100%;
|
|
4852
|
+
text-align: left;
|
|
4853
|
+
border: 0;
|
|
4854
|
+
background: transparent;
|
|
4855
|
+
padding: 7px 9px;
|
|
4856
|
+
border-radius: 6px;
|
|
4857
|
+
color: var(--text);
|
|
4858
|
+
}
|
|
4859
|
+
.menu button:hover { background: #1b1f26; }
|
|
4860
|
+
.menu .divider {
|
|
4861
|
+
height: 1px;
|
|
4862
|
+
margin: 4px 2px;
|
|
4863
|
+
background: var(--border);
|
|
4864
|
+
}
|
|
4865
|
+
.action-result {
|
|
4866
|
+
position: fixed;
|
|
4867
|
+
right: 16px;
|
|
4868
|
+
top: 64px;
|
|
4869
|
+
z-index: 1200;
|
|
4870
|
+
width: min(520px, calc(100vw - 32px));
|
|
4871
|
+
max-height: min(68vh, 640px);
|
|
4872
|
+
display: grid;
|
|
4873
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
4874
|
+
background: var(--panel);
|
|
4875
|
+
border: 1px solid var(--border);
|
|
4876
|
+
border-radius: 8px;
|
|
4877
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.5);
|
|
4878
|
+
overflow: hidden;
|
|
4879
|
+
}
|
|
4880
|
+
.action-result[hidden] { display: none; }
|
|
4881
|
+
.action-result header {
|
|
4882
|
+
display: flex;
|
|
4883
|
+
align-items: center;
|
|
4884
|
+
justify-content: space-between;
|
|
4885
|
+
gap: 10px;
|
|
4886
|
+
padding: 9px 10px;
|
|
4887
|
+
border-bottom: 1px solid var(--border);
|
|
4888
|
+
}
|
|
4889
|
+
.action-result header strong { font-size: 13px; }
|
|
4890
|
+
.action-result pre {
|
|
4891
|
+
margin: 0;
|
|
4892
|
+
padding: 10px;
|
|
4893
|
+
overflow: auto;
|
|
4894
|
+
white-space: pre-wrap;
|
|
4895
|
+
word-break: break-word;
|
|
4896
|
+
font-family: var(--mono);
|
|
4897
|
+
font-size: 11px;
|
|
4898
|
+
color: var(--text);
|
|
4899
|
+
background: #0d0f12;
|
|
3629
4900
|
}
|
|
3630
4901
|
#terminal {
|
|
3631
4902
|
min-height: 0;
|
|
@@ -3659,6 +4930,9 @@ function webIndexHtml() {
|
|
|
3659
4930
|
display: grid;
|
|
3660
4931
|
grid-template-rows: auto minmax(0, 1fr);
|
|
3661
4932
|
}
|
|
4933
|
+
.card.state-card.state-card-collapsed {
|
|
4934
|
+
grid-template-rows: auto 0;
|
|
4935
|
+
}
|
|
3662
4936
|
.card h2 {
|
|
3663
4937
|
margin: 0;
|
|
3664
4938
|
padding: 8px 10px;
|
|
@@ -3670,6 +4944,44 @@ function webIndexHtml() {
|
|
|
3670
4944
|
align-items: center;
|
|
3671
4945
|
gap: 8px;
|
|
3672
4946
|
}
|
|
4947
|
+
.state-card-toggle {
|
|
4948
|
+
width: 100%;
|
|
4949
|
+
min-width: 0;
|
|
4950
|
+
border: 0;
|
|
4951
|
+
border-bottom: 1px solid var(--border);
|
|
4952
|
+
border-radius: 0;
|
|
4953
|
+
background: transparent;
|
|
4954
|
+
color: inherit;
|
|
4955
|
+
padding: 8px 10px;
|
|
4956
|
+
display: flex;
|
|
4957
|
+
align-items: center;
|
|
4958
|
+
justify-content: space-between;
|
|
4959
|
+
gap: 8px;
|
|
4960
|
+
cursor: pointer;
|
|
4961
|
+
text-align: left;
|
|
4962
|
+
}
|
|
4963
|
+
.state-card-toggle:hover { background: #151922; }
|
|
4964
|
+
.state-card-toggle-title {
|
|
4965
|
+
min-width: 0;
|
|
4966
|
+
display: flex;
|
|
4967
|
+
align-items: center;
|
|
4968
|
+
gap: 8px;
|
|
4969
|
+
overflow: hidden;
|
|
4970
|
+
}
|
|
4971
|
+
.state-card-toggle-title strong {
|
|
4972
|
+
overflow: hidden;
|
|
4973
|
+
text-overflow: ellipsis;
|
|
4974
|
+
white-space: nowrap;
|
|
4975
|
+
}
|
|
4976
|
+
.state-card-chevron {
|
|
4977
|
+
flex: 0 0 auto;
|
|
4978
|
+
color: var(--muted);
|
|
4979
|
+
font-size: 12px;
|
|
4980
|
+
line-height: 1;
|
|
4981
|
+
}
|
|
4982
|
+
.state-card.state-card-collapsed .state-card-chevron {
|
|
4983
|
+
transform: rotate(-90deg);
|
|
4984
|
+
}
|
|
3673
4985
|
.card .body {
|
|
3674
4986
|
padding: 8px 10px;
|
|
3675
4987
|
display: grid;
|
|
@@ -3684,6 +4996,9 @@ function webIndexHtml() {
|
|
|
3684
4996
|
scrollbar-width: thin;
|
|
3685
4997
|
scrollbar-color: #3a3f4a transparent;
|
|
3686
4998
|
}
|
|
4999
|
+
.state-card.state-card-collapsed .body {
|
|
5000
|
+
display: none;
|
|
5001
|
+
}
|
|
3687
5002
|
.state-card .body::-webkit-scrollbar { width: 8px; }
|
|
3688
5003
|
.state-card .body::-webkit-scrollbar-track { background: transparent; }
|
|
3689
5004
|
.state-card .body::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
|
|
@@ -3712,22 +5027,30 @@ function webIndexHtml() {
|
|
|
3712
5027
|
<strong>hello-cc</strong>
|
|
3713
5028
|
<div class="path" id="rootPath"></div>
|
|
3714
5029
|
</div>
|
|
3715
|
-
<span id="connState">offline</span>
|
|
5030
|
+
<span id="connState" data-i18n="conn.offline">offline</span>
|
|
3716
5031
|
</div>
|
|
3717
5032
|
<div class="form" style="padding-top:10px;padding-bottom:10px">
|
|
3718
|
-
<
|
|
3719
|
-
|
|
3720
|
-
|
|
5033
|
+
<div class="grid2">
|
|
5034
|
+
<label><span data-i18n="project">Project</span><select id="projectSelect"></select></label>
|
|
5035
|
+
<label><span data-i18n="language">Language</span><select id="langSelect"><option value="en">English</option><option value="zh">中文</option></select></label>
|
|
5036
|
+
</div>
|
|
5037
|
+
<label><span data-i18n="projectPath">Project path</span><input id="projectPath" data-i18n-placeholder="projectPathPlaceholder" placeholder="/path/to/project"></label>
|
|
5038
|
+
<button id="addProjectBtn" type="button" data-i18n="registerProject">Register Project</button>
|
|
3721
5039
|
</div>
|
|
3722
5040
|
<form class="form" id="startForm">
|
|
3723
5041
|
<div class="start-row">
|
|
3724
|
-
<label>New session
|
|
3725
|
-
<button class="primary" type="submit">Start</button>
|
|
5042
|
+
<label><span data-i18n="newSession">New session</span><select id="kind"><option value="codex">codex</option><option value="claude">claude</option><option value="shell" data-i18n="kind.shell">shell</option></select></label>
|
|
5043
|
+
<button class="primary" type="submit" data-i18n="start">Start</button>
|
|
5044
|
+
</div>
|
|
5045
|
+
<div class="start-options">
|
|
5046
|
+
<label><span data-i18n="mode">Mode</span><select id="startMode"><option value="new" data-i18n="mode.new">new</option><option value="resume" data-i18n="mode.resume">resume</option><option value="last" data-i18n="mode.last">last</option><option value="continue" data-i18n="mode.continue">continue</option></select></label>
|
|
5047
|
+
<label data-resume-field><span data-i18n="session">Session</span><select id="resumeSelect"></select></label>
|
|
5048
|
+
<label data-resume-field data-resume-custom style="display:none"><span data-i18n="sessionId">Session id</span><input id="resumeArg" data-i18n-placeholder="sessionIdPlaceholder" placeholder="session id or name"></label>
|
|
3726
5049
|
</div>
|
|
3727
5050
|
</form>
|
|
3728
5051
|
<div class="session-header">
|
|
3729
|
-
<strong>Sessions</strong>
|
|
3730
|
-
<label>View
|
|
5052
|
+
<strong data-i18n="sessions">Sessions</strong>
|
|
5053
|
+
<label><span data-i18n="view">View</span><select id="sessionKindFilter"><option value="all" data-i18n="kind.all">all</option><option value="claude">claude</option><option value="codex">codex</option><option value="shell" data-i18n="kind.shell">shell</option><option value="other" data-i18n="kind.other">other</option></select></label>
|
|
3731
5054
|
</div>
|
|
3732
5055
|
<div class="sessions" id="sessions"></div>
|
|
3733
5056
|
</aside>
|
|
@@ -3735,29 +5058,50 @@ function webIndexHtml() {
|
|
|
3735
5058
|
<main class="main">
|
|
3736
5059
|
<div class="toolbar">
|
|
3737
5060
|
<div class="title">
|
|
3738
|
-
<strong id="activeTitle">No session selected</strong>
|
|
3739
|
-
<span class="path" id="activeMeta">Start or select a session from the left panel</span>
|
|
5061
|
+
<strong id="activeTitle" data-i18n="noSessionSelected">No session selected</strong>
|
|
5062
|
+
<span class="path" id="activeMeta" data-i18n="startOrSelect">Start or select a session from the left panel</span>
|
|
3740
5063
|
</div>
|
|
3741
5064
|
<div class="quick" id="quickBar">
|
|
3742
|
-
<
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
5065
|
+
<div class="menu-wrap">
|
|
5066
|
+
<button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
|
|
5067
|
+
<div class="menu" id="actionsMenu" hidden>
|
|
5068
|
+
<button data-action="state" data-i18n="action.state">state</button>
|
|
5069
|
+
<button data-action="status" data-i18n="action.status">status</button>
|
|
5070
|
+
<button data-action="inbox" data-i18n="action.inbox">inbox</button>
|
|
5071
|
+
<button data-action="task-next" data-i18n="action.claimNextTask">claim next task</button>
|
|
5072
|
+
<button data-action="heartbeat" data-i18n="action.renewHeartbeat">renew heartbeat</button>
|
|
5073
|
+
<div class="divider" role="separator"></div>
|
|
5074
|
+
<button data-action="register" data-i18n="action.reregister">re-register peer</button>
|
|
5075
|
+
<button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
|
|
5076
|
+
</div>
|
|
5077
|
+
</div>
|
|
5078
|
+
<button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
|
|
3748
5079
|
</div>
|
|
3749
5080
|
</div>
|
|
3750
5081
|
<div id="terminal" style="min-height:0;flex:1"></div>
|
|
3751
5082
|
<div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
|
|
3752
5083
|
</main>
|
|
3753
5084
|
|
|
5085
|
+
<section class="action-result" id="actionResult" hidden aria-live="polite">
|
|
5086
|
+
<header>
|
|
5087
|
+
<strong id="actionResultTitle"></strong>
|
|
5088
|
+
<button id="actionResultClose" type="button" aria-label="Close">×</button>
|
|
5089
|
+
</header>
|
|
5090
|
+
<pre id="actionResultBody"></pre>
|
|
5091
|
+
</section>
|
|
5092
|
+
|
|
3754
5093
|
<aside class="inspector">
|
|
3755
5094
|
<div class="brand">
|
|
3756
|
-
<strong>Project State</strong>
|
|
3757
|
-
<button id="refreshBtn" type="button">Refresh</button>
|
|
5095
|
+
<strong data-i18n="projectState">Project State</strong>
|
|
5096
|
+
<button id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
|
|
3758
5097
|
</div>
|
|
3759
5098
|
<div class="state" id="state"></div>
|
|
3760
5099
|
</aside>
|
|
5100
|
+
|
|
5101
|
+
<button class="edge-toggle edge-left" id="toggleLeft" type="button" data-i18n-title="collapseSidebar" data-i18n-aria="toggleLeftSidebar" title="Collapse sidebar" aria-label="Toggle left sidebar">⟨</button>
|
|
5102
|
+
<button class="edge-toggle edge-right" id="toggleRight" type="button" data-i18n-title="collapseStatePanel" data-i18n-aria="toggleRightPanel" title="Collapse state panel" aria-label="Toggle right panel">⟩</button>
|
|
5103
|
+
<div class="edge-resizer edge-resizer-left" id="resizeLeft" role="separator" aria-orientation="vertical" data-i18n-aria="resizeLeftSidebar" data-i18n-title="resizeLeftSidebar" aria-label="Resize left sidebar" title="Resize left sidebar"></div>
|
|
5104
|
+
<div class="edge-resizer edge-resizer-right" id="resizeRight" role="separator" aria-orientation="vertical" data-i18n-aria="resizeRightPanel" data-i18n-title="resizeRightPanel" aria-label="Resize right panel" title="Resize right panel"></div>
|
|
3761
5105
|
</div>
|
|
3762
5106
|
|
|
3763
5107
|
<script src="/assets/xterm.js"></script>
|
|
@@ -3770,6 +5114,7 @@ function webIndexHtml() {
|
|
|
3770
5114
|
let sessionKindFilter = initialParams.get('kind') || 'all';
|
|
3771
5115
|
let sessions = []; // managed (PTY) sessions
|
|
3772
5116
|
let detected = []; // coordination-only peers (from hooks/watcher)
|
|
5117
|
+
let resumableCache = []; // provider sessions available to resume (from /api/resumable)
|
|
3773
5118
|
let active = null; // active managed session id
|
|
3774
5119
|
let activeDetected = null; // active detected peer id
|
|
3775
5120
|
let activeType = 'managed'; // 'managed' | 'detected'
|
|
@@ -3778,6 +5123,284 @@ function webIndexHtml() {
|
|
|
3778
5123
|
let lastStateRoot = '';
|
|
3779
5124
|
let ws = null;
|
|
3780
5125
|
let wsReconnectTimer = null;
|
|
5126
|
+
const i18n = {
|
|
5127
|
+
en: {
|
|
5128
|
+
language: 'Language',
|
|
5129
|
+
project: 'Project',
|
|
5130
|
+
projectPath: 'Project path',
|
|
5131
|
+
projectPathPlaceholder: '/path/to/project',
|
|
5132
|
+
registerProject: 'Register Project',
|
|
5133
|
+
newSession: 'New session',
|
|
5134
|
+
start: 'Start',
|
|
5135
|
+
mode: 'Mode',
|
|
5136
|
+
'mode.new': 'new',
|
|
5137
|
+
'mode.resume': 'resume',
|
|
5138
|
+
'mode.last': 'last',
|
|
5139
|
+
'mode.continue': 'continue',
|
|
5140
|
+
session: 'Session',
|
|
5141
|
+
sessionId: 'Session id',
|
|
5142
|
+
sessionIdPlaceholder: 'session id or name',
|
|
5143
|
+
sessions: 'Sessions',
|
|
5144
|
+
view: 'View',
|
|
5145
|
+
'kind.all': 'all',
|
|
5146
|
+
'kind.shell': 'shell',
|
|
5147
|
+
'kind.other': 'other',
|
|
5148
|
+
noSessionSelected: 'No session selected',
|
|
5149
|
+
startOrSelect: 'Start or select a session from the left panel',
|
|
5150
|
+
actions: 'Actions',
|
|
5151
|
+
'action.state': 'state',
|
|
5152
|
+
'action.inbox': 'inbox',
|
|
5153
|
+
'action.claimNextTask': 'claim next task',
|
|
5154
|
+
'action.status': 'status',
|
|
5155
|
+
'action.renewHeartbeat': 'renew heartbeat',
|
|
5156
|
+
'action.reregister': 're-register peer',
|
|
5157
|
+
'action.runStatusTerminal': 'run status in terminal',
|
|
5158
|
+
actionResult: 'Action Result',
|
|
5159
|
+
stop: 'stop',
|
|
5160
|
+
projectState: 'Project State',
|
|
5161
|
+
refresh: 'Refresh',
|
|
5162
|
+
managed: 'Managed',
|
|
5163
|
+
detected: 'Detected',
|
|
5164
|
+
running: 'running',
|
|
5165
|
+
noActiveSessions: 'No active sessions.',
|
|
5166
|
+
startOneAbove: 'Start one above',
|
|
5167
|
+
orRunTerminal: 'or run in any terminal:',
|
|
5168
|
+
noDetectedPeers: 'No detected peers.',
|
|
5169
|
+
viewFilter: 'View filter',
|
|
5170
|
+
noTasks: 'No tasks.',
|
|
5171
|
+
noPeers: 'No peers.',
|
|
5172
|
+
noActiveLocks: 'No active locks.',
|
|
5173
|
+
noMessages: 'No messages.',
|
|
5174
|
+
noTimelineItems: 'No timeline items.',
|
|
5175
|
+
nextAction: 'Next Action',
|
|
5176
|
+
timeline: 'Timeline',
|
|
5177
|
+
messages: 'Messages',
|
|
5178
|
+
peers: 'Peers',
|
|
5179
|
+
tasks: 'Tasks',
|
|
5180
|
+
locks: 'Locks',
|
|
5181
|
+
owner: 'owner',
|
|
5182
|
+
assignee: 'assignee',
|
|
5183
|
+
task: 'task',
|
|
5184
|
+
all: 'all',
|
|
5185
|
+
next: 'next',
|
|
5186
|
+
finish: 'finish',
|
|
5187
|
+
warnings: 'warnings',
|
|
5188
|
+
noImmediateAction: 'No immediate coordination action',
|
|
5189
|
+
detectedSession: 'Detected Session',
|
|
5190
|
+
sendMessage: 'Send Message',
|
|
5191
|
+
messageHelp: "Message will appear in the peer's <code>hcc msg inbox</code> and be injected on next hook fire.",
|
|
5192
|
+
messageBodyPlaceholder: 'Message body...',
|
|
5193
|
+
send: 'Send',
|
|
5194
|
+
peer: 'peer',
|
|
5195
|
+
kind: 'kind',
|
|
5196
|
+
status: 'status',
|
|
5197
|
+
cwd: 'cwd',
|
|
5198
|
+
pid: 'pid',
|
|
5199
|
+
unknown: 'unknown',
|
|
5200
|
+
lastSeen: 'last seen',
|
|
5201
|
+
secondsAgo: 's ago',
|
|
5202
|
+
age: 'age',
|
|
5203
|
+
branch: 'branch',
|
|
5204
|
+
runtime: 'runtime',
|
|
5205
|
+
thread: 'thread',
|
|
5206
|
+
reply: 'reply',
|
|
5207
|
+
'status.active': 'active',
|
|
5208
|
+
'status.stale': 'stale',
|
|
5209
|
+
'status.unknown': 'unknown',
|
|
5210
|
+
'status.running': 'running',
|
|
5211
|
+
'status.idle': 'idle',
|
|
5212
|
+
'status.exited': 'exited',
|
|
5213
|
+
'status.claimed': 'claimed',
|
|
5214
|
+
'status.review': 'review',
|
|
5215
|
+
'status.blocked': 'blocked',
|
|
5216
|
+
'status.done': 'done',
|
|
5217
|
+
'status.abandoned': 'abandoned',
|
|
5218
|
+
'status.pending': 'pending',
|
|
5219
|
+
'conn.offline': 'offline',
|
|
5220
|
+
'conn.online': 'online',
|
|
5221
|
+
'conn.attached': 'attached',
|
|
5222
|
+
'conn.reconnecting': 'reconnecting...',
|
|
5223
|
+
'conn.coordinationOnly': 'coordination only',
|
|
5224
|
+
'conn.error': 'error',
|
|
5225
|
+
show: 'Show',
|
|
5226
|
+
collapse: 'Collapse',
|
|
5227
|
+
sidebar: 'sidebar',
|
|
5228
|
+
statePanel: 'state panel',
|
|
5229
|
+
dragToResize: 'drag to resize',
|
|
5230
|
+
collapseSidebar: 'Collapse sidebar',
|
|
5231
|
+
collapseStatePanel: 'Collapse state panel',
|
|
5232
|
+
toggleLeftSidebar: 'Toggle left sidebar',
|
|
5233
|
+
toggleRightPanel: 'Toggle right panel',
|
|
5234
|
+
resizeLeftSidebar: 'Resize left sidebar',
|
|
5235
|
+
resizeRightPanel: 'Resize right panel',
|
|
5236
|
+
customSession: 'custom session id...'
|
|
5237
|
+
},
|
|
5238
|
+
zh: {
|
|
5239
|
+
language: '语言',
|
|
5240
|
+
project: '项目',
|
|
5241
|
+
projectPath: '项目路径',
|
|
5242
|
+
projectPathPlaceholder: '/项目/路径',
|
|
5243
|
+
registerProject: '注册项目',
|
|
5244
|
+
newSession: '新会话',
|
|
5245
|
+
start: '启动',
|
|
5246
|
+
mode: '模式',
|
|
5247
|
+
'mode.new': '新建',
|
|
5248
|
+
'mode.resume': '恢复',
|
|
5249
|
+
'mode.last': '最近',
|
|
5250
|
+
'mode.continue': '继续',
|
|
5251
|
+
session: '会话',
|
|
5252
|
+
sessionId: '会话 ID',
|
|
5253
|
+
sessionIdPlaceholder: '会话 ID 或名称',
|
|
5254
|
+
sessions: '会话',
|
|
5255
|
+
view: '视图',
|
|
5256
|
+
'kind.all': '全部',
|
|
5257
|
+
'kind.shell': 'shell',
|
|
5258
|
+
'kind.other': '其他',
|
|
5259
|
+
noSessionSelected: '未选择会话',
|
|
5260
|
+
startOrSelect: '从左侧面板启动或选择一个会话',
|
|
5261
|
+
actions: '操作',
|
|
5262
|
+
'action.state': '状态详情',
|
|
5263
|
+
'action.inbox': '收件箱',
|
|
5264
|
+
'action.claimNextTask': '认领下个任务',
|
|
5265
|
+
'action.status': '状态',
|
|
5266
|
+
'action.renewHeartbeat': '续期心跳',
|
|
5267
|
+
'action.reregister': '重新注册协作方',
|
|
5268
|
+
'action.runStatusTerminal': '在终端运行状态命令',
|
|
5269
|
+
actionResult: '操作结果',
|
|
5270
|
+
stop: '停止',
|
|
5271
|
+
projectState: '项目状态',
|
|
5272
|
+
refresh: '刷新',
|
|
5273
|
+
managed: '托管',
|
|
5274
|
+
detected: '检测到',
|
|
5275
|
+
running: '运行中',
|
|
5276
|
+
noActiveSessions: '没有活动会话。',
|
|
5277
|
+
startOneAbove: '在上方启动一个会话',
|
|
5278
|
+
orRunTerminal: '或在任意终端运行:',
|
|
5279
|
+
noDetectedPeers: '没有检测到的协作方。',
|
|
5280
|
+
viewFilter: '视图过滤',
|
|
5281
|
+
noTasks: '没有任务。',
|
|
5282
|
+
noPeers: '没有协作方。',
|
|
5283
|
+
noActiveLocks: '没有活动锁。',
|
|
5284
|
+
noMessages: '没有消息。',
|
|
5285
|
+
noTimelineItems: '没有时间线条目。',
|
|
5286
|
+
nextAction: '下一步',
|
|
5287
|
+
timeline: '时间线',
|
|
5288
|
+
messages: '消息',
|
|
5289
|
+
peers: '协作方',
|
|
5290
|
+
tasks: '任务',
|
|
5291
|
+
locks: '锁',
|
|
5292
|
+
owner: '负责人',
|
|
5293
|
+
assignee: '执行者',
|
|
5294
|
+
task: '任务',
|
|
5295
|
+
all: '全部',
|
|
5296
|
+
next: '下一步',
|
|
5297
|
+
finish: '收尾',
|
|
5298
|
+
warnings: '警告',
|
|
5299
|
+
noImmediateAction: '当前没有需要立即处理的协作动作',
|
|
5300
|
+
detectedSession: '检测到的会话',
|
|
5301
|
+
sendMessage: '发送消息',
|
|
5302
|
+
messageHelp: '消息会出现在该 peer 的 <code>hcc msg inbox</code> 中,并在下一次 hook 触发时注入。',
|
|
5303
|
+
messageBodyPlaceholder: '消息内容...',
|
|
5304
|
+
send: '发送',
|
|
5305
|
+
peer: '协作方',
|
|
5306
|
+
kind: '类型',
|
|
5307
|
+
status: '状态',
|
|
5308
|
+
cwd: '工作目录',
|
|
5309
|
+
pid: 'PID',
|
|
5310
|
+
unknown: '未知',
|
|
5311
|
+
lastSeen: '最后出现',
|
|
5312
|
+
secondsAgo: ' 秒前',
|
|
5313
|
+
age: '时长',
|
|
5314
|
+
branch: '分支',
|
|
5315
|
+
runtime: '运行时',
|
|
5316
|
+
thread: '线程',
|
|
5317
|
+
reply: '回复',
|
|
5318
|
+
'status.active': '活跃',
|
|
5319
|
+
'status.stale': '过期',
|
|
5320
|
+
'status.unknown': '未知',
|
|
5321
|
+
'status.running': '运行中',
|
|
5322
|
+
'status.idle': '空闲',
|
|
5323
|
+
'status.exited': '已退出',
|
|
5324
|
+
'status.claimed': '已认领',
|
|
5325
|
+
'status.review': '审查中',
|
|
5326
|
+
'status.blocked': '阻塞',
|
|
5327
|
+
'status.done': '完成',
|
|
5328
|
+
'status.abandoned': '已放弃',
|
|
5329
|
+
'status.pending': '待处理',
|
|
5330
|
+
'conn.offline': '离线',
|
|
5331
|
+
'conn.online': '在线',
|
|
5332
|
+
'conn.attached': '已连接',
|
|
5333
|
+
'conn.reconnecting': '重连中...',
|
|
5334
|
+
'conn.coordinationOnly': '仅协作',
|
|
5335
|
+
'conn.error': '错误',
|
|
5336
|
+
show: '显示',
|
|
5337
|
+
collapse: '折叠',
|
|
5338
|
+
sidebar: '侧栏',
|
|
5339
|
+
statePanel: '状态面板',
|
|
5340
|
+
dragToResize: '拖动调整大小',
|
|
5341
|
+
collapseSidebar: '折叠侧栏',
|
|
5342
|
+
collapseStatePanel: '折叠状态面板',
|
|
5343
|
+
toggleLeftSidebar: '切换左侧栏',
|
|
5344
|
+
toggleRightPanel: '切换右侧面板',
|
|
5345
|
+
resizeLeftSidebar: '调整左侧栏宽度',
|
|
5346
|
+
resizeRightPanel: '调整右侧面板宽度',
|
|
5347
|
+
customSession: '自定义会话 ID...'
|
|
5348
|
+
}
|
|
5349
|
+
};
|
|
5350
|
+
let lang = localStorage.getItem('hcc.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
|
|
5351
|
+
if (!i18n[lang]) lang = 'en';
|
|
5352
|
+
|
|
5353
|
+
function tr(key, fallback = '') {
|
|
5354
|
+
return i18n[lang]?.[key] || i18n.en[key] || fallback || key;
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
function setText(id, key) {
|
|
5358
|
+
const el = document.getElementById(id);
|
|
5359
|
+
if (el) el.textContent = tr(key);
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5362
|
+
function connText(key) {
|
|
5363
|
+
const el = document.getElementById('connState');
|
|
5364
|
+
if (el) {
|
|
5365
|
+
el.dataset.stateKey = key;
|
|
5366
|
+
el.textContent = tr('conn.' + key);
|
|
5367
|
+
}
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5370
|
+
function applyLanguage() {
|
|
5371
|
+
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
|
|
5372
|
+
localStorage.setItem('hcc.lang', lang);
|
|
5373
|
+
const sel = document.getElementById('langSelect');
|
|
5374
|
+
if (sel) sel.value = lang;
|
|
5375
|
+
document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = tr(el.dataset.i18n); });
|
|
5376
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = tr(el.dataset.i18nPlaceholder); });
|
|
5377
|
+
document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = tr(el.dataset.i18nTitle); });
|
|
5378
|
+
document.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', tr(el.dataset.i18nAria)); });
|
|
5379
|
+
if (!active && !activeDetected) {
|
|
5380
|
+
setText('activeTitle', 'noSessionSelected');
|
|
5381
|
+
setText('activeMeta', 'startOrSelect');
|
|
5382
|
+
}
|
|
5383
|
+
const stateKey = document.getElementById('connState')?.dataset.stateKey || 'offline';
|
|
5384
|
+
connText(stateKey);
|
|
5385
|
+
syncStartModeOptions();
|
|
5386
|
+
renderSections();
|
|
5387
|
+
if (activeType === 'detected' && activeDetected) {
|
|
5388
|
+
const draft = document.getElementById('detMsg')?.value || '';
|
|
5389
|
+
const peer = detected.find((p) => p.id === activeDetected) || { id: activeDetected };
|
|
5390
|
+
document.getElementById('activeTitle').textContent = activeDetected + ' (' + tr('detected') + ')';
|
|
5391
|
+
renderDetectedPanel(peer);
|
|
5392
|
+
const detMsg = document.getElementById('detMsg');
|
|
5393
|
+
if (detMsg && draft) detMsg.value = draft;
|
|
5394
|
+
} else if (activeType === 'managed' && active) {
|
|
5395
|
+
const meta = sessions.find((s) => s.id === active);
|
|
5396
|
+
if (meta) {
|
|
5397
|
+
document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
|
|
5398
|
+
document.getElementById('activeMeta').textContent = sessionMetaText(meta);
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
if (lastStateRoot) refreshCurrentState().catch(console.error);
|
|
5402
|
+
syncToggleIcons();
|
|
5403
|
+
}
|
|
3781
5404
|
|
|
3782
5405
|
function requestQuery(extra = {}) {
|
|
3783
5406
|
const params = new URLSearchParams();
|
|
@@ -3862,6 +5485,50 @@ function webIndexHtml() {
|
|
|
3862
5485
|
return sessionKindFilter === 'all' || kind === sessionKindFilter;
|
|
3863
5486
|
}
|
|
3864
5487
|
|
|
5488
|
+
function syncStartModeOptions() {
|
|
5489
|
+
const kind = document.getElementById('kind').value;
|
|
5490
|
+
const modeSelect = document.getElementById('startMode');
|
|
5491
|
+
const current = modeSelect.value;
|
|
5492
|
+
const modes = kind === 'claude'
|
|
5493
|
+
? [['new', 'new'], ['resume', 'resume'], ['continue', 'continue']]
|
|
5494
|
+
: kind === 'codex'
|
|
5495
|
+
? [['new', 'new'], ['resume', 'resume'], ['last', 'last']]
|
|
5496
|
+
: [['new', 'new']];
|
|
5497
|
+
modeSelect.innerHTML = modes.map(([value]) => '<option value="' + value + '">' + tr('mode.' + value, value) + '</option>').join('');
|
|
5498
|
+
modeSelect.value = modes.some(([value]) => value === current) ? current : 'new';
|
|
5499
|
+
const isResume = modeSelect.value === 'resume';
|
|
5500
|
+
document.querySelector('[data-resume-field]:not([data-resume-custom])').style.display = isResume ? '' : 'none';
|
|
5501
|
+
if (isResume) loadResumable();
|
|
5502
|
+
else document.querySelector('[data-resume-custom]').style.display = 'none';
|
|
5503
|
+
}
|
|
5504
|
+
|
|
5505
|
+
// Fetch provider sessions hcc knows about and fill the resume dropdown.
|
|
5506
|
+
async function loadResumable() {
|
|
5507
|
+
try { const d = await api('/api/resumable'); resumableCache = d.resumable || []; }
|
|
5508
|
+
catch { resumableCache = []; }
|
|
5509
|
+
populateResumeSelect();
|
|
5510
|
+
}
|
|
5511
|
+
function populateResumeSelect() {
|
|
5512
|
+
const kind = document.getElementById('kind').value;
|
|
5513
|
+
const sel = document.getElementById('resumeSelect');
|
|
5514
|
+
const prev = sel.value;
|
|
5515
|
+
const items = resumableCache.filter((r) => r.provider === kind);
|
|
5516
|
+
const opts = items.map((r) => {
|
|
5517
|
+
const resume = r.resume || r.session_id || r.session_name || '';
|
|
5518
|
+
const shortResume = resume.length > 14 ? resume.slice(0, 10) + '…' : resume;
|
|
5519
|
+
const label = (r.name && r.name !== resume ? r.name + ' · ' : '') + shortResume + ' (' + r.peer + ')';
|
|
5520
|
+
return '<option value="' + esc(resume) + '">' + esc(label) + '</option>';
|
|
5521
|
+
});
|
|
5522
|
+
opts.push('<option value="__custom__">' + esc(tr('customSession')) + '</option>');
|
|
5523
|
+
sel.innerHTML = opts.join('');
|
|
5524
|
+
if (prev && [...sel.options].some((o) => o.value === prev)) sel.value = prev;
|
|
5525
|
+
toggleResumeCustom();
|
|
5526
|
+
}
|
|
5527
|
+
function toggleResumeCustom() {
|
|
5528
|
+
const sel = document.getElementById('resumeSelect');
|
|
5529
|
+
document.querySelector('[data-resume-custom]').style.display = sel.value === '__custom__' ? '' : 'none';
|
|
5530
|
+
}
|
|
5531
|
+
|
|
3865
5532
|
async function loadProjects() {
|
|
3866
5533
|
const data = await api('/api/projects');
|
|
3867
5534
|
projects = data.projects || [];
|
|
@@ -3878,8 +5545,8 @@ function webIndexHtml() {
|
|
|
3878
5545
|
activeType = 'managed';
|
|
3879
5546
|
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
3880
5547
|
term.reset();
|
|
3881
|
-
document.getElementById('activeTitle').textContent = '
|
|
3882
|
-
document.getElementById('activeMeta').textContent = '
|
|
5548
|
+
document.getElementById('activeTitle').textContent = tr('noSessionSelected');
|
|
5549
|
+
document.getElementById('activeMeta').textContent = tr('startOrSelect');
|
|
3883
5550
|
await Promise.all([refreshSessions(), refreshDetected(), refreshState()]);
|
|
3884
5551
|
const first = sessions.find(s => s.status === 'running');
|
|
3885
5552
|
if (first) connectManaged(first.id);
|
|
@@ -3900,7 +5567,30 @@ function webIndexHtml() {
|
|
|
3900
5567
|
|
|
3901
5568
|
function fmtAge(age) {
|
|
3902
5569
|
const n = Number(age);
|
|
3903
|
-
|
|
5570
|
+
if (!Number.isFinite(n)) return '?';
|
|
5571
|
+
return Math.max(0, Math.round(n)) + (lang === 'zh' ? '秒' : 's');
|
|
5572
|
+
}
|
|
5573
|
+
|
|
5574
|
+
function sessionPeerId(session) {
|
|
5575
|
+
return session?.peer_id || session?.id || '';
|
|
5576
|
+
}
|
|
5577
|
+
|
|
5578
|
+
function sessionRuntimeNote(session) {
|
|
5579
|
+
const peerId = sessionPeerId(session);
|
|
5580
|
+
return session?.id && peerId && session.id !== peerId ? tr('runtime') + '=' + session.id + ' · ' : '';
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
function sessionMetaText(session) {
|
|
5584
|
+
return sessionRuntimeNote(session) + (session.command || '') + (session.pane ? ' · ' + session.pane : '') + ' · ' + (session.cwd || '');
|
|
5585
|
+
}
|
|
5586
|
+
|
|
5587
|
+
function statusText(status) {
|
|
5588
|
+
const value = String(status || 'unknown');
|
|
5589
|
+
return tr('status.' + value.toLowerCase(), value);
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
function managedPeerId(id) {
|
|
5593
|
+
return sessionPeerId(sessions.find((s) => s.id === id)) || id;
|
|
3904
5594
|
}
|
|
3905
5595
|
|
|
3906
5596
|
function peerIsActive(peer, basisNow = lastStateNow) {
|
|
@@ -3915,28 +5605,83 @@ function webIndexHtml() {
|
|
|
3915
5605
|
const activity = peer?.status || 'unknown';
|
|
3916
5606
|
const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
|
|
3917
5607
|
const age = fmtAge(peer?.age_sec);
|
|
3918
|
-
const branch = peer?.branch ? ' branch=' + peer.branch : '';
|
|
5608
|
+
const branch = peer?.branch ? ' ' + tr('branch') + '=' + peer.branch : '';
|
|
3919
5609
|
if (runtime) {
|
|
3920
5610
|
return {
|
|
3921
5611
|
label: runtime.status || 'running',
|
|
3922
|
-
detail: 'peer=' + activity + ' ' + liveness + ' age=' + age + branch
|
|
5612
|
+
detail: tr('peer') + '=' + statusText(activity) + ' ' + statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
3923
5613
|
};
|
|
3924
5614
|
}
|
|
3925
5615
|
if (liveness === 'stale') {
|
|
3926
5616
|
return {
|
|
3927
5617
|
label: 'stale',
|
|
3928
|
-
detail: '
|
|
5618
|
+
detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
|
|
3929
5619
|
};
|
|
3930
5620
|
}
|
|
3931
5621
|
return {
|
|
3932
5622
|
label: activity,
|
|
3933
|
-
detail: liveness + ' age=' + age + branch
|
|
5623
|
+
detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
3934
5624
|
};
|
|
3935
5625
|
}
|
|
3936
5626
|
|
|
5627
|
+
function bodyPinned(el) {
|
|
5628
|
+
if (!el) return false;
|
|
5629
|
+
return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
function stateCardCollapsed(section) {
|
|
5633
|
+
return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
|
|
5634
|
+
}
|
|
5635
|
+
|
|
5636
|
+
function stateCardHtml(section, title, count, bodyHtml) {
|
|
5637
|
+
const collapsed = stateCardCollapsed(section);
|
|
5638
|
+
return \`
|
|
5639
|
+
<div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
|
|
5640
|
+
<button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
|
|
5641
|
+
<span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
|
|
5642
|
+
<span class="state-card-chevron">⌄</span>
|
|
5643
|
+
</button>
|
|
5644
|
+
<div class="body">\${bodyHtml}</div>
|
|
5645
|
+
</div>\`;
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
function bindStateCardToggles() {
|
|
5649
|
+
document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
|
|
5650
|
+
button.addEventListener('click', () => {
|
|
5651
|
+
const card = button.closest('.state-card[data-section]');
|
|
5652
|
+
if (!card) return;
|
|
5653
|
+
const section = card.dataset.section || '';
|
|
5654
|
+
const collapsed = !card.classList.contains('state-card-collapsed');
|
|
5655
|
+
card.classList.toggle('state-card-collapsed', collapsed);
|
|
5656
|
+
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
5657
|
+
localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
|
|
5658
|
+
});
|
|
5659
|
+
});
|
|
5660
|
+
}
|
|
5661
|
+
|
|
5662
|
+
function renderTimelineItem(item) {
|
|
5663
|
+
const meta = [
|
|
5664
|
+
item.source + ':' + item.source_id,
|
|
5665
|
+
item.task_id ? tr('task') + ' #' + item.task_id : '',
|
|
5666
|
+
item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
|
|
5667
|
+
item.direction || '',
|
|
5668
|
+
fmtTime(item.ts)
|
|
5669
|
+
].filter(Boolean).join(' · ');
|
|
5670
|
+
return \`
|
|
5671
|
+
<div class="item timeline-item">
|
|
5672
|
+
<strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
|
|
5673
|
+
<span>\${esc(meta)}</span>
|
|
5674
|
+
\${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
|
|
5675
|
+
</div>\`;
|
|
5676
|
+
}
|
|
5677
|
+
|
|
3937
5678
|
document.getElementById('projectSelect').addEventListener('change', (event) => {
|
|
3938
5679
|
switchProject(event.target.value).catch(console.error);
|
|
3939
5680
|
});
|
|
5681
|
+
document.getElementById('langSelect').addEventListener('change', (event) => {
|
|
5682
|
+
lang = event.target.value === 'zh' ? 'zh' : 'en';
|
|
5683
|
+
applyLanguage();
|
|
5684
|
+
});
|
|
3940
5685
|
document.getElementById('sessionKindFilter').addEventListener('change', (event) => {
|
|
3941
5686
|
sessionKindFilter = event.target.value || 'all';
|
|
3942
5687
|
updateLocationProject();
|
|
@@ -3957,33 +5702,36 @@ function webIndexHtml() {
|
|
|
3957
5702
|
const box = document.getElementById('sessions');
|
|
3958
5703
|
const visibleSessions = sessions.filter(kindMatches);
|
|
3959
5704
|
const visibleDetected = detected.filter(kindMatches);
|
|
3960
|
-
const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>
|
|
5705
|
+
const filterNote = sessionKindFilter === 'all' ? '' : '<br><br>' + esc(tr('viewFilter')) + ': ' + esc(sessionKindFilter);
|
|
3961
5706
|
const manHtml = visibleSessions.length
|
|
3962
|
-
? visibleSessions.map((s) =>
|
|
5707
|
+
? visibleSessions.map((s) => {
|
|
5708
|
+
const peerId = sessionPeerId(s);
|
|
5709
|
+
return \`
|
|
3963
5710
|
<div class="session \${active === s.id && activeType === 'managed' ? 'active' : ''}" data-id="\${esc(s.id)}" data-type="managed">
|
|
3964
|
-
<div class="row"><strong>\${esc(
|
|
5711
|
+
<div class="row"><strong>\${esc(peerId)}</strong><span class="badge \${badgeClass(s.status)}">\${esc(statusText(s.status))}</span></div>
|
|
3965
5712
|
<div class="row"><span class="badge">\${esc(s.kind)}</span><span class="badge \${s.type === 'external' || s.type === 'tmux' ? 'warn' : ''}">\${s.type === 'external' ? 'external' : s.type === 'tmux' ? 'tmux' : 'pty'}</span></div>
|
|
3966
|
-
<div class="path">\${esc(s.command)}</div>
|
|
3967
|
-
</div
|
|
3968
|
-
|
|
5713
|
+
<div class="path">\${esc(sessionRuntimeNote(s) + (s.command || ''))}</div>
|
|
5714
|
+
</div>\`;
|
|
5715
|
+
}).join('')
|
|
5716
|
+
: '<div class="empty">' + esc(tr('noActiveSessions')) + filterNote + '<br><br>' + esc(tr('startOneAbove')) + '<br>' + esc(tr('orRunTerminal')) + '<br><code>hcc peer start X -- claude</code></div>';
|
|
3969
5717
|
|
|
3970
5718
|
const detHtml = visibleDetected.length
|
|
3971
5719
|
? visibleDetected.map((p) => {
|
|
3972
5720
|
const state = peerStateView(p);
|
|
3973
5721
|
return \`
|
|
3974
5722
|
<div class="session \${activeDetected === p.id && activeType === 'detected' ? 'active' : ''}" data-id="\${esc(p.id)}" data-type="detected">
|
|
3975
|
-
<div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20"
|
|
3976
|
-
<div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(state.label)}</span></div>
|
|
5723
|
+
<div class="row"><strong>\${esc(p.id)}</strong><span class="badge" style="color:var(--warn);border-color:#6b5a20">\${esc(tr('detected'))}</span></div>
|
|
5724
|
+
<div class="row"><span class="badge">\${esc(p.kind)}</span><span class="badge \${badgeClass(state.label)}" title="\${esc(state.detail)}">\${esc(statusText(state.label))}</span></div>
|
|
3977
5725
|
<div class="path" title="\${esc(p.worktree || '')}">\${esc((p.worktree || '').split('/').slice(-2).join('/'))}</div>
|
|
3978
5726
|
</div>\`;
|
|
3979
5727
|
}).join('')
|
|
3980
|
-
: '<div class="empty">
|
|
5728
|
+
: '<div class="empty">' + esc(tr('noDetectedPeers')) + filterNote + '</div>';
|
|
3981
5729
|
|
|
3982
5730
|
const savedScroll = box.scrollTop;
|
|
3983
5731
|
box.innerHTML = \`
|
|
3984
|
-
<div class="sec-label"
|
|
5732
|
+
<div class="sec-label">\${esc(tr('managed'))} <span class="badge">\${visibleSessions.filter(s=>s.status==='running').length} \${esc(tr('running'))}</span></div>
|
|
3985
5733
|
\${manHtml}
|
|
3986
|
-
<div class="sec-label" style="margin-top:10px"
|
|
5734
|
+
<div class="sec-label" style="margin-top:10px">\${esc(tr('detected'))} <span class="badge" style="color:var(--warn)">\${visibleDetected.length}</span></div>
|
|
3987
5735
|
\${detHtml}
|
|
3988
5736
|
\`;
|
|
3989
5737
|
box.scrollTop = savedScroll;
|
|
@@ -3999,7 +5747,14 @@ function webIndexHtml() {
|
|
|
3999
5747
|
const data = await api('/api/sessions');
|
|
4000
5748
|
sessions = data.sessions || [];
|
|
4001
5749
|
renderSections();
|
|
4002
|
-
|
|
5750
|
+
if (active && activeType === 'managed') {
|
|
5751
|
+
const meta = sessions.find((s) => s.id === active);
|
|
5752
|
+
if (meta) {
|
|
5753
|
+
document.getElementById('activeTitle').textContent = sessionPeerId(meta) || active;
|
|
5754
|
+
document.getElementById('activeMeta').textContent = sessionMetaText(meta);
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
5757
|
+
connText('online');
|
|
4003
5758
|
}
|
|
4004
5759
|
|
|
4005
5760
|
async function refreshDetected() {
|
|
@@ -4025,52 +5780,71 @@ function webIndexHtml() {
|
|
|
4025
5780
|
? new Map(
|
|
4026
5781
|
[...state.querySelectorAll('.state-card[data-section]')].map((card) => [
|
|
4027
5782
|
card.dataset.section,
|
|
4028
|
-
|
|
5783
|
+
{
|
|
5784
|
+
top: card.querySelector('.body')?.scrollTop || 0,
|
|
5785
|
+
pinned: bodyPinned(card.querySelector('.body'))
|
|
5786
|
+
}
|
|
4029
5787
|
])
|
|
4030
5788
|
)
|
|
4031
5789
|
: new Map();
|
|
4032
|
-
const runtimeById = new Map(
|
|
5790
|
+
const runtimeById = new Map();
|
|
5791
|
+
for (const session of sessions || []) {
|
|
5792
|
+
runtimeById.set(session.id, session);
|
|
5793
|
+
const peerId = sessionPeerId(session);
|
|
5794
|
+
if (peerId) runtimeById.set(peerId, session);
|
|
5795
|
+
}
|
|
4033
5796
|
const tasksData = data.tasks || [];
|
|
4034
5797
|
const peersData = data.peers || [];
|
|
4035
5798
|
const locksData = data.locks || [];
|
|
4036
5799
|
const messagesData = data.messages || [];
|
|
4037
|
-
const
|
|
5800
|
+
const timelineData = data.timeline || [];
|
|
5801
|
+
const automation = data.automation || {};
|
|
5802
|
+
const nextAction = automation.next_action || {};
|
|
4038
5803
|
const tasks = tasksData.map((t) => \`
|
|
4039
|
-
|
|
4040
|
-
|
|
5804
|
+
<div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}</span></div>
|
|
5805
|
+
\`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
|
|
4041
5806
|
const peers = peersData.map((a) => {
|
|
4042
5807
|
const peerRuntime = runtimeById.get(a.id);
|
|
4043
5808
|
const peerState = peerStateView(a, peerRuntime, data.now);
|
|
4044
5809
|
return \`
|
|
4045
|
-
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(peerState.label)}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
5810
|
+
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
4046
5811
|
\`;
|
|
4047
|
-
}).join('') || '<div class="empty">
|
|
5812
|
+
}).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
|
|
4048
5813
|
const locks = locksData.map((l) => \`
|
|
4049
|
-
|
|
4050
|
-
|
|
5814
|
+
<div class="item"><strong>\${esc(l.resource)}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
|
|
5815
|
+
\`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
|
|
4051
5816
|
const messages = messagesData.map((m) => \`
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
const
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
<div class="
|
|
4059
|
-
<div class="
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
5817
|
+
<div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
|
|
5818
|
+
\`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
|
|
5819
|
+
const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
|
|
5820
|
+
const actionLines = [
|
|
5821
|
+
'<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
|
|
5822
|
+
nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
|
|
5823
|
+
(automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
|
|
5824
|
+
(automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
|
|
5825
|
+
].filter(Boolean).join('');
|
|
5826
|
+
state.innerHTML = [
|
|
5827
|
+
stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
|
|
5828
|
+
stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
|
|
5829
|
+
stateCardHtml('messages', tr('messages'), messagesData.length, messages),
|
|
5830
|
+
stateCardHtml('peers', tr('peers'), peersData.length, peers),
|
|
5831
|
+
stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
|
|
5832
|
+
stateCardHtml('locks', tr('locks'), locksData.length, locks)
|
|
5833
|
+
].join('');
|
|
4064
5834
|
state.scrollTop = savedStateScroll;
|
|
4065
|
-
for (const [section,
|
|
5835
|
+
for (const [section, saved] of savedCardScroll) {
|
|
4066
5836
|
const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
|
|
4067
|
-
if (body)
|
|
5837
|
+
if (!body) continue;
|
|
5838
|
+
if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
|
|
5839
|
+
else body.scrollTop = saved.top;
|
|
4068
5840
|
}
|
|
5841
|
+
bindStateCardToggles();
|
|
4069
5842
|
lastStateRoot = stateRoot;
|
|
4070
5843
|
}
|
|
4071
5844
|
|
|
4072
5845
|
async function refreshState() {
|
|
4073
|
-
const
|
|
5846
|
+
const peer = active ? managedPeerId(active) : null;
|
|
5847
|
+
const p = peer ? '/api/state?peer=' + encodeURIComponent(peer) : '/api/state';
|
|
4074
5848
|
const data = await api(p);
|
|
4075
5849
|
renderState(data);
|
|
4076
5850
|
}
|
|
@@ -4081,15 +5855,21 @@ function webIndexHtml() {
|
|
|
4081
5855
|
renderState(data);
|
|
4082
5856
|
}
|
|
4083
5857
|
|
|
5858
|
+
async function refreshCurrentState() {
|
|
5859
|
+
if (activeType === 'detected') return refreshDetectedState();
|
|
5860
|
+
return refreshState();
|
|
5861
|
+
}
|
|
5862
|
+
|
|
4084
5863
|
// ── Connect to managed (PTY) session ─────────────────────────────────
|
|
4085
5864
|
function connectManaged(id) {
|
|
4086
5865
|
const meta = sessions.find((s) => s.id === id);
|
|
5866
|
+
const peerId = sessionPeerId(meta) || id;
|
|
4087
5867
|
active = id;
|
|
4088
5868
|
activeDetected = null;
|
|
4089
5869
|
activeType = 'managed';
|
|
4090
5870
|
renderSections();
|
|
4091
|
-
document.getElementById('activeTitle').textContent =
|
|
4092
|
-
document.getElementById('activeMeta').textContent = meta ?
|
|
5871
|
+
document.getElementById('activeTitle').textContent = peerId;
|
|
5872
|
+
document.getElementById('activeMeta').textContent = meta ? sessionMetaText(meta) : '';
|
|
4093
5873
|
document.getElementById('terminal').style.display = '';
|
|
4094
5874
|
document.getElementById('detectedPanel').style.display = 'none';
|
|
4095
5875
|
document.getElementById('quickBar').style.display = '';
|
|
@@ -4105,7 +5885,7 @@ function webIndexHtml() {
|
|
|
4105
5885
|
ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
|
|
4106
5886
|
ws.onopen = () => {
|
|
4107
5887
|
resizeTerm();
|
|
4108
|
-
|
|
5888
|
+
connText('attached');
|
|
4109
5889
|
};
|
|
4110
5890
|
ws.onmessage = (event) => {
|
|
4111
5891
|
const msg = JSON.parse(event.data);
|
|
@@ -4122,12 +5902,12 @@ function webIndexHtml() {
|
|
|
4122
5902
|
if (msg.type === 'exit') { refreshSessions().catch(console.error); }
|
|
4123
5903
|
};
|
|
4124
5904
|
ws.onclose = () => {
|
|
4125
|
-
|
|
5905
|
+
connText('reconnecting');
|
|
4126
5906
|
// Auto-reconnect if session is still in the list and running
|
|
4127
5907
|
wsReconnectTimer = setTimeout(() => {
|
|
4128
5908
|
const s = sessions.find((s) => s.id === id);
|
|
4129
5909
|
if (s && s.status === 'running' && active === id) openWs(id);
|
|
4130
|
-
else
|
|
5910
|
+
else connText('online');
|
|
4131
5911
|
}, 2000);
|
|
4132
5912
|
};
|
|
4133
5913
|
}
|
|
@@ -4140,12 +5920,12 @@ function webIndexHtml() {
|
|
|
4140
5920
|
activeType = 'detected';
|
|
4141
5921
|
if (ws) { clearTimeout(wsReconnectTimer); ws.close(); ws = null; }
|
|
4142
5922
|
renderSections();
|
|
4143
|
-
document.getElementById('activeTitle').textContent = id + ' (detected)';
|
|
5923
|
+
document.getElementById('activeTitle').textContent = id + ' (' + tr('detected') + ')';
|
|
4144
5924
|
document.getElementById('activeMeta').textContent = peer ? peer.kind + ' · ' + (peer.worktree || '') : '';
|
|
4145
5925
|
document.getElementById('terminal').style.display = 'none';
|
|
4146
5926
|
document.getElementById('detectedPanel').style.display = '';
|
|
4147
5927
|
document.getElementById('quickBar').style.display = 'none';
|
|
4148
|
-
|
|
5928
|
+
connText('coordinationOnly');
|
|
4149
5929
|
renderDetectedPanel(peer || { id });
|
|
4150
5930
|
refreshDetectedState().catch(console.error);
|
|
4151
5931
|
}
|
|
@@ -4155,22 +5935,22 @@ function webIndexHtml() {
|
|
|
4155
5935
|
dp.innerHTML = \`
|
|
4156
5936
|
<div style="padding:16px;display:grid;gap:12px">
|
|
4157
5937
|
<div class="card">
|
|
4158
|
-
<h2
|
|
5938
|
+
<h2>\${esc(tr('detectedSession'))}</h2>
|
|
4159
5939
|
<div class="body">
|
|
4160
|
-
<div class="item"><strong
|
|
4161
|
-
<div class="item"><strong
|
|
4162
|
-
<div class="item"><strong
|
|
4163
|
-
<div class="item"><strong
|
|
4164
|
-
<div class="item"><strong
|
|
4165
|
-
<div class="item"><strong
|
|
5940
|
+
<div class="item"><strong>\${esc(tr('peer'))}</strong><span class="mono">\${esc(peer.id)}</span></div>
|
|
5941
|
+
<div class="item"><strong>\${esc(tr('kind'))}</strong><span>\${esc(peer.kind || '')}</span></div>
|
|
5942
|
+
<div class="item"><strong>\${esc(tr('status'))}</strong><span>\${esc(peer.status || '')}</span></div>
|
|
5943
|
+
<div class="item"><strong>\${esc(tr('cwd'))}</strong><span class="mono" style="font-size:11px">\${esc(peer.worktree || '')}</span></div>
|
|
5944
|
+
<div class="item"><strong>\${esc(tr('pid'))}</strong><span>\${esc(peer.pid || tr('unknown'))}</span></div>
|
|
5945
|
+
<div class="item"><strong>\${esc(tr('lastSeen'))}</strong><span>\${peer.age_sec != null ? esc(peer.age_sec + tr('secondsAgo')) : ''}</span></div>
|
|
4166
5946
|
</div>
|
|
4167
5947
|
</div>
|
|
4168
5948
|
<div class="card">
|
|
4169
|
-
<h2
|
|
5949
|
+
<h2>\${esc(tr('sendMessage'))}</h2>
|
|
4170
5950
|
<div class="body" style="gap:8px">
|
|
4171
|
-
<div style="font-size:12px;color:var(--muted)"
|
|
4172
|
-
<textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="
|
|
4173
|
-
<button class="primary" id="sendDetMsg"
|
|
5951
|
+
<div style="font-size:12px;color:var(--muted)">\${tr('messageHelp')}</div>
|
|
5952
|
+
<textarea id="detMsg" rows="3" style="width:100%;background:#0d0f12;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;font:inherit;resize:vertical" placeholder="\${esc(tr('messageBodyPlaceholder'))}"></textarea>
|
|
5953
|
+
<button class="primary" id="sendDetMsg">\${esc(tr('send'))}</button>
|
|
4174
5954
|
</div>
|
|
4175
5955
|
</div>
|
|
4176
5956
|
</div>
|
|
@@ -4181,10 +5961,11 @@ function webIndexHtml() {
|
|
|
4181
5961
|
await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
|
|
4182
5962
|
method: 'POST',
|
|
4183
5963
|
body: JSON.stringify({ body, from: 'web' })
|
|
5964
|
+
});
|
|
5965
|
+
document.getElementById('detMsg').value = '';
|
|
5966
|
+
await refreshDetectedState();
|
|
4184
5967
|
});
|
|
4185
|
-
|
|
4186
|
-
});
|
|
4187
|
-
}
|
|
5968
|
+
}
|
|
4188
5969
|
|
|
4189
5970
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
4190
5971
|
function sendLine(text) {
|
|
@@ -4193,40 +5974,291 @@ function webIndexHtml() {
|
|
|
4193
5974
|
}
|
|
4194
5975
|
|
|
4195
5976
|
// ── Start session form ────────────────────────────────────────────────
|
|
5977
|
+
document.getElementById('kind').addEventListener('change', syncStartModeOptions);
|
|
5978
|
+
document.getElementById('startMode').addEventListener('change', syncStartModeOptions);
|
|
5979
|
+
document.getElementById('resumeSelect').addEventListener('change', toggleResumeCustom);
|
|
5980
|
+
syncStartModeOptions();
|
|
5981
|
+
|
|
4196
5982
|
document.getElementById('startForm').addEventListener('submit', async (event) => {
|
|
4197
5983
|
event.preventDefault();
|
|
4198
|
-
const
|
|
4199
|
-
|
|
4200
|
-
|
|
5984
|
+
const kind = document.getElementById('kind').value;
|
|
5985
|
+
const mode = document.getElementById('startMode').value;
|
|
5986
|
+
const sel = document.getElementById('resumeSelect');
|
|
5987
|
+
const resume = (sel.value && sel.value !== '__custom__')
|
|
5988
|
+
? sel.value
|
|
5989
|
+
: document.getElementById('resumeArg').value.trim();
|
|
5990
|
+
if (mode === 'resume' && !resume) return;
|
|
5991
|
+
const payload = { kind, mode };
|
|
5992
|
+
if (mode === 'resume') payload.resume = resume;
|
|
4201
5993
|
const data = await api('/api/sessions', { method: 'POST', body: JSON.stringify(payload) });
|
|
4202
5994
|
await refreshSessions();
|
|
4203
5995
|
connectManaged(data.session.id);
|
|
4204
5996
|
});
|
|
4205
5997
|
|
|
4206
|
-
|
|
5998
|
+
function activePeerInfo() {
|
|
5999
|
+
if (!active) return null;
|
|
6000
|
+
const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
|
|
6001
|
+
return {
|
|
6002
|
+
session,
|
|
6003
|
+
peerId: sessionPeerId(session) || active
|
|
6004
|
+
};
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
function terminalCommandForAction(action, info) {
|
|
6008
|
+
const session = info.session || {};
|
|
6009
|
+
const peerId = info.peerId;
|
|
6010
|
+
const lines = {
|
|
6011
|
+
register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
|
|
6012
|
+
inbox: \`hcc msg inbox --peer \${peerId}\`,
|
|
6013
|
+
'task-next': \`hcc task next --peer \${peerId}\`,
|
|
6014
|
+
state: \`hcc state --peer \${peerId}\`,
|
|
6015
|
+
status: \`hcc status --peer \${peerId}\`,
|
|
6016
|
+
heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
|
|
6017
|
+
};
|
|
6018
|
+
return lines[action] || lines.status;
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
function formatActionResult(result) {
|
|
6022
|
+
if (!result) return '';
|
|
6023
|
+
const data = result.data || {};
|
|
6024
|
+
if (result.action === 'status') {
|
|
6025
|
+
const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
|
|
6026
|
+
return [
|
|
6027
|
+
data.root,
|
|
6028
|
+
'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
|
|
6029
|
+
'tasks ' + tasks,
|
|
6030
|
+
'locks active=' + data.active_locks,
|
|
6031
|
+
'unread ' + (data.unread ?? 0)
|
|
6032
|
+
].filter(Boolean).join('\\n');
|
|
6033
|
+
}
|
|
6034
|
+
if (result.action === 'state') {
|
|
6035
|
+
const automation = data.automation || {};
|
|
6036
|
+
const next = automation.next_action || {};
|
|
6037
|
+
return [
|
|
6038
|
+
'phase: ' + (automation.phase || 'idle'),
|
|
6039
|
+
'next: ' + (next.command || next.kind || 'none'),
|
|
6040
|
+
'why: ' + (next.reason || tr('noImmediateAction')),
|
|
6041
|
+
...(automation.warnings || []).map((w) => 'warning: ' + w)
|
|
6042
|
+
].join('\\n');
|
|
6043
|
+
}
|
|
6044
|
+
if (result.action === 'inbox') {
|
|
6045
|
+
const messages = data.messages || [];
|
|
6046
|
+
return messages.length
|
|
6047
|
+
? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
|
|
6048
|
+
: tr('noMessages');
|
|
6049
|
+
}
|
|
6050
|
+
if (result.action === 'task-next') {
|
|
6051
|
+
return data.task
|
|
6052
|
+
? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
|
|
6053
|
+
: result.summary;
|
|
6054
|
+
}
|
|
6055
|
+
return result.summary || JSON.stringify(result, null, 2);
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
function showActionResult(result) {
|
|
6059
|
+
const panel = document.getElementById('actionResult');
|
|
6060
|
+
document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
|
|
6061
|
+
document.getElementById('actionResultBody').textContent = formatActionResult(result);
|
|
6062
|
+
panel.hidden = false;
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
async function runPeerAction(action) {
|
|
6066
|
+
const info = activePeerInfo();
|
|
6067
|
+
if (!info) return;
|
|
6068
|
+
const payload = action === 'register'
|
|
6069
|
+
? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
|
|
6070
|
+
: action === 'heartbeat'
|
|
6071
|
+
? { renew_locks: true }
|
|
6072
|
+
: {};
|
|
6073
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
6074
|
+
const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
|
|
6075
|
+
? {}
|
|
6076
|
+
: { method: 'POST', body: JSON.stringify(payload) });
|
|
6077
|
+
showActionResult(result);
|
|
6078
|
+
await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
|
|
6079
|
+
}
|
|
6080
|
+
|
|
6081
|
+
document.querySelectorAll('[data-action]').forEach((button) => {
|
|
4207
6082
|
button.addEventListener('click', () => {
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
6083
|
+
closeActionsMenu();
|
|
6084
|
+
runPeerAction(button.dataset.action).catch((err) => {
|
|
6085
|
+
showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
|
|
6086
|
+
});
|
|
6087
|
+
});
|
|
6088
|
+
});
|
|
6089
|
+
document.querySelectorAll('[data-terminal-action]').forEach((button) => {
|
|
6090
|
+
button.addEventListener('click', () => {
|
|
6091
|
+
const info = activePeerInfo();
|
|
6092
|
+
if (!info) return;
|
|
6093
|
+
sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
|
|
6094
|
+
closeActionsMenu();
|
|
4218
6095
|
});
|
|
4219
6096
|
});
|
|
6097
|
+
document.getElementById('actionResultClose').addEventListener('click', () => {
|
|
6098
|
+
document.getElementById('actionResult').hidden = true;
|
|
6099
|
+
});
|
|
6100
|
+
|
|
6101
|
+
// ── Actions dropdown (declutters the toolbar) ─────────────────────────
|
|
6102
|
+
const actionsBtn = document.getElementById('actionsBtn');
|
|
6103
|
+
const actionsMenu = document.getElementById('actionsMenu');
|
|
6104
|
+
function closeActionsMenu() {
|
|
6105
|
+
actionsMenu.hidden = true;
|
|
6106
|
+
actionsBtn.setAttribute('aria-expanded', 'false');
|
|
6107
|
+
}
|
|
6108
|
+
actionsBtn.addEventListener('click', (e) => {
|
|
6109
|
+
e.stopPropagation();
|
|
6110
|
+
const open = actionsMenu.hidden;
|
|
6111
|
+
actionsMenu.hidden = !open;
|
|
6112
|
+
actionsBtn.setAttribute('aria-expanded', String(open));
|
|
6113
|
+
if (open) {
|
|
6114
|
+
// Position as a fixed layer so it escapes the toolbar/main overflow clip.
|
|
6115
|
+
const r = actionsBtn.getBoundingClientRect();
|
|
6116
|
+
actionsMenu.style.top = (r.bottom + 6) + 'px';
|
|
6117
|
+
actionsMenu.style.left = Math.max(8, r.right - actionsMenu.offsetWidth) + 'px';
|
|
6118
|
+
}
|
|
6119
|
+
});
|
|
6120
|
+
document.addEventListener('click', (e) => {
|
|
6121
|
+
if (!actionsMenu.hidden && !e.target.closest('.menu-wrap')) closeActionsMenu();
|
|
6122
|
+
});
|
|
6123
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionsMenu(); });
|
|
6124
|
+
|
|
6125
|
+
// ── Collapsible side panels ───────────────────────────────────────────
|
|
6126
|
+
const appEl = document.querySelector('.app');
|
|
6127
|
+
const toggleLeftBtn = document.getElementById('toggleLeft');
|
|
6128
|
+
const toggleRightBtn = document.getElementById('toggleRight');
|
|
6129
|
+
const resizeLeftHandle = document.getElementById('resizeLeft');
|
|
6130
|
+
const resizeRightHandle = document.getElementById('resizeRight');
|
|
6131
|
+
const sideDefaults = { left: 320, right: 360 };
|
|
6132
|
+
const sideMin = { left: 220, right: 240 };
|
|
6133
|
+
const sideMax = { left: 560, right: 640 };
|
|
6134
|
+
const sideWidthKey = { left: 'hcc.sidebar.left.width', right: 'hcc.sidebar.right.width' };
|
|
6135
|
+
const dragState = { side: null, startX: 0, startWidth: 0, moved: false, suppressClick: false };
|
|
6136
|
+
function sideIsCollapsed(side) {
|
|
6137
|
+
return appEl.classList.contains(side + '-collapsed') || localStorage.getItem('hcc.collapse.' + side) === '1';
|
|
6138
|
+
}
|
|
6139
|
+
function storedSideWidth(side) {
|
|
6140
|
+
const raw = Number(localStorage.getItem(sideWidthKey[side]));
|
|
6141
|
+
return Number.isFinite(raw) && raw > 0 ? raw : sideDefaults[side];
|
|
6142
|
+
}
|
|
6143
|
+
function effectiveOppositeWidth(side) {
|
|
6144
|
+
const opposite = side === 'left' ? 'right' : 'left';
|
|
6145
|
+
return sideIsCollapsed(opposite) ? 0 : storedSideWidth(opposite);
|
|
6146
|
+
}
|
|
6147
|
+
function clampSideWidth(side, width) {
|
|
6148
|
+
const viewport = Math.max(640, window.innerWidth || appEl.clientWidth || 0);
|
|
6149
|
+
const opposite = effectiveOppositeWidth(side);
|
|
6150
|
+
const maxByViewport = Math.max(sideMin[side], viewport - opposite - 280);
|
|
6151
|
+
return Math.max(sideMin[side], Math.min(sideMax[side], maxByViewport, Math.round(width)));
|
|
6152
|
+
}
|
|
6153
|
+
function readSideWidth(side) {
|
|
6154
|
+
return clampSideWidth(side, storedSideWidth(side));
|
|
6155
|
+
}
|
|
6156
|
+
function setSideWidth(side, width, persist = true) {
|
|
6157
|
+
const value = clampSideWidth(side, width);
|
|
6158
|
+
appEl.style.setProperty('--' + side + '-width', value + 'px');
|
|
6159
|
+
if (persist) localStorage.setItem(sideWidthKey[side], String(value));
|
|
6160
|
+
return value;
|
|
6161
|
+
}
|
|
6162
|
+
function applySideWidths() {
|
|
6163
|
+
setSideWidth('left', readSideWidth('left'), false);
|
|
6164
|
+
setSideWidth('right', readSideWidth('right'), false);
|
|
6165
|
+
}
|
|
6166
|
+
function syncToggleIcons() {
|
|
6167
|
+
const l = appEl.classList.contains('left-collapsed');
|
|
6168
|
+
const r = appEl.classList.contains('right-collapsed');
|
|
6169
|
+
toggleLeftBtn.textContent = l ? '⟩' : '⟨';
|
|
6170
|
+
toggleLeftBtn.title = (l ? tr('show') : tr('collapse')) + ' ' + tr('sidebar') + '; ' + tr('dragToResize');
|
|
6171
|
+
toggleRightBtn.textContent = r ? '⟨' : '⟩';
|
|
6172
|
+
toggleRightBtn.title = (r ? tr('show') : tr('collapse')) + ' ' + tr('statePanel') + '; ' + tr('dragToResize');
|
|
6173
|
+
}
|
|
6174
|
+
function applyCollapseState() {
|
|
6175
|
+
appEl.classList.toggle('left-collapsed', localStorage.getItem('hcc.collapse.left') === '1');
|
|
6176
|
+
appEl.classList.toggle('right-collapsed', localStorage.getItem('hcc.collapse.right') === '1');
|
|
6177
|
+
applySideWidths();
|
|
6178
|
+
syncToggleIcons();
|
|
6179
|
+
}
|
|
6180
|
+
function toggleSide(side) {
|
|
6181
|
+
const cls = side + '-collapsed';
|
|
6182
|
+
const on = appEl.classList.toggle(cls);
|
|
6183
|
+
localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');
|
|
6184
|
+
applySideWidths();
|
|
6185
|
+
syncToggleIcons();
|
|
6186
|
+
// Refit the terminal after the grid transition so xterm uses the new width.
|
|
6187
|
+
setTimeout(() => { try { resizeTerm(); } catch {} }, 200);
|
|
6188
|
+
}
|
|
6189
|
+
function beginSideDrag(side, event) {
|
|
6190
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
6191
|
+
dragState.side = side;
|
|
6192
|
+
dragState.startX = event.clientX;
|
|
6193
|
+
dragState.startWidth = readSideWidth(side);
|
|
6194
|
+
dragState.moved = false;
|
|
6195
|
+
dragState.suppressClick = false;
|
|
6196
|
+
appEl.classList.add('resizing');
|
|
6197
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
6198
|
+
event.preventDefault();
|
|
6199
|
+
}
|
|
6200
|
+
function moveSideDrag(event) {
|
|
6201
|
+
if (!dragState.side) return;
|
|
6202
|
+
const delta = event.clientX - dragState.startX;
|
|
6203
|
+
if (!dragState.moved && Math.abs(delta) <= 3) return;
|
|
6204
|
+
dragState.moved = true;
|
|
6205
|
+
const width = dragState.side === 'left'
|
|
6206
|
+
? dragState.startWidth + delta
|
|
6207
|
+
: dragState.startWidth - delta;
|
|
6208
|
+
setSideWidth(dragState.side, width);
|
|
6209
|
+
if (dragState.side === 'left') {
|
|
6210
|
+
appEl.classList.remove('left-collapsed');
|
|
6211
|
+
localStorage.setItem('hcc.collapse.left', '0');
|
|
6212
|
+
} else {
|
|
6213
|
+
appEl.classList.remove('right-collapsed');
|
|
6214
|
+
localStorage.setItem('hcc.collapse.right', '0');
|
|
6215
|
+
}
|
|
6216
|
+
syncToggleIcons();
|
|
6217
|
+
try { resizeTerm(); } catch {}
|
|
6218
|
+
event.preventDefault();
|
|
6219
|
+
}
|
|
6220
|
+
function endSideDrag(event) {
|
|
6221
|
+
if (!dragState.side) return;
|
|
6222
|
+
dragState.suppressClick = dragState.moved;
|
|
6223
|
+
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
|
6224
|
+
dragState.side = null;
|
|
6225
|
+
appEl.classList.remove('resizing');
|
|
6226
|
+
setTimeout(() => { dragState.suppressClick = false; }, 0);
|
|
6227
|
+
try { resizeTerm(); } catch {}
|
|
6228
|
+
}
|
|
6229
|
+
function bindSideHandle(handle, side, clickAction = null) {
|
|
6230
|
+
handle.addEventListener('pointerdown', (event) => beginSideDrag(side, event));
|
|
6231
|
+
handle.addEventListener('pointermove', moveSideDrag);
|
|
6232
|
+
handle.addEventListener('pointerup', endSideDrag);
|
|
6233
|
+
handle.addEventListener('pointercancel', endSideDrag);
|
|
6234
|
+
handle.addEventListener('click', (event) => {
|
|
6235
|
+
if (dragState.suppressClick) {
|
|
6236
|
+
event.preventDefault();
|
|
6237
|
+
return;
|
|
6238
|
+
}
|
|
6239
|
+
if (clickAction) clickAction(side);
|
|
6240
|
+
});
|
|
6241
|
+
}
|
|
6242
|
+
bindSideHandle(resizeLeftHandle, 'left');
|
|
6243
|
+
bindSideHandle(resizeRightHandle, 'right');
|
|
6244
|
+
bindSideHandle(toggleLeftBtn, 'left', toggleSide);
|
|
6245
|
+
bindSideHandle(toggleRightBtn, 'right', toggleSide);
|
|
6246
|
+
window.addEventListener('resize', () => {
|
|
6247
|
+
applySideWidths();
|
|
6248
|
+
setTimeout(() => { try { resizeTerm(); } catch {} }, 50);
|
|
6249
|
+
});
|
|
6250
|
+
applyCollapseState();
|
|
4220
6251
|
|
|
4221
6252
|
document.getElementById('stopBtn').addEventListener('click', async () => {
|
|
4222
6253
|
if (!active) return;
|
|
4223
6254
|
await api('/api/sessions/' + encodeURIComponent(active) + '/stop', { method: 'POST', body: '{}' });
|
|
4224
6255
|
await refreshSessions();
|
|
4225
6256
|
});
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
6257
|
+
document.getElementById('refreshBtn').addEventListener('click', () => {
|
|
6258
|
+
Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]).catch(console.error);
|
|
6259
|
+
});
|
|
4229
6260
|
|
|
6261
|
+
applyLanguage();
|
|
4230
6262
|
loadProjects().then(() => Promise.all([refreshSessions(), refreshDetected(), refreshState()])).then(() => {
|
|
4231
6263
|
// Auto-connect to first running managed session on load
|
|
4232
6264
|
if (!active && !activeDetected) {
|
|
@@ -4234,7 +6266,7 @@ function webIndexHtml() {
|
|
|
4234
6266
|
if (first) connectManaged(first.id);
|
|
4235
6267
|
}
|
|
4236
6268
|
}).catch((err) => {
|
|
4237
|
-
|
|
6269
|
+
connText('error');
|
|
4238
6270
|
console.error(err);
|
|
4239
6271
|
});
|
|
4240
6272
|
// ── Auto-poll state ──────────────────────────────────────────────────
|
|
@@ -4255,12 +6287,12 @@ function webIndexHtml() {
|
|
|
4255
6287
|
async function cmdWeb(ctx, args, startMeta = {}) {
|
|
4256
6288
|
if (args[0] === '--help' || args[0] === '-h') return helpWeb();
|
|
4257
6289
|
if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
|
|
4258
|
-
const opts = parseOpts(args, { booleans: ['local', 'no-guidance', 'no-discover'] });
|
|
4259
|
-
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-guidance', 'no-discover']);
|
|
4260
|
-
|
|
6290
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover'] });
|
|
6291
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover']);
|
|
6292
|
+
validateWebTokenOpts(opts);
|
|
6293
|
+
const host = expectedWebHost(opts);
|
|
4261
6294
|
const port = intOpt(opts, 'port', 8787);
|
|
4262
|
-
const
|
|
4263
|
-
const token = explicitToken;
|
|
6295
|
+
const token = makeWebToken(opts);
|
|
4264
6296
|
ensureTmuxAvailable({ autoInstall: false });
|
|
4265
6297
|
const ptyModule = await import('node-pty');
|
|
4266
6298
|
const { WebSocketServer } = await import('ws');
|
|
@@ -4306,8 +6338,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4306
6338
|
return [...sessions.values()].filter((session) => session.root === projectCtx.root);
|
|
4307
6339
|
}
|
|
4308
6340
|
|
|
4309
|
-
function getSession(projectCtx, id) {
|
|
4310
|
-
|
|
6341
|
+
function getSession(projectCtx, id, db = null) {
|
|
6342
|
+
const direct = sessions.get(sessionKey(projectCtx, id));
|
|
6343
|
+
if (direct) return direct;
|
|
6344
|
+
for (const session of sessionsForProject(projectCtx)) {
|
|
6345
|
+
if (session.peerId === id) return session;
|
|
6346
|
+
}
|
|
6347
|
+
if (db) {
|
|
6348
|
+
for (const session of sessionsForProject(projectCtx)) {
|
|
6349
|
+
if (resolveSessionPeerId(db, session) === id) return session;
|
|
6350
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
return null;
|
|
4311
6353
|
}
|
|
4312
6354
|
|
|
4313
6355
|
function knownPeerIds(projectCtx) {
|
|
@@ -4350,6 +6392,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4350
6392
|
|
|
4351
6393
|
const session = {
|
|
4352
6394
|
id,
|
|
6395
|
+
peerId: id,
|
|
4353
6396
|
root: pctx.root,
|
|
4354
6397
|
ctx: pctx,
|
|
4355
6398
|
kind: meta.kind || 'external',
|
|
@@ -4478,9 +6521,58 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4478
6521
|
const autoAttachPoller = setInterval(scanAndAttachDetectedPeers, 5000);
|
|
4479
6522
|
|
|
4480
6523
|
// ── Serialize + broadcast helpers ─────────────────────────────────────────
|
|
4481
|
-
function
|
|
6524
|
+
function resolveSessionPeerId(db, session) {
|
|
6525
|
+
if (!session) return null;
|
|
6526
|
+
if (!db) return session.peerId || session.id || null;
|
|
6527
|
+
|
|
6528
|
+
if (session.type === 'tmux' && session.pane) {
|
|
6529
|
+
const byTarget = db.prepare(`
|
|
6530
|
+
SELECT peer
|
|
6531
|
+
FROM peer_bindings
|
|
6532
|
+
WHERE runtime_target = ?
|
|
6533
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
6534
|
+
LIMIT 1
|
|
6535
|
+
`).get(session.pane);
|
|
6536
|
+
if (byTarget?.peer) {
|
|
6537
|
+
session.peerId = byTarget.peer;
|
|
6538
|
+
return byTarget.peer;
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
|
|
6542
|
+
if (session.id) {
|
|
6543
|
+
const byRuntime = db.prepare(`
|
|
6544
|
+
SELECT peer
|
|
6545
|
+
FROM peer_bindings
|
|
6546
|
+
WHERE runtime_session_id = ?
|
|
6547
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
6548
|
+
LIMIT 1
|
|
6549
|
+
`).get(session.id);
|
|
6550
|
+
if (byRuntime?.peer) {
|
|
6551
|
+
session.peerId = byRuntime.peer;
|
|
6552
|
+
return byRuntime.peer;
|
|
6553
|
+
}
|
|
6554
|
+
|
|
6555
|
+
const byPeer = db.prepare(`
|
|
6556
|
+
SELECT peer
|
|
6557
|
+
FROM peer_bindings
|
|
6558
|
+
WHERE peer = ?
|
|
6559
|
+
LIMIT 1
|
|
6560
|
+
`).get(session.id);
|
|
6561
|
+
if (byPeer?.peer) {
|
|
6562
|
+
session.peerId = byPeer.peer;
|
|
6563
|
+
return byPeer.peer;
|
|
6564
|
+
}
|
|
6565
|
+
}
|
|
6566
|
+
|
|
6567
|
+
session.peerId = session.peerId || session.id || null;
|
|
6568
|
+
return session.peerId;
|
|
6569
|
+
}
|
|
6570
|
+
|
|
6571
|
+
function serializeSession(session, db = null) {
|
|
6572
|
+
const peerId = resolveSessionPeerId(db, session);
|
|
4482
6573
|
return {
|
|
4483
6574
|
id: session.id,
|
|
6575
|
+
peer_id: peerId,
|
|
4484
6576
|
kind: session.kind,
|
|
4485
6577
|
role: session.role,
|
|
4486
6578
|
command: session.command,
|
|
@@ -4526,9 +6618,18 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4526
6618
|
sessions.delete(sessionKey(pctx, session.id));
|
|
4527
6619
|
const db = connect(pctx);
|
|
4528
6620
|
try {
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
6621
|
+
const t = now();
|
|
6622
|
+
const peerId = resolveSessionPeerId(db, session) || session.id;
|
|
6623
|
+
db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id IN (?, ?)').run(status, t, session.id, peerId);
|
|
6624
|
+
db.prepare(`
|
|
6625
|
+
UPDATE peer_bindings
|
|
6626
|
+
SET runtime_target = NULL, updated_at = ?
|
|
6627
|
+
WHERE peer IN (?, ?)
|
|
6628
|
+
OR runtime_session_id = ?
|
|
6629
|
+
OR (? IS NOT NULL AND runtime_target = ?)
|
|
6630
|
+
`).run(t, session.id, peerId, session.id, session.pane || null, session.pane || null);
|
|
6631
|
+
addEvent(db, status === 'exited' ? 'tmux.session.exited' : 'tmux.session.detached', peerId, null, {
|
|
6632
|
+
runtime_session_id: session.id,
|
|
4532
6633
|
pane: session.pane
|
|
4533
6634
|
});
|
|
4534
6635
|
} finally {
|
|
@@ -4577,6 +6678,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4577
6678
|
const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
4578
6679
|
const pipeFile = path.join(bufsDir, `tmux-${safePane}-${safeId}.pipe`);
|
|
4579
6680
|
session.pipeFile = pipeFile;
|
|
6681
|
+
// Restored panes may still have pipe-pane writers from a previous runtime.
|
|
6682
|
+
// Disable first so tmux tears down the stale writer before this runtime
|
|
6683
|
+
// installs its own FIFO reader.
|
|
6684
|
+
try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
|
|
4580
6685
|
// Capture the existing screen once for the initial paint; pipe-pane only
|
|
4581
6686
|
// forwards output produced after it is enabled.
|
|
4582
6687
|
try {
|
|
@@ -4645,6 +6750,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4645
6750
|
function stopTmuxStream(session) {
|
|
4646
6751
|
if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
|
|
4647
6752
|
if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
|
|
6753
|
+
if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
|
|
4648
6754
|
// Turn piping back off for this pane (no command toggles it off).
|
|
4649
6755
|
try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
|
|
4650
6756
|
if (session.streamFd !== null && session.streamFd !== undefined) {
|
|
@@ -4688,6 +6794,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4688
6794
|
const captured = tmuxCapturePane(info.pane);
|
|
4689
6795
|
const session = {
|
|
4690
6796
|
id,
|
|
6797
|
+
peerId: id,
|
|
4691
6798
|
root: pctx.root,
|
|
4692
6799
|
ctx: pctx,
|
|
4693
6800
|
kind,
|
|
@@ -4737,7 +6844,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4737
6844
|
capabilities: 'tmux'
|
|
4738
6845
|
});
|
|
4739
6846
|
const binding = input.binding || {};
|
|
4740
|
-
upsertCanonicalPeerBinding(db, {
|
|
6847
|
+
const canonical = upsertCanonicalPeerBinding(db, {
|
|
4741
6848
|
peer: id,
|
|
4742
6849
|
provider: binding.provider || kind,
|
|
4743
6850
|
provider_session_id: binding.provider_session_id || null,
|
|
@@ -4749,6 +6856,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4749
6856
|
runtime_session_id: id,
|
|
4750
6857
|
runtime_target: info.pane
|
|
4751
6858
|
}, Boolean(input.force));
|
|
6859
|
+
session.peerId = canonical.peer;
|
|
4752
6860
|
addEvent(db, 'tmux.session.attached', id, null, { pane: info.pane, command, cwd, pid: info.pid });
|
|
4753
6861
|
} finally {
|
|
4754
6862
|
db.close();
|
|
@@ -4761,11 +6869,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4761
6869
|
try { fs.appendFileSync(session.inFile, data); } catch {}
|
|
4762
6870
|
} else if (session.type === 'tmux') {
|
|
4763
6871
|
tmuxSendLiteral(session.pane, data);
|
|
6872
|
+
scheduleTmuxInputRefresh(session);
|
|
4764
6873
|
} else {
|
|
4765
6874
|
session.pty.write(data);
|
|
4766
6875
|
}
|
|
4767
6876
|
}
|
|
4768
6877
|
|
|
6878
|
+
function scheduleTmuxInputRefresh(session) {
|
|
6879
|
+
if (session.type !== 'tmux' || !session.pane) return;
|
|
6880
|
+
if (session.inputRefreshTimer) return;
|
|
6881
|
+
session.inputRefreshTimer = setTimeout(() => {
|
|
6882
|
+
session.inputRefreshTimer = null;
|
|
6883
|
+
if (session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
6884
|
+
}, 80);
|
|
6885
|
+
}
|
|
6886
|
+
|
|
4769
6887
|
function resizeSession(session, cols, rows) {
|
|
4770
6888
|
if (session.type === 'external') {
|
|
4771
6889
|
try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
|
|
@@ -4959,6 +7077,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4959
7077
|
});
|
|
4960
7078
|
const session = {
|
|
4961
7079
|
id,
|
|
7080
|
+
peerId: id,
|
|
4962
7081
|
root: pctx.root,
|
|
4963
7082
|
ctx: pctx,
|
|
4964
7083
|
kind,
|
|
@@ -4986,7 +7105,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4986
7105
|
status: 'running',
|
|
4987
7106
|
capabilities: 'web-pty'
|
|
4988
7107
|
});
|
|
4989
|
-
upsertCanonicalPeerBinding(db, {
|
|
7108
|
+
const canonical = upsertCanonicalPeerBinding(db, {
|
|
4990
7109
|
peer: id,
|
|
4991
7110
|
provider: input.binding?.provider || kind,
|
|
4992
7111
|
provider_session_id: input.binding?.provider_session_id || null,
|
|
@@ -4997,6 +7116,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
4997
7116
|
transport: 'web-pty',
|
|
4998
7117
|
runtime_session_id: id
|
|
4999
7118
|
}, Boolean(input.force));
|
|
7119
|
+
session.peerId = canonical.peer;
|
|
5000
7120
|
addEvent(db, 'web.session.started', id, null, { command, cwd, pid: child.pid });
|
|
5001
7121
|
} finally {
|
|
5002
7122
|
db.close();
|
|
@@ -5021,9 +7141,60 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5021
7141
|
return session;
|
|
5022
7142
|
}
|
|
5023
7143
|
|
|
7144
|
+
function webSessionBuildOptions(input) {
|
|
7145
|
+
const mode = input.mode || 'new';
|
|
7146
|
+
if (mode === 'new') return {};
|
|
7147
|
+
if (mode === 'resume') {
|
|
7148
|
+
const resume = String(input.resume || '').trim();
|
|
7149
|
+
if (!resume) throw new CliError('BAD_REQUEST', 'resume session required');
|
|
7150
|
+
return { resume };
|
|
7151
|
+
}
|
|
7152
|
+
if (mode === 'last') return { last: true };
|
|
7153
|
+
if (mode === 'continue') return { continue: true };
|
|
7154
|
+
throw new CliError('BAD_REQUEST', `Unsupported session mode: ${mode}`);
|
|
7155
|
+
}
|
|
7156
|
+
|
|
7157
|
+
function webSessionPeerId(projectCtx, kind, opts, input) {
|
|
7158
|
+
if (input.id) return input.id;
|
|
7159
|
+
if (opts.resume) return providerSessionPeerId(kind, opts.resume);
|
|
7160
|
+
return nextProjectSessionId(projectCtx, kind);
|
|
7161
|
+
}
|
|
7162
|
+
|
|
7163
|
+
function normalizeWebSessionInput(input) {
|
|
7164
|
+
const pctx = input.projectCtx || ctx;
|
|
7165
|
+
const kind = input.kind || 'shell';
|
|
7166
|
+
if (!['claude', 'codex', 'shell'].includes(kind)) {
|
|
7167
|
+
throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
|
|
7168
|
+
}
|
|
7169
|
+
if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };
|
|
7170
|
+
|
|
7171
|
+
const opts = webSessionBuildOptions(input);
|
|
7172
|
+
if (kind === 'shell' && hasResumeOpts(opts)) {
|
|
7173
|
+
throw new CliError('BAD_REQUEST', 'Resume modes are only supported for codex and claude sessions');
|
|
7174
|
+
}
|
|
7175
|
+
if (kind !== 'claude' && opts.continue) {
|
|
7176
|
+
throw new CliError('BAD_REQUEST', 'continue is only supported for claude sessions');
|
|
7177
|
+
}
|
|
7178
|
+
if (kind !== 'codex' && opts.last) {
|
|
7179
|
+
throw new CliError('BAD_REQUEST', 'last is only supported for codex sessions');
|
|
7180
|
+
}
|
|
7181
|
+
|
|
7182
|
+
const id = webSessionPeerId(pctx, kind, opts, input);
|
|
7183
|
+
const built = buildPeerCommand(id, kind, opts, []);
|
|
7184
|
+
return {
|
|
7185
|
+
...input,
|
|
7186
|
+
id,
|
|
7187
|
+
kind,
|
|
7188
|
+
command: built.command,
|
|
7189
|
+
binding: built.binding,
|
|
7190
|
+
projectCtx: pctx
|
|
7191
|
+
};
|
|
7192
|
+
}
|
|
7193
|
+
|
|
5024
7194
|
function startSession(input) {
|
|
5025
|
-
|
|
5026
|
-
return
|
|
7195
|
+
const normalized = normalizeWebSessionInput(input);
|
|
7196
|
+
if (normalized.backend === 'pty') return startPtySession(normalized);
|
|
7197
|
+
return startTmuxManagedSession(normalized);
|
|
5027
7198
|
}
|
|
5028
7199
|
|
|
5029
7200
|
for (const projectCtx of projectContexts.values()) {
|
|
@@ -5076,13 +7247,43 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5076
7247
|
return;
|
|
5077
7248
|
}
|
|
5078
7249
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
5079
|
-
|
|
7250
|
+
const resources = normalizeStateResources([
|
|
7251
|
+
...url.searchParams.getAll('resource'),
|
|
7252
|
+
url.searchParams.get('resources') || ''
|
|
7253
|
+
]);
|
|
7254
|
+
sendJson(res, 200, statusSnapshot(reqCtx, url.searchParams.get('peer'), {
|
|
7255
|
+
resources,
|
|
7256
|
+
intent: url.searchParams.get('intent') || null
|
|
7257
|
+
}));
|
|
7258
|
+
return;
|
|
7259
|
+
}
|
|
7260
|
+
const peerActionMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/actions\/([^/]+)$/);
|
|
7261
|
+
if (peerActionMatch) {
|
|
7262
|
+
const peer = decodeURIComponent(peerActionMatch[1]);
|
|
7263
|
+
const action = decodeURIComponent(peerActionMatch[2]);
|
|
7264
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
7265
|
+
if (readOnly && req.method !== 'GET') {
|
|
7266
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use GET for read-only peer actions' } });
|
|
7267
|
+
return;
|
|
7268
|
+
}
|
|
7269
|
+
if (!readOnly && req.method !== 'POST') {
|
|
7270
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use POST for mutating peer actions' } });
|
|
7271
|
+
return;
|
|
7272
|
+
}
|
|
7273
|
+
const input = readOnly
|
|
7274
|
+
? {
|
|
7275
|
+
...Object.fromEntries(url.searchParams.entries()),
|
|
7276
|
+
resource: url.searchParams.getAll('resource')
|
|
7277
|
+
}
|
|
7278
|
+
: await readJsonRequest(req);
|
|
7279
|
+
sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));
|
|
5080
7280
|
return;
|
|
5081
7281
|
}
|
|
5082
7282
|
// Detected sessions: peers registered via hooks/watcher but without PTY
|
|
5083
7283
|
if (req.method === 'GET' && url.pathname === '/api/detected') {
|
|
5084
7284
|
const db = connect(reqCtx);
|
|
5085
7285
|
let detected = [];
|
|
7286
|
+
const managedIds = new Set();
|
|
5086
7287
|
const t = now();
|
|
5087
7288
|
try {
|
|
5088
7289
|
detected = db.prepare(`
|
|
@@ -5092,11 +7293,15 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5092
7293
|
ORDER BY last_seen_at DESC, id ASC
|
|
5093
7294
|
LIMIT 100
|
|
5094
7295
|
`).all(t);
|
|
7296
|
+
for (const session of sessionsForProject(reqCtx)) {
|
|
7297
|
+
managedIds.add(session.id);
|
|
7298
|
+
const peerId = resolveSessionPeerId(db, session);
|
|
7299
|
+
if (peerId) managedIds.add(peerId);
|
|
7300
|
+
}
|
|
5095
7301
|
} finally {
|
|
5096
7302
|
db.close();
|
|
5097
7303
|
}
|
|
5098
7304
|
// Exclude peers that are already in the managed sessions Map
|
|
5099
|
-
const managedIds = new Set(sessionsForProject(reqCtx).map((s) => s.id));
|
|
5100
7305
|
sendJson(res, 200, {
|
|
5101
7306
|
now: t,
|
|
5102
7307
|
active_peer_ttl: ACTIVE_PEER_TTL,
|
|
@@ -5104,6 +7309,44 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5104
7309
|
});
|
|
5105
7310
|
return;
|
|
5106
7311
|
}
|
|
7312
|
+
if (req.method === 'GET' && url.pathname === '/api/resumable') {
|
|
7313
|
+
// Provider sessions hcc has seen (via hooks/detection) that carry a real
|
|
7314
|
+
// provider session id or resumable provider session name.
|
|
7315
|
+
const db = connect(reqCtx);
|
|
7316
|
+
let rows = [];
|
|
7317
|
+
try {
|
|
7318
|
+
rows = db.prepare(`
|
|
7319
|
+
SELECT b.provider, b.provider_session_id, b.provider_session_name, b.peer,
|
|
7320
|
+
p.last_seen_at
|
|
7321
|
+
FROM peer_bindings b
|
|
7322
|
+
LEFT JOIN peers p ON p.id = b.peer
|
|
7323
|
+
WHERE (b.provider_session_id IS NOT NULL AND b.provider_session_id != '')
|
|
7324
|
+
OR (b.provider_session_name IS NOT NULL AND b.provider_session_name != '')
|
|
7325
|
+
ORDER BY p.last_seen_at DESC, b.updated_at DESC
|
|
7326
|
+
`).all();
|
|
7327
|
+
} finally {
|
|
7328
|
+
db.close();
|
|
7329
|
+
}
|
|
7330
|
+
const seen = new Set();
|
|
7331
|
+
const resumable = [];
|
|
7332
|
+
for (const r of rows) {
|
|
7333
|
+
const resumeValue = r.provider_session_id || r.provider_session_name || '';
|
|
7334
|
+
if (!resumeValue) continue;
|
|
7335
|
+
const key = `${r.provider}:${resumeValue}`;
|
|
7336
|
+
if (seen.has(key)) continue;
|
|
7337
|
+
seen.add(key);
|
|
7338
|
+
resumable.push({
|
|
7339
|
+
provider: r.provider,
|
|
7340
|
+
session_id: r.provider_session_id,
|
|
7341
|
+
session_name: r.provider_session_name || null,
|
|
7342
|
+
resume: resumeValue,
|
|
7343
|
+
name: r.provider_session_name || null,
|
|
7344
|
+
peer: r.peer
|
|
7345
|
+
});
|
|
7346
|
+
}
|
|
7347
|
+
sendJson(res, 200, { resumable });
|
|
7348
|
+
return;
|
|
7349
|
+
}
|
|
5107
7350
|
if (req.method === 'GET' && url.pathname === '/api/runtime') {
|
|
5108
7351
|
sendJson(res, 200, {
|
|
5109
7352
|
product: PRODUCT_NAME,
|
|
@@ -5122,7 +7365,14 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5122
7365
|
return;
|
|
5123
7366
|
}
|
|
5124
7367
|
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
|
5125
|
-
|
|
7368
|
+
const db = connect(reqCtx);
|
|
7369
|
+
try {
|
|
7370
|
+
sendJson(res, 200, {
|
|
7371
|
+
sessions: sessionsForProject(reqCtx).map((session) => serializeSession(session, db))
|
|
7372
|
+
});
|
|
7373
|
+
} finally {
|
|
7374
|
+
db.close();
|
|
7375
|
+
}
|
|
5126
7376
|
return;
|
|
5127
7377
|
}
|
|
5128
7378
|
if (req.method === 'POST' && url.pathname === '/api/sessions') {
|
|
@@ -5140,7 +7390,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5140
7390
|
const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
|
|
5141
7391
|
if (req.method === 'POST' && inputMatch) {
|
|
5142
7392
|
const id = decodeURIComponent(inputMatch[1]);
|
|
5143
|
-
const
|
|
7393
|
+
const lookupDb = connect(reqCtx);
|
|
7394
|
+
let session;
|
|
7395
|
+
try {
|
|
7396
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7397
|
+
} finally {
|
|
7398
|
+
lookupDb.close();
|
|
7399
|
+
}
|
|
5144
7400
|
if (!session) {
|
|
5145
7401
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
5146
7402
|
return;
|
|
@@ -5165,7 +7421,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5165
7421
|
const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
|
|
5166
7422
|
if (req.method === 'POST' && stopMatch) {
|
|
5167
7423
|
const id = decodeURIComponent(stopMatch[1]);
|
|
5168
|
-
const
|
|
7424
|
+
const lookupDb = connect(reqCtx);
|
|
7425
|
+
let session;
|
|
7426
|
+
try {
|
|
7427
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7428
|
+
} finally {
|
|
7429
|
+
lookupDb.close();
|
|
7430
|
+
}
|
|
5169
7431
|
if (!session) {
|
|
5170
7432
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
|
|
5171
7433
|
return;
|
|
@@ -5227,7 +7489,13 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
5227
7489
|
}
|
|
5228
7490
|
const reqCtx = projectFromRequest(req, url);
|
|
5229
7491
|
const id = decodeURIComponent(match[1]);
|
|
5230
|
-
const
|
|
7492
|
+
const lookupDb = connect(reqCtx);
|
|
7493
|
+
let session;
|
|
7494
|
+
try {
|
|
7495
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
7496
|
+
} finally {
|
|
7497
|
+
lookupDb.close();
|
|
7498
|
+
}
|
|
5231
7499
|
if (!session) {
|
|
5232
7500
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
5233
7501
|
socket.destroy();
|
|
@@ -5557,6 +7825,7 @@ Commands:
|
|
|
5557
7825
|
heartbeat [--peer ID] Mark the current peer alive, optionally renew locks
|
|
5558
7826
|
peers List known peers
|
|
5559
7827
|
status [--peer ID] Show project coordination state
|
|
7828
|
+
state [--peer ID] Show timeline and next coordination action
|
|
5560
7829
|
scan [--register] Detect existing Claude/Codex sessions
|
|
5561
7830
|
prompt --peer ID Print copy/paste session instructions
|
|
5562
7831
|
run --peer ID -- COMMAND Register a peer and run a command in this terminal
|
|
@@ -5565,6 +7834,7 @@ Commands:
|
|
|
5565
7834
|
ask PEER MESSAGE Send a direct work request to one peer
|
|
5566
7835
|
broadcast MESSAGE Send a work request to all peers
|
|
5567
7836
|
task <subcommand> Create, list, claim, update, finish tasks
|
|
7837
|
+
team <subcommand> Plan, start, and inspect explicit task teams
|
|
5568
7838
|
msg <subcommand> Send, read, and ack messages
|
|
5569
7839
|
lock <subcommand> Acquire, renew, release, and list advisory locks
|
|
5570
7840
|
handoff <subcommand> Create and list handoffs
|
|
@@ -5579,7 +7849,7 @@ Environment:
|
|
|
5579
7849
|
HCC_ROOT Override project root
|
|
5580
7850
|
HCC_DB Override database path
|
|
5581
7851
|
HCC_PEER Default peer id; inferred automatically when absent
|
|
5582
|
-
HCC_WEB_TOKEN
|
|
7852
|
+
HCC_WEB_TOKEN Replace and save the stable web access token
|
|
5583
7853
|
`);
|
|
5584
7854
|
}
|
|
5585
7855
|
|
|
@@ -5588,21 +7858,60 @@ function helpTask() {
|
|
|
5588
7858
|
|
|
5589
7859
|
Usage:
|
|
5590
7860
|
${CLI_NAME} task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
|
|
7861
|
+
${CLI_NAME} task create --title TEXT --parent N [--team-role ROLE]
|
|
5591
7862
|
${CLI_NAME} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
|
|
5592
7863
|
${CLI_NAME} task claim [--peer ID] --id N
|
|
5593
|
-
${CLI_NAME} task next [--peer ID]
|
|
7864
|
+
${CLI_NAME} task next [--peer ID] [--force]
|
|
5594
7865
|
${CLI_NAME} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
|
|
5595
7866
|
${CLI_NAME} task done [--peer ID] --id N --summary TEXT
|
|
5596
7867
|
|
|
5597
7868
|
Default task list shows all project tasks that are not done or abandoned.
|
|
5598
7869
|
--peer is an explicit filter; HCC_PEER does not hide other open tasks.
|
|
5599
7870
|
Messages use per-peer unread ack state; tasks do not.
|
|
7871
|
+
task next returns your existing claimed/running/review/blocked task before
|
|
7872
|
+
claiming another pending task. Use --force only when intentionally taking
|
|
7873
|
+
another pending task.
|
|
5600
7874
|
|
|
5601
7875
|
If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
|
|
5602
7876
|
as a stable project-local peer.
|
|
5603
7877
|
`);
|
|
5604
7878
|
}
|
|
5605
7879
|
|
|
7880
|
+
function helpTeam() {
|
|
7881
|
+
console.log(`${CLI_NAME} team
|
|
7882
|
+
|
|
7883
|
+
Usage:
|
|
7884
|
+
${CLI_NAME} team plan --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1]
|
|
7885
|
+
${CLI_NAME} team start --from-task N [--item ROLE:TITLE] [--item PEER:ROLE:TITLE] [--workers A,B|codex:2,claude:1] [--force]
|
|
7886
|
+
${CLI_NAME} team status --task N
|
|
7887
|
+
|
|
7888
|
+
team plan is read-only. team start creates explicit child tasks under the parent
|
|
7889
|
+
task and optionally assigns them to workers. It does not silently spawn model
|
|
7890
|
+
processes or override the current-task rule.
|
|
7891
|
+
workers may be explicit peer ids or kind counts such as codex:2,claude:1.
|
|
7892
|
+
`);
|
|
7893
|
+
}
|
|
7894
|
+
|
|
7895
|
+
function helpState() {
|
|
7896
|
+
console.log(`${CLI_NAME} state
|
|
7897
|
+
|
|
7898
|
+
Usage:
|
|
7899
|
+
${CLI_NAME} state [--peer ID] [--resource PATH] [--intent work|stop|finish]
|
|
7900
|
+
|
|
7901
|
+
Shows the current collaboration timeline plus a machine-readable coordination
|
|
7902
|
+
state machine. With --json, the response includes automation.next_action.argv so
|
|
7903
|
+
agents can execute the suggested hcc command explicitly and leave an audit trail.
|
|
7904
|
+
automation.current_task shows the peer's active claimed/running/review/blocked
|
|
7905
|
+
task when one exists.
|
|
7906
|
+
If a current task looks splittable, automation may suggest hcc team plan; this
|
|
7907
|
+
is read-only until hcc team start is run explicitly.
|
|
7908
|
+
|
|
7909
|
+
State does not execute coordination actions: it does not ack messages, claim
|
|
7910
|
+
tasks, acquire locks, send messages, create handoffs, or mark tasks done. Opening
|
|
7911
|
+
state may still perform normal SQLite schema maintenance for known project DBs.
|
|
7912
|
+
`);
|
|
7913
|
+
}
|
|
7914
|
+
|
|
5606
7915
|
function helpJoin() {
|
|
5607
7916
|
console.log(`${CLI_NAME} join
|
|
5608
7917
|
|
|
@@ -5640,8 +7949,11 @@ Usage:
|
|
|
5640
7949
|
${CLI_NAME} msg send [--from ID] [--to ID|all] --body TEXT [--task N] [--kind note|task|handoff]
|
|
5641
7950
|
${CLI_NAME} msg inbox [--peer ID] [--wait SEC] [--all] [--limit N]
|
|
5642
7951
|
${CLI_NAME} msg ack [--peer ID] --id N
|
|
7952
|
+
${CLI_NAME} msg reply [--from ID] --id N --body TEXT [--to ID] [--kind reply]
|
|
7953
|
+
${CLI_NAME} msg thread --id N [--limit N]
|
|
5643
7954
|
|
|
5644
7955
|
If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal.
|
|
7956
|
+
Use msg reply when answering a message so the response stays in the same thread.
|
|
5645
7957
|
`);
|
|
5646
7958
|
}
|
|
5647
7959
|
|
|
@@ -5813,19 +8125,22 @@ function helpWeb() {
|
|
|
5813
8125
|
console.log(`${CLI_NAME} web
|
|
5814
8126
|
|
|
5815
8127
|
Usage:
|
|
5816
|
-
${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-discover] [--no-guidance]
|
|
8128
|
+
${CLI_NAME} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-token] [--no-discover] [--no-guidance]
|
|
5817
8129
|
|
|
5818
8130
|
Examples:
|
|
5819
8131
|
${CLI_NAME} web
|
|
5820
|
-
HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --
|
|
8132
|
+
HCC_WEB_TOKEN='long-token' ${CLI_NAME} web --port 8787
|
|
8133
|
+
${CLI_NAME} web --local
|
|
5821
8134
|
|
|
5822
8135
|
This is the default one-command entrypoint. It prepares local coordination,
|
|
5823
8136
|
installs Claude/Codex hooks and shims, ensures tmux is available, starts the
|
|
5824
8137
|
browser terminal console as a background runtime, prints the URL, PID, runtime
|
|
5825
8138
|
file, and log file, then returns the terminal to you.
|
|
5826
8139
|
|
|
5827
|
-
|
|
5828
|
-
|
|
8140
|
+
By default, the web runtime listens on 0.0.0.0 and uses a saved token,
|
|
8141
|
+
generating one on first use. Use HCC_WEB_TOKEN or --token to replace the saved
|
|
8142
|
+
token, --local to bind only to 127.0.0.1, or --no-token only for a trusted
|
|
8143
|
+
local/test environment.
|
|
5829
8144
|
|
|
5830
8145
|
After hcc web, plain claude/codex commands started from a new shell are wrapped
|
|
5831
8146
|
as local tmux-backed terminals. Existing ordinary terminals can communicate
|
|
@@ -5905,7 +8220,7 @@ async function cmdHook(ctx, args) {
|
|
|
5905
8220
|
if (!peerId) {
|
|
5906
8221
|
resumeId = providerSession.resumeId || readParentResumeId(kind);
|
|
5907
8222
|
if (resumeId) {
|
|
5908
|
-
peerId =
|
|
8223
|
+
peerId = providerSessionPeerId(kind, resumeId);
|
|
5909
8224
|
}
|
|
5910
8225
|
}
|
|
5911
8226
|
if (!peerId) {
|
|
@@ -5962,7 +8277,7 @@ async function cmdHook(ctx, args) {
|
|
|
5962
8277
|
addEvent(db, `hook.${hookKey}`, peerId, null, { session_id: sessionId, cwd: hookCwd });
|
|
5963
8278
|
|
|
5964
8279
|
if (['sessionstart', 'userpromptsubmit'].includes(hookKey)) {
|
|
5965
|
-
const snapshot = buildHookCoordinationContext(db, peerId);
|
|
8280
|
+
const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
|
|
5966
8281
|
ackMessages(db, peerId, snapshot.messages);
|
|
5967
8282
|
process.stdout.write(JSON.stringify({
|
|
5968
8283
|
hookSpecificOutput: {
|
|
@@ -5971,7 +8286,7 @@ async function cmdHook(ctx, args) {
|
|
|
5971
8286
|
}
|
|
5972
8287
|
}) + '\n');
|
|
5973
8288
|
} else if (['posttooluse', 'stop'].includes(hookKey)) {
|
|
5974
|
-
const snapshot = buildHookCoordinationContext(db, peerId);
|
|
8289
|
+
const snapshot = buildHookCoordinationContext(db, hookCtx, peerId);
|
|
5975
8290
|
if (snapshot.messages.length > 0) {
|
|
5976
8291
|
ackMessages(db, peerId, snapshot.messages);
|
|
5977
8292
|
process.stdout.write(JSON.stringify({
|
|
@@ -6404,6 +8719,7 @@ async function dispatch(ctx, rest) {
|
|
|
6404
8719
|
if (command === 'heartbeat') return cmdHeartbeat(ctx, args);
|
|
6405
8720
|
if (command === 'peers') return cmdPeers(ctx, args);
|
|
6406
8721
|
if (command === 'status') return cmdStatus(ctx, args);
|
|
8722
|
+
if (command === 'state') return cmdState(ctx, args);
|
|
6407
8723
|
if (command === 'prompt') return cmdPrompt(ctx, args);
|
|
6408
8724
|
if (command === 'run') return cmdRun(ctx, args);
|
|
6409
8725
|
if (command === 'peer') return cmdPeer(ctx, args);
|
|
@@ -6411,6 +8727,7 @@ async function dispatch(ctx, rest) {
|
|
|
6411
8727
|
if (command === 'ask') return cmdAsk(ctx, args);
|
|
6412
8728
|
if (command === 'broadcast') return cmdBroadcast(ctx, args);
|
|
6413
8729
|
if (command === 'task') return cmdTask(ctx, args);
|
|
8730
|
+
if (command === 'team') return cmdTeam(ctx, args);
|
|
6414
8731
|
if (command === 'msg') return cmdMsg(ctx, args);
|
|
6415
8732
|
if (command === 'lock') return cmdLock(ctx, args);
|
|
6416
8733
|
if (command === 'handoff') return cmdHandoff(ctx, args);
|