spendos 0.1.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.
Files changed (90) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +30 -0
  3. package/AGENTS.md +212 -0
  4. package/BOOTSTRAP.md +55 -0
  5. package/Dockerfile +52 -0
  6. package/HEARTBEAT.md +7 -0
  7. package/IDENTITY.md +23 -0
  8. package/LICENSE +21 -0
  9. package/README.md +162 -0
  10. package/SOUL.md +202 -0
  11. package/SUBMISSION.md +128 -0
  12. package/TOOLS.md +40 -0
  13. package/USER.md +17 -0
  14. package/acp-seller/bin/acp.ts +807 -0
  15. package/acp-seller/config.json +34 -0
  16. package/acp-seller/package.json +55 -0
  17. package/acp-seller/src/commands/agent.ts +328 -0
  18. package/acp-seller/src/commands/bounty.ts +1189 -0
  19. package/acp-seller/src/commands/deploy.ts +414 -0
  20. package/acp-seller/src/commands/job.ts +217 -0
  21. package/acp-seller/src/commands/profile.ts +71 -0
  22. package/acp-seller/src/commands/resource.ts +91 -0
  23. package/acp-seller/src/commands/search.ts +327 -0
  24. package/acp-seller/src/commands/sell.ts +883 -0
  25. package/acp-seller/src/commands/serve.ts +258 -0
  26. package/acp-seller/src/commands/setup.ts +399 -0
  27. package/acp-seller/src/commands/token.ts +88 -0
  28. package/acp-seller/src/commands/wallet.ts +123 -0
  29. package/acp-seller/src/lib/api.ts +118 -0
  30. package/acp-seller/src/lib/auth.ts +291 -0
  31. package/acp-seller/src/lib/bounty.ts +257 -0
  32. package/acp-seller/src/lib/client.ts +42 -0
  33. package/acp-seller/src/lib/config.ts +240 -0
  34. package/acp-seller/src/lib/open.ts +41 -0
  35. package/acp-seller/src/lib/openclawCron.ts +138 -0
  36. package/acp-seller/src/lib/output.ts +104 -0
  37. package/acp-seller/src/lib/wallet.ts +81 -0
  38. package/acp-seller/src/seller/offerings/_shared/preTransactionScan.ts +127 -0
  39. package/acp-seller/src/seller/offerings/canonical-catalog.ts +221 -0
  40. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/handlers.ts +20 -0
  41. package/acp-seller/src/seller/offerings/spendos/spendos_summarize_url/offering.json +18 -0
  42. package/acp-seller/src/seller/offerings/spendos/spendos_translate/handlers.ts +21 -0
  43. package/acp-seller/src/seller/offerings/spendos/spendos_translate/offering.json +22 -0
  44. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/handlers.ts +20 -0
  45. package/acp-seller/src/seller/offerings/spendos/spendos_tweet_gen/offering.json +18 -0
  46. package/acp-seller/src/seller/runtime/acpSocket.ts +413 -0
  47. package/acp-seller/src/seller/runtime/logger.ts +36 -0
  48. package/acp-seller/src/seller/runtime/offeringTypes.ts +52 -0
  49. package/acp-seller/src/seller/runtime/offerings.ts +277 -0
  50. package/acp-seller/src/seller/runtime/paymentVerification.test.ts +207 -0
  51. package/acp-seller/src/seller/runtime/paymentVerification.ts +363 -0
  52. package/acp-seller/src/seller/runtime/seller.onchain.test.ts +220 -0
  53. package/acp-seller/src/seller/runtime/seller.test.ts +823 -0
  54. package/acp-seller/src/seller/runtime/seller.ts +1041 -0
  55. package/acp-seller/src/seller/runtime/sellerApi.ts +71 -0
  56. package/acp-seller/src/seller/runtime/startup.ts +270 -0
  57. package/acp-seller/src/seller/runtime/types.ts +62 -0
  58. package/acp-seller/tsconfig.json +20 -0
  59. package/bin/spendos.js +23 -0
  60. package/contracts/SpendOSAudit.sol +29 -0
  61. package/dist/mcp-server.mjs +153 -0
  62. package/jobs/translate.json +7 -0
  63. package/jobs/tweet-gen.json +7 -0
  64. package/openclaw.json +41 -0
  65. package/package.json +49 -0
  66. package/plugins/spendos-events/index.ts +78 -0
  67. package/plugins/spendos-events/package.json +14 -0
  68. package/policies/enforce-bounds.mjs +71 -0
  69. package/public/index.html +509 -0
  70. package/public/landing.html +241 -0
  71. package/railway.json +12 -0
  72. package/railway.toml +12 -0
  73. package/scripts/deploy.ts +48 -0
  74. package/scripts/test-x402-mainnet.ts +30 -0
  75. package/scripts/xmtp-listener.ts +61 -0
  76. package/setup.sh +278 -0
  77. package/skills/spendos/skill.md +26 -0
  78. package/src/agent.ts +152 -0
  79. package/src/audit.ts +166 -0
  80. package/src/governance.ts +367 -0
  81. package/src/job-registry.ts +306 -0
  82. package/src/mcp-public.ts +145 -0
  83. package/src/mcp-server.ts +171 -0
  84. package/src/opportunity-scanner.ts +138 -0
  85. package/src/server.ts +870 -0
  86. package/src/venice-x402.ts +234 -0
  87. package/src/xmtp.ts +109 -0
  88. package/src/zerion.ts +58 -0
  89. package/start.sh +168 -0
  90. package/tsconfig.json +14 -0
@@ -0,0 +1,1189 @@
1
+ // =============================================================================
2
+ // acp bounty create [query]
3
+ // acp bounty list
4
+ // acp bounty status <bountyId>
5
+ // acp bounty select <bountyId>
6
+ // =============================================================================
7
+
8
+ import readline from "readline";
9
+ import client from "../lib/client.js";
10
+ import * as output from "../lib/output.js";
11
+ import { requireActiveAgent } from "../lib/wallet.js";
12
+ import {
13
+ type ActiveBounty,
14
+ type BountyCreateInput,
15
+ createBounty,
16
+ getActiveBounty,
17
+ getBountyDetails,
18
+ getMatchStatus,
19
+ listActiveBounties,
20
+ removeActiveBounty,
21
+ rejectCandidates,
22
+ saveActiveBounty,
23
+ syncBountyJobStatus,
24
+ confirmMatch,
25
+ updateBounty,
26
+ } from "../lib/bounty.js";
27
+ import { ROOT } from "../lib/config.js";
28
+ import {
29
+ ensureBountyPollCron,
30
+ removeBountyPollCronIfUnused,
31
+ } from "../lib/openclawCron.js";
32
+
33
+ function question(rl: readline.Interface, prompt: string): Promise<string> {
34
+ return new Promise((resolve) => rl.question(prompt, resolve));
35
+ }
36
+
37
+ function parseCandidateId(raw: unknown): number | null {
38
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
39
+ if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
40
+ return parseInt(raw.trim(), 10);
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function candidateField(candidate: any, names: string[]): string | undefined {
46
+ for (const name of names) {
47
+ const value = candidate?.[name];
48
+ if (typeof value === "string" && value.trim()) return value.trim();
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ function candidatePriceDisplay(candidate: Record<string, unknown>): string {
54
+ const rawPrice =
55
+ candidate.price ??
56
+ candidate.job_offering_price ??
57
+ candidate.jobOfferingPrice ??
58
+ candidate.job_fee ??
59
+ candidate.jobFee ??
60
+ candidate.fee;
61
+ const rawType =
62
+ candidate.priceType ??
63
+ candidate.price_type ??
64
+ candidate.jobFeeType ??
65
+ candidate.job_fee_type;
66
+
67
+ if (rawPrice == null) return "Unknown";
68
+ const price = String(rawPrice);
69
+ const type = rawType != null ? String(rawType).toLowerCase() : "";
70
+ if (type === "fixed") return `${price} USDC`;
71
+ if (type === "percentage") return `${price} (${type})`;
72
+ return rawType != null ? `${price} ${String(rawType)}` : price;
73
+ }
74
+
75
+ type JsonSchemaProperty = {
76
+ type?: string;
77
+ description?: string;
78
+ };
79
+
80
+ type RequirementSchema = {
81
+ type?: string;
82
+ required?: string[];
83
+ properties?: Record<string, JsonSchemaProperty>;
84
+ };
85
+
86
+ function getCandidateRequirementSchema(
87
+ candidate: Record<string, unknown>,
88
+ ): RequirementSchema | null {
89
+ const schemaCandidate =
90
+ candidate.requirementSchema ??
91
+ candidate.requirement_schema ??
92
+ candidate.requirement;
93
+ if (
94
+ !schemaCandidate ||
95
+ typeof schemaCandidate !== "object" ||
96
+ Array.isArray(schemaCandidate)
97
+ ) {
98
+ return null;
99
+ }
100
+ return schemaCandidate as RequirementSchema;
101
+ }
102
+
103
+ async function collectRequirementsFromSchema(
104
+ rl: readline.Interface,
105
+ schema: RequirementSchema,
106
+ ): Promise<Record<string, unknown>> {
107
+ const properties = schema.properties ?? {};
108
+ const requiredSet = new Set(
109
+ (schema.required ?? []).filter((k) => typeof k === "string"),
110
+ );
111
+ const keys = Object.keys(properties);
112
+ const out: Record<string, unknown> = {};
113
+
114
+ if (keys.length === 0) return out;
115
+
116
+ output.log("\n Fill service requirements:");
117
+ for (const key of keys) {
118
+ const prop = properties[key] ?? {};
119
+ const isRequired = requiredSet.has(key);
120
+ const desc =
121
+ typeof prop.description === "string" && prop.description.trim()
122
+ ? ` - ${prop.description.trim()}`
123
+ : "";
124
+ while (true) {
125
+ const answer = (
126
+ await question(
127
+ rl,
128
+ ` ${key}${isRequired ? " [required]" : " [optional]"}${desc}: `,
129
+ )
130
+ ).trim();
131
+
132
+ if (!answer) {
133
+ if (isRequired) {
134
+ output.error(`"${key}" is required.`);
135
+ continue;
136
+ }
137
+ out[key] = "";
138
+ break;
139
+ }
140
+ out[key] = answer;
141
+ break;
142
+ }
143
+ }
144
+
145
+ return out;
146
+ }
147
+
148
+ export async function createInteractive(
149
+ query?: string,
150
+ sourceChannel?: string,
151
+ ): Promise<void> {
152
+ if (output.isJsonMode()) {
153
+ output.fatal(
154
+ "Interactive bounty creation is not supported in --json mode. Use human mode.",
155
+ );
156
+ }
157
+
158
+ const rl = readline.createInterface({
159
+ input: process.stdin,
160
+ output: process.stdout,
161
+ });
162
+
163
+ try {
164
+ const agent = await requireActiveAgent();
165
+
166
+ const querySeed = query?.trim() || "";
167
+ const defaultTitle = querySeed ? `${querySeed}` : "Need service provider";
168
+ const defaultDescription = querySeed
169
+ ? `${querySeed}`
170
+ : "Need a provider to fulfill this request.";
171
+
172
+ const title =
173
+ (await question(rl, ` Title [${defaultTitle}]: `)).trim() ||
174
+ defaultTitle;
175
+ const description =
176
+ (await question(rl, ` Description [${defaultDescription}]: `)).trim() ||
177
+ defaultDescription;
178
+ const budgetRaw = (
179
+ await question(rl, " Budget in USD (number, e.g. 50): ")
180
+ ).trim();
181
+ let categoryInput = "digital";
182
+ while (true) {
183
+ const raw = (
184
+ await question(rl, " Category [digital|physical] (default: digital): ")
185
+ )
186
+ .trim()
187
+ .toLowerCase();
188
+ if (!raw) {
189
+ categoryInput = "digital";
190
+ break;
191
+ }
192
+ if (raw === "digital" || raw === "physical") {
193
+ categoryInput = raw;
194
+ break;
195
+ }
196
+ output.error('Invalid category. Enter only "digital" or "physical".');
197
+ }
198
+ const tags =
199
+ (await question(rl, ` Tags comma-separated [defi,web3,ai]: `)).trim() ||
200
+ "";
201
+
202
+ const budget = Number(budgetRaw);
203
+ if (!Number.isFinite(budget) || budget <= 0) {
204
+ output.fatal("Budget must be a positive number.");
205
+ }
206
+ const payload: BountyCreateInput = {
207
+ title,
208
+ description,
209
+ budget,
210
+ category: categoryInput,
211
+ tags,
212
+ };
213
+
214
+ const created = await createBounty(payload);
215
+
216
+ const active: ActiveBounty = {
217
+ bountyId: created.bountyId,
218
+ createdAt: new Date().toISOString(),
219
+ status: "open",
220
+ title,
221
+ description,
222
+ budget,
223
+ category: categoryInput,
224
+ tags,
225
+ posterName: agent.name,
226
+ posterSecret: created.posterSecret,
227
+ sourceChannel: sourceChannel || "cli",
228
+ };
229
+ saveActiveBounty(active);
230
+
231
+ let cronRegistered = false;
232
+ try {
233
+ const cron = ensureBountyPollCron();
234
+ cronRegistered = cron.enabled && cron.created;
235
+ if (cronRegistered) {
236
+ output.log(" OpenClaw cron job registered for `acp bounty poll`.\n");
237
+ }
238
+ } catch {
239
+ // Cron registration failed — include instruction in output
240
+ }
241
+
242
+ output.output(
243
+ {
244
+ bountyId: created.bountyId,
245
+ status: "open",
246
+ cronRegistered,
247
+ ...(!cronRegistered
248
+ ? {
249
+ cronAction: "required",
250
+ cronCommand: `openclaw cron add --id "openclaw-acp-bounty-poll" --schedule "*/10 * * * *" --command "cd \\"${ROOT}\\" && npx acp bounty poll --json"`,
251
+ }
252
+ : {}),
253
+ },
254
+ (data) => {
255
+ output.heading("Bounty Created");
256
+ output.field("Bounty ID", data.bountyId);
257
+ output.field("Status", data.status);
258
+ if (!cronRegistered) {
259
+ output.log(
260
+ "\n IMPORTANT: Register the bounty poll cron job by running:",
261
+ );
262
+ output.log(
263
+ ` openclaw cron add --id "openclaw-acp-bounty-poll" --schedule "*/10 * * * *" --command "cd \\"${ROOT}\\" && npx acp bounty poll --json"`,
264
+ );
265
+ }
266
+ output.log("");
267
+ },
268
+ );
269
+ } finally {
270
+ rl.close();
271
+ }
272
+ }
273
+
274
+ export interface BountyCreateFlags {
275
+ title?: string;
276
+ description?: string;
277
+ budget?: number;
278
+ category?: string;
279
+ tags?: string;
280
+ sourceChannel?: string;
281
+ }
282
+
283
+ export async function createFromFlags(flags: BountyCreateFlags): Promise<void> {
284
+ const agent = await requireActiveAgent();
285
+
286
+ const title = flags.title?.trim();
287
+ const description = flags.description?.trim() || title;
288
+ const budget = flags.budget;
289
+ const category = (flags.category?.trim() || "digital").toLowerCase();
290
+ const tags = flags.tags?.trim() || "";
291
+ const sourceChannel = flags.sourceChannel?.trim() || "cli";
292
+
293
+ if (!title) output.fatal("--title is required.");
294
+ if (budget == null || !Number.isFinite(budget) || budget <= 0) {
295
+ output.fatal("--budget must be a positive number.");
296
+ }
297
+ if (category !== "digital" && category !== "physical") {
298
+ output.fatal('--category must be "digital" or "physical".');
299
+ }
300
+
301
+ const payload: BountyCreateInput = {
302
+ title: title!,
303
+ description: description || title!,
304
+ budget: budget!,
305
+ category,
306
+ tags,
307
+ };
308
+
309
+ const created = await createBounty(payload);
310
+
311
+ const active: ActiveBounty = {
312
+ bountyId: created.bountyId,
313
+ createdAt: new Date().toISOString(),
314
+ status: "open",
315
+ title: title!,
316
+ description: description || title!,
317
+ budget: budget!,
318
+ category,
319
+ tags,
320
+ posterName: agent.name,
321
+ posterSecret: created.posterSecret,
322
+ ...(sourceChannel ? { sourceChannel } : {}),
323
+ };
324
+ saveActiveBounty(active);
325
+
326
+ let cronRegistered = false;
327
+ try {
328
+ const cron = ensureBountyPollCron();
329
+ cronRegistered = cron.enabled && cron.created;
330
+ if (cronRegistered) {
331
+ output.log(" OpenClaw cron job registered for `acp bounty poll`.\n");
332
+ }
333
+ } catch {
334
+ // Cron registration failed — include instruction in output
335
+ }
336
+
337
+ output.output(
338
+ {
339
+ bountyId: created.bountyId,
340
+ status: "open",
341
+ sourceChannel: sourceChannel || null,
342
+ cronRegistered,
343
+ ...(!cronRegistered
344
+ ? {
345
+ cronAction: "required",
346
+ cronCommand: `openclaw cron add --id "openclaw-acp-bounty-poll" --schedule "*/10 * * * *" --command "cd \\"${ROOT}\\" && npx acp bounty poll --json"`,
347
+ }
348
+ : {}),
349
+ },
350
+ (data) => {
351
+ output.heading("Bounty Created");
352
+ output.field("Bounty ID", data.bountyId);
353
+ output.field("Status", data.status);
354
+ if (data.sourceChannel)
355
+ output.field("Source Channel", data.sourceChannel);
356
+ if (!cronRegistered) {
357
+ output.log(
358
+ "\n IMPORTANT: Register the bounty poll cron job by running:",
359
+ );
360
+ output.log(
361
+ ` openclaw cron add --id "openclaw-acp-bounty-poll" --schedule "*/10 * * * *" --command "cd \\"${ROOT}\\" && npx acp bounty poll --json"`,
362
+ );
363
+ }
364
+ output.log("");
365
+ },
366
+ );
367
+ }
368
+
369
+ export async function create(
370
+ query?: string,
371
+ flags?: BountyCreateFlags,
372
+ ): Promise<void> {
373
+ // If any structured flag is provided, use the non-interactive path
374
+ if (flags && (flags.title || flags.budget != null)) {
375
+ return createFromFlags(flags);
376
+ }
377
+ return createInteractive(query, flags?.sourceChannel);
378
+ }
379
+
380
+ export async function list(): Promise<void> {
381
+ const bounties = listActiveBounties();
382
+ output.output({ bounties }, (data) => {
383
+ output.heading("Active Bounties");
384
+ if (data.bounties.length === 0) {
385
+ output.log(" No active bounties.\n");
386
+ return;
387
+ }
388
+ for (const b of data.bounties) {
389
+ output.field("Bounty ID", b.bountyId);
390
+ output.field("Status", b.status);
391
+ output.field("Title", b.title);
392
+ if (b.acpJobId) output.field("ACP Job ID", b.acpJobId);
393
+ output.log("");
394
+ }
395
+ });
396
+ }
397
+
398
+ function normalizeCandidateForWatch(
399
+ candidate: Record<string, unknown>,
400
+ ): Record<string, unknown> {
401
+ return {
402
+ id: candidate.id,
403
+ agentName:
404
+ candidateField(candidate, ["agent_name", "agentName", "name"]) ||
405
+ "(unknown)",
406
+ agentWallet:
407
+ candidateField(candidate, [
408
+ "agent_wallet",
409
+ "agentWallet",
410
+ "agent_wallet_address",
411
+ "agentWalletAddress",
412
+ "walletAddress",
413
+ "providerWalletAddress",
414
+ "provider_address",
415
+ ]) || "",
416
+ offeringName:
417
+ candidateField(candidate, [
418
+ "job_offering",
419
+ "jobOffering",
420
+ "offeringName",
421
+ "jobOfferingName",
422
+ "offering_name",
423
+ "name",
424
+ ]) || "",
425
+ price:
426
+ candidate.price ??
427
+ candidate.job_offering_price ??
428
+ candidate.jobOfferingPrice ??
429
+ candidate.job_fee ??
430
+ candidate.jobFee ??
431
+ candidate.fee ??
432
+ null,
433
+ priceType:
434
+ candidate.priceType ??
435
+ candidate.price_type ??
436
+ candidate.jobFeeType ??
437
+ candidate.job_fee_type ??
438
+ null,
439
+ requirementSchema:
440
+ candidate.requirementSchema ??
441
+ candidate.requirement_schema ??
442
+ candidate.requirement ??
443
+ null,
444
+ };
445
+ }
446
+
447
+ export async function poll(): Promise<void> {
448
+ const bounties = listActiveBounties();
449
+ const result: {
450
+ checked: number;
451
+ pendingMatch: Array<{
452
+ bountyId: string;
453
+ title: string;
454
+ description: string;
455
+ budget: number;
456
+ sourceChannel?: string;
457
+ candidates: Record<string, unknown>[];
458
+ }>;
459
+ claimedJobs: Array<{
460
+ bountyId: string;
461
+ acpJobId: string;
462
+ title: string;
463
+ jobPhase: string;
464
+ deliverable?: string;
465
+ sourceChannel?: string;
466
+ }>;
467
+ rejectedByProvider: Array<{
468
+ bountyId: string;
469
+ title: string;
470
+ description: string;
471
+ budget: number;
472
+ sourceChannel?: string;
473
+ candidates: Record<string, unknown>[];
474
+ }>;
475
+ cleaned: Array<{
476
+ bountyId: string;
477
+ title: string;
478
+ status: string;
479
+ sourceChannel?: string;
480
+ }>;
481
+ errors: Array<{
482
+ bountyId: string;
483
+ error: string;
484
+ }>;
485
+ } = {
486
+ checked: 0,
487
+ pendingMatch: [],
488
+ claimedJobs: [],
489
+ rejectedByProvider: [],
490
+ cleaned: [],
491
+ errors: [],
492
+ };
493
+
494
+ for (const b of bounties) {
495
+ result.checked += 1;
496
+ try {
497
+ // --- Claimed bounties: track ACP job status ---
498
+ if (b.status === "claimed" && !b.acpJobId) {
499
+ const remote = await getMatchStatus(b.bountyId);
500
+ const remoteJobId = String(remote.acp_job_id ?? "");
501
+ if (remoteJobId) {
502
+ saveActiveBounty({ ...b, acpJobId: remoteJobId });
503
+ b.acpJobId = remoteJobId;
504
+ } else {
505
+ result.errors.push({
506
+ bountyId: b.bountyId,
507
+ error:
508
+ "Bounty is claimed but missing acpJobId — resetting local claim state",
509
+ });
510
+
511
+ const reset: ActiveBounty = {
512
+ ...b,
513
+ status: "open",
514
+ selectedCandidateId: undefined,
515
+ acpJobId: undefined,
516
+ notifiedPendingMatch: false,
517
+ };
518
+ saveActiveBounty(reset);
519
+
520
+ b.status = "open";
521
+ b.selectedCandidateId = undefined;
522
+ b.acpJobId = undefined;
523
+ b.notifiedPendingMatch = false;
524
+ }
525
+ }
526
+ if (b.status === "claimed" && b.acpJobId) {
527
+ let jobPhase = "";
528
+ let deliverable: string | undefined;
529
+ try {
530
+ const jobRes = await client.get(`/acp/jobs/${b.acpJobId}`);
531
+ const jobData = jobRes.data?.data ?? jobRes.data;
532
+ jobPhase = String(jobData?.phase ?? "").toUpperCase();
533
+ deliverable = jobData?.deliverable ?? undefined;
534
+ } catch {
535
+ // If job fetch fails, skip this bounty for now
536
+ result.errors.push({
537
+ bountyId: b.bountyId,
538
+ error: `Failed to fetch ACP job ${b.acpJobId} status`,
539
+ });
540
+ continue;
541
+ }
542
+
543
+ const isTerminalJob =
544
+ jobPhase === "COMPLETED" ||
545
+ jobPhase === "REJECTED" ||
546
+ jobPhase === "EXPIRED";
547
+
548
+ if (jobPhase === "REJECTED") {
549
+ // Provider rejected — sync with backend (switches bounty back to open)
550
+ if (b.posterSecret) {
551
+ try {
552
+ await syncBountyJobStatus({
553
+ bountyId: b.bountyId,
554
+ posterSecret: b.posterSecret,
555
+ });
556
+ } catch {
557
+ // non-fatal
558
+ }
559
+ }
560
+
561
+ // Reset local state: back to open, clear job fields, allow re-notification
562
+ const reset: ActiveBounty = {
563
+ ...b,
564
+ status: "open",
565
+ selectedCandidateId: undefined,
566
+ acpJobId: undefined,
567
+ notifiedPendingMatch: false,
568
+ };
569
+ saveActiveBounty(reset);
570
+
571
+ // Re-fetch to check if backend already has new candidates
572
+ let candidates: Record<string, unknown>[] = [];
573
+ try {
574
+ const fresh = await getMatchStatus(b.bountyId);
575
+ const freshStatus = String(fresh.status).toLowerCase();
576
+ if (
577
+ freshStatus === "pending_match" &&
578
+ Array.isArray(fresh.candidates) &&
579
+ fresh.candidates.length > 0
580
+ ) {
581
+ candidates = fresh.candidates.map((c) =>
582
+ normalizeCandidateForWatch(c as Record<string, unknown>),
583
+ );
584
+ saveActiveBounty({
585
+ ...reset,
586
+ status: "pending_match",
587
+ notifiedPendingMatch: true,
588
+ });
589
+ }
590
+ } catch {
591
+ // non-fatal — candidates will be picked up on next poll
592
+ }
593
+
594
+ result.rejectedByProvider.push({
595
+ bountyId: b.bountyId,
596
+ title: b.title,
597
+ description: b.description,
598
+ budget: b.budget,
599
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
600
+ candidates,
601
+ });
602
+ } else if (isTerminalJob) {
603
+ // COMPLETED or EXPIRED — clean up
604
+ if (b.posterSecret) {
605
+ try {
606
+ await syncBountyJobStatus({
607
+ bountyId: b.bountyId,
608
+ posterSecret: b.posterSecret,
609
+ });
610
+ } catch {
611
+ // non-fatal — continue with cleanup
612
+ }
613
+ }
614
+
615
+ const terminalStatus =
616
+ jobPhase === "COMPLETED" ? "fulfilled" : jobPhase.toLowerCase();
617
+ removeActiveBounty(b.bountyId);
618
+ result.cleaned.push({
619
+ bountyId: b.bountyId,
620
+ status: terminalStatus,
621
+ title: b.title,
622
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
623
+ });
624
+ } else {
625
+ // Job still in progress — save current phase
626
+ saveActiveBounty({ ...b });
627
+ result.claimedJobs.push({
628
+ bountyId: b.bountyId,
629
+ acpJobId: b.acpJobId,
630
+ title: b.title,
631
+ jobPhase,
632
+ deliverable,
633
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
634
+ });
635
+ }
636
+ continue;
637
+ }
638
+
639
+ // --- Non-claimed bounties: check match status ---
640
+ const remote = await getMatchStatus(b.bountyId);
641
+ const status = String(remote.status).toLowerCase();
642
+
643
+ if (
644
+ status === "fulfilled" ||
645
+ status === "expired" ||
646
+ status === "rejected"
647
+ ) {
648
+ removeActiveBounty(b.bountyId);
649
+ result.cleaned.push({
650
+ bountyId: b.bountyId,
651
+ title: b.title,
652
+ status,
653
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
654
+ });
655
+ continue;
656
+ }
657
+
658
+ if (status === "claimed") {
659
+ const remoteJobId = String(remote.acp_job_id ?? "");
660
+ if (remoteJobId) {
661
+ saveActiveBounty({ ...b, status: "claimed", acpJobId: remoteJobId });
662
+ result.claimedJobs.push({
663
+ bountyId: b.bountyId,
664
+ acpJobId: remoteJobId,
665
+ title: b.title,
666
+ jobPhase: "UNKNOWN",
667
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
668
+ });
669
+ } else {
670
+ saveActiveBounty({ ...b, status: "claimed" });
671
+ result.errors.push({
672
+ bountyId: b.bountyId,
673
+ error: "Bounty is claimed but remote response missing acp_job_id",
674
+ });
675
+ }
676
+ continue;
677
+ }
678
+
679
+ const isNewPendingMatch =
680
+ status === "pending_match" &&
681
+ Array.isArray(remote.candidates) &&
682
+ remote.candidates.length > 0 &&
683
+ !b.notifiedPendingMatch;
684
+
685
+ saveActiveBounty({
686
+ ...b,
687
+ status: remote.status,
688
+ // Mark as notified once we include it in pendingMatch output
689
+ ...(isNewPendingMatch ? { notifiedPendingMatch: true } : {}),
690
+ });
691
+
692
+ if (isNewPendingMatch) {
693
+ const candidates = remote.candidates.map((c) =>
694
+ normalizeCandidateForWatch(c as Record<string, unknown>),
695
+ );
696
+
697
+ result.pendingMatch.push({
698
+ bountyId: b.bountyId,
699
+ title: b.title,
700
+ description: b.description,
701
+ budget: b.budget,
702
+ ...(b.sourceChannel ? { sourceChannel: b.sourceChannel } : {}),
703
+ candidates,
704
+ });
705
+ }
706
+ } catch (e) {
707
+ result.errors.push({
708
+ bountyId: b.bountyId,
709
+ error: e instanceof Error ? e.message : String(e),
710
+ });
711
+ }
712
+ }
713
+
714
+ try {
715
+ removeBountyPollCronIfUnused();
716
+ } catch {
717
+ // non-fatal
718
+ }
719
+
720
+ output.output(result, (r) => {
721
+ output.heading("Bounty Poll");
722
+ output.field("Checked", r.checked);
723
+ output.field("Pending Match", r.pendingMatch.length);
724
+ output.field("Rejected by Provider", r.rejectedByProvider.length);
725
+ output.field("Claimed Jobs", r.claimedJobs.length);
726
+ output.field("Cleaned", r.cleaned.length);
727
+ output.field("Errors", r.errors.length);
728
+ if (r.pendingMatch.length > 0) {
729
+ output.log("\n Pending Match (candidates ready):");
730
+ for (const p of r.pendingMatch) {
731
+ output.log(
732
+ ` - Bounty ${p.bountyId}: "${p.title}" — ${p.candidates.length} candidate(s)`,
733
+ );
734
+ for (const c of p.candidates) {
735
+ const price =
736
+ c.priceType === "fixed"
737
+ ? `${c.price} USDC`
738
+ : c.price != null
739
+ ? String(c.price)
740
+ : "N/A";
741
+ output.log(` ${c.agentName} — ${c.offeringName} (${price})`);
742
+ }
743
+ output.log(` -> run: acp bounty select ${p.bountyId}`);
744
+ }
745
+ }
746
+ if (r.rejectedByProvider.length > 0) {
747
+ output.log(
748
+ "\n Rejected by Provider (bounty reopened for new candidates):",
749
+ );
750
+ for (const rj of r.rejectedByProvider) {
751
+ const candidateCount = rj.candidates.length;
752
+ output.log(
753
+ ` - Bounty ${rj.bountyId}: "${rj.title}" — provider rejected the job`,
754
+ );
755
+ if (candidateCount > 0) {
756
+ output.log(` ${candidateCount} new candidate(s) available:`);
757
+ for (const c of rj.candidates) {
758
+ const price =
759
+ (c as any).priceType === "fixed"
760
+ ? `${(c as any).price} USDC`
761
+ : (c as any).price != null
762
+ ? String((c as any).price)
763
+ : "N/A";
764
+ output.log(
765
+ ` ${(c as any).agentName} — ${(c as any).offeringName} (${price})`,
766
+ );
767
+ }
768
+ output.log(` -> run: acp bounty select ${rj.bountyId}`);
769
+ } else {
770
+ output.log(
771
+ ` Bounty is back to open — waiting for new candidates.`,
772
+ );
773
+ }
774
+ }
775
+ }
776
+ if (r.claimedJobs.length > 0) {
777
+ output.log("\n Claimed Jobs (in progress):");
778
+ for (const j of r.claimedJobs) {
779
+ output.log(
780
+ ` - Bounty ${j.bountyId}: "${j.title}" — Job ${j.acpJobId} phase: ${j.jobPhase}`,
781
+ );
782
+ }
783
+ }
784
+ if (r.cleaned.length > 0) {
785
+ output.log("\n Cleaned (terminal):");
786
+ for (const c of r.cleaned) {
787
+ output.log(` - Bounty ${c.bountyId}: "${c.title}" — ${c.status}`);
788
+ }
789
+ }
790
+ if (r.errors.length > 0) {
791
+ output.log("\n Errors:");
792
+ for (const err of r.errors) {
793
+ output.log(` - ${err.bountyId}: ${err.error}`);
794
+ }
795
+ }
796
+ output.log("");
797
+ });
798
+ }
799
+
800
+ export async function status(
801
+ bountyId: string,
802
+ flags?: { sync?: boolean },
803
+ ): Promise<void> {
804
+ if (!bountyId) output.fatal("Usage: acp bounty status <bountyId> [--sync]");
805
+
806
+ let bounty = getActiveBounty(bountyId);
807
+
808
+ if (flags?.sync && bounty) {
809
+ if (!bounty.posterSecret)
810
+ output.fatal("Cannot sync: missing poster secret for this bounty.");
811
+ try {
812
+ await syncBountyJobStatus({
813
+ bountyId,
814
+ posterSecret: bounty.posterSecret,
815
+ });
816
+ } catch (e: any) {
817
+ const msg =
818
+ e?.response?.data?.detail?.detail ??
819
+ e?.response?.data?.detail ??
820
+ (e instanceof Error ? e.message : String(e));
821
+ output.warn(`Failed to sync job status: ${msg}`);
822
+ }
823
+ }
824
+
825
+ let remote: Record<string, unknown> | null = null;
826
+ try {
827
+ remote = await getBountyDetails(bountyId);
828
+ } catch (e: any) {
829
+ const msg =
830
+ e?.response?.data?.detail?.detail ??
831
+ e?.response?.data?.detail ??
832
+ (e instanceof Error ? e.message : String(e));
833
+ if (!bounty) output.fatal(`Bounty not found: ${msg}`);
834
+ }
835
+
836
+ if (!bounty && remote) {
837
+ bounty = {
838
+ bountyId: String(remote.id ?? bountyId),
839
+ createdAt: String(remote.created_at ?? ""),
840
+ status: String(remote.status ?? ""),
841
+ title: String(remote.title ?? ""),
842
+ description: String(remote.description ?? ""),
843
+ budget: Number(remote.budget ?? 0),
844
+ category: String(remote.category ?? "digital"),
845
+ tags: String(remote.tags ?? ""),
846
+ posterName: String(remote.poster_name ?? ""),
847
+ posterSecret: "",
848
+ };
849
+ }
850
+
851
+ if (!bounty) output.fatal(`Bounty ${bountyId} not found.`);
852
+
853
+ const status = String(remote?.status ?? bounty.status).toLowerCase();
854
+ const claimedBy = String(remote?.claimed_by ?? "");
855
+ const acpJobId = String(remote?.acp_job_id ?? bounty.acpJobId ?? "");
856
+ const matchedAgent = String(remote?.matched_acp_agent ?? "");
857
+ const candidates =
858
+ status === "pending_match" && remote?.matched_acp_agent_profile
859
+ ? [remote.matched_acp_agent_profile]
860
+ : [];
861
+
862
+ output.output(
863
+ {
864
+ bountyId: bounty.bountyId,
865
+ status,
866
+ title: bounty.title,
867
+ description: bounty.description,
868
+ budget: bounty.budget,
869
+ category: bounty.category,
870
+ tags: bounty.tags,
871
+ ...(acpJobId ? { acpJobId } : {}),
872
+ ...(claimedBy ? { claimedBy } : {}),
873
+ ...(matchedAgent ? { matchedAgent } : {}),
874
+ ...(status === "pending_match" && candidates.length > 0
875
+ ? { candidates }
876
+ : {}),
877
+ ...(bounty.sourceChannel ? { sourceChannel: bounty.sourceChannel } : {}),
878
+ createdAt: bounty.createdAt,
879
+ },
880
+ (data) => {
881
+ output.heading(`Bounty ${data.bountyId}`);
882
+ output.field("Status", data.status);
883
+ output.field("Title", data.title);
884
+ output.field("Description", data.description);
885
+ output.field("Budget", data.budget);
886
+ output.field("Category", data.category);
887
+ output.field("Tags", data.tags);
888
+ if (data.acpJobId) output.field("ACP Job ID", data.acpJobId);
889
+ if (data.claimedBy) output.field("Claimed By", data.claimedBy);
890
+ if (data.matchedAgent)
891
+ output.field("Claimed By Wallet Address", data.matchedAgent);
892
+ if (data.candidates) output.field("Candidates", data.candidates.length);
893
+ if (data.sourceChannel)
894
+ output.field("Source Channel", data.sourceChannel);
895
+ output.field("Created", data.createdAt);
896
+ output.log("");
897
+ },
898
+ );
899
+ }
900
+
901
+ export async function select(bountyId: string): Promise<void> {
902
+ if (!bountyId) output.fatal("Usage: acp bounty select <bountyId>");
903
+ const active = getActiveBounty(bountyId);
904
+ if (!active) output.fatal(`Bounty not found in local state: ${bountyId}`);
905
+ const posterSecret = active.posterSecret;
906
+ if (!posterSecret) {
907
+ output.fatal("Missing poster secret for this bounty.");
908
+ }
909
+
910
+ const match = await getMatchStatus(bountyId);
911
+ if (String(match.status).toLowerCase() !== "pending_match") {
912
+ output.fatal(
913
+ `Bounty is not pending_match. Current status: ${match.status}`,
914
+ );
915
+ }
916
+ if (!Array.isArray(match.candidates) || match.candidates.length === 0) {
917
+ output.fatal("No candidates available for this bounty.");
918
+ }
919
+
920
+ if (output.isJsonMode()) {
921
+ output.output(
922
+ { bountyId, status: match.status, candidates: match.candidates },
923
+ () => {},
924
+ );
925
+ return;
926
+ }
927
+
928
+ const rl = readline.createInterface({
929
+ input: process.stdin,
930
+ output: process.stdout,
931
+ });
932
+
933
+ try {
934
+ output.heading(`Select Candidate for Bounty ${bountyId}`);
935
+ for (let i = 0; i < match.candidates.length; i++) {
936
+ const c = match.candidates[i] as Record<string, unknown>;
937
+ const candidateId = parseCandidateId(c.id) ?? -1;
938
+ output.log(
939
+ ` [${i + 1}] candidateId=${candidateId} ${JSON.stringify(c)}`,
940
+ );
941
+ }
942
+ output.log(" [0] None of these candidates");
943
+
944
+ const choiceRaw = (
945
+ await question(rl, " Choose candidate number: ")
946
+ ).trim();
947
+ if (choiceRaw === "0") {
948
+ await rejectCandidates({
949
+ bountyId,
950
+ posterSecret,
951
+ });
952
+ saveActiveBounty({
953
+ ...active,
954
+ status: "open",
955
+ selectedCandidateId: undefined,
956
+ acpJobId: undefined,
957
+ notifiedPendingMatch: false,
958
+ });
959
+ output.log(
960
+ " Rejected current candidates. Bounty moved back to open for new matching.\n",
961
+ );
962
+ return;
963
+ }
964
+ const idx = parseInt(choiceRaw, 10) - 1;
965
+ if (!Number.isInteger(idx) || idx < 0 || idx >= match.candidates.length) {
966
+ output.fatal("Invalid candidate selection.");
967
+ }
968
+ const selected = match.candidates[idx] as Record<string, unknown>;
969
+ const candidateId = parseCandidateId(selected.id);
970
+ if (candidateId == null) output.fatal("Selected candidate has invalid id.");
971
+
972
+ const walletDefault = candidateField(selected, [
973
+ "agent_wallet",
974
+ "agentWallet",
975
+ "agent_wallet_address",
976
+ "agentWalletAddress",
977
+ "walletAddress",
978
+ "providerWalletAddress",
979
+ "provider_address",
980
+ ]);
981
+ const offeringDefault = candidateField(selected, [
982
+ "job_offering",
983
+ "jobOffering",
984
+ "offeringName",
985
+ "jobOfferingName",
986
+ "offering_name",
987
+ "name",
988
+ ]);
989
+
990
+ const wallet = walletDefault || "";
991
+ const offering = offeringDefault || "";
992
+
993
+ if (!wallet) {
994
+ output.fatal(
995
+ "Selected candidate is missing provider wallet (expected agent_wallet or walletAddress fields).",
996
+ );
997
+ }
998
+ if (!offering) {
999
+ output.fatal(
1000
+ "Selected candidate is missing job offering (expected job_offering/offeringName fields).",
1001
+ );
1002
+ }
1003
+
1004
+ const providerName =
1005
+ candidateField(selected, ["agent_name", "agentName", "name"]) ||
1006
+ "(unknown)";
1007
+ const offeringPrice = candidatePriceDisplay(selected);
1008
+
1009
+ output.log("\n Selected Candidate");
1010
+ output.log(" ------------------");
1011
+ output.log(` Provider: ${providerName}`);
1012
+ output.log(` Wallet: ${wallet}`);
1013
+ output.log(` Offering: ${offering}`);
1014
+ output.log(` Price: ${offeringPrice}`);
1015
+ const confirm = (
1016
+ await question(
1017
+ rl,
1018
+ "\n Continue and create ACP job for this candidate? (Y/n): ",
1019
+ )
1020
+ )
1021
+ .trim()
1022
+ .toLowerCase();
1023
+ if (!(confirm === "y" || confirm === "yes" || confirm === "")) {
1024
+ output.log(" Candidate selection cancelled.\n");
1025
+ return;
1026
+ }
1027
+
1028
+ const schema = getCandidateRequirementSchema(selected);
1029
+ const serviceRequirements =
1030
+ schema != null ? await collectRequirementsFromSchema(rl, schema) : {};
1031
+
1032
+ const job = await client.post<{
1033
+ data?: { jobId?: number };
1034
+ jobId?: number;
1035
+ }>("/acp/jobs", {
1036
+ providerWalletAddress: wallet,
1037
+ jobOfferingName: offering,
1038
+ serviceRequirements,
1039
+ });
1040
+ const acpJobId = String(job.data?.data?.jobId ?? job.data?.jobId ?? "");
1041
+ if (!acpJobId)
1042
+ output.fatal("Failed to create ACP job for selected candidate.");
1043
+
1044
+ await confirmMatch({
1045
+ bountyId,
1046
+ posterSecret,
1047
+ candidateId,
1048
+ acpJobId,
1049
+ });
1050
+
1051
+ const next: ActiveBounty = {
1052
+ ...active,
1053
+ status: "claimed",
1054
+ selectedCandidateId: candidateId,
1055
+ acpJobId,
1056
+ };
1057
+ saveActiveBounty(next);
1058
+
1059
+ output.output(
1060
+ {
1061
+ bountyId,
1062
+ candidateId,
1063
+ acpJobId,
1064
+ status: "claimed",
1065
+ },
1066
+ (data) => {
1067
+ output.heading("Bounty Claimed");
1068
+ output.field("Bounty ID", data.bountyId);
1069
+ output.field("Candidate ID", data.candidateId);
1070
+ output.field("ACP Job ID", data.acpJobId);
1071
+ output.field("Status", data.status);
1072
+ output.log(
1073
+ `\n Use \`acp job status <jobId>\` to monitor the ACP job.`,
1074
+ );
1075
+ output.log(
1076
+ ` Then run \`acp bounty status ${data.bountyId} --sync\` to sync/update bounty status.\n`,
1077
+ );
1078
+ },
1079
+ );
1080
+ } finally {
1081
+ rl.close();
1082
+ }
1083
+ }
1084
+
1085
+ export interface BountyUpdateFlags {
1086
+ title?: string;
1087
+ description?: string;
1088
+ budget?: number;
1089
+ tags?: string;
1090
+ }
1091
+
1092
+ export async function update(
1093
+ bountyId: string,
1094
+ flags: BountyUpdateFlags,
1095
+ ): Promise<void> {
1096
+ if (!bountyId) output.fatal("Usage: acp bounty update <bountyId> [flags]");
1097
+
1098
+ const active = getActiveBounty(bountyId);
1099
+ if (!active) output.fatal(`Bounty not found in local state: ${bountyId}`);
1100
+
1101
+ if (active.status !== "open") {
1102
+ output.fatal(
1103
+ `Bounty ${bountyId} cannot be updated — status is "${active.status}". Only "open" bounties can be updated.`,
1104
+ );
1105
+ }
1106
+
1107
+ if (!active.posterSecret) {
1108
+ output.fatal("Missing poster secret for this bounty.");
1109
+ }
1110
+
1111
+ const title = flags.title?.trim() || undefined;
1112
+ const description = flags.description?.trim() || undefined;
1113
+ const budget =
1114
+ flags.budget != null && Number.isFinite(flags.budget) && flags.budget > 0
1115
+ ? flags.budget
1116
+ : undefined;
1117
+ const tags = flags.tags?.trim() || undefined;
1118
+
1119
+ if (!title && !description && budget == null && !tags) {
1120
+ output.fatal(
1121
+ "Nothing to update. Provide at least one of: --title, --description, --budget, --tags",
1122
+ );
1123
+ }
1124
+
1125
+ try {
1126
+ await updateBounty(bountyId, {
1127
+ poster_secret: active.posterSecret,
1128
+ ...(title ? { title } : {}),
1129
+ ...(description ? { description } : {}),
1130
+ ...(budget != null ? { budget } : {}),
1131
+ ...(tags ? { tags } : {}),
1132
+ });
1133
+ } catch (e: any) {
1134
+ const msg =
1135
+ e?.response?.data?.detail?.detail ??
1136
+ e?.response?.data?.detail ??
1137
+ (e instanceof Error ? e.message : String(e));
1138
+ output.fatal(`Failed to update bounty ${bountyId}: ${msg}`);
1139
+ }
1140
+
1141
+ // Update local state
1142
+ const updated: ActiveBounty = {
1143
+ ...active,
1144
+ ...(title ? { title } : {}),
1145
+ ...(description ? { description } : {}),
1146
+ ...(budget != null ? { budget } : {}),
1147
+ ...(tags ? { tags } : {}),
1148
+ };
1149
+ saveActiveBounty(updated);
1150
+
1151
+ output.output(
1152
+ {
1153
+ bountyId,
1154
+ updated: {
1155
+ ...(title ? { title } : {}),
1156
+ ...(description ? { description } : {}),
1157
+ ...(budget != null ? { budget } : {}),
1158
+ ...(tags ? { tags } : {}),
1159
+ },
1160
+ },
1161
+ (data) => {
1162
+ output.heading("Bounty Updated");
1163
+ output.field("Bounty ID", data.bountyId);
1164
+ if (data.updated.title) output.field("Title", data.updated.title);
1165
+ if (data.updated.description)
1166
+ output.field("Description", data.updated.description);
1167
+ if (data.updated.budget != null)
1168
+ output.field("Budget", data.updated.budget);
1169
+ if (data.updated.tags) output.field("Tags", data.updated.tags);
1170
+ output.log("");
1171
+ },
1172
+ );
1173
+ }
1174
+
1175
+ export async function cleanup(bountyId: string): Promise<void> {
1176
+ if (!bountyId) output.fatal("Usage: acp bounty cleanup <bountyId>");
1177
+ const active = getActiveBounty(bountyId);
1178
+ if (!active) {
1179
+ output.log(` Bounty not found locally: ${bountyId}`);
1180
+ return;
1181
+ }
1182
+ removeActiveBounty(bountyId);
1183
+ try {
1184
+ removeBountyPollCronIfUnused();
1185
+ } catch {
1186
+ // non-fatal
1187
+ }
1188
+ output.log(` Cleaned up bounty ${bountyId}\n`);
1189
+ }