next-issue 1.1.0 → 2.0.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # next-issue
2
2
 
3
3
  A small harness that works through the open issues of a GitHub repository with
4
- the Claude Agent SDK. Run it inside a git repository.
4
+ the [pi](https://github.com/earendil-works/pi) SDK. Run it inside a git repository.
5
5
 
6
6
  ```bash
7
7
  npx -y next-issue@latest
@@ -54,7 +54,7 @@ and exits with code 1. For each issue:
54
54
  reviewer reads files and runs commands in the worktree, so it can prove a
55
55
  finding with the tests or the type check. The harness then throws away every
56
56
  change that the reviewer left, so no such change can reach a later commit.
57
- The reviewer returns a structured verdict: a summary and one entry per
57
+ The reviewer returns its verdict with a `submit_result` tool: a summary and one entry per
58
58
  finding, each rated `blocking` or `minor`. The findings are the only ground
59
59
  truth. The reviewer gives no approve or reject field, because such a field
60
60
  can contradict the findings. The harness puts the result on the pull
@@ -146,14 +146,14 @@ Press `Ctrl-C` to stop the run step by step:
146
146
  one checks round (wait, then fix) or one review round (review, then fix). The
147
147
  issue ends as `stopped`. Its state, worktree and labels stay, so the next run
148
148
  carries on from that point.
149
- 3. The third press kills the command that runs and every child of it, so the run
150
- dies in the middle of a step. The saved state lets the next run continue the
149
+ 3. The third press stops every agent, kills the command that runs and every
150
+ child of it, so the run dies in the middle of a step. The saved state lets the next run continue the
151
151
  issue, but the issue keeps `status:in-progress` and the run writes no
152
152
  summary.
153
153
 
154
154
  Each of the first two presses prints a line that says what happens next. The
155
- agents and commands run in a process group of their own, so a press reaches only
156
- the harness.
155
+ agents run inside the harness, and their commands and the harness commands run
156
+ in a process group of their own, so a press reaches only the harness.
157
157
 
158
158
  A status label that the harness puts on the issue goes on the pull request too,
159
159
  but only if the pull request exists at that moment. The `status:in-progress`
@@ -202,10 +202,9 @@ Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
202
202
  `push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
203
203
  `.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
204
204
  is recorded with its exit code and duration. A `tool` event holds the tool
205
- name, and for a `Bash` tool the command too. Each agent call adds a `usage`
205
+ name, and for a `bash` tool the command too. Each agent call adds a `usage`
206
206
  event with the tokens, the cost estimate in dollars, the turn count, the
207
- tool-call count, the model and the session id, so you can open the session
208
- again and read the full transcript.
207
+ tool-call count, the model and the session id.
209
208
 
210
209
  ```bash
211
210
  jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
@@ -224,8 +223,11 @@ Three levels of console output:
224
223
 
225
224
  - Node 24 or later
226
225
  - `gh`, authenticated with write access to the repository
227
- - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
228
- Claude subscription login
226
+ - pi, logged in to a model provider. The agents use the model, the thinking
227
+ level and the extensions from your pi settings, except the `pi-next-issue`
228
+ extension. The implementer and the fixer get the `read`, `bash`, `edit`,
229
+ `write`, `grep`, `find` and `ls` tools, and the reviewer gets all of them
230
+ except `edit` and `write`.
229
231
 
230
232
  ## Configuration
231
233
 
@@ -297,7 +299,7 @@ does not know all stop the run, so a typo cannot pass without a word.
297
299
  | `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
298
300
  | `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
299
301
  | `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
300
- | `models` | `null` per role | The model per agent role: `implementer`, `reviewer`, `fixer` |
302
+ | `models` | `null` per role | The pi model per agent role, as `provider/model`: `implementer`, `reviewer`, `fixer` |
301
303
  | `labels` | see above | The names of the labels that the harness reads and sets |
302
304
 
303
305
  An empty `labels.ready` turns the ready requirement off. The harness then
@@ -307,7 +309,7 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
307
309
  `in-flight` or `blocked`, the last one with the open blockers.
308
310
 
309
311
  A role set to `null`, or with no entry in `models`, uses the default model of
310
- the agent.
312
+ your pi settings.
311
313
 
312
314
  Without a `setupCommand`, the harness looks in the new worktree for a lock file
313
315
  and runs the install command that goes with it:
package/dist/agents.mjs CHANGED
@@ -1,186 +1,147 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk";
2
- import { spawn } from "node:child_process";
3
- import { once } from "node:events";
4
- import { setTimeout as delay } from "node:timers/promises";
5
- import { track } from "./exec.mjs";
1
+ import { join, sep } from "node:path";
2
+ import { createAgentSession, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
3
+ import { onStop } from "./exec.mjs";
6
4
  import { emptyUsage } from "./observe.mjs";
7
5
  const REVIEWING = {
8
- tools: ["Read", "Grep", "Glob", "Bash"],
9
- permissionMode: "dontAsk",
6
+ tools: ["read", "bash", "grep", "find", "ls"],
10
7
  };
11
8
  const CODING = {
12
- tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
13
- permissionMode: "bypassPermissions",
14
- allowDangerouslySkipPermissions: true,
9
+ tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
15
10
  };
16
11
  export { CODING, REVIEWING };
17
- const STDERR_TAIL = 2000;
18
- const STDERR_GRACE_MS = 500;
12
+ export const SUBMIT_TOOL = "submit_result";
13
+ export function isOwnExtension(path) {
14
+ return path.split(sep).includes("pi-next-issue");
15
+ }
19
16
  export async function runAgent(recorder, request) {
20
17
  const log = recorder.scope({ role: request.name });
21
- const usage = emptyUsage();
22
- const toolNames = new Map();
23
- let text = "";
24
- let toolCalls = 0;
25
- let turns = 0;
26
- let costUsd = 0;
27
- let model = request.model;
28
- let sessionId;
29
- let structured;
30
- let stderr = "";
31
- let stderrClosed = Promise.resolve();
18
+ const runtime = request.runtime ?? {};
19
+ const agentDir = runtime.agentDir ?? getAgentDir();
32
20
  const started = Date.now();
33
21
  log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
34
- const controller = new AbortController();
22
+ let structured;
23
+ const customTools = [];
24
+ if (request.outputSchema !== undefined) {
25
+ customTools.push(defineTool({
26
+ name: SUBMIT_TOOL,
27
+ label: "Submit result",
28
+ description: "Give the result. Call it once, as your last action.",
29
+ parameters: request.outputSchema,
30
+ async execute(_toolCallId, params) {
31
+ structured = params;
32
+ return {
33
+ content: [{ type: "text", text: "Result saved." }],
34
+ details: {},
35
+ terminate: true,
36
+ };
37
+ },
38
+ }));
39
+ }
40
+ const resourceLoader = new DefaultResourceLoader({
41
+ cwd: request.cwd,
42
+ agentDir,
43
+ extensionsOverride: (base) => ({
44
+ ...base,
45
+ extensions: base.extensions.filter((extension) => !isOwnExtension(extension.resolvedPath)),
46
+ }),
47
+ });
48
+ await resourceLoader.reload();
49
+ const modelRuntime = runtime.modelRuntime ?? (await ModelRuntime.create({
50
+ authPath: join(agentDir, "auth.json"),
51
+ modelsPath: join(agentDir, "models.json"),
52
+ }));
53
+ const model = request.model === undefined ? undefined : await findModel(modelRuntime, request.model);
54
+ const { session, modelFallbackMessage } = await createAgentSession({
55
+ cwd: request.cwd,
56
+ agentDir,
57
+ resourceLoader,
58
+ sessionManager: SessionManager.inMemory(request.cwd),
59
+ tools: [...request.profile.tools, ...customTools.map((tool) => tool.name)],
60
+ customTools,
61
+ modelRuntime,
62
+ ...(runtime.settingsManager === undefined ? {} : { settingsManager: runtime.settingsManager }),
63
+ ...(model === undefined ? {} : { model }),
64
+ });
65
+ if (modelFallbackMessage !== undefined) {
66
+ log.event("agent.model", { note: modelFallbackMessage }, "quiet");
67
+ }
68
+ let timedOut = false;
35
69
  const timer = request.timeoutMs === undefined
36
70
  ? undefined
37
- : setTimeout(() => controller.abort(), request.timeoutMs);
38
- const late = () => new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
71
+ : setTimeout(() => {
72
+ timedOut = true;
73
+ void session.abort();
74
+ }, request.timeoutMs);
75
+ const unsubscribe = session.subscribe((event) => observe(log, event));
76
+ const release = onStop(() => void session.abort());
39
77
  try {
40
- const stream = query({
41
- prompt: request.prompt,
42
- options: {
43
- cwd: request.cwd,
44
- model: request.model,
45
- tools: request.profile.tools,
46
- allowedTools: request.profile.tools,
47
- permissionMode: request.profile.permissionMode,
48
- allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
49
- systemPrompt: { type: "preset", preset: "claude_code" },
50
- abortController: controller,
51
- pathToClaudeCodeExecutable: request.executable,
52
- spawnClaudeCodeProcess: (options) => {
53
- const child = spawnClaude(options, (chunk) => {
54
- stderr = (stderr + chunk).slice(-STDERR_TAIL);
55
- });
56
- stderrClosed = once(child.stderr, "close").catch(() => undefined);
57
- return child;
58
- },
59
- outputFormat: request.outputSchema === undefined
60
- ? undefined
61
- : { type: "json_schema", schema: request.outputSchema },
62
- },
63
- });
64
- for await (const message of stream) {
65
- if (message.type === "assistant") {
66
- for (const block of message.message.content) {
67
- if (block.type === "tool_use") {
68
- toolCalls += 1;
69
- toolNames.set(block.id, block.name);
70
- log.event("tool", toolFields(block.name, block.input));
71
- }
72
- if (block.type === "text") {
73
- text += block.text;
74
- if (log.level === "verbose") {
75
- process.stderr.write(block.text);
76
- }
77
- }
78
- }
79
- }
80
- if (message.type === "user" && Array.isArray(message.message.content)) {
81
- for (const block of message.message.content) {
82
- if (block.type === "tool_result" && block.is_error === true) {
83
- log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
84
- }
85
- }
86
- }
87
- if (message.type === "result") {
88
- sessionId = message.session_id;
89
- turns = message.num_turns;
90
- costUsd = message.total_cost_usd;
91
- model = main(message.modelUsage) ?? model;
92
- for (const entry of Object.values(message.modelUsage)) {
93
- usage.input += entry.inputTokens;
94
- usage.output += entry.outputTokens;
95
- usage.cacheRead += entry.cacheReadInputTokens;
96
- usage.cacheWrite += entry.cacheCreationInputTokens;
97
- usage.total +=
98
- entry.inputTokens +
99
- entry.outputTokens +
100
- entry.cacheReadInputTokens +
101
- entry.cacheCreationInputTokens;
102
- }
103
- if (message.subtype === "error_during_execution") {
104
- throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
105
- }
106
- if (message.subtype === "success") {
107
- text = message.result.length > 0 ? message.result : text;
108
- structured = message.structured_output;
109
- }
110
- else {
111
- log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
112
- }
113
- }
78
+ await session.bindExtensions({});
79
+ await session.prompt(request.prompt, { expandPromptTemplates: false });
80
+ if (timedOut) {
81
+ throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
114
82
  }
115
- }
116
- catch (error) {
117
- if (controller.signal.aborted) {
118
- throw late();
83
+ const last = session.messages.findLast((message) => message.role === "assistant");
84
+ if (last !== undefined && "stopReason" in last && last.stopReason === "error") {
85
+ throw new Error(`The ${request.name} agent failed: ${last.errorMessage ?? "no reason given"}`);
119
86
  }
120
- await Promise.race([stderrClosed, delay(STDERR_GRACE_MS, undefined, { ref: false })]);
121
- throw withStderr(error, stderr);
87
+ const stats = session.getSessionStats();
88
+ const usage = { ...emptyUsage(), ...stats.tokens };
89
+ const name = session.model === undefined ? undefined : `${session.model.provider}/${session.model.id}`;
90
+ const ms = Date.now() - started;
91
+ recorder.usage(request.name, usage, ms, {
92
+ turns: stats.assistantMessages,
93
+ toolCalls: stats.toolCalls,
94
+ model: name,
95
+ session: stats.sessionId,
96
+ costUsd: stats.cost,
97
+ });
98
+ return {
99
+ text: (session.getLastAssistantText() ?? "").trim(),
100
+ usage,
101
+ turns: stats.assistantMessages,
102
+ toolCalls: stats.toolCalls,
103
+ ms,
104
+ costUsd: stats.cost,
105
+ model: name,
106
+ sessionId: stats.sessionId,
107
+ structured,
108
+ };
122
109
  }
123
110
  finally {
124
111
  clearTimeout(timer);
112
+ release();
113
+ unsubscribe();
114
+ session.dispose();
125
115
  }
126
- if (controller.signal.aborted) {
127
- throw late();
116
+ }
117
+ async function findModel(modelRuntime, name) {
118
+ const slash = name.indexOf("/");
119
+ if (slash <= 0) {
120
+ throw new Error(`The model ${name} is not in the form provider/model.`);
128
121
  }
129
- const ms = Date.now() - started;
130
- recorder.usage(request.name, usage, ms, {
131
- turns,
132
- toolCalls,
133
- model,
134
- session: sessionId,
135
- costUsd,
136
- });
137
- return {
138
- text: text.trim(),
139
- usage,
140
- turns,
141
- toolCalls,
142
- ms,
143
- costUsd,
144
- model,
145
- sessionId,
146
- structured,
147
- };
122
+ const found = modelRuntime.getModel(name.slice(0, slash), name.slice(slash + 1));
123
+ if (found === undefined) {
124
+ throw new Error(`The model ${name} is not known to pi.`);
125
+ }
126
+ return found;
148
127
  }
149
- function main(models) {
150
- let name;
151
- let output = -1;
152
- for (const [id, entry] of Object.entries(models)) {
153
- if (entry.outputTokens > output) {
154
- output = entry.outputTokens;
155
- name = id;
156
- }
128
+ function observe(log, event) {
129
+ if (event.type === "tool_execution_start") {
130
+ log.event("tool", toolFields(event.toolName, event.args));
131
+ }
132
+ else if (event.type === "tool_execution_end" && event.isError) {
133
+ log.event("tool.error", { tool: event.toolName }, "quiet");
134
+ }
135
+ else if (event.type === "message_update" &&
136
+ event.assistantMessageEvent.type === "text_delta" &&
137
+ log.level === "verbose") {
138
+ process.stderr.write(event.assistantMessageEvent.delta);
157
139
  }
158
- return name;
159
140
  }
160
141
  export function toolFields(name, input) {
161
- if (name !== "Bash" || typeof input !== "object" || input === null) {
142
+ if (name !== "bash" || typeof input !== "object" || input === null) {
162
143
  return { tool: name };
163
144
  }
164
145
  const { command } = input;
165
146
  return typeof command === "string" ? { tool: name, command } : { tool: name };
166
147
  }
167
- export function withStderr(error, stderr) {
168
- const tail = stderr.trim();
169
- if (tail.length === 0 || !(error instanceof Error)) {
170
- return error;
171
- }
172
- return new Error(`${error.message}\nClaude CLI stderr:\n${tail}`, { cause: error });
173
- }
174
- export function spawnClaude(options, onStderr = () => undefined) {
175
- const child = spawn(options.command, options.args, {
176
- cwd: options.cwd,
177
- env: options.env,
178
- signal: options.signal,
179
- detached: true,
180
- stdio: ["pipe", "pipe", "pipe"],
181
- });
182
- child.stderr.setEncoding("utf8");
183
- child.stderr.on("data", onStderr);
184
- track(child);
185
- return child;
186
- }
package/dist/exec.mjs CHANGED
@@ -3,7 +3,16 @@ let observer;
3
3
  let defaultTimeoutMs = 10 * 60_000;
4
4
  const STDERR_TAIL = 200_000;
5
5
  const groups = new Set();
6
+ const stoppers = new Set();
7
+ export function onStop(stopper) {
8
+ stoppers.add(stopper);
9
+ return () => stoppers.delete(stopper);
10
+ }
6
11
  export function stopChildren(signal) {
12
+ for (const stopper of stoppers) {
13
+ stopper();
14
+ }
15
+ stoppers.clear();
7
16
  for (const pid of groups) {
8
17
  try {
9
18
  process.kill(-pid, signal);
package/dist/prompts.mjs CHANGED
@@ -60,7 +60,7 @@ export function reviewPrompt(issue, comments, diff, earlier) {
60
60
  "The harness discards each change that you make, so a change is only lost work.",
61
61
  scope,
62
62
  "Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.",
63
- "Give the result as the structured output: a short summary and one entry per finding. The findings decide the outcome, so a problem that you leave out counts as no problem.",
63
+ "Give the result with the submit_result tool: a short summary and one entry per finding. The findings decide the outcome, so a problem that you leave out counts as no problem.",
64
64
  ].join("\n\n");
65
65
  }
66
66
  const UNRELATED_RULE = [
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "1.1.0",
4
- "description": "Autonomous issue harness driven by the Claude Agent SDK",
3
+ "version": "2.0.0",
4
+ "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",
7
7
  "issues",
8
8
  "agent",
9
- "claude",
9
+ "pi",
10
10
  "automation",
11
11
  "cli"
12
12
  ],
@@ -37,10 +37,12 @@
37
37
  "prepublishOnly": "npm run typecheck && npm test"
38
38
  },
39
39
  "devDependencies": {
40
+ "@earendil-works/pi-ai": "^1.5.0",
40
41
  "@types/node": "^26.6.6",
41
42
  "typescript": "^7.0.2"
42
43
  },
43
44
  "dependencies": {
44
- "@anthropic-ai/claude-agent-sdk": "^0.3.316"
45
+ "@earendil-works/pi-coding-agent": "^1.5.0",
46
+ "typebox": "^1.4.3"
45
47
  }
46
48
  }