@logicseek/hello-cc 1.0.1 → 1.0.2
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 +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +3 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +11 -3
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +243 -89
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
3
|
+
|
|
4
|
+
// Each transport owns the child it starts. It never connects to a desktop
|
|
5
|
+
// application's private server or signals a process supplied by the caller.
|
|
6
|
+
export class JsonRpcProcess {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.options = options;
|
|
9
|
+
this.pending = new Map();
|
|
10
|
+
this.nextId = 1;
|
|
11
|
+
this.buffer = '';
|
|
12
|
+
this.closed = false;
|
|
13
|
+
this.child = null;
|
|
14
|
+
this.closePromise = null;
|
|
15
|
+
this.lastCloseError = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async start() {
|
|
19
|
+
if (this.child || this.closed) throw new CliError('NATIVE_TRANSPORT_STATE', 'Transport cannot be started twice');
|
|
20
|
+
const { binary, args = [], cwd, env } = this.options;
|
|
21
|
+
this.child = spawn(binary, args, { cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
22
|
+
this.child.stdout.setEncoding('utf8');
|
|
23
|
+
this.child.stdout.on('data', (chunk) => this.read(chunk));
|
|
24
|
+
// Drain stderr without putting credentials or unbounded provider logs into
|
|
25
|
+
// the shared message bus. Protocol errors are reported through requests.
|
|
26
|
+
this.child.stderr.resume();
|
|
27
|
+
this.child.stdin.on('error', (error) => this.fail(error));
|
|
28
|
+
this.child.on('error', (error) => this.fail(new CliError('NATIVE_SPAWN_FAILED', `Cannot start ${binary}: ${error.message}`)));
|
|
29
|
+
this.child.once('exit', (code, signal) => {
|
|
30
|
+
const error = new CliError('NATIVE_PROCESS_EXITED', 'Native provider process exited', { code, signal, uncertain: true });
|
|
31
|
+
this.fail(error);
|
|
32
|
+
this.options.onExit?.({ code, signal, expected: this.closed });
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
this.child.once('spawn', resolve);
|
|
36
|
+
this.child.once('error', reject);
|
|
37
|
+
});
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
read(chunk) {
|
|
42
|
+
this.buffer += chunk;
|
|
43
|
+
const maxBytes = this.options.maxFrameBytes || 4 * 1024 * 1024;
|
|
44
|
+
let newline;
|
|
45
|
+
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
|
46
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
47
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
if (Buffer.byteLength(line) > maxBytes) return this.protocolFailure('Native protocol frame is too large');
|
|
50
|
+
let frame;
|
|
51
|
+
try { frame = JSON.parse(line); } catch { return this.protocolFailure('Invalid JSON from native provider'); }
|
|
52
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame)) return this.protocolFailure('Invalid native protocol frame');
|
|
53
|
+
if (frame.method) {
|
|
54
|
+
if (Object.hasOwn(frame, 'id')) void this.handleRequest(frame);
|
|
55
|
+
else Promise.resolve().then(() => this.options.onNotification?.(frame.method, frame.params || {}))
|
|
56
|
+
.catch(() => this.protocolFailure('Native notification handler failed'));
|
|
57
|
+
} else if (Object.hasOwn(frame, 'id')) {
|
|
58
|
+
const request = this.pending.get(frame.id);
|
|
59
|
+
if (!request) continue; // Late response to an uncertain, timed-out request.
|
|
60
|
+
this.pending.delete(frame.id);
|
|
61
|
+
clearTimeout(request.timer);
|
|
62
|
+
if (frame.error) request.reject(new CliError('NATIVE_RPC_ERROR', frame.error.message || 'Native provider rejected request', {
|
|
63
|
+
method: request.method, rpc_code: frame.error.code, data: frame.error.data
|
|
64
|
+
}));
|
|
65
|
+
else if (Object.hasOwn(frame, 'result')) request.resolve(frame.result);
|
|
66
|
+
else request.reject(new CliError('NATIVE_PROTOCOL_ERROR', 'Native response contains neither result nor error'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (Buffer.byteLength(this.buffer) > maxBytes) this.protocolFailure('Native protocol frame is too large');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async handleRequest(frame) {
|
|
73
|
+
try {
|
|
74
|
+
if (!this.options.onRequest) throw new Error('Unsupported native server request');
|
|
75
|
+
const result = await this.options.onRequest(frame.method, frame.params || {}, frame.id);
|
|
76
|
+
this.write({ id: frame.id, result: result ?? null });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!this.closed) {
|
|
79
|
+
try { this.write({ id: frame.id, error: { code: -32601, message: error.message || 'Unsupported server request' } }); } catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protocolFailure(message) {
|
|
85
|
+
if (this.closed) return;
|
|
86
|
+
const error = new CliError('NATIVE_PROTOCOL_ERROR', message, { uncertain: true });
|
|
87
|
+
this.fail(error);
|
|
88
|
+
this.reportError(error);
|
|
89
|
+
// Retain a rejected closePromise for explicit callers without making an
|
|
90
|
+
// asynchronous protocol failure produce an unhandled rejection.
|
|
91
|
+
void this.close().catch((closeError) => {
|
|
92
|
+
this.lastCloseError = closeError;
|
|
93
|
+
this.reportError(closeError);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reportError(error) {
|
|
98
|
+
try {
|
|
99
|
+
const delivery = this.options.onError?.(error);
|
|
100
|
+
delivery?.catch?.(() => {});
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fail(error) {
|
|
105
|
+
for (const request of this.pending.values()) {
|
|
106
|
+
clearTimeout(request.timer);
|
|
107
|
+
request.reject(error);
|
|
108
|
+
}
|
|
109
|
+
this.pending.clear();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
write(frame) {
|
|
113
|
+
if (this.closed || !this.child || this.child.exitCode !== null || this.child.signalCode !== null || !this.child.stdin.writable) {
|
|
114
|
+
throw new CliError('NATIVE_TRANSPORT_CLOSED', 'Native provider connection is closed');
|
|
115
|
+
}
|
|
116
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
request(method, params = {}, options = {}) {
|
|
120
|
+
const id = this.nextId++;
|
|
121
|
+
const timeoutMs = options.timeoutMs ?? this.options.timeoutMs ?? 30000;
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
124
|
+
this.pending.delete(id);
|
|
125
|
+
reject(new CliError('NATIVE_REQUEST_TIMEOUT', `Native request timed out: ${method}`, { method, uncertain: true }));
|
|
126
|
+
}, timeoutMs) : null;
|
|
127
|
+
this.pending.set(id, { resolve, reject, timer, method });
|
|
128
|
+
try { this.write({ id, method, params }); }
|
|
129
|
+
catch (error) { this.pending.delete(id); clearTimeout(timer); reject(error); }
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
notify(method, params = {}) { this.write({ method, params }); }
|
|
134
|
+
|
|
135
|
+
close() {
|
|
136
|
+
if (this.closePromise) return this.closePromise;
|
|
137
|
+
this.closed = true;
|
|
138
|
+
this.fail(new CliError('NATIVE_TRANSPORT_CLOSED', 'Native provider connection closed', { uncertain: true }));
|
|
139
|
+
const child = this.child;
|
|
140
|
+
// A failed spawn has no PID and emits close/error, but never exit.
|
|
141
|
+
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
|
|
142
|
+
this.closePromise = Promise.resolve();
|
|
143
|
+
return this.closePromise;
|
|
144
|
+
}
|
|
145
|
+
this.closePromise = new Promise((resolve, reject) => {
|
|
146
|
+
let termTimer;
|
|
147
|
+
let killTimer;
|
|
148
|
+
const cleanup = () => {
|
|
149
|
+
clearTimeout(termTimer);
|
|
150
|
+
clearTimeout(killTimer);
|
|
151
|
+
child.removeListener('exit', exited);
|
|
152
|
+
child.removeListener('error', failed);
|
|
153
|
+
};
|
|
154
|
+
const exited = () => { cleanup(); resolve(); };
|
|
155
|
+
const failed = (error) => {
|
|
156
|
+
cleanup();
|
|
157
|
+
this.lastCloseError = new CliError('NATIVE_CLOSE_FAILED', 'Cannot confirm native provider process termination', {
|
|
158
|
+
pid: child.pid, uncertain: true, cause: error.code || error.message
|
|
159
|
+
});
|
|
160
|
+
reject(this.lastCloseError);
|
|
161
|
+
};
|
|
162
|
+
const signal = (name) => {
|
|
163
|
+
try {
|
|
164
|
+
if (!child.kill(name)) {
|
|
165
|
+
failed(new CliError('NATIVE_SIGNAL_FAILED', `Native process did not accept ${name}`));
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
} catch (error) { failed(error); return false; }
|
|
170
|
+
};
|
|
171
|
+
child.once('exit', exited);
|
|
172
|
+
child.once('error', failed);
|
|
173
|
+
termTimer = setTimeout(() => {
|
|
174
|
+
if (child.exitCode !== null || child.signalCode !== null) return exited();
|
|
175
|
+
// Sending SIGKILL is not an exit receipt. Continue waiting for exit.
|
|
176
|
+
killTimer = setTimeout(() => failed(new CliError('NATIVE_EXIT_TIMEOUT',
|
|
177
|
+
'Native provider did not report exit after SIGKILL')), this.options.closeKillTimeoutMs ?? 2000);
|
|
178
|
+
signal('SIGKILL');
|
|
179
|
+
}, this.options.closeGraceMs ?? 2000);
|
|
180
|
+
try { child.stdin.end(); } catch {}
|
|
181
|
+
signal('SIGTERM');
|
|
182
|
+
});
|
|
183
|
+
return this.closePromise;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -28,7 +28,7 @@ export function currentTtyName() {
|
|
|
28
28
|
return tty && tty !== 'not a tty' ? tty : '';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const ancestorCliInfoCache = new Map();
|
|
32
32
|
|
|
33
33
|
function readProcCmdline(pid) {
|
|
34
34
|
if (process.platform !== 'linux' || !pid) return [];
|
|
@@ -62,7 +62,27 @@ function readProcParentPid(pid) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// dsh is a service runtime, not a session-specific executable. Recognize its
|
|
66
|
+
// actual executable or Node entry point, never a shell's arbitrary argument.
|
|
67
|
+
export function argsLookLikeDsh(args) {
|
|
68
|
+
if (!Array.isArray(args) || !args.length) return false;
|
|
69
|
+
const executable = path.basename(String(args[0] || '')).toLowerCase();
|
|
70
|
+
if (executable === 'dsh') return true;
|
|
71
|
+
if (!['node', 'nodejs'].includes(executable)) return false;
|
|
72
|
+
let index = 1;
|
|
73
|
+
const valueFlags = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--conditions']);
|
|
74
|
+
while (index < args.length && String(args[index]).startsWith('-')) {
|
|
75
|
+
const flag = String(args[index]);
|
|
76
|
+
if (flag === '--') { index += 1; break; }
|
|
77
|
+
if (/^-[ep]/.test(flag) || ['--eval', '--print'].some((name) => flag === name || flag.startsWith(`${name}=`))) return false;
|
|
78
|
+
index += valueFlags.has(flag) ? 2 : 1;
|
|
79
|
+
}
|
|
80
|
+
const entry = String(args[index] || '').replaceAll('\\', '/');
|
|
81
|
+
return path.basename(entry) === 'dsh' || entry.endsWith('/dsh/lib/bin.js');
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
function argsLookLikeCli(args, kind) {
|
|
85
|
+
if (kind === 'dsh') return argsLookLikeDsh(args);
|
|
66
86
|
return args.some((arg) => {
|
|
67
87
|
const text = String(arg || '').toLowerCase();
|
|
68
88
|
const base = path.basename(text);
|
|
@@ -73,6 +93,7 @@ function argsLookLikeCli(args, kind) {
|
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
function detectCliKindFromProcess(args, env) {
|
|
96
|
+
if (argsLookLikeDsh(args)) return 'dsh';
|
|
76
97
|
if (env.CLAUDE_CODE_SESSION_ID || env.CLAUDECODE === '1' || argsLookLikeCli(args, 'claude')) return 'claude';
|
|
77
98
|
if (
|
|
78
99
|
env.CODEX_SESSION_ID ||
|
|
@@ -101,7 +122,7 @@ export function findLinuxAncestorCliInfo(startPid, options = {}) {
|
|
|
101
122
|
const args = Array.isArray(row.args) ? row.args : [];
|
|
102
123
|
const env = row.env && typeof row.env === 'object' && !Array.isArray(row.env) ? row.env : {};
|
|
103
124
|
const kind = detectCliKindFromProcess(args, env);
|
|
104
|
-
if (kind) return { pid, kind, args, env };
|
|
125
|
+
if (kind && (!options.kind || options.kind === kind)) return { pid, kind, args, env };
|
|
105
126
|
pid = Number(row.parent);
|
|
106
127
|
}
|
|
107
128
|
return null;
|
|
@@ -136,23 +157,28 @@ export function findMacAncestorCliInfo(startPid, options = {}) {
|
|
|
136
157
|
seen.add(pid);
|
|
137
158
|
const parsed = parseMacAncestorLine(inspect(pid));
|
|
138
159
|
if (!parsed) return null;
|
|
139
|
-
|
|
160
|
+
// ps provides a rendered command, not lossless argv. Only tokenize enough
|
|
161
|
+
// to recognize the dsh executable/Node script; resume parsing remains off.
|
|
162
|
+
const commandArgs = parsed.command.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g)?.map((arg) =>
|
|
163
|
+
/^("|')/.test(arg) ? arg.slice(1, -1) : arg) || [];
|
|
164
|
+
const kind = argsLookLikeDsh(commandArgs) ? 'dsh' : detectCliKindFromProcess([parsed.command], {});
|
|
140
165
|
// `ps command` is sufficient for provider-kind detection but is not a
|
|
141
166
|
// lossless argv encoding. Do not feed it to resume-ID parsing.
|
|
142
|
-
if (kind) return { pid, kind, args: [], env: {}, command: parsed.command };
|
|
167
|
+
if (kind && (!options.kind || options.kind === kind)) return { pid, kind, args: [], env: {}, command: parsed.command };
|
|
143
168
|
pid = parsed.ppid;
|
|
144
169
|
}
|
|
145
170
|
return null;
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
export function readAncestorCliInfo() {
|
|
149
|
-
if (ancestorCliInfoCache
|
|
150
|
-
|
|
151
|
-
? findLinuxAncestorCliInfo(process.ppid)
|
|
173
|
+
export function readAncestorCliInfo(kindHint = null) {
|
|
174
|
+
if (ancestorCliInfoCache.has(kindHint)) return ancestorCliInfoCache.get(kindHint);
|
|
175
|
+
const info = process.platform === 'linux'
|
|
176
|
+
? findLinuxAncestorCliInfo(process.ppid, { kind: kindHint })
|
|
152
177
|
: process.platform === 'darwin'
|
|
153
|
-
? findMacAncestorCliInfo(process.ppid)
|
|
178
|
+
? findMacAncestorCliInfo(process.ppid, { kind: kindHint })
|
|
154
179
|
: null;
|
|
155
|
-
|
|
180
|
+
ancestorCliInfoCache.set(kindHint, info);
|
|
181
|
+
return info;
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
export function resumeIdFromArgs(kind, args) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { resumeIdFromArgs } from './identity.mjs';
|
|
3
|
+
import { argsLookLikeDsh, resumeIdFromArgs } from './identity.mjs';
|
|
4
4
|
|
|
5
5
|
function readProcCmdline(pid) {
|
|
6
6
|
if (process.platform !== 'linux' || !pid) return [];
|
|
@@ -49,6 +49,7 @@ function readProcChildren(pid) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function argsLookLikeCli(args, kind) {
|
|
52
|
+
if (kind === 'dsh') return argsLookLikeDsh(args);
|
|
52
53
|
return args.some((arg) => {
|
|
53
54
|
const text = String(arg || '').toLowerCase();
|
|
54
55
|
const base = path.basename(text);
|
|
@@ -59,6 +60,9 @@ function argsLookLikeCli(args, kind) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
function detectKind(args, env, kindHint = null) {
|
|
63
|
+
if (argsLookLikeDsh(args)) return 'dsh';
|
|
64
|
+
// No service-level Claude/Codex environment can identify a dsh session.
|
|
65
|
+
if (kindHint === 'dsh') return null;
|
|
62
66
|
if (kindHint && argsLookLikeCli(args, kindHint)) return kindHint;
|
|
63
67
|
if (env.CLAUDE_CODE_SESSION_ID || env.CLAUDECODE === '1' || argsLookLikeCli(args, 'claude')) return 'claude';
|
|
64
68
|
if (
|
|
@@ -78,7 +82,13 @@ function sessionFromEnv(kind, env) {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export function inspectProviderProcess(pid, kindHint = null, opts = {}) {
|
|
81
|
-
if (process.platform !== 'linux' || !pid) return null;
|
|
85
|
+
if ((process.platform !== 'linux' && typeof opts.read !== 'function') || !pid) return null;
|
|
86
|
+
const read = typeof opts.read === 'function' ? opts.read : (processPid) => ({
|
|
87
|
+
args: readProcCmdline(processPid),
|
|
88
|
+
env: readProcEnv(processPid),
|
|
89
|
+
parent: readProcParentPid(processPid),
|
|
90
|
+
children: readProcChildren(processPid)
|
|
91
|
+
});
|
|
82
92
|
const maxDepth = Number.isFinite(opts.maxDepth) ? opts.maxDepth : 8;
|
|
83
93
|
const queue = [{ pid: Number(pid), depth: 0 }];
|
|
84
94
|
const seen = new Set();
|
|
@@ -89,10 +99,12 @@ export function inspectProviderProcess(pid, kindHint = null, opts = {}) {
|
|
|
89
99
|
const depth = current?.depth || 0;
|
|
90
100
|
if (!currentPid || currentPid <= 1 || depth > maxDepth || seen.has(currentPid)) continue;
|
|
91
101
|
seen.add(currentPid);
|
|
92
|
-
const
|
|
93
|
-
const
|
|
102
|
+
const row = read(currentPid) || {};
|
|
103
|
+
const args = Array.isArray(row.args) ? row.args : [];
|
|
104
|
+
const env = row.env || {};
|
|
94
105
|
const kind = detectKind(args, env, kindHint);
|
|
95
106
|
if (kind) {
|
|
107
|
+
if (kind === 'dsh') return { pid: currentPid, kind, provider_session: null, source: 'process.argv.dsh' };
|
|
96
108
|
const envSession = sessionFromEnv(kind, env);
|
|
97
109
|
if (envSession) {
|
|
98
110
|
return {
|
|
@@ -112,7 +124,7 @@ export function inspectProviderProcess(pid, kindHint = null, opts = {}) {
|
|
|
112
124
|
};
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
|
-
for (const childPid of
|
|
127
|
+
for (const childPid of row.children || []) {
|
|
116
128
|
queue.push({ pid: childPid, depth: depth + 1 });
|
|
117
129
|
}
|
|
118
130
|
}
|
|
@@ -122,10 +134,12 @@ export function inspectProviderProcess(pid, kindHint = null, opts = {}) {
|
|
|
122
134
|
|
|
123
135
|
for (let depth = 0; currentPid && currentPid > 1 && depth <= maxDepth && !seen.has(currentPid); depth += 1) {
|
|
124
136
|
seen.add(currentPid);
|
|
125
|
-
const
|
|
126
|
-
const
|
|
137
|
+
const row = read(currentPid) || {};
|
|
138
|
+
const args = Array.isArray(row.args) ? row.args : [];
|
|
139
|
+
const env = row.env || {};
|
|
127
140
|
const kind = detectKind(args, env, kindHint);
|
|
128
141
|
if (kind) {
|
|
142
|
+
if (kind === 'dsh') return { pid: currentPid, kind, provider_session: null, source: 'process.argv.dsh' };
|
|
129
143
|
const envSession = sessionFromEnv(kind, env);
|
|
130
144
|
if (envSession) {
|
|
131
145
|
return {
|
|
@@ -145,7 +159,7 @@ export function inspectProviderProcess(pid, kindHint = null, opts = {}) {
|
|
|
145
159
|
};
|
|
146
160
|
}
|
|
147
161
|
}
|
|
148
|
-
currentPid =
|
|
162
|
+
currentPid = Number(row.parent);
|
|
149
163
|
}
|
|
150
164
|
return null;
|
|
151
165
|
}
|
|
@@ -13,9 +13,10 @@ function shellCommand(args) {
|
|
|
13
13
|
|
|
14
14
|
export function inferPeerKind(id, explicitKind, firstCommand) {
|
|
15
15
|
if (explicitKind) return explicitKind;
|
|
16
|
-
if (['codex', 'claude'].includes(firstCommand)) return firstCommand;
|
|
16
|
+
if (['codex', 'claude', 'dsh'].includes(firstCommand)) return firstCommand;
|
|
17
17
|
if (String(id).startsWith('codex')) return 'codex';
|
|
18
18
|
if (String(id).startsWith('claude')) return 'claude';
|
|
19
|
+
if (String(id).startsWith('dsh')) return 'dsh';
|
|
19
20
|
return 'shell';
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -26,6 +27,9 @@ export function hasResumeOpts(opts) {
|
|
|
26
27
|
export function defaultSessionCommand(kind) {
|
|
27
28
|
if (kind === 'codex') return 'codex';
|
|
28
29
|
if (kind === 'claude') return 'claude';
|
|
30
|
+
if (kind === 'dsh') {
|
|
31
|
+
throw new CliError('DSH_TUI_UNSUPPORTED', 'dsh has no bundled interactive terminal profile. Use hcc dsh web to launch its Web UI and connect sessions through hooks.');
|
|
32
|
+
}
|
|
29
33
|
return process.env.SHELL || 'bash';
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -49,8 +53,8 @@ export function buildPeerCommand(id, kind, opts, cmdArgs) {
|
|
|
49
53
|
}
|
|
50
54
|
if (kind === 'codex') return buildCodexCommand(id, opts);
|
|
51
55
|
if (kind === 'claude') return buildClaudeCommand(id, opts);
|
|
52
|
-
if (hasResumeOpts(opts)) throw new CliError('BAD_ARGS', `Resume options are only supported for codex and claude peers`);
|
|
53
56
|
const command = defaultSessionCommand(kind);
|
|
57
|
+
if (hasResumeOpts(opts)) throw new CliError('BAD_ARGS', `Resume options are only supported for codex and claude peers`);
|
|
54
58
|
return {
|
|
55
59
|
command,
|
|
56
60
|
binding: {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { CliError } from '../shared/errors.mjs';
|
|
8
|
+
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
9
|
+
|
|
10
|
+
const PATH_KEY = 'HCC_MCP_BOOTSTRAP_PATH';
|
|
11
|
+
const TOKEN_KEY = 'HCC_MCP_BOOTSTRAP_TOKEN';
|
|
12
|
+
const fail = () => { throw new CliError('MCP_SCOPE_INVALID', 'The scoped MCP capability is missing, revoked, or no longer owns this peer'); };
|
|
13
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
const canonical = value => fs.realpathSync(path.resolve(value));
|
|
15
|
+
function validIdentity(value) {
|
|
16
|
+
return value && Number.isSafeInteger(value.pid) && value.pid > 0 &&
|
|
17
|
+
typeof value.startToken === 'string' && value.startToken.length > 0 && value.startToken.length < 512 &&
|
|
18
|
+
typeof value.commandHash === 'string' && /^[a-f0-9]{64}$/.test(value.commandHash);
|
|
19
|
+
}
|
|
20
|
+
function sameIdentity(a, b) {
|
|
21
|
+
return a?.pid === b?.pid && a?.startToken === b?.startToken && a?.commandHash === b?.commandHash;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Per executor only: callers pass this entry to its thread configuration. No
|
|
25
|
+
// user/global Codex configuration is read or written.
|
|
26
|
+
export function createScopedMcpConfig({ root, dbPath, peer, executorId, ownerIdentity,
|
|
27
|
+
binding = { transport: 'app-server', runtimeSessionId: peer },
|
|
28
|
+
cliPath = fileURLToPath(new URL('../../bin/hcc.mjs', import.meta.url)) }) {
|
|
29
|
+
if (typeof peer !== 'string' || !peer.trim() || peer.length > 256 || /[\r\n\0]/.test(peer) ||
|
|
30
|
+
typeof executorId !== 'string' || !executorId || executorId.length > 256 || !validIdentity(ownerIdentity) ||
|
|
31
|
+
(binding.transport === 'native' && (binding.runtimeTarget !== executorId || binding.runtimeSessionId !== peer))) fail();
|
|
32
|
+
const scope = { root: canonical(root), dbPath: canonical(dbPath), peer, executorId,
|
|
33
|
+
ownerIdentity: { ...ownerIdentity }, binding: { ...binding } };
|
|
34
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-mcp-'));
|
|
35
|
+
fs.chmodSync(directory, 0o700);
|
|
36
|
+
const bootstrapPath = path.join(directory, 'scope.json');
|
|
37
|
+
const token = randomBytes(32).toString('base64url');
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(bootstrapPath, JSON.stringify({ version: 1, ...scope, tokenHash: digest(token) }),
|
|
40
|
+
{ flag: 'wx', mode: 0o600 });
|
|
41
|
+
} catch (error) { fs.rmSync(directory, { recursive: true, force: true }); throw error; }
|
|
42
|
+
let disposed = false;
|
|
43
|
+
return {
|
|
44
|
+
config: { command: process.execPath,
|
|
45
|
+
args: [canonical(cliPath), '--root', scope.root, '--db', scope.dbPath, 'mcp', 'serve', '--peer', peer],
|
|
46
|
+
env: { [PATH_KEY]: bootstrapPath, [TOKEN_KEY]: token } },
|
|
47
|
+
dispose() { if (!disposed) { disposed = true; fs.rmSync(directory, { recursive: true, force: true }); } }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadScopedMcpBootstrap(ctx, peer, env = process.env) {
|
|
52
|
+
const bootstrapPath = env[PATH_KEY], token = env[TOKEN_KEY];
|
|
53
|
+
if (typeof bootstrapPath !== 'string' || !path.isAbsolute(bootstrapPath) ||
|
|
54
|
+
typeof token !== 'string' || token.length < 32 || token.length > 256) fail();
|
|
55
|
+
function read() {
|
|
56
|
+
let fd;
|
|
57
|
+
try {
|
|
58
|
+
const parent = fs.lstatSync(path.dirname(bootstrapPath));
|
|
59
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() ||
|
|
60
|
+
(process.platform !== 'win32' && (parent.mode & 0o077))) fail();
|
|
61
|
+
fd = fs.openSync(bootstrapPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
62
|
+
const stat = fs.fstatSync(fd);
|
|
63
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 16384 ||
|
|
64
|
+
(process.platform !== 'win32' && (stat.mode & 0o077)) ||
|
|
65
|
+
(process.getuid && stat.uid !== process.getuid())) fail();
|
|
66
|
+
const value = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
67
|
+
if (value.version !== 1 || typeof value.tokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.tokenHash) ||
|
|
68
|
+
!timingSafeEqual(Buffer.from(value.tokenHash, 'hex'), Buffer.from(digest(token), 'hex')) ||
|
|
69
|
+
value.root !== canonical(ctx.root) || value.dbPath !== canonical(ctx.dbPath) ||
|
|
70
|
+
value.peer !== peer || !validIdentity(value.ownerIdentity) ||
|
|
71
|
+
typeof value.executorId !== 'string' || !value.executorId || !value.binding ||
|
|
72
|
+
typeof value.binding.transport !== 'string' ||
|
|
73
|
+
(value.binding.transport === 'native' && (value.binding.runtimeTarget !== value.executorId || value.binding.runtimeSessionId !== peer))) fail();
|
|
74
|
+
return value;
|
|
75
|
+
} catch { fail(); } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
76
|
+
}
|
|
77
|
+
const original = read(), originalHash = digest(JSON.stringify(original));
|
|
78
|
+
function assertValid() {
|
|
79
|
+
const value = read();
|
|
80
|
+
if (digest(JSON.stringify(value)) !== originalHash) fail();
|
|
81
|
+
const observation = inspectProcessIdentity(value.ownerIdentity.pid);
|
|
82
|
+
if (observation.state !== 'live' || !sameIdentity(value.ownerIdentity, observation.identity)) fail();
|
|
83
|
+
}
|
|
84
|
+
function assertOwnership(db) {
|
|
85
|
+
assertValid();
|
|
86
|
+
const row = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer);
|
|
87
|
+
if (!row || ['exited', 'detached'].includes(row.status) || !sameIdentity(original.ownerIdentity,
|
|
88
|
+
{ pid: Number(row.pid), startToken: row.pid_start_token, commandHash: row.pid_command_hash })) fail();
|
|
89
|
+
const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peer);
|
|
90
|
+
if (!binding || binding.transport !== original.binding.transport ||
|
|
91
|
+
(original.binding.runtimeSessionId !== undefined && binding.runtime_session_id !== original.binding.runtimeSessionId) ||
|
|
92
|
+
(original.binding.runtimeTarget !== undefined && binding.runtime_target !== original.binding.runtimeTarget)) fail();
|
|
93
|
+
if (original.binding.transport === 'app-server') {
|
|
94
|
+
const receipt = db.prepare(`SELECT payload FROM events WHERE type = 'codex.executor.started'
|
|
95
|
+
AND actor = ? ORDER BY id DESC LIMIT 1`).get(peer);
|
|
96
|
+
let executor;
|
|
97
|
+
try { executor = JSON.parse(receipt?.payload || '{}').executor_id; } catch { fail(); }
|
|
98
|
+
if (executor !== original.executorId) fail();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
assertValid();
|
|
102
|
+
return { scope: Object.freeze({ root: original.root, dbPath: original.dbPath, peer,
|
|
103
|
+
executorId: original.executorId }), assertValid, assertOwnership };
|
|
104
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
|
|
5
|
+
// Official JSONL stdio transport; batch envelopes are not part of these MCP
|
|
6
|
+
// versions. Stdout contains protocol messages only, including parse errors.
|
|
7
|
+
export const MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
8
|
+
const object = value => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
9
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
10
|
+
const validId = value => (typeof value === 'string' && value.length <= 256) || Number.isSafeInteger(value);
|
|
11
|
+
|
|
12
|
+
export async function serveMcpStdio({ tools, input = process.stdin, output = process.stdout,
|
|
13
|
+
serverVersion = '1.0.1', maxLineBytes = 1024 * 1024 }) {
|
|
14
|
+
let initialized = false, ready = false;
|
|
15
|
+
const decoder = new StringDecoder('utf8');
|
|
16
|
+
async function send(value) {
|
|
17
|
+
if (!output.write(JSON.stringify(value) + '\n')) await once(output, 'drain');
|
|
18
|
+
}
|
|
19
|
+
const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
20
|
+
async function handle(line) {
|
|
21
|
+
let request;
|
|
22
|
+
try { request = JSON.parse(line); } catch { return error(null, -32700, 'Parse error'); }
|
|
23
|
+
if (!object(request) || request.jsonrpc !== '2.0' || typeof request.method !== 'string' ||
|
|
24
|
+
(own(request, 'id') && !validId(request.id)) ||
|
|
25
|
+
(request.params !== undefined && !object(request.params))) {
|
|
26
|
+
return error(validId(request?.id) ? request.id : null, -32600, 'Invalid Request');
|
|
27
|
+
}
|
|
28
|
+
const { method, params = {}, id } = request;
|
|
29
|
+
if (!own(request, 'id')) {
|
|
30
|
+
if (method === 'notifications/initialized' && initialized) ready = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (method === 'ping') return send({ jsonrpc: '2.0', id, result: {} });
|
|
34
|
+
if (method === 'initialize') {
|
|
35
|
+
if (initialized) return error(id, -32600, 'Already initialized');
|
|
36
|
+
if (typeof params.protocolVersion !== 'string' || !object(params.capabilities) ||
|
|
37
|
+
!object(params.clientInfo) || typeof params.clientInfo.name !== 'string' ||
|
|
38
|
+
typeof params.clientInfo.version !== 'string') return error(id, -32602, 'Invalid initialize parameters');
|
|
39
|
+
initialized = true;
|
|
40
|
+
return send({ jsonrpc: '2.0', id, result: {
|
|
41
|
+
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(params.protocolVersion) ? params.protocolVersion : MCP_PROTOCOL_VERSIONS[0],
|
|
42
|
+
capabilities: { tools: { listChanged: false } },
|
|
43
|
+
serverInfo: { name: 'hello-cc-scoped', version: serverVersion },
|
|
44
|
+
instructions: 'Tools are restricted to the project and peer supplied by this executor. Task claiming and handoff do not complete tasks.'
|
|
45
|
+
} });
|
|
46
|
+
}
|
|
47
|
+
if (!ready) return error(id, -32002, 'Send initialize and notifications/initialized first');
|
|
48
|
+
if (method === 'tools/list') {
|
|
49
|
+
if (params.cursor !== undefined) return error(id, -32602, 'This server does not paginate tools');
|
|
50
|
+
return send({ jsonrpc: '2.0', id, result: { tools: tools.list() } });
|
|
51
|
+
}
|
|
52
|
+
if (method === 'tools/call') {
|
|
53
|
+
if (typeof params.name !== 'string' || !tools.has(params.name)) return error(id, -32602, 'Unknown tool');
|
|
54
|
+
if (params.arguments !== undefined && !object(params.arguments)) return error(id, -32602, 'Tool arguments must be an object');
|
|
55
|
+
return send({ jsonrpc: '2.0', id, result: await tools.call(params.name, params.arguments || {}) });
|
|
56
|
+
}
|
|
57
|
+
return error(id, -32601, 'Method not found');
|
|
58
|
+
}
|
|
59
|
+
let pending = '', dropping = false;
|
|
60
|
+
for await (const bytes of input) {
|
|
61
|
+
const text = typeof bytes === 'string' ? bytes : decoder.write(bytes);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const newline = text.indexOf('\n', offset);
|
|
65
|
+
const piece = text.slice(offset, newline < 0 ? undefined : newline);
|
|
66
|
+
if (!dropping) {
|
|
67
|
+
pending += piece;
|
|
68
|
+
if (Buffer.byteLength(pending) > maxLineBytes) {
|
|
69
|
+
pending = ''; dropping = true;
|
|
70
|
+
await error(null, -32600, 'Request line is too large');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (newline < 0) break;
|
|
74
|
+
if (!dropping && pending.trim()) await handle(pending);
|
|
75
|
+
pending = ''; dropping = false; offset = newline + 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
pending += decoder.end();
|
|
79
|
+
if (!dropping && pending.trim()) await handle(pending);
|
|
80
|
+
}
|