/** * pi-nightshift - background jobs for pi, with completion that wakes the agent (D7). * Phase 1: registry + four tools, no notifications yet (those are Phase 2). * This file only wires pi APIs; the registry logic lives in jobs.ts (硬约束 5). */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, getAgentDir, getShellConfig, truncateTail, } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; import { Type } from "typebox"; import { createBatcher } from "./batch.ts"; import { loadConfigFile } from "./config.ts"; import { DurableStore, replayNotices } from "./durable.ts"; import { JobRegistry, snapshot, statusLine, type ShellSpec } from "./jobs.ts"; import { decideLane, formatBatch, formatNotice, formatReplayNotice, sendCompletion } from "./notify.ts"; // Batch window: jobs settling within 150ms of each other share one notice; // a 1s cap from the first settle keeps a lone job's notice from waiting forever. const BATCH = { debounceMs: 150, maxWaitMs: 1_000 }; export default function (pi: ExtensionAPI) { const registry = new JobRegistry(); const config = loadConfigFile(join(getAgentDir(), "nightshift.json")); // Durable store is session-scoped: created at session_start once the // sessionId is known (replay leftovers on the same session after a crash). let durable: DurableStore | undefined; pi.on("session_start", (_event, ctx) => { const sessionId = ctx.sessionManager.getSessionId(); durable = new DurableStore(join(getAgentDir(), "pi-nightshift", sessionId)); // Replay crash survivors as steer without triggerTurn: never yank the // session open on startup, let the notice ride the next real turn. // The notice text is rewritten for replay - the job and its output died // with the old session, so "read its output" would point at nothing. replayNotices(durable, (content, wake) => sendCompletion(pi, formatReplayNotice(content), wake)); }); // Wake budget and idle tracking, session-scoped (the extension instance is // per-session, so these reset on session replacement automatically). let spentWakes = 0; let agentBusy = false; // session starts idle let shuttingDown = false; // True idle is agent_settled (AGENTS.md); ctx.isIdle() isn't reachable // outside an event turn, so track it ourselves. pi.on("agent_start", () => { agentBusy = true; }); pi.on("agent_settled", () => { agentBusy = false; }); // Budget refills ONLY on real user input (D3). before_agent_start fires // solely from the user-prompt path (startPrompt), never for our own // followUp/steer notices - a woken turn cannot refill what it spends. pi.on("before_agent_start", () => { spentWakes = 0; }); const batcher = createBatcher<{ job: ReturnType; wake: boolean }>({ config: BATCH, emit(items) { // triggerTurn takes the OR of the group's lanes; the budget is charged // once per opened turn, not per job. const wake = items.some((item) => item.wake); if (wake) spentWakes += 1; const accepted = sendCompletion(pi, formatBatch(items), wake); // Durable: delete only what delivery confirmed. A failed send leaves // the per-job files for the next session_start replay. if (accepted) for (const item of items) durable?.remove(item.job.id); }, }); registry.onSettle = (job) => { // dsh: an owner its own teardown is draining gets no wake and no inject. if (shuttingDown) return; const lane = decideLane({ reported: job.reported, idle: !agentBusy, spentWakes, budget: config.maxConsecutiveWakes, delivery: config.completionDelivery, }); if (lane === "drop") return; // Durable: write before batching, so a crash in the 150ms window (or // during delivery) cannot lose the notice - the file replays next session. durable?.write({ id: job.id, content: formatNotice(snapshot(job)), createdAt: Date.now() }); batcher.push({ job: snapshot(job), wake: lane === "wake" }); }; // Lazy: getShellConfig probes the filesystem / PATH, so keep it out of the // factory body (硬约束 1) and resolve once on first job_start. let shellBase: { shell: string; args: string[]; stdinTransport: boolean } | undefined; const resolveShell = (): { shell: string; args: string[]; stdinTransport: boolean } => { if (shellBase === undefined) { const cfg = getShellConfig(); shellBase = { shell: cfg.shell, args: cfg.args, stdinTransport: cfg.commandTransport === "stdin" }; } return shellBase; }; /** Hard constraint 6: truncate tool output with pi's own utilities. */ const trunc = (text: string): string => { const r = truncateTail(text, { maxBytes: DEFAULT_MAX_BYTES - 64, maxLines: DEFAULT_MAX_LINES }); return r.truncated ? `[older output truncated]\n${r.content}` : text; }; pi.registerTool({ name: "job_start", label: "Start background job", description: "Run a shell command as a background job and return its job id immediately. " + "Output accumulates on the job; read it with job_output. " + "Use this instead of bash for long-running commands (builds, test suites, long scripts) " + "so you can keep working; you are notified in-session when the job finishes.", promptSnippet: "Start a long-running shell command in the background and get a job id", promptGuidelines: [ "Use job_start instead of bash for commands that may run longer than a few seconds (builds, test suites, installs): job_start returns a job id immediately and the command keeps running in the background.", "Track every background job id you start; do not busy-poll a job with repeated job_output calls or sleep waiting for one - keep working on independent steps and do not duplicate a running job's work.", "Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when genuinely blocked on it), and job_kill jobs that stopped mattering.", ], parameters: Type.Object({ command: Type.String({ description: "Shell command to run in the background" }), label: Type.Optional(Type.String({ description: "Short label to identify the job (defaults to the command)" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const base = resolveShell(); // Builtin bash uses pi's getShellEnv() (adds ~/.pi/agent/bin to PATH), // but that helper is not exported at package root. process.env already // carries it for typical launches; background jobs don't need pi's CLI. const job = registry.start(params.command, params.label, { ...base, cwd: ctx.cwd, env: process.env, }); const text = `background job ${job.id} (${job.kind}: ${job.label}) started ${statusLine(job)}`; return { content: [{ type: "text", text }], details: { job: { id: job.id } } }; }, }); pi.registerTool({ name: "job_output", label: "Read background job output", description: "Read a background job. Returns only output since the previous read; every response " + "ends with `[status: ...]`. Reads are non-blocking unless wait: true, which waits up to " + "timeout_ms for a terminal status (a timed-out wait returns [status: running] and leaves " + "the job alive).", promptSnippet: "Read new output from a background job, optionally blocking until it finishes", parameters: Type.Object({ job_id: Type.String({ description: "Job id returned by job_start" }), wait: Type.Optional(Type.Boolean({ description: "Block until the job finishes or the timeout expires" })), timeout_ms: Type.Optional( Type.Number({ description: "Max wait in milliseconds (only with wait: true). Default 30s, capped at 10min" }), ), }), async execute(_toolCallId, params) { const timeout = Math.min(params.timeout_ms ?? config.waitTimeoutMs, config.maxWaitTimeoutMs); if (params.wait === true) await registry.waitFor(params.job_id, timeout); const { text, job } = registry.read(params.job_id); const body = text.length > 0 ? text : "(no new output)"; const sep = body.endsWith("\n") ? "" : "\n"; return { content: [{ type: "text", text: trunc(`${body}${sep}${statusLine(job)}`) }], details: { job }, }; }, }); pi.registerTool({ name: "job_list", label: "List background jobs", description: "List your background jobs (running and finished) with their ids, kinds, and statuses.", promptSnippet: "List background jobs and their statuses", parameters: Type.Object({}), async execute() { const jobs = registry.list(); const text = jobs.length === 0 ? "(no background jobs)" : jobs.map((t) => `${t.id} [${t.kind}] ${t.status} - ${t.label}`).join("\n"); return { content: [{ type: "text", text }], details: { jobs } }; }, }); pi.registerTool({ name: "job_kill", label: "Kill background job", description: "Request cancellation of a running background job by id. Returns immediately; the job settles " + "as killed once its process actually stops. Killing an already-finished job reports its final " + "state without consuming its unread output.", promptSnippet: "Cancel a running background job", parameters: Type.Object({ job_id: Type.String({ description: "Job id returned by job_start" }), reason: Type.Optional(Type.String({ description: "Optional short reason recorded on the job" })), }), async execute(_toolCallId, params) { const { outcome, job } = registry.kill(params.job_id, params.reason); const text = outcome === "already-finished" ? `job ${job.id} had already finished ${statusLine(job)}` : `requested cancellation of job ${job.id}`; return { content: [{ type: "text", text }], details: { outcome, job } }; }, }); // Jobs die with the session (D6); the only session-scoped cleanup (硬约束 1). pi.on("session_shutdown", () => { shuttingDown = true; batcher.dispose(); // drop pending notices: teardown settles get no wake/inject registry.shutdownAll(); }); }