Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"description": "Spawn the pi.dev coding agent (CLI + SDK) against the PostHog LLM gateway with PostHog OAuth",
"type": "module",
"bin": {
"harness": "dist/cli.js"
"harness": "dist/cli.js",
"hog": "dist/cli.js"
},
"exports": {
"./cli": {
Expand Down Expand Up @@ -55,13 +56,29 @@
"types": "./dist/extensions/subagent/*.d.ts",
"import": "./dist/extensions/subagent/*.js"
},
"./extensions/workflow": {
"types": "./dist/extensions/workflow/extension.d.ts",
"import": "./dist/extensions/workflow/extension.js"
},
"./extensions/workflow/*": {
"types": "./dist/extensions/workflow/*.d.ts",
"import": "./dist/extensions/workflow/*.js"
},
"./extensions/mcp": {
"types": "./dist/extensions/mcp/extension.d.ts",
"import": "./dist/extensions/mcp/extension.js"
},
"./extensions/mcp/*": {
"types": "./dist/extensions/mcp/*.d.ts",
"import": "./dist/extensions/mcp/*.js"
},
"./extensions/footer-focus-demo": {
"types": "./dist/extensions/footer-focus-demo/extension.d.ts",
"import": "./dist/extensions/footer-focus-demo/extension.js"
},
"./extensions/footer-focus-demo/*": {
"types": "./dist/extensions/footer-focus-demo/*.d.ts",
"import": "./dist/extensions/footer-focus-demo/*.js"
}
},
"scripts": {
Expand All @@ -73,8 +90,8 @@
},
"dependencies": {
"@earendil-works/pi-ai": "0.80.3",
"@earendil-works/pi-coding-agent": "0.80.3",
"@earendil-works/pi-tui": "0.80.3",
"@earendil-works/pi-coding-agent": "0.80.6",
"@earendil-works/pi-tui": "0.80.6",
"@modelcontextprotocol/sdk": "^1.29.0",
"@posthog/shared": "workspace:*",
"lru-cache": "^11.1.0",
Expand Down
41 changes: 38 additions & 3 deletions packages/harness/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
#!/usr/bin/env node

import { main } from "@earendil-works/pi-coding-agent";
import { harnessExtensionFiles } from "./spawn";
import {
formatHogBrandBanner,
installHogBrandEnv,
isHelpRequest,
} from "./extensions/hog-branding/brand-env";
// Must run — and finish running — before `@earendil-works/pi-coding-agent`
// is evaluated, so pi picks up "hog" branding when its config module first
// evaluates. `installHogBrandEnv` itself only touches Node builtins, so
// this static import carries no ordering risk; everything below that
// touches pi-coding-agent (directly or transitively, e.g. `./spawn`, which
// pulls in every extension) is loaded dynamically instead of via a static
// import — see `./extensions/hog-branding/brand-env` for why a static
// import here wouldn't reliably run first once bundled, and why `./spawn`
// below is imported by a *computed* (non-literal) specifier: a literal
// `import("./spawn")` is exactly as statically inlinable (and thus exactly
// as unordered) as a static `import`, since bundlers resolve and inline
// literal-specifier dynamic imports when there's no code-splitting. A
// specifier the bundler can't statically resolve forces a genuine runtime
// load of the already-separately-built `dist/spawn.js`.
import type * as SpawnModule from "./spawn";

installHogBrandEnv();

const { main, VERSION } = await import("@earendil-works/pi-coding-agent");
const spawnModuleUrl = new URL("./spawn.js", import.meta.url).href;
const { harnessExtensionFiles }: typeof SpawnModule = await import(
spawnModuleUrl
);

// pi generates its own `--help` text (see `cli/args.js`'s `printHelp()`)
// from `APP_NAME` alone, with no tagline — print ours first.
if (isHelpRequest(process.argv.slice(2))) {
console.log(`${formatHogBrandBanner(VERSION)}\n`);
}

// Load every harness extension by file path (rather than via
// `extensionFactories`) so each shows its real name in the startup banner
// instead of `<inline:N>`; pi's loader only has a display name to show when
// an extension is loaded from a path.
const extensionArgs = harnessExtensionFiles().flatMap((file) => ["-e", file]);
const extensionArgs = harnessExtensionFiles().flatMap((file: string) => [
"-e",
file,
]);
main([...extensionArgs, ...process.argv.slice(2)]);
118 changes: 118 additions & 0 deletions packages/harness/src/extensions/background-jobs/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBackgroundJobsExtension } from "./extension";
import { __resetBackgroundJobsForTesting, startBackgroundJob } from "./jobs";

type Handler = (event: unknown, ctx: unknown) => Promise<unknown>;

interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
) => Promise<{ content: Array<{ type: string; text?: string }> }>;
}

function fakePi() {
const handlers = new Map<string, Handler[]>();
const tools = new Map<string, RegisteredTool>();
const renderers = new Map<string, unknown>();
const sentMessages: unknown[] = [];
const pi = {
on: (event: string, handler: Handler) => {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
registerTool: (tool: unknown) => {
const t = tool as RegisteredTool;
tools.set(t.name, t);
},
registerMessageRenderer: (customType: string, renderer: unknown) => {
renderers.set(customType, renderer);
},
sendMessage: (message: unknown) => {
sentMessages.push(message);
},
};
return {
pi,
tools,
renderers,
sentMessages,
emit: async (event: string, payload: unknown) => {
for (const handler of handlers.get(event) ?? [])
await handler(payload, undefined);
},
};
}

describe("createBackgroundJobsExtension", () => {
beforeEach(() => {
__resetBackgroundJobsForTesting();
});

it("registers a message renderer for background-job messages", () => {
const { pi, renderers } = fakePi();
createBackgroundJobsExtension()(pi as never);
expect(renderers.has("background-job")).toBe(true);
});

it("list_background_jobs reports no jobs when none are running", async () => {
const { pi, tools } = fakePi();
createBackgroundJobsExtension()(pi as never);
const result = await tools.get("list_background_jobs")?.execute("id", {});
expect(result?.content[0]?.text).toBe("No background jobs running.");
});

it("list_background_jobs reports running jobs by label", async () => {
const { pi, tools } = fakePi();
startBackgroundJob({
pi,
label: "long task",
work: () => new Promise(() => {}),
onSuccess: () => "",
});
createBackgroundJobsExtension()(pi as never);
const result = await tools.get("list_background_jobs")?.execute("id", {});
expect(result?.content[0]?.text).toContain("long task");
});

it("cancel_background_job cancels a known job and reports unknown ids", async () => {
const { pi, tools } = fakePi();
const start = startBackgroundJob({
pi,
label: "cancel me",
work: (signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("aborted")));
}),
onSuccess: () => "",
});
createBackgroundJobsExtension()(pi as never);

const ok = await tools
.get("cancel_background_job")
?.execute("id", { jobId: start.jobId });
expect(ok?.content[0]?.text).toContain("Cancelling job");

const missing = await tools
.get("cancel_background_job")
?.execute("id", { jobId: "nope" });
expect(missing?.content[0]?.text).toContain("No running job");
});

it("aborts every running job on session_shutdown", async () => {
const { pi, emit, sentMessages } = fakePi();
createBackgroundJobsExtension()(pi as never);
startBackgroundJob({
pi,
label: "orphan",
work: (signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("aborted")));
}),
onSuccess: () => "",
});

await emit("session_shutdown", {});
await vi.waitFor(() => expect(sentMessages).toHaveLength(1));
});
});
90 changes: 90 additions & 0 deletions packages/harness/src/extensions/background-jobs/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Registers the two small tools that make background jobs manageable
* (`list_background_jobs`, `cancel_background_job`) plus a consistent
* renderer for the completion/failure messages `startBackgroundJob` sends.
*
* The actual "start a background job" primitive lives in `jobs.ts` as a
* plain function — `subagent` and `workflow` import it directly rather than
* depending on this extension being loaded first. This extension only owns
* the shared, cross-caller surface: cancellation, listing, and cleanup.
*/

import type {
ExtensionAPI,
ExtensionFactory,
} from "@earendil-works/pi-coding-agent";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
BACKGROUND_JOB_MESSAGE_TYPE,
cancelAllBackgroundJobs,
cancelBackgroundJob,
listBackgroundJobs,
} from "./jobs";
import { renderBackgroundJobMessage } from "./render";

export function createBackgroundJobsExtension(): ExtensionFactory {
return (pi) => {
pi.registerMessageRenderer(
BACKGROUND_JOB_MESSAGE_TYPE,
renderBackgroundJobMessage,
);

pi.registerTool(
defineTool({
name: "list_background_jobs",
label: "List Background Jobs",
description:
"List background jobs currently running (started by subagent/workflow calls with background: true). Returns job ids, labels, and how long each has been running.",
promptGuidelines: [
"Use list_background_jobs to check what's still running before starting more background work, or when the user asks for status.",
],
parameters: Type.Object({}),
execute: async () => {
const jobs = listBackgroundJobs();
const text =
jobs.length === 0
? "No background jobs running."
: jobs
.map((job) => {
const seconds = Math.round(
(Date.now() - job.startedAt) / 1000,
);
return `- ${job.jobId}: "${job.label}" (running ${seconds}s)`;
})
.join("\n");
return { content: [{ type: "text", text }], details: { jobs } };
},
}),
);

pi.registerTool(
defineTool({
name: "cancel_background_job",
label: "Cancel Background Job",
description:
"Cancel a running background job by id (from list_background_jobs). The job's own message will report it as cancelled once teardown finishes.",
parameters: Type.Object({
jobId: Type.String({
description: "Job id, from list_background_jobs",
}),
}),
execute: async (_toolCallId, params) => {
const cancelled = cancelBackgroundJob(params.jobId);
const text = cancelled
? `Cancelling job ${params.jobId}.`
: `No running job with id ${params.jobId}.`;
return { content: [{ type: "text", text }], details: { cancelled } };
},
}),
);

pi.on("session_shutdown", async () => {
cancelAllBackgroundJobs();
});
};
}

export default function backgroundJobs(pi: ExtensionAPI): void | Promise<void> {
return createBackgroundJobsExtension()(pi);
}
23 changes: 23 additions & 0 deletions packages/harness/src/extensions/background-jobs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Thin `index.ts` re-export used only as pi's `-e` extension entry point.
//
// pi's startup banner derives an extension's display name from its file
// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the
// parent directory name, so loading this file (instead of `./extension.ts`
// directly) makes the extension show as `background-jobs` instead of
// `background-jobs/extension.js`. `./extension.ts` remains the real
// implementation per the convention in `../README.md`.
export { createBackgroundJobsExtension, default } from "./extension";
export type {
BackgroundJobDetails,
BackgroundJobStart,
BackgroundJobStatus,
BackgroundJobSummary,
StartBackgroundJobOptions,
} from "./jobs";
export {
BACKGROUND_JOB_MESSAGE_TYPE,
cancelAllBackgroundJobs,
cancelBackgroundJob,
listBackgroundJobs,
startBackgroundJob,
} from "./jobs";
Loading
Loading