@logicseek/hello-cc 1.1.0-rc.7 → 1.1.0-rc.9
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 +79 -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 +79 -11
- 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/runtime-main.mjs +2 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +156 -52
- package/lib/web/tmux-stream.mjs +71 -6
- 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 +116 -9
- 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
|
@@ -19,6 +19,8 @@ export const DURABLE_RUNTIME_EVENTS = `
|
|
|
19
19
|
SELECT e.id FROM events e WHERE e.type = 'native.web.submission.pending'
|
|
20
20
|
UNION
|
|
21
21
|
SELECT e.id FROM events e WHERE e.type = 'codex.executor.started'
|
|
22
|
+
UNION
|
|
23
|
+
SELECT e.id FROM events e WHERE e.type = 'codex.thread.root-bound'
|
|
22
24
|
`;
|
|
23
25
|
|
|
24
26
|
export function pruneOldEventsPreservingTmuxAuthority(db, cutoff, options = {}) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
3
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot,
|
|
4
|
+
sameSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
|
|
5
|
+
import { buildCodexCommand } from '../../integrations/providers.mjs';
|
|
6
|
+
|
|
7
|
+
export const CODEX_THREAD_ROOT_EVENT = 'codex.thread.root-bound';
|
|
8
|
+
export const CODEX_SESSION_ORIGIN_ENV = 'HCC_CODEX_SESSION_ORIGIN';
|
|
9
|
+
|
|
10
|
+
function unverified() {
|
|
11
|
+
return new CliError('CODEX_HISTORY_UNVERIFIED',
|
|
12
|
+
'Codex thread has no trustworthy original project identity; manual review and association are required');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function invalid() {
|
|
16
|
+
return new CliError('CODEX_HISTORY_BINDING_INVALID',
|
|
17
|
+
'Codex thread has an invalid or conflicting original project identity');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function checkedThreadId(threadId) {
|
|
21
|
+
if (typeof threadId !== 'string' || !threadId.trim() || threadId.length > 512 || /[\r\n\0]/.test(threadId)) {
|
|
22
|
+
throw new CliError('BAD_REQUEST', 'A bounded Codex thread identity is required');
|
|
23
|
+
}
|
|
24
|
+
return threadId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rootIsValid(root) {
|
|
28
|
+
return typeof root?.canonical === 'string' && path.isAbsolute(root.canonical) &&
|
|
29
|
+
typeof root.identity?.dev === 'string' && /^\d+$/.test(root.identity.dev) &&
|
|
30
|
+
typeof root.identity?.ino === 'string' && /^\d+$/.test(root.identity.ino) &&
|
|
31
|
+
(root.identity?.birthtimeNs === null ||
|
|
32
|
+
(typeof root.identity?.birthtimeNs === 'string' && /^\d+$/.test(root.identity.birthtimeNs)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function selectedCodexRootSnapshot(root, expected = null) {
|
|
36
|
+
if (typeof expected?.assertUnchanged === 'function') expected.assertUnchanged();
|
|
37
|
+
else if (expected) assertSelectedCwdSnapshot(expected);
|
|
38
|
+
const snapshot = captureSelectedCwdSnapshot(root);
|
|
39
|
+
if (expected && !sameSelectedCwdIdentity(snapshot, expected)) {
|
|
40
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed');
|
|
41
|
+
}
|
|
42
|
+
return snapshot;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// event.actor is the thread ID so corrupt receipt payloads can be isolated to
|
|
46
|
+
// their own thread. Unknown or invalid receipts never fall back to cwd text.
|
|
47
|
+
export function readCodexThreadRootBindings(db, threadId = null) {
|
|
48
|
+
const bindings = new Map();
|
|
49
|
+
const rows = threadId === null
|
|
50
|
+
? db.prepare('SELECT actor,payload FROM events WHERE type = ?').all(CODEX_THREAD_ROOT_EVENT)
|
|
51
|
+
: db.prepare('SELECT actor,payload FROM events WHERE type = ? AND actor = ?')
|
|
52
|
+
.all(CODEX_THREAD_ROOT_EVENT, checkedThreadId(threadId));
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
const id = row.actor;
|
|
55
|
+
if (typeof id !== 'string' || !id || id.length > 512 || /[\r\n\0]/.test(id)) continue;
|
|
56
|
+
let payload;
|
|
57
|
+
try { payload = JSON.parse(row.payload); } catch { bindings.set(id, null); continue; }
|
|
58
|
+
const root = payload?.root;
|
|
59
|
+
if (payload?.version !== 1 || payload.thread_id !== id || !rootIsValid(root)) {
|
|
60
|
+
bindings.set(id, null);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const previous = bindings.get(id);
|
|
64
|
+
if (previous === null || (previous && !sameSelectedCwdIdentity(previous, root))) {
|
|
65
|
+
bindings.set(id, null);
|
|
66
|
+
} else {
|
|
67
|
+
bindings.set(id, root);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return bindings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function assertCodexThreadRoot(db, threadId, selected, knownBindings = null) {
|
|
74
|
+
checkedThreadId(threadId);
|
|
75
|
+
if (typeof selected?.assertUnchanged === 'function') selected.assertUnchanged();
|
|
76
|
+
else assertSelectedCwdSnapshot(selected);
|
|
77
|
+
const bindings = knownBindings || readCodexThreadRootBindings(db, threadId);
|
|
78
|
+
if (!bindings.has(threadId)) throw unverified();
|
|
79
|
+
const original = bindings.get(threadId);
|
|
80
|
+
if (!original) throw invalid();
|
|
81
|
+
if (!sameSelectedCwdIdentity(original, selected)) {
|
|
82
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex thread belongs to a different project directory identity');
|
|
83
|
+
}
|
|
84
|
+
return original;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function recordCodexThreadRoot(db, addEvent, threadId, selected, origin, { allowSame = false } = {}) {
|
|
88
|
+
checkedThreadId(threadId);
|
|
89
|
+
if (typeof selected?.assertUnchanged === 'function') selected.assertUnchanged();
|
|
90
|
+
else assertSelectedCwdSnapshot(selected);
|
|
91
|
+
const bindings = readCodexThreadRootBindings(db, threadId);
|
|
92
|
+
if (bindings.has(threadId)) {
|
|
93
|
+
if (allowSame && bindings.get(threadId) && sameSelectedCwdIdentity(bindings.get(threadId), selected)) return false;
|
|
94
|
+
throw new CliError('CODEX_THREAD_ALREADY_BOUND', 'Codex thread already has a project identity');
|
|
95
|
+
}
|
|
96
|
+
addEvent(db, CODEX_THREAD_ROOT_EVENT, threadId, null, {
|
|
97
|
+
version: 1, thread_id: threadId,
|
|
98
|
+
root: { canonical: selected.canonical, identity: selected.identity }, origin
|
|
99
|
+
});
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Validate the command we will actually launch, not only a caller-supplied
|
|
104
|
+
// binding: a binding for thread X must never authorize `codex resume Y`.
|
|
105
|
+
// Return an origin marker only for a first-party new/fork command whose hook
|
|
106
|
+
// can safely persist the resulting thread's launch-time directory identity.
|
|
107
|
+
export function assertCodexTerminalLaunch(db, binding, { command, root, rootIdentity = null,
|
|
108
|
+
cwd = root, cwdIdentity = null, argv = null } = {}) {
|
|
109
|
+
if (binding?.provider !== 'codex') return null;
|
|
110
|
+
const mode = binding.resume_mode || 'command';
|
|
111
|
+
if (!['new', 'resume', 'fork', 'last', 'fork-last'].includes(mode)) return null;
|
|
112
|
+
const selectedRoot = selectedCodexRootSnapshot(root, rootIdentity);
|
|
113
|
+
const selectedCwd = selectedCodexRootSnapshot(cwd, cwdIdentity);
|
|
114
|
+
if (!sameSelectedCwdIdentity(selectedRoot, selectedCwd)) {
|
|
115
|
+
if (mode === 'new') return null;
|
|
116
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex history can only be opened in its selected project directory');
|
|
117
|
+
}
|
|
118
|
+
if (mode === 'new') {
|
|
119
|
+
const directNew = Array.isArray(argv) && argv.length === 1 && path.basename(argv[0]) === 'codex';
|
|
120
|
+
return directNew || (!argv && command === 'codex') ? 'new' : null;
|
|
121
|
+
}
|
|
122
|
+
if (mode === 'last' || mode === 'fork-last') {
|
|
123
|
+
throw new CliError('CODEX_HISTORY_ID_REQUIRED',
|
|
124
|
+
'Codex --last cannot prove the selected thread identity; choose an explicit verified thread ID');
|
|
125
|
+
}
|
|
126
|
+
if (!binding.resume_arg) {
|
|
127
|
+
throw new CliError('CODEX_HISTORY_ID_REQUIRED', 'Choose an explicit verified Codex thread ID');
|
|
128
|
+
}
|
|
129
|
+
const sourceId = checkedThreadId(binding.resume_arg);
|
|
130
|
+
const expected = buildCodexCommand('history-check', mode === 'fork'
|
|
131
|
+
? { fork: true, resume: sourceId } : { resume: sourceId }).command;
|
|
132
|
+
const commandMatches = Array.isArray(argv)
|
|
133
|
+
? argv.length === 3 && path.basename(argv[0]) === 'codex' &&
|
|
134
|
+
argv[1] === mode && argv[2] === sourceId
|
|
135
|
+
: command === expected && (!binding.command || binding.command === expected);
|
|
136
|
+
if (!commandMatches ||
|
|
137
|
+
(mode === 'resume' && ((binding.provider_session_id && binding.provider_session_id !== sourceId) ||
|
|
138
|
+
(binding.provider_session_name && binding.provider_session_name !== sourceId)))) {
|
|
139
|
+
throw new CliError('CODEX_HISTORY_COMMAND_UNVERIFIED',
|
|
140
|
+
'Codex history command does not match the verified thread identity');
|
|
141
|
+
}
|
|
142
|
+
assertCodexThreadRoot(db, sourceId, selectedRoot);
|
|
143
|
+
return mode === 'fork' ? 'fork' : null;
|
|
144
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { shortHash } from '../peers/format.mjs';
|
|
5
5
|
import { HOOK_ROOT_IDENTITY_ENV, hookRootIdentityValue } from './hook-root-identity.mjs';
|
|
6
|
+
import { CODEX_SESSION_ORIGIN_ENV } from './codex-thread-root.mjs';
|
|
6
7
|
|
|
7
8
|
export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
8
9
|
export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
|
|
@@ -12,8 +13,10 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
12
13
|
'_',
|
|
13
14
|
'COLUMNS',
|
|
14
15
|
'HCC_DB',
|
|
16
|
+
CODEX_SESSION_ORIGIN_ENV,
|
|
15
17
|
HOOK_ROOT_IDENTITY_ENV,
|
|
16
18
|
'HCC_NO_AUTO_INSTALL_TMUX',
|
|
19
|
+
'HCC_PINNED_LAUNCH_MODE',
|
|
17
20
|
'HCC_PEER',
|
|
18
21
|
'HCC_ROOT',
|
|
19
22
|
'HCC_RUNTIME_LOCAL_ONLY',
|
|
@@ -45,6 +48,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
45
48
|
|
|
46
49
|
export function childSessionEnv(extra = {}, baseEnv = process.env, { rootIdentity = null } = {}) {
|
|
47
50
|
const env = { ...(baseEnv || {}), ...extra };
|
|
51
|
+
if (!Object.hasOwn(extra, CODEX_SESSION_ORIGIN_ENV)) delete env[CODEX_SESSION_ORIGIN_ENV];
|
|
48
52
|
if (Object.hasOwn(extra, 'HCC_ROOT')) {
|
|
49
53
|
if (extra.HCC_ROOT) env[HOOK_ROOT_IDENTITY_ENV] = hookRootIdentityValue(extra.HCC_ROOT, rootIdentity);
|
|
50
54
|
else delete env[HOOK_ROOT_IDENTITY_ENV];
|
package/lib/discover.mjs
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import os from 'node:os';
|
|
13
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
14
|
import { providerSessionPeerId } from './core/peers/session.mjs';
|
|
15
|
+
import { inspectProcessIdentity } from './process/identity.mjs';
|
|
16
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
|
|
15
17
|
|
|
16
18
|
// ─── Root detection ──────────────────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -177,259 +179,131 @@ function readEnvFile(envPath) {
|
|
|
177
179
|
|
|
178
180
|
// ─── Claude Code session discovery ───────────────────────────────────────────
|
|
179
181
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
* Read existing Claude Code session files and return info about live sessions
|
|
184
|
-
* that belong to a hello-cc project.
|
|
185
|
-
*/
|
|
182
|
+
// Session files contain a historical cwd and PID, not a root-bound proof of
|
|
183
|
+
// which directory inode owned the provider session. A reused PID or pathname
|
|
184
|
+
// can make an old session appear to belong to a replacement project.
|
|
186
185
|
export function scanClaudeSessions() {
|
|
187
|
-
|
|
188
|
-
let files;
|
|
189
|
-
try { files = fs.readdirSync(CLAUDE_SESSIONS_DIR); }
|
|
190
|
-
catch { return results; }
|
|
191
|
-
|
|
192
|
-
for (const file of files) {
|
|
193
|
-
if (!file.endsWith('.json')) continue;
|
|
194
|
-
try {
|
|
195
|
-
const content = JSON.parse(
|
|
196
|
-
fs.readFileSync(path.join(CLAUDE_SESSIONS_DIR, file), 'utf8')
|
|
197
|
-
);
|
|
198
|
-
const { pid, sessionId, cwd, status } = content;
|
|
199
|
-
if (!pid || !sessionId || !cwd) continue;
|
|
200
|
-
if (!isAlive(pid)) continue;
|
|
201
|
-
const env = readEnvFile(`/proc/${pid}/environ`);
|
|
202
|
-
|
|
203
|
-
const hccRoot = findHccRoot(cwd);
|
|
204
|
-
if (!hccRoot) continue;
|
|
205
|
-
|
|
206
|
-
results.push({
|
|
207
|
-
kind: 'claude',
|
|
208
|
-
sessionId,
|
|
209
|
-
peerId: derivePeerId('claude', sessionId, null, env.HCC_PEER),
|
|
210
|
-
pid,
|
|
211
|
-
cwd,
|
|
212
|
-
hccRoot,
|
|
213
|
-
status: status || 'unknown',
|
|
214
|
-
transport: 'detected',
|
|
215
|
-
});
|
|
216
|
-
} catch { /* ignore malformed files */ }
|
|
217
|
-
}
|
|
218
|
-
return results;
|
|
186
|
+
return [];
|
|
219
187
|
}
|
|
220
188
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
*/
|
|
226
|
-
export function watchClaudeSessions(onNew) {
|
|
227
|
-
try {
|
|
228
|
-
fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
|
|
229
|
-
} catch {}
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
return fs.watch(CLAUDE_SESSIONS_DIR, { persistent: false }, (event, filename) => {
|
|
233
|
-
if (event !== 'rename' || !filename?.endsWith('.json')) return;
|
|
234
|
-
// Small delay so Claude Code finishes writing the file
|
|
235
|
-
setTimeout(() => {
|
|
236
|
-
try {
|
|
237
|
-
const content = JSON.parse(
|
|
238
|
-
fs.readFileSync(path.join(CLAUDE_SESSIONS_DIR, filename), 'utf8')
|
|
239
|
-
);
|
|
240
|
-
const { pid, sessionId, cwd, status } = content;
|
|
241
|
-
if (!pid || !sessionId || !cwd) return;
|
|
242
|
-
if (!isAlive(pid)) return;
|
|
243
|
-
const env = readEnvFile(`/proc/${pid}/environ`);
|
|
244
|
-
const hccRoot = findHccRoot(cwd);
|
|
245
|
-
if (!hccRoot) return;
|
|
246
|
-
onNew({
|
|
247
|
-
kind: 'claude',
|
|
248
|
-
sessionId,
|
|
249
|
-
peerId: derivePeerId('claude', sessionId, null, env.HCC_PEER),
|
|
250
|
-
pid,
|
|
251
|
-
cwd,
|
|
252
|
-
hccRoot,
|
|
253
|
-
status: status || 'unknown',
|
|
254
|
-
transport: 'detected',
|
|
255
|
-
});
|
|
256
|
-
} catch {}
|
|
257
|
-
}, 300);
|
|
258
|
-
});
|
|
259
|
-
} catch {
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
189
|
+
// Keep the exported watcher interface inert until session files carry a
|
|
190
|
+
// verifiable root/session receipt. Watching must not reintroduce attribution.
|
|
191
|
+
export function watchClaudeSessions(_onNew) {
|
|
192
|
+
return null;
|
|
262
193
|
}
|
|
263
194
|
|
|
264
195
|
// ─── Codex session discovery ──────────────────────────────────────────────────
|
|
265
196
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
String(d.getFullYear()),
|
|
271
|
-
String(d.getMonth() + 1).padStart(2, '0'),
|
|
272
|
-
String(d.getDate()).padStart(2, '0')
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function parseCodexSessionFile(filePath) {
|
|
277
|
-
try {
|
|
278
|
-
const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0];
|
|
279
|
-
if (!firstLine) return null;
|
|
280
|
-
const parsed = JSON.parse(firstLine);
|
|
281
|
-
if (parsed.type !== 'session_meta') return null;
|
|
282
|
-
const { id, cwd } = parsed.payload || {};
|
|
283
|
-
if (!id || !cwd) return null;
|
|
284
|
-
return { sessionId: id, cwd };
|
|
285
|
-
} catch { return null; }
|
|
197
|
+
// Provider history may only be attributed by a separately persisted,
|
|
198
|
+
// root-bound receipt; a live process with the same cwd or resume ID is not one.
|
|
199
|
+
export function scanCodexSessions() {
|
|
200
|
+
return [];
|
|
286
201
|
}
|
|
287
202
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
export function scanCodexSessions() {
|
|
292
|
-
const results = [];
|
|
293
|
-
const dir = codexSessionsDir();
|
|
294
|
-
let files;
|
|
295
|
-
try { files = fs.readdirSync(dir); } catch { return results; }
|
|
296
|
-
|
|
297
|
-
// Build maps from /proc. Fresh sessions and --last/fork launches usually do
|
|
298
|
-
// not expose the new session id in argv, so cwd is the fallback.
|
|
299
|
-
const runningCodex = getRunningCodexSessions();
|
|
300
|
-
|
|
301
|
-
for (const file of files.sort().reverse().slice(0, 30)) {
|
|
302
|
-
if (!file.endsWith('.jsonl')) continue;
|
|
303
|
-
const info = parseCodexSessionFile(path.join(dir, file));
|
|
304
|
-
if (!info) continue;
|
|
305
|
-
|
|
306
|
-
const proc = runningCodex.bySessionId.get(info.sessionId) || firstProcessForCwd(runningCodex.byCwd, info.cwd);
|
|
307
|
-
if (!proc) continue;
|
|
308
|
-
|
|
309
|
-
const hccRoot = findHccRoot(info.cwd);
|
|
310
|
-
if (!hccRoot) continue;
|
|
311
|
-
|
|
312
|
-
results.push({
|
|
313
|
-
kind: 'codex',
|
|
314
|
-
sessionId: info.sessionId,
|
|
315
|
-
peerId: derivePeerId('codex', info.sessionId, null, proc.peerId),
|
|
316
|
-
pid: proc.pid,
|
|
317
|
-
cwd: info.cwd,
|
|
318
|
-
hccRoot,
|
|
319
|
-
status: 'running',
|
|
320
|
-
transport: 'detected',
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
return results;
|
|
203
|
+
// The same boundary applies to newly created session files.
|
|
204
|
+
export function watchCodexSessions(_onNew) {
|
|
205
|
+
return null;
|
|
324
206
|
}
|
|
325
207
|
|
|
326
|
-
|
|
327
|
-
* Walk /proc to find all running `codex` Node processes and map session ID → PID.
|
|
328
|
-
* Codex launches as: `node /path/to/codex [resume] <session-id>` or similar.
|
|
329
|
-
*/
|
|
330
|
-
function getRunningCodexSessions() {
|
|
331
|
-
const bySessionId = new Map();
|
|
332
|
-
const byCwd = new Map();
|
|
333
|
-
if (process.platform !== 'linux') return { bySessionId, byCwd };
|
|
208
|
+
// ─── Live process-only discovery ───────────────────────────────────────────────
|
|
334
209
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
210
|
+
function sameProcessIdentity(left, right) {
|
|
211
|
+
return left?.pid === right?.pid && left?.startToken === right?.startToken &&
|
|
212
|
+
left?.commandHash === right?.commandHash;
|
|
213
|
+
}
|
|
338
214
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (!cmdline.some(a => a.includes('codex'))) continue;
|
|
343
|
-
const cwd = fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
344
|
-
const env = readEnvFile(`/proc/${pid}/environ`);
|
|
345
|
-
const procInfo = { pid: parseInt(pid), peerId: env.HCC_PEER || null };
|
|
346
|
-
if (!byCwd.has(cwd)) byCwd.set(cwd, []);
|
|
347
|
-
byCwd.get(cwd).push(procInfo);
|
|
348
|
-
|
|
349
|
-
// codex resume <uuid>
|
|
350
|
-
const resumeId = parseCodexResumeId(cmdline);
|
|
351
|
-
if (resumeId) {
|
|
352
|
-
bySessionId.set(resumeId, procInfo);
|
|
353
|
-
}
|
|
354
|
-
} catch {}
|
|
355
|
-
}
|
|
356
|
-
return { bySessionId, byCwd };
|
|
215
|
+
function sameDirectoryStat(left, right) {
|
|
216
|
+
return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
|
|
217
|
+
left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
|
|
357
218
|
}
|
|
358
219
|
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if ((procCwd === cwd || procCwd.startsWith(`${cwd}${path.sep}`) || cwd.startsWith(`${procCwd}${path.sep}`)) && pids.length) {
|
|
364
|
-
return pids[0];
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return null;
|
|
220
|
+
function matchesSnapshot(stat, snapshot) {
|
|
221
|
+
return stat.isDirectory() && stat.dev.toString() === snapshot.identity.dev &&
|
|
222
|
+
stat.ino.toString() === snapshot.identity.ino &&
|
|
223
|
+
(stat.birthtimeNs?.toString() || null) === snapshot.identity.birthtimeNs;
|
|
368
224
|
}
|
|
369
225
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const dir = codexSessionsDir();
|
|
375
|
-
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
226
|
+
function processPeerId(kind, pid, identity) {
|
|
227
|
+
const suffix = createHash('sha256').update(`${pid}\0${identity.startToken}`).digest('hex').slice(0, 20);
|
|
228
|
+
return `${kind}-process-${suffix}`;
|
|
229
|
+
}
|
|
376
230
|
|
|
231
|
+
// Recheck immediately before registration. Historical paths and provider IDs
|
|
232
|
+
// are not authority: the same pathname can now name a different directory.
|
|
233
|
+
// The proc cwd itself, its current canonical name, and its parent chain must
|
|
234
|
+
// all lead to the selected root inode. A changed/reused PID is rejected too.
|
|
235
|
+
export function verifiedLiveProcessForRoot(record, ctx, { procRoot = '/proc', observeProcess = inspectProcessIdentity } = {}) {
|
|
236
|
+
if (record?.source !== 'process' || !Number.isSafeInteger(record.pid) || record.pid < 1 ||
|
|
237
|
+
!record.processIdentity || record.sessionId || record.resumeId) return null;
|
|
238
|
+
const selected = ctx.rootIdentity || ctx.initialRootIdentity || captureSelectedCwdSnapshot(ctx.root);
|
|
239
|
+
assertSelectedCwdSnapshot(selected);
|
|
377
240
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
});
|
|
400
|
-
}, 500);
|
|
401
|
-
});
|
|
402
|
-
} catch {
|
|
241
|
+
const before = observeProcess(record.pid);
|
|
242
|
+
if (before.state !== 'live' || !sameProcessIdentity(record.processIdentity, before.identity)) return null;
|
|
243
|
+
const procCwd = `${procRoot}/${record.pid}/cwd`;
|
|
244
|
+
const liveCwd = fs.realpathSync.native(procCwd);
|
|
245
|
+
const relative = path.relative(selected.canonical, liveCwd);
|
|
246
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
247
|
+
const procStat = fs.statSync(procCwd, { bigint: true });
|
|
248
|
+
if (!sameDirectoryStat(procStat, fs.statSync(liveCwd, { bigint: true }))) return null;
|
|
249
|
+
// Keep the /proc magic link in the path while walking upwards: path.join
|
|
250
|
+
// would erase "cwd/.." before the kernel could resolve the live inode.
|
|
251
|
+
let ancestor = procCwd;
|
|
252
|
+
for (let level = 0, depth = relative ? relative.split(path.sep).length : 0; level < depth; level++) {
|
|
253
|
+
ancestor += '/..';
|
|
254
|
+
}
|
|
255
|
+
if (!matchesSnapshot(fs.statSync(ancestor, { bigint: true }), selected)) return null;
|
|
256
|
+
const after = observeProcess(record.pid);
|
|
257
|
+
if (after.state !== 'live' || !sameProcessIdentity(before.identity, after.identity)) return null;
|
|
258
|
+
assertSelectedCwdSnapshot(selected);
|
|
259
|
+
return { ...record, cwd: liveCwd, hccRoot: selected.canonical, processIdentity: after.identity };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
|
|
403
262
|
return null;
|
|
404
263
|
}
|
|
405
264
|
}
|
|
406
265
|
|
|
407
|
-
//
|
|
266
|
+
// Binding updates merge an existing provider session ID when the incoming
|
|
267
|
+
// record omits one. Process-only discovery must never inherit that history.
|
|
268
|
+
export function canRegisterProcessOnly(db, record) {
|
|
269
|
+
const binding = db.prepare('SELECT transport, provider_session_id, provider_session_name, resume_mode, resume_arg, runtime_target FROM peer_bindings WHERE peer=?')
|
|
270
|
+
.get(record.peerId);
|
|
271
|
+
if (binding) return binding.transport === 'detected' && !binding.provider_session_id &&
|
|
272
|
+
!binding.provider_session_name && !binding.resume_arg && !binding.runtime_target &&
|
|
273
|
+
binding.resume_mode === 'unknown';
|
|
274
|
+
return !db.prepare('SELECT id FROM peers WHERE id=?').get(record.peerId);
|
|
275
|
+
}
|
|
408
276
|
|
|
409
277
|
/**
|
|
410
|
-
* Scan /proc for
|
|
411
|
-
*
|
|
412
|
-
*
|
|
278
|
+
* Scan /proc for live claude/codex processes. This does not infer ownership of
|
|
279
|
+
* provider history from argv, stale session files, or pathname equality.
|
|
280
|
+
* Call verifiedLiveProcessForRoot before registering a result to a project.
|
|
413
281
|
*/
|
|
414
|
-
export function scanProcesses(
|
|
282
|
+
export function scanProcesses({ procRoot = '/proc', platform = process.platform,
|
|
283
|
+
observeProcess = inspectProcessIdentity } = {}) {
|
|
415
284
|
const results = [];
|
|
416
|
-
if (
|
|
285
|
+
if (platform !== 'linux') return results;
|
|
417
286
|
|
|
418
287
|
let pids;
|
|
419
|
-
try { pids = fs.readdirSync(
|
|
288
|
+
try { pids = fs.readdirSync(procRoot).filter(e => /^\d+$/.test(e)); }
|
|
420
289
|
catch { return results; }
|
|
421
290
|
|
|
422
291
|
const seen = new Set();
|
|
423
292
|
|
|
424
293
|
for (const pidStr of pids) {
|
|
425
294
|
try {
|
|
426
|
-
const
|
|
295
|
+
const pid = Number(pidStr);
|
|
296
|
+
const cmdline = readFileSafe(`${procRoot}/${pidStr}/cmdline`).split('\0');
|
|
427
297
|
const hasClaude = cmdline.some(a => /claude/.test(a) && !a.includes('hcc'));
|
|
428
298
|
const hasCodex = cmdline.some(a => /\bcodex\b/.test(a));
|
|
429
299
|
if (!hasClaude && !hasCodex) continue;
|
|
430
300
|
|
|
431
|
-
const cwd = fs.readlinkSync(
|
|
432
|
-
const env = readEnvFile(
|
|
301
|
+
const cwd = fs.readlinkSync(`${procRoot}/${pidStr}/cwd`);
|
|
302
|
+
const env = readEnvFile(`${procRoot}/${pidStr}/environ`);
|
|
303
|
+
const observed = observeProcess(pid);
|
|
304
|
+
if (observed.state !== 'live' || !observed.identity) continue;
|
|
305
|
+
const commandHash = createHash('sha256').update(cmdline.filter(Boolean).join(' ').trim()).digest('hex');
|
|
306
|
+
if (observed.identity.commandHash !== commandHash) continue;
|
|
433
307
|
|
|
434
308
|
const kind = hasClaude ? 'claude' : 'codex';
|
|
435
309
|
|
|
@@ -440,19 +314,7 @@ export function scanProcesses() {
|
|
|
440
314
|
if (entrypoint && entrypoint !== 'cli') continue;
|
|
441
315
|
}
|
|
442
316
|
|
|
443
|
-
const
|
|
444
|
-
const sessionId = kind === 'claude'
|
|
445
|
-
? (env.CLAUDE_CODE_SESSION_ID || explicitClaudeSession || '')
|
|
446
|
-
: parseCodexResumeId(cmdline) || '';
|
|
447
|
-
|
|
448
|
-
let resumeId = null;
|
|
449
|
-
if (kind === 'claude') {
|
|
450
|
-
resumeId = parseClaudeFork(cmdline) ? null : parseClaudeResumeId(cmdline);
|
|
451
|
-
} else {
|
|
452
|
-
resumeId = parseCodexResumeId(cmdline);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const peerId = derivePeerId(kind, sessionId, resumeId, env.HCC_PEER);
|
|
317
|
+
const peerId = processPeerId(kind, pid, observed.identity);
|
|
456
318
|
if (seen.has(peerId)) continue;
|
|
457
319
|
seen.add(peerId);
|
|
458
320
|
|
|
@@ -461,14 +323,16 @@ export function scanProcesses() {
|
|
|
461
323
|
|
|
462
324
|
results.push({
|
|
463
325
|
kind,
|
|
464
|
-
sessionId,
|
|
465
|
-
resumeId,
|
|
326
|
+
sessionId: '',
|
|
327
|
+
resumeId: null,
|
|
466
328
|
peerId,
|
|
467
|
-
pid
|
|
329
|
+
pid,
|
|
468
330
|
cwd,
|
|
469
331
|
hccRoot,
|
|
470
332
|
status: 'running',
|
|
471
333
|
transport: 'detected',
|
|
334
|
+
source: 'process',
|
|
335
|
+
processIdentity: observed.identity,
|
|
472
336
|
});
|
|
473
337
|
} catch {}
|
|
474
338
|
}
|
|
@@ -17,6 +17,7 @@ import { createScopedMcpTools } from '../mcp/tools.mjs';
|
|
|
17
17
|
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
18
18
|
import { registerProjectActivity } from '../runtime/projects.mjs';
|
|
19
19
|
import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
|
|
20
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
20
21
|
|
|
21
22
|
const fail = (message) => { throw new CliError('DSH_COLLABORATION_CONFLICT', message); };
|
|
22
23
|
|
|
@@ -39,8 +40,11 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
|
|
|
39
40
|
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 1024 ||
|
|
40
41
|
sessionId.trim() !== sessionId || /[\u0000-\u001f\u007f]/.test(sessionId) ||
|
|
41
42
|
typeof cwd !== 'string' || !path.isAbsolute(cwd)) fail('Agent requires its real session ID and absolute cwd');
|
|
43
|
+
const rootIdentity = captureSelectedCwdSnapshot(cwd);
|
|
42
44
|
const project = resolveProjectDatabase({ root: cwd, createStateDir: true });
|
|
43
|
-
|
|
45
|
+
assertSelectedCwdSnapshot(rootIdentity);
|
|
46
|
+
const ctx = { root: project.root, cwd: project.root, dbPath: project.db, json: true,
|
|
47
|
+
initialRootIdentity: rootIdentity };
|
|
44
48
|
const peer = providerSessionPeerId('dsh', sessionId);
|
|
45
49
|
const owner = `cordis:${randomUUID()}`;
|
|
46
50
|
const now = () => Math.floor(Date.now() / 1000);
|
|
@@ -127,7 +131,10 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
|
|
|
127
131
|
finally { db.close(); }
|
|
128
132
|
}
|
|
129
133
|
const authority = { scope: Object.freeze({ root: ctx.root, dbPath: ctx.dbPath, peer, executorId: owner }),
|
|
130
|
-
assertValid: () => {
|
|
134
|
+
assertValid: () => {
|
|
135
|
+
if (disposed || retiring) fail('The Cordis Agent authority has been disposed');
|
|
136
|
+
assertSelectedCwdSnapshot(rootIdentity);
|
|
137
|
+
}, assertOwnership };
|
|
131
138
|
const service = createScopedMcpTools({ ctx, authority, connect, now, addEvent,
|
|
132
139
|
touchPeer: (db, id, status) => { assertOwnership(db); if (id !== peer) fail('Peer identity changed'); peers.touchPeer(db, id, status); } });
|
|
133
140
|
let queue = Promise.resolve();
|
|
@@ -6,6 +6,7 @@ import { createNativeInteractions } from './interactions.mjs';
|
|
|
6
6
|
import { CliError } from '../../shared/errors.mjs';
|
|
7
7
|
import { claudeUsage } from './telemetry.mjs';
|
|
8
8
|
import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
|
|
9
|
+
import { HOOK_ROOT_IDENTITY_ENV } from '../../core/sessions/hook-root-identity.mjs';
|
|
9
10
|
|
|
10
11
|
const SESSION_MARKERS = new Set([
|
|
11
12
|
'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_PARENT_SESSION_ID',
|
|
@@ -24,6 +25,11 @@ function isolatedEnv(source, coordinationEnv = source) {
|
|
|
24
25
|
// An inherited terminal's partial HCC markers must not identify the worker.
|
|
25
26
|
if (COORDINATION_KEYS.every((key) => typeof coordinationEnv[key] === 'string' && coordinationEnv[key])) {
|
|
26
27
|
for (const key of COORDINATION_KEYS) clean[key] = coordinationEnv[key];
|
|
28
|
+
// The hook must receive the same launch-time directory receipt as the
|
|
29
|
+
// worker; otherwise a rebound project path would look like a legacy hook.
|
|
30
|
+
if (typeof coordinationEnv[HOOK_ROOT_IDENTITY_ENV] === 'string' && coordinationEnv[HOOK_ROOT_IDENTITY_ENV]) {
|
|
31
|
+
clean[HOOK_ROOT_IDENTITY_ENV] = coordinationEnv[HOOK_ROOT_IDENTITY_ENV];
|
|
32
|
+
}
|
|
27
33
|
}
|
|
28
34
|
return clean;
|
|
29
35
|
}
|
|
@@ -102,7 +102,8 @@ export function createCodexAdapter(options = {}) {
|
|
|
102
102
|
} else if (method === 'turn/diff/updated') {
|
|
103
103
|
emit({ type: 'diff', diff: params.diff, turnId, submissionId });
|
|
104
104
|
} else if (method === 'error') {
|
|
105
|
-
emit({ type: 'error', turnId, error: params.error ?? params
|
|
105
|
+
emit({ type: 'error', turnId, submissionId, error: params.error ?? params,
|
|
106
|
+
...(typeof params.willRetry === 'boolean' ? { willRetry: params.willRetry } : {}) });
|
|
106
107
|
} else if (method === 'serverRequest/resolved') {
|
|
107
108
|
interactions.resolved(params.requestId);
|
|
108
109
|
} else if (method === 'thread/status/changed' && params.status?.type === 'systemError') {
|
|
@@ -169,7 +170,8 @@ export function createCodexAdapter(options = {}) {
|
|
|
169
170
|
status('opening');
|
|
170
171
|
try {
|
|
171
172
|
cwdBinding?.assertUnchanged();
|
|
172
|
-
rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env,
|
|
173
|
+
rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env,
|
|
174
|
+
expectedIdentity: cwdBinding || options.expectedCwdIdentity, timeoutMs,
|
|
173
175
|
onNotification: notification, onRequest: serverRequest, onExit: exited, onError: transportError });
|
|
174
176
|
await rpc.start();
|
|
175
177
|
if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
|