@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.
- package/CHANGELOG.md +136 -0
- package/README.md +50 -3
- package/README.zh-CN.md +48 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/guide.md +97 -3
- package/docs/guide.zh-CN.md +89 -2
- package/lib/cli/app.mjs +644 -0
- package/lib/cli/commands/coordination.mjs +249 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +1002 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +76 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +249 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +222 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +48 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +63 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +377 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +250 -0
- package/lib/terminal/tmux.mjs +131 -27
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +183 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +717 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +335 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +6 -3
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5404 -273
- package/docs/architecture-split-review.md +0 -217
- package/docs/layout-split-stack-audit.md +0 -298
|
@@ -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
|
+
}
|