@fulldivevr/workcord-cli 3.0.2 → 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,6 +112,7 @@ 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 = "";
@@ -131,8 +185,11 @@ export async function runCodex(input) {
131
185
  settle.reject(new Error(error.code === "ENOENT" ? "Codex is not installed on this machine (no `codex` on PATH)." : error.message));
132
186
  });
133
187
  // "close" fires after stdout and stderr have ended, so the stderr tail is complete here.
134
- child.on("close", (code) => {
135
- 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);
136
193
  for (const waiter of pending.values())
137
194
  waiter.reject(exited);
138
195
  pending.clear();
@@ -148,7 +205,8 @@ export async function runCodex(input) {
148
205
  };
149
206
  signal.addEventListener("abort", onAbort, { once: true });
150
207
  try {
151
- await request("initialize", { clientInfo: { name: "workcord", title: "WorkCord machine", version: "1" }, capabilities: null });
208
+ await request("initialize", INITIALIZE_PARAMS);
209
+ initialized = true;
152
210
  write({ method: "initialized" });
153
211
  const params = codexThreadParams(input.cwd);
154
212
  const resumed = input.resume
@@ -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.2",
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",