@matthewlam/pi-worker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/extension/index.ts +334 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Lam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# pi-worker
|
|
2
|
+
|
|
3
|
+
Headless worker helpers for `pi -p`.
|
|
4
|
+
|
|
5
|
+
## Flags
|
|
6
|
+
|
|
7
|
+
- `--last-message-file <path>` writes the final assistant text on `agent_end`.
|
|
8
|
+
- `--result-schema <path>` enables the `report_result` tool and validates tool args against the JSON Schema file. Gotcha: a `-t` allowlist applies to extension tools too — include `report_result` in it (e.g. `-t read,write,bash,report_result`) or the model cannot call the tool and no result file is written.
|
|
9
|
+
- `--result-file <path>` overrides the structured result output path. Without it, `result.json` is written next to `--last-message-file`.
|
|
10
|
+
- `--worker-heartbeat-file <path>` writes a JSON liveness record on `agent_start`, `tool_execution_end`, and `agent_end`.
|
|
11
|
+
- `--worker-trust` answers project trust with `{ "trusted": "yes" }` for this process.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
defineTool,
|
|
6
|
+
type AgentEndEvent,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
type ProjectTrustEventResult,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
13
|
+
|
|
14
|
+
interface JsonSchema {
|
|
15
|
+
readonly type?: string | readonly string[];
|
|
16
|
+
readonly properties?: Record<string, JsonSchema>;
|
|
17
|
+
readonly required?: readonly string[];
|
|
18
|
+
readonly additionalProperties?: boolean | JsonSchema;
|
|
19
|
+
readonly items?: JsonSchema;
|
|
20
|
+
readonly enum?: readonly JsonValue[];
|
|
21
|
+
readonly const?: JsonValue;
|
|
22
|
+
readonly minimum?: number;
|
|
23
|
+
readonly maximum?: number;
|
|
24
|
+
readonly minLength?: number;
|
|
25
|
+
readonly maxLength?: number;
|
|
26
|
+
readonly minItems?: number;
|
|
27
|
+
readonly maxItems?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ValidationResult {
|
|
31
|
+
readonly ok: boolean;
|
|
32
|
+
readonly errors: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WorkerState {
|
|
36
|
+
readonly registeredResultTool: { value: boolean };
|
|
37
|
+
shutdown: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface HeartbeatPayload {
|
|
41
|
+
readonly timestamp: string;
|
|
42
|
+
readonly lastEvent: "agent_start" | "tool_execution_end" | "agent_end";
|
|
43
|
+
readonly toolName?: string;
|
|
44
|
+
readonly tokenTotals?: JsonValue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const REPORT_RESULT_TOOL = "report_result";
|
|
48
|
+
const RESULT_INSTRUCTION_PREFIX = `
|
|
49
|
+
Structured worker result is enabled. Before finishing, you must call report_result exactly once with arguments matching this JSON Schema. Use report_result as the final action after you have enough information to answer.
|
|
50
|
+
|
|
51
|
+
JSON Schema:
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export default function piWorkerExtension(pi: ExtensionAPI) {
|
|
55
|
+
const state: WorkerState = {
|
|
56
|
+
registeredResultTool: { value: false },
|
|
57
|
+
shutdown: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
pi.registerFlag("last-message-file", {
|
|
61
|
+
description: "Write the final assistant message text to this file on agent_end",
|
|
62
|
+
type: "string",
|
|
63
|
+
});
|
|
64
|
+
pi.registerFlag("result-schema", {
|
|
65
|
+
description: "JSON Schema file for the report_result tool arguments",
|
|
66
|
+
type: "string",
|
|
67
|
+
});
|
|
68
|
+
pi.registerFlag("result-file", {
|
|
69
|
+
description: "Path for validated report_result JSON; defaults to result.json next to --last-message-file",
|
|
70
|
+
type: "string",
|
|
71
|
+
});
|
|
72
|
+
pi.registerFlag("worker-heartbeat-file", {
|
|
73
|
+
description: "Write worker liveness JSON on agent_start, tool_execution_end, and agent_end",
|
|
74
|
+
type: "string",
|
|
75
|
+
});
|
|
76
|
+
pi.registerFlag("worker-trust", {
|
|
77
|
+
description: "Trust the current project for this process without prompting",
|
|
78
|
+
type: "boolean",
|
|
79
|
+
default: false,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("project_trust", async (): Promise<ProjectTrustEventResult> => {
|
|
83
|
+
return pi.getFlag("worker-trust") === true ? { trusted: "yes" as const } : { trusted: "undecided" as const };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.on("session_start", async () => {
|
|
87
|
+
state.shutdown = false;
|
|
88
|
+
registerResultToolIfEnabled(pi, state);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.on("session_shutdown", async () => {
|
|
92
|
+
state.shutdown = true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
pi.on("before_agent_start", async (event) => {
|
|
96
|
+
if (!getStringFlag(pi, "result-schema")) return undefined;
|
|
97
|
+
registerResultToolIfEnabled(pi, state);
|
|
98
|
+
const schema = await loadResultSchema(pi);
|
|
99
|
+
return { systemPrompt: event.systemPrompt + RESULT_INSTRUCTION_PREFIX + JSON.stringify(schema, null, 2) };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
103
|
+
await writeHeartbeatIfEnabled(pi, state, ctx, { lastEvent: "agent_start" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
107
|
+
await writeHeartbeatIfEnabled(pi, state, ctx, {
|
|
108
|
+
lastEvent: "tool_execution_end",
|
|
109
|
+
toolName: event.toolName,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
114
|
+
await writeLastMessageIfEnabled(pi, state, event);
|
|
115
|
+
await writeHeartbeatIfEnabled(pi, state, ctx, { lastEvent: "agent_end" });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function registerResultToolIfEnabled(pi: ExtensionAPI, state: WorkerState): void {
|
|
120
|
+
if (state.registeredResultTool.value || !getStringFlag(pi, "result-schema")) return;
|
|
121
|
+
|
|
122
|
+
state.registeredResultTool.value = true;
|
|
123
|
+
pi.registerTool(
|
|
124
|
+
defineTool({
|
|
125
|
+
name: REPORT_RESULT_TOOL,
|
|
126
|
+
label: "Report Result",
|
|
127
|
+
description: "Validate and save the final structured worker result. Call this before finishing when --result-schema is set.",
|
|
128
|
+
promptSnippet: "Validate and save the final structured worker result",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Use report_result before finishing when structured worker result output is enabled.",
|
|
131
|
+
"After calling report_result successfully, do not call report_result again.",
|
|
132
|
+
],
|
|
133
|
+
parameters: Type.Object({}, { additionalProperties: true }),
|
|
134
|
+
executionMode: "sequential",
|
|
135
|
+
async execute(_toolCallId, params) {
|
|
136
|
+
const schema = await loadResultSchema(pi);
|
|
137
|
+
const result = validateJsonSchema(params as JsonValue, schema);
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
throw new Error(`report_result arguments failed schema validation: ${result.errors.join("; ")}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const outputPath = resultFilePath(pi);
|
|
143
|
+
await writeJsonFile(outputPath, params as JsonValue);
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: `Saved structured worker result to ${outputPath}` }],
|
|
146
|
+
details: { path: outputPath, result: params },
|
|
147
|
+
terminate: true,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function writeLastMessageIfEnabled(pi: ExtensionAPI, state: WorkerState, event: AgentEndEvent): Promise<void> {
|
|
155
|
+
if (state.shutdown) return;
|
|
156
|
+
const outputPath = getStringFlag(pi, "last-message-file");
|
|
157
|
+
if (!outputPath) return;
|
|
158
|
+
await writeTextFile(outputPath, finalAssistantText(event));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function writeHeartbeatIfEnabled(
|
|
162
|
+
pi: ExtensionAPI,
|
|
163
|
+
state: WorkerState,
|
|
164
|
+
ctx: ExtensionContext,
|
|
165
|
+
event: Omit<HeartbeatPayload, "timestamp" | "tokenTotals">,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (state.shutdown) return;
|
|
168
|
+
const outputPath = getStringFlag(pi, "worker-heartbeat-file");
|
|
169
|
+
if (!outputPath) return;
|
|
170
|
+
|
|
171
|
+
const tokenTotals = readTokenTotals(ctx);
|
|
172
|
+
const payload: HeartbeatPayload = {
|
|
173
|
+
timestamp: new Date().toISOString(),
|
|
174
|
+
...event,
|
|
175
|
+
...(tokenTotals === undefined ? {} : { tokenTotals }),
|
|
176
|
+
};
|
|
177
|
+
await writeJsonFile(outputPath, payload as unknown as JsonValue);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function finalAssistantText(event: AgentEndEvent): string {
|
|
181
|
+
const assistant = [...event.messages].reverse().find((message) => message.role === "assistant");
|
|
182
|
+
if (!assistant) return "";
|
|
183
|
+
|
|
184
|
+
return assistant.content
|
|
185
|
+
.flatMap((part) => {
|
|
186
|
+
if (typeof part === "string") return [part];
|
|
187
|
+
if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part) {
|
|
188
|
+
return [String(part.text)];
|
|
189
|
+
}
|
|
190
|
+
return [];
|
|
191
|
+
})
|
|
192
|
+
.join("");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadResultSchema(pi: ExtensionAPI): Promise<JsonSchema> {
|
|
196
|
+
const schemaPath = getStringFlag(pi, "result-schema");
|
|
197
|
+
if (!schemaPath) throw new Error("--result-schema is required before report_result can run");
|
|
198
|
+
|
|
199
|
+
const schema = JSON.parse(await readFile(resolve(schemaPath), "utf8")) as unknown;
|
|
200
|
+
if (!isRecord(schema)) throw new Error(`Result schema at ${schemaPath} must be a JSON object`);
|
|
201
|
+
return schema as JsonSchema;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resultFilePath(pi: ExtensionAPI): string {
|
|
205
|
+
const explicit = getStringFlag(pi, "result-file");
|
|
206
|
+
if (explicit) return resolve(explicit);
|
|
207
|
+
|
|
208
|
+
const lastMessageFile = getStringFlag(pi, "last-message-file");
|
|
209
|
+
if (lastMessageFile) return resolve(dirname(lastMessageFile), "result.json");
|
|
210
|
+
|
|
211
|
+
return resolve(process.cwd(), "result.json");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getStringFlag(pi: ExtensionAPI, name: string): string | undefined {
|
|
215
|
+
const value = pi.getFlag(name);
|
|
216
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function writeTextFile(path: string, text: string): Promise<void> {
|
|
220
|
+
await mkdir(dirname(path), { recursive: true });
|
|
221
|
+
await writeFile(path, text, "utf8");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function writeJsonFile(path: string, value: JsonValue): Promise<void> {
|
|
225
|
+
await writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readTokenTotals(ctx: ExtensionContext): JsonValue | undefined {
|
|
229
|
+
const usage = ctx.getContextUsage?.();
|
|
230
|
+
if (!usage || !isRecord(usage)) return undefined;
|
|
231
|
+
return JSON.parse(JSON.stringify(usage)) as JsonValue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function validateJsonSchema(value: JsonValue, schema: JsonSchema): ValidationResult {
|
|
235
|
+
const errors: string[] = [];
|
|
236
|
+
validateAt(value, schema, "$", errors);
|
|
237
|
+
return { ok: errors.length === 0, errors };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateAt(value: JsonValue, schema: JsonSchema, path: string, errors: string[]): void {
|
|
241
|
+
if (schema.const !== undefined && !jsonEqual(value, schema.const)) {
|
|
242
|
+
errors.push(`${path} must equal ${JSON.stringify(schema.const)}`);
|
|
243
|
+
}
|
|
244
|
+
if (schema.enum && !schema.enum.some((allowed) => jsonEqual(value, allowed))) {
|
|
245
|
+
errors.push(`${path} must be one of ${schema.enum.map((allowed) => JSON.stringify(allowed)).join(", ")}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (schema.type && !matchesType(value, schema.type)) {
|
|
249
|
+
errors.push(`${path} must be ${Array.isArray(schema.type) ? schema.type.join(" or ") : schema.type}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (typeof value === "number") {
|
|
254
|
+
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path} must be >= ${schema.minimum}`);
|
|
255
|
+
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path} must be <= ${schema.maximum}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof value === "string") {
|
|
259
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
260
|
+
errors.push(`${path} length must be >= ${schema.minLength}`);
|
|
261
|
+
}
|
|
262
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
263
|
+
errors.push(`${path} length must be <= ${schema.maxLength}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (Array.isArray(value)) {
|
|
268
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
269
|
+
errors.push(`${path} item count must be >= ${schema.minItems}`);
|
|
270
|
+
}
|
|
271
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
272
|
+
errors.push(`${path} item count must be <= ${schema.maxItems}`);
|
|
273
|
+
}
|
|
274
|
+
if (schema.items) {
|
|
275
|
+
value.forEach((item, index) => validateAt(item, schema.items!, `${path}[${index}]`, errors));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isRecord(value)) {
|
|
280
|
+
validateObject(value, schema, path, errors);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateObject(value: Record<string, JsonValue>, schema: JsonSchema, path: string, errors: string[]): void {
|
|
285
|
+
const properties = schema.properties ?? {};
|
|
286
|
+
for (const property of schema.required ?? []) {
|
|
287
|
+
if (!(property in value)) errors.push(`${path}.${property} is required`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
291
|
+
const childSchema = properties[key];
|
|
292
|
+
if (childSchema) {
|
|
293
|
+
validateAt(childValue, childSchema, `${path}.${key}`, errors);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (schema.additionalProperties === false) {
|
|
297
|
+
errors.push(`${path}.${key} is not allowed`);
|
|
298
|
+
} else if (isRecord(schema.additionalProperties)) {
|
|
299
|
+
validateAt(childValue, schema.additionalProperties, `${path}.${key}`, errors);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function matchesType(value: JsonValue, type: string | readonly string[]): boolean {
|
|
305
|
+
const options = Array.isArray(type) ? type : [type];
|
|
306
|
+
return options.some((option) => {
|
|
307
|
+
switch (option) {
|
|
308
|
+
case "array":
|
|
309
|
+
return Array.isArray(value);
|
|
310
|
+
case "boolean":
|
|
311
|
+
return typeof value === "boolean";
|
|
312
|
+
case "integer":
|
|
313
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
314
|
+
case "null":
|
|
315
|
+
return value === null;
|
|
316
|
+
case "number":
|
|
317
|
+
return typeof value === "number";
|
|
318
|
+
case "object":
|
|
319
|
+
return isRecord(value);
|
|
320
|
+
case "string":
|
|
321
|
+
return typeof value === "string";
|
|
322
|
+
default:
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isRecord(value: unknown): value is Record<string, JsonValue> {
|
|
329
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function jsonEqual(left: JsonValue, right: JsonValue): boolean {
|
|
333
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
334
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@matthewlam/pi-worker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless worker utilities for pi print-mode agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"worker",
|
|
10
|
+
"headless",
|
|
11
|
+
"automation"
|
|
12
|
+
],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./extension/index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
20
|
+
"test": "npm run typecheck && node test/worker-extension-smoke.mjs"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-ai": "*",
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
29
|
+
"@types/node": "^24.0.0",
|
|
30
|
+
"jiti": "^2.7.0",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"extension",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "minghinmatthewlam",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/minghinmatthewlam/pi-worker.git"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|