@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.3

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 (138) hide show
  1. package/CHANGELOG.md +190 -108
  2. package/README.md +1 -10
  3. package/README.zh-CN.md +1 -8
  4. package/docs/codex-account.zh-CN.md +38 -0
  5. package/docs/codex-protocol.md +59 -0
  6. package/docs/commands.md +13 -28
  7. package/docs/commands.zh-CN.md +11 -20
  8. package/docs/dsh.md +5 -3
  9. package/docs/dsh.zh-CN.md +6 -4
  10. package/docs/macos-process-identity.md +15 -0
  11. package/docs/native.md +95 -4
  12. package/docs/native.zh-CN.md +70 -1
  13. package/docs/web-handoff.zh-CN.md +107 -5
  14. package/lib/cli/app.mjs +9 -6
  15. package/lib/cli/commands/coordination.mjs +7 -2
  16. package/lib/cli/commands/gc.mjs +14 -12
  17. package/lib/cli/commands/hook.mjs +16 -3
  18. package/lib/cli/commands/install.mjs +37 -9
  19. package/lib/cli/commands/lock.mjs +3 -1
  20. package/lib/cli/commands/misc.mjs +105 -13
  21. package/lib/cli/commands/native.mjs +3 -44
  22. package/lib/cli/commands/peer.mjs +3 -0
  23. package/lib/cli/commands/run.mjs +13 -6
  24. package/lib/cli/commands/tmux.mjs +1 -2
  25. package/lib/cli-runtime.mjs +19 -2
  26. package/lib/core/coordination/gc-plan.mjs +5 -0
  27. package/lib/core/coordination/locks.mjs +3 -1
  28. package/lib/core/peers/evidence.mjs +21 -1
  29. package/lib/core/peers/tmux-safety.mjs +4 -0
  30. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  31. package/lib/core/sessions/launch.mjs +7 -1
  32. package/lib/db/connection.mjs +67 -21
  33. package/lib/diagnostics/codex-protocol.mjs +105 -0
  34. package/lib/guidance.mjs +20 -36
  35. package/lib/integrations/codex-account.mjs +143 -0
  36. package/lib/integrations/dsh-collaboration.mjs +3 -4
  37. package/lib/integrations/dsh.mjs +4 -2
  38. package/lib/integrations/native/claude.mjs +24 -6
  39. package/lib/integrations/native/codex.mjs +38 -7
  40. package/lib/integrations/native/dsh-acp.mjs +67 -24
  41. package/lib/integrations/native/index.mjs +4 -1
  42. package/lib/integrations/native/jsonrpc.mjs +37 -5
  43. package/lib/integrations/native/telemetry.mjs +30 -0
  44. package/lib/process/identity.mjs +67 -28
  45. package/lib/process/pinned-cwd.mjs +302 -0
  46. package/lib/process/pinned-guidance.mjs +138 -0
  47. package/lib/process/pinned-project-state.mjs +105 -0
  48. package/lib/process/selected-cwd-identity.mjs +95 -0
  49. package/lib/runtime/buffer-evidence.mjs +7 -1
  50. package/lib/runtime/client.mjs +18 -1
  51. package/lib/runtime/native/client.mjs +4 -1
  52. package/lib/runtime/native/history.mjs +140 -0
  53. package/lib/runtime/native/launcher.mjs +106 -0
  54. package/lib/runtime/native/service.mjs +160 -27
  55. package/lib/runtime/native/store.mjs +51 -21
  56. package/lib/runtime/project-path.mjs +24 -5
  57. package/lib/runtime/projects.mjs +3 -0
  58. package/lib/runtime/state.mjs +61 -26
  59. package/lib/shared/file-lock.mjs +62 -17
  60. package/lib/shared/redact.mjs +2 -1
  61. package/lib/shared/socket-lock-worker.mjs +15 -6
  62. package/lib/web/agent-defaults.mjs +110 -0
  63. package/lib/web/auto-attach.mjs +3 -2
  64. package/lib/web/browser/contracts.d.ts +10 -0
  65. package/lib/web/browser/core.mjs +1997 -0
  66. package/lib/web/browser/project-data.mjs +158 -0
  67. package/lib/web/browser/session-sync.mjs +82 -0
  68. package/lib/web/browser-assets.mjs +19 -0
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +104 -33
  71. package/lib/web/codex-sessions.mjs +60 -11
  72. package/lib/web/context-files.mjs +82 -0
  73. package/lib/web/cookie-auth.mjs +51 -3
  74. package/lib/web/external-sessions.mjs +22 -4
  75. package/lib/web/http-routes.mjs +236 -23
  76. package/lib/web/http.mjs +50 -4
  77. package/lib/web/liveness-reaper.mjs +2 -2
  78. package/lib/web/native-sessions.mjs +322 -13
  79. package/lib/web/project-contexts.mjs +235 -42
  80. package/lib/web/project-file-write.mjs +249 -0
  81. package/lib/web/project-files.mjs +268 -0
  82. package/lib/web/pty-sessions.mjs +11 -3
  83. package/lib/web/runtime-main.mjs +134 -82
  84. package/lib/web/session-serialize.mjs +87 -8
  85. package/lib/web/session-state-sync.mjs +98 -0
  86. package/lib/web/shutdown-diagnostics.mjs +38 -0
  87. package/lib/web/shutdown-response.mjs +21 -0
  88. package/lib/web/shutdown-state.mjs +91 -0
  89. package/lib/web/startup.mjs +76 -27
  90. package/lib/web/tmux-client-snapshot.mjs +85 -0
  91. package/lib/web/tmux-sessions.mjs +71 -7
  92. package/lib/web/tmux-stream.mjs +36 -8
  93. package/lib/web/ui-agent-defaults.mjs +149 -0
  94. package/lib/web/ui-agent-start.mjs +330 -0
  95. package/lib/web/ui-codex-account.mjs +81 -0
  96. package/lib/web/ui-codex.mjs +81 -73
  97. package/lib/web/ui-command-palette.mjs +21 -5
  98. package/lib/web/ui-file-writes.mjs +220 -0
  99. package/lib/web/ui-files.mjs +231 -0
  100. package/lib/web/ui-handoff.mjs +10 -2
  101. package/lib/web/ui-history.mjs +175 -105
  102. package/lib/web/ui-i18n.mjs +280 -8
  103. package/lib/web/ui-interactions.mjs +6 -4
  104. package/lib/web/ui-native-timeline.mjs +326 -0
  105. package/lib/web/ui-native.mjs +93 -49
  106. package/lib/web/ui-review.mjs +5 -3
  107. package/lib/web/ui-safe-markdown.mjs +79 -0
  108. package/lib/web/ui-session-tools.mjs +224 -0
  109. package/lib/web/ui-template.mjs +27 -1967
  110. package/lib/web/ui-terminal-find.mjs +5 -3
  111. package/lib/web/ui-workbench.mjs +5 -3
  112. package/lib/web/ui-workspace.mjs +3 -0
  113. package/package.json +8 -2
  114. package/scripts/benchmark-session-sync.mjs +53 -0
  115. package/scripts/codex-protocol.mjs +88 -0
  116. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  117. package/scripts/dsh-package-provenance.mjs +118 -0
  118. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  119. package/scripts/native-fixture-root.mjs +49 -0
  120. package/scripts/native-installed-acceptance.mjs +204 -6
  121. package/scripts/native-stability-checks.mjs +40 -0
  122. package/scripts/regression-cookie-expiry.mjs +70 -0
  123. package/scripts/regression-http-transport.mjs +9 -0
  124. package/scripts/regression-terminal-marker.mjs +95 -0
  125. package/scripts/regression.mjs +637 -186
  126. package/scripts/shim-reentry-probe.mjs +105 -0
  127. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  128. package/scripts/web-codex-account-acceptance.mjs +178 -0
  129. package/scripts/web-file-preview-checks.mjs +302 -0
  130. package/scripts/web-mcp-form-acceptance.mjs +22 -7
  131. package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
  132. package/scripts/web-native-handoff-checks.mjs +128 -0
  133. package/scripts/web-native-interaction-acceptance.mjs +152 -18
  134. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  135. package/scripts/web-native-model-acceptance.mjs +22 -7
  136. package/scripts/web-session-tools-checks.mjs +112 -0
  137. package/scripts/web-workbench-acceptance.mjs +435 -0
  138. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,105 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { CliError } from '../shared/errors.mjs';
5
+ import { preparePinnedCwdLaunch } from './pinned-cwd.mjs';
6
+
7
+ // The bootstrap enters the checked directory inode before this worker runs.
8
+ // All destructive names below are relative to that cwd, never to a project
9
+ // pathname that could have been rebound after validation.
10
+ function projectStateWorker() {
11
+ const fs = require('node:fs');
12
+ const mode = process.argv[1];
13
+ const expected = JSON.parse(process.argv[2]);
14
+ const content = process.argv[3] || '';
15
+ const sameDirectory = (stat) => stat.isDirectory() &&
16
+ stat.dev.toString() === expected.dev && stat.ino.toString() === expected.ino &&
17
+ (expected.birthtimeNs === null ||
18
+ stat.birthtimeNs?.toString() === expected.birthtimeNs);
19
+ let fd;
20
+ let temporary;
21
+ try {
22
+ if (mode === 'write-guidance') {
23
+ if (!sameDirectory(fs.statSync('.', { bigint: true }))) {
24
+ throw new Error('STATE_DIRECTORY_CHANGED');
25
+ }
26
+ temporary = `HCC.md.tmp.${process.pid}.${require('node:crypto').randomBytes(16).toString('hex')}`;
27
+ fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT |
28
+ fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
29
+ fs.writeFileSync(fd, content);
30
+ fs.fsyncSync(fd);
31
+ fs.closeSync(fd);
32
+ fd = undefined;
33
+ /* PINNED_STATE_TEST_BEFORE_RENAME */
34
+ fs.renameSync(temporary, 'HCC.md');
35
+ } else if (mode === 'purge-legacy') {
36
+ const state = fs.lstatSync('.hello-cc', { bigint: true });
37
+ if (!sameDirectory(state)) throw new Error('STATE_DIRECTORY_CHANGED');
38
+ /* PINNED_STATE_TEST_BEFORE_PURGE */
39
+ fs.rmSync('.hello-cc', { recursive: true, force: true });
40
+ } else throw new Error('STATE_OPERATION_INVALID');
41
+ process.stdout.write('ok');
42
+ } catch (error) {
43
+ process.stderr.write('HCC_PINNED_STATE_FAILED:' + (error?.code || error?.message || 'unknown') + '\n');
44
+ process.exitCode = 1;
45
+ } finally {
46
+ if (fd !== undefined) fs.closeSync(fd);
47
+ if (temporary) {
48
+ try { fs.unlinkSync(temporary); } catch {}
49
+ }
50
+ }
51
+ }
52
+
53
+ const PROJECT_STATE_WORKER_SOURCE = '(' + projectStateWorker.toString() + ')()';
54
+
55
+ function sameBinding(actual, expected) {
56
+ return actual.identity.cwd === expected.canonical &&
57
+ actual.identity.dev === expected.identity.dev &&
58
+ actual.identity.ino === expected.identity.ino &&
59
+ actual.identity.birthtimeNs === expected.identity.birthtimeNs;
60
+ }
61
+
62
+ function runPinnedStateWorker(cwd, mode, expectedCwd, targetIdentity, content, spawnProcess) {
63
+ expectedCwd.assertUnchanged();
64
+ const binding = preparePinnedCwdLaunch(cwd, process.execPath,
65
+ ['-e', PROJECT_STATE_WORKER_SOURCE, mode, JSON.stringify(targetIdentity), content], {
66
+ env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
67
+ });
68
+ try {
69
+ if (!sameBinding(binding, expectedCwd)) {
70
+ throw new CliError('PROJECT_PATH_CHANGED', 'Project state directory changed before pinned operation');
71
+ }
72
+ const result = spawnProcess(binding.command, binding.args, {
73
+ cwd: binding.cwd, env: binding.env, encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'pipe'], timeout: 300000, maxBuffer: 64 * 1024
75
+ });
76
+ if (result.error || result.status !== 0 || result.stdout !== 'ok') {
77
+ const marker = String(result.stderr || '');
78
+ if (/HCC_PINNED_CWD_CHANGED:|HCC_PINNED_STATE_FAILED:STATE_DIRECTORY_CHANGED/.test(marker)) {
79
+ throw new CliError('PROJECT_PATH_CHANGED', 'Project state directory changed during pinned operation');
80
+ }
81
+ throw new CliError('PROJECT_STATE_OPERATION_FAILED',
82
+ `Pinned project state operation failed: ${result.error?.code || marker.trim() || `exit ${result.status}`}`);
83
+ }
84
+ } finally { binding.release(); }
85
+ }
86
+
87
+ export function writePinnedProjectGuidance(stateDir, content, expectedState, {
88
+ spawnProcess = spawnSync
89
+ } = {}) {
90
+ runPinnedStateWorker(stateDir, 'write-guidance', expectedState, expectedState.identity,
91
+ content, spawnProcess);
92
+ return path.join(stateDir, 'HCC.md');
93
+ }
94
+
95
+ export function purgePinnedLegacyProjectState(root, stateDir, expectedRoot, expectedState, {
96
+ spawnProcess = spawnSync
97
+ } = {}) {
98
+ if (path.resolve(stateDir) !== path.join(expectedRoot.canonical, '.hello-cc')) {
99
+ throw new CliError('PROJECT_PATH_FORBIDDEN', 'Legacy state is not under the selected project root');
100
+ }
101
+ expectedState.assertUnchanged();
102
+ runPinnedStateWorker(root, 'purge-legacy', expectedRoot, expectedState.identity,
103
+ '', spawnProcess);
104
+ return stateDir;
105
+ }
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../shared/errors.mjs';
4
+
5
+ function changed() {
6
+ return new CliError('PROJECT_PATH_CHANGED', 'Selected working directory changed; create a new session from its current location');
7
+ }
8
+
9
+ function sameDirectory(left, right) {
10
+ return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
11
+ left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
12
+ }
13
+
14
+ export function sameSelectedCwdIdentity(left, right) {
15
+ return Boolean(left && right && left.canonical === right.canonical &&
16
+ left.identity?.dev === right.identity?.dev && left.identity?.ino === right.identity?.ino &&
17
+ left.identity?.birthtimeNs === right.identity?.birthtimeNs);
18
+ }
19
+
20
+ export function captureSelectedCwdSnapshot(cwd) {
21
+ const binding = captureSelectedCwdIdentity(cwd);
22
+ try {
23
+ return Object.freeze({ requested: binding.requested, canonical: binding.canonical,
24
+ identity: binding.identity });
25
+ } finally { binding.release(); }
26
+ }
27
+
28
+ export function assertSelectedCwdSnapshot(snapshot) {
29
+ const current = captureSelectedCwdIdentity(snapshot.requested);
30
+ try {
31
+ if (!sameSelectedCwdIdentity(snapshot, current)) throw changed();
32
+ } finally { current.release(); }
33
+ }
34
+
35
+ // A provider protocol may require an absolute path even after its process was
36
+ // started in a pinned cwd. Keep the originally selected directory identifiable
37
+ // until the adapter closes, and recheck immediately before each path handoff.
38
+ // This does not make the subsequent provider-internal path lookup atomic.
39
+ export function captureSelectedCwdIdentity(cwd, expectedIdentity = null) {
40
+ if (typeof cwd !== 'string' || !cwd) throw new CliError('BAD_ARGS', 'Selected working directory is required');
41
+ const requested = path.resolve(cwd);
42
+ let canonical;
43
+ try { canonical = fs.realpathSync.native(requested); }
44
+ catch { throw changed(); }
45
+
46
+ let fd;
47
+ let initial;
48
+ let mode = 'descriptor';
49
+ try {
50
+ fd = fs.openSync(canonical, fs.constants.O_RDONLY |
51
+ (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
52
+ initial = fs.fstatSync(fd, { bigint: true });
53
+ } catch (error) {
54
+ if (fd !== undefined) fs.closeSync(fd);
55
+ fd = undefined;
56
+ // Execute-only directories can still be valid provider workspaces. The
57
+ // weaker stat-only mode preserves that contract without hiding the mode.
58
+ if (error?.code !== 'EACCES') throw changed();
59
+ mode = 'stat-only';
60
+ try { initial = fs.statSync(canonical, { bigint: true }); }
61
+ catch { throw changed(); }
62
+ }
63
+ let released = false;
64
+ const identity = Object.freeze({ dev: initial.dev.toString(), ino: initial.ino.toString(),
65
+ birthtimeNs: initial.birthtimeNs?.toString() || null });
66
+ const binding = {
67
+ requested, canonical, mode, identity,
68
+ assertUnchanged() {
69
+ if (released) throw changed();
70
+ try {
71
+ const selected = fs.statSync(requested, { bigint: true });
72
+ const target = fs.statSync(canonical, { bigint: true });
73
+ const original = fd === undefined ? initial : fs.fstatSync(fd, { bigint: true });
74
+ if (!sameDirectory(initial, original) || !sameDirectory(initial, selected) ||
75
+ !sameDirectory(initial, target)) throw changed();
76
+ } catch { throw changed(); }
77
+ },
78
+ dropDescriptor() {
79
+ if (released) return;
80
+ if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
81
+ binding.mode = 'stat-only';
82
+ },
83
+ release() {
84
+ if (released) return;
85
+ released = true;
86
+ if (fd !== undefined) { fs.closeSync(fd); fd = undefined; }
87
+ }
88
+ };
89
+ try {
90
+ binding.assertUnchanged();
91
+ if (expectedIdentity && !sameSelectedCwdIdentity(expectedIdentity, binding)) throw changed();
92
+ }
93
+ catch (error) { binding.release(); throw error; }
94
+ return binding;
95
+ }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { performance } from 'node:perf_hooks';
4
4
 
5
- import { resolvePeerEvidence } from '../core/peers/evidence.mjs';
5
+ import { isPeerEvidenceCompatibilityProtected, resolvePeerEvidence } from '../core/peers/evidence.mjs';
6
6
  import { inspectProcessIdentity } from '../process/identity.mjs';
7
7
  import { resolveProjectDatabase } from './project-path.mjs';
8
8
 
@@ -262,6 +262,9 @@ export function collectBufferEvidence({
262
262
  if (evidence.state === 'live') {
263
263
  recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
264
264
  addExternalGroup(protectedPaths, directory, id);
265
+ } else if (isPeerEvidenceCompatibilityProtected(evidence)) {
266
+ recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
267
+ addExternalGroup(protectedPaths, directory, id);
265
268
  } else if (evidence.state === 'unknown' &&
266
269
  boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
267
270
  addExternalGroup(unknownPaths, directory, id);
@@ -303,6 +306,9 @@ export function collectBufferEvidence({
303
306
  if (evidence.state === 'live') {
304
307
  recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
305
308
  protectedPaths.add(file);
309
+ } else if (isPeerEvidenceCompatibilityProtected(evidence)) {
310
+ recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
311
+ protectedPaths.add(file);
306
312
  } else if (evidence.state === 'unknown' &&
307
313
  boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
308
314
  unknownPaths.add(file);
@@ -2,6 +2,8 @@ import { CliError } from '../shared/errors.mjs';
2
2
  import { readRuntime } from './state.mjs';
3
3
  import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
4
4
  import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
5
+ import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
6
+ import { performance } from 'node:perf_hooks';
5
7
 
6
8
  const DEFAULT_CLI_NAME = 'hcc';
7
9
  export const RUNTIME_SESSION_START_TIMEOUT_MS = 60_000;
@@ -46,17 +48,27 @@ function waitForAbort(promise, signal) {
46
48
  }
47
49
 
48
50
  export async function runtimeRequest(ctx, method, route, body = null, runtime = null, opts = {}) {
51
+ const expectedRootIdentity = ctx.initialRootIdentity || ctx.rootIdentity;
52
+ if (expectedRootIdentity) assertSelectedCwdSnapshot(expectedRootIdentity);
49
53
  const cliName = opts.cliName || DEFAULT_CLI_NAME;
50
54
  const rt = runtime || readRuntime(ctx, { cliName, localOnly: Boolean(opts.localOnly) });
51
55
  const url = runtimeApiUrl(rt, route);
52
56
  const headers = withRuntimeApiVersionHeader({ 'Content-Type': 'application/json' });
53
57
  headers['X-HCC-Root'] = ctx.root;
54
58
  headers['X-HCC-DB'] = ctx.dbPath;
59
+ if (expectedRootIdentity) {
60
+ headers['X-HCC-Root-Identity'] = Buffer.from(JSON.stringify({
61
+ canonical: expectedRootIdentity.canonical,
62
+ identity: expectedRootIdentity.identity
63
+ })).toString('base64url');
64
+ }
55
65
  if (rt.token) headers.Authorization = `Bearer ${rt.token}`;
56
66
  let res;
57
67
  let text;
58
68
  const abortScope = requestAbortScope(opts.signal, opts.timeoutMs);
69
+ const startedAt = performance.now();
59
70
  try {
71
+ abortScope.signal.throwIfAborted();
60
72
  res = await waitForAbort(runtimeHttpRequest(rt, route, {
61
73
  method,
62
74
  headers,
@@ -68,7 +80,12 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
68
80
  } catch (err) {
69
81
  throw new CliError('RUNTIME_UNREACHABLE', `Runtime is not reachable at ${rt.base_url}. Start ${cliName} web again.`, {
70
82
  runtime: rt.source || rt.base_url,
71
- message: err.message
83
+ message: err.message,
84
+ method,
85
+ path: url.pathname,
86
+ elapsedMs: Math.round(performance.now() - startedAt),
87
+ timeoutMs: opts.timeoutMs === undefined ? 8000 : opts.timeoutMs,
88
+ errorName: err.name || 'Error'
72
89
  });
73
90
  } finally {
74
91
  abortScope.cleanup();
@@ -10,7 +10,10 @@ export function nativeRequest(ctx, method, route, body = null, { timeoutMs = 350
10
10
  let timer;
11
11
  const fail = (error) => { clearTimeout(timer); reject(error); };
12
12
  const invalid = (message) => new CliError('NATIVE_RESPONSE_INVALID', message, { uncertain: method !== 'GET' });
13
- const request = http.request({ host: '127.0.0.1', port: pointer.port, method, path: route,
13
+ // Synchronous CLI work can delay processing an idle socket's remote close.
14
+ // Use a fresh loopback connection for each request instead of reusing it;
15
+ // uncertain mutations must not be repaired by replaying the request.
16
+ const request = http.request({ host: '127.0.0.1', port: pointer.port, method, path: route, agent: false,
14
17
  headers: { authorization: `Bearer ${pointer.token}`, ...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) }
15
18
  }, (response) => {
16
19
  let data = '';
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { CliError } from '../../shared/errors.mjs';
6
+ import { redactSecrets } from '../../shared/redact.mjs';
7
+ import { projectStateDir, legacyProjectStateDir } from '../paths.mjs';
8
+ import { ensurePrivateProjectStateDir } from '../private-state.mjs';
9
+ import { unsafeDirectoryAcl } from '../project-trust.mjs';
10
+ import { nativePaths } from './store.mjs';
11
+
12
+ const PAGE_SIZE = 100;
13
+ const WORKER_COLUMNS = 'peer, provider, session_id, cwd, status, updated_at';
14
+
15
+ function missing(file) {
16
+ try { return fs.lstatSync(file); }
17
+ catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
18
+ }
19
+
20
+ function unavailable() {
21
+ return new CliError('NATIVE_HISTORY_UNAVAILABLE', 'Stored native history cannot be read; inspect the native state before retrying');
22
+ }
23
+
24
+ function validateEntry(file, directory = false) {
25
+ const stat = missing(file);
26
+ if (!stat) return null;
27
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) ||
28
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
29
+ (process.platform !== 'win32' && (stat.mode & 0o022) !== 0) || unsafeDirectoryAcl(file, stat)) {
30
+ throw new CliError('NATIVE_STATE_UNSAFE', 'Stored native history must use owned state without symlinks or writable aliases');
31
+ }
32
+ return stat;
33
+ }
34
+
35
+ function historyPaths(ctx) {
36
+ const root = fs.realpathSync(ctx.root), state = projectStateDir(root);
37
+ if (state === legacyProjectStateDir(root)) {
38
+ // History is project-state scoped, not an authority to follow a caller's
39
+ // arbitrary --db path into another project. The live runtime validates
40
+ // its selected DB separately before it may write.
41
+ return nativePaths({ root: ctx.root, dbPath: path.join(root, '.hello-cc', 'mesh.db'),
42
+ initialRootIdentity: ctx.initialRootIdentity });
43
+ }
44
+ // The general resolver repairs missing/pending private authority markers.
45
+ // History must not perform that transition or tighten existing permissions.
46
+ const existing = ensurePrivateProjectStateDir(root, { create: false });
47
+ if (!existing) return null;
48
+ const dir = path.join(existing, 'native');
49
+ if (!validateEntry(dir, true)) return null;
50
+ for (const name of ['runtime.json', 'state.db', 'state.db-wal', 'state.db-shm', 'runtime.log']) {
51
+ try { validateEntry(path.join(dir, name)); }
52
+ catch (error) {
53
+ if ((name === 'state.db-wal' || name === 'state.db-shm') && !missing(path.join(dir, name))) continue;
54
+ throw error;
55
+ }
56
+ }
57
+ return { dir, db: path.join(dir, 'state.db') };
58
+ }
59
+
60
+ function withHistory(ctx, read, absent) {
61
+ let db;
62
+ try {
63
+ const paths = historyPaths(ctx);
64
+ if (!paths) return absent();
65
+ const before = validateEntry(paths.db);
66
+ if (!before) return absent();
67
+ // A read-only SQLite connection preserves committed data still in WAL.
68
+ // SQLite may maintain its own WAL/SHM sidecars; never initialize a store,
69
+ // run migrations, chmod state, or alter project records here.
70
+ db = new DatabaseSync(paths.db, { readOnly: true, timeout: 1000 });
71
+ const after = validateEntry(paths.db);
72
+ if (!after || before.dev !== after.dev || before.ino !== after.ino) throw unavailable();
73
+ db.exec('BEGIN'); // Keep the worker and its retained rows in one read snapshot.
74
+ const result = read(db);
75
+ const current = validateEntry(paths.db);
76
+ if (!current || before.dev !== current.dev || before.ino !== current.ino) throw unavailable();
77
+ const safe = redactSecrets(result);
78
+ for (const row of safe.workers || (safe.worker ? [safe.worker] : [])) {
79
+ if (row.capabilities_json !== undefined && row.capabilities_json !== null) row.capabilities_json = JSON.stringify(row.capabilities_json);
80
+ }
81
+ for (const row of safe.deliveries || []) {
82
+ if (row.detail !== undefined && row.detail !== null) row.detail = JSON.stringify(row.detail);
83
+ }
84
+ return safe;
85
+ } catch (error) {
86
+ if (error instanceof CliError) throw error;
87
+ throw unavailable();
88
+ } finally { if (db) db.close(); }
89
+ }
90
+
91
+ function workerColumns(db) {
92
+ const columns = db.prepare('PRAGMA table_info(workers)').all();
93
+ return WORKER_COLUMNS + (columns.some(column => column.name === 'cwd_identity')
94
+ ? ', cwd_identity' : ', NULL AS cwd_identity') +
95
+ (columns.some(column => column.name === 'capabilities_json') ? ', capabilities_json' : '');
96
+ }
97
+
98
+ function parseStored(value) {
99
+ if (value === null || value === undefined) return value;
100
+ try { return JSON.parse(value); }
101
+ catch { throw new CliError('NATIVE_HISTORY_INVALID', 'Stored native history contains invalid event data'); }
102
+ }
103
+
104
+ function workerRow(row) {
105
+ if (row && row.capabilities_json !== undefined && row.capabilities_json !== null) {
106
+ return { ...row, capabilities_json: parseStored(row.capabilities_json) };
107
+ }
108
+ return row;
109
+ }
110
+
111
+ export function readNativeHistory(ctx, { limit = PAGE_SIZE } = {}) {
112
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > PAGE_SIZE) {
113
+ throw new CliError('BAD_ARGS', 'Native history limit must be an integer from 1 to 100');
114
+ }
115
+ return withHistory(ctx, db => {
116
+ const rows = db.prepare('SELECT ' + workerColumns(db) + ' FROM workers ORDER BY updated_at DESC, peer LIMIT ?').all(limit + 1);
117
+ return { workers: rows.slice(0, limit).map(workerRow), truncated: rows.length > limit };
118
+ }, () => ({ workers: [], truncated: false }));
119
+ }
120
+
121
+ export function readNativeWorkerHistory(ctx, peer, { after = 0 } = {}) {
122
+ if (typeof peer !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(peer) || peer.toLowerCase() === 'all') {
123
+ throw new CliError('BAD_ARGS', 'Native history requires one valid worker name');
124
+ }
125
+ if (!Number.isSafeInteger(after) || after < 0) throw new CliError('BAD_ARGS', 'Native history cursor must be a nonnegative safe integer');
126
+ const absent = () => { throw new CliError('NATIVE_HISTORY_NOT_FOUND', 'No retained native history exists for this worker'); };
127
+ return withHistory(ctx, db => {
128
+ const worker = workerRow(db.prepare('SELECT ' + workerColumns(db) + ' FROM workers WHERE peer = ?').get(peer));
129
+ if (!worker) return absent();
130
+ const rows = db.prepare('SELECT id, peer, payload, created_at FROM provider_events WHERE peer = ? AND id > ? ORDER BY id LIMIT ?')
131
+ .all(peer, after, PAGE_SIZE + 1);
132
+ const deliveries = db.prepare('SELECT * FROM deliveries WHERE peer = ? ORDER BY id DESC LIMIT ?').all(peer, PAGE_SIZE + 1);
133
+ const events = rows.slice(0, PAGE_SIZE).map(row => ({ ...row, payload: parseStored(row.payload) }));
134
+ return { worker, events,
135
+ deliveries: deliveries.slice(0, PAGE_SIZE).map(row => ({ ...row,
136
+ detail: parseStored(row.detail) })),
137
+ truncated: rows.length > PAGE_SIZE, deliveriesTruncated: deliveries.length > PAGE_SIZE,
138
+ nextAfter: events.at(-1)?.id ?? after };
139
+ }, absent);
140
+ }
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import net from 'node:net';
4
+ import { spawn } from 'node:child_process';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { CliError } from '../../shared/errors.mjs';
8
+ import { fileLockEndpoint } from '../../shared/file-lock.mjs';
9
+ import { openPrivateAppendFile } from '../private-file.mjs';
10
+ import { nativePaths, readNativePointer } from './store.mjs';
11
+ import { nativeRequest } from './client.mjs';
12
+ import { spawnPinnedCwdProcess } from '../../process/pinned-cwd.mjs';
13
+ import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
14
+
15
+ // Shared by CLI and Web. The in-process promise and short-lived loopback lock
16
+ // prevent concurrent launchers from spawning competing project daemons.
17
+ export function createNativeLauncher({ spawnProcess = spawn, request = nativeRequest,
18
+ env = process.env, startupTimeoutMs = 10000, pollMs = 100 } = {}) {
19
+ const starting = new Map();
20
+
21
+ async function readReady(ctx) {
22
+ if (!readNativePointer(ctx)) return null;
23
+ try {
24
+ const status = await request(ctx, 'GET', '/status', null, { timeoutMs: 2000 });
25
+ if (!status || status.root !== ctx.root || status.meshDb !== ctx.dbPath ||
26
+ typeof status.generation !== 'string' || !status.generation) {
27
+ throw new CliError('NATIVE_RESPONSE_INVALID', 'Native runtime does not match this project');
28
+ }
29
+ if (status.stopping) throw new CliError('NATIVE_RUNTIME_STOPPING', 'Native runtime is stopping; finish its shutdown before creating another worker');
30
+ return status;
31
+ } catch (error) {
32
+ if (error.code === 'NATIVE_RUNTIME_OFFLINE') return null;
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async function launch(ctx) {
38
+ const ready = await readReady(ctx);
39
+ if (ready) return ready;
40
+ const paths = nativePaths(ctx, { create: true });
41
+ const endpoint = fileLockEndpoint(path.join(paths.dir, 'launcher-owner'));
42
+ const deadline = Date.now() + startupTimeoutMs;
43
+ let lock;
44
+ while (!lock && Date.now() < deadline) {
45
+ const candidate = net.createServer(socket => socket.end());
46
+ const acquired = await new Promise((resolve, reject) => {
47
+ candidate.once('error', error => error.code === 'EADDRINUSE' ? resolve(false) : reject(error));
48
+ candidate.listen(endpoint.port, '127.0.0.1', () => resolve(true));
49
+ });
50
+ if (acquired) lock = candidate;
51
+ else {
52
+ const existing = await readReady(ctx);
53
+ if (existing) return existing;
54
+ await delay(pollMs);
55
+ }
56
+ }
57
+ if (!lock) throw new CliError('NATIVE_RUNTIME_START_FAILED', 'Another native launcher is still starting; inspect native status before retrying');
58
+ try {
59
+ const existing = await readReady(ctx);
60
+ if (existing) return existing;
61
+ let log, child;
62
+ try {
63
+ log = openPrivateAppendFile(paths.log);
64
+ child = spawnPinnedCwdProcess(process.execPath, [fileURLToPath(new URL('../../../bin/hcc.mjs', import.meta.url)), '--root', ctx.root, '--db', ctx.dbPath, 'native', 'serve'], {
65
+ cwd: ctx.root, expectedIdentity: ctx.initialRootIdentity,
66
+ env, detached: true, stdio: ['ignore', log, log]
67
+ }, spawnProcess);
68
+ } catch (error) {
69
+ if (error?.code === 'PROJECT_PATH_FORBIDDEN') throw new CliError('NATIVE_STATE_UNSAFE', error.message);
70
+ throw error;
71
+ } finally {
72
+ if (log !== undefined) fs.closeSync(log);
73
+ }
74
+ let failed;
75
+ let exited;
76
+ child.on('error', error => { failed = error; });
77
+ child.once('exit', (code, signal) => { exited = { code, signal }; });
78
+ child.unref();
79
+ while (Date.now() < deadline) {
80
+ if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
81
+ if (exited) throw new CliError('NATIVE_RUNTIME_START_FAILED',
82
+ `Native runtime exited before it became ready (code=${exited.code ?? ''}${exited.signal ? ` signal=${exited.signal}` : ''}). Inspect ${paths.log}`);
83
+ const status = await readReady(ctx);
84
+ if (status) return status;
85
+ await delay(pollMs);
86
+ }
87
+ throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect native status and ${paths.log} before retrying`);
88
+ } finally { await new Promise(resolve => lock.close(resolve)); }
89
+ }
90
+
91
+ return function ensureNativeRuntime(ctx) {
92
+ const expectedRootIdentity = ctx.initialRootIdentity || ctx.rootIdentity || captureSelectedCwdSnapshot(ctx.root);
93
+ assertSelectedCwdSnapshot(expectedRootIdentity);
94
+ const canonical = { ...ctx, root: expectedRootIdentity.canonical,
95
+ initialRootIdentity: expectedRootIdentity, dbPath: path.resolve(ctx.dbPath) };
96
+ const key = `${canonical.root}\0${canonical.dbPath}`;
97
+ if (starting.has(key)) return starting.get(key);
98
+ const pending = launch(canonical).finally(() => {
99
+ if (starting.get(key) === pending) starting.delete(key);
100
+ });
101
+ starting.set(key, pending);
102
+ return pending;
103
+ };
104
+ }
105
+
106
+ export const ensureNativeRuntime = createNativeLauncher();