@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
@@ -13,7 +13,11 @@ export function unsafeDirectoryAcl(directory, stat = null) {
13
13
  try { identity ||= fs.lstatSync(directory); }
14
14
  catch { return true; }
15
15
  const signature = `${identity.dev}:${identity.ino}:${identity.ctimeNs ?? identity.ctimeMs}`;
16
- const cached = aclCache.get(directory);
16
+ // Number Stats carry milliseconds; BigInt Stats carry nanoseconds. Keep
17
+ // both exact signatures instead of replacing one with the other on every
18
+ // read through a different state-validation path.
19
+ const cacheKey = `${typeof identity.dev === 'bigint' ? 'bigint' : 'number'}\0${directory}`;
20
+ const cached = aclCache.get(cacheKey);
17
21
  if (cached?.signature === signature) return cached.unsafe;
18
22
  const ls = fs.existsSync('/bin/ls') ? '/bin/ls'
19
23
  : fs.existsSync('/usr/bin/ls') ? '/usr/bin/ls' : null;
@@ -36,7 +40,7 @@ export function unsafeDirectoryAcl(directory, stat = null) {
36
40
  : permissions[10] === '+')
37
41
  : permissions[10] === '+';
38
42
  if (aclCache.size >= 1024) aclCache.clear();
39
- aclCache.set(directory, { signature, unsafe });
43
+ aclCache.set(cacheKey, { signature, unsafe });
40
44
  return unsafe;
41
45
  }
42
46
 
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
25
25
  import { DatabaseSync } from 'node:sqlite';
26
26
  import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
27
27
  import { privateProjectStateDir, withPrivateProjectStateTransition,
28
- privateStateBaseDir } from './private-state.mjs';
28
+ privateProjectStateManifest, privateStateBaseDir } from './private-state.mjs';
29
29
  import { unsafeDirectoryAcl } from './project-trust.mjs';
30
30
 
31
31
  const DIRECTORY_FLAGS = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
@@ -264,6 +264,55 @@ function checkMeshPeers(db) {
264
264
  }
265
265
  }
266
266
 
267
+ // A binding-only v1 -> v2 upgrade never copies or opens the store for write.
268
+ // These checks are additional evidence, not proof that an unregistered writer
269
+ // (including a pre-pointer old Web process) is absent; the operator must also
270
+ // explicitly assert a cold drain.
271
+ export function assertPrivateStateOffline(directory) {
272
+ checkedDirectory(directory, { exactMode: 0o700 });
273
+ const globalPointer = path.join(privateStateBaseDir(), 'runtime.json');
274
+ for (const file of [globalPointer, path.join(directory, 'runtime.json'),
275
+ path.join(directory, 'native', 'runtime.json')]) {
276
+ if (existing(file)) checkPointer(file);
277
+ }
278
+ const bufs = path.join(directory, 'bufs');
279
+ if (existing(bufs)) {
280
+ checkedDirectory(bufs, { exactMode: 0o700 });
281
+ for (const name of fs.readdirSync(bufs)) {
282
+ if (name.endsWith('.meta')) checkBufferMetadata(path.join(bufs, name));
283
+ }
284
+ }
285
+ const mesh = path.join(directory, 'mesh.db');
286
+ if (existing(mesh)) {
287
+ checkedRegular(mesh);
288
+ let db;
289
+ try {
290
+ db = new DatabaseSync(mesh, { readOnly: true, timeout: 1000 });
291
+ checkMeshPeers(db);
292
+ } catch (error) {
293
+ throw unsafe(`Cannot establish offline mesh peer state: ${mesh}`, error);
294
+ } finally { db?.close(); }
295
+ }
296
+ const nativeDb = path.join(directory, 'native', 'state.db');
297
+ if (existing(nativeDb)) {
298
+ checkedRegular(nativeDb);
299
+ let db;
300
+ try {
301
+ db = new DatabaseSync(nativeDb, { readOnly: true, timeout: 1000 });
302
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_owner'").get()) {
303
+ const row = db.prepare('SELECT pid,start_token,command_hash,state FROM native_owner WHERE singleton=1').get();
304
+ if (row && row.state !== 'released') {
305
+ const stored = { pid: Number(row.pid), startToken: row.start_token,
306
+ commandHash: row.command_hash };
307
+ assertRecordedProcessDead(Number(row.pid), stored, 'native owner');
308
+ }
309
+ }
310
+ } catch (error) {
311
+ throw unsafe(`Cannot establish offline native owner state: ${nativeDb}`, error);
312
+ } finally { db?.close(); }
313
+ }
314
+ }
315
+
267
316
  function snapshotDatabase(scratchDb, destination, { mesh = false } = {}) {
268
317
  let source;
269
318
  let target;
@@ -553,18 +602,17 @@ function sourceIdentity(root) {
553
602
  return { canonicalRoot, rootStat, source, sourceStat };
554
603
  }
555
604
 
556
- function markerBody(project) {
557
- return JSON.stringify({
558
- version: 1,
559
- canonicalRoot: project.canonicalRoot,
560
- dev: String(project.rootStat.dev),
561
- ino: String(project.rootStat.ino)
562
- }) + '\n';
605
+ function markerBody(project, binding) {
606
+ if (binding.dev !== String(project.rootStat.dev) || binding.ino !== String(project.rootStat.ino) ||
607
+ (binding.version === 2 && binding.birthtimeNs !== String(project.rootStat.birthtimeNs))) {
608
+ throw unsafe('Project root identity changed before migration marker publication');
609
+ }
610
+ return `${JSON.stringify(binding)}\n`;
563
611
  }
564
612
 
565
- function writeMarker(stage, project) {
613
+ function writeMarker(stage, project, binding) {
566
614
  const marker = path.join(stage, MARKER);
567
- const body = markerBody(project);
615
+ const body = markerBody(project, binding);
568
616
  const fd = fs.openSync(marker, CREATE_FLAGS, 0o600);
569
617
  try {
570
618
  fs.writeFileSync(fd, body);
@@ -600,19 +648,22 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
600
648
  const project = sourceIdentity(root);
601
649
  const base = privateStateBaseDir();
602
650
  const destination = privateProjectStateDir(project.canonicalRoot);
651
+ const destinationManifest = privateProjectStateManifest(project.canonicalRoot);
603
652
  const projects = path.dirname(destination);
653
+ const stageParent = destinationManifest.version === 2 ? path.dirname(projects) : projects;
604
654
  if (project.source === base || projects.startsWith(`${project.source}${path.sep}`)) {
605
655
  throw unsafe('Legacy source overlaps private destination');
606
656
  }
607
657
  checkHomeAncestry(path.dirname(base));
608
658
  ensurePrivateDirectory(base);
609
659
  ensurePrivateDirectory(projects);
660
+ if (stageParent !== projects) ensurePrivateDirectory(stageParent);
610
661
  return withPrivateProjectStateTransition(project.canonicalRoot, ({ setAuthorityKind }) => {
611
662
  const projectsStat = checkedDirectory(projects, { exactMode: 0o700 });
612
663
  if (existing(destination)) {
613
664
  throw failure('STATE_MIGRATION_EXISTS', `Private project state already exists: ${destination}`);
614
665
  }
615
- const stage = path.join(projects, `.${path.basename(destination)}.migration-${randomBytes(12).toString('hex')}`);
666
+ const stage = path.join(stageParent, `.${path.basename(destination)}.migration-${randomBytes(12).toString('hex')}`);
616
667
  const stageStat = privateMkdir(stage);
617
668
  let committed = false;
618
669
  try {
@@ -644,7 +695,7 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
644
695
  if (!sameFile(checkedDirectory(projects, { exactMode: 0o700 }), projectsStat)) {
645
696
  throw unsafe('Private projects directory changed before commit');
646
697
  }
647
- writeMarker(stage, project);
698
+ writeMarker(stage, project, destinationManifest);
648
699
  // Persist authority before exposing the destination. If the rename fails,
649
700
  // the marker makes future access fail closed until an explicit retry.
650
701
  writeAuthorityMarker(projects, setAuthorityKind);
@@ -655,10 +706,12 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
655
706
  fs.renameSync(stage, destination);
656
707
  try {
657
708
  fsyncDirectory(projects);
709
+ if (stageParent !== projects) fsyncDirectory(stageParent);
658
710
  } catch (error) {
659
711
  try {
660
712
  fs.renameSync(destination, stage);
661
713
  fsyncDirectory(projects);
714
+ if (stageParent !== projects) fsyncDirectory(stageParent);
662
715
  } catch (rollbackError) {
663
716
  throw failure('STATE_MIGRATION_COMMIT_UNCERTAIN',
664
717
  `Cannot confirm or roll back migration commit at ${destination}`, rollbackError);
@@ -677,7 +730,7 @@ export function migrateLegacyProjectState(root, { confirmedOffline = false, asse
677
730
  const current = existing(stage);
678
731
  if (current && current.isDirectory() && sameFile(current, stageStat)) {
679
732
  fs.rmSync(stage, { recursive: true, force: false });
680
- fsyncDirectory(projects);
733
+ fsyncDirectory(stageParent);
681
734
  }
682
735
  } catch { /* Preserve the original error; an uncommitted stage is inert. */ }
683
736
  }
@@ -52,7 +52,10 @@ export function redactSecrets(value, seen = new WeakSet()) {
52
52
 
53
53
  const clone = {};
54
54
  for (const [key, entry] of Object.entries(value)) {
55
- clone[key] = isSecretKey(key) ? REDACTED : redactSecrets(entry, seen);
55
+ // Boolean isSecret describes an input control; its value is not a credential.
56
+ clone[key] = key === 'isSecret' && typeof entry === 'boolean'
57
+ ? entry
58
+ : isSecretKey(key) ? REDACTED : redactSecrets(entry, seen);
56
59
  }
57
60
  return clone;
58
61
  } finally {
@@ -4,6 +4,7 @@ import process from 'node:process';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
5
  import { performance } from 'node:perf_hooks';
6
6
  import { CliError } from '../shared/errors.mjs';
7
+ import { privateProjectGenerationFromPath } from '../runtime/private-state.mjs';
7
8
  import { shellQuoteArg } from '../format.mjs';
8
9
  import {
9
10
  LAUNCH_FINGERPRINT_ENV,
@@ -15,7 +16,7 @@ import {
15
16
  } from '../core/peers/format.mjs';
16
17
 
17
18
  export function runTmux(args, opts = {}) {
18
- const result = spawnSync('tmux', args, {
19
+ const result = (opts.spawn || spawnSync)('tmux', args, {
19
20
  encoding: 'utf8',
20
21
  stdio: ['pipe', 'pipe', 'pipe'],
21
22
  input: opts.input || '',
@@ -28,7 +29,10 @@ export function runTmux(args, opts = {}) {
28
29
  if (result.error.code === 'ETIMEDOUT' || result.killed) {
29
30
  throw new CliError('TMUX_ERROR', `tmux timed out after ${opts.timeout ?? 5000}ms: tmux ${args.join(' ')}`);
30
31
  }
31
- throw new CliError('TMUX_ERROR', `tmux failed: ${result.error.message}`);
32
+ const preSpawnCode = (!Number.isInteger(result.pid) || result.pid <= 0) &&
33
+ ['ENOENT', 'EACCES'].includes(result.error.code) ? result.error.code : null;
34
+ throw new CliError('TMUX_ERROR', `tmux failed: ${result.error.message}`,
35
+ preSpawnCode ? { preSpawnCode } : {});
32
36
  }
33
37
  if (result.status !== 0) {
34
38
  const message = (result.stderr || result.stdout || '').trim() || `tmux ${args.join(' ')} failed`;
@@ -201,6 +205,11 @@ function legacyManagedProjectRoot(root) {
201
205
  }
202
206
 
203
207
  export function tmuxManagedSessionPrefix(ctx) {
208
+ // A replacement project at the same spelling keeps A's v1 namespace, but
209
+ // its v2 private DB lives in an independent immutable generation. Derive the
210
+ // new tmux namespace from that captured DB route, never today's root inode.
211
+ const generation = privateProjectGenerationFromPath(ctx.dbPath);
212
+ if (generation) return `hcc2-${generation}-`;
204
213
  return `hcc-${shortHash(managedProjectRoot(ctx.root))}-`;
205
214
  }
206
215
 
@@ -228,6 +237,7 @@ function rootsIdentifySameProject(left, right) {
228
237
  export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot = null) {
229
238
  if (!sessionName) return false;
230
239
  if (sessionName.startsWith(tmuxManagedSessionPrefix(ctx))) return true;
240
+ if (tmuxManagedSessionPrefix(ctx).startsWith('hcc2-')) return false;
231
241
  return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
232
242
  sessionName.startsWith(legacyTmuxManagedSessionPrefix({ root: sessionRoot }));
233
243
  }
@@ -235,6 +245,7 @@ export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot =
235
245
  export function tmuxManagedSessionNameMatches(ctx, sessionName, peerId, sessionRoot = null) {
236
246
  if (!sessionName) return false;
237
247
  if (sessionName === tmuxManagedSessionName(ctx, peerId)) return true;
248
+ if (tmuxManagedSessionPrefix(ctx).startsWith('hcc2-')) return false;
238
249
  return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
239
250
  sessionName === legacyTmuxManagedSessionName({ root: sessionRoot }, peerId);
240
251
  }
package/lib/ui/help.mjs CHANGED
@@ -59,6 +59,7 @@ Environment:
59
59
  HCC_PEER Default peer id; inferred automatically when absent
60
60
  HCC_WEB_TOKEN Set an explicit web access token
61
61
  HCC_RUNTIME_CA CA file for HCC_RUNTIME_URL=https endpoints
62
+ HCC_PINNED_LAUNCH_MODE pinned (default) or hold new HCC-managed launches
62
63
  `);
63
64
  },
64
65
 
@@ -534,7 +534,7 @@ export function createCodexAppServer({
534
534
 
535
535
  async function call(method, params, mutation = false, absorb = true) {
536
536
  await initialize();
537
- if (method === 'thread/list') assertCwdHandoff();
537
+ if (method === 'thread/list' || method === 'turn/steer') assertCwdHandoff();
538
538
  return request(method, params, mutation, absorb);
539
539
  }
540
540
 
@@ -611,6 +611,7 @@ export function createCodexAppServer({
611
611
  [...uncertain.values()].some((entry) => entry.threadId === threadId)) {
612
612
  throw new CodexAppServerError('CODEX_THREAD_BUSY', 'thread has an active or uncertain turn; no input was submitted');
613
613
  }
614
+ assertCwdHandoff();
614
615
  currentThreadId = threadId; submittingThreads.add(threadId);
615
616
  try {
616
617
  return await request('turn/start', { ...objectParams(params), threadId, input: normalized }, true);
@@ -639,6 +640,7 @@ export function createCodexAppServer({
639
640
  // No await between checking a live request and replying to its exact RPC id.
640
641
  assertActiveTurn(threadId, turnId);
641
642
  if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
643
+ if (decision === 'accept' || (approval.kind === 'userInput' && decision !== 'cancel')) assertCwdHandoff();
642
644
  write({ id: requestId, result });
643
645
  approval.status = 'submitted';
644
646
  approval.params = redactMcpUrlInteraction(approval).params;
@@ -8,7 +8,14 @@ import { childSessionEnv } from '../core/sessions/launch.mjs';
8
8
  import { createCodexAppServer } from './codex-app-server.mjs';
9
9
  import { waitForLiveProcessIdentity } from '../process/identity.mjs';
10
10
  import { createScopedMcpConfig } from '../mcp/scope.mjs';
11
- import { sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
11
+ import { assertSelectedCwdSnapshot,
12
+ sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
13
+ import { selectedCodexRootSnapshot, readCodexThreadRootBindings,
14
+ assertCodexThreadRoot, recordCodexThreadRoot } from '../core/sessions/codex-thread-root.mjs';
15
+
16
+ function selectedRootSnapshot(pctx) {
17
+ return selectedCodexRootSnapshot(pctx.root, pctx.rootIdentity);
18
+ }
12
19
 
13
20
  // Each structured session owns one executor. Existing TUI sessions are never
14
21
  // silently resumed here: provider identity and live-owner evidence fence that.
@@ -21,7 +28,35 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
21
28
 
22
29
  function assertSelectedProject(pctx) {
23
30
  if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
24
- pctx.rootIdentity?.assertUnchanged();
31
+ if (typeof pctx.rootIdentity?.assertUnchanged === 'function') pctx.rootIdentity.assertUnchanged();
32
+ else if (pctx.rootIdentity) assertSelectedCwdSnapshot(pctx.rootIdentity);
33
+ }
34
+
35
+ function assertRecordedThreadRoot(pctx, threadId, knownBindings = null) {
36
+ assertSelectedProject(pctx);
37
+ let original;
38
+ if (knownBindings) original = assertCodexThreadRoot(null, threadId, selectedRootSnapshot(pctx), knownBindings);
39
+ else {
40
+ const db = connectWebProject(pctx);
41
+ try { original = assertCodexThreadRoot(db, threadId, selectedRootSnapshot(pctx)); }
42
+ finally { db.close(); }
43
+ }
44
+ assertSelectedProject(pctx);
45
+ return original;
46
+ }
47
+
48
+ function recordThreadRoot(pctx, threadId, snapshot, origin) {
49
+ threadIdentifier(threadId);
50
+ assertSelectedCwdSnapshot(snapshot);
51
+ assertSelectedProject(pctx);
52
+ const db = connectWebProject(pctx);
53
+ try {
54
+ tx(db, () => {
55
+ assertSelectedCwdSnapshot(snapshot);
56
+ recordCodexThreadRoot(db, addEvent, threadId, snapshot, origin);
57
+ });
58
+ } finally { db.close(); }
59
+ assertSelectedCwdSnapshot(snapshot);
25
60
  }
26
61
 
27
62
  function managedSessionMatchesProject(session, pctx) {
@@ -47,6 +82,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
47
82
  const pctx = input.projectCtx;
48
83
  assertSelectedProject(pctx);
49
84
  const cwd = pctx.root;
85
+ const selectedRoot = selectedRootSnapshot(pctx);
50
86
  const mode = input.mode || 'new';
51
87
  if (!['new', 'resume'].includes(mode)) throw new CliError('BAD_REQUEST', 'App Server supports new or explicit resume');
52
88
  const threadId = mode === 'resume' ? String(input.resume || '').trim() : null;
@@ -75,6 +111,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
75
111
  previousPeer = owner.id;
76
112
  }
77
113
  if (bindings.length > 1) throw new CliError('THREAD_IN_USE', 'Provider thread has ambiguous peer ownership');
114
+ assertRecordedThreadRoot(pctx, threadId);
78
115
  }
79
116
  } finally { db.close(); }
80
117
  startingThreads.add(reservation);
@@ -88,7 +125,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
88
125
  let closeScheduled = false;
89
126
  let adapter;
90
127
  let ownerIdentity = null;
91
- try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity || pctx.initialRootIdentity,
128
+ try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity || selectedRoot,
92
129
  changeView: true, env: childSessionEnv({
93
130
  HCC_PEER: id, HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1'
94
131
  }, process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }), async onSpawn({ pid, executorId }) {
@@ -97,7 +134,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
97
134
  assertSelectedProject(pctx);
98
135
  ownerIdentity = owner.identity;
99
136
  session.mcpCapability = mcpConfigFactory({ root: pctx.root, dbPath: pctx.dbPath,
100
- peer: id, executorId, ownerIdentity,
137
+ peer: id, executorId, ownerIdentity, rootIdentity: pctx.rootIdentity || selectedRoot,
101
138
  binding: { transport: 'app-server', runtimeSessionId: id } });
102
139
  return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
103
140
  }, onChange(state, event, metadata) {
@@ -127,6 +164,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
127
164
  if (threadId) {
128
165
  const source = await adapter.peekThread(threadId);
129
166
  assertProjectThread(pctx, source.thread, threadId);
167
+ assertRecordedThreadRoot(pctx, threadId);
130
168
  }
131
169
  assertSelectedProject(pctx);
132
170
  const response = threadId ? await adapter.resumeThread(threadId, params) : await adapter.startThread(params);
@@ -134,6 +172,8 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
134
172
  const thread = response.thread;
135
173
  if (!thread?.id) throw new CliError('CODEX_PROTOCOL_ERROR', 'App Server returned no thread identity');
136
174
  if (threadId && thread.id !== threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Resumed thread identity changed');
175
+ assertSelectedCwdSnapshot(selectedRoot);
176
+ if (!threadId) recordThreadRoot(pctx, thread.id, selectedRoot, 'new');
137
177
  const executorState = adapter.snapshot();
138
178
  if (executorState.status !== 'ready' || executorState.processExited) {
139
179
  throw new CliError('CODEX_DISCONNECTED', 'Executor disconnected while opening its thread; no session was registered');
@@ -233,7 +273,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
233
273
  broadcast(session, { type: 'exit', event: { reason: 'executor stopped' } });
234
274
  closeSessionClients(session);
235
275
  }
236
- function assertProjectThread(pctx, thread, expectedId = null) {
276
+ function assertThreadPath(pctx, thread, expectedId = null) {
237
277
  assertSelectedProject(pctx);
238
278
  let matches = false;
239
279
  try { matches = typeof thread?.cwd === 'string' && fs.realpathSync(thread.cwd) === fs.realpathSync(pctx.root); } catch {}
@@ -244,6 +284,12 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
244
284
  return thread;
245
285
  }
246
286
 
287
+ function assertProjectThread(pctx, thread, expectedId = null, knownBindings = null) {
288
+ assertThreadPath(pctx, thread, expectedId);
289
+ assertRecordedThreadRoot(pctx, thread.id, knownBindings);
290
+ return thread;
291
+ }
292
+
247
293
  function threadIdentifier(value) {
248
294
  if (typeof value !== 'string' || !value.trim() || value.length > 512 || /[\r\n\0]/.test(value)) {
249
295
  throw new CliError('BAD_REQUEST', 'A bounded thread identity is required');
@@ -253,18 +299,22 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
253
299
 
254
300
  async function withHistoryAdapter(pctx, action, fresh = false) {
255
301
  assertSelectedProject(pctx);
302
+ const selectedRoot = selectedRootSnapshot(pctx);
256
303
  const managed = !fresh && [...sessions.values()].find(session => session.type === 'app-server' &&
257
304
  managedSessionMatchesProject(session, pctx) && session.status === 'running' && session.adapter.snapshot().status === 'ready');
258
305
  if (managed) {
259
306
  const result = await action(managed.adapter);
307
+ assertSelectedCwdSnapshot(selectedRoot);
260
308
  assertSelectedProject(pctx);
261
309
  return result;
262
310
  }
263
311
  const adapter = adapterFactory({ cwd: pctx.root,
312
+ expectedCwdIdentity: pctx.rootIdentity || selectedRoot,
264
313
  env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' },
265
314
  process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }) });
266
315
  try {
267
316
  const result = await action(adapter);
317
+ assertSelectedCwdSnapshot(selectedRoot);
268
318
  assertSelectedProject(pctx);
269
319
  return result;
270
320
  } finally {
@@ -283,10 +333,17 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
283
333
  }
284
334
  const result = await withHistoryAdapter(pctx, adapter => adapter.listThreads({ cwd: pctx.root, limit, cursor }));
285
335
  assertSelectedProject(pctx);
336
+ const db = connectWebProject(pctx);
337
+ let knownBindings;
338
+ try { knownBindings = readCodexThreadRootBindings(db); }
339
+ finally { db.close(); }
340
+ let unverifiedCount = 0;
286
341
  const threads = (result.data || []).filter(thread => {
287
- try { assertProjectThread(pctx, thread); return true; }
342
+ try { assertProjectThread(pctx, thread, null, knownBindings); return true; }
288
343
  catch (error) {
289
344
  if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
345
+ if (error?.code === 'CODEX_HISTORY_UNVERIFIED' || error?.code === 'CODEX_HISTORY_BINDING_INVALID') unverifiedCount += 1;
346
+ else if (error?.code !== 'PROJECT_PATH_FORBIDDEN') throw error;
290
347
  return false;
291
348
  }
292
349
  }).map(thread => {
@@ -295,11 +352,12 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
295
352
  return { ...thread, managedSessionId: managed?.id || null };
296
353
  });
297
354
  assertSelectedProject(pctx);
298
- return { threads, nextCursor: result.nextCursor || null };
355
+ return { threads, nextCursor: result.nextCursor || null, unverifiedCount };
299
356
  }
300
357
 
301
358
  async function readCodexThread(pctx, threadId) {
302
359
  threadIdentifier(threadId);
360
+ assertRecordedThreadRoot(pctx, threadId);
303
361
  const result = await withHistoryAdapter(pctx, adapter => adapter.peekThread(threadId));
304
362
  return { thread: assertProjectThread(pctx, result.thread, threadId) };
305
363
  }
@@ -313,6 +371,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
313
371
  if (!['confirmed', 'lastTurnId', 'action_token', 'actionToken', 'epoch', 'authorizeMutation'].includes(key)) throw new CliError('BAD_REQUEST', `Unsupported history fork option: ${key}`);
314
372
  }
315
373
  if (input.lastTurnId !== undefined) threadIdentifier(input.lastTurnId);
374
+ assertRecordedThreadRoot(pctx, threadId);
316
375
  // Fork on a temporary executor and close it before resume. A live source
317
376
  // keeps its process and active turn; the new thread has one managed owner.
318
377
  const result = await withHistoryAdapter(pctx, async adapter => {
@@ -320,8 +379,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
320
379
  assertProjectThread(pctx, source.thread, threadId);
321
380
  assertSelectedProject(pctx);
322
381
  const forked = await adapter.forkThread(threadId, input.lastTurnId ? { lastTurnId: input.lastTurnId } : {}, input.authorizeMutation);
323
- assertProjectThread(pctx, forked.thread);
382
+ assertThreadPath(pctx, forked.thread);
324
383
  if (forked.thread.id === threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Fork did not create a new thread');
384
+ recordThreadRoot(pctx, forked.thread.id, selectedRootSnapshot(pctx), 'fork');
325
385
  return forked;
326
386
  }, true);
327
387
  try {