@logicseek/hello-cc 1.0.5 → 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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -17,8 +17,10 @@ export function terminalFindHtml() {
17
17
  </section>`;
18
18
  }
19
19
 
20
- export function terminalFindScript() {
21
- return `(() => {
20
+ export function terminalFindScript() { return '(' + installTerminalFind.toString() + ')();'; }
21
+
22
+ export function installTerminalFind() {
23
+ (() => {
22
24
  const host = window.hccTerminalFindHost;
23
25
  const bar = document.getElementById('terminalFind');
24
26
  const query = document.getElementById('terminalFindQuery');
@@ -94,5 +96,5 @@ export function terminalFindScript() {
94
96
  window.addEventListener('hcc:preferences', () => { if (!bar.hidden) { renderResult(); if (query.value) refreshMatches(); } });
95
97
  window.hccTerminalFind = {open, close, sync, capturesEscape:event => !bar.hidden && event.key === 'Escape' && (bar.contains(event.target) || host.term.element.contains(event.target))};
96
98
  sync();
97
- })();`;
99
+ })();
98
100
  }
@@ -29,8 +29,10 @@ export function workbenchControlsHtml() {
29
29
  }
30
30
 
31
31
  // Display state only: the caller owns requests, project scope and retry policy.
32
- export function workbenchScript() {
33
- return `(() => {
32
+ export function workbenchScript() { return '(' + installWorkbench.toString() + ')();'; }
33
+
34
+ export function installWorkbench() {
35
+ (() => {
34
36
  const tabs = document.getElementById('stateTabs');
35
37
  const state = document.getElementById('state');
36
38
  const notice = document.getElementById('stateFreshness');
@@ -90,5 +92,5 @@ export function workbenchScript() {
90
92
  };
91
93
  window.addEventListener('hcc:preferences', apply);
92
94
  apply();
93
- })();`;
95
+ })();
94
96
  }
@@ -133,6 +133,7 @@ function workspaceRuntime(normalize, validMessage) {
133
133
  destroy(); mounted = identity; status.hidden = false; status.textContent = tr('workspace.connecting');
134
134
  frame = document.createElement('iframe'); frame.id = 'workspaceFrame'; frame.title = tr('workspace.secondary');
135
135
  const url = new URL('/pane', location.origin); url.searchParams.set('project',root); url.searchParams.set('session',session.id);
136
+ if (host.projectIdentity?.()) url.searchParams.set('root_identity', host.projectIdentity());
136
137
  frame.src = url.href; secondary.appendChild(frame);
137
138
  timer = setTimeout(() => { status.hidden = false; status.textContent = tr('workspace.failed'); byId('workspaceRetry').hidden = false; }, 12000);
138
139
  }
@@ -220,3 +221,5 @@ function workspaceRuntime(normalize, validMessage) {
220
221
  export function workspaceScript() {
221
222
  return '(' + workspaceRuntime.toString() + ')(' + normalizeWorkspaceLayout.toString() + ',' + workspaceMessageValid.toString() + ');';
222
223
  }
224
+
225
+ export function installWorkspace() { workspaceRuntime(normalizeWorkspaceLayout, workspaceMessageValid); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.0.5",
3
+ "version": "1.1.0-rc.3",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,10 @@
32
32
  "test": "npm run test:regression",
33
33
  "test:audit": "node ./scripts/check-free-identifiers.mjs",
34
34
  "test:unit": "node --test test/*.test.mjs",
35
- "test:regression": "node ./scripts/regression.mjs"
35
+ "test:regression": "node ./scripts/regression.mjs",
36
+ "test:web": "node ./scripts/web-workbench-acceptance.mjs",
37
+ "test:web:installed": "node ./scripts/web-workbench-installed-acceptance.mjs",
38
+ "codex:protocol": "node ./scripts/codex-protocol.mjs"
36
39
  },
37
40
  "repository": {
38
41
  "type": "git",
@@ -67,5 +70,8 @@
67
70
  "bundle": {
68
71
  "patch": "./lib/integrations/dsh.bundle.yml"
69
72
  }
73
+ },
74
+ "devDependencies": {
75
+ "playwright": "1.62.1"
70
76
  }
71
77
  }
@@ -0,0 +1,53 @@
1
+ // Fixed-input transport benchmark. No processes, providers, model calls or user sessions.
2
+ import assert from 'node:assert/strict';
3
+ import { performance } from 'node:perf_hooks';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { createSessionStateStream } from '../lib/web/session-state-sync.mjs';
6
+ import { createSessionSync } from '../lib/web/browser/session-sync.mjs';
7
+
8
+ export function benchmarkSessionSync({ fragments = 1000, batchSize = 5 } = {}) {
9
+ function fixture() {
10
+ const turns = Array.from({ length: 9 }, (_, turn) => ({ id: 'history-' + turn, status: 'completed', items:
11
+ Array.from({ length: 30 }, (_, item) => ({ id: 'item-' + item, type: 'agentMessage', text: 'Fixed retained history '.repeat(48) })) }));
12
+ turns.push({ id: 'active', status: 'inProgress', items: [{ id: 'message', type: 'agentMessage', text: '' }] });
13
+ return { executorId: 'benchmark-executor', status: 'ready', threads: [{ id: 'thread', turns }], updateSequence: 0, events: [], truncated: false, pendingApprovals: [] };
14
+ }
15
+ function run(incremental) {
16
+ const state = fixture(), samples = [], cpuStart = process.cpuUsage(), start = performance.now();
17
+ const stream = incremental ? createSessionStateStream({ root: '/benchmark', sessionId: 'session', channel: 'codex', state, generation: 'benchmark' }) : null;
18
+ const receiver = createSessionSync({ root: '/benchmark', sessionId: 'session', requestSnapshot() { throw new Error('Unexpected recovery'); } });
19
+ let bytes = 0, frames = 0, received;
20
+ function deliver(frame) {
21
+ const encoded = JSON.stringify(frame); bytes += Buffer.byteLength(encoded); frames++;
22
+ const decoded = JSON.parse(encoded);
23
+ if (incremental) { assert.equal(receiver.receive(decoded).accepted, true); received = receiver.state; }
24
+ else received = decoded.state;
25
+ }
26
+ deliver(incremental ? stream.snapshot() : { type: 'codex_state', state: structuredClone(state) });
27
+ for (let index = 0; index < fragments; index++) {
28
+ const text = 'token-' + String(index).padStart(4, '0') + '|';
29
+ state.threads[0].turns[9].items[0].text += text;
30
+ state.events.push({ method: 'item/agentMessage/delta', params: { threadId: 'thread', turnId: 'active', itemId: 'message', delta: text }, at: index });
31
+ if (state.events.length > 100) state.events.shift();
32
+ state.updateSequence++;
33
+ if ((index + 1) % batchSize === 0 || index === fragments - 1) {
34
+ const tick = performance.now();
35
+ deliver(incremental ? stream.update(state, { textOnly: true, textItems: [{ threadId: 'thread', turnId: 'active', itemId: 'message' }] })
36
+ : { type: 'codex_state', state: structuredClone(state) });
37
+ samples.push(performance.now() - tick);
38
+ }
39
+ }
40
+ assert.deepEqual(received, state);
41
+ const cpu = process.cpuUsage(cpuStart), elapsedMs = performance.now() - start;
42
+ samples.sort((a, b) => a - b);
43
+ return { frames, bytes, elapsedMs: +elapsedMs.toFixed(3), cpuMs: +((cpu.user + cpu.system) / 1000).toFixed(3),
44
+ p95DeliveryMs: +samples[Math.floor(samples.length * .95)].toFixed(3), initialStateBytes: Buffer.byteLength(JSON.stringify(fixture())),
45
+ finalStateBytes: Buffer.byteLength(JSON.stringify(state)), finalOutputCharacters: state.threads[0].turns[9].items[0].text.length };
46
+ }
47
+ const legacy = run(false), incremental = run(true);
48
+ return { workload: { fragments, batchSize, sameInput: true, coldSnapshotIncluded: true, observers: 1 }, legacy, incremental,
49
+ byteReductionPercent: +((1 - incremental.bytes / legacy.bytes) * 100).toFixed(2),
50
+ limits: 'Synchronization layer only: server copy/diff/encode plus receiver parse/apply. Excludes provider parsing, SQLite, socket scheduling and browser DOM. Timing is machine-dependent; not production capacity.' };
51
+ }
52
+
53
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(benchmarkSessionSync(), null, 2));
@@ -0,0 +1,88 @@
1
+ // Generate the protocol from the selected installed Codex, without starting an
2
+ // App Server, model turn, or login. Existing output directories are never used.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import { CODEX_PROTOCOL_SCHEMA_FILES, CODEX_PROTOCOL_SCHEMA_PATHS, inspectCodexProtocol } from '../lib/diagnostics/codex-protocol.mjs';
9
+
10
+ function argumentsFor(argv) {
11
+ const options = { binary: 'codex', out: null };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ if (argv[i] === '--help' || argv[i] === '-h') return { help: true };
14
+ if (!['--codex-bin', '--out'].includes(argv[i]) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('Use --codex-bin PATH and/or --out NEW_DIRECTORY');
15
+ const key = argv[i] === '--codex-bin' ? 'binary' : 'out';
16
+ options[key] = argv[++i];
17
+ }
18
+ if (options.binary.includes(path.sep) || options.binary.includes('/')) options.binary = path.resolve(options.binary);
19
+ if (options.out) options.out = path.resolve(options.out);
20
+ return options;
21
+ }
22
+ function inventory(directory, parent = directory) {
23
+ return fs.readdirSync(parent, { withFileTypes: true }).flatMap(entry => {
24
+ const file = path.join(parent, entry.name);
25
+ if (entry.isDirectory()) return inventory(directory, file);
26
+ if (!entry.isFile()) throw new Error('Generated protocol contains a non-regular file');
27
+ return [{ path: path.relative(directory, file).split(path.sep).join('/'),
28
+ sha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex') }];
29
+ }).sort((a, b) => a.path.localeCompare(b.path, 'en'));
30
+ }
31
+
32
+ let workspace;
33
+ try {
34
+ const options = argumentsFor(process.argv.slice(2));
35
+ if (options.help) {
36
+ console.log('node scripts/codex-protocol.mjs [--codex-bin PATH] [--out NEW_DIRECTORY]\nGenerates official TypeScript and JSON Schema with --experimental, checks the HCC subset, and records version and file hashes. No model or account access. Output must not already exist.');
37
+ } else {
38
+ workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-codex-protocol-'));
39
+ fs.chmodSync(workspace, 0o700);
40
+ const home = path.join(workspace, 'home'), codexHome = path.join(workspace, 'codex-home');
41
+ fs.mkdirSync(home, { mode: 0o700 }); fs.mkdirSync(codexHome, { mode: 0o700 });
42
+ const output = options.out || path.join(workspace, 'protocol');
43
+ fs.mkdirSync(output, { mode: 0o700 });
44
+ const env = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, CODEX_HOME: codexHome,
45
+ APPDATA: home, LOCALAPPDATA: home, TMPDIR: workspace, TEMP: workspace, TMP: workspace,
46
+ HCC_SHIM_NO_ATTACH: '1', HCC_SHIM_ENSURED: '1', NO_COLOR: '1' };
47
+ const run = args => {
48
+ const result = spawnSync(options.binary, args, { cwd: workspace, env, encoding: 'utf8',
49
+ timeout: 30000, maxBuffer: 256 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
50
+ // Arbitrary executable output must not appear in a failure receipt.
51
+ if (result.error || result.status !== 0) throw new Error(`Codex protocol command failed: ${args.slice(0, 2).join(' ')}`);
52
+ return result.stdout.trim();
53
+ };
54
+ const versionOutput = run(['--version']);
55
+ const version = /^codex-cli (\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)$/.exec(versionOutput)?.[1];
56
+ if (!version) throw new Error('Codex returned an unrecognized version');
57
+ const commands = [['app-server', 'generate-json-schema', '--experimental', '--out', path.join(output, 'json-schema')],
58
+ ['app-server', 'generate-ts', '--experimental', '--out', path.join(output, 'typescript')]];
59
+ for (const command of commands) run(command);
60
+ if (run(['--version']) !== versionOutput) throw new Error('Codex version changed during generation');
61
+ let schemas;
62
+ try {
63
+ schemas = Object.fromEntries(CODEX_PROTOCOL_SCHEMA_FILES.map(name => [name,
64
+ JSON.parse(fs.readFileSync(path.join(output, 'json-schema', CODEX_PROTOCOL_SCHEMA_PATHS[name]), 'utf8'))]));
65
+ } catch { throw new Error('Official JSON Schema output is missing or invalid'); }
66
+ const contract = inspectCodexProtocol(schemas);
67
+ const files = inventory(output);
68
+ if (!files.some(file => file.path === 'typescript/ClientRequest.ts') || !files.some(file => file.path === 'typescript/ServerRequest.ts')) throw new Error('Official TypeScript output is incomplete');
69
+ const manifest = { schemaVersion: 1, codexVersion: version, experimental: true,
70
+ commands: commands.map(command => [...command.slice(0, -1), path.relative(output, command.at(-1))]),
71
+ contract, files, bundleSha256: createHash('sha256').update(JSON.stringify(files)).digest('hex') };
72
+ fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
73
+ if (!options.out) workspace = null; // Retain generated output; remove only disposable homes.
74
+ fs.rmSync(home, { recursive: true, force: true }); fs.rmSync(codexHome, { recursive: true, force: true });
75
+ console.log(JSON.stringify({ outputDirectory: output, codexVersion: version, status: contract.status,
76
+ checks: contract.checks.length, failedChecks: contract.checks.filter(check => check.status === 'fail'),
77
+ unsupportedServerRequests: contract.unsupportedServerRequests, fileCount: files.length,
78
+ bundleSha256: manifest.bundleSha256, inferenceCalled: false, accountAccessed: false }));
79
+ if (contract.status !== 'compatible') process.exitCode = 1;
80
+ }
81
+ } catch (error) {
82
+ console.error(JSON.stringify({ ok: false, code: 'CODEX_PROTOCOL_GENERATION_FAILED',
83
+ message: error.code === 'EEXIST' ? 'Output directory already exists; choose a new directory' :
84
+ error.code === 'ENOENT' ? 'Codex executable or generated protocol file is missing' : error.message }));
85
+ process.exitCode = 1;
86
+ } finally {
87
+ if (workspace) fs.rmSync(workspace, { recursive: true, force: true });
88
+ }
@@ -0,0 +1,172 @@
1
+ // Deterministic local Messages responses drive the real official Harness and
2
+ // the production ACP adapter. No real model, credential or global profile.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { createDshAcpAdapter } from '../lib/integrations/native/dsh-acp.mjs';
13
+ import { createDshEnvironment, resolveDshBinary } from '../lib/integrations/dsh.mjs';
14
+ import { isKnownDshEscalationApproval } from './web-native-interaction-diagnostics.mjs';
15
+
16
+ const args = process.argv.slice(2);
17
+ if (args.includes('--help')) {
18
+ console.log('Usage: node scripts/dsh-approval-layers-acceptance.mjs [--dsh-bin PATH] [--output FILE]');
19
+ process.exit(0);
20
+ }
21
+ const option = (name, fallback) => args.includes(name) ? args[args.indexOf(name) + 1] : fallback;
22
+ const requestedBinary = option('--dsh-bin');
23
+ let binary;
24
+ if (requestedBinary && /\.(?:mjs|cjs|js)$/.test(requestedBinary)) {
25
+ binary = fs.realpathSync(path.resolve(requestedBinary));
26
+ assert.ok(fs.statSync(binary).isFile(), 'Explicit JavaScript launcher must be a regular file');
27
+ fs.accessSync(binary, fs.constants.R_OK);
28
+ } else binary = resolveDshBinary({ dshBin: requestedBinary });
29
+ assert.ok(binary, 'An existing official DSH launcher is required');
30
+ const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-dsh-approval-layers-')));
31
+ fs.chmodSync(sandbox, 0o700);
32
+ // npm's bin.js may be a non-executable source file. Use this run's Node 24
33
+ // explicitly without changing installed file modes or the product transport.
34
+ const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
35
+ let launcher = binary;
36
+ if (/\.(?:mjs|cjs|js)$/.test(binary)) {
37
+ launcher = path.join(sandbox, 'official-dsh');
38
+ fs.writeFileSync(launcher, '#!/bin/sh\nexec ' + quote(process.execPath) + ' ' + quote(binary) + ' "$@"\n', { mode: 0o700 });
39
+ }
40
+ const output = path.resolve(option('--output', path.join(sandbox, 'evidence.json')));
41
+ const repo = fileURLToPath(new URL('..', import.meta.url));
42
+ const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
43
+ const sourceFiles = Object.fromEntries(['scripts/dsh-approval-layers-acceptance.mjs', 'lib/integrations/native/dsh-acp.mjs',
44
+ 'lib/integrations/native/jsonrpc.mjs', 'lib/integrations/native/interactions.mjs',
45
+ 'scripts/web-native-interaction-diagnostics.mjs'].map(name => [name, hash(path.join(repo, name))]));
46
+ const receipt = { schemaVersion: 1, startedAt: new Date().toISOString(), sourceFiles,
47
+ mode: 'Deterministic local Messages server; actual official Harness and production ACP adapter',
48
+ realModelCalls: false, scenarios: [], completed: false, cleanup: false };
49
+ const scenarios = [
50
+ { name: 'policy-only-allow', forcedAsk: true, escalation: false, decisions: ['accept'], written: true },
51
+ { name: 'policy-and-escalation-allow', forcedAsk: true, escalation: true, decisions: ['accept', 'accept'], written: true },
52
+ { name: 'policy-allow-escalation-decline', forcedAsk: true, escalation: true, decisions: ['accept', 'decline'], written: false },
53
+ { name: 'policy-decline', forcedAsk: true, escalation: true, decisions: ['decline'], written: false },
54
+ { name: 'escalation-only-allow', forcedAsk: false, escalation: true, decisions: ['accept'], written: true },
55
+ ];
56
+
57
+ function stream(res, model, block) {
58
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
59
+ const event = (type, value) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`);
60
+ event('message_start', { message: { id: 'msg-fixture', type: 'message', role: 'assistant', model,
61
+ content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
62
+ event('content_block_start', { index: 0, content_block: block.type === 'tool_use' ? { ...block, input: {} } : block });
63
+ if (block.type === 'tool_use') event('content_block_delta', { index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
64
+ event('content_block_stop', { index: 0 });
65
+ event('message_delta', { delta: { stop_reason: block.type === 'tool_use' ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
66
+ event('message_stop', {}); res.end();
67
+ }
68
+
69
+ try {
70
+ for (const scenario of scenarios) {
71
+ const directory = path.join(sandbox, scenario.name), root = path.join(directory, 'project'), home = path.join(directory, 'home');
72
+ const target = path.join(directory, 'target.txt'), marker = 'DETERMINISTIC_APPROVAL_OK', callId = 'call-' + scenario.name;
73
+ for (const folder of [directory, root, home]) fs.mkdirSync(folder, { mode: 0o700 });
74
+ let requests = 0, completed, adapter, server;
75
+ const observed = { name: scenario.name, requests: [], modelRequests: 0, realModelCalls: false, completed: false };
76
+ receipt.scenarios.push(observed);
77
+ try {
78
+ server = http.createServer(async (request, response) => {
79
+ try {
80
+ assert.equal(request.method, 'POST');
81
+ const chunks = []; let length = 0;
82
+ for await (const chunk of request) { length += chunk.length; assert.ok(length < 2 * 1024 * 1024); chunks.push(chunk); }
83
+ const body = JSON.parse(Buffer.concat(chunks).toString());
84
+ requests++;
85
+ assert.ok(requests <= 2, 'The fixture never emits another tool call or retries');
86
+ if (requests === 1) {
87
+ assert.ok(body.tools.some(tool => tool.name === 'write'));
88
+ stream(response, body.model, { type: 'tool_use', id: callId, name: 'write', input: {
89
+ file_path: target, content: marker,
90
+ ...(scenario.escalation ? { sandbox_permissions: 'danger-full-access', justification: 'Only this temporary acceptance target.' } : {}),
91
+ } });
92
+ } else {
93
+ observed.toolResultReceived = body.messages.some(message => Array.isArray(message.content) &&
94
+ message.content.some(block => block.type === 'tool_result' && block.tool_use_id === callId));
95
+ stream(response, body.model, { type: 'text', text: 'DETERMINISTIC_DONE' });
96
+ }
97
+ } catch (error) { response.writeHead(500); response.end(JSON.stringify({ error: { type: 'api_error', message: error.message } })); }
98
+ });
99
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
100
+ const env = createDshEnvironment({ PATH: process.env.PATH, LANG: 'C.UTF-8', SHELL: '/bin/sh' });
101
+ Object.assign(env, { HOME: home, DSH_HOME: path.join(home, 'dsh'), TMPDIR: directory, DSH_TELEMETRY_DISABLED: '1',
102
+ DEEPSEEK_API_KEY: 'isolated-local-no-credential', DEEPSEEK_BASE_URL: `http://127.0.0.1:${server.address().port}` });
103
+ const initialized = spawnSync(launcher, ['--profile', 'acp', '--dump-config'], { cwd: root, env, encoding: 'utf8', timeout: 30000 });
104
+ assert.equal(initialized.status, 0, 'Isolated profile initialization failed (config omitted)');
105
+ if (scenario.forcedAsk) {
106
+ const policy = path.join(directory, 'policy.mjs');
107
+ fs.writeFileSync(policy, `export const inject=['tools'];export function apply(ctx){ctx.on('tools/pre-execute',async(exec,next)=>exec.name==='write'&&exec.arguments.file_path===${JSON.stringify(target)}?{kind:'ask',reason:'exact test-owned write'}:next());}\n`, { mode: 0o600 });
108
+ fs.writeFileSync(path.join(env.DSH_HOME, 'profiles/acp/cordis.patch.yml'), '- insert:\n - id: deterministic-approval-policy\n name: ' + JSON.stringify(policy) + '\n', { mode: 0o600 });
109
+ }
110
+ adapter = createDshAcpAdapter({ binary: launcher, cwd: root, env, interactive: true, executorId: randomUUID(),
111
+ onEvent(event) { if (event.type === 'completed') completed = { status: event.status, stopReason: event.stopReason }; } });
112
+ const opened = await adapter.open();
113
+ const sent = await adapter.send({ text: 'Execute the one fixture tool, then finish.', submissionId: randomUUID() });
114
+ const seen = new Set(), deadline = Date.now() + 30000;
115
+ let firstRequest;
116
+ while (!completed && Date.now() < deadline) {
117
+ const snapshot = adapter.snapshot();
118
+ assert.ok(!snapshot.error, 'Unexpected ACP runtime error');
119
+ for (const pending of snapshot.pendingApprovals) {
120
+ assert.ok(!seen.has(pending.requestId), 'Answered request must leave pending state');
121
+ assert.equal(pending.method, 'session/request_permission');
122
+ assert.equal(pending.sessionId, opened.sessionId); assert.equal(pending.turnId, sent.turnId);
123
+ assert.equal(pending.params.toolCall.toolCallId, callId); assert.equal(pending.params.toolCall.title, 'write');
124
+ assert.equal(pending.params.toolCall.rawInput.file_path, target); assert.equal(pending.params.toolCall.rawInput.content, marker);
125
+ const decision = scenario.decisions[observed.requests.length];
126
+ assert.ok(decision, 'Unexpected additional approval is never answered');
127
+ if (firstRequest) {
128
+ assert.equal(isKnownDshEscalationApproval(firstRequest, pending, {
129
+ target, content: marker, priorDecision: scenario.decisions[0], additionalAnswers: observed.requests.length - 1,
130
+ }), true, 'The actual official second layer must satisfy the fixture operation guard');
131
+ } else firstRequest = structuredClone(pending);
132
+ observed.requests.push({ requestId: pending.requestId, method: pending.method, toolName: 'write',
133
+ toolCallId: callId, sameOriginalSession: true, sameOriginalTurn: true, targetExactOwned: true,
134
+ targetExistsBeforeAnswer: fs.existsSync(target), decision });
135
+ seen.add(pending.requestId);
136
+ adapter.respond({ executorId: pending.executorId, requestId: pending.requestId, sessionId: pending.sessionId, turnId: pending.turnId, decision });
137
+ }
138
+ await delay(20);
139
+ }
140
+ assert.equal(completed?.status, 'completed'); assert.equal(completed?.stopReason, 'end_turn');
141
+ assert.equal(observed.requests.length, scenario.decisions.length);
142
+ assert.equal(fs.existsSync(target), scenario.written);
143
+ if (scenario.written) assert.equal(fs.readFileSync(target, 'utf8'), marker);
144
+ assert.equal(requests, 2); assert.equal(observed.toolResultReceived, true);
145
+ observed.targetExistsAfterCompletion = fs.existsSync(target);
146
+ observed.targetSha256 = fs.existsSync(target) ? hash(target) : null;
147
+ observed.oneModelToolCall = true; observed.modelRequests = requests; observed.completed = true;
148
+ console.log(JSON.stringify({ scenario: scenario.name, permissionRequests: observed.requests.length, written: scenario.written, passed: true }));
149
+ } finally {
150
+ await adapter?.close();
151
+ if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
152
+ fs.rmSync(directory, { recursive: true, force: true });
153
+ }
154
+ }
155
+ receipt.completed = true;
156
+ } catch (error) {
157
+ receipt.error = { name: error.name, message: error.message }; process.exitCode = 1;
158
+ } finally {
159
+ receipt.cleanup = scenarios.every(scenario => !fs.existsSync(path.join(sandbox, scenario.name)));
160
+ if (launcher !== binary) {
161
+ try { fs.rmSync(launcher); } catch { receipt.cleanup = false; }
162
+ }
163
+ receipt.finishedAt = new Date().toISOString();
164
+ receipt.sourceChanges = Object.entries(sourceFiles).filter(([name, before]) => hash(path.join(repo, name)) !== before).map(([name]) => name);
165
+ if (!receipt.cleanup || receipt.sourceChanges.length) {
166
+ receipt.completed = false; process.exitCode = 1;
167
+ receipt.error ||= { name: 'AcceptanceIntegrityError', message: 'Owned cleanup failed or source changed during acceptance' };
168
+ }
169
+ fs.mkdirSync(path.dirname(output), { recursive: true });
170
+ fs.writeFileSync(output, JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 });
171
+ console.log(JSON.stringify({ output, completed: receipt.completed, cleanup: receipt.cleanup, error: receipt.error }));
172
+ }
@@ -0,0 +1,118 @@
1
+ // Freeze a checkout candidate or verify an immutable release without installing
2
+ // Harness, calling a model, changing an archive, or publishing a package.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const ARCHIVED_BASELINE = 'docs/verification/dsh-1.0.3-package.json';
11
+ const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
12
+
13
+ export function resolveAcceptanceMode({ eventName, ref, dispatchMode } = {}) {
14
+ if (ref?.startsWith('refs/tags/v')) return 'release';
15
+ if (eventName !== 'workflow_dispatch') return 'candidate';
16
+ const mode = dispatchMode || 'release';
17
+ assert.ok(['candidate', 'release'].includes(mode), 'Acceptance mode must be candidate or release');
18
+ return mode;
19
+ }
20
+
21
+ export function selectAcceptancePackage({ repo, metadata, candidateTarball, packageDir, mode,
22
+ archivedManifest = ARCHIVED_BASELINE }) {
23
+ assert.ok(['candidate', 'release'].includes(mode), 'Acceptance mode must be candidate or release');
24
+ // A development version need not have a release manifest yet. Its archived
25
+ // integrity baseline is explicit and independent of its package version.
26
+ const manifestPath = mode === 'release'
27
+ ? `docs/verification/dsh-${metadata.version}-package.json` : archivedManifest;
28
+ const frozen = JSON.parse(fs.readFileSync(path.resolve(repo, manifestPath), 'utf8'));
29
+ assert.equal(path.basename(frozen.filename), frozen.filename, 'Archive filename must be a basename');
30
+ const frozenArtifact = path.join(repo, 'docs', 'verification', frozen.filename);
31
+ assert.equal(hash(frozenArtifact), frozen.sha256, 'Stored release artifact must match its frozen digest');
32
+ let frozenContentMatch = null;
33
+ if (mode === 'release') {
34
+ assert.equal(metadata.version, frozen.version, 'Frozen manifest version must match');
35
+ assert.deepEqual(metadata.files.map(file => file.path).sort(), Object.keys(frozen.files).sort(),
36
+ 'The package file set must match the frozen release artifact');
37
+ for (const file of metadata.files) {
38
+ assert.equal(hash(path.join(packageDir, file.path)), frozen.files[file.path], `Frozen release file differs: ${file.path}`);
39
+ assert.equal(file.mode, frozen.fileModes[file.path], `Package mode differs: ${file.path}`);
40
+ }
41
+ frozenContentMatch = true;
42
+ }
43
+ return {
44
+ mode, acceptanceTarget: mode === 'release' ? 'immutable-release' : 'checkout-candidate',
45
+ tarball: mode === 'release' ? frozenArtifact : candidateTarball,
46
+ frozenContentMatch,
47
+ archiveIntegrity: { manifest: manifestPath, version: frozen.version,
48
+ filename: frozen.filename, sha256: frozen.sha256, verified: true }
49
+ };
50
+ }
51
+
52
+ export function freezeAcceptancePackage({ root, repo = fileURLToPath(new URL('..', import.meta.url)),
53
+ mode, env = process.env }) {
54
+ assert.ok(root, 'HCC_DSH_ACCEPTANCE_ROOT or --output-dir is required');
55
+ assert.ok(['candidate', 'release'].includes(mode), 'Acceptance mode must be candidate or release');
56
+ root = path.resolve(root);
57
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
58
+ // Require fresh output directories so an earlier extraction cannot supply
59
+ // stale files to this run's source comparison or acceptance script.
60
+ for (const name of ['home', 'official', 'candidate', 'candidate-unpacked', 'unpacked', 'evidence']) {
61
+ fs.mkdirSync(path.join(root, name), { mode: 0o700 });
62
+ }
63
+ const childEnv = { ...env, HOME: path.join(root, 'home'), DSH_HOME: path.join(root, 'home', 'dsh'),
64
+ NPM_CONFIG_CACHE: path.join(root, 'npm-cache'), NPM_CONFIG_USERCONFIG: path.join(root, 'home', '.npmrc') };
65
+ for (const key of ['DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY']) delete childEnv[key];
66
+ const run = (command, args) => execFileSync(command, args, { cwd: repo, env: childEnv, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
67
+ const npm = args => env.npm_execpath
68
+ ? run(process.execPath, [env.npm_execpath, ...args]) : run('npm', args);
69
+ const metadata = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', path.join(root, 'candidate')]))[0];
70
+ if (env.GITHUB_REF?.startsWith('refs/tags/v')) {
71
+ assert.equal(mode, 'release', 'Version tags require immutable release acceptance');
72
+ assert.equal(env.GITHUB_REF.slice('refs/tags/v'.length), metadata.version, 'Version tag must match the package version');
73
+ }
74
+ const candidateTarball = path.join(root, 'candidate', metadata.filename);
75
+ run('tar', ['-xzf', candidateTarball, '-C', path.join(root, 'candidate-unpacked')]);
76
+ const selected = selectAcceptancePackage({ repo, metadata, candidateTarball, mode,
77
+ packageDir: path.join(root, 'candidate-unpacked', 'package') });
78
+ // The script and the installed archive must come from the same selected
79
+ // package. Release mode must not run a candidate script against the archive.
80
+ run('tar', ['-xzf', selected.tarball, '-C', path.join(root, 'unpacked')]);
81
+ const receipt = {
82
+ schemaVersion: 2, createdAt: new Date().toISOString(),
83
+ revision: run('git', ['rev-parse', 'HEAD']).trim(),
84
+ platform: { os: process.platform, architecture: process.arch, node: process.version },
85
+ harness: '0.2.0-rc.2', pnpm: '12.8.1', npm: npm(['--version']).trim(),
86
+ mode, acceptanceTarget: selected.acceptanceTarget,
87
+ archiveIntegrity: selected.archiveIntegrity, frozenContentMatch: selected.frozenContentMatch,
88
+ repackedSha256: hash(candidateTarball),
89
+ package: { name: metadata.name, version: metadata.version, filename: path.basename(selected.tarball),
90
+ fileCount: metadata.files.length, sha256: hash(selected.tarball) },
91
+ registryReadbackPerformed: false, npmPublicationRequested: false,
92
+ validation: mode === 'candidate'
93
+ ? 'Checkout candidate installation on a CI runner; not a published-package, device or business acceptance'
94
+ : 'Immutable archived release installation on a CI runner; no registry readback, device or business acceptance'
95
+ };
96
+ fs.writeFileSync(path.join(root, 'evidence', 'package-provenance.json'), JSON.stringify(receipt, null, 2) + '\n');
97
+ fs.writeFileSync(path.join(root, 'candidate.json'), JSON.stringify({ tarball: selected.tarball, mode, acceptanceTarget: selected.acceptanceTarget }));
98
+ fs.writeFileSync(path.join(root, 'official', 'package.json'), JSON.stringify({ name: 'hcc-isolated-dsh-acceptance', private: true, version: '0.0.0' }));
99
+ if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `mode=${mode}\nacceptanceTarget=${selected.acceptanceTarget}\n`);
100
+ return receipt;
101
+ }
102
+
103
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
104
+ try {
105
+ const options = new Map();
106
+ for (let index = 2; index < process.argv.length; index += 2) {
107
+ const key = process.argv[index], value = process.argv[index + 1];
108
+ assert.ok(['--mode', '--output-dir'].includes(key) && value && !value.startsWith('--'), `Invalid option: ${key}`);
109
+ options.set(key, value);
110
+ }
111
+ const mode = options.get('--mode') || resolveAcceptanceMode({ eventName: process.env.GITHUB_EVENT_NAME,
112
+ ref: process.env.GITHUB_REF, dispatchMode: process.env.HCC_DSH_DISPATCH_MODE });
113
+ console.log(JSON.stringify(freezeAcceptancePackage({ root: options.get('--output-dir') || process.env.HCC_DSH_ACCEPTANCE_ROOT, mode }), null, 2));
114
+ } catch (error) {
115
+ const details = String(error.stack || error.message || error).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
116
+ console.error(`::error::${details}`); process.exitCode = 1;
117
+ }
118
+ }
@@ -0,0 +1,81 @@
1
+ // Disposable stdio MCP server for installed-model form acceptance.
2
+ // Only synthetic inputs from this harness are recorded; no provider credentials.
3
+ import fs from 'node:fs';
4
+ import readline from 'node:readline';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const args = process.argv.slice(2);
8
+ if (!args.includes('--stdio')) {
9
+ console.log('Acceptance fixture only: node scripts/mcp-form-live-fixture.mjs --stdio --log FILE');
10
+ process.exit(0);
11
+ }
12
+ const logIndex = args.indexOf('--log');
13
+ if (logIndex < 0 || !args[logIndex + 1]) throw new Error('--log FILE is required');
14
+ const logPath = args[logIndex + 1];
15
+ const pending = new Map();
16
+ const write = value => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n');
17
+ const record = value => fs.appendFileSync(logPath, JSON.stringify({ at: new Date().toISOString(), pid: process.pid, ...value }) + '\n', { mode: 0o600 });
18
+ const reply = (id, result) => write({ id, result });
19
+ const schema = {
20
+ type: 'object',
21
+ properties: {
22
+ project: { type: 'string', title: 'Project name / 项目名', minLength: 3, maxLength: 64 },
23
+ count: { type: 'integer', title: 'Run count / 次数', minimum: 1, maximum: 4 },
24
+ enabled: { type: 'boolean', title: 'Enable extra operation / 开启额外操作' },
25
+ region: { type: 'string', title: 'Region / 地区', enum: ['eu', 'us'], enumNames: ['Europe', 'USA'] },
26
+ features: { type: 'array', title: 'Features / 功能', minItems: 1, maxItems: 2,
27
+ items: { type: 'string', enum: ['logs', 'tests'] } },
28
+ note: { type: 'string', title: 'Optional note / 可选备注', default: 'suggested-but-not-automatically-submitted' }
29
+ },
30
+ required: ['project', 'count', 'enabled', 'region', 'features']
31
+ };
32
+ const lines = readline.createInterface({ input: process.stdin });
33
+ lines.on('line', line => {
34
+ let message;
35
+ try { message = JSON.parse(line); } catch { write({ id: null, error: { code: -32700, message: 'Invalid JSON' } }); return; }
36
+ const { id, method, params = {} } = message;
37
+ if (!method) {
38
+ const entry = pending.get(id);
39
+ if (!entry) return;
40
+ pending.delete(id); clearTimeout(entry.timer);
41
+ record({ event: 'form-response', phase: entry.phase, elicitationId: id, response: message.result ?? null,
42
+ ...(message.error ? { error: { code: message.error.code } } : {}) });
43
+ if (message.error) { reply(entry.callId, { content: [{ type: 'text', text: 'Form failed' }], isError: true }); return; }
44
+ reply(entry.callId, { content: [{ type: 'text', text: JSON.stringify({ phase: entry.phase, ...message.result }) }] });
45
+ return;
46
+ }
47
+ if (method === 'initialize') {
48
+ record({ event: 'initialize', protocolVersion: params.protocolVersion, clientName: params.clientInfo?.name,
49
+ elicitationCapabilities: params.capabilities?.elicitation ?? null });
50
+ reply(id, { protocolVersion: params.protocolVersion, capabilities: { tools: {} },
51
+ serverInfo: { name: 'hcc-form-live-acceptance', version: '1.0.0' } });
52
+ } else if (method === 'notifications/initialized') {
53
+ record({ event: 'ready' });
54
+ } else if (method === 'ping') {
55
+ reply(id, {});
56
+ } else if (method === 'tools/list') {
57
+ reply(id, { tools: [{ name: 'collect_project_settings',
58
+ description: 'Ask the human for project settings in a typed MCP form. Wait for their response and return the selected values.',
59
+ inputSchema: { type: 'object', properties: { phase: { type: 'string', enum: ['native', 'web-owned'] } },
60
+ required: ['phase'], additionalProperties: false } }] });
61
+ } else if (method === 'tools/call') {
62
+ const phase = params.arguments?.phase;
63
+ if (params.name !== 'collect_project_settings' || !['native', 'web-owned'].includes(phase)) {
64
+ write({ id, error: { code: -32602, message: 'Unknown acceptance tool or phase' } }); return;
65
+ }
66
+ const elicitationId = 'acceptance-form-' + randomUUID();
67
+ const timer = setTimeout(() => {
68
+ if (!pending.delete(elicitationId)) return;
69
+ record({ event: 'form-timeout', phase, elicitationId });
70
+ reply(id, { content: [{ type: 'text', text: 'Acceptance form timed out' }], isError: true });
71
+ }, 240000);
72
+ pending.set(elicitationId, { callId: id, phase, timer });
73
+ record({ event: 'tool-called', phase, elicitationId });
74
+ write({ id: elicitationId, method: 'elicitation/create', params: { mode: 'form',
75
+ message: 'Acceptance only: provide synthetic project settings / 验收表单:填写合成项目参数', requestedSchema: schema } });
76
+ record({ event: 'form-requested', phase, elicitationId });
77
+ } else if (id !== undefined) {
78
+ write({ id, error: { code: -32601, message: 'Method not found' } });
79
+ }
80
+ });
81
+ lines.on('close', () => { for (const entry of pending.values()) clearTimeout(entry.timer); pending.clear(); });