@sebastianandreasson/pi-autonomous-agents 0.5.0 → 0.5.2

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,58 +1,79 @@
1
- # PI Harness
1
+ # PI Autonomous Agents
2
2
 
3
- `pi-harness` is a portable CLI/workflow package for running a local PI-based unattended loop with:
3
+ `@sebastianandreasson/pi-autonomous-agents` is an npm package for running a bounded unattended [PI](https://pi.dev/) workflow inside another repository.
4
4
 
5
- - a `developer` pass
6
- - a fast verification step
7
- - a skeptical `tester` pass
8
- - optional periodic multimodal visual review
9
- - tester-owned final commit by default
5
+ It orchestrates:
10
6
 
11
- The package is intentionally generic. It does not know how to navigate or test a specific app on its own.
7
+ - a `developer` turn
8
+ - a fast local verification step
9
+ - an independent `tester` turn
10
+ - an optional focused `developerFix` turn when verification/tester finds a real issue
11
+ - optional periodic visual review from screenshots
12
12
 
13
- ## What Belongs In The Package
13
+ The package is intentionally generic. It handles supervision, prompts, runtime state, telemetry, retries, and guardrails. The consuming repo still owns its own tasks, instructions, tests, model endpoints, and screenshot capture flow.
14
14
 
15
- - supervisor/orchestration
16
- - PI adapter/runtime integration
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -D @sebastianandreasson/pi-autonomous-agents
19
+ ```
20
+
21
+ Then in the consuming repo, tell your agent:
22
+
23
+ ```text
24
+ Find SETUP.md in @sebastianandreasson/pi-autonomous-agents and set everything up for this repository.
25
+ ```
26
+
27
+ The package ships a top-level [SETUP.md](./SETUP.md) specifically for that workflow.
28
+
29
+ ## What This Package Owns
30
+
31
+ - unattended loop orchestration
32
+ - PI adapter integration
17
33
  - config loading
18
- - telemetry
19
- - loop guards, timeout guards, and retries
20
- - tester feedback + visual feedback handoff
21
- - optional legacy harness git finalize step for `commitMode: "plan"`
22
- - multimodal visual review client
34
+ - prompt assembly
35
+ - verification/tester/visual-review handoff
36
+ - timeout and loop guards
37
+ - telemetry and run summaries
38
+ - runtime isolation and stale-run recovery
23
39
 
24
- ## What Stays Per Project
40
+ ## What Each Repo Must Provide
25
41
 
26
42
  - `TODOS.md`
27
- - project instructions
28
- - browser tests
29
- - visual capture flow
30
- - app-specific verification commands
31
- - app/server startup scripts
43
+ - repo-specific `pi/DEVELOPER.md`
44
+ - repo-specific `pi/TESTER.md`
45
+ - a fast bounded `testCommand`
46
+ - model configuration that actually matches the local/cloud providers in use
47
+ - optionally a screenshot capture command for visual review
48
+
49
+ ## Quick Start In A Repo
32
50
 
33
- ## Layout
51
+ The normal setup shape is:
34
52
 
35
53
  ```text
36
- packages/pi-harness/
37
- package.json
38
- pi.config.json
39
- templates/DEVELOPER.md
40
- templates/TESTER.md
41
- docs/PI_SUPERVISOR.md
42
- src/
43
- cli.mjs
44
- pi-client.mjs
45
- pi-config.mjs
46
- pi-prompts.mjs
47
- pi-repo.mjs
48
- pi-report.mjs
49
- pi-rpc-adapter.mjs
50
- pi-supervisor.mjs
51
- pi-telemetry.mjs
52
- pi-visual-once.mjs
53
- pi-visual-review.mjs
54
+ TODOS.md
55
+ pi.config.json
56
+ pi/
57
+ DEVELOPER.md
58
+ TESTER.md
59
+ ```
60
+
61
+ Typical scripts:
62
+
63
+ ```json
64
+ {
65
+ "scripts": {
66
+ "pi:mock": "PI_CONFIG_FILE=pi.config.json PI_TRANSPORT=mock PI_TEST_CMD= pi-harness once",
67
+ "pi:once": "PI_CONFIG_FILE=pi.config.json pi-harness once",
68
+ "pi:run": "PI_CONFIG_FILE=pi.config.json pi-harness run",
69
+ "pi:report": "PI_CONFIG_FILE=pi.config.json pi-harness report",
70
+ "pi:visual:once": "PI_CONFIG_FILE=pi.config.json pi-harness visual-once"
71
+ }
72
+ }
54
73
  ```
55
74
 
75
+ Start from [templates/pi.config.example.json](./templates/pi.config.example.json), [templates/DEVELOPER.md](./templates/DEVELOPER.md), [templates/TESTER.md](./templates/TESTER.md), and [templates/gitignore.fragment](./templates/gitignore.fragment).
76
+
56
77
  ## CLI
57
78
 
58
79
  ```bash
@@ -65,65 +86,213 @@ pi-harness adapter
65
86
  pi-harness visual-review-worker
66
87
  ```
67
88
 
68
- Use `PI_CONFIG_FILE` to point the harness at a project-local config file. If you do not provide one, the bundled generic `pi.config.json` is used as a fallback.
69
-
70
- ## Setup In Another Repo
71
-
72
- After installing the package:
89
+ Use `PI_CONFIG_FILE` to point at the repo-local config file:
73
90
 
74
91
  ```bash
75
- npm install -D @sebastianandreasson/pi-autonomous-agents
92
+ PI_CONFIG_FILE=pi.config.json pi-harness once
76
93
  ```
77
94
 
78
- you can tell another agent in that repo:
79
-
80
- ```text
81
- Find SETUP.md in @sebastianandreasson/pi-autonomous-agents and set everything up for this repository.
95
+ If `PI_CONFIG_FILE` is not set, the package falls back to the bundled generic [pi.config.json](./pi.config.json).
96
+
97
+ ## Core Workflow
98
+
99
+ Each real iteration works like this:
100
+
101
+ 1. `developer` implements one unchecked task from `TODOS.md`.
102
+ 2. The harness runs the configured fast verification command.
103
+ 3. If verification passes, `tester` reviews the change independently.
104
+ 4. If tester or verification fails, the findings go back to `developerFix` for one focused repair pass.
105
+ 5. If tester reaches `PASS`, tester creates the final commit directly by default.
106
+ 6. Every `N` successful iterations, optional visual review can inspect screenshots and veto the success if it finds a real problem.
107
+
108
+ The default commit model is `commitMode: "agent"`. The older harness-managed parsed commit-plan flow still exists as `commitMode: "plan"`, but it is now a compatibility mode rather than the default.
109
+
110
+ ## Recommended Model Setup
111
+
112
+ The package supports:
113
+
114
+ - one default text model via `piModel`
115
+ - one default visual-review model via `visualReviewModel`
116
+ - optional per-role overrides via `roleModels`
117
+ - per-model endpoint config in `models`
118
+
119
+ Typical pattern:
120
+
121
+ - local model for `developer`
122
+ - local model for `developerRetry`
123
+ - local model for `developerFix`
124
+ - local or slightly stronger model for `tester`
125
+ - stronger frontier model only for `visualReview`
126
+
127
+ Example:
128
+
129
+ ```json
130
+ {
131
+ "piModel": "local/text-model",
132
+ "visualReviewModel": "cloud/vision-model",
133
+ "models": {
134
+ "local/text-model": {
135
+ "baseUrl": "http://localhost:8000/v1",
136
+ "apiKey": "local",
137
+ "vision": false
138
+ },
139
+ "local/tester-model": {
140
+ "baseUrl": "http://localhost:8000/v1",
141
+ "apiKey": "local",
142
+ "vision": false
143
+ },
144
+ "cloud/vision-model": {
145
+ "baseUrl": "https://api.openai.com/v1",
146
+ "apiKeyEnv": "OPENAI_API_KEY",
147
+ "vision": true
148
+ }
149
+ },
150
+ "roleModels": {
151
+ "developer": "local/text-model",
152
+ "developerRetry": "local/text-model",
153
+ "developerFix": "local/text-model",
154
+ "tester": "local/tester-model",
155
+ "visualReview": "cloud/vision-model"
156
+ }
157
+ }
82
158
  ```
83
159
 
84
- The package ships a top-level [SETUP.md](./SETUP.md) specifically for that workflow.
160
+ Important:
161
+
162
+ - do not guess model ids
163
+ - if using a custom OpenAI-compatible provider, verify `<baseUrl>/models`
164
+ - if using PI models directly, verify `pi --list-models`
165
+ - if `PI_CODING_AGENT_DIR` points at a repo-local PI home, make sure it is bootstrapped and contains `models.json`
166
+
167
+ The harness now preflights those checks before starting a real run.
168
+
169
+ ## Important Config Fields
170
+
171
+ Common fields in `pi.config.json`:
85
172
 
86
- If you want to wipe all harness-generated state and start over cleanly in a repo, run:
173
+ - `taskFile`
174
+ - `developerInstructionsFile`
175
+ - `testerInstructionsFile`
176
+ - `transport`
177
+ - `adapterCommand`
178
+ - `piModel`
179
+ - `models`
180
+ - `roleModels`
181
+ - `commitMode`
182
+ - `promptMode`
183
+ - `testCommand`
184
+ - `visualReviewEnabled`
185
+ - `visualCaptureCommand`
186
+ - `continueAfterSeconds`
187
+ - `toolContinueAfterSeconds`
188
+ - `noEventTimeoutSeconds`
189
+ - `toolNoEventTimeoutSeconds`
190
+ - `largeFileWarningLines`
191
+ - `largeSpecWarningLines`
87
192
 
88
- ```bash
89
- PI_CONFIG_FILE=pi.config.json pi-harness clear-history
90
- ```
193
+ Key defaults:
194
+
195
+ - `transport`: `adapter`
196
+ - `commitMode`: `agent`
197
+ - `promptMode`: `compact`
198
+ - `piTools`: `read,edit,write,find,ls,bash`
199
+ - `continueAfterSeconds`: `300`
200
+ - `toolContinueAfterSeconds`: `900`
201
+ - `noEventTimeoutSeconds`: `900`
202
+ - `toolNoEventTimeoutSeconds`: `1800`
203
+
204
+ ## Prompt and Tooling Behavior
205
+
206
+ The package is optimized for local models by default:
207
+
208
+ - prompts are compacted before handoff
209
+ - changed-file lists and feedback excerpts are capped
210
+ - prompts prefer `read` for source inspection
211
+ - shell is intended for `git`, tests, and narrow diagnostics
212
+ - the adapter warns on obvious oversized shell-based file reads
213
+ - the supervisor emits large-file/spec warnings when touched files are getting risky
214
+
215
+ This is deliberate. Large monolith files, huge e2e specs, and broad TODO items are one of the main causes of local-model drift and retry loops.
216
+
217
+ Recommended repo shape:
218
+
219
+ - keep TODO items very small and implementation-shaped
220
+ - split giant stores/modules before they become constant edit hotspots
221
+ - split ever-growing end-to-end specs into scenario files
222
+ - keep the default `testCommand` to a bounded smoke check, not a multi-minute happy-path run
91
223
 
92
- The command removes configured harness history/runtime files and verifies that no configured history paths remain afterward.
224
+ ## Runtime Isolation And Recovery
93
225
 
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
- For run isolation, the supervisor also maintains `.pi-runtime/active-run.json` and stores PI sessions plus per-run telemetry under `.pi-runtime/runs/<runId>/`.
226
+ Recent versions of the package isolate each run more aggressively:
97
227
 
98
- ## Generic Contracts
228
+ - active ownership lock at `.pi-runtime/active-run.json`
229
+ - per-run runtime directory under `.pi-runtime/runs/<runId>/`
230
+ - per-run PI sessions and telemetry
231
+ - `runId` added to telemetry
232
+ - in-progress iteration state persisted before agent work starts
233
+ - stale run locks recovered when the owning PID is gone
234
+ - timeout cleanup kills the full spawned process group, not only the direct child
235
+ - parent-death watchers shut down orphaned supervisor and adapter layers instead of letting them continue under `PPID 1`
99
236
 
100
- - `taskFile`: usually `TODOS.md`
101
- - `developerInstructionsFile`: per-project developer instructions
102
- - `testerInstructionsFile`: per-project tester instructions
103
- - `roleModels`: optional per-role model overrides
104
- - `commitMode`: `agent` by default, `plan` only for legacy harness-managed commit parsing
105
- - `promptMode`: `compact` by default
106
- - `testCommand`: fast verification command
107
- - `visualCaptureCommand`: project-defined screenshot capture command
108
- - `visualFeedbackFile`: latest visual-review handoff
109
- - `testerFeedbackFile`: latest tester-review handoff
237
+ That is meant to prevent orphaned timed-out agents or concurrent supervisors from corrupting shared state.
110
238
 
111
- 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.
239
+ ## Debugging Artifacts
112
240
 
113
- 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.
241
+ Useful files during a run:
114
242
 
115
- 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.
243
+ - `.pi-last-prompt.txt`
244
+ Exact assembled prompt for the current role.
245
+ - `.pi-last-output.txt`
246
+ Latest agent output snapshot.
247
+ - `.pi-last-verification.txt`
248
+ Latest verification output snapshot.
249
+ - `.pi-last-iteration.json`
250
+ Structured summary of the last completed iteration.
251
+ - `.pi-state.json`
252
+ Persistent harness state, including in-progress iteration data.
253
+ - `pi.log`
254
+ Main run log.
255
+ - `pi_telemetry.jsonl`
256
+ - `pi_telemetry.csv`
257
+ - `.pi-runtime/active-run.json`
258
+ - `.pi-runtime/runs/<runId>/...`
116
259
 
117
- The supervisor now enforces single-run ownership per repo/config. If a stale run crashed mid-iteration, the next run recovers the unfinished iteration number from `.pi-state.json` instead of silently rolling forward.
260
+ `pi-harness report` summarizes recent telemetry and surfaces things like terminal reasons and large-file warnings.
118
261
 
119
- `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"`.
262
+ ## Visual Review Contract
120
263
 
121
- 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.
264
+ Visual review is optional and generic. The harness does not know how to navigate your app.
122
265
 
123
- 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`.
266
+ If enabled, your repo must provide a real screenshot capture command that writes a manifest under the configured capture directory. The manifest shape is documented in [docs/PI_SUPERVISOR.md](./docs/PI_SUPERVISOR.md).
124
267
 
125
- The default coding tool mix is now safer for local models: `read,edit,write,find,ls,bash`. Prompts explicitly steer source inspection toward `read` and reserve shell usage for `git`, tests, and narrow diagnostics.
268
+ Visual review should be used as a periodic audit, not as the default inner-loop gate.
269
+
270
+ ## Resetting Harness State
271
+
272
+ If you want to wipe harness-generated state and start fresh:
126
273
 
127
- The harness also emits lightweight large-file warnings for touched source/spec files and carries them into `.pi-last-iteration.json`, `pi-harness report`, and relevant prompts. Tune `largeFileWarningLines` and `largeSpecWarningLines` if needed.
274
+ ```bash
275
+ PI_CONFIG_FILE=pi.config.json pi-harness clear-history
276
+ ```
277
+
278
+ That clears configured harness runtime/history artifacts and verifies they are gone. It does not remove project source files.
279
+
280
+ ## Docs
281
+
282
+ - [SETUP.md](./SETUP.md)
283
+ Agent-facing setup instructions for consuming repos.
284
+ - [docs/PI_SUPERVISOR.md](./docs/PI_SUPERVISOR.md)
285
+ More detailed flow, adapter, and runtime documentation.
286
+ - [templates/PROJECT_SETUP.md](./templates/PROJECT_SETUP.md)
287
+ Minimal consuming-repo layout summary.
288
+
289
+ ## Development
290
+
291
+ In this package repo:
292
+
293
+ ```bash
294
+ npm run check
295
+ npm test
296
+ ```
128
297
 
129
- The harness expects screenshot capture to produce a `manifest.json` plus image files under the configured visual capture directory.
298
+ The package requires Node `>=20`.
@@ -222,6 +222,7 @@ The built-in adapter mitigates obvious local loops by watching PI RPC tool event
222
222
  - a soft `continue` can be sent after inactivity
223
223
  - a separate tool-aware watchdog can tolerate long-running `bash` or browser work without treating the turn as dead
224
224
  - a hard no-event timeout aborts a wedged turn instead of hanging indefinitely
225
+ - parent-loss shutdown tears down the owned supervisor/adapter/PI child tree instead of allowing orphaned background runs
225
226
 
226
227
  Important: terminal streaming does not reset the heartbeat by itself. The watchdog keys off PI RPC events and active tool state, not raw shell output.
227
228
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebastianandreasson/pi-autonomous-agents",
3
3
  "private": false,
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
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 && 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"
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-lifecycle.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 && node --check test/fixtures/fake-pi.mjs",
20
+ "test": "node --test test/pi-heartbeat.test.mjs test/pi-lifecycle.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/src/cli.mjs CHANGED
@@ -4,6 +4,11 @@ import path from 'node:path'
4
4
  import { spawn } from 'node:child_process'
5
5
  import process from 'node:process'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import {
8
+ registerOwnedChildProcess,
9
+ signalChildProcess,
10
+ watchParentProcess,
11
+ } from './pi-repo.mjs'
7
12
 
8
13
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
9
14
 
@@ -36,10 +41,53 @@ function main() {
36
41
  env: process.env,
37
42
  stdio: 'inherit',
38
43
  })
44
+ registerOwnedChildProcess(child)
45
+
46
+ let shuttingDown = false
47
+ let forceKillTimer = null
48
+ const stopWatchingParent = watchParentProcess(() => {
49
+ shutdown({
50
+ signal: 'SIGTERM',
51
+ exitCode: 1,
52
+ })
53
+ })
54
+
55
+ function shutdown({
56
+ signal,
57
+ exitCode,
58
+ }) {
59
+ if (shuttingDown) {
60
+ return
61
+ }
62
+
63
+ shuttingDown = true
64
+ stopWatchingParent()
65
+ signalChildProcess(child.pid, signal)
66
+ forceKillTimer = setTimeout(() => {
67
+ signalChildProcess(child.pid, 'SIGKILL')
68
+ }, 1000)
69
+ if (typeof forceKillTimer.unref === 'function') {
70
+ forceKillTimer.unref()
71
+ }
72
+ process.exitCode = exitCode
73
+ }
74
+
75
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
76
+ process.on(signal, () => {
77
+ shutdown({
78
+ signal,
79
+ exitCode: 128,
80
+ })
81
+ })
82
+ }
39
83
 
40
84
  child.on('exit', (code, signal) => {
85
+ stopWatchingParent()
86
+ if (forceKillTimer) {
87
+ clearTimeout(forceKillTimer)
88
+ }
41
89
  if (signal) {
42
- process.kill(process.pid, signal)
90
+ process.exitCode = 128
43
91
  return
44
92
  }
45
93
  process.exitCode = code ?? 1
package/src/pi-repo.mjs CHANGED
@@ -114,6 +114,57 @@ export function isProcessRunning(pid) {
114
114
  }
115
115
  }
116
116
 
117
+ const ownedChildren = new Map()
118
+
119
+ export function registerOwnedChildProcess(child, options = {}) {
120
+ const pid = normalizePid(child?.pid)
121
+ if (pid <= 0) {
122
+ return () => {}
123
+ }
124
+
125
+ const entry = {
126
+ useProcessGroup: options.useProcessGroup === true && process.platform !== 'win32',
127
+ }
128
+ ownedChildren.set(pid, entry)
129
+
130
+ const unregister = () => {
131
+ ownedChildren.delete(pid)
132
+ }
133
+
134
+ if (typeof child?.once === 'function') {
135
+ child.once('exit', unregister)
136
+ child.once('close', unregister)
137
+ }
138
+
139
+ return unregister
140
+ }
141
+
142
+ export function signalChildProcess(pid, signal, options = {}) {
143
+ const normalizedPid = normalizePid(pid)
144
+ if (normalizedPid <= 0) {
145
+ return false
146
+ }
147
+
148
+ try {
149
+ if (options.useProcessGroup === true && process.platform !== 'win32') {
150
+ process.kill(-normalizedPid, signal)
151
+ } else {
152
+ process.kill(normalizedPid, signal)
153
+ }
154
+ return true
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
160
+ export function signalOwnedChildProcesses(signal) {
161
+ let handled = false
162
+ for (const [pid, entry] of [...ownedChildren.entries()]) {
163
+ handled = signalChildProcess(pid, signal, entry) || handled
164
+ }
165
+ return handled
166
+ }
167
+
117
168
  export async function readJsonFile(filePath, fallback = null) {
118
169
  try {
119
170
  const raw = await fs.readFile(filePath, 'utf8')
@@ -211,20 +262,45 @@ export async function releaseRunLock(lockFile, runId) {
211
262
  }
212
263
 
213
264
  export function signalProcessTree(pid, signal) {
214
- const normalizedPid = normalizePid(pid)
215
- if (normalizedPid <= 0) {
216
- return false
265
+ return signalChildProcess(pid, signal, { useProcessGroup: true })
266
+ }
267
+
268
+ export function watchParentProcess(onParentExit, options = {}) {
269
+ const expectedParentPid = normalizePid(options.parentPid ?? process.ppid)
270
+ if (expectedParentPid <= 0 || typeof onParentExit !== 'function') {
271
+ return () => {}
217
272
  }
218
273
 
219
- try {
220
- if (process.platform !== 'win32') {
221
- process.kill(-normalizedPid, signal)
222
- } else {
223
- process.kill(normalizedPid, signal)
274
+ let active = true
275
+ const intervalMs = Number.isFinite(Number(options.intervalMs))
276
+ ? Math.max(100, Number(options.intervalMs))
277
+ : 1000
278
+
279
+ const interval = setInterval(() => {
280
+ if (!active) {
281
+ return
224
282
  }
225
- return true
226
- } catch {
227
- return false
283
+
284
+ const currentParentPid = normalizePid(process.ppid)
285
+ if (currentParentPid === expectedParentPid && currentParentPid > 1) {
286
+ return
287
+ }
288
+
289
+ active = false
290
+ clearInterval(interval)
291
+ onParentExit({
292
+ expectedParentPid,
293
+ currentParentPid,
294
+ })
295
+ }, intervalMs)
296
+
297
+ if (typeof interval.unref === 'function') {
298
+ interval.unref()
299
+ }
300
+
301
+ return () => {
302
+ active = false
303
+ clearInterval(interval)
228
304
  }
229
305
  }
230
306
 
@@ -474,6 +550,9 @@ export async function runShellCommand({
474
550
  detached: process.platform !== 'win32',
475
551
  stdio: ['pipe', 'pipe', 'pipe'],
476
552
  })
553
+ const unregisterChild = registerOwnedChildProcess(child, {
554
+ useProcessGroup: process.platform !== 'win32',
555
+ })
477
556
 
478
557
  let stdout = ''
479
558
  let stderr = ''
@@ -506,6 +585,7 @@ export async function runShellCommand({
506
585
  })
507
586
 
508
587
  child.on('error', (error) => {
588
+ unregisterChild()
509
589
  if (killTimer) {
510
590
  clearTimeout(killTimer)
511
591
  }
@@ -524,6 +604,7 @@ export async function runShellCommand({
524
604
  })
525
605
 
526
606
  child.on('close', (code) => {
607
+ unregisterChild()
527
608
  if (killTimer) {
528
609
  clearTimeout(killTimer)
529
610
  }
@@ -10,7 +10,12 @@ import {
10
10
  getHeartbeatDecision,
11
11
  resolveHeartbeatConfig,
12
12
  } from './pi-heartbeat.mjs'
13
- import { signalProcessTree } from './pi-repo.mjs'
13
+ import {
14
+ registerOwnedChildProcess,
15
+ signalOwnedChildProcesses,
16
+ signalProcessTree,
17
+ watchParentProcess,
18
+ } from './pi-repo.mjs'
14
19
 
15
20
  function createJsonlReader(stream, onLine) {
16
21
  const rl = createInterface({ input: stream })
@@ -155,6 +160,9 @@ async function run() {
155
160
  detached: process.platform !== 'win32',
156
161
  stdio: ['pipe', 'pipe', 'pipe'],
157
162
  })
163
+ registerOwnedChildProcess(child, {
164
+ useProcessGroup: process.platform !== 'win32',
165
+ })
158
166
 
159
167
  let stderr = ''
160
168
  const events = []
@@ -197,6 +205,36 @@ async function run() {
197
205
  let continueAttempted = false
198
206
  let continueAccepted = false
199
207
  let continueRejected = false
208
+ let shutdownRequested = false
209
+ let shutdownReason = ''
210
+ let shutdownEscalationTimer = null
211
+
212
+ const requestShutdown = (reason, signal = 'SIGTERM') => {
213
+ if (shutdownRequested) {
214
+ return
215
+ }
216
+
217
+ shutdownRequested = true
218
+ shutdownReason = String(reason ?? 'adapter_shutdown')
219
+ closeAssistantLine()
220
+ signalOwnedChildProcesses(signal)
221
+ shutdownEscalationTimer = setTimeout(() => {
222
+ signalOwnedChildProcesses('SIGKILL')
223
+ }, 1000)
224
+ if (typeof shutdownEscalationTimer.unref === 'function') {
225
+ shutdownEscalationTimer.unref()
226
+ }
227
+ }
228
+
229
+ const stopWatchingParent = watchParentProcess(() => {
230
+ requestShutdown('parent_exit', 'SIGTERM')
231
+ })
232
+
233
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
234
+ process.on(signal, () => {
235
+ requestShutdown(signal, signal)
236
+ })
237
+ }
200
238
 
201
239
  const writeLive = (text) => {
202
240
  if (!streamTerminal) {
@@ -460,6 +498,26 @@ async function run() {
460
498
 
461
499
  await waitForAgentEnd()
462
500
 
501
+ if (shutdownRequested) {
502
+ console.log(JSON.stringify({
503
+ sessionId: request.sessionId ?? '',
504
+ sessionFile: request.sessionFile ?? '',
505
+ status: 'failed',
506
+ output: '',
507
+ notes: shutdownReason,
508
+ role: '',
509
+ model: requestedModel,
510
+ toolCalls: 0,
511
+ toolErrors: 0,
512
+ messageUpdates: 0,
513
+ stopReason: '',
514
+ loopDetected: false,
515
+ loopSignature: '',
516
+ terminalReason: 'adapter_shutdown',
517
+ }))
518
+ return
519
+ }
520
+
463
521
  if (heartbeatTimedOut) {
464
522
  const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
465
523
  const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
@@ -571,9 +629,13 @@ async function run() {
571
629
  terminalReason,
572
630
  }))
573
631
  } finally {
632
+ stopWatchingParent()
574
633
  if (heartbeatInterval) {
575
634
  clearInterval(heartbeatInterval)
576
635
  }
636
+ if (shutdownEscalationTimer) {
637
+ clearTimeout(shutdownEscalationTimer)
638
+ }
577
639
  stopReading()
578
640
  for (const current of pending.values()) {
579
641
  current.reject(new Error('RPC adapter shutting down'))
@@ -30,11 +30,13 @@ import {
30
30
  releaseRunLock,
31
31
  runVerification,
32
32
  runShellCommand,
33
+ signalOwnedChildProcesses,
33
34
  stageFiles,
34
35
  unstageFiles,
35
36
  updateRunLock,
36
37
  runVisualCapture,
37
38
  timestamp,
39
+ watchParentProcess,
38
40
  writeChangedFiles,
39
41
  writeSessionId,
40
42
  writeState,
@@ -49,13 +51,30 @@ import {
49
51
  import { runStartupPreflight } from './pi-preflight.mjs'
50
52
 
51
53
  let stopRequested = false
54
+ let shutdownEscalationTimer = null
52
55
 
53
- process.on('SIGINT', () => {
56
+ function requestStop() {
54
57
  stopRequested = true
55
- })
58
+ signalOwnedChildProcesses('SIGTERM')
59
+
60
+ if (!shutdownEscalationTimer) {
61
+ shutdownEscalationTimer = setTimeout(() => {
62
+ signalOwnedChildProcesses('SIGKILL')
63
+ }, 1000)
64
+ if (typeof shutdownEscalationTimer.unref === 'function') {
65
+ shutdownEscalationTimer.unref()
66
+ }
67
+ }
68
+ }
56
69
 
57
- process.on('SIGTERM', () => {
58
- stopRequested = true
70
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
71
+ process.on(signal, () => {
72
+ requestStop()
73
+ })
74
+ }
75
+
76
+ const stopWatchingParent = watchParentProcess(() => {
77
+ requestStop()
59
78
  })
60
79
 
61
80
  function sleep(seconds) {
@@ -1728,6 +1747,10 @@ async function main() {
1728
1747
  await appendLog(config.logFile, 'Stop requested by signal')
1729
1748
  }
1730
1749
  } finally {
1750
+ stopWatchingParent()
1751
+ if (shutdownEscalationTimer) {
1752
+ clearTimeout(shutdownEscalationTimer)
1753
+ }
1731
1754
  await updateRunOwnership(config, {
1732
1755
  status: stopRequested ? 'stopped' : 'finished',
1733
1756
  heartbeatAt: timestamp(),