@logicseek/hello-cc 0.1.5 → 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 (85) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/bin/hcc.mjs +1541 -3430
  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/layout-split-stack-audit.md +298 -0
  9. package/lib/automation.mjs +8 -0
  10. package/lib/cli-args.mjs +1 -1
  11. package/lib/cli-runtime.mjs +44 -0
  12. package/lib/coordination-state.mjs +218 -0
  13. package/lib/core/coordination/automation.mjs +222 -0
  14. package/lib/core/coordination/locks.mjs +46 -0
  15. package/lib/core/coordination/messages.mjs +116 -0
  16. package/lib/core/coordination/tasks.mjs +224 -0
  17. package/lib/core/coordination/teams.mjs +73 -0
  18. package/lib/core/coordination/timeline.mjs +187 -0
  19. package/lib/core/peers/bindings.mjs +83 -0
  20. package/lib/core/peers/format.mjs +13 -0
  21. package/lib/core/peers/identity.mjs +53 -0
  22. package/lib/core/peers/liveness.mjs +90 -0
  23. package/lib/core/peers/reconcile.mjs +238 -0
  24. package/lib/core/peers/session.mjs +14 -0
  25. package/lib/core/sessions/launch.mjs +89 -0
  26. package/lib/db/schema.mjs +324 -0
  27. package/lib/db/stores/peers.mjs +231 -0
  28. package/lib/db-schema.mjs +7 -324
  29. package/lib/errors.mjs +1 -7
  30. package/lib/handoff.mjs +26 -0
  31. package/lib/help.mjs +3 -0
  32. package/lib/integrations/hooks.mjs +171 -0
  33. package/lib/integrations/peers/identity.mjs +208 -0
  34. package/lib/integrations/peers/processes.mjs +151 -0
  35. package/lib/integrations/providers.mjs +282 -0
  36. package/lib/integrations/shims/script.mjs +384 -0
  37. package/lib/integrations/shims.mjs +106 -0
  38. package/lib/json-file.mjs +1 -14
  39. package/lib/locks.mjs +10 -0
  40. package/lib/messages.mjs +3 -0
  41. package/lib/package-meta.mjs +6 -24
  42. package/lib/peer-bindings.mjs +14 -0
  43. package/lib/peer-format.mjs +4 -0
  44. package/lib/peer-identity.mjs +15 -0
  45. package/lib/project-context.mjs +6 -0
  46. package/lib/project-registry.mjs +7 -0
  47. package/lib/provider-commands.mjs +13 -0
  48. package/lib/release/package-meta.mjs +31 -0
  49. package/lib/release/release-notes.mjs +40 -0
  50. package/lib/release-notes.mjs +6 -40
  51. package/lib/runtime/client.mjs +42 -0
  52. package/lib/runtime/paths.mjs +45 -0
  53. package/lib/runtime/project-context.mjs +32 -0
  54. package/lib/runtime/projects.mjs +76 -0
  55. package/lib/runtime/state.mjs +128 -0
  56. package/lib/runtime-client.mjs +3 -0
  57. package/lib/runtime-paths.mjs +11 -45
  58. package/lib/runtime-state.mjs +11 -0
  59. package/lib/session-launch.mjs +17 -0
  60. package/lib/setup.mjs +17 -293
  61. package/lib/shared/errors.mjs +7 -0
  62. package/lib/shared/json-file.mjs +14 -0
  63. package/lib/shim-script.mjs +1 -384
  64. package/lib/state-render.mjs +5 -0
  65. package/lib/task-cli.mjs +45 -0
  66. package/lib/task-liveness.mjs +8 -0
  67. package/lib/task-store.mjs +3 -0
  68. package/lib/team-planning.mjs +7 -0
  69. package/lib/terminal/tmux.mjs +372 -0
  70. package/lib/timeline.mjs +12 -0
  71. package/lib/tmux.mjs +18 -0
  72. package/lib/ui/help.mjs +419 -0
  73. package/lib/ui/state-render.mjs +61 -0
  74. package/lib/web/http.mjs +55 -0
  75. package/lib/web/peer-actions.mjs +270 -0
  76. package/lib/web/runtime.mjs +154 -0
  77. package/lib/web/ui-template.mjs +2055 -0
  78. package/lib/web-http.mjs +7 -55
  79. package/lib/web-peer-actions.mjs +3 -0
  80. package/lib/web-runtime.mjs +17 -108
  81. package/lib/web-ui-template.mjs +3 -1909
  82. package/package.json +1 -1
  83. package/scripts/github-release.mjs +2 -2
  84. package/scripts/regression.mjs +2468 -17
  85. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,224 @@
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 claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
23
+ return tx(db, () => {
24
+ const tasks = [];
25
+ for (const id of ids) {
26
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
27
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
28
+ if (row.owner && row.owner !== peer && !force) {
29
+ throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
30
+ }
31
+ if (row.assignee && row.assignee !== peer && !force) {
32
+ throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
33
+ }
34
+ if (!['pending', 'blocked', 'claimed', 'running'].includes(row.status) && !force) {
35
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
36
+ }
37
+ const t = now();
38
+ db.prepare(`
39
+ UPDATE tasks
40
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
41
+ WHERE id = ?
42
+ `).run(peer, t, t, id);
43
+ addEvent(db, 'task.claimed', peer, id, {
44
+ previous_owner: row.owner,
45
+ force,
46
+ ...(source ? { source } : {})
47
+ });
48
+ tasks.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(id));
49
+ }
50
+ return tasks;
51
+ });
52
+ }
53
+
54
+ function takeoverPolicyDetails(db, row, peer, { policy = 'any', staleAfter = activePeerTtl } = {}) {
55
+ const normalized = String(policy || 'any').toLowerCase();
56
+ const allowed = new Set(['any', 'blocked', 'stale', 'blocked-or-stale']);
57
+ if (!allowed.has(normalized)) {
58
+ throw new CliError('BAD_ARGS', `Unsupported takeover policy: ${policy}`);
59
+ }
60
+ const previousOwner = row.owner || null;
61
+ const blocked = row.status === 'blocked';
62
+ const ownerRow = previousOwner ? db.prepare('SELECT id, last_seen_at FROM peers WHERE id = ?').get(previousOwner) : null;
63
+ const ownerAge = ownerRow ? now() - Number(ownerRow.last_seen_at || 0) : null;
64
+ const ownerStale = Boolean(previousOwner && previousOwner !== peer && (!ownerRow || ownerAge > staleAfter));
65
+ const alreadyOwner = previousOwner === peer;
66
+ const ok = alreadyOwner ||
67
+ normalized === 'any' ||
68
+ (normalized === 'blocked' && blocked) ||
69
+ (normalized === 'stale' && ownerStale) ||
70
+ (normalized === 'blocked-or-stale' && (blocked || ownerStale));
71
+ return { policy: normalized, blocked, owner_stale: ownerStale, owner_age_sec: ownerAge, stale_after_sec: staleAfter, ok };
72
+ }
73
+
74
+ function takeOverTaskForPeer(db, peer, id, { reason, policy = 'any', staleAfter = activePeerTtl, source = null } = {}) {
75
+ return tx(db, () => {
76
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
77
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
78
+ if (['done', 'abandoned'].includes(row.status)) {
79
+ throw new CliError('BAD_STATE', `Task #${id} is ${row.status}`);
80
+ }
81
+ const policyDetails = takeoverPolicyDetails(db, row, peer, { policy, staleAfter });
82
+ if (!policyDetails.ok) {
83
+ throw new CliError('TAKEOVER_POLICY', `Task #${id} does not match takeover policy ${policyDetails.policy}`, {
84
+ task_id: id,
85
+ status: row.status,
86
+ owner: row.owner || null,
87
+ policy: policyDetails.policy,
88
+ owner_stale: policyDetails.owner_stale,
89
+ owner_age_sec: policyDetails.owner_age_sec,
90
+ stale_after_sec: policyDetails.stale_after_sec
91
+ });
92
+ }
93
+ const previousOwner = row.owner || null;
94
+ const previousAssignee = row.assignee || null;
95
+ const t = now();
96
+ db.prepare(`
97
+ UPDATE tasks
98
+ SET owner = ?, status = 'claimed', claimed_at = COALESCE(claimed_at, ?), updated_at = ?
99
+ WHERE id = ?
100
+ `).run(peer, t, t, id);
101
+ addEvent(db, 'task.takeover', peer, id, {
102
+ previous_owner: previousOwner,
103
+ previous_assignee: previousAssignee,
104
+ reason,
105
+ policy: policyDetails.policy,
106
+ owner_stale: policyDetails.owner_stale,
107
+ stale_after_sec: staleAfter,
108
+ ...(source ? { source } : {})
109
+ });
110
+ if (previousOwner && previousOwner !== peer) {
111
+ sendMessage(db, peer, previousOwner, id, 'task.takeover', `Task #${id} taken over by ${peer}: ${reason}`);
112
+ }
113
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
114
+ });
115
+ }
116
+
117
+ function queryOpenTasks(db, limit, peer = null) {
118
+ const sql = `
119
+ SELECT * FROM tasks
120
+ WHERE status NOT IN ('done', 'abandoned')
121
+ ${peer ? 'AND (owner = ? OR assignee = ?)' : ''}
122
+ ORDER BY
123
+ CASE status
124
+ WHEN 'claimed' THEN 0
125
+ WHEN 'running' THEN 1
126
+ WHEN 'pending' THEN 2
127
+ WHEN 'blocked' THEN 3
128
+ WHEN 'review' THEN 4
129
+ ELSE 5
130
+ END,
131
+ priority ASC,
132
+ id ASC
133
+ LIMIT ?
134
+ `;
135
+ return peer
136
+ ? db.prepare(sql).all(peer, peer, limit)
137
+ : db.prepare(sql).all(limit);
138
+ }
139
+
140
+ function taskById(db, id) {
141
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
142
+ }
143
+
144
+ function teamChildren(db, parentId) {
145
+ return db.prepare(`
146
+ SELECT *
147
+ FROM tasks
148
+ WHERE parent_id = ?
149
+ ORDER BY priority ASC, id ASC
150
+ `).all(parentId);
151
+ }
152
+
153
+ function teamSummary(db, parentId) {
154
+ const parent = taskById(db, parentId);
155
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
156
+ const children = teamChildren(db, parentId);
157
+ const handoffs = db.prepare(`
158
+ SELECT *
159
+ FROM handoffs
160
+ WHERE task_id = ? OR task_id IN (SELECT id FROM tasks WHERE parent_id = ?)
161
+ ORDER BY id ASC
162
+ `).all(parentId, parentId);
163
+ const counts = {};
164
+ for (const child of children) counts[child.status] = (counts[child.status] || 0) + 1;
165
+ return { parent, children, handoffs, counts };
166
+ }
167
+
168
+ function claimNextTasksForPeer(db, peer, { force = false, count = 1 } = {}) {
169
+ return tx(db, () => {
170
+ if (!force) {
171
+ const current = db.prepare(`
172
+ SELECT * FROM tasks
173
+ WHERE owner = ?
174
+ AND status IN ('claimed', 'running', 'review', 'blocked')
175
+ ORDER BY
176
+ CASE status
177
+ WHEN 'running' THEN 0
178
+ WHEN 'claimed' THEN 1
179
+ WHEN 'review' THEN 2
180
+ WHEN 'blocked' THEN 3
181
+ ELSE 4
182
+ END,
183
+ priority ASC,
184
+ id ASC
185
+ LIMIT 1
186
+ `).get(peer);
187
+ if (current) return { current: { ...current, current: true }, tasks: [] };
188
+ }
189
+ const rows = db.prepare(`
190
+ SELECT * FROM tasks
191
+ WHERE status = 'pending'
192
+ AND owner IS NULL
193
+ AND (assignee IS NULL OR assignee = ?)
194
+ ORDER BY CASE WHEN assignee = ? THEN 0 ELSE 1 END, priority ASC, id ASC
195
+ LIMIT ?
196
+ `).all(peer, peer, count);
197
+ if (!rows.length) return { current: null, tasks: [] };
198
+ const t = now();
199
+ const claimed = [];
200
+ for (const row of rows) {
201
+ const changes = db.prepare(`
202
+ UPDATE tasks
203
+ SET owner = ?, status = 'claimed', claimed_at = ?, updated_at = ?
204
+ WHERE id = ? AND owner IS NULL AND status = 'pending'
205
+ `).run(peer, t, t, row.id).changes;
206
+ if (!changes) continue;
207
+ addEvent(db, 'task.claimed', peer, row.id, { next: true, count });
208
+ claimed.push(db.prepare('SELECT * FROM tasks WHERE id = ?').get(row.id));
209
+ }
210
+ return { current: null, tasks: claimed };
211
+ });
212
+ }
213
+
214
+ return {
215
+ claimNextTasksForPeer,
216
+ claimTaskRowsForPeer,
217
+ queryOpenTasks,
218
+ takeOverTaskForPeer,
219
+ taskById,
220
+ teamChildren,
221
+ teamSummary,
222
+ takeoverPolicyDetails
223
+ };
224
+ }
@@ -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,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
+ }