import { workerLaunchDetail, workerLaunchSubject, workerQuestions, workerShortLabel, workerSummaryName, type WorkerStatus } from "./background-work.js"; import { readGitSnapshot } from "./git-context.js"; import type { LoadedArtifactContext } from "./loaded-artifact-context.js"; import type { ArtifactKind } from "./types.js"; import type { WorkerKindRegistry, WorkerKind } from "./worker-kinds.js"; import { workerKindCompatibility } from "./worker-kinds.js"; import { formatWorkerLaunchSummary, resolveWorkerSpawnPolicy, type WorkerExecutionModel, type WorkerThinking } from "./worker-spawn-policy.js"; import { explicitExtensionArgs, workerProjectKey, type WorkerStore } from "./worker-store.js"; import type { WorkerHandoffProvenance } from "./worker-deliverable.js"; export type WorkerCompletionCandidate = { value: string; label: string }; type NotifyLevel = "info" | "warning" | "error"; type DocketMessageKind = "list" | "success" | "action"; type WorkerCommandsDeps = { store: WorkerStore; loadedArtifacts: Pick; cwd: string; projectRoot?: string; parentSession?: string; parentModel(): string | undefined; parentThinking(): string | undefined; availableModels(): readonly WorkerExecutionModel[]; kinds: WorkerKindRegistry; maxActive(): number; /** Project-default kind picked when /docket spawn is invoked without --as. */ defaultKind?(): string | undefined; /** Default parent-seed policy when neither spawn flags nor legacy kind metadata set one. */ parentSeedPolicy?(): "full" | "none" | undefined; hasUI: boolean; confirmSpawn(title: string, detail: string): Promise; notify(text: string, level: NotifyLevel): void; announce(subject: string, detail?: string, kind?: DocketMessageKind, docket?: { kind: ArtifactKind; title: string; subtitle?: string }, meta?: { workerId: string }): void; emitText(text: string, kind: "list", heading: string): void; }; export type WorkerCommandSpawnOptions = { worktree?: boolean; fresh?: boolean; seed?: boolean; as?: string; model?: string; thinking?: WorkerThinking; sourceDeliverable?: { body: string; provenance: WorkerHandoffProvenance }; /** Reviewed Use → Implement launches: the approved plan discharges the kind's plan gate. */ planAuthorized?: boolean; /** Internal handoff guard checked after confirmation and before filesystem/tmux work. */ authorizeLaunch?: () => Promise; }; export type WorkerCommands = { spawn(task: string, options?: WorkerCommandSpawnOptions): Promise; tell(ref: string, text: string): Promise; list(options?: { allProjects?: boolean }): Promise; listKinds(): Promise; delete(ref: string | undefined): Promise; respawn(target: string): Promise; load(ref: string | undefined): Promise; unload(ref: string): Promise; completionCandidates(): Promise; }; export function workerAge(updatedAt: string): string { const ageMs = Date.now() - Date.parse(updatedAt); if (!Number.isFinite(ageMs) || ageMs < 0) return updatedAt; const seconds = Math.round(ageMs / 1000); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); return `${hours}h ago`; } export async function workerCompletionCandidates(store: WorkerStore, options: { projectRoot?: string } = {}): Promise { try { const workers = await store.list(options); return workers.slice(-10).reverse().map((w) => ({ value: workerShortLabel(w.index), label: `${workerShortLabel(w.index)} ${w.state} ${workerSummaryName(w, 40)}`, })); } catch { return []; } } function formatWorkerTell(worker: WorkerStatus, text: string): string { const questions = workerQuestions(worker); if (questions.length === 0) return `Parent message: ${text}`; const questionList = questions.map((question, index) => `${index + 1}) ${question.text}`).join(" "); return `Parent message for ${questions.length} question${questions.length === 1 ? "" : "s"}: ${questionList} Message: ${text}`; } function formatWorkerList(workers: WorkerStatus[], options: { groupByProject?: boolean } = {}): string { if (workers.length === 0) return "No Docket workers"; const lineFor = (w: WorkerStatus) => { const label = workerShortLabel(w.index).padEnd(4); const state = (w.state ?? "?").padEnd(8); const kind = (w.kind ?? "default").padEnd(8); const artifacts = `${w.artifactCount ?? "?"} artifacts`.padEnd(14); const age = workerAge(w.updatedAt).padEnd(8); return `${label} ${state} ${kind} ${artifacts} ${age} ${workerSummaryName(w, 40)}`; }; if (!options.groupByProject) return workers.map(lineFor).join("\n"); const groups = new Map(); for (const worker of workers) { const key = workerProjectKey(worker); groups.set(key, [...(groups.get(key) ?? []), worker]); } return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).flatMap(([project, entries]) => [`project: ${project}`, ...entries.map(lineFor)]).join("\n"); } const KIND_SOURCE_ORDER: WorkerKind["source"][] = ["builtin", "user", "runtime"]; function kindAuthority(kind: WorkerKind): string { return kind.readOnly ? "read-only" : kind.planGate ? "plan-gated" : "writable"; } /** * One indented block per kind rather than one dense row: name and authority read as a * heading, everything that qualifies the kind (description, decision rights, migration * warnings) hangs under it, and blank lines keep neighbouring kinds from running together. */ function formatKindBlock(kind: WorkerKind): string[] { const detail = [ ...(kind.description ? [kind.description] : []), ...(kind.decisionRights ?? []).map((right) => `rights: ${right}`), ...(workerKindCompatibility(kind)?.diagnostics ?? []).map((message) => `warning: ${message}`), ]; return [` ${kind.name} · ${kindAuthority(kind)}`, ...detail.map((line) => ` ${line}`)]; } export function formatKindList(kinds: WorkerKind[], options: { defaultKind?: string } = {}): string { if (kinds.length === 0) return "No Docket worker kinds registered"; const sources = KIND_SOURCE_ORDER.filter((source) => kinds.some((kind) => kind.source === source)); const groups = sources.map((source) => [ source, ...kinds.filter((kind) => kind.source === source).flatMap((kind, index) => [...(index === 0 ? [] : [""]), ...formatKindBlock(kind)]), ].join("\n")); const spawnHint = `Spawn with /docket spawn --as ${options.defaultKind ? ` · without --as: ${options.defaultKind}` : ""}`; return [...groups, spawnHint].join("\n\n"); } export function createWorkerCommands(deps: WorkerCommandsDeps): WorkerCommands { const loadWorker = async (worker: WorkerStatus): Promise => { const deliverable = await deps.store.readCurrentDeliverable?.(worker); if (worker.deliverable && !deliverable) throw new Error(`Worker deliverable ${worker.deliverable.ref} is missing or invalid`); const result = await deps.loadedArtifacts.loadSource(deliverable ? { kind: "deliverable", worker, deliverable } : { kind: "worker", worker }); deps.announce( `loaded ${result.slot.slot} · ${result.slot.artifacts.length} artifact${result.slot.artifacts.length === 1 ? "" : "s"}`, `${workerSummaryName(worker)}\nrefs: @${result.slot.slot}.`, "success", ); }; return { async spawn(task: string, options: WorkerCommandSpawnOptions = {}): Promise { try { const handoff = options.sourceDeliverable !== undefined; const policy = resolveWorkerSpawnPolicy({ kinds: deps.kinds, availableModels: deps.availableModels(), options: { ...options, ...(handoff ? { handoff: true } : {}) }, configuredDefaultKind: deps.defaultKind?.(), configuredParentSeedPolicy: deps.parentSeedPolicy?.(), parentSession: deps.parentSession, parentModel: deps.parentModel(), parentThinking: deps.parentThinking(), }); if (policy.unknownRequestedKind) deps.notify(`Docket: unknown worker kind "${policy.unknownRequestedKind}". Try /docket kinds. Using ${policy.kind.name}.`, "warning"); if (policy.unknownDefaultKind) deps.notify(`Docket: configured default worker kind "${policy.unknownDefaultKind}" not found. Using builtin default.`, "warning"); for (const warning of policy.warnings) deps.notify(`Docket: worker kind "${policy.kind.name}": ${warning}`, "warning"); const max = deps.maxActive(); if (max > 0) { const active = await deps.store.countActive(); if (active >= max) { deps.notify(`Docket: fleet cap reached (${active}/${max} active). Resolve or delete a worker before spawning another.`, "error"); return undefined; } } const launchSummary = formatWorkerLaunchSummary(policy); if (policy.requiresConfirmation && deps.hasUI) { const reviewedSource = options.sourceDeliverable?.provenance.sourceRef; const gateDischarged = reviewedSource !== undefined && options.planAuthorized === true && policy.kind.planGate === true; const detail = [ `Task: ${task}`, launchSummary, reviewedSource ? `Reviewed source: ${reviewedSource}` : undefined, gateDischarged ? `Plan gate: satisfied at launch by ${reviewedSource}` : undefined, ] .filter((line): line is string => line !== undefined) .join("\n"); const confirmed = await deps.confirmSpawn(handoff ? "Start reviewed handoff worker?" : "Start Docket worker?", detail); if (!confirmed) return undefined; } if (options.authorizeLaunch && !(await options.authorizeLaunch())) return undefined; const kind = policy.kind; const git = readGitSnapshot(deps.cwd); const worker = await deps.store.spawn({ task, cwd: deps.cwd, ...(policy.seedSource ? { parentSession: policy.seedSource } : {}), worktree: policy.useWorktree, ...(policy.freshLaunch ? { fresh: true } : {}), ...(git ? { git } : {}), model: policy.model, thinking: policy.thinking, ...(options.sourceDeliverable ? { sourceDeliverable: options.sourceDeliverable } : {}), // A plan can only discharge a gate through a reviewed handoff. ...(options.sourceDeliverable && options.planAuthorized ? { planAuthorized: true } : {}), kind: kind.name, readOnly: kind.readOnly, ...(kind.planGate ? { planGate: true } : {}), ...(kind.decisionRights?.length ? { decisionRights: kind.decisionRights } : {}), extensionArgs: [...explicitExtensionArgs(), ...policy.launchArgs], }); const now = Date.parse(worker.createdAt); deps.announce( workerLaunchSubject(worker, { now }), workerLaunchDetail(worker, { now, launchSummary }), "action", undefined, { workerId: worker.id }, ); return worker; } catch (err) { const message = err instanceof Error ? err.message : String(err); deps.notify(`Docket spawn failed: ${message}`, "error"); return undefined; } }, async tell(ref: string, text: string): Promise { const worker = await deps.store.find(ref); if (!worker) { deps.notify("Docket worker not found", "error"); return false; } const sent = await deps.store.sendInput(worker.id, formatWorkerTell(worker, text)); if (sent) deps.announce( `told ${workerShortLabel(worker.index)}`, text, "success", { kind: "prompt", title: `tell ${workerShortLabel(worker.index)}`, subtitle: workerSummaryName(worker) }, ); else deps.notify(`Docket could not send message to ${workerShortLabel(worker.index)}`, "error"); return sent; }, async list(options: { allProjects?: boolean } = {}): Promise { const projectRoot = options.allProjects ? undefined : deps.projectRoot; deps.emitText(formatWorkerList(await deps.store.list({ ...(projectRoot ? { projectRoot } : {}) }), { groupByProject: options.allProjects === true }), "list", "docket · workers"); }, async listKinds(): Promise { const fallback = deps.kinds.defaultKind(deps.defaultKind?.()).name; deps.emitText(formatKindList(deps.kinds.list(), { defaultKind: fallback }), "list", "docket · worker kinds"); }, async delete(ref: string | undefined): Promise { if (!ref) { deps.notify("Usage: /docket delete w", "error"); return; } const worker = await deps.store.find(ref); if (!worker) { deps.notify("Docket worker not found", "error"); return; } deps.loadedArtifacts.unloadSource("worker", worker.id); await deps.store.purge(worker.id); deps.announce(`worker ${workerShortLabel(worker.index)} killed`, `${workerSummaryName(worker)}\nid: ${worker.id}${worker.worktree ? `\nremoved workspace: ${worker.worktree.path}` : ""}`); }, async respawn(target: string): Promise { const ALL = target.toLowerCase() === "all"; const candidates = ALL ? (await deps.store.list()).filter((w) => ["ended", "error", "failed"].includes(w.state)) : await (async () => { const w = await deps.store.find(target); return w ? [w] : []; })(); if (candidates.length === 0) { deps.notify(ALL ? "Docket: no relaunch-eligible workers" : "Docket worker not found", "warning"); return; } const ok: string[] = []; const failed: { label: string; error: string }[] = []; for (const worker of candidates) { try { const result = await deps.store.respawn(worker.id); if (result) ok.push(workerShortLabel(result.index)); else failed.push({ label: workerShortLabel(worker.index), error: "no status" }); } catch (err) { failed.push({ label: workerShortLabel(worker.index), error: String(err) }); } } if (ok.length > 0) deps.announce(`respawned ${ok.length} worker${ok.length === 1 ? "" : "s"}`, ok.join(", "), "success"); if (failed.length > 0) deps.notify(`Docket respawn failed for: ${failed.map((entry) => `${entry.label} (${entry.error})`).join(", ")}`, "error"); }, async load(ref: string | undefined): Promise { if (!ref) { deps.notify("Usage: /docket load w", "error"); return; } try { const worker = await deps.store.find(ref); if (!worker) { deps.notify("Docket worker not found", "error"); return; } await loadWorker(worker); } catch (err) { deps.notify(`Docket load failed: ${String(err)}`, "error"); } }, async unload(ref: string): Promise { const worker = await deps.store.find(ref); const removed = worker ? deps.loadedArtifacts.unloadSource("worker", worker.id) : undefined; if (removed) deps.announce(`unloaded ${removed.slot}`, worker ? workerSummaryName(worker) : undefined); else deps.notify("Docket worker not loaded", "warning"); }, completionCandidates(): Promise { return workerCompletionCandidates(deps.store, { ...(deps.projectRoot ? { projectRoot: deps.projectRoot } : {}) }); }, }; }