@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
@@ -7,12 +7,13 @@ import { printResult } from '../../format.mjs';
7
7
  import { commandPath } from '../../cli-runtime.mjs';
8
8
  import { detectBranch } from '../../project-context.mjs';
9
9
  import { bindingFromDetected } from '../../core/peers/bindings.mjs';
10
+ import { tx } from '../../db/schema.mjs';
10
11
 
11
12
  export function createUpCommand(deps) {
12
13
  const {
13
14
  connect, helpUp, PRODUCT_NAME,
14
15
  loadSetup, loadDiscover,
15
- writeGuidance, sameResolvedPath,
16
+ writeGuidance,
16
17
  upsertPeer, upsertCanonicalPeerBinding
17
18
  } = deps;
18
19
 
@@ -88,12 +89,8 @@ async function prepareLocalBus(ctx, opts = {}) {
88
89
  const detected = [];
89
90
  if (!opts['no-discover']) {
90
91
  try {
91
- const { scanClaudeSessions, scanCodexSessions, scanProcesses } = await loadDiscover();
92
- const found = [
93
- ...scanClaudeSessions(),
94
- ...scanCodexSessions(),
95
- ...scanProcesses(),
96
- ].filter((s) => sameResolvedPath(s.hccRoot, ctx.root));
92
+ const { scanProcesses, verifiedLiveProcessForRoot, canRegisterProcessOnly } = await loadDiscover();
93
+ const found = scanProcesses().map((s) => verifiedLiveProcessForRoot(s, ctx)).filter(Boolean);
97
94
  const byId = new Map();
98
95
  for (const s of found) {
99
96
  if (!byId.has(s.peerId)) byId.set(s.peerId, s);
@@ -102,16 +99,27 @@ async function prepareLocalBus(ctx, opts = {}) {
102
99
  const db2 = connect(ctx);
103
100
  try {
104
101
  for (const s of byId.values()) {
105
- detected.push(s);
106
- upsertPeer(db2, {
107
- id: s.peerId, kind: s.kind, role: 'peer',
108
- worktree: s.cwd,
109
- branch: detectBranch(s.cwd),
110
- pid: s.pid,
111
- status: 'running',
112
- capabilities: 'detected'
113
- });
114
- upsertCanonicalPeerBinding(db2, bindingFromDetected(s, s.transport || 'detected'), true);
102
+ try {
103
+ const registered = tx(db2, () => {
104
+ const live = verifiedLiveProcessForRoot(s, ctx);
105
+ if (!live) return null;
106
+ if (!canRegisterProcessOnly(db2, live)) return null;
107
+ upsertPeer(db2, {
108
+ id: live.peerId, kind: live.kind, role: 'peer',
109
+ worktree: live.cwd, branch: detectBranch(live.cwd),
110
+ pid: live.pid, processIdentity: live.processIdentity,
111
+ status: 'running', capabilities: 'detected'
112
+ });
113
+ upsertCanonicalPeerBinding(db2, bindingFromDetected(live, live.transport || 'detected'), true);
114
+ if (!verifiedLiveProcessForRoot(live, ctx)) {
115
+ throw Object.assign(new Error('Discovered process changed during registration'), { code: 'DISCOVERY_PROCESS_CHANGED' });
116
+ }
117
+ return live;
118
+ });
119
+ if (registered) detected.push(registered);
120
+ } catch (error) {
121
+ if (error?.code !== 'DISCOVERY_PROCESS_CHANGED') throw error;
122
+ }
115
123
  }
116
124
  } finally {
117
125
  db2.close();
@@ -2,7 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { projectDbPath } from './runtime/paths.mjs';
5
- import { captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
5
+ import { captureSelectedCwdSnapshot, sameSelectedCwdIdentity } from './process/selected-cwd-identity.mjs';
6
+ import { HOOK_ROOT_IDENTITY_ENV, assertHookRootIdentity } from './core/sessions/hook-root-identity.mjs';
7
+ import { CliError } from './shared/errors.mjs';
6
8
 
7
9
  export function createContext(global = {}, deps = {}) {
8
10
  const cwd = deps.cwd || process.cwd();
@@ -24,6 +26,19 @@ export function createContext(global = {}, deps = {}) {
24
26
  if (error?.code !== 'ENOENT') throw error;
25
27
  root = requestedRoot;
26
28
  }
29
+ const inheritedIdentity = process.env.HCC_PINNED_ROOT_IDENTITY;
30
+ if (inheritedIdentity) {
31
+ // A background child has already entered the selected inode. Its later
32
+ // --root pathname lookup must not silently create a new B context.
33
+ delete process.env.HCC_PINNED_ROOT_IDENTITY;
34
+ let expected;
35
+ try { expected = JSON.parse(inheritedIdentity); }
36
+ catch { throw new CliError('PROJECT_PATH_CHANGED', 'Pinned project root identity is invalid'); }
37
+ if (!sameSelectedCwdIdentity(expected, initialRootIdentity)) {
38
+ throw new CliError('PROJECT_PATH_CHANGED', 'Pinned project root changed before child initialization');
39
+ }
40
+ }
41
+ assertHookRootIdentity(root, process.env[HOOK_ROOT_IDENTITY_ENV]);
27
42
  const envDb = global.root ? null : process.env.HCC_DB;
28
43
  const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
29
44
  return { cwd, root, dbPath, json: global.json,
@@ -19,6 +19,8 @@ export const DURABLE_RUNTIME_EVENTS = `
19
19
  SELECT e.id FROM events e WHERE e.type = 'native.web.submission.pending'
20
20
  UNION
21
21
  SELECT e.id FROM events e WHERE e.type = 'codex.executor.started'
22
+ UNION
23
+ SELECT e.id FROM events e WHERE e.type = 'codex.thread.root-bound'
22
24
  `;
23
25
 
24
26
  export function pruneOldEventsPreservingTmuxAuthority(db, cutoff, options = {}) {
@@ -0,0 +1,144 @@
1
+ import path from 'node:path';
2
+ import { CliError } from '../../shared/errors.mjs';
3
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot,
4
+ sameSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
5
+ import { buildCodexCommand } from '../../integrations/providers.mjs';
6
+
7
+ export const CODEX_THREAD_ROOT_EVENT = 'codex.thread.root-bound';
8
+ export const CODEX_SESSION_ORIGIN_ENV = 'HCC_CODEX_SESSION_ORIGIN';
9
+
10
+ function unverified() {
11
+ return new CliError('CODEX_HISTORY_UNVERIFIED',
12
+ 'Codex thread has no trustworthy original project identity; manual review and association are required');
13
+ }
14
+
15
+ function invalid() {
16
+ return new CliError('CODEX_HISTORY_BINDING_INVALID',
17
+ 'Codex thread has an invalid or conflicting original project identity');
18
+ }
19
+
20
+ function checkedThreadId(threadId) {
21
+ if (typeof threadId !== 'string' || !threadId.trim() || threadId.length > 512 || /[\r\n\0]/.test(threadId)) {
22
+ throw new CliError('BAD_REQUEST', 'A bounded Codex thread identity is required');
23
+ }
24
+ return threadId;
25
+ }
26
+
27
+ function rootIsValid(root) {
28
+ return typeof root?.canonical === 'string' && path.isAbsolute(root.canonical) &&
29
+ typeof root.identity?.dev === 'string' && /^\d+$/.test(root.identity.dev) &&
30
+ typeof root.identity?.ino === 'string' && /^\d+$/.test(root.identity.ino) &&
31
+ (root.identity?.birthtimeNs === null ||
32
+ (typeof root.identity?.birthtimeNs === 'string' && /^\d+$/.test(root.identity.birthtimeNs)));
33
+ }
34
+
35
+ export function selectedCodexRootSnapshot(root, expected = null) {
36
+ if (typeof expected?.assertUnchanged === 'function') expected.assertUnchanged();
37
+ else if (expected) assertSelectedCwdSnapshot(expected);
38
+ const snapshot = captureSelectedCwdSnapshot(root);
39
+ if (expected && !sameSelectedCwdIdentity(snapshot, expected)) {
40
+ throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed');
41
+ }
42
+ return snapshot;
43
+ }
44
+
45
+ // event.actor is the thread ID so corrupt receipt payloads can be isolated to
46
+ // their own thread. Unknown or invalid receipts never fall back to cwd text.
47
+ export function readCodexThreadRootBindings(db, threadId = null) {
48
+ const bindings = new Map();
49
+ const rows = threadId === null
50
+ ? db.prepare('SELECT actor,payload FROM events WHERE type = ?').all(CODEX_THREAD_ROOT_EVENT)
51
+ : db.prepare('SELECT actor,payload FROM events WHERE type = ? AND actor = ?')
52
+ .all(CODEX_THREAD_ROOT_EVENT, checkedThreadId(threadId));
53
+ for (const row of rows) {
54
+ const id = row.actor;
55
+ if (typeof id !== 'string' || !id || id.length > 512 || /[\r\n\0]/.test(id)) continue;
56
+ let payload;
57
+ try { payload = JSON.parse(row.payload); } catch { bindings.set(id, null); continue; }
58
+ const root = payload?.root;
59
+ if (payload?.version !== 1 || payload.thread_id !== id || !rootIsValid(root)) {
60
+ bindings.set(id, null);
61
+ continue;
62
+ }
63
+ const previous = bindings.get(id);
64
+ if (previous === null || (previous && !sameSelectedCwdIdentity(previous, root))) {
65
+ bindings.set(id, null);
66
+ } else {
67
+ bindings.set(id, root);
68
+ }
69
+ }
70
+ return bindings;
71
+ }
72
+
73
+ export function assertCodexThreadRoot(db, threadId, selected, knownBindings = null) {
74
+ checkedThreadId(threadId);
75
+ if (typeof selected?.assertUnchanged === 'function') selected.assertUnchanged();
76
+ else assertSelectedCwdSnapshot(selected);
77
+ const bindings = knownBindings || readCodexThreadRootBindings(db, threadId);
78
+ if (!bindings.has(threadId)) throw unverified();
79
+ const original = bindings.get(threadId);
80
+ if (!original) throw invalid();
81
+ if (!sameSelectedCwdIdentity(original, selected)) {
82
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex thread belongs to a different project directory identity');
83
+ }
84
+ return original;
85
+ }
86
+
87
+ export function recordCodexThreadRoot(db, addEvent, threadId, selected, origin, { allowSame = false } = {}) {
88
+ checkedThreadId(threadId);
89
+ if (typeof selected?.assertUnchanged === 'function') selected.assertUnchanged();
90
+ else assertSelectedCwdSnapshot(selected);
91
+ const bindings = readCodexThreadRootBindings(db, threadId);
92
+ if (bindings.has(threadId)) {
93
+ if (allowSame && bindings.get(threadId) && sameSelectedCwdIdentity(bindings.get(threadId), selected)) return false;
94
+ throw new CliError('CODEX_THREAD_ALREADY_BOUND', 'Codex thread already has a project identity');
95
+ }
96
+ addEvent(db, CODEX_THREAD_ROOT_EVENT, threadId, null, {
97
+ version: 1, thread_id: threadId,
98
+ root: { canonical: selected.canonical, identity: selected.identity }, origin
99
+ });
100
+ return true;
101
+ }
102
+
103
+ // Validate the command we will actually launch, not only a caller-supplied
104
+ // binding: a binding for thread X must never authorize `codex resume Y`.
105
+ // Return an origin marker only for a first-party new/fork command whose hook
106
+ // can safely persist the resulting thread's launch-time directory identity.
107
+ export function assertCodexTerminalLaunch(db, binding, { command, root, rootIdentity = null,
108
+ cwd = root, cwdIdentity = null, argv = null } = {}) {
109
+ if (binding?.provider !== 'codex') return null;
110
+ const mode = binding.resume_mode || 'command';
111
+ if (!['new', 'resume', 'fork', 'last', 'fork-last'].includes(mode)) return null;
112
+ const selectedRoot = selectedCodexRootSnapshot(root, rootIdentity);
113
+ const selectedCwd = selectedCodexRootSnapshot(cwd, cwdIdentity);
114
+ if (!sameSelectedCwdIdentity(selectedRoot, selectedCwd)) {
115
+ if (mode === 'new') return null;
116
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex history can only be opened in its selected project directory');
117
+ }
118
+ if (mode === 'new') {
119
+ const directNew = Array.isArray(argv) && argv.length === 1 && path.basename(argv[0]) === 'codex';
120
+ return directNew || (!argv && command === 'codex') ? 'new' : null;
121
+ }
122
+ if (mode === 'last' || mode === 'fork-last') {
123
+ throw new CliError('CODEX_HISTORY_ID_REQUIRED',
124
+ 'Codex --last cannot prove the selected thread identity; choose an explicit verified thread ID');
125
+ }
126
+ if (!binding.resume_arg) {
127
+ throw new CliError('CODEX_HISTORY_ID_REQUIRED', 'Choose an explicit verified Codex thread ID');
128
+ }
129
+ const sourceId = checkedThreadId(binding.resume_arg);
130
+ const expected = buildCodexCommand('history-check', mode === 'fork'
131
+ ? { fork: true, resume: sourceId } : { resume: sourceId }).command;
132
+ const commandMatches = Array.isArray(argv)
133
+ ? argv.length === 3 && path.basename(argv[0]) === 'codex' &&
134
+ argv[1] === mode && argv[2] === sourceId
135
+ : command === expected && (!binding.command || binding.command === expected);
136
+ if (!commandMatches ||
137
+ (mode === 'resume' && ((binding.provider_session_id && binding.provider_session_id !== sourceId) ||
138
+ (binding.provider_session_name && binding.provider_session_name !== sourceId)))) {
139
+ throw new CliError('CODEX_HISTORY_COMMAND_UNVERIFIED',
140
+ 'Codex history command does not match the verified thread identity');
141
+ }
142
+ assertCodexThreadRoot(db, sourceId, selectedRoot);
143
+ return mode === 'fork' ? 'fork' : null;
144
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { shortHash } from '../peers/format.mjs';
5
5
  import { HOOK_ROOT_IDENTITY_ENV, hookRootIdentityValue } from './hook-root-identity.mjs';
6
+ import { CODEX_SESSION_ORIGIN_ENV } from './codex-thread-root.mjs';
6
7
 
7
8
  export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
8
9
  export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
@@ -12,8 +13,10 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
12
13
  '_',
13
14
  'COLUMNS',
14
15
  'HCC_DB',
16
+ CODEX_SESSION_ORIGIN_ENV,
15
17
  HOOK_ROOT_IDENTITY_ENV,
16
18
  'HCC_NO_AUTO_INSTALL_TMUX',
19
+ 'HCC_PINNED_LAUNCH_MODE',
17
20
  'HCC_PEER',
18
21
  'HCC_ROOT',
19
22
  'HCC_RUNTIME_LOCAL_ONLY',
@@ -45,6 +48,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
45
48
 
46
49
  export function childSessionEnv(extra = {}, baseEnv = process.env, { rootIdentity = null } = {}) {
47
50
  const env = { ...(baseEnv || {}), ...extra };
51
+ if (!Object.hasOwn(extra, CODEX_SESSION_ORIGIN_ENV)) delete env[CODEX_SESSION_ORIGIN_ENV];
48
52
  if (Object.hasOwn(extra, 'HCC_ROOT')) {
49
53
  if (extra.HCC_ROOT) env[HOOK_ROOT_IDENTITY_ENV] = hookRootIdentityValue(extra.HCC_ROOT, rootIdentity);
50
54
  else delete env[HOOK_ROOT_IDENTITY_ENV];