@logicseek/hello-cc 0.1.6 → 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.
@@ -8,7 +8,8 @@ export function createContext(global = {}, deps = {}) {
8
8
  const detectRoot = deps.detectRoot;
9
9
  if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
10
10
  const root = detectRoot(cwd, global.root);
11
- const dbPath = path.resolve(global.db || process.env.HCC_DB || projectDbPath(root));
11
+ const envDb = global.root ? null : process.env.HCC_DB;
12
+ const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
12
13
  return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
13
14
  }
14
15
 
@@ -19,6 +19,16 @@ export function createTaskStore(deps = {}) {
19
19
  const sendMessage = typeof deps.sendMessage === 'function' ? deps.sendMessage : noopMessage;
20
20
  const activePeerTtl = Number(deps.activePeerTtl || DEFAULT_ACTIVE_PEER_TTL);
21
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
+
22
32
  function claimTaskRowsForPeer(db, peer, ids, { force = false, source = null } = {}) {
23
33
  return tx(db, () => {
24
34
  const tasks = [];
@@ -26,7 +36,7 @@ export function createTaskStore(deps = {}) {
26
36
  const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
27
37
  if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
28
38
  if (row.owner && row.owner !== peer && !force) {
29
- throw new CliError('TASK_OWNED', `Task #${id} is owned by ${row.owner}`, { owner: row.owner });
39
+ throw taskOwnedError(peer, row, 'claim');
30
40
  }
31
41
  if (row.assignee && row.assignee !== peer && !force) {
32
42
  throw new CliError('TASK_ASSIGNED', `Task #${id} is assigned to ${row.assignee}`, { assignee: row.assignee });
@@ -211,7 +221,13 @@ export function createTaskStore(deps = {}) {
211
221
  });
212
222
  }
213
223
 
224
+ function assertTaskOwnerForMutation(db, peer, row, action) {
225
+ if (!row.owner || row.owner === peer) return;
226
+ throw taskOwnedError(peer, row, action);
227
+ }
228
+
214
229
  return {
230
+ assertTaskOwnerForMutation,
215
231
  claimNextTasksForPeer,
216
232
  claimTaskRowsForPeer,
217
233
  queryOpenTasks,
@@ -25,7 +25,7 @@ export function taskParticipants(task) {
25
25
 
26
26
  export function payloadParticipants(payload) {
27
27
  const values = [];
28
- for (const key of ['peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
28
+ for (const key of ['peer', 'actor_peer', 'target_peer', 'from_peer', 'to_peer', 'assignee', 'owner', 'recipient', 'kept_peer', 'removed_peer', 'previous_owner']) {
29
29
  if (payload?.[key] && payload[key] !== 'all') values.push(payload[key]);
30
30
  }
31
31
  return uniqueList(values);
@@ -59,13 +59,22 @@ const TIMELINE_EVENT_ALLOW = new Set([
59
59
  'peer.registered',
60
60
  'peer.joined',
61
61
  'peer.auto_joined',
62
+ 'peer.start.requested',
63
+ 'peer.attach.requested',
64
+ 'peer.stop.requested',
62
65
  'peer.stopped',
66
+ 'peer.restarted',
63
67
  'provider.session.merged',
68
+ 'provider.session.rebound',
64
69
  'web.session.started',
65
70
  'web.session.exited',
71
+ 'web.session.stop_requested',
66
72
  'tmux.session.attached',
67
73
  'tmux.session.detached',
68
74
  'tmux.session.exited',
75
+ 'tmux.session.restarted',
76
+ 'tmux.session.rebound',
77
+ 'tmux.session.gc',
69
78
  'run.session.started',
70
79
  'run.session.exited'
71
80
  ]);
@@ -43,8 +43,16 @@ export function autoPeerId(ctx, kindHint = 'shell', observation = {}) {
43
43
  }
44
44
 
45
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 };
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 };
48
56
  return { id: autoPeerId(ctx, kindHint, observation), auto: true };
49
57
  }
50
58
 
@@ -30,6 +30,7 @@ function requireFn(value, name) {
30
30
  export function createWebPeerActions(deps = {}) {
31
31
  const activePeerTtl = Number(deps.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
32
32
  const addEvent = requireFn(deps.addEvent, 'addEvent');
33
+ const assertTaskOwnerForMutation = requireFn(deps.assertTaskOwnerForMutation, 'assertTaskOwnerForMutation');
33
34
  const claimNextTasksForPeer = requireFn(deps.claimNextTasksForPeer, 'claimNextTasksForPeer');
34
35
  const connect = requireFn(deps.connect, 'connect');
35
36
  const defaultLockTtl = Number(deps.defaultLockTtl ?? DEFAULT_LOCK_TTL);
@@ -44,7 +45,32 @@ export function createWebPeerActions(deps = {}) {
44
45
  const tx = requireFn(deps.tx, 'tx');
45
46
  const upsertPeer = requireFn(deps.upsertPeer, 'upsertPeer');
46
47
 
48
+ function webMutationPeer(peer, input = {}) {
49
+ const actorPeer = String(input.actorPeer || input.actor_peer || '').trim();
50
+ if (!actorPeer) {
51
+ throw new CliError('PEER_IDENTITY_REQUIRED', 'Web peer mutations require a managed session identity');
52
+ }
53
+ if (actorPeer !== peer) {
54
+ throw new CliError('PEER_IDENTITY_MISMATCH', `Web peer action for ${peer} cannot run as ${actorPeer}`, {
55
+ peer,
56
+ actor_peer: actorPeer
57
+ });
58
+ }
59
+ return actorPeer;
60
+ }
61
+
62
+ function webAuditPayload(peer, input = {}, extra = {}) {
63
+ const actorPeer = String(input.actorPeer || input.actor_peer || peer).trim() || peer;
64
+ return {
65
+ ...extra,
66
+ source: 'web',
67
+ actor_peer: actorPeer,
68
+ target_peer: peer
69
+ };
70
+ }
71
+
47
72
  function webPeerRegister(projectCtx, peer, input = {}) {
73
+ webMutationPeer(peer, input);
48
74
  const db = connect(projectCtx);
49
75
  const row = {
50
76
  id: peer,
@@ -58,7 +84,7 @@ export function createWebPeerActions(deps = {}) {
58
84
  };
59
85
  try {
60
86
  upsertPeer(db, row);
61
- addEvent(db, 'peer.registered', peer, null, { ...row, source: 'web' });
87
+ addEvent(db, 'peer.registered', peer, null, webAuditPayload(peer, input, row));
62
88
  } finally {
63
89
  db.close();
64
90
  }
@@ -66,6 +92,7 @@ export function createWebPeerActions(deps = {}) {
66
92
  }
67
93
 
68
94
  function webPeerHeartbeat(projectCtx, peer, input = {}) {
95
+ webMutationPeer(peer, input);
69
96
  const ttl = intOpt(input, 'ttl', defaultLockTtl);
70
97
  const status = input.status || null;
71
98
  const renewLocks = input['renew-locks'] !== false && input.renew_locks !== false;
@@ -80,7 +107,7 @@ export function createWebPeerActions(deps = {}) {
80
107
  WHERE owner = ? AND expires_at > ?
81
108
  `).run(t + ttl, peer, t).changes;
82
109
  }
83
- addEvent(db, 'peer.heartbeat', peer, null, { status, renewed, source: 'web' });
110
+ addEvent(db, 'peer.heartbeat', peer, null, webAuditPayload(peer, input, { status, renewed }));
84
111
  } finally {
85
112
  db.close();
86
113
  }
@@ -93,6 +120,7 @@ export function createWebPeerActions(deps = {}) {
93
120
  }
94
121
 
95
122
  function webPeerTaskNext(projectCtx, peer, input = {}) {
123
+ webMutationPeer(peer, input);
96
124
  const db = connect(projectCtx);
97
125
  try {
98
126
  touchPeer(db, peer, 'working');
@@ -117,6 +145,7 @@ export function createWebPeerActions(deps = {}) {
117
145
  }
118
146
 
119
147
  function webPeerTaskTakeover(projectCtx, peer, input = {}) {
148
+ webMutationPeer(peer, input);
120
149
  const id = intOpt(input, 'id', intOpt({ id: input.task }, 'id'));
121
150
  if (!id) throw new CliError('BAD_REQUEST', 'task id required');
122
151
  const reason = required(input, 'reason');
@@ -133,6 +162,7 @@ export function createWebPeerActions(deps = {}) {
133
162
  }
134
163
 
135
164
  function webPeerLockAcquire(projectCtx, peer, input = {}) {
165
+ webMutationPeer(peer, input);
136
166
  const requested = scopedLockResource(required(input, 'resource'), input.scope);
137
167
  const ttl = intOpt(input, 'ttl', defaultLockTtl);
138
168
  const taskId = intOpt(input, 'task', intOpt(input, 'task_id', null));
@@ -142,6 +172,11 @@ export function createWebPeerActions(deps = {}) {
142
172
  touchPeer(db, peer, 'working');
143
173
  const lock = tx(db, () => {
144
174
  const t = now();
175
+ if (taskId) {
176
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
177
+ if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
178
+ assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
179
+ }
145
180
  const activeLocks = db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
146
181
  const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
147
182
  if (conflict) {
@@ -166,7 +201,7 @@ export function createWebPeerActions(deps = {}) {
166
201
  reason = excluded.reason,
167
202
  expires_at = excluded.expires_at
168
203
  `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, t + ttl, existing ? existing.created_at : t);
169
- addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null, source: 'web' });
204
+ addEvent(db, 'lock.acquired', peer, taskId, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null }));
170
205
  return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
171
206
  });
172
207
  return { peer, lock, summary: `locked ${lockLabel(lock)} by ${lock.owner} until ${iso(lock.expires_at)}` };
@@ -176,19 +211,20 @@ export function createWebPeerActions(deps = {}) {
176
211
  }
177
212
 
178
213
  function webPeerLockRelease(projectCtx, peer, input = {}) {
214
+ webMutationPeer(peer, input);
179
215
  const requested = scopedLockResource(required(input, 'resource'), input.scope);
180
- const force = Boolean(input.force);
216
+ const force = false;
181
217
  const db = connect(projectCtx);
182
218
  try {
183
219
  touchPeer(db, peer, null);
184
220
  const result = tx(db, () => {
185
221
  const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
186
222
  if (!existing) return { released: false, ...requested };
187
- if (existing.owner !== peer && !force) {
223
+ if (existing.owner !== peer) {
188
224
  throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
189
225
  }
190
226
  db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
191
- addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force, source: 'web' });
227
+ addEvent(db, 'lock.released', peer, existing.task_id || null, webAuditPayload(peer, input, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force }));
192
228
  return { released: true, ...requested };
193
229
  });
194
230
  return { peer, result, summary: result.released ? `released ${lockLabel(result)}` : `no lock for ${lockLabel(result)}` };
@@ -1693,7 +1693,7 @@ export function webIndexHtml() {
1693
1693
  if (!body) return;
1694
1694
  await api('/api/detected/' + encodeURIComponent(peer.id) + '/msg', {
1695
1695
  method: 'POST',
1696
- body: JSON.stringify({ body, from: 'web' })
1696
+ body: JSON.stringify({ body })
1697
1697
  });
1698
1698
  document.getElementById('detMsg').value = '';
1699
1699
  await refreshDetectedState();
@@ -1733,7 +1733,8 @@ export function webIndexHtml() {
1733
1733
  const session = sessions.find((s) => s.id === active) || { id: active, kind: 'other', role: 'peer' };
1734
1734
  return {
1735
1735
  session,
1736
- peerId: sessionPeerId(session) || active
1736
+ peerId: sessionPeerId(session) || active,
1737
+ actionToken: session.action_token || ''
1737
1738
  };
1738
1739
  }
1739
1740
 
@@ -1807,12 +1808,13 @@ export function webIndexHtml() {
1807
1808
  async function runPeerAction(action) {
1808
1809
  const info = activePeerInfo();
1809
1810
  if (!info) return;
1811
+ const readOnly = ['status', 'state', 'inbox'].includes(action);
1810
1812
  const payload = action === 'register'
1811
1813
  ? { kind: info.session.kind || 'other', role: info.session.role || 'peer', worktree: info.session.cwd || currentProject }
1812
1814
  : action === 'heartbeat'
1813
1815
  ? { renew_locks: true }
1814
1816
  : {};
1815
- const readOnly = ['status', 'state', 'inbox'].includes(action);
1817
+ if (!readOnly) payload.action_token = info.actionToken;
1816
1818
  const result = await api('/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action), readOnly
1817
1819
  ? {}
1818
1820
  : { method: 'POST', body: JSON.stringify(payload) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {