@adibacsi/pi-jack 1.0.3 → 1.0.4

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/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ All notable changes to this project are documented here. The format follows
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [1.0.4] - 2026-10-04
12
+
13
+ ### Changed
14
+
15
+ - The extension's source files moved into `src/`, and the package's `main`, `exports` and `pi.extensions` point at
16
+ `src/index.ts`. Pi picks up the new location by itself; nothing changes for an installed package.
17
+
11
18
  ## [1.0.3] - 2026-10-04
12
19
 
13
20
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adibacsi/pi-jack",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "JACK (JSON Agent Contractor Kit): Pi extension for delegating to subagents under a JSON Schema contract, for structured, reliable, and type-safe agent responses.",
5
5
  "keywords": [
6
6
  "pi",
@@ -18,16 +18,13 @@
18
18
  "license": "MIT",
19
19
  "author": "Adam Jakab",
20
20
  "type": "module",
21
- "main": "index.ts",
22
- "exports": "./index.ts",
21
+ "main": "src/index.ts",
22
+ "exports": "./src/index.ts",
23
23
  "engines": {
24
24
  "node": ">=22.19.0"
25
25
  },
26
26
  "files": [
27
- "index.ts",
28
- "agents.ts",
29
- "contract.ts",
30
- "json-schema.ts",
27
+ "src/",
31
28
  "agents/",
32
29
  "prompts/",
33
30
  "CHANGELOG.md"
@@ -38,7 +35,7 @@
38
35
  "pi": {
39
36
  "image": "https://raw.githubusercontent.com/adamjakab/pi-jack/main/docs/logo.png",
40
37
  "extensions": [
41
- "./index.ts"
38
+ "./src/index.ts"
42
39
  ],
43
40
  "prompts": [
44
41
  "./prompts/*.md"
@@ -47,6 +44,7 @@
47
44
  "scripts": {
48
45
  "test": "vitest run",
49
46
  "test:watch": "vitest",
47
+ "coverage": "vitest run --coverage",
50
48
  "test:e2e": "node tests/e2e/run.ts",
51
49
  "typecheck": "node scripts/pi-modules.mjs && tsc -p tsconfig.json",
52
50
  "format": "prettier --write .",
@@ -70,6 +68,7 @@
70
68
  },
71
69
  "devDependencies": {
72
70
  "@types/node": "^26.6.4",
71
+ "@vitest/coverage-v8": "^5.0.3",
73
72
  "prettier": "^3.9.9",
74
73
  "typescript": "^7.0.2",
75
74
  "vitest": "^5.0.3"
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  /** Folder of the agents that ship with this extension. */
17
17
  export const BUILT_IN_AGENTS_DIR = fileURLToPath(
18
- new URL("./agents", import.meta.url),
18
+ new URL("../agents", import.meta.url),
19
19
  );
20
20
 
21
21
  /** Where an agent comes from: this extension, or the user's agents folder. */
@@ -18,22 +18,11 @@
18
18
 
19
19
  import { spawn } from "node:child_process";
20
20
  import * as fs from "node:fs";
21
- import * as os from "node:os";
22
- import * as path from "node:path";
23
21
  import type { Message } from "@earendil-works/pi-ai";
24
- import {
25
- defineTool,
26
- type ExtensionAPI,
27
- withFileMutationQueue,
28
- } from "@earendil-works/pi-coding-agent";
22
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
23
  import { fileURLToPath } from "node:url";
30
24
  import { Type, type TSchema } from "typebox";
31
- import {
32
- type AgentConfig,
33
- type AgentLoadError,
34
- type AgentSource,
35
- discoverAgents,
36
- } from "./agents.ts";
25
+ import { type AgentSource, discoverAgents } from "./agents.ts";
37
26
  import { registerJsonSchema } from "./json-schema.ts";
38
27
  import {
39
28
  DEFAULT_SCHEMA,
@@ -46,6 +35,17 @@ import {
46
35
  type ThinkingLevel,
47
36
  schemaErrors,
48
37
  } from "./contract.ts";
38
+ import {
39
+ describeAgent,
40
+ describeLoadErrors,
41
+ getFinalAssistantText,
42
+ getPiInvocation,
43
+ mapWithLimit,
44
+ normalizeTools,
45
+ shellQuote,
46
+ taskLabel,
47
+ writeTempFiles,
48
+ } from "./utils.ts";
49
49
 
50
50
  export const MAX_PARALLEL = 2;
51
51
 
@@ -58,59 +58,6 @@ const THIS_EXTENSION = fileURLToPath(import.meta.url);
58
58
  /** Agent used when a call omits `agent`. */
59
59
  export const DEFAULT_AGENT = "worker";
60
60
 
61
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
62
- const currentScript = process.argv[1];
63
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
64
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
65
- return { command: process.execPath, args: [currentScript, ...args] };
66
- }
67
-
68
- const execName = path.basename(process.execPath).toLowerCase();
69
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
70
- if (!isGenericRuntime) {
71
- return { command: process.execPath, args };
72
- }
73
-
74
- return { command: "pi", args };
75
- }
76
-
77
- /** Writes the child's schema and, when there is one, its system prompt into a fresh private temp dir. */
78
- async function writeTempFiles(
79
- agentName: string,
80
- schema: TSchema,
81
- prompt: string,
82
- ): Promise<{ dir: string; schemaPath: string; promptPath?: string }> {
83
- const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-jack-"));
84
- const write = (filePath: string, text: string) =>
85
- withFileMutationQueue(filePath, () =>
86
- fs.promises.writeFile(filePath, text, { encoding: "utf-8", mode: 0o600 }),
87
- );
88
-
89
- const schemaPath = path.join(dir, "schema.json");
90
- await write(schemaPath, JSON.stringify(schema));
91
- if (!prompt) return { dir, schemaPath };
92
-
93
- const promptPath = path.join(
94
- dir,
95
- `prompt-${agentName.replace(/[^\w.-]+/g, "_")}.md`,
96
- );
97
- await write(promptPath, prompt);
98
- return { dir, schemaPath, promptPath };
99
- }
100
-
101
- export function normalizeTools(value: unknown): string[] | undefined {
102
- const raw = Array.isArray(value)
103
- ? value
104
- : typeof value === "string"
105
- ? value.split(",")
106
- : [];
107
- const tools = raw
108
- .filter((t): t is string => typeof t === "string")
109
- .map((t) => t.trim())
110
- .filter(Boolean);
111
- return tools.length > 0 ? tools : undefined;
112
- }
113
-
114
61
  interface SubagentUsage {
115
62
  turns: number;
116
63
  input: number;
@@ -142,7 +89,7 @@ interface RanWith {
142
89
  }
143
90
 
144
91
  /** How a subagent was set up once defaults and overrides were applied; shown by `debug_mode`. */
145
- export interface SubagentSetup {
92
+ interface SubagentSetup {
146
93
  agent: string;
147
94
  /** Where the agent file came from; undefined for a bare pi agent (no default agent file). */
148
95
  agentSource?: AgentSource;
@@ -163,11 +110,8 @@ export interface SubagentSetup {
163
110
  command: string[];
164
111
  }
165
112
 
166
- const shellQuote = (arg: string) =>
167
- /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
168
-
169
113
  /** Renders a setup as indented lines, for the progress display and the result. */
170
- export function describeSetup(setup: SubagentSetup): string {
114
+ function describeSetup(setup: SubagentSetup): string {
171
115
  const from = (source: string | undefined) =>
172
116
  source ? ` (from the ${source})` : "";
173
117
  const agent = !setup.agentSource
@@ -191,6 +135,7 @@ export function describeSetup(setup: SubagentSetup): string {
191
135
  .join("\n");
192
136
  }
193
137
 
138
+ /** Builds the optional `thinking` parameter schema: one of THINKING_LEVELS, with the given description. */
194
139
  const thinkingSchema = (description: string) =>
195
140
  Type.Optional(
196
141
  Type.Union(
@@ -250,6 +195,7 @@ const outputSchema = Type.Object({
250
195
  ),
251
196
  });
252
197
 
198
+ /** Returns a fresh usage record with every counter at zero. */
253
199
  const zeroUsage = (): SubagentUsage => ({
254
200
  turns: 0,
255
201
  input: 0,
@@ -259,6 +205,11 @@ const zeroUsage = (): SubagentUsage => ({
259
205
  cost: 0,
260
206
  });
261
207
 
208
+ /**
209
+ * Runs one task in a child pi. Resolves its agent, system prompt, tools, model, thinking level, and schema from the
210
+ * call and the agent file, spawns the child, and turns its outcome into a result. Never throws for a task that fails;
211
+ * the failure is reported in the result's `error`.
212
+ */
262
213
  async function runSingleSubagent(
263
214
  taskText: string,
264
215
  agentNameInput: string | undefined,
@@ -283,6 +234,7 @@ async function runSingleSubagent(
283
234
  let schemaSource: unknown = schemaInput;
284
235
  let schemaBaseDir = process.cwd();
285
236
 
237
+ /** A result for a task that failed before any child was started. */
286
238
  const failure = (error: string): SingleResult => ({
287
239
  success: false,
288
240
  parsed: false,
@@ -416,6 +368,7 @@ async function runSingleSubagent(
416
368
 
417
369
  const usage = zeroUsage();
418
370
  const run = await runChild(args, usage, signal, onProgress);
371
+ /** A result for the finished child: successful when there is no `error`. */
419
372
  const result = (
420
373
  error: string | undefined,
421
374
  data: unknown = null,
@@ -508,6 +461,7 @@ async function runChild(
508
461
  let rejections = 0;
509
462
  let buffer = "";
510
463
 
464
+ /** Handles one JSON event from the child: counts usage, reports progress, and records answers and rejections. */
511
465
  const handleEvent = (event: any) => {
512
466
  if (event.type === "message_end" && event.message) {
513
467
  const msg = event.message as Message;
@@ -595,26 +549,6 @@ async function runChild(
595
549
  return run;
596
550
  }
597
551
 
598
- export async function mapWithLimit<TIn, TOut>(
599
- items: TIn[],
600
- concurrency: number,
601
- fn: (item: TIn, index: number) => Promise<TOut>,
602
- ): Promise<TOut[]> {
603
- if (items.length === 0) return [];
604
- const limit = Math.max(1, Math.min(concurrency, items.length));
605
- const results: TOut[] = new Array(items.length);
606
- let nextIndex = 0;
607
- const workers = Array.from({ length: limit }).map(async () => {
608
- while (true) {
609
- const current = nextIndex++;
610
- if (current >= items.length) return;
611
- results[current] = await fn(items[current], current);
612
- }
613
- });
614
- await Promise.all(workers);
615
- return results;
616
- }
617
-
618
552
  const jackTool = defineTool({
619
553
  name: "jack",
620
554
  label: "JACK",
@@ -697,6 +631,10 @@ const jackTool = defineTool({
697
631
  }),
698
632
  outputSchema,
699
633
 
634
+ /**
635
+ * Runs the call's task, or each of its `tasks` with missing fields taken from the top-level params, streaming
636
+ * per-task progress, and returns every subagent's answer to the model.
637
+ */
700
638
  async execute(_toolCallId, params, signal, onUpdate) {
701
639
  // Build normalized task list
702
640
  const taskItems: Array<{
@@ -744,14 +682,7 @@ const jackTool = defineTool({
744
682
  task: "",
745
683
  error: "Either `task` or `tasks` must be provided.",
746
684
  attempts: 0,
747
- usage: {
748
- turns: 0,
749
- input: 0,
750
- output: 0,
751
- cacheRead: 0,
752
- cacheWrite: 0,
753
- cost: 0,
754
- },
685
+ usage: zeroUsage(),
755
686
  },
756
687
  ],
757
688
  } as any,
@@ -771,6 +702,7 @@ const jackTool = defineTool({
771
702
  );
772
703
  const debug = params.debug_mode === true;
773
704
  const startedAt = Date.now();
705
+ /** Streams how many tasks are done and each task's current status, plus its setup with `debug_mode`. */
774
706
  const reportProgress = () => {
775
707
  const done = status.filter(
776
708
  (s) => s.startsWith("✓") || s.startsWith("✗"),
@@ -868,41 +800,10 @@ const jackTool = defineTool({
868
800
  },
869
801
  });
870
802
 
871
- export function taskLabel(task: string): string {
872
- const firstLine = task.split("\n")[0];
873
- return `${firstLine.slice(0, 60)}${firstLine.length > 60 || firstLine !== task ? "..." : ""}`;
874
- }
875
-
876
- export function getFinalAssistantText(messages: Message[]): string | undefined {
877
- for (let i = messages.length - 1; i >= 0; i--) {
878
- const msg = messages[i];
879
- if (msg.role === "assistant") {
880
- for (const part of msg.content) {
881
- if (part.type === "text") return part.text;
882
- }
883
- }
884
- }
885
- return undefined;
886
- }
887
-
888
- function describeLoadErrors(errors: AgentLoadError[]): string {
889
- return errors
890
- .map(
891
- (e) =>
892
- `${e.file}${e.source === "built-in" ? " [built-in]" : ""} (${e.message})`,
893
- )
894
- .join("; ");
895
- }
896
-
897
- function describeAgent(agent: AgentConfig): string {
898
- const origin = agent.overridesBuiltIn
899
- ? " (yours, overrides built-in)"
900
- : agent.source === "built-in"
901
- ? " (built-in)"
902
- : "";
903
- return `- ${agent.name}${origin}: ${agent.description}`;
904
- }
905
-
803
+ /**
804
+ * The extension's entry point: registers the `--json-schema` flag and its result tools, and the `jack` tool with the
805
+ * available agents listed in its description. Warns at session start about agent files that failed to load.
806
+ */
906
807
  export default function (pi: ExtensionAPI) {
907
808
  registerJsonSchema(pi);
908
809
  // The prompt templates in prompts/ are declared by the `pi.prompts` manifest entry instead of a
package/src/utils.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Helpers for the `jack` tool in index.ts that don't depend on its state: launching pi, temp files, formatting, and
3
+ * concurrency.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import type { Message } from "@earendil-works/pi-ai";
10
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
11
+ import type { TSchema } from "typebox";
12
+ import type { AgentConfig, AgentLoadError } from "./agents.ts";
13
+
14
+ /**
15
+ * Works out how to start a child pi with `args`: through the same runtime and script as this process when it is a
16
+ * script on disk, through this executable when pi is a compiled binary, else through `pi` on the PATH.
17
+ */
18
+ export function getPiInvocation(args: string[]): {
19
+ command: string;
20
+ args: string[];
21
+ } {
22
+ const currentScript = process.argv[1];
23
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
24
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
25
+ return { command: process.execPath, args: [currentScript, ...args] };
26
+ }
27
+
28
+ const execName = path.basename(process.execPath).toLowerCase();
29
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
30
+ if (!isGenericRuntime) {
31
+ return { command: process.execPath, args };
32
+ }
33
+
34
+ return { command: "pi", args };
35
+ }
36
+
37
+ /** Writes the child's schema and, when there is one, its system prompt into a fresh private temp dir. */
38
+ export async function writeTempFiles(
39
+ agentName: string,
40
+ schema: TSchema,
41
+ prompt: string,
42
+ ): Promise<{ dir: string; schemaPath: string; promptPath?: string }> {
43
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-jack-"));
44
+ const write = (filePath: string, text: string) =>
45
+ withFileMutationQueue(filePath, () =>
46
+ fs.promises.writeFile(filePath, text, { encoding: "utf-8", mode: 0o600 }),
47
+ );
48
+
49
+ const schemaPath = path.join(dir, "schema.json");
50
+ await write(schemaPath, JSON.stringify(schema));
51
+ if (!prompt) return { dir, schemaPath };
52
+
53
+ const promptPath = path.join(
54
+ dir,
55
+ `prompt-${agentName.replace(/[^\w.-]+/g, "_")}.md`,
56
+ );
57
+ await write(promptPath, prompt);
58
+ return { dir, schemaPath, promptPath };
59
+ }
60
+
61
+ /**
62
+ * Turns a `tools` parameter, an array or a comma-separated string, into a list of trimmed tool names. Returns
63
+ * undefined when no name is left, meaning pi's default tools.
64
+ */
65
+ export function normalizeTools(value: unknown): string[] | undefined {
66
+ const raw = Array.isArray(value)
67
+ ? value
68
+ : typeof value === "string"
69
+ ? value.split(",")
70
+ : [];
71
+ const tools = raw
72
+ .filter((t): t is string => typeof t === "string")
73
+ .map((t) => t.trim())
74
+ .filter(Boolean);
75
+ return tools.length > 0 ? tools : undefined;
76
+ }
77
+
78
+ /** Quotes a command-line argument for a POSIX shell, leaving it bare when it needs no quoting. */
79
+ export function shellQuote(arg: string): string {
80
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, "'\\''")}'`;
81
+ }
82
+
83
+ /**
84
+ * Runs `fn` over `items` with at most `concurrency` calls in flight, and returns the results in the order of
85
+ * `items`.
86
+ */
87
+ export async function mapWithLimit<TIn, TOut>(
88
+ items: TIn[],
89
+ concurrency: number,
90
+ fn: (item: TIn, index: number) => Promise<TOut>,
91
+ ): Promise<TOut[]> {
92
+ if (items.length === 0) return [];
93
+ const limit = Math.max(1, Math.min(concurrency, items.length));
94
+ const results: TOut[] = new Array(items.length);
95
+ let nextIndex = 0;
96
+ const workers = Array.from({ length: limit }).map(async () => {
97
+ while (true) {
98
+ const current = nextIndex++;
99
+ if (current >= items.length) return;
100
+ results[current] = await fn(items[current], current);
101
+ }
102
+ });
103
+ await Promise.all(workers);
104
+ return results;
105
+ }
106
+
107
+ /** Shortens a task to its first line, at most 60 characters, with "..." when anything was cut. */
108
+ export function taskLabel(task: string): string {
109
+ const firstLine = task.split("\n")[0];
110
+ return `${firstLine.slice(0, 60)}${firstLine.length > 60 || firstLine !== task ? "..." : ""}`;
111
+ }
112
+
113
+ /** Returns the first text part of the last assistant message, or undefined when there is none. */
114
+ export function getFinalAssistantText(messages: Message[]): string | undefined {
115
+ for (let i = messages.length - 1; i >= 0; i--) {
116
+ const msg = messages[i];
117
+ if (msg.role === "assistant") {
118
+ for (const part of msg.content) {
119
+ if (part.type === "text") return part.text;
120
+ }
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /** Lists agent files that failed to load, with their errors, on one line. */
127
+ export function describeLoadErrors(errors: AgentLoadError[]): string {
128
+ return errors
129
+ .map(
130
+ (e) =>
131
+ `${e.file}${e.source === "built-in" ? " [built-in]" : ""} (${e.message})`,
132
+ )
133
+ .join("; ");
134
+ }
135
+
136
+ /** Renders an agent as a bullet line for the tool description: its name, origin, and description. */
137
+ export function describeAgent(agent: AgentConfig): string {
138
+ const origin = agent.overridesBuiltIn
139
+ ? " (yours, overrides built-in)"
140
+ : agent.source === "built-in"
141
+ ? " (built-in)"
142
+ : "";
143
+ return `- ${agent.name}${origin}: ${agent.description}`;
144
+ }
File without changes
File without changes