coderemote 0.3.1 → 0.3.4
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 +17 -1
- package/dist/coderemote.js +1709 -130
- package/package.json +1 -1
package/dist/coderemote.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/coderemote.js
|
|
4
|
-
import { execFile as
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
6
|
-
import
|
|
4
|
+
import { execFile as execFile8, spawn as spawn3 } from "node:child_process";
|
|
5
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
6
|
+
import fs13 from "node:fs";
|
|
7
7
|
import net2 from "node:net";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
8
|
+
import os6 from "node:os";
|
|
9
|
+
import path11 from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { promisify as
|
|
11
|
+
import { promisify as promisify5 } from "node:util";
|
|
12
12
|
import qrcode from "qrcode-terminal";
|
|
13
13
|
|
|
14
14
|
// ../../packages/agent-core/src/server.js
|
|
15
|
-
import { createReadStream, createWriteStream } from "node:fs";
|
|
16
|
-
import
|
|
15
|
+
import { createReadStream, createWriteStream as createWriteStream2 } from "node:fs";
|
|
16
|
+
import fs7 from "node:fs/promises";
|
|
17
17
|
import http2 from "node:http";
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import { pipeline } from "node:stream/promises";
|
|
21
|
-
import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
18
|
+
import os5 from "node:os";
|
|
19
|
+
import path5 from "node:path";
|
|
20
|
+
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
21
|
+
import { randomUUID as randomUUID4, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
22
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
22
23
|
|
|
23
24
|
// ../../packages/protocol/index.js
|
|
24
25
|
var PROTOCOL_VERSION = 4;
|
|
@@ -56,13 +57,37 @@ var Routes = Object.freeze({
|
|
|
56
57
|
previewRequest: "GET /api/preview/request",
|
|
57
58
|
previewRequestsClear: "DELETE /api/preview/requests",
|
|
58
59
|
tunnelToken: "POST /api/tunnel/token",
|
|
59
|
-
tunnelStatus: "GET /api/tunnel"
|
|
60
|
+
tunnelStatus: "GET /api/tunnel",
|
|
61
|
+
agentsHook: "POST /api/agents/hook",
|
|
62
|
+
agentsSessions: "GET /api/agents/sessions",
|
|
63
|
+
agentsTimeline: "GET /api/agents/timeline",
|
|
64
|
+
agentsAnswer: "POST /api/agents/answer",
|
|
65
|
+
agentsLaunch: "POST /api/agents/sessions",
|
|
66
|
+
agentsSetup: "GET /api/agents/setup",
|
|
67
|
+
agentsPrompt: "POST /api/agents/prompt",
|
|
68
|
+
agentsKeys: "POST /api/agents/keys",
|
|
69
|
+
agentsUpload: "POST /api/agents/uploads",
|
|
70
|
+
pushDeviceSet: "PUT /api/push/device",
|
|
71
|
+
pushDeviceDelete: "DELETE /api/push/device",
|
|
72
|
+
pushTest: "POST /api/push/test"
|
|
60
73
|
});
|
|
61
74
|
var TUNNEL_HANDOFF_PATH = "/vsremote/handoff";
|
|
62
75
|
var TUNNEL_AUTH_CALLBACK_HOST = "tunnel-auth";
|
|
63
76
|
var TERMINAL_WS_PATH = "/api/term/ws";
|
|
64
77
|
var TerminalServerMessage = Object.freeze({ session: "session", exit: "exit" });
|
|
65
78
|
var TerminalClientMessage = Object.freeze({ input: "input", resize: "resize" });
|
|
79
|
+
var AGENTS_WS_PATH = "/api/agents/ws";
|
|
80
|
+
var HOOK_SECRET_HEADER = "x-coderemote-hook";
|
|
81
|
+
var AgentsServerMessage = Object.freeze({
|
|
82
|
+
snapshot: "snapshot",
|
|
83
|
+
session: "session",
|
|
84
|
+
entry: "entry",
|
|
85
|
+
request: "request",
|
|
86
|
+
requestResolved: "request_resolved",
|
|
87
|
+
sessionRemoved: "session_removed",
|
|
88
|
+
promptConfirmed: "prompt_confirmed"
|
|
89
|
+
});
|
|
90
|
+
var AgentsClientMessage = Object.freeze({ presence: "presence" });
|
|
66
91
|
function webUrl({ url, token }) {
|
|
67
92
|
return `${url}/#token=${encodeURIComponent(token)}`;
|
|
68
93
|
}
|
|
@@ -73,9 +98,9 @@ function pairingUrl({ url, token }) {
|
|
|
73
98
|
// ../../packages/agent-core/src/server.js
|
|
74
99
|
import { WebSocketServer } from "ws";
|
|
75
100
|
|
|
76
|
-
// ../../packages/agent-core/src/
|
|
77
|
-
import {
|
|
78
|
-
import
|
|
101
|
+
// ../../packages/agent-core/src/agents.js
|
|
102
|
+
import { randomUUID } from "node:crypto";
|
|
103
|
+
import { EventEmitter } from "node:events";
|
|
79
104
|
import path2 from "node:path";
|
|
80
105
|
|
|
81
106
|
// ../../packages/agent-core/src/sandbox.js
|
|
@@ -109,8 +134,8 @@ var Sandbox = class {
|
|
|
109
134
|
}
|
|
110
135
|
/** Like `resolve`, but refuses the root itself (deleting or renaming the shared folder). */
|
|
111
136
|
async resolveChild(p, { allowMissing = false } = {}) {
|
|
112
|
-
const { abs, real } = await this.#check(p, allowMissing);
|
|
113
|
-
if (
|
|
137
|
+
const { abs, real: real2 } = await this.#check(p, allowMissing);
|
|
138
|
+
if (real2 === this.realRoot) throw new HttpError(403, "cannot modify the shared folder itself");
|
|
114
139
|
return abs;
|
|
115
140
|
}
|
|
116
141
|
async #check(p, allowMissing) {
|
|
@@ -120,9 +145,9 @@ var Sandbox = class {
|
|
|
120
145
|
let tail = "";
|
|
121
146
|
while (true) {
|
|
122
147
|
try {
|
|
123
|
-
const
|
|
124
|
-
if (!this.#containsReal(
|
|
125
|
-
return { abs, real };
|
|
148
|
+
const real2 = path.join(await fs.realpath(existing), tail);
|
|
149
|
+
if (!this.#containsReal(real2)) throw outside();
|
|
150
|
+
return { abs, real: real2 };
|
|
126
151
|
} catch (e) {
|
|
127
152
|
if (e instanceof HttpError) throw e;
|
|
128
153
|
if (!this.#containsLexically(abs)) throw outside();
|
|
@@ -139,7 +164,633 @@ function outside() {
|
|
|
139
164
|
return new HttpError(403, "path is outside the shared folder");
|
|
140
165
|
}
|
|
141
166
|
|
|
167
|
+
// ../../packages/agent-core/src/codex-rollout.js
|
|
168
|
+
import fs2 from "node:fs/promises";
|
|
169
|
+
var POLL = 1e3;
|
|
170
|
+
function codexItem(item) {
|
|
171
|
+
if (!item || typeof item !== "object") return null;
|
|
172
|
+
if (item.type === "AgentMessage") {
|
|
173
|
+
const text = (item.content ?? []).filter((c) => typeof c?.text === "string").map((c) => c.text).join("\n").trim();
|
|
174
|
+
return text ? { kind: "assistant", text } : null;
|
|
175
|
+
}
|
|
176
|
+
if (item.type === "FileChange" && item.changes && typeof item.changes === "object") {
|
|
177
|
+
const verbs = { add: "Added", update: "Updated", delete: "Deleted" };
|
|
178
|
+
const lines = Object.entries(item.changes).map(([file, change]) => `${verbs[change?.type] ?? "Changed"} ${file}`);
|
|
179
|
+
return { kind: "tool_result", toolUseId: item.id ?? null, output: lines.join("\n"), error: false };
|
|
180
|
+
}
|
|
181
|
+
const output = item.aggregated_output ?? item.aggregatedOutput ?? item.output;
|
|
182
|
+
if (typeof item.id === "string" && typeof output === "string") {
|
|
183
|
+
const code = item.exit_code ?? item.exitCode;
|
|
184
|
+
return { kind: "tool_result", toolUseId: item.id, output, error: typeof code === "number" && code !== 0 };
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
var RolloutTail = class {
|
|
189
|
+
#file;
|
|
190
|
+
#offset = null;
|
|
191
|
+
#partial = "";
|
|
192
|
+
#timer = null;
|
|
193
|
+
#onItem;
|
|
194
|
+
constructor(file, onItem) {
|
|
195
|
+
this.#file = file;
|
|
196
|
+
this.#onItem = onItem;
|
|
197
|
+
this.#timer = setInterval(() => this.poll().catch(() => {
|
|
198
|
+
}), POLL);
|
|
199
|
+
this.#timer.unref?.();
|
|
200
|
+
}
|
|
201
|
+
async poll() {
|
|
202
|
+
const handle = await fs2.open(this.#file, "r");
|
|
203
|
+
try {
|
|
204
|
+
const { size } = await handle.stat();
|
|
205
|
+
if (this.#offset === null || size < this.#offset) {
|
|
206
|
+
this.#offset = size;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (size === this.#offset) return;
|
|
210
|
+
const buf = Buffer.alloc(size - this.#offset);
|
|
211
|
+
await handle.read(buf, 0, buf.length, this.#offset);
|
|
212
|
+
this.#offset = size;
|
|
213
|
+
const lines = (this.#partial + buf.toString("utf8")).split("\n");
|
|
214
|
+
this.#partial = lines.pop();
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
let record;
|
|
217
|
+
try {
|
|
218
|
+
record = JSON.parse(line);
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (record?.type === "event_msg" && record.payload?.type === "item_completed") this.#onItem(record.payload.item);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
await handle.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
stop() {
|
|
229
|
+
clearInterval(this.#timer);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// ../../packages/agent-core/src/transcript.js
|
|
234
|
+
import fs3 from "node:fs/promises";
|
|
235
|
+
var TAIL_BYTES = 2 * 1024 * 1024;
|
|
236
|
+
function textOf(content) {
|
|
237
|
+
if (typeof content === "string") return content;
|
|
238
|
+
if (!Array.isArray(content)) return "";
|
|
239
|
+
return content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
240
|
+
}
|
|
241
|
+
function isTyped(text) {
|
|
242
|
+
const t = text.trim();
|
|
243
|
+
return t && !t.startsWith("<command-") && !t.startsWith("<local-command") && !t.startsWith("[Request interrupted");
|
|
244
|
+
}
|
|
245
|
+
function* entriesOf(record) {
|
|
246
|
+
if (!record || record.isSidechain || record.isMeta) return;
|
|
247
|
+
const time2 = Date.parse(record.timestamp) || 0;
|
|
248
|
+
const content = record.message?.content;
|
|
249
|
+
if (record.type === "user") {
|
|
250
|
+
if (typeof content === "string") {
|
|
251
|
+
if (isTyped(content)) yield { time: time2, kind: "prompt", text: content };
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(content)) return;
|
|
255
|
+
for (const c of content) {
|
|
256
|
+
if (c?.type === "tool_result") {
|
|
257
|
+
yield { time: time2, kind: "tool_result", toolUseId: c.tool_use_id ?? null, name: null, output: textOf(c.content), error: c.is_error === true, truncated: false, duration: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const text = textOf(content);
|
|
261
|
+
if (isTyped(text) && content.every((c) => c?.type !== "tool_result")) yield { time: time2, kind: "prompt", text };
|
|
262
|
+
} else if (record.type === "assistant" && Array.isArray(content)) {
|
|
263
|
+
for (const c of content) {
|
|
264
|
+
if (c?.type === "text" && c.text?.trim()) yield { time: time2, kind: "assistant", text: c.text.trimEnd() };
|
|
265
|
+
else if (c?.type === "tool_use") yield { time: time2, kind: "tool", toolUseId: c.id ?? null, name: c.name ?? null, input: c.input ?? null };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function readTranscript(file, limit = 500) {
|
|
270
|
+
let text;
|
|
271
|
+
try {
|
|
272
|
+
const handle = await fs3.open(file, "r");
|
|
273
|
+
try {
|
|
274
|
+
const { size } = await handle.stat();
|
|
275
|
+
const start2 = Math.max(0, size - TAIL_BYTES);
|
|
276
|
+
const buf = Buffer.alloc(size - start2);
|
|
277
|
+
await handle.read(buf, 0, buf.length, start2);
|
|
278
|
+
text = buf.toString("utf8");
|
|
279
|
+
if (start2 > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
280
|
+
} finally {
|
|
281
|
+
await handle.close();
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
const entries = [];
|
|
287
|
+
for (const line of text.split("\n")) {
|
|
288
|
+
if (!line.trim()) continue;
|
|
289
|
+
try {
|
|
290
|
+
entries.push(...entriesOf(JSON.parse(line)));
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return entries.slice(-limit);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ../../packages/agent-core/src/agents.js
|
|
298
|
+
var MAX_ENTRIES = 500;
|
|
299
|
+
var MAX_TEXT = 16 * 1024;
|
|
300
|
+
var MAX_TITLE = 80;
|
|
301
|
+
var ENDED_TTL = 24 * 60 * 60 * 1e3;
|
|
302
|
+
var REQUEST_TTL = 60 * 60 * 1e3;
|
|
303
|
+
var START_TIMEOUT = 15 * 1e3;
|
|
304
|
+
var SEQUENTIAL_REQUEST_TTL = 10 * 60 * 1e3;
|
|
305
|
+
var ADAPTERS = {
|
|
306
|
+
claude: {
|
|
307
|
+
name: "Claude Code",
|
|
308
|
+
capabilities: { approvals: "parallel", alwaysAllow: true, keys: ["interrupt", "cycle_mode", "/compact", "/clear", "/model"] },
|
|
309
|
+
normalize: (e) => e
|
|
310
|
+
},
|
|
311
|
+
codex: {
|
|
312
|
+
name: "Codex",
|
|
313
|
+
// Codex reports a session only once it gets its first prompt.
|
|
314
|
+
startsOnPrompt: true,
|
|
315
|
+
setupHint: ", then trust its hooks with `/hooks` in Codex",
|
|
316
|
+
capabilities: { approvals: "presence", alwaysAllow: false, keys: ["interrupt", "/compact", "/model"] },
|
|
317
|
+
normalize: (e) => ({
|
|
318
|
+
...e,
|
|
319
|
+
// Codex's patch tool carries the patch where Bash has its command.
|
|
320
|
+
tool_input: e.tool_name === "apply_patch" && typeof e.tool_input === "string" ? { command: e.tool_input } : e.tool_input
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
function comparable(text, paths = []) {
|
|
325
|
+
let t = String(text);
|
|
326
|
+
for (const p of paths) t = t.split(p).join(" ");
|
|
327
|
+
return t.replace(/\[Image #\d+\]/g, " ").replace(/\s+/g, " ").trim();
|
|
328
|
+
}
|
|
329
|
+
function clip(text) {
|
|
330
|
+
const buf = Buffer.from(text, "utf8");
|
|
331
|
+
if (buf.length <= MAX_TEXT) return { text, truncated: false };
|
|
332
|
+
return { text: buf.subarray(0, MAX_TEXT).toString("utf8").replace(/�+$/, ""), truncated: true };
|
|
333
|
+
}
|
|
334
|
+
function clipInput(input) {
|
|
335
|
+
if (!input || typeof input !== "object") return input ?? null;
|
|
336
|
+
const out = Array.isArray(input) ? [] : {};
|
|
337
|
+
for (const [k, v] of Object.entries(input)) out[k] = typeof v === "string" ? clip(v).text : v;
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
function resultText(response) {
|
|
341
|
+
if (response == null) return "";
|
|
342
|
+
if (typeof response === "string") return response;
|
|
343
|
+
if (typeof response.stdout === "string" || typeof response.stderr === "string") {
|
|
344
|
+
return [response.stdout, response.stderr].filter(Boolean).join("\n");
|
|
345
|
+
}
|
|
346
|
+
return JSON.stringify(response, null, 2);
|
|
347
|
+
}
|
|
348
|
+
function titleOf(prompt) {
|
|
349
|
+
const line = prompt.trim().split("\n")[0].trim();
|
|
350
|
+
return line.length > MAX_TITLE ? `${line.slice(0, MAX_TITLE - 1)}\u2026` : line;
|
|
351
|
+
}
|
|
352
|
+
var AgentSession = class {
|
|
353
|
+
constructor({ id, agent, cwd, terminal, now }) {
|
|
354
|
+
this.id = id;
|
|
355
|
+
this.agent = agent;
|
|
356
|
+
this.cwd = cwd;
|
|
357
|
+
this.title = path2.basename(cwd || "") || "Session";
|
|
358
|
+
this.capabilities = (ADAPTERS[agent] ?? ADAPTERS.claude).capabilities;
|
|
359
|
+
this.titled = false;
|
|
360
|
+
this.state = "idle";
|
|
361
|
+
this.terminal = terminal;
|
|
362
|
+
this.startedAt = now;
|
|
363
|
+
this.lastActivityAt = now;
|
|
364
|
+
this.endedAt = null;
|
|
365
|
+
this.starting = false;
|
|
366
|
+
this.error = null;
|
|
367
|
+
this.turnStartedAt = null;
|
|
368
|
+
this.entries = [];
|
|
369
|
+
this.seq = 0;
|
|
370
|
+
this.streaming = /* @__PURE__ */ new Map();
|
|
371
|
+
this.requests = /* @__PURE__ */ new Map();
|
|
372
|
+
this.prompts = [];
|
|
373
|
+
this.tail = null;
|
|
374
|
+
this.ready = Promise.resolve();
|
|
375
|
+
}
|
|
376
|
+
toJSON() {
|
|
377
|
+
return {
|
|
378
|
+
id: this.id,
|
|
379
|
+
agent: this.agent,
|
|
380
|
+
cwd: this.cwd,
|
|
381
|
+
title: this.title,
|
|
382
|
+
state: this.state,
|
|
383
|
+
terminal: this.terminal,
|
|
384
|
+
startedAt: this.startedAt,
|
|
385
|
+
lastActivityAt: this.lastActivityAt,
|
|
386
|
+
pending: this.requests.size,
|
|
387
|
+
starting: this.starting,
|
|
388
|
+
error: this.error,
|
|
389
|
+
capabilities: this.capabilities
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
var AgentHub = class extends EventEmitter {
|
|
394
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
395
|
+
#requests = /* @__PURE__ */ new Map();
|
|
396
|
+
#now;
|
|
397
|
+
#requestTtl;
|
|
398
|
+
#transcript;
|
|
399
|
+
#startTimeout;
|
|
400
|
+
#sequentialTtl;
|
|
401
|
+
#rolloutTail;
|
|
402
|
+
/**
|
|
403
|
+
* `present` says whether someone is at the computer or looking at it in a client; agents whose
|
|
404
|
+
* approvals are sequential ask there instead of holding the request for the phone.
|
|
405
|
+
*/
|
|
406
|
+
constructor({
|
|
407
|
+
now = Date.now,
|
|
408
|
+
requestTtl = REQUEST_TTL,
|
|
409
|
+
sequentialTtl = SEQUENTIAL_REQUEST_TTL,
|
|
410
|
+
transcript = readTranscript,
|
|
411
|
+
startTimeout = START_TIMEOUT,
|
|
412
|
+
present = async () => false,
|
|
413
|
+
rolloutTail = (file, onItem) => new RolloutTail(file, onItem)
|
|
414
|
+
} = {}) {
|
|
415
|
+
super();
|
|
416
|
+
this.#now = now;
|
|
417
|
+
this.#requestTtl = requestTtl;
|
|
418
|
+
this.#sequentialTtl = sequentialTtl;
|
|
419
|
+
this.#transcript = transcript;
|
|
420
|
+
this.#startTimeout = startTimeout;
|
|
421
|
+
this.present = present;
|
|
422
|
+
this.#rolloutTail = rolloutTail;
|
|
423
|
+
this.presenceSet = arguments[0]?.present !== void 0;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* A prompt a client typed into a session's terminal. Once Claude Code reports a matching
|
|
427
|
+
* `UserPromptSubmit`, the socket gets `prompt_confirmed` with this id.
|
|
428
|
+
*/
|
|
429
|
+
prompted(sessionId, { id, text, paths = [] }) {
|
|
430
|
+
const s = this.#sessions.get(sessionId);
|
|
431
|
+
if (!s) return;
|
|
432
|
+
s.prompts.push({ id, text: comparable(text, paths), at: this.#now() });
|
|
433
|
+
s.prompts = s.prompts.filter((p) => this.#now() - p.at < 6e4).slice(-20);
|
|
434
|
+
}
|
|
435
|
+
/** Whether `resume` may reopen a session: only one no process runs any more. */
|
|
436
|
+
canResume(id) {
|
|
437
|
+
const s = this.#sessions.get(id);
|
|
438
|
+
return !s || s.state === "ended";
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A session the agent just started in `terminal` (`id`: the id it passed with --session-id,
|
|
442
|
+
* or the one it resumes). It shows right away; if Claude Code reports nothing within the
|
|
443
|
+
* start timeout, it ends with an error.
|
|
444
|
+
*/
|
|
445
|
+
launched({ id, terminal, cwd, agent = "claude" }) {
|
|
446
|
+
const now = this.#now();
|
|
447
|
+
let s = this.#sessions.get(id);
|
|
448
|
+
if (!s) {
|
|
449
|
+
s = new AgentSession({ id, agent, cwd, terminal, now });
|
|
450
|
+
this.#sessions.set(id, s);
|
|
451
|
+
}
|
|
452
|
+
const adapter = ADAPTERS[agent] ?? ADAPTERS.claude;
|
|
453
|
+
const state = adapter.startsOnPrompt ? "idle" : "running";
|
|
454
|
+
Object.assign(s, { terminal, state, starting: true, error: null, endedAt: null, lastActivityAt: now });
|
|
455
|
+
if (adapter.startsOnPrompt) {
|
|
456
|
+
this.#emitSession(s);
|
|
457
|
+
return s.toJSON();
|
|
458
|
+
}
|
|
459
|
+
const timer = setTimeout(() => {
|
|
460
|
+
if (!s.starting || this.#sessions.get(id) !== s) return;
|
|
461
|
+
s.starting = false;
|
|
462
|
+
s.error = `${adapter.name} didn't report back. If it runs in the terminal, run \`coderemote setup ${agent}\` once on the computer${adapter.setupHint ?? ""}.`;
|
|
463
|
+
this.#end(s);
|
|
464
|
+
this.#emitSession(s);
|
|
465
|
+
}, this.#startTimeout);
|
|
466
|
+
timer.unref?.();
|
|
467
|
+
this.#emitSession(s);
|
|
468
|
+
return s.toJSON();
|
|
469
|
+
}
|
|
470
|
+
sessions() {
|
|
471
|
+
return [...this.#sessions.values()].map((s) => s.toJSON()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
472
|
+
}
|
|
473
|
+
requests() {
|
|
474
|
+
return [...this.#requests.values()].map((r) => r.request);
|
|
475
|
+
}
|
|
476
|
+
session(id) {
|
|
477
|
+
return this.#sessions.get(id)?.toJSON() ?? null;
|
|
478
|
+
}
|
|
479
|
+
timeline(id, after = 0) {
|
|
480
|
+
const s = this.#sessions.get(id);
|
|
481
|
+
if (!s) throw new HttpError(404, "no such session");
|
|
482
|
+
const entries = s.entries.filter((e) => e.seq > after);
|
|
483
|
+
return { entries, next: s.seq, requests: [...s.requests.values()].map((r) => r.request) };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Takes one hook event. Resolves with the hook's output: for `PermissionRequest` once the
|
|
487
|
+
* request is answered (null when the computer should decide), `{}` for everything else.
|
|
488
|
+
* Aborting `signal` (the hook was killed because the computer answered) withdraws the request.
|
|
489
|
+
*/
|
|
490
|
+
async handle(raw, { agent = "claude", terminal = null, signal } = {}) {
|
|
491
|
+
if (!raw || typeof raw.session_id !== "string" || !raw.session_id) {
|
|
492
|
+
throw new HttpError(400, "hook event without session_id");
|
|
493
|
+
}
|
|
494
|
+
if (!Object.hasOwn(ADAPTERS, agent)) throw new HttpError(400, `unknown agent ${agent}`);
|
|
495
|
+
const event = ADAPTERS[agent].normalize(raw);
|
|
496
|
+
const now = this.#now();
|
|
497
|
+
let replaced = null;
|
|
498
|
+
if (!this.#sessions.has(event.session_id)) {
|
|
499
|
+
const candidates = [...this.#sessions.values()].filter(
|
|
500
|
+
(p) => p.starting && (terminal ? p.terminal === terminal : p.agent === agent && p.cwd === event.cwd)
|
|
501
|
+
);
|
|
502
|
+
if (candidates.length === 1) {
|
|
503
|
+
replaced = candidates[0];
|
|
504
|
+
this.#sessions.delete(replaced.id);
|
|
505
|
+
terminal ??= replaced.terminal;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
let s = this.#sessions.get(event.session_id);
|
|
509
|
+
if (!s) {
|
|
510
|
+
s = new AgentSession({ id: event.session_id, agent, cwd: event.cwd ?? null, terminal, now });
|
|
511
|
+
this.#sessions.set(s.id, s);
|
|
512
|
+
const fresh = event.hook_event_name === "SessionStart" && event.source !== "resume";
|
|
513
|
+
if (!fresh && typeof event.transcript_path === "string") s.ready = this.#backfill(s, event.transcript_path);
|
|
514
|
+
}
|
|
515
|
+
await s.ready;
|
|
516
|
+
if (s.agent === "codex" && !s.tail && typeof event.transcript_path === "string") {
|
|
517
|
+
s.tail = this.#rolloutTail(event.transcript_path, (item) => this.#fromRollout(s, item));
|
|
518
|
+
s.tail.poll?.().catch(() => {
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (replaced) {
|
|
522
|
+
s.prompts.push(...replaced.prompts);
|
|
523
|
+
this.emit("message", { type: "session_removed", session: replaced.id, replacedBy: s.id });
|
|
524
|
+
}
|
|
525
|
+
if (terminal && !s.terminal) s.terminal = terminal;
|
|
526
|
+
s.starting = false;
|
|
527
|
+
s.lastActivityAt = now;
|
|
528
|
+
if (s.state === "ended" && event.hook_event_name !== "SessionEnd") {
|
|
529
|
+
s.state = "idle";
|
|
530
|
+
s.endedAt = null;
|
|
531
|
+
}
|
|
532
|
+
switch (event.hook_event_name) {
|
|
533
|
+
case "SessionStart":
|
|
534
|
+
break;
|
|
535
|
+
case "UserPromptSubmit": {
|
|
536
|
+
const text = typeof event.prompt === "string" ? event.prompt : "";
|
|
537
|
+
if (!s.titled && text.trim()) {
|
|
538
|
+
s.title = titleOf(text);
|
|
539
|
+
s.titled = true;
|
|
540
|
+
}
|
|
541
|
+
this.#startTurn(s, now);
|
|
542
|
+
this.#entry(s, { kind: "prompt", text });
|
|
543
|
+
const said = comparable(text);
|
|
544
|
+
const i = s.prompts.findIndex((p) => p.text === said || p.text && said.startsWith(p.text));
|
|
545
|
+
if (i >= 0) {
|
|
546
|
+
const [prompt] = s.prompts.splice(i, 1);
|
|
547
|
+
this.emit("message", { type: "prompt_confirmed", session: s.id, prompt: prompt.id });
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
case "PreToolUse":
|
|
552
|
+
this.#startTurn(s, now);
|
|
553
|
+
this.#entry(s, {
|
|
554
|
+
kind: "tool",
|
|
555
|
+
toolUseId: event.tool_use_id ?? null,
|
|
556
|
+
name: event.tool_name,
|
|
557
|
+
input: clipInput(event.tool_input)
|
|
558
|
+
});
|
|
559
|
+
break;
|
|
560
|
+
case "PostToolUse":
|
|
561
|
+
case "PostToolUseFailure": {
|
|
562
|
+
const failed = event.hook_event_name === "PostToolUseFailure";
|
|
563
|
+
const { text, truncated } = clip(failed ? String(event.error ?? "") : resultText(event.tool_response));
|
|
564
|
+
this.#entry(s, {
|
|
565
|
+
kind: "tool_result",
|
|
566
|
+
toolUseId: event.tool_use_id ?? null,
|
|
567
|
+
name: event.tool_name,
|
|
568
|
+
output: text,
|
|
569
|
+
error: failed,
|
|
570
|
+
truncated,
|
|
571
|
+
duration: event.duration_ms ?? null
|
|
572
|
+
});
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
case "MessageDisplay": {
|
|
576
|
+
const message = s.streaming.get(event.message_id) ?? { parts: /* @__PURE__ */ new Map(), last: null };
|
|
577
|
+
s.streaming.set(event.message_id, message);
|
|
578
|
+
message.parts.set(event.index ?? message.parts.size, event.delta ?? "");
|
|
579
|
+
if (event.final) message.last = event.index ?? message.parts.size - 1;
|
|
580
|
+
if (message.last === null || message.parts.size <= message.last) break;
|
|
581
|
+
s.streaming.delete(event.message_id);
|
|
582
|
+
const text = [...message.parts.entries()].sort((a, b) => a[0] - b[0]).map(([, d]) => d).join("").trimEnd();
|
|
583
|
+
const previous = s.entries.at(-1)?.kind === "turn_end" ? s.entries.at(-2) : s.entries.at(-1);
|
|
584
|
+
if (text.trim() && !(previous?.kind === "assistant" && previous.text.trim() === text.trim())) {
|
|
585
|
+
this.#entry(s, { kind: "assistant", text });
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case "PermissionRequest": {
|
|
590
|
+
this.#emitSession(s);
|
|
591
|
+
const sequential = s.capabilities.approvals === "presence";
|
|
592
|
+
const deferNow = sequential && await this.present().catch(() => false);
|
|
593
|
+
return this.#openRequest(s, event, signal, { deferNow, ttl: sequential ? this.#sequentialTtl : this.#requestTtl });
|
|
594
|
+
}
|
|
595
|
+
case "Stop":
|
|
596
|
+
case "StopFailure": {
|
|
597
|
+
const last = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
|
|
598
|
+
const turnEntries = s.entries.filter((e) => e.time >= (s.turnStartedAt ?? 0));
|
|
599
|
+
if (last && !turnEntries.some((e) => e.kind === "assistant" && e.text.trim() === last)) {
|
|
600
|
+
this.#entry(s, { kind: "assistant", text: last });
|
|
601
|
+
}
|
|
602
|
+
s.streaming.clear();
|
|
603
|
+
const duration = s.turnStartedAt ? now - s.turnStartedAt : 0;
|
|
604
|
+
s.turnStartedAt = null;
|
|
605
|
+
s.state = s.requests.size ? "waiting" : "idle";
|
|
606
|
+
this.#entry(s, { kind: "turn_end", duration });
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
case "Interrupt":
|
|
610
|
+
case "Notification":
|
|
611
|
+
if ((event.hook_event_name === "Interrupt" || event.notification_type === "idle_prompt") && s.state === "running") {
|
|
612
|
+
s.state = "idle";
|
|
613
|
+
s.turnStartedAt = null;
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
case "SessionEnd":
|
|
617
|
+
this.#end(s);
|
|
618
|
+
break;
|
|
619
|
+
default:
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
this.#emitSession(s);
|
|
623
|
+
return {};
|
|
624
|
+
}
|
|
625
|
+
/** The terminal a session ran in exited: whatever ran in it is over. */
|
|
626
|
+
terminalExited(terminal) {
|
|
627
|
+
for (const s of this.#sessions.values()) {
|
|
628
|
+
if (s.terminal === terminal && s.state !== "ended") {
|
|
629
|
+
if (s.starting) s.error = `${(ADAPTERS[s.agent] ?? ADAPTERS.claude).name} exited before it started. Open the terminal to see why.`;
|
|
630
|
+
s.starting = false;
|
|
631
|
+
this.#end(s);
|
|
632
|
+
this.#emitSession(s);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Forgets sessions that ended more than a day ago. */
|
|
637
|
+
sweep() {
|
|
638
|
+
const cutoff = this.#now() - ENDED_TTL;
|
|
639
|
+
for (const s of this.#sessions.values()) {
|
|
640
|
+
if (s.state === "ended" && s.endedAt < cutoff) {
|
|
641
|
+
s.tail?.stop();
|
|
642
|
+
this.#sessions.delete(s.id);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Answers an open request from a client. Throws 404 for an unknown request and 409 for one
|
|
648
|
+
* already resolved (by another device, the computer, or the timeout).
|
|
649
|
+
*/
|
|
650
|
+
answer({ request: id, behavior, message, suggestion, answers } = {}) {
|
|
651
|
+
const pending = this.#requests.get(id);
|
|
652
|
+
if (!pending) {
|
|
653
|
+
throw new HttpError(this.#resolved.has(id) ? 409 : 404, this.#resolved.has(id) ? "already answered" : "no such request");
|
|
654
|
+
}
|
|
655
|
+
const { request } = pending;
|
|
656
|
+
if (behavior !== "allow" && behavior !== "deny") throw new HttpError(400, "behavior must be allow or deny");
|
|
657
|
+
let decision;
|
|
658
|
+
let outcome;
|
|
659
|
+
if (behavior === "deny") {
|
|
660
|
+
decision = { behavior: "deny", ...typeof message === "string" && message ? { message } : {} };
|
|
661
|
+
outcome = "denied";
|
|
662
|
+
} else if (request.kind === "question") {
|
|
663
|
+
if (!answers || typeof answers !== "object" || Object.values(answers).some((a) => typeof a !== "string")) {
|
|
664
|
+
throw new HttpError(400, "answers must map each question to a string");
|
|
665
|
+
}
|
|
666
|
+
decision = { behavior: "allow", updatedInput: { ...pending.input, answers } };
|
|
667
|
+
outcome = "answered";
|
|
668
|
+
} else {
|
|
669
|
+
decision = { behavior: "allow" };
|
|
670
|
+
if (suggestion !== void 0) {
|
|
671
|
+
const rule = Number.isInteger(suggestion) ? request.suggestions[suggestion] : void 0;
|
|
672
|
+
if (!rule || !pending.session.capabilities.alwaysAllow) {
|
|
673
|
+
throw new HttpError(400, "suggestion must index the request\u2019s suggestions");
|
|
674
|
+
}
|
|
675
|
+
decision.updatedPermissions = [rule];
|
|
676
|
+
}
|
|
677
|
+
outcome = "allowed";
|
|
678
|
+
}
|
|
679
|
+
this.#resolve(pending, { hookSpecificOutput: { hookEventName: "PermissionRequest", decision } }, outcome, "phone");
|
|
680
|
+
}
|
|
681
|
+
// Ids of requests resolved recently, to tell "already answered" (409) from unknown (404).
|
|
682
|
+
#resolved = /* @__PURE__ */ new Set();
|
|
683
|
+
#openRequest(s, event, signal, { deferNow = false, ttl = this.#requestTtl } = {}) {
|
|
684
|
+
const isQuestion = event.tool_name === "AskUserQuestion";
|
|
685
|
+
const input = event.tool_input ?? {};
|
|
686
|
+
const request = {
|
|
687
|
+
id: randomUUID(),
|
|
688
|
+
session: s.id,
|
|
689
|
+
kind: isQuestion ? "question" : "permission",
|
|
690
|
+
tool: event.tool_name ?? null,
|
|
691
|
+
input: clipInput(input),
|
|
692
|
+
suggestions: Array.isArray(event.permission_suggestions) ? event.permission_suggestions : [],
|
|
693
|
+
questions: isQuestion && Array.isArray(input.questions) ? input.questions : [],
|
|
694
|
+
createdAt: this.#now()
|
|
695
|
+
};
|
|
696
|
+
return new Promise((resolve) => {
|
|
697
|
+
const pending = { request, input, session: s, resolve, timer: null, signal, onAbort: null };
|
|
698
|
+
pending.timer = setTimeout(() => this.#resolve(pending, null, "expired", "agent"), ttl);
|
|
699
|
+
pending.timer.unref?.();
|
|
700
|
+
pending.onAbort = () => this.#resolve(pending, null, "withdrawn", "computer");
|
|
701
|
+
signal?.addEventListener("abort", pending.onAbort, { once: true });
|
|
702
|
+
s.requests.set(request.id, pending);
|
|
703
|
+
this.#requests.set(request.id, pending);
|
|
704
|
+
s.state = "waiting";
|
|
705
|
+
this.#entry(s, { kind: "request", request });
|
|
706
|
+
this.emit("message", { type: "request", request });
|
|
707
|
+
this.#emitSession(s);
|
|
708
|
+
if (signal?.aborted) pending.onAbort();
|
|
709
|
+
else if (deferNow) this.#resolve(pending, null, "deferred", "agent");
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
#resolve(pending, output, outcome, by) {
|
|
713
|
+
const { request, session: s } = pending;
|
|
714
|
+
if (!this.#requests.delete(request.id)) return;
|
|
715
|
+
s.requests.delete(request.id);
|
|
716
|
+
clearTimeout(pending.timer);
|
|
717
|
+
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
718
|
+
this.#resolved.add(request.id);
|
|
719
|
+
if (this.#resolved.size > 1e3) this.#resolved.delete(this.#resolved.values().next().value);
|
|
720
|
+
if (s.state === "waiting" && !s.requests.size) s.state = "running";
|
|
721
|
+
s.lastActivityAt = this.#now();
|
|
722
|
+
this.#entry(s, { kind: "request_resolved", request: request.id, outcome, by });
|
|
723
|
+
this.emit("message", { type: "request_resolved", session: s.id, request: request.id, outcome, by });
|
|
724
|
+
this.#emitSession(s);
|
|
725
|
+
pending.resolve(output);
|
|
726
|
+
}
|
|
727
|
+
async #backfill(s, file) {
|
|
728
|
+
let entries = [];
|
|
729
|
+
try {
|
|
730
|
+
entries = await this.#transcript(file, MAX_ENTRIES);
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
const tools = /* @__PURE__ */ new Map();
|
|
734
|
+
for (const e of entries) {
|
|
735
|
+
if (e.kind === "tool") {
|
|
736
|
+
tools.set(e.toolUseId, e.name);
|
|
737
|
+
e.input = clipInput(e.input);
|
|
738
|
+
} else if (e.kind === "tool_result") {
|
|
739
|
+
e.name ??= tools.get(e.toolUseId) ?? null;
|
|
740
|
+
({ text: e.output, truncated: e.truncated } = clip(e.output));
|
|
741
|
+
} else if (e.kind === "prompt" && !s.titled && e.text.trim()) {
|
|
742
|
+
s.title = titleOf(e.text);
|
|
743
|
+
s.titled = true;
|
|
744
|
+
}
|
|
745
|
+
s.entries.push({ seq: ++s.seq, ...e });
|
|
746
|
+
}
|
|
747
|
+
if (s.entries.length > MAX_ENTRIES) s.entries.splice(0, s.entries.length - MAX_ENTRIES);
|
|
748
|
+
}
|
|
749
|
+
/** Something Codex said or a tool's result, from its rollout, unless the hooks told it already. */
|
|
750
|
+
#fromRollout(s, item) {
|
|
751
|
+
const fields = codexItem(item);
|
|
752
|
+
if (!fields) return;
|
|
753
|
+
const recent = s.entries.slice(-8);
|
|
754
|
+
if (fields.kind === "assistant") {
|
|
755
|
+
if (recent.some((e) => e.kind === "assistant" && e.text.trim() === fields.text)) return;
|
|
756
|
+
this.#entry(s, fields);
|
|
757
|
+
} else {
|
|
758
|
+
if (s.entries.some((e) => e.kind === "tool_result" && e.toolUseId === fields.toolUseId)) return;
|
|
759
|
+
const call = [...s.entries].reverse().find((e) => e.kind === "tool" && e.toolUseId === fields.toolUseId);
|
|
760
|
+
if (!call) return;
|
|
761
|
+
const { text, truncated } = clip(fields.output);
|
|
762
|
+
this.#entry(s, { kind: "tool_result", toolUseId: fields.toolUseId, name: call.name, output: text, error: fields.error, truncated, duration: null });
|
|
763
|
+
}
|
|
764
|
+
s.lastActivityAt = this.#now();
|
|
765
|
+
this.#emitSession(s);
|
|
766
|
+
}
|
|
767
|
+
#startTurn(s, now) {
|
|
768
|
+
if (s.state !== "waiting") s.state = "running";
|
|
769
|
+
s.turnStartedAt ??= now;
|
|
770
|
+
}
|
|
771
|
+
#end(s) {
|
|
772
|
+
s.tail?.stop();
|
|
773
|
+
s.tail = null;
|
|
774
|
+
for (const pending of [...s.requests.values()]) this.#resolve(pending, null, "withdrawn", "computer");
|
|
775
|
+
s.state = "ended";
|
|
776
|
+
s.endedAt = this.#now();
|
|
777
|
+
s.turnStartedAt = null;
|
|
778
|
+
}
|
|
779
|
+
#entry(s, fields) {
|
|
780
|
+
const entry2 = { seq: ++s.seq, time: this.#now(), ...fields };
|
|
781
|
+
s.entries.push(entry2);
|
|
782
|
+
if (s.entries.length > MAX_ENTRIES) s.entries.shift();
|
|
783
|
+
this.emit("message", { type: "entry", session: s.id, entry: entry2 });
|
|
784
|
+
}
|
|
785
|
+
#emitSession(s) {
|
|
786
|
+
this.emit("message", { type: "session", session: s.toJSON() });
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
142
790
|
// ../../packages/agent-core/src/git.js
|
|
791
|
+
import { execFile } from "node:child_process";
|
|
792
|
+
import fs4 from "node:fs/promises";
|
|
793
|
+
import path3 from "node:path";
|
|
143
794
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".build", "build", "dist", "DerivedData", "Pods", "vendor", ".venv", "target"]);
|
|
144
795
|
var MAX_SCAN_DEPTH = 4;
|
|
145
796
|
var MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
@@ -168,7 +819,7 @@ async function findRepos(root2) {
|
|
|
168
819
|
async function walk(dir, depth) {
|
|
169
820
|
let entries;
|
|
170
821
|
try {
|
|
171
|
-
entries = await
|
|
822
|
+
entries = await fs4.readdir(dir, { withFileTypes: true });
|
|
172
823
|
} catch {
|
|
173
824
|
return;
|
|
174
825
|
}
|
|
@@ -178,14 +829,14 @@ async function findRepos(root2) {
|
|
|
178
829
|
}
|
|
179
830
|
if (depth >= MAX_SCAN_DEPTH) return;
|
|
180
831
|
const subdirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name));
|
|
181
|
-
await Promise.all(subdirs.map((e) => walk(
|
|
832
|
+
await Promise.all(subdirs.map((e) => walk(path3.join(dir, e.name), depth + 1)));
|
|
182
833
|
}
|
|
183
834
|
await walk(root2, 0);
|
|
184
|
-
repos.sort((a, b) => a.split(
|
|
835
|
+
repos.sort((a, b) => a.split(path3.sep).length - b.split(path3.sep).length || a.localeCompare(b));
|
|
185
836
|
return Promise.all(
|
|
186
837
|
repos.map(async (repo) => ({
|
|
187
838
|
path: repo,
|
|
188
|
-
name: repo === root2 ?
|
|
839
|
+
name: repo === root2 ? path3.basename(root2) : path3.relative(root2, repo),
|
|
189
840
|
branch: await currentBranch(repo)
|
|
190
841
|
}))
|
|
191
842
|
);
|
|
@@ -389,7 +1040,7 @@ var LANDING_HTML = `<!doctype html>
|
|
|
389
1040
|
|
|
390
1041
|
// ../../packages/agent-core/src/ports.js
|
|
391
1042
|
import { execFile as execFile2 } from "node:child_process";
|
|
392
|
-
import
|
|
1043
|
+
import fs5 from "node:fs/promises";
|
|
393
1044
|
import os from "node:os";
|
|
394
1045
|
function run(cmd2, args) {
|
|
395
1046
|
return new Promise((resolve, reject) => {
|
|
@@ -435,11 +1086,11 @@ async function listeningSockets() {
|
|
|
435
1086
|
return sockets;
|
|
436
1087
|
}
|
|
437
1088
|
async function processTable() {
|
|
438
|
-
const out = await run("ps", ["-A", "-ww", "-o", "pid=,ppid=,command="]);
|
|
1089
|
+
const out = await run("ps", ["-A", "-ww", "-o", "pid=,ppid=,tty=,command="]);
|
|
439
1090
|
const table = /* @__PURE__ */ new Map();
|
|
440
1091
|
for (const line of out.split("\n")) {
|
|
441
|
-
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
442
|
-
if (m) table.set(parseInt(m[1], 10), { ppid: parseInt(m[2], 10),
|
|
1092
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
1093
|
+
if (m) table.set(parseInt(m[1], 10), { ppid: parseInt(m[2], 10), tty: /^\?+$/.test(m[3]) ? null : m[3], command: m[4] });
|
|
443
1094
|
}
|
|
444
1095
|
return table;
|
|
445
1096
|
}
|
|
@@ -447,7 +1098,7 @@ async function workingDirs(pids) {
|
|
|
447
1098
|
const dirs = /* @__PURE__ */ new Map();
|
|
448
1099
|
if (!pids.length) return dirs;
|
|
449
1100
|
if (os.platform() === "linux") {
|
|
450
|
-
await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await
|
|
1101
|
+
await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await fs5.readlink(`/proc/${pid2}/cwd`).catch(() => null))));
|
|
451
1102
|
return dirs;
|
|
452
1103
|
}
|
|
453
1104
|
const out = await run("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-F", "pn"]).catch(() => "");
|
|
@@ -477,6 +1128,8 @@ async function listPorts(shells = /* @__PURE__ */ new Map()) {
|
|
|
477
1128
|
command: table.get(s.pid)?.command ?? s.name,
|
|
478
1129
|
cwd: null,
|
|
479
1130
|
session: sessionOf(s.pid, table, shells),
|
|
1131
|
+
// Started from a terminal (a dev server) rather than by launchd, an app or a daemon.
|
|
1132
|
+
terminal: table.get(s.pid)?.tty != null,
|
|
480
1133
|
self: s.pid === process.pid,
|
|
481
1134
|
agent: s.pid === process.pid || AGENT_COMMAND.test(table.get(s.pid)?.command ?? "")
|
|
482
1135
|
});
|
|
@@ -505,11 +1158,228 @@ async function killListener(pid, { force = false, shells } = {}) {
|
|
|
505
1158
|
}
|
|
506
1159
|
}
|
|
507
1160
|
|
|
1161
|
+
// ../../packages/agent-core/src/push.js
|
|
1162
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
1163
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
1164
|
+
import os2 from "node:os";
|
|
1165
|
+
import { promisify } from "node:util";
|
|
1166
|
+
var run2 = promisify(execFile3);
|
|
1167
|
+
var FINISHED_AFTER = 60 * 1e3;
|
|
1168
|
+
var ACTIVE_WITHIN = 2 * 60;
|
|
1169
|
+
async function macIdleSeconds() {
|
|
1170
|
+
if (os2.platform() !== "darwin") return null;
|
|
1171
|
+
try {
|
|
1172
|
+
const { stdout } = await run2("ioreg", ["-c", "IOHIDSystem", "-d", "4", "-k", "HIDIdleTime"], { timeout: 2e3 });
|
|
1173
|
+
const ns = stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/)?.[1];
|
|
1174
|
+
return ns ? Number(BigInt(ns) / 1000000000n) : null;
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async function userActiveHere() {
|
|
1180
|
+
const idle = await macIdleSeconds();
|
|
1181
|
+
return idle !== null && idle < ACTIVE_WITHIN;
|
|
1182
|
+
}
|
|
1183
|
+
function seal(key, data) {
|
|
1184
|
+
const nonce = randomBytes(12);
|
|
1185
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
1186
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(data), "utf8"), cipher.final()]);
|
|
1187
|
+
return Buffer.concat([nonce, body, cipher.getAuthTag()]).toString("base64");
|
|
1188
|
+
}
|
|
1189
|
+
function relayUrl(value) {
|
|
1190
|
+
let url;
|
|
1191
|
+
try {
|
|
1192
|
+
url = new URL(value);
|
|
1193
|
+
} catch {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
const local = url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost");
|
|
1197
|
+
return url.protocol === "https:" || local ? url.href.replace(/\/$/, "") : null;
|
|
1198
|
+
}
|
|
1199
|
+
function deviceFrom(body) {
|
|
1200
|
+
const relay = relayUrl(body?.relay);
|
|
1201
|
+
if (!relay) throw new HttpError(400, "relay must be an https URL");
|
|
1202
|
+
if (typeof body.token !== "string" || !/^[0-9a-f]{64,200}$/i.test(body.token)) throw new HttpError(400, "token must be an APNs device token (hex)");
|
|
1203
|
+
if (body.environment !== "production" && body.environment !== "development") {
|
|
1204
|
+
throw new HttpError(400, "environment must be production or development");
|
|
1205
|
+
}
|
|
1206
|
+
if (typeof body.ticket !== "string" || !body.ticket || body.ticket.length > 128) throw new HttpError(400, "ticket required");
|
|
1207
|
+
if (typeof body.key !== "string" || Buffer.from(body.key, "base64").length !== 32) throw new HttpError(400, "key must be 32 bytes, base64");
|
|
1208
|
+
if (typeof body.keyId !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(body.keyId)) throw new HttpError(400, "keyId required");
|
|
1209
|
+
const kinds = body.kinds ?? {};
|
|
1210
|
+
return {
|
|
1211
|
+
relay,
|
|
1212
|
+
token: body.token.toLowerCase(),
|
|
1213
|
+
environment: body.environment,
|
|
1214
|
+
ticket: body.ticket,
|
|
1215
|
+
key: body.key,
|
|
1216
|
+
keyId: body.keyId,
|
|
1217
|
+
kinds: { requests: kinds.requests !== false, finished: kinds.finished !== false }
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function describeRequest(request) {
|
|
1221
|
+
if (request.kind === "question") return request.questions[0]?.question ?? "Claude has a question";
|
|
1222
|
+
const input = request.input ?? {};
|
|
1223
|
+
const detail2 = input.command ?? input.file_path ?? input.notebook_path ?? input.url ?? input.pattern ?? null;
|
|
1224
|
+
return detail2 ? `${request.tool}: ${String(detail2).split("\n")[0].slice(0, 200)}` : `Allow ${request.tool}?`;
|
|
1225
|
+
}
|
|
1226
|
+
function minutes(ms) {
|
|
1227
|
+
const m = Math.round(ms / 6e4);
|
|
1228
|
+
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
1229
|
+
}
|
|
1230
|
+
var PushSender = class {
|
|
1231
|
+
#devices;
|
|
1232
|
+
#store;
|
|
1233
|
+
#hub;
|
|
1234
|
+
#foreground;
|
|
1235
|
+
#userActive;
|
|
1236
|
+
#fetch;
|
|
1237
|
+
// Requests a notification went out for, so their resolution can replace it.
|
|
1238
|
+
#notified = /* @__PURE__ */ new Set();
|
|
1239
|
+
/**
|
|
1240
|
+
* @param {object} opts
|
|
1241
|
+
* @param {import('./agents.js').AgentHub} opts.hub
|
|
1242
|
+
* @param {{ load: () => object[], save: (devices: object[]) => void }} [opts.store]
|
|
1243
|
+
* @param {() => boolean} [opts.foreground] whether a client shows this machine right now
|
|
1244
|
+
* @param {() => Promise<boolean>} [opts.userActive] whether someone is using the computer
|
|
1245
|
+
*/
|
|
1246
|
+
constructor({ hub, store = null, foreground = () => false, userActive = null, fetch: fetch2 = globalThis.fetch }) {
|
|
1247
|
+
this.#hub = hub;
|
|
1248
|
+
this.#store = store;
|
|
1249
|
+
this.#devices = store?.load() ?? [];
|
|
1250
|
+
this.#foreground = foreground;
|
|
1251
|
+
this.#userActive = userActive ?? userActiveHere;
|
|
1252
|
+
this.#fetch = fetch2;
|
|
1253
|
+
hub.on("message", (msg) => this.#follow(msg).catch(() => {
|
|
1254
|
+
}));
|
|
1255
|
+
}
|
|
1256
|
+
devices() {
|
|
1257
|
+
return this.#devices.map(({ key, ticket, ...rest2 }) => rest2);
|
|
1258
|
+
}
|
|
1259
|
+
register(body) {
|
|
1260
|
+
const device = deviceFrom(body);
|
|
1261
|
+
this.#devices = [...this.#devices.filter((d) => d.token !== device.token), device];
|
|
1262
|
+
this.#save();
|
|
1263
|
+
}
|
|
1264
|
+
unregister(token) {
|
|
1265
|
+
this.#devices = this.#devices.filter((d) => d.token !== String(token).toLowerCase());
|
|
1266
|
+
this.#save();
|
|
1267
|
+
}
|
|
1268
|
+
/** Sends a test notification; throws 404 for an unknown token, 502 when it doesn't go out. */
|
|
1269
|
+
async test(token) {
|
|
1270
|
+
const device = this.#devices.find((d) => d.token === String(token ?? "").toLowerCase());
|
|
1271
|
+
if (!device) throw new HttpError(404, "that device is not registered");
|
|
1272
|
+
const result = await this.#send(device, {
|
|
1273
|
+
kind: "test",
|
|
1274
|
+
title: "Code Remote",
|
|
1275
|
+
body: "Notifications from this computer work.",
|
|
1276
|
+
category: null,
|
|
1277
|
+
fallback: "Notifications work"
|
|
1278
|
+
});
|
|
1279
|
+
if (!result.ok) throw new HttpError(502, `push failed: ${result.reason ?? result.status}`);
|
|
1280
|
+
}
|
|
1281
|
+
#save() {
|
|
1282
|
+
this.#store?.save(this.#devices);
|
|
1283
|
+
}
|
|
1284
|
+
async #quiet() {
|
|
1285
|
+
if (this.#foreground()) return true;
|
|
1286
|
+
try {
|
|
1287
|
+
return await this.#userActive();
|
|
1288
|
+
} catch {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
async #follow(msg) {
|
|
1293
|
+
if (!this.#devices.length) return;
|
|
1294
|
+
if (msg.type === "request") {
|
|
1295
|
+
const { request } = msg;
|
|
1296
|
+
if (await this.#quiet()) return;
|
|
1297
|
+
if (!this.#hub.requests().some((r) => r.id === request.id)) return;
|
|
1298
|
+
this.#notified.add(request.id);
|
|
1299
|
+
const question = request.kind === "question";
|
|
1300
|
+
const answerable = question && request.questions.length === 1 && !request.questions[0].multiSelect;
|
|
1301
|
+
await this.#broadcast("requests", {
|
|
1302
|
+
kind: "request",
|
|
1303
|
+
title: this.#hub.session(request.session)?.title ?? "Claude Code",
|
|
1304
|
+
body: describeRequest(request),
|
|
1305
|
+
session: request.session,
|
|
1306
|
+
request: request.id,
|
|
1307
|
+
question: answerable ? request.questions[0].question : void 0,
|
|
1308
|
+
options: answerable ? request.questions[0].options.map((o) => o.label) : void 0,
|
|
1309
|
+
category: question ? "QUESTION" : "PERMISSION",
|
|
1310
|
+
collapseId: request.id,
|
|
1311
|
+
fallback: "A session needs your attention"
|
|
1312
|
+
});
|
|
1313
|
+
} else if (msg.type === "request_resolved") {
|
|
1314
|
+
if (!this.#notified.delete(msg.request) || msg.by === "phone") return;
|
|
1315
|
+
await this.#broadcast("requests", {
|
|
1316
|
+
kind: "resolved",
|
|
1317
|
+
title: this.#hub.session(msg.session)?.title ?? "Claude Code",
|
|
1318
|
+
body: msg.outcome === "expired" ? "No longer waiting for an answer" : "Answered on your computer",
|
|
1319
|
+
session: msg.session,
|
|
1320
|
+
request: msg.request,
|
|
1321
|
+
category: null,
|
|
1322
|
+
collapseId: msg.request,
|
|
1323
|
+
priority: 5,
|
|
1324
|
+
silent: true,
|
|
1325
|
+
fallback: "Answered on your computer"
|
|
1326
|
+
});
|
|
1327
|
+
} else if (msg.type === "entry" && msg.entry.kind === "turn_end" && msg.entry.duration >= FINISHED_AFTER) {
|
|
1328
|
+
if (await this.#quiet()) return;
|
|
1329
|
+
const session = this.#hub.session(msg.session);
|
|
1330
|
+
await this.#broadcast("finished", {
|
|
1331
|
+
kind: "finished",
|
|
1332
|
+
title: session?.title ?? "Claude Code",
|
|
1333
|
+
body: `Finished after ${minutes(msg.entry.duration)}`,
|
|
1334
|
+
session: msg.session,
|
|
1335
|
+
category: "DONE",
|
|
1336
|
+
fallback: "A session finished"
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async #broadcast(kind, note) {
|
|
1341
|
+
await Promise.all(this.#devices.filter((d) => d.kinds[kind]).map((d) => this.#send(d, note)));
|
|
1342
|
+
}
|
|
1343
|
+
async #send(device, { kind, title, body, session, request, question, options, category, collapseId, priority, silent, fallback }) {
|
|
1344
|
+
const secret = { kind, title, body, session, request, question, options };
|
|
1345
|
+
const aps = { alert: { title: "Code Remote", body: fallback }, "mutable-content": 1 };
|
|
1346
|
+
if (category) aps.category = category;
|
|
1347
|
+
if (session) aps["thread-id"] = session;
|
|
1348
|
+
if (!silent) aps.sound = "default";
|
|
1349
|
+
const payload = { aps, k: device.keyId, e: seal(Buffer.from(device.key, "base64"), secret) };
|
|
1350
|
+
let res;
|
|
1351
|
+
try {
|
|
1352
|
+
res = await this.#fetch(`${device.relay}/v1/send`, {
|
|
1353
|
+
method: "POST",
|
|
1354
|
+
headers: { "content-type": "application/json" },
|
|
1355
|
+
body: JSON.stringify({
|
|
1356
|
+
token: device.token,
|
|
1357
|
+
environment: device.environment,
|
|
1358
|
+
ticket: device.ticket,
|
|
1359
|
+
payload,
|
|
1360
|
+
...collapseId ? { collapseId } : {},
|
|
1361
|
+
...priority ? { priority } : {}
|
|
1362
|
+
}),
|
|
1363
|
+
signal: AbortSignal.timeout(1e4)
|
|
1364
|
+
});
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
return { ok: false, status: 0, reason: e.message };
|
|
1367
|
+
}
|
|
1368
|
+
let reason = null;
|
|
1369
|
+
try {
|
|
1370
|
+
reason = (await res.json()).reason ?? null;
|
|
1371
|
+
} catch {
|
|
1372
|
+
}
|
|
1373
|
+
if (res.status === 410 || res.status === 400 && reason === "BadDeviceToken") this.unregister(device.token);
|
|
1374
|
+
return { ok: res.ok, status: res.status, reason };
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
|
|
508
1378
|
// ../../packages/agent-core/src/inspector.js
|
|
509
1379
|
import http from "node:http";
|
|
510
1380
|
import net from "node:net";
|
|
511
1381
|
import zlib from "node:zlib";
|
|
512
|
-
var
|
|
1382
|
+
var MAX_ENTRIES2 = 200;
|
|
513
1383
|
var MAX_BODY = 64 * 1024;
|
|
514
1384
|
var TEXTUAL = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql|[\w.+-]+\+(json|xml)))/i;
|
|
515
1385
|
var nextId = 1;
|
|
@@ -588,7 +1458,7 @@ var InspectingProxy = class {
|
|
|
588
1458
|
response: { headers: {}, tap: new BodyTap() }
|
|
589
1459
|
};
|
|
590
1460
|
this.#entries.push(entry2);
|
|
591
|
-
if (this.#entries.length >
|
|
1461
|
+
if (this.#entries.length > MAX_ENTRIES2) this.#entries.shift();
|
|
592
1462
|
return entry2;
|
|
593
1463
|
}
|
|
594
1464
|
/** The dev server sees itself addressed as localhost:<its port>, as without the proxy. */
|
|
@@ -685,26 +1555,36 @@ function detail(e) {
|
|
|
685
1555
|
}
|
|
686
1556
|
|
|
687
1557
|
// ../../packages/agent-core/src/sessions.js
|
|
688
|
-
import { randomUUID } from "node:crypto";
|
|
689
|
-
import
|
|
1558
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1559
|
+
import os3 from "node:os";
|
|
690
1560
|
import pty from "node-pty";
|
|
691
1561
|
var SCROLLBACK_LIMIT = 256 * 1024;
|
|
1562
|
+
function shellQuote(arg) {
|
|
1563
|
+
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
|
1564
|
+
}
|
|
692
1565
|
var TerminalSession = class {
|
|
693
|
-
|
|
694
|
-
|
|
1566
|
+
/**
|
|
1567
|
+
* `command` (an argv array) runs that program through a login shell (for the user's PATH)
|
|
1568
|
+
* instead of an interactive shell; the session ends when it exits.
|
|
1569
|
+
*/
|
|
1570
|
+
constructor({ cols, rows, cwd, env = {}, command = null }) {
|
|
1571
|
+
this.id = randomUUID2();
|
|
695
1572
|
this.createdAt = Date.now();
|
|
696
1573
|
this.chunks = [];
|
|
697
1574
|
this.bufferedBytes = 0;
|
|
698
1575
|
this.clients = /* @__PURE__ */ new Set();
|
|
699
1576
|
this.exitCode = null;
|
|
700
|
-
const shell = process.env.SHELL || (
|
|
1577
|
+
const shell = process.env.SHELL || (os3.platform() === "win32" ? "powershell.exe" : "/bin/sh");
|
|
701
1578
|
this.shell = shell;
|
|
702
|
-
this.
|
|
1579
|
+
this.command = command;
|
|
1580
|
+
const args = command ? ["-l", "-c", `exec ${command.map(shellQuote).join(" ")}`] : ["-l"];
|
|
1581
|
+
this.pty = pty.spawn(shell, args, {
|
|
703
1582
|
name: "xterm-256color",
|
|
704
1583
|
cols,
|
|
705
1584
|
rows,
|
|
706
|
-
cwd: cwd ||
|
|
707
|
-
|
|
1585
|
+
cwd: cwd || os3.homedir(),
|
|
1586
|
+
// CODEREMOTE_TERM_ID lets `coderemote hook` tell which terminal a coding agent runs in.
|
|
1587
|
+
env: { ...process.env, ...env, CODEREMOTE_TERM_ID: this.id, TERM: "xterm-256color", COLORTERM: "truecolor" }
|
|
708
1588
|
});
|
|
709
1589
|
this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
|
|
710
1590
|
this.pty.onExit(({ exitCode }) => {
|
|
@@ -753,6 +1633,10 @@ var TerminalSession = class {
|
|
|
753
1633
|
kill() {
|
|
754
1634
|
this.pty.kill();
|
|
755
1635
|
}
|
|
1636
|
+
/** Types into the session, as a client's keyboard would. */
|
|
1637
|
+
write(data) {
|
|
1638
|
+
this.pty.write(data);
|
|
1639
|
+
}
|
|
756
1640
|
toJSON() {
|
|
757
1641
|
return {
|
|
758
1642
|
id: this.id,
|
|
@@ -766,9 +1650,16 @@ var TerminalSession = class {
|
|
|
766
1650
|
};
|
|
767
1651
|
var SessionManager = class {
|
|
768
1652
|
#sessions = /* @__PURE__ */ new Map();
|
|
1653
|
+
/** @param {{ onExit?: (id: string) => void }} [opts] */
|
|
1654
|
+
constructor({ onExit } = {}) {
|
|
1655
|
+
this.onExit = onExit;
|
|
1656
|
+
}
|
|
769
1657
|
create(opts) {
|
|
770
1658
|
const session = new TerminalSession(opts);
|
|
771
|
-
session.onExit = () =>
|
|
1659
|
+
session.onExit = () => {
|
|
1660
|
+
this.#sessions.delete(session.id);
|
|
1661
|
+
this.onExit?.(session.id);
|
|
1662
|
+
};
|
|
772
1663
|
this.#sessions.set(session.id, session);
|
|
773
1664
|
return session;
|
|
774
1665
|
}
|
|
@@ -783,6 +1674,110 @@ var SessionManager = class {
|
|
|
783
1674
|
}
|
|
784
1675
|
};
|
|
785
1676
|
|
|
1677
|
+
// ../../packages/agent-core/src/uploads.js
|
|
1678
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
1679
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1680
|
+
import { createWriteStream } from "node:fs";
|
|
1681
|
+
import fs6 from "node:fs/promises";
|
|
1682
|
+
import os4 from "node:os";
|
|
1683
|
+
import path4 from "node:path";
|
|
1684
|
+
import { Transform } from "node:stream";
|
|
1685
|
+
import { pipeline } from "node:stream/promises";
|
|
1686
|
+
import { promisify as promisify2 } from "node:util";
|
|
1687
|
+
var run3 = promisify2(execFile4);
|
|
1688
|
+
var UPLOAD_LIMIT = 100 * 1024 * 1024;
|
|
1689
|
+
var KEEP = 7 * 24 * 60 * 60 * 1e3;
|
|
1690
|
+
var ID = /^[0-9a-f-]{36}$/;
|
|
1691
|
+
function safeName(name) {
|
|
1692
|
+
const base = path4.basename(String(name ?? "").replace(/\\/g, "/"));
|
|
1693
|
+
const clean = base.replace(/[^\p{L}\p{N}._ -]/gu, "_").replace(/^[.\s]+/, "").slice(-80).trim();
|
|
1694
|
+
return clean || "file";
|
|
1695
|
+
}
|
|
1696
|
+
var UploadStore = class {
|
|
1697
|
+
constructor(dir, { now = Date.now } = {}) {
|
|
1698
|
+
this.dir = dir;
|
|
1699
|
+
this.now = now;
|
|
1700
|
+
}
|
|
1701
|
+
/** Streams `body` to disk. 413 past `limit`; HEIC photos become JPEG on macOS. */
|
|
1702
|
+
async save(body, { name, type, length, limit = UPLOAD_LIMIT }) {
|
|
1703
|
+
if (Number(length) > limit) throw new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`);
|
|
1704
|
+
const id = randomUUID3();
|
|
1705
|
+
const day = new Date(this.now()).toISOString().slice(0, 10);
|
|
1706
|
+
await fs6.mkdir(path4.join(this.dir, day), { recursive: true, mode: 448 });
|
|
1707
|
+
let file = path4.join(this.dir, day, `${id}-${safeName(name)}`);
|
|
1708
|
+
let size = 0;
|
|
1709
|
+
const counter = new Transform({
|
|
1710
|
+
transform(chunk, _enc, done) {
|
|
1711
|
+
size += chunk.length;
|
|
1712
|
+
done(size > limit ? new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`) : null, chunk);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
try {
|
|
1716
|
+
await pipeline(body, counter, createWriteStream(file, { mode: 384 }));
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
await fs6.rm(file, { force: true });
|
|
1719
|
+
throw e;
|
|
1720
|
+
}
|
|
1721
|
+
let kind = type || "application/octet-stream";
|
|
1722
|
+
if ((kind === "image/heic" || /\.heic$/i.test(file)) && os4.platform() === "darwin") {
|
|
1723
|
+
const jpeg = file.replace(/(\.heic)?$/i, ".jpg");
|
|
1724
|
+
try {
|
|
1725
|
+
await run3("sips", ["-s", "format", "jpeg", file, "--out", jpeg], { timeout: 3e4 });
|
|
1726
|
+
await fs6.rm(file);
|
|
1727
|
+
file = jpeg;
|
|
1728
|
+
kind = "image/jpeg";
|
|
1729
|
+
size = (await fs6.stat(file)).size;
|
|
1730
|
+
} catch {
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
return { id, name: path4.basename(file).slice(id.length + 1), type: kind, size, path: file };
|
|
1734
|
+
}
|
|
1735
|
+
/** The file of an upload; 404 once it's gone (swept, or never existed). */
|
|
1736
|
+
async path(id) {
|
|
1737
|
+
if (typeof id !== "string" || !ID.test(id)) throw new HttpError(400, "invalid upload id");
|
|
1738
|
+
let days = [];
|
|
1739
|
+
try {
|
|
1740
|
+
days = (await fs6.readdir(this.dir)).sort().reverse();
|
|
1741
|
+
} catch {
|
|
1742
|
+
}
|
|
1743
|
+
for (const day of days) {
|
|
1744
|
+
let names = [];
|
|
1745
|
+
try {
|
|
1746
|
+
names = await fs6.readdir(path4.join(this.dir, day));
|
|
1747
|
+
} catch {
|
|
1748
|
+
}
|
|
1749
|
+
const hit = names.find((n) => n.startsWith(`${id}-`));
|
|
1750
|
+
if (hit) return path4.join(this.dir, day, hit);
|
|
1751
|
+
}
|
|
1752
|
+
throw new HttpError(404, "no such upload (they are kept for 7 days)");
|
|
1753
|
+
}
|
|
1754
|
+
/** Deletes uploads older than a week. */
|
|
1755
|
+
async sweep() {
|
|
1756
|
+
let days = [];
|
|
1757
|
+
try {
|
|
1758
|
+
days = await fs6.readdir(this.dir);
|
|
1759
|
+
} catch {
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const cutoff = this.now() - KEEP;
|
|
1763
|
+
for (const day of days) {
|
|
1764
|
+
const dir = path4.join(this.dir, day);
|
|
1765
|
+
let names = [];
|
|
1766
|
+
try {
|
|
1767
|
+
names = await fs6.readdir(dir);
|
|
1768
|
+
} catch {
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
for (const n of names) {
|
|
1772
|
+
const file = path4.join(dir, n);
|
|
1773
|
+
const stat = await fs6.stat(file).catch(() => null);
|
|
1774
|
+
if (stat && stat.mtimeMs < cutoff) await fs6.rm(file, { force: true });
|
|
1775
|
+
}
|
|
1776
|
+
if (!(await fs6.readdir(dir).catch(() => [])).length) await fs6.rm(dir, { recursive: true, force: true });
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
786
1781
|
// ../../packages/agent-core/src/server.js
|
|
787
1782
|
function kindOf(stat) {
|
|
788
1783
|
if (stat.isDirectory()) return "dir";
|
|
@@ -791,17 +1786,25 @@ function kindOf(stat) {
|
|
|
791
1786
|
return "other";
|
|
792
1787
|
}
|
|
793
1788
|
async function describe(fullPath, name) {
|
|
794
|
-
const lst = await
|
|
1789
|
+
const lst = await fs7.lstat(fullPath);
|
|
795
1790
|
const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
|
|
796
1791
|
if (lst.isSymbolicLink()) {
|
|
797
1792
|
try {
|
|
798
|
-
entry2.target = kindOf(await
|
|
1793
|
+
entry2.target = kindOf(await fs7.stat(fullPath));
|
|
799
1794
|
} catch {
|
|
800
1795
|
entry2.target = "broken";
|
|
801
1796
|
}
|
|
802
1797
|
}
|
|
803
1798
|
return entry2;
|
|
804
1799
|
}
|
|
1800
|
+
var PASTE_START = "\x1B[200~";
|
|
1801
|
+
var PASTE_END = "\x1B[201~";
|
|
1802
|
+
var KEYS = { interrupt: "\x1B", cycle_mode: "\x1B[Z" };
|
|
1803
|
+
var COMMANDS = ["/compact", "/clear", "/model"];
|
|
1804
|
+
function typeable(text) {
|
|
1805
|
+
return text.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
1806
|
+
}
|
|
1807
|
+
var LAUNCH_MODES = ["manual", "acceptEdits", "plan", "auto"];
|
|
805
1808
|
function sendJson(res, status2, body) {
|
|
806
1809
|
const json = JSON.stringify(body);
|
|
807
1810
|
res.writeHead(status2, { "content-type": "application/json", "content-length": Buffer.byteLength(json) });
|
|
@@ -816,15 +1819,150 @@ async function readJson(req) {
|
|
|
816
1819
|
throw new HttpError(400, "invalid json");
|
|
817
1820
|
}
|
|
818
1821
|
}
|
|
819
|
-
function createAgentServer({
|
|
1822
|
+
function createAgentServer({
|
|
1823
|
+
token,
|
|
1824
|
+
root: root2,
|
|
1825
|
+
tunnelToken = async () => null,
|
|
1826
|
+
preview = null,
|
|
1827
|
+
tunnelStatus: tunnelStatus2 = null,
|
|
1828
|
+
hookSecret = null,
|
|
1829
|
+
hookBin = null,
|
|
1830
|
+
uploadsDir = null,
|
|
1831
|
+
claudeCommand = "claude",
|
|
1832
|
+
codexCommand = "codex",
|
|
1833
|
+
codexHome = process.env.CODEX_HOME || path5.join(os5.homedir(), ".codex"),
|
|
1834
|
+
pluginInstalled = null,
|
|
1835
|
+
agents = new AgentHub(),
|
|
1836
|
+
pushStore: pushStore2 = null,
|
|
1837
|
+
pushOptions = {}
|
|
1838
|
+
}) {
|
|
820
1839
|
const handoff = new TunnelHandoff({ token, mint: tunnelToken });
|
|
821
1840
|
const sandbox = new Sandbox(root2);
|
|
822
|
-
const sessions = new SessionManager();
|
|
1841
|
+
const sessions = new SessionManager({ onExit: (id) => agents.terminalExited(id) });
|
|
823
1842
|
const expected = Buffer.from(`Bearer ${token}`);
|
|
1843
|
+
const uploads = uploadsDir ? new UploadStore(uploadsDir) : null;
|
|
1844
|
+
const sweep = setInterval(() => {
|
|
1845
|
+
agents.sweep();
|
|
1846
|
+
uploads?.sweep().catch(() => {
|
|
1847
|
+
});
|
|
1848
|
+
}, 10 * 60 * 1e3);
|
|
1849
|
+
sweep.unref();
|
|
1850
|
+
let foregroundAt = 0;
|
|
1851
|
+
const clientInForeground = () => Date.now() - foregroundAt < 3e4;
|
|
1852
|
+
const push2 = new PushSender({ hub: agents, store: pushStore2, foreground: clientInForeground, ...pushOptions });
|
|
1853
|
+
if (!agents.presenceSet) {
|
|
1854
|
+
agents.present = async () => !clientInForeground() && await (pushOptions.userActive ?? userActiveHere)();
|
|
1855
|
+
}
|
|
824
1856
|
function authorized(req) {
|
|
825
1857
|
const got = Buffer.from(req.headers.authorization || "");
|
|
826
1858
|
return got.length === expected.length && timingSafeEqual2(got, expected);
|
|
827
1859
|
}
|
|
1860
|
+
function terminalEnv() {
|
|
1861
|
+
return {
|
|
1862
|
+
CODEREMOTE_AGENT_PORT: String(server.address()?.port ?? ""),
|
|
1863
|
+
...hookBin ? { CODEREMOTE_HOOK_BIN: hookBin } : {}
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
function typingTarget(id, { whileWaiting }) {
|
|
1867
|
+
const session = typeof id === "string" ? agents.session(id) : null;
|
|
1868
|
+
if (!session) throw new HttpError(404, "no such session");
|
|
1869
|
+
const term = session.terminal && session.state !== "ended" ? sessions.get(session.terminal) : null;
|
|
1870
|
+
if (!term) throw new HttpError(409, "this session runs outside Code Remote; it can be followed but not typed into");
|
|
1871
|
+
if (!whileWaiting && session.state === "waiting") throw new HttpError(409, "answer the pending request first");
|
|
1872
|
+
return { session, term };
|
|
1873
|
+
}
|
|
1874
|
+
async function typeInto(term, text) {
|
|
1875
|
+
term.write(`${PASTE_START}${text}${PASTE_END}`);
|
|
1876
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1877
|
+
term.write("\r");
|
|
1878
|
+
}
|
|
1879
|
+
function uploadPath(id) {
|
|
1880
|
+
if (!uploads) throw new HttpError(400, "this agent takes no uploads");
|
|
1881
|
+
return uploads.path(id);
|
|
1882
|
+
}
|
|
1883
|
+
async function attachmentPaths(ids) {
|
|
1884
|
+
if (ids === void 0) return [];
|
|
1885
|
+
if (!Array.isArray(ids) || ids.some((a) => typeof a !== "string")) throw new HttpError(400, "attachments must be upload ids");
|
|
1886
|
+
return Promise.all(ids.map((a) => uploadPath(a)));
|
|
1887
|
+
}
|
|
1888
|
+
function loginShell(script) {
|
|
1889
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
1890
|
+
return new Promise((resolve) => {
|
|
1891
|
+
execFile5(
|
|
1892
|
+
shell,
|
|
1893
|
+
["-l", "-c", script],
|
|
1894
|
+
{ timeout: 2e4, maxBuffer: 16 * 1024 * 1024 },
|
|
1895
|
+
(err, out) => resolve(err ? null : out)
|
|
1896
|
+
);
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
const launchers = {
|
|
1900
|
+
claude: {
|
|
1901
|
+
command: claudeCommand,
|
|
1902
|
+
name: "Claude Code",
|
|
1903
|
+
args: ({ id, resume, model, permissionMode }) => [
|
|
1904
|
+
...resume ? ["--resume", resume] : ["--session-id", id],
|
|
1905
|
+
...model ? ["--model", model] : [],
|
|
1906
|
+
...permissionMode ? ["--permission-mode", permissionMode] : [],
|
|
1907
|
+
...uploadsDir ? ["--add-dir", uploadsDir] : []
|
|
1908
|
+
],
|
|
1909
|
+
// Fullscreen rendering can fail to start while no client is attached to answer the
|
|
1910
|
+
// terminal's queries; the classic renderer doesn't need them.
|
|
1911
|
+
env: { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" },
|
|
1912
|
+
async plugin() {
|
|
1913
|
+
const list = JSON.parse(await loginShell(`${shellQuote(claudeCommand)} plugin list --json`) ?? "[]");
|
|
1914
|
+
return list.some((p) => p.id === "coderemote@coderemote" && p.enabled !== false);
|
|
1915
|
+
},
|
|
1916
|
+
missingPlugin: "Claude Code needs the Code Remote plugin first: run `coderemote setup claude` once on the computer."
|
|
1917
|
+
},
|
|
1918
|
+
codex: {
|
|
1919
|
+
command: codexCommand,
|
|
1920
|
+
name: "Codex",
|
|
1921
|
+
// Codex's sandbox reads anywhere already; --add-dir would make the uploads writable.
|
|
1922
|
+
// Without -a, recent Codex rejects every escalation outright ("granular" policy), so a
|
|
1923
|
+
// session started here asks, and its requests reach the phone or the terminal.
|
|
1924
|
+
args: ({ resume, model, approvalPolicy }) => [
|
|
1925
|
+
...resume ? ["resume", resume] : [],
|
|
1926
|
+
...model ? ["-m", model] : [],
|
|
1927
|
+
...approvalPolicy ? ["-a", approvalPolicy] : []
|
|
1928
|
+
],
|
|
1929
|
+
env: {},
|
|
1930
|
+
async plugin() {
|
|
1931
|
+
const list = JSON.parse(await loginShell(`${shellQuote(codexCommand)} plugin list --marketplace coderemote --json`) ?? "{}");
|
|
1932
|
+
return (list.installed ?? []).some((p) => p.pluginId === "coderemote@coderemote" && p.enabled !== false);
|
|
1933
|
+
},
|
|
1934
|
+
missingPlugin: "Codex needs the Code Remote plugin first: run `coderemote setup codex` once on the computer, then trust its hooks with `/hooks` in Codex."
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
async function installed(agent) {
|
|
1938
|
+
return Boolean((await loginShell(`command -v ${shellQuote(launchers[agent].command)}`))?.trim());
|
|
1939
|
+
}
|
|
1940
|
+
const plugins = {};
|
|
1941
|
+
async function hasPlugin(agent) {
|
|
1942
|
+
const known = plugins[agent];
|
|
1943
|
+
if (known && Date.now() - known.checkedAt < 3e4) return known.installed;
|
|
1944
|
+
let found = false;
|
|
1945
|
+
try {
|
|
1946
|
+
found = pluginInstalled ? await pluginInstalled(agent) : await launchers[agent].plugin();
|
|
1947
|
+
} catch {
|
|
1948
|
+
}
|
|
1949
|
+
plugins[agent] = { installed: found, checkedAt: Date.now() };
|
|
1950
|
+
return found;
|
|
1951
|
+
}
|
|
1952
|
+
async function codexModels() {
|
|
1953
|
+
try {
|
|
1954
|
+
const { models } = JSON.parse(await fs7.readFile(path5.join(codexHome, "models_cache.json"), "utf8"));
|
|
1955
|
+
return models.filter((m) => m.visibility === "list" && typeof m.slug === "string").sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((m) => ({ id: m.slug, name: m.display_name || m.slug, description: m.description ?? "" }));
|
|
1956
|
+
} catch {
|
|
1957
|
+
return [];
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
function hookAuthorized(req) {
|
|
1961
|
+
if (!hookSecret) return false;
|
|
1962
|
+
const got = Buffer.from(String(req.headers[HOOK_SECRET_HEADER] || ""));
|
|
1963
|
+
const want = Buffer.from(hookSecret);
|
|
1964
|
+
return got.length === want.length && timingSafeEqual2(got, want);
|
|
1965
|
+
}
|
|
828
1966
|
function previewPort(value, ownPort) {
|
|
829
1967
|
if (!preview) throw new HttpError(404, "previews need a tunnel");
|
|
830
1968
|
const port2 = Number(value);
|
|
@@ -863,20 +2001,20 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
863
2001
|
async function repoOf(p) {
|
|
864
2002
|
const repo = await sandbox.resolve(p);
|
|
865
2003
|
try {
|
|
866
|
-
await
|
|
2004
|
+
await fs7.access(path5.join(repo, ".git"));
|
|
867
2005
|
} catch {
|
|
868
2006
|
throw new HttpError(400, "not a git repository");
|
|
869
2007
|
}
|
|
870
2008
|
return repo;
|
|
871
2009
|
}
|
|
872
2010
|
async function filesIn(repo, files) {
|
|
873
|
-
if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f ||
|
|
2011
|
+
if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f || path5.isAbsolute(f))) {
|
|
874
2012
|
throw new HttpError(400, "files must be a non-empty list of paths relative to the repository");
|
|
875
2013
|
}
|
|
876
2014
|
return Promise.all(
|
|
877
2015
|
files.map(async (f) => {
|
|
878
|
-
const abs = await sandbox.resolve(
|
|
879
|
-
const rel =
|
|
2016
|
+
const abs = await sandbox.resolve(path5.join(repo, f), { allowMissing: true });
|
|
2017
|
+
const rel = path5.relative(repo, abs);
|
|
880
2018
|
if (!rel || rel.startsWith("..")) throw new HttpError(403, "path is outside the repository");
|
|
881
2019
|
return rel;
|
|
882
2020
|
})
|
|
@@ -885,52 +2023,52 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
885
2023
|
const routes = {
|
|
886
2024
|
[Routes.info]: async () => ({
|
|
887
2025
|
protocol: PROTOCOL_VERSION,
|
|
888
|
-
hostname:
|
|
889
|
-
platform:
|
|
2026
|
+
hostname: os5.hostname(),
|
|
2027
|
+
platform: os5.platform(),
|
|
890
2028
|
root: sandbox.root,
|
|
891
|
-
user:
|
|
2029
|
+
user: os5.userInfo().username,
|
|
892
2030
|
shell: process.env.SHELL || null
|
|
893
2031
|
}),
|
|
894
2032
|
[Routes.fsList]: async (url) => {
|
|
895
2033
|
const dir = await sandbox.resolve(url.searchParams.get("path"));
|
|
896
|
-
const names = await
|
|
897
|
-
const entries = (await Promise.all(names.map((n) => describe(
|
|
2034
|
+
const names = await fs7.readdir(dir);
|
|
2035
|
+
const entries = (await Promise.all(names.map((n) => describe(path5.join(dir, n), n).catch(() => null)))).filter(Boolean);
|
|
898
2036
|
return { path: dir, entries };
|
|
899
2037
|
},
|
|
900
2038
|
[Routes.fsStat]: async (url) => {
|
|
901
2039
|
const p = await sandbox.resolve(url.searchParams.get("path"));
|
|
902
|
-
return { path: p, ...await describe(p,
|
|
2040
|
+
return { path: p, ...await describe(p, path5.basename(p)) };
|
|
903
2041
|
},
|
|
904
2042
|
[Routes.fsRead]: async (url, req, res) => {
|
|
905
2043
|
const p = await sandbox.resolve(url.searchParams.get("path"));
|
|
906
|
-
const stat = await
|
|
2044
|
+
const stat = await fs7.stat(p);
|
|
907
2045
|
if (!stat.isFile()) throw new HttpError(400, "not a file");
|
|
908
2046
|
res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
|
|
909
|
-
await
|
|
2047
|
+
await pipeline2(createReadStream(p), res);
|
|
910
2048
|
},
|
|
911
2049
|
[Routes.fsWrite]: async (url, req) => {
|
|
912
2050
|
const p = await sandbox.resolveChild(url.searchParams.get("path"), { allowMissing: true });
|
|
913
2051
|
const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
|
|
914
2052
|
try {
|
|
915
|
-
await
|
|
916
|
-
await
|
|
2053
|
+
await pipeline2(req, createWriteStream2(tmp));
|
|
2054
|
+
await fs7.rename(tmp, p);
|
|
917
2055
|
} catch (e) {
|
|
918
|
-
await
|
|
2056
|
+
await fs7.rm(tmp, { force: true });
|
|
919
2057
|
throw e;
|
|
920
2058
|
}
|
|
921
|
-
return describe(p,
|
|
2059
|
+
return describe(p, path5.basename(p));
|
|
922
2060
|
},
|
|
923
2061
|
[Routes.fsMkdir]: async (url) => {
|
|
924
|
-
await
|
|
2062
|
+
await fs7.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
|
|
925
2063
|
return {};
|
|
926
2064
|
},
|
|
927
2065
|
[Routes.fsRename]: async (url, req) => {
|
|
928
2066
|
const { from, to } = await readJson(req);
|
|
929
|
-
await
|
|
2067
|
+
await fs7.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
|
|
930
2068
|
return {};
|
|
931
2069
|
},
|
|
932
2070
|
[Routes.fsDelete]: async (url) => {
|
|
933
|
-
await
|
|
2071
|
+
await fs7.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
|
|
934
2072
|
return {};
|
|
935
2073
|
},
|
|
936
2074
|
[Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
|
|
@@ -1045,6 +2183,127 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1045
2183
|
if (!issued) throw new HttpError(404, "this tunnel is anonymous");
|
|
1046
2184
|
return issued;
|
|
1047
2185
|
},
|
|
2186
|
+
[Routes.agentsSessions]: async () => ({ sessions: agents.sessions() }),
|
|
2187
|
+
[Routes.agentsTimeline]: async (url) => agents.timeline(url.searchParams.get("session"), parseInt(url.searchParams.get("after"), 10) || 0),
|
|
2188
|
+
[Routes.agentsSetup]: async () => {
|
|
2189
|
+
const [claude, codex] = await Promise.all([installed("claude"), installed("codex")]);
|
|
2190
|
+
const [plugin, codexPlugin] = await Promise.all([claude && hasPlugin("claude"), codex && hasPlugin("codex")]);
|
|
2191
|
+
return { claude, plugin: Boolean(plugin), codex, codexPlugin: Boolean(codexPlugin), codexModels: codex ? await codexModels() : [] };
|
|
2192
|
+
},
|
|
2193
|
+
[Routes.agentsLaunch]: async (url, req) => {
|
|
2194
|
+
const { agent = "claude", cwd, model, permissionMode, approvalPolicy, resume, cols, rows } = await readJson(req);
|
|
2195
|
+
if (approvalPolicy !== void 0 && !["on-request", "never"].includes(approvalPolicy)) {
|
|
2196
|
+
throw new HttpError(400, "approvalPolicy must be on-request or never");
|
|
2197
|
+
}
|
|
2198
|
+
if (!Object.hasOwn(launchers, agent)) throw new HttpError(400, `agent must be one of ${Object.keys(launchers).join(", ")}`);
|
|
2199
|
+
const launcher = launchers[agent];
|
|
2200
|
+
let dir;
|
|
2201
|
+
try {
|
|
2202
|
+
dir = await sandbox.resolve(cwd || sandbox.root);
|
|
2203
|
+
} catch (e) {
|
|
2204
|
+
throw new HttpError(400, e.message);
|
|
2205
|
+
}
|
|
2206
|
+
if (!(await fs7.stat(dir)).isDirectory()) throw new HttpError(400, "cwd must be a folder");
|
|
2207
|
+
if (model !== void 0 && (typeof model !== "string" || !/^[A-Za-z0-9._[\]-]{1,80}$/.test(model))) {
|
|
2208
|
+
throw new HttpError(400, "invalid model");
|
|
2209
|
+
}
|
|
2210
|
+
if (permissionMode !== void 0 && !LAUNCH_MODES.includes(permissionMode)) {
|
|
2211
|
+
throw new HttpError(400, `permissionMode must be one of ${LAUNCH_MODES.join(", ")}`);
|
|
2212
|
+
}
|
|
2213
|
+
if (resume !== void 0 && (typeof resume !== "string" || !/^[0-9a-f-]{8,64}$/i.test(resume))) {
|
|
2214
|
+
throw new HttpError(400, "resume must be a session id");
|
|
2215
|
+
}
|
|
2216
|
+
if (resume && !agents.canResume(resume)) {
|
|
2217
|
+
throw new HttpError(409, "that session is still open; continue it where it runs, or end it first");
|
|
2218
|
+
}
|
|
2219
|
+
if (!await installed(agent)) {
|
|
2220
|
+
throw Object.assign(new HttpError(400, `${launcher.name} is not installed on this computer (no \`${launcher.command}\` command).`), {
|
|
2221
|
+
code: agent === "claude" ? "ENOCLAUDE" : "ENOAGENT"
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
if (!await hasPlugin(agent)) {
|
|
2225
|
+
throw Object.assign(new HttpError(400, launcher.missingPlugin), { code: "ENOPLUGIN" });
|
|
2226
|
+
}
|
|
2227
|
+
const id = resume ?? randomUUID4();
|
|
2228
|
+
if (uploadsDir) await fs7.mkdir(uploadsDir, { recursive: true, mode: 448 });
|
|
2229
|
+
const argv2 = [
|
|
2230
|
+
launcher.command,
|
|
2231
|
+
...launcher.args({
|
|
2232
|
+
id,
|
|
2233
|
+
resume,
|
|
2234
|
+
model,
|
|
2235
|
+
permissionMode: agent === "claude" ? permissionMode : void 0,
|
|
2236
|
+
approvalPolicy: agent === "codex" ? approvalPolicy ?? "on-request" : void 0
|
|
2237
|
+
})
|
|
2238
|
+
];
|
|
2239
|
+
const term = sessions.create({
|
|
2240
|
+
cols: Number.isInteger(cols) && cols > 0 ? cols : 80,
|
|
2241
|
+
rows: Number.isInteger(rows) && rows > 0 ? rows : 24,
|
|
2242
|
+
cwd: dir,
|
|
2243
|
+
env: { ...terminalEnv(), ...launcher.env },
|
|
2244
|
+
command: argv2
|
|
2245
|
+
});
|
|
2246
|
+
return { terminal: term.id, session: agents.launched({ id, terminal: term.id, cwd: dir, agent }) };
|
|
2247
|
+
},
|
|
2248
|
+
[Routes.agentsPrompt]: async (url, req) => {
|
|
2249
|
+
const { session: id, text, attachments } = await readJson(req);
|
|
2250
|
+
if (typeof text !== "string" || !text.trim() || text.length > 1e5) {
|
|
2251
|
+
throw new HttpError(400, "text must be a non-empty string");
|
|
2252
|
+
}
|
|
2253
|
+
const { term } = typingTarget(id, { whileWaiting: false });
|
|
2254
|
+
const paths = await attachmentPaths(attachments);
|
|
2255
|
+
const typed = typeable([text.trim(), ...paths].join("\n"));
|
|
2256
|
+
const prompt = randomUUID4();
|
|
2257
|
+
agents.prompted(id, { id: prompt, text: typed, paths });
|
|
2258
|
+
await typeInto(term, typed);
|
|
2259
|
+
return { prompt };
|
|
2260
|
+
},
|
|
2261
|
+
[Routes.agentsUpload]: async (url, req) => {
|
|
2262
|
+
if (!uploads) throw new HttpError(404, "this agent takes no uploads");
|
|
2263
|
+
let name = req.headers["x-filename"];
|
|
2264
|
+
try {
|
|
2265
|
+
name = decodeURIComponent(name ?? "");
|
|
2266
|
+
} catch {
|
|
2267
|
+
}
|
|
2268
|
+
const { id, name: saved, type, size } = await uploads.save(req, {
|
|
2269
|
+
name,
|
|
2270
|
+
type: req.headers["content-type"],
|
|
2271
|
+
length: req.headers["content-length"]
|
|
2272
|
+
});
|
|
2273
|
+
return { id, name: saved, type, size };
|
|
2274
|
+
},
|
|
2275
|
+
[Routes.agentsKeys]: async (url, req) => {
|
|
2276
|
+
const { session: id, action, command } = await readJson(req);
|
|
2277
|
+
const allowed = agents.session(typeof id === "string" ? id : "")?.capabilities?.keys;
|
|
2278
|
+
if (allowed && !allowed.includes(action === "command" ? command : action)) {
|
|
2279
|
+
throw new HttpError(400, `this session takes ${allowed.join(", ")}`);
|
|
2280
|
+
}
|
|
2281
|
+
if (action === "command") {
|
|
2282
|
+
if (!COMMANDS.includes(command)) throw new HttpError(400, `command must be one of ${COMMANDS.join(", ")}`);
|
|
2283
|
+
await typeInto(typingTarget(id, { whileWaiting: false }).term, command);
|
|
2284
|
+
} else if (Object.hasOwn(KEYS, action)) {
|
|
2285
|
+
typingTarget(id, { whileWaiting: true }).term.write(KEYS[action]);
|
|
2286
|
+
} else {
|
|
2287
|
+
throw new HttpError(400, `action must be one of ${[...Object.keys(KEYS), "command"].join(", ")}`);
|
|
2288
|
+
}
|
|
2289
|
+
return {};
|
|
2290
|
+
},
|
|
2291
|
+
[Routes.agentsAnswer]: async (url, req) => {
|
|
2292
|
+
agents.answer(await readJson(req));
|
|
2293
|
+
return {};
|
|
2294
|
+
},
|
|
2295
|
+
[Routes.pushDeviceSet]: async (url, req) => {
|
|
2296
|
+
push2.register(await readJson(req));
|
|
2297
|
+
return {};
|
|
2298
|
+
},
|
|
2299
|
+
[Routes.pushDeviceDelete]: async (url) => {
|
|
2300
|
+
push2.unregister(url.searchParams.get("token") ?? "");
|
|
2301
|
+
return {};
|
|
2302
|
+
},
|
|
2303
|
+
[Routes.pushTest]: async (url, req) => {
|
|
2304
|
+
await push2.test((await readJson(req)).token);
|
|
2305
|
+
return {};
|
|
2306
|
+
},
|
|
1048
2307
|
[Routes.termKill]: async (url) => {
|
|
1049
2308
|
const s = sessions.get(url.searchParams.get("id"));
|
|
1050
2309
|
if (!s) throw new HttpError(404, "no such session");
|
|
@@ -1063,6 +2322,7 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1063
2322
|
return res.end(LANDING_HTML);
|
|
1064
2323
|
}
|
|
1065
2324
|
if (req.method === "GET" && url.pathname === TUNNEL_HANDOFF_PATH) return handoffRedirect(url, res);
|
|
2325
|
+
if (`${req.method} ${url.pathname}` === Routes.agentsHook) return hookDelivery(url, req, res);
|
|
1066
2326
|
if (!authorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
1067
2327
|
const handler = routes[`${req.method} ${url.pathname}`];
|
|
1068
2328
|
if (!handler) return sendJson(res, 404, { error: "not found" });
|
|
@@ -1075,6 +2335,28 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1075
2335
|
else res.destroy(e);
|
|
1076
2336
|
}
|
|
1077
2337
|
});
|
|
2338
|
+
async function hookDelivery(url, req, res) {
|
|
2339
|
+
if (!hookAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
2340
|
+
plugins[url.searchParams.get("agent") || "claude"] = { installed: true, checkedAt: Date.now() };
|
|
2341
|
+
const terminal = url.searchParams.get("terminal");
|
|
2342
|
+
const closed = new AbortController();
|
|
2343
|
+
res.on("close", () => {
|
|
2344
|
+
if (!res.writableFinished) closed.abort();
|
|
2345
|
+
});
|
|
2346
|
+
try {
|
|
2347
|
+
const output = await agents.handle(await readJson(req), {
|
|
2348
|
+
agent: url.searchParams.get("agent") || "claude",
|
|
2349
|
+
terminal: terminal && sessions.get(terminal) ? terminal : null,
|
|
2350
|
+
signal: closed.signal
|
|
2351
|
+
});
|
|
2352
|
+
if (res.destroyed) return;
|
|
2353
|
+
if (output) return sendJson(res, 200, output);
|
|
2354
|
+
res.writeHead(200, { "content-length": 0 });
|
|
2355
|
+
res.end();
|
|
2356
|
+
} catch (e) {
|
|
2357
|
+
if (!res.headersSent) sendJson(res, e.status || 500, { error: e.message });
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
1078
2360
|
async function handoffRedirect(url, res) {
|
|
1079
2361
|
const state = url.searchParams.get("state") || "";
|
|
1080
2362
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(state) || !handoff.verify(url.searchParams.get("nonce"), url.searchParams.get("sig"))) {
|
|
@@ -1097,6 +2379,10 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1097
2379
|
const wss = new WebSocketServer({ noServer: true });
|
|
1098
2380
|
server.on("upgrade", async (req, socket, head) => {
|
|
1099
2381
|
const url = new URL(req.url, "http://localhost");
|
|
2382
|
+
if (url.pathname === AGENTS_WS_PATH && authorized(req)) {
|
|
2383
|
+
wss.handleUpgrade(req, socket, head, watchAgents);
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
1100
2386
|
if (url.pathname !== TERMINAL_WS_PATH || !authorized(req)) {
|
|
1101
2387
|
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1102
2388
|
return;
|
|
@@ -1115,7 +2401,8 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1115
2401
|
cols: parseInt(url.searchParams.get("cols"), 10) || 80,
|
|
1116
2402
|
rows: parseInt(url.searchParams.get("rows"), 10) || 24,
|
|
1117
2403
|
// New shells start in the shared folder unless a subfolder is asked for.
|
|
1118
|
-
cwd: await sandbox.resolve(url.searchParams.get("cwd") || sandbox.root)
|
|
2404
|
+
cwd: await sandbox.resolve(url.searchParams.get("cwd") || sandbox.root),
|
|
2405
|
+
env: terminalEnv()
|
|
1119
2406
|
});
|
|
1120
2407
|
} catch (e) {
|
|
1121
2408
|
socket.end(`HTTP/1.1 ${e.status || (e.code === "ENOENT" ? 404 : 500)} Error\r
|
|
@@ -1126,21 +2413,42 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1126
2413
|
}
|
|
1127
2414
|
wss.handleUpgrade(req, socket, head, (ws) => session.attach(ws));
|
|
1128
2415
|
});
|
|
2416
|
+
function watchAgents(ws) {
|
|
2417
|
+
const send = (msg) => ws.send(JSON.stringify(msg));
|
|
2418
|
+
send({ type: "snapshot", sessions: agents.sessions(), requests: agents.requests() });
|
|
2419
|
+
agents.on("message", send);
|
|
2420
|
+
ws.on("message", (data, isBinary) => {
|
|
2421
|
+
if (isBinary) return;
|
|
2422
|
+
let msg;
|
|
2423
|
+
try {
|
|
2424
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
2425
|
+
} catch {
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
if (msg.type === AgentsClientMessage.presence) foregroundAt = msg.foreground === true ? Date.now() : 0;
|
|
2429
|
+
});
|
|
2430
|
+
ws.on("close", () => agents.off("message", send));
|
|
2431
|
+
}
|
|
1129
2432
|
server.on("close", () => {
|
|
2433
|
+
clearInterval(sweep);
|
|
1130
2434
|
sessions.killAll();
|
|
1131
2435
|
for (const proxy of proxies.values()) proxy.close();
|
|
1132
2436
|
});
|
|
1133
2437
|
server.killAllSessions = () => sessions.killAll();
|
|
2438
|
+
server.agents = agents;
|
|
2439
|
+
server.clientInForeground = clientInForeground;
|
|
2440
|
+
server.push = push2;
|
|
2441
|
+
server.agentsUploadPath = (id) => uploadPath(id);
|
|
1134
2442
|
return server;
|
|
1135
2443
|
}
|
|
1136
2444
|
|
|
1137
2445
|
// src/devtunnel.js
|
|
1138
|
-
import { execFile as
|
|
2446
|
+
import { execFile as execFile6 } from "node:child_process";
|
|
1139
2447
|
import { createHash } from "node:crypto";
|
|
1140
|
-
import
|
|
1141
|
-
import
|
|
1142
|
-
import { promisify } from "node:util";
|
|
1143
|
-
var
|
|
2448
|
+
import fs8 from "node:fs";
|
|
2449
|
+
import path6 from "node:path";
|
|
2450
|
+
import { promisify as promisify3 } from "node:util";
|
|
2451
|
+
var run4 = promisify3(execFile6);
|
|
1144
2452
|
var DOWNLOAD_BASE = "https://tunnelsassetsprod.blob.core.windows.net/cli";
|
|
1145
2453
|
var BUILDS = {
|
|
1146
2454
|
"darwin-arm64": "osx-arm64-devtunnel",
|
|
@@ -1152,15 +2460,15 @@ var MICROSOFT_TEAM_ID = "UBF8T346G9";
|
|
|
1152
2460
|
var REFRESH_INTERVAL_MS = 24 * 36e5;
|
|
1153
2461
|
function executable(file) {
|
|
1154
2462
|
try {
|
|
1155
|
-
|
|
1156
|
-
return
|
|
2463
|
+
fs8.accessSync(file, fs8.constants.X_OK);
|
|
2464
|
+
return fs8.statSync(file).isFile();
|
|
1157
2465
|
} catch {
|
|
1158
2466
|
return false;
|
|
1159
2467
|
}
|
|
1160
2468
|
}
|
|
1161
2469
|
function onPath(name) {
|
|
1162
|
-
for (const dir of (process.env.PATH || "").split(
|
|
1163
|
-
if (dir && executable(
|
|
2470
|
+
for (const dir of (process.env.PATH || "").split(path6.delimiter)) {
|
|
2471
|
+
if (dir && executable(path6.join(dir, name))) return path6.join(dir, name);
|
|
1164
2472
|
}
|
|
1165
2473
|
return null;
|
|
1166
2474
|
}
|
|
@@ -1178,11 +2486,11 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1178
2486
|
`No devtunnel build for ${process.platform}-${process.arch}. Install it yourself: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started`
|
|
1179
2487
|
);
|
|
1180
2488
|
}
|
|
1181
|
-
const bin =
|
|
1182
|
-
const metaFile =
|
|
2489
|
+
const bin = path6.join(dir, "devtunnel");
|
|
2490
|
+
const metaFile = path6.join(dir, "devtunnel.json");
|
|
1183
2491
|
let meta = {};
|
|
1184
2492
|
try {
|
|
1185
|
-
meta = JSON.parse(
|
|
2493
|
+
meta = JSON.parse(fs8.readFileSync(metaFile, "utf8"));
|
|
1186
2494
|
} catch {
|
|
1187
2495
|
}
|
|
1188
2496
|
const installed = executable(bin);
|
|
@@ -1193,7 +2501,7 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1193
2501
|
const head = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
|
|
1194
2502
|
const lastModified = head.headers.get("last-modified");
|
|
1195
2503
|
if (head.ok && lastModified && lastModified === meta.lastModified) {
|
|
1196
|
-
|
|
2504
|
+
fs8.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
|
|
1197
2505
|
return bin;
|
|
1198
2506
|
}
|
|
1199
2507
|
} catch {
|
|
@@ -1204,12 +2512,12 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1204
2512
|
return bin;
|
|
1205
2513
|
}
|
|
1206
2514
|
async function download(url, bin, metaFile, progress) {
|
|
1207
|
-
|
|
2515
|
+
fs8.mkdirSync(path6.dirname(bin), { recursive: true, mode: 448 });
|
|
1208
2516
|
const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
|
|
1209
2517
|
if (!res.ok || !res.body) throw new Error(`Downloading devtunnel failed: HTTP ${res.status} from ${url}`);
|
|
1210
2518
|
const total = Number(res.headers.get("content-length")) || 0;
|
|
1211
2519
|
const tmp = `${bin}.${process.pid}.download`;
|
|
1212
|
-
const out =
|
|
2520
|
+
const out = fs8.createWriteStream(tmp, { mode: 493 });
|
|
1213
2521
|
const md5 = createHash("md5");
|
|
1214
2522
|
let received = 0;
|
|
1215
2523
|
try {
|
|
@@ -1222,27 +2530,126 @@ async function download(url, bin, metaFile, progress) {
|
|
|
1222
2530
|
await new Promise((resolve, reject) => out.end((e) => e ? reject(e) : resolve()));
|
|
1223
2531
|
if (total && received !== total) throw new Error(`devtunnel download was cut short (${received} of ${total} bytes)`);
|
|
1224
2532
|
await verify(tmp, md5.digest("base64"), res.headers.get("content-md5"));
|
|
1225
|
-
|
|
1226
|
-
|
|
2533
|
+
fs8.chmodSync(tmp, 493);
|
|
2534
|
+
fs8.renameSync(tmp, bin);
|
|
1227
2535
|
} catch (e) {
|
|
1228
2536
|
out.destroy();
|
|
1229
|
-
|
|
2537
|
+
fs8.rmSync(tmp, { force: true });
|
|
1230
2538
|
throw e;
|
|
1231
2539
|
}
|
|
1232
|
-
|
|
2540
|
+
fs8.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
|
|
1233
2541
|
}
|
|
1234
2542
|
async function verify(file, md5, expectedMd5) {
|
|
1235
2543
|
if (expectedMd5 && md5 !== expectedMd5) throw new Error("devtunnel download is corrupt (MD5 mismatch)");
|
|
1236
2544
|
if (process.platform !== "darwin") return;
|
|
1237
2545
|
try {
|
|
1238
|
-
await
|
|
1239
|
-
const { stderr } = await
|
|
2546
|
+
await run4("codesign", ["--verify", "--strict", file]);
|
|
2547
|
+
const { stderr } = await run4("codesign", ["-dv", file]);
|
|
1240
2548
|
if (!stderr.includes(`TeamIdentifier=${MICROSOFT_TEAM_ID}`)) throw new Error("not signed by Microsoft");
|
|
1241
2549
|
} catch (e) {
|
|
1242
2550
|
throw new Error(`devtunnel download failed its signature check: ${e.message}`);
|
|
1243
2551
|
}
|
|
1244
2552
|
}
|
|
1245
2553
|
|
|
2554
|
+
// src/hook.js
|
|
2555
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2556
|
+
import fs9 from "node:fs";
|
|
2557
|
+
import http3 from "node:http";
|
|
2558
|
+
import path7 from "node:path";
|
|
2559
|
+
var CONNECT_TIMEOUT = 1e3;
|
|
2560
|
+
var EVENT_TIMEOUT = 5e3;
|
|
2561
|
+
function ensureHookSecret(dir) {
|
|
2562
|
+
const file = path7.join(dir, "hook-secret");
|
|
2563
|
+
try {
|
|
2564
|
+
const secret2 = fs9.readFileSync(file, "utf8").trim();
|
|
2565
|
+
if (secret2) return secret2;
|
|
2566
|
+
} catch {
|
|
2567
|
+
}
|
|
2568
|
+
fs9.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2569
|
+
const secret = randomBytes2(32).toString("base64url");
|
|
2570
|
+
fs9.writeFileSync(file, secret, { mode: 384 });
|
|
2571
|
+
return secret;
|
|
2572
|
+
}
|
|
2573
|
+
function writeHookLauncher(dir, { node = process.execPath, script }) {
|
|
2574
|
+
const bin = path7.join(dir, "bin");
|
|
2575
|
+
fs9.mkdirSync(bin, { recursive: true, mode: 448 });
|
|
2576
|
+
const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
2577
|
+
const file = path7.join(bin, "hook");
|
|
2578
|
+
fs9.writeFileSync(file, `#!/bin/sh
|
|
2579
|
+
exec ${quote(node)} ${quote(script)} hook "$@"
|
|
2580
|
+
`, { mode: 493 });
|
|
2581
|
+
fs9.chmodSync(file, 493);
|
|
2582
|
+
return file;
|
|
2583
|
+
}
|
|
2584
|
+
function readHookSecret(dir) {
|
|
2585
|
+
try {
|
|
2586
|
+
return fs9.readFileSync(path7.join(dir, "hook-secret"), "utf8").trim() || null;
|
|
2587
|
+
} catch {
|
|
2588
|
+
return null;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
function real(p) {
|
|
2592
|
+
try {
|
|
2593
|
+
return fs9.realpathSync(p);
|
|
2594
|
+
} catch {
|
|
2595
|
+
return path7.resolve(p);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
var within = (dir, root2) => dir === root2 || dir.startsWith(root2.endsWith(path7.sep) ? root2 : root2 + path7.sep);
|
|
2599
|
+
function hookTarget(agents, cwd, env = process.env) {
|
|
2600
|
+
const port2 = parseInt(env.CODEREMOTE_AGENT_PORT, 10);
|
|
2601
|
+
if (port2 && agents.some((a) => a.port === port2)) return { port: port2, terminal: env.CODEREMOTE_TERM_ID || null };
|
|
2602
|
+
if (typeof cwd !== "string" || !cwd) return null;
|
|
2603
|
+
const dir = real(cwd);
|
|
2604
|
+
const match = agents.map((a) => ({ ...a, real: real(a.root) })).filter((a) => within(dir, a.real)).sort((a, b) => b.real.length - a.real.length)[0];
|
|
2605
|
+
return match ? { port: match.port, terminal: null } : null;
|
|
2606
|
+
}
|
|
2607
|
+
function deliverHook({ port: port2, terminal, agent, secret, body, wait }) {
|
|
2608
|
+
return new Promise((resolve) => {
|
|
2609
|
+
const query = new URLSearchParams({ agent });
|
|
2610
|
+
if (terminal) query.set("terminal", terminal);
|
|
2611
|
+
const req = http3.request({
|
|
2612
|
+
host: "127.0.0.1",
|
|
2613
|
+
port: port2,
|
|
2614
|
+
method: "POST",
|
|
2615
|
+
path: `/api/agents/hook?${query}`,
|
|
2616
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), [HOOK_SECRET_HEADER]: secret }
|
|
2617
|
+
});
|
|
2618
|
+
const fail = () => {
|
|
2619
|
+
req.destroy();
|
|
2620
|
+
resolve("");
|
|
2621
|
+
};
|
|
2622
|
+
const connect = setTimeout(fail, CONNECT_TIMEOUT);
|
|
2623
|
+
const overall = wait ? null : setTimeout(fail, EVENT_TIMEOUT);
|
|
2624
|
+
req.on("socket", (socket) => socket.once("connect", () => clearTimeout(connect)));
|
|
2625
|
+
req.on("error", () => resolve(""));
|
|
2626
|
+
req.on("response", (res) => {
|
|
2627
|
+
const chunks = [];
|
|
2628
|
+
res.on("data", (c) => chunks.push(c));
|
|
2629
|
+
res.on("end", () => {
|
|
2630
|
+
clearTimeout(overall);
|
|
2631
|
+
resolve(res.statusCode === 200 ? Buffer.concat(chunks).toString("utf8") : "");
|
|
2632
|
+
});
|
|
2633
|
+
res.on("error", () => resolve(""));
|
|
2634
|
+
});
|
|
2635
|
+
req.end(body);
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
async function runHook({ input, agents, secret, agent = "claude", env = process.env }) {
|
|
2639
|
+
if (!secret) return "";
|
|
2640
|
+
let event;
|
|
2641
|
+
try {
|
|
2642
|
+
event = JSON.parse(input);
|
|
2643
|
+
} catch {
|
|
2644
|
+
return "";
|
|
2645
|
+
}
|
|
2646
|
+
const target = hookTarget(agents, event?.cwd, env);
|
|
2647
|
+
if (!target) return "";
|
|
2648
|
+
const wait = event.hook_event_name === "PermissionRequest";
|
|
2649
|
+
const out = await deliverHook({ ...target, agent, secret, body: input, wait });
|
|
2650
|
+
return wait ? out.trim() : "";
|
|
2651
|
+
}
|
|
2652
|
+
|
|
1246
2653
|
// src/preview.js
|
|
1247
2654
|
import { spawn } from "node:child_process";
|
|
1248
2655
|
var HOST_READY_TIMEOUT_MS = 3e4;
|
|
@@ -1426,20 +2833,41 @@ ${log2}`));
|
|
|
1426
2833
|
}
|
|
1427
2834
|
};
|
|
1428
2835
|
|
|
2836
|
+
// src/push-store.js
|
|
2837
|
+
import fs10 from "node:fs";
|
|
2838
|
+
import path8 from "node:path";
|
|
2839
|
+
function pushStore(dir, port2) {
|
|
2840
|
+
const file = path8.join(dir, `${port2}.json`);
|
|
2841
|
+
return {
|
|
2842
|
+
load() {
|
|
2843
|
+
try {
|
|
2844
|
+
const devices = JSON.parse(fs10.readFileSync(file, "utf8"));
|
|
2845
|
+
return Array.isArray(devices) ? devices : [];
|
|
2846
|
+
} catch {
|
|
2847
|
+
return [];
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
save(devices) {
|
|
2851
|
+
fs10.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2852
|
+
fs10.writeFileSync(file, JSON.stringify(devices, null, 2), { mode: 384 });
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
|
|
1429
2857
|
// src/registry.js
|
|
1430
|
-
import
|
|
1431
|
-
import
|
|
2858
|
+
import fs11 from "node:fs";
|
|
2859
|
+
import path9 from "node:path";
|
|
1432
2860
|
var AgentRegistry = class {
|
|
1433
2861
|
constructor(dir) {
|
|
1434
2862
|
this.dir = dir;
|
|
1435
2863
|
}
|
|
1436
2864
|
register({ port: port2, root: root2, url }) {
|
|
1437
|
-
|
|
2865
|
+
fs11.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
1438
2866
|
const file = this.#file(port2);
|
|
1439
|
-
|
|
2867
|
+
fs11.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
|
|
1440
2868
|
const cleanup = () => {
|
|
1441
2869
|
try {
|
|
1442
|
-
if (JSON.parse(
|
|
2870
|
+
if (JSON.parse(fs11.readFileSync(file, "utf8")).pid === process.pid) fs11.rmSync(file);
|
|
1443
2871
|
} catch {
|
|
1444
2872
|
}
|
|
1445
2873
|
};
|
|
@@ -1450,25 +2878,25 @@ var AgentRegistry = class {
|
|
|
1450
2878
|
list() {
|
|
1451
2879
|
let names = [];
|
|
1452
2880
|
try {
|
|
1453
|
-
names =
|
|
2881
|
+
names = fs11.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
|
|
1454
2882
|
} catch {
|
|
1455
2883
|
return [];
|
|
1456
2884
|
}
|
|
1457
2885
|
const agents = [];
|
|
1458
2886
|
for (const name of names) {
|
|
1459
|
-
const file =
|
|
2887
|
+
const file = path9.join(this.dir, name);
|
|
1460
2888
|
try {
|
|
1461
|
-
const agent = JSON.parse(
|
|
2889
|
+
const agent = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1462
2890
|
process.kill(agent.pid, 0);
|
|
1463
2891
|
agents.push(agent);
|
|
1464
2892
|
} catch {
|
|
1465
|
-
|
|
2893
|
+
fs11.rmSync(file, { force: true });
|
|
1466
2894
|
}
|
|
1467
2895
|
}
|
|
1468
2896
|
return agents.sort((a, b) => a.port - b.port);
|
|
1469
2897
|
}
|
|
1470
2898
|
#file(port2) {
|
|
1471
|
-
return
|
|
2899
|
+
return path9.join(this.dir, `${port2}.json`);
|
|
1472
2900
|
}
|
|
1473
2901
|
};
|
|
1474
2902
|
|
|
@@ -1532,8 +2960,8 @@ function tunnelStatus({ devtunnel: devtunnel2, tunnelId, account, anonymous: ano
|
|
|
1532
2960
|
}
|
|
1533
2961
|
|
|
1534
2962
|
// src/update.js
|
|
1535
|
-
import
|
|
1536
|
-
import
|
|
2963
|
+
import fs12 from "node:fs";
|
|
2964
|
+
import path10 from "node:path";
|
|
1537
2965
|
var LATEST_URL = "https://registry.npmjs.org/coderemote/latest";
|
|
1538
2966
|
var CHECK_INTERVAL_MS = 24 * 36e5;
|
|
1539
2967
|
var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
@@ -1557,10 +2985,10 @@ async function fetchLatest({ fetch: fetch2 = globalThis.fetch, timeoutMs = 3e3 }
|
|
|
1557
2985
|
}
|
|
1558
2986
|
}
|
|
1559
2987
|
async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }) {
|
|
1560
|
-
const file =
|
|
2988
|
+
const file = path10.join(dir, "update.json");
|
|
1561
2989
|
let cache = {};
|
|
1562
2990
|
try {
|
|
1563
|
-
cache = JSON.parse(
|
|
2991
|
+
cache = JSON.parse(fs12.readFileSync(file, "utf8"));
|
|
1564
2992
|
} catch {
|
|
1565
2993
|
}
|
|
1566
2994
|
let latest = cache.latest;
|
|
@@ -1569,8 +2997,8 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
|
|
|
1569
2997
|
if (fetched) {
|
|
1570
2998
|
latest = fetched;
|
|
1571
2999
|
try {
|
|
1572
|
-
|
|
1573
|
-
|
|
3000
|
+
fs12.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
3001
|
+
fs12.writeFileSync(file, JSON.stringify({ latest, checkedAt: now }));
|
|
1574
3002
|
} catch {
|
|
1575
3003
|
}
|
|
1576
3004
|
}
|
|
@@ -1578,7 +3006,7 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
|
|
|
1578
3006
|
return isNewer(latest, current) ? latest : null;
|
|
1579
3007
|
}
|
|
1580
3008
|
function installCommand(installedAt) {
|
|
1581
|
-
const p = installedAt.split(
|
|
3009
|
+
const p = installedAt.split(path10.sep).join("/");
|
|
1582
3010
|
if (p.includes("/pnpm/") || p.includes("/.pnpm/")) return ["pnpm", ["add", "-g", "coderemote@latest"]];
|
|
1583
3011
|
if (p.includes("/.bun/")) return ["bun", ["add", "-g", "coderemote@latest"]];
|
|
1584
3012
|
if (p.includes("/.yarn/") || p.includes("/yarn/global/")) return ["yarn", ["global", "add", "coderemote@latest"]];
|
|
@@ -1588,7 +3016,7 @@ function installCommand(installedAt) {
|
|
|
1588
3016
|
// package.json
|
|
1589
3017
|
var package_default = {
|
|
1590
3018
|
name: "coderemote",
|
|
1591
|
-
version: "0.3.
|
|
3019
|
+
version: "0.3.4",
|
|
1592
3020
|
description: "Use your computer from your iPhone: files, git and a real terminal over a secure Microsoft Dev Tunnel. No SSH, no port forwarding, no VPN.",
|
|
1593
3021
|
keywords: [
|
|
1594
3022
|
"remote",
|
|
@@ -1867,15 +3295,15 @@ var Spinner = class {
|
|
|
1867
3295
|
};
|
|
1868
3296
|
|
|
1869
3297
|
// src/vscode-tunnel.js
|
|
1870
|
-
import { execFile as
|
|
1871
|
-
import { promisify as
|
|
1872
|
-
var
|
|
3298
|
+
import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
|
|
3299
|
+
import { promisify as promisify4 } from "node:util";
|
|
3300
|
+
var run5 = promisify4(execFile7);
|
|
1873
3301
|
function findCode() {
|
|
1874
3302
|
return process.env.CODEREMOTE_CODE || onPath("code");
|
|
1875
3303
|
}
|
|
1876
3304
|
async function codeTunnelSignedIn(code) {
|
|
1877
3305
|
try {
|
|
1878
|
-
const { stdout } = await
|
|
3306
|
+
const { stdout } = await run5(code, ["tunnel", "user", "show"]);
|
|
1879
3307
|
return /^logged in/im.test(stdout);
|
|
1880
3308
|
} catch {
|
|
1881
3309
|
return false;
|
|
@@ -1917,24 +3345,28 @@ ${log2}`));
|
|
|
1917
3345
|
}
|
|
1918
3346
|
|
|
1919
3347
|
// bin/coderemote.js
|
|
1920
|
-
var
|
|
1921
|
-
var CONFIG_DIR =
|
|
1922
|
-
var LEGACY_CONFIG_DIR =
|
|
1923
|
-
if (!
|
|
1924
|
-
var CONFIG_FILE =
|
|
3348
|
+
var run6 = promisify5(execFile8);
|
|
3349
|
+
var CONFIG_DIR = path11.join(os6.homedir(), ".coderemote");
|
|
3350
|
+
var LEGACY_CONFIG_DIR = path11.join(os6.homedir(), ".vsremote");
|
|
3351
|
+
if (!fs13.existsSync(CONFIG_DIR) && fs13.existsSync(LEGACY_CONFIG_DIR)) fs13.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
|
|
3352
|
+
var CONFIG_FILE = path11.join(CONFIG_DIR, "config.json");
|
|
3353
|
+
var PUSH_DIR = path11.join(CONFIG_DIR, "push");
|
|
3354
|
+
var UPLOADS_DIR = path11.join(CONFIG_DIR, "uploads");
|
|
1925
3355
|
var DEFAULT_PORT = 31580;
|
|
1926
3356
|
var VERSION = package_default.version;
|
|
1927
|
-
var PACKAGED =
|
|
3357
|
+
var PACKAGED = path11.basename(path11.dirname(fileURLToPath(import.meta.url))) === "dist";
|
|
1928
3358
|
var spinner = new Spinner();
|
|
1929
3359
|
function loadConfig() {
|
|
1930
3360
|
let cfg2 = {};
|
|
1931
3361
|
try {
|
|
1932
|
-
cfg2 = JSON.parse(
|
|
3362
|
+
cfg2 = JSON.parse(fs13.readFileSync(CONFIG_FILE, "utf8"));
|
|
1933
3363
|
} catch {
|
|
1934
3364
|
}
|
|
1935
3365
|
if (!cfg2.token) {
|
|
1936
|
-
cfg2.token =
|
|
3366
|
+
cfg2.token = randomBytes3(32).toString("base64url");
|
|
1937
3367
|
saveConfig(cfg2);
|
|
3368
|
+
fs13.rmSync(PUSH_DIR, { recursive: true, force: true });
|
|
3369
|
+
fs13.rmSync(UPLOADS_DIR, { recursive: true, force: true });
|
|
1938
3370
|
}
|
|
1939
3371
|
if (cfg2.tunnelId && !cfg2.tunnels) {
|
|
1940
3372
|
cfg2.tunnels = { [DEFAULT_PORT]: cfg2.tunnelId };
|
|
@@ -1943,8 +3375,8 @@ function loadConfig() {
|
|
|
1943
3375
|
return cfg2;
|
|
1944
3376
|
}
|
|
1945
3377
|
function saveConfig(cfg2) {
|
|
1946
|
-
|
|
1947
|
-
|
|
3378
|
+
fs13.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
3379
|
+
fs13.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
|
|
1948
3380
|
}
|
|
1949
3381
|
function portFree(port2) {
|
|
1950
3382
|
return new Promise((resolve) => {
|
|
@@ -1954,7 +3386,20 @@ function portFree(port2) {
|
|
|
1954
3386
|
});
|
|
1955
3387
|
}
|
|
1956
3388
|
function listen(token, port2, root2, tunnelToken, preview, status2) {
|
|
1957
|
-
const
|
|
3389
|
+
const hookSecret = ensureHookSecret(CONFIG_DIR);
|
|
3390
|
+
writeHookLauncher(CONFIG_DIR, { script: fs13.realpathSync(process.argv[1]) });
|
|
3391
|
+
const server = createAgentServer({
|
|
3392
|
+
token,
|
|
3393
|
+
root: root2,
|
|
3394
|
+
tunnelToken,
|
|
3395
|
+
preview,
|
|
3396
|
+
tunnelStatus: status2,
|
|
3397
|
+
hookSecret,
|
|
3398
|
+
// This very CLI, so coding agents in the app's terminals report through the same version.
|
|
3399
|
+
hookBin: fs13.realpathSync(process.argv[1]),
|
|
3400
|
+
pushStore: pushStore(PUSH_DIR, port2),
|
|
3401
|
+
uploadsDir: UPLOADS_DIR
|
|
3402
|
+
});
|
|
1958
3403
|
return new Promise((resolve, reject) => {
|
|
1959
3404
|
server.once("error", reject);
|
|
1960
3405
|
server.listen(port2, "127.0.0.1", () => resolve(server));
|
|
@@ -1964,7 +3409,7 @@ var DEVTUNNEL = "devtunnel";
|
|
|
1964
3409
|
async function resolveDevtunnel() {
|
|
1965
3410
|
let installing = false;
|
|
1966
3411
|
DEVTUNNEL = await ensureDevtunnel({
|
|
1967
|
-
dir:
|
|
3412
|
+
dir: path11.join(CONFIG_DIR, "bin"),
|
|
1968
3413
|
progress: (detail2) => {
|
|
1969
3414
|
if (!installing) {
|
|
1970
3415
|
installing = true;
|
|
@@ -1977,7 +3422,7 @@ async function resolveDevtunnel() {
|
|
|
1977
3422
|
}
|
|
1978
3423
|
async function devtunnel(...args) {
|
|
1979
3424
|
try {
|
|
1980
|
-
const { stdout } = await
|
|
3425
|
+
const { stdout } = await run6(DEVTUNNEL, args);
|
|
1981
3426
|
return { ok: true, out: stdout };
|
|
1982
3427
|
} catch (e) {
|
|
1983
3428
|
return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` || e.message };
|
|
@@ -2035,7 +3480,7 @@ ${access.out}`);
|
|
|
2035
3480
|
if (!/not found/i.test(updated.out)) throw new Error(`devtunnel update failed:
|
|
2036
3481
|
${updated.out}`);
|
|
2037
3482
|
}
|
|
2038
|
-
const name = port2 === DEFAULT_PORT ?
|
|
3483
|
+
const name = port2 === DEFAULT_PORT ? os6.hostname() : `${os6.hostname()} :${port2}`;
|
|
2039
3484
|
const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${name}`, "--labels", MACHINE_LABEL, "--json"];
|
|
2040
3485
|
if (anonymous2) createArgs.push("--allow-anonymous");
|
|
2041
3486
|
const created = await devtunnel(...createArgs);
|
|
@@ -2132,7 +3577,7 @@ var USAGE = `Usage: coderemote <command> [options]
|
|
|
2132
3577
|
status list running agents and their terminal sessions
|
|
2133
3578
|
kill <id>... | --all kill terminal sessions (an id prefix is enough)
|
|
2134
3579
|
stop [--all] stop the agent for this folder (or every agent)
|
|
2135
|
-
rotate-token revoke every paired device
|
|
3580
|
+
rotate-token revoke every paired device and stop running agents
|
|
2136
3581
|
update install the latest coderemote (up says when there's one)
|
|
2137
3582
|
requests [PORT] list the requests that reached a previewed port, oldest first;
|
|
2138
3583
|
--grep TEXT, --failed (errors, 4xx and 5xx), --limit N (default 50)
|
|
@@ -2140,6 +3585,9 @@ var USAGE = `Usage: coderemote <command> [options]
|
|
|
2140
3585
|
request <ID> [PORT] headers and bodies of one request (bodies up to 64 KB are kept);
|
|
2141
3586
|
auth headers, cookies and token-like fields are masked unless
|
|
2142
3587
|
--show-secrets is given; --json on either for machine-readable output
|
|
3588
|
+
setup claude let the app follow Claude Code sessions and answer their prompts
|
|
3589
|
+
(installs the Code Remote plugin into Claude Code); --remove undoes it
|
|
3590
|
+
setup codex the same for Codex (then trust its hooks once with /hooks in Codex)
|
|
2143
3591
|
version print this coderemote's version (also --version, -v)
|
|
2144
3592
|
login sign in to Microsoft Dev Tunnels with GitHub (up does this when needed);
|
|
2145
3593
|
--microsoft for a Microsoft account, --device-code without a browser
|
|
@@ -2161,9 +3609,9 @@ var flag = (name) => {
|
|
|
2161
3609
|
};
|
|
2162
3610
|
var anonymous = rest.includes("--allow-anonymous") || rest.includes("--no-auth");
|
|
2163
3611
|
var port = flag("--port") ? parseInt(flag("--port"), 10) : DEFAULT_PORT;
|
|
2164
|
-
var root =
|
|
3612
|
+
var root = path11.resolve(flag("--root") ?? process.cwd());
|
|
2165
3613
|
var cfg = loadConfig();
|
|
2166
|
-
var registry = new AgentRegistry(
|
|
3614
|
+
var registry = new AgentRegistry(path11.join(CONFIG_DIR, "agents"));
|
|
2167
3615
|
async function start({ tunnel }) {
|
|
2168
3616
|
const agents = registry.list();
|
|
2169
3617
|
if (flag("--port")) {
|
|
@@ -2198,7 +3646,7 @@ async function start({ tunnel }) {
|
|
|
2198
3646
|
const preview = tunnel ? new PreviewTunnel({
|
|
2199
3647
|
devtunnel,
|
|
2200
3648
|
command: () => DEVTUNNEL,
|
|
2201
|
-
description: `coderemote preview ${
|
|
3649
|
+
description: `coderemote preview ${os6.hostname()}`,
|
|
2202
3650
|
leftover: cfg.previewTunnels?.[port],
|
|
2203
3651
|
remember: (id) => {
|
|
2204
3652
|
cfg.previewTunnels = { ...cfg.previewTunnels, [port]: id };
|
|
@@ -2261,6 +3709,105 @@ async function start({ tunnel }) {
|
|
|
2261
3709
|
});
|
|
2262
3710
|
return { url, access };
|
|
2263
3711
|
}
|
|
3712
|
+
var PLUGIN = "coderemote@coderemote";
|
|
3713
|
+
async function claudeCli(...args) {
|
|
3714
|
+
try {
|
|
3715
|
+
return (await run6("claude", args, { timeout: 12e4 })).stdout.trim();
|
|
3716
|
+
} catch (e) {
|
|
3717
|
+
if (e.code === "ENOENT") return null;
|
|
3718
|
+
throw new Error((e.stderr || e.stdout || e.message).trim());
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
async function setupClaude() {
|
|
3722
|
+
const version = await claudeCli("--version");
|
|
3723
|
+
if (version === null) {
|
|
3724
|
+
console.error("Claude Code is required: install it from https://claude.com/claude-code, then run this again.");
|
|
3725
|
+
process.exit(1);
|
|
3726
|
+
}
|
|
3727
|
+
if (rest.includes("--remove")) {
|
|
3728
|
+
await claudeCli("plugin", "uninstall", PLUGIN).catch((e) => console.error(e.message));
|
|
3729
|
+
await claudeCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3730
|
+
});
|
|
3731
|
+
console.log("Removed the Code Remote plugin. New Claude Code sessions no longer reach the app.");
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
const source = flag("--marketplace") ?? "CodeRemoteApp/coderemote";
|
|
3735
|
+
spinner.start(`Installing the Code Remote plugin into ${version}`);
|
|
3736
|
+
try {
|
|
3737
|
+
await claudeCli("plugin", "marketplace", "add", source);
|
|
3738
|
+
await claudeCli("plugin", "marketplace", "update", "coderemote").catch(() => {
|
|
3739
|
+
});
|
|
3740
|
+
await claudeCli("plugin", "install", PLUGIN);
|
|
3741
|
+
await claudeCli("plugin", "update", PLUGIN).catch(() => {
|
|
3742
|
+
});
|
|
3743
|
+
} catch (e) {
|
|
3744
|
+
spinner.fail();
|
|
3745
|
+
console.error(e.message);
|
|
3746
|
+
process.exit(1);
|
|
3747
|
+
}
|
|
3748
|
+
spinner.succeed();
|
|
3749
|
+
console.log("\n New Claude Code sessions in a folder shared with `coderemote up` now show in the");
|
|
3750
|
+
console.log(" app\u2019s Agents tab, where you can answer their permission prompts and questions.");
|
|
3751
|
+
console.log(" Sessions already running pick it up after a restart. Undo: coderemote setup claude --remove\n");
|
|
3752
|
+
}
|
|
3753
|
+
var CODEX_PLUGIN = "coderemote@coderemote";
|
|
3754
|
+
async function codexCli(...args) {
|
|
3755
|
+
try {
|
|
3756
|
+
return (await run6("codex", args, { timeout: 12e4 })).stdout.trim();
|
|
3757
|
+
} catch (e) {
|
|
3758
|
+
if (e.code === "ENOENT") return null;
|
|
3759
|
+
throw new Error((e.stderr || e.stdout || e.message).trim());
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
async function setupCodex() {
|
|
3763
|
+
const version = await codexCli("--version");
|
|
3764
|
+
if (version === null) {
|
|
3765
|
+
console.error("Codex is required: install it (npm install -g @openai/codex), then run this again.");
|
|
3766
|
+
process.exit(1);
|
|
3767
|
+
}
|
|
3768
|
+
const hooks = (await codexCli("features", "list")).split("\n").find((line) => /^hooks\s/.test(line));
|
|
3769
|
+
if (!hooks) {
|
|
3770
|
+
console.error(`${version} has no hooks. Update Codex to 0.139 or later (codex update), then run this again.`);
|
|
3771
|
+
process.exit(1);
|
|
3772
|
+
}
|
|
3773
|
+
if (rest.includes("--remove")) {
|
|
3774
|
+
await codexCli("plugin", "remove", CODEX_PLUGIN).catch((e) => console.error(e.message));
|
|
3775
|
+
await codexCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3776
|
+
});
|
|
3777
|
+
console.log("Removed the Code Remote plugin. New Codex sessions no longer reach the app.");
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
const given = flag("--marketplace");
|
|
3781
|
+
const source = given ?? "CodeRemoteApp/coderemote";
|
|
3782
|
+
spinner.start(`Installing the Code Remote plugin into ${version}`);
|
|
3783
|
+
try {
|
|
3784
|
+
if (/\bfalse\s*$/.test(hooks)) await codexCli("features", "enable", "hooks");
|
|
3785
|
+
const known = /^coderemote\s/m.test(await codexCli("plugin", "marketplace", "list"));
|
|
3786
|
+
if (known && given) {
|
|
3787
|
+
await codexCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3788
|
+
});
|
|
3789
|
+
await codexCli("plugin", "marketplace", "add", source);
|
|
3790
|
+
} else if (known) {
|
|
3791
|
+
await codexCli("plugin", "marketplace", "upgrade", "coderemote").catch(() => {
|
|
3792
|
+
});
|
|
3793
|
+
} else {
|
|
3794
|
+
await codexCli("plugin", "marketplace", "add", source);
|
|
3795
|
+
}
|
|
3796
|
+
await codexCli("plugin", "add", CODEX_PLUGIN);
|
|
3797
|
+
} catch (e) {
|
|
3798
|
+
spinner.fail();
|
|
3799
|
+
console.error(e.message);
|
|
3800
|
+
if (/not found in marketplace/.test(e.message) && !given) {
|
|
3801
|
+
console.error("Your coderemote may be newer than the published plugin; point at a checkout with --marketplace <dir>.");
|
|
3802
|
+
}
|
|
3803
|
+
process.exit(1);
|
|
3804
|
+
}
|
|
3805
|
+
spinner.succeed();
|
|
3806
|
+
console.log("\n One more step, once: open Codex, type /hooks and trust the Code Remote hooks.");
|
|
3807
|
+
console.log(" Codex runs a plugin's hooks only after you have reviewed them.");
|
|
3808
|
+
console.log(" Then new Codex sessions in a folder shared with `coderemote up` show in the app.");
|
|
3809
|
+
console.log(" Undo: coderemote setup codex --remove\n");
|
|
3810
|
+
}
|
|
2264
3811
|
async function agentApi(agent, method, route, query = {}) {
|
|
2265
3812
|
const url = new URL(route, `http://127.0.0.1:${agent.port}`);
|
|
2266
3813
|
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
|
@@ -2281,7 +3828,7 @@ function currentAgent() {
|
|
|
2281
3828
|
const agents = registry.list();
|
|
2282
3829
|
if (flag("--port")) return agents.find((a) => a.port === port);
|
|
2283
3830
|
const here = process.cwd();
|
|
2284
|
-
const containing = agents.filter((a) => here === a.root || here.startsWith(a.root +
|
|
3831
|
+
const containing = agents.filter((a) => here === a.root || here.startsWith(a.root + path11.sep)).sort((a, b) => b.root.length - a.root.length);
|
|
2285
3832
|
return containing[0] ?? (agents.length === 1 ? agents[0] : void 0);
|
|
2286
3833
|
}
|
|
2287
3834
|
async function previewPorts(agent, given) {
|
|
@@ -2305,6 +3852,32 @@ function ago(ms) {
|
|
|
2305
3852
|
return `${Math.round(s / 86400)}d`;
|
|
2306
3853
|
}
|
|
2307
3854
|
switch (cmd) {
|
|
3855
|
+
case "hook": {
|
|
3856
|
+
let out = "";
|
|
3857
|
+
try {
|
|
3858
|
+
const chunks = [];
|
|
3859
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
3860
|
+
out = await runHook({
|
|
3861
|
+
input: Buffer.concat(chunks).toString("utf8"),
|
|
3862
|
+
agents: registry.list(),
|
|
3863
|
+
secret: readHookSecret(CONFIG_DIR),
|
|
3864
|
+
agent: flag("--agent") ?? "claude"
|
|
3865
|
+
});
|
|
3866
|
+
} catch {
|
|
3867
|
+
}
|
|
3868
|
+
if (out) process.stdout.write(`${out}
|
|
3869
|
+
`);
|
|
3870
|
+
process.exit(0);
|
|
3871
|
+
}
|
|
3872
|
+
case "setup": {
|
|
3873
|
+
if (rest[0] === "claude") await setupClaude();
|
|
3874
|
+
else if (rest[0] === "codex") await setupCodex();
|
|
3875
|
+
else {
|
|
3876
|
+
console.error("Usage: coderemote setup claude|codex [--remove]");
|
|
3877
|
+
process.exit(1);
|
|
3878
|
+
}
|
|
3879
|
+
break;
|
|
3880
|
+
}
|
|
2308
3881
|
case "serve": {
|
|
2309
3882
|
await start({ tunnel: false });
|
|
2310
3883
|
console.log(`coderemote agent on http://127.0.0.1:${port}
|
|
@@ -2391,9 +3964,15 @@ Token: ${cfg.token}`);
|
|
|
2391
3964
|
break;
|
|
2392
3965
|
}
|
|
2393
3966
|
case "rotate-token": {
|
|
2394
|
-
cfg.token =
|
|
3967
|
+
cfg.token = randomBytes3(32).toString("base64url");
|
|
2395
3968
|
saveConfig(cfg);
|
|
2396
|
-
|
|
3969
|
+
const agents = registry.list();
|
|
3970
|
+
for (const agent of agents) {
|
|
3971
|
+
process.kill(agent.pid, "SIGTERM");
|
|
3972
|
+
console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
|
|
3973
|
+
}
|
|
3974
|
+
console.log("Token rotated. Re-pair your devices.");
|
|
3975
|
+
if (agents.length) console.log("Run `coderemote up` again in each folder above; each keeps its tunnel URL.");
|
|
2397
3976
|
break;
|
|
2398
3977
|
}
|
|
2399
3978
|
case "login": {
|
|
@@ -2414,7 +3993,7 @@ Token: ${cfg.token}`);
|
|
|
2414
3993
|
console.log(`coderemote ${VERSION} is the latest version.`);
|
|
2415
3994
|
break;
|
|
2416
3995
|
}
|
|
2417
|
-
const [command, args] = installCommand(
|
|
3996
|
+
const [command, args] = installCommand(fs13.realpathSync(process.argv[1]));
|
|
2418
3997
|
console.log(`Updating coderemote ${VERSION} \u2192 ${latest}: ${command} ${args.join(" ")}
|
|
2419
3998
|
`);
|
|
2420
3999
|
const code = await new Promise((resolve) => {
|