/** * Completion notice: pure functions only (AGENTS.md 硬约束 5, D4). * Lane decision, notice text, and the single sendMessage touchpoint. * Pattern: pi-subagents/src/runs/background/notify.ts. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { statusLine, type JobSnapshot } from "./jobs.ts"; export type Lane = "wake" | "steer" | "drop"; export type Delivery = "wakeup" | "quiet"; export interface LaneInput { reported: boolean; idle: boolean; spentWakes: number; budget: number; delivery: Delivery; } /** D2 contract: notice carries only id/kind/label/status + where to read output. */ export function formatNotice(job: JobSnapshot): string { return `background job ${job.id} (${job.kind}: ${job.label}) finished ${statusLine(job)}. Read its output with job_output.`; } /** * Lane decision (dsh onJobDone): * - reported (model already knows via kill/terminal read) -> drop * - quiet delivery -> never wake, always steer * - idle with wake budget left -> wake (followUp + triggerTurn) * - anything else -> steer (inject into the current/next turn) */ export function decideLane(input: LaneInput): Lane { if (input.reported) return "drop"; if (input.delivery === "quiet") return "steer"; if (input.idle && input.spentWakes < input.budget) return "wake"; return "steer"; } /** * Replay-only notice variant: at session_start the job and its output are gone * (they died with the old session), so the collection instruction must not * point at a job that no longer exists. Swap the tail for an honest note. */ export function formatReplayNotice(content: string): string { const marker = " Read its output with job_output."; if (!content.endsWith(marker)) return content; return `${content.slice(0, -marker.length)} The session ended before this notice was delivered; the job's output is no longer available.`; } /** The only place touching pi.sendMessage (D4). Accepted = did not throw; Phase 4 persists on false. */ export function sendCompletion( send: Pick, content: string, wake: boolean, ): boolean { try { send.sendMessage( { customType: "nightshift", content, display: true, }, wake ? { deliverAs: "followUp", triggerTurn: true } : { deliverAs: "steer" }, ); return true; } catch { return false; } } /** Ceiling for one completion notice; longer batches degrade (multi-byte safe). */ export const NOTICE_MAX_BYTES = 4096; const encoder = new TextEncoder(); /** * Batch notice text. One job keeps the D2 single-line form; several become a * bulleted list. Over the byte ceiling the labels are dropped for ids only; * at pathological sizes it degrades to the count + job_list pointer. */ export function formatBatch(items: Array<{ job: JobSnapshot }>): string { if (items.length === 1) return formatNotice(items[0]!.job); const full = [ `background jobs finished (${items.length}):`, ...items.map(({ job }) => `- ${job.id} (${job.kind}: ${job.label}) ${statusLine(job)}`), "Read their output with job_output.", ].join("\n"); if (encoder.encode(full).byteLength <= NOTICE_MAX_BYTES) return full; const ids = `${items.length} background jobs finished: ${items.map(({ job }) => job.id).join(", ")}. Read their output with job_output.`; if (encoder.encode(ids).byteLength <= NOTICE_MAX_BYTES) return ids; return `${items.length} background jobs finished. Use job_list to see them and job_output to read output.`; }