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