@logicseek/hello-cc 0.1.9 → 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 +59 -0
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +17 -3
  4. package/bin/hcc.mjs +3 -5656
  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 +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  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 +39 -3
  14. package/docs/guide.zh-CN.md +34 -2
  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 +13 -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 +111 -9
  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 +60 -7
  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 +227 -27
  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 +135 -6
  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 +5397 -263
@@ -0,0 +1,312 @@
1
+ // Runtime evidence helpers extracted from bin/hcc.mjs.
2
+ // Process/tmux evidence observation, optimistic evidence-checked peer
3
+ // mutation, and clock-safety observation adapters shared by web + GC.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { tx } from '../../db/schema.mjs';
8
+ import {
9
+ clockSafetyUnavailable,
10
+ observeClockSafety,
11
+ observeClockSafetyInTransaction
12
+ } from '../../core/coordination/clock-safety.mjs';
13
+ import {
14
+ captureLockAcquireSubject,
15
+ clockCandidatesFromLocks,
16
+ observeLockOwnerEvidence
17
+ } from '../../core/coordination/lock-evidence.mjs';
18
+ import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
19
+ import { inspectProcessIdentity } from '../../process/identity.mjs';
20
+ import {
21
+ runTmux,
22
+ tmuxManagedSessionName,
23
+ tmuxManagedSessionNameMatches,
24
+ tmuxManagedSessionPrefixMatches,
25
+ tmuxPaneInfo,
26
+ tmuxSessionEnvironmentValue
27
+ } from '../../tmux.mjs';
28
+
29
+ export function createEvidenceRuntime({ now }) {
30
+ function isProjectManagedTmuxSession(projectCtx, sessionName, sessionRoot = null) {
31
+ return tmuxManagedSessionPrefixMatches(projectCtx, sessionName, sessionRoot);
32
+ }
33
+
34
+ function liveProcessIdentity(pid) {
35
+ const inspected = inspectProcessIdentity(pid);
36
+ return inspected.state === 'live' ? inspected.identity : null;
37
+ }
38
+
39
+ function storedPeerIdentity(row) {
40
+ return row?.pid ? {
41
+ pid: Number(row.pid),
42
+ startToken: row.pid_start_token,
43
+ commandHash: row.pid_command_hash
44
+ } : null;
45
+ }
46
+
47
+ function processEvidenceFromRow(row, name = 'peer') {
48
+ return {
49
+ name,
50
+ storedIdentity: storedPeerIdentity(row),
51
+ current: inspectProcessIdentity(row?.pid)
52
+ };
53
+ }
54
+
55
+ function canonicalRoot(value) {
56
+ if (!value) return null;
57
+ try { return fs.realpathSync(value); }
58
+ catch {
59
+ try { return path.resolve(value); } catch { return null; }
60
+ }
61
+ }
62
+
63
+ function rootEvidence(expected, actual) {
64
+ const expectedRoot = canonicalRoot(expected);
65
+ const actualRoot = canonicalRoot(actual);
66
+ if (!expectedRoot || !actualRoot) {
67
+ return { state: 'unknown', expected: expectedRoot, actual: actualRoot };
68
+ }
69
+ return {
70
+ state: expectedRoot === actualRoot ? 'match' : 'mismatch',
71
+ expected: expectedRoot,
72
+ actual: actualRoot
73
+ };
74
+ }
75
+
76
+ function tmuxTargetMissing(error) {
77
+ return /can't find pane|can't find session|no server running/i.test(String(error?.message || ''));
78
+ }
79
+
80
+ function inspectTmuxTarget(expectedSession, target) {
81
+ let actualSession = null;
82
+ try {
83
+ actualSession = runTmux(['display-message', '-p', '-t', target, '#{session_name}']).trim() || null;
84
+ } catch (targetError) {
85
+ try {
86
+ runTmux(['has-session', '-t', expectedSession]);
87
+ actualSession = expectedSession;
88
+ } catch (sessionError) {
89
+ return {
90
+ session: { state: tmuxTargetMissing(sessionError) ? 'dead' : 'unknown', expected: expectedSession, actual: null },
91
+ pane: { state: tmuxTargetMissing(targetError) ? 'dead' : 'unknown', expected: target, actual: null },
92
+ paneInfo: null
93
+ };
94
+ }
95
+ }
96
+ if (!actualSession) {
97
+ try {
98
+ runTmux(['has-session', '-t', expectedSession]);
99
+ actualSession = expectedSession;
100
+ } catch (sessionError) {
101
+ return {
102
+ session: { state: tmuxTargetMissing(sessionError) ? 'dead' : 'unknown', expected: expectedSession, actual: null },
103
+ pane: { state: 'unknown', expected: target, actual: null },
104
+ paneInfo: null
105
+ };
106
+ }
107
+ }
108
+
109
+ try {
110
+ const paneInfo = tmuxPaneInfo(target);
111
+ const expectedPane = target === `${expectedSession}:0.0` ? paneInfo.pane : target;
112
+ return {
113
+ session: { state: 'live', expected: expectedSession, actual: actualSession },
114
+ pane: { state: paneInfo.dead ? 'dead' : 'live', expected: expectedPane, actual: paneInfo.pane },
115
+ paneInfo
116
+ };
117
+ } catch (error) {
118
+ return {
119
+ session: { state: 'live', expected: expectedSession, actual: actualSession },
120
+ pane: { state: tmuxTargetMissing(error) ? 'dead' : 'unknown', expected: target, actual: null },
121
+ paneInfo: null
122
+ };
123
+ }
124
+ }
125
+
126
+ function observePeerEvidence(projectCtx, row, binding = null) {
127
+ if (binding?.transport !== 'tmux') {
128
+ return resolvePeerEvidence({ peer: row, processes: [processEvidenceFromRow(row)] });
129
+ }
130
+
131
+ const expectedSession = tmuxManagedSessionName(projectCtx, row.id);
132
+ const runtimeTarget = binding.runtime_target || `${expectedSession}:0.0`;
133
+ const target = inspectTmuxTarget(expectedSession, runtimeTarget);
134
+ const panePid = target.paneInfo?.pid || row.pid;
135
+ const paneProcess = {
136
+ name: 'pane',
137
+ storedIdentity: Number(row?.pid) === Number(panePid) ? storedPeerIdentity(row) : null,
138
+ current: inspectProcessIdentity(panePid)
139
+ };
140
+ const actualRoot = target.session.actual
141
+ ? tmuxSessionEnvironmentValue(target.session.actual, 'HCC_ROOT')
142
+ : null;
143
+ if (target.session.actual && tmuxManagedSessionNameMatches(
144
+ projectCtx,
145
+ target.session.actual,
146
+ row.id,
147
+ actualRoot
148
+ )) {
149
+ target.session.expected = target.session.actual;
150
+ }
151
+ return resolvePeerEvidence({
152
+ peer: row,
153
+ processes: [processEvidenceFromRow(row, 'owner')],
154
+ tmux: {
155
+ managed: true,
156
+ session: target.session,
157
+ pane: target.pane,
158
+ root: rootEvidence(projectCtx.root, actualRoot),
159
+ process: paneProcess
160
+ }
161
+ });
162
+ }
163
+
164
+ function peerEvidenceFromDb(db, projectCtx, peerId) {
165
+ const row = db.prepare(`
166
+ SELECT id, status, pid, pid_start_token, pid_command_hash
167
+ FROM peers WHERE id = ?
168
+ `).get(peerId);
169
+ if (!row) return { state: 'unknown', reason: 'peer_missing' };
170
+ const binding = db.prepare(`
171
+ SELECT transport, runtime_target FROM peer_bindings WHERE peer = ?
172
+ `).get(peerId) || null;
173
+ return observePeerEvidence(projectCtx, row, binding);
174
+ }
175
+
176
+ function providerOwnerEvidenceFromDb(db, peerId) {
177
+ const row = db.prepare(`
178
+ SELECT id, status, pid, pid_start_token, pid_command_hash
179
+ FROM peers WHERE id = ?
180
+ `).get(peerId);
181
+ return row
182
+ ? resolvePeerEvidence({ peer: row, processes: [processEvidenceFromRow(row, 'provider-owner')] })
183
+ : { state: 'unknown', reason: 'peer_missing' };
184
+ }
185
+
186
+ function peerMutationSubject(db, peerId) {
187
+ const peer = db.prepare(`
188
+ SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at
189
+ FROM peers WHERE id = ?
190
+ `).get(peerId) || null;
191
+ const binding = db.prepare(`
192
+ SELECT peer, transport, runtime_target, updated_at
193
+ FROM peer_bindings WHERE peer = ?
194
+ `).get(peerId) || null;
195
+ return { peer, binding };
196
+ }
197
+
198
+ function mutatePeerWithEvidence(db, projectCtx, peerId, mutate, options = {}) {
199
+ const acceptEvidence = typeof options.acceptEvidence === 'function'
200
+ ? options.acceptEvidence
201
+ : (evidence) => evidence.state === 'dead';
202
+ for (let attempt = 0; attempt < 2; attempt += 1) {
203
+ const subject = peerMutationSubject(db, peerId);
204
+ if (!subject.peer) return { changed: false, evidence: { state: 'unknown', reason: 'peer_missing' } };
205
+ const evidence = observePeerEvidence(projectCtx, subject.peer, subject.binding);
206
+ if (!acceptEvidence(evidence, subject)) return { changed: false, evidence };
207
+ let subjectChanged = false;
208
+ let blocked = false;
209
+ const changed = tx(db, () => {
210
+ if (options.beforeMutate?.({ subject, evidence }) === false) {
211
+ blocked = true;
212
+ return false;
213
+ }
214
+ const current = peerMutationSubject(db, peerId);
215
+ if (JSON.stringify(current) !== JSON.stringify(subject)) {
216
+ subjectChanged = true;
217
+ return false;
218
+ }
219
+ mutate(current, evidence);
220
+ return true;
221
+ });
222
+ if (blocked) return { changed: false, evidence, blocked: true };
223
+ if (!subjectChanged) return { changed, evidence };
224
+ }
225
+ return { changed: false, evidence: { state: 'unknown', reason: 'subject_changed' } };
226
+ }
227
+
228
+ function mutateConfirmedDeadPeer(db, projectCtx, peerId, mutate, options = {}) {
229
+ return mutatePeerWithEvidence(db, projectCtx, peerId, mutate, options);
230
+ }
231
+
232
+ function observeClockSafetyOrThrow(db, options) {
233
+ try {
234
+ return observeClockSafety(db, options);
235
+ } catch (err) {
236
+ throw clockSafetyUnavailable(err);
237
+ }
238
+ }
239
+
240
+ function observeClockSafetyInTransactionOrThrow(db, options) {
241
+ try {
242
+ return observeClockSafetyInTransaction(db, options);
243
+ } catch (err) {
244
+ throw clockSafetyUnavailable(err);
245
+ }
246
+ }
247
+
248
+ function prepareLockClockObservation(db, subject, evidenceByOwner) {
249
+ return observeClockSafetyInTransactionOrThrow(db, {
250
+ operation: 'ownership',
251
+ candidates: clockCandidatesFromLocks(subject, evidenceByOwner),
252
+ nowSec: subject.observedAt
253
+ });
254
+ }
255
+
256
+ function observeLockClockSafety(db, projectCtx, {
257
+ requested = null,
258
+ taskId = null,
259
+ owner = null,
260
+ observedAt = now()
261
+ } = {}) {
262
+ try {
263
+ const subject = captureLockAcquireSubject(db, {
264
+ taskId,
265
+ requested,
266
+ now: observedAt
267
+ });
268
+ const evidenceByOwner = observeLockOwnerEvidence(subject, (row, binding) =>
269
+ observePeerEvidence(projectCtx, row, binding));
270
+ const candidates = subject.locks
271
+ .filter((lock) => Number(lock.expires_at) <= observedAt && (!owner || lock.owner === owner))
272
+ .map((lock) => ({
273
+ boundary: Number(lock.expires_at),
274
+ evidence: evidenceByOwner.get(lock.owner)?.state || 'unknown',
275
+ owner: lock.owner,
276
+ resource: lock.resource
277
+ }));
278
+ return observeClockSafetyOrThrow(db, {
279
+ operation: 'ownership',
280
+ candidates,
281
+ nowSec: observedAt
282
+ });
283
+ } catch (err) {
284
+ throw clockSafetyUnavailable(err);
285
+ }
286
+ }
287
+
288
+ function observeTaskTakeoverClockSafety({ db, row, ownerRow, evidence, staleAfter }) {
289
+ const boundary = ownerRow
290
+ ? Number(ownerRow.last_seen_at || 0) + Number(staleAfter)
291
+ : 0;
292
+ return observeClockSafetyOrThrow(db, {
293
+ operation: 'ownership',
294
+ candidates: row?.owner ? [{
295
+ boundary,
296
+ evidence: evidence?.state || 'unknown',
297
+ owner: row.owner
298
+ }] : [],
299
+ nowSec: now()
300
+ });
301
+ }
302
+
303
+ return {
304
+ isProjectManagedTmuxSession, liveProcessIdentity, storedPeerIdentity,
305
+ processEvidenceFromRow, canonicalRoot, rootEvidence, tmuxTargetMissing,
306
+ inspectTmuxTarget, observePeerEvidence, peerEvidenceFromDb,
307
+ providerOwnerEvidenceFromDb, peerMutationSubject, mutatePeerWithEvidence,
308
+ mutateConfirmedDeadPeer, observeClockSafetyOrThrow,
309
+ observeClockSafetyInTransactionOrThrow, prepareLockClockObservation,
310
+ observeLockClockSafety, observeTaskTakeoverClockSafety
311
+ };
312
+ }
@@ -0,0 +1,103 @@
1
+ import { compareProcessIdentity } from '../../process/identity.mjs';
2
+
3
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
4
+
5
+ export function classifyPeerActivity(peer = {}, options = {}) {
6
+ const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
7
+ const graceActive = Boolean(options.graceActive);
8
+ const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
9
+ const age = Number(peer.age_sec);
10
+ const active = evidenceState === 'live' ||
11
+ (evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
12
+ return { active, stale: !active };
13
+ }
14
+
15
+ export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
16
+ if (evidence.state === 'dead') return true;
17
+ if (evidence.state !== 'unknown') return false;
18
+ const nowSec = Number(options.nowSec);
19
+ const lastSeenAt = Number(options.lastSeenAt);
20
+ const staleAfterSec = Number(options.staleAfterSec);
21
+ const graceUntil = Number(options.graceUntil ?? 0);
22
+ if (![nowSec, lastSeenAt, staleAfterSec, graceUntil].every((value) =>
23
+ Number.isSafeInteger(value) && value >= 0)) return false;
24
+ return nowSec - lastSeenAt >= staleAfterSec && nowSec >= graceUntil;
25
+ }
26
+
27
+ function processState(evidence) {
28
+ if (!evidence) return 'unknown';
29
+ if (compareProcessIdentity(evidence.storedIdentity, evidence.storedIdentity) !== 'live') {
30
+ // CS-06: no usable stored fingerprint (legacy row without pid_start_token /
31
+ // pid_command_hash, or a missing pid). We cannot claim ownership — but a
32
+ // process that is demonstrably gone is still a dead process, so such peers
33
+ // are reaped once their process exits. A live-but-unverifiable process
34
+ // stays 'unknown' (protected).
35
+ return evidence.current?.state === 'dead' ? 'dead' : 'unknown';
36
+ }
37
+ if (evidence.current?.state === 'unknown') return 'unknown';
38
+ if (evidence.current?.state === 'dead') return 'dead';
39
+ if (evidence.current?.state !== 'live') return 'unknown';
40
+ return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
41
+ }
42
+
43
+ function aggregateProcesses(processes) {
44
+ if (!Array.isArray(processes) || processes.length === 0) {
45
+ return { state: 'unknown', reason: 'process_evidence_missing' };
46
+ }
47
+ const states = processes.map(processState);
48
+ if (states.includes('live')) {
49
+ return { state: 'live', reason: 'process_identity_match' };
50
+ }
51
+ if (states.every((state) => state === 'dead')) {
52
+ const missing = processes.every((item) => item?.current?.state === 'dead');
53
+ return {
54
+ state: 'dead',
55
+ reason: missing ? 'process_missing' : 'process_identity_mismatch'
56
+ };
57
+ }
58
+ return { state: 'unknown', reason: 'process_identity_incomplete' };
59
+ }
60
+
61
+ function tmuxProcessState(tmux) {
62
+ return processState(tmux?.process);
63
+ }
64
+
65
+ function tmuxConfirmedDead(tmux) {
66
+ const exactSession = tmux?.session?.state === 'live' &&
67
+ tmux?.session?.expected === tmux?.session?.actual;
68
+ return tmux?.session?.state === 'dead' ||
69
+ (exactSession && tmux?.pane?.state === 'dead');
70
+ }
71
+
72
+ function tmuxVerifiedLive(tmux) {
73
+ return tmux?.session?.state === 'live' &&
74
+ tmux?.session?.expected === tmux?.session?.actual &&
75
+ tmux?.pane?.state === 'live' &&
76
+ tmux?.pane?.expected === tmux?.pane?.actual &&
77
+ tmux?.root?.state === 'match' &&
78
+ tmuxProcessState(tmux) === 'live';
79
+ }
80
+
81
+ export function resolvePeerEvidence(input = {}) {
82
+ const ordinaryProcess = aggregateProcesses(input.processes);
83
+ if (ordinaryProcess.state === 'live') return ordinaryProcess;
84
+
85
+ const tmux = input.tmux?.managed ? input.tmux : null;
86
+ if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
87
+ return { state: 'live', reason: 'tmux_identity_match' };
88
+ }
89
+
90
+ if (input.peer?.status === 'exited') {
91
+ return { state: 'dead', reason: 'explicit_exited' };
92
+ }
93
+ if (ordinaryProcess.state === 'dead' || !tmux) return ordinaryProcess;
94
+
95
+ if (tmux.root?.state === 'mismatch') {
96
+ return { state: 'unknown', reason: 'tmux_root_mismatch' };
97
+ }
98
+ const paneProcess = tmuxProcessState(tmux);
99
+ if (tmuxConfirmedDead(tmux) && paneProcess === 'dead') {
100
+ return { state: 'dead', reason: 'tmux_and_process_dead' };
101
+ }
102
+ return { state: 'unknown', reason: 'tmux_evidence_incomplete' };
103
+ }
@@ -0,0 +1,32 @@
1
+ function requireObservedAt(value) {
2
+ if (!Number.isSafeInteger(value) || value < 0) {
3
+ throw new TypeError('observedAt must be a non-negative safe integer');
4
+ }
5
+ return value;
6
+ }
7
+
8
+ export function refreshHookOwnerIdentity(db, {
9
+ peerId,
10
+ status = null,
11
+ observedAt,
12
+ processIdentity = null
13
+ } = {}) {
14
+ if (!db || typeof db.prepare !== 'function') throw new TypeError('db is required');
15
+ if (typeof peerId !== 'string' || !peerId) throw new TypeError('peerId is required');
16
+ const timestamp = requireObservedAt(observedAt);
17
+ if (!processIdentity) {
18
+ return db.prepare(`
19
+ UPDATE peers
20
+ SET last_seen_at = ?, status = COALESCE(?, status), pid = NULL,
21
+ pid_start_token = NULL, pid_command_hash = NULL
22
+ WHERE id = ?
23
+ `).run(timestamp, status, peerId).changes;
24
+ }
25
+ return db.prepare(`
26
+ UPDATE peers
27
+ SET last_seen_at = ?, status = COALESCE(?, status), pid = ?,
28
+ pid_start_token = ?, pid_command_hash = ?
29
+ WHERE id = ?
30
+ `).run(timestamp, status, processIdentity.pid, processIdentity.startToken,
31
+ processIdentity.commandHash, peerId).changes;
32
+ }
@@ -1,9 +1,32 @@
1
1
  const DEFAULT_ACTIVE_PEER_TTL = 600;
2
2
 
3
+ import { clockGraceSuppressed } from '../../shared/clock-grace.mjs';
4
+ import { inspectProcessIdentity } from '../../process/identity.mjs';
5
+ import { classifyPeerActivity, resolvePeerEvidence } from './evidence.mjs';
6
+
3
7
  function nowSec() {
4
8
  return Math.floor(Date.now() / 1000);
5
9
  }
6
10
 
11
+ function rowEvidence(row) {
12
+ if (row?.evidence_state) {
13
+ return { state: row.evidence_state, reason: row.evidence_reason || 'observed' };
14
+ }
15
+ const storedIdentity = row?.pid ? {
16
+ pid: Number(row.pid),
17
+ startToken: row.pid_start_token,
18
+ commandHash: row.pid_command_hash
19
+ } : null;
20
+ return resolvePeerEvidence({
21
+ peer: row,
22
+ processes: row?.pid ? [{
23
+ name: 'peer',
24
+ storedIdentity,
25
+ current: inspectProcessIdentity(row.pid)
26
+ }] : []
27
+ });
28
+ }
29
+
7
30
  export function taskRelatedLocks(task, locks) {
8
31
  const taskId = Number(task?.id || 0);
9
32
  const owner = task?.owner || '';
@@ -13,7 +36,7 @@ export function taskRelatedLocks(task, locks) {
13
36
  });
14
37
  }
15
38
 
16
- export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
39
+ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL, graceUntil = 0) {
17
40
  const owner = task?.owner || null;
18
41
  const relatedLocks = taskRelatedLocks(task, locks);
19
42
  if (!owner) {
@@ -22,6 +45,8 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
22
45
  owner_active: null,
23
46
  owner_stale: false,
24
47
  owner_age_sec: null,
48
+ owner_evidence_state: 'unknown',
49
+ owner_evidence_reason: 'owner_missing',
25
50
  related_lock_count: relatedLocks.length,
26
51
  takeover_ready: false
27
52
  };
@@ -30,7 +55,18 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
30
55
  const ownerAge = ownerRow
31
56
  ? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
32
57
  : null;
33
- const ownerActive = Boolean(ownerRow && Number.isFinite(ownerAge) && ownerAge <= activePeerTtl);
58
+ const evidence = ownerRow ? rowEvidence(ownerRow) : { state: 'unknown', reason: 'peer_missing' };
59
+ // hb-05: after a wall-clock jump (sleep/NTP), age is untrustworthy for the
60
+ // grace window; treat every owner as active so a live owner's task is never
61
+ // spuriously marked stale/takeover-able.
62
+ const graceActive = clockGraceSuppressed(t, graceUntil);
63
+ const activity = classifyPeerActivity({
64
+ ...ownerRow,
65
+ age_sec: ownerAge,
66
+ evidence_state: evidence.state,
67
+ evidence_reason: evidence.reason
68
+ }, { activePeerTtl, graceActive });
69
+ const ownerActive = Boolean(ownerRow && activity.active);
34
70
  const ownerStale = !ownerActive;
35
71
  const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
36
72
  return {
@@ -38,15 +74,17 @@ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTt
38
74
  owner_active: ownerActive,
39
75
  owner_stale: ownerStale,
40
76
  owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
77
+ owner_evidence_state: evidence.state,
78
+ owner_evidence_reason: evidence.reason,
41
79
  related_lock_count: relatedLocks.length,
42
80
  takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
43
81
  };
44
82
  }
45
83
 
46
- export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
84
+ export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL, graceUntil = 0) {
47
85
  return (tasks || []).map((task) => ({
48
86
  ...task,
49
- ...taskOwnerLiveness(task, peers, locks, t, activePeerTtl)
87
+ ...taskOwnerLiveness(task, peers, locks, t, activePeerTtl, graceUntil)
50
88
  }));
51
89
  }
52
90
 
@@ -75,6 +113,8 @@ export function summarizeTask(task) {
75
113
  owner_active: task.owner_active ?? null,
76
114
  owner_stale: Boolean(task.owner_stale),
77
115
  owner_age_sec: task.owner_age_sec ?? null,
116
+ owner_evidence_state: task.owner_evidence_state || 'unknown',
117
+ owner_evidence_reason: task.owner_evidence_reason || null,
78
118
  related_lock_count: Number(task.related_lock_count || 0),
79
119
  takeover_ready: Boolean(task.takeover_ready)
80
120
  };
@@ -0,0 +1,121 @@
1
+ // Peer upsert/touch helpers extracted from bin/hcc.mjs.
2
+ // Factory pattern: callers inject the functions that remain in bin/hcc.mjs.
3
+
4
+ export function createPeerHelpers({
5
+ now,
6
+ addEvent,
7
+ liveProcessIdentity,
8
+ detectBranch,
9
+ registerProjectActivity,
10
+ upsertCanonicalPeerBinding,
11
+ autoPeerKind,
12
+ autoPeerSessionId,
13
+ autoPeerResumeId,
14
+ autoPeerDefaults,
15
+ autoPeerBasis,
16
+ providerSessionParts
17
+ }) {
18
+ function upsertPeer(db, peer) {
19
+ const t = now();
20
+ const identity = Object.hasOwn(peer, 'processIdentity')
21
+ ? peer.processIdentity
22
+ : liveProcessIdentity(peer.pid);
23
+ db.prepare(`
24
+ INSERT INTO peers(id, kind, role, worktree, branch, pid, pid_start_token, pid_command_hash, status, capabilities, created_at, last_seen_at)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
26
+ ON CONFLICT(id) DO UPDATE SET
27
+ kind = excluded.kind,
28
+ role = excluded.role,
29
+ worktree = excluded.worktree,
30
+ branch = excluded.branch,
31
+ pid = excluded.pid,
32
+ pid_start_token = excluded.pid_start_token,
33
+ pid_command_hash = excluded.pid_command_hash,
34
+ -- hb-08: an incidental upsert with the default 'idle' status must not
35
+ -- resurrect a peer that was explicitly marked exited/detached.
36
+ status = CASE WHEN excluded.status = 'idle' AND peers.status IN ('exited', 'detached')
37
+ THEN peers.status ELSE excluded.status END,
38
+ capabilities = excluded.capabilities,
39
+ last_seen_at = excluded.last_seen_at
40
+ `).run(
41
+ peer.id,
42
+ peer.kind || 'other',
43
+ peer.role || '',
44
+ peer.worktree || '',
45
+ peer.branch || '',
46
+ peer.pid || null,
47
+ identity?.startToken || null,
48
+ identity?.commandHash || null,
49
+ peer.status || 'idle',
50
+ peer.capabilities || '',
51
+ t,
52
+ t
53
+ );
54
+ }
55
+
56
+ function touchPeer(db, id, status = null) {
57
+ if (!id) return;
58
+ const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(id);
59
+ if (!existing) {
60
+ upsertPeer(db, {
61
+ id,
62
+ kind: 'other',
63
+ role: 'auto',
64
+ worktree: process.cwd(),
65
+ branch: detectBranch(process.cwd()),
66
+ pid: process.ppid,
67
+ status: status || 'idle',
68
+ capabilities: ''
69
+ });
70
+ } else {
71
+ db.prepare(`
72
+ UPDATE peers
73
+ SET last_seen_at = ?, status = COALESCE(?, status)
74
+ WHERE id = ?
75
+ `).run(now(), status, id);
76
+ }
77
+ }
78
+
79
+ function touchCurrentPeer(db, ctx, resolved, status = null, kindHint = 'shell') {
80
+ registerProjectActivity(ctx);
81
+ const identity = typeof resolved === 'string'
82
+ ? { id: resolved, auto: false }
83
+ : resolved;
84
+ if (!identity || !identity.id) return;
85
+ if (!identity.auto) {
86
+ touchPeer(db, identity.id, status);
87
+ return;
88
+ }
89
+
90
+ const existing = db.prepare('SELECT id FROM peers WHERE id = ?').get(identity.id);
91
+ if (existing) {
92
+ touchPeer(db, identity.id, status);
93
+ return;
94
+ }
95
+
96
+ const kind = autoPeerKind(kindHint);
97
+ const sessionId = autoPeerSessionId(kind);
98
+ const resumeId = autoPeerResumeId(kind);
99
+ upsertPeer(db, {
100
+ id: identity.id,
101
+ ...autoPeerDefaults(ctx, kindHint, status || 'idle')
102
+ });
103
+ upsertCanonicalPeerBinding(db, {
104
+ peer: identity.id,
105
+ provider: kind,
106
+ ...providerSessionParts(resumeId || sessionId),
107
+ resume_mode: resumeId ? 'resume' : (sessionId ? 'detected' : 'auto'),
108
+ resume_arg: resumeId || null,
109
+ command: null,
110
+ transport: process.env.TMUX_PANE ? 'auto-tmux' : 'auto-shell',
111
+ runtime_session_id: identity.id
112
+ }, true);
113
+ addEvent(db, 'peer.auto_joined', identity.id, null, {
114
+ root: ctx.root,
115
+ basis: autoPeerBasis(kind),
116
+ provider_session: resumeId || sessionId || null
117
+ });
118
+ }
119
+
120
+ return { upsertPeer, touchPeer, touchCurrentPeer };
121
+ }
@@ -1,7 +1,13 @@
1
- import { sanitizePeerPart, shortHash } from './format.mjs';
1
+ import { shortHash } from './format.mjs';
2
2
 
3
+ // sess-04: derive the peer id from a sha1 hash of the FULL provider session
4
+ // id/name, not the first 8 raw chars — name-based ids that share a prefix
5
+ // (e.g. codex `feature-login` vs `feature-logout`) used to collide into the
6
+ // same peer id. The shim script derives the identical value (see
7
+ // lib/integrations/shims/script.mjs peer_hash). NOTE: this changes existing
8
+ // peer ids for name-based resumes (pre-1.0 intentional identity change).
3
9
  export function providerSessionPeerId(kind, providerId) {
4
- return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
10
+ return `${kind}-${shortHash(String(providerId || ''))}`;
5
11
  }
6
12
 
7
13
  export function providerSessionParts(value) {