zyndo 0.1.4 → 0.1.6

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/dist/index.js CHANGED
@@ -47,7 +47,7 @@ async function main() {
47
47
  break;
48
48
  }
49
49
  case 'version':
50
- process.stdout.write('zyndo 0.1.4\n');
50
+ process.stdout.write('zyndo 0.1.5\n');
51
51
  break;
52
52
  case 'help':
53
53
  case undefined:
package/dist/init.js CHANGED
@@ -335,13 +335,30 @@ function runNonInteractive(flags) {
335
335
  binary = lookup;
336
336
  }
337
337
  else {
338
- // Auto-detect
339
- const detected = detectInstalledHarness();
340
- if (detected === undefined) {
338
+ // Auto-detect. Refuse to guess if BOTH harnesses are present — the AI
339
+ // agent calling this must tell us which one IT is, otherwise we'll
340
+ // happily write a config that points at the wrong binary and crash at
341
+ // load time with the harness/binary mismatch error.
342
+ const claudeBin = findClaudeBinary();
343
+ const codexBin = findCodexBinary();
344
+ if (claudeBin !== undefined && codexBin !== undefined) {
345
+ throw new Error('Both Claude Code and Codex CLI are installed on this machine.\n' +
346
+ 'Pass --harness explicitly so we know which one YOU are:\n' +
347
+ ' --harness claude (if you are Claude Code)\n' +
348
+ ' --harness codex (if you are Codex CLI)\n' +
349
+ 'The harness MUST match the AI agent that will run `zyndo serve`.');
350
+ }
351
+ if (claudeBin !== undefined) {
352
+ harness = 'claude';
353
+ binary = claudeBin;
354
+ }
355
+ else if (codexBin !== undefined) {
356
+ harness = 'codex';
357
+ binary = codexBin;
358
+ }
359
+ else {
341
360
  throw new Error('No AI harness found. Install Claude Code or Codex CLI, or pass --harness and --binary explicitly.');
342
361
  }
343
- harness = detected.harness;
344
- binary = detected.binary;
345
362
  }
346
363
  // Pick model: explicit > harness default
347
364
  if (flags.model !== undefined) {
@@ -394,7 +411,21 @@ async function runInteractive() {
394
411
  // Auto-detect available harnesses up front so the menu shows what is found.
395
412
  const detectedClaude = findClaudeBinary();
396
413
  const detectedCodex = findCodexBinary();
397
- const autoPick = detectedClaude !== undefined ? 0 : detectedCodex !== undefined ? 1 : -1;
414
+ const bothDetected = detectedClaude !== undefined && detectedCodex !== undefined;
415
+ // If BOTH are installed we refuse to default — the human must pick which
416
+ // AI agent will actually run `zyndo serve`. Picking the wrong one writes
417
+ // a config that crashes at load time with a harness/binary mismatch.
418
+ const autoPick = bothDetected
419
+ ? -1
420
+ : detectedClaude !== undefined
421
+ ? 0
422
+ : detectedCodex !== undefined
423
+ ? 1
424
+ : -1;
425
+ if (bothDetected) {
426
+ process.stdout.write(' \x1b[33mBoth Claude Code and Codex CLI are installed.\x1b[0m\n');
427
+ process.stdout.write(' Pick the one that matches the AI agent that will run `zyndo serve`.\n\n');
428
+ }
398
429
  process.stdout.write(' \x1b[1mSelect your AI harness:\x1b[0m\n');
399
430
  for (let i = 0; i < HARNESS_OPTIONS.length; i++) {
400
431
  const opt = HARNESS_OPTIONS[i];
@@ -13,8 +13,7 @@ export type McpSessionState = {
13
13
  };
14
14
  export declare const TOOLS: ReadonlyArray<McpToolDefinition>;
15
15
  export declare const TERMINAL_STATES: Set<string>;
16
- export declare const WAIT_POLL_INTERVAL_MS = 10000;
17
- export declare const MAX_WAIT_SECONDS = 600;
16
+ export declare const WAIT_LONG_POLL_MS = 270000;
18
17
  export declare function eventToState(eventType: string): string;
19
18
  export declare function handleToolCall(state: McpSessionState, name: string, args: Record<string, unknown>): Promise<string>;
20
19
  export declare function mcpSuccess(id: string | number, result: unknown): string;
@@ -24,7 +24,7 @@ export const TOOLS = [
24
24
  },
25
25
  {
26
26
  name: 'zyndo_browse_sellers',
27
- description: 'Browse available seller agents on the Zyndo marketplace. Returns a list of sellers with their skills and descriptions.',
27
+ description: 'Browse available seller agents on the Zyndo marketplace. Returns a list of sellers, each with skills that include `buyerPriceCents` — the total amount in USD cents your buyer will be charged for that skill (already includes the Zyndo marketplace fee). This is the ONLY price field: quote `buyerPriceCents` verbatim to your human and confirm before calling zyndo_hire.',
28
28
  inputSchema: {
29
29
  type: 'object',
30
30
  properties: {
@@ -86,7 +86,7 @@ export const TOOLS = [
86
86
  },
87
87
  {
88
88
  name: 'zyndo_wait_for_completion',
89
- description: 'Check whether a task has reached an actionable state (delivered, completed, failed, or seller question). Returns quickly (within ~25 seconds). Call this repeatedly in a loop until you get a terminal or actionable state.',
89
+ description: 'Blocks until the seller delivers, asks a question, or the task reaches a terminal state (completed, failed, rejected, canceled). Wakes on the broker event bus, NOT a polling loop. Call this EXACTLY ONCE after zyndo_hire and wait for it to return — do NOT call it in a loop. If it returns state "still-working" after ~4-5 minutes, you may call it again.',
90
90
  inputSchema: {
91
91
  type: 'object',
92
92
  properties: {
@@ -125,8 +125,10 @@ export const TOOLS = [
125
125
  // Constants
126
126
  // ---------------------------------------------------------------------------
127
127
  export const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled', 'rejected']);
128
- export const WAIT_POLL_INTERVAL_MS = 10_000;
129
- export const MAX_WAIT_SECONDS = 600;
128
+ // Long-poll window the CLI asks the broker to hold the connection open for.
129
+ // Capped under Railway's 300s idle timeout so the proxy never tears it down
130
+ // mid-wait. See broker /agent/tasks/:id/wait for the server-side cap.
131
+ export const WAIT_LONG_POLL_MS = 270_000;
130
132
  // ---------------------------------------------------------------------------
131
133
  // Auto-reconnect helpers
132
134
  // ---------------------------------------------------------------------------
@@ -356,37 +358,48 @@ async function handleRequestRevision(state, args) {
356
358
  message: `Revision #${data.revisionNumber} requested. Use zyndo_wait_for_completion to wait for the updated delivery.`
357
359
  });
358
360
  }
359
- const WAIT_SHORT_WINDOW_MS = 25_000; // Max wall time per tool call — safely under MCP client timeouts
360
361
  async function handleWaitForCompletion(state, args) {
361
362
  if (state.agentSession === undefined)
362
363
  return JSON.stringify({ error: 'Not connected. Call zyndo_connect first.' });
363
364
  const taskId = args.taskId;
364
- const windowDeadline = Date.now() + WAIT_SHORT_WINDOW_MS;
365
- while (Date.now() < windowDeadline) {
366
- const res = await fetchWithReconnect(state, (s) => `${state.bridgeUrl}/agent/tasks/${taskId}`, (s) => ({ headers: { authorization: `Bearer ${s.token}` } }));
367
- if (res.status === 403) {
368
- return JSON.stringify({
369
- error: 'Access denied (403). This task belongs to a different session. Use zyndo_connect with your original reconnectToken to recover it.',
370
- taskId
371
- });
372
- }
373
- if (!res.ok) {
374
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_INTERVAL_MS));
375
- continue;
376
- }
377
- const detail = (await res.json());
378
- if (TERMINAL_STATES.has(detail.state)) {
379
- return JSON.stringify({ taskId, state: detail.state, message: `Task reached terminal state: ${detail.state}` });
380
- }
381
- if (detail.state === 'input-required') {
382
- const hint = detail.inputType === 'delivery'
383
- ? 'Seller has delivered. Use zyndo_get_delivery to retrieve the output, then zyndo_complete_task or zyndo_request_revision.'
384
- : 'Seller has a question. Use zyndo_get_messages to read it and zyndo_send_response to answer.';
385
- return JSON.stringify({ taskId, state: detail.state, inputType: detail.inputType, message: hint });
386
- }
387
- await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_INTERVAL_MS));
365
+ // ONE long-poll request. The broker holds the connection open and wakes on
366
+ // the task event bus — no client-side polling loop. The client-side fetch
367
+ // gets a slightly longer AbortController timeout than the broker's wait
368
+ // ceiling so we never abort a response that's about to arrive.
369
+ const controller = new AbortController();
370
+ const abortTimer = setTimeout(() => controller.abort(), WAIT_LONG_POLL_MS + 10_000);
371
+ let res;
372
+ try {
373
+ res = await fetchWithReconnect(state, (s) => `${state.bridgeUrl}/agent/tasks/${taskId}/wait?timeoutMs=${WAIT_LONG_POLL_MS}`, (s) => ({ headers: { authorization: `Bearer ${s.token}` }, signal: controller.signal }));
374
+ }
375
+ catch (err) {
376
+ clearTimeout(abortTimer);
377
+ const message = err instanceof Error ? err.message : String(err);
378
+ return JSON.stringify({ taskId, state: 'still-working', message: `Long-poll interrupted (${message}). Call zyndo_wait_for_completion again to resume waiting.` });
379
+ }
380
+ clearTimeout(abortTimer);
381
+ if (res.status === 403) {
382
+ return JSON.stringify({
383
+ error: 'Access denied (403). This task belongs to a different session. Use zyndo_connect with your original reconnectToken to recover it.',
384
+ taskId
385
+ });
386
+ }
387
+ if (!res.ok) {
388
+ return JSON.stringify({ error: `Broker returned ${res.status}`, taskId });
389
+ }
390
+ const detail = (await res.json());
391
+ if (TERMINAL_STATES.has(detail.state)) {
392
+ return JSON.stringify({ taskId, state: detail.state, message: `Task reached terminal state: ${detail.state}` });
393
+ }
394
+ if (detail.state === 'input-required') {
395
+ const hint = detail.inputType === 'delivery'
396
+ ? 'Seller has delivered. Use zyndo_get_delivery to retrieve the output, then zyndo_complete_task or zyndo_request_revision.'
397
+ : 'Seller has a question. Use zyndo_get_messages to read it and zyndo_send_response to answer.';
398
+ return JSON.stringify({ taskId, state: detail.state, inputType: detail.inputType, message: hint });
388
399
  }
389
- return JSON.stringify({ taskId, state: 'still-working', message: 'Task is still in progress. Call zyndo_wait_for_completion again to keep waiting.' });
400
+ // Broker hit its timeout cap before any state change. Safe for the LLM to
401
+ // call again — a second call is still ~30x cheaper than the legacy 10s poll.
402
+ return JSON.stringify({ taskId, state: 'still-working', message: 'Seller is still working. Call zyndo_wait_for_completion again to keep waiting.' });
390
403
  }
391
404
  // ---------------------------------------------------------------------------
392
405
  // Tool dispatch
@@ -2,7 +2,14 @@
2
2
  // Claude Code provider — spawns a CLI harness (claude, codex, or generic)
3
3
  // instead of making raw LLM API calls.
4
4
  // ---------------------------------------------------------------------------
5
- import { spawn } from 'node:child_process';
5
+ // Use cross-spawn instead of node:child_process spawn. cross-spawn resolves
6
+ // Windows .cmd/.bat shims (codex.cmd, claude.cmd installed via `npm i -g`)
7
+ // directly to their underlying node entry script, so we can spawn without
8
+ // shell:true. Piping the prompt through node:child_process spawn + shell:true
9
+ // on Windows deadlocks: cmd.exe does not reliably forward stdin EOF to the
10
+ // grandchild node process behind the .cmd shim, and codex blocks forever
11
+ // on `stdin.read()`, hitting our 600s abort.
12
+ import spawn from 'cross-spawn';
6
13
  import { basename, join } from 'node:path';
7
14
  import { tmpdir } from 'node:os';
8
15
  import { randomBytes } from 'node:crypto';
@@ -140,49 +147,6 @@ function parseOutput(raw, harness) {
140
147
  return { output: trimmed, paused: false };
141
148
  }
142
149
  // ---------------------------------------------------------------------------
143
- // Cross-platform spawn helpers
144
- // ---------------------------------------------------------------------------
145
- /**
146
- * Quote a single arg for cmd.exe. Wraps in double quotes if it contains
147
- * any whitespace, quote, or shell metacharacter. Internal double quotes are
148
- * doubled per cmd.exe convention. We only ever pass flag values and paths
149
- * here (the prompt goes via stdin), so this is sufficient.
150
- */
151
- function quoteWinArg(arg) {
152
- if (arg === '')
153
- return '""';
154
- if (!/[\s"&|<>^()]/.test(arg))
155
- return arg;
156
- return `"${arg.replace(/"/g, '""')}"`;
157
- }
158
- /**
159
- * Spawn a child process. On Windows, builds a single shell-quoted command
160
- * string and passes shell:true so .cmd / .bat shims (codex.cmd, claude.cmd)
161
- * resolve correctly. On POSIX, spawns the binary directly with the args
162
- * array — no shell, no quoting concerns.
163
- *
164
- * Avoids Node DEP0190 (the warning fires when you pass shell:true together
165
- * with an args array, because Node concatenates without escaping).
166
- */
167
- function spawnHarness(binary, args, opts) {
168
- if (process.platform === 'win32') {
169
- const cmdLine = [binary, ...args].map(quoteWinArg).join(' ');
170
- return spawn(cmdLine, {
171
- cwd: opts.cwd,
172
- signal: opts.signal,
173
- stdio: ['pipe', 'pipe', 'pipe'],
174
- env: opts.env,
175
- shell: true
176
- });
177
- }
178
- return spawn(binary, [...args], {
179
- cwd: opts.cwd,
180
- signal: opts.signal,
181
- stdio: ['pipe', 'pipe', 'pipe'],
182
- env: opts.env
183
- });
184
- }
185
- // ---------------------------------------------------------------------------
186
150
  // Main entry point
187
151
  // ---------------------------------------------------------------------------
188
152
  const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
@@ -197,19 +161,20 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
197
161
  return new Promise((resolve) => {
198
162
  const controller = new AbortController();
199
163
  const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
200
- // Windows .cmd shims need shell:true to resolve. spawnHarness builds a
201
- // pre-quoted command string for that case so we don't trigger DEP0190
202
- // (passing shell:true + args array). On POSIX we spawn directly.
203
- const proc = spawnHarness(binary, args, {
164
+ // cross-spawn handles Windows .cmd/.bat shim resolution without shell:true,
165
+ // so stdin pipes propagate EOF correctly on both Windows and POSIX. Without
166
+ // this, Windows + codex.cmd deadlocks because cmd.exe does not forward the
167
+ // stdin close to the real node child.
168
+ const proc = spawn(binary, [...args], {
204
169
  cwd: config.workingDirectory,
205
170
  signal: controller.signal,
171
+ stdio: ['pipe', 'pipe', 'pipe'],
206
172
  env: { ...process.env }
207
173
  });
208
174
  const stdoutChunks = [];
209
175
  const stderrChunks = [];
210
- // stdio is always ['pipe','pipe','pipe'] in spawnHarness, so the
211
- // streams are guaranteed non-null at runtime. The wrapped return type
212
- // does not narrow that, so assert.
176
+ // stdio is always ['pipe','pipe','pipe'] so the streams are guaranteed
177
+ // non-null at runtime. The wrapped return type does not narrow that, so assert.
213
178
  proc.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
214
179
  proc.stderr.on('data', (chunk) => stderrChunks.push(chunk));
215
180
  proc.on('error', (err) => {
@@ -252,7 +217,16 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
252
217
  }
253
218
  resolve(parseOutput(agentOutput, harness));
254
219
  });
255
- proc.stdin.write(prompt);
220
+ // Surface stdin write failures loudly. If the child closes its stdin
221
+ // early (e.g. a shim-resolution regression), silence would mean a 600s
222
+ // abort with no signal. Logging the error lets us fail fast.
223
+ proc.stdin.on('error', (err) => {
224
+ logger.error(`Harness stdin error: ${err.message}`);
225
+ });
226
+ proc.stdin.write(prompt, (err) => {
227
+ if (err)
228
+ logger.error(`Harness stdin write failed: ${err.message}`);
229
+ });
256
230
  proc.stdin.end();
257
231
  });
258
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyndo",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,9 +35,11 @@
35
35
  "lint": "tsc -p tsconfig.json --noEmit"
36
36
  },
37
37
  "dependencies": {
38
+ "cross-spawn": "^7.0.6",
38
39
  "yaml": "^2.7.0"
39
40
  },
40
41
  "devDependencies": {
42
+ "@types/cross-spawn": "^6.0.6",
41
43
  "@types/node": "^22.12.0",
42
44
  "typescript": "^5.7.3",
43
45
  "vitest": "^2.1.9"