@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,242 @@
|
|
|
1
|
+
import { JsonRpcProcess } from './jsonrpc.mjs';
|
|
2
|
+
import { createNativeInteractions } from './interactions.mjs';
|
|
3
|
+
import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../codex-interactions.mjs';
|
|
4
|
+
|
|
5
|
+
const CAPABILITIES = Object.freeze({ create: true, resume: true, send: true, observe: true,
|
|
6
|
+
steer: true, interrupt: true, close: true, fork: false });
|
|
7
|
+
const TERMINAL_TURN_STATUSES = new Set(['completed', 'interrupted', 'failed']);
|
|
8
|
+
|
|
9
|
+
function adapterError(code, message) {
|
|
10
|
+
return Object.assign(new Error(message), { code });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Owns a new app-server process; resume IDs must be ownership-checked by the HCC host. */
|
|
14
|
+
export function createCodexAdapter(options = {}) {
|
|
15
|
+
const { binary = 'codex', cwd, env, timeoutMs = 30_000, onEvent = () => {},
|
|
16
|
+
rpcFactory = (config) => new JsonRpcProcess(config) } = options;
|
|
17
|
+
const state = { provider: 'codex', sessionId: null, turnId: null, status: 'new', owned: true };
|
|
18
|
+
const terminalTurns = new Set();
|
|
19
|
+
const interruptedTurns = new Set();
|
|
20
|
+
const turnSubmissions = new Map();
|
|
21
|
+
const finalMessages = new Map();
|
|
22
|
+
let rpc;
|
|
23
|
+
let closed = false;
|
|
24
|
+
let admission = Promise.resolve();
|
|
25
|
+
let pendingSubmissionId;
|
|
26
|
+
let closePromise;
|
|
27
|
+
const capabilities = { ...CAPABILITIES, approvals: Boolean(options.interactive), userInput: Boolean(options.interactive), mcp: Boolean(options.mcpServers) };
|
|
28
|
+
const interactions = createNativeInteractions({ executorId: options.executorId,
|
|
29
|
+
isActive: (sessionId, turnId) => !closed && sessionId === state.sessionId && turnId === state.turnId && !terminalTurns.has(turnId) && !interruptedTurns.has(turnId) && !['error', 'uncertain', 'closed'].includes(state.status),
|
|
30
|
+
onChange: emit });
|
|
31
|
+
|
|
32
|
+
function emit(event) {
|
|
33
|
+
// Consumer callbacks must not prevent a permission denial or corrupt protocol admission.
|
|
34
|
+
try {
|
|
35
|
+
const delivery = onEvent({ provider: 'codex', sessionId: state.sessionId, ...event });
|
|
36
|
+
delivery?.catch?.(() => {});
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function status(value, details = {}) {
|
|
41
|
+
state.status = value;
|
|
42
|
+
emit({ type: 'status', status: value, turnId: state.turnId, ...details });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireOpen() {
|
|
46
|
+
if (closed || !rpc || !state.sessionId || ['new', 'opening', 'error', 'uncertain', 'closed'].includes(state.status)) {
|
|
47
|
+
throw adapterError('NATIVE_SESSION_NOT_OPEN', 'The Codex native session is not open.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function serial(operation) {
|
|
52
|
+
const result = admission.then(operation);
|
|
53
|
+
admission = result.catch(() => {});
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function notification(method, params = {}) {
|
|
58
|
+
if (closed || !state.sessionId || params.threadId !== state.sessionId) return;
|
|
59
|
+
const turnId = params.turn?.id ?? params.turnId;
|
|
60
|
+
const submissionId = turnSubmissions.get(turnId) ?? pendingSubmissionId;
|
|
61
|
+
if (method === 'turn/started' && turnId && !terminalTurns.has(turnId)) {
|
|
62
|
+
if (submissionId) turnSubmissions.set(turnId, submissionId);
|
|
63
|
+
state.turnId = turnId;
|
|
64
|
+
status('busy');
|
|
65
|
+
} else if (method === 'turn/completed' && turnId) {
|
|
66
|
+
if (terminalTurns.has(turnId)) return;
|
|
67
|
+
terminalTurns.add(turnId);
|
|
68
|
+
interruptedTurns.delete(turnId);
|
|
69
|
+
interactions.expire(turnId);
|
|
70
|
+
if (terminalTurns.size > 128) {
|
|
71
|
+
const oldest = terminalTurns.values().next().value;
|
|
72
|
+
terminalTurns.delete(oldest);
|
|
73
|
+
turnSubmissions.delete(oldest);
|
|
74
|
+
finalMessages.delete(oldest);
|
|
75
|
+
}
|
|
76
|
+
if (!state.turnId || state.turnId === turnId) {
|
|
77
|
+
state.turnId = null;
|
|
78
|
+
status('idle', { completedTurnId: turnId, turnStatus: params.turn?.status });
|
|
79
|
+
}
|
|
80
|
+
emit({ type: 'completed', status: params.turn?.status ?? 'completed', turnId, submissionId,
|
|
81
|
+
text: finalMessages.get(turnId) });
|
|
82
|
+
if (params.turn?.error) emit({ type: 'error', turnId, error: params.turn.error });
|
|
83
|
+
} else if (method === 'item/agentMessage/delta') {
|
|
84
|
+
emit({ type: 'delta', text: params.delta ?? '', turnId, submissionId, itemId: params.itemId });
|
|
85
|
+
} else if (method === 'item/completed' && params.item?.type === 'agentMessage') {
|
|
86
|
+
finalMessages.set(turnId, params.item.text ?? '');
|
|
87
|
+
emit({ type: 'message', text: params.item.text ?? '', turnId, submissionId, itemId: params.item.id });
|
|
88
|
+
} else if (['item/started', 'item/completed'].includes(method) && ['commandExecution', 'fileChange', 'mcpToolCall'].includes(params.item?.type)) {
|
|
89
|
+
emit({ type: 'item', phase: method.split('/')[1], item: params.item, turnId, submissionId });
|
|
90
|
+
} else if (method === 'turn/plan/updated') {
|
|
91
|
+
emit({ type: 'plan', plan: params.plan, explanation: params.explanation, turnId, submissionId });
|
|
92
|
+
} else if (method === 'turn/diff/updated') {
|
|
93
|
+
emit({ type: 'diff', diff: params.diff, turnId, submissionId });
|
|
94
|
+
} else if (method === 'error') {
|
|
95
|
+
emit({ type: 'error', turnId, error: params.error ?? params });
|
|
96
|
+
} else if (method === 'serverRequest/resolved') {
|
|
97
|
+
interactions.resolved(params.requestId);
|
|
98
|
+
} else if (method === 'thread/status/changed' && params.status?.type === 'systemError') {
|
|
99
|
+
interactions.expire();
|
|
100
|
+
status('error');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function serverRequest(method, params = {}, requestId) {
|
|
105
|
+
if (!CODEX_INTERACTION_METHODS.has(method)) throw adapterError('NATIVE_SERVER_REQUEST_UNSUPPORTED', `Unsupported Codex server request: ${method}`);
|
|
106
|
+
if (options.interactive && params.threadId === state.sessionId && params.turnId && !terminalTurns.has(params.turnId)) {
|
|
107
|
+
// A server request can precede the turn/start admission response.
|
|
108
|
+
if (!state.turnId && pendingSubmissionId) { state.turnId = params.turnId; status('busy'); }
|
|
109
|
+
return interactions.request({ requestId, sessionId: params.threadId, turnId: params.turnId,
|
|
110
|
+
kind: codexInteractionKind(method), method, params,
|
|
111
|
+
validate: codexInteractionResponse, cancelled: cancelCodexInteraction(method) });
|
|
112
|
+
}
|
|
113
|
+
if (method !== 'item/tool/requestUserInput') emit({ type: 'approval', method, turnId: params.turnId,
|
|
114
|
+
itemId: params.itemId, decision: 'decline', reason: params.reason ?? null });
|
|
115
|
+
return method === 'mcpServer/elicitation/request' ? { action: 'decline' }
|
|
116
|
+
: method === 'item/permissions/requestApproval' || method === 'item/tool/requestUserInput'
|
|
117
|
+
? cancelCodexInteraction(method) : { decision: 'decline' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function exited(details = {}) {
|
|
121
|
+
if (closed) return;
|
|
122
|
+
interactions.expire();
|
|
123
|
+
state.turnId = null;
|
|
124
|
+
status('error');
|
|
125
|
+
emit({ type: 'error', code: 'NATIVE_PROCESS_EXITED', text: 'The owned Codex app-server exited.',
|
|
126
|
+
exitCode: details?.code ?? null, signal: details?.signal ?? null });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function transportError(error) {
|
|
130
|
+
if (closed) return;
|
|
131
|
+
interactions.expire();
|
|
132
|
+
status('error');
|
|
133
|
+
emit({ type: 'error', code: error.code ?? 'NATIVE_TRANSPORT_ERROR', text: error.message,
|
|
134
|
+
turnId: state.turnId, extra: { ...error.extra } });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function open({ sessionId, model } = {}) {
|
|
138
|
+
if (closed || state.status !== 'new') {
|
|
139
|
+
throw adapterError('NATIVE_SESSION_ALREADY_OPEN', 'This Codex adapter can open only one owned session.');
|
|
140
|
+
}
|
|
141
|
+
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId.trim())) {
|
|
142
|
+
throw adapterError('BAD_ARGS', 'A Codex resume sessionId must be a nonempty string.');
|
|
143
|
+
}
|
|
144
|
+
status('opening');
|
|
145
|
+
try {
|
|
146
|
+
rpc = rpcFactory({ binary, args: ['app-server', '--stdio'], cwd, env, timeoutMs,
|
|
147
|
+
onNotification: notification, onRequest: serverRequest, onExit: exited, onError: transportError });
|
|
148
|
+
await rpc.start();
|
|
149
|
+
if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
|
|
150
|
+
await rpc.request('initialize', { clientInfo: { name: 'hello_cc', version: '1.0.1' },
|
|
151
|
+
capabilities: { experimentalApi: Boolean(options.interactive) } });
|
|
152
|
+
if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
|
|
153
|
+
await rpc.notify('initialized', {});
|
|
154
|
+
if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
|
|
155
|
+
const config = codexInteractiveConfig(options.mcpServers ? { mcp_servers: options.mcpServers } : {}, Boolean(options.interactive));
|
|
156
|
+
const params = { sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
|
|
157
|
+
...(Object.keys(config).length ? { config } : {}),
|
|
158
|
+
...(cwd ? { cwd } : {}), ...(model ? { model } : {}), ...(sessionId ? { threadId: sessionId } : {}) };
|
|
159
|
+
const response = await rpc.request(sessionId ? 'thread/resume' : 'thread/start', params);
|
|
160
|
+
if (!response?.thread?.id || (sessionId && response.thread.id !== sessionId)) {
|
|
161
|
+
throw adapterError('NATIVE_PROTOCOL_ERROR', 'Codex returned an invalid native thread identity.');
|
|
162
|
+
}
|
|
163
|
+
if (closed) throw adapterError('NATIVE_SESSION_CLOSED', 'The Codex session was closed while opening.');
|
|
164
|
+
state.sessionId = response.thread.id;
|
|
165
|
+
const activeTurn = response.thread.turns?.findLast((turn) => turn.status === 'inProgress');
|
|
166
|
+
state.turnId = activeTurn?.id ?? null;
|
|
167
|
+
status(state.turnId ? 'busy' : 'idle');
|
|
168
|
+
return snapshot();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!closed) {
|
|
171
|
+
status('error');
|
|
172
|
+
emit({ type: 'error', code: error.code, text: error.message, extra: { ...error.extra } });
|
|
173
|
+
}
|
|
174
|
+
await rpc?.close();
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function send({ text, submissionId, expectedTurnId } = {}) {
|
|
180
|
+
return serial(async () => {
|
|
181
|
+
requireOpen();
|
|
182
|
+
if (typeof text !== 'string' || !text.trim()) throw adapterError('BAD_ARGS', 'A native message needs nonempty text.');
|
|
183
|
+
const turnId = state.turnId;
|
|
184
|
+
if (expectedTurnId !== undefined && expectedTurnId !== turnId) {
|
|
185
|
+
throw adapterError('NATIVE_TURN_MISMATCH', 'The expected Codex turn is no longer active.');
|
|
186
|
+
}
|
|
187
|
+
if (state.status === 'busy' && !turnId) {
|
|
188
|
+
throw adapterError('NATIVE_TURN_UNKNOWN', 'The active Codex turn identity is unavailable.');
|
|
189
|
+
}
|
|
190
|
+
const method = turnId ? 'turn/steer' : 'turn/start';
|
|
191
|
+
const params = { threadId: state.sessionId, input: [{ type: 'text', text }],
|
|
192
|
+
...(submissionId ? { clientUserMessageId: submissionId } : {}),
|
|
193
|
+
...(turnId ? { expectedTurnId: expectedTurnId ?? turnId } : {}) };
|
|
194
|
+
try {
|
|
195
|
+
pendingSubmissionId = submissionId;
|
|
196
|
+
const response = await rpc.request(method, params);
|
|
197
|
+
const acceptedTurnId = turnId ? response?.turnId : response?.turn?.id;
|
|
198
|
+
if (!acceptedTurnId || (turnId && acceptedTurnId !== turnId)) {
|
|
199
|
+
throw adapterError('NATIVE_PROTOCOL_ERROR', 'Codex returned an invalid turn admission.');
|
|
200
|
+
}
|
|
201
|
+
if (submissionId && !turnSubmissions.has(acceptedTurnId)) turnSubmissions.set(acceptedTurnId, submissionId);
|
|
202
|
+
if (!closed && !terminalTurns.has(acceptedTurnId)) {
|
|
203
|
+
const turnStatus = response?.turn?.status;
|
|
204
|
+
state.turnId = TERMINAL_TURN_STATUSES.has(turnStatus) ? null : acceptedTurnId;
|
|
205
|
+
status(state.turnId ? 'busy' : 'idle');
|
|
206
|
+
}
|
|
207
|
+
return { sessionId: state.sessionId, turnId: acceptedTurnId, status: 'accepted' };
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!closed && error.code === 'NATIVE_REQUEST_TIMEOUT' && error.extra?.uncertain) status('uncertain');
|
|
210
|
+
emit({ type: 'error', code: error.code, text: error.message, turnId, extra: { ...error.extra } });
|
|
211
|
+
// A rejected steer is not retried as a new turn: that would remove its turn precondition.
|
|
212
|
+
throw error;
|
|
213
|
+
} finally {
|
|
214
|
+
pendingSubmissionId = undefined;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function interrupt({ turnId = state.turnId } = {}) {
|
|
220
|
+
requireOpen();
|
|
221
|
+
if (!turnId) return { sessionId: state.sessionId, turnId: null, status: 'idle' };
|
|
222
|
+
if (turnId !== state.turnId) throw adapterError('NATIVE_TURN_MISMATCH', 'The requested Codex turn is no longer active.');
|
|
223
|
+
await rpc.request('turn/interrupt', { threadId: state.sessionId, turnId });
|
|
224
|
+
interruptedTurns.add(turnId);
|
|
225
|
+
interactions.expire(turnId, 'interrupted');
|
|
226
|
+
return { sessionId: state.sessionId, turnId, status: 'accepted' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function close() {
|
|
230
|
+
if (closePromise) return closePromise;
|
|
231
|
+
closed = true;
|
|
232
|
+
interactions.expire();
|
|
233
|
+
state.turnId = null;
|
|
234
|
+
status('closed');
|
|
235
|
+
closePromise = Promise.resolve().then(() => rpc?.close()).then(() => snapshot());
|
|
236
|
+
return closePromise;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function snapshot() { return { ...state, executorId: interactions.executorId, capabilities: { ...capabilities }, pendingApprovals: interactions.snapshot() }; }
|
|
240
|
+
|
|
241
|
+
return { capabilities, open, send, interrupt, close, snapshot, respond: interactions.respond };
|
|
242
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createNativeInteractions } from './interactions.mjs';
|
|
4
|
+
import { JsonRpcProcess } from './jsonrpc.mjs';
|
|
5
|
+
|
|
6
|
+
export const DSH_ACP_BASELINE_VERSION = '0.2.0-rc.2';
|
|
7
|
+
|
|
8
|
+
// Contract verified against the official @deepseek-ai/dsh-acp baseline above.
|
|
9
|
+
// ACP v1 uses session/prompt requests and session/cancel notifications. Resume
|
|
10
|
+
// and close are advertised session extensions; resume restores the Agent, but
|
|
11
|
+
// does not replay its transcript. turnId identifies this adapter's submission,
|
|
12
|
+
// not a native ACP turn. Updates are committed semantic events, not raw tokens.
|
|
13
|
+
// model, when supplied, is an advertised model configuration option value.
|
|
14
|
+
// This adapter owns a dedicated dsh --profile acp process, never an existing
|
|
15
|
+
// dsh Web service. Its close operation may dispose only that owned process.
|
|
16
|
+
export function createDshAcpAdapter(options = {}) {
|
|
17
|
+
const cwd = options.cwd || process.cwd();
|
|
18
|
+
if (!path.isAbsolute(cwd)) throw failure('NATIVE_BAD_CWD', 'ACP requires an absolute workspace directory');
|
|
19
|
+
const capabilities = {
|
|
20
|
+
create: true, resume: false, send: true, observe: true,
|
|
21
|
+
steer: false, interrupt: true, close: false, fork: false, approvals: Boolean(options.interactive), mcp: Boolean(options.mcpServers)
|
|
22
|
+
};
|
|
23
|
+
let rpc = null;
|
|
24
|
+
let sessionId = null;
|
|
25
|
+
let status = 'new';
|
|
26
|
+
let active = null;
|
|
27
|
+
let opening = null;
|
|
28
|
+
let openingIdentity = null;
|
|
29
|
+
let closing = null;
|
|
30
|
+
let runtimeDisposed = false;
|
|
31
|
+
let lastError = null;
|
|
32
|
+
const receipts = new Map();
|
|
33
|
+
const toolCalls = new Map(), toolContextWaiters = new Set();
|
|
34
|
+
const interactions = createNativeInteractions({ executorId: options.executorId,
|
|
35
|
+
isActive: (id, turnId) => !closing && !runtimeDisposed && id === sessionId && turnId === active?.turnId && !active?.interrupted, onChange: emit });
|
|
36
|
+
|
|
37
|
+
function emit(event) {
|
|
38
|
+
// A consumer failure must not break ACP parsing or turn a permission
|
|
39
|
+
// rejection into a transport failure.
|
|
40
|
+
try {
|
|
41
|
+
const delivery = options.onEvent?.({ provider: 'dsh', sessionId, ...event });
|
|
42
|
+
delivery?.catch?.(() => {});
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function snapshot() {
|
|
47
|
+
return {
|
|
48
|
+
provider: 'dsh', transport: 'acp', protocolVersion: 1,
|
|
49
|
+
baselineVersion: DSH_ACP_BASELINE_VERSION,
|
|
50
|
+
cwd, sessionId, status,
|
|
51
|
+
turnId: active?.turnId || null,
|
|
52
|
+
submissionId: active?.submissionId || null,
|
|
53
|
+
capabilities: { ...capabilities }, executorId: interactions.executorId, pendingApprovals: interactions.snapshot(),
|
|
54
|
+
error: lastError ? { ...lastError } : null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clearToolContext() {
|
|
59
|
+
toolCalls.clear();
|
|
60
|
+
for (const waiter of [...toolContextWaiters]) waiter.finish(null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rememberToolCall(update) {
|
|
64
|
+
if (!active || active.interrupted || !['tool_call', 'tool_call_update'].includes(update.sessionUpdate) ||
|
|
65
|
+
typeof update.toolCallId !== 'string' || !update.toolCallId || update.toolCallId.length > 256) return;
|
|
66
|
+
const detail = { ...(toolCalls.get(update.toolCallId) || {}), toolCallId: update.toolCallId };
|
|
67
|
+
for (const key of ['title', 'kind', 'rawInput', 'locations']) if (Object.hasOwn(update, key)) detail[key] = update[key];
|
|
68
|
+
if (Object.hasOwn(update, 'rawInput')) delete detail.contextTruncated;
|
|
69
|
+
const bounded = JSON.stringify(detail).length > 60000
|
|
70
|
+
? { toolCallId: update.toolCallId, title: typeof detail.title === 'string' ? detail.title.slice(0, 1024) : undefined, contextTruncated: true }
|
|
71
|
+
: structuredClone(detail);
|
|
72
|
+
toolCalls.set(update.toolCallId, bounded);
|
|
73
|
+
if (toolCalls.size > 128) toolCalls.delete(toolCalls.keys().next().value);
|
|
74
|
+
if (Object.hasOwn(bounded, 'rawInput') || bounded.contextTruncated) {
|
|
75
|
+
for (const waiter of [...toolContextWaiters]) if (waiter.toolCallId === update.toolCallId) waiter.finish(bounded);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function waitForToolContext(toolCallId) {
|
|
80
|
+
if (toolContextWaiters.size >= 64) throw failure('NATIVE_PROTOCOL_ERROR', 'Excessive ACP permission context requests');
|
|
81
|
+
return new Promise(resolve => {
|
|
82
|
+
const waiter = { toolCallId, finish(detail) { clearTimeout(timer); toolContextWaiters.delete(waiter); resolve(detail); } };
|
|
83
|
+
const timer = setTimeout(() => waiter.finish(null), 1000);
|
|
84
|
+
toolContextWaiters.add(waiter);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onNotification(method, params) {
|
|
89
|
+
if (closing || ['closed', 'failed', 'exited'].includes(status) || method !== 'session/update' || params?.sessionId !== sessionId) return;
|
|
90
|
+
const update = params.update;
|
|
91
|
+
if (!update || typeof update !== 'object') return;
|
|
92
|
+
rememberToolCall(update);
|
|
93
|
+
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text' && typeof update.content.text === 'string') {
|
|
94
|
+
if (active) active.text += update.content.text;
|
|
95
|
+
emit({ type: 'output', turnId: active?.turnId || null, submissionId: active?.submissionId || null, text: update.content.text });
|
|
96
|
+
}
|
|
97
|
+
emit({ type: 'session.update', turnId: active?.turnId || null, submissionId: active?.submissionId || null, update });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onRequest(method, params, requestId) {
|
|
101
|
+
if (method !== 'session/request_permission') throw failure(-32601, `Unsupported ACP client request: ${method}`);
|
|
102
|
+
if (closing || params?.sessionId !== sessionId) return { outcome: { outcome: 'cancelled' } };
|
|
103
|
+
const choices = Array.isArray(params.options) ? params.options : [];
|
|
104
|
+
const reject = choices.find((option) => option?.kind === 'reject_once')
|
|
105
|
+
|| choices.find((option) => option?.kind === 'reject_always');
|
|
106
|
+
if (options.interactive && active) {
|
|
107
|
+
const turn = active, toolCallId = params.toolCall?.toolCallId;
|
|
108
|
+
if (turn.interrupted) return { outcome: { outcome: 'cancelled' } };
|
|
109
|
+
const publish = detail => {
|
|
110
|
+
if (closing || active !== turn || turn.interrupted) return { outcome: { outcome: 'cancelled' } };
|
|
111
|
+
const toolCall = { ...detail, ...params.toolCall };
|
|
112
|
+
if (toolCallId && !Object.hasOwn(toolCall, 'rawInput') && !toolCall.contextTruncated) toolCall.contextPending = true;
|
|
113
|
+
return interactions.request({ requestId, sessionId, turnId: turn.turnId,
|
|
114
|
+
method, params: { toolCall, options: choices }, cancelled: { outcome: { outcome: 'cancelled' } },
|
|
115
|
+
validate: (request, response) => {
|
|
116
|
+
if (!['accept', 'decline', 'cancel'].includes(response.decision) || (request.truncated && response.decision === 'accept')) throw failure('INTERACTION_RESPONSE_INVALID', 'Invalid or truncated ACP permission');
|
|
117
|
+
if (response.decision === 'accept' && (request.params.toolCall?.contextPending || request.params.toolCall?.contextTruncated)) throw failure('NATIVE_APPROVAL_CONTEXT_MISSING', 'ACP tool input is missing or truncated; only rejection is available');
|
|
118
|
+
const selected = choices.find(option => option.kind === (response.decision === 'accept' ? 'allow_once' : 'reject_once'));
|
|
119
|
+
if (response.decision === 'accept' && !selected) throw failure('INTERACTION_RESPONSE_INVALID', 'ACP did not offer approval for this operation');
|
|
120
|
+
return response.decision === 'cancel' || !selected ? { outcome: { outcome: 'cancelled' } }
|
|
121
|
+
: { outcome: { outcome: 'selected', optionId: selected.optionId } };
|
|
122
|
+
} });
|
|
123
|
+
};
|
|
124
|
+
const detail = toolCalls.get(toolCallId);
|
|
125
|
+
if (toolCallId && !Object.hasOwn(params.toolCall, 'rawInput') && !Object.hasOwn(detail || {}, 'rawInput') && !detail?.contextTruncated) {
|
|
126
|
+
// rc.2 can ask with only an id before publishing the matching tool update.
|
|
127
|
+
return waitForToolContext(toolCallId).then(value => publish(value || detail));
|
|
128
|
+
}
|
|
129
|
+
return publish(detail);
|
|
130
|
+
}
|
|
131
|
+
emit({ type: 'permission', decision: 'reject', toolCall: params.toolCall });
|
|
132
|
+
return reject && typeof reject.optionId === 'string'
|
|
133
|
+
? { outcome: { outcome: 'selected', optionId: reject.optionId } }
|
|
134
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onExit(info) {
|
|
138
|
+
if (closing || runtimeDisposed || status === 'closed') return;
|
|
139
|
+
const turn = active;
|
|
140
|
+
interactions.expire();
|
|
141
|
+
clearToolContext();
|
|
142
|
+
active = null;
|
|
143
|
+
status = 'exited';
|
|
144
|
+
lastError = { code: 'NATIVE_RUNTIME_EXITED', message: 'The owned dsh ACP runtime exited', uncertain: Boolean(turn) };
|
|
145
|
+
emit({ type: 'error', status: 'unknown', turnId: turn?.turnId || null, submissionId: turn?.submissionId || null, error: lastError, exit: info });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function onTransportError(error) {
|
|
149
|
+
if (closing || status === 'closed') return;
|
|
150
|
+
const turn = active;
|
|
151
|
+
interactions.expire();
|
|
152
|
+
clearToolContext();
|
|
153
|
+
active = null;
|
|
154
|
+
status = 'failed';
|
|
155
|
+
lastError = { ...describe(error), uncertain: true };
|
|
156
|
+
emit({ type: 'error', status: 'unknown', turnId: turn?.turnId || null,
|
|
157
|
+
submissionId: turn?.submissionId || null, error: lastError });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function open(input = {}) {
|
|
161
|
+
if (input.sessionId !== undefined && (typeof input.sessionId !== 'string' || !input.sessionId.trim())) {
|
|
162
|
+
throw failure('NATIVE_BAD_SESSION', 'Session identity must be a nonempty string');
|
|
163
|
+
}
|
|
164
|
+
if (closing || status === 'closed') throw failure('NATIVE_ADAPTER_CLOSED', 'The dsh ACP adapter is closed');
|
|
165
|
+
if (['failed', 'exited'].includes(status)) throw failure('NATIVE_NOT_OPEN', 'The dsh ACP runtime cannot be reused after a failed open');
|
|
166
|
+
if (sessionId) {
|
|
167
|
+
if (input.sessionId && input.sessionId !== sessionId) throw failure('NATIVE_SESSION_MISMATCH', 'This adapter already owns a different session');
|
|
168
|
+
return snapshot();
|
|
169
|
+
}
|
|
170
|
+
if (opening) {
|
|
171
|
+
if ((input.sessionId || null) !== openingIdentity) throw failure('NATIVE_SESSION_MISMATCH', 'This adapter is opening a different session');
|
|
172
|
+
return opening;
|
|
173
|
+
}
|
|
174
|
+
if (status !== 'new') throw failure('NATIVE_NOT_OPEN', 'The dsh ACP runtime cannot be reused after a failed open');
|
|
175
|
+
status = 'opening';
|
|
176
|
+
openingIdentity = input.sessionId || null;
|
|
177
|
+
opening = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
const config = {
|
|
180
|
+
binary: options.binary || 'dsh', args: ['--profile', 'acp'],
|
|
181
|
+
cwd, env: options.env, timeoutMs: options.timeoutMs,
|
|
182
|
+
onNotification, onRequest, onExit, onError: onTransportError
|
|
183
|
+
};
|
|
184
|
+
rpc = options.rpcFactory ? options.rpcFactory(config) : new JsonRpcProcess(config);
|
|
185
|
+
await rpc.start();
|
|
186
|
+
const initialized = await rpc.request('initialize', {
|
|
187
|
+
protocolVersion: 1,
|
|
188
|
+
clientCapabilities: {},
|
|
189
|
+
clientInfo: { name: 'hello-cc', version: '1.0.1' }
|
|
190
|
+
});
|
|
191
|
+
if (initialized?.protocolVersion !== 1) throw failure('NATIVE_PROTOCOL_MISMATCH', 'The dsh runtime did not negotiate ACP v1');
|
|
192
|
+
const extensions = initialized.agentCapabilities?.sessionCapabilities || {};
|
|
193
|
+
capabilities.resume = extensions.resume !== undefined && extensions.resume !== null && extensions.resume !== false;
|
|
194
|
+
capabilities.close = extensions.close !== undefined && extensions.close !== null && extensions.close !== false;
|
|
195
|
+
if (input.sessionId && !capabilities.resume) throw failure('NATIVE_UNSUPPORTED', 'This ACP runtime does not advertise session resume');
|
|
196
|
+
const result = await rpc.request(input.sessionId ? 'session/resume' : 'session/new', {
|
|
197
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
198
|
+
cwd, mcpServers: Object.entries(options.mcpServers || {}).map(([name, config]) => ({ name, command: config.command, args: config.args,
|
|
199
|
+
env: Object.entries(config.env || {}).map(([name, value]) => ({ name, value })) }))
|
|
200
|
+
});
|
|
201
|
+
const resolvedId = input.sessionId || result?.sessionId;
|
|
202
|
+
if (typeof resolvedId !== 'string' || !resolvedId.trim() || (input.sessionId && result?.sessionId && result.sessionId !== input.sessionId)) {
|
|
203
|
+
throw failure('NATIVE_PROTOCOL_ERROR', 'ACP returned an invalid session identity');
|
|
204
|
+
}
|
|
205
|
+
sessionId = resolvedId;
|
|
206
|
+
if (input.model !== undefined) {
|
|
207
|
+
const model = result?.configOptions?.find((option) => option?.id === 'model');
|
|
208
|
+
if (!model || !advertisesValue(model.options, input.model)) throw failure('NATIVE_UNSUPPORTED_MODEL', 'Model must match an advertised ACP model option value');
|
|
209
|
+
await rpc.request('session/set_config_option', { sessionId, configId: 'model', value: input.model });
|
|
210
|
+
}
|
|
211
|
+
if (closing || status === 'exited') throw failure('NATIVE_ADAPTER_CLOSED', 'The dsh ACP runtime closed during session initialization');
|
|
212
|
+
status = 'idle';
|
|
213
|
+
emit({ type: 'opened', resumed: Boolean(input.sessionId), status });
|
|
214
|
+
return snapshot();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
status = closing ? 'closing' : 'failed';
|
|
217
|
+
lastError = describe(error);
|
|
218
|
+
try { await disposeRuntime(); } catch (cleanupError) {
|
|
219
|
+
throw new AggregateError([error, cleanupError], 'ACP initialization and owned runtime cleanup failed');
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
return opening;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function send(input = {}) {
|
|
228
|
+
if (closing || status === 'closed') throw failure('NATIVE_ADAPTER_CLOSED', 'The dsh ACP adapter is closed');
|
|
229
|
+
if (!sessionId || !['idle', 'running', 'interrupting'].includes(status)) throw failure('NATIVE_NOT_OPEN', 'Open a dsh ACP session before sending a prompt');
|
|
230
|
+
if (typeof input.text !== 'string' || !input.text.trim()) throw failure('NATIVE_BAD_PROMPT', 'Prompt text must be a nonempty string');
|
|
231
|
+
if (input.submissionId !== undefined && (typeof input.submissionId !== 'string' || !input.submissionId.trim())) {
|
|
232
|
+
throw failure('NATIVE_BAD_SUBMISSION', 'Submission identity must be a nonempty string');
|
|
233
|
+
}
|
|
234
|
+
const submissionId = input.submissionId || randomUUID();
|
|
235
|
+
const previous = receipts.get(submissionId);
|
|
236
|
+
if (previous) {
|
|
237
|
+
if (previous.text !== input.text) throw failure('NATIVE_SUBMISSION_CONFLICT', 'A submission identity cannot be reused with different text');
|
|
238
|
+
return { ...previous.receipt };
|
|
239
|
+
}
|
|
240
|
+
if (input.expectedTurnId && input.expectedTurnId !== active?.turnId) throw failure('NATIVE_STALE_TURN', 'The expected ACP submission is no longer active');
|
|
241
|
+
if (active) throw failure('NATIVE_BUSY', 'ACP permits one in-flight prompt per session and does not support steering');
|
|
242
|
+
clearToolContext();
|
|
243
|
+
const turn = { turnId: randomUUID(), submissionId, text: '' };
|
|
244
|
+
active = turn;
|
|
245
|
+
status = 'running';
|
|
246
|
+
lastError = null;
|
|
247
|
+
const receipt = { sessionId, turnId: turn.turnId, submissionId, status: 'queued' };
|
|
248
|
+
receipts.set(submissionId, { text: input.text, receipt });
|
|
249
|
+
if (receipts.size > 256) receipts.delete(receipts.keys().next().value);
|
|
250
|
+
emit({ type: 'queued', ...receipt });
|
|
251
|
+
// prompt settles only after Agent idle. Return the local queue receipt now;
|
|
252
|
+
// only the real RPC response constitutes a completed submission.
|
|
253
|
+
void Promise.resolve().then(() => {
|
|
254
|
+
if (closing) throw failure('NATIVE_ADAPTER_CLOSED', 'The adapter closed before prompt submission');
|
|
255
|
+
return rpc.request('session/prompt', {
|
|
256
|
+
sessionId, prompt: [{ type: 'text', text: input.text }]
|
|
257
|
+
}, { timeoutMs: 0 });
|
|
258
|
+
}).then((result) => {
|
|
259
|
+
if (active !== turn) return;
|
|
260
|
+
if (!['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(result?.stopReason)) {
|
|
261
|
+
throw failure('NATIVE_PROTOCOL_ERROR', 'ACP prompt returned an invalid stop reason');
|
|
262
|
+
}
|
|
263
|
+
interactions.expire(turn.turnId);
|
|
264
|
+
clearToolContext();
|
|
265
|
+
active = null;
|
|
266
|
+
status = closing ? 'closing' : 'idle';
|
|
267
|
+
emit({ type: 'completed', status: result.stopReason === 'cancelled' ? 'interrupted' : 'completed',
|
|
268
|
+
sessionId, turnId: turn.turnId, submissionId, text: turn.text, stopReason: result.stopReason });
|
|
269
|
+
}).catch((error) => {
|
|
270
|
+
if (active !== turn) return;
|
|
271
|
+
interactions.expire(turn.turnId);
|
|
272
|
+
clearToolContext();
|
|
273
|
+
active = null;
|
|
274
|
+
lastError = describe(error);
|
|
275
|
+
const wireFailure = typeof error?.code === 'number' ||
|
|
276
|
+
(error?.code === 'NATIVE_RPC_ERROR' && typeof error?.extra?.rpc_code === 'number');
|
|
277
|
+
status = closing ? 'closing' : wireFailure ? 'idle' : 'failed';
|
|
278
|
+
emit({ type: wireFailure ? 'completed' : 'error', status: wireFailure ? 'failed' : 'unknown',
|
|
279
|
+
sessionId, turnId: turn.turnId, submissionId, text: turn.text,
|
|
280
|
+
error: { ...lastError, uncertain: !wireFailure } });
|
|
281
|
+
});
|
|
282
|
+
return receipt;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function interrupt(input = {}) {
|
|
286
|
+
if (closing || status === 'closed') throw failure('NATIVE_ADAPTER_CLOSED', 'The dsh ACP adapter is closed');
|
|
287
|
+
if (input.turnId && input.turnId !== active?.turnId) throw failure('NATIVE_STALE_TURN', 'The requested ACP submission is no longer active');
|
|
288
|
+
if (!active) return { sessionId, turnId: null, status };
|
|
289
|
+
const turn = active;
|
|
290
|
+
await rpc.notify('session/cancel', { sessionId });
|
|
291
|
+
// The prompt remains in flight until its terminal RPC response; late
|
|
292
|
+
// permission requests must stay cancelled throughout that interval.
|
|
293
|
+
turn.interrupted = true;
|
|
294
|
+
interactions.expire(turn.turnId, 'interrupted');
|
|
295
|
+
clearToolContext();
|
|
296
|
+
if (active === turn) status = 'interrupting';
|
|
297
|
+
const receipt = { sessionId, turnId: turn.turnId, submissionId: turn.submissionId, status: 'interrupting' };
|
|
298
|
+
emit({ type: 'interrupt.requested', ...receipt });
|
|
299
|
+
return receipt;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function close() {
|
|
303
|
+
if (closing) return closing;
|
|
304
|
+
closing = (async () => {
|
|
305
|
+
status = 'closing';
|
|
306
|
+
interactions.expire();
|
|
307
|
+
clearToolContext();
|
|
308
|
+
const errors = [];
|
|
309
|
+
if (opening) { try { await opening; } catch {} }
|
|
310
|
+
if (rpc && !runtimeDisposed && sessionId && capabilities.close) {
|
|
311
|
+
try { await rpc.request('session/close', { sessionId }); } catch (error) { errors.push(error); }
|
|
312
|
+
}
|
|
313
|
+
try { await disposeRuntime(); } catch (error) { errors.push(error); }
|
|
314
|
+
active = null;
|
|
315
|
+
status = 'closed';
|
|
316
|
+
emit({ type: 'closed', status });
|
|
317
|
+
if (errors.length) throw new AggregateError(errors, 'ACP session or owned runtime cleanup failed');
|
|
318
|
+
return snapshot();
|
|
319
|
+
})();
|
|
320
|
+
return closing;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function disposeRuntime() {
|
|
324
|
+
if (runtimeDisposed) return;
|
|
325
|
+
runtimeDisposed = true;
|
|
326
|
+
await rpc?.close();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { capabilities, open, send, interrupt, close, snapshot, respond: interactions.respond };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function failure(code, message) {
|
|
333
|
+
return Object.assign(new Error(message), { code });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function describe(error) {
|
|
337
|
+
return { code: error?.code || 'NATIVE_ERROR', message: String(error?.message || error),
|
|
338
|
+
...(error?.extra?.uncertain ? { uncertain: true } : {}) };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function advertisesValue(options, value) {
|
|
342
|
+
return Array.isArray(options) && options.some((option) => option?.value === value || advertisesValue(option?.options, value));
|
|
343
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
|
|
3
|
+
export const NATIVE_PROVIDERS = Object.freeze(['codex', 'claude', 'dsh']);
|
|
4
|
+
|
|
5
|
+
export async function createNativeAdapter(provider, options) {
|
|
6
|
+
if (provider === 'codex') return (await import('./codex.mjs')).createCodexAdapter(options);
|
|
7
|
+
if (provider === 'claude') return (await import('./claude.mjs')).createClaudeAdapter(options);
|
|
8
|
+
if (provider === 'dsh') return (await import('./dsh-acp.mjs')).createDshAcpAdapter(options);
|
|
9
|
+
throw new CliError('BAD_ARGS', `Unsupported native provider: ${provider}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function nativeWorkerEnv(env, ctx, peer, owner) {
|
|
13
|
+
const clean = Object.fromEntries(Object.entries(env || {}).filter(([key]) =>
|
|
14
|
+
!key.startsWith('HCC_') && !key.startsWith('CLAUDE_CODE_') &&
|
|
15
|
+
!['CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CLAUDE_SESSION_ID', 'CLAUDECODE'].includes(key)));
|
|
16
|
+
// Explicit, worker-specific coordination identity; provider credentials and
|
|
17
|
+
// the real HOME are preserved. No global configuration is rewritten.
|
|
18
|
+
return { ...clean, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, HCC_PEER: peer, ...(owner ? { HCC_NATIVE_OWNER: owner } : {}) };
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Only the owning runtime can answer. Answers, including secrets, are never
|
|
4
|
+
// placed in snapshots, events or persistent delivery receipts.
|
|
5
|
+
export function createNativeInteractions({ executorId = randomUUID(), isActive, onChange }) {
|
|
6
|
+
const pending = new Map(), used = new Set();
|
|
7
|
+
function snapshot() {
|
|
8
|
+
return [...pending.values()].map(({ resolve, validate, cancelled, cleanup, ...view }) => structuredClone(view));
|
|
9
|
+
}
|
|
10
|
+
function settle(key, result, reason) {
|
|
11
|
+
const request = pending.get(key);
|
|
12
|
+
if (!request) return;
|
|
13
|
+
pending.delete(key); request.cleanup?.();
|
|
14
|
+
request.resolve(result);
|
|
15
|
+
onChange?.({ type: 'approval.resolved', requestId: request.requestId, turnId: request.turnId, reason });
|
|
16
|
+
}
|
|
17
|
+
function expire(turnId = null, reason = 'expired') {
|
|
18
|
+
for (const [key, request] of pending) if (!turnId || request.turnId === turnId) settle(key, request.cancelled, reason);
|
|
19
|
+
}
|
|
20
|
+
function request({ requestId, sessionId, turnId, kind = 'approval', method, params, validate, cancelled, signal }) {
|
|
21
|
+
const id = requestId ?? randomUUID(), key = JSON.stringify([typeof id, id]);
|
|
22
|
+
if (signal?.aborted || !isActive(sessionId, turnId)) return Promise.resolve(cancelled);
|
|
23
|
+
if (used.has(key) || pending.size >= 64) throw Object.assign(new Error('Duplicate or excessive native interaction requests'), { code: 'NATIVE_PROTOCOL_ERROR' });
|
|
24
|
+
used.add(key); if (used.size > 256) used.delete(used.values().next().value);
|
|
25
|
+
let truncated = false, preview = structuredClone(params);
|
|
26
|
+
if (JSON.stringify(preview).length > 65536) { truncated = true; preview = { preview: JSON.stringify(params).slice(0, 65536) }; }
|
|
27
|
+
const promise = new Promise(resolve => {
|
|
28
|
+
const entry = { executorId, requestId: id, sessionId, turnId, kind, method, params: preview,
|
|
29
|
+
truncated, status: 'pending', createdAt: Date.now(), resolve, validate, cancelled };
|
|
30
|
+
pending.set(key, entry);
|
|
31
|
+
if (signal) {
|
|
32
|
+
const aborted = () => settle(key, cancelled, 'cancelled');
|
|
33
|
+
signal.addEventListener('abort', aborted, { once: true });
|
|
34
|
+
entry.cleanup = () => signal.removeEventListener('abort', aborted);
|
|
35
|
+
}
|
|
36
|
+
onChange?.({ type: 'approval', ...snapshot().find(value => value.requestId === id) });
|
|
37
|
+
});
|
|
38
|
+
return promise;
|
|
39
|
+
}
|
|
40
|
+
function respond(input) {
|
|
41
|
+
const key = JSON.stringify([typeof input.requestId, input.requestId]), entry = pending.get(key);
|
|
42
|
+
if (!entry || input.executorId !== executorId || input.sessionId !== entry.sessionId || input.turnId !== entry.turnId || !isActive(entry.sessionId, entry.turnId)) {
|
|
43
|
+
throw Object.assign(new Error('Native interaction expired or belongs to another worker/turn'), { code: 'NATIVE_APPROVAL_MISMATCH' });
|
|
44
|
+
}
|
|
45
|
+
const result = entry.validate(entry, input);
|
|
46
|
+
settle(key, result, 'answered');
|
|
47
|
+
return { executorId, requestId: entry.requestId, sessionId: entry.sessionId, turnId: entry.turnId, status: 'submitted' };
|
|
48
|
+
}
|
|
49
|
+
return { executorId, request, snapshot, respond, expire, resolved(requestId) {
|
|
50
|
+
const key = JSON.stringify([typeof requestId, requestId]);
|
|
51
|
+
if (pending.has(key)) settle(key, pending.get(key).cancelled, 'resolved-by-provider');
|
|
52
|
+
} };
|
|
53
|
+
}
|