From 789a48cfb85c5ebd0a6aac431c9e41a04d9febf9 Mon Sep 17 00:00:00 2001 From: Delega Bot Date: Tue, 14 Apr 2026 12:41:37 -0500 Subject: [PATCH] feat: 1.2.0 multi-agent coordination commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the CLI in line with @delega-dev/mcp 1.2.0. The 1.1 release focused on agent-friendly polish (--yes, --json, --dry-run flags on destructive ops); this release adds the missing command coverage. New commands (6): - `delega tasks assign ` — assign or unassign a task. For multi-agent handoffs use `delegate` instead — `assign` does not record a chain. - `delega tasks chain ` — show the full parent/child delegation chain, indented by depth. Renders the tree-ish view (hosted {root_id} vs self-hosted {root: Task} both normalized). - `delega tasks set-context --kv key=value...` — deep-merge keys into persistent context. Accepts repeated --kv pairs (JSON-parsed values so numbers/bools/arrays work) or a full --context '{...}' JSON object. - `delega tasks dedup --content "..." [--threshold 0.6]` — Jaccard similarity check against open tasks. Call before `tasks create` to avoid redundant work. - `delega agents delete ` — delete an agent. --yes / --dry-run follow the existing 1.1 destructive-action pattern. - `delega usage` — plan quota + rate-limit info. Hosted API only; gated client-side with a clear error on self-hosted, mirroring the delega-mcp + delega-python behavior. All commands follow the existing pattern: Commander subcommand, apiCall, human-readable default output with --json opt-out. Version: 1.1.5 → 1.2.0 (minor — 6 new commands, no breaking changes). Verified: tsc --noEmit clean; build clean; `delega --help` renders; hosted-only gate on `delega usage` prints a clear error against a self-hosted URL without making a request. Out of scope: - No test suite. The CLI CI runs typecheck + build only (pre-existing). A proper test harness is a separate project. Relying on live smoke against the hosted API after release. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 +++ package.json | 2 +- src/commands/agents.ts | 62 ++++++++++- src/commands/tasks.ts | 230 ++++++++++++++++++++++++++++++++++++++++- src/commands/usage.ts | 94 +++++++++++++++++ src/index.ts | 2 + 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 src/commands/usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24331a0..03d3b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-04-14 + +### Added +- `delega tasks assign ` — assign/unassign a task (PUT /tasks/:id with `assigned_to_agent_id`) +- `delega tasks chain ` — show the full parent/child delegation chain, indented by depth +- `delega tasks set-context --kv key=value...` (or `--context '{...}'`) — deep-merge keys into a task's persistent context blob (PATCH /tasks/:id/context) +- `delega tasks dedup --content "..." [--threshold 0.6]` — Jaccard similarity check against open tasks (POST /tasks/dedup); call before `delega tasks create` to avoid redundant work +- `delega agents delete ` — delete an agent (`--yes` for scripts, `--dry-run` for preview). API refuses if the agent has active tasks, is the recovery agent, is the last active, or is the caller +- `delega usage` — plan quota + rate-limit info (hosted API only; gated client-side with a clear error on self-hosted) + +## [1.1.5] - 2026-03-28 + ### Added - `delega status` command for connection diagnostics - `delega reset` command to wipe local config and credentials diff --git a/package.json b/package.json index e83c993..55f2931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@delega-dev/cli", - "version": "1.1.5", + "version": "1.2.0", "description": "CLI for Delega task API", "type": "module", "bin": { diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 9b58621..1a9ae5d 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -148,8 +148,68 @@ Examples: console.log(); }); +const agentsDelete = new Command("delete") + .description("Delete an agent") + .argument("", "Agent ID to delete") + .option("-y, --yes", "Skip confirmation prompt") + .option("--json", "Output raw JSON") + .option("--dry-run", "Show what would happen without deleting") + .addHelpText("after", ` +Examples: + $ delega agents delete abc123 + $ delega agents delete abc123 --yes Skip confirmation (for scripts/agents) + $ delega agents delete abc123 --dry-run Preview without deleting + +The API refuses to delete an agent that has active tasks, is the recovery +agent, is the last active agent, or is the caller itself. +`) + .action(async (id: string, opts) => { + if (opts.dryRun) { + const result = await apiRequest("GET", `/agents/${id}`); + const agent = result.ok ? (result.data as Agent) : undefined; + if (opts.json) { + console.log( + JSON.stringify( + { + dry_run: true, + agent_id: id, + agent_name: agent ? (agent.display_name || agent.name) : null, + action: "delete", + }, + null, + 2, + ), + ); + return; + } + if (agent) { + console.log(`Would delete agent "${agent.display_name || agent.name}" (${id}).`); + } else { + console.log(`Would delete agent ${id}.`); + } + console.log("No changes made."); + return; + } + if (!opts.yes) { + const ok = await confirm( + `Delete agent ${id}? This action cannot be undone. (y/N) `, + ); + if (!ok) { + console.log("Cancelled."); + return; + } + } + await apiCall("DELETE", `/agents/${id}`); + if (opts.json) { + console.log(JSON.stringify({ id, deleted: true }, null, 2)); + return; + } + console.log(`Agent ${id} deleted.`); + }); + export const agentsCommand = new Command("agents") .description("Manage agents") .addCommand(agentsList) .addCommand(agentsCreate) - .addCommand(agentsRotate); + .addCommand(agentsRotate) + .addCommand(agentsDelete); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index d5ad137..f32bdce 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -263,6 +263,230 @@ Examples: console.log(`Task delegated to ${agentId}.`); }); +// ── 1.2.0 multi-agent coordination commands ── + +const tasksAssign = new Command("assign") + .description("Assign a task to an agent (or --unassign to clear)") + .argument("", "Task ID") + .argument("[agent_id]", "Agent ID to assign to (omit with --unassign)") + .option("--unassign", "Clear the assignment (pass instead of an agent_id)") + .option("--json", "Output raw JSON") + .addHelpText("after", ` +Examples: + $ delega tasks assign abc123 agent456 + $ delega tasks assign abc123 --unassign + $ delega tasks assign abc123 agent456 --json + +For multi-agent handoffs where you want the parent/child chain recorded, +use \`delega tasks delegate\` instead — assign does not record a chain. +`) + .action(async (taskId: string, agentId: string | undefined, opts) => { + if (opts.unassign && agentId) { + console.error("Error: pass either --unassign or an , not both."); + process.exit(1); + } + if (!opts.unassign && !agentId) { + console.error("Error: must supply either or --unassign."); + process.exit(1); + } + const body = { assigned_to_agent_id: opts.unassign ? null : agentId }; + const task = await apiCall("PUT", `/tasks/${taskId}`, body); + if (opts.json) { + console.log(JSON.stringify(task, null, 2)); + return; + } + console.log( + opts.unassign + ? `Task ${taskId} unassigned.` + : `Task ${taskId} assigned to ${agentId}.`, + ); + }); + +interface ChainResponse { + root_id?: string | number; + root?: { id: string | number; content?: string }; + chain: Array<{ + id: string | number; + content: string; + delegation_depth?: number; + status?: string; + completed?: boolean; + }>; + depth: number; + completed_count: number; + total_count: number; +} + +const tasksChain = new Command("chain") + .description("Show the full parent/child delegation chain for a task") + .argument("", "Any task ID in the chain") + .option("--json", "Output raw JSON") + .addHelpText("after", ` +Examples: + $ delega tasks chain abc123 + $ delega tasks chain abc123 --json +`) + .action(async (taskId: string, opts) => { + const resp = await apiCall("GET", `/tasks/${taskId}/chain`); + if (opts.json) { + console.log(JSON.stringify(resp, null, 2)); + return; + } + // Normalize: hosted returns {root_id}, self-hosted returns {root: {...}}. + const rootId = + resp.root_id !== undefined + ? resp.root_id + : resp.root && resp.root.id !== undefined + ? resp.root.id + : ""; + console.log( + `\nDelegation chain (root #${rootId}, depth ${resp.depth}, ` + + `${resp.completed_count}/${resp.total_count} complete):`, + ); + const sorted = [...(resp.chain || [])].sort((a, b) => { + const da = typeof a.delegation_depth === "number" ? a.delegation_depth : 0; + const db = typeof b.delegation_depth === "number" ? b.delegation_depth : 0; + return da - db; + }); + for (const node of sorted) { + const d = + typeof node.delegation_depth === "number" ? node.delegation_depth : 0; + const indent = " ".repeat(1 + d); + const status = node.status || (node.completed ? "completed" : "pending"); + console.log(`${indent}[#${node.id}] ${node.content} (depth ${d}, ${status})`); + } + if (!sorted.length) { + console.log(" (empty chain)"); + } + console.log(); + }); + +function parseContextInput(kvPairs: string[] | undefined, rawJson: string | undefined): Record { + if (rawJson) { + try { + const parsed = JSON.parse(rawJson); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("--context must be a JSON object"); + } + return parsed as Record; + } catch (e) { + throw new Error( + `--context must be valid JSON object: ${(e as Error).message}`, + ); + } + } + const out: Record = {}; + for (const pair of kvPairs || []) { + const eq = pair.indexOf("="); + if (eq <= 0) { + throw new Error(`--kv entries must be key=value, got: ${pair}`); + } + const key = pair.slice(0, eq); + const rawValue = pair.slice(eq + 1); + // Try to parse as JSON (so numbers, bools, arrays work) — fall back to string. + let value: unknown = rawValue; + try { + value = JSON.parse(rawValue); + } catch { + value = rawValue; + } + out[key] = value; + } + return out; +} + +const tasksSetContext = new Command("set-context") + .description("Merge keys into a task's persistent context blob") + .argument("", "Task ID") + .option("--kv ", "Key=value pair to merge (repeatable)") + .option("--context ", "Full context as a JSON object") + .option("--json", "Output raw JSON") + .addHelpText("after", ` +Examples: + $ delega tasks set-context abc123 --kv step=research_done --kv count=3 + $ delega tasks set-context abc123 --context '{"findings":["price=$20/mo"]}' + +Keys are deep-merged into existing context (not replaced). +`) + .action(async (taskId: string, opts) => { + let body: Record; + try { + body = parseContextInput(opts.kv, opts.context); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(1); + } + if (Object.keys(body).length === 0) { + console.error( + "Error: supply at least one --kv pair or a --context JSON object.", + ); + process.exit(1); + } + const resp = await apiCall("PATCH", `/tasks/${taskId}/context`, body); + if (opts.json) { + console.log(JSON.stringify(resp, null, 2)); + return; + } + console.log(`Context updated for task ${taskId}.`); + // Normalize display: hosted returns bare context dict, self-hosted returns full Task. + let merged: unknown = resp; + if ( + resp && typeof resp === "object" && + "content" in resp && "id" in resp + ) { + merged = (resp as { context?: unknown }).context || {}; + } + console.log(JSON.stringify(merged, null, 2)); + }); + +interface DedupMatch { + task_id: string | number; + content: string; + score: number; +} +interface DedupResult { + has_duplicates: boolean; + matches: DedupMatch[]; +} + +const tasksDedup = new Command("dedup") + .description("Check if content is similar to an existing open task") + .requiredOption("--content ", "Proposed task content (required)") + .option("--threshold ", "Similarity threshold 0-1 (default 0.6)", (v) => + Number.parseFloat(v), + ) + .option("--json", "Output raw JSON") + .addHelpText("after", ` +Examples: + $ delega tasks dedup --content "Research pricing" + $ delega tasks dedup --content "Research pricing" --threshold 0.8 + $ delega tasks dedup --content "Research pricing" --json + +Call before \`delega tasks create\` to avoid redundant work. +`) + .action(async (opts) => { + if (opts.threshold !== undefined && (Number.isNaN(opts.threshold) || opts.threshold < 0 || opts.threshold > 1)) { + console.error("Error: --threshold must be a number between 0 and 1."); + process.exit(1); + } + const body: { content: string; threshold?: number } = { content: opts.content }; + if (opts.threshold !== undefined) body.threshold = opts.threshold; + const resp = await apiCall("POST", "/tasks/dedup", body); + if (opts.json) { + console.log(JSON.stringify(resp, null, 2)); + return; + } + if (!resp.matches?.length) { + console.log("No duplicates found."); + return; + } + console.log(`Found ${resp.matches.length} possible duplicate${resp.matches.length === 1 ? "" : "s"}:`); + for (const m of resp.matches) { + const score = typeof m.score === "number" ? m.score.toFixed(2) : String(m.score); + console.log(` [#${m.task_id}] ${m.content} (score ${score})`); + } + }); + export const tasksCommand = new Command("tasks") .description("Manage tasks") .addCommand(tasksList) @@ -270,4 +494,8 @@ export const tasksCommand = new Command("tasks") .addCommand(tasksShow) .addCommand(tasksComplete) .addCommand(tasksDelete) - .addCommand(tasksDelegate); + .addCommand(tasksDelegate) + .addCommand(tasksAssign) + .addCommand(tasksChain) + .addCommand(tasksSetContext) + .addCommand(tasksDedup); diff --git a/src/commands/usage.ts b/src/commands/usage.ts new file mode 100644 index 0000000..23dd927 --- /dev/null +++ b/src/commands/usage.ts @@ -0,0 +1,94 @@ +import { Command } from "commander"; +import { apiCall } from "../api.js"; +import { getApiUrl } from "../config.js"; +import { label } from "../ui.js"; + +interface Usage { + plan?: string; + task_count_month?: number; + tasks_this_month?: number; + task_count?: number; + task_limit?: number | null; + limit?: number | null; + reset_date?: string; + resets_at?: string; + agent_count?: number; + agent_limit?: number | null; + webhook_count?: number; + webhook_limit?: number | null; + project_count?: number; + project_limit?: number | null; + rate_limit_rpm?: number; + max_content_chars?: number; + [key: string]: unknown; +} + +function formatLimit(n: number | null | undefined): string { + if (n === null || n === undefined) return "unlimited"; + return String(n); +} + +// Hosted-only gate: the /usage endpoint doesn't exist on self-hosted backends +// (mirrors delega-mcp's client-side gate at delega-mcp/src/delega-client.ts:170). +function isHostedApi(apiUrl: string): boolean { + try { + const parsed = new URL(apiUrl); + // getApiUrl always appends /v1 or /api based on hostname; check the path. + return parsed.pathname.startsWith("/v1"); + } catch { + return false; + } +} + +export const usageCommand = new Command("usage") + .description("Show plan quota and rate-limit info (hosted API only)") + .option("--json", "Output raw JSON") + .addHelpText("after", ` +Examples: + $ delega usage + $ delega usage --json + +Hosted API only (api.delega.dev). Self-hosted deployments do not expose +a usage endpoint. +`) + .action(async (opts) => { + const apiUrl = getApiUrl(); + if (!isHostedApi(apiUrl)) { + console.error( + "Error: \`delega usage\` is only available on the hosted Delega API " + + "(api.delega.dev). Self-hosted deployments do not expose a usage endpoint.", + ); + process.exit(1); + } + + const data = await apiCall("GET", "/usage"); + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + console.log(); + if (data.plan) label("Plan", data.plan); + const taskCount = data.task_count_month ?? data.tasks_this_month ?? data.task_count; + if (taskCount !== undefined) { + const resets = data.reset_date ?? data.resets_at; + const resetLabel = resets ? ` (resets ${resets})` : ""; + label("Tasks", `${taskCount}/${formatLimit(data.task_limit ?? data.limit)}${resetLabel}`); + } + if (data.agent_count !== undefined) { + label("Agents", `${data.agent_count}/${formatLimit(data.agent_limit)}`); + } + if (data.webhook_count !== undefined) { + label("Webhooks", `${data.webhook_count}/${formatLimit(data.webhook_limit)}`); + } + if (data.project_count !== undefined) { + label("Projects", `${data.project_count}/${formatLimit(data.project_limit)}`); + } + if (data.rate_limit_rpm !== undefined) { + label("Rate limit", `${data.rate_limit_rpm} req/min`); + } + if (data.max_content_chars !== undefined) { + label("Max content chars", String(data.max_content_chars)); + } + console.log(); + }); diff --git a/src/index.ts b/src/index.ts index e903222..f820028 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { agentsCommand } from "./commands/agents.js"; import { statsCommand } from "./commands/stats.js"; import { statusCommand } from "./commands/status.js"; import { resetCommand } from "./commands/reset.js"; +import { usageCommand } from "./commands/usage.js"; import { printBanner } from "./ui.js"; const require = createRequire(import.meta.url); @@ -35,6 +36,7 @@ program.addCommand(whoamiCommand); program.addCommand(tasksCommand); program.addCommand(agentsCommand); program.addCommand(statsCommand); +program.addCommand(usageCommand); program.addCommand(statusCommand); program.addCommand(resetCommand);