Skip to content
← Public packages

@hypercubed/handoff

Agent handoff queue via storage — any agent to any agent, use your own harness name

src/index.ts

448 lines · 17.8 KB · TypeScript
import { packageStorage } from 'kody:runtime'

const INDEX_KEY = 'handoff:index'
const ITEM_PREFIX = 'handoff:item:'

export type TaskStatus = 'pending' | 'claimed' | 'completed' | 'failed'

export type Task = {
	id: string
	title: string
	prompt: string
	status: TaskStatus
	priority: string
	tags: string[]
	createdAt: string
	createdBy: string | null
	forAgent: string | null
	sessionName: string | null
	claimedAt: string | null
	claimedBy: string | null
	completedAt: string | null
	result: string | null
}

export type CreateTaskInput = {
	/** Short title for the task (required). */
	title: string
	/** Full prompt/instructions for the worker agent (required). */
	prompt: string
	/** Priority hint: low | medium | high (default medium). */
	priority?: string
	/** Optional tags for filtering. */
	tags?: string[]
	/** Harness that created it — defaults to caller harness name when passed, otherwise unknown. Pass your own harness name (e.g. "JAIC"), never an example name. */
	createdBy?: string
	/** Target agent/harness this task is for. When set, only that agent should claim it. Use the target's actual harness name. Null = for anyone. Optional: omit the name to create an anonymous task with only a session id. */
	forAgent?: string | null
}

export type ClaimTaskInput = {
	/** Specific task id to claim. If omitted, claims oldest pending task. */
	taskId?: string
	/** Task title to claim (when id not known). Matches newest pending with exact title. */
	title?: string
	/** Creator harness to filter by. When provided with no id/title, claims oldest pending where createdBy matches. */
	createdBy?: string
	/** Target agent to filter by. When provided (with no id/title), claims oldest pending where forAgent matches. Enables "claim next task created for me". */
	forAgent?: string
	/** Harness claiming the task — use your own name (e.g. "JAIC"), never an example name. Defaults to session-only when omitted. */
	agentId?: string
}

export type CompleteTaskInput = {
	/** Task id to complete. */
	taskId: string
	/** Result summary / artifact note. */
	result?: string
	/** Final status: completed (default) or failed. */
	status?: 'completed' | 'failed'
	/** Who completed it — defaults to claimedBy. */
	completedBy?: string
}

export type ListTasksInput = {
	/** Filter by status. */
	status?: TaskStatus
	/** Filter to tasks targeted at this agent (forAgent exact match). */
	forAgent?: string
	/** Max tasks to return (1-100, default 20). */
	limit?: number
}

export type GetTaskInput = {
	/** Task id. */
	taskId: string
}

export type DeleteTaskInput = {
	/** Task id to delete. */
	taskId: string
}

function newId(): string {
	return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
}

function requireString(v: unknown, name: string): string {
	if (typeof v !== 'string' || v.trim() === '') throw new Error(`${name} is required and must be non-empty string.`)
	return v.trim()
}

function clampLimit(v: unknown, fallback: number): number {
	const n = Number(v)
	if (!Number.isFinite(n)) return fallback
	return Math.min(100, Math.max(1, Math.floor(n)))
}

/** Split a name into base harness and optional session id. "agent:hdJs" -> {base:"agent", sessionId:"hdJs"}; "agent" -> {base:"agent", sessionId:null}; ":hdJs" -> {base:"", sessionId:"hdJs"}. */
function splitName(name: string): { base: string; sessionId: string | null } {
	if (name.startsWith(':')) {
		const sid = name.slice(1)
		if (sid) return { base: '', sessionId: sid }
	}
	const i = name.indexOf(':')
	if (i > 0) {
		const base = name.slice(0, i)
		const sid = name.slice(i + 1)
		if (base && sid) return { base, sessionId: sid }
	}
	return { base: name, sessionId: null }
}

/** Short collision-resistant session id. */
function newSessionId(): string {
	return Math.random().toString(36).slice(2, 6) + Date.now().toString(36).slice(-4)
}

/** Ensure a name is fully qualified. If it already has a session id, keep it (no double-attach); otherwise mint one. Name is optional: when absent (empty/unknown) the qualified form is just ":<sid>". */
function qualify(name: string): { base: string; sessionId: string; qualified: string } {
	const { base, sessionId } = splitName(name)
	const sid = sessionId ?? newSessionId()
	const trimmed = base.trim()
	const qualified = trimmed && trimmed !== 'unknown' ? `${trimmed}:${sid}` : `:${sid}`
	return { base: trimmed && trimmed !== 'unknown' ? trimmed : '', sessionId: sid, qualified }
}

/** Match a stored forAgent against a query that may be bare ("agent"), fully qualified ("agent:hdJs"), or session-only (":hdJs"). */
function matchesForAgent(stored: string | null, query: string): boolean {
	if (!stored) return false
	const q = splitName(query)
	if (q.sessionId) return stored === query
	return splitName(stored).base === q.base
}

async function readIndex(store: ReturnType<typeof packageStorage>): Promise<string[]> {
	const raw = await store.get(INDEX_KEY)
	if (!raw) return []
	try {
		const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
		return Array.isArray(parsed) ? parsed.map(String) : []
	} catch { return [] }
}

async function readTask(store: ReturnType<typeof packageStorage>, id: string): Promise<Task | null> {
	const raw = await store.get(ITEM_PREFIX + id)
	if (!raw) return null
	try {
		const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
		if (parsed && typeof parsed === 'object' && parsed.id) return parsed as Task
		return null
	} catch { return null }
}

async function writeTask(store: ReturnType<typeof packageStorage>, task: Task): Promise<void> {
	await store.set(ITEM_PREFIX + task.id, JSON.stringify(task))
}

async function pruneCompleted(store: ReturnType<typeof packageStorage>): Promise<{ pruned: string[] }> {
	const index = await readIndex(store)
	const tasks: Task[] = []
	for (const id of index) {
		const t = await readTask(store, id)
		if (t && (t.status === 'completed' || t.status === 'failed')) tasks.push(t)
	}
	// sort newest completed first
	tasks.sort((a, b) => new Date(b.completedAt || b.createdAt).getTime() - new Date(a.completedAt || a.createdAt).getTime())
	const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000
	const toDelete: Task[] = []
	// age-off >7 days
	for (const t of tasks) {
		const ts = t.completedAt ? new Date(t.completedAt).getTime() : new Date(t.createdAt).getTime()
		if (ts < cutoff) toDelete.push(t)
	}
	// cap 100 — keep newest 100, delete rest not already marked
	const keepIds = new Set(tasks.slice(0, 100).map(t => t.id))
	for (const t of tasks) {
		if (!keepIds.has(t.id) && !toDelete.find(x => x.id === t.id)) toDelete.push(t)
	}
	if (!toDelete.length) return { pruned: [] }
	const ids = new Set(toDelete.map(t => t.id))
	// remove from index and delete items
	const newIndex = index.filter(id => !ids.has(id))
	await store.set(INDEX_KEY, JSON.stringify(newIndex))
	for (const t of toDelete) {
		const key = ITEM_PREFIX + t.id
		if (typeof (store as any).delete === 'function') await (store as any).delete(key)
		else await store.set(key, '')
	}
	return { pruned: [...ids] }
}

/**
 * Create a pending handoff task for a worker to do later — do NOT execute the prompt yourself.
 * Use when you need to delegate work to a different agent. Pass your harness name as createdBy and target agent as forAgent when known.
 * This call only stores title/prompt in packageStorage; the worker will claim and run it.
 * Prompt must be self-contained (worker has zero context): Goal / Context+Files / Do+Do-not-touch / Done+Proof / Kody routing.
 * See AGENTS.md Handoff Prompt Playbook for the template.
 *
 * @param input - title, prompt (self-contained payload for worker, not for you), optional priority/tags/createdBy/forAgent
 * @returns Created stored task — prompt is NOT executed by this call
 *
 * @example
 * import createTask from 'kody:@hypercubed/handoff/createTask'
 * await createTask({ title: "Fix lint", prompt: "Goal: fix lint in src/create-task.ts. Context: TS package, lint fails on no-unused-vars at src/create-task.ts:42. Do: edit only src/create-task.ts, run npm run lint. Do-not-touch: src/claim-task.ts. Done when npm run lint passes. Return changed files.", createdBy: "my-harness", forAgent: "worker-harness" })
 */
export async function createTask(input: CreateTaskInput): Promise<Task> {
	const title = requireString(input?.title, 'title')
	const prompt = requireString(input?.prompt, 'prompt')
	const priority = typeof input?.priority === 'string' && input.priority.trim() ? input.priority.trim() : 'medium'
	const tags = Array.isArray(input?.tags) ? input.tags.map(String).filter(Boolean) : []
	const createdByRaw = typeof input?.createdBy === 'string' && input.createdBy.trim() ? input.createdBy.trim() : (typeof (input as any)?.agentId === 'string' && (input as any).agentId.trim() ? (input as any).agentId.trim() : '')
	const created = qualify(createdByRaw)
	const createdBy = created.qualified
	const forAgent = typeof input?.forAgent === 'string' && input.forAgent.trim() ? input.forAgent.trim() : null
	const store = packageStorage()
	const task: Task = {
		id: newId(),
		title,
		prompt,
		status: 'pending',
		priority,
		tags,
		createdAt: new Date().toISOString(),
		createdBy,
		forAgent,
		sessionName: created.qualified,
		claimedAt: null,
		claimedBy: null,
		completedAt: null,
		result: null,
	}
	const index = await readIndex(store)
	index.unshift(task.id)
	await writeTask(store, task)
	await store.set(INDEX_KEY, JSON.stringify(index.slice(0, 500)))
	await pruneCompleted(store)
	return task
}

/**
 * Claim a pending task for a worker agent — do NOT execute yet, just reserves it.
 * Use when a worker is ready. Pass your harness name as agentId; defaults to unknown if omitted. Supports by id, by title, by creator, by forAgent, or next pending.
 *
 * @param input - agentId plus optional taskId (exact id), title (exact title), createdBy (creator harness), forAgent (target harness), or neither for next pending
 * @returns Claimed task with prompt for worker to execute next
 *
 * @example
 * import claimTask from 'kody:@hypercubed/handoff/claimTask'
 * const byId = await claimTask({ taskId: "abc123", agentId: "my-name" })
 * const byTitle = await claimTask({ title: "Fix docs-lint", agentId: "my-name" })
 * const byCreator = await claimTask({ createdBy: "my-name", agentId: "my-name" })
 * const forMe = await claimTask({ forAgent: "my-name", agentId: "my-name" })
 * const next = await claimTask({ agentId: "my-name" })
 */
export async function claimTask(input: ClaimTaskInput): Promise<Task> {
	const agentIdRaw = typeof input?.agentId === 'string' && input.agentId.trim() ? input.agentId.trim() : ''
	const claimed = qualify(agentIdRaw)
	const agentId = claimed.qualified
	const store = packageStorage()
	let task: Task | null = null

	if (input?.taskId) {
		const id = requireString(input.taskId, 'taskId')
		task = await readTask(store, id)
		if (!task && typeof input?.title === 'string' && input.title.trim()) {
			// id not found — try title fallback when both provided
		} else {
			if (!task) throw new Error(`Task not found: ${id}`)
			if (task.status !== 'pending') throw new Error(`Task ${id} is not pending (status=${task.status})`)
		}
	}
	if (!task && typeof input?.title === 'string' && input.title.trim()) {
		const title = input.title.trim()
		const creatorFilter = typeof input?.createdBy === 'string' && input.createdBy.trim() ? input.createdBy.trim() : null
		const index = await readIndex(store)
		for (const id of index) {
			const tt = await readTask(store, id)
			if (tt && tt.status === 'pending' && tt.title === title && (!creatorFilter || tt.createdBy === creatorFilter)) { task = tt; break }
		}
		if (!task) throw new Error(`No pending task with title: ${title}` + (creatorFilter ? ` created by ${creatorFilter}` : ''))
	}
	if (!task && typeof input?.forAgent === 'string' && input.forAgent.trim()) {
		const forAgent = input.forAgent.trim()
		const index = await readIndex(store)
		for (let i = index.length - 1; i >= 0; i--) {
			const id = index[i]
			const tt = await readTask(store, id)
			if (tt && tt.status === 'pending' && matchesForAgent(tt.forAgent, forAgent)) { task = tt; break }
		}
		if (!task) {
			const index2 = await readIndex(store)
			for (const id of index2) {
				const tt = await readTask(store, id)
				if (tt && tt.status === 'pending' && matchesForAgent(tt.forAgent, forAgent)) { task = tt; break }
			}
		}
		if (!task) throw new Error(`No pending task for agent: ${forAgent}`)
	}
	if (!task && typeof input?.createdBy === 'string' && input.createdBy.trim()) {
		const creator = input.createdBy.trim()
		const index = await readIndex(store)
		for (let i = index.length - 1; i >= 0; i--) {
			const id = index[i]
			const tt = await readTask(store, id)
			if (tt && tt.status === 'pending' && tt.createdBy === creator) { task = tt; break }
		}
		if (!task) {
			const index2 = await readIndex(store)
			for (const id of index2) {
				const tt = await readTask(store, id)
				if (tt && tt.status === 'pending' && tt.createdBy === creator) { task = tt; break }
			}
		}
		if (!task) throw new Error(`No pending task created by: ${creator}`)
	}
	if (!task) {
		const index = await readIndex(store)
		for (let i = index.length - 1; i >= 0; i--) {
			const id = index[i]
			const t = await readTask(store, id)
			if (t && t.status === 'pending') { task = t; break }
		}
		if (!task) {
			const index2 = await readIndex(store)
			for (const id of index2) {
				const t = await readTask(store, id)
				if (t && t.status === 'pending') { task = t; break }
			}
		}
		if (!task) throw new Error('No pending tasks to claim.')
	}

	task.status = 'claimed'
	task.claimedAt = new Date().toISOString()
	task.claimedBy = agentId
	task.sessionName = claimed.qualified
	await writeTask(store, task)
	return task
}

/**
 * Mark a claimed task as completed or failed.
 * Use when worker finishes the delegated work.
 *
 * @param input - taskId and optional result/status
 * @returns Updated task
 *
 * @example
 * import completeTask from 'kody:@hypercubed/handoff/completeTask'
 * await completeTask({ taskId: "abc123", result: "Fixed lint, 3 files" })
 */
export async function completeTask(input: CompleteTaskInput): Promise<Task> {
	const taskId = requireString(input?.taskId, 'taskId')
	const store = packageStorage()
	const task = await readTask(store, taskId)
	if (!task) throw new Error(`Task not found: ${taskId}`)
	if (task.status !== 'claimed' && task.status !== 'pending') throw new Error(`Task ${taskId} cannot be completed from status=${task.status}`)
	const status = input?.status === 'failed' ? 'failed' : 'completed'
	task.status = status
	task.completedAt = new Date().toISOString()
	if (typeof input?.result === 'string') task.result = input.result
	else if (input?.result !== undefined) task.result = String(input.result)
	return (await writeTask(store, task), task)
}

/**
 * Delete a handoff task by id.
 * Use to manually clean up completed/old tasks.
 *
 * @param input - taskId
 * @returns deleted id and status
 *
 * @example
 * import deleteTask from 'kody:@hypercubed/handoff/deleteTask'
 * await deleteTask({ taskId: "abc123" })
 */
export async function deleteTask(input: DeleteTaskInput): Promise<{ deleted: boolean; id: string }> {
	const taskId = requireString(input?.taskId, 'taskId')
	const store = packageStorage()
	const task = await readTask(store, taskId)
	if (!task) return { deleted: false, id: taskId }
	const index = await readIndex(store)
	const newIndex = index.filter(id => id !== taskId)
	await store.set(INDEX_KEY, JSON.stringify(newIndex))
	const key = ITEM_PREFIX + taskId
	if (typeof (store as any).delete === 'function') await (store as any).delete(key)
	else await store.set(key, '')
	return { deleted: true, id: taskId }
}

/**
 * List handoff tasks newest-first with optional status/forAgent filter.
 * Use to inspect queue depth or poll for pending work.
 *
 * @param input - optional status, forAgent, and limit
 * @returns Array of tasks
 *
 * @example
 * import listTasks from 'kody:@hypercubed/handoff/listTasks'
 * const pending = await listTasks({ status: "pending", limit: 10 })
 */
export async function listTasks(input: ListTasksInput = {}): Promise<Task[]> {
	const limit = clampLimit(input?.limit, 20)
	const status = input?.status as TaskStatus | undefined
	const forAgent = typeof (input as any)?.forAgent === 'string' && (input as any).forAgent.trim() ? (input as any).forAgent.trim() : undefined
	const store = packageStorage()
	const index = await readIndex(store)
	const out: Task[] = []
	for (const id of index) {
		if (out.length >= limit) break
		const t = await readTask(store, id)
		if (!t) continue
		if (status && t.status !== status) continue
		if (forAgent && !matchesForAgent((t as any).forAgent, forAgent)) continue
		out.push(t)
	}
	return out
}

/**
 * Fetch a single task by id.
 * Use to check status/result of a specific handoff.
 *
 * @param input - taskId
 * @returns Task or null if not found
 *
 * @example
 * import getTask from 'kody:@hypercubed/handoff/getTask'
 * const t = await getTask({ taskId: "abc123" })
 */
export async function getTask(input: GetTaskInput): Promise<Task | null> {
	const taskId = requireString(input?.taskId, 'taskId')
	const store = packageStorage()
	return readTask(store, taskId)
}

/** Root dispatcher for packages.invoke({ exportName: '.', params }). */
export default async function handoff(
	input: (CreateTaskInput & { action?: string }) | (ClaimTaskInput & { action?: string }) | (CompleteTaskInput & { action?: string }) | (ListTasksInput & { action?: string }) | (GetTaskInput & { action?: string }) | (DeleteTaskInput & { action?: string }) = { title: '', prompt: '' } as any,
) {
	const action = String((input as any)?.action || 'createTask')
	if (action === 'claimTask' || action === 'claim') return claimTask(input as ClaimTaskInput)
	if (action === 'completeTask' || action === 'complete') return completeTask(input as CompleteTaskInput)
	if (action === 'listTasks' || action === 'list') return listTasks(input as ListTasksInput)
	if (action === 'getTask' || action === 'get') return getTask(input as GetTaskInput)
	if (action === 'deleteTask' || action === 'delete') return deleteTask(input as DeleteTaskInput)
	return createTask(input as CreateTaskInput)
}