@logicseek/hello-cc 0.1.8 → 1.0.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,135 @@
1
+ // Up command extracted from bin/hcc.mjs.
2
+ // hcc up bootstraps the local coordination bus: guidance, provider hooks,
3
+ // optional shims, and provider-session discovery.
4
+
5
+ import { parseOpts, validateOpts } from '../../cli-args.mjs';
6
+ import { printResult } from '../../format.mjs';
7
+ import { commandPath } from '../../cli-runtime.mjs';
8
+ import { detectBranch } from '../../project-context.mjs';
9
+ import { bindingFromDetected } from '../../core/peers/bindings.mjs';
10
+
11
+ export function createUpCommand(deps) {
12
+ const {
13
+ connect, helpUp, PRODUCT_NAME,
14
+ loadSetup, loadDiscover,
15
+ writeGuidance, sameResolvedPath,
16
+ upsertPeer, upsertCanonicalPeerBinding
17
+ } = deps;
18
+
19
+ async function cmdUp(ctx, args) {
20
+ if (args[0] === '--help' || args[0] === '-h') return helpUp();
21
+ const opts = parseOpts(args, { booleans: ['no-guidance', 'no-discover'] });
22
+ validateOpts('up', opts, ['no-guidance', 'no-discover']);
23
+ const result = await prepareLocalBus(ctx, opts);
24
+ return printResult(ctx, result, (r) => {
25
+ const lines = [
26
+ `${PRODUCT_NAME} local coordination ready`,
27
+ `project: ${r.root}`,
28
+ `database: ${r.db}`
29
+ ];
30
+ if (r.guidance) lines.push(`guidance: ${r.guidance}`);
31
+ if (r.hooks.claudeInstalled) lines.push('Claude Code hooks installed');
32
+ if (r.hooks.codexInstalled) lines.push('Codex hooks installed');
33
+ if (r.detected.length) lines.push(`detected: ${r.detected.map((s) => s.peerId).join(', ')}`);
34
+ if (r.warnings?.length) lines.push(...r.warnings.map((warning) => `warning: ${warning}`));
35
+ lines.push('web: run hcc web when you need browser terminal control');
36
+ return lines.join('\n');
37
+ });
38
+ }
39
+
40
+ async function prepareLocalBus(ctx, opts = {}) {
41
+ let guidance = null;
42
+ const db = connect(ctx);
43
+ try {
44
+ guidance = opts['no-guidance'] ? null : writeGuidance(ctx);
45
+ } finally {
46
+ db.close();
47
+ }
48
+
49
+ const hooks = { claudeInstalled: false, codexInstalled: false };
50
+ const shims = { installed: [], skipped: [], pathUpdated: false, rcFile: null };
51
+ const warnings = [];
52
+ try {
53
+ const setup = await loadSetup();
54
+ try {
55
+ if (!setup.verifyClaudeHooks()) {
56
+ setup.installClaudeHooks(commandPath());
57
+ hooks.claudeInstalled = true;
58
+ }
59
+ } catch (err) {
60
+ warnings.push(`Claude Code hooks installation failed: ${err.message}`);
61
+ }
62
+ try {
63
+ if (!setup.verifyCodexHooks()) {
64
+ setup.installCodexHooks(commandPath());
65
+ hooks.codexInstalled = true;
66
+ }
67
+ } catch (err) {
68
+ warnings.push(`Codex hooks installation failed: ${err.message}`);
69
+ }
70
+ if (opts.installShims) {
71
+ try {
72
+ const result = setup.installShims(commandPath());
73
+ shims.installed = result.installed;
74
+ shims.skipped = result.skipped;
75
+ if (result.installed.length) {
76
+ const pathResult = setup.installPathEntry();
77
+ shims.pathUpdated = !pathResult.alreadyPresent;
78
+ shims.rcFile = pathResult.rcFile;
79
+ }
80
+ } catch (err) {
81
+ warnings.push(`shim installation failed: ${err.message}`);
82
+ }
83
+ }
84
+ } catch (err) {
85
+ warnings.push(`local integration setup failed: ${err.message}`);
86
+ }
87
+
88
+ const detected = [];
89
+ if (!opts['no-discover']) {
90
+ try {
91
+ const { scanClaudeSessions, scanCodexSessions, scanProcesses } = await loadDiscover();
92
+ const found = [
93
+ ...scanClaudeSessions(),
94
+ ...scanCodexSessions(),
95
+ ...scanProcesses(),
96
+ ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
97
+ const byId = new Map();
98
+ for (const s of found) {
99
+ if (!byId.has(s.peerId)) byId.set(s.peerId, s);
100
+ }
101
+ if (byId.size > 0) {
102
+ const db2 = connect(ctx);
103
+ try {
104
+ for (const s of byId.values()) {
105
+ detected.push(s);
106
+ upsertPeer(db2, {
107
+ id: s.peerId, kind: s.kind, role: 'peer',
108
+ worktree: s.cwd,
109
+ branch: detectBranch(s.cwd),
110
+ pid: s.pid,
111
+ status: 'running',
112
+ capabilities: 'detected'
113
+ });
114
+ upsertCanonicalPeerBinding(db2, bindingFromDetected(s, s.transport || 'detected'), true);
115
+ }
116
+ } finally {
117
+ db2.close();
118
+ }
119
+ }
120
+ } catch {}
121
+ }
122
+
123
+ return {
124
+ root: ctx.root,
125
+ db: ctx.dbPath,
126
+ guidance,
127
+ hooks,
128
+ shims,
129
+ detected,
130
+ warnings
131
+ };
132
+ }
133
+
134
+ return { cmdUp, prepareLocalBus };
135
+ }
package/lib/cli-args.mjs CHANGED
@@ -83,7 +83,21 @@ export function required(opts, key, envName = null) {
83
83
 
84
84
  export function intOpt(opts, key, fallback = null) {
85
85
  if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
86
- const value = Number.parseInt(String(opts[key]), 10);
87
- if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
86
+ const text = String(opts[key]);
87
+ if (!/^-?[0-9]+$/.test(text)) {
88
+ throw new CliError('BAD_ARGS', `--${key} must be a safe integer`);
89
+ }
90
+ const value = Number(text);
91
+ if (!Number.isSafeInteger(value)) {
92
+ throw new CliError('BAD_ARGS', `--${key} must be a safe integer`);
93
+ }
94
+ return value;
95
+ }
96
+
97
+ export function positiveSafeIntOpt(opts, key, fallback = null) {
98
+ const value = intOpt(opts, key, fallback);
99
+ if (!Number.isSafeInteger(value) || value < 1) {
100
+ throw new CliError('BAD_ARGS', `--${key} must be a positive safe integer`);
101
+ }
88
102
  return value;
89
103
  }
@@ -8,6 +8,9 @@ import {
8
8
  } from './core/peers/liveness.mjs';
9
9
  import { lockLabel } from './core/coordination/locks.mjs';
10
10
  import { timelineFromRows } from './core/coordination/timeline.mjs';
11
+ import { readTx } from './db/schema.mjs';
12
+ import { clockGraceSuppressed, readClockGraceUntil } from './shared/clock-grace.mjs';
13
+ import { classifyPeerActivity } from './core/peers/evidence.mjs';
11
14
 
12
15
  const DEFAULT_ACTIVE_PEER_TTL = 600;
13
16
  const DEFAULT_LOCK_TTL = 900;
@@ -28,57 +31,97 @@ export function createCoordinationState(deps = {}) {
28
31
  const connect = requireFn(deps.connect, 'connect');
29
32
  const defaultLockTtl = Number(deps.defaultLockTtl ?? DEFAULT_LOCK_TTL);
30
33
  const now = typeof deps.now === 'function' ? deps.now : defaultNow;
34
+ const observePeerEvidence = requireFn(deps.observePeerEvidence, 'observePeerEvidence');
31
35
  const queryInbox = requireFn(deps.queryInbox, 'queryInbox');
32
36
  const queryOpenTasks = requireFn(deps.queryOpenTasks, 'queryOpenTasks');
33
37
  const queryTimelineMessages = requireFn(deps.queryTimelineMessages, 'queryTimelineMessages');
34
- const touchCurrentPeer = typeof deps.touchCurrentPeer === 'function'
35
- ? deps.touchCurrentPeer
36
- : () => {};
38
+ function annotatePeerEvidence(ctx, peers, bindings, graceActive = false) {
39
+ const bindingByPeer = new Map();
40
+ for (const binding of bindings || []) {
41
+ if (!bindingByPeer.has(binding.peer)) bindingByPeer.set(binding.peer, binding);
42
+ }
43
+ return (peers || []).map((peer) => {
44
+ const evidence = observePeerEvidence(ctx, peer, bindingByPeer.get(peer.id) || null);
45
+ const { pid_start_token: _startToken, pid_command_hash: _commandHash, ...publicPeer } = peer;
46
+ const observed = {
47
+ ...publicPeer,
48
+ evidence_state: evidence.state,
49
+ evidence_reason: evidence.reason
50
+ };
51
+ return {
52
+ ...observed,
53
+ ...classifyPeerActivity(observed, { activePeerTtl, graceActive })
54
+ };
55
+ });
56
+ }
57
+
58
+ function visibleLocks(allLocks, peers, t, graceActive) {
59
+ if (graceActive) return allLocks;
60
+ const peerById = new Map((peers || []).map((peer) => [peer.id, peer]));
61
+ return (allLocks || []).filter((lock) =>
62
+ Number(lock.expires_at) > t || peerById.get(lock.owner)?.evidence_state === 'live'
63
+ );
64
+ }
37
65
 
38
66
  function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
39
67
  const t = now();
40
- const peers = db.prepare(`
41
- SELECT id, kind, role, status, worktree, branch, pid, capabilities,
42
- created_at, last_seen_at, (? - last_seen_at) AS age_sec
43
- FROM peers
44
- ORDER BY last_seen_at DESC, id ASC
45
- LIMIT 200
46
- `).all(t);
47
- const taskRows = queryOpenTasks(db, 200);
48
- const locks = db.prepare(`
49
- SELECT resource, base_resource, scope, owner, task_id, reason, expires_at, created_at
50
- FROM locks
51
- WHERE expires_at > ?
52
- ORDER BY resource ASC
53
- LIMIT 200
54
- `).all(t);
55
- const tasks = annotateTasksWithLiveness(taskRows, peers, locks, t, activePeerTtl);
56
- const messages = peer
57
- ? queryInbox(db, peer, false, 50)
58
- : db.prepare(`
59
- SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
60
- FROM messages
61
- ORDER BY id DESC
62
- LIMIT 50
63
- `).all().reverse();
64
- const timelineMessages = queryTimelineMessages(db, peer, 80);
65
- const handoffs = db.prepare(`
66
- SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
67
- FROM handoffs
68
- ORDER BY id DESC
69
- LIMIT 50
70
- `).all().reverse();
71
- const events = db.prepare(`
72
- SELECT id, type, actor, task_id, payload, created_at
73
- FROM events
74
- ORDER BY id DESC
75
- LIMIT 80
76
- `).all().reverse();
68
+ // Read every table inside one deferred transaction for a consistent
69
+ // snapshot (conc-06): without this, a heartbeat between reads can make
70
+ // counts drift and produce a transiently wrong takeover_ready.
71
+ const { peerRows, bindings, taskRows, allLocks, messages, timelineMessages, handoffs, events, graceUntil } = readTx(db, () => {
72
+ const peers = db.prepare(`
73
+ SELECT id, kind, role, status, worktree, branch, pid,
74
+ pid_start_token, pid_command_hash, capabilities,
75
+ created_at, last_seen_at, (? - last_seen_at) AS age_sec
76
+ FROM peers
77
+ ORDER BY last_seen_at DESC, id ASC
78
+ `).all(t);
79
+ const bindings = db.prepare(`
80
+ SELECT peer, transport, runtime_target, updated_at, created_at
81
+ FROM peer_bindings
82
+ ORDER BY updated_at DESC, created_at DESC
83
+ `).all();
84
+ const taskRows = queryOpenTasks(db, 200);
85
+ const graceUntil = readClockGraceUntil(db);
86
+ const allLocks = db.prepare(`
87
+ SELECT resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec
88
+ FROM locks
89
+ ORDER BY resource ASC
90
+ `).all();
91
+ const messages = peer
92
+ ? queryInbox(db, peer, false, 50)
93
+ : db.prepare(`
94
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
95
+ FROM messages
96
+ ORDER BY id DESC
97
+ LIMIT 50
98
+ `).all().reverse();
99
+ const timelineMessages = queryTimelineMessages(db, peer, 80);
100
+ const handoffs = db.prepare(`
101
+ SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
102
+ FROM handoffs
103
+ ORDER BY id DESC
104
+ LIMIT 50
105
+ `).all().reverse();
106
+ const events = db.prepare(`
107
+ SELECT id, type, actor, task_id, payload, created_at
108
+ FROM events
109
+ ORDER BY id DESC
110
+ LIMIT 80
111
+ `).all().reverse();
112
+ return { peerRows: peers, bindings, taskRows, allLocks, messages, timelineMessages, handoffs, events, graceUntil };
113
+ });
114
+ const graceActive = clockGraceSuppressed(t, graceUntil);
115
+ const peers = annotatePeerEvidence(ctx, peerRows, bindings, graceActive);
116
+ const locks = visibleLocks(allLocks, peers, t, graceActive);
117
+ const tasks = annotateTasksWithLiveness(taskRows, peers, locks, t, activePeerTtl, graceUntil);
77
118
  const snapshot = {
78
119
  root: ctx.root,
79
120
  db: ctx.dbPath,
80
121
  now: t,
81
122
  active_peer_ttl: activePeerTtl,
123
+ clock_grace_active: graceActive,
124
+ clock_grace_until: graceUntil || null,
82
125
  peers,
83
126
  tasks,
84
127
  locks,
@@ -114,8 +157,7 @@ export function createCoordinationState(deps = {}) {
114
157
  if (peers.length > 0) {
115
158
  parts.push('[hello-cc known peers]');
116
159
  parts.push(...peers.map((peer) => {
117
- const age = Math.max(0, snapshot.now - Number(peer.last_seen_at || 0));
118
- const active = age <= activePeerTtl ? 'active' : 'stale';
160
+ const active = peer.active ? 'active' : 'stale';
119
161
  return `- ${peer.id} ${peer.kind || 'other'} ${peer.status || 'idle'} ${active}`;
120
162
  }));
121
163
  } else {
@@ -169,24 +211,43 @@ export function createCoordinationState(deps = {}) {
169
211
  }
170
212
  }
171
213
 
172
- function statusSummary(ctx, peer = null, identity = null) {
214
+ function statusSummary(ctx, peer = null, _identity = null) {
173
215
  const db = connect(ctx);
174
216
  try {
175
- if (identity) touchCurrentPeer(db, ctx, identity, null, 'shell');
176
217
  const t = now();
177
- const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - activePeerTtl).n;
178
- const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - activePeerTtl).n;
179
- const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
180
- const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
181
- const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
182
- const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
218
+ const { peerRows, bindings, taskRows, allLocks, unread, recent, graceUntil } = readTx(db, () => ({
219
+ peerRows: db.prepare(`
220
+ SELECT id, kind, role, status, worktree, branch, pid,
221
+ pid_start_token, pid_command_hash, capabilities,
222
+ created_at, last_seen_at, (? - last_seen_at) AS age_sec
223
+ FROM peers
224
+ ORDER BY last_seen_at DESC, id ASC
225
+ `).all(t),
226
+ bindings: db.prepare(`
227
+ SELECT peer, transport, runtime_target, updated_at, created_at
228
+ FROM peer_bindings
229
+ ORDER BY updated_at DESC, created_at DESC
230
+ `).all(),
231
+ taskRows: db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all(),
232
+ allLocks: db.prepare('SELECT * FROM locks').all(),
233
+ unread: peer ? queryInbox(db, peer, false, 1000).length : null,
234
+ recent: db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse(),
235
+ graceUntil: readClockGraceUntil(db)
236
+ }));
237
+ const graceActive = clockGraceSuppressed(t, graceUntil);
238
+ const peers = annotatePeerEvidence(ctx, peerRows, bindings, graceActive);
239
+ const activePeers = peers.filter((row) => row.active).length;
240
+ const stalePeers = peers.length - activePeers;
241
+ const locks = visibleLocks(allLocks, peers, t, graceActive);
183
242
  return {
184
243
  root: ctx.root,
185
244
  db: ctx.dbPath,
186
245
  active_peers: activePeers,
187
246
  stale_peers: stalePeers,
247
+ clock_grace_active: graceActive,
248
+ clock_grace_until: graceUntil || null,
188
249
  tasks: taskRows,
189
- active_locks: locks,
250
+ active_locks: locks.length,
190
251
  unread,
191
252
  recent_events: recent
192
253
  };
@@ -13,15 +13,12 @@ import {
13
13
  uniqueList
14
14
  } from './timeline.mjs';
15
15
  import { summarizeTask } from '../peers/liveness.mjs';
16
+ import { classifyPeerActivity } from '../peers/evidence.mjs';
16
17
 
17
18
  const DEFAULT_CLI_NAME = 'hcc';
18
19
  const DEFAULT_ACTIVE_PEER_TTL = 600;
19
20
  const DEFAULT_LOCK_TTL = 900;
20
21
 
21
- function nowSec() {
22
- return Math.floor(Date.now() / 1000);
23
- }
24
-
25
22
  export function actionCommand(argv, cliName = DEFAULT_CLI_NAME) {
26
23
  if (!argv?.length) return '';
27
24
  return [cliName, ...argv].map(shellQuoteArg).join(' ');
@@ -67,7 +64,7 @@ export function deriveAutomation(snapshot, peer = null, opts = {}, config = {})
67
64
  makeAction(kind, argv, reason, mutates, extra, { cliName });
68
65
 
69
66
  const peerId = peer || '';
70
- const t = Number(snapshot.now || nowSec());
67
+ const graceActive = Boolean(snapshot.clock_grace_active);
71
68
  const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
72
69
  const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
73
70
  const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
@@ -82,8 +79,8 @@ export function deriveAutomation(snapshot, peer = null, opts = {}, config = {})
82
79
  const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
83
80
  const requestedLocks = resources.map((resource) => {
84
81
  const requested = scopedLockResource(resource, scope);
85
- const lock = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && locksConflict(row, requested));
86
- const exact = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope);
82
+ const lock = (snapshot.locks || []).find((row) => locksConflict(row, requested));
83
+ const exact = (snapshot.locks || []).find((row) => lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope);
87
84
  return { ...requested, lock, exact };
88
85
  });
89
86
  const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
@@ -100,7 +97,7 @@ export function deriveAutomation(snapshot, peer = null, opts = {}, config = {})
100
97
  peer: peerId ? {
101
98
  id: peerId,
102
99
  known: Boolean(peerRow),
103
- active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= activePeerTtl),
100
+ active: Boolean(peerRow && classifyPeerActivity(peerRow, { activePeerTtl, graceActive }).active),
104
101
  age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
105
102
  } : null,
106
103
  current_task: summarizeTask(ownedTask),