@logicseek/hello-cc 1.1.0-rc.3 → 1.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/docs/README.md +2 -0
  3. package/docs/README.zh-CN.md +2 -0
  4. package/docs/app-bridge.md +110 -0
  5. package/docs/app-bridge.zh-CN.md +80 -0
  6. package/docs/dsh.md +1 -1
  7. package/docs/dsh.zh-CN.md +1 -1
  8. package/docs/native.md +81 -2
  9. package/docs/native.zh-CN.md +68 -2
  10. package/lib/cli/app.mjs +3 -0
  11. package/lib/cli/commands/app-bridge.mjs +89 -0
  12. package/lib/cli/commands/hook.mjs +21 -2
  13. package/lib/cli/commands/native.mjs +42 -8
  14. package/lib/coordination-state.mjs +5 -2
  15. package/lib/core/coordination/messages.mjs +7 -2
  16. package/lib/integrations/claude-app-collaboration.mjs +222 -0
  17. package/lib/integrations/claude-app.mjs +289 -0
  18. package/lib/integrations/claude-mod/register.mjs +141 -0
  19. package/lib/integrations/codex-app-probe.mjs +213 -0
  20. package/lib/integrations/dsh-collaboration.mjs +19 -2
  21. package/lib/integrations/dsh-cordis.d.ts +2 -0
  22. package/lib/integrations/dsh-cordis.mjs +26 -33
  23. package/lib/integrations/dsh-inbox.mjs +251 -0
  24. package/lib/integrations/native/claude.mjs +29 -3
  25. package/lib/integrations/native/codex.mjs +38 -9
  26. package/lib/integrations/native/dsh-acp.mjs +10 -0
  27. package/lib/integrations/native/interactions.mjs +16 -3
  28. package/lib/runtime/native/client.mjs +8 -1
  29. package/lib/runtime/native/service.mjs +189 -28
  30. package/lib/runtime/native/store.mjs +28 -4
  31. package/lib/ui/help.mjs +1 -0
  32. package/lib/web/browser/core.mjs +9 -1
  33. package/lib/web/http-routes.mjs +1 -1
  34. package/lib/web/native-sessions.mjs +53 -13
  35. package/lib/web/ui-i18n.mjs +6 -0
  36. package/lib/web/ui-native.mjs +22 -2
  37. package/lib/web/ui-template.mjs +2 -2
  38. package/lib/web/ui-workspace.mjs +6 -2
  39. package/package.json +1 -1
  40. package/scripts/dsh-inbox-acceptance.mjs +210 -0
  41. package/scripts/helpers/gc-clock-observation.mjs +33 -0
  42. package/scripts/native-installed-acceptance.mjs +80 -19
  43. package/scripts/regression.mjs +18 -2
  44. package/scripts/web-native-retry-checks.mjs +177 -0
  45. package/scripts/web-workbench-acceptance.mjs +7 -4
@@ -0,0 +1,289 @@
1
+ import http from 'node:http';
2
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { CliError } from '../shared/errors.mjs';
8
+ import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
9
+
10
+ export const CLAUDE_APP_MIN_VERSION = '2.1.287';
11
+ export const CLAUDE_APP_PROTOCOL = 1;
12
+ const TERMINAL = new Set(['completed', 'aborted', 'uncertain', 'rejected']);
13
+ const clone = value => structuredClone(value);
14
+ const fail = (code, message) => new CliError(code, message);
15
+
16
+ export function claudeAppCapability(version) {
17
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || ''));
18
+ const parts = match?.slice(1).map(Number);
19
+ const supported = !!parts && (parts[0] > 2 || parts[0] === 2 &&
20
+ (parts[1] > 1 || parts[1] === 1 && parts[2] >= 287));
21
+ return { supported, version: version || null, minimumVersion: CLAUDE_APP_MIN_VERSION,
22
+ reason: supported ? null : 'CLAUDE_APP_ENGINE_UNSUPPORTED' };
23
+ }
24
+
25
+ function identifier(value, name) {
26
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value)) {
27
+ throw fail('BAD_ARGS', `Invalid ${name}`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function fields(value, allowed) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
34
+ Object.keys(value).some(key => !allowed.includes(key))) throw fail('BAD_ARGS', 'Unexpected bridge fields');
35
+ }
36
+
37
+ function reply(res, status, data) {
38
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
39
+ res.end(JSON.stringify(data));
40
+ }
41
+
42
+ async function readBody(req) {
43
+ let size = 0;
44
+ const chunks = [];
45
+ for await (const chunk of req) {
46
+ size += chunk.length;
47
+ if (size > 256 * 1024) throw fail('BAD_ARGS', 'Bridge body exceeds 256 KiB');
48
+ chunks.push(chunk);
49
+ }
50
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
51
+ catch { throw fail('BAD_ARGS', 'Expected a JSON object'); }
52
+ }
53
+
54
+ /** A local capability for ONE explicitly selected existing Desktop session.
55
+ * No Claude account credentials, transcript files, peer bindings or settings are read.
56
+ * The secret authorizes this HCC bridge only. Same-OS-user code that can read a
57
+ * generated plugin is inside this boundary; it is not provider attestation.
58
+ */
59
+ export async function createClaudeAppBridge({ root, initialRootIdentity = null, sessionId, port = 0, pollMs = 1000,
60
+ leaseMs = 15000, maxRequests = 256, onConnected, onDisconnected, onStatus, onRequest } = {}) {
61
+ identifier(sessionId, 'sessionId');
62
+ if (!Number.isInteger(port) || port < 0 || port > 65535 ||
63
+ !Number.isInteger(pollMs) || pollMs < 100 || pollMs > 10000 ||
64
+ !Number.isInteger(leaseMs) || leaseMs < pollMs * 3 || leaseMs > 120000 ||
65
+ !Number.isInteger(maxRequests) || maxRequests < 1 || maxRequests > 4096) {
66
+ throw fail('BAD_ARGS', 'Invalid Claude bridge limits');
67
+ }
68
+ const cwdBinding = captureSelectedCwdIdentity(root, initialRootIdentity);
69
+ const secret = randomBytes(32).toString('base64url');
70
+ const requests = new Map();
71
+ const events = new Map();
72
+ const maxEvents = Math.max(256, maxRequests * 4);
73
+ let closed = false, connection = null, address;
74
+ const publicStatus = () => ({ sessionId, cwd: cwdBinding.canonical,
75
+ version: connection?.version || null, ready: Boolean(connection?.ready && !closed),
76
+ surfaces: connection?.surfaces || [], transport: 'claude-desktop-mod',
77
+ minimumVersion: CLAUDE_APP_MIN_VERSION, reason: connection?.reason || null });
78
+ function notify(callback, value) {
79
+ try { Promise.resolve(callback?.(clone(value))).catch(() => {}); } catch { /* observers cannot change delivery */ }
80
+ }
81
+ function publish(request) { notify(onRequest, request); return clone(request); }
82
+ function disconnect(reason) {
83
+ const wasReady = connection?.ready;
84
+ if (connection) { connection.ready = false; connection.reason = reason; }
85
+ for (const request of requests.values()) {
86
+ if (!TERMINAL.has(request.status)) {
87
+ request.status = 'uncertain'; request.reason = reason; publish(request);
88
+ }
89
+ }
90
+ if (wasReady) notify(onDisconnected, publicStatus());
91
+ notify(onStatus, publicStatus());
92
+ }
93
+ function check() {
94
+ if (closed) throw fail('CLAUDE_APP_CLOSED', 'Claude Desktop bridge is closed');
95
+ try { cwdBinding.assertUnchanged(); }
96
+ catch (error) { disconnect('PROJECT_PATH_CHANGED'); throw error; }
97
+ if (connection?.ready && Date.now() - connection.lastSeen > leaseMs) disconnect('CLAUDE_APP_CONNECTION_EXPIRED');
98
+ }
99
+ function assertIdentity(body, connected = true) {
100
+ if (body.sessionId !== sessionId || body.cwd !== cwdBinding.canonical) {
101
+ throw fail('CLAUDE_APP_IDENTITY_MISMATCH', 'The session or directory differs from this bridge');
102
+ }
103
+ if (connected && (!connection?.ready || body.connectionId !== connection.id)) {
104
+ throw fail('CLAUDE_APP_CONNECTION_STALE', 'This mod connection is no longer active');
105
+ }
106
+ }
107
+ function applyEvent(body) {
108
+ fields(body, ['sessionId', 'cwd', 'connectionId', 'eventId', 'requestId', 'type', 'turnId', 'answer', 'reason']);
109
+ assertIdentity(body);
110
+ identifier(body.eventId, 'eventId'); identifier(body.requestId, 'requestId');
111
+ const digest = createHash('sha256').update(JSON.stringify(body)).digest('hex');
112
+ if (events.has(body.eventId)) {
113
+ if (events.get(body.eventId) !== digest) throw fail('CLAUDE_APP_EVENT_CONFLICT', 'Event ID was reused with different content');
114
+ return { ok: true };
115
+ }
116
+ const request = requests.get(body.requestId);
117
+ if (!request || request.connectionId !== connection.id) throw fail('CLAUDE_APP_REQUEST_UNKNOWN', 'Request is not owned by this connection');
118
+ if (TERMINAL.has(request.status)) throw fail('CLAUDE_APP_REQUEST_SETTLED', 'Request already reached a final state');
119
+ if (body.type === 'accepted') {
120
+ if (request.status !== 'claimed' || request.accepted) throw fail('CLAUDE_APP_EVENT_ORDER', 'Only a newly polled request can be accepted');
121
+ request.accepted = true;
122
+ } else if (body.type === 'started') {
123
+ identifier(body.turnId, 'turnId');
124
+ if (!request.accepted || request.status !== 'claimed') throw fail('CLAUDE_APP_EVENT_ORDER', 'A start requires an accepted request');
125
+ request.status = 'started'; request.turnId = body.turnId;
126
+ } else if (body.type === 'completed' || body.type === 'aborted') {
127
+ if (request.status !== 'started' || body.turnId !== request.turnId) {
128
+ throw fail('CLAUDE_APP_TURN_MISMATCH', 'Only the request’s observed turn can complete it');
129
+ }
130
+ if (typeof body.answer !== 'string' || body.answer.length > 100000) throw fail('BAD_ARGS', 'Invalid final answer');
131
+ request.status = body.type; request.answer = body.answer; request.reason = body.reason || null;
132
+ } else if (body.type === 'uncertain' || body.type === 'rejected') {
133
+ if (!request.accepted) throw fail('CLAUDE_APP_EVENT_ORDER', 'A rejection requires an accepted request');
134
+ request.status = body.type; request.reason = String(body.reason || body.type).slice(0,500);
135
+ } else throw fail('BAD_ARGS', 'Unknown bridge event');
136
+ // Keep small receipt digests after request release: a lost HTTP response
137
+ // retries this exact event, even if its durable owner already released it.
138
+ events.set(body.eventId, digest);
139
+ if (events.size > maxEvents) events.delete(events.keys().next().value);
140
+ publish(request);
141
+ return { ok: true };
142
+ }
143
+ const server = http.createServer(async (req, res) => {
144
+ try {
145
+ // No browser origin can use this API, even with an accidentally disclosed token.
146
+ if (req.headers.origin || req.headers.host !== new URL(address).host || req.method !== 'POST') {
147
+ return reply(res, 403, { error: { code: 'CLAUDE_APP_FORBIDDEN' } });
148
+ }
149
+ const presented = Buffer.from(String(req.headers.authorization || ''));
150
+ const expected = Buffer.from(`Bearer ${secret}`);
151
+ if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
152
+ return reply(res, 401, { error: { code: 'CLAUDE_APP_UNAUTHORIZED' } });
153
+ }
154
+ if (!['/connect', '/poll', '/event', '/disconnect'].includes(req.url)) return reply(res, 404, { error: { code: 'NOT_FOUND' } });
155
+ check();
156
+ const body = await readBody(req);
157
+ check();
158
+ if (req.url === '/connect') {
159
+ fields(body, ['sessionId', 'cwd', 'version', 'surfaces', 'instanceId']);
160
+ assertIdentity(body, false); identifier(body.instanceId, 'instanceId');
161
+ if (!claudeAppCapability(body.version).supported) throw fail('CLAUDE_APP_ENGINE_UNSUPPORTED', `Claude Code ${CLAUDE_APP_MIN_VERSION} or later is required`);
162
+ if (!Array.isArray(body.surfaces) || !body.surfaces.includes('desktop') || body.surfaces.some(x => typeof x !== 'string')) {
163
+ throw fail('CLAUDE_APP_DESKTOP_REQUIRED', 'The current session has no Desktop surface');
164
+ }
165
+ if (connection?.ready && connection.instanceId !== body.instanceId) throw fail('CLAUDE_APP_ALREADY_CONNECTED', 'A mod instance is already connected');
166
+ if (!connection?.ready) {
167
+ connection = { id: randomUUID(), instanceId: body.instanceId, version: body.version,
168
+ surfaces: body.surfaces.slice(), ready: true, lastSeen: Date.now(), reason: null };
169
+ notify(onConnected, publicStatus()); notify(onStatus, publicStatus());
170
+ }
171
+ connection.lastSeen = Date.now();
172
+ return reply(res, 200, { protocol: CLAUDE_APP_PROTOCOL, connectionId: connection.id });
173
+ }
174
+ assertIdentity(body);
175
+ connection.lastSeen = Date.now();
176
+ if (req.url === '/poll') {
177
+ fields(body, ['sessionId', 'cwd', 'connectionId']);
178
+ const request = [...requests.values()].find(x => x.connectionId === connection.id &&
179
+ (x.status === 'queued' || x.status === 'claimed') && !x.accepted);
180
+ if (request?.status === 'queued') { request.status = 'claimed'; publish(request); }
181
+ return reply(res, 200, { request: request ? { requestId: request.requestId, prompt: request.prompt } : null });
182
+ }
183
+ if (req.url === '/event') return reply(res, 200, applyEvent(body));
184
+ fields(body, ['sessionId', 'cwd', 'connectionId', 'reason']);
185
+ disconnect(String(body.reason || 'session_ended').slice(0,200));
186
+ return reply(res, 200, { ok: true });
187
+ } catch (error) {
188
+ if (!res.headersSent) reply(res, error?.code === 'BAD_ARGS' ? 400 : 409,
189
+ { error: { code: error?.code || 'CLAUDE_APP_ERROR', message: error?.message || 'Bridge error' } });
190
+ }
191
+ });
192
+ server.requestTimeout = 5000; server.headersTimeout = 5000;
193
+ try {
194
+ await new Promise((resolve, reject) => {
195
+ server.once('error', reject);
196
+ server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(); });
197
+ });
198
+ } catch (error) { cwdBinding.release(); throw error; }
199
+ address = `http://127.0.0.1:${server.address().port}`;
200
+ const sweep = setInterval(() => { try { check(); } catch {} }, pollMs);
201
+ sweep.unref();
202
+ const pluginConfig = Object.freeze({ protocol: CLAUDE_APP_PROTOCOL, endpoint: address, secret,
203
+ sessionId, cwd: cwdBinding.canonical, minVersion: CLAUDE_APP_MIN_VERSION, pollMs });
204
+ return {
205
+ address, pluginConfig,
206
+ list() { check(); return [clone(publicStatus())]; },
207
+ send(input) {
208
+ fields(input, ['sessionId', 'text', 'requestId']); check();
209
+ if (input.sessionId !== sessionId) throw fail('CLAUDE_APP_IDENTITY_MISMATCH', 'Target differs from the bound Desktop session');
210
+ identifier(input.requestId, 'requestId');
211
+ if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 64000) throw fail('BAD_ARGS', 'Message must contain 1–64000 characters');
212
+ const existing = requests.get(input.requestId);
213
+ if (existing) {
214
+ if (existing.text !== input.text) throw fail('CLAUDE_APP_REQUEST_CONFLICT', 'Request ID was reused with different content');
215
+ return clone(existing);
216
+ }
217
+ if (!connection?.ready) throw fail('CLAUDE_APP_NOT_CONNECTED', 'The selected Desktop session has not connected its mod');
218
+ if (requests.size >= maxRequests) throw fail('CLAUDE_APP_QUEUE_FULL', 'Bridge request capacity reached; persist and release terminal requests before sending more');
219
+ const request = { requestId: input.requestId, sessionId, cwd: cwdBinding.canonical,
220
+ text: input.text, prompt: `[HCC message ${randomUUID()}]\n${input.text}`,
221
+ connectionId: connection.id, status: 'queued', accepted: false, turnId: null,
222
+ answer: null, reason: null };
223
+ requests.set(request.requestId, request); return publish(request);
224
+ },
225
+ getRequest(id) { check(); return clone(requests.get(id) || null); },
226
+ // The caller must persist the terminal receipt and own request-id
227
+ // idempotency before releasing the in-memory body and its capacity.
228
+ releaseRequest(id) {
229
+ identifier(id, 'requestId'); check();
230
+ const request = requests.get(id);
231
+ if (!request) return false;
232
+ if (!TERMINAL.has(request.status)) throw fail('CLAUDE_APP_REQUEST_PENDING', 'Only terminal requests can be released');
233
+ return requests.delete(id);
234
+ },
235
+ async close() {
236
+ if (closed) return;
237
+ disconnect('CLAUDE_APP_CLOSED'); closed = true; clearInterval(sweep);
238
+ await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); });
239
+ cwdBinding.release();
240
+ }
241
+ };
242
+ }
243
+
244
+ /** Generate a capability-bearing plugin in a NEW private directory. Never installs it. */
245
+ export function writeClaudeAppPlugin({ directory, bridge } = {}) {
246
+ if (!bridge?.pluginConfig || bridge.pluginConfig.protocol !== CLAUDE_APP_PROTOCOL) throw fail('BAD_ARGS', 'A running Claude bridge is required');
247
+ let output;
248
+ if (directory) { output = path.resolve(directory); fs.mkdirSync(output, { mode: 0o700 }); }
249
+ else output = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-session-link-'));
250
+ const identity = fs.lstatSync(output);
251
+ function dispose() {
252
+ let current;
253
+ try { current = fs.lstatSync(output); }
254
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
255
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== identity.dev ||
256
+ current.ino !== identity.ino || current.birthtimeMs !== identity.birthtimeMs) {
257
+ throw fail('CLAUDE_APP_PLUGIN_PATH_CHANGED', 'Generated plugin directory was replaced; refusing to remove it');
258
+ }
259
+ // This catches replacement before cleanup. Same-user concurrent pathname
260
+ // races are not an atomic filesystem security boundary.
261
+ fs.rmSync(output, { recursive: true, force: true });
262
+ }
263
+ try {
264
+ fs.chmodSync(output, 0o700);
265
+ const runId = randomBytes(12).toString('hex');
266
+ const name = 'hcc-session-link', marketplace = `hcc-session-link-${runId}`, version = `0.1.0-bridge.${runId}`;
267
+ const pluginDirectory = path.join(output, 'plugin');
268
+ fs.mkdirSync(path.join(output, '.claude-plugin'), { mode: 0o700 });
269
+ fs.mkdirSync(pluginDirectory, { mode: 0o700 });
270
+ fs.mkdirSync(path.join(pluginDirectory, '.claude-plugin'), { mode: 0o700 });
271
+ fs.mkdirSync(path.join(pluginDirectory, 'hooks'), { mode: 0o700 });
272
+ const template = fs.readFileSync(fileURLToPath(new URL('./claude-mod/register.mjs', import.meta.url)), 'utf8');
273
+ const source = template.replace('const HCC_CONFIG = null;', `const HCC_CONFIG = ${JSON.stringify(bridge.pluginConfig)};`);
274
+ fs.writeFileSync(path.join(output, '.claude-plugin/marketplace.json'), JSON.stringify({
275
+ name: marketplace, description: 'Session-scoped hello-cc desktop collaboration bridge',
276
+ owner: { name: 'hello-cc' }, plugins: [{ name, source: './plugin', version,
277
+ description: 'Connect one explicitly selected existing Claude Desktop Code session' }]
278
+ }, null, 2), { flag: 'wx', mode: 0o600 });
279
+ fs.writeFileSync(path.join(pluginDirectory, '.claude-plugin/plugin.json'), JSON.stringify({ name, version, author: { name: 'hello-cc' },
280
+ description: 'Explicitly connect this existing Claude Desktop Code session to hello-cc' }, null, 2), { flag: 'wx', mode: 0o600 });
281
+ fs.writeFileSync(path.join(pluginDirectory, 'hooks/hooks.json'), JSON.stringify({ modules: ['./register.mjs'] }, null, 2), { flag: 'wx', mode: 0o600 });
282
+ fs.writeFileSync(path.join(pluginDirectory, 'hooks/register.mjs'), source, { flag: 'wx', mode: 0o600 });
283
+ return { directory: pluginDirectory, marketplaceDirectory: output, name, marketplace, version,
284
+ minimumVersion: CLAUDE_APP_MIN_VERSION, sessionId: bridge.pluginConfig.sessionId, dispose };
285
+ } catch (error) {
286
+ try { dispose(); } catch (cleanupError) { cleanupError.cause = error; throw cleanupError; }
287
+ throw error;
288
+ }
289
+ }
@@ -0,0 +1,141 @@
1
+ // Generated plugins replace this constant with one session-scoped HCC capability.
2
+ // Official contract: https://code.claude.com/docs/en/plugins/mods/api
3
+ // Exact event fields: https://github.com/anthropics/claude-code/blob/main/mods/types/claude-code.d.ts
4
+ // No Node imports, credentials, permission changes, other-session sends or transcript reads.
5
+ const HCC_CONFIG = null;
6
+
7
+ const config = HCC_CONFIG;
8
+ let alive = false, busy = false, ticking = false, connectionId = null, pending = null;
9
+ let timer = null, instanceId = '', eventSequence = 0;
10
+ const outbox = [];
11
+ const observed = new Set();
12
+
13
+ function event(type, request, details = {}) {
14
+ outbox.push({ eventId: `${instanceId}:${++eventSequence}`, requestId: request.requestId, type, ...details });
15
+ }
16
+ async function identity($) {
17
+ return await $.session.id() === config.sessionId && await $.session.cwd() === config.cwd;
18
+ }
19
+ async function post($, route, body) {
20
+ const response = await $.http.fetch(config.endpoint + route, { method: 'POST',
21
+ headers: { Authorization: `Bearer ${config.secret}`, 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ sessionId: config.sessionId, cwd: config.cwd, ...body }) });
23
+ const value = JSON.parse(response.text);
24
+ if (!response.ok) throw new Error(value.error?.code || 'CLAUDE_APP_HTTP_ERROR');
25
+ return value;
26
+ }
27
+ async function flush($) {
28
+ while (outbox.length && alive && connectionId) {
29
+ await post($, '/event', { connectionId, ...outbox[0] });
30
+ outbox.shift();
31
+ }
32
+ }
33
+ async function tick($) {
34
+ if (!alive || ticking) return;
35
+ ticking = true;
36
+ try {
37
+ if (!await identity($)) { await stop($, 'CLAUDE_APP_IDENTITY_CHANGED'); return; }
38
+ if (!connectionId) {
39
+ const version = await $.session.version();
40
+ const surfaces = await $.session.surfaces();
41
+ const response = await post($, '/connect', { version: version.version, surfaces, instanceId });
42
+ if (!alive) return;
43
+ connectionId = response.connectionId;
44
+ }
45
+ await flush($);
46
+ if (!alive) return;
47
+ // Poll even while busy, so an approval dialog does not expire the lease.
48
+ const response = await post($, '/poll', { connectionId });
49
+ if (!alive || !await identity($)) return;
50
+ if (busy || pending?.invoking) return;
51
+ if (!pending) {
52
+ if (!response.request) return;
53
+ const request = response.request;
54
+ if (observed.has(request.requestId)) return; // never replay after an ambiguous receipt
55
+ observed.add(request.requestId);
56
+ pending = { ...request, turnId: null, invoking: false };
57
+ event('accepted', pending);
58
+ }
59
+ await flush($); // retry a lost receipt, never the actual model submission
60
+ if (!alive || !await identity($)) return;
61
+ const owned = pending;
62
+ owned.invoking = true;
63
+ // Do not await in the timer or a running turn. This call waits until idle.
64
+ // Keep the plugin's attribution; never use asUser or modify permission state.
65
+ Promise.resolve().then(() => $.prompt.submit({ text: owned.prompt })).then(result => {
66
+ if (!alive || pending !== owned) return;
67
+ if (result?.drop) { event('rejected', owned, { reason: String(result.drop).slice(0,500) }); pending = null; }
68
+ else if (!owned.turnId && typeof result?.text === 'string' && result.text !== owned.prompt) {
69
+ event('uncertain', owned, { reason: 'prompt_changed_by_another_hook' }); pending = null;
70
+ }
71
+ // Resolution alone is NOT evidence the model saw it. turn.start binds it.
72
+ }).catch(() => {
73
+ if (!alive || pending !== owned) return;
74
+ event('uncertain', owned, { reason: 'prompt_submit_failed' }); pending = null;
75
+ });
76
+ } catch (error) {
77
+ // A replacement/revocation is final. Transient I/O retains the outbox.
78
+ if (['CLAUDE_APP_CONNECTION_STALE', 'CLAUDE_APP_IDENTITY_MISMATCH', 'PROJECT_PATH_CHANGED'].includes(error?.message)) {
79
+ await stop($, error.message);
80
+ }
81
+ }
82
+ finally { ticking = false; }
83
+ }
84
+ async function stop($, reason) {
85
+ alive = false;
86
+ timer?.cancel(); timer = null;
87
+ const old = connectionId; connectionId = null; pending = null;
88
+ if (old) { try { await post($, '/disconnect', { connectionId: old, reason }); } catch {} }
89
+ }
90
+ export function register(on) {
91
+ if (!config) return;
92
+ on('session.start', async ($, e, next) => {
93
+ try {
94
+ if (!await identity($)) return next(e);
95
+ instanceId = `mod-${await $.clock.now()}-${Math.random().toString(36).slice(2)}`;
96
+ alive = true;
97
+ timer = $.clock.every(config.pollMs, () => { void tick($); });
98
+ void tick($);
99
+ } catch { /* a missing/denied Mod API must not interfere with the session */ }
100
+ return next(e);
101
+ });
102
+ on('prompt.submit', async ($, e, next) => {
103
+ if (e.origin?.kind === 'plugin' && e.origin.name === 'hcc-session-link') {
104
+ if (!alive || !pending?.invoking || e.text !== pending.prompt || !await identity($)) {
105
+ return { drop: 'hello-cc connection or session identity changed' };
106
+ }
107
+ }
108
+ return next(e);
109
+ });
110
+ on('turn.start', async ($, e, next) => {
111
+ try {
112
+ if (alive && !e.agentId) {
113
+ busy = true;
114
+ if (pending?.invoking && e.text === pending.prompt && await identity($)) {
115
+ pending.turnId = e.turnId;
116
+ event('started', pending, { turnId: e.turnId });
117
+ }
118
+ }
119
+ } catch { /* observation failure cannot rewrite or stop the original turn */ }
120
+ return next(e);
121
+ });
122
+ on('turn.complete', async ($, e, next) => {
123
+ try {
124
+ if (!e.agentId) {
125
+ busy = false;
126
+ if (alive && pending?.turnId === e.turnId) {
127
+ if (await identity($)) {
128
+ const type = e.isAborted || e.reason === 'aborted' ? 'aborted' : e.reason === 'answer' ? 'completed' : 'uncertain';
129
+ event(type, pending, { turnId: e.turnId, answer: String(e.answer || '').slice(0,100000), reason: e.reason || 'unknown_turn_result' });
130
+ }
131
+ pending = null;
132
+ }
133
+ }
134
+ } catch { /* original completion always continues */ }
135
+ return next(e);
136
+ });
137
+ on('session.end', async ($, e, next) => {
138
+ try { await stop($, e.reason || 'session_ended'); }
139
+ finally { return next(e); }
140
+ });
141
+ }
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import path from 'node:path';
4
+ import WebSocket from 'ws';
5
+ import { CliError } from '../shared/errors.mjs';
6
+
7
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const text = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value);
9
+ const statuses = new Set(['notLoaded', 'idle', 'active', 'systemError']);
10
+ const methods = new Set(['initialize', 'thread/read', 'thread/loaded/list']);
11
+ const messages = Object.freeze({
12
+ DESKTOP_ENDPOINT_UNVERIFIED: 'The endpoint has not been verified as the server used by Codex Desktop.',
13
+ READ_ONLY_PROBE: 'This probe does not attach, resume, subscribe to, or send input to a thread.',
14
+ ENDPOINT_UNAVAILABLE: 'The explicitly selected Unix socket is unavailable.',
15
+ ENDPOINT_CHANGED: 'The selected Unix socket changed during connection setup.',
16
+ TRANSPORT_FAILED: 'The endpoint did not establish or retain the required WebSocket connection.',
17
+ PROBE_TIMEOUT: 'The bounded probe deadline expired.',
18
+ PROTOCOL_INVALID: 'The endpoint returned an invalid or unsupported protocol response.',
19
+ RESPONSE_LIMIT: 'The endpoint exceeded a probe response limit.',
20
+ METHOD_UNSUPPORTED: 'The endpoint does not support a required read-only method.',
21
+ REQUEST_REJECTED: 'The endpoint rejected a read-only probe request.',
22
+ THREAD_ID_MISMATCH: 'The returned thread identity did not match the requested thread.',
23
+ CWD_MISMATCH: 'The reported thread working directory did not match the expected absolute path.',
24
+ THREAD_NOT_LOADED: 'The target thread was not confirmed as loaded in this endpoint.',
25
+ THREAD_STATE_CHANGED: 'Thread metadata and the loaded-thread list did not agree.',
26
+ LOADED_LIST_INCOMPLETE: 'The loaded-thread list could not be completed within the page limit.',
27
+ SERVER_REQUEST_REJECTED: 'The endpoint requested an operation from the probe; the probe rejected it.',
28
+ DIRECT_INPUT_UNAVAILABLE: 'The endpoint did not advertise direct-input support for the target thread.'
29
+ });
30
+ const failure = code => Object.assign(new Error(code), { code });
31
+
32
+ function validate(options) {
33
+ if (!object(options)) throw new CliError('BAD_ARGS', 'Codex probing requires explicit endpoint, thread, and working-directory options.');
34
+ for (const key of ['socketPath', 'expectedCwd']) {
35
+ if (!text(options[key], 4096) || !path.isAbsolute(options[key])) {
36
+ throw new CliError('BAD_ARGS', `${key} must be an explicit absolute path.`);
37
+ }
38
+ }
39
+ if (!text(options.threadId, 512)) throw new CliError('BAD_ARGS', 'threadId must be a nonempty bounded identifier.');
40
+ const limits = { timeoutMs: [5000, 10, 30000], pageSize: [100, 1, 100], maxPages: [10, 1, 100], maxPayloadBytes: [262144, 1024, 1048576] };
41
+ const result = { socketPath: options.socketPath, expectedCwd: options.expectedCwd, threadId: options.threadId };
42
+ for (const [key, [defaultValue, minimum, maximum]] of Object.entries(limits)) {
43
+ const value = options[key] ?? defaultValue;
44
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
45
+ throw new CliError('BAD_ARGS', `${key} must be an integer between ${minimum} and ${maximum}.`);
46
+ }
47
+ result[key] = value;
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function socketIdentity(socketPath) {
53
+ try {
54
+ const stat = fs.lstatSync(socketPath);
55
+ if (!stat.isSocket()) throw failure('ENDPOINT_UNAVAILABLE');
56
+ return { dev: stat.dev, ino: stat.ino };
57
+ } catch { throw failure('ENDPOINT_UNAVAILABLE'); }
58
+ }
59
+
60
+ function connectProbe(options, identity, report, addCondition) {
61
+ let ws, timer, pending, openResolve, openReject, failed, disposed = false;
62
+ let nextId = 1, frameCount = 0, totalBytes = 0;
63
+ const byteLimit = Math.min(options.maxPayloadBytes * (options.maxPages + 4), 16 * 1024 * 1024);
64
+ const opened = new Promise((resolve, reject) => { openResolve = resolve; openReject = reject; });
65
+ function fail(code) {
66
+ if (failed || disposed) return;
67
+ failed = failure(code);
68
+ openReject(failed);
69
+ pending?.reject(failed);
70
+ pending = null;
71
+ }
72
+ function write(frame) {
73
+ if (failed) throw failed;
74
+ if (disposed || ws.readyState !== WebSocket.OPEN) throw failure('TRANSPORT_FAILED');
75
+ ws.send(JSON.stringify(frame), error => { if (error) fail('TRANSPORT_FAILED'); });
76
+ }
77
+ function onMessage(bytes, binary) {
78
+ if (failed || disposed) return;
79
+ totalBytes += bytes.length;
80
+ if (++frameCount > 1024 || totalBytes > byteLimit) return fail('RESPONSE_LIMIT');
81
+ if (binary) return fail('PROTOCOL_INVALID');
82
+ let frame;
83
+ try { frame = JSON.parse(bytes.toString('utf8')); } catch { return fail('PROTOCOL_INVALID'); }
84
+ if (!object(frame) || (frame.jsonrpc !== undefined && frame.jsonrpc !== '2.0')) return fail('PROTOCOL_INVALID');
85
+ if (Object.hasOwn(frame, 'method')) {
86
+ if (!text(frame.method, 256)) return fail('PROTOCOL_INVALID');
87
+ if (Object.hasOwn(frame, 'id')) {
88
+ if (!(Number.isSafeInteger(frame.id) || text(frame.id, 512))) return fail('PROTOCOL_INVALID');
89
+ addCondition('SERVER_REQUEST_REJECTED');
90
+ try { write({ id: frame.id, error: { code: -32601, message: 'Read-only probe does not execute server requests.' } }); }
91
+ catch { fail('TRANSPORT_FAILED'); }
92
+ }
93
+ return; // Notifications and their contents are never retained.
94
+ }
95
+ if (!pending || frame.id !== pending.id || Object.hasOwn(frame, 'result') === Object.hasOwn(frame, 'error')) return fail('PROTOCOL_INVALID');
96
+ const operation = pending;
97
+ pending = null;
98
+ if (Object.hasOwn(frame, 'error')) {
99
+ operation.reject(failure(object(frame.error) && frame.error.code === -32601 ? 'METHOD_UNSUPPORTED' : 'REQUEST_REJECTED'));
100
+ } else operation.resolve(frame.result);
101
+ }
102
+ try {
103
+ // The public UDS transport uses a WebSocket handshake, not stdio JSONL.
104
+ // No URL, ambient proxy, cookie, auth token, or default socket is accepted.
105
+ ws = new WebSocket('ws://localhost/', {
106
+ createConnection: () => net.createConnection({ path: options.socketPath }),
107
+ followRedirects: false, perMessageDeflate: false,
108
+ maxPayload: options.maxPayloadBytes, handshakeTimeout: options.timeoutMs
109
+ });
110
+ timer = setTimeout(() => fail('PROBE_TIMEOUT'), options.timeoutMs);
111
+ ws.on('open', () => {
112
+ if (failed || disposed) return;
113
+ try {
114
+ const current = socketIdentity(options.socketPath);
115
+ if (identity.dev !== current.dev || identity.ino !== current.ino) throw failure('ENDPOINT_CHANGED');
116
+ } catch { fail('ENDPOINT_CHANGED'); return; }
117
+ report.transportConnected = true;
118
+ openResolve();
119
+ });
120
+ ws.on('message', onMessage);
121
+ ws.on('error', error => fail(error.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ? 'RESPONSE_LIMIT' : 'TRANSPORT_FAILED'));
122
+ ws.on('close', () => fail('TRANSPORT_FAILED'));
123
+ } catch { fail('TRANSPORT_FAILED'); }
124
+ return {
125
+ opened,
126
+ request(method, params) {
127
+ if (!methods.has(method) || pending) return Promise.reject(failure('PROTOCOL_INVALID'));
128
+ return new Promise((resolve, reject) => {
129
+ const id = nextId++;
130
+ pending = { id, resolve, reject };
131
+ try { write({ id, method, params }); }
132
+ catch (error) { pending = null; reject(error); }
133
+ });
134
+ },
135
+ initialized() { write({ method: 'initialized', params: {} }); },
136
+ async close() {
137
+ disposed = true;
138
+ clearTimeout(timer);
139
+ if (!ws || ws.readyState === WebSocket.CLOSED) return;
140
+ // Close this connection only. Never unsubscribe, stop a daemon, or signal
141
+ // any process. A bounded forced close affects only this probe's socket.
142
+ await new Promise(resolve => {
143
+ const closing = setTimeout(() => { ws.terminate(); resolve(); }, 100);
144
+ ws.once('close', () => { clearTimeout(closing); resolve(); });
145
+ if (ws.readyState === WebSocket.CONNECTING) ws.terminate();
146
+ else ws.close();
147
+ });
148
+ }
149
+ };
150
+ }
151
+
152
+ /** Inspect one explicitly selected public endpoint. This never creates an executor or resumes a thread. */
153
+ export async function probeCodexEndpoint(input) {
154
+ const options = validate(input);
155
+ const report = {
156
+ transportConnected: false, initialized: false, threadMatched: false, cwdMatched: false,
157
+ loaded: null, status: null, directInputAdvertised: null,
158
+ checks: { threadRead: false, loadedList: false },
159
+ writable: false, desktopEndpointVerified: false, unmetConditions: []
160
+ };
161
+ const addCondition = code => {
162
+ if (!report.unmetConditions.some(item => item.code === code)) report.unmetConditions.push({ code, message: messages[code] });
163
+ };
164
+ addCondition('DESKTOP_ENDPOINT_UNVERIFIED');
165
+ addCondition('READ_ONLY_PROBE');
166
+ let connection;
167
+ try {
168
+ const identity = socketIdentity(options.socketPath);
169
+ connection = connectProbe(options, identity, report, addCondition);
170
+ await connection.opened;
171
+ const initialized = await connection.request('initialize', {
172
+ clientInfo: { name: 'hello_cc_read_only_probe', title: 'hello-cc read-only endpoint probe', version: '1' },
173
+ capabilities: { experimentalApi: false }
174
+ });
175
+ if (!object(initialized) || !text(initialized.userAgent, 1024)) throw failure('PROTOCOL_INVALID');
176
+ connection.initialized();
177
+ report.initialized = true;
178
+ const result = await connection.request('thread/read', { threadId: options.threadId, includeTurns: false });
179
+ const thread = result?.thread;
180
+ if (!object(thread) || !text(thread.id, 512)) throw failure('PROTOCOL_INVALID');
181
+ report.checks.threadRead = true;
182
+ report.threadMatched = thread.id === options.threadId;
183
+ if (!report.threadMatched) { addCondition('THREAD_ID_MISMATCH'); return report; }
184
+ if (!text(thread.cwd, 4096) || !path.isAbsolute(thread.cwd) || !object(thread.status) || !statuses.has(thread.status.type)) throw failure('PROTOCOL_INVALID');
185
+ report.cwdMatched = path.normalize(thread.cwd) === path.normalize(options.expectedCwd);
186
+ report.status = thread.status.type;
187
+ report.directInputAdvertised = typeof thread.canAcceptDirectInput === 'boolean' ? thread.canAcceptDirectInput : null;
188
+ if (!report.cwdMatched) addCondition('CWD_MISMATCH');
189
+ if (report.directInputAdvertised !== true) addCondition('DIRECT_INPUT_UNAVAILABLE');
190
+ let cursor, found = false, complete = false;
191
+ const cursors = new Set();
192
+ for (let page = 0; page < options.maxPages; page++) {
193
+ const list = await connection.request('thread/loaded/list', { limit: options.pageSize, ...(cursor ? { cursor } : {}) });
194
+ if (!object(list) || !Array.isArray(list.data) || list.data.length > options.pageSize ||
195
+ !list.data.every(id => text(id, 512)) || (list.nextCursor !== null && !text(list.nextCursor, 4096))) throw failure('PROTOCOL_INVALID');
196
+ report.checks.loadedList = true;
197
+ found = list.data.includes(options.threadId);
198
+ if (found || list.nextCursor === null) { complete = true; break; }
199
+ if (cursors.has(list.nextCursor)) throw failure('PROTOCOL_INVALID');
200
+ cursors.add(list.nextCursor);
201
+ cursor = list.nextCursor;
202
+ }
203
+ if (!complete) addCondition('LOADED_LIST_INCOMPLETE');
204
+ else {
205
+ report.loaded = found && report.status !== 'notLoaded';
206
+ if (!report.loaded) addCondition('THREAD_NOT_LOADED');
207
+ if (found !== (report.status !== 'notLoaded')) addCondition('THREAD_STATE_CHANGED');
208
+ }
209
+ } catch (error) {
210
+ addCondition(Object.hasOwn(messages, error?.code) ? error.code : 'PROTOCOL_INVALID');
211
+ } finally { await connection?.close(); }
212
+ return report;
213
+ }