@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.
- package/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- 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
|
|
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)`);
|
package/scripts/regression.mjs
CHANGED
|
@@ -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
|
|
5740
|
-
|
|
5741
|
-
|
|
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 (
|
|
7805
|
-
fail(`scan
|
|
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 });
|