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