@nowcrew/daemon 0.5.26 → 0.5.27
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/dist/execution-supervisor.js +19 -5
- package/dist/remote/claude-bridge.js +402 -0
- package/dist/remote/claude-channel.js +164 -0
- package/dist/remote/codex-client.js +408 -0
- package/dist/remote/codex-runtime.js +77 -0
- package/dist/remote/config.js +83 -0
- package/dist/remote/gateway.js +572 -0
- package/dist/remote/protocol.js +178 -0
- package/dist/remote/remote-cli.js +233 -0
- package/dist/remote/session-discovery.js +249 -0
- package/dist/remote/wrapper.js +40 -0
- package/dist/runtimes/codex-app-server-runner.js +5 -1
- package/package.json +2 -2
|
@@ -67,10 +67,12 @@ async function signalOwnedTreeIfPresent(pid, signal, platform, signalTree) {
|
|
|
67
67
|
throw error;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
async function terminateAndConfirmOwnedTree(pid, platform, timeoutMs,
|
|
70
|
+
async function terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisorExited, hasSupervisorExited, signalTree) {
|
|
71
71
|
const waitUntilStopped = () => platform === "win32"
|
|
72
|
-
? waitForExit(
|
|
72
|
+
? waitForExit(supervisorExited.then(() => ({ exitCode: 0 })), timeoutMs, pid)
|
|
73
73
|
: waitForProcessGroupExit(pid, timeoutMs);
|
|
74
|
+
if (platform === "win32" && hasSupervisorExited())
|
|
75
|
+
return;
|
|
74
76
|
let termError;
|
|
75
77
|
try {
|
|
76
78
|
await signalOwnedTreeIfPresent(pid, "SIGTERM", platform, signalTree);
|
|
@@ -78,6 +80,8 @@ async function terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisor
|
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
80
82
|
catch (error) {
|
|
83
|
+
if (platform === "win32" && hasSupervisorExited())
|
|
84
|
+
return;
|
|
81
85
|
termError = error;
|
|
82
86
|
}
|
|
83
87
|
try {
|
|
@@ -85,13 +89,15 @@ async function terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisor
|
|
|
85
89
|
await waitUntilStopped();
|
|
86
90
|
}
|
|
87
91
|
catch (killError) {
|
|
92
|
+
if (platform === "win32" && hasSupervisorExited())
|
|
93
|
+
return;
|
|
88
94
|
throw new AggregateError([termError, killError], "Supervisor process-tree termination failed");
|
|
89
95
|
}
|
|
90
96
|
}
|
|
91
|
-
async function confirmOrTerminateOwnedTree(pid, platform, timeoutMs,
|
|
97
|
+
async function confirmOrTerminateOwnedTree(pid, platform, timeoutMs, supervisorExited, hasSupervisorExited, signalTree) {
|
|
92
98
|
if (platform !== "win32" && !(await processGroupExists(pid)))
|
|
93
99
|
return;
|
|
94
|
-
await terminateAndConfirmOwnedTree(pid, platform, timeoutMs,
|
|
100
|
+
await terminateAndConfirmOwnedTree(pid, platform, timeoutMs, supervisorExited, hasSupervisorExited, signalTree);
|
|
95
101
|
}
|
|
96
102
|
async function withTimeout(promise, timeoutMs, phase) {
|
|
97
103
|
let timer;
|
|
@@ -174,9 +180,17 @@ export async function startDormantSupervisor(launch, options = {}) {
|
|
|
174
180
|
}));
|
|
175
181
|
});
|
|
176
182
|
const supervisorClosed = new Promise((resolve) => child.once("close", () => resolve()));
|
|
183
|
+
// Windows PID ownership ends at `exit`; `close` may lag while stdio/IPC handles drain.
|
|
184
|
+
let didSupervisorExit = false;
|
|
185
|
+
const supervisorExited = new Promise((resolve) => {
|
|
186
|
+
child.once("exit", () => {
|
|
187
|
+
didSupervisorExit = true;
|
|
188
|
+
resolve();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
177
191
|
let treeStopPromise = null;
|
|
178
192
|
const ensureTreeStopped = () => {
|
|
179
|
-
treeStopPromise ??= confirmOrTerminateOwnedTree(pid, platform, abortTimeoutMs,
|
|
193
|
+
treeStopPromise ??= confirmOrTerminateOwnedTree(pid, platform, abortTimeoutMs, supervisorExited, () => didSupervisorExit, signalTree);
|
|
180
194
|
return treeStopPromise;
|
|
181
195
|
};
|
|
182
196
|
const exit = supervisorExit.then(async (result) => {
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { query, } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { toConsoleLines } from "../console.js";
|
|
7
|
+
import { ChannelToGatewaySchema, GatewayToChannelSchema, REMOTE_PROTOCOL_VERSION, } from "./protocol.js";
|
|
8
|
+
import { resolveClaudeWrapperSession } from "./wrapper.js";
|
|
9
|
+
class InputStream {
|
|
10
|
+
values = [];
|
|
11
|
+
waiters = [];
|
|
12
|
+
stopped = false;
|
|
13
|
+
push(value) {
|
|
14
|
+
if (this.stopped)
|
|
15
|
+
return;
|
|
16
|
+
const waiter = this.waiters.shift();
|
|
17
|
+
if (waiter)
|
|
18
|
+
waiter({ value, done: false });
|
|
19
|
+
else
|
|
20
|
+
this.values.push(value);
|
|
21
|
+
}
|
|
22
|
+
close() {
|
|
23
|
+
this.stopped = true;
|
|
24
|
+
for (const waiter of this.waiters.splice(0))
|
|
25
|
+
waiter({ value: undefined, done: true });
|
|
26
|
+
}
|
|
27
|
+
[Symbol.asyncIterator]() {
|
|
28
|
+
return {
|
|
29
|
+
next: () => {
|
|
30
|
+
const value = this.values.shift();
|
|
31
|
+
if (value)
|
|
32
|
+
return Promise.resolve({ value, done: false });
|
|
33
|
+
if (this.stopped)
|
|
34
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
35
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const AskQuestionInputSchema = z.object({
|
|
41
|
+
questions: z.array(z.object({
|
|
42
|
+
question: z.string().min(1),
|
|
43
|
+
header: z.string().default("Question"),
|
|
44
|
+
options: z.array(z.object({
|
|
45
|
+
label: z.string(),
|
|
46
|
+
description: z.string().default(""),
|
|
47
|
+
}).passthrough()).default([]),
|
|
48
|
+
}).passthrough()).min(1).max(4),
|
|
49
|
+
}).passthrough();
|
|
50
|
+
function takeValue(args, index, option) {
|
|
51
|
+
const value = args[index + 1];
|
|
52
|
+
if (!value || value.startsWith("-"))
|
|
53
|
+
throw new Error(`${option} requires a value`);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function splitTools(value) {
|
|
57
|
+
return value.split(/[ ,]+/).map((part) => part.trim()).filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
export function parseClaudeBridgeArgs(userArgs) {
|
|
60
|
+
const resolved = resolveClaudeWrapperSession(userArgs);
|
|
61
|
+
const options = {};
|
|
62
|
+
const prompts = [];
|
|
63
|
+
let title;
|
|
64
|
+
const args = resolved.args;
|
|
65
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
const inline = (name) => arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
|
|
68
|
+
if (["--session-id", "--resume", "-r"].includes(arg)) {
|
|
69
|
+
index += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg.startsWith("--session-id=") || arg.startsWith("--resume="))
|
|
73
|
+
continue;
|
|
74
|
+
if (arg === "--model" || arg === "--effort" || arg === "--permission-mode" || arg === "--name" || arg === "-n"
|
|
75
|
+
|| arg === "--add-dir" || arg === "--allowedTools" || arg === "--allowed-tools"
|
|
76
|
+
|| arg === "--disallowedTools" || arg === "--disallowed-tools" || arg === "--settings") {
|
|
77
|
+
const value = takeValue(args, index, arg);
|
|
78
|
+
index += 1;
|
|
79
|
+
if (arg === "--model")
|
|
80
|
+
options.model = value;
|
|
81
|
+
else if (arg === "--effort")
|
|
82
|
+
options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(value);
|
|
83
|
+
else if (arg === "--permission-mode")
|
|
84
|
+
options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(value);
|
|
85
|
+
else if (arg === "--name" || arg === "-n")
|
|
86
|
+
title = value;
|
|
87
|
+
else if (arg === "--add-dir")
|
|
88
|
+
(options.additionalDirectories ??= []).push(value);
|
|
89
|
+
else if (arg === "--allowedTools" || arg === "--allowed-tools")
|
|
90
|
+
options.allowedTools = splitTools(value);
|
|
91
|
+
else if (arg === "--disallowedTools" || arg === "--disallowed-tools")
|
|
92
|
+
options.disallowedTools = splitTools(value);
|
|
93
|
+
else
|
|
94
|
+
options.settings = value;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const model = inline("--model");
|
|
98
|
+
const effort = inline("--effort");
|
|
99
|
+
const permission = inline("--permission-mode");
|
|
100
|
+
const name = inline("--name");
|
|
101
|
+
if (model !== null)
|
|
102
|
+
options.model = model;
|
|
103
|
+
else if (effort !== null)
|
|
104
|
+
options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(effort);
|
|
105
|
+
else if (permission !== null)
|
|
106
|
+
options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(permission);
|
|
107
|
+
else if (name !== null)
|
|
108
|
+
title = name;
|
|
109
|
+
else if (arg === "--dangerously-skip-permissions") {
|
|
110
|
+
options.permissionMode = "bypassPermissions";
|
|
111
|
+
options.allowDangerouslySkipPermissions = true;
|
|
112
|
+
}
|
|
113
|
+
else if (arg.startsWith("-")) {
|
|
114
|
+
throw new Error(`Claude mobile bridge does not support ${arg}`);
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
prompts.push(arg);
|
|
118
|
+
}
|
|
119
|
+
const hasResume = userArgs.some((value) => value === "--resume" || value === "-r" || value.startsWith("--resume="));
|
|
120
|
+
return {
|
|
121
|
+
sessionId: resolved.sessionId,
|
|
122
|
+
resume: hasResume,
|
|
123
|
+
...(title === undefined ? {} : { title }),
|
|
124
|
+
...(prompts.length === 0 ? {} : { initialPrompt: prompts.join(" ") }),
|
|
125
|
+
options,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function userMessage(sessionId, text) {
|
|
129
|
+
return {
|
|
130
|
+
type: "user",
|
|
131
|
+
message: { role: "user", content: text },
|
|
132
|
+
parent_tool_use_id: null,
|
|
133
|
+
session_id: sessionId,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function preview(input) {
|
|
137
|
+
const command = typeof input.command === "string" ? input.command : null;
|
|
138
|
+
const path = typeof input.file_path === "string" ? input.file_path : null;
|
|
139
|
+
return (command ?? path ?? JSON.stringify(input)).slice(0, 16_000);
|
|
140
|
+
}
|
|
141
|
+
function printMessage(message) {
|
|
142
|
+
let printedText = false;
|
|
143
|
+
for (const line of toConsoleLines(message)) {
|
|
144
|
+
if (line.stream === "result" || !line.text.trim())
|
|
145
|
+
continue;
|
|
146
|
+
if (line.stream === "text")
|
|
147
|
+
printedText = true;
|
|
148
|
+
const prefix = line.stream === "thinking" ? "Thinking: "
|
|
149
|
+
: line.stream === "tool" ? "Tool: "
|
|
150
|
+
: line.stream === "tool_result" ? "Result: "
|
|
151
|
+
: line.stream === "error" ? "Error: " : "";
|
|
152
|
+
process.stdout.write(`\n${prefix}${line.text}\n`);
|
|
153
|
+
}
|
|
154
|
+
return printedText;
|
|
155
|
+
}
|
|
156
|
+
export async function runClaudeBridge(userArgs, context) {
|
|
157
|
+
const parsed = parseClaudeBridgeArgs(userArgs);
|
|
158
|
+
const generation = randomUUID();
|
|
159
|
+
const inputStream = new InputStream();
|
|
160
|
+
const inputs = [];
|
|
161
|
+
const interactions = new Map();
|
|
162
|
+
const terminalInteractions = [];
|
|
163
|
+
let terminalHandler = null;
|
|
164
|
+
let active = null;
|
|
165
|
+
let stopped = false;
|
|
166
|
+
let socket = null;
|
|
167
|
+
let reconnectTimer;
|
|
168
|
+
let assistantPrinted = false;
|
|
169
|
+
const url = new URL("/ws/channel", context.gatewayUrl);
|
|
170
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
171
|
+
const send = (value) => {
|
|
172
|
+
if (socket?.readyState === WebSocket.OPEN)
|
|
173
|
+
socket.send(JSON.stringify(ChannelToGatewaySchema.parse(value)));
|
|
174
|
+
};
|
|
175
|
+
const register = () => send({
|
|
176
|
+
type: "channel.register",
|
|
177
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
178
|
+
sessionId: parsed.sessionId,
|
|
179
|
+
cwd: context.cwd,
|
|
180
|
+
pid: process.pid,
|
|
181
|
+
generation,
|
|
182
|
+
...(parsed.title === undefined ? {} : { title: parsed.title }),
|
|
183
|
+
});
|
|
184
|
+
const finishInteraction = (requestId, value, local) => {
|
|
185
|
+
const interaction = interactions.get(requestId);
|
|
186
|
+
if (!interaction)
|
|
187
|
+
return;
|
|
188
|
+
interactions.delete(requestId);
|
|
189
|
+
const index = terminalInteractions.indexOf(requestId);
|
|
190
|
+
if (index >= 0)
|
|
191
|
+
terminalInteractions.splice(index, 1);
|
|
192
|
+
terminalHandler = null;
|
|
193
|
+
interaction.resolve(value);
|
|
194
|
+
if (local)
|
|
195
|
+
send({
|
|
196
|
+
type: "bridge.resolved",
|
|
197
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
198
|
+
sessionId: parsed.sessionId,
|
|
199
|
+
requestId,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const connectGateway = () => new Promise((resolve, reject) => {
|
|
203
|
+
const next = new WebSocket(url, { headers: { authorization: `Bearer ${context.token}` } });
|
|
204
|
+
socket = next;
|
|
205
|
+
next.once("open", () => {
|
|
206
|
+
register();
|
|
207
|
+
if (active)
|
|
208
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
|
|
209
|
+
for (const interaction of interactions.values())
|
|
210
|
+
send(interaction.requestFrame);
|
|
211
|
+
resolve();
|
|
212
|
+
});
|
|
213
|
+
next.on("message", (raw) => {
|
|
214
|
+
let decoded;
|
|
215
|
+
try {
|
|
216
|
+
decoded = JSON.parse(raw.toString());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const frame = GatewayToChannelSchema.safeParse(decoded);
|
|
222
|
+
if (!frame.success)
|
|
223
|
+
return;
|
|
224
|
+
if (frame.data.type === "channel.prompt")
|
|
225
|
+
enqueue({ text: frame.data.text, requestId: frame.data.requestId });
|
|
226
|
+
else if (frame.data.type === "bridge.approval_response") {
|
|
227
|
+
finishInteraction(frame.data.requestId, { decision: frame.data.decision }, false);
|
|
228
|
+
}
|
|
229
|
+
else if (frame.data.type === "bridge.question_response") {
|
|
230
|
+
finishInteraction(frame.data.requestId, { answers: frame.data.answers }, false);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
next.once("error", reject);
|
|
234
|
+
next.on("close", () => {
|
|
235
|
+
if (socket === next)
|
|
236
|
+
socket = null;
|
|
237
|
+
if (!stopped)
|
|
238
|
+
reconnectTimer = setTimeout(() => { void connectGateway().catch(() => { }); }, 1_000);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
const sendNext = () => {
|
|
242
|
+
if (active || inputs.length === 0 || stopped)
|
|
243
|
+
return;
|
|
244
|
+
active = inputs.shift();
|
|
245
|
+
assistantPrinted = false;
|
|
246
|
+
inputStream.push(userMessage(parsed.sessionId, active.text));
|
|
247
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
|
|
248
|
+
};
|
|
249
|
+
function enqueue(value) {
|
|
250
|
+
inputs.push(value);
|
|
251
|
+
sendNext();
|
|
252
|
+
}
|
|
253
|
+
const waitForInteraction = (interaction, render, handleLine) => {
|
|
254
|
+
const promise = new Promise((resolve) => {
|
|
255
|
+
interactions.set(interaction.id, { ...interaction, resolve });
|
|
256
|
+
});
|
|
257
|
+
terminalInteractions.push(interaction.id);
|
|
258
|
+
render();
|
|
259
|
+
terminalHandler = (line) => {
|
|
260
|
+
const result = handleLine(line);
|
|
261
|
+
if (result)
|
|
262
|
+
finishInteraction(interaction.id, result, true);
|
|
263
|
+
};
|
|
264
|
+
return promise;
|
|
265
|
+
};
|
|
266
|
+
const canUseTool = async (toolName, input, { signal }) => {
|
|
267
|
+
const requestId = randomUUID();
|
|
268
|
+
if (toolName === "AskUserQuestion") {
|
|
269
|
+
const parsedQuestions = AskQuestionInputSchema.safeParse(input);
|
|
270
|
+
if (!parsedQuestions.success)
|
|
271
|
+
return { behavior: "deny", message: "Unsupported question format" };
|
|
272
|
+
const questions = parsedQuestions.data.questions.map((question, index) => ({
|
|
273
|
+
id: `q${index}`,
|
|
274
|
+
header: question.header,
|
|
275
|
+
question: question.question,
|
|
276
|
+
isSecret: false,
|
|
277
|
+
options: question.options,
|
|
278
|
+
}));
|
|
279
|
+
const requestFrame = {
|
|
280
|
+
type: "bridge.question",
|
|
281
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
282
|
+
sessionId: parsed.sessionId,
|
|
283
|
+
requestId,
|
|
284
|
+
questions,
|
|
285
|
+
};
|
|
286
|
+
send(requestFrame);
|
|
287
|
+
const waiting = waitForInteraction({ id: requestId, kind: "question", questions, requestFrame }, () => {
|
|
288
|
+
process.stdout.write("\nInput needed:\n");
|
|
289
|
+
for (const [index, question] of questions.entries()) {
|
|
290
|
+
process.stdout.write(`${index + 1}. ${question.header}: ${question.question}\n`);
|
|
291
|
+
for (const option of question.options)
|
|
292
|
+
process.stdout.write(` - ${option.label}: ${option.description}\n`);
|
|
293
|
+
}
|
|
294
|
+
process.stdout.write("Answer: ");
|
|
295
|
+
}, (line) => {
|
|
296
|
+
const values = line.split(";").map((value) => value.trim());
|
|
297
|
+
return { answers: Object.fromEntries(questions.map((question, index) => [question.id, [values[index] ?? ""]])) };
|
|
298
|
+
});
|
|
299
|
+
signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
|
|
300
|
+
const result = await waiting;
|
|
301
|
+
if (!result.answers)
|
|
302
|
+
return { behavior: "deny", message: "User input cancelled" };
|
|
303
|
+
const answers = Object.fromEntries(questions.map((question) => [
|
|
304
|
+
question.question,
|
|
305
|
+
result.answers?.[question.id]?.join(", ") ?? "",
|
|
306
|
+
]));
|
|
307
|
+
return { behavior: "allow", updatedInput: { ...input, answers } };
|
|
308
|
+
}
|
|
309
|
+
const requestFrame = {
|
|
310
|
+
type: "bridge.approval",
|
|
311
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
312
|
+
sessionId: parsed.sessionId,
|
|
313
|
+
requestId,
|
|
314
|
+
tool: toolName,
|
|
315
|
+
preview: preview(input),
|
|
316
|
+
};
|
|
317
|
+
send(requestFrame);
|
|
318
|
+
const waiting = waitForInteraction({ id: requestId, kind: "approval", requestFrame }, () => process.stdout.write(`\nApproval required: ${toolName}\n${preview(input)}\nAllow? `), (line) => /^(y|yes|allow)$/i.test(line.trim()) ? { decision: "allow" }
|
|
319
|
+
: /^(n|no|deny)$/i.test(line.trim()) ? { decision: "deny" } : null);
|
|
320
|
+
signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
|
|
321
|
+
const result = await waiting;
|
|
322
|
+
return result.decision === "allow"
|
|
323
|
+
? { behavior: "allow", updatedInput: input }
|
|
324
|
+
: { behavior: "deny", message: "User denied this action" };
|
|
325
|
+
};
|
|
326
|
+
await connectGateway();
|
|
327
|
+
const conversation = query({
|
|
328
|
+
prompt: inputStream,
|
|
329
|
+
options: {
|
|
330
|
+
cwd: context.cwd,
|
|
331
|
+
canUseTool,
|
|
332
|
+
settingSources: ["user", "project", "local"],
|
|
333
|
+
includePartialMessages: false,
|
|
334
|
+
...(parsed.resume ? { resume: parsed.sessionId } : { sessionId: parsed.sessionId }),
|
|
335
|
+
...parsed.options,
|
|
336
|
+
...(process.env.NOWCREW_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.NOWCREW_CLAUDE_BIN } : {}),
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
340
|
+
terminal.setPrompt("› ");
|
|
341
|
+
terminal.on("line", (line) => {
|
|
342
|
+
if (terminalHandler && terminalInteractions.length > 0)
|
|
343
|
+
terminalHandler(line);
|
|
344
|
+
else if (line.trim())
|
|
345
|
+
enqueue({ text: line.trim(), requestId: null });
|
|
346
|
+
terminal.prompt();
|
|
347
|
+
});
|
|
348
|
+
terminal.on("close", () => { stopped = true; conversation.close(); });
|
|
349
|
+
process.stdout.write(`Claude mobile session ${parsed.sessionId}\n`);
|
|
350
|
+
terminal.prompt();
|
|
351
|
+
if (parsed.initialPrompt)
|
|
352
|
+
enqueue({ text: parsed.initialPrompt, requestId: null });
|
|
353
|
+
const stop = () => {
|
|
354
|
+
if (stopped)
|
|
355
|
+
return;
|
|
356
|
+
stopped = true;
|
|
357
|
+
if (reconnectTimer)
|
|
358
|
+
clearTimeout(reconnectTimer);
|
|
359
|
+
inputStream.close();
|
|
360
|
+
conversation.close();
|
|
361
|
+
terminal.close();
|
|
362
|
+
socket?.close();
|
|
363
|
+
for (const id of [...interactions.keys()])
|
|
364
|
+
finishInteraction(id, { decision: "deny" }, true);
|
|
365
|
+
};
|
|
366
|
+
process.once("SIGINT", stop);
|
|
367
|
+
process.once("SIGTERM", stop);
|
|
368
|
+
try {
|
|
369
|
+
for await (const message of conversation) {
|
|
370
|
+
if (message.type === "assistant")
|
|
371
|
+
assistantPrinted = printMessage(message) || assistantPrinted;
|
|
372
|
+
else
|
|
373
|
+
printMessage(message);
|
|
374
|
+
if (message.type !== "result")
|
|
375
|
+
continue;
|
|
376
|
+
const result = message.subtype === "success" && "result" in message && typeof message.result === "string"
|
|
377
|
+
? message.result.trim()
|
|
378
|
+
: "";
|
|
379
|
+
if (!assistantPrinted && result)
|
|
380
|
+
process.stdout.write(`\n${result}\n`);
|
|
381
|
+
const completedInput = active;
|
|
382
|
+
if (completedInput?.requestId && result)
|
|
383
|
+
send({
|
|
384
|
+
type: "channel.reply",
|
|
385
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
386
|
+
sessionId: parsed.sessionId,
|
|
387
|
+
requestId: completedInput.requestId,
|
|
388
|
+
text: result,
|
|
389
|
+
});
|
|
390
|
+
active = null;
|
|
391
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: false });
|
|
392
|
+
terminal.prompt();
|
|
393
|
+
sendNext();
|
|
394
|
+
}
|
|
395
|
+
return stopped ? 0 : 1;
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
process.off("SIGINT", stop);
|
|
399
|
+
process.off("SIGTERM", stop);
|
|
400
|
+
stop();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, NotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { z as z4 } from "zod/v4";
|
|
8
|
+
import { ChannelToGatewaySchema, GatewayToChannelSchema, REMOTE_PROTOCOL_VERSION, } from "./protocol.js";
|
|
9
|
+
const PermissionRequestSchema = NotificationSchema.extend({
|
|
10
|
+
method: z4.literal("notifications/claude/channel/permission_request"),
|
|
11
|
+
params: z4.object({
|
|
12
|
+
request_id: z4.string().regex(/^[a-km-z]{5}$/),
|
|
13
|
+
tool_name: z4.string(),
|
|
14
|
+
description: z4.string(),
|
|
15
|
+
input_preview: z4.string(),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
const ChannelEnvironmentSchema = z.object({
|
|
19
|
+
NOWCREW_REMOTE_URL: z.string().url(),
|
|
20
|
+
NOWCREW_REMOTE_TOKEN_FILE: z.string().min(1),
|
|
21
|
+
NOWCREW_REMOTE_SESSION_ID: z.string().uuid(),
|
|
22
|
+
NOWCREW_REMOTE_GENERATION: z.string().uuid(),
|
|
23
|
+
NOWCREW_REMOTE_CWD: z.string().min(1),
|
|
24
|
+
}).passthrough();
|
|
25
|
+
export function createClaudeChannelServer(sendToGateway) {
|
|
26
|
+
const mcp = new Server({ name: "nowcrew", version: "1.0.0" }, {
|
|
27
|
+
capabilities: {
|
|
28
|
+
experimental: {
|
|
29
|
+
"claude/channel": {},
|
|
30
|
+
"claude/channel/permission": {},
|
|
31
|
+
},
|
|
32
|
+
tools: {},
|
|
33
|
+
},
|
|
34
|
+
instructions: "Messages from the user's authenticated NowCrew phone arrive as <channel source=\"nowcrew\" request_id=\"...\">. "
|
|
35
|
+
+ "Handle them as direct user requests in this session. Render the complete user-facing answer normally in the terminal, "
|
|
36
|
+
+ "then call the reply tool exactly once with the same complete answer and request_id so the phone stays synchronized.",
|
|
37
|
+
});
|
|
38
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
39
|
+
tools: [{
|
|
40
|
+
name: "reply",
|
|
41
|
+
description: "Send the complete user-facing answer back to the authenticated NowCrew phone",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
request_id: { type: "string", format: "uuid" },
|
|
46
|
+
text: { type: "string", minLength: 1 },
|
|
47
|
+
},
|
|
48
|
+
required: ["request_id", "text"],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
}],
|
|
52
|
+
}));
|
|
53
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
54
|
+
if (request.params.name !== "reply")
|
|
55
|
+
throw new Error(`unknown tool: ${request.params.name}`);
|
|
56
|
+
const args = z.object({ request_id: z.string().uuid(), text: z.string().min(1).max(200_000) })
|
|
57
|
+
.parse(request.params.arguments);
|
|
58
|
+
sendToGateway({ type: "reply", ...args });
|
|
59
|
+
return { content: [{ type: "text", text: "sent" }] };
|
|
60
|
+
});
|
|
61
|
+
mcp.setNotificationHandler(PermissionRequestSchema, ({ params }) => {
|
|
62
|
+
sendToGateway({
|
|
63
|
+
type: "permission",
|
|
64
|
+
requestId: params.request_id,
|
|
65
|
+
tool: params.tool_name,
|
|
66
|
+
description: params.description,
|
|
67
|
+
preview: params.input_preview,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
return mcp;
|
|
71
|
+
}
|
|
72
|
+
export async function runClaudeChannel(env = process.env) {
|
|
73
|
+
const parsed = ChannelEnvironmentSchema.parse(env);
|
|
74
|
+
const token = (await readFile(parsed.NOWCREW_REMOTE_TOKEN_FILE, "utf8")).trim();
|
|
75
|
+
const url = new URL("/ws/channel", parsed.NOWCREW_REMOTE_URL);
|
|
76
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
77
|
+
let socket = null;
|
|
78
|
+
const queued = [];
|
|
79
|
+
const send = (value) => {
|
|
80
|
+
if (socket?.readyState === WebSocket.OPEN)
|
|
81
|
+
socket.send(JSON.stringify(value));
|
|
82
|
+
else
|
|
83
|
+
queued.push(value);
|
|
84
|
+
};
|
|
85
|
+
const mcp = createClaudeChannelServer((value) => {
|
|
86
|
+
const record = value;
|
|
87
|
+
if (record.type === "reply") {
|
|
88
|
+
send(ChannelToGatewaySchema.parse({
|
|
89
|
+
type: "channel.reply",
|
|
90
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
91
|
+
sessionId: parsed.NOWCREW_REMOTE_SESSION_ID,
|
|
92
|
+
requestId: record.request_id,
|
|
93
|
+
text: record.text,
|
|
94
|
+
}));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
send(ChannelToGatewaySchema.parse({
|
|
98
|
+
type: "channel.permission",
|
|
99
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
100
|
+
sessionId: parsed.NOWCREW_REMOTE_SESSION_ID,
|
|
101
|
+
requestId: record.requestId,
|
|
102
|
+
tool: record.tool,
|
|
103
|
+
description: record.description,
|
|
104
|
+
preview: record.preview,
|
|
105
|
+
}));
|
|
106
|
+
});
|
|
107
|
+
await mcp.connect(new StdioServerTransport());
|
|
108
|
+
let stopped = false;
|
|
109
|
+
const connect = () => {
|
|
110
|
+
if (stopped)
|
|
111
|
+
return;
|
|
112
|
+
const next = new WebSocket(url, { headers: { authorization: `Bearer ${token}` } });
|
|
113
|
+
socket = next;
|
|
114
|
+
next.on("open", () => {
|
|
115
|
+
next.send(JSON.stringify(ChannelToGatewaySchema.parse({
|
|
116
|
+
type: "channel.register",
|
|
117
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
118
|
+
sessionId: parsed.NOWCREW_REMOTE_SESSION_ID,
|
|
119
|
+
cwd: parsed.NOWCREW_REMOTE_CWD,
|
|
120
|
+
pid: process.ppid,
|
|
121
|
+
generation: parsed.NOWCREW_REMOTE_GENERATION,
|
|
122
|
+
})));
|
|
123
|
+
for (const value of queued.splice(0))
|
|
124
|
+
next.send(JSON.stringify(value));
|
|
125
|
+
});
|
|
126
|
+
next.on("message", (raw) => {
|
|
127
|
+
const frame = GatewayToChannelSchema.safeParse(JSON.parse(raw.toString()));
|
|
128
|
+
if (!frame.success)
|
|
129
|
+
return;
|
|
130
|
+
if (frame.data.type === "channel.prompt") {
|
|
131
|
+
void mcp.notification({
|
|
132
|
+
method: "notifications/claude/channel",
|
|
133
|
+
params: {
|
|
134
|
+
content: frame.data.text,
|
|
135
|
+
meta: { request_id: frame.data.requestId },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else if (frame.data.type === "channel.permission_response") {
|
|
140
|
+
void mcp.notification({
|
|
141
|
+
method: "notifications/claude/channel/permission",
|
|
142
|
+
params: {
|
|
143
|
+
request_id: frame.data.requestId,
|
|
144
|
+
behavior: frame.data.decision,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
next.on("close", () => {
|
|
150
|
+
if (socket === next)
|
|
151
|
+
socket = null;
|
|
152
|
+
if (!stopped)
|
|
153
|
+
setTimeout(connect, 1_000).unref();
|
|
154
|
+
});
|
|
155
|
+
next.on("error", () => { });
|
|
156
|
+
};
|
|
157
|
+
connect();
|
|
158
|
+
const stop = () => {
|
|
159
|
+
stopped = true;
|
|
160
|
+
socket?.close();
|
|
161
|
+
};
|
|
162
|
+
process.once("SIGTERM", stop);
|
|
163
|
+
process.once("SIGINT", stop);
|
|
164
|
+
}
|