@logicseek/hello-cc 1.1.0-rc.8 → 1.1.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.
- package/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- package/scripts/web-workbench-installed-acceptance.mjs +11 -1
|
@@ -6,6 +6,9 @@ import { tx } from '../../db/schema.mjs';
|
|
|
6
6
|
import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
|
|
7
7
|
import { projectDbPath } from '../../runtime/paths.mjs';
|
|
8
8
|
import { HOOK_ROOT_IDENTITY_ENV, assertHookRootIdentity } from '../../core/sessions/hook-root-identity.mjs';
|
|
9
|
+
import { CODEX_SESSION_ORIGIN_ENV, recordCodexThreadRoot } from '../../core/sessions/codex-thread-root.mjs';
|
|
10
|
+
import { captureSelectedCwdSnapshot, sameSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
|
|
11
|
+
import { acquireNativeOwnerWrite, nativeOwnerGeneration } from '../../runtime/native/store.mjs';
|
|
9
12
|
|
|
10
13
|
export function createHookCommand(deps) {
|
|
11
14
|
const {
|
|
@@ -105,6 +108,17 @@ async function cmdHook(ctx, args) {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
const codexOrigin = kind === 'codex' && hookKey === 'sessionstart' && !nativeOwner
|
|
112
|
+
? process.env[CODEX_SESSION_ORIGIN_ENV] : null;
|
|
113
|
+
if (codexOrigin) {
|
|
114
|
+
if (!['new', 'fork'].includes(codexOrigin) || !launchRootIdentity ||
|
|
115
|
+
!process.env.HCC_PEER || !process.env.HCC_DB ||
|
|
116
|
+
!sameSelectedCwdIdentity(captureSelectedCwdSnapshot(hookCwd), launchRootIdentity)) {
|
|
117
|
+
throw new CliError('CODEX_HISTORY_BINDING_INVALID',
|
|
118
|
+
'First-party Codex session hook does not match its pinned project launch');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
const dshProject = isDsh && !nativeOwner ? resolveProjectDatabase({ root: hccRoot, createStateDir: false }) : null;
|
|
109
123
|
const nativeProject = nativeOwner ? resolveProjectDatabase({ root: hccRoot,
|
|
110
124
|
db: process.env.HCC_DB, createStateDir: false }) : null;
|
|
@@ -143,9 +157,17 @@ async function cmdHook(ctx, args) {
|
|
|
143
157
|
: `${kind}-${shortHash(`${hookCtx.root}:${autoPeerBasis(kind)}`)}`;
|
|
144
158
|
}
|
|
145
159
|
|
|
146
|
-
//
|
|
147
|
-
|
|
160
|
+
// Native hooks lock state.db before connect(), whose schema setup may write
|
|
161
|
+
// mesh.db. Keep that lock through the heartbeat transaction and DB close.
|
|
162
|
+
const nativeGeneration = nativeOwner ? nativeOwnerGeneration(nativeOwner) : null;
|
|
163
|
+
if (nativeOwner && !nativeGeneration) {
|
|
164
|
+
throw new CliError('NATIVE_HOOK_OWNERSHIP_MISMATCH', 'Native hook has no verifiable owner generation');
|
|
165
|
+
}
|
|
166
|
+
const finishNative = nativeOwner ? acquireNativeOwnerWrite(hookCtx, nativeGeneration) : null;
|
|
167
|
+
let db, nativeSuccess = false;
|
|
148
168
|
try {
|
|
169
|
+
// Hook delivery must not migrate another workspace's registered database.
|
|
170
|
+
db = isDsh || nativeOwner ? connect(hookCtx, { migrateRegistered: false }) : connect(hookCtx);
|
|
149
171
|
if (!nativeOwner) registerProjectActivity(hookCtx);
|
|
150
172
|
if (nativeOwner) {
|
|
151
173
|
// Native delivery owns context injection and completion receipts. Hooks
|
|
@@ -166,6 +188,7 @@ async function cmdHook(ctx, args) {
|
|
|
166
188
|
ttlCap: 3600, includeExpired: true });
|
|
167
189
|
if (renewed > 0) addEvent(db, 'lock.renewed_by_hook', peerId, null, { renewed, source: 'native-hook' });
|
|
168
190
|
});
|
|
191
|
+
nativeSuccess = true;
|
|
169
192
|
process.exitCode = 0;
|
|
170
193
|
return;
|
|
171
194
|
}
|
|
@@ -226,6 +249,9 @@ async function cmdHook(ctx, args) {
|
|
|
226
249
|
runtime_session_id: peerId
|
|
227
250
|
};
|
|
228
251
|
const canonical = upsertCanonicalPeerBinding(db, hookBinding, true);
|
|
252
|
+
if (codexOrigin) {
|
|
253
|
+
recordCodexThreadRoot(db, addEvent, sessionId, launchRootIdentity, codexOrigin, { allowSame: true });
|
|
254
|
+
}
|
|
229
255
|
if (canonical.peer !== peerId) {
|
|
230
256
|
const previousPeer = peerId;
|
|
231
257
|
peerId = canonical.peer;
|
|
@@ -254,7 +280,7 @@ async function cmdHook(ctx, args) {
|
|
|
254
280
|
};
|
|
255
281
|
// Keep the dsh conflict check and identity/binding writes atomic, so a
|
|
256
282
|
// concurrent service/session registration cannot change the inspected row.
|
|
257
|
-
const registered = isDsh || kind === 'claude' ? tx(db, registerHookIdentity) : registerHookIdentity();
|
|
283
|
+
const registered = isDsh || kind === 'claude' || codexOrigin ? tx(db, registerHookIdentity) : registerHookIdentity();
|
|
258
284
|
if (registered === false) { process.exitCode = 0; return; }
|
|
259
285
|
// hb-06: an active hook is proof the peer is working. During clock grace,
|
|
260
286
|
// renew retained locks even when the wall-clock jump made them look expired.
|
|
@@ -321,7 +347,8 @@ async function cmdHook(ctx, args) {
|
|
|
321
347
|
}
|
|
322
348
|
}
|
|
323
349
|
} finally {
|
|
324
|
-
try { db
|
|
350
|
+
try { db?.close(); } catch {}
|
|
351
|
+
finishNative?.(nativeSuccess);
|
|
325
352
|
}
|
|
326
353
|
// Let Node exit after the hook output has flushed instead of truncating a
|
|
327
354
|
// pipe with process.exit(). A successful write is not provider acceptance.
|
package/lib/cli/commands/mcp.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { parseOpts, required, validateOpts, wantsHelp } from '../../cli-args.mjs
|
|
|
3
3
|
import { loadScopedMcpBootstrap } from '../../mcp/scope.mjs';
|
|
4
4
|
import { createScopedMcpTools } from '../../mcp/tools.mjs';
|
|
5
5
|
import { serveMcpStdio } from '../../mcp/stdio.mjs';
|
|
6
|
+
import { nativeOwnerGeneration } from '../../runtime/native/store.mjs';
|
|
6
7
|
|
|
7
8
|
export function mcpHelp() {
|
|
8
9
|
console.log(`Usage: hcc --root PROJECT --db DATABASE mcp serve --peer PEER
|
|
@@ -21,6 +22,11 @@ export function createMcpCommands(dependencies) {
|
|
|
21
22
|
validateOpts('mcp serve', options, ['peer']);
|
|
22
23
|
const peer = required(options, 'peer');
|
|
23
24
|
const authority = loadScopedMcpBootstrap(ctx, peer);
|
|
25
|
+
const inheritedGeneration = process.env.HCC_NATIVE_OWNER && nativeOwnerGeneration(process.env.HCC_NATIVE_OWNER);
|
|
26
|
+
if (process.env.HCC_NATIVE_OWNER &&
|
|
27
|
+
(!inheritedGeneration || authority.scope.nativeOwnerGeneration !== inheritedGeneration)) {
|
|
28
|
+
throw new CliError('NATIVE_OWNER_UNVERIFIED', 'Native MCP capability does not match the inherited worker generation');
|
|
29
|
+
}
|
|
24
30
|
const tools = createScopedMcpTools({ ...dependencies, ctx, authority });
|
|
25
31
|
await serveMcpStdio({ tools });
|
|
26
32
|
}
|
|
@@ -5,6 +5,9 @@ import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
|
|
|
5
5
|
import { printResult } from '../../format.mjs';
|
|
6
6
|
import { ensureNativeRuntime } from '../../runtime/native/launcher.mjs';
|
|
7
7
|
import { nativeRequest } from '../../runtime/native/client.mjs';
|
|
8
|
+
import { listCliSubmissions, matchingCliSubmission, prepareCliSubmission,
|
|
9
|
+
recordCliSubmissionReceipt } from '../../runtime/native/cli-submissions.mjs';
|
|
10
|
+
import { assertPinnedSessionLaunchAllowed } from '../../process/pinned-cwd.mjs';
|
|
8
11
|
import { startNativeService } from '../../runtime/native/service.mjs';
|
|
9
12
|
|
|
10
13
|
export function nativeHelp() {
|
|
@@ -14,6 +17,7 @@ export function nativeHelp() {
|
|
|
14
17
|
hcc native send --peer NAME --body TEXT [--from NAME] [--task ID] [--submission-id ID]
|
|
15
18
|
hcc native events --peer NAME [--after ID]
|
|
16
19
|
hcc native deliveries [--peer NAME]
|
|
20
|
+
hcc native submissions [--peer NAME]
|
|
17
21
|
hcc native requests --peer NAME
|
|
18
22
|
hcc native respond --peer NAME --request ID --decision accept|decline|cancel [--response-file JSON]
|
|
19
23
|
hcc native interrupt --peer NAME [--turn ID]
|
|
@@ -21,8 +25,9 @@ export function nativeHelp() {
|
|
|
21
25
|
|
|
22
26
|
Native workers share the project task/message/lock bus. Only HCC-owned saved
|
|
23
27
|
sessions can resume. Existing TUI/Desktop sessions keep their own transport.
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
Generated CLI submission IDs are saved before sending. Inspect native
|
|
29
|
+
submissions after an uncertain receipt; retry only with the original ID and
|
|
30
|
+
exact peer, sender, task and body. Explicit --submission-id also remains valid.
|
|
26
31
|
Providers retain their permission checks; interactive requests await an explicit Web or local response.
|
|
27
32
|
Codex alone accepts --sandbox. New sessions default to workspace-write; resumed and forked sessions inherit their saved policy.
|
|
28
33
|
read-only disables permission escalation and HCC scoped MCP tools. It is a provider file sandbox, not an all-tools/network guarantee.
|
|
@@ -49,7 +54,7 @@ export function createNativeCommands(deps) {
|
|
|
49
54
|
up: [], serve: [], down: [], status: [],
|
|
50
55
|
start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume', 'sandbox'],
|
|
51
56
|
send: ['peer', 'body', 'from', 'task', 'submission-id'],
|
|
52
|
-
events: ['peer', 'after'], deliveries: ['peer'], requests: ['peer'],
|
|
57
|
+
events: ['peer', 'after'], deliveries: ['peer'], submissions: ['peer'], requests: ['peer'],
|
|
53
58
|
respond: ['peer', 'request', 'decision', 'response-file'],
|
|
54
59
|
fork: ['peer', 'parent', 'model', 'binary'],
|
|
55
60
|
interrupt: ['peer', 'turn'], close: ['peer']
|
|
@@ -73,6 +78,7 @@ export function createNativeCommands(deps) {
|
|
|
73
78
|
process.once('SIGINT', shutdown);
|
|
74
79
|
return;
|
|
75
80
|
} else if (command === 'start') {
|
|
81
|
+
assertPinnedSessionLaunchAllowed();
|
|
76
82
|
const runtime = await ensureNativeRuntime(ctx);
|
|
77
83
|
if (opts.provider === 'codex') requireSandboxRuntime(runtime);
|
|
78
84
|
result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
|
|
@@ -80,6 +86,7 @@ export function createNativeCommands(deps) {
|
|
|
80
86
|
...(opts.sandbox !== undefined ? { sandbox: opts.sandbox } : {}) },
|
|
81
87
|
opts.provider === 'codex' ? { expectedGeneration: runtime.generation } : undefined);
|
|
82
88
|
} else if (command === 'fork') {
|
|
89
|
+
assertPinnedSessionLaunchAllowed();
|
|
83
90
|
if (!opts.parent) throw new CliError('BAD_ARGS', 'Missing --parent');
|
|
84
91
|
const runtime = await nativeRequest(ctx, 'GET', '/status');
|
|
85
92
|
requireSandboxRuntime(runtime);
|
|
@@ -93,14 +100,20 @@ export function createNativeCommands(deps) {
|
|
|
93
100
|
throw new CliError('BAD_ARGS', 'Submission ID must contain 8 to 100 letters, digits, underscores, or hyphens');
|
|
94
101
|
}
|
|
95
102
|
const sender = deps.resolveCurrentPeer(ctx, opts, 'from', 'shell').id;
|
|
103
|
+
const input = { peer: opts.peer, from: sender, taskId, body: opts.body };
|
|
104
|
+
const prepared = submissionId ? matchingCliSubmission(ctx, { ...input, submissionId })
|
|
105
|
+
: prepareCliSubmission(ctx, input);
|
|
96
106
|
result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId,
|
|
97
|
-
|
|
107
|
+
submissionId: submissionId || prepared.submission_id });
|
|
108
|
+
if (prepared) recordCliSubmissionReceipt(ctx, prepared, result);
|
|
98
109
|
} else if (command === 'events') {
|
|
99
110
|
const after = intOpt(opts, 'after', 0);
|
|
100
111
|
if (after < 0) throw new CliError('BAD_ARGS', 'Event cursor must be nonnegative');
|
|
101
112
|
result = await nativeRequest(ctx, 'GET', `/events?peer=${encodeURIComponent(opts.peer)}&after=${after}`);
|
|
102
113
|
} else if (command === 'deliveries') {
|
|
103
114
|
result = await nativeRequest(ctx, 'GET', `/deliveries${opts.peer ? `?peer=${encodeURIComponent(opts.peer)}` : ''}`);
|
|
115
|
+
} else if (command === 'submissions') {
|
|
116
|
+
result = listCliSubmissions(ctx, { peer: opts.peer || null });
|
|
104
117
|
} else if (command === 'requests' || command === 'respond') {
|
|
105
118
|
const state = await nativeRequest(ctx, 'GET', '/workers/' + encodeURIComponent(opts.peer) + '/state');
|
|
106
119
|
if (command === 'requests') result = state.snapshot.pendingApprovals || [];
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
RUNTIME_SESSION_START_TIMEOUT_MS
|
|
6
6
|
} from '../../runtime/client.mjs';
|
|
7
7
|
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
8
|
+
import { assertCodexTerminalLaunch } from '../../core/sessions/codex-thread-root.mjs';
|
|
8
9
|
|
|
9
10
|
export function createPeerCommands(deps) {
|
|
10
11
|
const {
|
|
@@ -96,6 +97,8 @@ async function peerStart(ctx, args) {
|
|
|
96
97
|
const db = connect(ctx);
|
|
97
98
|
try {
|
|
98
99
|
tx(db, () => {
|
|
100
|
+
assertCodexTerminalLaunch(db, binding, { command, root: ctx.root,
|
|
101
|
+
rootIdentity: ctx.initialRootIdentity, cwd, cwdIdentity });
|
|
99
102
|
if (!opts.force) {
|
|
100
103
|
const conflict = findProviderSessionBinding(db, binding);
|
|
101
104
|
if (bindingHasRuntime(conflict)) {
|
package/lib/cli/commands/run.mjs
CHANGED
|
@@ -6,9 +6,10 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
9
10
|
import { CliError } from '../../shared/errors.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
11
|
+
import { assertPinnedSessionLaunchAllowed, preparePinnedCwdLaunch } from '../../process/pinned-cwd.mjs';
|
|
12
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
12
13
|
import { tx } from '../../db/schema.mjs';
|
|
13
14
|
import { parseOpts, validateOpts } from '../../cli-args.mjs';
|
|
14
15
|
import { runtimeProcessIdentity } from '../../runtime/state.mjs';
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
} from '../../process/pty-lifecycle.mjs';
|
|
32
33
|
import { detectBranch } from '../../project-context.mjs';
|
|
33
34
|
import { childSessionEnv } from '../../core/sessions/launch.mjs';
|
|
35
|
+
import { assertCodexTerminalLaunch, CODEX_SESSION_ORIGIN_ENV } from '../../core/sessions/codex-thread-root.mjs';
|
|
34
36
|
import {
|
|
35
37
|
bindingFromRun,
|
|
36
38
|
defaultSessionCommand
|
|
@@ -42,12 +44,22 @@ export function createRunCommands(deps) {
|
|
|
42
44
|
const {
|
|
43
45
|
connect, now, addEvent, auditPayload,
|
|
44
46
|
upsertPeer, upsertCanonicalPeerBinding,
|
|
45
|
-
helpRun, redactedLogText, CLI_NAME, BUFS_DIR_NAME
|
|
47
|
+
helpRun, redactedLogText, CLI_NAME, BUFS_DIR_NAME,
|
|
48
|
+
spawnProcess = spawn,
|
|
49
|
+
loadPty = () => import('node-pty')
|
|
46
50
|
} = deps;
|
|
47
51
|
|
|
52
|
+
const assertRunDirectories = (ctx, cwdIdentity = null) => {
|
|
53
|
+
if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
54
|
+
if (cwdIdentity && cwdIdentity !== ctx.initialRootIdentity) assertSelectedCwdSnapshot(cwdIdentity);
|
|
55
|
+
};
|
|
56
|
+
|
|
48
57
|
async function cmdRun(ctx, args) {
|
|
49
58
|
if (args[0] === '--help' || args[0] === '-h') return helpRun();
|
|
50
|
-
|
|
59
|
+
// A rollout hold must not publish a running peer before the launch is
|
|
60
|
+
// refused. Existing sessions remain untouched.
|
|
61
|
+
assertPinnedSessionLaunchAllowed();
|
|
62
|
+
assertRunDirectories(ctx);
|
|
51
63
|
const sep = args.indexOf('--');
|
|
52
64
|
const optArgs = sep >= 0 ? args.slice(0, sep) : args;
|
|
53
65
|
const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
|
|
@@ -58,10 +70,22 @@ async function cmdRun(ctx, args) {
|
|
|
58
70
|
const id = identity.id;
|
|
59
71
|
const role = opts.role || 'peer';
|
|
60
72
|
const cwd = path.resolve(opts.cwd || ctx.cwd);
|
|
61
|
-
const expectedCwdIdentity =
|
|
73
|
+
const expectedCwdIdentity = ctx.initialRootIdentity && cwd === path.resolve(ctx.root)
|
|
74
|
+
? ctx.initialRootIdentity : captureSelectedCwdSnapshot(cwd);
|
|
75
|
+
assertRunDirectories(ctx, expectedCwdIdentity);
|
|
62
76
|
const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
|
|
63
77
|
const commandArgs = cmdArgs.length ? cmdArgs.slice(1) : [];
|
|
64
78
|
const binding = bindingFromRun(id, kind, command, commandArgs, 'hcc-run');
|
|
79
|
+
const originBinding = kind === 'codex' && binding.resume_mode === 'command' &&
|
|
80
|
+
path.basename(command) === 'codex' && commandArgs.length === 0
|
|
81
|
+
? { ...binding, resume_mode: 'new' } : binding;
|
|
82
|
+
const validationDb = connect(ctx);
|
|
83
|
+
let codexOrigin;
|
|
84
|
+
try {
|
|
85
|
+
codexOrigin = assertCodexTerminalLaunch(validationDb, originBinding, {
|
|
86
|
+
command: binding.command, argv: [command, ...commandArgs], root: ctx.root,
|
|
87
|
+
rootIdentity: ctx.initialRootIdentity, cwd, cwdIdentity: expectedCwdIdentity });
|
|
88
|
+
} finally { validationDb.close(); }
|
|
65
89
|
|
|
66
90
|
if (process.env.HCC_INTERNAL_WEB_MANAGED_RUN === '1') {
|
|
67
91
|
return cmdRunWebManaged(ctx, {
|
|
@@ -73,10 +97,20 @@ async function cmdRun(ctx, args) {
|
|
|
73
97
|
command,
|
|
74
98
|
commandArgs,
|
|
75
99
|
binding,
|
|
100
|
+
codexOrigin,
|
|
76
101
|
force: Boolean(opts.force)
|
|
77
102
|
});
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
const launch = preparePinnedCwdLaunch(cwd, command, commandArgs, {
|
|
106
|
+
expectedIdentity: expectedCwdIdentity,
|
|
107
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath,
|
|
108
|
+
...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {}) },
|
|
109
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
assertRunDirectories(ctx, expectedCwdIdentity);
|
|
113
|
+
registerProjectActivity(ctx);
|
|
80
114
|
const db = connect(ctx);
|
|
81
115
|
const wrapperIdentity = runtimeProcessIdentity();
|
|
82
116
|
try {
|
|
@@ -95,6 +129,7 @@ async function cmdRun(ctx, args) {
|
|
|
95
129
|
}]
|
|
96
130
|
}) : null;
|
|
97
131
|
registered = tx(db, () => {
|
|
132
|
+
assertRunDirectories(ctx, expectedCwdIdentity);
|
|
98
133
|
const current = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
|
|
99
134
|
if (JSON.stringify(current) !== JSON.stringify(previous)) return false;
|
|
100
135
|
if (previous && evidence.state !== 'dead' && (
|
|
@@ -134,20 +169,32 @@ async function cmdRun(ctx, args) {
|
|
|
134
169
|
db.close();
|
|
135
170
|
}
|
|
136
171
|
console.error(redactedLogText(`${CLI_NAME}: running ${id} (${kind}, ${role}) -> ${command} ${commandArgs.join(' ')}`.trim()));
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
143
|
-
});
|
|
144
|
-
const exitCode = await new Promise((resolve) => {
|
|
145
|
-
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
146
|
-
child.on('error', (err) => {
|
|
147
|
-
console.error(redactedLogText(`${CLI_NAME}: failed to start ${command}: ${err.message}`));
|
|
148
|
-
resolve({ code: 127, signal: null });
|
|
172
|
+
let exitCode;
|
|
173
|
+
let child;
|
|
174
|
+
try {
|
|
175
|
+
child = spawnProcess(launch.command, launch.args, {
|
|
176
|
+
cwd: launch.cwd, env: launch.env, stdio: 'inherit'
|
|
149
177
|
});
|
|
150
|
-
})
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// A synchronous spawn rejection did not create a child. Retain the audit
|
|
180
|
+
// trail, but close this wrapper's owned peer below instead of leaving it
|
|
181
|
+
// falsely running. Never roll back a possibly superseded binding/event.
|
|
182
|
+
exitCode = { code: 127, signal: null, startup: true,
|
|
183
|
+
error: err?.code || err?.message || 'spawn_failed' };
|
|
184
|
+
console.error(redactedLogText(`${CLI_NAME}: failed to start ${command}: ${err.message}`));
|
|
185
|
+
}
|
|
186
|
+
if (child) {
|
|
187
|
+
child.once('exit', launch.release);
|
|
188
|
+
child.once('error', launch.release);
|
|
189
|
+
exitCode = await new Promise((resolve) => {
|
|
190
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
191
|
+
child.on('error', (err) => {
|
|
192
|
+
console.error(redactedLogText(`${CLI_NAME}: failed to start ${command}: ${err.message}`));
|
|
193
|
+
resolve({ code: 127, signal: null, startup: true,
|
|
194
|
+
error: err?.code || err?.message || 'spawn_failed' });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
151
198
|
const db2 = connect(ctx);
|
|
152
199
|
try {
|
|
153
200
|
tx(db2, () => {
|
|
@@ -172,6 +219,9 @@ async function cmdRun(ctx, args) {
|
|
|
172
219
|
} else {
|
|
173
220
|
process.exitCode = exitCode.code ?? 0;
|
|
174
221
|
}
|
|
222
|
+
} finally {
|
|
223
|
+
launch.release();
|
|
224
|
+
}
|
|
175
225
|
}
|
|
176
226
|
|
|
177
227
|
/**
|
|
@@ -179,11 +229,26 @@ async function cmdRun(ctx, args) {
|
|
|
179
229
|
* the local terminal and a shared buffer file so hcc web can stream it to
|
|
180
230
|
* browsers. Input from the browser is written to a .in file that we relay.
|
|
181
231
|
*/
|
|
182
|
-
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity, command, commandArgs, binding,
|
|
183
|
-
|
|
184
|
-
|
|
232
|
+
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity, command, commandArgs, binding,
|
|
233
|
+
codexOrigin = null, force = false }) {
|
|
234
|
+
assertPinnedSessionLaunchAllowed();
|
|
235
|
+
const cwdIdentity = expectedCwdIdentity || (ctx.initialRootIdentity && cwd === path.resolve(ctx.root)
|
|
236
|
+
? ctx.initialRootIdentity : captureSelectedCwdSnapshot(cwd));
|
|
237
|
+
assertRunDirectories(ctx, cwdIdentity);
|
|
238
|
+
const ptyModule = await loadPty();
|
|
185
239
|
const pty = ptyModule.default || ptyModule;
|
|
186
240
|
|
|
241
|
+
const launch = preparePinnedCwdLaunch(cwd, command, commandArgs, {
|
|
242
|
+
expectedIdentity: cwdIdentity,
|
|
243
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color',
|
|
244
|
+
...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {}) },
|
|
245
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
246
|
+
});
|
|
247
|
+
try {
|
|
248
|
+
assertRunDirectories(ctx, cwdIdentity);
|
|
249
|
+
registerProjectActivity(ctx);
|
|
250
|
+
|
|
251
|
+
assertRunDirectories(ctx, cwdIdentity);
|
|
187
252
|
const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
|
|
188
253
|
const outFile = path.join(bufsDir, `${id}.out`);
|
|
189
254
|
const inFile = path.join(bufsDir, `${id}.in`);
|
|
@@ -208,11 +273,22 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity,
|
|
|
208
273
|
}
|
|
209
274
|
return true;
|
|
210
275
|
});
|
|
276
|
+
const createdBufferFiles = [];
|
|
277
|
+
const removeCreatedBufferFiles = () => {
|
|
278
|
+
for (const { file, dev, ino, birthtimeNs } of createdBufferFiles) {
|
|
279
|
+
try {
|
|
280
|
+
const stat = fs.lstatSync(file, { bigint: true });
|
|
281
|
+
if (stat.isFile() && stat.dev === dev && stat.ino === ino &&
|
|
282
|
+
stat.birthtimeNs === birthtimeNs) fs.unlinkSync(file);
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
211
286
|
|
|
212
287
|
// Publish live wrapper evidence in the same lease that creates the group.
|
|
213
288
|
// This closes the pre-.meta window even for `gc --older-than 0`.
|
|
214
289
|
const publishingWrapperIdentity = runtimeProcessIdentity();
|
|
215
290
|
withBufferDirectoryLease(bufsDir, () => {
|
|
291
|
+
assertRunDirectories(ctx, cwdIdentity);
|
|
216
292
|
const existingFiles = bufferFiles.filter((file) => fs.existsSync(file));
|
|
217
293
|
if (existingFiles.length > 0) {
|
|
218
294
|
let existingMeta;
|
|
@@ -224,45 +300,76 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity,
|
|
|
224
300
|
}
|
|
225
301
|
for (const file of bufferFiles) fs.rmSync(file, { force: true });
|
|
226
302
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
...(publishingWrapperIdentity ? { wrapper_identity: publishingWrapperIdentity } : {}),
|
|
238
|
-
publishing: true
|
|
239
|
-
}), { flag: 'wx', mode: 0o600 });
|
|
240
|
-
|
|
241
|
-
const db = connect(ctx);
|
|
303
|
+
const createOwnedFile = (file, contents) => {
|
|
304
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT |
|
|
305
|
+
fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
306
|
+
try {
|
|
307
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
308
|
+
createdBufferFiles.push({ file, dev: stat.dev, ino: stat.ino,
|
|
309
|
+
birthtimeNs: stat.birthtimeNs });
|
|
310
|
+
fs.writeFileSync(fd, contents);
|
|
311
|
+
} finally { fs.closeSync(fd); }
|
|
312
|
+
};
|
|
242
313
|
try {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
314
|
+
createOwnedFile(inFile, '');
|
|
315
|
+
createOwnedFile(resizeFile, '');
|
|
316
|
+
createOwnedFile(metaFile, JSON.stringify({
|
|
317
|
+
id,
|
|
318
|
+
generation: bufferGeneration,
|
|
319
|
+
kind,
|
|
320
|
+
role,
|
|
321
|
+
command: [command, ...commandArgs].join(' '),
|
|
322
|
+
cwd,
|
|
323
|
+
wrapper_pid: process.pid,
|
|
324
|
+
...(publishingWrapperIdentity ? { wrapper_identity: publishingWrapperIdentity } : {}),
|
|
325
|
+
publishing: true
|
|
326
|
+
}));
|
|
327
|
+
|
|
328
|
+
const db = connect(ctx);
|
|
329
|
+
try {
|
|
330
|
+
tx(db, () => {
|
|
331
|
+
assertRunDirectories(ctx, cwdIdentity);
|
|
332
|
+
const previous = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
|
|
333
|
+
const evidence = previous ? resolvePeerEvidence({
|
|
334
|
+
peer: previous,
|
|
335
|
+
processes: [{
|
|
336
|
+
storedIdentity: {
|
|
337
|
+
pid: Number(previous.pid),
|
|
338
|
+
startToken: previous.pid_start_token,
|
|
339
|
+
commandHash: previous.pid_command_hash
|
|
340
|
+
},
|
|
341
|
+
current: inspectProcessIdentity(previous.pid)
|
|
342
|
+
}]
|
|
343
|
+
}) : null;
|
|
344
|
+
if (previous && evidence.state !== 'dead' && (
|
|
345
|
+
previous.pid || ['running', 'working', 'busy', 'starting', 'blocked', 'detached'].includes(previous.status)
|
|
346
|
+
)) {
|
|
347
|
+
throw new CliError('PEER_SESSION_EXISTS', `Peer ${id} already has a live or unresolved owner`, {
|
|
348
|
+
peer: id, evidence_state: evidence.state
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
upsertPeer(db, {
|
|
352
|
+
id, kind, role,
|
|
353
|
+
worktree: cwd,
|
|
354
|
+
branch: detectBranch(cwd),
|
|
355
|
+
pid: process.pid,
|
|
356
|
+
processIdentity: publishingWrapperIdentity,
|
|
357
|
+
status: 'running',
|
|
358
|
+
capabilities: 'run-pty'
|
|
359
|
+
});
|
|
360
|
+
upsertCanonicalPeerBinding(db, binding, force);
|
|
361
|
+
addEvent(db, 'run.session.started', id, null, auditPayload({
|
|
362
|
+
actor: id,
|
|
363
|
+
target: id,
|
|
364
|
+
command: [command, ...commandArgs].join(' '),
|
|
365
|
+
cwd,
|
|
366
|
+
webManaged: true
|
|
367
|
+
}));
|
|
251
368
|
});
|
|
252
|
-
|
|
253
|
-
addEvent(db, 'run.session.started', id, null, auditPayload({
|
|
254
|
-
actor: id,
|
|
255
|
-
target: id,
|
|
256
|
-
command: [command, ...commandArgs].join(' '),
|
|
257
|
-
cwd,
|
|
258
|
-
webManaged: true
|
|
259
|
-
}));
|
|
260
|
-
});
|
|
369
|
+
} finally { db.close(); }
|
|
261
370
|
} catch (error) {
|
|
262
|
-
|
|
371
|
+
removeCreatedBufferFiles();
|
|
263
372
|
throw error;
|
|
264
|
-
} finally {
|
|
265
|
-
db.close();
|
|
266
373
|
}
|
|
267
374
|
});
|
|
268
375
|
|
|
@@ -270,12 +377,36 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity,
|
|
|
270
377
|
const cols = process.stdout.columns || 120;
|
|
271
378
|
const rows = process.stdout.rows || 40;
|
|
272
379
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
})
|
|
380
|
+
let child;
|
|
381
|
+
try {
|
|
382
|
+
child = pty.spawn(launch.command, launch.args, {
|
|
383
|
+
name: 'xterm-256color', cols, rows, cwd: launch.cwd, env: launch.env
|
|
384
|
+
});
|
|
385
|
+
} catch (error) {
|
|
386
|
+
// No PTY was returned. Only the current buffer generation may be removed;
|
|
387
|
+
// historical bindings/events are kept as audit evidence rather than
|
|
388
|
+
// blindly restored over a possible replacement owner.
|
|
389
|
+
try { withBufferDirectoryLease(bufsDir, removeCreatedBufferFiles); } catch {}
|
|
390
|
+
const failedDb = connect(ctx);
|
|
391
|
+
try {
|
|
392
|
+
tx(failedDb, () => {
|
|
393
|
+
const mutation = failedDb.prepare(`
|
|
394
|
+
UPDATE peers SET status = ?, last_seen_at = ?
|
|
395
|
+
WHERE id = ? AND pid IS ? AND pid_start_token IS ? AND pid_command_hash IS ?
|
|
396
|
+
AND status = 'running' AND capabilities = 'run-pty'
|
|
397
|
+
`).run('exited', now(), id, process.pid, publishingWrapperIdentity?.startToken || null,
|
|
398
|
+
publishingWrapperIdentity?.commandHash || null);
|
|
399
|
+
if (Number(mutation.changes) > 0) {
|
|
400
|
+
addEvent(failedDb, 'run.session.exited', id, null, auditPayload({
|
|
401
|
+
actor: id, target: id, code: 127, signal: null, startup: true,
|
|
402
|
+
error: error?.code || error?.message || 'pty_spawn_failed'
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
} finally { failedDb.close(); }
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
child.onExit(launch.release);
|
|
279
410
|
const childExit = trackPtyExit(child);
|
|
280
411
|
const wrapperTermination = installPtyTerminationHandlers(child);
|
|
281
412
|
let outFd;
|
|
@@ -510,6 +641,9 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity,
|
|
|
510
641
|
} else {
|
|
511
642
|
process.exitCode = exitCode.exitCode ?? 0;
|
|
512
643
|
}
|
|
644
|
+
} finally {
|
|
645
|
+
launch.release();
|
|
646
|
+
}
|
|
513
647
|
}
|
|
514
648
|
|
|
515
649
|
return { cmdRun, cmdRunWebManaged };
|
|
@@ -35,7 +35,7 @@ export function createTmuxCommands(deps) {
|
|
|
35
35
|
const {
|
|
36
36
|
connect, now, addEvent, auditPayload,
|
|
37
37
|
upsertPeer, upsertCanonicalPeerBinding,
|
|
38
|
-
loadDiscover,
|
|
38
|
+
loadDiscover,
|
|
39
39
|
helpTmux,
|
|
40
40
|
canonicalRoot, rootEvidence, processEvidenceFromRow,
|
|
41
41
|
inspectTmuxTarget, isProjectManagedTmuxSession
|
|
@@ -43,39 +43,48 @@ export function createTmuxCommands(deps) {
|
|
|
43
43
|
|
|
44
44
|
async function cmdScan(ctx, args) {
|
|
45
45
|
const opts = parseOpts(args, { booleans: ['register'] });
|
|
46
|
-
const {
|
|
47
|
-
|
|
48
|
-
const found = [
|
|
49
|
-
...scanClaudeSessions(),
|
|
50
|
-
...scanCodexSessions(),
|
|
51
|
-
...scanProcesses(),
|
|
52
|
-
].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
|
|
46
|
+
const { scanProcesses, verifiedLiveProcessForRoot, canRegisterProcessOnly } = await loadDiscover();
|
|
47
|
+
const found = scanProcesses().map((s) => verifiedLiveProcessForRoot(s, ctx)).filter(Boolean);
|
|
53
48
|
|
|
54
49
|
// Deduplicate by peerId
|
|
55
50
|
const byId = new Map();
|
|
56
51
|
for (const s of found) {
|
|
57
52
|
if (!byId.has(s.peerId)) byId.set(s.peerId, s);
|
|
58
53
|
}
|
|
59
|
-
|
|
54
|
+
let results = [...byId.values()];
|
|
60
55
|
|
|
61
56
|
if (opts.register && results.length) {
|
|
62
|
-
registerProjectActivity(ctx);
|
|
63
57
|
const db = connect(ctx);
|
|
58
|
+
const registered = [];
|
|
64
59
|
try {
|
|
65
60
|
for (const s of results) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
61
|
+
try {
|
|
62
|
+
const live = tx(db, () => {
|
|
63
|
+
const current = verifiedLiveProcessForRoot(s, ctx);
|
|
64
|
+
if (!current) return null;
|
|
65
|
+
if (!canRegisterProcessOnly(db, current)) return null;
|
|
66
|
+
upsertPeer(db, {
|
|
67
|
+
id: current.peerId, kind: current.kind, role: 'peer',
|
|
68
|
+
worktree: current.cwd, branch: detectBranch(current.cwd),
|
|
69
|
+
pid: current.pid, processIdentity: current.processIdentity,
|
|
70
|
+
status: current.status || 'running', capabilities: 'detected'
|
|
71
|
+
});
|
|
72
|
+
upsertCanonicalPeerBinding(db, bindingFromDetected(current, current.transport || 'detected'), true);
|
|
73
|
+
if (!verifiedLiveProcessForRoot(current, ctx)) {
|
|
74
|
+
throw Object.assign(new Error('Discovered process changed during registration'), { code: 'DISCOVERY_PROCESS_CHANGED' });
|
|
75
|
+
}
|
|
76
|
+
return current;
|
|
77
|
+
});
|
|
78
|
+
if (live) registered.push(live);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code !== 'DISCOVERY_PROCESS_CHANGED') throw error;
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
} finally {
|
|
77
84
|
db.close();
|
|
78
85
|
}
|
|
86
|
+
if (registered.length) registerProjectActivity(ctx);
|
|
87
|
+
results = registered;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
printResult(ctx, results, (rows) => {
|