zyndo 0.2.1 → 0.3.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.
@@ -1,7 +1,10 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Seller daemon — poll for tasks, run agent loop, deliver results
3
3
  // ---------------------------------------------------------------------------
4
- import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail } from './connection.js';
4
+ import { resolve } from 'node:path';
5
+ import { connect, reconnect, heartbeat, pollEvents, acceptTask, deliverTask, sendTaskMessage, getTaskMessages, getTaskDetail, registerIdentity } from './connection.js';
6
+ import { ensureIdentityKeypair, signDelivery } from './identity.js';
7
+ import { composeSystemPrompt, truncateToContract } from './scopeContract.js';
5
8
  import { runAgentLoop } from './agentLoop.js';
6
9
  import { createAnthropicProvider } from './providers/anthropic.js';
7
10
  import { createOpenAIProvider } from './providers/openai.js';
@@ -13,7 +16,7 @@ import { createBashTool } from './tools/bash.js';
13
16
  import { createGlobTool } from './tools/glob.js';
14
17
  import { createGrepTool } from './tools/grep.js';
15
18
  import { createAskBuyerTool } from './tools/askBuyer.js';
16
- import { loadState, saveState, deleteState, loadSession, saveSession } from './state.js';
19
+ import { loadState, saveState, deleteState, loadSession, saveSession, loadLastEventId, saveLastEventId } from './state.js';
17
20
  const POLL_INTERVAL_MS = 25_000;
18
21
  const HEARTBEAT_INTERVAL_MS = 45_000;
19
22
  // ---------------------------------------------------------------------------
@@ -91,6 +94,22 @@ function loadTools(allowedTools, workingDirectory) {
91
94
  export async function startSellerDaemon(config, opts) {
92
95
  const logger = opts?.logger ?? defaultLogger;
93
96
  const signal = opts?.signal;
97
+ // Slice 3b — load or generate the Ed25519 identity key once. Private key
98
+ // stays in memory for the lifetime of the daemon; the public key is
99
+ // registered with the broker after every fresh connect / re-connect so
100
+ // rotations are a single env change + restart.
101
+ let identityPrivateKey;
102
+ let identityPublicKeyB64;
103
+ try {
104
+ const keyPath = config.identityKeyPath ?? resolve(process.env.HOME ?? '.', '.zyndo', 'keys', `${config.name}.pem`);
105
+ const keypair = ensureIdentityKeypair(keyPath);
106
+ identityPrivateKey = keypair.privateKey;
107
+ identityPublicKeyB64 = keypair.publicKeyB64;
108
+ logger.info(`Identity key loaded: ${keyPath}`);
109
+ }
110
+ catch (err) {
111
+ logger.error(`Identity key setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing unsigned.`);
112
+ }
94
113
  // Try to reconnect as the same agent from a previous session
95
114
  let session;
96
115
  const previousSession = loadSession();
@@ -123,13 +142,26 @@ export async function startSellerDaemon(config, opts) {
123
142
  description: config.description,
124
143
  skills: [...config.skills],
125
144
  categories: [...config.categories],
126
- maxConcurrentTasks: config.maxConcurrentTasks
145
+ maxConcurrentTasks: config.maxConcurrentTasks,
146
+ sellerSlug: config.id
127
147
  });
128
148
  logger.info(`Connected: agentId=${session.agentId}`);
129
149
  }
130
150
  // Persist session for future restarts
131
151
  saveSession(session.agentId, session.reconnectToken);
132
- let lastEventId = 0;
152
+ // Slice 3b — register the Ed25519 public key with the broker. Failures
153
+ // are logged but non-fatal because signing is soft-enforced until
154
+ // rollout > 80%.
155
+ if (identityPublicKeyB64 !== undefined) {
156
+ try {
157
+ await registerIdentity(session, identityPublicKeyB64);
158
+ logger.info('Identity public key registered with broker.');
159
+ }
160
+ catch (err) {
161
+ logger.error(`Identity registration failed: ${err instanceof Error ? err.message : String(err)}. Deliveries will be unsigned.`);
162
+ }
163
+ }
164
+ let lastEventId = loadLastEventId();
133
165
  let lastHeartbeat = Date.now();
134
166
  const activeTasks = new Set();
135
167
  while (signal === undefined || !signal.aborted) {
@@ -193,7 +225,8 @@ export async function startSellerDaemon(config, opts) {
193
225
  description: config.description,
194
226
  skills: [...config.skills],
195
227
  categories: [...config.categories],
196
- maxConcurrentTasks: config.maxConcurrentTasks
228
+ maxConcurrentTasks: config.maxConcurrentTasks,
229
+ sellerSlug: config.id
197
230
  });
198
231
  saveSession(session.agentId, session.reconnectToken);
199
232
  logger.info(`Re-registered: agentId=${session.agentId}`);
@@ -235,7 +268,7 @@ export async function startSellerDaemon(config, opts) {
235
268
  if (event.eventId > lastEventId)
236
269
  lastEventId = event.eventId;
237
270
  logger.info(`Task assigned: ${taskId} (active: ${activeTasks.size}/${config.maxConcurrentTasks})`);
238
- handleTask(session, taskId, config, logger)
271
+ handleTask(session, taskId, config, logger, identityPrivateKey)
239
272
  .catch((err) => {
240
273
  logger.error(`Task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
241
274
  })
@@ -252,7 +285,7 @@ export async function startSellerDaemon(config, opts) {
252
285
  continue; // Task handler is already running
253
286
  activeTasks.add(taskId);
254
287
  logger.info(`Message received for task: ${taskId}`);
255
- handleBuyerMessage(session, taskId, config, logger)
288
+ handleBuyerMessage(session, taskId, config, logger, identityPrivateKey)
256
289
  .catch((err) => {
257
290
  logger.error(`Message handling for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
258
291
  })
@@ -265,12 +298,25 @@ export async function startSellerDaemon(config, opts) {
265
298
  continue;
266
299
  activeTasks.add(taskId);
267
300
  logger.info(`Revision requested for task: ${taskId}`);
268
- handleRevision(session, taskId, feedback, config, logger)
301
+ handleRevision(session, taskId, feedback, config, logger, identityPrivateKey)
269
302
  .catch((err) => {
270
303
  logger.error(`Revision for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
271
304
  })
272
305
  .finally(() => activeTasks.delete(taskId));
273
306
  }
307
+ // Dispute lifecycle events — log so the seller operator is aware
308
+ if (event.type === 'task.dispute.opened') {
309
+ const taskId = event.payload.taskId;
310
+ const reason = event.payload.reason ?? '';
311
+ const disputeId = event.payload.disputeId ?? '';
312
+ logger.info(`DISPUTE OPENED on task ${taskId} (${disputeId}): ${reason}`);
313
+ }
314
+ if (event.type === 'task.dispute.resolved') {
315
+ const taskId = event.payload.taskId;
316
+ const resolution = event.payload.resolution ?? '';
317
+ const disputeId = event.payload.disputeId ?? '';
318
+ logger.info(`DISPUTE RESOLVED on task ${taskId} (${disputeId}): ${resolution}`);
319
+ }
274
320
  // Clean up state on terminal events
275
321
  if (event.type === 'task.completed' || event.type === 'task.rejected' || event.type === 'task.failed' || event.type === 'task.canceled') {
276
322
  const taskId = event.payload.taskId;
@@ -282,6 +328,8 @@ export async function startSellerDaemon(config, opts) {
282
328
  if (minDeferredEventId !== undefined && lastEventId >= minDeferredEventId) {
283
329
  lastEventId = minDeferredEventId - 1;
284
330
  }
331
+ // Persist cursor so restarts resume from here
332
+ saveLastEventId(lastEventId);
285
333
  }
286
334
  catch (error) {
287
335
  logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
@@ -293,8 +341,18 @@ export async function startSellerDaemon(config, opts) {
293
341
  // ---------------------------------------------------------------------------
294
342
  // Task handler
295
343
  // ---------------------------------------------------------------------------
296
- async function handleTask(session, taskId, config, logger) {
297
- await acceptTask(session, taskId);
344
+ async function handleTask(session, taskId, config, logger, identityPrivateKey) {
345
+ try {
346
+ await acceptTask(session, taskId);
347
+ }
348
+ catch (err) {
349
+ const msg = err instanceof Error ? err.message : String(err);
350
+ if (msg.includes('409')) {
351
+ logger.info(`Task ${taskId}: already in progress or completed, skipping.`);
352
+ return;
353
+ }
354
+ throw err;
355
+ }
298
356
  logger.info(`Task ${taskId}: accepted, starting work...`);
299
357
  // Get task context from task detail first, then messages as supplement
300
358
  const detail = await getTaskDetail(session, taskId);
@@ -302,9 +360,20 @@ async function handleTask(session, taskId, config, logger) {
302
360
  const messages = await getTaskMessages(session, taskId);
303
361
  const messageContext = messages.map((m) => m.content).join('\n');
304
362
  const context = taskContext || messageContext || 'Task assigned. No additional context provided.';
363
+ // Compose the system prompt with the BOUND CONTRACT + REFUSAL PROTOCOL
364
+ // derived from the hire's frozen deliverables snapshot. Seller persona is
365
+ // subordinate; the contract and refusal blocks cannot be overridden by the
366
+ // seller or by anything the buyer says mid-task.
367
+ const scopedSystemPrompt = composeSystemPrompt(config.systemPrompt, detail?.deliverablesSnapshot);
368
+ if (detail?.deliverablesSnapshot === undefined) {
369
+ logger.info(`Task ${taskId}: no deliverables snapshot on task (legacy hire or scope guard disabled), running with seller persona only.`);
370
+ }
371
+ else {
372
+ logger.info(`Task ${taskId}: bound contract applied — ${detail.deliverablesSnapshot.items.length} item(s), ${detail.deliverablesSnapshot.inScope.length} in-scope, ${detail.deliverablesSnapshot.outOfScope.length} out-of-scope.`);
373
+ }
305
374
  const result = config.provider === 'claude-code'
306
- ? await runClaudeCodeTask(context, config, logger)
307
- : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: config.systemPrompt, taskId });
375
+ ? await runClaudeCodeTask(context, { ...config, systemPrompt: scopedSystemPrompt }, logger)
376
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: scopedSystemPrompt, taskId });
308
377
  if (result.timedOut === true) {
309
378
  logger.error(`Task ${taskId}: agent timed out or errored, notifying buyer.`);
310
379
  await sendTaskMessage(session, taskId, 'info', 'The seller agent encountered a timeout processing this task. Please allow more time or simplify the request. The task remains assigned.');
@@ -317,15 +386,29 @@ async function handleTask(session, taskId, config, logger) {
317
386
  await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
318
387
  return;
319
388
  }
320
- logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
321
- await deliverTask(session, taskId, result.output);
389
+ // Output-side truncation: count produced units, clamp to contract, log a
390
+ // scope.truncated info message back to the buyer so the seller is not
391
+ // silently abused via prompt injection mid-conversation.
392
+ const truncated = truncateToContract(result.output, detail?.deliverablesSnapshot);
393
+ const finalOutput = truncated.output;
394
+ if (truncated.events.length > 0) {
395
+ for (const ev of truncated.events) {
396
+ logger.info(`Task ${taskId}: scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
397
+ await sendTaskMessage(session, taskId, 'info', `[scope-guard] Your brief requested more "${ev.itemName}" than the listing delivers. The seller agent produced ${ev.producedQuantity} but the contract allows only ${ev.allowedQuantity}. The delivery has been trimmed to the contracted quantity. To get more, hire again with a listing that covers the extra volume.`);
398
+ }
399
+ }
400
+ logger.info(`Task ${taskId}: delivering result (${finalOutput.length} chars)`);
401
+ const signature = identityPrivateKey !== undefined
402
+ ? signDelivery({ taskId, content: finalOutput, privateKey: identityPrivateKey })
403
+ : undefined;
404
+ await deliverTask(session, taskId, finalOutput, signature);
322
405
  // Preserve state for potential revision — only deleted on terminal events
323
- saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: result.output });
406
+ saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: finalOutput });
324
407
  }
325
408
  // ---------------------------------------------------------------------------
326
409
  // Revision handler
327
410
  // ---------------------------------------------------------------------------
328
- async function handleRevision(session, taskId, feedback, config, logger) {
411
+ async function handleRevision(session, taskId, feedback, config, logger, identityPrivateKey) {
329
412
  const savedState = loadState(taskId);
330
413
  // Use original task context (not compounded revision context) to avoid quadratic growth
331
414
  const originalContext = savedState?.originalContext ?? savedState?.claudeCodeContext ?? '';
@@ -344,9 +427,12 @@ async function handleRevision(session, taskId, feedback, config, logger) {
344
427
  '',
345
428
  'Revise your work based on the buyer feedback above. Output the complete updated deliverable.'
346
429
  ].join('\n');
430
+ // Re-fetch the frozen snapshot so revisions stay bound to the contract.
431
+ const revisionDetail = await getTaskDetail(session, taskId);
432
+ const revisionSystemPrompt = composeSystemPrompt(config.systemPrompt, revisionDetail?.deliverablesSnapshot);
347
433
  const result = config.provider === 'claude-code'
348
- ? await runClaudeCodeTask(revisionContext, config, logger)
349
- : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: config.systemPrompt, taskId });
434
+ ? await runClaudeCodeTask(revisionContext, { ...config, systemPrompt: revisionSystemPrompt }, logger)
435
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: revisionSystemPrompt, taskId });
350
436
  if (result.timedOut === true) {
351
437
  logger.error(`Task ${taskId}: revision timed out, notifying buyer.`);
352
438
  await sendTaskMessage(session, taskId, 'info', 'The seller agent timed out while working on the revision. Please allow more time or simplify the request.');
@@ -359,9 +445,20 @@ async function handleRevision(session, taskId, feedback, config, logger) {
359
445
  await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
360
446
  return;
361
447
  }
362
- logger.info(`Task ${taskId}: delivering revision (${result.output.length} chars)`);
363
- await deliverTask(session, taskId, result.output);
364
- saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: result.output });
448
+ const truncatedRevision = truncateToContract(result.output, revisionDetail?.deliverablesSnapshot);
449
+ const finalRevisionOutput = truncatedRevision.output;
450
+ if (truncatedRevision.events.length > 0) {
451
+ for (const ev of truncatedRevision.events) {
452
+ logger.info(`Task ${taskId}: revision scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
453
+ await sendTaskMessage(session, taskId, 'info', `[scope-guard] Revision trimmed: "${ev.itemName}" produced ${ev.producedQuantity}, contract allows ${ev.allowedQuantity}.`);
454
+ }
455
+ }
456
+ logger.info(`Task ${taskId}: delivering revision (${finalRevisionOutput.length} chars)`);
457
+ const revisionSignature = identityPrivateKey !== undefined
458
+ ? signDelivery({ taskId, content: finalRevisionOutput, privateKey: identityPrivateKey })
459
+ : undefined;
460
+ await deliverTask(session, taskId, finalRevisionOutput, revisionSignature);
461
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: finalRevisionOutput });
365
462
  }
366
463
  // ---------------------------------------------------------------------------
367
464
  // Buyer message handler (resume paused loop)
@@ -376,7 +473,7 @@ function buildResumedContext(priorContext, priorQuestion, buyerAnswer) {
376
473
  'Continue the task with this new information.'
377
474
  ].join('\n');
378
475
  }
379
- async function handleBuyerMessage(session, taskId, config, logger) {
476
+ async function handleBuyerMessage(session, taskId, config, logger, identityPrivateKey) {
380
477
  const savedState = loadState(taskId);
381
478
  if (savedState === undefined) {
382
479
  logger.info(`Task ${taskId}: no saved state found, ignoring message.`);
@@ -421,7 +518,10 @@ async function handleBuyerMessage(session, taskId, config, logger) {
421
518
  return;
422
519
  }
423
520
  logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
424
- await deliverTask(session, taskId, result.output);
521
+ const resumedSignature = identityPrivateKey !== undefined
522
+ ? signDelivery({ taskId, content: result.output, privateKey: identityPrivateKey })
523
+ : undefined;
524
+ await deliverTask(session, taskId, result.output, resumedSignature);
425
525
  const resumedContext = config.provider === 'claude-code'
426
526
  ? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
427
527
  : savedState.claudeCodeContext ?? '';
package/dist/state.d.ts CHANGED
@@ -11,6 +11,8 @@ export type ConversationState = Readonly<{
11
11
  export declare function saveState(state: ConversationState): void;
12
12
  export declare function loadState(taskId: string): ConversationState | undefined;
13
13
  export declare function deleteState(taskId: string): void;
14
+ export declare function saveLastEventId(lastEventId: number): void;
15
+ export declare function loadLastEventId(): number;
14
16
  export type PersistedSession = Readonly<{
15
17
  agentId: string;
16
18
  reconnectToken: string;
package/dist/state.js CHANGED
@@ -36,6 +36,29 @@ export function deleteState(taskId) {
36
36
  unlinkSync(path);
37
37
  }
38
38
  }
39
+ // ---------------------------------------------------------------------------
40
+ // Event cursor persistence — resume from last processed event on restart
41
+ // ---------------------------------------------------------------------------
42
+ function getEventCursorFile() {
43
+ return resolve(getBaseDir(), 'event-cursor.json');
44
+ }
45
+ export function saveLastEventId(lastEventId) {
46
+ const baseDir = getBaseDir();
47
+ mkdirSync(baseDir, { recursive: true });
48
+ writeFileSync(getEventCursorFile(), JSON.stringify({ lastEventId, savedAt: new Date().toISOString() }), 'utf-8');
49
+ }
50
+ export function loadLastEventId() {
51
+ if (!existsSync(getEventCursorFile()))
52
+ return 0;
53
+ try {
54
+ const raw = readFileSync(getEventCursorFile(), 'utf-8');
55
+ const parsed = JSON.parse(raw);
56
+ return typeof parsed.lastEventId === 'number' ? parsed.lastEventId : 0;
57
+ }
58
+ catch {
59
+ return 0;
60
+ }
61
+ }
39
62
  function getSessionFile() {
40
63
  return resolve(getBaseDir(), 'session.json');
41
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyndo",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",