@nowcrew/daemon 0.5.26 → 0.5.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/execution-supervisor.js +19 -5
- package/dist/remote/claude-bridge.js +402 -0
- package/dist/remote/claude-channel.js +164 -0
- package/dist/remote/codex-client.js +408 -0
- package/dist/remote/codex-runtime.js +77 -0
- package/dist/remote/config.js +83 -0
- package/dist/remote/gateway.js +572 -0
- package/dist/remote/protocol.js +178 -0
- package/dist/remote/remote-cli.js +233 -0
- package/dist/remote/session-discovery.js +249 -0
- package/dist/remote/wrapper.js +40 -0
- package/dist/runtimes/codex-app-server-runner.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const REMOTE_PROTOCOL_VERSION = 1;
|
|
3
|
+
export const MAX_REMOTE_PROMPT_BYTES = 64 * 1024;
|
|
4
|
+
export const RemoteRuntimeSchema = z.enum(["claude", "codex"]);
|
|
5
|
+
export const RemoteControlStateSchema = z.enum([
|
|
6
|
+
"live",
|
|
7
|
+
"read_only",
|
|
8
|
+
"offline",
|
|
9
|
+
"unsupported",
|
|
10
|
+
]);
|
|
11
|
+
export const RemoteSessionSourceSchema = z.enum(["channel", "app_server", "history"]);
|
|
12
|
+
export const RemoteSessionSummarySchema = z.object({
|
|
13
|
+
id: z.string().min(1).max(128),
|
|
14
|
+
runtime: RemoteRuntimeSchema,
|
|
15
|
+
title: z.string().min(1).max(240),
|
|
16
|
+
cwd: z.string().max(4096),
|
|
17
|
+
updatedAt: z.string().datetime(),
|
|
18
|
+
controlState: RemoteControlStateSchema,
|
|
19
|
+
busy: z.boolean(),
|
|
20
|
+
source: RemoteSessionSourceSchema,
|
|
21
|
+
}).strict();
|
|
22
|
+
const TimelineBaseSchema = z.object({
|
|
23
|
+
id: z.string().min(1).max(256),
|
|
24
|
+
createdAt: z.string().datetime(),
|
|
25
|
+
});
|
|
26
|
+
export const RemoteQuestionsSchema = z.array(z.object({
|
|
27
|
+
id: z.string().min(1).max(160),
|
|
28
|
+
header: z.string().max(160),
|
|
29
|
+
question: z.string().min(1).max(4_000),
|
|
30
|
+
isSecret: z.boolean(),
|
|
31
|
+
options: z.array(z.object({
|
|
32
|
+
label: z.string().min(1).max(240),
|
|
33
|
+
description: z.string().max(1_000),
|
|
34
|
+
}).strict()).max(32).nullable(),
|
|
35
|
+
}).strict()).min(1).max(4);
|
|
36
|
+
export const RemoteTimelineItemSchema = z.discriminatedUnion("kind", [
|
|
37
|
+
TimelineBaseSchema.extend({
|
|
38
|
+
kind: z.literal("message"),
|
|
39
|
+
role: z.enum(["user", "assistant"]),
|
|
40
|
+
text: z.string().min(1),
|
|
41
|
+
}).strict(),
|
|
42
|
+
TimelineBaseSchema.extend({
|
|
43
|
+
kind: z.literal("tool"),
|
|
44
|
+
name: z.string().min(1).max(160),
|
|
45
|
+
summary: z.string().max(12_000),
|
|
46
|
+
status: z.string().min(1).max(80),
|
|
47
|
+
}).strict(),
|
|
48
|
+
TimelineBaseSchema.extend({
|
|
49
|
+
kind: z.literal("approval"),
|
|
50
|
+
requestId: z.string().min(1).max(160),
|
|
51
|
+
tool: z.string().min(1).max(160),
|
|
52
|
+
preview: z.string().max(12_000),
|
|
53
|
+
}).strict(),
|
|
54
|
+
TimelineBaseSchema.extend({
|
|
55
|
+
kind: z.literal("question"),
|
|
56
|
+
requestId: z.string().uuid(),
|
|
57
|
+
questions: RemoteQuestionsSchema,
|
|
58
|
+
}).strict(),
|
|
59
|
+
TimelineBaseSchema.extend({
|
|
60
|
+
kind: z.literal("status"),
|
|
61
|
+
text: z.string().min(1).max(2_000),
|
|
62
|
+
}).strict(),
|
|
63
|
+
]);
|
|
64
|
+
export const SubmitRemoteInputSchema = z.object({
|
|
65
|
+
text: z.string().trim().min(1).refine((value) => Buffer.byteLength(value, "utf8") <= MAX_REMOTE_PROMPT_BYTES, `prompt must not exceed ${MAX_REMOTE_PROMPT_BYTES} UTF-8 bytes`),
|
|
66
|
+
idempotencyKey: z.string().uuid(),
|
|
67
|
+
}).strict();
|
|
68
|
+
export const ResolveRemoteApprovalSchema = z.object({
|
|
69
|
+
decision: z.enum(["allow", "deny"]),
|
|
70
|
+
}).strict();
|
|
71
|
+
export const ResolveRemoteQuestionSchema = z.object({
|
|
72
|
+
answers: z.record(z.array(z.string().max(4_000)).max(16)),
|
|
73
|
+
}).strict();
|
|
74
|
+
export const ChannelRegistrationSchema = z.object({
|
|
75
|
+
type: z.literal("channel.register"),
|
|
76
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
77
|
+
sessionId: z.string().uuid(),
|
|
78
|
+
cwd: z.string().min(1).max(4096),
|
|
79
|
+
pid: z.number().int().positive(),
|
|
80
|
+
generation: z.string().uuid(),
|
|
81
|
+
title: z.string().min(1).max(240).optional(),
|
|
82
|
+
}).strict();
|
|
83
|
+
export const ChannelReplySchema = z.object({
|
|
84
|
+
type: z.literal("channel.reply"),
|
|
85
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
86
|
+
sessionId: z.string().uuid(),
|
|
87
|
+
requestId: z.string().uuid(),
|
|
88
|
+
text: z.string().min(1).max(200_000),
|
|
89
|
+
}).strict();
|
|
90
|
+
export const ChannelPermissionRequestSchema = z.object({
|
|
91
|
+
type: z.literal("channel.permission"),
|
|
92
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
93
|
+
sessionId: z.string().uuid(),
|
|
94
|
+
requestId: z.string().regex(/^[a-km-z]{5}$/),
|
|
95
|
+
tool: z.string().min(1).max(160),
|
|
96
|
+
description: z.string().max(4_000),
|
|
97
|
+
preview: z.string().max(16_000),
|
|
98
|
+
}).strict();
|
|
99
|
+
export const BridgeApprovalRequestSchema = z.object({
|
|
100
|
+
type: z.literal("bridge.approval"),
|
|
101
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
102
|
+
sessionId: z.string().uuid(),
|
|
103
|
+
requestId: z.string().uuid(),
|
|
104
|
+
tool: z.string().min(1).max(160),
|
|
105
|
+
preview: z.string().max(16_000),
|
|
106
|
+
}).strict();
|
|
107
|
+
export const BridgeQuestionRequestSchema = z.object({
|
|
108
|
+
type: z.literal("bridge.question"),
|
|
109
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
110
|
+
sessionId: z.string().uuid(),
|
|
111
|
+
requestId: z.string().uuid(),
|
|
112
|
+
questions: RemoteQuestionsSchema,
|
|
113
|
+
}).strict();
|
|
114
|
+
export const BridgeStatusSchema = z.object({
|
|
115
|
+
type: z.literal("bridge.status"),
|
|
116
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
117
|
+
sessionId: z.string().uuid(),
|
|
118
|
+
busy: z.boolean(),
|
|
119
|
+
}).strict();
|
|
120
|
+
export const BridgeResolvedSchema = z.object({
|
|
121
|
+
type: z.literal("bridge.resolved"),
|
|
122
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
123
|
+
sessionId: z.string().uuid(),
|
|
124
|
+
requestId: z.string().uuid(),
|
|
125
|
+
}).strict();
|
|
126
|
+
export const ChannelToGatewaySchema = z.discriminatedUnion("type", [
|
|
127
|
+
ChannelRegistrationSchema,
|
|
128
|
+
ChannelReplySchema,
|
|
129
|
+
ChannelPermissionRequestSchema,
|
|
130
|
+
BridgeApprovalRequestSchema,
|
|
131
|
+
BridgeQuestionRequestSchema,
|
|
132
|
+
BridgeStatusSchema,
|
|
133
|
+
BridgeResolvedSchema,
|
|
134
|
+
]);
|
|
135
|
+
export const GatewayToChannelSchema = z.discriminatedUnion("type", [
|
|
136
|
+
z.object({
|
|
137
|
+
type: z.literal("channel.prompt"),
|
|
138
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
139
|
+
requestId: z.string().uuid(),
|
|
140
|
+
text: z.string().min(1).max(MAX_REMOTE_PROMPT_BYTES),
|
|
141
|
+
}).strict(),
|
|
142
|
+
z.object({
|
|
143
|
+
type: z.literal("bridge.approval_response"),
|
|
144
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
145
|
+
requestId: z.string().uuid(),
|
|
146
|
+
decision: z.enum(["allow", "deny"]),
|
|
147
|
+
}).strict(),
|
|
148
|
+
z.object({
|
|
149
|
+
type: z.literal("bridge.question_response"),
|
|
150
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
151
|
+
requestId: z.string().uuid(),
|
|
152
|
+
answers: ResolveRemoteQuestionSchema.shape.answers,
|
|
153
|
+
}).strict(),
|
|
154
|
+
z.object({
|
|
155
|
+
type: z.literal("channel.permission_response"),
|
|
156
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
157
|
+
requestId: z.string().regex(/^[a-km-z]{5}$/),
|
|
158
|
+
decision: z.enum(["allow", "deny"]),
|
|
159
|
+
}).strict(),
|
|
160
|
+
]);
|
|
161
|
+
export const GatewayClientEventSchema = z.discriminatedUnion("type", [
|
|
162
|
+
z.object({
|
|
163
|
+
type: z.literal("sessions.changed"),
|
|
164
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
165
|
+
}).strict(),
|
|
166
|
+
z.object({
|
|
167
|
+
type: z.literal("timeline.appended"),
|
|
168
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
169
|
+
sessionId: z.string().min(1).max(128),
|
|
170
|
+
item: RemoteTimelineItemSchema,
|
|
171
|
+
}).strict(),
|
|
172
|
+
z.object({
|
|
173
|
+
type: z.literal("interaction.resolved"),
|
|
174
|
+
protocolVersion: z.literal(REMOTE_PROTOCOL_VERSION),
|
|
175
|
+
sessionId: z.string().min(1).max(128),
|
|
176
|
+
requestId: z.string().min(1).max(160),
|
|
177
|
+
}).strict(),
|
|
178
|
+
]);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import spawn from "cross-spawn";
|
|
8
|
+
import { runClaudeBridge } from "./claude-bridge.js";
|
|
9
|
+
import { loadOrCreateRemoteConfig, updateRemoteBinding, writeRemoteLock, } from "./config.js";
|
|
10
|
+
import { startRemoteGateway } from "./gateway.js";
|
|
11
|
+
import { startCodexRuntime } from "./codex-runtime.js";
|
|
12
|
+
import { buildCodexWrappedInvocation } from "./wrapper.js";
|
|
13
|
+
function bundledUiDir() {
|
|
14
|
+
try {
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
return join(dirname(require.resolve("@nowcrew/remote-web/package.json")), "dist");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function displayHost(host) {
|
|
23
|
+
if (host !== "0.0.0.0" && host !== "::")
|
|
24
|
+
return host;
|
|
25
|
+
for (const values of Object.values(networkInterfaces())) {
|
|
26
|
+
const address = values?.find((value) => value.family === "IPv4" && !value.internal)?.address;
|
|
27
|
+
if (address)
|
|
28
|
+
return address;
|
|
29
|
+
}
|
|
30
|
+
return "127.0.0.1";
|
|
31
|
+
}
|
|
32
|
+
function baseUrl(config) {
|
|
33
|
+
return `http://${displayHost(config.host)}:${config.port}`;
|
|
34
|
+
}
|
|
35
|
+
function localBaseUrl(config) {
|
|
36
|
+
const host = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
|
|
37
|
+
return `http://${host}:${config.port}`;
|
|
38
|
+
}
|
|
39
|
+
function pairingUrl(config) {
|
|
40
|
+
return `${baseUrl(config)}/#token=${encodeURIComponent(config.token)}`;
|
|
41
|
+
}
|
|
42
|
+
async function identity(config) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`${localBaseUrl(config)}/api/v1/identity`, {
|
|
45
|
+
headers: { authorization: `Bearer ${config.token}` },
|
|
46
|
+
signal: AbortSignal.timeout(700),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
return null;
|
|
50
|
+
return await response.json();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function currentDaemonInvocation() {
|
|
57
|
+
const entry = process.argv[1];
|
|
58
|
+
if (!entry)
|
|
59
|
+
throw new Error("Unable to locate the crew-daemon entrypoint");
|
|
60
|
+
return { command: process.execPath, args: [...process.execArgv, entry] };
|
|
61
|
+
}
|
|
62
|
+
function spawnAndWait(bin, args, env) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const child = spawn(bin, [...args], { env, stdio: "inherit" });
|
|
65
|
+
child.once("error", reject);
|
|
66
|
+
child.once("close", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function ensureGateway(config) {
|
|
70
|
+
if (await identity(config))
|
|
71
|
+
return;
|
|
72
|
+
const invocation = currentDaemonInvocation();
|
|
73
|
+
const child = spawn(invocation.command, [...invocation.args, "remote", "serve", "--background"], {
|
|
74
|
+
env: process.env,
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: "ignore",
|
|
77
|
+
});
|
|
78
|
+
child.unref();
|
|
79
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
81
|
+
if (await identity(config))
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`NowCrew remote gateway did not start at ${localBaseUrl(config)}`);
|
|
85
|
+
}
|
|
86
|
+
function usage() {
|
|
87
|
+
return [
|
|
88
|
+
"NowCrew mobile runtime control:",
|
|
89
|
+
" crew-daemon remote setup [--host 127.0.0.1|0.0.0.0] [--port 4317]",
|
|
90
|
+
" crew-daemon remote serve [--host ...] [--port ...] [--ui-dir <path>]",
|
|
91
|
+
" crew-daemon remote status|url",
|
|
92
|
+
" crew-daemon remote run claude -- [claude options]",
|
|
93
|
+
" crew-daemon remote run codex -- [codex options]",
|
|
94
|
+
].join("\n") + "\n";
|
|
95
|
+
}
|
|
96
|
+
async function serveCommand(argv) {
|
|
97
|
+
const parsed = parseArgs({
|
|
98
|
+
args: [...argv],
|
|
99
|
+
strict: true,
|
|
100
|
+
options: {
|
|
101
|
+
host: { type: "string" },
|
|
102
|
+
port: { type: "string" },
|
|
103
|
+
"ui-dir": { type: "string" },
|
|
104
|
+
background: { type: "boolean", default: false },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const port = parsed.values.port === undefined ? undefined : Number(parsed.values.port);
|
|
108
|
+
const config = await loadOrCreateRemoteConfig({
|
|
109
|
+
...(parsed.values.host === undefined ? {} : { host: parsed.values.host }),
|
|
110
|
+
...(port === undefined ? {} : { port }),
|
|
111
|
+
});
|
|
112
|
+
const codexRuntime = await startCodexRuntime(config.paths.codexSocket).catch((error) => {
|
|
113
|
+
process.stderr.write(`NowCrew remote: Codex live control unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
114
|
+
return null;
|
|
115
|
+
});
|
|
116
|
+
const gatewayId = randomUUID();
|
|
117
|
+
let gateway;
|
|
118
|
+
try {
|
|
119
|
+
gateway = await startRemoteGateway({
|
|
120
|
+
config,
|
|
121
|
+
gatewayId,
|
|
122
|
+
...(codexRuntime === null ? {} : { codex: codexRuntime.adapter }),
|
|
123
|
+
uiDir: parsed.values["ui-dir"] ?? process.env.NOWCREW_REMOTE_UI_DIR ?? bundledUiDir(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
await codexRuntime?.close();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
await writeRemoteLock(config.paths.lock, {
|
|
131
|
+
version: 1,
|
|
132
|
+
gatewayId,
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
host: gateway.host,
|
|
135
|
+
port: gateway.port,
|
|
136
|
+
startedAt: new Date().toISOString(),
|
|
137
|
+
});
|
|
138
|
+
if (!parsed.values.background)
|
|
139
|
+
process.stdout.write(`NowCrew remote listening at ${baseUrl(config)}\n`);
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
process.once("SIGINT", resolve);
|
|
142
|
+
process.once("SIGTERM", resolve);
|
|
143
|
+
});
|
|
144
|
+
await gateway.close();
|
|
145
|
+
await codexRuntime?.close();
|
|
146
|
+
try {
|
|
147
|
+
const lock = JSON.parse(await readFile(config.paths.lock, "utf8"));
|
|
148
|
+
if (lock.gatewayId === gatewayId)
|
|
149
|
+
await unlink(config.paths.lock);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT")
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
async function setupCommand(argv) {
|
|
158
|
+
const parsed = parseArgs({
|
|
159
|
+
args: [...argv],
|
|
160
|
+
strict: true,
|
|
161
|
+
options: {
|
|
162
|
+
host: { type: "string" },
|
|
163
|
+
port: { type: "string" },
|
|
164
|
+
help: { type: "boolean", short: "h", default: false },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
if (parsed.values.help) {
|
|
168
|
+
process.stdout.write(usage());
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
let config = await loadOrCreateRemoteConfig();
|
|
172
|
+
if (parsed.values.host !== undefined || parsed.values.port !== undefined) {
|
|
173
|
+
config = await updateRemoteBinding(config, {
|
|
174
|
+
host: parsed.values.host ?? config.host,
|
|
175
|
+
port: parsed.values.port === undefined ? config.port : Number(parsed.values.port),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
process.stdout.write([
|
|
179
|
+
`Phone URL: ${pairingUrl(config)}`,
|
|
180
|
+
"",
|
|
181
|
+
"Add these shell functions to your zsh configuration:",
|
|
182
|
+
"claude() { crew-daemon remote run claude -- \"$@\"; }",
|
|
183
|
+
"codex() { crew-daemon remote run codex -- \"$@\"; }",
|
|
184
|
+
"",
|
|
185
|
+
config.host === "127.0.0.1"
|
|
186
|
+
? "Loopback mode is enabled. Use --host 0.0.0.0 for a trusted LAN, or expose this URL through private HTTPS."
|
|
187
|
+
: "LAN mode is enabled. Keep the capability URL private and do not expose this HTTP port to the public internet.",
|
|
188
|
+
].join("\n") + "\n");
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
export async function runRemoteCommand(argv) {
|
|
192
|
+
if (argv[0] !== "remote")
|
|
193
|
+
return null;
|
|
194
|
+
const action = argv[1];
|
|
195
|
+
if (!action || action === "help" || action === "--help" || action === "-h") {
|
|
196
|
+
process.stdout.write(usage());
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
if (action === "serve")
|
|
200
|
+
return serveCommand(argv.slice(2));
|
|
201
|
+
if (action === "setup")
|
|
202
|
+
return setupCommand(argv.slice(2));
|
|
203
|
+
const config = await loadOrCreateRemoteConfig();
|
|
204
|
+
if (action === "status") {
|
|
205
|
+
const current = await identity(config);
|
|
206
|
+
process.stdout.write(`${JSON.stringify({
|
|
207
|
+
running: current !== null,
|
|
208
|
+
url: baseUrl(config),
|
|
209
|
+
...(current ?? {}),
|
|
210
|
+
}, null, 2)}\n`);
|
|
211
|
+
return current ? 0 : 3;
|
|
212
|
+
}
|
|
213
|
+
if (action === "url") {
|
|
214
|
+
process.stdout.write(`${pairingUrl(config)}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (action !== "run" || !["claude", "codex"].includes(argv[2] ?? "")) {
|
|
218
|
+
process.stderr.write(usage());
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
await ensureGateway(config);
|
|
222
|
+
const separator = argv.indexOf("--", 3);
|
|
223
|
+
const runtimeArgs = argv.slice(separator >= 0 ? separator + 1 : 3);
|
|
224
|
+
if (argv[2] === "claude") {
|
|
225
|
+
return runClaudeBridge(runtimeArgs, {
|
|
226
|
+
gatewayUrl: localBaseUrl(config),
|
|
227
|
+
token: config.token,
|
|
228
|
+
cwd: process.cwd(),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const invocation = buildCodexWrappedInvocation(runtimeArgs, config.paths.codexSocket);
|
|
232
|
+
return spawnAndWait(invocation.bin, invocation.args, invocation.env);
|
|
233
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
const DEFAULT_HISTORY_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
const DEFAULT_TIMELINE_ITEMS = 400;
|
|
6
|
+
async function collectJsonl(root, depth, cap) {
|
|
7
|
+
const result = [];
|
|
8
|
+
const walk = async (dir, remaining) => {
|
|
9
|
+
if (remaining < 0 || result.length >= cap)
|
|
10
|
+
return;
|
|
11
|
+
let entries;
|
|
12
|
+
try {
|
|
13
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === "ENOENT")
|
|
17
|
+
return;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (result.length >= cap)
|
|
22
|
+
return;
|
|
23
|
+
const path = join(dir, entry.name);
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
await walk(path, remaining - 1);
|
|
26
|
+
}
|
|
27
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
28
|
+
const name = basename(entry.name, ".jsonl");
|
|
29
|
+
const id = name.startsWith("rollout-") ? name.slice(name.lastIndexOf("-") + 1) : name;
|
|
30
|
+
const match = entry.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.jsonl$/i);
|
|
31
|
+
const sessionId = match?.[1] ?? id;
|
|
32
|
+
if (!SESSION_ID.test(sessionId))
|
|
33
|
+
continue;
|
|
34
|
+
const info = await stat(path);
|
|
35
|
+
result.push({ path, id: sessionId, mtimeMs: info.mtimeMs, updatedAt: info.mtime.toISOString() });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
await walk(root, depth);
|
|
40
|
+
return result.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
41
|
+
}
|
|
42
|
+
async function readTailLines(path, maxBytes) {
|
|
43
|
+
const file = await open(path, "r");
|
|
44
|
+
try {
|
|
45
|
+
const info = await file.stat();
|
|
46
|
+
const length = Math.min(info.size, maxBytes);
|
|
47
|
+
if (length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
50
|
+
await file.read(buffer, 0, length, info.size - length);
|
|
51
|
+
const text = buffer.toString("utf8");
|
|
52
|
+
const lines = text.split("\n");
|
|
53
|
+
if (info.size > length)
|
|
54
|
+
lines.shift();
|
|
55
|
+
return lines.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await file.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function object(value) {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
63
|
+
? value
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
function string(value) {
|
|
67
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
68
|
+
}
|
|
69
|
+
function textFromContent(value) {
|
|
70
|
+
if (typeof value === "string")
|
|
71
|
+
return string(value);
|
|
72
|
+
if (!Array.isArray(value))
|
|
73
|
+
return null;
|
|
74
|
+
const parts = [];
|
|
75
|
+
for (const raw of value) {
|
|
76
|
+
const block = object(raw);
|
|
77
|
+
if (!block)
|
|
78
|
+
continue;
|
|
79
|
+
if (["text", "input_text", "output_text"].includes(String(block.type))) {
|
|
80
|
+
const text = string(block.text);
|
|
81
|
+
if (text)
|
|
82
|
+
parts.push(text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.length ? parts.join("\n") : null;
|
|
86
|
+
}
|
|
87
|
+
function cleanChannelText(value) {
|
|
88
|
+
return value
|
|
89
|
+
.replace(/^<channel\b[^>]*>\s*/i, "")
|
|
90
|
+
.replace(/\s*<\/channel>$/i, "")
|
|
91
|
+
.trim();
|
|
92
|
+
}
|
|
93
|
+
function parseLine(line) {
|
|
94
|
+
try {
|
|
95
|
+
return object(JSON.parse(line));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function claudeItems(lines, fallbackAt) {
|
|
102
|
+
let cwd = "";
|
|
103
|
+
let title = null;
|
|
104
|
+
const items = [];
|
|
105
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
106
|
+
const row = parseLine(lines[index]);
|
|
107
|
+
if (!row)
|
|
108
|
+
continue;
|
|
109
|
+
cwd = string(row.cwd) ?? cwd;
|
|
110
|
+
const message = object(row.message);
|
|
111
|
+
const role = row.type === "user" || row.type === "assistant" ? row.type : null;
|
|
112
|
+
const text = message ? textFromContent(message.content) : null;
|
|
113
|
+
const createdAt = string(row.timestamp) ?? fallbackAt;
|
|
114
|
+
const id = string(row.uuid) ?? `claude:${index}`;
|
|
115
|
+
if (role && text) {
|
|
116
|
+
const cleaned = cleanChannelText(text);
|
|
117
|
+
if (!cleaned)
|
|
118
|
+
continue;
|
|
119
|
+
if (role === "user")
|
|
120
|
+
title = cleaned.slice(0, 120);
|
|
121
|
+
items.push({ id, kind: "message", role, text: cleaned, createdAt });
|
|
122
|
+
}
|
|
123
|
+
if (role !== "assistant" || !message || !Array.isArray(message.content))
|
|
124
|
+
continue;
|
|
125
|
+
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex += 1) {
|
|
126
|
+
const block = object(message.content[blockIndex]);
|
|
127
|
+
if (!block || block.type !== "tool_use")
|
|
128
|
+
continue;
|
|
129
|
+
const name = string(block.name) ?? "tool";
|
|
130
|
+
const summary = JSON.stringify(block.input ?? {}).slice(0, 12_000);
|
|
131
|
+
items.push({
|
|
132
|
+
id: `${id}:tool:${blockIndex}`,
|
|
133
|
+
kind: "tool",
|
|
134
|
+
name,
|
|
135
|
+
summary,
|
|
136
|
+
status: "requested",
|
|
137
|
+
createdAt,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { cwd, title, items };
|
|
142
|
+
}
|
|
143
|
+
function codexItems(lines, fallbackAt) {
|
|
144
|
+
let cwd = "";
|
|
145
|
+
let title = null;
|
|
146
|
+
const items = [];
|
|
147
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
148
|
+
const row = parseLine(lines[index]);
|
|
149
|
+
if (!row)
|
|
150
|
+
continue;
|
|
151
|
+
const payload = object(row.payload);
|
|
152
|
+
if (!payload)
|
|
153
|
+
continue;
|
|
154
|
+
if (row.type === "session_meta")
|
|
155
|
+
cwd = string(payload.cwd) ?? cwd;
|
|
156
|
+
if (row.type !== "response_item" || payload.type !== "message")
|
|
157
|
+
continue;
|
|
158
|
+
const role = payload.role === "user" || payload.role === "assistant" ? payload.role : null;
|
|
159
|
+
const text = textFromContent(payload.content);
|
|
160
|
+
if (!role || !text)
|
|
161
|
+
continue;
|
|
162
|
+
const createdAt = string(row.timestamp) ?? fallbackAt;
|
|
163
|
+
const id = `codex:${index}:${createdAt}`;
|
|
164
|
+
if (role === "user")
|
|
165
|
+
title = text.slice(0, 120);
|
|
166
|
+
items.push({ id, kind: "message", role, text, createdAt });
|
|
167
|
+
}
|
|
168
|
+
return { cwd, title, items };
|
|
169
|
+
}
|
|
170
|
+
function capItems(items, max) {
|
|
171
|
+
return items.length <= max ? [...items] : items.slice(items.length - max);
|
|
172
|
+
}
|
|
173
|
+
export class RemoteSessionDiscovery {
|
|
174
|
+
options;
|
|
175
|
+
files = new Map();
|
|
176
|
+
constructor(options) {
|
|
177
|
+
this.options = options;
|
|
178
|
+
}
|
|
179
|
+
async list() {
|
|
180
|
+
const maxSessions = this.options.maxSessions ?? 100;
|
|
181
|
+
const historyBytes = this.options.maxHistoryBytes ?? DEFAULT_HISTORY_BYTES;
|
|
182
|
+
const [claude, codex] = await Promise.all([
|
|
183
|
+
collectJsonl(this.options.claudeProjectsRoot, 2, maxSessions * 4),
|
|
184
|
+
collectJsonl(this.options.codexSessionsRoot, 4, maxSessions * 4),
|
|
185
|
+
]);
|
|
186
|
+
this.files.clear();
|
|
187
|
+
const summaries = [];
|
|
188
|
+
for (const [runtime, candidates] of [["claude", claude], ["codex", codex]]) {
|
|
189
|
+
for (const candidate of candidates.slice(0, maxSessions)) {
|
|
190
|
+
const key = `${runtime}:${candidate.id}`;
|
|
191
|
+
if (this.files.has(key))
|
|
192
|
+
continue;
|
|
193
|
+
this.files.set(key, candidate);
|
|
194
|
+
const lines = await readTailLines(candidate.path, historyBytes);
|
|
195
|
+
const parsed = runtime === "claude"
|
|
196
|
+
? claudeItems(lines, candidate.updatedAt)
|
|
197
|
+
: codexItems(lines, candidate.updatedAt);
|
|
198
|
+
const active = runtime === "claude"
|
|
199
|
+
? this.options.activeClaude?.get(candidate.id)
|
|
200
|
+
: this.options.activeCodex?.get(candidate.id);
|
|
201
|
+
summaries.push({
|
|
202
|
+
id: candidate.id,
|
|
203
|
+
runtime,
|
|
204
|
+
title: active?.title ?? parsed.title ?? `${runtime === "claude" ? "Claude" : "Codex"} session`,
|
|
205
|
+
cwd: active?.cwd ?? parsed.cwd,
|
|
206
|
+
updatedAt: active?.updatedAt ?? candidate.updatedAt,
|
|
207
|
+
controlState: active ? "live" : "read_only",
|
|
208
|
+
busy: active?.busy ?? false,
|
|
209
|
+
source: active ? (runtime === "claude" ? "channel" : "app_server") : "history",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const [runtime, activeSessions] of [["claude", this.options.activeClaude], ["codex", this.options.activeCodex]]) {
|
|
214
|
+
for (const [id, active] of activeSessions ?? []) {
|
|
215
|
+
if (summaries.some((session) => session.runtime === runtime && session.id === id))
|
|
216
|
+
continue;
|
|
217
|
+
summaries.push({
|
|
218
|
+
id,
|
|
219
|
+
runtime,
|
|
220
|
+
title: active.title ?? `${runtime === "claude" ? "Claude" : "Codex"} session`,
|
|
221
|
+
cwd: active.cwd,
|
|
222
|
+
updatedAt: active.updatedAt ?? new Date().toISOString(),
|
|
223
|
+
controlState: "live",
|
|
224
|
+
busy: active.busy,
|
|
225
|
+
source: runtime === "claude" ? "channel" : "app_server",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, maxSessions);
|
|
230
|
+
}
|
|
231
|
+
async timeline(runtime, sessionId) {
|
|
232
|
+
if (!SESSION_ID.test(sessionId))
|
|
233
|
+
return null;
|
|
234
|
+
let candidate = this.files.get(`${runtime}:${sessionId}`);
|
|
235
|
+
if (!candidate) {
|
|
236
|
+
await this.list();
|
|
237
|
+
candidate = this.files.get(`${runtime}:${sessionId}`);
|
|
238
|
+
}
|
|
239
|
+
if (!candidate) {
|
|
240
|
+
const active = runtime === "claude" ? this.options.activeClaude?.has(sessionId) : this.options.activeCodex?.has(sessionId);
|
|
241
|
+
return active ? [] : null;
|
|
242
|
+
}
|
|
243
|
+
const lines = await readTailLines(candidate.path, this.options.maxHistoryBytes ?? DEFAULT_HISTORY_BYTES);
|
|
244
|
+
const parsed = runtime === "claude"
|
|
245
|
+
? claudeItems(lines, candidate.updatedAt)
|
|
246
|
+
: codexItems(lines, candidate.updatedAt);
|
|
247
|
+
return capItems(parsed.items, this.options.maxTimelineItems ?? DEFAULT_TIMELINE_ITEMS);
|
|
248
|
+
}
|
|
249
|
+
}
|