Skip to content

Commit bda54d8

Browse files
authored
feat(harness): add workflow extension, update agents, update branding (#3386)
1 parent 45f9f0c commit bda54d8

52 files changed

Lines changed: 6503 additions & 148 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/harness/package.json

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"description": "Spawn the pi.dev coding agent (CLI + SDK) against the PostHog LLM gateway with PostHog OAuth",
55
"type": "module",
66
"bin": {
7-
"harness": "dist/cli.js"
7+
"harness": "dist/cli.js",
8+
"hog": "dist/cli.js"
89
},
910
"exports": {
1011
"./cli": {
@@ -55,13 +56,29 @@
5556
"types": "./dist/extensions/subagent/*.d.ts",
5657
"import": "./dist/extensions/subagent/*.js"
5758
},
59+
"./extensions/workflow": {
60+
"types": "./dist/extensions/workflow/extension.d.ts",
61+
"import": "./dist/extensions/workflow/extension.js"
62+
},
63+
"./extensions/workflow/*": {
64+
"types": "./dist/extensions/workflow/*.d.ts",
65+
"import": "./dist/extensions/workflow/*.js"
66+
},
5867
"./extensions/mcp": {
5968
"types": "./dist/extensions/mcp/extension.d.ts",
6069
"import": "./dist/extensions/mcp/extension.js"
6170
},
6271
"./extensions/mcp/*": {
6372
"types": "./dist/extensions/mcp/*.d.ts",
6473
"import": "./dist/extensions/mcp/*.js"
74+
},
75+
"./extensions/footer-focus-demo": {
76+
"types": "./dist/extensions/footer-focus-demo/extension.d.ts",
77+
"import": "./dist/extensions/footer-focus-demo/extension.js"
78+
},
79+
"./extensions/footer-focus-demo/*": {
80+
"types": "./dist/extensions/footer-focus-demo/*.d.ts",
81+
"import": "./dist/extensions/footer-focus-demo/*.js"
6582
}
6683
},
6784
"scripts": {
@@ -73,8 +90,8 @@
7390
},
7491
"dependencies": {
7592
"@earendil-works/pi-ai": "0.80.3",
76-
"@earendil-works/pi-coding-agent": "0.80.3",
77-
"@earendil-works/pi-tui": "0.80.3",
93+
"@earendil-works/pi-coding-agent": "0.80.6",
94+
"@earendil-works/pi-tui": "0.80.6",
7895
"@modelcontextprotocol/sdk": "^1.29.0",
7996
"@posthog/shared": "workspace:*",
8097
"lru-cache": "^11.1.0",

packages/harness/src/cli.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
11
#!/usr/bin/env node
22

3-
import { main } from "@earendil-works/pi-coding-agent";
4-
import { harnessExtensionFiles } from "./spawn";
3+
import {
4+
formatHogBrandBanner,
5+
installHogBrandEnv,
6+
isHelpRequest,
7+
} from "./extensions/hog-branding/brand-env";
8+
// Must run — and finish running — before `@earendil-works/pi-coding-agent`
9+
// is evaluated, so pi picks up "hog" branding when its config module first
10+
// evaluates. `installHogBrandEnv` itself only touches Node builtins, so
11+
// this static import carries no ordering risk; everything below that
12+
// touches pi-coding-agent (directly or transitively, e.g. `./spawn`, which
13+
// pulls in every extension) is loaded dynamically instead of via a static
14+
// import — see `./extensions/hog-branding/brand-env` for why a static
15+
// import here wouldn't reliably run first once bundled, and why `./spawn`
16+
// below is imported by a *computed* (non-literal) specifier: a literal
17+
// `import("./spawn")` is exactly as statically inlinable (and thus exactly
18+
// as unordered) as a static `import`, since bundlers resolve and inline
19+
// literal-specifier dynamic imports when there's no code-splitting. A
20+
// specifier the bundler can't statically resolve forces a genuine runtime
21+
// load of the already-separately-built `dist/spawn.js`.
22+
import type * as SpawnModule from "./spawn";
23+
24+
installHogBrandEnv();
25+
26+
const { main, VERSION } = await import("@earendil-works/pi-coding-agent");
27+
const spawnModuleUrl = new URL("./spawn.js", import.meta.url).href;
28+
const { harnessExtensionFiles }: typeof SpawnModule = await import(
29+
spawnModuleUrl
30+
);
31+
32+
// pi generates its own `--help` text (see `cli/args.js`'s `printHelp()`)
33+
// from `APP_NAME` alone, with no tagline — print ours first.
34+
if (isHelpRequest(process.argv.slice(2))) {
35+
console.log(`${formatHogBrandBanner(VERSION)}\n`);
36+
}
537

638
// Load every harness extension by file path (rather than via
739
// `extensionFactories`) so each shows its real name in the startup banner
840
// instead of `<inline:N>`; pi's loader only has a display name to show when
941
// an extension is loaded from a path.
10-
const extensionArgs = harnessExtensionFiles().flatMap((file) => ["-e", file]);
42+
const extensionArgs = harnessExtensionFiles().flatMap((file: string) => [
43+
"-e",
44+
file,
45+
]);
1146
main([...extensionArgs, ...process.argv.slice(2)]);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { createBackgroundJobsExtension } from "./extension";
3+
import { __resetBackgroundJobsForTesting, startBackgroundJob } from "./jobs";
4+
5+
type Handler = (event: unknown, ctx: unknown) => Promise<unknown>;
6+
7+
interface RegisteredTool {
8+
name: string;
9+
execute: (
10+
toolCallId: string,
11+
params: Record<string, unknown>,
12+
) => Promise<{ content: Array<{ type: string; text?: string }> }>;
13+
}
14+
15+
function fakePi() {
16+
const handlers = new Map<string, Handler[]>();
17+
const tools = new Map<string, RegisteredTool>();
18+
const renderers = new Map<string, unknown>();
19+
const sentMessages: unknown[] = [];
20+
const pi = {
21+
on: (event: string, handler: Handler) => {
22+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
23+
},
24+
registerTool: (tool: unknown) => {
25+
const t = tool as RegisteredTool;
26+
tools.set(t.name, t);
27+
},
28+
registerMessageRenderer: (customType: string, renderer: unknown) => {
29+
renderers.set(customType, renderer);
30+
},
31+
sendMessage: (message: unknown) => {
32+
sentMessages.push(message);
33+
},
34+
};
35+
return {
36+
pi,
37+
tools,
38+
renderers,
39+
sentMessages,
40+
emit: async (event: string, payload: unknown) => {
41+
for (const handler of handlers.get(event) ?? [])
42+
await handler(payload, undefined);
43+
},
44+
};
45+
}
46+
47+
describe("createBackgroundJobsExtension", () => {
48+
beforeEach(() => {
49+
__resetBackgroundJobsForTesting();
50+
});
51+
52+
it("registers a message renderer for background-job messages", () => {
53+
const { pi, renderers } = fakePi();
54+
createBackgroundJobsExtension()(pi as never);
55+
expect(renderers.has("background-job")).toBe(true);
56+
});
57+
58+
it("list_background_jobs reports no jobs when none are running", async () => {
59+
const { pi, tools } = fakePi();
60+
createBackgroundJobsExtension()(pi as never);
61+
const result = await tools.get("list_background_jobs")?.execute("id", {});
62+
expect(result?.content[0]?.text).toBe("No background jobs running.");
63+
});
64+
65+
it("list_background_jobs reports running jobs by label", async () => {
66+
const { pi, tools } = fakePi();
67+
startBackgroundJob({
68+
pi,
69+
label: "long task",
70+
work: () => new Promise(() => {}),
71+
onSuccess: () => "",
72+
});
73+
createBackgroundJobsExtension()(pi as never);
74+
const result = await tools.get("list_background_jobs")?.execute("id", {});
75+
expect(result?.content[0]?.text).toContain("long task");
76+
});
77+
78+
it("cancel_background_job cancels a known job and reports unknown ids", async () => {
79+
const { pi, tools } = fakePi();
80+
const start = startBackgroundJob({
81+
pi,
82+
label: "cancel me",
83+
work: (signal) =>
84+
new Promise((_resolve, reject) => {
85+
signal.addEventListener("abort", () => reject(new Error("aborted")));
86+
}),
87+
onSuccess: () => "",
88+
});
89+
createBackgroundJobsExtension()(pi as never);
90+
91+
const ok = await tools
92+
.get("cancel_background_job")
93+
?.execute("id", { jobId: start.jobId });
94+
expect(ok?.content[0]?.text).toContain("Cancelling job");
95+
96+
const missing = await tools
97+
.get("cancel_background_job")
98+
?.execute("id", { jobId: "nope" });
99+
expect(missing?.content[0]?.text).toContain("No running job");
100+
});
101+
102+
it("aborts every running job on session_shutdown", async () => {
103+
const { pi, emit, sentMessages } = fakePi();
104+
createBackgroundJobsExtension()(pi as never);
105+
startBackgroundJob({
106+
pi,
107+
label: "orphan",
108+
work: (signal) =>
109+
new Promise((_resolve, reject) => {
110+
signal.addEventListener("abort", () => reject(new Error("aborted")));
111+
}),
112+
onSuccess: () => "",
113+
});
114+
115+
await emit("session_shutdown", {});
116+
await vi.waitFor(() => expect(sentMessages).toHaveLength(1));
117+
});
118+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Registers the two small tools that make background jobs manageable
3+
* (`list_background_jobs`, `cancel_background_job`) plus a consistent
4+
* renderer for the completion/failure messages `startBackgroundJob` sends.
5+
*
6+
* The actual "start a background job" primitive lives in `jobs.ts` as a
7+
* plain function — `subagent` and `workflow` import it directly rather than
8+
* depending on this extension being loaded first. This extension only owns
9+
* the shared, cross-caller surface: cancellation, listing, and cleanup.
10+
*/
11+
12+
import type {
13+
ExtensionAPI,
14+
ExtensionFactory,
15+
} from "@earendil-works/pi-coding-agent";
16+
import { defineTool } from "@earendil-works/pi-coding-agent";
17+
import { Type } from "typebox";
18+
import {
19+
BACKGROUND_JOB_MESSAGE_TYPE,
20+
cancelAllBackgroundJobs,
21+
cancelBackgroundJob,
22+
listBackgroundJobs,
23+
} from "./jobs";
24+
import { renderBackgroundJobMessage } from "./render";
25+
26+
export function createBackgroundJobsExtension(): ExtensionFactory {
27+
return (pi) => {
28+
pi.registerMessageRenderer(
29+
BACKGROUND_JOB_MESSAGE_TYPE,
30+
renderBackgroundJobMessage,
31+
);
32+
33+
pi.registerTool(
34+
defineTool({
35+
name: "list_background_jobs",
36+
label: "List Background Jobs",
37+
description:
38+
"List background jobs currently running (started by subagent/workflow calls with background: true). Returns job ids, labels, and how long each has been running.",
39+
promptGuidelines: [
40+
"Use list_background_jobs to check what's still running before starting more background work, or when the user asks for status.",
41+
],
42+
parameters: Type.Object({}),
43+
execute: async () => {
44+
const jobs = listBackgroundJobs();
45+
const text =
46+
jobs.length === 0
47+
? "No background jobs running."
48+
: jobs
49+
.map((job) => {
50+
const seconds = Math.round(
51+
(Date.now() - job.startedAt) / 1000,
52+
);
53+
return `- ${job.jobId}: "${job.label}" (running ${seconds}s)`;
54+
})
55+
.join("\n");
56+
return { content: [{ type: "text", text }], details: { jobs } };
57+
},
58+
}),
59+
);
60+
61+
pi.registerTool(
62+
defineTool({
63+
name: "cancel_background_job",
64+
label: "Cancel Background Job",
65+
description:
66+
"Cancel a running background job by id (from list_background_jobs). The job's own message will report it as cancelled once teardown finishes.",
67+
parameters: Type.Object({
68+
jobId: Type.String({
69+
description: "Job id, from list_background_jobs",
70+
}),
71+
}),
72+
execute: async (_toolCallId, params) => {
73+
const cancelled = cancelBackgroundJob(params.jobId);
74+
const text = cancelled
75+
? `Cancelling job ${params.jobId}.`
76+
: `No running job with id ${params.jobId}.`;
77+
return { content: [{ type: "text", text }], details: { cancelled } };
78+
},
79+
}),
80+
);
81+
82+
pi.on("session_shutdown", async () => {
83+
cancelAllBackgroundJobs();
84+
});
85+
};
86+
}
87+
88+
export default function backgroundJobs(pi: ExtensionAPI): void | Promise<void> {
89+
return createBackgroundJobsExtension()(pi);
90+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Thin `index.ts` re-export used only as pi's `-e` extension entry point.
2+
//
3+
// pi's startup banner derives an extension's display name from its file
4+
// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the
5+
// parent directory name, so loading this file (instead of `./extension.ts`
6+
// directly) makes the extension show as `background-jobs` instead of
7+
// `background-jobs/extension.js`. `./extension.ts` remains the real
8+
// implementation per the convention in `../README.md`.
9+
export { createBackgroundJobsExtension, default } from "./extension";
10+
export type {
11+
BackgroundJobDetails,
12+
BackgroundJobStart,
13+
BackgroundJobStatus,
14+
BackgroundJobSummary,
15+
StartBackgroundJobOptions,
16+
} from "./jobs";
17+
export {
18+
BACKGROUND_JOB_MESSAGE_TYPE,
19+
cancelAllBackgroundJobs,
20+
cancelBackgroundJob,
21+
listBackgroundJobs,
22+
startBackgroundJob,
23+
} from "./jobs";

0 commit comments

Comments
 (0)