@logicseek/hello-cc 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/bin/hcc.mjs +569 -280
- package/lib/guidance.mjs +115 -0
- package/lib/package-meta.mjs +24 -0
- package/lib/release-notes.mjs +40 -0
- package/package.json +1 -1
- package/scripts/github-release.mjs +11 -49
- package/scripts/regression.mjs +168 -8
- package/scripts/release-notes.mjs +6 -38
package/bin/hcc.mjs
CHANGED
|
@@ -10,16 +10,22 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
10
10
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
11
|
import { URL, fileURLToPath } from 'node:url';
|
|
12
12
|
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { readPackageMeta } from '../lib/package-meta.mjs';
|
|
14
|
+
import {
|
|
15
|
+
removeGuidanceBlocks as removeGuidanceBlocksForRoot,
|
|
16
|
+
writeGuidance as writeGuidanceForRoot
|
|
17
|
+
} from '../lib/guidance.mjs';
|
|
13
18
|
|
|
14
19
|
// Lazy-load lib modules (they may import node-pty which needs to be optional)
|
|
15
20
|
const _libDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'lib');
|
|
16
21
|
async function loadDiscover() { return import(path.join(_libDir, 'discover.mjs')); }
|
|
17
22
|
async function loadSetup() { return import(path.join(_libDir, 'setup.mjs')); }
|
|
18
23
|
|
|
19
|
-
const
|
|
24
|
+
const PACKAGE_META = readPackageMeta(path.resolve(fileURLToPath(import.meta.url), '..', '..'));
|
|
25
|
+
const VERSION = PACKAGE_META.version;
|
|
20
26
|
const PRODUCT_NAME = 'hello-cc';
|
|
21
27
|
const CLI_NAME = 'hcc';
|
|
22
|
-
const NPM_PACKAGE_NAME =
|
|
28
|
+
const NPM_PACKAGE_NAME = PACKAGE_META.name;
|
|
23
29
|
const DEFAULT_LOCK_TTL = 900;
|
|
24
30
|
const ACTIVE_PEER_TTL = 600;
|
|
25
31
|
const DB_SCHEMA_VERSION = 4;
|
|
@@ -2794,112 +2800,11 @@ function table(rows, columns) {
|
|
|
2794
2800
|
}
|
|
2795
2801
|
|
|
2796
2802
|
function writeGuidance(ctx) {
|
|
2797
|
-
|
|
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;
|
|
2803
|
+
return writeGuidanceForRoot(ctx.root);
|
|
2885
2804
|
}
|
|
2886
2805
|
|
|
2887
2806
|
function removeGuidanceBlocks(ctx) {
|
|
2888
|
-
|
|
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;
|
|
2807
|
+
return removeGuidanceBlocksForRoot(ctx.root);
|
|
2903
2808
|
}
|
|
2904
2809
|
|
|
2905
2810
|
function normalizeListText(value, fallback = []) {
|
|
@@ -3186,44 +3091,7 @@ async function taskNext(ctx, args) {
|
|
|
3186
3091
|
const peer = identity.id;
|
|
3187
3092
|
const db = connect(ctx);
|
|
3188
3093
|
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
3189
|
-
const task =
|
|
3190
|
-
if (!opts.force) {
|
|
3191
|
-
const current = db.prepare(`
|
|
3192
|
-
SELECT * FROM tasks
|
|
3193
|
-
WHERE owner = ?
|
|
3194
|
-
AND status IN ('claimed', 'running', 'review', 'blocked')
|
|
3195
|
-
ORDER BY
|
|
3196
|
-
CASE status
|
|
3197
|
-
WHEN 'running' THEN 0
|
|
3198
|
-
WHEN 'claimed' THEN 1
|
|
3199
|
-
WHEN 'review' THEN 2
|
|
3200
|
-
WHEN 'blocked' THEN 3
|
|
3201
|
-
ELSE 4
|
|
3202
|
-
END,
|
|
3203
|
-
priority ASC,
|
|
3204
|
-
id ASC
|
|
3205
|
-
LIMIT 1
|
|
3206
|
-
`).get(peer);
|
|
3207
|
-
if (current) return { ...current, current: true };
|
|
3208
|
-
}
|
|
3209
|
-
const row = db.prepare(`
|
|
3210
|
-
SELECT * FROM tasks
|
|
3211
|
-
WHERE status = 'pending'
|
|
3212
|
-
AND owner IS NULL
|
|
3213
|
-
AND (assignee IS NULL OR assignee = ?)
|
|
3214
|
-
ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
|
|
3215
|
-
LIMIT 1
|
|
3216
|
-
`).get(peer, peer);
|
|
3217
|
-
if (!row) return null;
|
|
3218
|
-
const t = now();
|
|
3219
|
-
db.prepare(`
|
|
3220
|
-
UPDATE tasks
|
|
3221
|
-
SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
|
|
3222
|
-
WHERE id = ? AND owner IS NULL AND status = 'pending'
|
|
3223
|
-
`).run(peer, t, t, row.id);
|
|
3224
|
-
addEvent(db, 'task.claimed', peer, row.id, { next: true });
|
|
3225
|
-
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
|
|
3226
|
-
});
|
|
3094
|
+
const task = claimNextTaskForPeer(db, peer, Boolean(opts.force));
|
|
3227
3095
|
printResult(ctx, task, (data) => {
|
|
3228
3096
|
if (!data) return 'no pending task';
|
|
3229
3097
|
return data.current
|
|
@@ -4009,36 +3877,46 @@ async function cmdStatus(ctx, args) {
|
|
|
4009
3877
|
const opts = parseOpts(args);
|
|
4010
3878
|
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
4011
3879
|
const peer = identity.id;
|
|
3880
|
+
const data = statusSummary(ctx, peer, identity);
|
|
3881
|
+
printResult(ctx, data, (s) => renderStatusSummary(s, peer));
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
function statusSummary(ctx, peer = null, identity = null) {
|
|
4012
3885
|
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
|
-
|
|
3886
|
+
try {
|
|
3887
|
+
if (identity) touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
3888
|
+
const t = now();
|
|
3889
|
+
const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - ACTIVE_PEER_TTL).n;
|
|
3890
|
+
const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - ACTIVE_PEER_TTL).n;
|
|
3891
|
+
const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
|
|
3892
|
+
const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
|
|
3893
|
+
const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
|
|
3894
|
+
const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
|
|
3895
|
+
return { root: ctx.root, db: ctx.dbPath, active_peers: activePeers, stale_peers: stalePeers, tasks: taskRows, active_locks: locks, unread, recent_events: recent };
|
|
3896
|
+
} finally {
|
|
3897
|
+
db.close();
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
function renderStatusSummary(s, peer = null) {
|
|
3902
|
+
const taskSummary = s.tasks.length ? s.tasks.map((r) => `${r.status}:${r.n}`).join(', ') : 'none';
|
|
3903
|
+
return [
|
|
3904
|
+
`root: ${s.root}`,
|
|
3905
|
+
`db: ${s.db}`,
|
|
3906
|
+
`peers: active=${s.active_peers}, stale=${s.stale_peers}`,
|
|
3907
|
+
`tasks: ${taskSummary}`,
|
|
3908
|
+
`locks: active=${s.active_locks}`,
|
|
3909
|
+
peer ? `inbox(${peer}): unread=${s.unread}` : null,
|
|
3910
|
+
'',
|
|
3911
|
+
'recent events:',
|
|
3912
|
+
table(s.recent_events, [
|
|
3913
|
+
{ label: 'id', value: (r) => `#${r.id}` },
|
|
3914
|
+
{ label: 'type', value: (r) => r.type },
|
|
3915
|
+
{ label: 'actor', value: (r) => r.actor || '' },
|
|
3916
|
+
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
3917
|
+
{ label: 'time', value: (r) => iso(r.created_at) }
|
|
3918
|
+
])
|
|
3919
|
+
].filter((line) => line !== null).join('\n');
|
|
4042
3920
|
}
|
|
4043
3921
|
|
|
4044
3922
|
function normalizeStateResources(values) {
|
|
@@ -4478,6 +4356,161 @@ function statusSnapshot(ctx, peer = null, opts = {}) {
|
|
|
4478
4356
|
}
|
|
4479
4357
|
}
|
|
4480
4358
|
|
|
4359
|
+
function webPeerRegister(projectCtx, peer, input = {}) {
|
|
4360
|
+
const db = connect(projectCtx);
|
|
4361
|
+
const row = {
|
|
4362
|
+
id: peer,
|
|
4363
|
+
kind: input.kind || 'other',
|
|
4364
|
+
role: input.role || 'peer',
|
|
4365
|
+
worktree: path.resolve(input.worktree || projectCtx.cwd),
|
|
4366
|
+
branch: input.branch || detectBranch(projectCtx.cwd),
|
|
4367
|
+
pid: intOpt(input, 'pid', process.ppid),
|
|
4368
|
+
status: input.status || 'idle',
|
|
4369
|
+
capabilities: Array.isArray(input.cap) ? input.cap.join(',') : (input.cap || input.capabilities || 'web')
|
|
4370
|
+
};
|
|
4371
|
+
try {
|
|
4372
|
+
upsertPeer(db, row);
|
|
4373
|
+
addEvent(db, 'peer.registered', peer, null, { ...row, source: 'web' });
|
|
4374
|
+
} finally {
|
|
4375
|
+
db.close();
|
|
4376
|
+
}
|
|
4377
|
+
return { peer: row, summary: `registered ${row.id} (${row.kind}${row.role ? `, ${row.role}` : ''})` };
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
function webPeerHeartbeat(projectCtx, peer, input = {}) {
|
|
4381
|
+
const ttl = intOpt(input, 'ttl', DEFAULT_LOCK_TTL);
|
|
4382
|
+
const status = input.status || null;
|
|
4383
|
+
const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
|
|
4384
|
+
const db = connect(projectCtx);
|
|
4385
|
+
const t = now();
|
|
4386
|
+
let renewed = 0;
|
|
4387
|
+
try {
|
|
4388
|
+
touchPeer(db, peer, status);
|
|
4389
|
+
if (renewLocks) {
|
|
4390
|
+
renewed = db.prepare(`
|
|
4391
|
+
UPDATE locks SET expires_at = ?
|
|
4392
|
+
WHERE owner = ? AND expires_at > ?
|
|
4393
|
+
`).run(t + ttl, peer, t).changes;
|
|
4394
|
+
}
|
|
4395
|
+
addEvent(db, 'peer.heartbeat', peer, null, { status, renewed, source: 'web' });
|
|
4396
|
+
} finally {
|
|
4397
|
+
db.close();
|
|
4398
|
+
}
|
|
4399
|
+
return {
|
|
4400
|
+
peer,
|
|
4401
|
+
status,
|
|
4402
|
+
renewed,
|
|
4403
|
+
summary: `heartbeat ${peer}${renewed ? `, renewed locks: ${renewed}` : ''}`
|
|
4404
|
+
};
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
function claimNextTaskForPeer(db, peer, force = false) {
|
|
4408
|
+
return tx(db, () => {
|
|
4409
|
+
if (!force) {
|
|
4410
|
+
const current = db.prepare(`
|
|
4411
|
+
SELECT * FROM tasks
|
|
4412
|
+
WHERE owner = ?
|
|
4413
|
+
AND status IN ('claimed', 'running', 'review', 'blocked')
|
|
4414
|
+
ORDER BY
|
|
4415
|
+
CASE status
|
|
4416
|
+
WHEN 'running' THEN 0
|
|
4417
|
+
WHEN 'claimed' THEN 1
|
|
4418
|
+
WHEN 'review' THEN 2
|
|
4419
|
+
WHEN 'blocked' THEN 3
|
|
4420
|
+
ELSE 4
|
|
4421
|
+
END,
|
|
4422
|
+
priority ASC,
|
|
4423
|
+
id ASC
|
|
4424
|
+
LIMIT 1
|
|
4425
|
+
`).get(peer);
|
|
4426
|
+
if (current) return { ...current, current: true };
|
|
4427
|
+
}
|
|
4428
|
+
const row = db.prepare(`
|
|
4429
|
+
SELECT * FROM tasks
|
|
4430
|
+
WHERE status = 'pending'
|
|
4431
|
+
AND owner IS NULL
|
|
4432
|
+
AND (assignee IS NULL OR assignee = ?)
|
|
4433
|
+
ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
|
|
4434
|
+
LIMIT 1
|
|
4435
|
+
`).get(peer, peer);
|
|
4436
|
+
if (!row) return null;
|
|
4437
|
+
const t = now();
|
|
4438
|
+
db.prepare(`
|
|
4439
|
+
UPDATE tasks
|
|
4440
|
+
SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
|
|
4441
|
+
WHERE id = ? AND owner IS NULL AND status = 'pending'
|
|
4442
|
+
`).run(peer, t, t, row.id);
|
|
4443
|
+
addEvent(db, 'task.claimed', peer, row.id, { next: true });
|
|
4444
|
+
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id);
|
|
4445
|
+
});
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
function webPeerTaskNext(projectCtx, peer, input = {}) {
|
|
4449
|
+
const db = connect(projectCtx);
|
|
4450
|
+
try {
|
|
4451
|
+
touchPeer(db, peer, 'working');
|
|
4452
|
+
const task = claimNextTaskForPeer(db, peer, Boolean(input.force));
|
|
4453
|
+
if (!task) return { peer, task: null, summary: 'no pending task' };
|
|
4454
|
+
return {
|
|
4455
|
+
peer,
|
|
4456
|
+
task,
|
|
4457
|
+
current: Boolean(task.current),
|
|
4458
|
+
summary: task.current
|
|
4459
|
+
? `current task #${task.id}: ${task.title} (${task.status})`
|
|
4460
|
+
: `claimed task #${task.id}: ${task.title}`
|
|
4461
|
+
};
|
|
4462
|
+
} finally {
|
|
4463
|
+
db.close();
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
function webPeerInbox(projectCtx, peer, input = {}) {
|
|
4468
|
+
const db = connect(projectCtx);
|
|
4469
|
+
try {
|
|
4470
|
+
const messages = queryInbox(db, peer, Boolean(input.all), intOpt(input, 'limit', 20));
|
|
4471
|
+
return {
|
|
4472
|
+
peer,
|
|
4473
|
+
messages,
|
|
4474
|
+
summary: messages.length ? `${messages.length} message${messages.length === 1 ? '' : 's'}` : 'no messages'
|
|
4475
|
+
};
|
|
4476
|
+
} finally {
|
|
4477
|
+
db.close();
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
function webPeerAction(projectCtx, peer, action, input = {}) {
|
|
4482
|
+
const normalized = String(action || '').replace(/_/g, '-');
|
|
4483
|
+
if (!peer) throw new CliError('BAD_REQUEST', 'peer required');
|
|
4484
|
+
if (normalized === 'status') {
|
|
4485
|
+
const status = statusSummary(projectCtx, peer);
|
|
4486
|
+
return { ok: true, action: normalized, peer, summary: `active=${status.active_peers}, stale=${status.stale_peers}, locks=${status.active_locks}, unread=${status.unread ?? 0}`, data: status };
|
|
4487
|
+
}
|
|
4488
|
+
if (normalized === 'state') {
|
|
4489
|
+
const data = statusSnapshot(projectCtx, peer, {
|
|
4490
|
+
resources: normalizeStateResources(input.resource || input.resources || []),
|
|
4491
|
+
intent: input.intent || null
|
|
4492
|
+
});
|
|
4493
|
+
return { ok: true, action: normalized, peer, summary: data.automation?.next_action?.reason || 'state loaded', data };
|
|
4494
|
+
}
|
|
4495
|
+
if (normalized === 'inbox') {
|
|
4496
|
+
const data = webPeerInbox(projectCtx, peer, input);
|
|
4497
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4498
|
+
}
|
|
4499
|
+
if (normalized === 'task-next') {
|
|
4500
|
+
const data = webPeerTaskNext(projectCtx, peer, input);
|
|
4501
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4502
|
+
}
|
|
4503
|
+
if (normalized === 'heartbeat') {
|
|
4504
|
+
const data = webPeerHeartbeat(projectCtx, peer, input);
|
|
4505
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4506
|
+
}
|
|
4507
|
+
if (normalized === 'register') {
|
|
4508
|
+
const data = webPeerRegister(projectCtx, peer, input);
|
|
4509
|
+
return { ok: true, action: normalized, peer, summary: data.summary, data };
|
|
4510
|
+
}
|
|
4511
|
+
throw new CliError('BAD_REQUEST', `Unknown peer action: ${action}`);
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4481
4514
|
function webIndexHtml() {
|
|
4482
4515
|
return `<!doctype html>
|
|
4483
4516
|
<html lang="en">
|
|
@@ -4824,6 +4857,47 @@ function webIndexHtml() {
|
|
|
4824
4857
|
color: var(--text);
|
|
4825
4858
|
}
|
|
4826
4859
|
.menu button:hover { background: #1b1f26; }
|
|
4860
|
+
.menu .divider {
|
|
4861
|
+
height: 1px;
|
|
4862
|
+
margin: 4px 2px;
|
|
4863
|
+
background: var(--border);
|
|
4864
|
+
}
|
|
4865
|
+
.action-result {
|
|
4866
|
+
position: fixed;
|
|
4867
|
+
right: 16px;
|
|
4868
|
+
top: 64px;
|
|
4869
|
+
z-index: 1200;
|
|
4870
|
+
width: min(520px, calc(100vw - 32px));
|
|
4871
|
+
max-height: min(68vh, 640px);
|
|
4872
|
+
display: grid;
|
|
4873
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
4874
|
+
background: var(--panel);
|
|
4875
|
+
border: 1px solid var(--border);
|
|
4876
|
+
border-radius: 8px;
|
|
4877
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.5);
|
|
4878
|
+
overflow: hidden;
|
|
4879
|
+
}
|
|
4880
|
+
.action-result[hidden] { display: none; }
|
|
4881
|
+
.action-result header {
|
|
4882
|
+
display: flex;
|
|
4883
|
+
align-items: center;
|
|
4884
|
+
justify-content: space-between;
|
|
4885
|
+
gap: 10px;
|
|
4886
|
+
padding: 9px 10px;
|
|
4887
|
+
border-bottom: 1px solid var(--border);
|
|
4888
|
+
}
|
|
4889
|
+
.action-result header strong { font-size: 13px; }
|
|
4890
|
+
.action-result pre {
|
|
4891
|
+
margin: 0;
|
|
4892
|
+
padding: 10px;
|
|
4893
|
+
overflow: auto;
|
|
4894
|
+
white-space: pre-wrap;
|
|
4895
|
+
word-break: break-word;
|
|
4896
|
+
font-family: var(--mono);
|
|
4897
|
+
font-size: 11px;
|
|
4898
|
+
color: var(--text);
|
|
4899
|
+
background: #0d0f12;
|
|
4900
|
+
}
|
|
4827
4901
|
#terminal {
|
|
4828
4902
|
min-height: 0;
|
|
4829
4903
|
overflow: hidden;
|
|
@@ -4856,6 +4930,9 @@ function webIndexHtml() {
|
|
|
4856
4930
|
display: grid;
|
|
4857
4931
|
grid-template-rows: auto minmax(0, 1fr);
|
|
4858
4932
|
}
|
|
4933
|
+
.card.state-card.state-card-collapsed {
|
|
4934
|
+
grid-template-rows: auto 0;
|
|
4935
|
+
}
|
|
4859
4936
|
.card h2 {
|
|
4860
4937
|
margin: 0;
|
|
4861
4938
|
padding: 8px 10px;
|
|
@@ -4867,6 +4944,44 @@ function webIndexHtml() {
|
|
|
4867
4944
|
align-items: center;
|
|
4868
4945
|
gap: 8px;
|
|
4869
4946
|
}
|
|
4947
|
+
.state-card-toggle {
|
|
4948
|
+
width: 100%;
|
|
4949
|
+
min-width: 0;
|
|
4950
|
+
border: 0;
|
|
4951
|
+
border-bottom: 1px solid var(--border);
|
|
4952
|
+
border-radius: 0;
|
|
4953
|
+
background: transparent;
|
|
4954
|
+
color: inherit;
|
|
4955
|
+
padding: 8px 10px;
|
|
4956
|
+
display: flex;
|
|
4957
|
+
align-items: center;
|
|
4958
|
+
justify-content: space-between;
|
|
4959
|
+
gap: 8px;
|
|
4960
|
+
cursor: pointer;
|
|
4961
|
+
text-align: left;
|
|
4962
|
+
}
|
|
4963
|
+
.state-card-toggle:hover { background: #151922; }
|
|
4964
|
+
.state-card-toggle-title {
|
|
4965
|
+
min-width: 0;
|
|
4966
|
+
display: flex;
|
|
4967
|
+
align-items: center;
|
|
4968
|
+
gap: 8px;
|
|
4969
|
+
overflow: hidden;
|
|
4970
|
+
}
|
|
4971
|
+
.state-card-toggle-title strong {
|
|
4972
|
+
overflow: hidden;
|
|
4973
|
+
text-overflow: ellipsis;
|
|
4974
|
+
white-space: nowrap;
|
|
4975
|
+
}
|
|
4976
|
+
.state-card-chevron {
|
|
4977
|
+
flex: 0 0 auto;
|
|
4978
|
+
color: var(--muted);
|
|
4979
|
+
font-size: 12px;
|
|
4980
|
+
line-height: 1;
|
|
4981
|
+
}
|
|
4982
|
+
.state-card.state-card-collapsed .state-card-chevron {
|
|
4983
|
+
transform: rotate(-90deg);
|
|
4984
|
+
}
|
|
4870
4985
|
.card .body {
|
|
4871
4986
|
padding: 8px 10px;
|
|
4872
4987
|
display: grid;
|
|
@@ -4881,6 +4996,9 @@ function webIndexHtml() {
|
|
|
4881
4996
|
scrollbar-width: thin;
|
|
4882
4997
|
scrollbar-color: #3a3f4a transparent;
|
|
4883
4998
|
}
|
|
4999
|
+
.state-card.state-card-collapsed .body {
|
|
5000
|
+
display: none;
|
|
5001
|
+
}
|
|
4884
5002
|
.state-card .body::-webkit-scrollbar { width: 8px; }
|
|
4885
5003
|
.state-card .body::-webkit-scrollbar-track { background: transparent; }
|
|
4886
5004
|
.state-card .body::-webkit-scrollbar-thumb { background: #3a3f4a; border-radius: 4px; }
|
|
@@ -4947,11 +5065,14 @@ function webIndexHtml() {
|
|
|
4947
5065
|
<div class="menu-wrap">
|
|
4948
5066
|
<button class="menu-btn" id="actionsBtn" type="button" aria-haspopup="true" aria-expanded="false"><span data-i18n="actions">Actions</span> ▾</button>
|
|
4949
5067
|
<div class="menu" id="actionsMenu" hidden>
|
|
4950
|
-
<button data-
|
|
4951
|
-
<button data-
|
|
4952
|
-
<button data-
|
|
4953
|
-
<button data-
|
|
4954
|
-
<button data-
|
|
5068
|
+
<button data-action="state" data-i18n="action.state">state</button>
|
|
5069
|
+
<button data-action="status" data-i18n="action.status">status</button>
|
|
5070
|
+
<button data-action="inbox" data-i18n="action.inbox">inbox</button>
|
|
5071
|
+
<button data-action="task-next" data-i18n="action.claimNextTask">claim next task</button>
|
|
5072
|
+
<button data-action="heartbeat" data-i18n="action.renewHeartbeat">renew heartbeat</button>
|
|
5073
|
+
<div class="divider" role="separator"></div>
|
|
5074
|
+
<button data-action="register" data-i18n="action.reregister">re-register peer</button>
|
|
5075
|
+
<button data-terminal-action="status" data-i18n="action.runStatusTerminal">run status in terminal</button>
|
|
4955
5076
|
</div>
|
|
4956
5077
|
</div>
|
|
4957
5078
|
<button class="danger" id="stopBtn" type="button" data-i18n="stop">stop</button>
|
|
@@ -4961,6 +5082,14 @@ function webIndexHtml() {
|
|
|
4961
5082
|
<div id="detectedPanel" style="display:none;overflow:auto;flex:1"></div>
|
|
4962
5083
|
</main>
|
|
4963
5084
|
|
|
5085
|
+
<section class="action-result" id="actionResult" hidden aria-live="polite">
|
|
5086
|
+
<header>
|
|
5087
|
+
<strong id="actionResultTitle"></strong>
|
|
5088
|
+
<button id="actionResultClose" type="button" aria-label="Close">×</button>
|
|
5089
|
+
</header>
|
|
5090
|
+
<pre id="actionResultBody"></pre>
|
|
5091
|
+
</section>
|
|
5092
|
+
|
|
4964
5093
|
<aside class="inspector">
|
|
4965
5094
|
<div class="brand">
|
|
4966
5095
|
<strong data-i18n="projectState">Project State</strong>
|
|
@@ -5019,11 +5148,14 @@ function webIndexHtml() {
|
|
|
5019
5148
|
noSessionSelected: 'No session selected',
|
|
5020
5149
|
startOrSelect: 'Start or select a session from the left panel',
|
|
5021
5150
|
actions: 'Actions',
|
|
5022
|
-
'action.
|
|
5151
|
+
'action.state': 'state',
|
|
5023
5152
|
'action.inbox': 'inbox',
|
|
5024
|
-
'action.
|
|
5153
|
+
'action.claimNextTask': 'claim next task',
|
|
5025
5154
|
'action.status': 'status',
|
|
5026
|
-
'action.
|
|
5155
|
+
'action.renewHeartbeat': 'renew heartbeat',
|
|
5156
|
+
'action.reregister': 're-register peer',
|
|
5157
|
+
'action.runStatusTerminal': 'run status in terminal',
|
|
5158
|
+
actionResult: 'Action Result',
|
|
5027
5159
|
stop: 'stop',
|
|
5028
5160
|
projectState: 'Project State',
|
|
5029
5161
|
refresh: 'Refresh',
|
|
@@ -5127,11 +5259,14 @@ function webIndexHtml() {
|
|
|
5127
5259
|
noSessionSelected: '未选择会话',
|
|
5128
5260
|
startOrSelect: '从左侧面板启动或选择一个会话',
|
|
5129
5261
|
actions: '操作',
|
|
5130
|
-
'action.
|
|
5262
|
+
'action.state': '状态详情',
|
|
5131
5263
|
'action.inbox': '收件箱',
|
|
5132
|
-
'action.
|
|
5264
|
+
'action.claimNextTask': '认领下个任务',
|
|
5133
5265
|
'action.status': '状态',
|
|
5134
|
-
'action.
|
|
5266
|
+
'action.renewHeartbeat': '续期心跳',
|
|
5267
|
+
'action.reregister': '重新注册协作方',
|
|
5268
|
+
'action.runStatusTerminal': '在终端运行状态命令',
|
|
5269
|
+
actionResult: '操作结果',
|
|
5135
5270
|
stop: '停止',
|
|
5136
5271
|
projectState: '项目状态',
|
|
5137
5272
|
refresh: '刷新',
|
|
@@ -5466,7 +5601,7 @@ function webIndexHtml() {
|
|
|
5466
5601
|
return seen > 0 && (t - seen) <= activePeerTtl;
|
|
5467
5602
|
}
|
|
5468
5603
|
|
|
5469
|
-
|
|
5604
|
+
function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
|
|
5470
5605
|
const activity = peer?.status || 'unknown';
|
|
5471
5606
|
const liveness = peerIsActive(peer, basisNow) ? 'active' : 'stale';
|
|
5472
5607
|
const age = fmtAge(peer?.age_sec);
|
|
@@ -5483,32 +5618,62 @@ function webIndexHtml() {
|
|
|
5483
5618
|
detail: tr('lastSeen') + '=' + statusText(activity) + ' ' + tr('age') + '=' + age + branch
|
|
5484
5619
|
};
|
|
5485
5620
|
}
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5621
|
+
return {
|
|
5622
|
+
label: activity,
|
|
5623
|
+
detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
|
|
5624
|
+
};
|
|
5625
|
+
}
|
|
5491
5626
|
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5627
|
+
function bodyPinned(el) {
|
|
5628
|
+
if (!el) return false;
|
|
5629
|
+
return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
|
5630
|
+
}
|
|
5496
5631
|
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5632
|
+
function stateCardCollapsed(section) {
|
|
5633
|
+
return localStorage.getItem('hcc.stateCard.' + section + '.collapsed') === '1';
|
|
5634
|
+
}
|
|
5635
|
+
|
|
5636
|
+
function stateCardHtml(section, title, count, bodyHtml) {
|
|
5637
|
+
const collapsed = stateCardCollapsed(section);
|
|
5638
|
+
return \`
|
|
5639
|
+
<div class="card state-card \${collapsed ? 'state-card-collapsed' : ''}" data-section="\${esc(section)}">
|
|
5640
|
+
<button class="state-card-toggle" type="button" aria-expanded="\${collapsed ? 'false' : 'true'}">
|
|
5641
|
+
<span class="state-card-toggle-title"><strong>\${esc(title)}</strong> <span class="badge">\${esc(count)}</span></span>
|
|
5642
|
+
<span class="state-card-chevron">⌄</span>
|
|
5643
|
+
</button>
|
|
5644
|
+
<div class="body">\${bodyHtml}</div>
|
|
5645
|
+
</div>\`;
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
function bindStateCardToggles() {
|
|
5649
|
+
document.querySelectorAll('.state-card[data-section] .state-card-toggle').forEach((button) => {
|
|
5650
|
+
button.addEventListener('click', () => {
|
|
5651
|
+
const card = button.closest('.state-card[data-section]');
|
|
5652
|
+
if (!card) return;
|
|
5653
|
+
const section = card.dataset.section || '';
|
|
5654
|
+
const collapsed = !card.classList.contains('state-card-collapsed');
|
|
5655
|
+
card.classList.toggle('state-card-collapsed', collapsed);
|
|
5656
|
+
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
5657
|
+
localStorage.setItem('hcc.stateCard.' + section + '.collapsed', collapsed ? '1' : '0');
|
|
5658
|
+
});
|
|
5659
|
+
});
|
|
5660
|
+
}
|
|
5661
|
+
|
|
5662
|
+
function renderTimelineItem(item) {
|
|
5663
|
+
const meta = [
|
|
5664
|
+
item.source + ':' + item.source_id,
|
|
5665
|
+
item.task_id ? tr('task') + ' #' + item.task_id : '',
|
|
5666
|
+
item.thread_id ? tr('thread') + ' #' + item.thread_id : '',
|
|
5667
|
+
item.direction || '',
|
|
5668
|
+
fmtTime(item.ts)
|
|
5669
|
+
].filter(Boolean).join(' · ');
|
|
5670
|
+
return \`
|
|
5506
5671
|
<div class="item timeline-item">
|
|
5507
5672
|
<strong>\${esc(item.title || item.kind || item.source)} <span class="badge">\${esc(item.kind || item.source)}</span></strong>
|
|
5508
5673
|
<span>\${esc(meta)}</span>
|
|
5509
5674
|
\${item.text ? '<span>' + esc(item.text) + '</span>' : ''}
|
|
5510
5675
|
</div>\`;
|
|
5511
|
-
|
|
5676
|
+
}
|
|
5512
5677
|
|
|
5513
5678
|
document.getElementById('projectSelect').addEventListener('change', (event) => {
|
|
5514
5679
|
switchProject(event.target.value).catch(console.error);
|
|
@@ -5610,71 +5775,72 @@ function webIndexHtml() {
|
|
|
5610
5775
|
lastStateNow = Number(data.now || lastStateNow);
|
|
5611
5776
|
const state = document.getElementById('state');
|
|
5612
5777
|
const preserveScroll = lastStateRoot === stateRoot;
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5778
|
+
const savedStateScroll = preserveScroll ? state.scrollTop : 0;
|
|
5779
|
+
const savedCardScroll = preserveScroll
|
|
5780
|
+
? new Map(
|
|
5781
|
+
[...state.querySelectorAll('.state-card[data-section]')].map((card) => [
|
|
5782
|
+
card.dataset.section,
|
|
5783
|
+
{
|
|
5784
|
+
top: card.querySelector('.body')?.scrollTop || 0,
|
|
5785
|
+
pinned: bodyPinned(card.querySelector('.body'))
|
|
5786
|
+
}
|
|
5787
|
+
])
|
|
5788
|
+
)
|
|
5789
|
+
: new Map();
|
|
5790
|
+
const runtimeById = new Map();
|
|
5791
|
+
for (const session of sessions || []) {
|
|
5792
|
+
runtimeById.set(session.id, session);
|
|
5793
|
+
const peerId = sessionPeerId(session);
|
|
5794
|
+
if (peerId) runtimeById.set(peerId, session);
|
|
5795
|
+
}
|
|
5796
|
+
const tasksData = data.tasks || [];
|
|
5797
|
+
const peersData = data.peers || [];
|
|
5798
|
+
const locksData = data.locks || [];
|
|
5799
|
+
const messagesData = data.messages || [];
|
|
5800
|
+
const timelineData = data.timeline || [];
|
|
5801
|
+
const automation = data.automation || {};
|
|
5802
|
+
const nextAction = automation.next_action || {};
|
|
5638
5803
|
const tasks = tasksData.map((t) => \`
|
|
5639
5804
|
<div class="item"><strong>#\${t.id} \${esc(t.title)}</strong><span>\${esc(statusText(t.status))} \${esc(tr('owner'))}=\${esc(t.owner || '')} \${esc(tr('assignee'))}=\${esc(t.assignee || '')}</span></div>
|
|
5640
5805
|
\`).join('') || '<div class="empty">' + esc(tr('noTasks')) + '</div>';
|
|
5641
|
-
|
|
5806
|
+
const peers = peersData.map((a) => {
|
|
5642
5807
|
const peerRuntime = runtimeById.get(a.id);
|
|
5643
5808
|
const peerState = peerStateView(a, peerRuntime, data.now);
|
|
5644
5809
|
return \`
|
|
5645
5810
|
<div class="item"><strong>\${esc(a.id)} <span class="badge">\${esc(a.kind)}</span> <span class="badge \${badgeClass(peerState.label)}">\${esc(statusText(peerState.label))}</span></strong><span>\${esc(peerState.detail)}</span></div>
|
|
5646
5811
|
\`;
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
}
|
|
5676
|
-
lastStateRoot = stateRoot;
|
|
5812
|
+
}).join('') || '<div class="empty">' + esc(tr('noPeers')) + '</div>';
|
|
5813
|
+
const locks = locksData.map((l) => \`
|
|
5814
|
+
<div class="item"><strong>\${esc(l.resource)}</strong><span>\${esc(tr('owner'))}=\${esc(l.owner)} \${esc(tr('task'))}=\${l.task_id ? '#' + l.task_id : ''}</span></div>
|
|
5815
|
+
\`).join('') || '<div class="empty">' + esc(tr('noActiveLocks')) + '</div>';
|
|
5816
|
+
const messages = messagesData.map((m) => \`
|
|
5817
|
+
<div class="item"><strong>#\${m.id} \${esc(m.sender)} → \${esc(m.recipient || tr('all'))}\${m.reply_to ? ' ' + esc(tr('reply')) + ' #' + m.reply_to : ''}</strong><span>\${esc(m.body)}</span></div>
|
|
5818
|
+
\`).join('') || '<div class="empty">' + esc(tr('noMessages')) + '</div>';
|
|
5819
|
+
const timeline = timelineData.map(renderTimelineItem).join('') || '<div class="empty">' + esc(tr('noTimelineItems')) + '</div>';
|
|
5820
|
+
const actionLines = [
|
|
5821
|
+
'<div class="item"><strong>' + esc(statusText(automation.phase || 'idle')) + '</strong><span>' + esc(nextAction.reason || tr('noImmediateAction')) + '</span></div>',
|
|
5822
|
+
nextAction.command ? '<div class="item"><strong>' + esc(tr('next')) + '</strong><span class="mono">' + esc(nextAction.command) + '</span></div>' : '',
|
|
5823
|
+
(automation.finish_actions || []).length ? '<div class="item"><strong>' + esc(tr('finish')) + '</strong><span>' + esc(automation.finish_actions.map((a) => a.command).join(' | ')) + '</span></div>' : '',
|
|
5824
|
+
(automation.warnings || []).length ? '<div class="item"><strong>' + esc(tr('warnings')) + '</strong><span>' + esc(automation.warnings.join(' | ')) + '</span></div>' : ''
|
|
5825
|
+
].filter(Boolean).join('');
|
|
5826
|
+
state.innerHTML = [
|
|
5827
|
+
stateCardHtml('automation', tr('nextAction'), statusText(automation.phase || 'idle'), actionLines),
|
|
5828
|
+
stateCardHtml('timeline', tr('timeline'), timelineData.length, timeline),
|
|
5829
|
+
stateCardHtml('messages', tr('messages'), messagesData.length, messages),
|
|
5830
|
+
stateCardHtml('peers', tr('peers'), peersData.length, peers),
|
|
5831
|
+
stateCardHtml('tasks', tr('tasks'), tasksData.length, tasks),
|
|
5832
|
+
stateCardHtml('locks', tr('locks'), locksData.length, locks)
|
|
5833
|
+
].join('');
|
|
5834
|
+
state.scrollTop = savedStateScroll;
|
|
5835
|
+
for (const [section, saved] of savedCardScroll) {
|
|
5836
|
+
const body = state.querySelector('.state-card[data-section="' + section + '"] .body');
|
|
5837
|
+
if (!body) continue;
|
|
5838
|
+
if (section === 'timeline' && saved.pinned) body.scrollTop = body.scrollHeight;
|
|
5839
|
+
else body.scrollTop = saved.top;
|
|
5677
5840
|
}
|
|
5841
|
+
bindStateCardToggles();
|
|
5842
|
+
lastStateRoot = stateRoot;
|
|
5843
|
+
}
|
|
5678
5844
|
|
|
5679
5845
|
async function refreshState() {
|
|
5680
5846
|
const peer = active ? managedPeerId(active) : null;
|
|
@@ -5683,16 +5849,16 @@ function webIndexHtml() {
|
|
|
5683
5849
|
renderState(data);
|
|
5684
5850
|
}
|
|
5685
5851
|
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5852
|
+
async function refreshDetectedState() {
|
|
5853
|
+
if (!activeDetected) return;
|
|
5854
|
+
const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
|
|
5855
|
+
renderState(data);
|
|
5856
|
+
}
|
|
5691
5857
|
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5858
|
+
async function refreshCurrentState() {
|
|
5859
|
+
if (activeType === 'detected') return refreshDetectedState();
|
|
5860
|
+
return refreshState();
|
|
5861
|
+
}
|
|
5696
5862
|
|
|
5697
5863
|
// ── Connect to managed (PTY) session ─────────────────────────────────
|
|
5698
5864
|
function connectManaged(id) {
|
|
@@ -5829,22 +5995,108 @@ function webIndexHtml() {
|
|
|
5829
5995
|
connectManaged(data.session.id);
|
|
5830
5996
|
});
|
|
5831
5997
|
|
|
5832
|
-
|
|
5998
|
+
function activePeerInfo() {
|
|
5999
|
+
if (!active) return null;
|
|
6000
|
+
const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
|
|
6001
|
+
return {
|
|
6002
|
+
session,
|
|
6003
|
+
peerId: sessionPeerId(session) || active
|
|
6004
|
+
};
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
function terminalCommandForAction(action, info) {
|
|
6008
|
+
const session = info.session || {};
|
|
6009
|
+
const peerId = info.peerId;
|
|
6010
|
+
const lines = {
|
|
6011
|
+
register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
|
|
6012
|
+
inbox: \`hcc msg inbox --peer \${peerId}\`,
|
|
6013
|
+
'task-next': \`hcc task next --peer \${peerId}\`,
|
|
6014
|
+
state: \`hcc state --peer \${peerId}\`,
|
|
6015
|
+
status: \`hcc status --peer \${peerId}\`,
|
|
6016
|
+
heartbeat:\`hcc heartbeat --peer \${peerId} --renew-locks\`
|
|
6017
|
+
};
|
|
6018
|
+
return lines[action] || lines.status;
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
function formatActionResult(result) {
|
|
6022
|
+
if (!result) return '';
|
|
6023
|
+
const data = result.data || {};
|
|
6024
|
+
if (result.action === 'status') {
|
|
6025
|
+
const tasks = (data.tasks || []).map((row) => row.status + ':' + row.n).join(', ') || 'none';
|
|
6026
|
+
return [
|
|
6027
|
+
data.root,
|
|
6028
|
+
'peers active=' + data.active_peers + ' stale=' + data.stale_peers,
|
|
6029
|
+
'tasks ' + tasks,
|
|
6030
|
+
'locks active=' + data.active_locks,
|
|
6031
|
+
'unread ' + (data.unread ?? 0)
|
|
6032
|
+
].filter(Boolean).join('\\n');
|
|
6033
|
+
}
|
|
6034
|
+
if (result.action === 'state') {
|
|
6035
|
+
const automation = data.automation || {};
|
|
6036
|
+
const next = automation.next_action || {};
|
|
6037
|
+
return [
|
|
6038
|
+
'phase: ' + (automation.phase || 'idle'),
|
|
6039
|
+
'next: ' + (next.command || next.kind || 'none'),
|
|
6040
|
+
'why: ' + (next.reason || tr('noImmediateAction')),
|
|
6041
|
+
...(automation.warnings || []).map((w) => 'warning: ' + w)
|
|
6042
|
+
].join('\\n');
|
|
6043
|
+
}
|
|
6044
|
+
if (result.action === 'inbox') {
|
|
6045
|
+
const messages = data.messages || [];
|
|
6046
|
+
return messages.length
|
|
6047
|
+
? messages.map((m) => '#' + m.id + ' ' + m.sender + ' -> ' + (m.recipient || 'all') + ': ' + m.body).join('\\n')
|
|
6048
|
+
: tr('noMessages');
|
|
6049
|
+
}
|
|
6050
|
+
if (result.action === 'task-next') {
|
|
6051
|
+
return data.task
|
|
6052
|
+
? (data.current ? 'current ' : 'claimed ') + '#' + data.task.id + ' ' + data.task.title + ' (' + data.task.status + ')'
|
|
6053
|
+
: result.summary;
|
|
6054
|
+
}
|
|
6055
|
+
return result.summary || JSON.stringify(result, null, 2);
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
function showActionResult(result) {
|
|
6059
|
+
const panel = document.getElementById('actionResult');
|
|
6060
|
+
document.getElementById('actionResultTitle').textContent = (result.action || tr('actionResult')) + ' · ' + (result.peer || '');
|
|
6061
|
+
document.getElementById('actionResultBody').textContent = formatActionResult(result);
|
|
6062
|
+
panel.hidden = false;
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
async function runPeerAction(action) {
|
|
6066
|
+
const info = activePeerInfo();
|
|
6067
|
+
if (!info) return;
|
|
6068
|
+
const payload = action === 'register'
|
|
6069
|
+
? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
|
|
6070
|
+
: action === 'heartbeat'
|
|
6071
|
+
? { renew_locks: true }
|
|
6072
|
+
: {};
|
|
6073
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
6074
|
+
const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
|
|
6075
|
+
? {}
|
|
6076
|
+
: { method: 'POST', body: JSON.stringify(payload) });
|
|
6077
|
+
showActionResult(result);
|
|
6078
|
+
await Promise.all([refreshSessions(), refreshDetected(), refreshCurrentState()]);
|
|
6079
|
+
}
|
|
6080
|
+
|
|
6081
|
+
document.querySelectorAll('[data-action]').forEach((button) => {
|
|
5833
6082
|
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
6083
|
closeActionsMenu();
|
|
6084
|
+
runPeerAction(button.dataset.action).catch((err) => {
|
|
6085
|
+
showActionResult({ action: button.dataset.action, peer: activePeerInfo()?.peerId || '', summary: err.message, data: { error: err.message } });
|
|
6086
|
+
});
|
|
5846
6087
|
});
|
|
5847
6088
|
});
|
|
6089
|
+
document.querySelectorAll('[data-terminal-action]').forEach((button) => {
|
|
6090
|
+
button.addEventListener('click', () => {
|
|
6091
|
+
const info = activePeerInfo();
|
|
6092
|
+
if (!info) return;
|
|
6093
|
+
sendLine(terminalCommandForAction(button.dataset.terminalAction, info));
|
|
6094
|
+
closeActionsMenu();
|
|
6095
|
+
});
|
|
6096
|
+
});
|
|
6097
|
+
document.getElementById('actionResultClose').addEventListener('click', () => {
|
|
6098
|
+
document.getElementById('actionResult').hidden = true;
|
|
6099
|
+
});
|
|
5848
6100
|
|
|
5849
6101
|
// ── Actions dropdown (declutters the toolbar) ─────────────────────────
|
|
5850
6102
|
const actionsBtn = document.getElementById('actionsBtn');
|
|
@@ -6426,6 +6678,10 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
6426
6678
|
const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
6427
6679
|
const pipeFile = path.join(bufsDir, `tmux-${safePane}-${safeId}.pipe`);
|
|
6428
6680
|
session.pipeFile = pipeFile;
|
|
6681
|
+
// Restored panes may still have pipe-pane writers from a previous runtime.
|
|
6682
|
+
// Disable first so tmux tears down the stale writer before this runtime
|
|
6683
|
+
// installs its own FIFO reader.
|
|
6684
|
+
try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
|
|
6429
6685
|
// Capture the existing screen once for the initial paint; pipe-pane only
|
|
6430
6686
|
// forwards output produced after it is enabled.
|
|
6431
6687
|
try {
|
|
@@ -6494,6 +6750,7 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
6494
6750
|
function stopTmuxStream(session) {
|
|
6495
6751
|
if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
|
|
6496
6752
|
if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
|
|
6753
|
+
if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
|
|
6497
6754
|
// Turn piping back off for this pane (no command toggles it off).
|
|
6498
6755
|
try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
|
|
6499
6756
|
if (session.streamFd !== null && session.streamFd !== undefined) {
|
|
@@ -6612,11 +6869,21 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
6612
6869
|
try { fs.appendFileSync(session.inFile, data); } catch {}
|
|
6613
6870
|
} else if (session.type === 'tmux') {
|
|
6614
6871
|
tmuxSendLiteral(session.pane, data);
|
|
6872
|
+
scheduleTmuxInputRefresh(session);
|
|
6615
6873
|
} else {
|
|
6616
6874
|
session.pty.write(data);
|
|
6617
6875
|
}
|
|
6618
6876
|
}
|
|
6619
6877
|
|
|
6878
|
+
function scheduleTmuxInputRefresh(session) {
|
|
6879
|
+
if (session.type !== 'tmux' || !session.pane) return;
|
|
6880
|
+
if (session.inputRefreshTimer) return;
|
|
6881
|
+
session.inputRefreshTimer = setTimeout(() => {
|
|
6882
|
+
session.inputRefreshTimer = null;
|
|
6883
|
+
if (session.status === 'running') broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
|
|
6884
|
+
}, 80);
|
|
6885
|
+
}
|
|
6886
|
+
|
|
6620
6887
|
function resizeSession(session, cols, rows) {
|
|
6621
6888
|
if (session.type === 'external') {
|
|
6622
6889
|
try { fs.appendFileSync(session.resizeFile, JSON.stringify({ cols, rows }) + '\n'); } catch {}
|
|
@@ -6990,6 +7257,28 @@ async function cmdWeb(ctx, args, startMeta = {}) {
|
|
|
6990
7257
|
}));
|
|
6991
7258
|
return;
|
|
6992
7259
|
}
|
|
7260
|
+
const peerActionMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/actions\/([^/]+)$/);
|
|
7261
|
+
if (peerActionMatch) {
|
|
7262
|
+
const peer = decodeURIComponent(peerActionMatch[1]);
|
|
7263
|
+
const action = decodeURIComponent(peerActionMatch[2]);
|
|
7264
|
+
const readOnly = ['status', 'state', 'inbox'].includes(action);
|
|
7265
|
+
if (readOnly && req.method !== 'GET') {
|
|
7266
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use GET for read-only peer actions' } });
|
|
7267
|
+
return;
|
|
7268
|
+
}
|
|
7269
|
+
if (!readOnly && req.method !== 'POST') {
|
|
7270
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use POST for mutating peer actions' } });
|
|
7271
|
+
return;
|
|
7272
|
+
}
|
|
7273
|
+
const input = readOnly
|
|
7274
|
+
? {
|
|
7275
|
+
...Object.fromEntries(url.searchParams.entries()),
|
|
7276
|
+
resource: url.searchParams.getAll('resource')
|
|
7277
|
+
}
|
|
7278
|
+
: await readJsonRequest(req);
|
|
7279
|
+
sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));
|
|
7280
|
+
return;
|
|
7281
|
+
}
|
|
6993
7282
|
// Detected sessions: peers registered via hooks/watcher but without PTY
|
|
6994
7283
|
if (req.method === 'GET' && url.pathname === '/api/detected') {
|
|
6995
7284
|
const db = connect(reqCtx);
|