@logicseek/hello-cc 0.1.9 → 1.0.1

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 (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,197 @@
1
+ // Lock CLI commands extracted from bin/hcc.mjs.
2
+ // Factory pattern with injected deps.
3
+
4
+ export function createLockCommands(deps) {
5
+ const {
6
+ connect,
7
+ now,
8
+ iso,
9
+ tx,
10
+ addEvent,
11
+ touchCurrentPeer,
12
+ resolveCurrentPeer,
13
+ parseOpts,
14
+ intOpt,
15
+ required,
16
+ positiveSafeIntOpt,
17
+ wantsHelp,
18
+ helpLock,
19
+ printResult,
20
+ table,
21
+ CliError,
22
+ DEFAULT_LOCK_TTL,
23
+ leaseDeadline,
24
+ scopedLockResource,
25
+ lockLabel,
26
+ lockScope,
27
+ lockBaseResource,
28
+ locksConflict,
29
+ clockGraceSuppressed,
30
+ readClockGraceUntil,
31
+ clockSafetyUnavailable,
32
+ captureLockAcquireSubject,
33
+ sameLockAcquireSubject,
34
+ observeLockOwnerEvidence,
35
+ observePeerEvidence,
36
+ prepareLockClockObservation,
37
+ runOptimisticEvidenceMutation,
38
+ assertTaskOwnerForMutation,
39
+ notifyTaskOwnerConflict
40
+ } = deps;
41
+
42
+ async function cmdLock(ctx, args) {
43
+ const sub = args[0];
44
+ if (!sub || wantsHelp(args)) return helpLock();
45
+ if (sub === 'acquire') return lockAcquire(ctx, args.slice(1));
46
+ if (sub === 'release') return lockRelease(ctx, args.slice(1));
47
+ if (sub === 'renew') return lockRenew(ctx, args.slice(1));
48
+ if (sub === 'list') return lockList(ctx, args.slice(1));
49
+ throw new CliError('BAD_ARGS', `Unknown lock command: ${sub}`);
50
+ }
51
+
52
+ async function lockAcquire(ctx, args) {
53
+ const opts = parseOpts(args);
54
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
55
+ const peer = identity.id;
56
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
57
+ const taskId = intOpt(opts, 'task', null);
58
+ const ttl = positiveSafeIntOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
59
+ const reason = opts.reason || '';
60
+ const acquisitionNow = now();
61
+ leaseDeadline(acquisitionNow, ttl);
62
+ const db = connect(ctx);
63
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
64
+ let lock;
65
+ try {
66
+ const outcome = runOptimisticEvidenceMutation(db, {
67
+ capture: (subjectDb) => {
68
+ try {
69
+ return captureLockAcquireSubject(subjectDb, {
70
+ taskId,
71
+ requested,
72
+ now: acquisitionNow
73
+ });
74
+ } catch (err) {
75
+ throw clockSafetyUnavailable(err);
76
+ }
77
+ },
78
+ observe: (subject) => observeLockOwnerEvidence(subject, (row, binding) =>
79
+ observePeerEvidence(ctx, row, binding)),
80
+ same: sameLockAcquireSubject,
81
+ beforeMutate: (subject, evidenceByOwner) =>
82
+ prepareLockClockObservation(db, subject, evidenceByOwner),
83
+ changedMessage: `Lock subjects changed while acquiring ${lockLabel(requested)}; retry`,
84
+ mutate: (subject, evidenceByOwner, clockObservation) => {
85
+ const t = subject.observedAt;
86
+ if (taskId) {
87
+ const task = subject.task;
88
+ if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
89
+ assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
90
+ }
91
+ const graceActive = clockGraceSuppressed(t, clockObservation.graceUntil);
92
+ const activeLocks = graceActive
93
+ ? subject.locks.filter((row) =>
94
+ !(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
95
+ : subject.locks.filter((row) =>
96
+ Number(row.expires_at) > t ||
97
+ evidenceByOwner.get(row.owner)?.state === 'live'
98
+ );
99
+ const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
100
+ if (conflict) {
101
+ return { error: new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
102
+ resource: requested.base_resource,
103
+ scope: requested.scope,
104
+ lock_resource: conflict.resource,
105
+ lock_scope: lockScope(conflict),
106
+ owner: conflict.owner,
107
+ expires_at: iso(conflict.expires_at)
108
+ }) };
109
+ }
110
+ const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
111
+ const expiresAt = leaseDeadline(t, ttl);
112
+ db.prepare(`
113
+ INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
114
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
115
+ ON CONFLICT(resource) DO UPDATE SET
116
+ base_resource = excluded.base_resource,
117
+ scope = excluded.scope,
118
+ owner = excluded.owner,
119
+ task_id = excluded.task_id,
120
+ reason = excluded.reason,
121
+ expires_at = excluded.expires_at,
122
+ created_at = excluded.created_at,
123
+ ttl_sec = excluded.ttl_sec
124
+ `).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
125
+ addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
126
+ return { lock: db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) };
127
+ }
128
+ });
129
+ if (outcome.error) throw outcome.error;
130
+ lock = outcome.lock;
131
+ } catch (err) {
132
+ notifyTaskOwnerConflict(ctx, err);
133
+ throw err;
134
+ }
135
+ printResult(ctx, lock, (data) => `locked ${lockLabel(data)} by ${data.owner} until ${iso(data.expires_at)}`);
136
+ }
137
+
138
+ async function lockRelease(ctx, args) {
139
+ const opts = parseOpts(args, { booleans: ['force'] });
140
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
141
+ const peer = identity.id;
142
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
143
+ const db = connect(ctx);
144
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
145
+ const result = tx(db, () => {
146
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
147
+ if (!existing) return { released: false, ...requested };
148
+ if (existing.owner !== peer && !opts.force) {
149
+ throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
150
+ }
151
+ db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
152
+ addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force: Boolean(opts.force) });
153
+ return { released: true, ...requested };
154
+ });
155
+ printResult(ctx, result, (data) => data.released ? `released ${lockLabel(data)}` : `no lock for ${lockLabel(data)}`);
156
+ }
157
+
158
+ async function lockRenew(ctx, args) {
159
+ const opts = parseOpts(args);
160
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
161
+ const peer = identity.id;
162
+ const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
163
+ const ttl = positiveSafeIntOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
164
+ const renewalNow = now();
165
+ const expiresAt = leaseDeadline(renewalNow, ttl);
166
+ const db = connect(ctx);
167
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
168
+ const lock = tx(db, () => {
169
+ const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
170
+ if (!existing) throw new CliError('NOT_FOUND', `No lock for ${lockLabel(requested)}`);
171
+ if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
172
+ db.prepare('UPDATE locks SET expires_at = ?, ttl_sec = ? WHERE resource = ?').run(expiresAt, ttl, requested.resource);
173
+ addEvent(db, 'lock.renewed', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl });
174
+ return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
175
+ });
176
+ printResult(ctx, lock, (data) => `renewed ${lockLabel(data)} until ${iso(data.expires_at)}`);
177
+ }
178
+
179
+ async function lockList(ctx, args) {
180
+ const opts = parseOpts(args, { booleans: ['all'] });
181
+ const db = connect(ctx);
182
+ const t = now();
183
+ const rows = opts.all || clockGraceSuppressed(t, readClockGraceUntil(db))
184
+ ? db.prepare('SELECT * FROM locks ORDER BY resource ASC').all()
185
+ : db.prepare('SELECT * FROM locks WHERE expires_at > ? ORDER BY resource ASC').all(t);
186
+ printResult(ctx, rows, (data) => table(data, [
187
+ { label: 'resource', value: (r) => lockBaseResource(r) },
188
+ { label: 'scope', value: (r) => lockScope(r) },
189
+ { label: 'owner', value: (r) => r.owner },
190
+ { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
191
+ { label: 'expires', value: (r) => iso(r.expires_at) },
192
+ { label: 'reason', value: (r) => r.reason || '' }
193
+ ]));
194
+ }
195
+
196
+ return { cmdLock };
197
+ }
@@ -0,0 +1,76 @@
1
+ // Small utility commands extracted from bin/hcc.mjs: down, find-root, which-real.
2
+
3
+ export function createMiscCommands(deps) {
4
+ const {
5
+ path, process, CliError, parseOpts, printResult,
6
+ readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
7
+ reclaimRuntimePointerFiles, helpDown, loadSetup,
8
+ waitForProcessIdentityExit,
9
+ PRODUCT_NAME
10
+ } = deps;
11
+
12
+ async function cmdDown(ctx, args) {
13
+ if (args[0] === '--help' || args[0] === '-h') return helpDown();
14
+ const pointerFiles = [runtimePath(ctx), globalRuntimePath()];
15
+ let runtime;
16
+ try {
17
+ runtime = readRuntime(ctx);
18
+ } catch (err) {
19
+ if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') || process.env.HCC_RUNTIME_URL) throw err;
20
+ const cleanup = reclaimRuntimePointerFiles(pointerFiles, { reclaimUnknown: false });
21
+ if (cleanup.reclaimed < 1 || cleanup.blocked) throw err;
22
+ printResult(ctx, { pointers: cleanup.reclaimed }, (result) =>
23
+ `${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
24
+ return;
25
+ }
26
+ try {
27
+ await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
28
+ } catch (err) {
29
+ if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
30
+ if (runtime.source === 'env') throw err;
31
+ const source = path.resolve(String(runtime.source || ''));
32
+ const localSource = pointerFiles.find((file) => path.resolve(file) === source);
33
+ if (!localSource) throw err;
34
+ const cleanup = reclaimRuntimePointerFiles([localSource], { reclaimUnknown: false });
35
+ if (cleanup.reclaimed !== 1 || cleanup.blocked) throw err;
36
+ printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
37
+ return;
38
+ }
39
+ if (runtime.source !== 'env') {
40
+ const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
41
+ const stopped = await waitForProcessIdentityExit(processIdentity, {
42
+ timeoutMs: 5_000,
43
+ intervalMs: 25
44
+ });
45
+ if (stopped.state !== 'dead') {
46
+ throw new CliError(
47
+ 'RUNTIME_STOP_TIMEOUT',
48
+ `Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
49
+ { pid: runtime.pid || null, state: stopped.state }
50
+ );
51
+ }
52
+ }
53
+ printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
54
+ }
55
+
56
+ async function cmdFindRoot(ctx, args) {
57
+ const opts = parseOpts(args);
58
+ if (process.env.HCC_ROOT) {
59
+ process.stdout.write(path.resolve(process.env.HCC_ROOT) + '\n');
60
+ return;
61
+ }
62
+ const root = ctx.explicitRoot ? ctx.root : path.resolve(opts.cwd || process.cwd());
63
+ process.stdout.write(root + '\n');
64
+ }
65
+
66
+ async function cmdWhichReal(ctx, args) {
67
+ const name = args[0];
68
+ if (!name) throw new CliError('BAD_ARGS', 'Usage: hcc which-real <binary>');
69
+ const { findRealBinary } = await loadSetup();
70
+ const p = findRealBinary(name);
71
+ if (!p) { process.exitCode = 1; return; }
72
+ process.stdout.write(p + '\n');
73
+ }
74
+
75
+ return { cmdDown, cmdFindRoot, cmdWhichReal };
76
+ }
@@ -0,0 +1,152 @@
1
+ // Message CLI commands extracted from bin/hcc.mjs.
2
+ // Factory pattern: all bin/hcc.mjs dependencies are injected.
3
+
4
+ export function createMsgCommands(deps) {
5
+ const {
6
+ connect,
7
+ now,
8
+ iso,
9
+ touchCurrentPeer,
10
+ resolveCurrentPeer,
11
+ registerProjectActivity,
12
+ parseOpts,
13
+ intOpt,
14
+ required,
15
+ wantsHelp,
16
+ helpMsg,
17
+ printResult,
18
+ table,
19
+ sleep,
20
+ CliError,
21
+ ackMessage,
22
+ getMessage,
23
+ queryInbox,
24
+ queryMessageThread,
25
+ sendMessage
26
+ } = deps;
27
+
28
+ async function cmdMsg(ctx, args) {
29
+ const sub = args[0];
30
+ if (!sub || wantsHelp(args)) return helpMsg();
31
+ if (sub === 'send') return msgSend(ctx, args.slice(1));
32
+ if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
33
+ if (sub === 'ack') return msgAck(ctx, args.slice(1));
34
+ if (sub === 'reply') return msgReply(ctx, args.slice(1));
35
+ if (sub === 'thread') return msgThread(ctx, args.slice(1));
36
+ throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
37
+ }
38
+
39
+ async function msgSend(ctx, args) {
40
+ const opts = parseOpts(args);
41
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
42
+ const sender = identity.id;
43
+ const recipient = opts.to || 'all';
44
+ const body = required(opts, 'body');
45
+ const taskId = intOpt(opts, 'task', null);
46
+ const kind = opts.kind || 'note';
47
+ const db = connect(ctx);
48
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
49
+ const id = sendMessage(db, sender, recipient, taskId, kind, body);
50
+ printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, reply_to: null, thread_id: id },
51
+ (data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
52
+ }
53
+
54
+ async function msgInbox(ctx, args) {
55
+ const opts = parseOpts(args, { booleans: ['all'] });
56
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
57
+ const peer = identity.id;
58
+ const waitSec = intOpt(opts, 'wait', 0);
59
+ const limit = intOpt(opts, 'limit', 20);
60
+ const includeAll = Boolean(opts.all);
61
+ const db = connect(ctx);
62
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
63
+ const deadline = Date.now() + waitSec * 1000;
64
+ let rows = queryInbox(db, peer, includeAll, limit);
65
+ while (!rows.length && waitSec > 0 && Date.now() < deadline) {
66
+ await sleep(1000);
67
+ rows = queryInbox(db, peer, includeAll, limit);
68
+ }
69
+ printResult(ctx, rows, (data) => table(data, [
70
+ { label: 'id', value: (r) => `#${r.id}` },
71
+ { label: 'from', value: (r) => r.sender },
72
+ { label: 'kind', value: (r) => r.kind },
73
+ { label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
74
+ { label: 'reply', value: (r) => r.reply_to ? `#${r.reply_to}` : '' },
75
+ { label: 'thread', value: (r) => r.thread_id ? `#${r.thread_id}` : '' },
76
+ { label: 'time', value: (r) => iso(r.created_at) },
77
+ { label: 'body', value: (r) => r.body }
78
+ ]));
79
+ }
80
+
81
+ async function msgAck(ctx, args) {
82
+ const opts = parseOpts(args);
83
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
84
+ const peer = identity.id;
85
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
86
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
87
+ const db = connect(ctx);
88
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
89
+ const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
90
+ if (!message) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
91
+ ackMessage(db, peer, message);
92
+ printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
93
+ }
94
+
95
+ async function msgReply(ctx, args) {
96
+ const opts = parseOpts(args);
97
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
98
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
99
+ const body = required(opts, 'body');
100
+ const db = connect(ctx);
101
+ const original = getMessage(db, id);
102
+ if (!original) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
103
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
104
+ const sender = identity.id;
105
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
106
+ const recipient = opts.to || original.sender;
107
+ const taskId = intOpt(opts, 'task', original.task_id || null);
108
+ const kind = opts.kind || 'reply';
109
+ const threadId = original.thread_id || original.id;
110
+ const replyId = sendMessage(db, sender, recipient, taskId, kind, body, {
111
+ reply_to: original.id,
112
+ thread_id: threadId
113
+ });
114
+ ackMessage(db, sender, original);
115
+ printResult(ctx, {
116
+ id: replyId,
117
+ sender,
118
+ recipient,
119
+ task_id: taskId,
120
+ kind,
121
+ body,
122
+ reply_to: original.id,
123
+ thread_id: threadId
124
+ }, (data) => `sent reply #${data.id} to #${data.reply_to} ${data.sender} -> ${data.recipient}`);
125
+ }
126
+
127
+ async function msgThread(ctx, args) {
128
+ const opts = parseOpts(args);
129
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
130
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
131
+ const limit = intOpt(opts, 'limit', 50);
132
+ const db = connect(ctx);
133
+ const data = queryMessageThread(db, id, limit);
134
+ printResult(ctx, data, (thread) => {
135
+ const lines = [`thread #${thread.thread_id} (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`];
136
+ for (const message of thread.messages) {
137
+ const parts = [
138
+ `#${message.id}`,
139
+ `${message.sender} -> ${message.recipient || 'all'}`,
140
+ message.task_id ? `task #${message.task_id}` : '',
141
+ message.reply_to ? `reply #${message.reply_to}` : '',
142
+ message.kind || 'note',
143
+ iso(message.created_at)
144
+ ].filter(Boolean).join(' ');
145
+ lines.push(`${parts}\n ${message.body}`);
146
+ }
147
+ return lines.join('\n');
148
+ });
149
+ }
150
+
151
+ return { cmdMsg };
152
+ }
@@ -0,0 +1,249 @@
1
+ // Peer management commands extracted from bin/hcc.mjs.
2
+
3
+ import {
4
+ RUNTIME_SESSION_MUTATION_TIMEOUT_MS,
5
+ RUNTIME_SESSION_START_TIMEOUT_MS
6
+ } from '../../runtime/client.mjs';
7
+
8
+ export function createPeerCommands(deps) {
9
+ const {
10
+ connect, now, tx, addEvent, auditPayload, printResult, CliError, parseOpts,
11
+ detectBranch, buildPeerCommand, childSessionEnv, inferPeerKind,
12
+ findProviderSessionBinding, bindingHasRuntime,
13
+ resolveCurrentPeer, helpPeer, wantsHelp, CLI_NAME, cmdPeers,
14
+ runtimeRequest, readRuntime,
15
+ upsertPeer, upsertCanonicalPeerBinding, storedPeerIdentity,
16
+ path, process, Map, table
17
+ } = deps;
18
+
19
+ async function cmdPeer(ctx, args) {
20
+ const sub = args[0];
21
+ if (!sub || wantsHelp(args)) return helpPeer();
22
+ if (sub === 'list') return peerList(ctx, args.slice(1));
23
+ if (sub === 'start') return peerStart(ctx, args.slice(1));
24
+ if (sub === 'attach') return peerAttach(ctx, args.slice(1));
25
+ if (sub === 'stop') return peerStop(ctx, args.slice(1));
26
+ throw new CliError('BAD_ARGS', `Unknown peer command: ${sub}`);
27
+ }
28
+
29
+ async function peerList(ctx, args) {
30
+ parseOpts(args);
31
+ try {
32
+ const data = await runtimeRequest(ctx, 'GET', '/api/sessions');
33
+ const db = connect(ctx);
34
+ let bindings;
35
+ try {
36
+ bindings = new Map(db.prepare('SELECT * FROM peer_bindings').all().map((row) => [row.peer, row]));
37
+ } finally {
38
+ db.close();
39
+ }
40
+ const rows = (data.sessions || []).map((session) => ({
41
+ ...session,
42
+ binding: bindings.get(session.peer_id || session.id) || bindings.get(session.id) || null
43
+ }));
44
+ printResult(ctx, rows, (items) => table(items, [
45
+ { label: 'id', value: (r) => r.id },
46
+ { label: 'peer', value: (r) => r.peer_id && r.peer_id !== r.id ? r.peer_id : '' },
47
+ { label: 'kind', value: (r) => r.kind },
48
+ { label: 'role', value: (r) => r.role || '' },
49
+ { label: 'status', value: (r) => r.status },
50
+ { label: 'type', value: (r) => r.type || '' },
51
+ { label: 'pane', value: (r) => r.pane || '' },
52
+ { label: 'provider', value: (r) => r.binding ? r.binding.provider : '' },
53
+ { label: 'resume', value: (r) => r.binding ? r.binding.resume_mode : '' },
54
+ { label: 'session', value: (r) => r.binding ? (r.binding.provider_session_id || r.binding.provider_session_name || '') : '' },
55
+ { label: 'pid', value: (r) => r.pid || '' },
56
+ { label: 'command', value: (r) => r.command || '' }
57
+ ]));
58
+ } catch (err) {
59
+ if (err instanceof CliError && ['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
60
+ return cmdPeers(ctx, []);
61
+ }
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ async function peerStart(ctx, args) {
67
+ const sep = args.indexOf('--');
68
+ const optArgs = sep >= 0 ? args.slice(0, sep) : args;
69
+ const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
70
+ const opts = parseOpts(optArgs, { booleans: ['last', 'continue', 'fork', 'force', 'restart-env'] });
71
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
72
+ const id = opts.peer || opts._[0];
73
+ if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
74
+ const firstCommand = cmdArgs[0] || '';
75
+ const kind = inferPeerKind(id, opts.kind, firstCommand);
76
+ const role = opts.role || 'peer';
77
+ const cwd = path.resolve(opts.cwd || ctx.root);
78
+ const built = buildPeerCommand(id, kind, opts, cmdArgs);
79
+ const command = built.command;
80
+ const binding = { ...built.binding, transport: 'tmux', runtime_session_id: id };
81
+
82
+ let runtime;
83
+ try { runtime = readRuntime(ctx); } catch (e) {
84
+ if (e instanceof CliError && e.code === 'RUNTIME_NOT_RUNNING') {
85
+ throw new CliError('RUNTIME_NOT_RUNNING',
86
+ `No running web runtime found. Start ${CLI_NAME} web first, then ${CLI_NAME} peer start ${id}`);
87
+ }
88
+ throw e;
89
+ }
90
+
91
+ await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime, {
92
+ timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS
93
+ });
94
+ const db = connect(ctx);
95
+ try {
96
+ tx(db, () => {
97
+ if (!opts.force) {
98
+ const conflict = findProviderSessionBinding(db, binding);
99
+ if (bindingHasRuntime(conflict)) {
100
+ const providerSession = binding.provider_session_id || binding.provider_session_name || '';
101
+ throw new CliError('PROVIDER_SESSION_IN_USE', `${binding.provider} session ${providerSession} is already bound to ${conflict.peer}`, {
102
+ peer: conflict.peer,
103
+ provider: conflict.provider,
104
+ provider_session: providerSession
105
+ });
106
+ }
107
+ }
108
+ const previousOwner = db.prepare(`
109
+ SELECT pid, pid_start_token, pid_command_hash
110
+ FROM peers WHERE id = ?
111
+ `).get(id) || null;
112
+ upsertPeer(db, {
113
+ id, kind, role,
114
+ worktree: cwd,
115
+ branch: detectBranch(cwd),
116
+ pid: previousOwner?.pid || null,
117
+ processIdentity: storedPeerIdentity(previousOwner),
118
+ status: 'starting',
119
+ capabilities: 'tmux'
120
+ });
121
+ addEvent(db, 'peer.start.requested', actor, null, auditPayload({
122
+ actor,
123
+ target: id,
124
+ peer: id,
125
+ source: 'cli',
126
+ admin: actor !== id,
127
+ kind,
128
+ role,
129
+ command,
130
+ cwd
131
+ }));
132
+ });
133
+ } finally {
134
+ db.close();
135
+ }
136
+ const data = await runtimeRequest(ctx, 'POST', '/api/sessions', {
137
+ id,
138
+ kind,
139
+ role,
140
+ command,
141
+ cwd,
142
+ binding,
143
+ env: childSessionEnv(),
144
+ restartOnEnvChange: Boolean(opts['restart-env']),
145
+ providerForce: Boolean(opts.force)
146
+ }, runtime, { timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS });
147
+ printResult(ctx, data.session, (session) =>
148
+ `started ${session.id} (${session.kind}, ${session.role})${session.pane ? ` pane=${session.pane}` : ` pid=${session.pid}`}`);
149
+ }
150
+
151
+ async function peerAttach(ctx, args) {
152
+ const opts = parseOpts(args, { booleans: ['force'] });
153
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
154
+ const id = opts.peer || opts._[0];
155
+ if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
156
+ const pane = opts.pane || process.env.TMUX_PANE || null;
157
+ const kind = opts.kind || null;
158
+ const role = opts.role || 'peer';
159
+ const cwd = opts.cwd ? path.resolve(opts.cwd) : null;
160
+
161
+ let runtime;
162
+ try { runtime = readRuntime(ctx); } catch (e) {
163
+ if (e instanceof CliError && e.code === 'RUNTIME_NOT_RUNNING') {
164
+ throw new CliError('RUNTIME_NOT_RUNNING',
165
+ `No running web runtime found. Start ${CLI_NAME} web first, then ${CLI_NAME} peer attach ${id}`);
166
+ }
167
+ throw e;
168
+ }
169
+ await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime, {
170
+ timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS
171
+ });
172
+ const db = connect(ctx);
173
+ try {
174
+ addEvent(db, 'peer.attach.requested', actor, null, auditPayload({
175
+ actor,
176
+ target: id,
177
+ peer: id,
178
+ source: 'cli',
179
+ admin: actor !== id,
180
+ pane,
181
+ kind,
182
+ role,
183
+ cwd
184
+ }));
185
+ } finally {
186
+ db.close();
187
+ }
188
+ const data = await runtimeRequest(ctx, 'POST', '/api/sessions/attach', {
189
+ id,
190
+ kind,
191
+ role,
192
+ pane,
193
+ cwd,
194
+ force: Boolean(opts.force)
195
+ }, runtime, { timeoutMs: RUNTIME_SESSION_START_TIMEOUT_MS });
196
+ printResult(ctx, data.session, (session) => `attached ${session.id} (${session.kind}, ${session.role}) pane=${session.pane}`);
197
+ }
198
+
199
+ async function peerStop(ctx, args) {
200
+ const opts = parseOpts(args);
201
+ const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
202
+ const id = opts.peer || opts._[0];
203
+ if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
204
+ let data;
205
+ try {
206
+ const db = connect(ctx);
207
+ try {
208
+ addEvent(db, 'peer.stop.requested', actor, null, auditPayload({
209
+ actor,
210
+ target: id,
211
+ peer: id,
212
+ source: 'cli',
213
+ admin: actor !== id
214
+ }));
215
+ } finally {
216
+ db.close();
217
+ }
218
+ data = await runtimeRequest(
219
+ ctx,
220
+ 'POST',
221
+ `/api/sessions/${encodeURIComponent(id)}/stop`,
222
+ {},
223
+ null,
224
+ { timeoutMs: RUNTIME_SESSION_MUTATION_TIMEOUT_MS }
225
+ );
226
+ } catch (err) {
227
+ if (err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') {
228
+ // No server: just update the DB to mark the peer as exited
229
+ const db = connect(ctx);
230
+ try {
231
+ db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
232
+ addEvent(db, 'peer.stopped', actor, null, auditPayload({
233
+ actor,
234
+ target: id,
235
+ peer: id,
236
+ admin: actor !== id
237
+ }));
238
+ } finally { db.close(); }
239
+ printResult(ctx, { id, status: 'exited' }, (s) => `stopped ${s.id} (no server running — DB marked exited)`);
240
+ return;
241
+ }
242
+ throw err;
243
+ }
244
+ printResult(ctx, data.session, (session) => `stopped ${session.id}`);
245
+ }
246
+
247
+
248
+ return { cmdPeer };
249
+ }