@sebastianandreasson/pi-autonomous-agents 0.2.0 → 0.3.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
@@ -91,12 +91,17 @@ PI_CONFIG_FILE=pi.config.json pi-harness clear-history
91
91
 
92
92
  The command removes configured harness history/runtime files and verifies that no configured history paths remain afterward.
93
93
 
94
+ For prompt debugging, the harness also writes the exact assembled prompt for the current role to `.pi-last-prompt.txt` by default.
95
+ For flow debugging, it also writes a machine-readable `.pi-last-iteration.json` summary with the selected task, tester verdict, commit-plan state, and terminal reason.
96
+
94
97
  ## Generic Contracts
95
98
 
96
99
  - `taskFile`: usually `TODOS.md`
97
100
  - `developerInstructionsFile`: per-project developer instructions
98
101
  - `testerInstructionsFile`: per-project tester instructions
99
102
  - `roleModels`: optional per-role model overrides
103
+ - `commitMode`: `agent` by default, `plan` only for legacy harness-managed commit parsing
104
+ - `promptMode`: `compact` by default
100
105
  - `testCommand`: fast verification command
101
106
  - `visualCaptureCommand`: project-defined screenshot capture command
102
107
  - `visualFeedbackFile`: latest visual-review handoff
@@ -104,8 +109,14 @@ The command removes configured harness history/runtime files and verifies that n
104
109
 
105
110
  For unattended loops, keep `testCommand` fast and bounded, such as a smoke suite. Long real-time Playwright happy-path specs belong in an explicit nightly or post-run lane, not the default developer/tester inner loop.
106
111
 
112
+ Keep TODO items extremely small and implementation-shaped when using weaker local models. Broad tasks tend to produce much longer turns, more retries, and more tester drift than narrow one-step tasks.
113
+
107
114
  The adapter heartbeat is PI-RPC-event based. Streaming shell output does not count as progress on its own, so long-running tools should rely on the tool-aware watchdog thresholds rather than terminal streaming.
108
115
 
109
- `piModel` remains the default text model, but you can override specific roles with `roleModels` such as `developer`, `developerRetry`, `developerFix`, `tester`, `testerCommit`, and `visualReview`.
116
+ `piModel` remains the default text model, but you can override specific roles with `roleModels` such as `developer`, `developerRetry`, `developerFix`, `tester`, and `visualReview`. `testerCommit` is only relevant if you opt back into `commitMode: "plan"`.
117
+
118
+ By default, successful tester passes should stage and create the commit directly in the same PI turn. The old commit-plan parsing flow is still available as `commitMode: "plan"`, but it is now a compatibility mode rather than the default.
119
+
120
+ Prompt/context handoff is compact by default. The harness now caps prior feedback excerpts, changed-file lists, verification excerpts, and prompt note handoff. If needed, tune `maxPromptChangedFiles`, `maxVisualFeedbackLines`, `maxTesterFeedbackLines`, `maxPromptNotesLines`, and `maxVerificationExcerptLines`.
110
121
 
111
122
  The harness expects screenshot capture to produce a `manifest.json` plus image files under the configured visual capture directory.
package/SETUP.md CHANGED
@@ -46,6 +46,7 @@ If the repo uses another package manager already, use the repo-native equivalent
46
46
  - `taskFile`: usually `TODOS.md`
47
47
  - `developerInstructionsFile`: `pi/DEVELOPER.md`
48
48
  - `testerInstructionsFile`: `pi/TESTER.md`
49
+ - `commitMode`: normally `agent`
49
50
  - `testCommand`: a fast bounded verification command for this repo
50
51
  - `visualCaptureCommand`: only if this repo has a real screenshot capture flow
51
52
  - `models` / `piModel` / `visualReviewModel` / `roleModels`: configure the models actually available in this environment
@@ -123,6 +124,7 @@ Recommended pattern:
123
124
  - local model for `developerFix`
124
125
  - local or slightly stronger model for `tester`
125
126
  - stronger frontier model for `visualReview` only if available
127
+ - keep `commitMode` as `agent` unless the repo explicitly needs legacy harness-managed commit-plan parsing
126
128
 
127
129
  Example shape:
128
130
 
@@ -179,6 +181,9 @@ The harness should fail fast if:
179
181
  - a configured provider endpoint is unreachable
180
182
  - a configured provider does not serve the configured model id
181
183
 
184
+ For prompt debugging, inspect `.pi-last-prompt.txt` after a run. It contains the exact assembled prompt that was sent for the active role.
185
+ For flow debugging, inspect `.pi-last-iteration.json` after a run. It summarizes the selected task, repo-change outcome, tester verdict, commit-plan state, and terminal reason.
186
+
182
187
  ## Agent Rules
183
188
 
184
189
  - Reuse existing repo conventions where possible.
@@ -186,6 +191,7 @@ The harness should fail fast if:
186
191
  - Do not invent fake test commands or model endpoints.
187
192
  - Do not enable visual review unless the repo actually has a usable capture command and model config.
188
193
  - Keep changes minimal and local to harness setup.
194
+ - Prefer very small, implementation-shaped TODO items for local models. Broad tasks tend to create long turns, retries, and weak tester behavior.
189
195
 
190
196
  ## What To Report Back
191
197
 
@@ -18,7 +18,7 @@ Each real iteration follows this sequence:
18
18
  2. A fast local verification command runs immediately after the developer round.
19
19
  3. If verification passes, `tester` reviews the change independently from a skeptical user-facing perspective.
20
20
  4. If tester or verification finds a real issue, the supervisor gives the findings back to `developer` for one focused repair pass.
21
- 5. If tester reaches `PASS`, tester provides a commit plan and the harness performs the actual git finalization.
21
+ 5. If tester reaches `PASS`, tester creates the commit directly in the same turn by default.
22
22
  6. Optionally, every `N` successful iterations, the harness runs a read-only visual review over screenshots and persists the feedback for later runs.
23
23
  7. If that visual review returns `FAIL`, `BLOCKED`, or times out, the iteration is not counted as a success and the feedback is carried into later prompts.
24
24
 
@@ -69,6 +69,7 @@ Projects typically provide their own `pi.config.json` with fields such as:
69
69
  - `models`
70
70
  - `piModel`
71
71
  - `visualReviewModel`
72
+ - `commitMode`
72
73
 
73
74
  Model entries may carry their own OpenAI-compatible endpoint settings, so the PI text loop and the multimodal visual reviewer can point at different backends without changing code.
74
75
 
@@ -83,7 +84,6 @@ Model entries may carry their own OpenAI-compatible endpoint settings, so the PI
83
84
  "developerRetry": "local/dev-model",
84
85
  "developerFix": "local/dev-model",
85
86
  "tester": "local/tester-model",
86
- "testerCommit": "local/tester-model",
87
87
  "visualReview": "cloud/vision-model"
88
88
  }
89
89
  }
@@ -162,15 +162,13 @@ Allowed response `status` values:
162
162
 
163
163
  ## Git Finalization
164
164
 
165
- The harness is designed to keep commit history structured:
165
+ The default flow keeps commit ownership with the active agent:
166
166
 
167
167
  1. `developer` should leave a clean, reviewable diff and should not commit.
168
- 2. `tester` should review functionality and, on `PASS`, provide a commit plan:
169
- - `COMMIT_MESSAGE: ...`
170
- - `COMMIT_FILES:`
171
- - `- path/to/file`
172
- 3. The harness stages only those requested files and performs the commit itself.
173
- 4. If the requested plan cannot be isolated safely, the iteration is blocked or failed instead of committing unrelated work.
168
+ 2. `tester` should review functionality and, on `PASS`, stage only the task-related files and create the commit directly.
169
+ 3. If the working tree is too messy to isolate safely, tester should return `VERDICT: BLOCKED` instead of guessing.
170
+
171
+ If a repo explicitly needs the older harness-managed commit-plan flow, set `commitMode` to `plan`. In that mode, `testerCommit` and parsed commit plans are used as a compatibility path rather than the default.
174
172
 
175
173
  ## Persistent Handoffs
176
174
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebastianandreasson/pi-autonomous-agents",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "description": "Portable unattended PI harness for developer/tester/visual-review loops.",
7
7
  "license": "MIT",
@@ -16,8 +16,8 @@
16
16
  "pi-harness": "./src/cli.mjs"
17
17
  },
18
18
  "scripts": {
19
- "check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs",
20
- "test": "node --test test/pi-heartbeat.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs"
19
+ "check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-telemetry.test.mjs",
20
+ "test": "node --test test/pi-heartbeat.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-telemetry.test.mjs"
21
21
  },
22
22
  "files": [
23
23
  "src",
package/pi.config.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "adapterCommand": "pi-harness adapter",
4
4
  "instructionsFile": "",
5
5
  "taskFile": "TODOS.md",
6
+ "commitMode": "agent",
6
7
  "streamTerminal": false,
7
8
  "loopRepeatThreshold": 12,
8
9
  "samePathRepeatThreshold": 8,
package/src/pi-client.mjs CHANGED
@@ -18,6 +18,7 @@ function formatLastAgentOutput(response) {
18
18
  `status: ${String(response.status ?? '')}`,
19
19
  `sessionId: ${String(response.sessionId ?? '')}`,
20
20
  `sessionFile: ${String(response.sessionFile ?? '')}`,
21
+ `terminalReason: ${String(response.terminalReason ?? '')}`,
21
22
  `notes: ${String(response.notes ?? '').trim()}`,
22
23
  ]
23
24
 
@@ -58,6 +59,15 @@ async function runMockTurn({ config, sessionId, sessionFile, prompt, reason }) {
58
59
  durationSeconds: 0,
59
60
  output,
60
61
  notes: 'Mock transport completed without repo edits.',
62
+ role: '',
63
+ model: '',
64
+ toolCalls: 0,
65
+ toolErrors: 0,
66
+ messageUpdates: 0,
67
+ stopReason: '',
68
+ loopDetected: false,
69
+ loopSignature: '',
70
+ terminalReason: 'mock_completed',
61
71
  }
62
72
  }
63
73
 
@@ -142,6 +152,15 @@ async function runAdapterTurn({ config, model, sessionId, sessionFile, prompt, i
142
152
  durationSeconds: result.durationSeconds,
143
153
  output: result.combinedOutput,
144
154
  notes: 'Adapter process exceeded the configured timeout.',
155
+ role: '',
156
+ model: model ?? config.piModel,
157
+ toolCalls: 0,
158
+ toolErrors: 0,
159
+ messageUpdates: 0,
160
+ stopReason: '',
161
+ loopDetected: false,
162
+ loopSignature: '',
163
+ terminalReason: 'agent_timeout',
145
164
  }
146
165
  }
147
166
 
@@ -157,6 +176,15 @@ async function runAdapterTurn({ config, model, sessionId, sessionFile, prompt, i
157
176
  durationSeconds: result.durationSeconds,
158
177
  output: result.combinedOutput,
159
178
  notes: truncateForNotes(result.combinedOutput) || 'Adapter exited non-zero.',
179
+ role: '',
180
+ model: model ?? config.piModel,
181
+ toolCalls: 0,
182
+ toolErrors: 0,
183
+ messageUpdates: 0,
184
+ stopReason: '',
185
+ loopDetected: false,
186
+ loopSignature: '',
187
+ terminalReason: 'adapter_failed',
160
188
  }
161
189
  }
162
190
 
@@ -179,6 +207,15 @@ async function runAdapterTurn({ config, model, sessionId, sessionFile, prompt, i
179
207
  durationSeconds: result.durationSeconds,
180
208
  output,
181
209
  notes,
210
+ role: String(response.role ?? ''),
211
+ model: String(response.model ?? model ?? config.piModel ?? ''),
212
+ toolCalls: Number.isFinite(Number(response.toolCalls)) ? Number(response.toolCalls) : 0,
213
+ toolErrors: Number.isFinite(Number(response.toolErrors)) ? Number(response.toolErrors) : 0,
214
+ messageUpdates: Number.isFinite(Number(response.messageUpdates)) ? Number(response.messageUpdates) : 0,
215
+ stopReason: String(response.stopReason ?? ''),
216
+ loopDetected: response.loopDetected === true,
217
+ loopSignature: String(response.loopSignature ?? ''),
218
+ terminalReason: String(response.terminalReason ?? ''),
182
219
  }
183
220
  }
184
221
 
package/src/pi-config.mjs CHANGED
@@ -130,6 +130,22 @@ function normalizeRoleModels(raw) {
130
130
  return normalized
131
131
  }
132
132
 
133
+ function normalizeCommitMode(raw) {
134
+ const value = normalizeString(raw, 'agent').trim().toLowerCase()
135
+ if (value === 'agent' || value === 'plan') {
136
+ return value
137
+ }
138
+ throw new Error(`Expected commitMode to be "agent" or "plan", received "${raw}"`)
139
+ }
140
+
141
+ function normalizePromptMode(raw) {
142
+ const value = normalizeString(raw, 'compact').trim().toLowerCase()
143
+ if (value === 'compact' || value === 'full') {
144
+ return value
145
+ }
146
+ throw new Error(`Expected promptMode to be "compact" or "full", received "${raw}"`)
147
+ }
148
+
133
149
  function resolveModelProfile(modelProfiles, modelName) {
134
150
  if (!modelName || typeof modelName !== 'string') {
135
151
  return null
@@ -181,12 +197,30 @@ export function loadConfig(mode = 'once') {
181
197
  const repoConfig = readRepoConfig(cwd)
182
198
  const file = repoConfig.values
183
199
  const bundledAdapterCommand = 'pi-harness adapter'
200
+ const bundledDeveloperInstructionsFile = path.join(packageRoot, 'templates', 'DEVELOPER.md')
201
+ const bundledTesterInstructionsFile = path.join(packageRoot, 'templates', 'TESTER.md')
184
202
  const modelProfiles = readObject('models', file.models, {})
185
203
  const roleModels = normalizeRoleModels(file.roleModels)
186
204
  const piModel = readString('PI_MODEL', file.piModel, '')
187
205
  const visualReviewModel = readString('PI_VISUAL_REVIEW_MODEL', file.visualReviewModel, '')
188
206
  const resolvedPiModel = resolveModelProfile(modelProfiles, piModel)
189
207
  const resolvedVisualReviewModel = resolveModelProfile(modelProfiles, visualReviewModel)
208
+ const developerInstructionsFile = resolveInstructionsFile(
209
+ cwd,
210
+ 'PI_DEVELOPER_INSTRUCTIONS_FILE',
211
+ file.developerInstructionsFile,
212
+ hasValue(file.instructionsFile)
213
+ ? String(file.instructionsFile)
214
+ : bundledDeveloperInstructionsFile
215
+ )
216
+ const testerInstructionsFile = resolveInstructionsFile(
217
+ cwd,
218
+ 'PI_TESTER_INSTRUCTIONS_FILE',
219
+ file.testerInstructionsFile,
220
+ hasValue(file.instructionsFile)
221
+ ? String(file.instructionsFile)
222
+ : bundledTesterInstructionsFile
223
+ )
190
224
 
191
225
  return {
192
226
  cwd,
@@ -196,23 +230,11 @@ export function loadConfig(mode = 'once') {
196
230
  agentName: readString('PI_AGENT_NAME', file.agentName, 'PI'),
197
231
  adapterCommand: readString('PI_ADAPTER_COMMAND', file.adapterCommand, bundledAdapterCommand),
198
232
  taskFile: resolveFromCwd(cwd, 'PI_TASK_FILE', file.taskFile, 'TODOS.md'),
199
- instructionsFile: resolveInstructionsFile(cwd, 'PI_INSTRUCTIONS_FILE', file.instructionsFile, path.join(packageRoot, 'templates', 'DEVELOPER.md')),
200
- developerInstructionsFile: resolveInstructionsFile(
201
- cwd,
202
- 'PI_DEVELOPER_INSTRUCTIONS_FILE',
203
- file.developerInstructionsFile,
204
- hasValue(file.instructionsFile)
205
- ? String(file.instructionsFile)
206
- : path.join(packageRoot, 'templates', 'DEVELOPER.md')
207
- ),
208
- testerInstructionsFile: resolveInstructionsFile(
209
- cwd,
210
- 'PI_TESTER_INSTRUCTIONS_FILE',
211
- file.testerInstructionsFile,
212
- hasValue(file.instructionsFile)
213
- ? String(file.instructionsFile)
214
- : path.join(packageRoot, 'templates', 'TESTER.md')
215
- ),
233
+ instructionsFile: resolveInstructionsFile(cwd, 'PI_INSTRUCTIONS_FILE', file.instructionsFile, bundledDeveloperInstructionsFile),
234
+ developerInstructionsFile,
235
+ testerInstructionsFile,
236
+ usingBundledDeveloperInstructions: developerInstructionsFile === bundledDeveloperInstructionsFile,
237
+ usingBundledTesterInstructions: testerInstructionsFile === bundledTesterInstructionsFile,
216
238
  logFile: resolveFromCwd(cwd, 'PI_LOG_FILE', file.logFile, 'pi.log'),
217
239
  telemetryJsonl: resolveFromCwd(cwd, 'PI_TELEMETRY_JSONL', file.telemetryJsonl, 'pi_telemetry.jsonl'),
218
240
  telemetryCsv: resolveFromCwd(cwd, 'PI_TELEMETRY_CSV', file.telemetryCsv, 'pi_telemetry.csv'),
@@ -221,12 +243,21 @@ export function loadConfig(mode = 'once') {
221
243
  lastAgentOutputFile: resolveFromCwd(cwd, 'PI_LAST_AGENT_OUTPUT_FILE', file.lastAgentOutputFile, '.pi-last-output.txt'),
222
244
  lastVerificationOutputFile: resolveFromCwd(cwd, 'PI_LAST_VERIFICATION_OUTPUT_FILE', file.lastVerificationOutputFile, '.pi-last-verification.txt'),
223
245
  changedFilesFile: resolveFromCwd(cwd, 'PI_CHANGED_FILES_FILE', file.changedFilesFile, '.pi-changed-files.txt'),
246
+ lastPromptFile: resolveFromCwd(cwd, 'PI_LAST_PROMPT_FILE', file.lastPromptFile, '.pi-last-prompt.txt'),
247
+ lastIterationSummaryFile: resolveFromCwd(cwd, 'PI_LAST_ITERATION_SUMMARY_FILE', file.lastIterationSummaryFile, '.pi-last-iteration.json'),
224
248
  piRuntimeDir: resolveFromCwd(cwd, 'PI_RUNTIME_DIR', file.piRuntimeDir, '.pi-runtime'),
225
249
  piCli: readString('PI_CLI', file.piCli, 'pi'),
226
250
  piModel,
227
251
  piModelProfile: resolvedPiModel,
228
252
  modelProfiles,
229
253
  roleModels,
254
+ commitMode: normalizeCommitMode(readString('PI_COMMIT_MODE', file.commitMode, 'agent')),
255
+ promptMode: normalizePromptMode(readString('PI_PROMPT_MODE', file.promptMode, 'compact')),
256
+ maxPromptChangedFiles: readInt('PI_MAX_PROMPT_CHANGED_FILES', file.maxPromptChangedFiles, 10),
257
+ maxVisualFeedbackLines: readInt('PI_MAX_VISUAL_FEEDBACK_LINES', file.maxVisualFeedbackLines, 20),
258
+ maxTesterFeedbackLines: readInt('PI_MAX_TESTER_FEEDBACK_LINES', file.maxTesterFeedbackLines, 32),
259
+ maxPromptNotesLines: readInt('PI_MAX_PROMPT_NOTES_LINES', file.maxPromptNotesLines, 16),
260
+ maxVerificationExcerptLines: readInt('PI_MAX_VERIFICATION_EXCERPT_LINES', file.maxVerificationExcerptLines, 40),
230
261
  piTools: readString('PI_TOOLS', file.piTools, 'read,bash,edit,write,grep,find,ls'),
231
262
  piThinking: readString('PI_THINKING', file.piThinking, ''),
232
263
  piNoExtensions: readBool('PI_NO_EXTENSIONS', file.piNoExtensions, false),
@@ -20,6 +20,8 @@ export function collectHistoryTargets(config) {
20
20
  config.lastAgentOutputFile,
21
21
  config.lastVerificationOutputFile,
22
22
  config.changedFilesFile,
23
+ config.lastPromptFile,
24
+ config.lastIterationSummaryFile,
23
25
  config.piRuntimeDir,
24
26
  config.visualFeedbackFile,
25
27
  config.testerFeedbackFile,
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs/promises'
2
- import { execFileSync } from 'node:child_process'
2
+ import { spawnSync } from 'node:child_process'
3
3
  import path from 'node:path'
4
4
  import process from 'node:process'
5
5
 
@@ -34,20 +34,42 @@ export function parsePiListModelsOutput(output) {
34
34
  }
35
35
 
36
36
  const ids = []
37
+ let modelColumnIndex = -1
37
38
  for (const rawLine of text.split('\n')) {
38
39
  const line = rawLine.trim()
40
+ const stripped = line.replace(/^[-*]\s+/, '').trim()
41
+ const columns = stripped.split(/\s+/).filter(Boolean)
42
+ const normalizedColumns = columns.map((value) => value.toLowerCase())
43
+
44
+ if (
45
+ modelColumnIndex === -1
46
+ && normalizedColumns.includes('model')
47
+ && normalizedColumns.some((value) => value === 'provider' || value === 'id' || value === 'name')
48
+ ) {
49
+ modelColumnIndex = normalizedColumns.indexOf('model')
50
+ continue
51
+ }
52
+
39
53
  if (
40
54
  line === ''
41
55
  || /^available models:?$/i.test(line)
42
56
  || /^models:?$/i.test(line)
43
57
  || /^id\s+/i.test(line)
44
58
  || /^name\s+/i.test(line)
59
+ || /^[-=\s]+$/.test(line)
45
60
  ) {
46
61
  continue
47
62
  }
48
63
 
49
- const stripped = line.replace(/^[-*]\s+/, '').trim()
50
- const firstToken = stripped.split(/\s+/)[0]?.trim() ?? ''
64
+ if (modelColumnIndex >= 0) {
65
+ const modelToken = columns[modelColumnIndex]?.trim() ?? ''
66
+ if (modelToken !== '') {
67
+ ids.push(modelToken)
68
+ }
69
+ continue
70
+ }
71
+
72
+ const firstToken = columns[0]?.trim() ?? ''
51
73
  if (firstToken !== '') {
52
74
  ids.push(firstToken)
53
75
  }
@@ -106,24 +128,33 @@ async function ensurePiHomeModelsConfig() {
106
128
  }
107
129
 
108
130
  function listPiModels(config) {
109
- try {
110
- const output = execFileSync(config.piCli, ['--list-models'], {
111
- cwd: config.cwd,
112
- env: process.env,
113
- encoding: 'utf8',
114
- stdio: ['ignore', 'pipe', 'pipe'],
115
- })
116
- return parsePiListModelsOutput(output)
117
- } catch (error) {
118
- const stdout = error?.stdout ? String(error.stdout).trim() : ''
119
- const stderr = error?.stderr ? String(error.stderr).trim() : ''
120
- const details = [stdout, stderr].filter(Boolean).join('\n')
131
+ const result = spawnSync(config.piCli, ['--list-models'], {
132
+ cwd: config.cwd,
133
+ env: process.env,
134
+ encoding: 'utf8',
135
+ stdio: ['ignore', 'pipe', 'pipe'],
136
+ })
137
+ const stdout = String(result.stdout ?? '').trim()
138
+ const stderr = String(result.stderr ?? '').trim()
139
+ const combinedOutput = [stdout, stderr].filter(Boolean).join('\n').trim()
140
+
141
+ if (result.error) {
142
+ throw new Error(
143
+ combinedOutput === ''
144
+ ? `Failed to list PI models via "${config.piCli} --list-models".`
145
+ : `Failed to list PI models via "${config.piCli} --list-models".\n${combinedOutput}`
146
+ )
147
+ }
148
+
149
+ if (result.status !== 0) {
121
150
  throw new Error(
122
- details === ''
151
+ combinedOutput === ''
123
152
  ? `Failed to list PI models via "${config.piCli} --list-models".`
124
- : `Failed to list PI models via "${config.piCli} --list-models".\n${details}`
153
+ : `Failed to list PI models via "${config.piCli} --list-models".\n${combinedOutput}`
125
154
  )
126
155
  }
156
+
157
+ return parsePiListModelsOutput(combinedOutput)
127
158
  }
128
159
 
129
160
  function getConfiguredTextModels(config) {