@chenacc/agentbridge 0.0.0-stage → 0.1.1
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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/bin/agentbridge.js +7 -0
- package/docs/RELEASING.md +47 -0
- package/package.json +65 -4
- package/public/adapter-capabilities.js +29 -0
- package/public/app.js +2211 -0
- package/public/background.jpg +0 -0
- package/public/command-id.js +16 -0
- package/public/highlight.js +125 -0
- package/public/index.html +339 -0
- package/public/markdown.js +291 -0
- package/public/styles.css +1073 -0
- package/public/transcript.js +368 -0
- package/public/workspace-navigation.js +241 -0
- package/src/adapter-registry.js +23 -0
- package/src/adapters/capabilities.js +36 -0
- package/src/adapters/claude.js +493 -0
- package/src/adapters/codex.js +529 -0
- package/src/adapters/deepseek-harness.js +413 -0
- package/src/adapters/dsh-desktop.js +322 -0
- package/src/adapters/echo.js +57 -0
- package/src/adapters/process-utils.js +125 -0
- package/src/adapters/unavailable.js +30 -0
- package/src/adapters/zcode-native.js +40 -0
- package/src/attachments.js +187 -0
- package/src/cc-switch-models.js +86 -0
- package/src/cli.js +199 -0
- package/src/config.js +195 -0
- package/src/conversations/conversation-catalog.js +283 -0
- package/src/dsh-desktop-channels.js +460 -0
- package/src/event-store.js +330 -0
- package/src/notifier.js +266 -0
- package/src/pairing.js +167 -0
- package/src/server.js +964 -0
- package/src/session-manager.js +1355 -0
- package/src/workspaces/workspace-catalog.js +196 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_QUESTIONS = 10;
|
|
4
|
+
const MAX_OPTIONS = 25;
|
|
5
|
+
|
|
6
|
+
function contentText(content) {
|
|
7
|
+
if (!Array.isArray(content)) return "";
|
|
8
|
+
return content
|
|
9
|
+
.filter((block) => block?.type === "text" || block?.type === "reasoning")
|
|
10
|
+
.map((block) => String(block.text ?? ""))
|
|
11
|
+
.join("");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function turnId(providerSessionId, turn) {
|
|
15
|
+
return `${providerSessionId}:${turn}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function text(value, limit) {
|
|
19
|
+
return typeof value === "string" ? value.slice(0, limit) : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Project DSH's `AskUserQuestionRequest` questions onto the phone's wire shape.
|
|
24
|
+
*
|
|
25
|
+
* The phone renders these directly, so every field is length-capped and rebuilt
|
|
26
|
+
* as a plain object: the request arrives from another process and must never be
|
|
27
|
+
* forwarded as an opaque live value.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeQuestions(value) {
|
|
30
|
+
if (!Array.isArray(value)) return [];
|
|
31
|
+
return value.slice(0, MAX_QUESTIONS).flatMap((question, index) => {
|
|
32
|
+
if (!question || typeof question !== "object") return [];
|
|
33
|
+
const questionText = text(question.question, 4_000);
|
|
34
|
+
if (!questionText) return [];
|
|
35
|
+
const options = Array.isArray(question.options)
|
|
36
|
+
? question.options.slice(0, MAX_OPTIONS).flatMap((option) => {
|
|
37
|
+
const label = text(option?.label, 200);
|
|
38
|
+
if (!label) return [];
|
|
39
|
+
const description = text(option?.description, 500);
|
|
40
|
+
return [{ label, ...(description ? { description } : {}) }];
|
|
41
|
+
})
|
|
42
|
+
: [];
|
|
43
|
+
const header = text(question.header, 200);
|
|
44
|
+
return [{
|
|
45
|
+
id: text(question.id, 100) || `q${index + 1}`,
|
|
46
|
+
question: questionText,
|
|
47
|
+
...(header ? { header } : {}),
|
|
48
|
+
...(options.length ? { options } : {}),
|
|
49
|
+
...(question.multiSelect === true ? { multiSelect: true } : {}),
|
|
50
|
+
}];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function dshEventToBridge(record, state, emit) {
|
|
55
|
+
const { event, origin } = record;
|
|
56
|
+
const data = event.data ?? {};
|
|
57
|
+
const extra = Number.isSafeInteger(data.turn) ? { turnId: turnId(state.providerSessionId, data.turn) } : {};
|
|
58
|
+
|
|
59
|
+
switch (event.type) {
|
|
60
|
+
case "turn/start":
|
|
61
|
+
return emit("turn.started", {
|
|
62
|
+
adapter: "deepseek-harness-desktop",
|
|
63
|
+
provider: state.provider,
|
|
64
|
+
model: state.model,
|
|
65
|
+
dshEventSeq: event.seq,
|
|
66
|
+
}, extra);
|
|
67
|
+
case "assistant/chunk":
|
|
68
|
+
if (data.chunk?.type === "text-delta" && data.chunk.text) {
|
|
69
|
+
return emit("message.delta", { role: "assistant", text: data.chunk.text, dshEventSeq: event.seq }, extra);
|
|
70
|
+
}
|
|
71
|
+
if (data.chunk?.type === "reasoning-delta" && data.chunk.text) {
|
|
72
|
+
return emit("adapter.output", { stream: "reasoning", text: data.chunk.text, dshEventSeq: event.seq }, extra);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
case "assistant/message":
|
|
76
|
+
return emit("message.completed", {
|
|
77
|
+
role: "assistant",
|
|
78
|
+
text: contentText(data.message?.content),
|
|
79
|
+
provider: data.message?.source?.provider ?? state.provider,
|
|
80
|
+
model: data.message?.source?.model ?? state.model,
|
|
81
|
+
usage: data.usage,
|
|
82
|
+
dshEventSeq: event.seq,
|
|
83
|
+
}, extra);
|
|
84
|
+
case "user/message":
|
|
85
|
+
if (origin === "phone") return undefined;
|
|
86
|
+
return emit("message.completed", {
|
|
87
|
+
role: "user",
|
|
88
|
+
text: contentText(data.content),
|
|
89
|
+
source: "dsh-desktop",
|
|
90
|
+
dshEventSeq: event.seq,
|
|
91
|
+
}, extra);
|
|
92
|
+
case "tool/call":
|
|
93
|
+
return emit("tool.started", {
|
|
94
|
+
type: data.name,
|
|
95
|
+
command: data.arguments,
|
|
96
|
+
callId: data.callId,
|
|
97
|
+
dshEventSeq: event.seq,
|
|
98
|
+
}, extra);
|
|
99
|
+
case "tool/result":
|
|
100
|
+
return emit("tool.completed", {
|
|
101
|
+
type: data.message?.content?.[0]?.toolCallId ? "tool-result" : "tool",
|
|
102
|
+
callId: data.message?.content?.[0]?.toolCallId,
|
|
103
|
+
text: contentText(data.message?.content?.[0]?.content),
|
|
104
|
+
status: data.message?.content?.[0]?.isError || data.error ? "error" : "completed",
|
|
105
|
+
error: data.error,
|
|
106
|
+
dshEventSeq: event.seq,
|
|
107
|
+
}, extra);
|
|
108
|
+
case "turn/end": {
|
|
109
|
+
const kind = data.reason?.kind;
|
|
110
|
+
if (kind === "cancelled" || kind === "interrupted") {
|
|
111
|
+
return emit("turn.cancelled", { reason: data.reason, dshEventSeq: event.seq }, extra);
|
|
112
|
+
}
|
|
113
|
+
// A turn can end in an error — a dropped model connection, a provider failure, a
|
|
114
|
+
// broken transport. This used to fall through to `turn.completed`, so a turn that
|
|
115
|
+
// produced no answer at all was reported to the phone as "本轮已完成": the person
|
|
116
|
+
// saw their message, some tool rows, "completed", and no reply, with nothing
|
|
117
|
+
// anywhere saying the run had failed.
|
|
118
|
+
if (kind === "error") {
|
|
119
|
+
const failure = data.reason?.error;
|
|
120
|
+
const message = typeof failure === "string" ? failure : failure?.message;
|
|
121
|
+
const code = typeof failure === "object" && failure ? failure.code : undefined;
|
|
122
|
+
return emit("turn.failed", {
|
|
123
|
+
...(message ? { error: code ? `${message}(${code})` : message } : { error: "Agent 的回合异常结束,没有产出回复" }),
|
|
124
|
+
reason: data.reason,
|
|
125
|
+
dshEventSeq: event.seq,
|
|
126
|
+
}, extra);
|
|
127
|
+
}
|
|
128
|
+
return emit("turn.completed", { reason: data.reason, dshEventSeq: event.seq }, extra);
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class DshDesktopAdapter {
|
|
136
|
+
id = "deepseek-harness-desktop";
|
|
137
|
+
label = "DeepSeek Harness Desktop";
|
|
138
|
+
|
|
139
|
+
constructor({ channels }) {
|
|
140
|
+
this.channels = channels;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A new conversation can only be opened inside the desktop app, so `create`
|
|
145
|
+
* is true exactly while a companion is paired: the bridge asks that companion
|
|
146
|
+
* to open one, and the companion registers it back. `createReason` explains
|
|
147
|
+
* the refusal in the phone's create sheet instead of a dead greyed row.
|
|
148
|
+
*/
|
|
149
|
+
get capabilities() {
|
|
150
|
+
const online = this.channels.hasOnlineChannel();
|
|
151
|
+
return {
|
|
152
|
+
create: online,
|
|
153
|
+
...(online ? {} : { createReason: "桌面端离线" }),
|
|
154
|
+
attach: true,
|
|
155
|
+
discover: false,
|
|
156
|
+
resume: true,
|
|
157
|
+
cancel: true,
|
|
158
|
+
approve: true,
|
|
159
|
+
answer: true,
|
|
160
|
+
selectModel: true,
|
|
161
|
+
streaming: true,
|
|
162
|
+
sameConversation: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async detect() {
|
|
167
|
+
return {
|
|
168
|
+
available: true,
|
|
169
|
+
supported: true,
|
|
170
|
+
mode: "desktop-companion",
|
|
171
|
+
note: "Run /phone inside a DSH Desktop conversation to attach it",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async attach(context, providerSessionId) {
|
|
176
|
+
return this.#handleFor(providerSessionId, context);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a conversation *in the desktop app* and hand it to the bridge.
|
|
181
|
+
*
|
|
182
|
+
* The phone cannot start a DSH process of its own here: the conversation has to
|
|
183
|
+
* exist inside the running desktop so both surfaces share it. So this asks the
|
|
184
|
+
* paired companion to open one and waits for it to register, then wires the
|
|
185
|
+
* usual bridge observability onto the new provider session.
|
|
186
|
+
*/
|
|
187
|
+
async start(context) {
|
|
188
|
+
const created = await this.channels.requestSession({
|
|
189
|
+
cwd: context.cwd,
|
|
190
|
+
title: context.title,
|
|
191
|
+
bridgeSessionId: context.bridgeSessionId,
|
|
192
|
+
});
|
|
193
|
+
return this.#handleFor(created.providerSessionId, context);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#handleFor(providerSessionId, context) {
|
|
197
|
+
const handle = {
|
|
198
|
+
providerSessionId,
|
|
199
|
+
emit: context.emit,
|
|
200
|
+
// Pending human interactions, keyed by the id AgentBridge hands the phone
|
|
201
|
+
// and carrying the DSH rpc id that answers them. Kept on the runtime
|
|
202
|
+
// handle rather than persisted: a question dies with its turn.
|
|
203
|
+
inputs: new Map(),
|
|
204
|
+
approvals: new Map(),
|
|
205
|
+
};
|
|
206
|
+
this.channels.attachBridge(providerSessionId, {
|
|
207
|
+
onEvent: (record, state) => dshEventToBridge(record, state, context.emit),
|
|
208
|
+
onInteraction: (interaction) => this.#onInteraction(handle, interaction),
|
|
209
|
+
});
|
|
210
|
+
return handle;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async send(handle, { text: prompt, attachments = [], commandId }) {
|
|
214
|
+
// The companion forwards a single text frame, so attachments are handed over as
|
|
215
|
+
// absolute paths the DSH agent can read with its tools.
|
|
216
|
+
const lines = attachments.map((attachment) => `[附件] ${attachment.name} → ${attachment.path}`);
|
|
217
|
+
const composed = [prompt, ...lines].filter(Boolean).join("\n\n");
|
|
218
|
+
this.channels.enqueue(handle.providerSessionId, { kind: "message", text: composed, commandId });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async stop(handle) {
|
|
222
|
+
this.channels.enqueue(handle.providerSessionId, { kind: "cancel" });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// DSH Desktop asks the human in two different shapes: a question form
|
|
227
|
+
// (`ask_user_question`) and a one-off approval prompt. Both block the turn
|
|
228
|
+
// exactly like an approval does, so both must reach the phone; `selectModel`
|
|
229
|
+
// is the existing precedent for waiting on a real desktop confirmation.
|
|
230
|
+
async resolveInput(handle, requestId, { answers = [], cancel = false } = {}) {
|
|
231
|
+
const pending = handle.inputs.get(requestId);
|
|
232
|
+
if (!pending) throw Object.assign(new Error("This question is missing or already answered"), { statusCode: 409 });
|
|
233
|
+
handle.inputs.delete(requestId);
|
|
234
|
+
await this.channels.request(handle.providerSessionId, cancel
|
|
235
|
+
? { kind: "cancel-question", rpcId: pending.rpcId }
|
|
236
|
+
: { kind: "answer-question", rpcId: pending.rpcId, answers });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async resolveApproval(handle, approvalId, decision) {
|
|
241
|
+
const pending = handle.approvals.get(approvalId);
|
|
242
|
+
if (!pending) throw Object.assign(new Error("Approval is missing or already resolved"), { statusCode: 409 });
|
|
243
|
+
handle.approvals.delete(approvalId);
|
|
244
|
+
await this.channels.request(handle.providerSessionId, {
|
|
245
|
+
kind: "answer-approval",
|
|
246
|
+
rpcId: pending.rpcId,
|
|
247
|
+
dshApprovalId: pending.dshApprovalId,
|
|
248
|
+
outcome: decision === "approve" ? "allowed-once" : "rejected",
|
|
249
|
+
});
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
modelDirectory(handle) {
|
|
254
|
+
return this.channels.metadata(handle.providerSessionId)?.modelDirectory ?? null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async selectModel(handle, selection) {
|
|
258
|
+
const directory = this.modelDirectory(handle);
|
|
259
|
+
const provider = directory?.groups.find((group) => group.id === selection.provider);
|
|
260
|
+
const target = provider?.models.find((model) => model.id === selection.model);
|
|
261
|
+
if (!target) throw Object.assign(new Error("The selected model is not in the current DSH Desktop model directory"), { statusCode: 403 });
|
|
262
|
+
if (selection.reasoningEffort && !target.reasoning?.efforts.some((effort) => effort.id === selection.reasoningEffort)) {
|
|
263
|
+
throw Object.assign(new Error("The selected reasoning effort is not available for this model"), { statusCode: 400 });
|
|
264
|
+
}
|
|
265
|
+
return this.channels.request(handle.providerSessionId, { kind: "select-model", selection });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#onInteraction(handle, interaction) {
|
|
269
|
+
const emit = handle.emit;
|
|
270
|
+
if (!emit || !interaction) return;
|
|
271
|
+
if (interaction.kind === "question-requested") {
|
|
272
|
+
const questions = normalizeQuestions(interaction.questions);
|
|
273
|
+
if (!questions.length) return;
|
|
274
|
+
const requestId = randomUUID();
|
|
275
|
+
handle.inputs.set(requestId, { rpcId: interaction.requestId });
|
|
276
|
+
void emit("input.requested", {
|
|
277
|
+
requestId,
|
|
278
|
+
adapter: this.id,
|
|
279
|
+
questions,
|
|
280
|
+
expiresAt: Date.now() + 30 * 60_000,
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (interaction.kind === "approval-requested") {
|
|
285
|
+
const approvalId = randomUUID();
|
|
286
|
+
const toolName = text(interaction.toolName, 200) || "tool";
|
|
287
|
+
const reason = text(interaction.reason, 2_000);
|
|
288
|
+
const callId = text(interaction.callId, 200);
|
|
289
|
+
handle.approvals.set(approvalId, { rpcId: interaction.requestId, dshApprovalId: text(interaction.dshApprovalId, 200) });
|
|
290
|
+
void emit("approval.requested", {
|
|
291
|
+
approvalId,
|
|
292
|
+
adapter: this.id,
|
|
293
|
+
method: toolName,
|
|
294
|
+
request: { toolName, ...(reason ? { reason } : {}), ...(callId ? { callId } : {}) },
|
|
295
|
+
expiresAt: Date.now() + 30 * 60_000,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// The desktop window answered first: retire the phone's now-stale card
|
|
300
|
+
// instead of leaving a button that can only fail.
|
|
301
|
+
if (interaction.kind === "question-resolved") {
|
|
302
|
+
for (const [requestId, pending] of handle.inputs) {
|
|
303
|
+
if (pending.rpcId !== interaction.requestId) continue;
|
|
304
|
+
handle.inputs.delete(requestId);
|
|
305
|
+
void emit("input.resolved", { requestId, outcome: "desktop" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (interaction.kind === "approval-resolved") {
|
|
311
|
+
for (const [approvalId, pending] of handle.approvals) {
|
|
312
|
+
if (pending.dshApprovalId !== interaction.dshApprovalId) continue;
|
|
313
|
+
handle.approvals.delete(approvalId);
|
|
314
|
+
void emit("approval.resolved", {
|
|
315
|
+
approvalId,
|
|
316
|
+
decision: interaction.outcome === "allowed-once" ? "approve" : "deny",
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export class EchoAdapter {
|
|
4
|
+
id = "echo";
|
|
5
|
+
label = "Echo Demo";
|
|
6
|
+
capabilities = {
|
|
7
|
+
create: true,
|
|
8
|
+
attach: false,
|
|
9
|
+
discover: false,
|
|
10
|
+
streaming: true,
|
|
11
|
+
approve: false,
|
|
12
|
+
resume: false,
|
|
13
|
+
cancel: true,
|
|
14
|
+
selectModel: false,
|
|
15
|
+
realAgent: false,
|
|
16
|
+
};
|
|
17
|
+
#runs = new Map();
|
|
18
|
+
|
|
19
|
+
async detect() {
|
|
20
|
+
return { available: true, version: "built-in" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async start(context) {
|
|
24
|
+
return { providerSessionId: randomUUID(), context };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async send(handle, { text, emit }) {
|
|
28
|
+
const turnId = randomUUID();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
this.#runs.set(handle.providerSessionId, controller);
|
|
31
|
+
await emit("turn.started", { adapter: this.id }, { turnId });
|
|
32
|
+
await emit("message.started", { role: "assistant" }, { turnId });
|
|
33
|
+
|
|
34
|
+
const response = `已收到:${text}\n\n这是 Echo 适配器,用于验证微信扫码、实时同步和断线补拉。`;
|
|
35
|
+
for (const chunk of response.match(/.{1,5}/gs) ?? []) {
|
|
36
|
+
if (controller.signal.aborted) {
|
|
37
|
+
await emit("turn.cancelled", { reason: "user" }, { turnId });
|
|
38
|
+
this.#runs.delete(handle.providerSessionId);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
42
|
+
await emit("message.delta", { role: "assistant", text: chunk }, { turnId });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await emit("message.completed", { role: "assistant", text: response }, { turnId });
|
|
46
|
+
await emit("turn.completed", { status: "completed" }, { turnId });
|
|
47
|
+
this.#runs.delete(handle.providerSessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async stop(handle) {
|
|
51
|
+
return this.#runs.get(handle.providerSessionId)?.abort() ?? false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async resolveApproval() {
|
|
55
|
+
throw Object.assign(new Error("Echo has no pending approval"), { statusCode: 409 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// Codex App Server requires HOME even on Windows, where desktop-launched
|
|
7
|
+
// processes commonly inherit only USERPROFILE.
|
|
8
|
+
export function withHomeEnvironment(env = process.env, homeDirectory = os.homedir(), directoryExists = existsSync) {
|
|
9
|
+
const childEnv = { ...env };
|
|
10
|
+
if (!childEnv.HOME) childEnv.HOME = childEnv.USERPROFILE || homeDirectory;
|
|
11
|
+
if (!childEnv.USERPROFILE && childEnv.HOME) childEnv.USERPROFILE = childEnv.HOME;
|
|
12
|
+
const codexHome = childEnv.HOME ? path.join(childEnv.HOME, ".codex") : null;
|
|
13
|
+
if (!childEnv.CODEX_HOME && codexHome && directoryExists(codexHome)) childEnv.CODEX_HOME = codexHome;
|
|
14
|
+
return childEnv;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveCommand(name, {
|
|
18
|
+
env = process.env,
|
|
19
|
+
platform = process.platform,
|
|
20
|
+
homeDirectory = os.homedir(),
|
|
21
|
+
fileExists = existsSync,
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (platform !== "win32") return name;
|
|
24
|
+
const extensions = [".exe", ".com", ".ps1", ".cmd", ".bat"];
|
|
25
|
+
const pathDirectories = String(env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
26
|
+
const userProfile = env.USERPROFILE || env.HOME || homeDirectory;
|
|
27
|
+
const appData = env.APPDATA || (userProfile ? path.join(userProfile, "AppData", "Roaming") : null);
|
|
28
|
+
const npmBin = appData ? path.join(appData, "npm") : null;
|
|
29
|
+
if (npmBin && !pathDirectories.some((directory) => directory.replace(/^"|"$/g, "").toLowerCase() === npmBin.toLowerCase())) {
|
|
30
|
+
pathDirectories.push(npmBin);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const directory of pathDirectories) {
|
|
34
|
+
if (!directory) continue;
|
|
35
|
+
for (const extension of extensions) {
|
|
36
|
+
const candidate = path.join(directory.replace(/^"|"$/g, ""), `${name}${extension}`);
|
|
37
|
+
if (fileExists(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function spawnCommand(name, args, options = {}) {
|
|
44
|
+
const resolved = resolveCommand(name);
|
|
45
|
+
if (!resolved) throw Object.assign(new Error(`Command not found: ${name}`), { code: "ENOENT" });
|
|
46
|
+
const childOptions = { ...options, env: withHomeEnvironment(options.env) };
|
|
47
|
+
if (process.platform === "win32" && resolved.toLowerCase().endsWith(".ps1")) {
|
|
48
|
+
const npmBin = path.dirname(resolved);
|
|
49
|
+
const directEntrypoints = {
|
|
50
|
+
codex: {
|
|
51
|
+
executable: process.execPath,
|
|
52
|
+
prefix: [path.join(npmBin, "node_modules", "@openai", "codex", "bin", "codex.js")],
|
|
53
|
+
},
|
|
54
|
+
claude: {
|
|
55
|
+
executable: path.join(npmBin, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"),
|
|
56
|
+
prefix: [],
|
|
57
|
+
},
|
|
58
|
+
dsh: {
|
|
59
|
+
executable: process.execPath,
|
|
60
|
+
prefix: [path.join(npmBin, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js")],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const direct = directEntrypoints[name];
|
|
64
|
+
if (direct && existsSync(direct.executable) && direct.prefix.every(existsSync)) {
|
|
65
|
+
return spawn(direct.executable, [...direct.prefix, ...args], childOptions);
|
|
66
|
+
}
|
|
67
|
+
return spawn("powershell.exe", [
|
|
68
|
+
"-NoLogo",
|
|
69
|
+
"-NoProfile",
|
|
70
|
+
"-NonInteractive",
|
|
71
|
+
"-ExecutionPolicy",
|
|
72
|
+
"Bypass",
|
|
73
|
+
"-File",
|
|
74
|
+
resolved,
|
|
75
|
+
...args,
|
|
76
|
+
], childOptions);
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved)) {
|
|
79
|
+
throw Object.assign(new Error(`Unsafe command shim without PowerShell alternative: ${resolved}`), { code: "ENOTSUP" });
|
|
80
|
+
}
|
|
81
|
+
return spawn(resolved, args, childOptions);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function probeCommand(name, args = ["--version"]) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let child;
|
|
87
|
+
try {
|
|
88
|
+
child = spawnCommand(name, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
89
|
+
} catch {
|
|
90
|
+
resolve({ available: false, version: null });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let output = "";
|
|
94
|
+
const timer = setTimeout(() => child.kill(), 3000);
|
|
95
|
+
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
96
|
+
child.stderr.on("data", (chunk) => { output += chunk; });
|
|
97
|
+
child.on("error", () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve({ available: false, version: null });
|
|
100
|
+
});
|
|
101
|
+
child.on("close", (code) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve({ available: code === 0, version: output.trim().split(/\r?\n/).at(-1) || null });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function lines(stream, onLine) {
|
|
109
|
+
let buffer = "";
|
|
110
|
+
stream.setEncoding("utf8");
|
|
111
|
+
stream.on("data", (chunk) => {
|
|
112
|
+
buffer += chunk;
|
|
113
|
+
while (true) {
|
|
114
|
+
const index = buffer.indexOf("\n");
|
|
115
|
+
if (index < 0) break;
|
|
116
|
+
const line = buffer.slice(0, index).replace(/\r$/, "");
|
|
117
|
+
buffer = buffer.slice(index + 1);
|
|
118
|
+
if (line) onLine(line);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
stream.on("end", () => {
|
|
122
|
+
const line = buffer.trim();
|
|
123
|
+
if (line) onLine(line);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { probeCommand } from "./process-utils.js";
|
|
2
|
+
|
|
3
|
+
export class UnavailableAdapter {
|
|
4
|
+
constructor({ id, label, command, note }) {
|
|
5
|
+
this.id = id;
|
|
6
|
+
this.label = label;
|
|
7
|
+
this.command = command;
|
|
8
|
+
this.note = note;
|
|
9
|
+
this.capabilities = {
|
|
10
|
+
create: false,
|
|
11
|
+
attach: false,
|
|
12
|
+
discover: false,
|
|
13
|
+
streaming: false,
|
|
14
|
+
approve: false,
|
|
15
|
+
resume: false,
|
|
16
|
+
cancel: false,
|
|
17
|
+
selectModel: false,
|
|
18
|
+
realAgent: true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async detect() {
|
|
23
|
+
const result = await probeCommand(this.command);
|
|
24
|
+
return { ...result, supported: false, note: this.note };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start() {
|
|
28
|
+
throw Object.assign(new Error(`${this.label} adapter is not implemented in this MVP`), { statusCode: 501 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { probeCommand } from "./process-utils.js";
|
|
2
|
+
|
|
3
|
+
const REMOTE_CONTROL_URL = "https://zcode.z.ai/en/docs/remote-control";
|
|
4
|
+
|
|
5
|
+
export class ZCodeNativeAdapter {
|
|
6
|
+
id = "zcode";
|
|
7
|
+
label = "ZCode(原生远控)";
|
|
8
|
+
capabilities = {
|
|
9
|
+
create: false,
|
|
10
|
+
attach: false,
|
|
11
|
+
discover: false,
|
|
12
|
+
streaming: false,
|
|
13
|
+
approve: false,
|
|
14
|
+
resume: false,
|
|
15
|
+
cancel: false,
|
|
16
|
+
selectModel: false,
|
|
17
|
+
realAgent: true,
|
|
18
|
+
nativeRemoteControl: true,
|
|
19
|
+
};
|
|
20
|
+
#probe;
|
|
21
|
+
|
|
22
|
+
constructor({ probe = probeCommand } = {}) {
|
|
23
|
+
this.#probe = probe;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async detect() {
|
|
27
|
+
const command = await this.#probe("zcode");
|
|
28
|
+
return {
|
|
29
|
+
...command,
|
|
30
|
+
supported: false,
|
|
31
|
+
native: true,
|
|
32
|
+
docsUrl: REMOTE_CONTROL_URL,
|
|
33
|
+
note: "ZCode 的官方手机远控和微信 Bot 控制已打开的桌面工作区;它未公开可供 AgentBridge 创建或附着会话的本机 API。请在 ZCode 桌面端使用原生远控入口。",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start() {
|
|
38
|
+
throw Object.assign(new Error("ZCode currently exposes native Remote Control/Bot Channel, not a third-party local session API"), { statusCode: 501 });
|
|
39
|
+
}
|
|
40
|
+
}
|