@logicseek/hello-cc 0.1.5 → 0.1.7

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 (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,240 @@
1
+ import { tx } from '../../db/schema.mjs';
2
+ import { CliError } from '../../shared/errors.mjs';
3
+
4
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
5
+
6
+ function defaultNow() {
7
+ return Math.floor(Date.now() / 1000);
8
+ }
9
+
10
+ function noopEvent() {}
11
+
12
+ function noopMessage() {
13
+ return null;
14
+ }
15
+
16
+ export function createTaskStore(deps = {}) {
17
+ const now = typeof deps.now === 'function' ? deps.now : defaultNow;
18
+ const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
19
+ const sendMessage = typeof deps.sendMessage === 'function' ? deps.sendMessage : noopMessage;
20
+ const activePeerTtl = Number(deps.activePeerTtl || DEFAULT_ACTIVE_PEER_TTL);
21
+
22
+ function taskOwnedError(peer, row, action) {
23
+ return new CliError('TASK_OWNED', `Task #${row.id} is owned by ${row.owner}`, {
24
+ owner: row.owner,
25
+ task_id: row.id,
26
+ attempted_by: peer,
27
+ action,
28
+ notify_owner: true
29
+ });
30
+ }
31
+
32
+ function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
33
+ return tx(db, () => {
34
+ const tasks = [];
35
+ for (const id of ids) {
36
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
37
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
38
+ if (row.owner && row.owner !== peer && !force) {
39
+ throw taskOwnedError(peer, row, 'claim');
40
+ }
41
+ if (row.assignee && row.assignee !== peer && !force) {
42
+ throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
43
+ }
44
+ if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !force) {
45
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
46
+ }
47
+ const t = now();
48
+ db.prepare(`
49
+ UPDATE tasks
50
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
51
+ WHERE id = ?
52
+ `).run(peer, t, t, id);
53
+ addEvent(db, 'task.claimed', peer, id, {
54
+ previous_owner: row.owner,
55
+ force,
56
+ ...(source ? { source } : {})
57
+ });
58
+ tasks.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(id));
59
+ }
60
+ return tasks;
61
+ });
62
+ }
63
+
64
+ function takeoverPolicyDetails(db, row, peer, { policy = 'any', staleAfter = activePeerTtl } = {}) {
65
+ const normalized = String(policy || 'any').toLowerCase();
66
+ const allowed = new Set(['any', 'blocked', 'stale', 'blocked-or-stale']);
67
+ if (!allowed.has(normalized)) {
68
+ throw new CliError('BAD_ARGS', `Unsupported takeover policy: ${policy}`);
69
+ }
70
+ const previousOwner = row.owner || null;
71
+ const blocked = row.status === 'blocked';
72
+ const ownerRow = previousOwner ? db.prepare('SELECT id, last_seen_at FROM peers WHERE id = ?').get(previousOwner) : null;
73
+ const ownerAge = ownerRow ? now() - Number(ownerRow.last_seen_at || 0) : null;
74
+ const ownerStale = Boolean(previousOwner && previousOwner !== peer && (!ownerRow || ownerAge > staleAfter));
75
+ const alreadyOwner = previousOwner === peer;
76
+ const ok = alreadyOwner ||
77
+ normalized === 'any' ||
78
+ (normalized === 'blocked' && blocked) ||
79
+ (normalized === 'stale' && ownerStale) ||
80
+ (normalized === 'blocked-or-stale' && (blocked || ownerStale));
81
+ return { policy: normalized, blocked, owner_stale: ownerStale, owner_age_sec: ownerAge, stale_after_sec: staleAfter, ok };
82
+ }
83
+
84
+ function takeOverTaskForPeer(db, peer, id, { reason, policy = 'any', staleAfter = activePeerTtl, source = null } = {}) {
85
+ return tx(db, () => {
86
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
87
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
88
+ if (['done', 'abandoned'].includes(row.status)) {
89
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
90
+ }
91
+ const policyDetails = takeoverPolicyDetails(db, row, peer, { policy, staleAfter });
92
+ if (!policyDetails.ok) {
93
+ throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
94
+ task_id: id,
95
+ status: row.status,
96
+ owner: row.owner || null,
97
+ policy: policyDetails.policy,
98
+ owner_stale: policyDetails.owner_stale,
99
+ owner_age_sec: policyDetails.owner_age_sec,
100
+ stale_after_sec: policyDetails.stale_after_sec
101
+ });
102
+ }
103
+ const previousOwner = row.owner || null;
104
+ const previousAssignee = row.assignee || null;
105
+ const t = now();
106
+ db.prepare(`
107
+ UPDATE tasks
108
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
109
+ WHERE id = ?
110
+ `).run(peer, t, t, id);
111
+ addEvent(db, 'task.takeover', peer, id, {
112
+ previous_owner: previousOwner,
113
+ previous_assignee: previousAssignee,
114
+ reason,
115
+ policy: policyDetails.policy,
116
+ owner_stale: policyDetails.owner_stale,
117
+ stale_after_sec: staleAfter,
118
+ ...(source ? { source } : {})
119
+ });
120
+ if (previousOwner && previousOwner !== peer) {
121
+ sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
122
+ }
123
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
124
+ });
125
+ }
126
+
127
+ function queryOpenTasks(db, limit, peer = null) {
128
+ const sql = `
129
+ SELECT * FROM tasks
130
+ WHERE status NOT IN ('done', 'abandoned')
131
+ ${peer ? 'AND (owner = ? OR assignee = ?)' : ''}
132
+ ORDER BY
133
+ CASE status
134
+ WHEN 'claimed' THEN 0
135
+ WHEN 'running' THEN 1
136
+ WHEN 'pending' THEN 2
137
+ WHEN 'blocked' THEN 3
138
+ WHEN 'review' THEN 4
139
+ ELSE 5
140
+ END,
141
+ priority ASC,
142
+ id ASC
143
+ LIMIT ?
144
+ `;
145
+ return peer
146
+ ? db.prepare(sql).all(peer, peer, limit)
147
+ : db.prepare(sql).all(limit);
148
+ }
149
+
150
+ function taskById(db, id) {
151
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
152
+ }
153
+
154
+ function teamChildren(db, parentId) {
155
+ return db.prepare(`
156
+ SELECT *
157
+ FROM tasks
158
+ WHERE parent_id = ?
159
+ ORDER BY priority ASC, id ASC
160
+ `).all(parentId);
161
+ }
162
+
163
+ function teamSummary(db, parentId) {
164
+ const parent = taskById(db, parentId);
165
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
166
+ const children = teamChildren(db, parentId);
167
+ const handoffs = db.prepare(`
168
+ SELECT *
169
+ FROM handoffs
170
+ WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
171
+ ORDER BY id ASC
172
+ `).all(parentId, parentId);
173
+ const counts = {};
174
+ for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
175
+ return { parent, children, handoffs, counts };
176
+ }
177
+
178
+ function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {}) {
179
+ return tx(db, () => {
180
+ if (!force) {
181
+ const current = db.prepare(`
182
+ SELECT * FROM tasks
183
+ WHERE owner = ?
184
+ AND status IN ('claimed', 'running', 'review', 'blocked')
185
+ ORDER BY
186
+ CASE status
187
+ WHEN 'running' THEN 0
188
+ WHEN 'claimed' THEN 1
189
+ WHEN 'review' THEN 2
190
+ WHEN 'blocked' THEN 3
191
+ ELSE 4
192
+ END,
193
+ priority ASC,
194
+ id ASC
195
+ LIMIT 1
196
+ `).get(peer);
197
+ if (current) return { current: { ...current, current: true }, tasks: [] };
198
+ }
199
+ const rows = db.prepare(`
200
+ SELECT * FROM tasks
201
+ WHERE status = 'pending'
202
+ AND owner IS NULL
203
+ AND (assignee IS NULL OR assignee = ?)
204
+ ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
205
+ LIMIT ?
206
+ `).all(peer, peer, count);
207
+ if (!rows.length) return { current: null, tasks: [] };
208
+ const t = now();
209
+ const claimed = [];
210
+ for (const row of rows) {
211
+ const changes = db.prepare(`
212
+ UPDATE tasks
213
+ SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
214
+ WHERE id = ? AND owner IS NULL AND status = 'pending'
215
+ `).run(peer, t, t, row.id).changes;
216
+ if (!changes) continue;
217
+ addEvent(db, 'task.claimed', peer, row.id, { next: true, count });
218
+ claimed.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id));
219
+ }
220
+ return { current: null, tasks: claimed };
221
+ });
222
+ }
223
+
224
+ function assertTaskOwnerForMutation(db, peer, row, action) {
225
+ if (!row.owner || row.owner === peer) return;
226
+ throw taskOwnedError(peer, row, action);
227
+ }
228
+
229
+ return {
230
+ assertTaskOwnerForMutation,
231
+ claimNextTasksForPeer,
232
+ claimTaskRowsForPeer,
233
+ queryOpenTasks,
234
+ takeOverTaskForPeer,
235
+ taskById,
236
+ teamChildren,
237
+ teamSummary,
238
+ takeoverPolicyDetails
239
+ };
240
+ }
@@ -0,0 +1,73 @@
1
+ import { CliError } from '../../shared/errors.mjs';
2
+ import { sanitizePeerPart } from '../peers/format.mjs';
3
+
4
+ function intTeamOpt(opts, key, fallback = null) {
5
+ if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
6
+ const value = Number.parseInt(String(opts[key]), 10);
7
+ if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
8
+ return value;
9
+ }
10
+
11
+ export function splitCsvList(value) {
12
+ if (Array.isArray(value)) return value.flatMap((item) => splitCsvList(item));
13
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
14
+ }
15
+
16
+ export function parseTeamItems(opts) {
17
+ const rawItems = [
18
+ ...splitCsvList(opts.item || []),
19
+ ...splitCsvList(opts.items || [])
20
+ ];
21
+ return rawItems.map((raw, index) => {
22
+ const parts = String(raw).split(':').map((part) => part.trim());
23
+ let assignee = null;
24
+ let role = null;
25
+ let title = raw.trim();
26
+ if (parts.length >= 3) {
27
+ assignee = parts.shift() || null;
28
+ role = parts.shift() || null;
29
+ title = parts.join(':').trim();
30
+ } else if (parts.length === 2) {
31
+ role = parts[0] || null;
32
+ title = parts[1] || title;
33
+ }
34
+ if (!title) title = `subtask ${index + 1}`;
35
+ return { title, role: role || `worker-${index + 1}`, assignee };
36
+ });
37
+ }
38
+
39
+ export function inferTeamItems(task, opts) {
40
+ const explicit = parseTeamItems(opts);
41
+ if (explicit.length) return explicit;
42
+ const count = Math.max(1, intTeamOpt(opts, 'count', 3));
43
+ const baseTitle = task?.title || 'team task';
44
+ return Array.from({ length: count }, (_, index) => ({
45
+ title: `${baseTitle} / subtask ${index + 1}`,
46
+ role: `worker-${index + 1}`,
47
+ assignee: null
48
+ }));
49
+ }
50
+
51
+ export function expandTeamWorkers(workers, parentId) {
52
+ const expanded = [];
53
+ for (const token of splitCsvList(workers || [])) {
54
+ const match = token.match(/^([A-Za-z][A-Za-z0-9._-]*):([1-9][0-9]*)$/);
55
+ if (!match) {
56
+ expanded.push(token);
57
+ continue;
58
+ }
59
+ const kind = sanitizePeerPart(match[1], 'peer');
60
+ const count = Number.parseInt(match[2], 10);
61
+ for (let i = 1; i <= count; i += 1) expanded.push(`${kind}-team-${parentId}-${i}`);
62
+ }
63
+ return expanded;
64
+ }
65
+
66
+ export function assignTeamWorkers(items, workers, parentId) {
67
+ const workerList = expandTeamWorkers(workers, parentId);
68
+ if (!workerList.length) return items;
69
+ return items.map((item, index) => ({
70
+ ...item,
71
+ assignee: item.assignee || workerList[index % workerList.length]
72
+ }));
73
+ }
@@ -0,0 +1,196 @@
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', 'actor_peer', 'target_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.start.requested',
63
+ 'peer.attach.requested',
64
+ 'peer.stop.requested',
65
+ 'peer.stopped',
66
+ 'peer.restarted',
67
+ 'provider.session.merged',
68
+ 'provider.session.rebound',
69
+ 'web.session.started',
70
+ 'web.session.exited',
71
+ 'web.session.stop_requested',
72
+ 'tmux.session.attached',
73
+ 'tmux.session.detached',
74
+ 'tmux.session.exited',
75
+ 'tmux.session.restarted',
76
+ 'tmux.session.rebound',
77
+ 'tmux.session.gc',
78
+ 'run.session.started',
79
+ 'run.session.exited'
80
+ ]);
81
+
82
+ export function shouldHideTimelineEvent(event) {
83
+ if (['message.sent', 'message.ack', 'handoff.created', 'lock.renewed'].includes(event.type)) return true;
84
+ if (event.type && event.type.startsWith('hook.')) return true;
85
+ if (event.type && event.type.startsWith('web.session.input')) return true;
86
+ return !TIMELINE_EVENT_ALLOW.has(event.type);
87
+ }
88
+
89
+ export function timelineDirection(message, peer) {
90
+ if (!peer) return message.recipient === 'all' ? 'broadcast' : 'project';
91
+ if (message.sender === peer && message.recipient === peer) return 'self';
92
+ if (message.sender === peer) return 'out';
93
+ if (message.recipient === 'all') return 'broadcast';
94
+ if (message.recipient === peer) return 'in';
95
+ return 'project';
96
+ }
97
+
98
+ export function timelineFromRows({ messages = [], handoffs = [], tasks = [], locks = [], events = [] }, peer = null) {
99
+ const items = [];
100
+ for (const message of messages) {
101
+ if (shouldHideTimelineMessage(message)) continue;
102
+ const item = {
103
+ id: `message:${message.id}`,
104
+ source: 'message',
105
+ source_id: message.id,
106
+ ts: message.created_at,
107
+ actor: message.sender,
108
+ peers: messageParticipants(message),
109
+ task_id: message.task_id || null,
110
+ kind: message.kind || 'note',
111
+ title: `${message.sender} -> ${message.recipient || 'all'}`,
112
+ text: compactText(message.body),
113
+ unread: message.read_at === null || message.read_at === undefined,
114
+ direction: timelineDirection(message, peer),
115
+ thread_id: message.thread_id || message.id,
116
+ reply_to: message.reply_to || null,
117
+ broadcast: !message.recipient || message.recipient === 'all'
118
+ };
119
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
120
+ }
121
+ for (const handoff of handoffs) {
122
+ const item = {
123
+ id: `handoff:${handoff.id}`,
124
+ source: 'handoff',
125
+ source_id: handoff.id,
126
+ ts: handoff.created_at,
127
+ actor: handoff.from_peer,
128
+ peers: uniqueList([handoff.from_peer, handoff.to_peer]),
129
+ task_id: handoff.task_id || null,
130
+ kind: 'handoff',
131
+ title: `handoff ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}`,
132
+ text: compactText(handoff.summary),
133
+ direction: 'project',
134
+ broadcast: !handoff.to_peer,
135
+ tests: handoff.tests || '',
136
+ risks: handoff.risks || ''
137
+ };
138
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
139
+ }
140
+ for (const task of tasks) {
141
+ const item = {
142
+ id: `task:${task.id}`,
143
+ source: 'task',
144
+ source_id: task.id,
145
+ ts: task.updated_at || task.created_at,
146
+ actor: task.owner || task.created_by || task.assignee || '',
147
+ peers: taskParticipants(task),
148
+ task_id: task.id,
149
+ kind: task.status,
150
+ title: `task #${task.id} ${task.status}${task.parent_id ? ` child of #${task.parent_id}` : ''}`,
151
+ text: compactText(task.title),
152
+ direction: 'project'
153
+ };
154
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
155
+ }
156
+ for (const lock of locks) {
157
+ const item = {
158
+ id: `lock:${lock.resource}`,
159
+ source: 'lock',
160
+ source_id: lock.resource,
161
+ ts: lock.created_at,
162
+ actor: lock.owner,
163
+ peers: uniqueList([lock.owner]),
164
+ task_id: lock.task_id || null,
165
+ kind: 'active',
166
+ title: `lock ${lockLabel(lock)}`,
167
+ text: compactText(lock.reason || `owner=${lock.owner}`),
168
+ direction: 'project'
169
+ };
170
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
171
+ }
172
+ for (const event of events) {
173
+ if (shouldHideTimelineEvent(event)) continue;
174
+ const payload = parseEventPayload(event);
175
+ const item = {
176
+ id: `event:${event.id}`,
177
+ source: 'event',
178
+ source_id: event.id,
179
+ ts: event.created_at,
180
+ actor: event.actor || '',
181
+ peers: uniqueList([event.actor, ...payloadParticipants(payload)]),
182
+ task_id: event.task_id || null,
183
+ kind: event.type,
184
+ title: event.type,
185
+ text: compactText(payload.summary || payload.reason || payload.title || payload.resource || payload.peer || ''),
186
+ direction: 'project'
187
+ };
188
+ if (peerMatchesTimelineItem(item, peer)) items.push(item);
189
+ }
190
+ const order = { message: 10, handoff: 20, task: 30, lock: 40, event: 50 };
191
+ items.sort((a, b) =>
192
+ (a.ts || 0) - (b.ts || 0) ||
193
+ (order[a.source] || 99) - (order[b.source] || 99) ||
194
+ String(a.source_id).localeCompare(String(b.source_id), undefined, { numeric: true }));
195
+ return items.slice(-120);
196
+ }
@@ -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,61 @@
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
+ const explicit = opts[key] || '';
47
+ const envPeer = observation.env?.HCC_PEER || '';
48
+ if (envPeer) {
49
+ return {
50
+ id: envPeer,
51
+ auto: false,
52
+ corrected_from: explicit && explicit !== envPeer ? explicit : null
53
+ };
54
+ }
55
+ if (explicit) return { id: explicit, auto: false, corrected_from: null };
56
+ return { id: autoPeerId(ctx, kindHint, observation), auto: true };
57
+ }
58
+
59
+ export function currentPeer(ctx, opts = {}, key = 'peer', kindHint = 'shell', observation = {}) {
60
+ return resolveCurrentPeer(ctx, opts, key, kindHint, observation).id;
61
+ }