@logicseek/hello-cc 0.1.4 → 0.1.6

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 (90) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/bin/hcc.mjs +2739 -6482
  3. package/docs/README.md +2 -0
  4. package/docs/README.zh-CN.md +3 -1
  5. package/docs/architecture-split-review.md +217 -0
  6. package/docs/architecture.md +385 -0
  7. package/docs/architecture.zh-CN.md +364 -0
  8. package/docs/commands.md +21 -9
  9. package/docs/commands.zh-CN.md +19 -9
  10. package/docs/layout-split-stack-audit.md +298 -0
  11. package/lib/automation.mjs +8 -0
  12. package/lib/cli-args.mjs +89 -0
  13. package/lib/cli-runtime.mjs +44 -0
  14. package/lib/coordination-state.mjs +218 -0
  15. package/lib/core/coordination/automation.mjs +222 -0
  16. package/lib/core/coordination/locks.mjs +46 -0
  17. package/lib/core/coordination/messages.mjs +116 -0
  18. package/lib/core/coordination/tasks.mjs +224 -0
  19. package/lib/core/coordination/teams.mjs +73 -0
  20. package/lib/core/coordination/timeline.mjs +187 -0
  21. package/lib/core/peers/bindings.mjs +83 -0
  22. package/lib/core/peers/format.mjs +13 -0
  23. package/lib/core/peers/identity.mjs +53 -0
  24. package/lib/core/peers/liveness.mjs +90 -0
  25. package/lib/core/peers/reconcile.mjs +238 -0
  26. package/lib/core/peers/session.mjs +14 -0
  27. package/lib/core/sessions/launch.mjs +89 -0
  28. package/lib/db/schema.mjs +324 -0
  29. package/lib/db/stores/peers.mjs +231 -0
  30. package/lib/db-schema.mjs +7 -0
  31. package/lib/errors.mjs +1 -0
  32. package/lib/format.mjs +39 -0
  33. package/lib/guidance.mjs +10 -2
  34. package/lib/handoff.mjs +26 -0
  35. package/lib/help.mjs +3 -0
  36. package/lib/integrations/hooks.mjs +171 -0
  37. package/lib/integrations/peers/identity.mjs +208 -0
  38. package/lib/integrations/peers/processes.mjs +151 -0
  39. package/lib/integrations/providers.mjs +282 -0
  40. package/lib/integrations/shims/script.mjs +384 -0
  41. package/lib/integrations/shims.mjs +106 -0
  42. package/lib/json-file.mjs +1 -0
  43. package/lib/locks.mjs +10 -0
  44. package/lib/messages.mjs +3 -0
  45. package/lib/package-meta.mjs +6 -24
  46. package/lib/peer-bindings.mjs +14 -0
  47. package/lib/peer-format.mjs +4 -0
  48. package/lib/peer-identity.mjs +15 -0
  49. package/lib/project-context.mjs +6 -0
  50. package/lib/project-registry.mjs +7 -0
  51. package/lib/provider-commands.mjs +13 -0
  52. package/lib/release/package-meta.mjs +31 -0
  53. package/lib/release/release-notes.mjs +40 -0
  54. package/lib/release-notes.mjs +6 -40
  55. package/lib/runtime/client.mjs +42 -0
  56. package/lib/runtime/paths.mjs +45 -0
  57. package/lib/runtime/project-context.mjs +32 -0
  58. package/lib/runtime/projects.mjs +76 -0
  59. package/lib/runtime/state.mjs +128 -0
  60. package/lib/runtime-client.mjs +3 -0
  61. package/lib/runtime-paths.mjs +11 -0
  62. package/lib/runtime-state.mjs +11 -0
  63. package/lib/session-launch.mjs +17 -0
  64. package/lib/setup.mjs +18 -765
  65. package/lib/shared/errors.mjs +7 -0
  66. package/lib/shared/json-file.mjs +14 -0
  67. package/lib/shell-path.mjs +69 -0
  68. package/lib/shim-script.mjs +1 -0
  69. package/lib/state-render.mjs +5 -0
  70. package/lib/task-cli.mjs +45 -0
  71. package/lib/task-liveness.mjs +8 -0
  72. package/lib/task-store.mjs +3 -0
  73. package/lib/team-planning.mjs +7 -0
  74. package/lib/terminal/tmux.mjs +372 -0
  75. package/lib/timeline.mjs +12 -0
  76. package/lib/tmux.mjs +18 -0
  77. package/lib/ui/help.mjs +419 -0
  78. package/lib/ui/state-render.mjs +61 -0
  79. package/lib/web/http.mjs +55 -0
  80. package/lib/web/peer-actions.mjs +270 -0
  81. package/lib/web/runtime.mjs +154 -0
  82. package/lib/web/ui-template.mjs +2055 -0
  83. package/lib/web-http.mjs +7 -0
  84. package/lib/web-peer-actions.mjs +3 -0
  85. package/lib/web-runtime.mjs +17 -0
  86. package/lib/web-ui-template.mjs +3 -0
  87. package/package.json +1 -1
  88. package/scripts/github-release.mjs +2 -2
  89. package/scripts/regression.mjs +2738 -22
  90. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,187 @@
1
+ import { compactText } from '../../format.mjs';
2
+ import { lockLabel } from './locks.mjs';
3
+
4
+ export function parseEventPayload(row) {
5
+ try {
6
+ return row?.payload ? JSON.parse(row.payload) : {};
7
+ } catch {
8
+ return {};
9
+ }
10
+ }
11
+
12
+ export function uniqueList(values) {
13
+ return [...new Set(values.filter(Boolean).map(String))];
14
+ }
15
+
16
+ export function messageParticipants(message) {
17
+ const participants = [message.sender];
18
+ if (message.recipient && !['all', ''].includes(message.recipient)) participants.push(message.recipient);
19
+ return uniqueList(participants);
20
+ }
21
+
22
+ export function taskParticipants(task) {
23
+ return uniqueList([task.created_by, task.owner, task.assignee]);
24
+ }
25
+
26
+ export function payloadParticipants(payload) {
27
+ const values = [];
28
+ for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
29
+ if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
30
+ }
31
+ return uniqueList(values);
32
+ }
33
+
34
+ export function peerMatchesTimelineItem(item, peer) {
35
+ if (!peer) return true;
36
+ if (item.broadcast) return true;
37
+ return (item.peers || []).includes(peer);
38
+ }
39
+
40
+ export function shouldHideTimelineMessage(message) {
41
+ if (message.kind === 'task' && /^Task #\d+ assigned: /.test(message.body || '')) return true;
42
+ if (message.kind === 'handoff' && /^Handoff #\d+: /.test(message.body || '')) return true;
43
+ return false;
44
+ }
45
+
46
+ const TIMELINE_EVENT_ALLOW = new Set([
47
+ 'task.created',
48
+ 'task.claimed',
49
+ 'task.pending',
50
+ 'task.running',
51
+ 'task.review',
52
+ 'task.blocked',
53
+ 'task.done',
54
+ 'task.abandoned',
55
+ 'task.takeover',
56
+ 'team.started',
57
+ 'lock.acquired',
58
+ 'lock.released',
59
+ 'peer.registered',
60
+ 'peer.joined',
61
+ 'peer.auto_joined',
62
+ 'peer.stopped',
63
+ 'provider.session.merged',
64
+ 'web.session.started',
65
+ 'web.session.exited',
66
+ 'tmux.session.attached',
67
+ 'tmux.session.detached',
68
+ 'tmux.session.exited',
69
+ 'run.session.started',
70
+ 'run.session.exited'
71
+ ]);
72
+
73
+ export function shouldHideTimelineEvent(event) {
74
+ if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
75
+ if (event.type && event.type.startsWith('hook.')) return true;
76
+ if (event.type && event.type.startsWith('web.session.input')) return true;
77
+ return !TIMELINE_EVENT_ALLOW.has(event.type);
78
+ }
79
+
80
+ export function timelineDirection(message, peer) {
81
+ if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
82
+ if (message.sender === peer && message.recipient === peer) return 'self';
83
+ if (message.sender === peer) return 'out';
84
+ if (message.recipient === 'all') return 'broadcast';
85
+ if (message.recipient === peer) return 'in';
86
+ return 'project';
87
+ }
88
+
89
+ export function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
90
+ const items = [];
91
+ for (const message of messages) {
92
+ if (shouldHideTimelineMessage(message)) continue;
93
+ const item = {
94
+ id: `message:${message.id}`,
95
+ source: 'message',
96
+ source_id: message.id,
97
+ ts: message.created_at,
98
+ actor: message.sender,
99
+ peers: messageParticipants(message),
100
+ task_id: message.task_id || null,
101
+ kind: message.kind || 'note',
102
+ title: `${message.sender} -> ${message.recipient || 'all'}`,
103
+ text: compactText(message.body),
104
+ unread: message.read_at === null || message.read_at === undefined,
105
+ direction: timelineDirection(message, peer),
106
+ thread_id: message.thread_id || message.id,
107
+ reply_to: message.reply_to || null,
108
+ broadcast: !message.recipient || message.recipient === 'all'
109
+ };
110
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
111
+ }
112
+ for (const handoff of handoffs) {
113
+ const item = {
114
+ id: `handoff:${handoff.id}`,
115
+ source: 'handoff',
116
+ source_id: handoff.id,
117
+ ts: handoff.created_at,
118
+ actor: handoff.from_peer,
119
+ peers: uniqueList([handoff.from_peer, handoff.to_peer]),
120
+ task_id: handoff.task_id || null,
121
+ kind: 'handoff',
122
+ title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
123
+ text: compactText(handoff.summary),
124
+ direction: 'project',
125
+ broadcast: !handoff.to_peer,
126
+ tests: handoff.tests || '',
127
+ risks: handoff.risks || ''
128
+ };
129
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
130
+ }
131
+ for (const task of tasks) {
132
+ const item = {
133
+ id: `task:${task.id}`,
134
+ source: 'task',
135
+ source_id: task.id,
136
+ ts: task.updated_at || task.created_at,
137
+ actor: task.owner || task.created_by || task.assignee || '',
138
+ peers: taskParticipants(task),
139
+ task_id: task.id,
140
+ kind: task.status,
141
+ title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
142
+ text: compactText(task.title),
143
+ direction: 'project'
144
+ };
145
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
146
+ }
147
+ for (const lock of locks) {
148
+ const item = {
149
+ id: `lock:${lock.resource}`,
150
+ source: 'lock',
151
+ source_id: lock.resource,
152
+ ts: lock.created_at,
153
+ actor: lock.owner,
154
+ peers: uniqueList([lock.owner]),
155
+ task_id: lock.task_id || null,
156
+ kind: 'active',
157
+ title: `lock ${lockLabel(lock)}`,
158
+ text: compactText(lock.reason || `owner=${lock.owner}`),
159
+ direction: 'project'
160
+ };
161
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
162
+ }
163
+ for (const event of events) {
164
+ if (shouldHideTimelineEvent(event)) continue;
165
+ const payload = parseEventPayload(event);
166
+ const item = {
167
+ id: `event:${event.id}`,
168
+ source: 'event',
169
+ source_id: event.id,
170
+ ts: event.created_at,
171
+ actor: event.actor || '',
172
+ peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
173
+ task_id: event.task_id || null,
174
+ kind: event.type,
175
+ title: event.type,
176
+ text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
177
+ direction: 'project'
178
+ };
179
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
180
+ }
181
+ const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
182
+ items.sort((a, b) =>
183
+ (a.ts || 0) - (b.ts || 0) ||
184
+ (order[a.source] || 99) - (order[b.source] || 99) ||
185
+ String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
186
+ return items.slice(-120);
187
+ }
@@ -0,0 +1,83 @@
1
+ import { providerSessionParts } from './session.mjs';
2
+
3
+ export function bindingFromDetected(peer, transport = 'detected') {
4
+ const provider = peer.kind || 'other';
5
+ const session = providerSessionParts(peer.resumeId || peer.sessionId || '');
6
+ return {
7
+ peer: peer.peerId || peer.id,
8
+ provider,
9
+ ...session,
10
+ resume_mode: peer.resumeId ? 'resume' : (peer.sessionId ? 'detected' : 'unknown'),
11
+ resume_arg: peer.resumeId || null,
12
+ command: peer.command || null,
13
+ transport,
14
+ runtime_session_id: peer.peerId || peer.id
15
+ };
16
+ }
17
+
18
+ export function peerBindingRuntimeRank(row) {
19
+ if (row?.runtime_target && row.transport === 'tmux') return 50;
20
+ if (row?.runtime_target) return 40;
21
+ if (['tmux', 'web-pty'].includes(row?.transport)) return 30;
22
+ if (row?.transport === 'hcc-run') return 20;
23
+ if (row?.transport === 'hook') return 10;
24
+ if (row?.transport === 'detected') return 5;
25
+ return 0;
26
+ }
27
+
28
+ export function comparePeerBindings(a, b) {
29
+ return peerBindingRuntimeRank(b) - peerBindingRuntimeRank(a) ||
30
+ Number(b.updated_at || 0) - Number(a.updated_at || 0) ||
31
+ Number(b.created_at || 0) - Number(a.created_at || 0) ||
32
+ String(a.peer || '').localeCompare(String(b.peer || ''));
33
+ }
34
+
35
+ export function bindingHasProviderSession(binding) {
36
+ return Boolean(binding?.provider_session_id || binding?.provider_session_name);
37
+ }
38
+
39
+ export function bindingProviderSessionValue(binding) {
40
+ return binding?.provider_session_id || binding?.provider_session_name || null;
41
+ }
42
+
43
+ export function bindingHasRuntime(binding) {
44
+ return Boolean(binding?.runtime_target) || ['tmux', 'web-pty'].includes(binding?.transport);
45
+ }
46
+
47
+ export function mergeRuntimeBinding(existing, binding) {
48
+ if (!existing || !bindingHasRuntime(existing) || bindingHasRuntime(binding)) return binding;
49
+ return {
50
+ ...binding,
51
+ command: existing.command || binding.command || null,
52
+ transport: existing.transport,
53
+ runtime_session_id: existing.runtime_session_id || binding.runtime_session_id || binding.peer,
54
+ runtime_target: existing.runtime_target || null
55
+ };
56
+ }
57
+
58
+ function providerCompatible(existing, binding) {
59
+ if (!existing?.provider || !binding?.provider) return true;
60
+ return existing.provider === binding.provider;
61
+ }
62
+
63
+ function providerSessionResumeMode(mode) {
64
+ return new Set(['detected', 'resume', 'session', 'named']).has(mode);
65
+ }
66
+
67
+ export function mergePeerBinding(existing, binding) {
68
+ let next = mergeRuntimeBinding(existing, binding);
69
+ if (!existing || bindingHasProviderSession(next) || !bindingHasProviderSession(existing)) return next;
70
+ if (!providerCompatible(existing, next)) return next;
71
+
72
+ next = {
73
+ ...next,
74
+ provider: next.provider || existing.provider,
75
+ provider_session_id: existing.provider_session_id || null,
76
+ provider_session_name: existing.provider_session_name || null
77
+ };
78
+ if (providerSessionResumeMode(existing.resume_mode) && !providerSessionResumeMode(next.resume_mode)) {
79
+ next.resume_mode = existing.resume_mode;
80
+ next.resume_arg = existing.resume_arg || next.resume_arg || null;
81
+ }
82
+ return next;
83
+ }
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function sanitizePeerPart(value, fallback = 'peer') {
4
+ const text = String(value || '').toLowerCase()
5
+ .replace(/[^a-z0-9._-]/g, '-')
6
+ .replace(/^-+|-+$/g, '')
7
+ .slice(0, 32);
8
+ return text || fallback;
9
+ }
10
+
11
+ export function shortHash(value) {
12
+ return createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
13
+ }
@@ -0,0 +1,53 @@
1
+ import { sanitizePeerPart, shortHash } from './format.mjs';
2
+ import { providerSessionPeerId } from './session.mjs';
3
+
4
+ export { sanitizePeerPart, shortHash } from './format.mjs';
5
+
6
+ export function autoPeerProviderSession(_kind, observation = {}) {
7
+ return {
8
+ sessionId: observation.sessionId || '',
9
+ resumeId: observation.resumeId || null,
10
+ ancestor: observation.ancestor || null
11
+ };
12
+ }
13
+
14
+ export function autoPeerSessionId(kind, observation = {}) {
15
+ return autoPeerProviderSession(kind, observation).sessionId;
16
+ }
17
+
18
+ export function autoPeerResumeId(kind, observation = {}) {
19
+ return autoPeerProviderSession(kind, observation).resumeId;
20
+ }
21
+
22
+ export function autoPeerKind(kindHint = 'shell', observation = {}) {
23
+ return observation.kind || kindHint || 'shell';
24
+ }
25
+
26
+ export function autoPeerBasis(kind = null, observation = {}) {
27
+ if (observation.basis) return observation.basis;
28
+ const ancestor = observation.ancestor || null;
29
+ if (ancestor && (!kind || ancestor.kind === kind)) return `cli:${ancestor.kind}:${ancestor.pid}`;
30
+ if (observation.tmuxPane) return `tmux:${observation.tmuxPane}`;
31
+ if (observation.tty) return `tty:${observation.tty}`;
32
+ return `ppid:${observation.ppid ?? 0}`;
33
+ }
34
+
35
+ export function autoPeerId(ctx, kindHint = 'shell', observation = {}) {
36
+ const kind = autoPeerKind(kindHint, observation);
37
+ const { sessionId, resumeId } = autoPeerProviderSession(kind, observation);
38
+ const providerId = resumeId || sessionId;
39
+ if (providerId) return providerSessionPeerId(kind, providerId);
40
+
41
+ const basis = autoPeerBasis(kind, observation);
42
+ return `${kind}-${shortHash(`${ctx.root}:${basis}`)}`;
43
+ }
44
+
45
+ export function resolveCurrentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
46
+ if (opts[key]) return { id: opts[key], auto: false };
47
+ if (observation.env?.HCC_PEER) return { id: observation.env.HCC_PEER, auto: false };
48
+ return { id: autoPeerId(ctx, kindHint, observation), auto: true };
49
+ }
50
+
51
+ export function currentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
52
+ return resolveCurrentPeer(ctx, opts, key, kindHint, observation).id;
53
+ }
@@ -0,0 +1,90 @@
1
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
2
+
3
+ function nowSec() {
4
+ return Math.floor(Date.now() / 1000);
5
+ }
6
+
7
+ export function taskRelatedLocks(task, locks) {
8
+ const taskId = Number(task?.id || 0);
9
+ const owner = task?.owner || '';
10
+ return (locks || []).filter((lock) => {
11
+ if (taskId && Number(lock.task_id || 0) === taskId) return true;
12
+ return Boolean(owner && lock.owner === owner);
13
+ });
14
+ }
15
+
16
+ export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
17
+ const owner = task?.owner || null;
18
+ const relatedLocks = taskRelatedLocks(task, locks);
19
+ if (!owner) {
20
+ return {
21
+ owner_known: false,
22
+ owner_active: null,
23
+ owner_stale: false,
24
+ owner_age_sec: null,
25
+ related_lock_count: relatedLocks.length,
26
+ takeover_ready: false
27
+ };
28
+ }
29
+ const ownerRow = (peers || []).find((row) => row.id === owner) || null;
30
+ const ownerAge = ownerRow
31
+ ? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
32
+ : null;
33
+ const ownerActive = Boolean(ownerRow && Number.isFinite(ownerAge) && ownerAge <= activePeerTtl);
34
+ const ownerStale = !ownerActive;
35
+ const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
36
+ return {
37
+ owner_known: Boolean(ownerRow),
38
+ owner_active: ownerActive,
39
+ owner_stale: ownerStale,
40
+ owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
41
+ related_lock_count: relatedLocks.length,
42
+ takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
43
+ };
44
+ }
45
+
46
+ export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
47
+ return (tasks || []).map((task) => ({
48
+ ...task,
49
+ ...taskOwnerLiveness(task, peers, locks, t, activePeerTtl)
50
+ }));
51
+ }
52
+
53
+ export function taskOwnerStateText(task) {
54
+ if (!task?.owner) return '';
55
+ if (task.owner_stale) {
56
+ if (task.takeover_ready) return 'stale/no-lock';
57
+ const locks = Number(task.related_lock_count || 0);
58
+ return locks ? `stale/locks=${locks}` : 'stale';
59
+ }
60
+ if (task.owner_active) return 'active';
61
+ return '';
62
+ }
63
+
64
+ export function summarizeTask(task) {
65
+ if (!task) return null;
66
+ return {
67
+ id: task.id,
68
+ title: task.title,
69
+ status: task.status,
70
+ owner: task.owner || null,
71
+ assignee: task.assignee || null,
72
+ parent_id: task.parent_id || null,
73
+ team_role: task.team_role || null,
74
+ priority: task.priority,
75
+ owner_active: task.owner_active ?? null,
76
+ owner_stale: Boolean(task.owner_stale),
77
+ owner_age_sec: task.owner_age_sec ?? null,
78
+ related_lock_count: Number(task.related_lock_count || 0),
79
+ takeover_ready: Boolean(task.takeover_ready)
80
+ };
81
+ }
82
+
83
+ export function formatOpenTaskLine(task) {
84
+ const parts = [`#${task.id}`, task.status];
85
+ if (task.owner) parts.push(`owner=${task.owner}`);
86
+ if (task.assignee) parts.push(`assignee=${task.assignee}`);
87
+ const ownerState = taskOwnerStateText(task);
88
+ if (ownerState) parts.push(`owner_state=${ownerState}`);
89
+ return `${parts.join(' ')}: ${task.title}`;
90
+ }
@@ -0,0 +1,238 @@
1
+ import path from 'node:path';
2
+ import { providerSessionParts } from './session.mjs';
3
+ import {
4
+ bindingHasProviderSession,
5
+ bindingProviderSessionValue
6
+ } from './bindings.mjs';
7
+
8
+ function defaultNow() {
9
+ return Math.floor(Date.now() / 1000);
10
+ }
11
+
12
+ function noopEvent() {}
13
+
14
+ function normalizedRoot(root) {
15
+ return root ? path.resolve(root) : '';
16
+ }
17
+
18
+ function runtimePaneMatches(row, pane) {
19
+ return Boolean(row?.runtime_target && pane?.pane && row.runtime_target === pane.pane);
20
+ }
21
+
22
+ function runtimePidMatches(row, pane) {
23
+ return Boolean(row?.pid && pane?.pid && Number(row.pid) === Number(pane.pid));
24
+ }
25
+
26
+ function runtimeCwdMatches(root, row, pane) {
27
+ const paneCwd = normalizedRoot(pane?.cwd);
28
+ if (!paneCwd) return false;
29
+ const worktree = normalizedRoot(row?.worktree);
30
+ const projectRoot = normalizedRoot(root);
31
+ return Boolean((worktree && paneCwd === worktree) || (projectRoot && paneCwd === projectRoot));
32
+ }
33
+
34
+ function commandEvidence(row) {
35
+ const value = row?.resume_arg || null;
36
+ if (!value || value === '--last' || String(value).startsWith('%')) return null;
37
+ if (!['resume', 'session', 'detected', 'named'].includes(row?.resume_mode)) return null;
38
+ return {
39
+ provider: row.provider || row.kind || null,
40
+ value,
41
+ source: `binding.${row.resume_mode}`
42
+ };
43
+ }
44
+
45
+ function processEvidence(row, inspectProcess) {
46
+ if (typeof inspectProcess !== 'function' || !row?.pid) return null;
47
+ const info = inspectProcess(Number(row.pid), row.provider || row.kind || null);
48
+ if (!info?.provider_session) return null;
49
+ return {
50
+ provider: info.kind || row.provider || row.kind || null,
51
+ value: info.provider_session,
52
+ source: info.source || 'process'
53
+ };
54
+ }
55
+
56
+ function tmuxEvidence(row, pane, opts) {
57
+ const provider = row.provider || row.kind || null;
58
+ const byEvent = eventEvidence(row, opts);
59
+ if (byEvent) return byEvent;
60
+ const byProcess = processEvidence(row, opts.inspectProcess);
61
+ if (byProcess) return byProcess;
62
+ const byCommand = commandEvidence(row);
63
+ if (!byCommand) return null;
64
+ if (!runtimePaneMatches(row, pane) && !runtimePidMatches(row, pane)) return null;
65
+ return {
66
+ ...byCommand,
67
+ provider
68
+ };
69
+ }
70
+
71
+ function eventEvidence(row, opts) {
72
+ if (typeof opts.latestProviderSessionForPeer !== 'function') return null;
73
+ const value = opts.latestProviderSessionForPeer(row.peer, row.provider || row.kind || null);
74
+ if (!value) return null;
75
+ return {
76
+ provider: row.provider || row.kind || null,
77
+ value,
78
+ source: 'events.hook'
79
+ };
80
+ }
81
+
82
+ function existingProviderSessionConflict(db, row, session, provider = row?.provider) {
83
+ if (!session?.provider_session_id && !session?.provider_session_name) return null;
84
+ if (!provider) return null;
85
+ if (session.provider_session_id) {
86
+ return db.prepare(`
87
+ SELECT *
88
+ FROM peer_bindings
89
+ WHERE provider = ?
90
+ AND peer <> ?
91
+ AND provider_session_id = ?
92
+ LIMIT 1
93
+ `).get(provider, row.peer, session.provider_session_id) || null;
94
+ }
95
+ return db.prepare(`
96
+ SELECT *
97
+ FROM peer_bindings
98
+ WHERE provider = ?
99
+ AND peer <> ?
100
+ AND provider_session_name = ?
101
+ LIMIT 1
102
+ `).get(provider, row.peer, session.provider_session_name) || null;
103
+ }
104
+
105
+ function updatePeerProviderSession(db, row, candidate, opts) {
106
+ const t = opts.now();
107
+ const provider = candidate.provider || row.provider;
108
+ const session = providerSessionParts(candidate.value);
109
+ const conflict = existingProviderSessionConflict(db, row, session, provider);
110
+ if (conflict) {
111
+ return {
112
+ peer: row.peer,
113
+ provider,
114
+ action: 'skipped',
115
+ reason: 'provider_session_conflict',
116
+ conflict_peer: conflict.peer,
117
+ provider_session: bindingProviderSessionValue(session)
118
+ };
119
+ }
120
+
121
+ try {
122
+ db.prepare(`
123
+ UPDATE peer_bindings
124
+ SET provider = ?,
125
+ provider_session_id = ?,
126
+ provider_session_name = ?,
127
+ resume_mode = CASE
128
+ WHEN resume_mode IN ('unknown', 'attached', 'auto', 'command') THEN ?
129
+ ELSE resume_mode
130
+ END,
131
+ resume_arg = COALESCE(NULLIF(resume_arg, runtime_target), ?),
132
+ updated_at = ?
133
+ WHERE peer = ?
134
+ `).run(
135
+ provider,
136
+ session.provider_session_id || null,
137
+ session.provider_session_name || null,
138
+ candidate.source === 'binding.session' ? 'session' : 'detected',
139
+ candidate.value,
140
+ t,
141
+ row.peer
142
+ );
143
+ } catch (err) {
144
+ const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
145
+ if (!/UNIQUE constraint failed/i.test(text)) throw err;
146
+ const conflict = existingProviderSessionConflict(db, row, session, provider);
147
+ return {
148
+ peer: row.peer,
149
+ provider,
150
+ action: 'skipped',
151
+ reason: 'provider_session_conflict',
152
+ conflict_peer: conflict?.peer || null,
153
+ provider_session: bindingProviderSessionValue(session)
154
+ };
155
+ }
156
+ opts.addEvent(db, 'provider.session.backfilled', row.peer, null, {
157
+ provider,
158
+ provider_session: candidate.value,
159
+ source: candidate.source,
160
+ runtime_target: row.runtime_target || null
161
+ });
162
+ return {
163
+ peer: row.peer,
164
+ provider,
165
+ action: 'backfilled',
166
+ source: candidate.source,
167
+ provider_session: candidate.value,
168
+ runtime_target: row.runtime_target || null
169
+ };
170
+ }
171
+
172
+ export function reconcileRunningPeerBindings(db, projectCtx = {}, options = {}) {
173
+ const opts = {
174
+ now: typeof options.now === 'function' ? options.now : defaultNow,
175
+ addEvent: typeof options.addEvent === 'function' ? options.addEvent : noopEvent,
176
+ inspectProcess: options.inspectProcess || null,
177
+ latestProviderSessionForPeer: options.latestProviderSessionForPeer || null,
178
+ panes: Array.isArray(options.panes) ? options.panes : []
179
+ };
180
+ const paneByTarget = new Map(opts.panes.filter((pane) => pane?.pane).map((pane) => [pane.pane, pane]));
181
+ const paneByPid = new Map();
182
+ for (const pane of opts.panes) {
183
+ if (pane?.pid && !paneByPid.has(Number(pane.pid))) paneByPid.set(Number(pane.pid), pane);
184
+ }
185
+
186
+ const rows = db.prepare(`
187
+ SELECT p.id AS peer, p.kind, p.status, p.worktree, p.pid, p.last_seen_at,
188
+ b.provider, b.provider_session_id, b.provider_session_name,
189
+ b.resume_mode, b.resume_arg, b.command, b.transport,
190
+ b.runtime_session_id, b.runtime_target, b.updated_at
191
+ FROM peer_bindings b
192
+ JOIN peers p ON p.id = b.peer
193
+ WHERE b.transport = 'tmux'
194
+ AND b.runtime_target IS NOT NULL
195
+ AND p.status IN ('running', 'working', 'busy')
196
+ ORDER BY p.last_seen_at DESC, b.updated_at DESC
197
+ `).all();
198
+
199
+ const results = [];
200
+ for (const row of rows) {
201
+ if (bindingHasProviderSession(row)) {
202
+ results.push({
203
+ peer: row.peer,
204
+ provider: row.provider,
205
+ action: 'skipped',
206
+ reason: 'provider_session_known',
207
+ provider_session: bindingProviderSessionValue(row)
208
+ });
209
+ continue;
210
+ }
211
+
212
+ const pane = paneByTarget.get(row.runtime_target) || paneByPid.get(Number(row.pid));
213
+ if (!pane) {
214
+ results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_live_tmux_pane' });
215
+ continue;
216
+ }
217
+ if (!runtimePaneMatches(row, pane)) {
218
+ results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'runtime_target_mismatch' });
219
+ continue;
220
+ }
221
+ if (!runtimePidMatches(row, pane) && !runtimeCwdMatches(projectCtx.root, row, pane)) {
222
+ results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'weak_runtime_evidence' });
223
+ continue;
224
+ }
225
+
226
+ const candidate = tmuxEvidence(row, pane, opts);
227
+ if (!candidate?.value) {
228
+ results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_strong_provider_evidence' });
229
+ continue;
230
+ }
231
+ results.push(updatePeerProviderSession(db, row, candidate, opts));
232
+ }
233
+ return {
234
+ checked: rows.length,
235
+ backfilled: results.filter((result) => result.action === 'backfilled').length,
236
+ results
237
+ };
238
+ }
@@ -0,0 +1,14 @@
1
+ import { sanitizePeerPart, shortHash } from './format.mjs';
2
+
3
+ export function providerSessionPeerId(kind, providerId) {
4
+ return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
5
+ }
6
+
7
+ export function providerSessionParts(value) {
8
+ if (!value) return { provider_session_id: null, provider_session_name: null };
9
+ const text = String(value);
10
+ const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
11
+ return uuidLike
12
+ ? { provider_session_id: text, provider_session_name: null }
13
+ : { provider_session_id: null, provider_session_name: text };
14
+ }