@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. 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
- // Hook delivery must not migrate another workspace's registered database.
147
- const db = isDsh || nativeOwner ? connect(hookCtx, { migrateRegistered: false }) : connect(hookCtx);
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.close(); } catch {}
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.
@@ -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
- Use a unique --submission-id for each new message and reuse it only for an
25
- exact retry after an uncertain receipt.
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
- ...(submissionId ? { submissionId } : {}) });
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)) {
@@ -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 { spawnPinnedCwdProcess, spawnPinnedCwdPty } from '../../process/pinned-cwd.mjs';
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
- registerProjectActivity(ctx);
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 = captureSelectedCwdSnapshot(cwd);
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
- const child = spawnPinnedCwdProcess(command, commandArgs, {
138
- cwd,
139
- expectedIdentity: expectedCwdIdentity,
140
- stdio: 'inherit',
141
- env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath },
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, force = false }) {
183
- registerProjectActivity(ctx);
184
- const ptyModule = await import('node-pty');
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
- fs.writeFileSync(inFile, '', { flag: 'wx', mode: 0o600 });
228
- fs.writeFileSync(resizeFile, '', { flag: 'wx', mode: 0o600 });
229
- fs.writeFileSync(metaFile, JSON.stringify({
230
- id,
231
- generation: bufferGeneration,
232
- kind,
233
- role,
234
- command: [command, ...commandArgs].join(' '),
235
- cwd,
236
- wrapper_pid: process.pid,
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
- tx(db, () => {
244
- upsertPeer(db, {
245
- id, kind, role,
246
- worktree: cwd,
247
- branch: detectBranch(cwd),
248
- pid: process.pid,
249
- status: 'running',
250
- capabilities: 'run-pty'
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
- upsertCanonicalPeerBinding(db, binding, force);
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
- for (const file of bufferFiles) fs.rmSync(file, { force: true });
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
- const child = spawnPinnedCwdPty(pty, command, commandArgs, {
274
- name: 'xterm-256color', cols, rows, cwd,
275
- expectedIdentity: expectedCwdIdentity,
276
- env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' },
277
- process.env, { rootIdentity: ctx.initialRootIdentity })
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, sameResolvedPath,
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 { scanClaudeSessions, scanCodexSessions, scanProcesses } = await loadDiscover();
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
- const results = [...byId.values()];
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
- upsertPeer(db, {
67
- id: s.peerId, kind: s.kind, role: 'peer',
68
- worktree: s.cwd,
69
- branch: detectBranch(s.cwd),
70
- pid: s.pid,
71
- status: s.status || 'running',
72
- capabilities: 'detected'
73
- });
74
- upsertCanonicalPeerBinding(db, bindingFromDetected(s, s.transport || 'detected'), true);
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) => {