@fulldivevr/workcord-cli 2.17.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 +2 -17
- package/dist/auth.js +8 -0
- package/dist/cli.js +2 -0
- 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
- package/dist/commands/hermes.js +0 -649
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { execFileSync, 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 STOP_GRACE_MS = 3_000;
|
|
6
|
+
/** The owner's own agent with no permission gate, as Codex runs with approvals and sandbox bypassed. */
|
|
7
|
+
export function claudeArgs(prompt, roots, resume) {
|
|
8
|
+
return [
|
|
9
|
+
"-p", "--output-format", "stream-json", "--verbose",
|
|
10
|
+
"--dangerously-skip-permissions",
|
|
11
|
+
...(resume ? ["--resume", resume] : []),
|
|
12
|
+
...(roots.length ? ["--add-dir", ...roots] : []),
|
|
13
|
+
"--",
|
|
14
|
+
prompt,
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
18
|
+
/** A Claude Code tool call in the hub's run-event shape, so its step line reads as a Codex one does. */
|
|
19
|
+
export function claudeToolEvent(block) {
|
|
20
|
+
const input = block?.input && typeof block.input === "object" ? block.input : {};
|
|
21
|
+
const name = String(block?.name || "");
|
|
22
|
+
const base = { event: "tool.started", item_id: String(block?.id || "") };
|
|
23
|
+
const path = String(input.file_path || input.notebook_path || input.path || "");
|
|
24
|
+
if (name === "Bash")
|
|
25
|
+
return { ...base, kind: "commandExecution", command: String(input.command || "") };
|
|
26
|
+
if (name === "Read")
|
|
27
|
+
return { ...base, kind: "commandExecution", command_actions: [{ type: "read", path }] };
|
|
28
|
+
if (name === "Grep" || name === "Glob") {
|
|
29
|
+
return { ...base, kind: "commandExecution", command_actions: [{ type: "search", query: String(input.pattern || ""), path: String(input.path || "") }] };
|
|
30
|
+
}
|
|
31
|
+
if (name === "LS")
|
|
32
|
+
return { ...base, kind: "commandExecution", command_actions: [{ type: "listFiles", path }] };
|
|
33
|
+
if (EDIT_TOOLS.has(name))
|
|
34
|
+
return { ...base, kind: "fileChange", paths: path ? [path] : [] };
|
|
35
|
+
if (name === "WebSearch" || name === "WebFetch")
|
|
36
|
+
return { ...base, kind: "webSearch" };
|
|
37
|
+
if (name === "Task" || name === "Agent")
|
|
38
|
+
return { ...base, kind: "collabAgentToolCall" };
|
|
39
|
+
const mcp = name.match(/^mcp__(.+?)__(.+)$/);
|
|
40
|
+
if (mcp)
|
|
41
|
+
return { ...base, kind: "mcpToolCall", tool_namespace: mcp[1], tool_name: mcp[2] };
|
|
42
|
+
return { ...base, kind: "dynamicToolCall", tool_name: name };
|
|
43
|
+
}
|
|
44
|
+
export async function runClaude(input) {
|
|
45
|
+
const signal = AbortSignal.any([input.signal, AbortSignal.timeout(TASK_TIMEOUT_MS)]);
|
|
46
|
+
if (!input.resume)
|
|
47
|
+
return runOnce(input, signal, { started: false });
|
|
48
|
+
const attempt = { started: false };
|
|
49
|
+
try {
|
|
50
|
+
return await runOnce(input, signal, attempt);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// A session Claude Code no longer has fails before the turn starts; the turn then runs in a new session.
|
|
54
|
+
if (attempt.started || signal.aborted)
|
|
55
|
+
throw error;
|
|
56
|
+
return runOnce({ ...input, resume: undefined }, signal, { started: false });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function runOnce(input, signal, attempt) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const child = spawn(input.binary ?? "claude", claudeArgs(input.prompt, input.roots, input.resume), {
|
|
62
|
+
cwd: input.cwd,
|
|
63
|
+
// One process per turn: a background command would outlive the turn and its answer.
|
|
64
|
+
// This also lifts Claude Code's block on a long foreground `sleep`, which offers only the background.
|
|
65
|
+
env: { ...childEnv(input.env), CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" },
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
const onAbort = () => stopTree(child);
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
onAbort();
|
|
71
|
+
else
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
child.on("exit", () => {
|
|
74
|
+
signal.removeEventListener("abort", onAbort);
|
|
75
|
+
if (signal.aborted)
|
|
76
|
+
reject(new Error("The task was stopped."));
|
|
77
|
+
});
|
|
78
|
+
let stderr = "";
|
|
79
|
+
let result;
|
|
80
|
+
let lastText = "";
|
|
81
|
+
let sessionId = "";
|
|
82
|
+
child.stdout.setEncoding("utf8");
|
|
83
|
+
child.stderr.setEncoding("utf8");
|
|
84
|
+
child.stderr.on("data", (chunk) => {
|
|
85
|
+
if (stderr.length < 8_192)
|
|
86
|
+
stderr += chunk;
|
|
87
|
+
});
|
|
88
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
89
|
+
let message;
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof message?.session_id === "string" && message.session_id && message.session_id !== sessionId) {
|
|
97
|
+
sessionId = message.session_id;
|
|
98
|
+
input.onThread?.(sessionId);
|
|
99
|
+
}
|
|
100
|
+
if (message?.type === "result") {
|
|
101
|
+
result = message;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (message?.type === "assistant")
|
|
105
|
+
attempt.started = true;
|
|
106
|
+
const content = Array.isArray(message?.message?.content) ? message.message.content : [];
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
if (message.type === "assistant" && block?.type === "tool_use")
|
|
109
|
+
input.onEvent?.(claudeToolEvent(block));
|
|
110
|
+
else if (message.type === "assistant" && block?.type === "text" && String(block.text || "").trim()) {
|
|
111
|
+
lastText = String(block.text);
|
|
112
|
+
input.onEvent?.({ event: "message.completed", output: lastText });
|
|
113
|
+
}
|
|
114
|
+
else if (message.type === "user" && block?.type === "tool_result") {
|
|
115
|
+
input.onEvent?.({ event: "tool.completed", item_id: String(block.tool_use_id || "") });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
child.on("error", (error) => {
|
|
120
|
+
reject(new Error(error.code === "ENOENT"
|
|
121
|
+
? "Claude Code is not installed on this machine (no `claude` on PATH)."
|
|
122
|
+
: error.message));
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
if (!result) {
|
|
126
|
+
reject(new Error(`Claude Code exited with ${code}: ${stderr.trim().slice(0, 500)}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (result.is_error || code !== 0) {
|
|
130
|
+
reject(new Error(String(result.result || stderr || `Claude Code exited with ${code}`).slice(0, 1_000)));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
resolve(String(result.result ?? lastText));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function descendants(root) {
|
|
138
|
+
if (process.platform === "win32")
|
|
139
|
+
return [];
|
|
140
|
+
let table = "";
|
|
141
|
+
try {
|
|
142
|
+
table = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const children = new Map();
|
|
148
|
+
for (const line of table.split("\n")) {
|
|
149
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
150
|
+
if (pid && ppid)
|
|
151
|
+
children.set(ppid, [...(children.get(ppid) ?? []), pid]);
|
|
152
|
+
}
|
|
153
|
+
const found = [];
|
|
154
|
+
for (let queue = [root]; queue.length;) {
|
|
155
|
+
for (const pid of children.get(queue.shift()) ?? []) {
|
|
156
|
+
found.push(pid);
|
|
157
|
+
queue.push(pid);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Claude Code runs its Bash tool in a detached shell of its own process group, and on
|
|
164
|
+
* SIGTERM it ends those shells itself. A Claude that does not exit in time is killed with
|
|
165
|
+
* the tree it had, which no process-group signal would reach.
|
|
166
|
+
*/
|
|
167
|
+
function stopTree(child) {
|
|
168
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null)
|
|
169
|
+
return;
|
|
170
|
+
const tree = descendants(child.pid);
|
|
171
|
+
child.kill("SIGTERM");
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
174
|
+
child.kill("SIGKILL");
|
|
175
|
+
for (const pid of tree) {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, "SIGKILL");
|
|
178
|
+
}
|
|
179
|
+
catch { }
|
|
180
|
+
}
|
|
181
|
+
}, STOP_GRACE_MS).unref();
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=claude.js.map
|
|
@@ -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
|