codexteer 0.14.1
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/LICENSE +21 -0
- package/README.md +519 -0
- package/assets/send-pururu.wav +0 -0
- package/bin/codexteer-wrapper.mjs +10 -0
- package/bin/codexteer.mjs +5 -0
- package/docs/distribution.md +186 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/node_modules/ws/lib/receiver.js +743 -0
- package/node_modules/ws/lib/sender.js +607 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +562 -0
- package/node_modules/ws/lib/websocket.js +1407 -0
- package/node_modules/ws/package.json +74 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/package.json +41 -0
- package/scripts/accessibility.applescript +43 -0
- package/scripts/inspect.applescript +72 -0
- package/scripts/send.applescript +45 -0
- package/src/app-server.mjs +117 -0
- package/src/bridge.mjs +79 -0
- package/src/checkpoint.mjs +128 -0
- package/src/cli.mjs +358 -0
- package/src/compatibility.mjs +115 -0
- package/src/desktop.mjs +207 -0
- package/src/directive.mjs +39 -0
- package/src/distribution.mjs +192 -0
- package/src/evidence.mjs +33 -0
- package/src/help.mjs +145 -0
- package/src/journal.mjs +120 -0
- package/src/launcher.mjs +145 -0
- package/src/monitor.mjs +123 -0
- package/src/observe.mjs +185 -0
- package/src/paged-read.mjs +228 -0
- package/src/prompt.mjs +51 -0
- package/src/resource.mjs +70 -0
- package/src/rpc.mjs +162 -0
- package/src/runner.mjs +32 -0
- package/src/runtime.mjs +130 -0
- package/src/settings.mjs +49 -0
- package/src/sound.mjs +16 -0
- package/src/store.mjs +78 -0
- package/src/subscription.mjs +118 -0
- package/src/supervise.mjs +57 -0
- package/src/thread-id.mjs +35 -0
- package/src/thread-store.mjs +72 -0
- package/src/version.mjs +19 -0
- package/src/wrapper.mjs +149 -0
package/src/rpc.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
import { VERSION } from "./help.mjs";
|
|
6
|
+
|
|
7
|
+
export class RpcFailure extends Error {
|
|
8
|
+
constructor(message, { code = "CONNECTION_FAILED", uncertain = false, rpcCode } = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.uncertain = uncertain;
|
|
12
|
+
this.rpcCode = rpcCode;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function connectionCode(error) {
|
|
17
|
+
if (["EACCES", "EPERM"].includes(error?.code)) return "PERMISSION_DENIED";
|
|
18
|
+
if (error?.code === "ETIMEDOUT") return "TIMEOUT";
|
|
19
|
+
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "ECONNABORTED", "EPIPE"].includes(error?.code)) return "CONNECTION_FAILED";
|
|
20
|
+
return "PROTOCOL_ERROR";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function connectSocket(socketPath, { timeoutMs = 8000, signal } = {}) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
if (signal?.aborted) { reject(new RpcFailure("App Server connection cancelled.", { code: "CANCELLED" })); return; }
|
|
26
|
+
const socket = new WebSocket("ws://localhost/rpc", {
|
|
27
|
+
createConnection: () => net.createConnection(socketPath),
|
|
28
|
+
perMessageDeflate: false,
|
|
29
|
+
maxPayload: 64 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
let settled = false;
|
|
32
|
+
const finish = error => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
signal?.removeEventListener("abort", cancel);
|
|
37
|
+
if (error) { socket.terminate(); reject(error); }
|
|
38
|
+
else resolve(socket);
|
|
39
|
+
};
|
|
40
|
+
const cancel = () => finish(new RpcFailure("App Server connection cancelled.", { code: "CANCELLED" }));
|
|
41
|
+
const timer = setTimeout(() => finish(new RpcFailure("App Server connection timed out.", { code: "TIMEOUT" })), timeoutMs);
|
|
42
|
+
// Keep an error listener even after connection so a peer reset never throws globally.
|
|
43
|
+
socket.on("error", () => {});
|
|
44
|
+
socket.once("error", error => finish(new RpcFailure("Could not connect to the shared App Server.", { code: connectionCode(error) })));
|
|
45
|
+
socket.once("close", () => finish(new RpcFailure("App Server connection closed before initialization.")));
|
|
46
|
+
socket.once("open", () => finish());
|
|
47
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
48
|
+
if (signal?.aborted) cancel();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RpcClient extends EventEmitter {
|
|
53
|
+
constructor(socket, { timeoutMs = 8000 } = {}) {
|
|
54
|
+
super();
|
|
55
|
+
this.socket = socket;
|
|
56
|
+
this.timeoutMs = timeoutMs;
|
|
57
|
+
this.pending = new Map();
|
|
58
|
+
this.failure = null;
|
|
59
|
+
socket.on("message", (data, binary) => {
|
|
60
|
+
let message;
|
|
61
|
+
try {
|
|
62
|
+
if (binary) throw new Error();
|
|
63
|
+
message = JSON.parse(data.toString());
|
|
64
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error();
|
|
65
|
+
} catch {
|
|
66
|
+
this.abort("App Server returned an invalid protocol message.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.emit("message", message);
|
|
70
|
+
if (Object.hasOwn(message, "method")) {
|
|
71
|
+
if (typeof message.method !== "string" || !message.method) {
|
|
72
|
+
this.abort("App Server returned an invalid protocol method.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.emit(message.id == null ? "notification" : "serverRequest", message);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const request = this.pending.get(message.id);
|
|
79
|
+
if (!request) return;
|
|
80
|
+
clearTimeout(request.timer);
|
|
81
|
+
this.pending.delete(message.id);
|
|
82
|
+
if (message.error && typeof message.error === "object" && !Array.isArray(message.error)
|
|
83
|
+
&& Number.isSafeInteger(message.error.code) && !Object.hasOwn(message, "result")) {
|
|
84
|
+
request.reject(new RpcFailure(`${request.method} was rejected by App Server.`, {
|
|
85
|
+
code: "RPC_REJECTED", rpcCode: message.error.code,
|
|
86
|
+
}));
|
|
87
|
+
} else if (Object.hasOwn(message, "result") && message.error == null) request.resolve(message.result);
|
|
88
|
+
else {
|
|
89
|
+
request.reject(new RpcFailure("App Server returned an invalid response.", { code: "PROTOCOL_ERROR", uncertain: request.mutation }));
|
|
90
|
+
this.abort("App Server returned an invalid response.");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
socket.on("close", () => this.rejectPending("App Server connection closed."));
|
|
94
|
+
socket.on("error", error => this.abort("App Server connection failed.", connectionCode(error)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static async connect(socketPath, options = {}) {
|
|
98
|
+
const client = new RpcClient(await connectSocket(socketPath, options), options);
|
|
99
|
+
const cancel = () => client.abort("App Server initialization cancelled.", "CANCELLED");
|
|
100
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
101
|
+
try {
|
|
102
|
+
if (options.signal?.aborted) cancel();
|
|
103
|
+
client.initialization = await client.request("initialize", {
|
|
104
|
+
clientInfo: { name: "codexteer", title: "Codexteer", version: VERSION },
|
|
105
|
+
capabilities: { experimentalApi: true },
|
|
106
|
+
});
|
|
107
|
+
if (!client.initialization || typeof client.initialization !== "object" || Array.isArray(client.initialization)) {
|
|
108
|
+
throw new RpcFailure("App Server returned invalid initialization data.", { code: "PROTOCOL_ERROR" });
|
|
109
|
+
}
|
|
110
|
+
client.notify("initialized", {});
|
|
111
|
+
return client;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
client.close();
|
|
114
|
+
throw error;
|
|
115
|
+
} finally { options.signal?.removeEventListener("abort", cancel); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
request(method, params = {}, { mutation = false } = {}) {
|
|
119
|
+
if (this.socket.readyState !== WebSocket.OPEN) {
|
|
120
|
+
return Promise.reject(new RpcFailure(this.failure?.message ?? "App Server is not connected.", { code: this.failure?.code }));
|
|
121
|
+
}
|
|
122
|
+
const id = randomUUID();
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.pending.delete(id);
|
|
126
|
+
reject(new RpcFailure(`${method} timed out.`, { code: "TIMEOUT", uncertain: mutation }));
|
|
127
|
+
}, this.timeoutMs);
|
|
128
|
+
this.pending.set(id, { resolve, reject, timer, method, mutation });
|
|
129
|
+
this.socket.send(JSON.stringify({ id, method, params }), (error) => {
|
|
130
|
+
if (error) this.rejectPending("App Server connection failed.");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
notify(method, params) {
|
|
136
|
+
this.socket.send(JSON.stringify({ method, params }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
rejectPending(message, code = "CONNECTION_FAILED") {
|
|
140
|
+
for (const request of this.pending.values()) {
|
|
141
|
+
clearTimeout(request.timer);
|
|
142
|
+
request.reject(new RpcFailure(this.failure?.message ?? message, { code: this.failure?.code ?? code, uncertain: request.mutation }));
|
|
143
|
+
}
|
|
144
|
+
this.pending.clear();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
abort(message, code = "PROTOCOL_ERROR") {
|
|
148
|
+
this.failure ??= { message, code };
|
|
149
|
+
this.rejectPending(message, code);
|
|
150
|
+
this.socket.terminate();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
close() {
|
|
154
|
+
this.rejectPending("App Server client closed.");
|
|
155
|
+
if (this.socket.readyState === WebSocket.OPEN) {
|
|
156
|
+
this.socket.close();
|
|
157
|
+
const timer = setTimeout(() => this.socket.terminate(), 500);
|
|
158
|
+
timer.unref();
|
|
159
|
+
this.socket.once("close", () => clearTimeout(timer));
|
|
160
|
+
} else if (this.socket.readyState !== WebSocket.CLOSED) this.socket.terminate();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function runCommand(argv, { cwd, timeoutMs = 0, onStart = async () => {}, signal: abortSignal } = {}) {
|
|
4
|
+
if (!argv.length || !argv[0] || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 86400000) throw new Error("A command and timeout-ms between 0 and 86400000 are required.");
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const started = new Date().toISOString();
|
|
7
|
+
let output = "", truncated = false, timedOut = false, failure, killer, timer, startUpdate = Promise.resolve(), closed = false;
|
|
8
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, shell: false, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
9
|
+
const terminate = signal => {
|
|
10
|
+
if (!child.pid || closed) return;
|
|
11
|
+
try { process.kill(-child.pid, signal); } catch (e) { if (e.code !== "ESRCH") failure ??= e; }
|
|
12
|
+
};
|
|
13
|
+
const stop = () => { terminate("SIGTERM"); killer ??= setTimeout(() => terminate("SIGKILL"), 1000); };
|
|
14
|
+
const interrupt = () => { failure ??= new Error("Command interrupted."); stop(); };
|
|
15
|
+
process.on("SIGINT", interrupt); process.on("SIGTERM", interrupt);
|
|
16
|
+
abortSignal?.addEventListener("abort", interrupt, { once: true });
|
|
17
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
18
|
+
stream.setEncoding("utf8"); stream.on("data", data => { output += data; if (output.length > 32768) { truncated = true; output = output.slice(-32768); } });
|
|
19
|
+
}
|
|
20
|
+
child.once("spawn", () => { if (abortSignal?.aborted) interrupt(); startUpdate = Promise.resolve(onStart(child.pid)).catch(e => { failure = e; stop(); }); });
|
|
21
|
+
child.once("error", e => { failure = e; });
|
|
22
|
+
if (timeoutMs) timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
|
|
23
|
+
child.once("close", async (code, signal) => {
|
|
24
|
+
closed = true;
|
|
25
|
+
clearTimeout(timer); clearTimeout(killer); process.off("SIGINT", interrupt); process.off("SIGTERM", interrupt);
|
|
26
|
+
abortSignal?.removeEventListener("abort", interrupt);
|
|
27
|
+
await startUpdate;
|
|
28
|
+
const result = { pid: child.pid ?? null, exit_code: code, signal, timed_out: timedOut, started_at: started, completed_at: new Date().toISOString(), output_tail: output, output_truncated: truncated, error: failure?.message ?? null };
|
|
29
|
+
resolve(result);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile, realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const runtimeFailure = (message, code) => Object.assign(new Error(message), { code });
|
|
7
|
+
|
|
8
|
+
export function codexHome() {
|
|
9
|
+
return path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function runtimePaths(home = codexHome()) {
|
|
13
|
+
const canonicalHome = await canonicalPath(path.resolve(home));
|
|
14
|
+
const key = createHash("sha256").update(canonicalHome).digest("hex").slice(0, 20);
|
|
15
|
+
// macOS sockaddr_un allows only 104 bytes, including the trailing NUL.
|
|
16
|
+
// Keep the namespace stable so codexteer discovers already-running wrappers.
|
|
17
|
+
const root = `/private/tmp/codex-steer-${process.getuid()}`;
|
|
18
|
+
const directory = path.join(root, key);
|
|
19
|
+
const lease = path.join(directory, "lease");
|
|
20
|
+
return { home: canonicalHome, root, directory, lease, state: path.join(lease, "state.json"), socket: path.join(lease, "rpc.sock"), control: path.join(lease, "ui.sock") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function canonicalPath(input) {
|
|
24
|
+
try { return await realpath(input); } catch (error) {
|
|
25
|
+
if (error.code !== "ENOENT" || path.dirname(input) === input) throw error;
|
|
26
|
+
return path.join(await canonicalPath(path.dirname(input)), path.basename(input));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function checkOwned(file, type) {
|
|
31
|
+
const info = await lstat(file);
|
|
32
|
+
if (info.uid !== process.getuid() || (info.mode & 0o077) !== 0 || info.isSymbolicLink()
|
|
33
|
+
|| (type === "directory" ? !info.isDirectory() : type === "socket" ? !info.isSocket() : !info.isFile())) {
|
|
34
|
+
throw runtimeFailure("Shared App Server runtime has unsafe ownership, permissions, or file type.", "RUNTIME_UNSAFE");
|
|
35
|
+
}
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isAlive(pid) {
|
|
40
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
41
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
42
|
+
if (error.code === "ESRCH") return false;
|
|
43
|
+
// An inspection denial is not evidence of a dead process.
|
|
44
|
+
throw runtimeFailure("Cannot verify the shared App Server process. Run this command from your terminal.", "PERMISSION_DENIED");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readRuntime(paths) {
|
|
49
|
+
for (const dir of [paths.root, paths.directory, paths.lease]) await checkOwned(dir, "directory");
|
|
50
|
+
await checkOwned(paths.state, "file");
|
|
51
|
+
let state;
|
|
52
|
+
try { state = JSON.parse(await readFile(paths.state, "utf8")); }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof SyntaxError) throw runtimeFailure("Invalid shared App Server runtime record.", "RUNTIME_INVALID");
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
if (state?.schema !== 1 || state.codex_home !== paths.home || typeof state.instance !== "string" || !/^[0-9a-f-]{36}$/.test(state.instance)
|
|
58
|
+
|| !Number.isSafeInteger(state.pid) || state.pid <= 0) throw runtimeFailure("Invalid shared App Server runtime record.", "RUNTIME_INVALID");
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function verifyControlEndpoint(paths) {
|
|
63
|
+
try { await checkOwned(paths.control, "socket"); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === "ENOENT") throw runtimeFailure("Desktop subscription is unavailable. Only --new-turn requires this endpoint.", "DESKTOP_SUBSCRIPTION_UNAVAILABLE");
|
|
66
|
+
if (["EACCES", "EPERM"].includes(error.code)) throw runtimeFailure("Cannot inspect the Desktop subscription endpoint. Check access permissions.", "PERMISSION_DENIED");
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function discoverRuntime(home) {
|
|
72
|
+
const paths = await runtimePaths(home);
|
|
73
|
+
try {
|
|
74
|
+
const state = await readRuntime(paths);
|
|
75
|
+
if (!isAlive(state.pid) || !isAlive(state.server_pid) || !state.desktop_connected) {
|
|
76
|
+
throw runtimeFailure("Shared App Server is not ready. Finish current tasks, quit Desktop, then run: codexteer desktop start", "RUNTIME_NOT_READY");
|
|
77
|
+
}
|
|
78
|
+
await checkOwned(paths.socket, "socket");
|
|
79
|
+
return { paths, state };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === "ENOENT") throw runtimeFailure("Shared App Server is unavailable. Finish current tasks, quit Desktop, then run: codexteer desktop start", "RUNTIME_UNAVAILABLE");
|
|
82
|
+
if (["EACCES", "EPERM"].includes(error.code)) throw runtimeFailure("Cannot inspect the shared App Server runtime. Check access permissions.", "PERMISSION_DENIED");
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function claimRuntime(home, details = {}) {
|
|
88
|
+
const paths = await runtimePaths(home);
|
|
89
|
+
for (const dir of [paths.root, paths.directory]) {
|
|
90
|
+
await mkdir(dir, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST") throw error; });
|
|
91
|
+
await checkOwned(dir, "directory");
|
|
92
|
+
}
|
|
93
|
+
try { await mkdir(paths.lease, { mode: 0o700 }); } catch (error) {
|
|
94
|
+
if (error.code !== "EEXIST") throw error;
|
|
95
|
+
const old = await readRuntime(paths);
|
|
96
|
+
if (isAlive(old.pid) || isAlive(old.server_pid)) throw new Error("A shared App Server already owns this CODEX_HOME.");
|
|
97
|
+
const recovery = path.join(paths.directory, `recover-${old.instance}`);
|
|
98
|
+
await mkdir(recovery, { mode: 0o700 });
|
|
99
|
+
try {
|
|
100
|
+
const current = await readRuntime(paths);
|
|
101
|
+
if (current.instance !== old.instance || isAlive(current.pid) || isAlive(current.server_pid)) {
|
|
102
|
+
throw new Error("Shared App Server ownership changed during startup. Retry once startup finishes.");
|
|
103
|
+
}
|
|
104
|
+
const stale = path.join(recovery, "stale");
|
|
105
|
+
await rename(paths.lease, stale);
|
|
106
|
+
await mkdir(paths.lease, { mode: 0o700 });
|
|
107
|
+
} finally { await rm(recovery, { recursive: true, force: true }); }
|
|
108
|
+
}
|
|
109
|
+
let state = { schema: 1, instance: randomUUID(), codex_home: paths.home, pid: process.pid, server_pid: null, desktop_connected: false, ...details };
|
|
110
|
+
let writes = Promise.resolve();
|
|
111
|
+
const update = patch => {
|
|
112
|
+
state = { ...state, ...patch };
|
|
113
|
+
const snapshot = JSON.stringify(state);
|
|
114
|
+
writes = writes.then(async () => {
|
|
115
|
+
const temp = `${paths.state}.${state.instance}.tmp`;
|
|
116
|
+
await writeFile(temp, snapshot, { mode: 0o600 });
|
|
117
|
+
await rename(temp, paths.state);
|
|
118
|
+
});
|
|
119
|
+
return writes;
|
|
120
|
+
};
|
|
121
|
+
await update({});
|
|
122
|
+
return {
|
|
123
|
+
paths, update,
|
|
124
|
+
async release() {
|
|
125
|
+
await writes.catch(() => {});
|
|
126
|
+
const current = await readRuntime(paths).catch(() => null);
|
|
127
|
+
if (current?.instance === state.instance) await rm(paths.lease, { recursive: true, force: true });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
follow_up_mode: "steer",
|
|
7
|
+
composer_enter_behavior: "enter",
|
|
8
|
+
submit_shortcut: "plain",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function parseDesktopSettings(source) {
|
|
12
|
+
let section = "";
|
|
13
|
+
const values = {};
|
|
14
|
+
|
|
15
|
+
for (const rawLine of source.split(/\r?\n/u)) {
|
|
16
|
+
const line = rawLine.trim();
|
|
17
|
+
const sectionMatch = line.match(/^\[([^\]]+)\]$/u);
|
|
18
|
+
if (sectionMatch) {
|
|
19
|
+
section = sectionMatch[1];
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (section !== "desktop" || line === "" || line.startsWith("#")) continue;
|
|
23
|
+
|
|
24
|
+
const valueMatch = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/u);
|
|
25
|
+
if (valueMatch) values[valueMatch[1]] = valueMatch[2];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const followUpMode = values.followUpQueueMode === "queue" ? "queue" : "steer";
|
|
29
|
+
const composerEnterBehavior = ["cmdIfMultiline", "cmdAlways"].includes(values.composerEnterBehavior)
|
|
30
|
+
? values.composerEnterBehavior
|
|
31
|
+
: "enter";
|
|
32
|
+
const submitShortcut = followUpMode === "queue"
|
|
33
|
+
? composerEnterBehavior === "enter" ? "command" : "command-shift"
|
|
34
|
+
: "plain";
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
follow_up_mode: followUpMode,
|
|
38
|
+
composer_enter_behavior: composerEnterBehavior,
|
|
39
|
+
submit_shortcut: submitShortcut,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readDesktopSettings(configPath = path.join(
|
|
44
|
+
process.env.CODEX_HOME || path.join(homedir(), ".codex"),
|
|
45
|
+
"config.toml",
|
|
46
|
+
)) {
|
|
47
|
+
if (!existsSync(configPath)) return { ...DEFAULTS };
|
|
48
|
+
return parseDesktopSettings(readFileSync(configPath, "utf8"));
|
|
49
|
+
}
|
package/src/sound.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export const SEND_NOTIFICATION_SOUND = fileURLToPath(new URL("../assets/send-pururu.wav", import.meta.url));
|
|
5
|
+
|
|
6
|
+
export function playSendSound(receipt, { run = spawnSync, platform = process.platform } = {}) {
|
|
7
|
+
if (receipt.dry_run) return { played: false, reason: "dry_run" };
|
|
8
|
+
if (receipt.sent !== true || receipt.delivery_status !== "accepted") return { played: false, reason: "not_accepted" };
|
|
9
|
+
if (platform !== "darwin") return { played: false, reason: "unsupported_platform" };
|
|
10
|
+
// Audio failure must not turn an accepted send into a retryable failure.
|
|
11
|
+
try {
|
|
12
|
+
const result = run("/usr/bin/afplay", [SEND_NOTIFICATION_SOUND], { encoding: "utf8", timeout: 5000 });
|
|
13
|
+
if (!result.error && result.status === 0) return { played: true };
|
|
14
|
+
} catch { /* Preserve the send receipt even if playback cannot start. */ }
|
|
15
|
+
return { played: false, reason: "playback_failed" };
|
|
16
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, writeFile, rename, rm, readdir, link } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { codexHome, isAlive } from "./runtime.mjs";
|
|
5
|
+
|
|
6
|
+
const valid = value => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(value);
|
|
7
|
+
// The persisted namespace predates the codexteer rename; keep existing records.
|
|
8
|
+
export function storePath(bucket, id, home = codexHome()) {
|
|
9
|
+
if (!valid(bucket) || (id != null && !valid(id))) throw new Error("Invalid local record identifier.");
|
|
10
|
+
return path.join(home, "codex-steer", bucket, ...(id == null ? [] : [`${id}.json`]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function owned(file, directory = false) {
|
|
14
|
+
const s = await lstat(file);
|
|
15
|
+
if (s.uid !== process.getuid() || s.isSymbolicLink() || (s.mode & 0o077) || (directory ? !s.isDirectory() : !s.isFile())) throw new Error("Unsafe local state ownership, permissions, or file type.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function directory(bucket, home, create = false) {
|
|
19
|
+
const root = path.join(home, "codex-steer"), target = storePath(bucket, null, home);
|
|
20
|
+
for (const p of [root, target]) {
|
|
21
|
+
if (create) await mkdir(p, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
|
|
22
|
+
await owned(p, true);
|
|
23
|
+
}
|
|
24
|
+
return target;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readRecord(bucket, id, { home = codexHome() } = {}) {
|
|
28
|
+
try {
|
|
29
|
+
await directory(bucket, home);
|
|
30
|
+
const file = storePath(bucket, id, home); await owned(file);
|
|
31
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
32
|
+
} catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeRecord(bucket, id, data, { home = codexHome() } = {}) {
|
|
36
|
+
await directory(bucket, home, true);
|
|
37
|
+
const target = storePath(bucket, id, home), temp = `${target}.${randomUUID()}.tmp`;
|
|
38
|
+
await owned(target).catch(e => { if (e.code !== "ENOENT") throw e; });
|
|
39
|
+
try { await writeFile(temp, JSON.stringify(data) + "\n", { mode: 0o600, flag: "wx" }); await rename(temp, target); }
|
|
40
|
+
finally { await rm(temp, { force: true }); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function listRecords(bucket, options = {}) {
|
|
44
|
+
const home = options.home ?? codexHome(); let dir;
|
|
45
|
+
try { dir = await directory(bucket, home); } catch (e) { if (e.code === "ENOENT") return []; throw e; }
|
|
46
|
+
const files = (await readdir(dir)).filter(x => x.endsWith(".json"));
|
|
47
|
+
return Promise.all(files.map(x => readRecord(bucket, x.slice(0, -5), { home })));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function withStoreLock(key, fn, { home = codexHome() } = {}) {
|
|
51
|
+
await directory("locks", home, true);
|
|
52
|
+
const file = storePath("locks", key, home), token = randomUUID(), temp = `${file}.${token}.tmp`;
|
|
53
|
+
await writeFile(temp, JSON.stringify({ token, pid: process.pid }), { mode: 0o600, flag: "wx" });
|
|
54
|
+
let acquired = false;
|
|
55
|
+
let outcome;
|
|
56
|
+
try {
|
|
57
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
58
|
+
try { await link(temp, file); acquired = true; break; } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
59
|
+
const old = await readRecord("locks", key, { home });
|
|
60
|
+
if (old && !isAlive(old.pid)) {
|
|
61
|
+
const recovery = `${file}.recover`;
|
|
62
|
+
try { await mkdir(recovery, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; break; }
|
|
63
|
+
try {
|
|
64
|
+
const current = await readRecord("locks", key, { home });
|
|
65
|
+
if (current?.token === old.token && !isAlive(current.pid)) await rm(file);
|
|
66
|
+
} finally { await rm(recovery, { recursive: true }); }
|
|
67
|
+
} else break;
|
|
68
|
+
}
|
|
69
|
+
if (!acquired) throw Object.assign(new Error("Another operation owns this local record. Retry after it finishes."), { code: "RECORD_BUSY" });
|
|
70
|
+
outcome = await fn();
|
|
71
|
+
return outcome;
|
|
72
|
+
} finally {
|
|
73
|
+
try {
|
|
74
|
+
if (acquired && (await readRecord("locks", key, { home }))?.token === token) await rm(file);
|
|
75
|
+
await rm(temp, { force: true });
|
|
76
|
+
} catch { if (outcome && typeof outcome === "object") outcome.state_cleanup_required = true; }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { chmod } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { normalizeThreadId } from "./thread-id.mjs";
|
|
5
|
+
|
|
6
|
+
// This is our own owner-only socket, not Desktop's private/authenticated MCP pipe.
|
|
7
|
+
// It exposes only thread subscription, never generic RPC or approval decisions.
|
|
8
|
+
export class DesktopSubscriptions {
|
|
9
|
+
constructor(socket, { timeoutMs = 8000 } = {}) {
|
|
10
|
+
this.socket = socket;
|
|
11
|
+
this.timeoutMs = timeoutMs;
|
|
12
|
+
this.pending = new Map();
|
|
13
|
+
this.prefix = `codexteer-subscribe-${randomUUID()}-`;
|
|
14
|
+
this.ready = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
consume(message) {
|
|
18
|
+
if (message.method || typeof message.id !== "string" || !message.id.startsWith(this.prefix)) return true;
|
|
19
|
+
// Late replies to timed-out helper calls also belong to the helper.
|
|
20
|
+
if (!this.pending.has(message.id)) return false;
|
|
21
|
+
const request = this.pending.get(message.id);
|
|
22
|
+
this.pending.delete(message.id);
|
|
23
|
+
clearTimeout(request.timer);
|
|
24
|
+
if (message.error || message.result?.thread?.id !== request.threadId) {
|
|
25
|
+
request.reject(new Error("Desktop could not subscribe to the target task."));
|
|
26
|
+
} else request.resolve();
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ensure(threadId) {
|
|
31
|
+
normalizeThreadId(threadId);
|
|
32
|
+
if (!this.ready) return Promise.reject(new Error("Desktop has not initialized its App Server connection."));
|
|
33
|
+
const id = this.prefix + randomUUID();
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
this.pending.delete(id);
|
|
37
|
+
reject(new Error("Desktop subscription timed out. No message was sent."));
|
|
38
|
+
}, this.timeoutMs);
|
|
39
|
+
this.pending.set(id, { threadId, timer, resolve, reject });
|
|
40
|
+
this.socket.send(JSON.stringify({ id, method: "thread/resume", params: { threadId, excludeTurns: true } }), error => {
|
|
41
|
+
if (error && this.pending.delete(id)) { clearTimeout(timer); reject(new Error("Desktop subscription connection failed.")); }
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
close() {
|
|
47
|
+
this.ready = false;
|
|
48
|
+
for (const request of this.pending.values()) {
|
|
49
|
+
clearTimeout(request.timer);
|
|
50
|
+
request.reject(new Error("Desktop connection closed before subscribing."));
|
|
51
|
+
}
|
|
52
|
+
this.pending.clear();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function serveSubscriptions(socketPath, subscriptions) {
|
|
57
|
+
const sockets = new Set();
|
|
58
|
+
const server = net.createServer(socket => {
|
|
59
|
+
sockets.add(socket);
|
|
60
|
+
socket.on("close", () => sockets.delete(socket));
|
|
61
|
+
socket.on("error", () => {});
|
|
62
|
+
socket.setTimeout(10000, () => socket.destroy());
|
|
63
|
+
let text = "";
|
|
64
|
+
let handled = false;
|
|
65
|
+
socket.setEncoding("utf8");
|
|
66
|
+
socket.on("data", chunk => {
|
|
67
|
+
if (handled) return;
|
|
68
|
+
text += chunk;
|
|
69
|
+
if (Buffer.byteLength(text) > 1024) { socket.destroy(); return; }
|
|
70
|
+
if (!text.includes("\n")) return;
|
|
71
|
+
handled = true;
|
|
72
|
+
Promise.resolve().then(async () => {
|
|
73
|
+
const request = JSON.parse(text.trim());
|
|
74
|
+
if (request.method !== "subscribe" || Object.keys(request).some(key => !["method", "thread_id"].includes(key))) throw new Error();
|
|
75
|
+
const threadId = normalizeThreadId(request.thread_id);
|
|
76
|
+
await subscriptions.ensure(threadId);
|
|
77
|
+
socket.end(JSON.stringify({ ok: true, thread_id: threadId }) + "\n");
|
|
78
|
+
}).catch(() => socket.end(JSON.stringify({ ok: false, error: "Desktop subscription failed. No message was sent." }) + "\n"));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); });
|
|
82
|
+
await chmod(socketPath, 0o600);
|
|
83
|
+
return async () => {
|
|
84
|
+
subscriptions.close();
|
|
85
|
+
for (const socket of sockets) socket.destroy();
|
|
86
|
+
await new Promise(resolve => server.close(resolve));
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function ensureDesktopSubscription(socketPath, threadId, { timeoutMs = 10000 } = {}) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const socket = net.createConnection(socketPath);
|
|
93
|
+
let text = "";
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = error => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
socket.destroy();
|
|
99
|
+
if (error) reject(new Error("Could not establish the Desktop task subscription. No message was sent."));
|
|
100
|
+
else resolve();
|
|
101
|
+
};
|
|
102
|
+
socket.setTimeout(timeoutMs, () => finish(true));
|
|
103
|
+
socket.on("error", () => finish(true));
|
|
104
|
+
socket.on("end", () => finish(true));
|
|
105
|
+
socket.on("close", () => finish(true));
|
|
106
|
+
socket.setEncoding("utf8");
|
|
107
|
+
socket.on("connect", () => socket.write(JSON.stringify({ method: "subscribe", thread_id: threadId }) + "\n"));
|
|
108
|
+
socket.on("data", chunk => {
|
|
109
|
+
text += chunk;
|
|
110
|
+
if (Buffer.byteLength(text) > 2048) { finish(true); return; }
|
|
111
|
+
if (!text.includes("\n")) return;
|
|
112
|
+
try {
|
|
113
|
+
const response = JSON.parse(text.trim());
|
|
114
|
+
finish(!(response.ok === true && response.thread_id === threadId));
|
|
115
|
+
} catch { finish(true); }
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { constants } from "node:os";
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { prepareDeployment } from "./distribution.mjs";
|
|
8
|
+
import { normalizeThreadId } from "./thread-id.mjs";
|
|
9
|
+
import { codexHome } from "./runtime.mjs";
|
|
10
|
+
|
|
11
|
+
function shellArgument(value) {
|
|
12
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
|
13
|
+
throw Object.assign(new Error("Cannot generate supervision commands from a path containing control characters. Use a path without them and generate the prompt again."), { code: "SUPERVISION_PATH_UNSAFE" });
|
|
14
|
+
}
|
|
15
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function prepareSupervisorPrompt(threadInput) {
|
|
19
|
+
const threadId = normalizeThreadId(threadInput);
|
|
20
|
+
const node = { path: await realpath(process.execPath), version: process.version };
|
|
21
|
+
const home = codexHome();
|
|
22
|
+
shellArgument(node.path); shellArgument(home);
|
|
23
|
+
const deployment = await prepareDeployment(home);
|
|
24
|
+
// Bind the validated canonical profile as well as the saved executable.
|
|
25
|
+
const command = `CODEX_HOME=${shellArgument(deployment.codex_home)} ${shellArgument(node.path)} ${shellArgument(path.join(deployment.directory, "bin/codexteer.mjs"))} --require-node-version ${shellArgument(node.version)}`;
|
|
26
|
+
// Render with the saved distribution's implementation as well as its CLI.
|
|
27
|
+
// An update to the source/cache after placement cannot mix prompt and code.
|
|
28
|
+
const { supervisorPrompt } = await import(pathToFileURL(path.join(deployment.directory, "src/prompt.mjs")).href);
|
|
29
|
+
return { ...supervisorPrompt(threadId, command), deployment, node };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function launchFailure(agent, error) {
|
|
33
|
+
return Object.assign(new Error(`Could not start ${agent} (${error.code ?? "START_FAILED"}). Check that it is installed and executable on PATH.`), { code: error.code ?? "START_FAILED" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function superviseAgent(threadInput, { agent, agentArgs = [] } = {}) {
|
|
37
|
+
if (agent !== "claude") throw new Error("supervise requires --agent claude. See codexteer help supervise.");
|
|
38
|
+
const { prompt } = await prepareSupervisorPrompt(threadInput);
|
|
39
|
+
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
// Keep forwarded argv intact. The agent's own terminator keeps its variadic
|
|
43
|
+
// options from consuming the generated prompt as another option value.
|
|
44
|
+
child = spawn(agent, [...agentArgs, "--", prompt], { shell: false, stdio: "inherit" });
|
|
45
|
+
} catch (error) { throw launchFailure(agent, error); }
|
|
46
|
+
|
|
47
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
48
|
+
const handlers = signals.map(signal => () => {
|
|
49
|
+
if (child.exitCode === null && child.signalCode === null) child.kill(signal);
|
|
50
|
+
});
|
|
51
|
+
signals.forEach((signal, index) => process.on(signal, handlers[index]));
|
|
52
|
+
try {
|
|
53
|
+
const [code, signal] = await once(child, "exit");
|
|
54
|
+
return code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1);
|
|
55
|
+
} catch (error) { throw launchFailure(agent, error); }
|
|
56
|
+
finally { signals.forEach((signal, index) => process.off(signal, handlers[index])); }
|
|
57
|
+
}
|