@logicseek/hello-cc 0.1.9 → 1.0.0

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 (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +17 -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/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +39 -3
  14. package/docs/guide.zh-CN.md +34 -2
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +13 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +111 -9
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +60 -7
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +227 -27
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +135 -6
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5397 -263
@@ -0,0 +1,96 @@
1
+ // Coordination query commands extracted from bin/hcc.mjs: peers, status, state, prompt.
2
+
3
+ export function createQueryCommands(deps) {
4
+ const {
5
+ connect, now, printResult, table, CliError, ACTIVE_PEER_TTL,
6
+ parseOpts, required, wantsHelp, helpState,
7
+ resolveTargetPeer, resolveCurrentPeer,
8
+ statusSummary, statusSnapshot, normalizeStateResources,
9
+ renderStatusSummary, renderStateSummary,
10
+ observePeerEvidence, classifyPeerActivity,
11
+ clockGraceSuppressed, readClockGraceUntil,
12
+ commandPath
13
+ } = deps;
14
+
15
+ async function cmdPeers(ctx, args) {
16
+ parseOpts(args);
17
+ const db = connect(ctx);
18
+ const t = now();
19
+ const graceActive = clockGraceSuppressed(t, readClockGraceUntil(db));
20
+ const rows = db.prepare(`
21
+ SELECT *, (? - last_seen_at) AS age_sec
22
+ FROM peers
23
+ ORDER BY last_seen_at DESC, id ASC
24
+ `).all(t);
25
+ for (const row of rows) {
26
+ const binding = db.prepare(`SELECT transport, runtime_target FROM peer_bindings WHERE peer = ?`).get(row.id) || null;
27
+ const evidence = observePeerEvidence(ctx, row, binding);
28
+ row.evidence_state = evidence.state;
29
+ row.evidence_reason = evidence.reason;
30
+ Object.assign(row, classifyPeerActivity(row, { activePeerTtl: ACTIVE_PEER_TTL, graceActive }));
31
+ }
32
+ printResult(ctx, rows, (data) => table(data, [
33
+ { label: 'id', value: (r) => r.id },
34
+ { label: 'kind', value: (r) => r.kind },
35
+ { label: 'role', value: (r) => r.role || '' },
36
+ { label: 'status', value: (r) => r.status },
37
+ { label: 'age', value: (r) => `${r.age_sec}s` },
38
+ { label: 'active', value: (r) => r.active ? 'yes' : 'stale' },
39
+ { label: 'branch', value: (r) => r.branch || '' }
40
+ ]));
41
+ }
42
+
43
+ async function cmdStatus(ctx, args) {
44
+ const opts = parseOpts(args);
45
+ const identity = resolveTargetPeer(ctx, opts, 'peer', 'shell');
46
+ const peer = identity.id;
47
+ const data = statusSummary(ctx, peer, identity);
48
+ printResult(ctx, data, (s) => renderStatusSummary(s, peer));
49
+ }
50
+
51
+ async function cmdState(ctx, args) {
52
+ if (wantsHelp(args)) return helpState();
53
+ const opts = parseOpts(args, { arrays: ['resource'] });
54
+ const identity = resolveTargetPeer(ctx, opts, 'peer', 'shell');
55
+ const peer = identity.id;
56
+ const resources = normalizeStateResources(opts.resource || opts.resources || []);
57
+ const snapshot = statusSnapshot(ctx, peer, { resources, intent: opts.intent || null, scope: opts.scope || null });
58
+ printResult(ctx, snapshot, (data) => renderStateSummary(data, peer));
59
+ }
60
+
61
+ async function cmdPrompt(ctx, args) {
62
+ const opts = parseOpts(args);
63
+ const peer = required(opts, 'peer', 'HCC_PEER');
64
+ const kind = opts.kind || 'codex';
65
+ const role = opts.role || 'peer';
66
+ const cmd = `node ${commandPath()} --root ${JSON.stringify(ctx.root)}`;
67
+ const text = `You are ${peer}, a ${kind} ${role} session working in this project.
68
+
69
+ Use hcc as the shared coordination bus for this terminal session. This
70
+ project uses a flat peer mesh: there is no required main/worker hierarchy.
71
+
72
+ Run these commands before changing files:
73
+
74
+ ${cmd} register --peer ${peer} --kind ${kind} --role ${role}
75
+ ${cmd} state --peer ${peer}
76
+ ${cmd} msg inbox --peer ${peer}
77
+ ${cmd} task next --peer ${peer}
78
+
79
+ Coordination rules:
80
+ - Claim exactly one task before editing.
81
+ - If state shows a current task for ${peer}, continue that task before claiming another pending task.
82
+ - Before editing a file, directory, module, or shared test resource, run:
83
+ ${cmd} lock acquire --peer ${peer} --resource <path-or-module> [--scope <scope>] --task <task-id>
84
+ - If a lock is held by another peer, message that peer instead of editing:
85
+ ${cmd} msg send --from ${peer} --to <peer-id> --body "<question>"
86
+ - Report progress or requests through msg send.
87
+ - Before stopping, run tests, create a handoff, and release locks:
88
+ ${cmd} handoff create --from ${peer} --task <task-id> --summary "<what changed>" --tests "<commands/results>" --risks "<known risks>"
89
+ ${cmd} task done --peer ${peer} --id <task-id> --summary "<done summary>"
90
+ ${cmd} lock release --peer ${peer} --resource <path-or-module> [--scope <scope>]
91
+ `;
92
+ printResult(ctx, { prompt: text }, () => text);
93
+ }
94
+
95
+ return { cmdPeers, cmdStatus, cmdState, cmdPrompt };
96
+ }
@@ -0,0 +1,463 @@
1
+ // Run command extracted from bin/hcc.mjs.
2
+ // hcc run: wrap a child process as a tracked peer; the web-managed variant
3
+ // bridges an external PTY into shared buffer files for hcc web streaming.
4
+
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import process from 'node:process';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+ import { CliError } from '../../shared/errors.mjs';
11
+ import { tx } from '../../db/schema.mjs';
12
+ import { parseOpts, validateOpts } from '../../cli-args.mjs';
13
+ import { runtimeProcessIdentity } from '../../runtime/state.mjs';
14
+ import { withBufferDirectoryLease } from '../../runtime/buffer-directory-lease.mjs';
15
+ import {
16
+ externalBufferEvidence,
17
+ externalBufferOwnerKey,
18
+ readExternalBufferMetadata
19
+ } from '../../runtime/buffer-evidence.mjs';
20
+ import { inspectProcessIdentity } from '../../process/identity.mjs';
21
+ import {
22
+ capturePtyStartupEvidence,
23
+ installPtyTerminationHandlers,
24
+ ptyStartupFailureDisposition,
25
+ ptyTerminationSignal,
26
+ stopPtyAfterStartupFailure,
27
+ trackPtyExit
28
+ } from '../../process/pty-lifecycle.mjs';
29
+ import { detectBranch } from '../../project-context.mjs';
30
+ import { childSessionEnv } from '../../core/sessions/launch.mjs';
31
+ import {
32
+ bindingFromRun,
33
+ defaultSessionCommand
34
+ } from '../../integrations/providers.mjs';
35
+ import { registerProjectActivity } from '../../runtime/projects.mjs';
36
+ import { resolveCurrentPeer } from '../../integrations/peers/identity.mjs';
37
+
38
+ export function createRunCommands(deps) {
39
+ const {
40
+ connect, now, addEvent, auditPayload,
41
+ upsertPeer, upsertCanonicalPeerBinding,
42
+ helpRun, redactedLogText, CLI_NAME, BUFS_DIR_NAME
43
+ } = deps;
44
+
45
+ async function cmdRun(ctx, args) {
46
+ if (args[0] === '--help' || args[0] === '-h') return helpRun();
47
+ registerProjectActivity(ctx);
48
+ const sep = args.indexOf('--');
49
+ const optArgs = sep >= 0 ? args.slice(0, sep) : args;
50
+ const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
51
+ const opts = parseOpts(optArgs, { booleans: ['force', 'web-managed'] });
52
+ validateOpts('run', opts, ['peer', 'kind', 'role', 'cwd', 'force']);
53
+ const kind = opts.kind || 'other';
54
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', kind);
55
+ const id = identity.id;
56
+ const role = opts.role || 'peer';
57
+ const cwd = path.resolve(opts.cwd || ctx.cwd);
58
+ const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
59
+ const commandArgs = cmdArgs.length ? cmdArgs.slice(1) : [];
60
+ const binding = bindingFromRun(id, kind, command, commandArgs, 'hcc-run');
61
+
62
+ if (process.env.HCC_INTERNAL_WEB_MANAGED_RUN === '1') {
63
+ return cmdRunWebManaged(ctx, {
64
+ id,
65
+ kind,
66
+ role,
67
+ cwd,
68
+ command,
69
+ commandArgs,
70
+ binding,
71
+ force: Boolean(opts.force)
72
+ });
73
+ }
74
+
75
+ const db = connect(ctx);
76
+ try {
77
+ upsertPeer(db, {
78
+ id, kind, role,
79
+ worktree: cwd,
80
+ branch: detectBranch(cwd),
81
+ pid: process.pid,
82
+ status: 'running',
83
+ capabilities: 'run-wrapper'
84
+ });
85
+ upsertCanonicalPeerBinding(db, binding, Boolean(opts.force));
86
+ addEvent(db, 'run.session.started', id, null, auditPayload({
87
+ actor: id,
88
+ target: id,
89
+ command: [command, ...commandArgs].join(' '),
90
+ cwd
91
+ }));
92
+ } finally {
93
+ db.close();
94
+ }
95
+ console.error(redactedLogText(`${CLI_NAME}: running ${id} (${kind}, ${role}) -> ${command} ${commandArgs.join(' ')}`.trim()));
96
+ const child = spawn(command, commandArgs, {
97
+ cwd,
98
+ stdio: 'inherit',
99
+ env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath })
100
+ });
101
+ const exitCode = await new Promise((resolve) => {
102
+ child.on('exit', (code, signal) => resolve({ code, signal }));
103
+ child.on('error', (err) => {
104
+ console.error(redactedLogText(`${CLI_NAME}: failed to start ${command}: ${err.message}`));
105
+ resolve({ code: 127, signal: null });
106
+ });
107
+ });
108
+ const db2 = connect(ctx);
109
+ try {
110
+ db2.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
111
+ addEvent(db2, 'run.session.exited', id, null, auditPayload({
112
+ actor: id,
113
+ target: id,
114
+ ...exitCode
115
+ }));
116
+ } finally {
117
+ db2.close();
118
+ }
119
+ if (exitCode.signal) {
120
+ process.kill(process.pid, exitCode.signal);
121
+ } else {
122
+ process.exitCode = exitCode.code ?? 0;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Internal external PTY bridge: start a child in a PTY, forward output to both
128
+ * the local terminal and a shared buffer file so hcc web can stream it to
129
+ * browsers. Input from the browser is written to a .in file that we relay.
130
+ */
131
+ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs, binding, force = false }) {
132
+ registerProjectActivity(ctx);
133
+ const ptyModule = await import('node-pty');
134
+ const pty = ptyModule.default || ptyModule;
135
+
136
+ const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
137
+ fs.mkdirSync(bufsDir, { recursive: true });
138
+ const outFile = path.join(bufsDir, `${id}.out`);
139
+ const inFile = path.join(bufsDir, `${id}.in`);
140
+ const resizeFile = path.join(bufsDir, `${id}.resize`);
141
+ const metaFile = path.join(bufsDir, `${id}.meta`);
142
+ const bufferFiles = [outFile, inFile, resizeFile, metaFile];
143
+ const bufferGeneration = randomBytes(18).toString('base64url');
144
+
145
+ const ownsExternalBufferGroup = () => {
146
+ try {
147
+ return externalBufferOwnerKey(readExternalBufferMetadata(metaFile)) ===
148
+ `generation:${bufferGeneration}`;
149
+ } catch {
150
+ return false;
151
+ }
152
+ };
153
+
154
+ const removeOwnedExternalBufferGroup = () => withBufferDirectoryLease(bufsDir, () => {
155
+ if (!ownsExternalBufferGroup()) return false;
156
+ for (const file of bufferFiles) {
157
+ fs.rmSync(file, { force: true });
158
+ }
159
+ return true;
160
+ });
161
+
162
+ // Publish live wrapper evidence in the same lease that creates the group.
163
+ // This closes the pre-.meta window even for `gc --older-than 0`.
164
+ const publishingWrapperIdentity = runtimeProcessIdentity();
165
+ withBufferDirectoryLease(bufsDir, () => {
166
+ const existingFiles = bufferFiles.filter((file) => fs.existsSync(file));
167
+ if (existingFiles.length > 0) {
168
+ let existingMeta;
169
+ try { existingMeta = readExternalBufferMetadata(metaFile); } catch {
170
+ throw new CliError('EXTERNAL_SESSION_EXISTS', `External session ${id} has unresolved buffer ownership`);
171
+ }
172
+ if (externalBufferEvidence(existingMeta, inspectProcessIdentity).state !== 'dead') {
173
+ throw new CliError('EXTERNAL_SESSION_EXISTS', `External session ${id} already has a live or unknown owner`);
174
+ }
175
+ for (const file of bufferFiles) fs.rmSync(file, { force: true });
176
+ }
177
+ fs.writeFileSync(inFile, '');
178
+ fs.writeFileSync(resizeFile, '');
179
+ fs.writeFileSync(metaFile, JSON.stringify({
180
+ id,
181
+ generation: bufferGeneration,
182
+ kind,
183
+ role,
184
+ command: [command, ...commandArgs].join(' '),
185
+ cwd,
186
+ wrapper_pid: process.pid,
187
+ ...(publishingWrapperIdentity ? { wrapper_identity: publishingWrapperIdentity } : {}),
188
+ publishing: true
189
+ }));
190
+
191
+ const db = connect(ctx);
192
+ try {
193
+ tx(db, () => {
194
+ upsertPeer(db, {
195
+ id, kind, role,
196
+ worktree: cwd,
197
+ branch: detectBranch(cwd),
198
+ pid: process.pid,
199
+ status: 'running',
200
+ capabilities: 'run-pty'
201
+ });
202
+ upsertCanonicalPeerBinding(db, binding, force);
203
+ addEvent(db, 'run.session.started', id, null, auditPayload({
204
+ actor: id,
205
+ target: id,
206
+ command: [command, ...commandArgs].join(' '),
207
+ cwd,
208
+ webManaged: true
209
+ }));
210
+ });
211
+ } catch (error) {
212
+ for (const file of bufferFiles) fs.rmSync(file, { force: true });
213
+ throw error;
214
+ } finally {
215
+ db.close();
216
+ }
217
+ });
218
+
219
+ // Estimate terminal size from current process
220
+ const cols = process.stdout.columns || 120;
221
+ const rows = process.stdout.rows || 40;
222
+
223
+ const child = pty.spawn(command, commandArgs, {
224
+ name: 'xterm-256color', cols, rows, cwd,
225
+ env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' })
226
+ });
227
+ const childExit = trackPtyExit(child);
228
+ const wrapperTermination = installPtyTerminationHandlers(child);
229
+ let outFd;
230
+
231
+ const failStartup = async (reason, childIdentity = null) => {
232
+ const termination = await stopPtyAfterStartupFailure(child, childExit);
233
+ const disposition = ptyStartupFailureDisposition({
234
+ termination,
235
+ childPid: child.pid,
236
+ childIdentity
237
+ });
238
+ try { fs.closeSync(outFd); } catch {}
239
+ outFd = null;
240
+ let metadataPreserved = false;
241
+ let ownedFailureRecord = false;
242
+ if (disposition.preserveEvidence) {
243
+ try {
244
+ metadataPreserved = withBufferDirectoryLease(bufsDir, () => {
245
+ if (!ownsExternalBufferGroup()) return false;
246
+ fs.writeFileSync(metaFile, JSON.stringify({
247
+ id,
248
+ generation: bufferGeneration,
249
+ kind,
250
+ role,
251
+ command: [command, ...commandArgs].join(' '),
252
+ cwd,
253
+ pid: child.pid,
254
+ ...(childIdentity ? { child_identity: childIdentity } : {}),
255
+ startup_failed: true,
256
+ termination_unconfirmed: true,
257
+ error: reason,
258
+ cols,
259
+ rows
260
+ }));
261
+ return true;
262
+ });
263
+ ownedFailureRecord = metadataPreserved;
264
+ } catch {}
265
+ } else {
266
+ try {
267
+ ownedFailureRecord = removeOwnedExternalBufferGroup();
268
+ } catch {}
269
+ }
270
+ if (ownedFailureRecord) {
271
+ const failedDb = connect(ctx);
272
+ try {
273
+ const mutation = disposition.preserveEvidence
274
+ ? failedDb.prepare(`
275
+ UPDATE peers
276
+ SET status = ?, last_seen_at = ?, pid = ?, pid_start_token = ?, pid_command_hash = ?
277
+ WHERE id = ? AND pid = ?
278
+ `).run(
279
+ disposition.status,
280
+ now(),
281
+ child.pid,
282
+ childIdentity?.startToken || null,
283
+ childIdentity?.commandHash || null,
284
+ id,
285
+ process.pid
286
+ )
287
+ : failedDb.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ? AND pid = ?')
288
+ .run(disposition.status, now(), id, process.pid);
289
+ if (Number(mutation.changes || 0) > 0) {
290
+ addEvent(failedDb, disposition.eventType, id, null, auditPayload({
291
+ actor: id,
292
+ target: id,
293
+ startup: true,
294
+ error: reason,
295
+ childTerminationConfirmed: termination.exited,
296
+ childPid: child.pid,
297
+ childIdentity,
298
+ metadataPreserved,
299
+ ...(termination.event || {})
300
+ }));
301
+ }
302
+ } finally {
303
+ failedDb.close();
304
+ }
305
+ }
306
+ const signal = ptyTerminationSignal(wrapperTermination.signal, null);
307
+ wrapperTermination.dispose();
308
+ if (signal) process.kill(process.pid, signal);
309
+ throw new CliError('PROCESS_START_FAILED', `Process identity could not be recorded (${reason}): ${command}`);
310
+ };
311
+
312
+ try {
313
+ withBufferDirectoryLease(bufsDir, () => {
314
+ if (!ownsExternalBufferGroup()) {
315
+ throw new CliError('EXTERNAL_SESSION_SUPERSEDED', `External session ${id} was replaced during startup`);
316
+ }
317
+ outFd = fs.openSync(outFile, 'w');
318
+ });
319
+ } catch (error) {
320
+ await failStartup(error?.code || 'external_buffer_open_failed');
321
+ }
322
+ child.onData((data) => {
323
+ process.stdout.write(data);
324
+ try { fs.write(outFd, data, () => {}); } catch {}
325
+ });
326
+
327
+ // Capture immutable process evidence before publishing the session. The
328
+ // exit listener above is installed synchronously so a short-lived PTY cannot
329
+ // disappear while identity collection is polling under load.
330
+ const startupEvidence = await capturePtyStartupEvidence({
331
+ childPid: child.pid,
332
+ wrapperPid: process.pid,
333
+ exit: childExit,
334
+ timeoutMs: 2000
335
+ });
336
+ if (startupEvidence.state === 'failed') {
337
+ await failStartup(startupEvidence.reason, startupEvidence.childIdentity || null);
338
+ }
339
+ const { wrapperIdentity, childIdentity } = startupEvidence;
340
+ if (childExit.event !== null) {
341
+ await failStartup('child_exited_before_identity', childIdentity);
342
+ }
343
+ const writeExternalMeta = (metaCols, metaRows) => withBufferDirectoryLease(bufsDir, () => {
344
+ if (!ownsExternalBufferGroup()) return false;
345
+ fs.writeFileSync(metaFile, JSON.stringify({
346
+ id,
347
+ generation: bufferGeneration,
348
+ kind,
349
+ role,
350
+ command: [command, ...commandArgs].join(' '),
351
+ cwd,
352
+ pid: child.pid,
353
+ wrapper_pid: process.pid,
354
+ ...(childIdentity ? { child_identity: childIdentity } : {}),
355
+ wrapper_identity: wrapperIdentity,
356
+ cols: metaCols,
357
+ rows: metaRows
358
+ }));
359
+ return true;
360
+ });
361
+ // Write metadata so hcc web can discover this session
362
+ if (!writeExternalMeta(cols, rows)) {
363
+ await failStartup('external_buffer_owner_changed', childIdentity);
364
+ }
365
+
366
+ // Poll for browser input (written to .in file by hcc web)
367
+ let inOffset = 0;
368
+ const inputPoller = setInterval(() => {
369
+ try {
370
+ const stat = fs.statSync(inFile);
371
+ if (stat.size > inOffset) {
372
+ const buf = Buffer.alloc(stat.size - inOffset);
373
+ const fd = fs.openSync(inFile, 'r');
374
+ fs.readSync(fd, buf, 0, buf.length, inOffset);
375
+ fs.closeSync(fd);
376
+ inOffset = stat.size;
377
+ if (buf.length) child.write(buf.toString());
378
+ }
379
+ } catch {}
380
+ }, 100);
381
+
382
+ let resizeOffset = 0;
383
+ const resizePoller = setInterval(() => {
384
+ try {
385
+ const stat = fs.statSync(resizeFile);
386
+ if (stat.size <= resizeOffset) return;
387
+ const buf = Buffer.alloc(stat.size - resizeOffset);
388
+ const fd = fs.openSync(resizeFile, 'r');
389
+ fs.readSync(fd, buf, 0, buf.length, resizeOffset);
390
+ fs.closeSync(fd);
391
+ resizeOffset = stat.size;
392
+ const lines = buf.toString().trim().split('\n').filter(Boolean);
393
+ const last = lines.at(-1);
394
+ if (!last) return;
395
+ const size = JSON.parse(last);
396
+ const c = Math.max(20, Number.parseInt(size.cols || 120, 10));
397
+ const r = Math.max(8, Number.parseInt(size.rows || 40, 10));
398
+ child.resize(c, r);
399
+ try { writeExternalMeta(c, r); } catch {}
400
+ } catch {}
401
+ }, 250);
402
+
403
+ // Handle SIGWINCH for local terminal resize
404
+ const onStdoutResize = () => {
405
+ const c = process.stdout.columns || 120;
406
+ const r = process.stdout.rows || 40;
407
+ child.resize(c, r);
408
+ try { writeExternalMeta(c, r); } catch {}
409
+ };
410
+ process.stdout.on('resize', onStdoutResize);
411
+
412
+ // Forward local stdin to PTY
413
+ if (process.stdin.isTTY) {
414
+ process.stdin.setRawMode(true);
415
+ process.stdin.resume();
416
+ process.stdin.on('data', (data) => child.write(data));
417
+ }
418
+
419
+ const exitCode = await childExit.promise;
420
+
421
+ clearInterval(inputPoller);
422
+ clearInterval(resizePoller);
423
+ wrapperTermination.dispose();
424
+ process.stdout.off('resize', onStdoutResize);
425
+ if (process.stdin.isTTY) {
426
+ try { process.stdin.setRawMode(false); } catch {}
427
+ }
428
+ try { fs.closeSync(outFd); } catch {}
429
+ // Clean up only the generation this producer published. A replacement with
430
+ // the same peer id owns its own files and DB status.
431
+ let removedOwnedBufferGroup = false;
432
+ try {
433
+ removedOwnedBufferGroup = removeOwnedExternalBufferGroup();
434
+ } catch {}
435
+
436
+ if (removedOwnedBufferGroup) {
437
+ const db2 = connect(ctx);
438
+ try {
439
+ const mutation = db2.prepare(
440
+ 'UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ? AND pid = ?'
441
+ ).run('exited', now(), id, process.pid);
442
+ if (Number(mutation.changes || 0) > 0) {
443
+ addEvent(db2, 'run.session.exited', id, null, auditPayload({
444
+ actor: id,
445
+ target: id,
446
+ ...exitCode
447
+ }));
448
+ }
449
+ } finally {
450
+ db2.close();
451
+ }
452
+ }
453
+
454
+ const signal = ptyTerminationSignal(wrapperTermination.signal, exitCode.signal);
455
+ if (signal) {
456
+ process.kill(process.pid, signal);
457
+ } else {
458
+ process.exitCode = exitCode.exitCode ?? 0;
459
+ }
460
+ }
461
+
462
+ return { cmdRun, cmdRunWebManaged };
463
+ }