← 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 · TypeScriptimport { 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)
}