coderemote 0.3.2 → 0.3.5
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 +16 -0
- package/dist/coderemote.js +1730 -127
- 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,39 @@ 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
|
+
agentsStop: "POST /api/agents/stop",
|
|
70
|
+
agentsForget: "POST /api/agents/forget",
|
|
71
|
+
agentsUpload: "POST /api/agents/uploads",
|
|
72
|
+
pushDeviceSet: "PUT /api/push/device",
|
|
73
|
+
pushDeviceDelete: "DELETE /api/push/device",
|
|
74
|
+
pushTest: "POST /api/push/test"
|
|
60
75
|
});
|
|
61
76
|
var TUNNEL_HANDOFF_PATH = "/vsremote/handoff";
|
|
62
77
|
var TUNNEL_AUTH_CALLBACK_HOST = "tunnel-auth";
|
|
63
78
|
var TERMINAL_WS_PATH = "/api/term/ws";
|
|
64
79
|
var TerminalServerMessage = Object.freeze({ session: "session", exit: "exit" });
|
|
65
80
|
var TerminalClientMessage = Object.freeze({ input: "input", resize: "resize" });
|
|
81
|
+
var AGENTS_WS_PATH = "/api/agents/ws";
|
|
82
|
+
var HOOK_SECRET_HEADER = "x-coderemote-hook";
|
|
83
|
+
var AgentsServerMessage = Object.freeze({
|
|
84
|
+
snapshot: "snapshot",
|
|
85
|
+
session: "session",
|
|
86
|
+
entry: "entry",
|
|
87
|
+
request: "request",
|
|
88
|
+
requestResolved: "request_resolved",
|
|
89
|
+
sessionRemoved: "session_removed",
|
|
90
|
+
promptConfirmed: "prompt_confirmed"
|
|
91
|
+
});
|
|
92
|
+
var AgentsClientMessage = Object.freeze({ presence: "presence" });
|
|
66
93
|
function webUrl({ url, token }) {
|
|
67
94
|
return `${url}/#token=${encodeURIComponent(token)}`;
|
|
68
95
|
}
|
|
@@ -73,9 +100,9 @@ function pairingUrl({ url, token }) {
|
|
|
73
100
|
// ../../packages/agent-core/src/server.js
|
|
74
101
|
import { WebSocketServer } from "ws";
|
|
75
102
|
|
|
76
|
-
// ../../packages/agent-core/src/
|
|
77
|
-
import {
|
|
78
|
-
import
|
|
103
|
+
// ../../packages/agent-core/src/agents.js
|
|
104
|
+
import { randomUUID } from "node:crypto";
|
|
105
|
+
import { EventEmitter } from "node:events";
|
|
79
106
|
import path2 from "node:path";
|
|
80
107
|
|
|
81
108
|
// ../../packages/agent-core/src/sandbox.js
|
|
@@ -109,8 +136,8 @@ var Sandbox = class {
|
|
|
109
136
|
}
|
|
110
137
|
/** Like `resolve`, but refuses the root itself (deleting or renaming the shared folder). */
|
|
111
138
|
async resolveChild(p, { allowMissing = false } = {}) {
|
|
112
|
-
const { abs, real } = await this.#check(p, allowMissing);
|
|
113
|
-
if (
|
|
139
|
+
const { abs, real: real2 } = await this.#check(p, allowMissing);
|
|
140
|
+
if (real2 === this.realRoot) throw new HttpError(403, "cannot modify the shared folder itself");
|
|
114
141
|
return abs;
|
|
115
142
|
}
|
|
116
143
|
async #check(p, allowMissing) {
|
|
@@ -120,9 +147,9 @@ var Sandbox = class {
|
|
|
120
147
|
let tail = "";
|
|
121
148
|
while (true) {
|
|
122
149
|
try {
|
|
123
|
-
const
|
|
124
|
-
if (!this.#containsReal(
|
|
125
|
-
return { abs, real };
|
|
150
|
+
const real2 = path.join(await fs.realpath(existing), tail);
|
|
151
|
+
if (!this.#containsReal(real2)) throw outside();
|
|
152
|
+
return { abs, real: real2 };
|
|
126
153
|
} catch (e) {
|
|
127
154
|
if (e instanceof HttpError) throw e;
|
|
128
155
|
if (!this.#containsLexically(abs)) throw outside();
|
|
@@ -139,7 +166,644 @@ function outside() {
|
|
|
139
166
|
return new HttpError(403, "path is outside the shared folder");
|
|
140
167
|
}
|
|
141
168
|
|
|
169
|
+
// ../../packages/agent-core/src/codex-rollout.js
|
|
170
|
+
import fs2 from "node:fs/promises";
|
|
171
|
+
var POLL = 1e3;
|
|
172
|
+
function codexItem(item) {
|
|
173
|
+
if (!item || typeof item !== "object") return null;
|
|
174
|
+
if (item.type === "AgentMessage") {
|
|
175
|
+
const text = (item.content ?? []).filter((c) => typeof c?.text === "string").map((c) => c.text).join("\n").trim();
|
|
176
|
+
return text ? { kind: "assistant", text } : null;
|
|
177
|
+
}
|
|
178
|
+
if (item.type === "FileChange" && item.changes && typeof item.changes === "object") {
|
|
179
|
+
const verbs = { add: "Added", update: "Updated", delete: "Deleted" };
|
|
180
|
+
const lines = Object.entries(item.changes).map(([file, change]) => `${verbs[change?.type] ?? "Changed"} ${file}`);
|
|
181
|
+
return { kind: "tool_result", toolUseId: item.id ?? null, output: lines.join("\n"), error: false };
|
|
182
|
+
}
|
|
183
|
+
const output = item.aggregated_output ?? item.aggregatedOutput ?? item.output;
|
|
184
|
+
if (typeof item.id === "string" && typeof output === "string") {
|
|
185
|
+
const code = item.exit_code ?? item.exitCode;
|
|
186
|
+
return { kind: "tool_result", toolUseId: item.id, output, error: typeof code === "number" && code !== 0 };
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
var RolloutTail = class {
|
|
191
|
+
#file;
|
|
192
|
+
#offset = null;
|
|
193
|
+
#partial = "";
|
|
194
|
+
#timer = null;
|
|
195
|
+
#onItem;
|
|
196
|
+
constructor(file, onItem) {
|
|
197
|
+
this.#file = file;
|
|
198
|
+
this.#onItem = onItem;
|
|
199
|
+
this.#timer = setInterval(() => this.poll().catch(() => {
|
|
200
|
+
}), POLL);
|
|
201
|
+
this.#timer.unref?.();
|
|
202
|
+
}
|
|
203
|
+
async poll() {
|
|
204
|
+
const handle = await fs2.open(this.#file, "r");
|
|
205
|
+
try {
|
|
206
|
+
const { size } = await handle.stat();
|
|
207
|
+
if (this.#offset === null || size < this.#offset) {
|
|
208
|
+
this.#offset = size;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (size === this.#offset) return;
|
|
212
|
+
const buf = Buffer.alloc(size - this.#offset);
|
|
213
|
+
await handle.read(buf, 0, buf.length, this.#offset);
|
|
214
|
+
this.#offset = size;
|
|
215
|
+
const lines = (this.#partial + buf.toString("utf8")).split("\n");
|
|
216
|
+
this.#partial = lines.pop();
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
let record;
|
|
219
|
+
try {
|
|
220
|
+
record = JSON.parse(line);
|
|
221
|
+
} catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (record?.type === "event_msg" && record.payload?.type === "item_completed") this.#onItem(record.payload.item);
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
await handle.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
stop() {
|
|
231
|
+
clearInterval(this.#timer);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// ../../packages/agent-core/src/transcript.js
|
|
236
|
+
import fs3 from "node:fs/promises";
|
|
237
|
+
var TAIL_BYTES = 2 * 1024 * 1024;
|
|
238
|
+
function textOf(content) {
|
|
239
|
+
if (typeof content === "string") return content;
|
|
240
|
+
if (!Array.isArray(content)) return "";
|
|
241
|
+
return content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
242
|
+
}
|
|
243
|
+
function isTyped(text) {
|
|
244
|
+
const t = text.trim();
|
|
245
|
+
return t && !t.startsWith("<command-") && !t.startsWith("<local-command") && !t.startsWith("[Request interrupted");
|
|
246
|
+
}
|
|
247
|
+
function* entriesOf(record) {
|
|
248
|
+
if (!record || record.isSidechain || record.isMeta) return;
|
|
249
|
+
const time2 = Date.parse(record.timestamp) || 0;
|
|
250
|
+
const content = record.message?.content;
|
|
251
|
+
if (record.type === "user") {
|
|
252
|
+
if (typeof content === "string") {
|
|
253
|
+
if (isTyped(content)) yield { time: time2, kind: "prompt", text: content };
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!Array.isArray(content)) return;
|
|
257
|
+
for (const c of content) {
|
|
258
|
+
if (c?.type === "tool_result") {
|
|
259
|
+
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 };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const text = textOf(content);
|
|
263
|
+
if (isTyped(text) && content.every((c) => c?.type !== "tool_result")) yield { time: time2, kind: "prompt", text };
|
|
264
|
+
} else if (record.type === "assistant" && Array.isArray(content)) {
|
|
265
|
+
for (const c of content) {
|
|
266
|
+
if (c?.type === "text" && c.text?.trim()) yield { time: time2, kind: "assistant", text: c.text.trimEnd() };
|
|
267
|
+
else if (c?.type === "tool_use") yield { time: time2, kind: "tool", toolUseId: c.id ?? null, name: c.name ?? null, input: c.input ?? null };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function readTranscript(file, limit = 500) {
|
|
272
|
+
let text;
|
|
273
|
+
try {
|
|
274
|
+
const handle = await fs3.open(file, "r");
|
|
275
|
+
try {
|
|
276
|
+
const { size } = await handle.stat();
|
|
277
|
+
const start2 = Math.max(0, size - TAIL_BYTES);
|
|
278
|
+
const buf = Buffer.alloc(size - start2);
|
|
279
|
+
await handle.read(buf, 0, buf.length, start2);
|
|
280
|
+
text = buf.toString("utf8");
|
|
281
|
+
if (start2 > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
282
|
+
} finally {
|
|
283
|
+
await handle.close();
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
const entries = [];
|
|
289
|
+
for (const line of text.split("\n")) {
|
|
290
|
+
if (!line.trim()) continue;
|
|
291
|
+
try {
|
|
292
|
+
entries.push(...entriesOf(JSON.parse(line)));
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return entries.slice(-limit);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ../../packages/agent-core/src/agents.js
|
|
300
|
+
var MAX_ENTRIES = 500;
|
|
301
|
+
var MAX_TEXT = 16 * 1024;
|
|
302
|
+
var MAX_TITLE = 80;
|
|
303
|
+
var ENDED_TTL = 24 * 60 * 60 * 1e3;
|
|
304
|
+
var REQUEST_TTL = 60 * 60 * 1e3;
|
|
305
|
+
var START_TIMEOUT = 15 * 1e3;
|
|
306
|
+
var SEQUENTIAL_REQUEST_TTL = 10 * 60 * 1e3;
|
|
307
|
+
var ADAPTERS = {
|
|
308
|
+
claude: {
|
|
309
|
+
name: "Claude Code",
|
|
310
|
+
capabilities: { approvals: "parallel", alwaysAllow: true, keys: ["interrupt", "cycle_mode", "/compact", "/clear", "/model"] },
|
|
311
|
+
normalize: (e) => e
|
|
312
|
+
},
|
|
313
|
+
codex: {
|
|
314
|
+
name: "Codex",
|
|
315
|
+
// Codex reports a session only once it gets its first prompt.
|
|
316
|
+
startsOnPrompt: true,
|
|
317
|
+
setupHint: ", then trust its hooks with `/hooks` in Codex",
|
|
318
|
+
capabilities: { approvals: "presence", alwaysAllow: false, keys: ["interrupt", "/compact", "/model"] },
|
|
319
|
+
normalize: (e) => ({
|
|
320
|
+
...e,
|
|
321
|
+
// Codex's patch tool carries the patch where Bash has its command.
|
|
322
|
+
tool_input: e.tool_name === "apply_patch" && typeof e.tool_input === "string" ? { command: e.tool_input } : e.tool_input
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
function comparable(text, paths = []) {
|
|
327
|
+
let t = String(text);
|
|
328
|
+
for (const p of paths) t = t.split(p).join(" ");
|
|
329
|
+
return t.replace(/\[Image #\d+\]/g, " ").replace(/\s+/g, " ").trim();
|
|
330
|
+
}
|
|
331
|
+
function clip(text) {
|
|
332
|
+
const buf = Buffer.from(text, "utf8");
|
|
333
|
+
if (buf.length <= MAX_TEXT) return { text, truncated: false };
|
|
334
|
+
return { text: buf.subarray(0, MAX_TEXT).toString("utf8").replace(/�+$/, ""), truncated: true };
|
|
335
|
+
}
|
|
336
|
+
function clipInput(input) {
|
|
337
|
+
if (!input || typeof input !== "object") return input ?? null;
|
|
338
|
+
const out = Array.isArray(input) ? [] : {};
|
|
339
|
+
for (const [k, v] of Object.entries(input)) out[k] = typeof v === "string" ? clip(v).text : v;
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
function resultText(response) {
|
|
343
|
+
if (response == null) return "";
|
|
344
|
+
if (typeof response === "string") return response;
|
|
345
|
+
if (typeof response.stdout === "string" || typeof response.stderr === "string") {
|
|
346
|
+
return [response.stdout, response.stderr].filter(Boolean).join("\n");
|
|
347
|
+
}
|
|
348
|
+
return JSON.stringify(response, null, 2);
|
|
349
|
+
}
|
|
350
|
+
function titleOf(prompt) {
|
|
351
|
+
const line = prompt.trim().split("\n")[0].trim();
|
|
352
|
+
return line.length > MAX_TITLE ? `${line.slice(0, MAX_TITLE - 1)}\u2026` : line;
|
|
353
|
+
}
|
|
354
|
+
var AgentSession = class {
|
|
355
|
+
constructor({ id, agent, cwd, terminal, now }) {
|
|
356
|
+
this.id = id;
|
|
357
|
+
this.agent = agent;
|
|
358
|
+
this.cwd = cwd;
|
|
359
|
+
this.title = path2.basename(cwd || "") || "Session";
|
|
360
|
+
this.capabilities = (ADAPTERS[agent] ?? ADAPTERS.claude).capabilities;
|
|
361
|
+
this.titled = false;
|
|
362
|
+
this.state = "idle";
|
|
363
|
+
this.terminal = terminal;
|
|
364
|
+
this.startedAt = now;
|
|
365
|
+
this.lastActivityAt = now;
|
|
366
|
+
this.endedAt = null;
|
|
367
|
+
this.starting = false;
|
|
368
|
+
this.error = null;
|
|
369
|
+
this.turnStartedAt = null;
|
|
370
|
+
this.entries = [];
|
|
371
|
+
this.seq = 0;
|
|
372
|
+
this.streaming = /* @__PURE__ */ new Map();
|
|
373
|
+
this.requests = /* @__PURE__ */ new Map();
|
|
374
|
+
this.prompts = [];
|
|
375
|
+
this.tail = null;
|
|
376
|
+
this.ready = Promise.resolve();
|
|
377
|
+
}
|
|
378
|
+
toJSON() {
|
|
379
|
+
return {
|
|
380
|
+
id: this.id,
|
|
381
|
+
agent: this.agent,
|
|
382
|
+
cwd: this.cwd,
|
|
383
|
+
title: this.title,
|
|
384
|
+
state: this.state,
|
|
385
|
+
terminal: this.terminal,
|
|
386
|
+
startedAt: this.startedAt,
|
|
387
|
+
lastActivityAt: this.lastActivityAt,
|
|
388
|
+
pending: this.requests.size,
|
|
389
|
+
starting: this.starting,
|
|
390
|
+
error: this.error,
|
|
391
|
+
capabilities: this.capabilities
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
var AgentHub = class extends EventEmitter {
|
|
396
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
397
|
+
#requests = /* @__PURE__ */ new Map();
|
|
398
|
+
#now;
|
|
399
|
+
#requestTtl;
|
|
400
|
+
#transcript;
|
|
401
|
+
#startTimeout;
|
|
402
|
+
#sequentialTtl;
|
|
403
|
+
#rolloutTail;
|
|
404
|
+
/**
|
|
405
|
+
* `present` says whether someone is at the computer or looking at it in a client; agents whose
|
|
406
|
+
* approvals are sequential ask there instead of holding the request for the phone.
|
|
407
|
+
*/
|
|
408
|
+
constructor({
|
|
409
|
+
now = Date.now,
|
|
410
|
+
requestTtl = REQUEST_TTL,
|
|
411
|
+
sequentialTtl = SEQUENTIAL_REQUEST_TTL,
|
|
412
|
+
transcript = readTranscript,
|
|
413
|
+
startTimeout = START_TIMEOUT,
|
|
414
|
+
present = async () => false,
|
|
415
|
+
rolloutTail = (file, onItem) => new RolloutTail(file, onItem)
|
|
416
|
+
} = {}) {
|
|
417
|
+
super();
|
|
418
|
+
this.#now = now;
|
|
419
|
+
this.#requestTtl = requestTtl;
|
|
420
|
+
this.#sequentialTtl = sequentialTtl;
|
|
421
|
+
this.#transcript = transcript;
|
|
422
|
+
this.#startTimeout = startTimeout;
|
|
423
|
+
this.present = present;
|
|
424
|
+
this.#rolloutTail = rolloutTail;
|
|
425
|
+
this.presenceSet = arguments[0]?.present !== void 0;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* A prompt a client typed into a session's terminal. Once Claude Code reports a matching
|
|
429
|
+
* `UserPromptSubmit`, the socket gets `prompt_confirmed` with this id.
|
|
430
|
+
*/
|
|
431
|
+
prompted(sessionId, { id, text, paths = [] }) {
|
|
432
|
+
const s = this.#sessions.get(sessionId);
|
|
433
|
+
if (!s) return;
|
|
434
|
+
s.prompts.push({ id, text: comparable(text, paths), at: this.#now() });
|
|
435
|
+
s.prompts = s.prompts.filter((p) => this.#now() - p.at < 6e4).slice(-20);
|
|
436
|
+
}
|
|
437
|
+
/** Whether `resume` may reopen a session: only one no process runs any more. */
|
|
438
|
+
canResume(id) {
|
|
439
|
+
const s = this.#sessions.get(id);
|
|
440
|
+
return !s || s.state === "ended";
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* A session the agent just started in `terminal` (`id`: the id it passed with --session-id,
|
|
444
|
+
* or the one it resumes). It shows right away; if Claude Code reports nothing within the
|
|
445
|
+
* start timeout, it ends with an error.
|
|
446
|
+
*/
|
|
447
|
+
launched({ id, terminal, cwd, agent = "claude" }) {
|
|
448
|
+
const now = this.#now();
|
|
449
|
+
let s = this.#sessions.get(id);
|
|
450
|
+
if (!s) {
|
|
451
|
+
s = new AgentSession({ id, agent, cwd, terminal, now });
|
|
452
|
+
this.#sessions.set(id, s);
|
|
453
|
+
}
|
|
454
|
+
const adapter = ADAPTERS[agent] ?? ADAPTERS.claude;
|
|
455
|
+
const state = adapter.startsOnPrompt ? "idle" : "running";
|
|
456
|
+
Object.assign(s, { terminal, state, starting: true, error: null, endedAt: null, lastActivityAt: now });
|
|
457
|
+
if (adapter.startsOnPrompt) {
|
|
458
|
+
this.#emitSession(s);
|
|
459
|
+
return s.toJSON();
|
|
460
|
+
}
|
|
461
|
+
const timer = setTimeout(() => {
|
|
462
|
+
if (!s.starting || this.#sessions.get(id) !== s) return;
|
|
463
|
+
s.starting = false;
|
|
464
|
+
s.error = `${adapter.name} didn't report back. If it runs in the terminal, run \`coderemote setup ${agent}\` once on the computer${adapter.setupHint ?? ""}.`;
|
|
465
|
+
this.#end(s);
|
|
466
|
+
this.#emitSession(s);
|
|
467
|
+
}, this.#startTimeout);
|
|
468
|
+
timer.unref?.();
|
|
469
|
+
this.#emitSession(s);
|
|
470
|
+
return s.toJSON();
|
|
471
|
+
}
|
|
472
|
+
sessions() {
|
|
473
|
+
return [...this.#sessions.values()].map((s) => s.toJSON()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
474
|
+
}
|
|
475
|
+
requests() {
|
|
476
|
+
return [...this.#requests.values()].map((r) => r.request);
|
|
477
|
+
}
|
|
478
|
+
session(id) {
|
|
479
|
+
return this.#sessions.get(id)?.toJSON() ?? null;
|
|
480
|
+
}
|
|
481
|
+
timeline(id, after = 0) {
|
|
482
|
+
const s = this.#sessions.get(id);
|
|
483
|
+
if (!s) throw new HttpError(404, "no such session");
|
|
484
|
+
const entries = s.entries.filter((e) => e.seq > after);
|
|
485
|
+
return { entries, next: s.seq, requests: [...s.requests.values()].map((r) => r.request) };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Takes one hook event. Resolves with the hook's output: for `PermissionRequest` once the
|
|
489
|
+
* request is answered (null when the computer should decide), `{}` for everything else.
|
|
490
|
+
* Aborting `signal` (the hook was killed because the computer answered) withdraws the request.
|
|
491
|
+
*/
|
|
492
|
+
async handle(raw, { agent = "claude", terminal = null, signal } = {}) {
|
|
493
|
+
if (!raw || typeof raw.session_id !== "string" || !raw.session_id) {
|
|
494
|
+
throw new HttpError(400, "hook event without session_id");
|
|
495
|
+
}
|
|
496
|
+
if (!Object.hasOwn(ADAPTERS, agent)) throw new HttpError(400, `unknown agent ${agent}`);
|
|
497
|
+
const event = ADAPTERS[agent].normalize(raw);
|
|
498
|
+
const now = this.#now();
|
|
499
|
+
let replaced = null;
|
|
500
|
+
if (!this.#sessions.has(event.session_id)) {
|
|
501
|
+
const candidates = [...this.#sessions.values()].filter(
|
|
502
|
+
(p) => p.starting && (terminal ? p.terminal === terminal : p.agent === agent && p.cwd === event.cwd)
|
|
503
|
+
);
|
|
504
|
+
if (candidates.length === 1) {
|
|
505
|
+
replaced = candidates[0];
|
|
506
|
+
this.#sessions.delete(replaced.id);
|
|
507
|
+
terminal ??= replaced.terminal;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
let s = this.#sessions.get(event.session_id);
|
|
511
|
+
if (!s) {
|
|
512
|
+
s = new AgentSession({ id: event.session_id, agent, cwd: event.cwd ?? null, terminal, now });
|
|
513
|
+
this.#sessions.set(s.id, s);
|
|
514
|
+
const fresh = event.hook_event_name === "SessionStart" && event.source !== "resume";
|
|
515
|
+
if (!fresh && typeof event.transcript_path === "string") s.ready = this.#backfill(s, event.transcript_path);
|
|
516
|
+
}
|
|
517
|
+
await s.ready;
|
|
518
|
+
if (s.agent === "codex" && !s.tail && typeof event.transcript_path === "string") {
|
|
519
|
+
s.tail = this.#rolloutTail(event.transcript_path, (item) => this.#fromRollout(s, item));
|
|
520
|
+
s.tail.poll?.().catch(() => {
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (replaced) {
|
|
524
|
+
s.prompts.push(...replaced.prompts);
|
|
525
|
+
this.emit("message", { type: "session_removed", session: replaced.id, replacedBy: s.id });
|
|
526
|
+
}
|
|
527
|
+
if (terminal && !s.terminal) s.terminal = terminal;
|
|
528
|
+
s.starting = false;
|
|
529
|
+
s.lastActivityAt = now;
|
|
530
|
+
if (s.state === "ended" && event.hook_event_name !== "SessionEnd") {
|
|
531
|
+
s.state = "idle";
|
|
532
|
+
s.endedAt = null;
|
|
533
|
+
}
|
|
534
|
+
switch (event.hook_event_name) {
|
|
535
|
+
case "SessionStart":
|
|
536
|
+
break;
|
|
537
|
+
case "UserPromptSubmit": {
|
|
538
|
+
const text = typeof event.prompt === "string" ? event.prompt : "";
|
|
539
|
+
if (!s.titled && text.trim()) {
|
|
540
|
+
s.title = titleOf(text);
|
|
541
|
+
s.titled = true;
|
|
542
|
+
}
|
|
543
|
+
this.#startTurn(s, now);
|
|
544
|
+
this.#entry(s, { kind: "prompt", text });
|
|
545
|
+
const said = comparable(text);
|
|
546
|
+
const i = s.prompts.findIndex((p) => p.text === said || p.text && said.startsWith(p.text));
|
|
547
|
+
if (i >= 0) {
|
|
548
|
+
const [prompt] = s.prompts.splice(i, 1);
|
|
549
|
+
this.emit("message", { type: "prompt_confirmed", session: s.id, prompt: prompt.id });
|
|
550
|
+
}
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "PreToolUse":
|
|
554
|
+
this.#startTurn(s, now);
|
|
555
|
+
this.#entry(s, {
|
|
556
|
+
kind: "tool",
|
|
557
|
+
toolUseId: event.tool_use_id ?? null,
|
|
558
|
+
name: event.tool_name,
|
|
559
|
+
input: clipInput(event.tool_input)
|
|
560
|
+
});
|
|
561
|
+
break;
|
|
562
|
+
case "PostToolUse":
|
|
563
|
+
case "PostToolUseFailure": {
|
|
564
|
+
const failed = event.hook_event_name === "PostToolUseFailure";
|
|
565
|
+
const { text, truncated } = clip(failed ? String(event.error ?? "") : resultText(event.tool_response));
|
|
566
|
+
this.#entry(s, {
|
|
567
|
+
kind: "tool_result",
|
|
568
|
+
toolUseId: event.tool_use_id ?? null,
|
|
569
|
+
name: event.tool_name,
|
|
570
|
+
output: text,
|
|
571
|
+
error: failed,
|
|
572
|
+
truncated,
|
|
573
|
+
duration: event.duration_ms ?? null
|
|
574
|
+
});
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
case "MessageDisplay": {
|
|
578
|
+
const message = s.streaming.get(event.message_id) ?? { parts: /* @__PURE__ */ new Map(), last: null };
|
|
579
|
+
s.streaming.set(event.message_id, message);
|
|
580
|
+
message.parts.set(event.index ?? message.parts.size, event.delta ?? "");
|
|
581
|
+
if (event.final) message.last = event.index ?? message.parts.size - 1;
|
|
582
|
+
if (message.last === null || message.parts.size <= message.last) break;
|
|
583
|
+
s.streaming.delete(event.message_id);
|
|
584
|
+
const text = [...message.parts.entries()].sort((a, b) => a[0] - b[0]).map(([, d]) => d).join("").trimEnd();
|
|
585
|
+
const previous = s.entries.at(-1)?.kind === "turn_end" ? s.entries.at(-2) : s.entries.at(-1);
|
|
586
|
+
if (text.trim() && !(previous?.kind === "assistant" && previous.text.trim() === text.trim())) {
|
|
587
|
+
this.#entry(s, { kind: "assistant", text });
|
|
588
|
+
}
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case "PermissionRequest": {
|
|
592
|
+
this.#emitSession(s);
|
|
593
|
+
const sequential = s.capabilities.approvals === "presence";
|
|
594
|
+
const deferNow = sequential && await this.present().catch(() => false);
|
|
595
|
+
return this.#openRequest(s, event, signal, { deferNow, ttl: sequential ? this.#sequentialTtl : this.#requestTtl });
|
|
596
|
+
}
|
|
597
|
+
case "Stop":
|
|
598
|
+
case "StopFailure": {
|
|
599
|
+
const last = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
|
|
600
|
+
const turnEntries = s.entries.filter((e) => e.time >= (s.turnStartedAt ?? 0));
|
|
601
|
+
if (last && !turnEntries.some((e) => e.kind === "assistant" && e.text.trim() === last)) {
|
|
602
|
+
this.#entry(s, { kind: "assistant", text: last });
|
|
603
|
+
}
|
|
604
|
+
s.streaming.clear();
|
|
605
|
+
const duration = s.turnStartedAt ? now - s.turnStartedAt : 0;
|
|
606
|
+
s.turnStartedAt = null;
|
|
607
|
+
s.state = s.requests.size ? "waiting" : "idle";
|
|
608
|
+
this.#entry(s, { kind: "turn_end", duration });
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
case "Interrupt":
|
|
612
|
+
case "Notification":
|
|
613
|
+
if ((event.hook_event_name === "Interrupt" || event.notification_type === "idle_prompt") && s.state === "running") {
|
|
614
|
+
s.state = "idle";
|
|
615
|
+
s.turnStartedAt = null;
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
case "SessionEnd":
|
|
619
|
+
this.#end(s);
|
|
620
|
+
break;
|
|
621
|
+
default:
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
this.#emitSession(s);
|
|
625
|
+
return {};
|
|
626
|
+
}
|
|
627
|
+
/** The terminal a session ran in exited: whatever ran in it is over. */
|
|
628
|
+
terminalExited(terminal) {
|
|
629
|
+
for (const s of this.#sessions.values()) {
|
|
630
|
+
if (s.terminal === terminal && s.state !== "ended") {
|
|
631
|
+
if (s.starting) s.error = `${(ADAPTERS[s.agent] ?? ADAPTERS.claude).name} exited before it started. Open the terminal to see why.`;
|
|
632
|
+
s.starting = false;
|
|
633
|
+
this.#end(s);
|
|
634
|
+
this.#emitSession(s);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Drops a session from the list, withdrawing its requests. One still running elsewhere comes
|
|
640
|
+
* back with its next hook event.
|
|
641
|
+
*/
|
|
642
|
+
forget(id) {
|
|
643
|
+
const s = this.#sessions.get(id);
|
|
644
|
+
if (!s) throw new HttpError(404, "no such session");
|
|
645
|
+
if (s.state !== "ended") this.#end(s);
|
|
646
|
+
this.#sessions.delete(id);
|
|
647
|
+
this.emit("message", { type: "session_removed", session: id, replacedBy: null });
|
|
648
|
+
}
|
|
649
|
+
/** Forgets sessions that ended more than a day ago. */
|
|
650
|
+
sweep() {
|
|
651
|
+
const cutoff = this.#now() - ENDED_TTL;
|
|
652
|
+
for (const s of this.#sessions.values()) {
|
|
653
|
+
if (s.state === "ended" && s.endedAt < cutoff) {
|
|
654
|
+
s.tail?.stop();
|
|
655
|
+
this.#sessions.delete(s.id);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Answers an open request from a client. Throws 404 for an unknown request and 409 for one
|
|
661
|
+
* already resolved (by another device, the computer, or the timeout).
|
|
662
|
+
*/
|
|
663
|
+
answer({ request: id, behavior, message, suggestion, answers } = {}) {
|
|
664
|
+
const pending = this.#requests.get(id);
|
|
665
|
+
if (!pending) {
|
|
666
|
+
throw new HttpError(this.#resolved.has(id) ? 409 : 404, this.#resolved.has(id) ? "already answered" : "no such request");
|
|
667
|
+
}
|
|
668
|
+
const { request } = pending;
|
|
669
|
+
if (behavior !== "allow" && behavior !== "deny") throw new HttpError(400, "behavior must be allow or deny");
|
|
670
|
+
let decision;
|
|
671
|
+
let outcome;
|
|
672
|
+
if (behavior === "deny") {
|
|
673
|
+
decision = { behavior: "deny", ...typeof message === "string" && message ? { message } : {} };
|
|
674
|
+
outcome = "denied";
|
|
675
|
+
} else if (request.kind === "question") {
|
|
676
|
+
if (!answers || typeof answers !== "object" || Object.values(answers).some((a) => typeof a !== "string")) {
|
|
677
|
+
throw new HttpError(400, "answers must map each question to a string");
|
|
678
|
+
}
|
|
679
|
+
decision = { behavior: "allow", updatedInput: { ...pending.input, answers } };
|
|
680
|
+
outcome = "answered";
|
|
681
|
+
} else {
|
|
682
|
+
decision = { behavior: "allow" };
|
|
683
|
+
if (suggestion !== void 0) {
|
|
684
|
+
const rule = Number.isInteger(suggestion) ? request.suggestions[suggestion] : void 0;
|
|
685
|
+
if (!rule || !pending.session.capabilities.alwaysAllow) {
|
|
686
|
+
throw new HttpError(400, "suggestion must index the request\u2019s suggestions");
|
|
687
|
+
}
|
|
688
|
+
decision.updatedPermissions = [rule];
|
|
689
|
+
}
|
|
690
|
+
outcome = "allowed";
|
|
691
|
+
}
|
|
692
|
+
this.#resolve(pending, { hookSpecificOutput: { hookEventName: "PermissionRequest", decision } }, outcome, "phone");
|
|
693
|
+
}
|
|
694
|
+
// Ids of requests resolved recently, to tell "already answered" (409) from unknown (404).
|
|
695
|
+
#resolved = /* @__PURE__ */ new Set();
|
|
696
|
+
#openRequest(s, event, signal, { deferNow = false, ttl = this.#requestTtl } = {}) {
|
|
697
|
+
const isQuestion = event.tool_name === "AskUserQuestion";
|
|
698
|
+
const input = event.tool_input ?? {};
|
|
699
|
+
const request = {
|
|
700
|
+
id: randomUUID(),
|
|
701
|
+
session: s.id,
|
|
702
|
+
kind: isQuestion ? "question" : "permission",
|
|
703
|
+
tool: event.tool_name ?? null,
|
|
704
|
+
input: clipInput(input),
|
|
705
|
+
suggestions: Array.isArray(event.permission_suggestions) ? event.permission_suggestions : [],
|
|
706
|
+
questions: isQuestion && Array.isArray(input.questions) ? input.questions : [],
|
|
707
|
+
createdAt: this.#now()
|
|
708
|
+
};
|
|
709
|
+
return new Promise((resolve) => {
|
|
710
|
+
const pending = { request, input, session: s, resolve, timer: null, signal, onAbort: null };
|
|
711
|
+
pending.timer = setTimeout(() => this.#resolve(pending, null, "expired", "agent"), ttl);
|
|
712
|
+
pending.timer.unref?.();
|
|
713
|
+
pending.onAbort = () => this.#resolve(pending, null, "withdrawn", "computer");
|
|
714
|
+
signal?.addEventListener("abort", pending.onAbort, { once: true });
|
|
715
|
+
s.requests.set(request.id, pending);
|
|
716
|
+
this.#requests.set(request.id, pending);
|
|
717
|
+
s.state = "waiting";
|
|
718
|
+
this.#entry(s, { kind: "request", request });
|
|
719
|
+
this.emit("message", { type: "request", request });
|
|
720
|
+
this.#emitSession(s);
|
|
721
|
+
if (signal?.aborted) pending.onAbort();
|
|
722
|
+
else if (deferNow) this.#resolve(pending, null, "deferred", "agent");
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
#resolve(pending, output, outcome, by) {
|
|
726
|
+
const { request, session: s } = pending;
|
|
727
|
+
if (!this.#requests.delete(request.id)) return;
|
|
728
|
+
s.requests.delete(request.id);
|
|
729
|
+
clearTimeout(pending.timer);
|
|
730
|
+
pending.signal?.removeEventListener("abort", pending.onAbort);
|
|
731
|
+
this.#resolved.add(request.id);
|
|
732
|
+
if (this.#resolved.size > 1e3) this.#resolved.delete(this.#resolved.values().next().value);
|
|
733
|
+
if (s.state === "waiting" && !s.requests.size) s.state = "running";
|
|
734
|
+
s.lastActivityAt = this.#now();
|
|
735
|
+
this.#entry(s, { kind: "request_resolved", request: request.id, outcome, by });
|
|
736
|
+
this.emit("message", { type: "request_resolved", session: s.id, request: request.id, outcome, by });
|
|
737
|
+
this.#emitSession(s);
|
|
738
|
+
pending.resolve(output);
|
|
739
|
+
}
|
|
740
|
+
async #backfill(s, file) {
|
|
741
|
+
let entries = [];
|
|
742
|
+
try {
|
|
743
|
+
entries = await this.#transcript(file, MAX_ENTRIES);
|
|
744
|
+
} catch {
|
|
745
|
+
}
|
|
746
|
+
const tools = /* @__PURE__ */ new Map();
|
|
747
|
+
for (const e of entries) {
|
|
748
|
+
if (e.kind === "tool") {
|
|
749
|
+
tools.set(e.toolUseId, e.name);
|
|
750
|
+
e.input = clipInput(e.input);
|
|
751
|
+
} else if (e.kind === "tool_result") {
|
|
752
|
+
e.name ??= tools.get(e.toolUseId) ?? null;
|
|
753
|
+
({ text: e.output, truncated: e.truncated } = clip(e.output));
|
|
754
|
+
} else if (e.kind === "prompt" && !s.titled && e.text.trim()) {
|
|
755
|
+
s.title = titleOf(e.text);
|
|
756
|
+
s.titled = true;
|
|
757
|
+
}
|
|
758
|
+
s.entries.push({ seq: ++s.seq, ...e });
|
|
759
|
+
}
|
|
760
|
+
if (s.entries.length > MAX_ENTRIES) s.entries.splice(0, s.entries.length - MAX_ENTRIES);
|
|
761
|
+
}
|
|
762
|
+
/** Something Codex said or a tool's result, from its rollout, unless the hooks told it already. */
|
|
763
|
+
#fromRollout(s, item) {
|
|
764
|
+
const fields = codexItem(item);
|
|
765
|
+
if (!fields) return;
|
|
766
|
+
const recent = s.entries.slice(-8);
|
|
767
|
+
if (fields.kind === "assistant") {
|
|
768
|
+
if (recent.some((e) => e.kind === "assistant" && e.text.trim() === fields.text)) return;
|
|
769
|
+
this.#entry(s, fields);
|
|
770
|
+
} else {
|
|
771
|
+
if (s.entries.some((e) => e.kind === "tool_result" && e.toolUseId === fields.toolUseId)) return;
|
|
772
|
+
const call = [...s.entries].reverse().find((e) => e.kind === "tool" && e.toolUseId === fields.toolUseId);
|
|
773
|
+
if (!call) return;
|
|
774
|
+
const { text, truncated } = clip(fields.output);
|
|
775
|
+
this.#entry(s, { kind: "tool_result", toolUseId: fields.toolUseId, name: call.name, output: text, error: fields.error, truncated, duration: null });
|
|
776
|
+
}
|
|
777
|
+
s.lastActivityAt = this.#now();
|
|
778
|
+
this.#emitSession(s);
|
|
779
|
+
}
|
|
780
|
+
#startTurn(s, now) {
|
|
781
|
+
if (s.state !== "waiting") s.state = "running";
|
|
782
|
+
s.turnStartedAt ??= now;
|
|
783
|
+
}
|
|
784
|
+
#end(s) {
|
|
785
|
+
s.tail?.stop();
|
|
786
|
+
s.tail = null;
|
|
787
|
+
for (const pending of [...s.requests.values()]) this.#resolve(pending, null, "withdrawn", "computer");
|
|
788
|
+
s.state = "ended";
|
|
789
|
+
s.endedAt = this.#now();
|
|
790
|
+
s.turnStartedAt = null;
|
|
791
|
+
}
|
|
792
|
+
#entry(s, fields) {
|
|
793
|
+
const entry2 = { seq: ++s.seq, time: this.#now(), ...fields };
|
|
794
|
+
s.entries.push(entry2);
|
|
795
|
+
if (s.entries.length > MAX_ENTRIES) s.entries.shift();
|
|
796
|
+
this.emit("message", { type: "entry", session: s.id, entry: entry2 });
|
|
797
|
+
}
|
|
798
|
+
#emitSession(s) {
|
|
799
|
+
this.emit("message", { type: "session", session: s.toJSON() });
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
142
803
|
// ../../packages/agent-core/src/git.js
|
|
804
|
+
import { execFile } from "node:child_process";
|
|
805
|
+
import fs4 from "node:fs/promises";
|
|
806
|
+
import path3 from "node:path";
|
|
143
807
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".build", "build", "dist", "DerivedData", "Pods", "vendor", ".venv", "target"]);
|
|
144
808
|
var MAX_SCAN_DEPTH = 4;
|
|
145
809
|
var MAX_DIFF_BYTES = 2 * 1024 * 1024;
|
|
@@ -168,7 +832,7 @@ async function findRepos(root2) {
|
|
|
168
832
|
async function walk(dir, depth) {
|
|
169
833
|
let entries;
|
|
170
834
|
try {
|
|
171
|
-
entries = await
|
|
835
|
+
entries = await fs4.readdir(dir, { withFileTypes: true });
|
|
172
836
|
} catch {
|
|
173
837
|
return;
|
|
174
838
|
}
|
|
@@ -178,14 +842,14 @@ async function findRepos(root2) {
|
|
|
178
842
|
}
|
|
179
843
|
if (depth >= MAX_SCAN_DEPTH) return;
|
|
180
844
|
const subdirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name));
|
|
181
|
-
await Promise.all(subdirs.map((e) => walk(
|
|
845
|
+
await Promise.all(subdirs.map((e) => walk(path3.join(dir, e.name), depth + 1)));
|
|
182
846
|
}
|
|
183
847
|
await walk(root2, 0);
|
|
184
|
-
repos.sort((a, b) => a.split(
|
|
848
|
+
repos.sort((a, b) => a.split(path3.sep).length - b.split(path3.sep).length || a.localeCompare(b));
|
|
185
849
|
return Promise.all(
|
|
186
850
|
repos.map(async (repo) => ({
|
|
187
851
|
path: repo,
|
|
188
|
-
name: repo === root2 ?
|
|
852
|
+
name: repo === root2 ? path3.basename(root2) : path3.relative(root2, repo),
|
|
189
853
|
branch: await currentBranch(repo)
|
|
190
854
|
}))
|
|
191
855
|
);
|
|
@@ -389,7 +1053,7 @@ var LANDING_HTML = `<!doctype html>
|
|
|
389
1053
|
|
|
390
1054
|
// ../../packages/agent-core/src/ports.js
|
|
391
1055
|
import { execFile as execFile2 } from "node:child_process";
|
|
392
|
-
import
|
|
1056
|
+
import fs5 from "node:fs/promises";
|
|
393
1057
|
import os from "node:os";
|
|
394
1058
|
function run(cmd2, args) {
|
|
395
1059
|
return new Promise((resolve, reject) => {
|
|
@@ -447,7 +1111,7 @@ async function workingDirs(pids) {
|
|
|
447
1111
|
const dirs = /* @__PURE__ */ new Map();
|
|
448
1112
|
if (!pids.length) return dirs;
|
|
449
1113
|
if (os.platform() === "linux") {
|
|
450
|
-
await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await
|
|
1114
|
+
await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await fs5.readlink(`/proc/${pid2}/cwd`).catch(() => null))));
|
|
451
1115
|
return dirs;
|
|
452
1116
|
}
|
|
453
1117
|
const out = await run("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-F", "pn"]).catch(() => "");
|
|
@@ -507,11 +1171,228 @@ async function killListener(pid, { force = false, shells } = {}) {
|
|
|
507
1171
|
}
|
|
508
1172
|
}
|
|
509
1173
|
|
|
1174
|
+
// ../../packages/agent-core/src/push.js
|
|
1175
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
1176
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
1177
|
+
import os2 from "node:os";
|
|
1178
|
+
import { promisify } from "node:util";
|
|
1179
|
+
var run2 = promisify(execFile3);
|
|
1180
|
+
var FINISHED_AFTER = 60 * 1e3;
|
|
1181
|
+
var ACTIVE_WITHIN = 2 * 60;
|
|
1182
|
+
async function macIdleSeconds() {
|
|
1183
|
+
if (os2.platform() !== "darwin") return null;
|
|
1184
|
+
try {
|
|
1185
|
+
const { stdout } = await run2("ioreg", ["-c", "IOHIDSystem", "-d", "4", "-k", "HIDIdleTime"], { timeout: 2e3 });
|
|
1186
|
+
const ns = stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/)?.[1];
|
|
1187
|
+
return ns ? Number(BigInt(ns) / 1000000000n) : null;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function userActiveHere() {
|
|
1193
|
+
const idle = await macIdleSeconds();
|
|
1194
|
+
return idle !== null && idle < ACTIVE_WITHIN;
|
|
1195
|
+
}
|
|
1196
|
+
function seal(key, data) {
|
|
1197
|
+
const nonce = randomBytes(12);
|
|
1198
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
1199
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(data), "utf8"), cipher.final()]);
|
|
1200
|
+
return Buffer.concat([nonce, body, cipher.getAuthTag()]).toString("base64");
|
|
1201
|
+
}
|
|
1202
|
+
function relayUrl(value) {
|
|
1203
|
+
let url;
|
|
1204
|
+
try {
|
|
1205
|
+
url = new URL(value);
|
|
1206
|
+
} catch {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
const local = url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost");
|
|
1210
|
+
return url.protocol === "https:" || local ? url.href.replace(/\/$/, "") : null;
|
|
1211
|
+
}
|
|
1212
|
+
function deviceFrom(body) {
|
|
1213
|
+
const relay = relayUrl(body?.relay);
|
|
1214
|
+
if (!relay) throw new HttpError(400, "relay must be an https URL");
|
|
1215
|
+
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)");
|
|
1216
|
+
if (body.environment !== "production" && body.environment !== "development") {
|
|
1217
|
+
throw new HttpError(400, "environment must be production or development");
|
|
1218
|
+
}
|
|
1219
|
+
if (typeof body.ticket !== "string" || !body.ticket || body.ticket.length > 128) throw new HttpError(400, "ticket required");
|
|
1220
|
+
if (typeof body.key !== "string" || Buffer.from(body.key, "base64").length !== 32) throw new HttpError(400, "key must be 32 bytes, base64");
|
|
1221
|
+
if (typeof body.keyId !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(body.keyId)) throw new HttpError(400, "keyId required");
|
|
1222
|
+
const kinds = body.kinds ?? {};
|
|
1223
|
+
return {
|
|
1224
|
+
relay,
|
|
1225
|
+
token: body.token.toLowerCase(),
|
|
1226
|
+
environment: body.environment,
|
|
1227
|
+
ticket: body.ticket,
|
|
1228
|
+
key: body.key,
|
|
1229
|
+
keyId: body.keyId,
|
|
1230
|
+
kinds: { requests: kinds.requests !== false, finished: kinds.finished !== false }
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function describeRequest(request) {
|
|
1234
|
+
if (request.kind === "question") return request.questions[0]?.question ?? "Claude has a question";
|
|
1235
|
+
const input = request.input ?? {};
|
|
1236
|
+
const detail2 = input.command ?? input.file_path ?? input.notebook_path ?? input.url ?? input.pattern ?? null;
|
|
1237
|
+
return detail2 ? `${request.tool}: ${String(detail2).split("\n")[0].slice(0, 200)}` : `Allow ${request.tool}?`;
|
|
1238
|
+
}
|
|
1239
|
+
function minutes(ms) {
|
|
1240
|
+
const m = Math.round(ms / 6e4);
|
|
1241
|
+
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
1242
|
+
}
|
|
1243
|
+
var PushSender = class {
|
|
1244
|
+
#devices;
|
|
1245
|
+
#store;
|
|
1246
|
+
#hub;
|
|
1247
|
+
#foreground;
|
|
1248
|
+
#userActive;
|
|
1249
|
+
#fetch;
|
|
1250
|
+
// Requests a notification went out for, so their resolution can replace it.
|
|
1251
|
+
#notified = /* @__PURE__ */ new Set();
|
|
1252
|
+
/**
|
|
1253
|
+
* @param {object} opts
|
|
1254
|
+
* @param {import('./agents.js').AgentHub} opts.hub
|
|
1255
|
+
* @param {{ load: () => object[], save: (devices: object[]) => void }} [opts.store]
|
|
1256
|
+
* @param {() => boolean} [opts.foreground] whether a client shows this machine right now
|
|
1257
|
+
* @param {() => Promise<boolean>} [opts.userActive] whether someone is using the computer
|
|
1258
|
+
*/
|
|
1259
|
+
constructor({ hub, store = null, foreground = () => false, userActive = null, fetch: fetch2 = globalThis.fetch }) {
|
|
1260
|
+
this.#hub = hub;
|
|
1261
|
+
this.#store = store;
|
|
1262
|
+
this.#devices = store?.load() ?? [];
|
|
1263
|
+
this.#foreground = foreground;
|
|
1264
|
+
this.#userActive = userActive ?? userActiveHere;
|
|
1265
|
+
this.#fetch = fetch2;
|
|
1266
|
+
hub.on("message", (msg) => this.#follow(msg).catch(() => {
|
|
1267
|
+
}));
|
|
1268
|
+
}
|
|
1269
|
+
devices() {
|
|
1270
|
+
return this.#devices.map(({ key, ticket, ...rest2 }) => rest2);
|
|
1271
|
+
}
|
|
1272
|
+
register(body) {
|
|
1273
|
+
const device = deviceFrom(body);
|
|
1274
|
+
this.#devices = [...this.#devices.filter((d) => d.token !== device.token), device];
|
|
1275
|
+
this.#save();
|
|
1276
|
+
}
|
|
1277
|
+
unregister(token) {
|
|
1278
|
+
this.#devices = this.#devices.filter((d) => d.token !== String(token).toLowerCase());
|
|
1279
|
+
this.#save();
|
|
1280
|
+
}
|
|
1281
|
+
/** Sends a test notification; throws 404 for an unknown token, 502 when it doesn't go out. */
|
|
1282
|
+
async test(token) {
|
|
1283
|
+
const device = this.#devices.find((d) => d.token === String(token ?? "").toLowerCase());
|
|
1284
|
+
if (!device) throw new HttpError(404, "that device is not registered");
|
|
1285
|
+
const result = await this.#send(device, {
|
|
1286
|
+
kind: "test",
|
|
1287
|
+
title: "Code Remote",
|
|
1288
|
+
body: "Notifications from this computer work.",
|
|
1289
|
+
category: null,
|
|
1290
|
+
fallback: "Notifications work"
|
|
1291
|
+
});
|
|
1292
|
+
if (!result.ok) throw new HttpError(502, `push failed: ${result.reason ?? result.status}`);
|
|
1293
|
+
}
|
|
1294
|
+
#save() {
|
|
1295
|
+
this.#store?.save(this.#devices);
|
|
1296
|
+
}
|
|
1297
|
+
async #quiet() {
|
|
1298
|
+
if (this.#foreground()) return true;
|
|
1299
|
+
try {
|
|
1300
|
+
return await this.#userActive();
|
|
1301
|
+
} catch {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async #follow(msg) {
|
|
1306
|
+
if (!this.#devices.length) return;
|
|
1307
|
+
if (msg.type === "request") {
|
|
1308
|
+
const { request } = msg;
|
|
1309
|
+
if (await this.#quiet()) return;
|
|
1310
|
+
if (!this.#hub.requests().some((r) => r.id === request.id)) return;
|
|
1311
|
+
this.#notified.add(request.id);
|
|
1312
|
+
const question = request.kind === "question";
|
|
1313
|
+
const answerable = question && request.questions.length === 1 && !request.questions[0].multiSelect;
|
|
1314
|
+
await this.#broadcast("requests", {
|
|
1315
|
+
kind: "request",
|
|
1316
|
+
title: this.#hub.session(request.session)?.title ?? "Claude Code",
|
|
1317
|
+
body: describeRequest(request),
|
|
1318
|
+
session: request.session,
|
|
1319
|
+
request: request.id,
|
|
1320
|
+
question: answerable ? request.questions[0].question : void 0,
|
|
1321
|
+
options: answerable ? request.questions[0].options.map((o) => o.label) : void 0,
|
|
1322
|
+
category: question ? "QUESTION" : "PERMISSION",
|
|
1323
|
+
collapseId: request.id,
|
|
1324
|
+
fallback: "A session needs your attention"
|
|
1325
|
+
});
|
|
1326
|
+
} else if (msg.type === "request_resolved") {
|
|
1327
|
+
if (!this.#notified.delete(msg.request) || msg.by === "phone") return;
|
|
1328
|
+
await this.#broadcast("requests", {
|
|
1329
|
+
kind: "resolved",
|
|
1330
|
+
title: this.#hub.session(msg.session)?.title ?? "Claude Code",
|
|
1331
|
+
body: msg.outcome === "expired" ? "No longer waiting for an answer" : "Answered on your computer",
|
|
1332
|
+
session: msg.session,
|
|
1333
|
+
request: msg.request,
|
|
1334
|
+
category: null,
|
|
1335
|
+
collapseId: msg.request,
|
|
1336
|
+
priority: 5,
|
|
1337
|
+
silent: true,
|
|
1338
|
+
fallback: "Answered on your computer"
|
|
1339
|
+
});
|
|
1340
|
+
} else if (msg.type === "entry" && msg.entry.kind === "turn_end" && msg.entry.duration >= FINISHED_AFTER) {
|
|
1341
|
+
if (await this.#quiet()) return;
|
|
1342
|
+
const session = this.#hub.session(msg.session);
|
|
1343
|
+
await this.#broadcast("finished", {
|
|
1344
|
+
kind: "finished",
|
|
1345
|
+
title: session?.title ?? "Claude Code",
|
|
1346
|
+
body: `Finished after ${minutes(msg.entry.duration)}`,
|
|
1347
|
+
session: msg.session,
|
|
1348
|
+
category: "DONE",
|
|
1349
|
+
fallback: "A session finished"
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async #broadcast(kind, note) {
|
|
1354
|
+
await Promise.all(this.#devices.filter((d) => d.kinds[kind]).map((d) => this.#send(d, note)));
|
|
1355
|
+
}
|
|
1356
|
+
async #send(device, { kind, title, body, session, request, question, options, category, collapseId, priority, silent, fallback }) {
|
|
1357
|
+
const secret = { kind, title, body, session, request, question, options };
|
|
1358
|
+
const aps = { alert: { title: "Code Remote", body: fallback }, "mutable-content": 1 };
|
|
1359
|
+
if (category) aps.category = category;
|
|
1360
|
+
if (session) aps["thread-id"] = session;
|
|
1361
|
+
if (!silent) aps.sound = "default";
|
|
1362
|
+
const payload = { aps, k: device.keyId, e: seal(Buffer.from(device.key, "base64"), secret) };
|
|
1363
|
+
let res;
|
|
1364
|
+
try {
|
|
1365
|
+
res = await this.#fetch(`${device.relay}/v1/send`, {
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: { "content-type": "application/json" },
|
|
1368
|
+
body: JSON.stringify({
|
|
1369
|
+
token: device.token,
|
|
1370
|
+
environment: device.environment,
|
|
1371
|
+
ticket: device.ticket,
|
|
1372
|
+
payload,
|
|
1373
|
+
...collapseId ? { collapseId } : {},
|
|
1374
|
+
...priority ? { priority } : {}
|
|
1375
|
+
}),
|
|
1376
|
+
signal: AbortSignal.timeout(1e4)
|
|
1377
|
+
});
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
return { ok: false, status: 0, reason: e.message };
|
|
1380
|
+
}
|
|
1381
|
+
let reason = null;
|
|
1382
|
+
try {
|
|
1383
|
+
reason = (await res.json()).reason ?? null;
|
|
1384
|
+
} catch {
|
|
1385
|
+
}
|
|
1386
|
+
if (res.status === 410 || res.status === 400 && reason === "BadDeviceToken") this.unregister(device.token);
|
|
1387
|
+
return { ok: res.ok, status: res.status, reason };
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
|
|
510
1391
|
// ../../packages/agent-core/src/inspector.js
|
|
511
1392
|
import http from "node:http";
|
|
512
1393
|
import net from "node:net";
|
|
513
1394
|
import zlib from "node:zlib";
|
|
514
|
-
var
|
|
1395
|
+
var MAX_ENTRIES2 = 200;
|
|
515
1396
|
var MAX_BODY = 64 * 1024;
|
|
516
1397
|
var TEXTUAL = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql|[\w.+-]+\+(json|xml)))/i;
|
|
517
1398
|
var nextId = 1;
|
|
@@ -590,7 +1471,7 @@ var InspectingProxy = class {
|
|
|
590
1471
|
response: { headers: {}, tap: new BodyTap() }
|
|
591
1472
|
};
|
|
592
1473
|
this.#entries.push(entry2);
|
|
593
|
-
if (this.#entries.length >
|
|
1474
|
+
if (this.#entries.length > MAX_ENTRIES2) this.#entries.shift();
|
|
594
1475
|
return entry2;
|
|
595
1476
|
}
|
|
596
1477
|
/** The dev server sees itself addressed as localhost:<its port>, as without the proxy. */
|
|
@@ -687,26 +1568,36 @@ function detail(e) {
|
|
|
687
1568
|
}
|
|
688
1569
|
|
|
689
1570
|
// ../../packages/agent-core/src/sessions.js
|
|
690
|
-
import { randomUUID } from "node:crypto";
|
|
691
|
-
import
|
|
1571
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1572
|
+
import os3 from "node:os";
|
|
692
1573
|
import pty from "node-pty";
|
|
693
1574
|
var SCROLLBACK_LIMIT = 256 * 1024;
|
|
1575
|
+
function shellQuote(arg) {
|
|
1576
|
+
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
|
1577
|
+
}
|
|
694
1578
|
var TerminalSession = class {
|
|
695
|
-
|
|
696
|
-
|
|
1579
|
+
/**
|
|
1580
|
+
* `command` (an argv array) runs that program through a login shell (for the user's PATH)
|
|
1581
|
+
* instead of an interactive shell; the session ends when it exits.
|
|
1582
|
+
*/
|
|
1583
|
+
constructor({ cols, rows, cwd, env = {}, command = null }) {
|
|
1584
|
+
this.id = randomUUID2();
|
|
697
1585
|
this.createdAt = Date.now();
|
|
698
1586
|
this.chunks = [];
|
|
699
1587
|
this.bufferedBytes = 0;
|
|
700
1588
|
this.clients = /* @__PURE__ */ new Set();
|
|
701
1589
|
this.exitCode = null;
|
|
702
|
-
const shell = process.env.SHELL || (
|
|
1590
|
+
const shell = process.env.SHELL || (os3.platform() === "win32" ? "powershell.exe" : "/bin/sh");
|
|
703
1591
|
this.shell = shell;
|
|
704
|
-
this.
|
|
1592
|
+
this.command = command;
|
|
1593
|
+
const args = command ? ["-l", "-c", `exec ${command.map(shellQuote).join(" ")}`] : ["-l"];
|
|
1594
|
+
this.pty = pty.spawn(shell, args, {
|
|
705
1595
|
name: "xterm-256color",
|
|
706
1596
|
cols,
|
|
707
1597
|
rows,
|
|
708
|
-
cwd: cwd ||
|
|
709
|
-
|
|
1598
|
+
cwd: cwd || os3.homedir(),
|
|
1599
|
+
// CODEREMOTE_TERM_ID lets `coderemote hook` tell which terminal a coding agent runs in.
|
|
1600
|
+
env: { ...process.env, ...env, CODEREMOTE_TERM_ID: this.id, TERM: "xterm-256color", COLORTERM: "truecolor" }
|
|
710
1601
|
});
|
|
711
1602
|
this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
|
|
712
1603
|
this.pty.onExit(({ exitCode }) => {
|
|
@@ -752,8 +1643,12 @@ var TerminalSession = class {
|
|
|
752
1643
|
this.pty.resize(cols, rows);
|
|
753
1644
|
}
|
|
754
1645
|
}
|
|
755
|
-
kill() {
|
|
756
|
-
this.pty.kill();
|
|
1646
|
+
kill(signal) {
|
|
1647
|
+
this.pty.kill(signal);
|
|
1648
|
+
}
|
|
1649
|
+
/** Types into the session, as a client's keyboard would. */
|
|
1650
|
+
write(data) {
|
|
1651
|
+
this.pty.write(data);
|
|
757
1652
|
}
|
|
758
1653
|
toJSON() {
|
|
759
1654
|
return {
|
|
@@ -768,9 +1663,16 @@ var TerminalSession = class {
|
|
|
768
1663
|
};
|
|
769
1664
|
var SessionManager = class {
|
|
770
1665
|
#sessions = /* @__PURE__ */ new Map();
|
|
1666
|
+
/** @param {{ onExit?: (id: string) => void }} [opts] */
|
|
1667
|
+
constructor({ onExit } = {}) {
|
|
1668
|
+
this.onExit = onExit;
|
|
1669
|
+
}
|
|
771
1670
|
create(opts) {
|
|
772
1671
|
const session = new TerminalSession(opts);
|
|
773
|
-
session.onExit = () =>
|
|
1672
|
+
session.onExit = () => {
|
|
1673
|
+
this.#sessions.delete(session.id);
|
|
1674
|
+
this.onExit?.(session.id);
|
|
1675
|
+
};
|
|
774
1676
|
this.#sessions.set(session.id, session);
|
|
775
1677
|
return session;
|
|
776
1678
|
}
|
|
@@ -785,6 +1687,110 @@ var SessionManager = class {
|
|
|
785
1687
|
}
|
|
786
1688
|
};
|
|
787
1689
|
|
|
1690
|
+
// ../../packages/agent-core/src/uploads.js
|
|
1691
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
1692
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1693
|
+
import { createWriteStream } from "node:fs";
|
|
1694
|
+
import fs6 from "node:fs/promises";
|
|
1695
|
+
import os4 from "node:os";
|
|
1696
|
+
import path4 from "node:path";
|
|
1697
|
+
import { Transform } from "node:stream";
|
|
1698
|
+
import { pipeline } from "node:stream/promises";
|
|
1699
|
+
import { promisify as promisify2 } from "node:util";
|
|
1700
|
+
var run3 = promisify2(execFile4);
|
|
1701
|
+
var UPLOAD_LIMIT = 100 * 1024 * 1024;
|
|
1702
|
+
var KEEP = 7 * 24 * 60 * 60 * 1e3;
|
|
1703
|
+
var ID = /^[0-9a-f-]{36}$/;
|
|
1704
|
+
function safeName(name) {
|
|
1705
|
+
const base = path4.basename(String(name ?? "").replace(/\\/g, "/"));
|
|
1706
|
+
const clean = base.replace(/[^\p{L}\p{N}._ -]/gu, "_").replace(/^[.\s]+/, "").slice(-80).trim();
|
|
1707
|
+
return clean || "file";
|
|
1708
|
+
}
|
|
1709
|
+
var UploadStore = class {
|
|
1710
|
+
constructor(dir, { now = Date.now } = {}) {
|
|
1711
|
+
this.dir = dir;
|
|
1712
|
+
this.now = now;
|
|
1713
|
+
}
|
|
1714
|
+
/** Streams `body` to disk. 413 past `limit`; HEIC photos become JPEG on macOS. */
|
|
1715
|
+
async save(body, { name, type, length, limit = UPLOAD_LIMIT }) {
|
|
1716
|
+
if (Number(length) > limit) throw new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`);
|
|
1717
|
+
const id = randomUUID3();
|
|
1718
|
+
const day = new Date(this.now()).toISOString().slice(0, 10);
|
|
1719
|
+
await fs6.mkdir(path4.join(this.dir, day), { recursive: true, mode: 448 });
|
|
1720
|
+
let file = path4.join(this.dir, day, `${id}-${safeName(name)}`);
|
|
1721
|
+
let size = 0;
|
|
1722
|
+
const counter = new Transform({
|
|
1723
|
+
transform(chunk, _enc, done) {
|
|
1724
|
+
size += chunk.length;
|
|
1725
|
+
done(size > limit ? new HttpError(413, `files are limited to ${limit / 1024 / 1024} MB`) : null, chunk);
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
try {
|
|
1729
|
+
await pipeline(body, counter, createWriteStream(file, { mode: 384 }));
|
|
1730
|
+
} catch (e) {
|
|
1731
|
+
await fs6.rm(file, { force: true });
|
|
1732
|
+
throw e;
|
|
1733
|
+
}
|
|
1734
|
+
let kind = type || "application/octet-stream";
|
|
1735
|
+
if ((kind === "image/heic" || /\.heic$/i.test(file)) && os4.platform() === "darwin") {
|
|
1736
|
+
const jpeg = file.replace(/(\.heic)?$/i, ".jpg");
|
|
1737
|
+
try {
|
|
1738
|
+
await run3("sips", ["-s", "format", "jpeg", file, "--out", jpeg], { timeout: 3e4 });
|
|
1739
|
+
await fs6.rm(file);
|
|
1740
|
+
file = jpeg;
|
|
1741
|
+
kind = "image/jpeg";
|
|
1742
|
+
size = (await fs6.stat(file)).size;
|
|
1743
|
+
} catch {
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return { id, name: path4.basename(file).slice(id.length + 1), type: kind, size, path: file };
|
|
1747
|
+
}
|
|
1748
|
+
/** The file of an upload; 404 once it's gone (swept, or never existed). */
|
|
1749
|
+
async path(id) {
|
|
1750
|
+
if (typeof id !== "string" || !ID.test(id)) throw new HttpError(400, "invalid upload id");
|
|
1751
|
+
let days = [];
|
|
1752
|
+
try {
|
|
1753
|
+
days = (await fs6.readdir(this.dir)).sort().reverse();
|
|
1754
|
+
} catch {
|
|
1755
|
+
}
|
|
1756
|
+
for (const day of days) {
|
|
1757
|
+
let names = [];
|
|
1758
|
+
try {
|
|
1759
|
+
names = await fs6.readdir(path4.join(this.dir, day));
|
|
1760
|
+
} catch {
|
|
1761
|
+
}
|
|
1762
|
+
const hit = names.find((n) => n.startsWith(`${id}-`));
|
|
1763
|
+
if (hit) return path4.join(this.dir, day, hit);
|
|
1764
|
+
}
|
|
1765
|
+
throw new HttpError(404, "no such upload (they are kept for 7 days)");
|
|
1766
|
+
}
|
|
1767
|
+
/** Deletes uploads older than a week. */
|
|
1768
|
+
async sweep() {
|
|
1769
|
+
let days = [];
|
|
1770
|
+
try {
|
|
1771
|
+
days = await fs6.readdir(this.dir);
|
|
1772
|
+
} catch {
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const cutoff = this.now() - KEEP;
|
|
1776
|
+
for (const day of days) {
|
|
1777
|
+
const dir = path4.join(this.dir, day);
|
|
1778
|
+
let names = [];
|
|
1779
|
+
try {
|
|
1780
|
+
names = await fs6.readdir(dir);
|
|
1781
|
+
} catch {
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
for (const n of names) {
|
|
1785
|
+
const file = path4.join(dir, n);
|
|
1786
|
+
const stat = await fs6.stat(file).catch(() => null);
|
|
1787
|
+
if (stat && stat.mtimeMs < cutoff) await fs6.rm(file, { force: true });
|
|
1788
|
+
}
|
|
1789
|
+
if (!(await fs6.readdir(dir).catch(() => [])).length) await fs6.rm(dir, { recursive: true, force: true });
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
|
|
788
1794
|
// ../../packages/agent-core/src/server.js
|
|
789
1795
|
function kindOf(stat) {
|
|
790
1796
|
if (stat.isDirectory()) return "dir";
|
|
@@ -793,17 +1799,25 @@ function kindOf(stat) {
|
|
|
793
1799
|
return "other";
|
|
794
1800
|
}
|
|
795
1801
|
async function describe(fullPath, name) {
|
|
796
|
-
const lst = await
|
|
1802
|
+
const lst = await fs7.lstat(fullPath);
|
|
797
1803
|
const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
|
|
798
1804
|
if (lst.isSymbolicLink()) {
|
|
799
1805
|
try {
|
|
800
|
-
entry2.target = kindOf(await
|
|
1806
|
+
entry2.target = kindOf(await fs7.stat(fullPath));
|
|
801
1807
|
} catch {
|
|
802
1808
|
entry2.target = "broken";
|
|
803
1809
|
}
|
|
804
1810
|
}
|
|
805
1811
|
return entry2;
|
|
806
1812
|
}
|
|
1813
|
+
var PASTE_START = "\x1B[200~";
|
|
1814
|
+
var PASTE_END = "\x1B[201~";
|
|
1815
|
+
var KEYS = { interrupt: "\x1B", cycle_mode: "\x1B[Z" };
|
|
1816
|
+
var COMMANDS = ["/compact", "/clear", "/model"];
|
|
1817
|
+
function typeable(text) {
|
|
1818
|
+
return text.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
1819
|
+
}
|
|
1820
|
+
var LAUNCH_MODES = ["manual", "acceptEdits", "plan", "auto"];
|
|
807
1821
|
function sendJson(res, status2, body) {
|
|
808
1822
|
const json = JSON.stringify(body);
|
|
809
1823
|
res.writeHead(status2, { "content-type": "application/json", "content-length": Buffer.byteLength(json) });
|
|
@@ -818,15 +1832,150 @@ async function readJson(req) {
|
|
|
818
1832
|
throw new HttpError(400, "invalid json");
|
|
819
1833
|
}
|
|
820
1834
|
}
|
|
821
|
-
function createAgentServer({
|
|
1835
|
+
function createAgentServer({
|
|
1836
|
+
token,
|
|
1837
|
+
root: root2,
|
|
1838
|
+
tunnelToken = async () => null,
|
|
1839
|
+
preview = null,
|
|
1840
|
+
tunnelStatus: tunnelStatus2 = null,
|
|
1841
|
+
hookSecret = null,
|
|
1842
|
+
hookBin = null,
|
|
1843
|
+
uploadsDir = null,
|
|
1844
|
+
claudeCommand = "claude",
|
|
1845
|
+
codexCommand = "codex",
|
|
1846
|
+
codexHome = process.env.CODEX_HOME || path5.join(os5.homedir(), ".codex"),
|
|
1847
|
+
pluginInstalled = null,
|
|
1848
|
+
agents = new AgentHub(),
|
|
1849
|
+
pushStore: pushStore2 = null,
|
|
1850
|
+
pushOptions = {}
|
|
1851
|
+
}) {
|
|
822
1852
|
const handoff = new TunnelHandoff({ token, mint: tunnelToken });
|
|
823
1853
|
const sandbox = new Sandbox(root2);
|
|
824
|
-
const sessions = new SessionManager();
|
|
1854
|
+
const sessions = new SessionManager({ onExit: (id) => agents.terminalExited(id) });
|
|
825
1855
|
const expected = Buffer.from(`Bearer ${token}`);
|
|
1856
|
+
const uploads = uploadsDir ? new UploadStore(uploadsDir) : null;
|
|
1857
|
+
const sweep = setInterval(() => {
|
|
1858
|
+
agents.sweep();
|
|
1859
|
+
uploads?.sweep().catch(() => {
|
|
1860
|
+
});
|
|
1861
|
+
}, 10 * 60 * 1e3);
|
|
1862
|
+
sweep.unref();
|
|
1863
|
+
let foregroundAt = 0;
|
|
1864
|
+
const clientInForeground = () => Date.now() - foregroundAt < 3e4;
|
|
1865
|
+
const push2 = new PushSender({ hub: agents, store: pushStore2, foreground: clientInForeground, ...pushOptions });
|
|
1866
|
+
if (!agents.presenceSet) {
|
|
1867
|
+
agents.present = async () => !clientInForeground() && await (pushOptions.userActive ?? userActiveHere)();
|
|
1868
|
+
}
|
|
826
1869
|
function authorized(req) {
|
|
827
1870
|
const got = Buffer.from(req.headers.authorization || "");
|
|
828
1871
|
return got.length === expected.length && timingSafeEqual2(got, expected);
|
|
829
1872
|
}
|
|
1873
|
+
function terminalEnv() {
|
|
1874
|
+
return {
|
|
1875
|
+
CODEREMOTE_AGENT_PORT: String(server.address()?.port ?? ""),
|
|
1876
|
+
...hookBin ? { CODEREMOTE_HOOK_BIN: hookBin } : {}
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
function typingTarget(id, { whileWaiting }) {
|
|
1880
|
+
const session = typeof id === "string" ? agents.session(id) : null;
|
|
1881
|
+
if (!session) throw new HttpError(404, "no such session");
|
|
1882
|
+
const term = session.terminal && session.state !== "ended" ? sessions.get(session.terminal) : null;
|
|
1883
|
+
if (!term) throw new HttpError(409, "this session runs outside Code Remote; it can be followed but not typed into");
|
|
1884
|
+
if (!whileWaiting && session.state === "waiting") throw new HttpError(409, "answer the pending request first");
|
|
1885
|
+
return { session, term };
|
|
1886
|
+
}
|
|
1887
|
+
async function typeInto(term, text) {
|
|
1888
|
+
term.write(`${PASTE_START}${text}${PASTE_END}`);
|
|
1889
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1890
|
+
term.write("\r");
|
|
1891
|
+
}
|
|
1892
|
+
function uploadPath(id) {
|
|
1893
|
+
if (!uploads) throw new HttpError(400, "this agent takes no uploads");
|
|
1894
|
+
return uploads.path(id);
|
|
1895
|
+
}
|
|
1896
|
+
async function attachmentPaths(ids) {
|
|
1897
|
+
if (ids === void 0) return [];
|
|
1898
|
+
if (!Array.isArray(ids) || ids.some((a) => typeof a !== "string")) throw new HttpError(400, "attachments must be upload ids");
|
|
1899
|
+
return Promise.all(ids.map((a) => uploadPath(a)));
|
|
1900
|
+
}
|
|
1901
|
+
function loginShell(script) {
|
|
1902
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
1903
|
+
return new Promise((resolve) => {
|
|
1904
|
+
execFile5(
|
|
1905
|
+
shell,
|
|
1906
|
+
["-l", "-c", script],
|
|
1907
|
+
{ timeout: 2e4, maxBuffer: 16 * 1024 * 1024 },
|
|
1908
|
+
(err, out) => resolve(err ? null : out)
|
|
1909
|
+
);
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
const launchers = {
|
|
1913
|
+
claude: {
|
|
1914
|
+
command: claudeCommand,
|
|
1915
|
+
name: "Claude Code",
|
|
1916
|
+
args: ({ id, resume, model, permissionMode }) => [
|
|
1917
|
+
...resume ? ["--resume", resume] : ["--session-id", id],
|
|
1918
|
+
...model ? ["--model", model] : [],
|
|
1919
|
+
...permissionMode ? ["--permission-mode", permissionMode] : [],
|
|
1920
|
+
...uploadsDir ? ["--add-dir", uploadsDir] : []
|
|
1921
|
+
],
|
|
1922
|
+
// Fullscreen rendering can fail to start while no client is attached to answer the
|
|
1923
|
+
// terminal's queries; the classic renderer doesn't need them.
|
|
1924
|
+
env: { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: "1" },
|
|
1925
|
+
async plugin() {
|
|
1926
|
+
const list = JSON.parse(await loginShell(`${shellQuote(claudeCommand)} plugin list --json`) ?? "[]");
|
|
1927
|
+
return list.some((p) => p.id === "coderemote@coderemote" && p.enabled !== false);
|
|
1928
|
+
},
|
|
1929
|
+
missingPlugin: "Claude Code needs the Code Remote plugin first: run `coderemote setup claude` once on the computer."
|
|
1930
|
+
},
|
|
1931
|
+
codex: {
|
|
1932
|
+
command: codexCommand,
|
|
1933
|
+
name: "Codex",
|
|
1934
|
+
// Codex's sandbox reads anywhere already; --add-dir would make the uploads writable.
|
|
1935
|
+
// Without -a, recent Codex rejects every escalation outright ("granular" policy), so a
|
|
1936
|
+
// session started here asks, and its requests reach the phone or the terminal.
|
|
1937
|
+
args: ({ resume, model, approvalPolicy }) => [
|
|
1938
|
+
...resume ? ["resume", resume] : [],
|
|
1939
|
+
...model ? ["-m", model] : [],
|
|
1940
|
+
...approvalPolicy ? ["-a", approvalPolicy] : []
|
|
1941
|
+
],
|
|
1942
|
+
env: {},
|
|
1943
|
+
async plugin() {
|
|
1944
|
+
const list = JSON.parse(await loginShell(`${shellQuote(codexCommand)} plugin list --marketplace coderemote --json`) ?? "{}");
|
|
1945
|
+
return (list.installed ?? []).some((p) => p.pluginId === "coderemote@coderemote" && p.enabled !== false);
|
|
1946
|
+
},
|
|
1947
|
+
missingPlugin: "Codex needs the Code Remote plugin first: run `coderemote setup codex` once on the computer, then trust its hooks with `/hooks` in Codex."
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
async function installed(agent) {
|
|
1951
|
+
return Boolean((await loginShell(`command -v ${shellQuote(launchers[agent].command)}`))?.trim());
|
|
1952
|
+
}
|
|
1953
|
+
const plugins = {};
|
|
1954
|
+
async function hasPlugin(agent) {
|
|
1955
|
+
const known = plugins[agent];
|
|
1956
|
+
if (known && Date.now() - known.checkedAt < 3e4) return known.installed;
|
|
1957
|
+
let found = false;
|
|
1958
|
+
try {
|
|
1959
|
+
found = pluginInstalled ? await pluginInstalled(agent) : await launchers[agent].plugin();
|
|
1960
|
+
} catch {
|
|
1961
|
+
}
|
|
1962
|
+
plugins[agent] = { installed: found, checkedAt: Date.now() };
|
|
1963
|
+
return found;
|
|
1964
|
+
}
|
|
1965
|
+
async function codexModels() {
|
|
1966
|
+
try {
|
|
1967
|
+
const { models } = JSON.parse(await fs7.readFile(path5.join(codexHome, "models_cache.json"), "utf8"));
|
|
1968
|
+
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 ?? "" }));
|
|
1969
|
+
} catch {
|
|
1970
|
+
return [];
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
function hookAuthorized(req) {
|
|
1974
|
+
if (!hookSecret) return false;
|
|
1975
|
+
const got = Buffer.from(String(req.headers[HOOK_SECRET_HEADER] || ""));
|
|
1976
|
+
const want = Buffer.from(hookSecret);
|
|
1977
|
+
return got.length === want.length && timingSafeEqual2(got, want);
|
|
1978
|
+
}
|
|
830
1979
|
function previewPort(value, ownPort) {
|
|
831
1980
|
if (!preview) throw new HttpError(404, "previews need a tunnel");
|
|
832
1981
|
const port2 = Number(value);
|
|
@@ -865,20 +2014,20 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
865
2014
|
async function repoOf(p) {
|
|
866
2015
|
const repo = await sandbox.resolve(p);
|
|
867
2016
|
try {
|
|
868
|
-
await
|
|
2017
|
+
await fs7.access(path5.join(repo, ".git"));
|
|
869
2018
|
} catch {
|
|
870
2019
|
throw new HttpError(400, "not a git repository");
|
|
871
2020
|
}
|
|
872
2021
|
return repo;
|
|
873
2022
|
}
|
|
874
2023
|
async function filesIn(repo, files) {
|
|
875
|
-
if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f ||
|
|
2024
|
+
if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f || path5.isAbsolute(f))) {
|
|
876
2025
|
throw new HttpError(400, "files must be a non-empty list of paths relative to the repository");
|
|
877
2026
|
}
|
|
878
2027
|
return Promise.all(
|
|
879
2028
|
files.map(async (f) => {
|
|
880
|
-
const abs = await sandbox.resolve(
|
|
881
|
-
const rel =
|
|
2029
|
+
const abs = await sandbox.resolve(path5.join(repo, f), { allowMissing: true });
|
|
2030
|
+
const rel = path5.relative(repo, abs);
|
|
882
2031
|
if (!rel || rel.startsWith("..")) throw new HttpError(403, "path is outside the repository");
|
|
883
2032
|
return rel;
|
|
884
2033
|
})
|
|
@@ -887,52 +2036,52 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
887
2036
|
const routes = {
|
|
888
2037
|
[Routes.info]: async () => ({
|
|
889
2038
|
protocol: PROTOCOL_VERSION,
|
|
890
|
-
hostname:
|
|
891
|
-
platform:
|
|
2039
|
+
hostname: os5.hostname(),
|
|
2040
|
+
platform: os5.platform(),
|
|
892
2041
|
root: sandbox.root,
|
|
893
|
-
user:
|
|
2042
|
+
user: os5.userInfo().username,
|
|
894
2043
|
shell: process.env.SHELL || null
|
|
895
2044
|
}),
|
|
896
2045
|
[Routes.fsList]: async (url) => {
|
|
897
2046
|
const dir = await sandbox.resolve(url.searchParams.get("path"));
|
|
898
|
-
const names = await
|
|
899
|
-
const entries = (await Promise.all(names.map((n) => describe(
|
|
2047
|
+
const names = await fs7.readdir(dir);
|
|
2048
|
+
const entries = (await Promise.all(names.map((n) => describe(path5.join(dir, n), n).catch(() => null)))).filter(Boolean);
|
|
900
2049
|
return { path: dir, entries };
|
|
901
2050
|
},
|
|
902
2051
|
[Routes.fsStat]: async (url) => {
|
|
903
2052
|
const p = await sandbox.resolve(url.searchParams.get("path"));
|
|
904
|
-
return { path: p, ...await describe(p,
|
|
2053
|
+
return { path: p, ...await describe(p, path5.basename(p)) };
|
|
905
2054
|
},
|
|
906
2055
|
[Routes.fsRead]: async (url, req, res) => {
|
|
907
2056
|
const p = await sandbox.resolve(url.searchParams.get("path"));
|
|
908
|
-
const stat = await
|
|
2057
|
+
const stat = await fs7.stat(p);
|
|
909
2058
|
if (!stat.isFile()) throw new HttpError(400, "not a file");
|
|
910
2059
|
res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
|
|
911
|
-
await
|
|
2060
|
+
await pipeline2(createReadStream(p), res);
|
|
912
2061
|
},
|
|
913
2062
|
[Routes.fsWrite]: async (url, req) => {
|
|
914
2063
|
const p = await sandbox.resolveChild(url.searchParams.get("path"), { allowMissing: true });
|
|
915
2064
|
const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
|
|
916
2065
|
try {
|
|
917
|
-
await
|
|
918
|
-
await
|
|
2066
|
+
await pipeline2(req, createWriteStream2(tmp));
|
|
2067
|
+
await fs7.rename(tmp, p);
|
|
919
2068
|
} catch (e) {
|
|
920
|
-
await
|
|
2069
|
+
await fs7.rm(tmp, { force: true });
|
|
921
2070
|
throw e;
|
|
922
2071
|
}
|
|
923
|
-
return describe(p,
|
|
2072
|
+
return describe(p, path5.basename(p));
|
|
924
2073
|
},
|
|
925
2074
|
[Routes.fsMkdir]: async (url) => {
|
|
926
|
-
await
|
|
2075
|
+
await fs7.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
|
|
927
2076
|
return {};
|
|
928
2077
|
},
|
|
929
2078
|
[Routes.fsRename]: async (url, req) => {
|
|
930
2079
|
const { from, to } = await readJson(req);
|
|
931
|
-
await
|
|
2080
|
+
await fs7.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
|
|
932
2081
|
return {};
|
|
933
2082
|
},
|
|
934
2083
|
[Routes.fsDelete]: async (url) => {
|
|
935
|
-
await
|
|
2084
|
+
await fs7.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
|
|
936
2085
|
return {};
|
|
937
2086
|
},
|
|
938
2087
|
[Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
|
|
@@ -1047,6 +2196,146 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1047
2196
|
if (!issued) throw new HttpError(404, "this tunnel is anonymous");
|
|
1048
2197
|
return issued;
|
|
1049
2198
|
},
|
|
2199
|
+
[Routes.agentsSessions]: async () => ({ sessions: agents.sessions() }),
|
|
2200
|
+
[Routes.agentsTimeline]: async (url) => agents.timeline(url.searchParams.get("session"), parseInt(url.searchParams.get("after"), 10) || 0),
|
|
2201
|
+
[Routes.agentsSetup]: async () => {
|
|
2202
|
+
const [claude, codex] = await Promise.all([installed("claude"), installed("codex")]);
|
|
2203
|
+
const [plugin, codexPlugin] = await Promise.all([claude && hasPlugin("claude"), codex && hasPlugin("codex")]);
|
|
2204
|
+
return { claude, plugin: Boolean(plugin), codex, codexPlugin: Boolean(codexPlugin), codexModels: codex ? await codexModels() : [] };
|
|
2205
|
+
},
|
|
2206
|
+
[Routes.agentsLaunch]: async (url, req) => {
|
|
2207
|
+
const { agent = "claude", cwd, model, permissionMode, approvalPolicy, resume, cols, rows } = await readJson(req);
|
|
2208
|
+
if (approvalPolicy !== void 0 && !["on-request", "never"].includes(approvalPolicy)) {
|
|
2209
|
+
throw new HttpError(400, "approvalPolicy must be on-request or never");
|
|
2210
|
+
}
|
|
2211
|
+
if (!Object.hasOwn(launchers, agent)) throw new HttpError(400, `agent must be one of ${Object.keys(launchers).join(", ")}`);
|
|
2212
|
+
const launcher = launchers[agent];
|
|
2213
|
+
let dir;
|
|
2214
|
+
try {
|
|
2215
|
+
dir = await sandbox.resolve(cwd || sandbox.root);
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
throw new HttpError(400, e.message);
|
|
2218
|
+
}
|
|
2219
|
+
if (!(await fs7.stat(dir)).isDirectory()) throw new HttpError(400, "cwd must be a folder");
|
|
2220
|
+
if (model !== void 0 && (typeof model !== "string" || !/^[A-Za-z0-9._[\]-]{1,80}$/.test(model))) {
|
|
2221
|
+
throw new HttpError(400, "invalid model");
|
|
2222
|
+
}
|
|
2223
|
+
if (permissionMode !== void 0 && !LAUNCH_MODES.includes(permissionMode)) {
|
|
2224
|
+
throw new HttpError(400, `permissionMode must be one of ${LAUNCH_MODES.join(", ")}`);
|
|
2225
|
+
}
|
|
2226
|
+
if (resume !== void 0 && (typeof resume !== "string" || !/^[0-9a-f-]{8,64}$/i.test(resume))) {
|
|
2227
|
+
throw new HttpError(400, "resume must be a session id");
|
|
2228
|
+
}
|
|
2229
|
+
if (resume && !agents.canResume(resume)) {
|
|
2230
|
+
throw new HttpError(409, "that session is still open; continue it where it runs, or end it first");
|
|
2231
|
+
}
|
|
2232
|
+
if (!await installed(agent)) {
|
|
2233
|
+
throw Object.assign(new HttpError(400, `${launcher.name} is not installed on this computer (no \`${launcher.command}\` command).`), {
|
|
2234
|
+
code: agent === "claude" ? "ENOCLAUDE" : "ENOAGENT"
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
if (!await hasPlugin(agent)) {
|
|
2238
|
+
throw Object.assign(new HttpError(400, launcher.missingPlugin), { code: "ENOPLUGIN" });
|
|
2239
|
+
}
|
|
2240
|
+
const id = resume ?? randomUUID4();
|
|
2241
|
+
if (uploadsDir) await fs7.mkdir(uploadsDir, { recursive: true, mode: 448 });
|
|
2242
|
+
const argv2 = [
|
|
2243
|
+
launcher.command,
|
|
2244
|
+
...launcher.args({
|
|
2245
|
+
id,
|
|
2246
|
+
resume,
|
|
2247
|
+
model,
|
|
2248
|
+
permissionMode: agent === "claude" ? permissionMode : void 0,
|
|
2249
|
+
approvalPolicy: agent === "codex" ? approvalPolicy ?? "on-request" : void 0
|
|
2250
|
+
})
|
|
2251
|
+
];
|
|
2252
|
+
const term = sessions.create({
|
|
2253
|
+
cols: Number.isInteger(cols) && cols > 0 ? cols : 80,
|
|
2254
|
+
rows: Number.isInteger(rows) && rows > 0 ? rows : 24,
|
|
2255
|
+
cwd: dir,
|
|
2256
|
+
env: { ...terminalEnv(), ...launcher.env },
|
|
2257
|
+
command: argv2
|
|
2258
|
+
});
|
|
2259
|
+
return { terminal: term.id, session: agents.launched({ id, terminal: term.id, cwd: dir, agent }) };
|
|
2260
|
+
},
|
|
2261
|
+
[Routes.agentsPrompt]: async (url, req) => {
|
|
2262
|
+
const { session: id, text, attachments } = await readJson(req);
|
|
2263
|
+
if (typeof text !== "string" || !text.trim() || text.length > 1e5) {
|
|
2264
|
+
throw new HttpError(400, "text must be a non-empty string");
|
|
2265
|
+
}
|
|
2266
|
+
const { term } = typingTarget(id, { whileWaiting: false });
|
|
2267
|
+
const paths = await attachmentPaths(attachments);
|
|
2268
|
+
const typed = typeable([text.trim(), ...paths].join("\n"));
|
|
2269
|
+
const prompt = randomUUID4();
|
|
2270
|
+
agents.prompted(id, { id: prompt, text: typed, paths });
|
|
2271
|
+
await typeInto(term, typed);
|
|
2272
|
+
return { prompt };
|
|
2273
|
+
},
|
|
2274
|
+
[Routes.agentsUpload]: async (url, req) => {
|
|
2275
|
+
if (!uploads) throw new HttpError(404, "this agent takes no uploads");
|
|
2276
|
+
let name = req.headers["x-filename"];
|
|
2277
|
+
try {
|
|
2278
|
+
name = decodeURIComponent(name ?? "");
|
|
2279
|
+
} catch {
|
|
2280
|
+
}
|
|
2281
|
+
const { id, name: saved, type, size } = await uploads.save(req, {
|
|
2282
|
+
name,
|
|
2283
|
+
type: req.headers["content-type"],
|
|
2284
|
+
length: req.headers["content-length"]
|
|
2285
|
+
});
|
|
2286
|
+
return { id, name: saved, type, size };
|
|
2287
|
+
},
|
|
2288
|
+
[Routes.agentsKeys]: async (url, req) => {
|
|
2289
|
+
const { session: id, action, command } = await readJson(req);
|
|
2290
|
+
const allowed = agents.session(typeof id === "string" ? id : "")?.capabilities?.keys;
|
|
2291
|
+
if (allowed && !allowed.includes(action === "command" ? command : action)) {
|
|
2292
|
+
throw new HttpError(400, `this session takes ${allowed.join(", ")}`);
|
|
2293
|
+
}
|
|
2294
|
+
if (action === "command") {
|
|
2295
|
+
if (!COMMANDS.includes(command)) throw new HttpError(400, `command must be one of ${COMMANDS.join(", ")}`);
|
|
2296
|
+
await typeInto(typingTarget(id, { whileWaiting: false }).term, command);
|
|
2297
|
+
} else if (Object.hasOwn(KEYS, action)) {
|
|
2298
|
+
typingTarget(id, { whileWaiting: true }).term.write(KEYS[action]);
|
|
2299
|
+
} else {
|
|
2300
|
+
throw new HttpError(400, `action must be one of ${[...Object.keys(KEYS), "command"].join(", ")}`);
|
|
2301
|
+
}
|
|
2302
|
+
return {};
|
|
2303
|
+
},
|
|
2304
|
+
[Routes.agentsStop]: async (url, req) => {
|
|
2305
|
+
const { session: id } = await readJson(req);
|
|
2306
|
+
const { term } = typingTarget(id, { whileWaiting: true });
|
|
2307
|
+
term.kill();
|
|
2308
|
+
setTimeout(() => {
|
|
2309
|
+
if (term.exitCode === null) term.kill("SIGKILL");
|
|
2310
|
+
}, 3e3).unref();
|
|
2311
|
+
return {};
|
|
2312
|
+
},
|
|
2313
|
+
[Routes.agentsForget]: async (url, req) => {
|
|
2314
|
+
const { session: id } = await readJson(req);
|
|
2315
|
+
const session = typeof id === "string" ? agents.session(id) : null;
|
|
2316
|
+
if (!session) throw new HttpError(404, "no such session");
|
|
2317
|
+
if (session.state !== "ended" && session.terminal && sessions.get(session.terminal)) {
|
|
2318
|
+
throw new HttpError(409, "this session still runs here; stop it first");
|
|
2319
|
+
}
|
|
2320
|
+
agents.forget(id);
|
|
2321
|
+
return {};
|
|
2322
|
+
},
|
|
2323
|
+
[Routes.agentsAnswer]: async (url, req) => {
|
|
2324
|
+
agents.answer(await readJson(req));
|
|
2325
|
+
return {};
|
|
2326
|
+
},
|
|
2327
|
+
[Routes.pushDeviceSet]: async (url, req) => {
|
|
2328
|
+
push2.register(await readJson(req));
|
|
2329
|
+
return {};
|
|
2330
|
+
},
|
|
2331
|
+
[Routes.pushDeviceDelete]: async (url) => {
|
|
2332
|
+
push2.unregister(url.searchParams.get("token") ?? "");
|
|
2333
|
+
return {};
|
|
2334
|
+
},
|
|
2335
|
+
[Routes.pushTest]: async (url, req) => {
|
|
2336
|
+
await push2.test((await readJson(req)).token);
|
|
2337
|
+
return {};
|
|
2338
|
+
},
|
|
1050
2339
|
[Routes.termKill]: async (url) => {
|
|
1051
2340
|
const s = sessions.get(url.searchParams.get("id"));
|
|
1052
2341
|
if (!s) throw new HttpError(404, "no such session");
|
|
@@ -1065,6 +2354,7 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1065
2354
|
return res.end(LANDING_HTML);
|
|
1066
2355
|
}
|
|
1067
2356
|
if (req.method === "GET" && url.pathname === TUNNEL_HANDOFF_PATH) return handoffRedirect(url, res);
|
|
2357
|
+
if (`${req.method} ${url.pathname}` === Routes.agentsHook) return hookDelivery(url, req, res);
|
|
1068
2358
|
if (!authorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
1069
2359
|
const handler = routes[`${req.method} ${url.pathname}`];
|
|
1070
2360
|
if (!handler) return sendJson(res, 404, { error: "not found" });
|
|
@@ -1077,6 +2367,28 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1077
2367
|
else res.destroy(e);
|
|
1078
2368
|
}
|
|
1079
2369
|
});
|
|
2370
|
+
async function hookDelivery(url, req, res) {
|
|
2371
|
+
if (!hookAuthorized(req)) return sendJson(res, 401, { error: "unauthorized" });
|
|
2372
|
+
plugins[url.searchParams.get("agent") || "claude"] = { installed: true, checkedAt: Date.now() };
|
|
2373
|
+
const terminal = url.searchParams.get("terminal");
|
|
2374
|
+
const closed = new AbortController();
|
|
2375
|
+
res.on("close", () => {
|
|
2376
|
+
if (!res.writableFinished) closed.abort();
|
|
2377
|
+
});
|
|
2378
|
+
try {
|
|
2379
|
+
const output = await agents.handle(await readJson(req), {
|
|
2380
|
+
agent: url.searchParams.get("agent") || "claude",
|
|
2381
|
+
terminal: terminal && sessions.get(terminal) ? terminal : null,
|
|
2382
|
+
signal: closed.signal
|
|
2383
|
+
});
|
|
2384
|
+
if (res.destroyed) return;
|
|
2385
|
+
if (output) return sendJson(res, 200, output);
|
|
2386
|
+
res.writeHead(200, { "content-length": 0 });
|
|
2387
|
+
res.end();
|
|
2388
|
+
} catch (e) {
|
|
2389
|
+
if (!res.headersSent) sendJson(res, e.status || 500, { error: e.message });
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
1080
2392
|
async function handoffRedirect(url, res) {
|
|
1081
2393
|
const state = url.searchParams.get("state") || "";
|
|
1082
2394
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(state) || !handoff.verify(url.searchParams.get("nonce"), url.searchParams.get("sig"))) {
|
|
@@ -1099,6 +2411,10 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1099
2411
|
const wss = new WebSocketServer({ noServer: true });
|
|
1100
2412
|
server.on("upgrade", async (req, socket, head) => {
|
|
1101
2413
|
const url = new URL(req.url, "http://localhost");
|
|
2414
|
+
if (url.pathname === AGENTS_WS_PATH && authorized(req)) {
|
|
2415
|
+
wss.handleUpgrade(req, socket, head, watchAgents);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
1102
2418
|
if (url.pathname !== TERMINAL_WS_PATH || !authorized(req)) {
|
|
1103
2419
|
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1104
2420
|
return;
|
|
@@ -1117,7 +2433,8 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1117
2433
|
cols: parseInt(url.searchParams.get("cols"), 10) || 80,
|
|
1118
2434
|
rows: parseInt(url.searchParams.get("rows"), 10) || 24,
|
|
1119
2435
|
// New shells start in the shared folder unless a subfolder is asked for.
|
|
1120
|
-
cwd: await sandbox.resolve(url.searchParams.get("cwd") || sandbox.root)
|
|
2436
|
+
cwd: await sandbox.resolve(url.searchParams.get("cwd") || sandbox.root),
|
|
2437
|
+
env: terminalEnv()
|
|
1121
2438
|
});
|
|
1122
2439
|
} catch (e) {
|
|
1123
2440
|
socket.end(`HTTP/1.1 ${e.status || (e.code === "ENOENT" ? 404 : 500)} Error\r
|
|
@@ -1128,21 +2445,42 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null,
|
|
|
1128
2445
|
}
|
|
1129
2446
|
wss.handleUpgrade(req, socket, head, (ws) => session.attach(ws));
|
|
1130
2447
|
});
|
|
2448
|
+
function watchAgents(ws) {
|
|
2449
|
+
const send = (msg) => ws.send(JSON.stringify(msg));
|
|
2450
|
+
send({ type: "snapshot", sessions: agents.sessions(), requests: agents.requests() });
|
|
2451
|
+
agents.on("message", send);
|
|
2452
|
+
ws.on("message", (data, isBinary) => {
|
|
2453
|
+
if (isBinary) return;
|
|
2454
|
+
let msg;
|
|
2455
|
+
try {
|
|
2456
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
2457
|
+
} catch {
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (msg.type === AgentsClientMessage.presence) foregroundAt = msg.foreground === true ? Date.now() : 0;
|
|
2461
|
+
});
|
|
2462
|
+
ws.on("close", () => agents.off("message", send));
|
|
2463
|
+
}
|
|
1131
2464
|
server.on("close", () => {
|
|
2465
|
+
clearInterval(sweep);
|
|
1132
2466
|
sessions.killAll();
|
|
1133
2467
|
for (const proxy of proxies.values()) proxy.close();
|
|
1134
2468
|
});
|
|
1135
2469
|
server.killAllSessions = () => sessions.killAll();
|
|
2470
|
+
server.agents = agents;
|
|
2471
|
+
server.clientInForeground = clientInForeground;
|
|
2472
|
+
server.push = push2;
|
|
2473
|
+
server.agentsUploadPath = (id) => uploadPath(id);
|
|
1136
2474
|
return server;
|
|
1137
2475
|
}
|
|
1138
2476
|
|
|
1139
2477
|
// src/devtunnel.js
|
|
1140
|
-
import { execFile as
|
|
2478
|
+
import { execFile as execFile6 } from "node:child_process";
|
|
1141
2479
|
import { createHash } from "node:crypto";
|
|
1142
|
-
import
|
|
1143
|
-
import
|
|
1144
|
-
import { promisify } from "node:util";
|
|
1145
|
-
var
|
|
2480
|
+
import fs8 from "node:fs";
|
|
2481
|
+
import path6 from "node:path";
|
|
2482
|
+
import { promisify as promisify3 } from "node:util";
|
|
2483
|
+
var run4 = promisify3(execFile6);
|
|
1146
2484
|
var DOWNLOAD_BASE = "https://tunnelsassetsprod.blob.core.windows.net/cli";
|
|
1147
2485
|
var BUILDS = {
|
|
1148
2486
|
"darwin-arm64": "osx-arm64-devtunnel",
|
|
@@ -1154,15 +2492,15 @@ var MICROSOFT_TEAM_ID = "UBF8T346G9";
|
|
|
1154
2492
|
var REFRESH_INTERVAL_MS = 24 * 36e5;
|
|
1155
2493
|
function executable(file) {
|
|
1156
2494
|
try {
|
|
1157
|
-
|
|
1158
|
-
return
|
|
2495
|
+
fs8.accessSync(file, fs8.constants.X_OK);
|
|
2496
|
+
return fs8.statSync(file).isFile();
|
|
1159
2497
|
} catch {
|
|
1160
2498
|
return false;
|
|
1161
2499
|
}
|
|
1162
2500
|
}
|
|
1163
2501
|
function onPath(name) {
|
|
1164
|
-
for (const dir of (process.env.PATH || "").split(
|
|
1165
|
-
if (dir && executable(
|
|
2502
|
+
for (const dir of (process.env.PATH || "").split(path6.delimiter)) {
|
|
2503
|
+
if (dir && executable(path6.join(dir, name))) return path6.join(dir, name);
|
|
1166
2504
|
}
|
|
1167
2505
|
return null;
|
|
1168
2506
|
}
|
|
@@ -1180,11 +2518,11 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1180
2518
|
`No devtunnel build for ${process.platform}-${process.arch}. Install it yourself: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started`
|
|
1181
2519
|
);
|
|
1182
2520
|
}
|
|
1183
|
-
const bin =
|
|
1184
|
-
const metaFile =
|
|
2521
|
+
const bin = path6.join(dir, "devtunnel");
|
|
2522
|
+
const metaFile = path6.join(dir, "devtunnel.json");
|
|
1185
2523
|
let meta = {};
|
|
1186
2524
|
try {
|
|
1187
|
-
meta = JSON.parse(
|
|
2525
|
+
meta = JSON.parse(fs8.readFileSync(metaFile, "utf8"));
|
|
1188
2526
|
} catch {
|
|
1189
2527
|
}
|
|
1190
2528
|
const installed = executable(bin);
|
|
@@ -1195,7 +2533,7 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1195
2533
|
const head = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
|
|
1196
2534
|
const lastModified = head.headers.get("last-modified");
|
|
1197
2535
|
if (head.ok && lastModified && lastModified === meta.lastModified) {
|
|
1198
|
-
|
|
2536
|
+
fs8.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
|
|
1199
2537
|
return bin;
|
|
1200
2538
|
}
|
|
1201
2539
|
} catch {
|
|
@@ -1206,12 +2544,12 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
1206
2544
|
return bin;
|
|
1207
2545
|
}
|
|
1208
2546
|
async function download(url, bin, metaFile, progress) {
|
|
1209
|
-
|
|
2547
|
+
fs8.mkdirSync(path6.dirname(bin), { recursive: true, mode: 448 });
|
|
1210
2548
|
const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
|
|
1211
2549
|
if (!res.ok || !res.body) throw new Error(`Downloading devtunnel failed: HTTP ${res.status} from ${url}`);
|
|
1212
2550
|
const total = Number(res.headers.get("content-length")) || 0;
|
|
1213
2551
|
const tmp = `${bin}.${process.pid}.download`;
|
|
1214
|
-
const out =
|
|
2552
|
+
const out = fs8.createWriteStream(tmp, { mode: 493 });
|
|
1215
2553
|
const md5 = createHash("md5");
|
|
1216
2554
|
let received = 0;
|
|
1217
2555
|
try {
|
|
@@ -1224,27 +2562,126 @@ async function download(url, bin, metaFile, progress) {
|
|
|
1224
2562
|
await new Promise((resolve, reject) => out.end((e) => e ? reject(e) : resolve()));
|
|
1225
2563
|
if (total && received !== total) throw new Error(`devtunnel download was cut short (${received} of ${total} bytes)`);
|
|
1226
2564
|
await verify(tmp, md5.digest("base64"), res.headers.get("content-md5"));
|
|
1227
|
-
|
|
1228
|
-
|
|
2565
|
+
fs8.chmodSync(tmp, 493);
|
|
2566
|
+
fs8.renameSync(tmp, bin);
|
|
1229
2567
|
} catch (e) {
|
|
1230
2568
|
out.destroy();
|
|
1231
|
-
|
|
2569
|
+
fs8.rmSync(tmp, { force: true });
|
|
1232
2570
|
throw e;
|
|
1233
2571
|
}
|
|
1234
|
-
|
|
2572
|
+
fs8.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
|
|
1235
2573
|
}
|
|
1236
2574
|
async function verify(file, md5, expectedMd5) {
|
|
1237
2575
|
if (expectedMd5 && md5 !== expectedMd5) throw new Error("devtunnel download is corrupt (MD5 mismatch)");
|
|
1238
2576
|
if (process.platform !== "darwin") return;
|
|
1239
2577
|
try {
|
|
1240
|
-
await
|
|
1241
|
-
const { stderr } = await
|
|
2578
|
+
await run4("codesign", ["--verify", "--strict", file]);
|
|
2579
|
+
const { stderr } = await run4("codesign", ["-dv", file]);
|
|
1242
2580
|
if (!stderr.includes(`TeamIdentifier=${MICROSOFT_TEAM_ID}`)) throw new Error("not signed by Microsoft");
|
|
1243
2581
|
} catch (e) {
|
|
1244
2582
|
throw new Error(`devtunnel download failed its signature check: ${e.message}`);
|
|
1245
2583
|
}
|
|
1246
2584
|
}
|
|
1247
2585
|
|
|
2586
|
+
// src/hook.js
|
|
2587
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2588
|
+
import fs9 from "node:fs";
|
|
2589
|
+
import http3 from "node:http";
|
|
2590
|
+
import path7 from "node:path";
|
|
2591
|
+
var CONNECT_TIMEOUT = 1e3;
|
|
2592
|
+
var EVENT_TIMEOUT = 5e3;
|
|
2593
|
+
function ensureHookSecret(dir) {
|
|
2594
|
+
const file = path7.join(dir, "hook-secret");
|
|
2595
|
+
try {
|
|
2596
|
+
const secret2 = fs9.readFileSync(file, "utf8").trim();
|
|
2597
|
+
if (secret2) return secret2;
|
|
2598
|
+
} catch {
|
|
2599
|
+
}
|
|
2600
|
+
fs9.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2601
|
+
const secret = randomBytes2(32).toString("base64url");
|
|
2602
|
+
fs9.writeFileSync(file, secret, { mode: 384 });
|
|
2603
|
+
return secret;
|
|
2604
|
+
}
|
|
2605
|
+
function writeHookLauncher(dir, { node = process.execPath, script }) {
|
|
2606
|
+
const bin = path7.join(dir, "bin");
|
|
2607
|
+
fs9.mkdirSync(bin, { recursive: true, mode: 448 });
|
|
2608
|
+
const quote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
2609
|
+
const file = path7.join(bin, "hook");
|
|
2610
|
+
fs9.writeFileSync(file, `#!/bin/sh
|
|
2611
|
+
exec ${quote(node)} ${quote(script)} hook "$@"
|
|
2612
|
+
`, { mode: 493 });
|
|
2613
|
+
fs9.chmodSync(file, 493);
|
|
2614
|
+
return file;
|
|
2615
|
+
}
|
|
2616
|
+
function readHookSecret(dir) {
|
|
2617
|
+
try {
|
|
2618
|
+
return fs9.readFileSync(path7.join(dir, "hook-secret"), "utf8").trim() || null;
|
|
2619
|
+
} catch {
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
function real(p) {
|
|
2624
|
+
try {
|
|
2625
|
+
return fs9.realpathSync(p);
|
|
2626
|
+
} catch {
|
|
2627
|
+
return path7.resolve(p);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
var within = (dir, root2) => dir === root2 || dir.startsWith(root2.endsWith(path7.sep) ? root2 : root2 + path7.sep);
|
|
2631
|
+
function hookTarget(agents, cwd, env = process.env) {
|
|
2632
|
+
const port2 = parseInt(env.CODEREMOTE_AGENT_PORT, 10);
|
|
2633
|
+
if (port2 && agents.some((a) => a.port === port2)) return { port: port2, terminal: env.CODEREMOTE_TERM_ID || null };
|
|
2634
|
+
if (typeof cwd !== "string" || !cwd) return null;
|
|
2635
|
+
const dir = real(cwd);
|
|
2636
|
+
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];
|
|
2637
|
+
return match ? { port: match.port, terminal: null } : null;
|
|
2638
|
+
}
|
|
2639
|
+
function deliverHook({ port: port2, terminal, agent, secret, body, wait }) {
|
|
2640
|
+
return new Promise((resolve) => {
|
|
2641
|
+
const query = new URLSearchParams({ agent });
|
|
2642
|
+
if (terminal) query.set("terminal", terminal);
|
|
2643
|
+
const req = http3.request({
|
|
2644
|
+
host: "127.0.0.1",
|
|
2645
|
+
port: port2,
|
|
2646
|
+
method: "POST",
|
|
2647
|
+
path: `/api/agents/hook?${query}`,
|
|
2648
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), [HOOK_SECRET_HEADER]: secret }
|
|
2649
|
+
});
|
|
2650
|
+
const fail = () => {
|
|
2651
|
+
req.destroy();
|
|
2652
|
+
resolve("");
|
|
2653
|
+
};
|
|
2654
|
+
const connect = setTimeout(fail, CONNECT_TIMEOUT);
|
|
2655
|
+
const overall = wait ? null : setTimeout(fail, EVENT_TIMEOUT);
|
|
2656
|
+
req.on("socket", (socket) => socket.once("connect", () => clearTimeout(connect)));
|
|
2657
|
+
req.on("error", () => resolve(""));
|
|
2658
|
+
req.on("response", (res) => {
|
|
2659
|
+
const chunks = [];
|
|
2660
|
+
res.on("data", (c) => chunks.push(c));
|
|
2661
|
+
res.on("end", () => {
|
|
2662
|
+
clearTimeout(overall);
|
|
2663
|
+
resolve(res.statusCode === 200 ? Buffer.concat(chunks).toString("utf8") : "");
|
|
2664
|
+
});
|
|
2665
|
+
res.on("error", () => resolve(""));
|
|
2666
|
+
});
|
|
2667
|
+
req.end(body);
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
async function runHook({ input, agents, secret, agent = "claude", env = process.env }) {
|
|
2671
|
+
if (!secret) return "";
|
|
2672
|
+
let event;
|
|
2673
|
+
try {
|
|
2674
|
+
event = JSON.parse(input);
|
|
2675
|
+
} catch {
|
|
2676
|
+
return "";
|
|
2677
|
+
}
|
|
2678
|
+
const target = hookTarget(agents, event?.cwd, env);
|
|
2679
|
+
if (!target) return "";
|
|
2680
|
+
const wait = event.hook_event_name === "PermissionRequest";
|
|
2681
|
+
const out = await deliverHook({ ...target, agent, secret, body: input, wait });
|
|
2682
|
+
return wait ? out.trim() : "";
|
|
2683
|
+
}
|
|
2684
|
+
|
|
1248
2685
|
// src/preview.js
|
|
1249
2686
|
import { spawn } from "node:child_process";
|
|
1250
2687
|
var HOST_READY_TIMEOUT_MS = 3e4;
|
|
@@ -1428,20 +2865,41 @@ ${log2}`));
|
|
|
1428
2865
|
}
|
|
1429
2866
|
};
|
|
1430
2867
|
|
|
2868
|
+
// src/push-store.js
|
|
2869
|
+
import fs10 from "node:fs";
|
|
2870
|
+
import path8 from "node:path";
|
|
2871
|
+
function pushStore(dir, port2) {
|
|
2872
|
+
const file = path8.join(dir, `${port2}.json`);
|
|
2873
|
+
return {
|
|
2874
|
+
load() {
|
|
2875
|
+
try {
|
|
2876
|
+
const devices = JSON.parse(fs10.readFileSync(file, "utf8"));
|
|
2877
|
+
return Array.isArray(devices) ? devices : [];
|
|
2878
|
+
} catch {
|
|
2879
|
+
return [];
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
save(devices) {
|
|
2883
|
+
fs10.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2884
|
+
fs10.writeFileSync(file, JSON.stringify(devices, null, 2), { mode: 384 });
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
|
|
1431
2889
|
// src/registry.js
|
|
1432
|
-
import
|
|
1433
|
-
import
|
|
2890
|
+
import fs11 from "node:fs";
|
|
2891
|
+
import path9 from "node:path";
|
|
1434
2892
|
var AgentRegistry = class {
|
|
1435
2893
|
constructor(dir) {
|
|
1436
2894
|
this.dir = dir;
|
|
1437
2895
|
}
|
|
1438
2896
|
register({ port: port2, root: root2, url }) {
|
|
1439
|
-
|
|
2897
|
+
fs11.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
1440
2898
|
const file = this.#file(port2);
|
|
1441
|
-
|
|
2899
|
+
fs11.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
|
|
1442
2900
|
const cleanup = () => {
|
|
1443
2901
|
try {
|
|
1444
|
-
if (JSON.parse(
|
|
2902
|
+
if (JSON.parse(fs11.readFileSync(file, "utf8")).pid === process.pid) fs11.rmSync(file);
|
|
1445
2903
|
} catch {
|
|
1446
2904
|
}
|
|
1447
2905
|
};
|
|
@@ -1452,25 +2910,25 @@ var AgentRegistry = class {
|
|
|
1452
2910
|
list() {
|
|
1453
2911
|
let names = [];
|
|
1454
2912
|
try {
|
|
1455
|
-
names =
|
|
2913
|
+
names = fs11.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
|
|
1456
2914
|
} catch {
|
|
1457
2915
|
return [];
|
|
1458
2916
|
}
|
|
1459
2917
|
const agents = [];
|
|
1460
2918
|
for (const name of names) {
|
|
1461
|
-
const file =
|
|
2919
|
+
const file = path9.join(this.dir, name);
|
|
1462
2920
|
try {
|
|
1463
|
-
const agent = JSON.parse(
|
|
2921
|
+
const agent = JSON.parse(fs11.readFileSync(file, "utf8"));
|
|
1464
2922
|
process.kill(agent.pid, 0);
|
|
1465
2923
|
agents.push(agent);
|
|
1466
2924
|
} catch {
|
|
1467
|
-
|
|
2925
|
+
fs11.rmSync(file, { force: true });
|
|
1468
2926
|
}
|
|
1469
2927
|
}
|
|
1470
2928
|
return agents.sort((a, b) => a.port - b.port);
|
|
1471
2929
|
}
|
|
1472
2930
|
#file(port2) {
|
|
1473
|
-
return
|
|
2931
|
+
return path9.join(this.dir, `${port2}.json`);
|
|
1474
2932
|
}
|
|
1475
2933
|
};
|
|
1476
2934
|
|
|
@@ -1534,8 +2992,8 @@ function tunnelStatus({ devtunnel: devtunnel2, tunnelId, account, anonymous: ano
|
|
|
1534
2992
|
}
|
|
1535
2993
|
|
|
1536
2994
|
// src/update.js
|
|
1537
|
-
import
|
|
1538
|
-
import
|
|
2995
|
+
import fs12 from "node:fs";
|
|
2996
|
+
import path10 from "node:path";
|
|
1539
2997
|
var LATEST_URL = "https://registry.npmjs.org/coderemote/latest";
|
|
1540
2998
|
var CHECK_INTERVAL_MS = 24 * 36e5;
|
|
1541
2999
|
var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
@@ -1559,10 +3017,10 @@ async function fetchLatest({ fetch: fetch2 = globalThis.fetch, timeoutMs = 3e3 }
|
|
|
1559
3017
|
}
|
|
1560
3018
|
}
|
|
1561
3019
|
async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }) {
|
|
1562
|
-
const file =
|
|
3020
|
+
const file = path10.join(dir, "update.json");
|
|
1563
3021
|
let cache = {};
|
|
1564
3022
|
try {
|
|
1565
|
-
cache = JSON.parse(
|
|
3023
|
+
cache = JSON.parse(fs12.readFileSync(file, "utf8"));
|
|
1566
3024
|
} catch {
|
|
1567
3025
|
}
|
|
1568
3026
|
let latest = cache.latest;
|
|
@@ -1571,8 +3029,8 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
|
|
|
1571
3029
|
if (fetched) {
|
|
1572
3030
|
latest = fetched;
|
|
1573
3031
|
try {
|
|
1574
|
-
|
|
1575
|
-
|
|
3032
|
+
fs12.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
3033
|
+
fs12.writeFileSync(file, JSON.stringify({ latest, checkedAt: now }));
|
|
1576
3034
|
} catch {
|
|
1577
3035
|
}
|
|
1578
3036
|
}
|
|
@@ -1580,7 +3038,7 @@ async function availableUpdate({ current, dir, fetch: fetch2, now = Date.now() }
|
|
|
1580
3038
|
return isNewer(latest, current) ? latest : null;
|
|
1581
3039
|
}
|
|
1582
3040
|
function installCommand(installedAt) {
|
|
1583
|
-
const p = installedAt.split(
|
|
3041
|
+
const p = installedAt.split(path10.sep).join("/");
|
|
1584
3042
|
if (p.includes("/pnpm/") || p.includes("/.pnpm/")) return ["pnpm", ["add", "-g", "coderemote@latest"]];
|
|
1585
3043
|
if (p.includes("/.bun/")) return ["bun", ["add", "-g", "coderemote@latest"]];
|
|
1586
3044
|
if (p.includes("/.yarn/") || p.includes("/yarn/global/")) return ["yarn", ["global", "add", "coderemote@latest"]];
|
|
@@ -1590,7 +3048,7 @@ function installCommand(installedAt) {
|
|
|
1590
3048
|
// package.json
|
|
1591
3049
|
var package_default = {
|
|
1592
3050
|
name: "coderemote",
|
|
1593
|
-
version: "0.3.
|
|
3051
|
+
version: "0.3.5",
|
|
1594
3052
|
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.",
|
|
1595
3053
|
keywords: [
|
|
1596
3054
|
"remote",
|
|
@@ -1869,15 +3327,15 @@ var Spinner = class {
|
|
|
1869
3327
|
};
|
|
1870
3328
|
|
|
1871
3329
|
// src/vscode-tunnel.js
|
|
1872
|
-
import { execFile as
|
|
1873
|
-
import { promisify as
|
|
1874
|
-
var
|
|
3330
|
+
import { execFile as execFile7, spawn as spawn2 } from "node:child_process";
|
|
3331
|
+
import { promisify as promisify4 } from "node:util";
|
|
3332
|
+
var run5 = promisify4(execFile7);
|
|
1875
3333
|
function findCode() {
|
|
1876
3334
|
return process.env.CODEREMOTE_CODE || onPath("code");
|
|
1877
3335
|
}
|
|
1878
3336
|
async function codeTunnelSignedIn(code) {
|
|
1879
3337
|
try {
|
|
1880
|
-
const { stdout } = await
|
|
3338
|
+
const { stdout } = await run5(code, ["tunnel", "user", "show"]);
|
|
1881
3339
|
return /^logged in/im.test(stdout);
|
|
1882
3340
|
} catch {
|
|
1883
3341
|
return false;
|
|
@@ -1919,24 +3377,28 @@ ${log2}`));
|
|
|
1919
3377
|
}
|
|
1920
3378
|
|
|
1921
3379
|
// bin/coderemote.js
|
|
1922
|
-
var
|
|
1923
|
-
var CONFIG_DIR =
|
|
1924
|
-
var LEGACY_CONFIG_DIR =
|
|
1925
|
-
if (!
|
|
1926
|
-
var CONFIG_FILE =
|
|
3380
|
+
var run6 = promisify5(execFile8);
|
|
3381
|
+
var CONFIG_DIR = path11.join(os6.homedir(), ".coderemote");
|
|
3382
|
+
var LEGACY_CONFIG_DIR = path11.join(os6.homedir(), ".vsremote");
|
|
3383
|
+
if (!fs13.existsSync(CONFIG_DIR) && fs13.existsSync(LEGACY_CONFIG_DIR)) fs13.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
|
|
3384
|
+
var CONFIG_FILE = path11.join(CONFIG_DIR, "config.json");
|
|
3385
|
+
var PUSH_DIR = path11.join(CONFIG_DIR, "push");
|
|
3386
|
+
var UPLOADS_DIR = path11.join(CONFIG_DIR, "uploads");
|
|
1927
3387
|
var DEFAULT_PORT = 31580;
|
|
1928
3388
|
var VERSION = package_default.version;
|
|
1929
|
-
var PACKAGED =
|
|
3389
|
+
var PACKAGED = path11.basename(path11.dirname(fileURLToPath(import.meta.url))) === "dist";
|
|
1930
3390
|
var spinner = new Spinner();
|
|
1931
3391
|
function loadConfig() {
|
|
1932
3392
|
let cfg2 = {};
|
|
1933
3393
|
try {
|
|
1934
|
-
cfg2 = JSON.parse(
|
|
3394
|
+
cfg2 = JSON.parse(fs13.readFileSync(CONFIG_FILE, "utf8"));
|
|
1935
3395
|
} catch {
|
|
1936
3396
|
}
|
|
1937
3397
|
if (!cfg2.token) {
|
|
1938
|
-
cfg2.token =
|
|
3398
|
+
cfg2.token = randomBytes3(32).toString("base64url");
|
|
1939
3399
|
saveConfig(cfg2);
|
|
3400
|
+
fs13.rmSync(PUSH_DIR, { recursive: true, force: true });
|
|
3401
|
+
fs13.rmSync(UPLOADS_DIR, { recursive: true, force: true });
|
|
1940
3402
|
}
|
|
1941
3403
|
if (cfg2.tunnelId && !cfg2.tunnels) {
|
|
1942
3404
|
cfg2.tunnels = { [DEFAULT_PORT]: cfg2.tunnelId };
|
|
@@ -1945,8 +3407,8 @@ function loadConfig() {
|
|
|
1945
3407
|
return cfg2;
|
|
1946
3408
|
}
|
|
1947
3409
|
function saveConfig(cfg2) {
|
|
1948
|
-
|
|
1949
|
-
|
|
3410
|
+
fs13.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
3411
|
+
fs13.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
|
|
1950
3412
|
}
|
|
1951
3413
|
function portFree(port2) {
|
|
1952
3414
|
return new Promise((resolve) => {
|
|
@@ -1956,7 +3418,20 @@ function portFree(port2) {
|
|
|
1956
3418
|
});
|
|
1957
3419
|
}
|
|
1958
3420
|
function listen(token, port2, root2, tunnelToken, preview, status2) {
|
|
1959
|
-
const
|
|
3421
|
+
const hookSecret = ensureHookSecret(CONFIG_DIR);
|
|
3422
|
+
writeHookLauncher(CONFIG_DIR, { script: fs13.realpathSync(process.argv[1]) });
|
|
3423
|
+
const server = createAgentServer({
|
|
3424
|
+
token,
|
|
3425
|
+
root: root2,
|
|
3426
|
+
tunnelToken,
|
|
3427
|
+
preview,
|
|
3428
|
+
tunnelStatus: status2,
|
|
3429
|
+
hookSecret,
|
|
3430
|
+
// This very CLI, so coding agents in the app's terminals report through the same version.
|
|
3431
|
+
hookBin: fs13.realpathSync(process.argv[1]),
|
|
3432
|
+
pushStore: pushStore(PUSH_DIR, port2),
|
|
3433
|
+
uploadsDir: UPLOADS_DIR
|
|
3434
|
+
});
|
|
1960
3435
|
return new Promise((resolve, reject) => {
|
|
1961
3436
|
server.once("error", reject);
|
|
1962
3437
|
server.listen(port2, "127.0.0.1", () => resolve(server));
|
|
@@ -1966,7 +3441,7 @@ var DEVTUNNEL = "devtunnel";
|
|
|
1966
3441
|
async function resolveDevtunnel() {
|
|
1967
3442
|
let installing = false;
|
|
1968
3443
|
DEVTUNNEL = await ensureDevtunnel({
|
|
1969
|
-
dir:
|
|
3444
|
+
dir: path11.join(CONFIG_DIR, "bin"),
|
|
1970
3445
|
progress: (detail2) => {
|
|
1971
3446
|
if (!installing) {
|
|
1972
3447
|
installing = true;
|
|
@@ -1979,7 +3454,7 @@ async function resolveDevtunnel() {
|
|
|
1979
3454
|
}
|
|
1980
3455
|
async function devtunnel(...args) {
|
|
1981
3456
|
try {
|
|
1982
|
-
const { stdout } = await
|
|
3457
|
+
const { stdout } = await run6(DEVTUNNEL, args);
|
|
1983
3458
|
return { ok: true, out: stdout };
|
|
1984
3459
|
} catch (e) {
|
|
1985
3460
|
return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` || e.message };
|
|
@@ -2037,7 +3512,7 @@ ${access.out}`);
|
|
|
2037
3512
|
if (!/not found/i.test(updated.out)) throw new Error(`devtunnel update failed:
|
|
2038
3513
|
${updated.out}`);
|
|
2039
3514
|
}
|
|
2040
|
-
const name = port2 === DEFAULT_PORT ?
|
|
3515
|
+
const name = port2 === DEFAULT_PORT ? os6.hostname() : `${os6.hostname()} :${port2}`;
|
|
2041
3516
|
const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${name}`, "--labels", MACHINE_LABEL, "--json"];
|
|
2042
3517
|
if (anonymous2) createArgs.push("--allow-anonymous");
|
|
2043
3518
|
const created = await devtunnel(...createArgs);
|
|
@@ -2142,6 +3617,9 @@ var USAGE = `Usage: coderemote <command> [options]
|
|
|
2142
3617
|
request <ID> [PORT] headers and bodies of one request (bodies up to 64 KB are kept);
|
|
2143
3618
|
auth headers, cookies and token-like fields are masked unless
|
|
2144
3619
|
--show-secrets is given; --json on either for machine-readable output
|
|
3620
|
+
setup claude let the app follow Claude Code sessions and answer their prompts
|
|
3621
|
+
(installs the Code Remote plugin into Claude Code); --remove undoes it
|
|
3622
|
+
setup codex the same for Codex (then trust its hooks once with /hooks in Codex)
|
|
2145
3623
|
version print this coderemote's version (also --version, -v)
|
|
2146
3624
|
login sign in to Microsoft Dev Tunnels with GitHub (up does this when needed);
|
|
2147
3625
|
--microsoft for a Microsoft account, --device-code without a browser
|
|
@@ -2163,9 +3641,9 @@ var flag = (name) => {
|
|
|
2163
3641
|
};
|
|
2164
3642
|
var anonymous = rest.includes("--allow-anonymous") || rest.includes("--no-auth");
|
|
2165
3643
|
var port = flag("--port") ? parseInt(flag("--port"), 10) : DEFAULT_PORT;
|
|
2166
|
-
var root =
|
|
3644
|
+
var root = path11.resolve(flag("--root") ?? process.cwd());
|
|
2167
3645
|
var cfg = loadConfig();
|
|
2168
|
-
var registry = new AgentRegistry(
|
|
3646
|
+
var registry = new AgentRegistry(path11.join(CONFIG_DIR, "agents"));
|
|
2169
3647
|
async function start({ tunnel }) {
|
|
2170
3648
|
const agents = registry.list();
|
|
2171
3649
|
if (flag("--port")) {
|
|
@@ -2200,7 +3678,7 @@ async function start({ tunnel }) {
|
|
|
2200
3678
|
const preview = tunnel ? new PreviewTunnel({
|
|
2201
3679
|
devtunnel,
|
|
2202
3680
|
command: () => DEVTUNNEL,
|
|
2203
|
-
description: `coderemote preview ${
|
|
3681
|
+
description: `coderemote preview ${os6.hostname()}`,
|
|
2204
3682
|
leftover: cfg.previewTunnels?.[port],
|
|
2205
3683
|
remember: (id) => {
|
|
2206
3684
|
cfg.previewTunnels = { ...cfg.previewTunnels, [port]: id };
|
|
@@ -2263,6 +3741,105 @@ async function start({ tunnel }) {
|
|
|
2263
3741
|
});
|
|
2264
3742
|
return { url, access };
|
|
2265
3743
|
}
|
|
3744
|
+
var PLUGIN = "coderemote@coderemote";
|
|
3745
|
+
async function claudeCli(...args) {
|
|
3746
|
+
try {
|
|
3747
|
+
return (await run6("claude", args, { timeout: 12e4 })).stdout.trim();
|
|
3748
|
+
} catch (e) {
|
|
3749
|
+
if (e.code === "ENOENT") return null;
|
|
3750
|
+
throw new Error((e.stderr || e.stdout || e.message).trim());
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
async function setupClaude() {
|
|
3754
|
+
const version = await claudeCli("--version");
|
|
3755
|
+
if (version === null) {
|
|
3756
|
+
console.error("Claude Code is required: install it from https://claude.com/claude-code, then run this again.");
|
|
3757
|
+
process.exit(1);
|
|
3758
|
+
}
|
|
3759
|
+
if (rest.includes("--remove")) {
|
|
3760
|
+
await claudeCli("plugin", "uninstall", PLUGIN).catch((e) => console.error(e.message));
|
|
3761
|
+
await claudeCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3762
|
+
});
|
|
3763
|
+
console.log("Removed the Code Remote plugin. New Claude Code sessions no longer reach the app.");
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
const source = flag("--marketplace") ?? "CodeRemoteApp/coderemote";
|
|
3767
|
+
spinner.start(`Installing the Code Remote plugin into ${version}`);
|
|
3768
|
+
try {
|
|
3769
|
+
await claudeCli("plugin", "marketplace", "add", source);
|
|
3770
|
+
await claudeCli("plugin", "marketplace", "update", "coderemote").catch(() => {
|
|
3771
|
+
});
|
|
3772
|
+
await claudeCli("plugin", "install", PLUGIN);
|
|
3773
|
+
await claudeCli("plugin", "update", PLUGIN).catch(() => {
|
|
3774
|
+
});
|
|
3775
|
+
} catch (e) {
|
|
3776
|
+
spinner.fail();
|
|
3777
|
+
console.error(e.message);
|
|
3778
|
+
process.exit(1);
|
|
3779
|
+
}
|
|
3780
|
+
spinner.succeed();
|
|
3781
|
+
console.log("\n New Claude Code sessions in a folder shared with `coderemote up` now show in the");
|
|
3782
|
+
console.log(" app\u2019s Agents tab, where you can answer their permission prompts and questions.");
|
|
3783
|
+
console.log(" Sessions already running pick it up after a restart. Undo: coderemote setup claude --remove\n");
|
|
3784
|
+
}
|
|
3785
|
+
var CODEX_PLUGIN = "coderemote@coderemote";
|
|
3786
|
+
async function codexCli(...args) {
|
|
3787
|
+
try {
|
|
3788
|
+
return (await run6("codex", args, { timeout: 12e4 })).stdout.trim();
|
|
3789
|
+
} catch (e) {
|
|
3790
|
+
if (e.code === "ENOENT") return null;
|
|
3791
|
+
throw new Error((e.stderr || e.stdout || e.message).trim());
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
async function setupCodex() {
|
|
3795
|
+
const version = await codexCli("--version");
|
|
3796
|
+
if (version === null) {
|
|
3797
|
+
console.error("Codex is required: install it (npm install -g @openai/codex), then run this again.");
|
|
3798
|
+
process.exit(1);
|
|
3799
|
+
}
|
|
3800
|
+
const hooks = (await codexCli("features", "list")).split("\n").find((line) => /^hooks\s/.test(line));
|
|
3801
|
+
if (!hooks) {
|
|
3802
|
+
console.error(`${version} has no hooks. Update Codex to 0.139 or later (codex update), then run this again.`);
|
|
3803
|
+
process.exit(1);
|
|
3804
|
+
}
|
|
3805
|
+
if (rest.includes("--remove")) {
|
|
3806
|
+
await codexCli("plugin", "remove", CODEX_PLUGIN).catch((e) => console.error(e.message));
|
|
3807
|
+
await codexCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3808
|
+
});
|
|
3809
|
+
console.log("Removed the Code Remote plugin. New Codex sessions no longer reach the app.");
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
const given = flag("--marketplace");
|
|
3813
|
+
const source = given ?? "CodeRemoteApp/coderemote";
|
|
3814
|
+
spinner.start(`Installing the Code Remote plugin into ${version}`);
|
|
3815
|
+
try {
|
|
3816
|
+
if (/\bfalse\s*$/.test(hooks)) await codexCli("features", "enable", "hooks");
|
|
3817
|
+
const known = /^coderemote\s/m.test(await codexCli("plugin", "marketplace", "list"));
|
|
3818
|
+
if (known && given) {
|
|
3819
|
+
await codexCli("plugin", "marketplace", "remove", "coderemote").catch(() => {
|
|
3820
|
+
});
|
|
3821
|
+
await codexCli("plugin", "marketplace", "add", source);
|
|
3822
|
+
} else if (known) {
|
|
3823
|
+
await codexCli("plugin", "marketplace", "upgrade", "coderemote").catch(() => {
|
|
3824
|
+
});
|
|
3825
|
+
} else {
|
|
3826
|
+
await codexCli("plugin", "marketplace", "add", source);
|
|
3827
|
+
}
|
|
3828
|
+
await codexCli("plugin", "add", CODEX_PLUGIN);
|
|
3829
|
+
} catch (e) {
|
|
3830
|
+
spinner.fail();
|
|
3831
|
+
console.error(e.message);
|
|
3832
|
+
if (/not found in marketplace/.test(e.message) && !given) {
|
|
3833
|
+
console.error("Your coderemote may be newer than the published plugin; point at a checkout with --marketplace <dir>.");
|
|
3834
|
+
}
|
|
3835
|
+
process.exit(1);
|
|
3836
|
+
}
|
|
3837
|
+
spinner.succeed();
|
|
3838
|
+
console.log("\n One more step, once: open Codex, type /hooks and trust the Code Remote hooks.");
|
|
3839
|
+
console.log(" Codex runs a plugin's hooks only after you have reviewed them.");
|
|
3840
|
+
console.log(" Then new Codex sessions in a folder shared with `coderemote up` show in the app.");
|
|
3841
|
+
console.log(" Undo: coderemote setup codex --remove\n");
|
|
3842
|
+
}
|
|
2266
3843
|
async function agentApi(agent, method, route, query = {}) {
|
|
2267
3844
|
const url = new URL(route, `http://127.0.0.1:${agent.port}`);
|
|
2268
3845
|
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
|
@@ -2283,7 +3860,7 @@ function currentAgent() {
|
|
|
2283
3860
|
const agents = registry.list();
|
|
2284
3861
|
if (flag("--port")) return agents.find((a) => a.port === port);
|
|
2285
3862
|
const here = process.cwd();
|
|
2286
|
-
const containing = agents.filter((a) => here === a.root || here.startsWith(a.root +
|
|
3863
|
+
const containing = agents.filter((a) => here === a.root || here.startsWith(a.root + path11.sep)).sort((a, b) => b.root.length - a.root.length);
|
|
2287
3864
|
return containing[0] ?? (agents.length === 1 ? agents[0] : void 0);
|
|
2288
3865
|
}
|
|
2289
3866
|
async function previewPorts(agent, given) {
|
|
@@ -2307,6 +3884,32 @@ function ago(ms) {
|
|
|
2307
3884
|
return `${Math.round(s / 86400)}d`;
|
|
2308
3885
|
}
|
|
2309
3886
|
switch (cmd) {
|
|
3887
|
+
case "hook": {
|
|
3888
|
+
let out = "";
|
|
3889
|
+
try {
|
|
3890
|
+
const chunks = [];
|
|
3891
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
3892
|
+
out = await runHook({
|
|
3893
|
+
input: Buffer.concat(chunks).toString("utf8"),
|
|
3894
|
+
agents: registry.list(),
|
|
3895
|
+
secret: readHookSecret(CONFIG_DIR),
|
|
3896
|
+
agent: flag("--agent") ?? "claude"
|
|
3897
|
+
});
|
|
3898
|
+
} catch {
|
|
3899
|
+
}
|
|
3900
|
+
if (out) process.stdout.write(`${out}
|
|
3901
|
+
`);
|
|
3902
|
+
process.exit(0);
|
|
3903
|
+
}
|
|
3904
|
+
case "setup": {
|
|
3905
|
+
if (rest[0] === "claude") await setupClaude();
|
|
3906
|
+
else if (rest[0] === "codex") await setupCodex();
|
|
3907
|
+
else {
|
|
3908
|
+
console.error("Usage: coderemote setup claude|codex [--remove]");
|
|
3909
|
+
process.exit(1);
|
|
3910
|
+
}
|
|
3911
|
+
break;
|
|
3912
|
+
}
|
|
2310
3913
|
case "serve": {
|
|
2311
3914
|
await start({ tunnel: false });
|
|
2312
3915
|
console.log(`coderemote agent on http://127.0.0.1:${port}
|
|
@@ -2393,7 +3996,7 @@ Token: ${cfg.token}`);
|
|
|
2393
3996
|
break;
|
|
2394
3997
|
}
|
|
2395
3998
|
case "rotate-token": {
|
|
2396
|
-
cfg.token =
|
|
3999
|
+
cfg.token = randomBytes3(32).toString("base64url");
|
|
2397
4000
|
saveConfig(cfg);
|
|
2398
4001
|
const agents = registry.list();
|
|
2399
4002
|
for (const agent of agents) {
|
|
@@ -2422,7 +4025,7 @@ Token: ${cfg.token}`);
|
|
|
2422
4025
|
console.log(`coderemote ${VERSION} is the latest version.`);
|
|
2423
4026
|
break;
|
|
2424
4027
|
}
|
|
2425
|
-
const [command, args] = installCommand(
|
|
4028
|
+
const [command, args] = installCommand(fs13.realpathSync(process.argv[1]));
|
|
2426
4029
|
console.log(`Updating coderemote ${VERSION} \u2192 ${latest}: ${command} ${args.join(" ")}
|
|
2427
4030
|
`);
|
|
2428
4031
|
const code = await new Promise((resolve) => {
|