@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.1.0-rc.8",
3
+ "version": "1.1.0",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "bin/",
12
+ "native/bin/",
13
+ "native/cwd-handoff/",
12
14
  "lib/",
13
15
  "scripts/",
14
16
  "assets/",
@@ -35,7 +37,11 @@
35
37
  "test:regression": "node ./scripts/regression.mjs",
36
38
  "test:web": "node ./scripts/web-workbench-acceptance.mjs",
37
39
  "test:web:installed": "node ./scripts/web-workbench-installed-acceptance.mjs",
38
- "codex:protocol": "node ./scripts/codex-protocol.mjs"
40
+ "codex:protocol": "node ./scripts/codex-protocol.mjs",
41
+ "build:native:cwd-handoff": "node ./scripts/build-cwd-handoff.mjs",
42
+ "release:native:verify": "node ./scripts/verify-cwd-handoff-package.mjs",
43
+ "release:native:pack": "node ./scripts/pack-cwd-handoff.mjs",
44
+ "prepublishOnly": "npm run release:native:verify"
39
45
  },
40
46
  "repository": {
41
47
  "type": "git",
@@ -0,0 +1,47 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const hostTarget = `${process.platform}-${process.arch}`;
10
+ const target = process.argv.length === 4 && process.argv[2] === '--target'
11
+ ? process.argv[3] : hostTarget;
12
+ const supported = new Set(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']);
13
+ if ((process.argv.length !== 2 &&
14
+ !(process.argv.length === 4 && process.argv[2] === '--target')) ||
15
+ !supported.has(target) ||
16
+ (target !== hostTarget && !(process.platform === 'darwin' && target.startsWith('darwin-')))) {
17
+ console.error(`Unsupported native cwd handoff build target: ${target}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ const source = path.join(root, 'native', 'cwd-handoff', 'hcc-cwd-handoff.c');
22
+ const sourceHash = createHash('sha256').update(fs.readFileSync(source)).digest('hex');
23
+ const outputDirectory = path.join(root, 'native', 'bin', target);
24
+ const output = path.join(outputDirectory, 'hcc-cwd-handoff');
25
+ fs.mkdirSync(outputDirectory, { recursive: true, mode: 0o755 });
26
+ const temporary = path.join(outputDirectory,
27
+ `.hcc-cwd-handoff-${process.pid}-${randomBytes(4).toString('hex')}.tmp`);
28
+ const compiler = process.env.CC || 'cc';
29
+ const args = [
30
+ '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-pedantic',
31
+ '-D_FORTIFY_SOURCE=2', '-fstack-protector-strong',
32
+ `-DHCC_SOURCE_SHA256="${sourceHash}"`,
33
+ ...(process.platform === 'darwin' ? ['-arch', target.endsWith('arm64') ? 'arm64' : 'x86_64'] : []),
34
+ ...(process.platform === 'linux' ? ['-static', '-Wl,-z,relro,-z,now'] : []),
35
+ '-o', temporary, source
36
+ ];
37
+ try {
38
+ const result = spawnSync(compiler, args, { stdio: 'inherit' });
39
+ if (result.error) throw result.error;
40
+ if (result.status !== 0) throw new Error(`Native compiler exited with ${result.status ?? result.signal}`);
41
+ fs.chmodSync(temporary, 0o755);
42
+ fs.renameSync(temporary, output);
43
+ console.log(`Built ${path.relative(root, output)}`);
44
+ } finally {
45
+ try { fs.unlinkSync(temporary); }
46
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
47
+ }
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import process from 'node:process';
3
+
4
+ const verify = spawnSync(process.execPath, ['./scripts/verify-cwd-handoff-package.mjs'], {
5
+ stdio: 'inherit'
6
+ });
7
+ if (verify.error) throw verify.error;
8
+ if (verify.status !== 0) process.exit(verify.status || 1);
9
+
10
+ const packed = spawnSync('npm', ['pack', '--ignore-scripts', '--json'], {
11
+ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024
12
+ });
13
+ if (packed.error) throw packed.error;
14
+ if (packed.status !== 0) {
15
+ process.stderr.write(packed.stderr);
16
+ process.exit(packed.status || 1);
17
+ }
18
+ const [archive] = JSON.parse(packed.stdout);
19
+ const files = new Map(archive.files.map(file => [file.path, file]));
20
+ for (const target of ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']) {
21
+ const name = `native/bin/${target}/hcc-cwd-handoff`;
22
+ const entry = files.get(name);
23
+ if (!entry || (entry.mode & 0o111) === 0 || (entry.mode & 0o022) !== 0) {
24
+ throw new Error(`npm tarball is missing safe executable ${name}`);
25
+ }
26
+ }
27
+ console.log(`Verified npm tarball ${archive.filename} (${archive.files.length} files)`);
@@ -11,6 +11,9 @@ import crypto from 'node:crypto';
11
11
  import { performance } from 'node:perf_hooks';
12
12
  import { setTimeout as sleep } from 'node:timers/promises';
13
13
  import { DatabaseSync } from 'node:sqlite';
14
+ import { captureSelectedCwdSnapshot } from '../lib/process/selected-cwd-identity.mjs';
15
+ import { recordCodexThreadRoot } from '../lib/core/sessions/codex-thread-root.mjs';
16
+ import { createEventHelpers } from '../lib/db/events.mjs';
14
17
  import { Readable } from 'node:stream';
15
18
  import { pathToFileURL } from 'node:url';
16
19
  import WebSocket from 'ws';
@@ -53,6 +56,7 @@ const diagnosticSecrets = new Set();
53
56
  const fixtureFailures = new WeakSet();
54
57
  const fixtureCommandFailures = new WeakMap();
55
58
  const fixtureHttpFailures = new WeakMap();
59
+ const fixtureShimRegistrationFailures = new WeakMap();
56
60
  const managedTmuxSessions = new Set();
57
61
 
58
62
  const env = {
@@ -95,6 +99,9 @@ export function fixtureFailureError(message, commandResult = null, diagnosticOpe
95
99
  fixtureFailures.add(error);
96
100
  const command = fixtureCommandResult(commandResult, diagnosticOperation);
97
101
  if (command) fixtureCommandFailures.set(error, command);
102
+ if (diagnosticOperation === 'shim-registration') {
103
+ fixtureShimRegistrationFailures.set(error, fixtureShimRegistrationSummary(commandResult));
104
+ }
98
105
  return error;
99
106
  }
100
107
 
@@ -108,10 +115,47 @@ export function fixtureFailureDiagnostic(error) {
108
115
  : new Error('regression failure handler').stack.split('\n').slice(1, 7),
109
116
  ...(fixtureCommandFailures.has(error) ? { command: fixtureCommandFailures.get(error) } : {}),
110
117
  ...(fixtureHttpFailures.has(error) ? { http: fixtureHttpFailures.get(error) } : {}),
118
+ ...(fixtureShimRegistrationFailures.has(error)
119
+ ? { shimRegistration: fixtureShimRegistrationFailures.get(error) } : {}),
111
120
  ...(terminalMarker ? { terminalMarker } : {})
112
121
  };
113
122
  }
114
123
 
124
+ export function fixtureShimRegistrationSummary(observation = {}) {
125
+ // Public annotations retain only fixture-owned booleans and finite enums.
126
+ // CLI tables, provider output, identifiers, paths and environment are excluded.
127
+ const bool = value => typeof value === 'boolean' ? value : null;
128
+ const member = (value, allowed) => allowed.includes(value) ? value : 'unknown';
129
+ const section = key => observation?.[key] && typeof observation[key] === 'object'
130
+ ? observation[key] : {};
131
+ const text = section('text'), persisted = section('persisted'), pane = section('pane');
132
+ return Object.freeze({
133
+ text: Object.freeze({ format: member(text.format, ['runtime', 'database']),
134
+ peerPresent: bool(text.peerPresent), tmuxPresent: bool(text.tmuxPresent), panePresent: bool(text.panePresent) }),
135
+ providerEntered: bool(observation?.providerEntered),
136
+ persisted: Object.freeze({ state: member(persisted.state, ['available', 'unavailable']),
137
+ peerPresent: bool(persisted.peerPresent), bindingPresent: bool(persisted.bindingPresent),
138
+ transport: member(persisted.transport, ['tmux', 'web-pty', 'native', 'app-server', 'claude-mod', 'hook', 'detected']),
139
+ targetMatches: bool(persisted.targetMatches), sessionMatches: bool(persisted.sessionMatches),
140
+ peerStatus: member(persisted.peerStatus, ['starting', 'running', 'idle', 'working', 'busy', 'active', 'exited', 'detached', 'stale', 'blocked', 'closed']),
141
+ processState: member(persisted.processState, ['live', 'dead', 'unknown']) }),
142
+ pane: Object.freeze({ state: member(pane.state, ['found', 'not-found', 'unavailable']),
143
+ dead: bool(pane.dead), processState: member(pane.processState, ['live', 'dead', 'unknown']),
144
+ command: member(pane.command, ['bash', 'sh', 'zsh', 'node', 'env', 'hcc-cwd-handoff']) }),
145
+ events: Object.freeze((Array.isArray(observation?.events) ? observation.events : [])
146
+ .filter(type => ['peer.start.requested', 'tmux.session.attached', 'tmux.session.detached',
147
+ 'tmux.session.exited', 'tmux.session.gc', 'tmux.session.rebind_cleanup_pending'].includes(type)).slice(0, 8))
148
+ });
149
+ }
150
+
151
+ export function fixturePeerListFormat(list) {
152
+ if (typeof list !== 'string') return 'unknown';
153
+ const header = list.split(/\r?\n/, 1)[0].trim().split(/\s+/).join(' ');
154
+ if (header === 'id peer kind role status type pane provider resume session pid command') return 'runtime';
155
+ if (header === 'id kind role status age active branch') return 'database';
156
+ return 'unknown';
157
+ }
158
+
115
159
  function fixtureHttpRoute(route) {
116
160
  if (typeof route !== 'string') return null;
117
161
  const pathname = route.split(/[?#]/, 1)[0];
@@ -5697,6 +5741,19 @@ async function multiProjectWebWorkflow() {
5697
5741
  await stopSession(claudeResume.id);
5698
5742
 
5699
5743
  const codexResumeName = `web-codex-resume-${testId}`;
5744
+ const unverifiedCodexResume = await runtimeFetch('/api/sessions', {
5745
+ method: 'POST',
5746
+ headers: { 'Content-Type': 'application/json' },
5747
+ body: JSON.stringify({ kind: 'codex', mode: 'resume', resume: codexResumeName })
5748
+ }, { root });
5749
+ const unverifiedCodexBody = await unverifiedCodexResume.json();
5750
+ if (unverifiedCodexResume.ok || unverifiedCodexBody.error?.code !== 'CODEX_HISTORY_UNVERIFIED') {
5751
+ fail(`path-only Codex history was accepted: ${JSON.stringify(unverifiedCodexBody)}`);
5752
+ }
5753
+ // This disposable fixture explicitly models a first-party root receipt;
5754
+ // matching cwd text alone must not authorize the resume above.
5755
+ withMeshDb((db) => recordCodexThreadRoot(db, createEventHelpers().addEvent,
5756
+ codexResumeName, captureSelectedCwdSnapshot(root), 'regression-fixture'));
5700
5757
  const codexResume = await startProvider({ kind: 'codex', mode: 'resume', resume: codexResumeName });
5701
5758
  const expectedCodexPeer = `codex-${shortHash(codexResumeName)}`;
5702
5759
  if (!codexResume.command.includes(`codex resume ${codexResumeName}`)) {
@@ -5736,11 +5793,15 @@ async function multiProjectWebWorkflow() {
5736
5793
  fail(`resumable API omitted named codex resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
5737
5794
  }
5738
5795
 
5739
- const codexLast = await startProvider({ kind: 'codex', mode: 'last' });
5740
- if (codexLast.command !== 'codex resume --last') {
5741
- fail(`web codex last command wrong:\n${JSON.stringify(codexLast, null, 2)}`);
5796
+ const codexLastResponse = await runtimeFetch('/api/sessions', {
5797
+ method: 'POST',
5798
+ headers: { 'Content-Type': 'application/json' },
5799
+ body: JSON.stringify({ kind: 'codex', mode: 'last' })
5800
+ }, { root });
5801
+ const codexLastBody = await codexLastResponse.json();
5802
+ if (codexLastResponse.ok || codexLastBody.error?.code !== 'CODEX_HISTORY_ID_REQUIRED') {
5803
+ fail(`Codex --last was accepted without a provable thread ID: ${JSON.stringify(codexLastBody)}`);
5742
5804
  }
5743
- await stopSession(codexLast.id);
5744
5805
 
5745
5806
  const claudeContinue = await startProvider({ kind: 'claude', mode: 'continue' });
5746
5807
  if (claudeContinue.command !== 'claude --continue') {
@@ -6571,6 +6632,43 @@ async function tmuxBackedStartWorkflow() {
6571
6632
  await assertTmuxGcPolicy();
6572
6633
  }
6573
6634
 
6635
+ function collectShimRegistrationObservation(peer, pane, list, providerLog) {
6636
+ const observation = {
6637
+ text: { format: fixturePeerListFormat(list), peerPresent: list.includes(peer),
6638
+ tmuxPresent: list.includes('tmux'), panePresent: list.includes(pane) },
6639
+ providerEntered: fs.existsSync(providerLog), persisted: { state: 'unavailable' },
6640
+ pane: { state: 'unavailable' }, events: []
6641
+ };
6642
+ let db;
6643
+ try {
6644
+ db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 250 });
6645
+ const savedPeer = db.prepare('SELECT status, pid FROM peers WHERE id = ?').get(peer);
6646
+ const binding = db.prepare('SELECT transport, runtime_target, runtime_session_id FROM peer_bindings WHERE peer = ?').get(peer);
6647
+ observation.persisted = { state: 'available', peerPresent: Boolean(savedPeer), bindingPresent: Boolean(binding),
6648
+ transport: binding?.transport, targetMatches: binding?.runtime_target === pane,
6649
+ sessionMatches: binding?.runtime_session_id === peer, peerStatus: savedPeer?.status,
6650
+ processState: Number.isSafeInteger(savedPeer?.pid) && savedPeer.pid > 0
6651
+ ? inspectProcessIdentity(savedPeer.pid).state : 'unknown' };
6652
+ observation.events = db.prepare(`SELECT type FROM events
6653
+ WHERE type IN ('peer.start.requested', 'tmux.session.attached', 'tmux.session.detached',
6654
+ 'tmux.session.exited', 'tmux.session.gc', 'tmux.session.rebind_cleanup_pending')
6655
+ AND (actor = ? OR (json_valid(payload) AND json_extract(payload, '$.target_peer') = ?))
6656
+ ORDER BY id DESC LIMIT 8`).all(peer, peer).map(event => event.type);
6657
+ } catch { /* A failed inspection must not replace the original assertion. */ }
6658
+ finally { try { db?.close(); } catch {} }
6659
+ const result = spawnSync('tmux', ['display-message', '-p', '-t', pane,
6660
+ '#{pane_id}|#{pane_dead}|#{pane_pid}|#{pane_current_command}'], {
6661
+ env, encoding: 'utf8', timeout: 1000, maxBuffer: 1024, stdio: ['ignore', 'pipe', 'ignore']
6662
+ });
6663
+ const parts = String(result.stdout || '').trim().split('|');
6664
+ if (result.status === 0 && parts[0] === pane && ['0', '1'].includes(parts[1])) {
6665
+ const pid = /^\d+$/.test(parts[2] || '') ? Number(parts[2]) : null;
6666
+ observation.pane = { state: 'found', dead: parts[1] === '1', command: parts[3],
6667
+ processState: Number.isSafeInteger(pid) && pid > 0 ? inspectProcessIdentity(pid).state : 'unknown' };
6668
+ } else if (result.status === 1) observation.pane = { state: 'not-found' };
6669
+ return observation;
6670
+ }
6671
+
6574
6672
  async function shimTmuxWorkflow() {
6575
6673
  if (!tmuxAvailable()) {
6576
6674
  log('[6/13] shim tmux-backed launch skipped (tmux not installed)');
@@ -6601,10 +6699,12 @@ async function shimTmuxWorkflow() {
6601
6699
  fail(`codex shim did not pass through exec:\n${codexExec}`);
6602
6700
  }
6603
6701
 
6702
+ const shimProviderLog = path.join(outDir, 'shim-initial-provider');
6604
6703
  const shimEnv = {
6605
6704
  ...env,
6606
6705
  HCC_SHIM_NO_ATTACH: '1',
6607
6706
  HCC_FAKE_STAY_ALIVE: '1',
6707
+ HCC_FAKE_LOG: shimProviderLog,
6608
6708
  HCC_NO_AUTO_INSTALL_TMUX: '1',
6609
6709
  HCC_REG_VALUE: 'shim-first'
6610
6710
  };
@@ -6615,7 +6715,8 @@ async function shimTmuxWorkflow() {
6615
6715
  const pane = parsePane(output);
6616
6716
  const list = hcc(['peer', 'list']);
6617
6717
  if (!list.includes(peer) || !list.includes('tmux') || !list.includes(pane)) {
6618
- fail(`shim peer missing from list:\n${list}`);
6718
+ fail(`shim peer missing from list:\n${list}`,
6719
+ collectShimRegistrationObservation(peer, pane, list, shimProviderLog), 'shim-registration');
6619
6720
  }
6620
6721
  const hookPreservePayload = JSON.stringify({
6621
6722
  session_id: 'hook-preserve-session',
@@ -7801,8 +7902,13 @@ async function syntaxAndHelp() {
7801
7902
  HOME: scanHome
7802
7903
  };
7803
7904
  const scanOutput = run(process.execPath, [hccBin, '--root', scanRealRoot, 'scan'], { env: scanEnv });
7804
- if (!scanOutput.includes('claude') || !scanOutput.includes('scan-realpath')) {
7805
- fail(`scan did not match discovered hccRoot through realpath comparison:\n${scanOutput}`);
7905
+ if (scanOutput.includes('scan-realpath-session') || scanOutput.includes('scan-realpath')) {
7906
+ fail(`scan attributed path-only Claude history to the selected project:\n${scanOutput}`);
7907
+ }
7908
+ run(process.execPath, [hccBin, '--root', scanRealRoot, 'scan', '--register'], { env: scanEnv });
7909
+ const registered = run(process.execPath, [hccBin, '--root', scanRealRoot, 'peer', 'list'], { env: scanEnv });
7910
+ if (registered.includes('scan-realpath')) {
7911
+ fail(`scan registered path-only Claude history as a live peer:\n${registered}`);
7806
7912
  }
7807
7913
  } finally {
7808
7914
  try { fs.rmSync(scanHome, { recursive: true, force: true }); } catch {}
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const source = fs.readFileSync(path.join(root, 'native', 'cwd-handoff', 'hcc-cwd-handoff.c'));
9
+ const sourceHash = createHash('sha256').update(source).digest('hex');
10
+ const buildIdentity = Buffer.from(`HCC_SOURCE_SHA256:${sourceHash}`);
11
+ const targets = new Map([
12
+ ['darwin-arm64', 0x0100000c],
13
+ ['darwin-x64', 0x01000007],
14
+ ['linux-arm64', 183],
15
+ ['linux-x64', 62]
16
+ ]);
17
+
18
+ function verifyBinary(target, machine) {
19
+ const filename = path.join(root, 'native', 'bin', target, 'hcc-cwd-handoff');
20
+ const stat = fs.lstatSync(filename);
21
+ if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) {
22
+ throw new Error(`${target}: helper must be an executable, non-writable regular file`);
23
+ }
24
+ const binary = fs.readFileSync(filename);
25
+ if (binary.indexOf(buildIdentity) < 0) {
26
+ throw new Error(`${target}: binary is stale relative to its packaged C source`);
27
+ }
28
+ if (target.startsWith('darwin-')) {
29
+ if (binary.length < 8 || binary.readUInt32LE(0) !== 0xfeedfacf ||
30
+ binary.readUInt32LE(4) !== machine) {
31
+ throw new Error(`${target}: unexpected Mach-O architecture`);
32
+ }
33
+ } else {
34
+ if (binary.length < 64 || binary.subarray(0, 4).toString('hex') !== '7f454c46' ||
35
+ binary[4] !== 2 || binary[5] !== 1 || binary.readUInt16LE(18) !== machine) {
36
+ throw new Error(`${target}: unexpected ELF architecture`);
37
+ }
38
+ const programHeaderOffset = Number(binary.readBigUInt64LE(32));
39
+ const programHeaderSize = binary.readUInt16LE(54);
40
+ const programHeaderCount = binary.readUInt16LE(56);
41
+ if (programHeaderSize < 56 || programHeaderCount === 0 ||
42
+ programHeaderOffset + programHeaderSize * programHeaderCount > binary.length) {
43
+ throw new Error(`${target}: invalid ELF program headers`);
44
+ }
45
+ for (let i = 0; i < programHeaderCount; i++) {
46
+ const type = binary.readUInt32LE(programHeaderOffset + programHeaderSize * i);
47
+ if (type === 2 || type === 3) {
48
+ throw new Error(`${target}: Linux helper must not need a dynamic loader`);
49
+ }
50
+ }
51
+ }
52
+ console.log(`Verified ${path.relative(root, filename)}`);
53
+ }
54
+
55
+ try {
56
+ for (const [target, machine] of targets) verifyBinary(target, machine);
57
+ } catch (error) {
58
+ console.error(`Native cwd handoff package incomplete: ${error.message}`);
59
+ process.exitCode = 1;
60
+ }
@@ -73,6 +73,7 @@ const evidence = {
73
73
  schemaVersion: 1, dir, packageRoot: repo, packageVersion: JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')).version,
74
74
  node: process.version, platform: process.platform, checks: [], screenshots: [], errors: [], console: [], expectedErrors: [], cleanup: {},
75
75
  sourceFiles: hashes(), browser: 'Playwright 1.62.1; isolated headless context; repeatable CI runner',
76
+ browserFallbackReason: 'Browser plugin not available',
76
77
  mode: 'Simulated native adapters; actual HTTP, SQLite, native service and browser; no models or credentials',
77
78
  startedAt: new Date().toISOString(), success: false
78
79
  };
@@ -168,6 +169,82 @@ async function checkSharedSplit(page) {
168
169
  check('real split panes share one project GET while keeping response copies, drafts and action tokens independent');
169
170
  } finally { page.off('request', track); }
170
171
  }
172
+ async function userQuestionChecks(page) {
173
+ await select(page, 'qa-claude');
174
+ // Selecting a session opens a viewer; an interactive answer needs an
175
+ // explicit lease claim through the same visible control as ordinary users.
176
+ if (!await page.evaluate(() => window.hccHandoff.canControl)) {
177
+ assert.equal(await page.locator('#nativeSend').isDisabled(), true);
178
+ await page.locator('#claimControlBtn').click();
179
+ }
180
+ await page.waitForFunction(() => window.hccHandoff.active === 'qa-claude' && window.hccHandoff.canControl);
181
+ const adapter = adapters.get('claude');
182
+ const answer = 'FORM_FIXTURE_ANSWER_DO_NOT_PERSIST';
183
+ const request = { kind: 'userInput', method: 'item/tool/requestUserInput', requestId: 'qa-user-question',
184
+ executorId: adapter.state.executorId, sessionId: adapter.state.sessionId, turnId: 'qa-question-turn',
185
+ params: { questions: [{ id: 'fixture-answer', header: 'Acceptance question',
186
+ question: 'Enter the test-owned answer, then explicitly send it.', isSecret: true }] } };
187
+ adapter.state.capabilities.userInput = true;
188
+ adapter.state.pendingApprovals = [request];
189
+ const responses = adapter.responses?.length || 0;
190
+ await page.locator('#nativeRead').click();
191
+ const form = page.locator('#nativeApprovals form.hcc-interaction-form');
192
+ await form.waitFor({ state: 'visible' });
193
+ const showQuestion = async () => {
194
+ // Use the product's response jump so the question and its field are both
195
+ // visible without disturbing a user's retained conversation position.
196
+ await page.locator('#nativeApprovalJump').click();
197
+ const label = form.locator('label'), paragraph = form.locator('p');
198
+ // At 390px the retained conversation area is short. The jump focuses the
199
+ // answer control; scroll its full label into the reading area as a user can.
200
+ await label.scrollIntoViewIfNeeded();
201
+ assert.ok((await label.innerText()).includes('Acceptance question'));
202
+ assert.equal(await paragraph.innerText(), 'Enter the test-owned answer, then explicitly send it.');
203
+ const [labelBox, questionBox, scrollBox] = await Promise.all([
204
+ label.boundingBox(), paragraph.boundingBox(), page.locator('#nativeScroll').boundingBox()
205
+ ]);
206
+ assert.ok(labelBox && questionBox && scrollBox);
207
+ assert.ok(labelBox.y >= scrollBox.y && questionBox.y + questionBox.height <= scrollBox.y + scrollBox.height,
208
+ 'The question header and text must remain visible with its answer field');
209
+ };
210
+ await showQuestion();
211
+ const field = form.locator('[data-answer="0"]');
212
+ assert.ok(await field.getAttribute('name'));
213
+ assert.equal(await form.getAttribute('autocomplete'), 'off');
214
+ await field.fill(answer);
215
+ await page.evaluate(() => {
216
+ window.__qaQuestionSubmits = [];
217
+ document.addEventListener('submit', event => {
218
+ if (event.target.matches('form.hcc-interaction-form')) window.__qaQuestionSubmits.push(event.defaultPrevented);
219
+ });
220
+ });
221
+ const initialUrl = page.url();
222
+ await field.press('Enter');
223
+ await page.waitForFunction(() => window.__qaQuestionSubmits.length > 0);
224
+ assert.deepEqual(await page.evaluate(() => window.__qaQuestionSubmits), [true]);
225
+ assert.equal(page.url(), initialUrl);
226
+ assert.equal(adapter.responses?.length || 0, responses, 'Enter must not answer an executor request');
227
+ await showQuestion();
228
+ await shot(page, 'desktop-user-question-form');
229
+ await page.setViewportSize({ width: 390, height: 844 });
230
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
231
+ await showQuestion();
232
+ await shot(page, 'mobile-user-question-form');
233
+ await page.setViewportSize({ width: 1440, height: 1000 });
234
+ assert.equal(await page.evaluate(() => window.hccHandoff.active), 'qa-claude');
235
+ assert.equal(await page.evaluate(() => window.hccHandoff.canControl), true);
236
+ const submit = page.locator('#nativeApprovals [data-decision="accept"]');
237
+ assert.equal(await submit.isDisabled(), false, 'Only the current controller can answer this request');
238
+ await submit.click();
239
+ await form.waitFor({ state: 'detached' });
240
+ assert.equal(adapter.responses.length, responses + 1);
241
+ assert.deepEqual(adapter.responses.at(-1).answers, { 'fixture-answer': { answers: [answer] } });
242
+ assert.equal(await page.evaluate(answer =>
243
+ [...Object.values(localStorage), ...Object.values(sessionStorage)].some(value => value.includes(answer)), answer), false);
244
+ check('user-question forms retain named fields, suppress Enter navigation, and send only after an explicit answer without browser storage');
245
+ await select(page, 'qa-codex');
246
+ }
247
+
171
248
  async function extendedChecks(page, context) {
172
249
  await page.setViewportSize({ width: 1440, height: 1000 });
173
250
  await select(page, 'qa-codex');
@@ -187,6 +264,7 @@ async function extendedChecks(page, context) {
187
264
  await page.waitForFunction(() => window.hccHandoff.canControl);
188
265
  check('actual WebSocket reconnect restores missing output without replaying unsent input');
189
266
  await automaticReconnectChecks(page);
267
+ await userQuestionChecks(page);
190
268
 
191
269
  const observerContext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
192
270
  await observerContext.addInitScript(browserInstrumentation);
@@ -355,7 +433,7 @@ try{
355
433
  service=await startNativeService(ctx,deps,{pollMs:1000000,afterUserSubmissionMeshCommit:retryFault.afterMeshCommit,adapterFactory:async(provider,options)=>{
356
434
  const peer=options.env.HCC_PEER;
357
435
  const state={provider,status:'idle',sessionId:peer,turnId:null,executorId:options.executorId,capabilities:{send:true,interrupt:true,close:true,resume:true}};
358
- const adapter={opens:[],snapshot:()=>structuredClone(state),capabilities:state.capabilities,async open(input={}){this.opens.push(structuredClone(input));if(input.sessionId)state.sessionId=input.sessionId;return this.snapshot();},async send(input){this.sent=(this.sent||0)+1;state.status='running';state.turnId='qa-turn';this.active=input;return{status:'queued',turnId:state.turnId};},async interrupt(){state.status='idle';state.turnId=null;},async close(){state.status='closed';},emit(value){options.onEvent({provider,sessionId:state.sessionId,turnId:'qa-turn',...value});},state};
436
+ const adapter={opens:[],snapshot:()=>structuredClone(state),capabilities:state.capabilities,async open(input={}){this.opens.push(structuredClone(input));if(input.sessionId)state.sessionId=input.sessionId;return this.snapshot();},async send(input){this.sent=(this.sent||0)+1;state.status='running';state.turnId='qa-turn';this.active=input;return{status:'queued',turnId:state.turnId};},async interrupt(){state.status='idle';state.turnId=null;},async respond(input){this.responses ||= [];this.responses.push(structuredClone(input));state.pendingApprovals=[];return {status:'submitted',requestId:input.requestId};},async close(){state.status='closed';},emit(value){options.onEvent({provider,sessionId:state.sessionId,turnId:'qa-turn',...value});},state};
359
437
  adapters.set(provider,adapter);adaptersByPeer.set(peer,adapter);return adapter;
360
438
  }});
361
439
  for(const provider of ['codex','claude','dsh'])await api('POST','/workers',{peer:'qa-'+provider,provider});
@@ -49,7 +49,7 @@ function hashes() {
49
49
  if (fs.statSync(filename).isDirectory()) for (const name of fs.readdirSync(filename).sort()) walk(path.join(relative, name));
50
50
  else result[relative] = sha256(filename);
51
51
  };
52
- for (const name of ['bin', 'lib', 'scripts', 'package.json', 'package-lock.json']) walk(name);
52
+ for (const name of ['bin', 'lib', 'native', 'scripts', 'package.json', 'package-lock.json']) walk(name);
53
53
  return result;
54
54
  }
55
55
  const receipt = {
@@ -78,6 +78,16 @@ try {
78
78
  run(install.command, install.parameters, 'install');
79
79
  const installed = path.join(prefix, 'node_modules', '@logicseek', 'hello-cc');
80
80
  assert.equal(JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8')).version, metadata.version);
81
+ receipt.nativeHelpers = [];
82
+ for (const target of ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']) {
83
+ const relative = `native/bin/${target}/hcc-cwd-handoff`;
84
+ const entry = metadata.files.find(file => file.path === relative);
85
+ assert.ok(entry && (entry.mode & 0o111) && !(entry.mode & 0o022), 'Safe packaged helper missing: ' + target);
86
+ const installedHelper = path.join(installed, relative);
87
+ assert.equal(sha256(installedHelper), sha256(path.join(repo, relative)), 'Installed helper differs from source: ' + target);
88
+ receipt.nativeHelpers.push({ target, sha256: sha256(installedHelper), mode: entry.mode });
89
+ }
90
+ receipt.checks.push('four executable native helpers match their packed and installed source bytes');
81
91
  const publicCli = path.join(prefix, 'node_modules', '.bin', 'hcc');
82
92
  assert.equal(fs.realpathSync(publicCli), path.join(installed, 'bin', 'hcc.mjs'));
83
93
  const help = run(publicCli, ['--help'], 'public-cli', { cwd: prefix });