zyndo 0.2.1 → 0.3.1

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, listAgentTasks, 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,9 +16,14 @@ 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;
22
+ // Every N poll cycles, reconcile the local active-task set against the
23
+ // broker's authoritative task list. This catches missed `task.assigned`
24
+ // events caused by broker restarts (in-memory event queue reset), network
25
+ // partitions, or dropped SSE frames. 8 cycles at 25s = ~200s safety net.
26
+ const RECONCILE_EVERY_N_POLLS = 8;
19
27
  // ---------------------------------------------------------------------------
20
28
  // Reconnect error classification (incident 2026-04-09 follow-up)
21
29
  //
@@ -91,6 +99,22 @@ function loadTools(allowedTools, workingDirectory) {
91
99
  export async function startSellerDaemon(config, opts) {
92
100
  const logger = opts?.logger ?? defaultLogger;
93
101
  const signal = opts?.signal;
102
+ // Slice 3b — load or generate the Ed25519 identity key once. Private key
103
+ // stays in memory for the lifetime of the daemon; the public key is
104
+ // registered with the broker after every fresh connect / re-connect so
105
+ // rotations are a single env change + restart.
106
+ let identityPrivateKey;
107
+ let identityPublicKeyB64;
108
+ try {
109
+ const keyPath = config.identityKeyPath ?? resolve(process.env.HOME ?? '.', '.zyndo', 'keys', `${config.name}.pem`);
110
+ const keypair = ensureIdentityKeypair(keyPath);
111
+ identityPrivateKey = keypair.privateKey;
112
+ identityPublicKeyB64 = keypair.publicKeyB64;
113
+ logger.info(`Identity key loaded: ${keyPath}`);
114
+ }
115
+ catch (err) {
116
+ logger.error(`Identity key setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing unsigned.`);
117
+ }
94
118
  // Try to reconnect as the same agent from a previous session
95
119
  let session;
96
120
  const previousSession = loadSession();
@@ -123,15 +147,94 @@ export async function startSellerDaemon(config, opts) {
123
147
  description: config.description,
124
148
  skills: [...config.skills],
125
149
  categories: [...config.categories],
126
- maxConcurrentTasks: config.maxConcurrentTasks
150
+ maxConcurrentTasks: config.maxConcurrentTasks,
151
+ sellerSlug: config.id
127
152
  });
128
153
  logger.info(`Connected: agentId=${session.agentId}`);
129
154
  }
130
155
  // Persist session for future restarts
131
156
  saveSession(session.agentId, session.reconnectToken);
132
- let lastEventId = 0;
157
+ // Slice 3b — register the Ed25519 public key with the broker. Failures
158
+ // are logged but non-fatal because signing is soft-enforced until
159
+ // rollout > 80%.
160
+ if (identityPublicKeyB64 !== undefined) {
161
+ try {
162
+ await registerIdentity(session, identityPublicKeyB64);
163
+ logger.info('Identity public key registered with broker.');
164
+ }
165
+ catch (err) {
166
+ logger.error(`Identity registration failed: ${err instanceof Error ? err.message : String(err)}. Deliveries will be unsigned.`);
167
+ }
168
+ }
169
+ let lastEventId = loadLastEventId();
133
170
  let lastHeartbeat = Date.now();
134
171
  const activeTasks = new Set();
172
+ let pollsSinceReconcile = 0;
173
+ /**
174
+ * Reconcile the local active-task set against the broker's authoritative
175
+ * task list. Picks up any `submitted` task assigned to this seller that we
176
+ * do not already have in flight, regardless of whether we ever saw the
177
+ * matching `task.assigned` event. This is the recovery path for missed
178
+ * events (broker restart wipes the in-memory event queue, network drop
179
+ * loses an event frame, etc.).
180
+ *
181
+ * Also handles `input-required` tasks in `question` input-type (buyer
182
+ * answered a prior question we never got the event for) and
183
+ * `working` tasks we have no record of (we crashed mid-task and need to
184
+ * resume — but without saved state the safest action is to notify the
185
+ * buyer; we just re-add to activeTasks so we do not re-accept).
186
+ */
187
+ async function reconcileTasks(reason) {
188
+ const activeSession = session;
189
+ if (activeSession === undefined)
190
+ return;
191
+ try {
192
+ const tasks = await listAgentTasks(activeSession);
193
+ let picked = 0;
194
+ for (const task of tasks) {
195
+ if (task.sellerAgentId !== activeSession.agentId)
196
+ continue;
197
+ if (activeTasks.has(task.taskId))
198
+ continue;
199
+ if (task.state === 'submitted') {
200
+ if (activeTasks.size >= config.maxConcurrentTasks) {
201
+ logger.info(`Reconcile: task ${task.taskId} submitted but seller at capacity, will retry.`);
202
+ continue;
203
+ }
204
+ activeTasks.add(task.taskId);
205
+ picked += 1;
206
+ logger.info(`Reconcile (${reason}): picking up stuck submitted task ${task.taskId}`);
207
+ handleTask(activeSession, task.taskId, config, logger, identityPrivateKey)
208
+ .catch((err) => {
209
+ logger.error(`Reconciled task ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
210
+ })
211
+ .finally(() => activeTasks.delete(task.taskId));
212
+ continue;
213
+ }
214
+ if (task.state === 'input-required' && task.inputType === 'question') {
215
+ activeTasks.add(task.taskId);
216
+ picked += 1;
217
+ logger.info(`Reconcile (${reason}): picking up input-required task ${task.taskId}`);
218
+ handleBuyerMessage(activeSession, task.taskId, config, logger, identityPrivateKey)
219
+ .catch((err) => {
220
+ logger.error(`Reconciled message for ${task.taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
221
+ })
222
+ .finally(() => activeTasks.delete(task.taskId));
223
+ continue;
224
+ }
225
+ }
226
+ if (picked > 0) {
227
+ logger.info(`Reconcile (${reason}): recovered ${picked} task(s).`);
228
+ }
229
+ }
230
+ catch (err) {
231
+ logger.error(`Reconcile (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ }
234
+ // Startup reconcile — if the broker restarted while this daemon was down
235
+ // (or this daemon is resuming a long-running session across a broker
236
+ // deploy), pick up any submitted task assigned to us on the very first tick.
237
+ await reconcileTasks('startup');
135
238
  while (signal === undefined || !signal.aborted) {
136
239
  try {
137
240
  // Heartbeat
@@ -165,6 +268,14 @@ export async function startSellerDaemon(config, opts) {
165
268
  logger.info(`Reconnected successfully (attempt ${attempt + 1}).`);
166
269
  lastHeartbeat = Date.now();
167
270
  reconnected = true;
271
+ // Force a reconcile on the next tick: the broker may have
272
+ // restarted and wiped its in-memory event queue, so our
273
+ // lastEventId cursor is now stale relative to the fresh
274
+ // nextEventId counter. Without this the seller polls forever
275
+ // and never sees task.assigned events that happened while we
276
+ // were disconnected or that were issued after the reset.
277
+ pollsSinceReconcile = RECONCILE_EVERY_N_POLLS;
278
+ await reconcileTasks('reconnect');
168
279
  break;
169
280
  }
170
281
  catch (reconnectError) {
@@ -193,12 +304,15 @@ export async function startSellerDaemon(config, opts) {
193
304
  description: config.description,
194
305
  skills: [...config.skills],
195
306
  categories: [...config.categories],
196
- maxConcurrentTasks: config.maxConcurrentTasks
307
+ maxConcurrentTasks: config.maxConcurrentTasks,
308
+ sellerSlug: config.id
197
309
  });
198
310
  saveSession(session.agentId, session.reconnectToken);
199
311
  logger.info(`Re-registered: agentId=${session.agentId}`);
200
312
  lastHeartbeat = Date.now();
201
313
  reconnected = true;
314
+ pollsSinceReconcile = RECONCILE_EVERY_N_POLLS;
315
+ await reconcileTasks('re-register');
202
316
  }
203
317
  catch (freshErr) {
204
318
  const msg = freshErr instanceof Error ? freshErr.message : String(freshErr);
@@ -235,7 +349,7 @@ export async function startSellerDaemon(config, opts) {
235
349
  if (event.eventId > lastEventId)
236
350
  lastEventId = event.eventId;
237
351
  logger.info(`Task assigned: ${taskId} (active: ${activeTasks.size}/${config.maxConcurrentTasks})`);
238
- handleTask(session, taskId, config, logger)
352
+ handleTask(session, taskId, config, logger, identityPrivateKey)
239
353
  .catch((err) => {
240
354
  logger.error(`Task ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
241
355
  })
@@ -252,7 +366,7 @@ export async function startSellerDaemon(config, opts) {
252
366
  continue; // Task handler is already running
253
367
  activeTasks.add(taskId);
254
368
  logger.info(`Message received for task: ${taskId}`);
255
- handleBuyerMessage(session, taskId, config, logger)
369
+ handleBuyerMessage(session, taskId, config, logger, identityPrivateKey)
256
370
  .catch((err) => {
257
371
  logger.error(`Message handling for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
258
372
  })
@@ -265,12 +379,25 @@ export async function startSellerDaemon(config, opts) {
265
379
  continue;
266
380
  activeTasks.add(taskId);
267
381
  logger.info(`Revision requested for task: ${taskId}`);
268
- handleRevision(session, taskId, feedback, config, logger)
382
+ handleRevision(session, taskId, feedback, config, logger, identityPrivateKey)
269
383
  .catch((err) => {
270
384
  logger.error(`Revision for ${taskId} failed: ${err instanceof Error ? err.message : String(err)}`);
271
385
  })
272
386
  .finally(() => activeTasks.delete(taskId));
273
387
  }
388
+ // Dispute lifecycle events — log so the seller operator is aware
389
+ if (event.type === 'task.dispute.opened') {
390
+ const taskId = event.payload.taskId;
391
+ const reason = event.payload.reason ?? '';
392
+ const disputeId = event.payload.disputeId ?? '';
393
+ logger.info(`DISPUTE OPENED on task ${taskId} (${disputeId}): ${reason}`);
394
+ }
395
+ if (event.type === 'task.dispute.resolved') {
396
+ const taskId = event.payload.taskId;
397
+ const resolution = event.payload.resolution ?? '';
398
+ const disputeId = event.payload.disputeId ?? '';
399
+ logger.info(`DISPUTE RESOLVED on task ${taskId} (${disputeId}): ${resolution}`);
400
+ }
274
401
  // Clean up state on terminal events
275
402
  if (event.type === 'task.completed' || event.type === 'task.rejected' || event.type === 'task.failed' || event.type === 'task.canceled') {
276
403
  const taskId = event.payload.taskId;
@@ -282,6 +409,18 @@ export async function startSellerDaemon(config, opts) {
282
409
  if (minDeferredEventId !== undefined && lastEventId >= minDeferredEventId) {
283
410
  lastEventId = minDeferredEventId - 1;
284
411
  }
412
+ // Persist cursor so restarts resume from here
413
+ saveLastEventId(lastEventId);
414
+ // Periodic reconcile: even without a reconnect, poll the task list as a
415
+ // safety net. If a single `task.assigned` event was dropped (network
416
+ // partition, broker partial outage, client-side parse error) the seller
417
+ // would otherwise leave a buyer stuck indefinitely. Every N polls we
418
+ // re-check the authoritative task list and pick up anything missed.
419
+ pollsSinceReconcile += 1;
420
+ if (pollsSinceReconcile >= RECONCILE_EVERY_N_POLLS) {
421
+ pollsSinceReconcile = 0;
422
+ await reconcileTasks('periodic');
423
+ }
285
424
  }
286
425
  catch (error) {
287
426
  logger.error(`Poll error: ${error instanceof Error ? error.message : String(error)}`);
@@ -293,8 +432,18 @@ export async function startSellerDaemon(config, opts) {
293
432
  // ---------------------------------------------------------------------------
294
433
  // Task handler
295
434
  // ---------------------------------------------------------------------------
296
- async function handleTask(session, taskId, config, logger) {
297
- await acceptTask(session, taskId);
435
+ async function handleTask(session, taskId, config, logger, identityPrivateKey) {
436
+ try {
437
+ await acceptTask(session, taskId);
438
+ }
439
+ catch (err) {
440
+ const msg = err instanceof Error ? err.message : String(err);
441
+ if (msg.includes('409')) {
442
+ logger.info(`Task ${taskId}: already in progress or completed, skipping.`);
443
+ return;
444
+ }
445
+ throw err;
446
+ }
298
447
  logger.info(`Task ${taskId}: accepted, starting work...`);
299
448
  // Get task context from task detail first, then messages as supplement
300
449
  const detail = await getTaskDetail(session, taskId);
@@ -302,9 +451,20 @@ async function handleTask(session, taskId, config, logger) {
302
451
  const messages = await getTaskMessages(session, taskId);
303
452
  const messageContext = messages.map((m) => m.content).join('\n');
304
453
  const context = taskContext || messageContext || 'Task assigned. No additional context provided.';
454
+ // Compose the system prompt with the BOUND CONTRACT + REFUSAL PROTOCOL
455
+ // derived from the hire's frozen deliverables snapshot. Seller persona is
456
+ // subordinate; the contract and refusal blocks cannot be overridden by the
457
+ // seller or by anything the buyer says mid-task.
458
+ const scopedSystemPrompt = composeSystemPrompt(config.systemPrompt, detail?.deliverablesSnapshot);
459
+ if (detail?.deliverablesSnapshot === undefined) {
460
+ logger.info(`Task ${taskId}: no deliverables snapshot on task (legacy hire or scope guard disabled), running with seller persona only.`);
461
+ }
462
+ else {
463
+ 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.`);
464
+ }
305
465
  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 });
466
+ ? await runClaudeCodeTask(context, { ...config, systemPrompt: scopedSystemPrompt }, logger)
467
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), context, { systemPrompt: scopedSystemPrompt, taskId });
308
468
  if (result.timedOut === true) {
309
469
  logger.error(`Task ${taskId}: agent timed out or errored, notifying buyer.`);
310
470
  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 +477,29 @@ async function handleTask(session, taskId, config, logger) {
317
477
  await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
318
478
  return;
319
479
  }
320
- logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
321
- await deliverTask(session, taskId, result.output);
480
+ // Output-side truncation: count produced units, clamp to contract, log a
481
+ // scope.truncated info message back to the buyer so the seller is not
482
+ // silently abused via prompt injection mid-conversation.
483
+ const truncated = truncateToContract(result.output, detail?.deliverablesSnapshot);
484
+ const finalOutput = truncated.output;
485
+ if (truncated.events.length > 0) {
486
+ for (const ev of truncated.events) {
487
+ logger.info(`Task ${taskId}: scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
488
+ 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.`);
489
+ }
490
+ }
491
+ logger.info(`Task ${taskId}: delivering result (${finalOutput.length} chars)`);
492
+ const signature = identityPrivateKey !== undefined
493
+ ? signDelivery({ taskId, content: finalOutput, privateKey: identityPrivateKey })
494
+ : undefined;
495
+ await deliverTask(session, taskId, finalOutput, signature);
322
496
  // Preserve state for potential revision — only deleted on terminal events
323
- saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: result.output });
497
+ saveState({ taskId, messages: [], claudeCodeContext: context, originalContext: context, lastDelivery: finalOutput });
324
498
  }
325
499
  // ---------------------------------------------------------------------------
326
500
  // Revision handler
327
501
  // ---------------------------------------------------------------------------
328
- async function handleRevision(session, taskId, feedback, config, logger) {
502
+ async function handleRevision(session, taskId, feedback, config, logger, identityPrivateKey) {
329
503
  const savedState = loadState(taskId);
330
504
  // Use original task context (not compounded revision context) to avoid quadratic growth
331
505
  const originalContext = savedState?.originalContext ?? savedState?.claudeCodeContext ?? '';
@@ -344,9 +518,12 @@ async function handleRevision(session, taskId, feedback, config, logger) {
344
518
  '',
345
519
  'Revise your work based on the buyer feedback above. Output the complete updated deliverable.'
346
520
  ].join('\n');
521
+ // Re-fetch the frozen snapshot so revisions stay bound to the contract.
522
+ const revisionDetail = await getTaskDetail(session, taskId);
523
+ const revisionSystemPrompt = composeSystemPrompt(config.systemPrompt, revisionDetail?.deliverablesSnapshot);
347
524
  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 });
525
+ ? await runClaudeCodeTask(revisionContext, { ...config, systemPrompt: revisionSystemPrompt }, logger)
526
+ : await runAgentLoop(createProvider(config.provider, config.model, config.providerApiKey), loadTools(config.allowedTools, config.workingDirectory), revisionContext, { systemPrompt: revisionSystemPrompt, taskId });
350
527
  if (result.timedOut === true) {
351
528
  logger.error(`Task ${taskId}: revision timed out, notifying buyer.`);
352
529
  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 +536,20 @@ async function handleRevision(session, taskId, feedback, config, logger) {
359
536
  await sendTaskMessage(session, taskId, 'question', result.pendingQuestion ?? 'Could you clarify?');
360
537
  return;
361
538
  }
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 });
539
+ const truncatedRevision = truncateToContract(result.output, revisionDetail?.deliverablesSnapshot);
540
+ const finalRevisionOutput = truncatedRevision.output;
541
+ if (truncatedRevision.events.length > 0) {
542
+ for (const ev of truncatedRevision.events) {
543
+ logger.info(`Task ${taskId}: revision scope truncation — item="${ev.itemName}" produced=${ev.producedQuantity} allowed=${ev.allowedQuantity}`);
544
+ await sendTaskMessage(session, taskId, 'info', `[scope-guard] Revision trimmed: "${ev.itemName}" produced ${ev.producedQuantity}, contract allows ${ev.allowedQuantity}.`);
545
+ }
546
+ }
547
+ logger.info(`Task ${taskId}: delivering revision (${finalRevisionOutput.length} chars)`);
548
+ const revisionSignature = identityPrivateKey !== undefined
549
+ ? signDelivery({ taskId, content: finalRevisionOutput, privateKey: identityPrivateKey })
550
+ : undefined;
551
+ await deliverTask(session, taskId, finalRevisionOutput, revisionSignature);
552
+ saveState({ taskId, messages: [], claudeCodeContext: revisionContext, originalContext, lastDelivery: finalRevisionOutput });
365
553
  }
366
554
  // ---------------------------------------------------------------------------
367
555
  // Buyer message handler (resume paused loop)
@@ -376,7 +564,7 @@ function buildResumedContext(priorContext, priorQuestion, buyerAnswer) {
376
564
  'Continue the task with this new information.'
377
565
  ].join('\n');
378
566
  }
379
- async function handleBuyerMessage(session, taskId, config, logger) {
567
+ async function handleBuyerMessage(session, taskId, config, logger, identityPrivateKey) {
380
568
  const savedState = loadState(taskId);
381
569
  if (savedState === undefined) {
382
570
  logger.info(`Task ${taskId}: no saved state found, ignoring message.`);
@@ -421,7 +609,10 @@ async function handleBuyerMessage(session, taskId, config, logger) {
421
609
  return;
422
610
  }
423
611
  logger.info(`Task ${taskId}: delivering result (${result.output.length} chars)`);
424
- await deliverTask(session, taskId, result.output);
612
+ const resumedSignature = identityPrivateKey !== undefined
613
+ ? signDelivery({ taskId, content: result.output, privateKey: identityPrivateKey })
614
+ : undefined;
615
+ await deliverTask(session, taskId, result.output, resumedSignature);
425
616
  const resumedContext = config.provider === 'claude-code'
426
617
  ? buildResumedContext(savedState.claudeCodeContext ?? '', savedState.pendingQuestion, lastMessage.content)
427
618
  : 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.1",
4
4
  "description": "The agent-to-agent CLI tool for sellers in the Zyndo Marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",