@nowcrew/daemon 0.6.12 → 0.6.13
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/README.md +6 -3
- package/dist/config.js +4 -2
- package/dist/execution-runner.js +4 -1
- package/dist/host-execution-coordinator.js +3 -3
- package/dist/memory-prune-diagnostics.js +6 -0
- package/dist/remote/claude-bridge.js +558 -0
- package/dist/remote/claude-channel.js +164 -0
- package/dist/remote/codex-client.js +451 -0
- package/dist/remote/codex-runtime.js +77 -0
- package/dist/remote/config.js +135 -0
- package/dist/remote/gateway.js +879 -0
- package/dist/remote/identity.js +39 -0
- package/dist/remote/owner.js +77 -0
- package/dist/remote/protocol.js +211 -0
- package/dist/remote/remote-cli.js +254 -0
- package/dist/remote/runtime-probe.js +182 -0
- package/dist/remote/session-discovery.js +249 -0
- package/dist/remote/wrapper.js +40 -0
- package/dist/remote-web/assets/index-B_6VM_tw.js +94 -0
- package/dist/remote-web/assets/index-L6EiQbJn.css +1 -0
- package/dist/remote-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/remote-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/remote-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/remote-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/remote-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/remote-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/remote-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/remote-web/icons/nowwork-192.png +0 -0
- package/dist/remote-web/icons/nowwork-512.png +0 -0
- package/dist/remote-web/icons/nowwork.svg +7 -0
- package/dist/remote-web/index.html +20 -0
- package/dist/remote-web/manifest.webmanifest +13 -0
- package/dist/remote-web/sw.js +12 -0
- package/dist/serve.js +9 -3
- package/dist/shared-execution-slots.js +17 -9
- package/dist/slog.js +11 -0
- package/dist/workspace.js +18 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -371,9 +371,10 @@ CREW_RUNTIME_SAFE=1
|
|
|
371
371
|
CREW_EXECUTION_MAX_PROMPT_BYTES=256000
|
|
372
372
|
CREW_EXECUTION_MAX_TIMEOUT_MS=10800000
|
|
373
373
|
CREW_EXECUTION_MAX_EVENT_BYTES=64000
|
|
374
|
-
CREW_MAX_PARALLEL=
|
|
374
|
+
CREW_MAX_PARALLEL=10
|
|
375
|
+
CREW_SCHEDULED_MAX_PARALLEL=4
|
|
375
376
|
CREW_EXECUTION_MAX_QUEUED_PER_AGENT=32
|
|
376
|
-
CREW_EXECUTION_MAX_PARALLEL_TOTAL=
|
|
377
|
+
CREW_EXECUTION_MAX_PARALLEL_TOTAL=10
|
|
377
378
|
CREW_EXECUTION_MAX_QUEUED_TOTAL=128
|
|
378
379
|
CREW_EXECUTION_MAX_STARTING_TOTAL=1
|
|
379
380
|
CREW_EXECUTION_MAX_STARTING_PER_RUNTIME=1
|
|
@@ -381,7 +382,9 @@ CREW_EXECUTION_START_GAP_MS=3000
|
|
|
381
382
|
CREW_EXECUTION_STARTUP_TIMEOUT_MS=120000
|
|
382
383
|
```
|
|
383
384
|
|
|
384
|
-
`
|
|
385
|
+
`CREW_MAX_PARALLEL` limits normal executions per Agent. `CREW_SCHEDULED_MAX_PARALLEL` separately
|
|
386
|
+
limits scheduled executions per Agent. `MAX_PARALLEL_TOTAL` is the machine-wide process cap shared by
|
|
387
|
+
protocol-v1 and legacy work.
|
|
385
388
|
Runtime startup is a separate FIFO gate: by default only one Claude, Codex, or Kimi process may be
|
|
386
389
|
initializing at a time, and launches are spaced by three seconds. A process leaves the startup gate
|
|
387
390
|
only after its runtime-specific ready event; startup timeout cancels the owned process tree.
|
package/dist/config.js
CHANGED
|
@@ -10,9 +10,10 @@ export const DEFAULT_EXECUTION_LIMITS = Object.freeze({
|
|
|
10
10
|
maxPromptBytes: 256_000,
|
|
11
11
|
maxTimeoutMs: 3 * 60 * 60_000,
|
|
12
12
|
maxEventBytes: 64_000,
|
|
13
|
-
maxParallelPerAgent:
|
|
13
|
+
maxParallelPerAgent: 10,
|
|
14
|
+
maxParallelScheduledPerAgent: 4,
|
|
14
15
|
maxQueuedPerAgent: 32,
|
|
15
|
-
maxParallelTotal:
|
|
16
|
+
maxParallelTotal: 10,
|
|
16
17
|
maxQueuedTotal: 128,
|
|
17
18
|
maxStartingTotal: 1,
|
|
18
19
|
maxStartingPerRuntime: 1,
|
|
@@ -68,6 +69,7 @@ export function loadConfig(env = process.env) {
|
|
|
68
69
|
maxTimeoutMs: integerEnvAtMost(env, "CREW_EXECUTION_MAX_TIMEOUT_MS", DEFAULT_EXECUTION_LIMITS.maxTimeoutMs, MAX_EXECUTION_TIMEOUT_MS),
|
|
69
70
|
maxEventBytes: integerEnvAtLeast(env, "CREW_EXECUTION_MAX_EVENT_BYTES", DEFAULT_EXECUTION_LIMITS.maxEventBytes, MIN_EXECUTION_EVENT_BYTES),
|
|
70
71
|
maxParallelPerAgent: positiveIntegerEnv(env, "CREW_MAX_PARALLEL", DEFAULT_EXECUTION_LIMITS.maxParallelPerAgent),
|
|
72
|
+
maxParallelScheduledPerAgent: positiveIntegerEnv(env, "CREW_SCHEDULED_MAX_PARALLEL", DEFAULT_EXECUTION_LIMITS.maxParallelScheduledPerAgent),
|
|
71
73
|
maxQueuedPerAgent: positiveIntegerEnv(env, "CREW_EXECUTION_MAX_QUEUED_PER_AGENT", DEFAULT_EXECUTION_LIMITS.maxQueuedPerAgent),
|
|
72
74
|
maxParallelTotal: positiveIntegerEnv(env, "CREW_EXECUTION_MAX_PARALLEL_TOTAL", DEFAULT_EXECUTION_LIMITS.maxParallelTotal),
|
|
73
75
|
maxQueuedTotal: positiveIntegerEnv(env, "CREW_EXECUTION_MAX_QUEUED_TOTAL", DEFAULT_EXECUTION_LIMITS.maxQueuedTotal),
|
package/dist/execution-runner.js
CHANGED
|
@@ -231,7 +231,10 @@ function admission(spec, config, dependencies, at) {
|
|
|
231
231
|
|| dependencies.facts.queuedTotal < 0) {
|
|
232
232
|
return { rejected: rejection(spec.executionId, "invalid_spec", "Invalid local resource facts", at) };
|
|
233
233
|
}
|
|
234
|
-
const
|
|
234
|
+
const maxParallelForAgent = spec.context.scheduledRunId === undefined
|
|
235
|
+
? config.executionLimits.maxParallelPerAgent
|
|
236
|
+
: config.executionLimits.maxParallelScheduledPerAgent;
|
|
237
|
+
const agentAtCapacity = dependencies.facts.activeForAgent >= maxParallelForAgent;
|
|
235
238
|
const machineAtCapacity = dependencies.facts.activeTotal >= config.executionLimits.maxParallelTotal;
|
|
236
239
|
const slotInvalid = dependencies.slot?.state === "ready"
|
|
237
240
|
? agentAtCapacity || machineAtCapacity
|
|
@@ -4,7 +4,7 @@ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { defaultProcessController } from "./execution-journal.js";
|
|
6
6
|
import { createJournalLease, createJournalLeaseRegistry, JournalLockedError, } from "./execution-journal-lock.js";
|
|
7
|
-
const HOST_EXECUTION_SLOT_COUNT =
|
|
7
|
+
const HOST_EXECUTION_SLOT_COUNT = 10;
|
|
8
8
|
const HOST_STARTUP_GAP_MS = 3_000;
|
|
9
9
|
const RETRY_MS = 50;
|
|
10
10
|
export function defaultHostExecutionCoordinatorRoot(userHome = homedir()) {
|
|
@@ -215,8 +215,8 @@ export function createHostExecutionCoordinator(options = {}) {
|
|
|
215
215
|
}
|
|
216
216
|
export function hostCoordinatedSlotManager(local, host) {
|
|
217
217
|
return {
|
|
218
|
-
reserve: (handle, kind) => {
|
|
219
|
-
const localReservation = local.reserve(handle, kind);
|
|
218
|
+
reserve: (handle, kind, executionClass) => {
|
|
219
|
+
const localReservation = local.reserve(handle, kind, executionClass);
|
|
220
220
|
if (!localReservation.accepted)
|
|
221
221
|
return localReservation;
|
|
222
222
|
const hostReservation = host.reserveExecution(localReservation.ready);
|
|
@@ -372,6 +372,10 @@ export function evaluateMemoryPrunePostcondition(before, after, execution) {
|
|
|
372
372
|
outcome = "source_work_log_empty";
|
|
373
373
|
else if (!facts.work_log_cleared)
|
|
374
374
|
outcome = "work_log_not_cleared";
|
|
375
|
+
else if (!after.memory.exists)
|
|
376
|
+
outcome = "shared_memory_missing_after";
|
|
377
|
+
else if ((after.memory.size ?? 0) === 0)
|
|
378
|
+
outcome = "shared_memory_empty_after";
|
|
375
379
|
else if (facts.lessons_changed && facts.shared_memory_has_lessons_reference) {
|
|
376
380
|
outcome = "promoted_and_indexed";
|
|
377
381
|
}
|
|
@@ -381,6 +385,8 @@ export function evaluateMemoryPrunePostcondition(before, after, execution) {
|
|
|
381
385
|
}
|
|
382
386
|
else if (facts.lessons_changed)
|
|
383
387
|
outcome = "promoted_without_index_change";
|
|
388
|
+
else if (facts.shared_memory_changed)
|
|
389
|
+
outcome = "shared_memory_updated";
|
|
384
390
|
else
|
|
385
391
|
outcome = "no_files_changed";
|
|
386
392
|
return { outcome, ...facts };
|
|
@@ -0,0 +1,558 @@
|
|
|
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, MAX_CLAUDE_INPUT_QUEUE_BYTES, MAX_CLAUDE_INPUT_QUEUE_ITEMS, MAX_REMOTE_PROMPT_BYTES, MAX_REMOTE_WEBSOCKET_BUFFER_BYTES, REMOTE_PROTOCOL_VERSION, } from "./protocol.js";
|
|
8
|
+
import { resolveClaudeWrapperSession } from "./wrapper.js";
|
|
9
|
+
const MAX_CLAUDE_INPUT_DEDUP_ENTRIES = 1_000;
|
|
10
|
+
export function claudeInputQueueStatus(outstandingItems, outstandingBytes, text) {
|
|
11
|
+
const byteLength = Buffer.byteLength(text, "utf8");
|
|
12
|
+
if (byteLength > MAX_REMOTE_PROMPT_BYTES)
|
|
13
|
+
return "prompt_too_large";
|
|
14
|
+
if (outstandingItems >= MAX_CLAUDE_INPUT_QUEUE_ITEMS
|
|
15
|
+
|| outstandingBytes + byteLength > MAX_CLAUDE_INPUT_QUEUE_BYTES)
|
|
16
|
+
return "queue_full";
|
|
17
|
+
return "accepted";
|
|
18
|
+
}
|
|
19
|
+
export function shouldReconnectClaudeChannel(closeCode) {
|
|
20
|
+
return closeCode !== 4001;
|
|
21
|
+
}
|
|
22
|
+
class InputStream {
|
|
23
|
+
values = [];
|
|
24
|
+
waiters = [];
|
|
25
|
+
stopped = false;
|
|
26
|
+
push(value) {
|
|
27
|
+
if (this.stopped)
|
|
28
|
+
return;
|
|
29
|
+
const waiter = this.waiters.shift();
|
|
30
|
+
if (waiter)
|
|
31
|
+
waiter({ value, done: false });
|
|
32
|
+
else
|
|
33
|
+
this.values.push(value);
|
|
34
|
+
}
|
|
35
|
+
close() {
|
|
36
|
+
this.stopped = true;
|
|
37
|
+
for (const waiter of this.waiters.splice(0))
|
|
38
|
+
waiter({ value: undefined, done: true });
|
|
39
|
+
}
|
|
40
|
+
[Symbol.asyncIterator]() {
|
|
41
|
+
return {
|
|
42
|
+
next: () => {
|
|
43
|
+
const value = this.values.shift();
|
|
44
|
+
if (value)
|
|
45
|
+
return Promise.resolve({ value, done: false });
|
|
46
|
+
if (this.stopped)
|
|
47
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
48
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const AskQuestionInputSchema = z.object({
|
|
54
|
+
questions: z.array(z.object({
|
|
55
|
+
question: z.string().min(1),
|
|
56
|
+
header: z.string().default("Question"),
|
|
57
|
+
options: z.array(z.object({
|
|
58
|
+
label: z.string(),
|
|
59
|
+
description: z.string().default(""),
|
|
60
|
+
}).passthrough()).default([]),
|
|
61
|
+
}).passthrough()).min(1).max(4),
|
|
62
|
+
}).passthrough();
|
|
63
|
+
function takeValue(args, index, option) {
|
|
64
|
+
const value = args[index + 1];
|
|
65
|
+
if (!value || value.startsWith("-"))
|
|
66
|
+
throw new Error(`${option} requires a value`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function splitTools(value) {
|
|
70
|
+
return value.split(/[ ,]+/).map((part) => part.trim()).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
export function parseClaudeBridgeArgs(userArgs) {
|
|
73
|
+
const resolved = resolveClaudeWrapperSession(userArgs);
|
|
74
|
+
const options = {};
|
|
75
|
+
const prompts = [];
|
|
76
|
+
let title;
|
|
77
|
+
const args = resolved.args;
|
|
78
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
79
|
+
const arg = args[index];
|
|
80
|
+
const inline = (name) => arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
|
|
81
|
+
if (["--session-id", "--resume", "-r"].includes(arg)) {
|
|
82
|
+
index += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg.startsWith("--session-id=") || arg.startsWith("--resume="))
|
|
86
|
+
continue;
|
|
87
|
+
if (arg === "--model" || arg === "--effort" || arg === "--permission-mode" || arg === "--name" || arg === "-n"
|
|
88
|
+
|| arg === "--add-dir" || arg === "--allowedTools" || arg === "--allowed-tools"
|
|
89
|
+
|| arg === "--disallowedTools" || arg === "--disallowed-tools" || arg === "--settings") {
|
|
90
|
+
const value = takeValue(args, index, arg);
|
|
91
|
+
index += 1;
|
|
92
|
+
if (arg === "--model")
|
|
93
|
+
options.model = value;
|
|
94
|
+
else if (arg === "--effort")
|
|
95
|
+
options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(value);
|
|
96
|
+
else if (arg === "--permission-mode")
|
|
97
|
+
options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(value);
|
|
98
|
+
else if (arg === "--name" || arg === "-n")
|
|
99
|
+
title = value;
|
|
100
|
+
else if (arg === "--add-dir")
|
|
101
|
+
(options.additionalDirectories ??= []).push(value);
|
|
102
|
+
else if (arg === "--allowedTools" || arg === "--allowed-tools")
|
|
103
|
+
options.allowedTools = splitTools(value);
|
|
104
|
+
else if (arg === "--disallowedTools" || arg === "--disallowed-tools")
|
|
105
|
+
options.disallowedTools = splitTools(value);
|
|
106
|
+
else
|
|
107
|
+
options.settings = value;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const model = inline("--model");
|
|
111
|
+
const effort = inline("--effort");
|
|
112
|
+
const permission = inline("--permission-mode");
|
|
113
|
+
const name = inline("--name");
|
|
114
|
+
if (model !== null)
|
|
115
|
+
options.model = model;
|
|
116
|
+
else if (effort !== null)
|
|
117
|
+
options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(effort);
|
|
118
|
+
else if (permission !== null)
|
|
119
|
+
options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(permission);
|
|
120
|
+
else if (name !== null)
|
|
121
|
+
title = name;
|
|
122
|
+
else if (arg === "--dangerously-skip-permissions") {
|
|
123
|
+
options.permissionMode = "bypassPermissions";
|
|
124
|
+
options.allowDangerouslySkipPermissions = true;
|
|
125
|
+
}
|
|
126
|
+
else if (arg.startsWith("-")) {
|
|
127
|
+
throw new Error(`Claude mobile bridge does not support ${arg}`);
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
prompts.push(arg);
|
|
131
|
+
}
|
|
132
|
+
const hasResume = userArgs.some((value) => value === "--resume" || value === "-r" || value.startsWith("--resume="));
|
|
133
|
+
return {
|
|
134
|
+
sessionId: resolved.sessionId,
|
|
135
|
+
resume: hasResume,
|
|
136
|
+
...(title === undefined ? {} : { title }),
|
|
137
|
+
...(prompts.length === 0 ? {} : { initialPrompt: prompts.join(" ") }),
|
|
138
|
+
options,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function userMessage(sessionId, text, uuid, timestamp) {
|
|
142
|
+
return {
|
|
143
|
+
type: "user",
|
|
144
|
+
message: { role: "user", content: text },
|
|
145
|
+
parent_tool_use_id: null,
|
|
146
|
+
session_id: sessionId,
|
|
147
|
+
uuid,
|
|
148
|
+
timestamp,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function preview(input) {
|
|
152
|
+
const command = typeof input.command === "string" ? input.command : null;
|
|
153
|
+
const path = typeof input.file_path === "string" ? input.file_path : null;
|
|
154
|
+
return (command ?? path ?? JSON.stringify(input)).slice(0, 16_000);
|
|
155
|
+
}
|
|
156
|
+
function printMessage(message) {
|
|
157
|
+
let printedText = false;
|
|
158
|
+
for (const line of toConsoleLines(message)) {
|
|
159
|
+
if (line.stream === "result" || !line.text.trim())
|
|
160
|
+
continue;
|
|
161
|
+
if (line.stream === "text")
|
|
162
|
+
printedText = true;
|
|
163
|
+
const prefix = line.stream === "thinking" ? "Thinking: "
|
|
164
|
+
: line.stream === "tool" ? "Tool: "
|
|
165
|
+
: line.stream === "tool_result" ? "Result: "
|
|
166
|
+
: line.stream === "error" ? "Error: " : "";
|
|
167
|
+
process.stdout.write(`\n${prefix}${line.text}\n`);
|
|
168
|
+
}
|
|
169
|
+
return printedText;
|
|
170
|
+
}
|
|
171
|
+
export async function runClaudeBridge(userArgs, context) {
|
|
172
|
+
const parsed = parseClaudeBridgeArgs(userArgs);
|
|
173
|
+
const generation = randomUUID();
|
|
174
|
+
const inputStream = new InputStream();
|
|
175
|
+
const inputs = [];
|
|
176
|
+
const interactions = new Map();
|
|
177
|
+
const acceptedRemoteInputs = new Set();
|
|
178
|
+
const terminalInteractions = [];
|
|
179
|
+
let terminalHandler = null;
|
|
180
|
+
let active = null;
|
|
181
|
+
let outstandingInputBytes = 0;
|
|
182
|
+
let stopped = false;
|
|
183
|
+
let cleaned = false;
|
|
184
|
+
let superseded = false;
|
|
185
|
+
let channelReady = false;
|
|
186
|
+
let hasRegisteredOnce = false;
|
|
187
|
+
let socket = null;
|
|
188
|
+
let reconnectTimer;
|
|
189
|
+
let assistantPrinted = false;
|
|
190
|
+
let conversation = null;
|
|
191
|
+
let terminal = null;
|
|
192
|
+
const url = new URL("/ws/channel", context.gatewayUrl);
|
|
193
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
194
|
+
const send = (value) => {
|
|
195
|
+
if (socket?.readyState !== WebSocket.OPEN)
|
|
196
|
+
return false;
|
|
197
|
+
const frame = ChannelToGatewaySchema.parse(value);
|
|
198
|
+
if (frame.type !== "channel.register" && !channelReady)
|
|
199
|
+
return false;
|
|
200
|
+
const serialized = JSON.stringify(frame);
|
|
201
|
+
if (socket.bufferedAmount + Buffer.byteLength(serialized, "utf8") > MAX_REMOTE_WEBSOCKET_BUFFER_BYTES) {
|
|
202
|
+
socket.close(1013, "outbound buffer limit");
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
socket.send(serialized);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const register = () => send({
|
|
214
|
+
type: "channel.register",
|
|
215
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
216
|
+
sessionId: parsed.sessionId,
|
|
217
|
+
cwd: context.cwd,
|
|
218
|
+
pid: process.pid,
|
|
219
|
+
generation,
|
|
220
|
+
...(parsed.title === undefined ? {} : { title: parsed.title }),
|
|
221
|
+
});
|
|
222
|
+
const sendActiveLocalUserMessage = () => {
|
|
223
|
+
if (!active || active.requestId !== null)
|
|
224
|
+
return;
|
|
225
|
+
send({
|
|
226
|
+
type: "bridge.user_message",
|
|
227
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
228
|
+
sessionId: parsed.sessionId,
|
|
229
|
+
messageId: active.messageId,
|
|
230
|
+
text: active.text,
|
|
231
|
+
createdAt: active.createdAt,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const finishInteraction = (requestId, value, local) => {
|
|
235
|
+
const interaction = interactions.get(requestId);
|
|
236
|
+
if (!interaction)
|
|
237
|
+
return false;
|
|
238
|
+
interactions.delete(requestId);
|
|
239
|
+
const index = terminalInteractions.indexOf(requestId);
|
|
240
|
+
if (index >= 0)
|
|
241
|
+
terminalInteractions.splice(index, 1);
|
|
242
|
+
terminalHandler = null;
|
|
243
|
+
interaction.resolve(value);
|
|
244
|
+
if (local)
|
|
245
|
+
send({
|
|
246
|
+
type: "bridge.resolved",
|
|
247
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
248
|
+
sessionId: parsed.sessionId,
|
|
249
|
+
requestId,
|
|
250
|
+
});
|
|
251
|
+
return true;
|
|
252
|
+
};
|
|
253
|
+
const stop = () => {
|
|
254
|
+
if (cleaned)
|
|
255
|
+
return;
|
|
256
|
+
cleaned = true;
|
|
257
|
+
stopped = true;
|
|
258
|
+
channelReady = false;
|
|
259
|
+
if (reconnectTimer)
|
|
260
|
+
clearTimeout(reconnectTimer);
|
|
261
|
+
inputStream.close();
|
|
262
|
+
conversation?.close();
|
|
263
|
+
terminal?.close();
|
|
264
|
+
socket?.close();
|
|
265
|
+
for (const id of [...interactions.keys()])
|
|
266
|
+
finishInteraction(id, { decision: "deny" }, true);
|
|
267
|
+
};
|
|
268
|
+
const acknowledgeDelivery = (deliveryId, status) => {
|
|
269
|
+
send({
|
|
270
|
+
type: "channel.delivery_ack",
|
|
271
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
272
|
+
sessionId: parsed.sessionId,
|
|
273
|
+
generation,
|
|
274
|
+
deliveryId,
|
|
275
|
+
status,
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
const connectGateway = () => new Promise((resolve, reject) => {
|
|
279
|
+
const next = new WebSocket(url, { headers: { authorization: `Bearer ${context.token}` } });
|
|
280
|
+
socket = next;
|
|
281
|
+
channelReady = false;
|
|
282
|
+
let settled = false;
|
|
283
|
+
let registrationTimer;
|
|
284
|
+
next.once("open", () => {
|
|
285
|
+
if (!register()) {
|
|
286
|
+
next.close(1013, "registration delivery failed");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
registrationTimer = setTimeout(() => next.close(1008, "registration timeout"), 2_000);
|
|
290
|
+
});
|
|
291
|
+
next.on("message", (raw) => {
|
|
292
|
+
let decoded;
|
|
293
|
+
try {
|
|
294
|
+
decoded = JSON.parse(raw.toString());
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const frame = GatewayToChannelSchema.safeParse(decoded);
|
|
300
|
+
if (!frame.success)
|
|
301
|
+
return;
|
|
302
|
+
if (frame.data.type === "channel.registered") {
|
|
303
|
+
if (frame.data.sessionId !== parsed.sessionId || frame.data.generation !== generation) {
|
|
304
|
+
next.close(1008, "registration mismatch");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (registrationTimer)
|
|
308
|
+
clearTimeout(registrationTimer);
|
|
309
|
+
channelReady = true;
|
|
310
|
+
hasRegisteredOnce = true;
|
|
311
|
+
if (active) {
|
|
312
|
+
sendActiveLocalUserMessage();
|
|
313
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
|
|
314
|
+
}
|
|
315
|
+
for (const interaction of interactions.values())
|
|
316
|
+
send(interaction.requestFrame);
|
|
317
|
+
if (!settled) {
|
|
318
|
+
settled = true;
|
|
319
|
+
resolve();
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!channelReady)
|
|
324
|
+
return;
|
|
325
|
+
if (frame.data.type === "channel.prompt") {
|
|
326
|
+
if (acceptedRemoteInputs.has(frame.data.requestId)) {
|
|
327
|
+
acknowledgeDelivery(frame.data.deliveryId, "accepted");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const status = enqueue({ text: frame.data.text, requestId: frame.data.requestId });
|
|
331
|
+
if (status === "accepted") {
|
|
332
|
+
acceptedRemoteInputs.add(frame.data.requestId);
|
|
333
|
+
while (acceptedRemoteInputs.size > MAX_CLAUDE_INPUT_DEDUP_ENTRIES) {
|
|
334
|
+
const oldest = acceptedRemoteInputs.values().next().value;
|
|
335
|
+
if (!oldest)
|
|
336
|
+
break;
|
|
337
|
+
acceptedRemoteInputs.delete(oldest);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
acknowledgeDelivery(frame.data.deliveryId, status === "accepted" ? "accepted" : "queue_full");
|
|
341
|
+
}
|
|
342
|
+
else if (frame.data.type === "bridge.approval_response") {
|
|
343
|
+
const accepted = finishInteraction(frame.data.requestId, { decision: frame.data.decision }, false);
|
|
344
|
+
acknowledgeDelivery(frame.data.deliveryId, accepted ? "accepted" : "not_pending");
|
|
345
|
+
}
|
|
346
|
+
else if (frame.data.type === "bridge.question_response") {
|
|
347
|
+
const accepted = finishInteraction(frame.data.requestId, { answers: frame.data.answers }, false);
|
|
348
|
+
acknowledgeDelivery(frame.data.deliveryId, accepted ? "accepted" : "not_pending");
|
|
349
|
+
}
|
|
350
|
+
else if (frame.data.type === "channel.permission_response") {
|
|
351
|
+
acknowledgeDelivery(frame.data.deliveryId, "not_pending");
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
next.once("error", (error) => {
|
|
355
|
+
if (!settled) {
|
|
356
|
+
settled = true;
|
|
357
|
+
reject(error);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
next.on("close", (code) => {
|
|
361
|
+
if (registrationTimer)
|
|
362
|
+
clearTimeout(registrationTimer);
|
|
363
|
+
const wasCurrent = socket === next;
|
|
364
|
+
if (wasCurrent) {
|
|
365
|
+
socket = null;
|
|
366
|
+
channelReady = false;
|
|
367
|
+
}
|
|
368
|
+
if (code === 4001) {
|
|
369
|
+
superseded = true;
|
|
370
|
+
process.stderr.write("NowCrew remote: this Claude session was replaced by a newer bridge.\n");
|
|
371
|
+
stop();
|
|
372
|
+
if (!settled) {
|
|
373
|
+
settled = true;
|
|
374
|
+
reject(new Error("Claude remote bridge was superseded"));
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!settled) {
|
|
379
|
+
settled = true;
|
|
380
|
+
reject(new Error(`Claude remote gateway closed before registration (${code})`));
|
|
381
|
+
}
|
|
382
|
+
if (wasCurrent && hasRegisteredOnce && !stopped && shouldReconnectClaudeChannel(code)) {
|
|
383
|
+
reconnectTimer = setTimeout(() => { void connectGateway().catch(() => { }); }, 1_000);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
const sendNext = () => {
|
|
388
|
+
if (active || inputs.length === 0 || stopped)
|
|
389
|
+
return;
|
|
390
|
+
active = inputs.shift();
|
|
391
|
+
assistantPrinted = false;
|
|
392
|
+
inputStream.push(userMessage(parsed.sessionId, active.text, active.messageId, active.createdAt));
|
|
393
|
+
sendActiveLocalUserMessage();
|
|
394
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
|
|
395
|
+
};
|
|
396
|
+
function enqueue(value) {
|
|
397
|
+
const status = claudeInputQueueStatus(inputs.length + (active ? 1 : 0), outstandingInputBytes, value.text);
|
|
398
|
+
if (status !== "accepted")
|
|
399
|
+
return status;
|
|
400
|
+
const queued = {
|
|
401
|
+
...value,
|
|
402
|
+
byteLength: Buffer.byteLength(value.text, "utf8"),
|
|
403
|
+
messageId: (value.requestId ?? randomUUID()),
|
|
404
|
+
createdAt: new Date().toISOString(),
|
|
405
|
+
};
|
|
406
|
+
outstandingInputBytes += queued.byteLength;
|
|
407
|
+
inputs.push(queued);
|
|
408
|
+
sendNext();
|
|
409
|
+
return "accepted";
|
|
410
|
+
}
|
|
411
|
+
const waitForInteraction = (interaction, render, handleLine) => {
|
|
412
|
+
const promise = new Promise((resolve) => {
|
|
413
|
+
interactions.set(interaction.id, { ...interaction, resolve });
|
|
414
|
+
});
|
|
415
|
+
terminalInteractions.push(interaction.id);
|
|
416
|
+
render();
|
|
417
|
+
terminalHandler = (line) => {
|
|
418
|
+
const result = handleLine(line);
|
|
419
|
+
if (result)
|
|
420
|
+
finishInteraction(interaction.id, result, true);
|
|
421
|
+
};
|
|
422
|
+
return promise;
|
|
423
|
+
};
|
|
424
|
+
const canUseTool = async (toolName, input, { signal }) => {
|
|
425
|
+
const requestId = randomUUID();
|
|
426
|
+
if (toolName === "AskUserQuestion") {
|
|
427
|
+
const parsedQuestions = AskQuestionInputSchema.safeParse(input);
|
|
428
|
+
if (!parsedQuestions.success)
|
|
429
|
+
return { behavior: "deny", message: "Unsupported question format" };
|
|
430
|
+
const questions = parsedQuestions.data.questions.map((question, index) => ({
|
|
431
|
+
id: `q${index}`,
|
|
432
|
+
header: question.header,
|
|
433
|
+
question: question.question,
|
|
434
|
+
isSecret: false,
|
|
435
|
+
options: question.options,
|
|
436
|
+
}));
|
|
437
|
+
const requestFrame = {
|
|
438
|
+
type: "bridge.question",
|
|
439
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
440
|
+
sessionId: parsed.sessionId,
|
|
441
|
+
requestId,
|
|
442
|
+
questions,
|
|
443
|
+
};
|
|
444
|
+
const waiting = waitForInteraction({ id: requestId, kind: "question", questions, requestFrame }, () => {
|
|
445
|
+
process.stdout.write("\nInput needed:\n");
|
|
446
|
+
for (const [index, question] of questions.entries()) {
|
|
447
|
+
process.stdout.write(`${index + 1}. ${question.header}: ${question.question}\n`);
|
|
448
|
+
for (const option of question.options)
|
|
449
|
+
process.stdout.write(` - ${option.label}: ${option.description}\n`);
|
|
450
|
+
}
|
|
451
|
+
process.stdout.write("Answer: ");
|
|
452
|
+
}, (line) => {
|
|
453
|
+
const values = line.split(";").map((value) => value.trim());
|
|
454
|
+
return { answers: Object.fromEntries(questions.map((question, index) => [question.id, [values[index] ?? ""]])) };
|
|
455
|
+
});
|
|
456
|
+
send(requestFrame);
|
|
457
|
+
signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
|
|
458
|
+
const result = await waiting;
|
|
459
|
+
if (!result.answers)
|
|
460
|
+
return { behavior: "deny", message: "User input cancelled" };
|
|
461
|
+
const answers = Object.fromEntries(questions.map((question) => [
|
|
462
|
+
question.question,
|
|
463
|
+
result.answers?.[question.id]?.join(", ") ?? "",
|
|
464
|
+
]));
|
|
465
|
+
return { behavior: "allow", updatedInput: { ...input, answers } };
|
|
466
|
+
}
|
|
467
|
+
const requestFrame = {
|
|
468
|
+
type: "bridge.approval",
|
|
469
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
470
|
+
sessionId: parsed.sessionId,
|
|
471
|
+
requestId,
|
|
472
|
+
tool: toolName,
|
|
473
|
+
preview: preview(input),
|
|
474
|
+
};
|
|
475
|
+
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" }
|
|
476
|
+
: /^(n|no|deny)$/i.test(line.trim()) ? { decision: "deny" } : null);
|
|
477
|
+
send(requestFrame);
|
|
478
|
+
signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
|
|
479
|
+
const result = await waiting;
|
|
480
|
+
return result.decision === "allow"
|
|
481
|
+
? { behavior: "allow", updatedInput: input }
|
|
482
|
+
: { behavior: "deny", message: "User denied this action" };
|
|
483
|
+
};
|
|
484
|
+
await connectGateway();
|
|
485
|
+
if (stopped)
|
|
486
|
+
return 1;
|
|
487
|
+
const activeConversation = query({
|
|
488
|
+
prompt: inputStream,
|
|
489
|
+
options: {
|
|
490
|
+
cwd: context.cwd,
|
|
491
|
+
canUseTool,
|
|
492
|
+
settingSources: ["user", "project", "local"],
|
|
493
|
+
includePartialMessages: false,
|
|
494
|
+
...(parsed.resume ? { resume: parsed.sessionId } : { sessionId: parsed.sessionId }),
|
|
495
|
+
...parsed.options,
|
|
496
|
+
...(process.env.NOWCREW_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.NOWCREW_CLAUDE_BIN } : {}),
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
conversation = activeConversation;
|
|
500
|
+
terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
501
|
+
terminal.setPrompt("› ");
|
|
502
|
+
terminal.on("line", (line) => {
|
|
503
|
+
if (terminalHandler && terminalInteractions.length > 0)
|
|
504
|
+
terminalHandler(line);
|
|
505
|
+
else if (line.trim()) {
|
|
506
|
+
const status = enqueue({ text: line.trim(), requestId: null });
|
|
507
|
+
if (status !== "accepted")
|
|
508
|
+
process.stderr.write(`NowCrew remote: input rejected (${status}).\n`);
|
|
509
|
+
}
|
|
510
|
+
terminal.prompt();
|
|
511
|
+
});
|
|
512
|
+
terminal.on("close", stop);
|
|
513
|
+
process.stdout.write(`Claude mobile session ${parsed.sessionId}\n`);
|
|
514
|
+
terminal.prompt();
|
|
515
|
+
if (parsed.initialPrompt) {
|
|
516
|
+
const status = enqueue({ text: parsed.initialPrompt, requestId: null });
|
|
517
|
+
if (status !== "accepted")
|
|
518
|
+
process.stderr.write(`NowCrew remote: initial input rejected (${status}).\n`);
|
|
519
|
+
}
|
|
520
|
+
process.once("SIGINT", stop);
|
|
521
|
+
process.once("SIGTERM", stop);
|
|
522
|
+
try {
|
|
523
|
+
for await (const message of activeConversation) {
|
|
524
|
+
if (message.type === "assistant")
|
|
525
|
+
assistantPrinted = printMessage(message) || assistantPrinted;
|
|
526
|
+
else
|
|
527
|
+
printMessage(message);
|
|
528
|
+
if (message.type !== "result")
|
|
529
|
+
continue;
|
|
530
|
+
const result = message.subtype === "success" && "result" in message && typeof message.result === "string"
|
|
531
|
+
? message.result.trim()
|
|
532
|
+
: "";
|
|
533
|
+
if (!assistantPrinted && result)
|
|
534
|
+
process.stdout.write(`\n${result}\n`);
|
|
535
|
+
const completedInput = active;
|
|
536
|
+
if (completedInput)
|
|
537
|
+
send({
|
|
538
|
+
type: "channel.reply",
|
|
539
|
+
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
540
|
+
sessionId: parsed.sessionId,
|
|
541
|
+
requestId: completedInput.messageId,
|
|
542
|
+
text: result,
|
|
543
|
+
});
|
|
544
|
+
if (completedInput)
|
|
545
|
+
outstandingInputBytes = Math.max(0, outstandingInputBytes - completedInput.byteLength);
|
|
546
|
+
active = null;
|
|
547
|
+
send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: false });
|
|
548
|
+
terminal.prompt();
|
|
549
|
+
sendNext();
|
|
550
|
+
}
|
|
551
|
+
return superseded ? 1 : stopped ? 0 : 1;
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
process.off("SIGINT", stop);
|
|
555
|
+
process.off("SIGTERM", stop);
|
|
556
|
+
stop();
|
|
557
|
+
}
|
|
558
|
+
}
|