zyndo 0.3.4 → 0.4.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.
@@ -10,5 +10,15 @@ export type AgentLoopResult = Readonly<{
10
10
  paused: boolean;
11
11
  timedOut?: boolean;
12
12
  pendingQuestion?: string;
13
+ /**
14
+ * Set when the underlying agent harness (codex, claude, generic binary)
15
+ * exited with a non-zero status and produced no usable output. The caller
16
+ * MUST NOT deliver `output` to the buyer in this case — it is a stderr
17
+ * dump, not a deliverable. Instead, send a human-readable info message
18
+ * and leave the task alone so the buyer can revise or dispute.
19
+ */
20
+ harnessFailed?: boolean;
21
+ /** When harnessFailed is set, this is the short human-readable reason. */
22
+ harnessError?: string;
13
23
  }>;
14
24
  export declare function runAgentLoop(provider: LLMProvider, tools: ReadonlyArray<Tool>, initialMessage: string, opts: AgentLoopOptions): Promise<AgentLoopResult>;
@@ -0,0 +1,8 @@
1
+ import { type SellerConfig } from '../config.js';
2
+ export type SellerCommandDeps = Readonly<{
3
+ loadConfig: () => SellerConfig;
4
+ httpFetch: typeof fetch;
5
+ stderr: (msg: string) => void;
6
+ stdout: (msg: string) => void;
7
+ }>;
8
+ export declare function handleSellerCommand(args: ReadonlyArray<string>, deps?: SellerCommandDeps): Promise<void>;
@@ -0,0 +1,142 @@
1
+ // ---------------------------------------------------------------------------
2
+ // zyndo seller — local CLI for managing seller ownership
3
+ //
4
+ // Subcommands:
5
+ // zyndo seller list
6
+ // GET /user/seller-agents — prints the user's slug ↔ agentId table
7
+ // so the operator can find orphaned reputation after a slug change.
8
+ //
9
+ // zyndo seller adopt <agentId>
10
+ // POST /user/seller-agents/adopt — rebinds the current seller.yaml
11
+ // slug to an existing agentId the user already owns. Used to reclaim
12
+ // reputation when the local slug drifts from what the broker persisted.
13
+ //
14
+ // zyndo seller retire <slug>
15
+ // POST /user/seller-agents/:slug/retire — marks a slug row retired so
16
+ // it stops being restored on connect and frees a cap slot.
17
+ //
18
+ // Auth: reads `api_key` from the local seller.yaml and passes it via
19
+ // `x-zyndo-api-key`. The /user/seller-agents/* routes accept API keys as
20
+ // a dashboard-less auth path (incident 2026-04-15).
21
+ // ---------------------------------------------------------------------------
22
+ import { loadSellerConfig } from '../config.js';
23
+ const DEFAULT_DEPS = {
24
+ loadConfig: () => loadSellerConfig(),
25
+ httpFetch: fetch,
26
+ stderr: (msg) => process.stderr.write(msg),
27
+ stdout: (msg) => process.stdout.write(msg)
28
+ };
29
+ export async function handleSellerCommand(args, deps = DEFAULT_DEPS) {
30
+ const [subcommand, ...rest] = args;
31
+ if (subcommand === undefined) {
32
+ printUsage(deps);
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ let config;
37
+ try {
38
+ config = deps.loadConfig();
39
+ }
40
+ catch (err) {
41
+ deps.stderr(`Failed to load seller config: ${err.message}\n`);
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ if (config.apiKey.length === 0) {
46
+ deps.stderr('Missing api_key in seller.yaml. Add one from https://zyndo.ai/dashboard (API Keys tab).\n');
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ const headers = {
51
+ 'content-type': 'application/json',
52
+ 'x-zyndo-api-key': config.apiKey
53
+ };
54
+ switch (subcommand) {
55
+ case 'list': {
56
+ const res = await deps.httpFetch(`${config.bridgeUrl}/user/seller-agents`, { headers });
57
+ if (!res.ok) {
58
+ deps.stderr(`List failed (${res.status}): ${await res.text().catch(() => '')}\n`);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+ const body = (await res.json());
63
+ if (body.agents.length === 0) {
64
+ deps.stdout('No sellers registered under this account.\n');
65
+ return;
66
+ }
67
+ deps.stdout(`Sellers for this account (${body.agents.length}):\n\n`);
68
+ for (const a of body.agents) {
69
+ const status = a.retiredAt !== undefined ? '[retired]' : a.online ? '[online]' : '[offline]';
70
+ deps.stdout(` ${status} ${a.displayName}\n` +
71
+ ` slug: ${a.sellerSlug}\n` +
72
+ ` agentId: ${a.agentId}\n` +
73
+ ` tasks: ${a.completedTaskCount} completed / ${a.lifetimeTaskCount} lifetime\n` +
74
+ ` last seen ${a.lastSeenAt}\n\n`);
75
+ }
76
+ return;
77
+ }
78
+ case 'adopt': {
79
+ const agentId = rest[0];
80
+ if (agentId === undefined || agentId.length === 0) {
81
+ deps.stderr('Usage: zyndo seller adopt <agentId>\n');
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ if (config.id === undefined || config.id.length === 0) {
86
+ deps.stderr('Current seller.yaml has no `id:` slug. Add one and retry.\n');
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ const res = await deps.httpFetch(`${config.bridgeUrl}/user/seller-agents/adopt`, {
91
+ method: 'POST',
92
+ headers,
93
+ body: JSON.stringify({ sellerSlug: config.id, agentId })
94
+ });
95
+ if (!res.ok) {
96
+ const text = await res.text().catch(() => '');
97
+ deps.stderr(`Adopt failed (${res.status}): ${text}\n`);
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ const body = (await res.json());
102
+ deps.stdout(`Adopted agentId ${body.agentId} under slug "${body.sellerSlug}" (display name: ${body.displayName}).\n`);
103
+ if (body.previousSlug !== undefined) {
104
+ deps.stdout(`Previous slug "${body.previousSlug}" was retired to free the binding.\n`);
105
+ }
106
+ deps.stdout('Run `zyndo serve` to resume as this seller. Reputation stays attached to the adopted agentId.\n');
107
+ return;
108
+ }
109
+ case 'retire': {
110
+ const slug = rest[0];
111
+ if (slug === undefined || slug.length === 0) {
112
+ deps.stderr('Usage: zyndo seller retire <slug>\n');
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ const res = await deps.httpFetch(`${config.bridgeUrl}/user/seller-agents/${encodeURIComponent(slug)}/retire`, { method: 'POST', headers });
117
+ if (!res.ok) {
118
+ deps.stderr(`Retire failed (${res.status}): ${await res.text().catch(() => '')}\n`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ const body = (await res.json());
123
+ if (body.alreadyRetired === true) {
124
+ deps.stdout(`Slug "${body.sellerSlug}" was already retired. No change.\n`);
125
+ }
126
+ else {
127
+ deps.stdout(`Retired slug "${body.sellerSlug}". Its cap slot is freed.\n`);
128
+ }
129
+ return;
130
+ }
131
+ default: {
132
+ printUsage(deps);
133
+ process.exitCode = 1;
134
+ }
135
+ }
136
+ }
137
+ function printUsage(deps) {
138
+ deps.stderr('Usage:\n' +
139
+ ' zyndo seller list List seller agents on this account\n' +
140
+ ' zyndo seller adopt <agentId> Rebind current seller.yaml slug to an existing agentId\n' +
141
+ ' zyndo seller retire <slug> Retire a seller slug (frees a cap slot)\n');
142
+ }
package/dist/config.d.ts CHANGED
@@ -54,6 +54,7 @@ export type SellerConfig = Readonly<{
54
54
  claudeCodeTimeoutMs?: number;
55
55
  claudeCodeMaxBudgetUsd?: number;
56
56
  identityKeyPath?: string;
57
+ forceNewSlug: boolean;
57
58
  }>;
58
59
  export type BuyerConfig = Readonly<{
59
60
  apiKey: string;
package/dist/config.js CHANGED
@@ -44,7 +44,13 @@ export function loadSellerConfig(configPath) {
44
44
  const name = requireString(data, 'name');
45
45
  const description = requireString(data, 'description');
46
46
  const provider = requireEnum(data, 'provider', ['anthropic', 'openai', 'ollama', 'claude-code']);
47
- const model = requireString(data, 'model');
47
+ // Optional: when empty, the harness CLI uses its own native default.
48
+ // For codex: reads ~/.codex/config.toml `model` (auto-tracks whatever
49
+ // OpenAI currently supports on the seller's ChatGPT plan). For claude:
50
+ // uses Claude Code's built-in default. Incident 2026-04-14: a hardcoded
51
+ // `gpt-5-codex` override broke every task when OpenAI rotated the
52
+ // ChatGPT-edu allowlist to `gpt-5.3-codex`. Blank is the safest default.
53
+ const model = optionalString(data, 'model') ?? '';
48
54
  const providerApiKey = optionalString(data, 'provider_api_key');
49
55
  const workingDirectory = optionalString(data, 'working_directory') ?? process.cwd();
50
56
  const systemPrompt = optionalString(data, 'system_prompt') ?? 'You are a helpful AI agent.';
@@ -89,6 +95,7 @@ export function loadSellerConfig(configPath) {
89
95
  const claudeCodeTimeoutMs = typeof data.claude_code_timeout_ms === 'number' ? data.claude_code_timeout_ms : undefined;
90
96
  const claudeCodeMaxBudgetUsd = typeof data.claude_code_max_budget_usd === 'number' ? data.claude_code_max_budget_usd : undefined;
91
97
  const identityKeyPath = optionalString(data, 'identity_key_path');
98
+ const forceNewSlug = data.force_new_slug === true;
92
99
  const skills = requireArray(data, 'skills').map((s) => {
93
100
  const skill = s;
94
101
  const id = requireString(skill, 'id');
@@ -137,7 +144,8 @@ export function loadSellerConfig(configPath) {
137
144
  claudeCodeBinary,
138
145
  claudeCodeTimeoutMs,
139
146
  claudeCodeMaxBudgetUsd,
140
- identityKeyPath
147
+ identityKeyPath,
148
+ forceNewSlug
141
149
  };
142
150
  }
143
151
  // ---------------------------------------------------------------------------
@@ -91,6 +91,7 @@ export declare function connect(bridgeUrl: string, apiKey: string, opts: {
91
91
  categories?: ReadonlyArray<string>;
92
92
  maxConcurrentTasks?: number;
93
93
  sellerSlug?: string;
94
+ forceNewSlug?: boolean;
94
95
  }): Promise<AgentSession>;
95
96
  export declare function reconnect(session: AgentSession, apiKey: string, opts?: {
96
97
  role?: 'buyer' | 'seller';
@@ -117,6 +117,8 @@ export async function connect(bridgeUrl, apiKey, opts) {
117
117
  };
118
118
  if (opts.sellerSlug !== undefined)
119
119
  body.sellerSlug = opts.sellerSlug;
120
+ if (opts.forceNewSlug === true)
121
+ body.forceNewSlug = true;
120
122
  const res = await fetch(`${bridgeUrl}/agent/connect`, {
121
123
  method: 'POST',
122
124
  headers,
@@ -124,6 +126,39 @@ export async function connect(bridgeUrl, apiKey, opts) {
124
126
  });
125
127
  if (!res.ok) {
126
128
  const errBody = await res.text();
129
+ // Per-user seller cap: surface a human-readable hint so the operator
130
+ // can retire an existing slug instead of guessing why their daemon
131
+ // refused to start.
132
+ if (res.status === 409) {
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(errBody);
136
+ }
137
+ catch {
138
+ parsed = undefined;
139
+ }
140
+ if (parsed !== undefined && parsed.error === 'seller_limit_reached') {
141
+ const existing = parsed.existing !== undefined && parsed.existing.length > 0
142
+ ? parsed.existing.join(', ')
143
+ : '<none>';
144
+ throw new Error(`Connect failed: seller_limit_reached. This account already runs ${parsed.limit ?? '?'} sellers (${existing}). Retire one via the dashboard before launching another listing.`);
145
+ }
146
+ if (parsed !== undefined && parsed.error === 'possible_orphaned_seller') {
147
+ const candidates = parsed.candidates ?? [];
148
+ const lines = candidates
149
+ .map((c) => ` - agentId=${c.agentId} slug=${c.sellerSlug} last_seen=${c.lastSeenAt ?? '<unknown>'}`)
150
+ .join('\n');
151
+ throw new Error(`Connect failed: another seller on your account already uses the display name "${opts.name}".\n` +
152
+ `\n` +
153
+ `If it is the SAME seller you ran before, reclaim its reputation with:\n` +
154
+ ` zyndo seller adopt <agentId>\n` +
155
+ `\n` +
156
+ `Candidates:\n${lines.length > 0 ? lines : ' (none reported by broker)'}\n` +
157
+ `\n` +
158
+ `If you really want a brand new empty seller under this display name, add this to seller.yaml:\n` +
159
+ ` force_new_slug: true`);
160
+ }
161
+ }
127
162
  throw new Error(`Connect failed (${res.status}): ${errBody}`);
128
163
  }
129
164
  const data = (await res.json());
@@ -0,0 +1,14 @@
1
+ import type { SellerConfig } from './config.js';
2
+ export type SelfCheckResult = Readonly<{
3
+ ok: true;
4
+ }> | Readonly<{
5
+ ok: false;
6
+ reason: string;
7
+ fix: string;
8
+ }>;
9
+ /**
10
+ * Spawn the harness with a trivial prompt and verify it completes successfully.
11
+ * This is the same command shape that `runClaudeCodeTask` uses, minus the
12
+ * system prompt / BOUND CONTRACT block so the probe stays fast (<30s).
13
+ */
14
+ export declare function verifyHarnessReady(config: SellerConfig): Promise<SelfCheckResult>;
@@ -0,0 +1,165 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Harness self-check
3
+ //
4
+ // Runs a trivial dry-run spawn of the configured harness (claude / codex)
5
+ // at daemon startup, BEFORE the seller connects to the broker. Catches three
6
+ // failure modes that would otherwise burn a real buyer hire:
7
+ //
8
+ // 1. Seller pinned an explicit model that the account no longer allows
9
+ // (e.g. a stale `gpt-5-codex` after OpenAI rotated the ChatGPT allowlist
10
+ // to `gpt-5.3-codex`). Incident 2026-04-14.
11
+ // 2. Harness binary is missing, unauthenticated, or rate-limited.
12
+ // 3. Any other startup-time error the harness itself surfaces.
13
+ //
14
+ // The probe uses the SAME spawn path as runClaudeCodeTask (cross-spawn,
15
+ // stdin-piped prompt, -o tempfile for codex) so anything it reports is
16
+ // representative of what a real task would hit.
17
+ // ---------------------------------------------------------------------------
18
+ import spawn from 'cross-spawn';
19
+ import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { readFileSync, unlinkSync } from 'node:fs';
23
+ import { detectHarness } from './providers/claudeCode.js';
24
+ const PROBE_TIMEOUT_MS = 30_000;
25
+ const PROBE_PROMPT = 'Reply with exactly the two characters: ok';
26
+ /**
27
+ * Parse harness stderr for known failure patterns and return actionable fix
28
+ * text. Falls through to a generic "see stderr tail" message.
29
+ */
30
+ function diagnoseFailure(stderr, harness, config) {
31
+ // Codex + Claude ChatGPT-account allowlist rejection. The exact string from
32
+ // Codex v0.116.0 is "The '<model>' model is not supported when using Codex
33
+ // with a ChatGPT account." — future Anthropic equivalents likely follow the
34
+ // same pattern, hence the broad regex.
35
+ if (/is not supported when using .* with a ChatGPT account/i.test(stderr)
36
+ || /model .* is not (?:available|supported)/i.test(stderr)) {
37
+ const pinnedModel = config.model.length > 0 ? config.model : '(none)';
38
+ const fix = harness === 'codex'
39
+ ? `Your seller.yaml pins model="${pinnedModel}" but your Codex account does not allow it.\n` +
40
+ ` Fix: remove (or blank) the "model:" line in seller.yaml so Codex uses\n` +
41
+ ` its own default from ~/.codex/config.toml. Alternatively, set\n` +
42
+ ` OPENAI_API_KEY in your shell before "zyndo serve" to switch to API billing.`
43
+ : `Your seller.yaml pins model="${pinnedModel}" but your Claude account does not allow it.\n` +
44
+ ` Fix: remove (or blank) the "model:" line in seller.yaml so Claude Code\n` +
45
+ ` uses its own default model.`;
46
+ return { reason: 'Harness rejected the configured model (allowlist mismatch).', fix };
47
+ }
48
+ if (/401|unauthorized|not (?:logged in|authenticated)/i.test(stderr)) {
49
+ const cmd = harness === 'codex' ? 'codex login' : 'claude login';
50
+ return {
51
+ reason: 'Harness is not authenticated.',
52
+ fix: `Run "${cmd}" in this shell, then restart "zyndo serve".`
53
+ };
54
+ }
55
+ if (/rate.?limit/i.test(stderr)) {
56
+ return {
57
+ reason: 'Harness is rate-limited.',
58
+ fix: 'Wait a few minutes and restart "zyndo serve". If this keeps happening, upgrade your account plan.'
59
+ };
60
+ }
61
+ if (/command not found|ENOENT|spawn .* ENOENT/i.test(stderr)) {
62
+ return {
63
+ reason: 'Harness binary not found on PATH.',
64
+ fix: `Install the ${harness === 'codex' ? 'Codex' : 'Claude Code'} CLI and re-run "zyndo init" to update the binary path in seller.yaml.`
65
+ };
66
+ }
67
+ // Generic fallback — show the tail so the operator can see the real error.
68
+ const tail = stderr.length > 1000 ? stderr.slice(-1000) : stderr;
69
+ return {
70
+ reason: 'Harness exited non-zero during startup probe.',
71
+ fix: `Stderr tail:\n${tail.trim()}`
72
+ };
73
+ }
74
+ /**
75
+ * Spawn the harness with a trivial prompt and verify it completes successfully.
76
+ * This is the same command shape that `runClaudeCodeTask` uses, minus the
77
+ * system prompt / BOUND CONTRACT block so the probe stays fast (<30s).
78
+ */
79
+ export async function verifyHarnessReady(config) {
80
+ const binary = config.claudeCodeBinary ?? 'claude';
81
+ const harness = config.harness ?? detectHarness(binary);
82
+ // `generic` harness has no prescribed args — we trust whatever shell the
83
+ // seller wired up. Skip the probe; there's nothing standard to test.
84
+ if (harness === 'generic') {
85
+ return { ok: true };
86
+ }
87
+ let outputFile;
88
+ const args = [];
89
+ if (harness === 'codex') {
90
+ outputFile = join(tmpdir(), `zyndo-probe-${Date.now()}-${randomBytes(4).toString('hex')}.txt`);
91
+ args.push('exec', '-');
92
+ if (config.model.length > 0)
93
+ args.push('-m', config.model);
94
+ args.push('--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check', '-C', config.workingDirectory, '--color', 'never', '-o', outputFile);
95
+ }
96
+ else {
97
+ // claude
98
+ args.push('--print', '--output-format', 'json');
99
+ if (config.model.length > 0)
100
+ args.push('--model', config.model);
101
+ args.push('--add-dir', config.workingDirectory, '--permission-mode', 'bypassPermissions', '--no-session-persistence');
102
+ }
103
+ return new Promise((resolve) => {
104
+ const controller = new AbortController();
105
+ const timeoutHandle = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
106
+ const proc = spawn(binary, args, {
107
+ cwd: config.workingDirectory,
108
+ signal: controller.signal,
109
+ stdio: ['pipe', 'pipe', 'pipe'],
110
+ env: { ...process.env }
111
+ });
112
+ const stdoutChunks = [];
113
+ const stderrChunks = [];
114
+ proc.stdout.on('data', (c) => stdoutChunks.push(c));
115
+ proc.stderr.on('data', (c) => stderrChunks.push(c));
116
+ proc.on('error', (err) => {
117
+ clearTimeout(timeoutHandle);
118
+ if (outputFile !== undefined) {
119
+ try {
120
+ unlinkSync(outputFile);
121
+ }
122
+ catch { /* ignore */ }
123
+ }
124
+ if (err.name === 'AbortError') {
125
+ resolve({
126
+ ok: false,
127
+ reason: `Harness probe timed out after ${PROBE_TIMEOUT_MS / 1000}s.`,
128
+ fix: `The ${harness} binary hung without responding. Try running it manually once to confirm it works, then restart "zyndo serve".`
129
+ });
130
+ return;
131
+ }
132
+ resolve({
133
+ ok: false,
134
+ reason: `Failed to spawn harness binary: ${err.message}`,
135
+ fix: `Check that "${binary}" is installed and on your PATH. Re-run "zyndo init" to update the binary path.`
136
+ });
137
+ });
138
+ proc.on('close', (code) => {
139
+ clearTimeout(timeoutHandle);
140
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
141
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
142
+ let probeOutput = stdout;
143
+ if (outputFile !== undefined) {
144
+ try {
145
+ probeOutput = readFileSync(outputFile, 'utf-8');
146
+ }
147
+ catch { /* use stdout */ }
148
+ try {
149
+ unlinkSync(outputFile);
150
+ }
151
+ catch { /* ignore */ }
152
+ }
153
+ if (code === 0 && probeOutput.trim().length > 0) {
154
+ resolve({ ok: true });
155
+ return;
156
+ }
157
+ const diagnosis = diagnoseFailure(stderr, harness, config);
158
+ resolve({ ok: false, ...diagnosis });
159
+ });
160
+ // Feed the probe prompt and close stdin so the harness completes.
161
+ proc.stdin.on('error', () => { });
162
+ proc.stdin.write(PROBE_PROMPT);
163
+ proc.stdin.end();
164
+ });
165
+ }
package/dist/index.js CHANGED
@@ -70,6 +70,11 @@ async function main() {
70
70
  await handleRefereeCommand(args.slice(1));
71
71
  break;
72
72
  }
73
+ case 'seller': {
74
+ const { handleSellerCommand } = await import('./commands/seller.js');
75
+ await handleSellerCommand(args.slice(1));
76
+ break;
77
+ }
73
78
  case 'version':
74
79
  process.stdout.write(`zyndo ${getPackageVersion()}\n`);
75
80
  break;
@@ -96,6 +101,9 @@ Usage:
96
101
  zyndo cashout View earnings and cashout (opens dashboard)
97
102
  zyndo connect [onboard|status] Stripe Connect setup (opens dashboard)
98
103
  zyndo referee <enable|disable> <skillId> Toggle Kimi K2.5 referee sampling on a skill
104
+ zyndo seller list List seller agents on this account
105
+ zyndo seller adopt <agentId> Rebind current seller.yaml slug to an existing agentId
106
+ zyndo seller retire <slug> Retire a seller slug (frees a cap slot)
99
107
  zyndo version Show version
100
108
  zyndo help Show this help
101
109
 
package/dist/init.js CHANGED
@@ -171,18 +171,23 @@ function testBinary(binary) {
171
171
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
172
172
  }
173
173
  }
174
+ // Default model is intentionally blank for every harness. Each CLI (claude,
175
+ // codex) has its own native default that auto-tracks whatever the provider
176
+ // currently rolls out on the seller's account. Hardcoding a string here used
177
+ // to break every seller when OpenAI / Anthropic rotated their allowlists.
178
+ // Sellers who want to pin a specific model can still enter one at the prompt.
174
179
  const HARNESS_OPTIONS = [
175
- { label: 'Claude Code', harness: 'claude', binary: 'claude', defaultModel: 'sonnet' },
176
- { label: 'Codex CLI', harness: 'codex', binary: 'codex', defaultModel: 'gpt-5-codex' },
177
- { label: 'Other / Custom binary', harness: 'generic', binary: '', defaultModel: 'default' }
180
+ { label: 'Claude Code', harness: 'claude', binary: 'claude', defaultModel: '' },
181
+ { label: 'Codex CLI', harness: 'codex', binary: 'codex', defaultModel: '' },
182
+ { label: 'Other / Custom binary', harness: 'generic', binary: '', defaultModel: '' }
178
183
  ];
179
184
  function detectInstalledHarness() {
180
185
  const claudeBin = findClaudeBinary();
181
186
  if (claudeBin !== undefined)
182
- return { harness: 'claude', binary: claudeBin, model: 'sonnet' };
187
+ return { harness: 'claude', binary: claudeBin, model: '' };
183
188
  const codexBin = findCodexBinary();
184
189
  if (codexBin !== undefined)
185
- return { harness: 'codex', binary: codexBin, model: 'gpt-5-codex' };
190
+ return { harness: 'codex', binary: codexBin, model: '' };
186
191
  return undefined;
187
192
  }
188
193
  function parseFlags(argv) {
@@ -506,11 +511,20 @@ const YAML_HEADER = `# Zyndo seller configuration.
506
511
  # provider Always 'claude-code' (the spawn-a-CLI provider).
507
512
  # harness 'claude' | 'codex' | 'generic'. MUST match the binary below.
508
513
  # claude_code_binary Absolute path to your harness binary. Set by \`zyndo init\`.
509
- # model Model name passed to the harness (e.g. 'sonnet', 'gpt-5-codex').
514
+ # model OPTIONAL. Model name passed to the harness via -m/--model.
515
+ # Leave blank (or omit entirely) to use the harness CLI's
516
+ # own default — recommended, since it auto-tracks whatever
517
+ # model your Claude/Codex account currently supports.
510
518
  # working_directory Where the seller can read/write files.
511
519
  # max_concurrent_tasks How many tasks the daemon will run in parallel.
512
520
  # skills What you offer. price_cents is REQUIRED ($0.10 floor).
513
521
  # categories Free-form tags used in marketplace browse filters.
522
+ # force_new_slug OPTIONAL, default false. Set to true ONLY if you
523
+ # deliberately want a second empty seller on this
524
+ # account that shares a display name with an existing
525
+ # one. Normally leave it unset — the broker's orphan
526
+ # guard will tell you to adopt the existing agentId
527
+ # instead of silently minting a duplicate.
514
528
  `;
515
529
  /**
516
530
  * If a seller.yaml already exists, return its `id:` (slug) if any. Used so
@@ -553,7 +567,9 @@ function writeConfig(out, force) {
553
567
  provider: 'claude-code',
554
568
  harness: out.harness,
555
569
  claude_code_binary: out.binary,
556
- model: out.model,
570
+ // Omit `model` entirely when blank so the generated yaml stays clean.
571
+ // An absent `model:` line means the harness CLI picks its own default.
572
+ ...(out.model.length > 0 ? { model: out.model } : {}),
557
573
  working_directory: out.workingDirectory,
558
574
  max_concurrent_tasks: 1,
559
575
  identity_key_path: identityKeyPath,
@@ -683,19 +699,11 @@ function runNonInteractive(flags) {
683
699
  throw new Error('No AI harness found. Install Claude Code or Codex CLI, or pass --harness and --binary explicitly.');
684
700
  }
685
701
  }
686
- // Pick model: explicit > harness default
687
- if (flags.model !== undefined) {
688
- model = flags.model;
689
- }
690
- else if (harness === 'claude') {
691
- model = 'sonnet';
692
- }
693
- else if (harness === 'codex') {
694
- model = 'gpt-5-codex';
695
- }
696
- else {
697
- model = 'default';
698
- }
702
+ // Pick model: explicit > blank (harness CLI uses its own default).
703
+ // Blank is safer than a hardcoded name because providers rotate model
704
+ // availability on ChatGPT/Claude account plans and any stale pin breaks
705
+ // every task overnight. See `HARNESS_OPTIONS` comment above.
706
+ model = flags.model ?? '';
699
707
  // Functional test
700
708
  const test = testBinary(binary);
701
709
  if (!test.ok) {
@@ -820,6 +828,7 @@ async function runInteractive() {
820
828
  process.stdout.write(' rejects any buyer brief that exceeds it. Be specific and strict.\n\n');
821
829
  const deliverables = await promptDeliverables(ask, skillName);
822
830
  process.stdout.write('\n');
831
+ process.stdout.write(' \x1b[2m(leave blank to use your harness CLI\'s own default — recommended)\x1b[0m\n');
823
832
  const model = await ask('Model', selected.defaultModel);
824
833
  const workingDir = await ask('Working directory', process.cwd());
825
834
  process.stdout.write('\n \x1b[1mZyndo Marketplace\x1b[0m\n');
@@ -28,11 +28,16 @@ export function detectHarness(binary) {
28
28
  function buildHarnessSpawn(harness, config, systemPrompt) {
29
29
  switch (harness) {
30
30
  case 'claude': {
31
+ // Only pass --model when the seller explicitly pinned one in seller.yaml.
32
+ // Empty model lets Claude Code use its own built-in default, which
33
+ // auto-tracks whatever Anthropic currently rolls out. Prevents stale
34
+ // seller.yaml pins (e.g. `sonnet-3`) from overriding a working default.
35
+ const modelArgs = config.model.length > 0 ? ['--model', config.model] : [];
31
36
  const args = [
32
37
  '--print',
33
38
  '--output-format', 'json',
34
39
  '--system-prompt', systemPrompt,
35
- '--model', config.model,
40
+ ...modelArgs,
36
41
  '--add-dir', config.workingDirectory,
37
42
  '--permission-mode', 'bypassPermissions',
38
43
  '--no-session-persistence'
@@ -53,10 +58,18 @@ function buildHarnessSpawn(harness, config, systemPrompt) {
53
58
  // events that would otherwise pollute parseOutput.
54
59
  // - `--color never` strips ANSI escapes from any stderr we might log.
55
60
  const outputFile = join(tmpdir(), `zyndo-codex-${Date.now()}-${randomBytes(4).toString('hex')}.txt`);
61
+ // Only pass -m when the seller explicitly pinned a model. Empty model
62
+ // lets Codex use its own ~/.codex/config.toml default, which
63
+ // auto-tracks whatever OpenAI currently supports on the seller's plan.
64
+ // Incident 2026-04-14: a hardcoded `gpt-5-codex` override broke every
65
+ // task when OpenAI rotated the ChatGPT-edu allowlist to `gpt-5.3-codex`
66
+ // overnight. Blank is the safest default; sellers can still pin when
67
+ // they know they need a specific model.
68
+ const modelArgs = config.model.length > 0 ? ['-m', config.model] : [];
56
69
  const args = [
57
70
  'exec',
58
71
  '-',
59
- '-m', config.model,
72
+ ...modelArgs,
60
73
  '--dangerously-bypass-approvals-and-sandbox',
61
74
  '--skip-git-repo-check',
62
75
  '-C', config.workingDirectory,
@@ -209,18 +222,32 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
209
222
  clearTimeout(timeoutHandle);
210
223
  if (err.name === 'AbortError') {
211
224
  logger.error(`Task timed out after ${timeoutMs / 1000}s`);
212
- resolve({ output: `Task timed out after ${timeoutMs / 1000} seconds.`, paused: false, timedOut: true });
225
+ // timedOut path caller already treats this as non-deliverable
226
+ // and sends an info message instead of calling deliverTask. Keep
227
+ // the output field empty so there's no accidental leak.
228
+ resolve({ output: '', paused: false, timedOut: true });
213
229
  return;
214
230
  }
215
231
  logger.error(`Spawn error: ${err.message}`);
216
- resolve({ output: `Harness error: ${err.message}`, paused: false, timedOut: true });
232
+ resolve({
233
+ output: '',
234
+ paused: false,
235
+ harnessFailed: true,
236
+ harnessError: `Failed to spawn harness: ${err.message}`
237
+ });
217
238
  });
218
239
  proc.on('close', (code) => {
219
240
  clearTimeout(timeoutHandle);
220
241
  const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
221
242
  const stderr = Buffer.concat(stderrChunks).toString('utf-8');
222
243
  if (stderr.length > 0) {
223
- logger.info(`Harness stderr: ${stderr.slice(0, 500)}`);
244
+ // Dump the full stderr tail so we can see the actual codex error.
245
+ // Codex prints its startup banner first, then the real error below,
246
+ // so a 500-char slice would hide everything useful. Cap at 8000
247
+ // to avoid pathological logs.
248
+ const STDERR_MAX = 8000;
249
+ const tail = stderr.length > STDERR_MAX ? stderr.slice(-STDERR_MAX) : stderr;
250
+ logger.info(`Harness stderr (len=${stderr.length}):\n${tail}`);
224
251
  }
225
252
  // Codex writes the agent's final message to a tempfile via `-o`.
226
253
  // Read it back, then unlink. If the file is missing or unreadable
@@ -239,8 +266,22 @@ export async function runClaudeCodeTask(taskContext, config, logger) {
239
266
  catch { /* ignore */ }
240
267
  }
241
268
  if (code !== 0 && agentOutput.length === 0) {
242
- logger.error(`Harness exited with code ${code}`);
243
- resolve({ output: `Harness failed (exit ${code}): ${stderr.slice(0, 1000)}`, paused: false });
269
+ // Log the full stderr tail (already emitted above at INFO, but make
270
+ // sure the ERROR line also carries enough context to diagnose).
271
+ const stderrTail = stderr.length > 4000 ? stderr.slice(-4000) : stderr;
272
+ logger.error(`Harness exited with code ${code}. stderr tail:\n${stderrTail}`);
273
+ // CRITICAL: do NOT put the stderr dump into `output` — the caller
274
+ // would deliver that verbatim to the buyer as the task deliverable.
275
+ // Signal failure via the harnessFailed flag so handleTask can send
276
+ // a human-readable info message to the buyer instead. Incident
277
+ // 2026-04-14: a failing codex run was delivered as "Harness failed
278
+ // (exit 1): OpenAI Codex ..." which the buyer saw as their post.
279
+ resolve({
280
+ output: '',
281
+ paused: false,
282
+ harnessFailed: true,
283
+ harnessError: `Codex exited ${code}. Try requesting a revision to retry.`
284
+ });
244
285
  return;
245
286
  }
246
287
  resolve(parseOutput(agentOutput, harness));
@@ -10,6 +10,7 @@ import { createAnthropicProvider } from './providers/anthropic.js';
10
10
  import { createOpenAIProvider } from './providers/openai.js';
11
11
  import { createOllamaProvider } from './providers/ollama.js';
12
12
  import { runClaudeCodeTask } from './providers/claudeCode.js';
13
+ import { verifyHarnessReady } from './harnessSelfCheck.js';
13
14
  import { createReadFileTool } from './tools/readFile.js';
14
15
  import { createWriteFileTool } from './tools/writeFile.js';
15
16
  import { createBashTool } from './tools/bash.js';
@@ -120,6 +121,21 @@ export async function startSellerDaemon(config, opts) {
120
121
  catch (err) {
121
122
  logger.error(`Identity key setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing unsigned.`);
122
123
  }
124
+ // Harness self-check: before touching the broker, spawn the configured
125
+ // harness with a trivial prompt to verify it can actually produce output.
126
+ // Catches stale model pins, expired auth, missing binaries, and rate
127
+ // limits — every failure mode that would otherwise burn a real buyer
128
+ // hire. Fail fast with an actionable fix instead of accepting work we
129
+ // know we cannot deliver. Incident 2026-04-14.
130
+ logger.info('Running harness self-check…');
131
+ const selfCheck = await verifyHarnessReady(config);
132
+ if (!selfCheck.ok) {
133
+ logger.error(`Harness self-check FAILED: ${selfCheck.reason}`);
134
+ logger.error(`Fix:\n ${selfCheck.fix}`);
135
+ logger.error('Daemon refusing to start. Fix the issue above and re-run "zyndo serve".');
136
+ throw new Error(`Harness self-check failed: ${selfCheck.reason}`);
137
+ }
138
+ logger.info('Harness self-check passed.');
123
139
  // Try to reconnect as the same agent from a previous session
124
140
  let session;
125
141
  const previousSession = loadSession();
@@ -153,7 +169,8 @@ export async function startSellerDaemon(config, opts) {
153
169
  skills: [...config.skills],
154
170
  categories: [...config.categories],
155
171
  maxConcurrentTasks: config.maxConcurrentTasks,
156
- sellerSlug: config.id
172
+ sellerSlug: config.id,
173
+ forceNewSlug: config.forceNewSlug
157
174
  });
158
175
  logger.info(`Connected: agentId=${session.agentId}`);
159
176
  }
@@ -603,6 +620,19 @@ async function handleTask(holder, taskId, config, logger, identityPrivateKey) {
603
620
  saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context });
604
621
  return;
605
622
  }
623
+ // Harness failure (codex/claude/generic exited non-zero with no output).
624
+ // NEVER deliver in this branch — the caller's `output` would be empty or
625
+ // a stderr dump and the buyer would receive it as their deliverable.
626
+ // Send a human-readable info message instead and leave the task in its
627
+ // current state so the buyer can request a revision to retry or open a
628
+ // dispute. Incident 2026-04-14.
629
+ if (result.harnessFailed === true) {
630
+ const reason = result.harnessError ?? 'The seller agent crashed during this run.';
631
+ logger.error(`Task ${taskId}: harness failed — ${reason}. NOT delivering stderr.`);
632
+ await sendTaskMessage(holder, taskId, 'info', `The seller agent encountered an error and could not produce a deliverable on this attempt. Details: ${reason}. Please click "Request revision" with the same brief to retry, or open a dispute if the problem persists.`);
633
+ saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context });
634
+ return;
635
+ }
606
636
  if (result.paused) {
607
637
  logger.info(`Task ${taskId}: paused, asking buyer: "${result.pendingQuestion?.slice(0, 100)}..."`);
608
638
  saveState({ taskId, messages: [], claudeCodeContext: context, pendingQuestion: result.pendingQuestion });
@@ -698,6 +728,13 @@ async function handleRevision(holder, taskId, feedback, config, logger, identity
698
728
  saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext });
699
729
  return;
700
730
  }
731
+ if (result.harnessFailed === true) {
732
+ const reason = result.harnessError ?? 'The seller agent crashed during the revision.';
733
+ logger.error(`Task ${taskId}: harness failed on revision — ${reason}. NOT delivering stderr.`);
734
+ await sendTaskMessage(holder, taskId, 'info', `The seller agent hit an error while reworking this delivery (${reason}). Try requesting another revision, or open a dispute if the issue persists.`);
735
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext });
736
+ return;
737
+ }
701
738
  if (result.paused) {
702
739
  logger.info(`Task ${taskId}: revision paused, asking buyer...`);
703
740
  saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, pendingQuestion: result.pendingQuestion });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyndo",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",