td-barrage 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # td-barrage
2
2
 
3
- A small TypeScript queue runner for OpenCode tasks. It loads a JSON task file, resolves dependencies, runs ready tasks through the OpenCode SDK, persists state after every transition, and resumes interrupted work on the next invocation.
3
+ A persistent, dependency-aware task queue for the pi coding agent. It loads a JSON task file, runs ready tasks through pi, saves state after every transition, and resumes interrupted work on the next invocation.
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Node.js 20.11 or newer
7
+ - Node.js 22.19 or newer
8
8
  - npm
9
- - OpenCode available when running against a real server
9
+ - pi authenticated with a model provider (`pi`, then `/login`), or a supported provider API key in the environment
10
10
 
11
11
  ## Install
12
12
 
@@ -62,7 +62,7 @@ Task files use a top-level `tasks` array. A bare array is also accepted when loa
62
62
  Supported task fields:
63
63
 
64
64
  - `id`: unique task id
65
- - `prompt`: prompt sent to OpenCode
65
+ - `prompt`: prompt sent to pi
66
66
  - `dependsOn`: dependency ids
67
67
  - `status`: `pending`, `running`, `done`, `failed`, or `blocked`
68
68
  - `attempts`: persisted attempt counter
@@ -98,6 +98,13 @@ Exit codes:
98
98
  - `2`: CLI usage or missing task-file error
99
99
  - `75`: the run paused on a provider quota / rate-limit wall (see below)
100
100
 
101
+ ## Pi sessions
102
+
103
+ Each task runs in its own in-memory pi agent session scoped to the task working
104
+ directory. Barrage uses pi's existing authentication and model settings. Terminal
105
+ provider errors from pi's final assistant state feed into the pause/resume flow
106
+ below. Sessions are disposed after their task prompt settles.
107
+
101
108
  ## Pausing on quota / rate limits
102
109
 
103
110
  Running out of provider tokens is treated as a property of the run, not of any
@@ -111,12 +118,13 @@ task. When a prompt fails with a quota, rate-limit, or overload signal (HTTP
111
118
  same wall, and
112
119
  - exits `75` (`EX_TEMPFAIL`) with the reason on stderr.
113
120
 
114
- Because state is persisted atomically after every transition, resuming is just
115
- re-running the same command once tokens are back work picks up where it left
116
- off, with no failed or blocked tasks recorded from the pause. The distinct exit
117
- code lets a wrapper or scheduler tell "try again later" apart from `1` ("something
118
- is broken"). A `Retry-After` header, when present, is surfaced as a suggested
119
- wait in the pause message.
121
+ Because task state and workspace changes are persisted, resuming is just
122
+ re-running the same command once tokens are back. The pending task starts a fresh
123
+ in-memory pi session and can inspect the work already on disk; conversation
124
+ history is not restored. No failed or blocked task is recorded for the pause.
125
+ The distinct exit code lets a wrapper or scheduler tell "try again later" apart
126
+ from `1` ("something is broken"). A `Retry-After` header, when present, is
127
+ surfaced as a suggested wait in the pause message.
120
128
 
121
129
  ## Telemetry & output
122
130
 
@@ -128,7 +136,7 @@ are always reported, along with a closing summary and total elapsed time.
128
136
 
129
137
  Verbosity flags:
130
138
 
131
- - `--verbose, -v`: add per-step traces — the OpenCode session id and the
139
+ - `--verbose, -v`: add per-step traces — the agent session id and the
132
140
  success-check result for every task
133
141
  - `--quiet, -q`: show only failures, blocks, and the final summary
134
142
  - `--timestamps`: prefix each line with a wall-clock time
@@ -176,16 +184,14 @@ Integration tests are excluded by default. Run them with:
176
184
  RUN_INTEGRATION=1 npm run test:integration
177
185
  ```
178
186
 
179
- Optional integration environment variables:
180
-
181
- - `OPENCODE_BIN`: OpenCode executable, default `opencode`
182
- - `OPENCODE_PORT`: server port, default `4096`
187
+ The integration test runs real pi sessions using your configured authentication
188
+ and model, so it may consume provider credits.
183
189
 
184
190
  ## Architecture
185
191
 
186
192
  The orchestrator accepts its dependencies as arguments:
187
193
 
188
- - `client`: OpenCode client seam
194
+ - `client`: pi-compatible agent client seam
189
195
  - `fs`: filesystem seam
190
196
  - `clock`: timer seam for timeout tests
191
197
  - `log`: human or JSON event logger
package/dist/client.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { OpenCodeClient } from "./orchestrator.js";
2
- export interface ClientOptions {
3
- baseUrl?: string;
4
- hostname?: string;
5
- port?: number;
6
- timeout?: number;
7
- config?: unknown;
8
- }
9
- /** Imports an SDK module by specifier. Injectable so the export-selection logic is testable. */
1
+ import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentClient } from "./orchestrator.js";
3
+ /** Pi session settings Barrage allows callers to override. */
4
+ export type PiClientOptions = Pick<CreateAgentSessionOptions, "model" | "tools" | "excludeTools" | "noTools" | "thinkingLevel">;
5
+ /** Imports an SDK module by specifier. Injectable so the lazy wiring is testable. */
10
6
  export type SdkImport = (specifier: string) => Promise<unknown>;
11
- export declare function createOpenCodeClient(options?: ClientOptions, importSdk?: SdkImport): Promise<OpenCodeClient>;
7
+ /**
8
+ * Builds an orchestrator client backed by pi (`@earendil-works/pi-coding-agent`).
9
+ * Each task gets a one-shot in-memory session scoped to its working directory.
10
+ */
11
+ export declare function createPiClient(options?: PiClientOptions, importSdk?: SdkImport): Promise<AgentClient>;
package/dist/client.js CHANGED
@@ -1,98 +1,81 @@
1
- // A `new Function` indirection keeps TypeScript from rewriting this dynamic
2
- // import into a require during transpile, so the specifier resolves at runtime.
3
- const defaultImport = new Function("specifier", "return import(specifier)");
4
- export async function createOpenCodeClient(options = {}, importSdk = defaultImport) {
5
- const sdk = (await importSdkWithFallback(importSdk));
6
- // A bare client only works when we already know where the server lives.
7
- // Without a baseUrl it would issue requests against relative URLs like
8
- // "/session?directory=..." and fail with "Failed to parse URL".
9
- if (options.baseUrl && typeof sdk.createOpencodeClient === "function") {
10
- return wrapSdkClient(sdk.createOpencodeClient({ ...options, throwOnError: true }));
1
+ const defaultImport = (specifier) => import(specifier);
2
+ const PI_SPECIFIER = "@earendil-works/pi-coding-agent";
3
+ /**
4
+ * Builds an orchestrator client backed by pi (`@earendil-works/pi-coding-agent`).
5
+ * Each task gets a one-shot in-memory session scoped to its working directory.
6
+ */
7
+ export async function createPiClient(options = {}, importSdk = defaultImport) {
8
+ const sdk = (await importSdk(PI_SPECIFIER));
9
+ if (typeof sdk.createAgentSession !== "function") {
10
+ throw new Error("Unable to locate the pi createAgentSession export.");
11
11
  }
12
- // No baseUrl: spawn a local server and use the client it wires up for us.
13
- if (typeof sdk.createOpencode === "function") {
14
- const created = (await sdk.createOpencode(options));
15
- if (created.client) {
16
- registerServerShutdown(created.server);
17
- return wrapSdkClient(created.client);
18
- }
12
+ if (typeof sdk.SessionManager?.inMemory !== "function") {
13
+ throw new Error("Unable to locate the pi SessionManager.inMemory export.");
19
14
  }
20
- if (typeof sdk.createOpencodeClient === "function") {
21
- return wrapSdkClient(sdk.createOpencodeClient({ ...options, throwOnError: true }));
22
- }
23
- const Client = (sdk.OpenCode ?? sdk.Opencode ?? sdk.Client ?? sdk.default);
24
- if (!Client) {
25
- throw new Error("Unable to locate an OpenCode SDK client export.");
26
- }
27
- return new Client(options);
28
- }
29
- function registerServerShutdown(server) {
30
- if (typeof server?.close !== "function")
31
- return;
32
- let closed = false;
33
- const close = () => {
34
- if (closed)
35
- return;
36
- closed = true;
37
- server.close?.();
38
- };
39
- process.once("exit", close);
40
- process.once("SIGINT", () => {
41
- close();
42
- process.exit(130);
43
- });
44
- process.once("SIGTERM", () => {
45
- close();
46
- process.exit(143);
47
- });
48
- }
49
- function wrapSdkClient(client) {
50
- const sessionDirs = new Map();
51
- const raw = client;
15
+ const createAgentSession = sdk.createAgentSession;
16
+ const SessionManager = sdk.SessionManager;
17
+ const sessions = new Map();
52
18
  return {
53
19
  session: {
54
- create: async (input) => {
55
- const result = unwrapData(await raw.session.create({ directory: input.cwd, title: "orchestrator task" }));
56
- const id = result.id;
57
- if (typeof id !== "string" || id.length === 0) {
58
- throw new Error("OpenCode SDK session.create did not return a session id.");
59
- }
60
- sessionDirs.set(id, input.cwd);
61
- return { id };
20
+ create: async ({ cwd }) => {
21
+ const sessionOptions = {
22
+ ...options,
23
+ cwd,
24
+ sessionManager: SessionManager.inMemory(cwd),
25
+ };
26
+ const { session } = await createAgentSession(sessionOptions);
27
+ sessions.set(session.sessionId, session);
28
+ return { id: session.sessionId };
62
29
  },
63
30
  prompt: async (sessionId, input) => {
64
- const directory = sessionDirs.get(sessionId) ?? process.cwd();
65
- const abort = async () => {
66
- await raw.session.abort?.({ sessionID: sessionId, directory });
67
- };
68
- if (input.signal?.aborted)
69
- await abort();
70
- input.signal?.addEventListener("abort", abort, { once: true });
31
+ const session = sessions.get(sessionId);
32
+ if (!session)
33
+ throw new Error(`Unknown pi session: ${sessionId}`);
71
34
  try {
72
- return await raw.session.prompt({
73
- sessionID: sessionId,
74
- directory,
75
- parts: [{ type: "text", text: input.prompt }],
76
- }, { signal: input.signal });
35
+ return await runPrompt(session, input.prompt, input.signal);
77
36
  }
78
37
  finally {
79
- input.signal?.removeEventListener("abort", abort);
38
+ sessions.delete(sessionId);
39
+ // Pi documents dispose() as best-effort cleanup, and its implementation
40
+ // deliberately does not throw. Keep that guarantee at the adapter seam.
41
+ try {
42
+ session.dispose();
43
+ }
44
+ catch {
45
+ // Cleanup must not replace the prompt result or provider error.
46
+ }
80
47
  }
81
48
  },
82
49
  },
83
50
  };
84
51
  }
85
- function unwrapData(value) {
86
- if (value && typeof value === "object" && "data" in value) {
87
- return value.data;
88
- }
89
- return value;
90
- }
91
- async function importSdkWithFallback(importSdk) {
52
+ async function runPrompt(session, prompt, signal) {
53
+ // Aborting an idle Pi session is a no-op, so never start work that its caller
54
+ // has already cancelled.
55
+ if (signal?.aborted)
56
+ throw abortError(signal);
57
+ const abort = () => {
58
+ void session.abort().catch(() => {
59
+ // The timeout/cancellation error belongs to the caller. An abort cleanup
60
+ // failure must not become an unhandled rejection or mask that error.
61
+ });
62
+ };
63
+ signal?.addEventListener("abort", abort, { once: true });
92
64
  try {
93
- return await importSdk("@opencode-ai/sdk/v2");
65
+ await session.prompt(prompt);
66
+ }
67
+ finally {
68
+ signal?.removeEventListener("abort", abort);
94
69
  }
95
- catch {
96
- return importSdk("@opencode-ai/sdk");
70
+ // Pi encodes provider/runtime failures in the terminal assistant message and
71
+ // resolves prompt(). Its state exposes only the final error, after Pi's own
72
+ // retry loop has settled, so successful retries are not misclassified.
73
+ if (session.state.errorMessage) {
74
+ throw new Error(session.state.errorMessage);
97
75
  }
98
76
  }
77
+ function abortError(signal) {
78
+ if (signal.reason instanceof Error)
79
+ return signal.reason;
80
+ return new DOMException("The operation was aborted.", "AbortError");
81
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import { type GitCommitter } from "./git.js";
4
- import { type OpenCodeClient } from "./orchestrator.js";
4
+ import { type AgentClient } from "./orchestrator.js";
5
5
  export interface MainDeps {
6
6
  fs?: typeof fs;
7
- client?: OpenCodeClient;
7
+ client?: AgentClient;
8
8
  git?: GitCommitter;
9
9
  stdout?: Pick<NodeJS.WritableStream, "write">;
10
10
  stderr?: Pick<NodeJS.WritableStream, "write">;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
5
5
  import process from "node:process";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
- import { createOpenCodeClient } from "./client.js";
8
+ import { createPiClient } from "./client.js";
9
9
  import { topologicalOrder } from "./deps.js";
10
10
  import { createGitCommitter } from "./git.js";
11
11
  import { createLogger } from "./log.js";
@@ -61,7 +61,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
61
61
  logger.event({ type: "dry_run", taskIds: ordered.map((task) => task.id) });
62
62
  return 0;
63
63
  }
64
- const client = deps.client ?? (await createOpenCodeClient());
64
+ const client = deps.client ?? (await createPiClient());
65
65
  const git = parsed.values["no-commit"] ? undefined : deps.git ?? createGitCommitter();
66
66
  const summary = await runOrchestrator({
67
67
  tasksPath,
@@ -4,7 +4,7 @@ import { type TaskFs } from "./tasks.js";
4
4
  import type { Logger } from "./log.js";
5
5
  export declare const DEFAULT_TIMEOUT_MS: number;
6
6
  export declare const DEFAULT_MAX_ATTEMPTS = 2;
7
- export interface OpenCodeClient {
7
+ export interface AgentClient {
8
8
  session: {
9
9
  create(input: {
10
10
  cwd: string;
@@ -26,7 +26,7 @@ export interface OrchestratorFs extends TaskFs, SuccessFs {
26
26
  }
27
27
  export interface OrchestratorOptions {
28
28
  tasksPath: string;
29
- client: OpenCodeClient;
29
+ client: AgentClient;
30
30
  fs?: OrchestratorFs;
31
31
  clock?: Clock;
32
32
  log?: Logger;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "td-barrage",
3
- "version": "0.1.4",
4
- "description": "TypeScript queue runner that loads a JSON task file, resolves dependencies, runs tasks through the OpenCode SDK, and resumes interrupted work.",
3
+ "version": "0.1.5",
4
+ "description": "A persistent, dependency-aware task queue for the pi coding agent.",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=20.11"
7
+ "node": ">=22.19"
8
8
  },
9
9
  "bin": {
10
10
  "td-barrage": "dist/index.js"
@@ -24,7 +24,8 @@
24
24
  "access": "public"
25
25
  },
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.json",
27
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
28
+ "build": "npm run clean && tsc -p tsconfig.json",
28
29
  "start": "node dist/index.js",
29
30
  "dev": "tsx src/index.ts",
30
31
  "test": "vitest run",
@@ -35,11 +36,11 @@
35
36
  },
36
37
  "dependencies": {
37
38
  "@clack/prompts": "^1.4.0",
38
- "@opencode-ai/sdk": "^1.15.10",
39
+ "@earendil-works/pi-coding-agent": "^0.85.1",
39
40
  "zod": "^3.25.0"
40
41
  },
41
42
  "devDependencies": {
42
- "@types/node": "^20.11.0",
43
+ "@types/node": "^22.19.0",
43
44
  "tsx": "^4.19.0",
44
45
  "typescript": "^5.8.0",
45
46
  "vitest": "^3.1.0"
package/dist/server.d.ts DELETED
@@ -1,18 +0,0 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
2
- export interface StartServerOptions {
3
- command?: string;
4
- args?: string[];
5
- port: number;
6
- host?: string;
7
- timeoutMs?: number;
8
- pollMs?: number;
9
- spawnProcess?: typeof spawn;
10
- portOpen?: (host: string, port: number) => Promise<boolean>;
11
- sleep?: (ms: number) => Promise<void>;
12
- now?: () => number;
13
- }
14
- export interface StopServerOptions {
15
- graceMs?: number;
16
- }
17
- export declare function startServer(options: StartServerOptions): Promise<ChildProcess>;
18
- export declare function stopServer(child: Pick<ChildProcess, "kill" | "killed" | "exitCode" | "once">, options?: StopServerOptions): Promise<void>;
package/dist/server.js DELETED
@@ -1,64 +0,0 @@
1
- // Integration-test harness for standing up a real `opencode serve` at a known
2
- // port. Production does not use this: createOpenCodeClient (client.ts) lets the
3
- // SDK's createOpencode spawn its own ephemeral server. These helpers exist so the
4
- // integration test can connect a client to an explicit baseUrl.
5
- import { spawn } from "node:child_process";
6
- import net from "node:net";
7
- export async function startServer(options) {
8
- const command = options.command ?? "opencode";
9
- const args = options.args ?? ["serve", "--port", String(options.port)];
10
- const host = options.host ?? "127.0.0.1";
11
- const timeoutMs = options.timeoutMs ?? 30_000;
12
- const pollMs = options.pollMs ?? 100;
13
- const portOpen = options.portOpen ?? isPortOpen;
14
- const wait = options.sleep ?? sleep;
15
- const now = options.now ?? Date.now;
16
- const child = (options.spawnProcess ?? spawn)(command, args, {
17
- stdio: "ignore",
18
- detached: false,
19
- });
20
- const startedAt = now();
21
- while (now() - startedAt < timeoutMs) {
22
- if (await portOpen(host, options.port))
23
- return child;
24
- if (child.exitCode !== null)
25
- throw new Error(`Server exited before port ${options.port} opened.`);
26
- await wait(pollMs);
27
- }
28
- child.kill("SIGTERM");
29
- throw new Error(`Timed out waiting for ${host}:${options.port}.`);
30
- }
31
- export async function stopServer(child, options = {}) {
32
- const graceMs = options.graceMs ?? 5_000;
33
- if (child.exitCode !== null)
34
- return;
35
- child.kill("SIGTERM");
36
- await new Promise((resolve) => {
37
- const timeout = setTimeout(() => {
38
- if (child.exitCode === null)
39
- child.kill("SIGKILL");
40
- resolve();
41
- }, graceMs);
42
- child.once("exit", () => {
43
- clearTimeout(timeout);
44
- resolve();
45
- });
46
- });
47
- }
48
- async function isPortOpen(host, port) {
49
- return new Promise((resolve) => {
50
- const socket = net.createConnection({ host, port });
51
- socket.once("connect", () => {
52
- socket.destroy();
53
- resolve(true);
54
- });
55
- socket.once("error", () => resolve(false));
56
- socket.setTimeout(250, () => {
57
- socket.destroy();
58
- resolve(false);
59
- });
60
- });
61
- }
62
- function sleep(ms) {
63
- return new Promise((resolve) => setTimeout(resolve, ms));
64
- }