@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.
@@ -0,0 +1,408 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocket } from "ws";
3
+ import { z } from "zod";
4
+ const ThreadStatusSchema = z.discriminatedUnion("type", [
5
+ z.object({ type: z.literal("notLoaded") }).passthrough(),
6
+ z.object({ type: z.literal("idle") }).passthrough(),
7
+ z.object({ type: z.literal("systemError") }).passthrough(),
8
+ z.object({ type: z.literal("active"), activeFlags: z.array(z.unknown()).optional() }).passthrough(),
9
+ ]);
10
+ const ThreadItemSchema = z.object({
11
+ type: z.string(),
12
+ id: z.string().optional(),
13
+ }).passthrough();
14
+ const TurnSchema = z.object({
15
+ id: z.string(),
16
+ status: z.string(),
17
+ items: z.array(ThreadItemSchema),
18
+ startedAt: z.number().nullable().optional(),
19
+ completedAt: z.number().nullable().optional(),
20
+ }).passthrough();
21
+ const ThreadSchema = z.object({
22
+ id: z.string().uuid(),
23
+ preview: z.string().default(""),
24
+ createdAt: z.number(),
25
+ updatedAt: z.number(),
26
+ recencyAt: z.number().nullable().optional(),
27
+ status: ThreadStatusSchema,
28
+ cwd: z.string(),
29
+ name: z.string().nullable().optional(),
30
+ turns: z.array(TurnSchema).default([]),
31
+ }).passthrough();
32
+ const ThreadListResponseSchema = z.object({
33
+ data: z.array(ThreadSchema),
34
+ nextCursor: z.string().nullable(),
35
+ }).passthrough();
36
+ const ThreadReadResponseSchema = z.object({ thread: ThreadSchema }).passthrough();
37
+ const ThreadResumeResponseSchema = z.object({ thread: ThreadSchema }).passthrough();
38
+ const ApprovalParamsSchema = z.object({
39
+ threadId: z.string().uuid(),
40
+ command: z.string().nullable().optional(),
41
+ reason: z.string().nullable().optional(),
42
+ grantRoot: z.string().nullable().optional(),
43
+ }).passthrough();
44
+ const QuestionParamsSchema = z.object({
45
+ threadId: z.string().uuid(),
46
+ questions: z.array(z.object({
47
+ id: z.string().min(1),
48
+ header: z.string(),
49
+ question: z.string(),
50
+ isSecret: z.boolean(),
51
+ options: z.array(z.object({
52
+ label: z.string(),
53
+ description: z.string(),
54
+ }).passthrough()).nullable(),
55
+ }).passthrough()).min(1).max(3),
56
+ }).passthrough();
57
+ function isoFromSeconds(value) {
58
+ return new Date((value ?? Date.now() / 1_000) * 1_000).toISOString();
59
+ }
60
+ function textInputs(content) {
61
+ if (!Array.isArray(content))
62
+ return "";
63
+ return content.flatMap((entry) => {
64
+ const value = entry;
65
+ if (value.type === "text" && typeof value.text === "string")
66
+ return [value.text];
67
+ if (value.type === "localImage" && typeof value.path === "string")
68
+ return [`[Image: ${value.path}]`];
69
+ return [];
70
+ }).join("\n").trim();
71
+ }
72
+ function itemToTimeline(raw, createdAt) {
73
+ const item = raw;
74
+ const id = typeof item.id === "string" ? `codex:${item.id}` : `codex:${randomUUID()}`;
75
+ if (item.type === "userMessage") {
76
+ const text = textInputs(item.content);
77
+ return text ? { id, kind: "message", role: "user", text, createdAt } : null;
78
+ }
79
+ if (item.type === "agentMessage" && typeof item.text === "string" && item.text.trim()) {
80
+ return { id, kind: "message", role: "assistant", text: item.text, createdAt };
81
+ }
82
+ if (item.type === "plan" && typeof item.text === "string") {
83
+ return { id, kind: "tool", name: "Plan", summary: item.text.slice(0, 12_000), status: "updated", createdAt };
84
+ }
85
+ if (item.type === "commandExecution" && typeof item.command === "string") {
86
+ const output = typeof item.aggregatedOutput === "string" ? `\n\n${item.aggregatedOutput}` : "";
87
+ return {
88
+ id,
89
+ kind: "tool",
90
+ name: "Command",
91
+ summary: `${item.command}${output}`.slice(0, 12_000),
92
+ status: typeof item.status === "string" ? item.status : "completed",
93
+ createdAt,
94
+ };
95
+ }
96
+ if (item.type === "fileChange") {
97
+ return {
98
+ id,
99
+ kind: "tool",
100
+ name: "File change",
101
+ summary: JSON.stringify(item.changes ?? []).slice(0, 12_000),
102
+ status: typeof item.status === "string" ? item.status : "completed",
103
+ createdAt,
104
+ };
105
+ }
106
+ if (item.type === "mcpToolCall" || item.type === "dynamicToolCall") {
107
+ const server = typeof item.server === "string" ? `${item.server}.` : "";
108
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
109
+ return {
110
+ id,
111
+ kind: "tool",
112
+ name: `${server}${tool}`,
113
+ summary: JSON.stringify(item.arguments ?? {}).slice(0, 12_000),
114
+ status: typeof item.status === "string" ? item.status : "completed",
115
+ createdAt,
116
+ };
117
+ }
118
+ return null;
119
+ }
120
+ function threadSummary(thread) {
121
+ return {
122
+ id: thread.id,
123
+ runtime: "codex",
124
+ title: thread.name?.trim() || thread.preview.trim().slice(0, 240) || "Codex session",
125
+ cwd: thread.cwd,
126
+ updatedAt: isoFromSeconds(thread.recencyAt ?? thread.updatedAt),
127
+ controlState: "live",
128
+ busy: thread.status.type === "active",
129
+ source: "app_server",
130
+ };
131
+ }
132
+ function threadTimeline(thread) {
133
+ const items = [];
134
+ for (const turn of thread.turns) {
135
+ const createdAt = isoFromSeconds(turn.startedAt ?? turn.completedAt ?? thread.updatedAt);
136
+ for (const raw of turn.items) {
137
+ const mapped = itemToTimeline(raw, createdAt);
138
+ if (mapped)
139
+ items.push(mapped);
140
+ }
141
+ }
142
+ return items.length > 400 ? items.slice(items.length - 400) : items;
143
+ }
144
+ function ipcWebSocketUrl(socketPath) {
145
+ if (!socketPath.startsWith("/"))
146
+ throw new Error("Codex app-server socket path must be absolute");
147
+ if (socketPath.includes(":"))
148
+ throw new Error("Codex app-server socket path cannot contain ':'");
149
+ return `ws+unix://${socketPath}:/`;
150
+ }
151
+ export class CodexAppServerClient {
152
+ socket;
153
+ nextId = 1;
154
+ pending = new Map();
155
+ interactions = new Map();
156
+ listeners = new Set();
157
+ closedError = null;
158
+ constructor(socket) {
159
+ this.socket = socket;
160
+ socket.on("message", (raw) => this.receive(raw.toString()));
161
+ socket.once("close", () => this.fail(new Error("Codex app-server connection closed")));
162
+ socket.once("error", (error) => this.fail(error));
163
+ }
164
+ static async connect(socketPath, timeoutMs = 5_000) {
165
+ const socket = new WebSocket(ipcWebSocketUrl(socketPath), { perMessageDeflate: false });
166
+ await new Promise((resolve, reject) => {
167
+ const timer = setTimeout(() => {
168
+ socket.terminate();
169
+ reject(new Error(`Timed out connecting to Codex app-server at ${socketPath}`));
170
+ }, timeoutMs);
171
+ socket.once("open", () => {
172
+ clearTimeout(timer);
173
+ resolve();
174
+ });
175
+ socket.once("error", (error) => {
176
+ clearTimeout(timer);
177
+ reject(error);
178
+ });
179
+ });
180
+ const client = new CodexAppServerClient(socket);
181
+ await client.request("initialize", {
182
+ clientInfo: { name: "nowcrew_remote", title: "NowCrew Remote", version: "1.0.0" },
183
+ capabilities: { experimentalApi: true, requestAttestation: false },
184
+ });
185
+ client.notify("initialized");
186
+ return client;
187
+ }
188
+ async listSessions() {
189
+ const threads = [];
190
+ let cursor = null;
191
+ for (let page = 0; page < 2; page += 1) {
192
+ const response = ThreadListResponseSchema.parse(await this.request("thread/list", {
193
+ limit: 100,
194
+ ...(cursor === null ? {} : { cursor }),
195
+ }));
196
+ threads.push(...response.data);
197
+ cursor = response.nextCursor;
198
+ if (!cursor)
199
+ break;
200
+ }
201
+ return threads.map(threadSummary).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
202
+ }
203
+ async timeline(sessionId) {
204
+ try {
205
+ const response = ThreadReadResponseSchema.parse(await this.request("thread/read", {
206
+ threadId: sessionId,
207
+ includeTurns: true,
208
+ }));
209
+ return threadTimeline(response.thread);
210
+ }
211
+ catch (error) {
212
+ if (error instanceof Error && /not found|does not exist/i.test(error.message))
213
+ return null;
214
+ throw error;
215
+ }
216
+ }
217
+ async submit(sessionId, input) {
218
+ const resumed = ThreadResumeResponseSchema.parse(await this.request("thread/resume", { threadId: sessionId }));
219
+ const active = [...resumed.thread.turns].reverse().find((turn) => turn.status === "inProgress");
220
+ const params = {
221
+ threadId: sessionId,
222
+ clientUserMessageId: input.idempotencyKey,
223
+ input: [{ type: "text", text: input.text, text_elements: [] }],
224
+ };
225
+ if (active) {
226
+ await this.request("turn/steer", { ...params, expectedTurnId: active.id });
227
+ }
228
+ else {
229
+ await this.request("turn/start", params);
230
+ }
231
+ return { requestId: input.idempotencyKey };
232
+ }
233
+ resolveApproval(sessionId, requestId, decision) {
234
+ const pending = this.takeInteraction(sessionId, requestId, "approval");
235
+ if (!pending)
236
+ return false;
237
+ this.respond(pending.rpcId, { decision: decision === "allow" ? "accept" : "decline" });
238
+ return true;
239
+ }
240
+ resolveQuestion(sessionId, requestId, input) {
241
+ const pending = this.takeInteraction(sessionId, requestId, "question");
242
+ if (!pending)
243
+ return false;
244
+ this.respond(pending.rpcId, {
245
+ answers: Object.fromEntries(Object.entries(input.answers).map(([id, answers]) => [id, { answers }])),
246
+ });
247
+ return true;
248
+ }
249
+ onEvent(listener) {
250
+ this.listeners.add(listener);
251
+ return () => this.listeners.delete(listener);
252
+ }
253
+ async close() {
254
+ if (this.socket.readyState === WebSocket.CLOSED)
255
+ return;
256
+ await new Promise((resolve) => {
257
+ const timer = setTimeout(() => {
258
+ this.socket.terminate();
259
+ resolve();
260
+ }, 1_000);
261
+ this.socket.once("close", () => {
262
+ clearTimeout(timer);
263
+ resolve();
264
+ });
265
+ this.socket.close(1000, "NowCrew gateway stopping");
266
+ });
267
+ }
268
+ request(method, params, timeoutMs = 30_000) {
269
+ if (this.closedError)
270
+ return Promise.reject(this.closedError);
271
+ const id = this.nextId++;
272
+ return new Promise((resolve, reject) => {
273
+ const timer = setTimeout(() => {
274
+ this.pending.delete(id);
275
+ reject(new Error(`Codex app-server ${method} timed out`));
276
+ }, timeoutMs);
277
+ this.pending.set(id, { resolve, reject, timer });
278
+ this.send({ id, method, params });
279
+ });
280
+ }
281
+ notify(method, params) {
282
+ this.send({ method, ...(params === undefined ? {} : { params }) });
283
+ }
284
+ respond(id, result) {
285
+ this.send({ id, result });
286
+ }
287
+ send(value) {
288
+ if (this.socket.readyState !== WebSocket.OPEN)
289
+ throw this.closedError ?? new Error("Codex app-server is not open");
290
+ this.socket.send(JSON.stringify(value));
291
+ }
292
+ receive(raw) {
293
+ let message;
294
+ try {
295
+ message = JSON.parse(raw);
296
+ }
297
+ catch {
298
+ return;
299
+ }
300
+ if (message.id !== undefined && message.method !== undefined) {
301
+ this.receiveServerRequest(message.id, message.method, message.params);
302
+ return;
303
+ }
304
+ if (message.id !== undefined) {
305
+ const pending = this.pending.get(message.id);
306
+ if (!pending)
307
+ return;
308
+ this.pending.delete(message.id);
309
+ clearTimeout(pending.timer);
310
+ if (message.error)
311
+ pending.reject(new Error(`Codex app-server RPC failed: ${message.error.message ?? "unknown error"}`));
312
+ else
313
+ pending.resolve(message.result);
314
+ return;
315
+ }
316
+ if (message.method)
317
+ this.receiveNotification(message.method, message.params);
318
+ }
319
+ receiveServerRequest(id, method, params) {
320
+ if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
321
+ const parsed = ApprovalParamsSchema.safeParse(params);
322
+ if (!parsed.success) {
323
+ this.respond(id, { decision: "decline" });
324
+ return;
325
+ }
326
+ const requestId = randomUUID();
327
+ this.interactions.set(requestId, { rpcId: id, sessionId: parsed.data.threadId, type: "approval" });
328
+ const tool = method.includes("commandExecution") ? "Command" : "File change";
329
+ const preview = parsed.data.command ?? parsed.data.grantRoot ?? parsed.data.reason ?? tool;
330
+ this.emit({
331
+ type: "timeline.appended",
332
+ sessionId: parsed.data.threadId,
333
+ item: { id: `codex-approval:${requestId}`, kind: "approval", requestId, tool, preview, createdAt: new Date().toISOString() },
334
+ });
335
+ return;
336
+ }
337
+ if (method === "item/tool/requestUserInput") {
338
+ const parsed = QuestionParamsSchema.safeParse(params);
339
+ if (!parsed.success) {
340
+ this.respond(id, { answers: {} });
341
+ return;
342
+ }
343
+ const requestId = randomUUID();
344
+ this.interactions.set(requestId, { rpcId: id, sessionId: parsed.data.threadId, type: "question" });
345
+ this.emit({
346
+ type: "timeline.appended",
347
+ sessionId: parsed.data.threadId,
348
+ item: {
349
+ id: `codex-question:${requestId}`,
350
+ kind: "question",
351
+ requestId,
352
+ questions: parsed.data.questions.map((question) => ({
353
+ id: question.id,
354
+ header: question.header,
355
+ question: question.question,
356
+ isSecret: question.isSecret,
357
+ options: question.options,
358
+ })),
359
+ createdAt: new Date().toISOString(),
360
+ },
361
+ });
362
+ return;
363
+ }
364
+ this.send({ id, error: { code: -32001, message: `NowCrew does not support ${method}` } });
365
+ }
366
+ receiveNotification(method, params) {
367
+ if (["thread/started", "thread/status/changed", "thread/name/updated", "turn/started", "turn/completed"].includes(method)) {
368
+ this.emit({ type: "sessions.changed" });
369
+ }
370
+ if (method !== "item/completed")
371
+ return;
372
+ const parsed = z.object({
373
+ threadId: z.string().uuid(),
374
+ item: ThreadItemSchema,
375
+ completedAtMs: z.number().optional(),
376
+ }).passthrough().safeParse(params);
377
+ if (!parsed.success)
378
+ return;
379
+ if (parsed.data.item.type === "userMessage")
380
+ return;
381
+ const item = itemToTimeline(parsed.data.item, new Date(parsed.data.completedAtMs ?? Date.now()).toISOString());
382
+ if (item)
383
+ this.emit({ type: "timeline.appended", sessionId: parsed.data.threadId, item });
384
+ }
385
+ takeInteraction(sessionId, requestId, type) {
386
+ const pending = this.interactions.get(requestId);
387
+ if (!pending || pending.sessionId !== sessionId || pending.type !== type)
388
+ return null;
389
+ this.interactions.delete(requestId);
390
+ return pending;
391
+ }
392
+ emit(event) {
393
+ for (const listener of this.listeners)
394
+ listener(event);
395
+ }
396
+ fail(error) {
397
+ if (this.closedError)
398
+ return;
399
+ this.closedError = error;
400
+ for (const pending of this.pending.values()) {
401
+ clearTimeout(pending.timer);
402
+ pending.reject(error);
403
+ }
404
+ this.pending.clear();
405
+ this.interactions.clear();
406
+ this.emit({ type: "sessions.changed" });
407
+ }
408
+ }
@@ -0,0 +1,77 @@
1
+ import { once } from "node:events";
2
+ import { lstat, mkdir, unlink } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import spawn from "cross-spawn";
5
+ import { CodexAppServerClient } from "./codex-client.js";
6
+ async function connect(socketPath, timeoutMs) {
7
+ try {
8
+ return await CodexAppServerClient.connect(socketPath, timeoutMs);
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ async function removeStaleSocket(socketPath) {
15
+ try {
16
+ const info = await lstat(socketPath);
17
+ if (!info.isSocket())
18
+ throw new Error(`Refusing to replace non-socket path: ${socketPath}`);
19
+ await unlink(socketPath);
20
+ }
21
+ catch (error) {
22
+ if (error.code !== "ENOENT")
23
+ throw error;
24
+ }
25
+ }
26
+ export async function startCodexRuntime(socketPath, bin = process.env.NOWCREW_CODEX_BIN ?? "codex") {
27
+ const existing = await connect(socketPath, 500);
28
+ if (existing) {
29
+ return { adapter: existing, socketPath, close: () => existing.close() };
30
+ }
31
+ await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 });
32
+ await removeStaleSocket(socketPath);
33
+ const child = spawn(bin, ["app-server", "--listen", `unix://${socketPath}`], {
34
+ env: process.env,
35
+ stdio: ["ignore", "ignore", "pipe"],
36
+ });
37
+ let stderr = "";
38
+ child.stderr?.on("data", (chunk) => {
39
+ stderr = `${stderr}${String(chunk)}`.slice(-4_000);
40
+ });
41
+ let adapter = null;
42
+ for (let attempt = 0; attempt < 50; attempt += 1) {
43
+ if (child.exitCode !== null || child.signalCode !== null)
44
+ break;
45
+ await new Promise((resolve) => setTimeout(resolve, 100));
46
+ adapter = await connect(socketPath, 500);
47
+ if (adapter)
48
+ break;
49
+ }
50
+ if (!adapter) {
51
+ if (child.exitCode === null && child.signalCode === null)
52
+ child.kill("SIGTERM");
53
+ throw new Error(`Codex app-server did not start at ${socketPath}${stderr ? `: ${stderr.trim()}` : ""}`);
54
+ }
55
+ return {
56
+ adapter,
57
+ socketPath,
58
+ async close() {
59
+ await adapter.close();
60
+ if (child.exitCode !== null || child.signalCode !== null)
61
+ return;
62
+ const closed = once(child, "close").then(() => undefined);
63
+ child.kill("SIGTERM");
64
+ let timer;
65
+ const stopped = await Promise.race([
66
+ closed.then(() => true),
67
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), 1_000); }),
68
+ ]);
69
+ if (timer)
70
+ clearTimeout(timer);
71
+ if (!stopped && child.exitCode === null && child.signalCode === null) {
72
+ child.kill("SIGKILL");
73
+ await closed;
74
+ }
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { z } from "zod";
6
+ const RemoteConfigFileSchema = z.object({
7
+ version: z.literal(1),
8
+ host: z.string().min(1),
9
+ port: z.number().int().min(1).max(65_535),
10
+ allowedOrigins: z.array(z.string().url()).max(16),
11
+ }).strict();
12
+ export function remotePaths(root = process.env.NOWCREW_REMOTE_HOME ?? join(homedir(), ".nowcrew", "remote")) {
13
+ return {
14
+ root,
15
+ config: join(root, "config.json"),
16
+ token: join(root, "token"),
17
+ lock: join(root, "gateway.lock.json"),
18
+ codexSocket: join(root, "codex", "app-server.sock"),
19
+ };
20
+ }
21
+ async function readOptional(path) {
22
+ try {
23
+ return await readFile(path, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT")
27
+ return null;
28
+ throw error;
29
+ }
30
+ }
31
+ async function writeOwnerOnly(path, contents) {
32
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
33
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
34
+ const file = await open(tmp, "wx", 0o600);
35
+ try {
36
+ await file.writeFile(contents, "utf8");
37
+ await file.sync();
38
+ }
39
+ finally {
40
+ await file.close();
41
+ }
42
+ await rename(tmp, path);
43
+ }
44
+ export async function loadOrCreateRemoteConfig(overrides = {}) {
45
+ const paths = remotePaths(overrides.root);
46
+ await mkdir(paths.root, { recursive: true, mode: 0o700 });
47
+ const existingConfig = await readOptional(paths.config);
48
+ const parsed = existingConfig === null
49
+ ? { version: 1, host: "127.0.0.1", port: 4317, allowedOrigins: [] }
50
+ : RemoteConfigFileSchema.parse(JSON.parse(existingConfig));
51
+ const fileConfig = RemoteConfigFileSchema.parse({
52
+ ...parsed,
53
+ ...(overrides.host === undefined ? {} : { host: overrides.host }),
54
+ ...(overrides.port === undefined ? {} : { port: overrides.port }),
55
+ });
56
+ if (existingConfig === null || fileConfig.host !== parsed.host || fileConfig.port !== parsed.port) {
57
+ await writeOwnerOnly(paths.config, `${JSON.stringify(fileConfig, null, 2)}\n`);
58
+ }
59
+ const existingToken = (await readOptional(paths.token))?.trim();
60
+ const token = existingToken && /^[A-Za-z0-9_-]{43}$/.test(existingToken)
61
+ ? existingToken
62
+ : randomBytes(32).toString("base64url");
63
+ if (token !== existingToken)
64
+ await writeOwnerOnly(paths.token, `${token}\n`);
65
+ return { ...fileConfig, token, paths };
66
+ }
67
+ export async function updateRemoteBinding(config, input) {
68
+ const next = RemoteConfigFileSchema.parse({
69
+ version: 1,
70
+ host: input.host,
71
+ port: input.port,
72
+ allowedOrigins: input.allowedOrigins ?? config.allowedOrigins,
73
+ });
74
+ await writeOwnerOnly(config.paths.config, `${JSON.stringify(next, null, 2)}\n`);
75
+ return { ...next, token: config.token, paths: config.paths };
76
+ }
77
+ export async function writeRemoteLock(path, value) {
78
+ await writeOwnerOnly(path, `${JSON.stringify(value, null, 2)}\n`);
79
+ }
80
+ // Exported for tests that verify atomic replacement without duplicating filesystem logic.
81
+ export async function writeRemoteConfigForTest(path, contents) {
82
+ await writeOwnerOnly(path, contents);
83
+ }