@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/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 VERSION = '0.1.2';
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 = '@logicseek/hello-cc';
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
- 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;
2803
+ return writeGuidanceForRoot(ctx.root);
2885
2804
  }
2886
2805
 
2887
2806
  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;
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 = tx(db, () => {
3190
- if (!opts.force) {
3191
- const current = db.prepare(`
3192
- SELECT * FROM tasks
3193
- WHERE owner = ?
3194
- AND status IN ('claimed', 'running', 'review', 'blocked')
3195
- ORDER BY
3196
- CASE status
3197
- WHEN 'running' THEN 0
3198
- WHEN 'claimed' THEN 1
3199
- WHEN 'review' THEN 2
3200
- WHEN 'blocked' THEN 3
3201
- ELSE 4
3202
- END,
3203
- priority ASC,
3204
- id ASC
3205
- LIMIT 1
3206
- `).get(peer);
3207
- if (current) return { ...current, current: true };
3208
- }
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
- touchCurrentPeer(db, ctx, identity, null, 'shell');
4014
- const t = now();
4015
- const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - ACTIVE_PEER_TTL).n;
4016
- const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - ACTIVE_PEER_TTL).n;
4017
- const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
4018
- const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
4019
- const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
4020
- const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
4021
- const data = { root: ctx.root, db: ctx.dbPath, active_peers: activePeers, stale_peers: stalePeers, tasks: taskRows, active_locks: locks, unread, recent_events: recent };
4022
- printResult(ctx, data, (s) => {
4023
- const taskSummary = s.tasks.length ? s.tasks.map((r) => `${r.status}:${r.n}`).join(', ') : 'none';
4024
- return [
4025
- `root: ${s.root}`,
4026
- `db: ${s.db}`,
4027
- `peers: active=${s.active_peers}, stale=${s.stale_peers}`,
4028
- `tasks: ${taskSummary}`,
4029
- `locks: active=${s.active_locks}`,
4030
- peer ? `inbox(${peer}): unread=${s.unread}` : null,
4031
- '',
4032
- 'recent events:',
4033
- table(s.recent_events, [
4034
- { label: 'id', value: (r) => `#${r.id}` },
4035
- { label: 'type', value: (r) => r.type },
4036
- { label: 'actor', value: (r) => r.actor || '' },
4037
- { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
4038
- { label: 'time', value: (r) => iso(r.created_at) }
4039
- ])
4040
- ].filter((line) => line !== null).join('\n');
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-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>
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.register': 'register',
5151
+ 'action.state': 'state',
5023
5152
  'action.inbox': 'inbox',
5024
- 'action.nextTask': 'next task',
5153
+ 'action.claimNextTask': 'claim next task',
5025
5154
  'action.status': 'status',
5026
- 'action.heartbeat': 'heartbeat',
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.register': '注册',
5262
+ 'action.state': '状态详情',
5131
5263
  'action.inbox': '收件箱',
5132
- 'action.nextTask': '下个任务',
5264
+ 'action.claimNextTask': '认领下个任务',
5133
5265
  'action.status': '状态',
5134
- 'action.heartbeat': '心跳',
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
- function peerStateView(peer, runtime = null, basisNow = lastStateNow) {
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
- return {
5487
- label: activity,
5488
- detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5489
- };
5490
- }
5621
+ return {
5622
+ label: activity,
5623
+ detail: statusText(liveness) + ' ' + tr('age') + '=' + age + branch
5624
+ };
5625
+ }
5491
5626
 
5492
- function bodyPinned(el) {
5493
- if (!el) return false;
5494
- return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
5495
- }
5627
+ function bodyPinned(el) {
5628
+ if (!el) return false;
5629
+ return el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
5630
+ }
5496
5631
 
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 \`
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
- 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 || {};
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
- const peers = peersData.map((a) => {
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
- }).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;
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
- async function refreshDetectedState() {
5687
- if (!activeDetected) return;
5688
- const data = await api('/api/state?peer=' + encodeURIComponent(activeDetected));
5689
- renderState(data);
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
- async function refreshCurrentState() {
5693
- if (activeType === 'detected') return refreshDetectedState();
5694
- return refreshState();
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
- document.querySelectorAll('[data-send]').forEach((button) => {
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);