@fulldivevr/workcord-cli 3.0.1 → 3.0.3

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.
@@ -7,8 +7,8 @@ import { call, getBaseUrl } from "../client.js";
7
7
  import { deleteMachineFile, getMachineLockPath, getMachinePath, getMachineThreadsPath, readMachineFile, readOrCreateInstallationId, saveMachineFile, } from "../config.js";
8
8
  import { normalizeBaseUrl } from "../http.js";
9
9
  import { runClaude } from "../machine/adapters/claude.js";
10
- import { runCodex } from "../machine/adapters/codex.js";
11
- import { ADAPTER_NAMES, detectAdapters } from "../machine/agents.js";
10
+ import { binaryVersion, probeCodex, runCodex } from "../machine/adapters/codex.js";
11
+ import { ADAPTER_NAMES, childEnv, detectAdapters } from "../machine/agents.js";
12
12
  import { handleTask, TaskContexts } from "../machine/handler.js";
13
13
  import { pairComputer } from "../machine/pair.js";
14
14
  import { nextRotationDelay, rotateMachineKey, ROTATE_EVERY_MS } from "../machine/rotate.js";
@@ -78,15 +78,17 @@ export function registerMachine(program) {
78
78
  group
79
79
  .command("start")
80
80
  .description("Connect this computer and run your tasks on it until stopped (foreground). Tasks run with full access, without approvals.")
81
- .option("--root <dir>", "directory a task may work in (repeatable); without one, only session search runs", collect, [])
81
+ .option("--root <dir>", "directory a task may work in (repeatable, default: the current directory)", collect, [])
82
82
  .option("--adapter <name>", "coding agent that runs tasks: auto, claude or codex", "auto")
83
83
  .option("--home <dir>", "home directory the coding agent runs with (HOME, CODEX_HOME=<dir>/.codex, CLAUDE_CONFIG_DIR=<dir>/.claude)")
84
84
  .action(async (opts) => {
85
85
  const credential = resolveMachineCredential(process.env, await readMachineFile());
86
86
  const roots = await resolveRoots(opts.root);
87
87
  const home = await resolveHome(opts.home);
88
- const installed = await detectAdapters();
89
- const adapters = pickAdapters(installed, opts.adapter, home);
88
+ const detected = await detectAdapters();
89
+ pickAdapters(detected, opts.adapter, home);
90
+ // checkAdapters already narrowed the set to the chosen adapter.
91
+ const adapters = pickAdapters(await checkAdapters(detected, opts.adapter, home), "auto", home);
90
92
  const baseUrl = credential.baseUrl || (await getBaseUrl());
91
93
  const installationId = await readOrCreateInstallationId();
92
94
  const release = await acquireMachineLock(getMachineLockPath());
@@ -105,7 +107,7 @@ export function registerMachine(program) {
105
107
  ...(credential.source === "file" ? { reloadKey: () => savedKeyFor(baseUrl) } : {}),
106
108
  });
107
109
  const rotation = credential.source === "file" ? scheduleRotation(client) : undefined;
108
- process.stderr.write(`machine: full access, agents: ${Object.keys(adapters).join(", ") || "none"}, roots: ${roots.length ? roots.join(", ") : "none (session search only)"}\n`);
110
+ process.stderr.write(`machine: full access, agents: ${Object.keys(adapters).join(", ") || "none"}, roots: ${roots.join(", ")}\n`);
109
111
  process.once("SIGINT", () => client.stop());
110
112
  process.once("SIGTERM", () => client.stop());
111
113
  const outcome = await client.start().finally(() => {
@@ -119,7 +121,7 @@ export function registerMachine(program) {
119
121
  group
120
122
  .command("install-service")
121
123
  .description("Keep `machine start` running in the background (launchd on macOS, systemd on Linux).")
122
- .option("--root <dir>", "directory a task may work in (repeatable)", collect, [])
124
+ .option("--root <dir>", "directory a task may work in (repeatable, default: the current directory)", collect, [])
123
125
  .option("--adapter <name>", "coding agent that runs tasks: auto, claude or codex", "auto")
124
126
  .option("--home <dir>", "home directory the coding agent runs with (HOME, CODEX_HOME=<dir>/.codex, CLAUDE_CONFIG_DIR=<dir>/.claude)")
125
127
  .action(async (opts) => {
@@ -180,6 +182,33 @@ function collect(value, previous) {
180
182
  return [...previous, value];
181
183
  }
182
184
  const RUNNERS = { claude: runClaude, codex: runCodex };
185
+ /**
186
+ * Prints the binary each adapter uses and drops one that cannot run tasks, so it is
187
+ * never advertised to the server. A Codex older than `app-server` exits at once.
188
+ */
189
+ export async function checkAdapters(installed, choice, home, log = (line) => process.stderr.write(line + "\n"), env = process.env) {
190
+ const usable = {};
191
+ for (const [name, binary] of Object.entries(installed)) {
192
+ if (!binary || (choice !== "auto" && choice !== name))
193
+ continue;
194
+ if (name === "codex") {
195
+ const probe = await probeCodex(binary, env, home);
196
+ if (!probe.ok) {
197
+ log(`ERROR: ${probe.message}`);
198
+ if (probe.stderr)
199
+ log(`machine: codex said: ${probe.stderr.replace(/\s+/g, " ").slice(-300)}`);
200
+ continue;
201
+ }
202
+ }
203
+ else if (name === "claude" && (await binaryVersion(binary, childEnv(env, home))) === "unknown") {
204
+ log(`ERROR: claude at ${binary} does not answer "claude --version". Reinstall it: npm i -g @anthropic-ai/claude-code@latest`);
205
+ continue;
206
+ }
207
+ log(`machine: ${name} at ${binary}`);
208
+ usable[name] = binary;
209
+ }
210
+ return usable;
211
+ }
183
212
  export function pickAdapters(installed, choice, home) {
184
213
  const names = choice === "auto" ? ADAPTER_NAMES.filter((name) => installed[name]) : [choice];
185
214
  const picked = {};
@@ -1,8 +1,11 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { childEnv } from "../agents.js";
4
4
  const TASK_TIMEOUT_MS = 20 * 60_000;
5
5
  const INTERRUPT_GRACE_MS = 5_000;
6
+ const PROBE_TIMEOUT_MS = 10_000;
7
+ const VERSION_TIMEOUT_MS = 5_000;
8
+ const INITIALIZE_PARAMS = { clientInfo: { name: "workcord", title: "WorkCord machine", version: "1" }, capabilities: null };
6
9
  const TOOL_ITEMS = new Set([
7
10
  "commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall",
8
11
  "subAgentActivity", "webSearch", "imageView", "imageGeneration", "sleep",
@@ -39,15 +42,65 @@ export function codexItemEvent(method, item) {
39
42
  ...(typeof item.namespace === "string" ? { tool_namespace: item.namespace } : {}),
40
43
  };
41
44
  }
45
+ /** `<binary> --version`, or "unknown" when it does not answer. */
46
+ export function binaryVersion(binary, env) {
47
+ return new Promise((resolve) => {
48
+ execFile(binary, ["--version"], { env, timeout: VERSION_TIMEOUT_MS }, (error, stdout) => {
49
+ const text = String(stdout || "").trim().split("\n")[0]?.trim();
50
+ resolve(error || !text ? "unknown" : text);
51
+ });
52
+ });
53
+ }
54
+ export function codexUnusableMessage(binary, version) {
55
+ return `codex at ${binary} (version ${version}) cannot run "codex app-server". Update it: npm i -g @openai/codex@latest`;
56
+ }
57
+ /** Starts `codex app-server` and accepts it once it answers `initialize`; a legacy Codex CLI never does. */
58
+ export async function probeCodex(binary, env, home, timeoutMs = PROBE_TIMEOUT_MS) {
59
+ const spawnEnv = childEnv(env, home);
60
+ const stderr = await new Promise((resolve) => {
61
+ const child = spawn(binary, ["app-server"], { env: spawnEnv, stdio: ["pipe", "pipe", "pipe"] });
62
+ child.stdout.setEncoding("utf8");
63
+ child.stderr.setEncoding("utf8");
64
+ let tail = "";
65
+ child.stderr.on("data", (chunk) => {
66
+ tail = (tail + chunk).slice(-2_000);
67
+ });
68
+ const finish = (result) => {
69
+ clearTimeout(timer);
70
+ child.stdin.destroy();
71
+ child.kill();
72
+ resolve(result);
73
+ };
74
+ const timer = setTimeout(() => finish(tail || `no answer to initialize within ${timeoutMs / 1000}s`), timeoutMs);
75
+ createInterface({ input: child.stdout }).on("line", (line) => {
76
+ try {
77
+ const message = JSON.parse(line);
78
+ if (message?.id === 1 && message.result !== undefined)
79
+ finish(undefined);
80
+ }
81
+ catch { }
82
+ });
83
+ child.on("error", (error) => finish(error.message));
84
+ child.on("close", (code) => finish(tail || `exited with ${code}`));
85
+ child.stdin.on("error", () => undefined);
86
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: INITIALIZE_PARAMS }) + "\n");
87
+ });
88
+ if (stderr === undefined)
89
+ return { ok: true };
90
+ const version = await binaryVersion(binary, spawnEnv);
91
+ return { ok: false, version, stderr: stderr.trim(), message: codexUnusableMessage(binary, version) };
92
+ }
42
93
  /**
43
94
  * One `codex app-server` per task, spoken to over stdio JSON-RPC as the Codex
44
95
  * desktop and mobile clients do: initialize, thread/resume (or thread/start
45
96
  * when there is no thread to resume), turn/start, and turn/interrupt for a cancel.
46
97
  */
47
98
  export async function runCodex(input) {
48
- const child = spawn(input.binary ?? "codex", ["app-server"], {
99
+ const binary = input.binary ?? "codex";
100
+ const env = childEnv(input.env, input.home);
101
+ const child = spawn(binary, ["app-server"], {
49
102
  cwd: input.cwd,
50
- env: childEnv(input.env, input.home),
103
+ env,
51
104
  stdio: ["pipe", "pipe", "pipe"],
52
105
  });
53
106
  child.stdout.setEncoding("utf8");
@@ -59,10 +112,12 @@ export async function runCodex(input) {
59
112
  });
60
113
  let nextId = 0;
61
114
  const pending = new Map();
115
+ let initialized = false;
62
116
  let threadId = "";
63
117
  let turnId = "";
64
118
  let finalText = "";
65
119
  let lastText = "";
120
+ const earlyTurns = new Map();
66
121
  let settle;
67
122
  const done = new Promise((resolve, reject) => { settle = { resolve, reject }; });
68
123
  done.catch(() => undefined);
@@ -109,22 +164,32 @@ export async function runCodex(input) {
109
164
  lastText = text;
110
165
  }
111
166
  }
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}`)));
167
+ else if (message.method === "turn/completed" && params.turn?.id) {
168
+ // Readline emits every line of one stdout chunk synchronously, so a turn that ends
169
+ // in the chunk carrying the turn/start answer completes before turnId is set.
170
+ if (!turnId)
171
+ earlyTurns.set(String(params.turn.id), params.turn);
172
+ else if (params.turn.id === turnId)
173
+ finishTurn(params.turn);
120
174
  }
121
175
  });
176
+ const finishTurn = (turn) => {
177
+ if (turn.status === "completed")
178
+ settle.resolve(finalText || lastText);
179
+ else if (turn.status === "interrupted")
180
+ settle.reject(new Error("The task was stopped."));
181
+ else
182
+ settle.reject(new Error(String(turn.error?.message || `Codex ended the turn as ${turn.status}`)));
183
+ };
122
184
  child.on("error", (error) => {
123
185
  settle.reject(new Error(error.code === "ENOENT" ? "Codex is not installed on this machine (no `codex` on PATH)." : error.message));
124
186
  });
125
187
  // "close" fires after stdout and stderr have ended, so the stderr tail is complete here.
126
- child.on("close", (code) => {
127
- const exited = new Error(`Codex exited with ${code}: ${stderr.trim().slice(0, 500)}`);
188
+ child.on("close", async (code) => {
189
+ let text = `Codex exited with ${code}: ${stderr.trim().slice(0, 500)}`;
190
+ if (!initialized)
191
+ text += `\n${codexUnusableMessage(binary, await binaryVersion(binary, env))}`;
192
+ const exited = new Error(text);
128
193
  for (const waiter of pending.values())
129
194
  waiter.reject(exited);
130
195
  pending.clear();
@@ -140,7 +205,8 @@ export async function runCodex(input) {
140
205
  };
141
206
  signal.addEventListener("abort", onAbort, { once: true });
142
207
  try {
143
- await request("initialize", { clientInfo: { name: "workcord", title: "WorkCord machine", version: "1" }, capabilities: null });
208
+ await request("initialize", INITIALIZE_PARAMS);
209
+ initialized = true;
144
210
  write({ method: "initialized" });
145
211
  const params = codexThreadParams(input.cwd);
146
212
  const resumed = input.resume
@@ -152,6 +218,8 @@ export async function runCodex(input) {
152
218
  input.onThread?.(threadId);
153
219
  const started = await request("turn/start", { threadId, input: [{ type: "text", text: input.prompt, text_elements: [] }] });
154
220
  turnId = String(started?.turn?.id || "");
221
+ if (earlyTurns.has(turnId))
222
+ finishTurn(earlyTurns.get(turnId));
155
223
  if (signal.aborted)
156
224
  onAbort();
157
225
  return await done;
@@ -12,8 +12,8 @@ export async function resolveHome(dir) {
12
12
  throw new Error(`--home ${absolute} is not a directory.`);
13
13
  return absolute;
14
14
  }
15
- export async function resolveRoots(roots) {
16
- return Promise.all(roots.map((root) => realpath(root)));
15
+ export async function resolveRoots(roots, cwd = process.cwd()) {
16
+ return Promise.all((roots.length ? roots : [cwd]).map((root) => realpath(root)));
17
17
  }
18
18
  /** `roots` are already realpath-resolved, once at start. */
19
19
  export async function resolveCwd(roots, requested) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulldivevr/workcord-cli",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Agent-first CLI using persistent Hermes tenant chat, with advanced API diagnostics.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",