Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bin/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,18 @@ async function main(): Promise<void> {
if (subcommand === "status") return bounty.status(rest[0]);
if (subcommand === "select") return bounty.select(rest[0]);
if (subcommand === "cleanup") return bounty.cleanup(rest[0]);
if (subcommand === "apply") {
const offering = getFlagValue(args, "--offering");
const priceRaw = getFlagValue(args, "--price");
const priceType = getFlagValue(args, "--price-type");
const note = getFlagValue(args, "--note");
return bounty.apply(rest[0], {
offering: offering || undefined,
price: priceRaw ? parseFloat(priceRaw) : undefined,
priceType: priceType || undefined,
note: note || undefined,
});
}
console.log(buildCommandHelp("bounty"));
return;
}
Expand Down
49 changes: 42 additions & 7 deletions src/commands/bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
syncBountyJobStatus,
confirmMatch,
} from "../lib/bounty.js";
import { ROOT } from "../lib/config.js";
import { ROOT, loadApiKey } from "../lib/config.js";
import {
ensureBountyPollCron,
removeBountyPollCronIfUnused,
Expand Down Expand Up @@ -433,7 +433,7 @@ export async function poll(): Promise<void> {
result.checked += 1;
try {
// --- Claimed bounties: track ACP job status ---
if (b.status === "claimed" && b.acpJobId) {
if (b.status === "matched" && b.acpJobId) {
let jobPhase = "";
let deliverable: string | undefined;
try {
Expand Down Expand Up @@ -491,7 +491,7 @@ export async function poll(): Promise<void> {
}

const isNewPendingMatch =
status === "pending_match" &&
status === "open" &&
Array.isArray(remote.candidates) &&
remote.candidates.length > 0 &&
!b.notifiedPendingMatch;
Expand Down Expand Up @@ -633,7 +633,7 @@ export async function status(bountyId: string): Promise<void> {
}
);

if (!output.isJsonMode() && String(remote.status).toLowerCase() === "pending_match") {
if (!output.isJsonMode() && ["open","pending_match"].includes(String(remote.status).toLowerCase())) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
Expand Down Expand Up @@ -665,7 +665,7 @@ export async function select(bountyId: string): Promise<void> {
}

const match = await getMatchStatus(bountyId);
if (String(match.status).toLowerCase() !== "pending_match") {
if (!["open","matched","pending_match"].includes(String(match.status).toLowerCase())) {
output.fatal(`Bounty is not pending_match. Current status: ${match.status}`);
}
if (!Array.isArray(match.candidates) || match.candidates.length === 0) {
Expand Down Expand Up @@ -797,7 +797,7 @@ export async function select(bountyId: string): Promise<void> {

const next: ActiveBounty = {
...active,
status: "claimed",
status: "matched",
selectedCandidateId: candidateId,
acpJobId,
};
Expand All @@ -808,7 +808,7 @@ export async function select(bountyId: string): Promise<void> {
bountyId,
candidateId,
acpJobId,
status: "claimed",
status: "matched",
},
(data) => {
output.heading("Bounty Claimed");
Expand Down Expand Up @@ -845,3 +845,38 @@ export async function cleanup(bountyId: string): Promise<void> {
output.log(` Cleaned up bounty ${bountyId}\n`);
}


export async function apply(bountyId: string, flags: {
offering?: string;
price?: number;
priceType?: string;
note?: string;
}): Promise<void> {
if (!bountyId) output.fatal("Usage: acp bounty apply <bountyId> --offering <service> [--price 100] [--note 'why you']");

const agent = await requireActiveAgent();
const apiKey = await loadApiKey();

const offering = flags.offering?.trim();
if (!offering) output.fatal("--offering is required. Describe what you will deliver.");

const input: BountyApplyInput = {
agent_wallet: agent.walletAddress,
agent_name: agent.name,
job_offering: offering,
...(flags.price != null ? { price: flags.price } : {}),
...(flags.priceType ? { price_type: flags.priceType as "fixed" | "percentage" } : {}),
...(flags.note ? { note: flags.note } : {}),
};

const candidate = await applyToBounty(bountyId, input, apiKey);

output.output({ bountyId, candidate }, (data) => {
output.heading("Applied to Bounty");
output.field("Bounty ID", data.bountyId);
output.field("Your offering", offering);
if (flags.price != null) output.field("Proposed price", `${flags.price} USDC`);
if (flags.note) output.field("Note", flags.note);
output.log("\n Application submitted. The poster will see you in their candidate list.\n");
});
}
32 changes: 30 additions & 2 deletions src/lib/bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import * as path from "path";
import { ROOT, loadApiKey } from "./config.js";

export type BountyStatus =
// Active statuses (no ACP contract needed)
| "open"
| "pending_match"
| "claimed"
| "matched"
| "fulfilled"
| "cancelled"
| "expired"
// Legacy aliases (old DB rows — still returned by API)
| "pending_match"
| "claimed"
| "rejected";

export interface BountyCreateInput {
Expand All @@ -29,9 +33,20 @@ export interface BountyMatchCandidate {
name?: string;
walletAddress?: string;
offeringName?: string;
source?: "auto_match" | "applied"; // auto_match = ACP registry, applied = agent self-nominated
note?: string; // agent pitch (only for applied)
[key: string]: unknown;
}

export interface BountyApplyInput {
agent_wallet: string;
agent_name: string;
job_offering: string;
price?: number;
price_type?: "fixed" | "percentage";
note?: string;
}

export interface BountyMatchStatusResponse {
status: BountyStatus | string;
candidates: BountyMatchCandidate[];
Expand Down Expand Up @@ -226,3 +241,16 @@ export async function syncBountyJobStatus(params: {
return extractData<unknown>(res.data);
}


export async function applyToBounty(
bountyId: string,
input: BountyApplyInput,
apiKey: string
): Promise<BountyMatchCandidate> {
const { data } = await axios.post<{ data?: BountyMatchCandidate }>(
`${process.env.ACP_BOUNTY_API_URL || "https://bounty.virtuals.io/api/v1"}/bounties/${bountyId}/apply`,
{ ...input, api_key: apiKey },
{ headers: { "x-api-key": apiKey } }
);
return extractData<BountyMatchCandidate>(data);
}
6 changes: 3 additions & 3 deletions src/lib/openclawCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ const DEFAULT_SCHEDULE = "*/10 * * * *";
const POLL_SYSTEM_EVENT = [
`[ACP Bounty Poll] This is an automated bounty check. You MUST:`,
`1. Run this command: cd "${ROOT}" && npx acp bounty poll --json`,
`2. Parse the JSON output and check the pendingMatch, claimedJobs, cleaned, and errors arrays.`,
`2. Parse the JSON output and check the pendingMatch, cleaned, and errors arrays.`,
``,
`3. IF anything needs attention (non-empty arrays), you MUST use the "message" tool`,
` (action: "send") to proactively notify the user. Do NOT just reply in conversation —`,
` use the message tool so the notification is pushed even if the user is not actively chatting.`,
``,
` For pendingMatch: list bounty IDs, candidate agent names, offerings, and prices.`,
` Filter out irrelevant or malicious candidates. Ask which candidate to select.`,
` For claimedJobs: report job phase/status.`,
` For cleaned (completed/fulfilled/expired): inform user and share deliverables.`,
` Note: claimedJobs tracking is disabled until the ACP contract is deployed.`,
` For cleaned (fulfilled/expired/cancelled): inform user of outcome.`,
` For errors: report them.`,
``,
`4. IF everything is empty (all arrays are empty or zero), reply HEARTBEAT_OK.`,
Expand Down