@fulldivevr/workcord-cli 1.6.0 → 3.0.0
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 +69 -39
- package/dist/auth.js +32 -8
- package/dist/cli.js +5 -3
- package/dist/commands/{hermes.js → hub.js} +91 -73
- package/dist/commands/machine.js +212 -0
- package/dist/config.js +53 -1
- package/dist/generated/commands/browser-assistance.js +65 -0
- package/dist/generated/commands/call-recordings.js +13 -0
- package/dist/generated/commands/client-config.js +18 -0
- package/dist/generated/commands/dashboard-actions.js +33 -0
- package/dist/generated/commands/internal.js +25 -0
- package/dist/generated/commands/local-folders.js +157 -0
- package/dist/generated/commands/machine-agents.js +134 -0
- package/dist/generated/commands/messages.js +35 -1
- package/dist/generated/commands/room-agents.js +6 -0
- package/dist/generated/commands/room-call.js +14 -0
- package/dist/generated/commands/rooms.js +6 -0
- package/dist/generated/commands/users.js +24 -0
- package/dist/generated/index.js +10 -0
- package/dist/machine/adapters/claude.js +183 -0
- package/dist/machine/adapters/codex.js +166 -0
- package/dist/machine/agents.js +22 -0
- package/dist/machine/chunks.js +57 -0
- package/dist/machine/handler.js +90 -0
- package/dist/machine/lock.js +36 -0
- package/dist/machine/pair.js +67 -0
- package/dist/machine/policy.js +24 -0
- package/dist/machine/relay.js +234 -0
- package/dist/machine/rotate.js +36 -0
- package/dist/machine/service.js +112 -0
- package/dist/machine/sessions.js +143 -0
- package/dist/machine/threads.js +68 -0
- package/package.json +4 -3
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { childEnv } from "../agents.js";
|
|
4
|
+
const TASK_TIMEOUT_MS = 20 * 60_000;
|
|
5
|
+
const INTERRUPT_GRACE_MS = 5_000;
|
|
6
|
+
const TOOL_ITEMS = new Set([
|
|
7
|
+
"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall",
|
|
8
|
+
"subAgentActivity", "webSearch", "imageView", "imageGeneration", "sleep",
|
|
9
|
+
]);
|
|
10
|
+
/** The app-server form of `--dangerously-bypass-approvals-and-sandbox`: the owner's own agent, with no gate. */
|
|
11
|
+
export function codexThreadParams(cwd) {
|
|
12
|
+
return {
|
|
13
|
+
cwd,
|
|
14
|
+
sandbox: "danger-full-access",
|
|
15
|
+
approvalPolicy: "never",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** The item mapping of the hub's Codex runtime adapter (codexruntime/server.go). */
|
|
19
|
+
export function codexItemEvent(method, item) {
|
|
20
|
+
if (!item || typeof item !== "object")
|
|
21
|
+
return undefined;
|
|
22
|
+
if (item.type === "agentMessage") {
|
|
23
|
+
const text = String(item.text || "");
|
|
24
|
+
return method === "item/completed" && text.trim() ? { event: "message.completed", item_id: item.id, output: text } : undefined;
|
|
25
|
+
}
|
|
26
|
+
if (!TOOL_ITEMS.has(item.type))
|
|
27
|
+
return undefined;
|
|
28
|
+
const paths = Array.isArray(item.changes)
|
|
29
|
+
? item.changes.map((change) => String(change?.path || "")).filter(Boolean).slice(0, 20)
|
|
30
|
+
: undefined;
|
|
31
|
+
return {
|
|
32
|
+
event: method === "item/completed" ? "tool.completed" : "tool.started",
|
|
33
|
+
item_id: item.id,
|
|
34
|
+
kind: item.type,
|
|
35
|
+
...(typeof item.command === "string" ? { command: item.command } : {}),
|
|
36
|
+
...(Array.isArray(item.commandActions) ? { command_actions: item.commandActions.slice(0, 20) } : {}),
|
|
37
|
+
...(paths?.length ? { paths } : {}),
|
|
38
|
+
...(typeof item.tool === "string" ? { tool_name: item.tool } : {}),
|
|
39
|
+
...(typeof item.namespace === "string" ? { tool_namespace: item.namespace } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One `codex app-server` per task, spoken to over stdio JSON-RPC as the Codex
|
|
44
|
+
* desktop and mobile clients do: initialize, thread/resume (or thread/start
|
|
45
|
+
* when there is no thread to resume), turn/start, and turn/interrupt for a cancel.
|
|
46
|
+
*/
|
|
47
|
+
export async function runCodex(input) {
|
|
48
|
+
const child = spawn(input.binary ?? "codex", ["app-server"], {
|
|
49
|
+
cwd: input.cwd,
|
|
50
|
+
env: childEnv(input.env),
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
child.stdout.setEncoding("utf8");
|
|
54
|
+
child.stderr.setEncoding("utf8");
|
|
55
|
+
let stderr = "";
|
|
56
|
+
child.stderr.on("data", (chunk) => {
|
|
57
|
+
if (stderr.length < 8_192)
|
|
58
|
+
stderr += chunk;
|
|
59
|
+
});
|
|
60
|
+
let nextId = 0;
|
|
61
|
+
const pending = new Map();
|
|
62
|
+
let threadId = "";
|
|
63
|
+
let turnId = "";
|
|
64
|
+
let finalText = "";
|
|
65
|
+
let lastText = "";
|
|
66
|
+
let settle;
|
|
67
|
+
const done = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
68
|
+
done.catch(() => undefined);
|
|
69
|
+
const write = (message) => {
|
|
70
|
+
if (child.stdin.writable)
|
|
71
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
|
|
72
|
+
};
|
|
73
|
+
const request = (method, params) => new Promise((resolve, reject) => {
|
|
74
|
+
const id = ++nextId;
|
|
75
|
+
pending.set(id, { resolve, reject });
|
|
76
|
+
write({ id, method, params });
|
|
77
|
+
});
|
|
78
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
79
|
+
let message;
|
|
80
|
+
try {
|
|
81
|
+
message = JSON.parse(line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (message.id !== undefined && !message.method) {
|
|
87
|
+
const waiter = pending.get(Number(message.id));
|
|
88
|
+
pending.delete(Number(message.id));
|
|
89
|
+
if (message.error)
|
|
90
|
+
waiter?.reject(new Error(String(message.error.message || "Codex refused the request")));
|
|
91
|
+
else
|
|
92
|
+
waiter?.resolve(message.result);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (message.id !== undefined && message.method) {
|
|
96
|
+
write({ id: message.id, error: { code: -32601, message: "Not supported by the WorkCord machine relay" } });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const params = message.params || {};
|
|
100
|
+
if ((message.method === "item/started" || message.method === "item/completed") && (!turnId || params.turnId === turnId)) {
|
|
101
|
+
const event = codexItemEvent(message.method, params.item);
|
|
102
|
+
if (event)
|
|
103
|
+
input.onEvent?.(event);
|
|
104
|
+
if (message.method === "item/completed" && params.item?.type === "agentMessage") {
|
|
105
|
+
const text = String(params.item.text || "");
|
|
106
|
+
if (params.item.phase === "final_answer")
|
|
107
|
+
finalText = text;
|
|
108
|
+
if (text)
|
|
109
|
+
lastText = text;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (message.method === "turn/completed" && params.turn?.id === turnId) {
|
|
113
|
+
const turn = params.turn;
|
|
114
|
+
if (turn.status === "completed")
|
|
115
|
+
settle.resolve(finalText || lastText);
|
|
116
|
+
else if (turn.status === "interrupted")
|
|
117
|
+
settle.reject(new Error("The task was stopped."));
|
|
118
|
+
else
|
|
119
|
+
settle.reject(new Error(String(turn.error?.message || `Codex ended the turn as ${turn.status}`)));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
child.on("error", (error) => {
|
|
123
|
+
settle.reject(new Error(error.code === "ENOENT" ? "Codex is not installed on this machine (no `codex` on PATH)." : error.message));
|
|
124
|
+
});
|
|
125
|
+
child.on("close", (code) => {
|
|
126
|
+
for (const waiter of pending.values())
|
|
127
|
+
waiter.reject(new Error(`Codex exited with ${code}`));
|
|
128
|
+
pending.clear();
|
|
129
|
+
settle.reject(new Error(`Codex exited with ${code}: ${stderr.trim().slice(0, 500)}`));
|
|
130
|
+
});
|
|
131
|
+
const signal = AbortSignal.any([input.signal, AbortSignal.timeout(TASK_TIMEOUT_MS)]);
|
|
132
|
+
const onAbort = () => {
|
|
133
|
+
if (threadId && turnId)
|
|
134
|
+
void request("turn/interrupt", { threadId, turnId }).catch(() => undefined);
|
|
135
|
+
else
|
|
136
|
+
settle.reject(new Error("The task was stopped."));
|
|
137
|
+
setTimeout(() => child.kill(), INTERRUPT_GRACE_MS).unref();
|
|
138
|
+
};
|
|
139
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
140
|
+
try {
|
|
141
|
+
await request("initialize", { clientInfo: { name: "workcord", title: "WorkCord machine", version: "1" }, capabilities: null });
|
|
142
|
+
write({ method: "initialized" });
|
|
143
|
+
const params = codexThreadParams(input.cwd);
|
|
144
|
+
const resumed = input.resume
|
|
145
|
+
? await request("thread/resume", { ...params, threadId: input.resume, excludeTurns: true }).catch(() => undefined)
|
|
146
|
+
: undefined;
|
|
147
|
+
const thread = resumed ?? await request("thread/start", params);
|
|
148
|
+
threadId = String(thread?.thread?.id || "");
|
|
149
|
+
if (threadId)
|
|
150
|
+
input.onThread?.(threadId);
|
|
151
|
+
const started = await request("turn/start", { threadId, input: [{ type: "text", text: input.prompt, text_elements: [] }] });
|
|
152
|
+
turnId = String(started?.turn?.id || "");
|
|
153
|
+
if (signal.aborted)
|
|
154
|
+
onAbort();
|
|
155
|
+
return await done;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
signal.removeEventListener("abort", onAbort);
|
|
162
|
+
child.stdin.end();
|
|
163
|
+
child.kill();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
export const ADAPTER_NAMES = ["claude", "codex"];
|
|
4
|
+
export function childEnv(env = process.env) {
|
|
5
|
+
return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("WORKCORD_")));
|
|
6
|
+
}
|
|
7
|
+
/** Absolute path of each installed agent CLI, found the way a shell would find it. */
|
|
8
|
+
export async function detectAdapters(env = process.env) {
|
|
9
|
+
const found = {};
|
|
10
|
+
const dirs = String(env.PATH || "").split(delimiter).filter(Boolean);
|
|
11
|
+
for (const name of ADAPTER_NAMES) {
|
|
12
|
+
for (const dir of dirs) {
|
|
13
|
+
const candidate = join(dir, name);
|
|
14
|
+
if (await access(candidate, constants.X_OK).then(() => true, () => false)) {
|
|
15
|
+
found[name] = candidate;
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=agents.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export const CHUNK_THRESHOLD_BYTES = 256 * 1024;
|
|
3
|
+
export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const SEGMENT_BYTES = 160 * 1024;
|
|
5
|
+
const MAX_PENDING_REFS = 8;
|
|
6
|
+
/** Mirrors Codex `server_message_chunk`: a frame over the threshold travels as base64 segments of its JSON. */
|
|
7
|
+
export function encodeFrame(frame) {
|
|
8
|
+
const json = JSON.stringify(frame);
|
|
9
|
+
const bytes = Buffer.from(json, "utf8");
|
|
10
|
+
if (bytes.length <= CHUNK_THRESHOLD_BYTES)
|
|
11
|
+
return [json];
|
|
12
|
+
if (bytes.length > MAX_MESSAGE_BYTES)
|
|
13
|
+
throw new Error(`frame of ${bytes.length} bytes exceeds the ${MAX_MESSAGE_BYTES} byte cap`);
|
|
14
|
+
const ref = randomUUID();
|
|
15
|
+
const n = Math.ceil(bytes.length / SEGMENT_BYTES);
|
|
16
|
+
const out = [];
|
|
17
|
+
for (let i = 0; i < n; i++) {
|
|
18
|
+
const chunk = { t: "chunk", ref, i, n, data: bytes.subarray(i * SEGMENT_BYTES, (i + 1) * SEGMENT_BYTES).toString("base64") };
|
|
19
|
+
out.push(JSON.stringify(chunk));
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export class ChunkAssembler {
|
|
24
|
+
pending = new Map();
|
|
25
|
+
/** Returns the reassembled frame when the last segment arrives, undefined while incomplete. Throws past the cap. */
|
|
26
|
+
accept(chunk) {
|
|
27
|
+
const n = Number(chunk.n);
|
|
28
|
+
const i = Number(chunk.i);
|
|
29
|
+
if (!chunk.ref || !Number.isSafeInteger(n) || n < 1 || !Number.isSafeInteger(i) || i < 0 || i >= n) {
|
|
30
|
+
throw new Error("invalid chunk");
|
|
31
|
+
}
|
|
32
|
+
if (n * SEGMENT_BYTES > MAX_MESSAGE_BYTES + SEGMENT_BYTES)
|
|
33
|
+
throw new Error("chunked frame exceeds the size cap");
|
|
34
|
+
let entry = this.pending.get(chunk.ref);
|
|
35
|
+
if (!entry) {
|
|
36
|
+
if (this.pending.size >= MAX_PENDING_REFS)
|
|
37
|
+
throw new Error("too many chunked frames in flight");
|
|
38
|
+
entry = { n, parts: [], received: 0, bytes: 0 };
|
|
39
|
+
this.pending.set(chunk.ref, entry);
|
|
40
|
+
}
|
|
41
|
+
if (entry.n !== n)
|
|
42
|
+
throw new Error("invalid chunk");
|
|
43
|
+
if (entry.parts[i])
|
|
44
|
+
return undefined;
|
|
45
|
+
const part = Buffer.from(String(chunk.data || ""), "base64");
|
|
46
|
+
entry.bytes += part.length;
|
|
47
|
+
if (entry.bytes > MAX_MESSAGE_BYTES)
|
|
48
|
+
throw new Error("chunked frame exceeds the size cap");
|
|
49
|
+
entry.parts[i] = part;
|
|
50
|
+
entry.received += 1;
|
|
51
|
+
if (entry.received < n)
|
|
52
|
+
return undefined;
|
|
53
|
+
this.pending.delete(chunk.ref);
|
|
54
|
+
return JSON.parse(Buffer.concat(entry.parts).toString("utf8"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=chunks.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolveCwd } from "./policy.js";
|
|
3
|
+
import { searchSessions } from "./sessions.js";
|
|
4
|
+
/**
|
|
5
|
+
* Tasks the caller tied to one contextId run one at a time, in arrival order,
|
|
6
|
+
* as a hub tenant queues a follow-up behind the running turn.
|
|
7
|
+
*/
|
|
8
|
+
export class TaskContexts {
|
|
9
|
+
tails = new Map();
|
|
10
|
+
async run(contextId, start) {
|
|
11
|
+
if (!contextId)
|
|
12
|
+
return start();
|
|
13
|
+
const previous = this.tails.get(contextId) ?? Promise.resolve();
|
|
14
|
+
const task = previous.catch(() => undefined).then(start);
|
|
15
|
+
const tail = task.catch(() => undefined);
|
|
16
|
+
this.tails.set(contextId, tail);
|
|
17
|
+
try {
|
|
18
|
+
return await task;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
if (this.tails.get(contextId) === tail)
|
|
22
|
+
this.tails.delete(contextId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function handleTask(rpc, ctx) {
|
|
27
|
+
const id = rpc?.id ?? null;
|
|
28
|
+
const message = rpc?.params?.message;
|
|
29
|
+
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
30
|
+
try {
|
|
31
|
+
const data = parts.find((part) => part?.kind === "data" && part.data && typeof part.data === "object")?.data;
|
|
32
|
+
if (data?.skill === "sessions.search") {
|
|
33
|
+
const hits = await searchSessions({
|
|
34
|
+
query: String(data.query || ""),
|
|
35
|
+
since: data.since ? new Date(String(data.since)) : undefined,
|
|
36
|
+
limit: typeof data.limit === "number" ? data.limit : undefined,
|
|
37
|
+
homeDir: ctx.homeDir,
|
|
38
|
+
});
|
|
39
|
+
return reply(id, message?.contextId, [
|
|
40
|
+
{ kind: "text", text: hits.length ? `Found ${hits.length} matching session messages.` : "No matching session messages." },
|
|
41
|
+
{ kind: "data", data: { hits } },
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
const prompt = parts.filter((part) => part?.kind === "text").map((part) => String(part.text || "")).join("\n").trim();
|
|
45
|
+
if (!prompt)
|
|
46
|
+
throw new Error("Send a text task, or a data part {\"skill\":\"sessions.search\",\"query\":\"...\"}.");
|
|
47
|
+
if (!ctx.roots.length) {
|
|
48
|
+
throw new Error("This machine was started without --root, so it can only run sessions.search.");
|
|
49
|
+
}
|
|
50
|
+
const requested = typeof message?.metadata?.adapter === "string" ? message.metadata.adapter : undefined;
|
|
51
|
+
const adapterName = requested ?? ctx.adapter ?? Object.keys(ctx.adapters)[0];
|
|
52
|
+
const runner = adapterName ? ctx.adapters[adapterName] : undefined;
|
|
53
|
+
if (!runner) {
|
|
54
|
+
throw new Error(`${adapterName ?? "No coding agent"} is not installed on this machine. Installed: ${Object.keys(ctx.adapters).join(", ") || "none"}.`);
|
|
55
|
+
}
|
|
56
|
+
const requestedCwd = typeof message?.metadata?.cwd === "string" ? message.metadata.cwd : undefined;
|
|
57
|
+
const cwd = await resolveCwd(ctx.roots, requestedCwd);
|
|
58
|
+
const contextId = typeof message?.contextId === "string" && !message.contextId.startsWith("machine:") ? message.contextId : undefined;
|
|
59
|
+
const signal = ctx.signal ?? new AbortController().signal;
|
|
60
|
+
const text = await (ctx.contexts ?? new TaskContexts()).run(contextId, async () => {
|
|
61
|
+
if (signal.aborted)
|
|
62
|
+
throw new Error("The task was stopped.");
|
|
63
|
+
ctx.onEvent?.({ event: "run.started" });
|
|
64
|
+
const threads = contextId ? ctx.threads : undefined;
|
|
65
|
+
const resume = threads ? await threads.get(adapterName, contextId).catch(() => undefined) : undefined;
|
|
66
|
+
return runner({
|
|
67
|
+
prompt, cwd, roots: ctx.roots, signal, onEvent: ctx.onEvent, resume,
|
|
68
|
+
onThread: (thread) => { void threads?.set(adapterName, contextId, thread).catch(() => undefined); },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return reply(id, message?.contextId, [{ kind: "text", text }]);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return { jsonrpc: "2.0", id, error: { code: -32603, message: error.message || String(error) } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function reply(id, contextId, parts) {
|
|
78
|
+
return {
|
|
79
|
+
jsonrpc: "2.0",
|
|
80
|
+
id,
|
|
81
|
+
result: {
|
|
82
|
+
kind: "message",
|
|
83
|
+
role: "agent",
|
|
84
|
+
messageId: randomUUID(),
|
|
85
|
+
...(typeof contextId === "string" ? { contextId } : {}),
|
|
86
|
+
parts,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=handler.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
/** One `machine start` per computer, as the Codex daemon holds daemon.pid + daemon.lock. */
|
|
3
|
+
export async function acquireMachineLock(path) {
|
|
4
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
5
|
+
try {
|
|
6
|
+
await writeFile(path, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
7
|
+
return async () => {
|
|
8
|
+
if ((await readPid(path)) === process.pid)
|
|
9
|
+
await rm(path, { force: true });
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (err.code !== "EEXIST")
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
const pid = await readPid(path);
|
|
17
|
+
if (pid && alive(pid))
|
|
18
|
+
throw new Error(`workcord machine is already running on this computer (pid ${pid}).`);
|
|
19
|
+
await rm(path, { force: true });
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Could not take the machine lock at ${path}.`);
|
|
22
|
+
}
|
|
23
|
+
async function readPid(path) {
|
|
24
|
+
const pid = Number((await readFile(path, "utf8").catch(() => "")).trim());
|
|
25
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
26
|
+
}
|
|
27
|
+
function alive(pid) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return err.code === "EPERM";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { normalizeBaseUrl } from "../http.js";
|
|
3
|
+
import { writeMachineFileAtomic } from "./rotate.js";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const qrcode = require("qrcode-terminal");
|
|
6
|
+
export function renderQr(payload) {
|
|
7
|
+
let out = "";
|
|
8
|
+
qrcode.generate(payload, { small: true }, (qr) => { out = qr; });
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Codex host pairing (server/pair, then pair/status until claimed): this computer
|
|
13
|
+
* asks for a code, the owner claims it in the app, and the key is handed over once.
|
|
14
|
+
*/
|
|
15
|
+
export async function pairComputer(input) {
|
|
16
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
17
|
+
const print = input.print ?? ((line) => process.stderr.write(`${line}\n`));
|
|
18
|
+
const base = normalizeBaseUrl(input.baseUrl);
|
|
19
|
+
const created = await fetchImpl(new URL("/api/v1/machine-agents/pairings", base), {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
name: input.name,
|
|
24
|
+
platform: `${process.platform}-${process.arch}`,
|
|
25
|
+
cliVersion: input.cliVersion,
|
|
26
|
+
installationId: input.installationId,
|
|
27
|
+
}),
|
|
28
|
+
signal: AbortSignal.timeout(30_000),
|
|
29
|
+
});
|
|
30
|
+
if (!created.ok)
|
|
31
|
+
throw new Error(`pairing request refused with ${created.status}: ${(await created.text()).slice(0, 300)}`);
|
|
32
|
+
const offer = (await created.json());
|
|
33
|
+
print(`Pairing code: ${offer.code}`);
|
|
34
|
+
print(`Open ${offer.qrPayload} in WorkCord, or scan:`);
|
|
35
|
+
print(renderQr(offer.qrPayload));
|
|
36
|
+
print(`The code expires at ${new Date(offer.expiresAt).toLocaleTimeString()}. Waiting for you to confirm it...`);
|
|
37
|
+
for (;;) {
|
|
38
|
+
const response = await fetchImpl(new URL(`/api/v1/machine-agents/pairings/${encodeURIComponent(offer.pairingId)}`, base), {
|
|
39
|
+
headers: { "x-pairing-secret": offer.secret },
|
|
40
|
+
signal: AbortSignal.timeout(30_000),
|
|
41
|
+
}).catch(() => undefined);
|
|
42
|
+
if (response?.ok) {
|
|
43
|
+
const status = (await response.json());
|
|
44
|
+
if (status.status === "expired")
|
|
45
|
+
throw new Error("The pairing code expired. Run `workcord machine pair` again.");
|
|
46
|
+
if (status.status === "claimed") {
|
|
47
|
+
if (!status.machineId || !status.machineKey) {
|
|
48
|
+
throw new Error("The pairing was claimed but its key was already handed out. Run `workcord machine pair` again.");
|
|
49
|
+
}
|
|
50
|
+
const file = {
|
|
51
|
+
machineId: status.machineId,
|
|
52
|
+
key: status.machineKey,
|
|
53
|
+
baseUrl: base,
|
|
54
|
+
name: input.name,
|
|
55
|
+
rotatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
await writeMachineFileAtomic(input.path, file);
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (response && response.status !== 503 && response.status !== 502) {
|
|
62
|
+
throw new Error(`pairing status refused with ${response.status}`);
|
|
63
|
+
}
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, input.pollMs ?? 2_000));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=pair.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
export async function resolveRoots(roots) {
|
|
4
|
+
return Promise.all(roots.map((root) => realpath(root)));
|
|
5
|
+
}
|
|
6
|
+
/** `roots` are already realpath-resolved, once at start. */
|
|
7
|
+
export async function resolveCwd(roots, requested) {
|
|
8
|
+
if (!roots.length)
|
|
9
|
+
throw new Error("This machine was started without --root, so it can only search sessions.");
|
|
10
|
+
if (!requested)
|
|
11
|
+
return roots[0];
|
|
12
|
+
let target;
|
|
13
|
+
try {
|
|
14
|
+
target = await realpath(requested);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`${requested} does not exist on this machine.`);
|
|
18
|
+
}
|
|
19
|
+
if (!roots.some((root) => target === root || target.startsWith(root + sep))) {
|
|
20
|
+
throw new Error(`${requested} is outside the allowed roots.`);
|
|
21
|
+
}
|
|
22
|
+
return target;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=policy.js.map
|