@xingular/harness 1.6.0-unstable → 2.0.1-unstable

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cli.js +23375 -19883
  3. package/dist/sdk/cli.js +16 -1
  4. package/dist/sdk/harness.js +2 -1
  5. package/dist/sdk/prompt/reporting.d.ts +1 -1
  6. package/dist/sdk/prompt/reporting.js +8 -3
  7. package/dist/sdk/server/methods.js +12 -3
  8. package/dist/sdk/server/serve.d.ts +5 -2
  9. package/dist/sdk/server/serve.js +10 -3
  10. package/dist/sdk/server/socket.d.ts +28 -0
  11. package/dist/sdk/server/socket.js +122 -0
  12. package/dist/sdk/sessions/contracts.d.ts +14 -1
  13. package/dist/sdk/sessions/contracts.js +9 -0
  14. package/dist/sdk/sessions/extensionUi.js +4 -4
  15. package/dist/sdk/sessions/live.d.ts +101 -0
  16. package/dist/sdk/sessions/live.js +265 -0
  17. package/dist/sdk/sessions/registry.d.ts +12 -0
  18. package/dist/sdk/sessions/registry.js +3 -0
  19. package/dist/sdk/sessions/runner.d.ts +25 -3
  20. package/dist/sdk/sessions/runner.js +171 -31
  21. package/dist/sdk/sessions/sessions.d.ts +49 -0
  22. package/dist/sdk/sessions/sessions.js +165 -8
  23. package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
  24. package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
  25. package/dist/sdk/sessions/subagentService.js +1 -1
  26. package/dist/sdk/terminal/controller.js +1 -3
  27. package/dist/sdk/terminal/daemon.d.ts +44 -0
  28. package/dist/sdk/terminal/daemon.js +264 -0
  29. package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
  30. package/dist/sdk/terminal/liveTerminal.js +436 -0
  31. package/dist/sdk/terminal/standalone.d.ts +19 -1
  32. package/dist/sdk/terminal/standalone.js +75 -25
  33. package/dist/sdk/terminal/terminals.d.ts +43 -0
  34. package/dist/sdk/terminal/terminals.js +171 -14
  35. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  36. package/dist/sdk/terminal/tui/commands.js +24 -2
  37. package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
  38. package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
  39. package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
  40. package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
  41. package/dist/terminal-opentui.js +2013 -975
  42. package/dist/terminal-pi.js +9 -0
  43. package/docs/architecture/02-sessions-and-runs.md +61 -3
  44. package/docs/architecture/04-prompt.md +8 -6
  45. package/docs/architecture/10-server-protocol.md +23 -1
  46. package/docs/architecture/11-terminal.md +67 -3
  47. package/docs/architecture/12-storage.md +3 -1
  48. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  49. package/docs/architecture/15-remote-control.md +3 -1
  50. package/docs/architecture/README.md +5 -0
  51. package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
  52. package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
  53. package/docs/parity.md +4 -3
  54. package/docs/releases/2.0.1-unstable.md +63 -0
  55. package/docs/terminal-tui.md +10 -5
  56. package/package.json +1 -1
@@ -384,6 +384,15 @@ var questionRequestedPayload = z.object({
384
384
  multiSelect: z.boolean(),
385
385
  allowText: z.boolean(),
386
386
  userOnly: z.boolean().optional(),
387
+ /** The Pi extension dialog this question stands for (ADR-0083); a host may ignore it. */
388
+ dialog: z.object({
389
+ kind: z.enum(["select", "confirm", "input", "editor"]),
390
+ title: z.string().max(4e3),
391
+ message: z.string().max(4e3).optional(),
392
+ placeholder: z.string().max(4e3).optional(),
393
+ prefill: z.string().max(1e5).optional(),
394
+ options: z.array(z.string().max(4e3)).max(200).optional()
395
+ }).optional(),
387
396
  ...questionBatchFields
388
397
  }).superRefine(questionBatchRefinement).superRefine((value, context) => {
389
398
  const error = questionSketchError(value.uiSketch, value.options);
@@ -261,6 +261,58 @@ not positional message counts. A host can route `/history` to Harness `sessions.
261
261
  navigate the same saved session; it must not substitute a fork. Shared session catalog registration
262
262
  is approved but remains in progress; no completed cross-surface catalog is implied (PAR-008).
263
263
 
264
+ ## Live sessions (ADR-0083)
265
+
266
+ A *live session* is a Pi session the harness keeps open between the runs of one conversation, so a
267
+ later run reuses it instead of opening and loading a new one. `sessions/live.ts::LiveSessions` holds
268
+ them, one per `run.sessionId`.
269
+
270
+ - **Opt in.** `SessionRunInput.live: true` keeps the session open after the run and reuses an open
271
+ one. It is refused (`invalid_request`) for an unattended, child, nested, probe, improver or
272
+ reflection run, and refused with `session_busy` while another run of the session is using it.
273
+ Without `live` a run opens and disposes its own session, as before.
274
+ - **Pins.** A live session serves a run only when everything the run pins equals what the session
275
+ was opened with: account and its config folder, variant scope and version, variant extensions and
276
+ skill paths, the frozen capability set, working folder, workspace root and history folder, plan
277
+ and plan-repair flags, the allowed, gated and bound tool names, the stable system prompt, model,
278
+ effort, the run-scoped extension names and whether provider tools are asked. A supplied history
279
+ file and native session id must be the session's own. Any difference closes the idle session at
280
+ the turn boundary and the run opens a fresh one on the same native history.
281
+ - **Rebinding.** `piSessionFactory` reads the run's gate, bound tools, extension host and
282
+ plan flag through a binding that `PiSessionLike.rebind` replaces at the start of each run. The
283
+ run-scoped inline extensions (lineage join, subagents, compaction checkpoint, work slice, goal,
284
+ first-party hooks) are loaded in slots: a rebind removes the last run's handlers (Pi's `on`
285
+ returns the remover) and runs the next run's factory against the same extension. A session whose
286
+ run-scoped extension registers anything but handlers is not reusable. Between runs an extension
287
+ dialog is refused with `run_not_active`.
288
+ - **One writer of the history.** A top-level run of the same session that is not live (a command
289
+ run, for example), and `sessions.navigate` or `sessions.rename` on its history folder, close an
290
+ idle live session first so nothing holds a stale copy of the file; while the live session is
291
+ replying they are refused with `session_busy`.
292
+ - **Settling.** `runTurn` calls `TurnParams.release` instead of disposing the session. An idle live
293
+ session closes after 30 minutes (`SessionsOptions.liveIdleMs`), on `sessions.close`, or when the
294
+ harness closes.
295
+ - **Follow-ups.** `sessions.followUp { sessionId, text }` starts the next run at once when the
296
+ session is idle, or queues the text (at most 20) for after the active run. The queue follows the
297
+ queued-message dispatch rule above: it is sent after a reply that completed or that the person
298
+ stopped, and parked after a failure or any other interruption; a work-slice continuation runs
299
+ first. A follow-up run repeats the last live run's input with its own text and no Bash, images,
300
+ command, continuation or goal. `runs.steer` steers the active run as before.
301
+ - **Other run inputs a live client uses.** `newHistory: true` starts a new native history in
302
+ `sessionDir` (refused with `resume`, `continueFrom` or `command`). `hiddenNotes` (at most 8, each a
303
+ lower-case type and up to 20,000 characters) are saved as hidden messages before the request, as
304
+ the context update is. `planTools: 'active'` makes a plan run's frozen tool set the plan's own
305
+ (`planModeTools`), so the request carries only those tools; the default `gate` keeps every tool in
306
+ the request and refuses at the gate. An initial Bash command is accepted on a live turn that
307
+ resumes its history.
308
+ - **Question dialogs.** `question.requested` may carry `dialog { kind: select | confirm | input |
309
+ editor, title, message?, placeholder?, prefill?, options? }` when a Pi extension's dialog asked it
310
+ (`sessions/extensionUi.ts`). A host may ignore it; the answer is the usual `QuestionAnswer`.
311
+ - **Methods.** `sessions.live {}` → `{ sessions: LiveSessionStatus[] }`; `sessions.close { sessionId }`
312
+ → `{ closed, afterRun }` (a busy session closes when its run settles);
313
+ `sessions.followUp { sessionId, text }` → `{ runId } | { queued }`;
314
+ `sessions.followUp.clear { sessionId }` → `{ removed }`.
315
+
264
316
  ## Invariants
265
317
 
266
318
  - **INV-RUN-1** A run pins one account, one variant version, one capability set and one canonical
@@ -387,10 +439,16 @@ is approved but remains in progress; no completed cross-surface catalog is impli
387
439
  the tools running at that moment). Every live reply emits a
388
440
  heartbeat every 30 seconds, terminal replies included. A heartbeat older than five minutes while
389
441
  the owner process lives marks the run `interrupted` with reason `stalled`; a run that never emitted
390
- one keeps the 30-minute silence rule. A reply whose heartbeat keeps arriving but which produces no
391
- other event for fifteen minutes is a wedged turn, not a busy one: the terminal ends it through the
442
+ one keeps the 30-minute silence rule. A heartbeat of a run with no work slice carries zero slice
443
+ counters. A reply whose heartbeat keeps arriving but which produces no
444
+ other event for fifteen minutes is a wedged turn, not a busy one: the harness ends it through the
392
445
  native abort and records `interrupted` with reason `stalled`, and grants a reply whose oldest
393
- running tool has run for thirty minutes the same ending. A reply whose only running tools are
446
+ running tool has run for thirty minutes the same ending. `runTurn` applies this to every run
447
+ (a structured session, a live session, the client terminal, an unattended run) with the same
448
+ windows (`sessions/stallWatch.ts`); the in-process terminal's controller applies it to its own
449
+ reply. A live session whose turn stalled is closed rather than reused, its queued follow-ups
450
+ parked for the next session, and closed under the turn after thirty seconds if the abort did not
451
+ unwind it (ADR-0083). A reply whose only running tools are
394
452
  `wait_subagents` or `spawn_subagent` is judged by its children instead: each child has its own
395
453
  clock (its last event other than a heartbeat; its running tools), and a child quiet for the same
396
454
  windows is interrupted with reason `stalled`, so the wait returns and the parent carries on by
@@ -91,17 +91,19 @@ by ADR-0079: the reply is self-contained, outcome-first, explains the work and i
91
91
  limits, and gives the reader enough context to pick it up cold. It explicitly reports checks that
92
92
  were not run or failed; a person-owned next step appears only when one is needed. For substantial
93
93
  replies with distinct parts, it often uses content-named Markdown `#` and `##` headings; there is no
94
- fixed heading vocabulary or required template, and short replies may stay in prose. Tables are used
95
- for mappings and comparisons. Length follows complexity; prose comes first, with lists when the
96
- information is genuinely parallel, sequential or easier to compare. References and formatting
94
+ fixed heading vocabulary or required template, and short replies may stay in prose. Code fences name
95
+ the language; commands the person must run use a `bash` fence, while short references stay inline.
96
+ Tables are used for mappings and comparisons. Length follows complexity; prose comes first, with
97
+ lists when the information is genuinely parallel, sequential or easier to compare. References and formatting
97
98
  follow rules supplied by the active session or host. Progress updates stay in the standard `writing`
98
99
  section, not `reporting`. A subagent's reply gives its caller the concise work
99
100
  summary and key findings, with useful absolute paths and evidence; snippets appear only when exact
100
101
  text is load-bearing, and code merely read is not recapped. The section ships two examples (a
101
102
  headed screen change and a heading-free blocked publish), the shape-by-work table and a four-line
102
- pre-send list; the exact command for a blocked login is in `autonomy`. A person, application or mode
103
- format takes precedence. The section names no application, is never dynamic, and is never cut; its
104
- budget is 12,000 characters, with 4,853 currently delivered (ADR-0079).
103
+ pre-send list; the blocked-publish example puts its login command in a `bash` fence. A person,
104
+ application or mode format takes precedence. The section names no application, is never dynamic, and is never cut; its
105
+ budget is 12,000 characters, with 5,081 currently delivered (ADR-0079, amended for language-labeled
106
+ code fences).
105
107
 
106
108
  ## When a reply may end
107
109
 
@@ -96,6 +96,10 @@ harness → host { v: 1, id, call, params } host → harness { v: 1
96
96
  - Run streams also carry `tool.refused` with the call id, name, refusal code and bounded reason.
97
97
  Run records expose nullable `terminalCode`; `terminalReason` remains descriptive text. Older
98
98
  records and events may omit the code. Trajectory search exposes both fields (pages 02 and 08).
99
+ - `sessions.run` accepts `live: true` (ADR-0083): the Pi session stays open for the conversation's
100
+ next run. `sessions.live`, `sessions.close`, `sessions.followUp` and `sessions.followUp.clear`
101
+ manage live sessions (page 02). A run refused because the live session is replying fails with
102
+ `session_busy`.
99
103
  - Errors carry a stable `code`, a message and an optional hint (`core/errors.ts`).
100
104
 
101
105
  ## Browser configuration and extension (ADR-0082)
@@ -199,6 +203,23 @@ export instead of prompt submission (page 02). These retain tracked run lifecycl
199
203
  Saved-history helpers currently validate file scope but do not themselves fence active work;
200
204
  host adapters must not infer concurrency safety from method availability.
201
205
 
206
+ ## Socket transport and the long-lived harness (ADR-0083)
207
+
208
+ `server/socket.ts::serveSocket` serves the same framing on a Unix socket, readable and writable by
209
+ its owner only, with one peer per connection; a closed connection stops receiving events and
210
+ notices. `connectHarness` is the matching client. The long-lived harness (page 11) adds
211
+ `daemon.status` → `{ pid, startedAt, variantScope, live, detached }` and `daemon.stop` on its own
212
+ socket only.
213
+
214
+ | Method | Input | Reply |
215
+ |---|---|---|
216
+ | `terminal.detach` | `{ sessionId }` | `{ detached, activeRunId }`; a client terminal with a running reply keeps it without a window, anything else is disposed |
217
+ | `terminal.detached` | `{}` | `{ sessions: DetachedTerminal[] }`: session id, folder, history folder, account, active run, history file, time |
218
+ | `terminal.takeRelaunch` | `{ sessionId }` | the `TerminalRelaunch` the session asked for, or null |
219
+
220
+ `terminal.prepare` accepts `attach: true` to reconnect to a detached session of the same id, folder,
221
+ history folder and account. The private `terminal.live.*` routes are listed on page 11.
222
+
202
223
  ## Invariants
203
224
 
204
225
  - **INV-SRV-1** `src/server/methods.ts` is the contract for every non-Node host. Adding a method
@@ -207,7 +228,8 @@ host adapters must not infer concurrency safety from method availability.
207
228
  - **INV-SRV-2** A method handler calls the same in-process API a Node application uses. The server
208
229
  holds no logic of its own beyond parameter checking.
209
230
  - **INV-SRV-3** Events reach the host only after they are persisted and redacted (`INV-RUN-3`).
210
- - **INV-SRV-4** Changing the framing or `PROTOCOL_VERSION` is an architecture change.
231
+ - **INV-SRV-4** Changing the framing or `PROTOCOL_VERSION` is an architecture change. A transport
232
+ that carries the same framing (stdio, the owner-only socket) is not; a new one is listed here.
211
233
  - **INV-SRV-5** The harness works with none of the host calls supplied (`INV-EXT-2`).
212
234
  - **INV-SRV-6** After a change to the server, the CLI or startup, `npm run prove` must pass: it
213
235
  starts the server on a throwaway data root with no application present and exercises accounts,
@@ -144,13 +144,67 @@ The `/plugins` Proposals tab previews the complete stored files and generated ma
144
144
  confirmation. Accept and reject pass the preview hash through the control-key-protected socket;
145
145
  the proposal service checks it again. Acceptance leaves the package switched off and untrusted.
146
146
 
147
+ ## Client terminal and the long-lived harness (ADR-0083)
148
+
149
+ The OpenTUI frontend is a client of the harness by default: `tui/harnessRuntime.ts` implements the
150
+ same `TerminalRuntime` as `tui/runtime.ts`, so the screen code is shared.
151
+ `XINGULAR_TERMINAL_RUNTIME=inprocess` runs the in-process runtime instead, for plugins that need
152
+ their own terminal components (INV-TUI-8).
153
+
154
+ - **Parent.** `terminal/liveTerminal.ts::LiveTerminal`, created on the first `terminal.live.*`
155
+ call, runs each turn as `sessions.run { live: true }` of the terminal's own session id, account,
156
+ folder, history folder, pinned variant version (`{ dir, scope }`), launch sections, Pi's four
157
+ default tools plus the launch and switched-on world tools, and no gated tools, as the in-process
158
+ terminal's policy has it. It journals the live session's native events (a newer
159
+ `message_update` replaces an unread one; at most 4,000) and serves them by long poll.
160
+ - **Routes.** `terminal.live.state`, `.events { after, wait?, waitMs? }`, `.run { text, model?,
161
+ effort?, resume? | newHistory?, images?, plan?, goal?, command?, initialBashCommand?,
162
+ hiddenNotes? }`, `.steer`, `.followUp`, `.followUp.clear`, `.interrupt`, `.close { forget? }`,
163
+ `.rename`, `.pending`, `.pending.wait`, `.approve`, `.answer`, `.answers`. Once a client exists,
164
+ `terminal.pending|pending.wait|approve|answer|answers|interrupt` and remote control's send and
165
+ stop reach it instead of `TerminalController`. Pending decisions include the active reply's
166
+ subagents.
167
+ - **Client.** It keeps the model catalogue, settings, the skills, prompts and themes it lists, and
168
+ `ModeChoices`. `$name` skills and goal notes go as `hiddenNotes`; a plugin command is sent as its
169
+ `/<prefix>:<name>` text; `/compact`, `/tree` navigation and HTML export are command runs;
170
+ `/new`, `/resume`, fork and import change the file it resumes and close the live session; `/session`
171
+ takes a history file or the session id its runs record. A `!` command is the run's initial Bash.
172
+ A plan run sends only the plan's tools (`planTools: 'active'`). A mode with no saved model keeps
173
+ the current one. Closing the client never interrupts a reply; the parent decides.
174
+ - **What the window draws.** Besides the native events, the journal carries `harness_notice` (a
175
+ plugin's notice, status line, widget or title, and a failed reply's message), `harness_settled`
176
+ (the reply is over, with its goal state and history file, even when Pi's loop never started) and
177
+ `harness_dialog` / `harness_dialog_done` (a plugin's `select`, `confirm`, `input` or `editor`,
178
+ shown with the window's own dialog and closed when another surface answers first; the window's
179
+ question dock asks `terminal.pending.wait { ownDialogs: true }` and leaves them out). Before the
180
+ first turn the parent lists the trusted extensions' commands by loading them without a session;
181
+ one that cannot load stops the window's start, as in process. A session change or a steer first
182
+ asks the harness whether a reply really runs, so a window a moment behind its event stream does
183
+ not refuse `/new` or steer a reply that has ended.
184
+ - **Failure.** When the window's harness is unreachable twice in a row, the window closes cleanly.
185
+ An error nothing caught in the standalone host (a plugin's timer, say) closes the window first,
186
+ then exits 1. The long-lived harness logs such an error and keeps its other sessions.
187
+ - **Identity.** A client terminal's runs are harness runs: they record Pi's session id as
188
+ `nativeSessionId`, where an in-process terminal's record the history file. `/todo`,
189
+ `/trajectory`, `/context`, subagent rows and remote-control history match by the client's id, and
190
+ a relaunch resumes its history file.
191
+ - **Long-lived harness.** `xagent daemon start|stop|status` runs one harness per state folder
192
+ (`terminal/daemon.ts`): a lock (`harness.lock`) refuses a second, and `harness.sock` (owner-only)
193
+ serves the protocol with `daemon.status` and `daemon.stop`. `xagent --daemon` starts it when
194
+ needed, prepares its terminal there and runs the client frontend. When the window closes while a
195
+ reply runs, `terminal.detach` keeps the reply and its live session without a window until it
196
+ settles; `xagent attach [session]` reattaches a new window to it (`prepare { attach: true }`, same
197
+ folder and account). Without a running reply, closing the window ends the session as before.
198
+
147
199
  ## Invariants
148
200
 
149
201
  - **INV-TUI-1** OpenTUI is the only terminal frontend. Pi's `InteractiveMode` is never constructed
150
202
  ([ADR-0006](../decisions/ADR-0006-opentui-only-terminal.md)).
151
203
  - **INV-TUI-2** The harness owns screen composition, input, scrolling, command routing and plugin
152
204
  UI. Pi owns the execution runtime, native history and rich content components. A host owns the
153
- PTY and process supervision ([ADR-0001](../decisions/ADR-0001-owned-terminal.md)).
205
+ PTY and process supervision ([ADR-0001](../decisions/ADR-0001-owned-terminal.md)). In the default
206
+ client terminal the Pi session runs in the harness process as a live session (ADR-0083); the
207
+ window keeps composition, input and rendering.
154
208
  - **INV-TUI-3** Execution, account isolation, trust, authorization and run recording stay behind
155
209
  their existing layers. The terminal adds no second path for any of them; its runs are written
156
210
  through the controller and the run store.
@@ -167,8 +221,14 @@ the proposal service checks it again. Acceptance leaves the package switched off
167
221
  reconnects and resynchronizes after errors, and cancels its outstanding request on shutdown.
168
222
  - **INV-TUI-5** Every resource reload revalidates the original trusted selection; changed or
169
223
  revoked code needs review and a fresh terminal.
170
- - **INV-TUI-6** `sessions.run` stays the embedding and unattended path. The terminal keeps a
171
- persistent child instead of opening a session per turn.
224
+ - **INV-TUI-6** `sessions.run` stays the embedding and unattended path. The in-process terminal
225
+ keeps a persistent child instead of opening a session per turn; the client terminal (below) runs
226
+ every turn as a `sessions.run { live: true }` of one live session, which is equally persistent.
227
+ - **INV-TUI-8** A client terminal runs no agent loop, tool or Pi extension of its own. It draws the
228
+ live session from its native events and its own read of the history file, and every action is a
229
+ protected `terminal.live.*` call (ADR-0083). Plugin presentation that needs an in-process
230
+ terminal (custom components, key handlers, renderers, shortcuts) is unavailable there, as it is
231
+ in a host: extensions get the dialog, notice, status and text-widget subset.
172
232
  - **INV-TUI-7** Harness core commands use unprefixed names and are registered independently of
173
233
  feature-plugin command metadata. This includes `/doctor`, `/worktree`, `/trajectory`, `/subagents`
174
234
  and `/agents`; `/history` remains session-branch navigation, and `/mcp` remains a Connections
@@ -439,6 +499,10 @@ has run for thirty minutes ends the same way, and a reply waiting on an approval
439
499
  cut short. An interrupt, a stall included, also ends a settling reply's wait for its subagents
440
500
  (`terminal.subagents.delivery`).
441
501
 
502
+ The client terminal (ADR-0083) has no controller of its own: its replies are harness runs, so
503
+ `runTurn` heartbeats and applies the same stall rule (page 02, INV-RUN-14), and the live session of a
504
+ stalled reply is closed. The in-process runtime keeps the controller's watch.
505
+
442
506
  ## Subagent activity monitor
443
507
 
444
508
  During an active parent run, the terminal polls a private read-only monitor once per second. It
@@ -49,7 +49,9 @@ index.
49
49
  browser.json device-local BrowserConfig: backend, CDP, private-address policy and domains (13)
50
50
  browser/ isolated profiles, downloads, and owner-only extension-bridge.json pairing state (13, ADR-0082)
51
51
  notes-bridge.json the page bridge's port and token, kept so an app's script tag stays valid (13, PROP-0018)
52
- harness.log standalone terminal diagnostics
52
+ harness.log standalone terminal and long-lived harness diagnostics
53
+ harness.sock the long-lived harness's protocol socket, owner-only, while it runs (10, ADR-0083)
54
+ harness.lock { pid, startedAt, socket } of the one long-lived harness for this folder (10, ADR-0083)
53
55
  ```
54
56
 
55
57
  `/remote-control` (page 15) stores nothing here: its pairing and device keys live in memory and end
@@ -113,6 +113,10 @@ terminal command or page, a host screen.
113
113
  harness serves. It is not a host and owns no capability. A paired device acts through the same
114
114
  `Terminals` and controller paths as the terminal, and reaches no command, shell, link, plugin or
115
115
  account control.
116
+ - The terminal is a client of the harness by default (page 11, ADR-0083): it presents the same
117
+ capabilities over harness live sessions, and its plugin presentation is the host subset
118
+ (INV-TUI-8). Its runs, approvals and questions are the structured path's, so the terminal and a
119
+ host share one lifecycle. `XINGULAR_TERMINAL_RUNTIME=inprocess` keeps the in-process runtime.
116
120
  - The register of capabilities and their surfaces is [`docs/parity.md`](../parity.md). It is
117
121
  descriptive; the invariants below are binding.
118
122
  - A host keeps its own register of host-only surfaces. The Harness names no host surface.
@@ -78,7 +78,9 @@ Phone browser ── http://<tailscale or LAN address>:<port> ──> RemoteCont
78
78
  the data root or the state directory; a phone keeps its own device key in its browser storage.
79
79
  - **INV-RC-3** A paired device acts only through the terminal's existing paths: `Terminals.send`,
80
80
  and `TerminalController.control` for `terminal.interrupt`, `terminal.approve`,
81
- `terminal.answer`, `terminal.answers` and `terminal.pending.wait`. The validation those paths
81
+ `terminal.answer`, `terminal.answers` and `terminal.pending.wait`. For a client terminal
82
+ (ADR-0083) the same calls reach its `LiveTerminal`, and a message goes as a new reply or a
83
+ follow-up of the live session. The validation those paths
82
84
  apply (a busy session, a terminal draft, the rememberable check on "always allow", the policy
83
85
  recheck of a direct approval) applies unchanged.
84
86
  - **INV-RC-4** A decision is shown on the phone with its complete action text, never shortened,
@@ -109,6 +109,11 @@ resolve relevant drift by updating the implementation and specification together
109
109
 
110
110
  | ID | Where | What disagrees |
111
111
  |---|---|---|
112
+ | D-32 | Ordered and nested list wrapping, `opentui/markdown.ts` (`alignListItems`) | The transcript renderer was written to keep a list item's wrapped lines beneath its text (nested lists and ordered markers wider than one digit). A general OpenTUI `renderNode` drops the blank rows between paragraphs and headings, so the renderer is code-block-only (`codeBlockOnly`) and lists no longer reach `alignListItems`: they wrap as in the previous release, continuation rows at column 4 where 7 was wanted. The verifier no longer checks it. Open; needs either structured lists with the block spacing restored, or OpenTUI support for both. |
113
+ | D-31 | `scripts/verify-terminal.py --markdown-only` — resolved 2026-11-04 | The fixture sent 100 list items and its checks read the 40 visible rows. It now sends three short lists (starting at 1, 10 and 100) on a 120x72 screen. The suite also found the blank rows between paragraphs and headings missing with the new renderer, which is why that renderer is now code-block-only (D-32). |
114
+ | D-30 | `scripts/verify-terminal.py` focused suites — resolved 2026-11-04 | The `browser`, `palette`, `accounts` and `trajectory` suites asserted layouts the terminal no longer draws; the product was right. Browser: the skill's description wraps in the 40-column completion column ("Inspect a" / "browser fixture."). Palette: command names carry their owner (ADR-0061) and wrap, and a narrow palette shows three lines per row. Accounts at 120x40: the search field holds the typed text, so its placeholder is not shown. Trajectory at 120x40: a wide event row shows its summary, not the event type. The script now reads the name column down its rows, matches wrapped text and compares columns. |
115
+ | D-29 | `scripts/verify-terminal.py`, approval titles — resolved 2026-11-03 | The script expected `Approve ask_user?` and `Approve workspace-write?`; the terminal titles dialogs with display names (`AskUser`, `WorkspaceWrite`), and a harness run asks `ask_user` questions without an approval. The script now accepts either title, and the question with or without an approval. |
116
+ | D-28 | Client terminal record contract — resolved 2026-11-03 | The script asserted that a terminal run's `nativeSessionId` is a history file. Client terminal runs record Pi's session id, as host runs do (page 11, Identity); the script resolves either to the history file, and `/session` takes either. |
112
117
  | D-27 | Subagent compaction, pages 02 and 04 | A subagent compacted at the context threshold (run `5cd1d8aa`, 2026-11-01) produced a summary whose `## Goal` read "No specific research question or task was provided" while its split-turn section kept the brief. The model that continued still had the task through the turn context, but the summary's headline contradicts it. Not resolved here; the cause (the brief sits in the split part of the turn) is not confirmed. |
113
118
  | D-26 | App browser tool catalog, pages 13 and 14 (PAR-015) — rechecked with ADR-0082 | Brave mode now supplies all shared Harness browser tools except the App-only upload through `browserToolsForSession` (`agent-app/sidecar/src/app.ts:172-177`, `appTools.ts:91-126`); WebKit still filters unsupported snapshot/ref operations. The Rust `app.capabilities` response remains backend-agnostic (`src-tauri/src/app_tools.rs:111-123`), while its `TOOL_IDS` and shared contract contain the new Browser tools (`shared/contracts.ts:557-562`, `src-tauri/src/agents.rs:90-103`). Thus a connected Brave backend implements the advertised refs, but the capability catalog can still mark WebKit-unsupported tools available. Partly resolved; backend-aware capability reporting remains open. |
114
119
  | D-25 | Native WebKit interaction, page 13 — rechecked with ADR-0082 | The Brave adapter now uses the shared ref-based definitions, but the App's Rust backend still routes `browser_click`, `browser_type` and the App-only `browser_upload` by CSS `selector` (`agent-app/src-tauri/src/browser_workspace.rs:1493`, `agent-app/shared/appTools.ts:20`). The shared provider refuses those WebKit operations and never maps refs to selectors. Open: extend the contract to selectors or remove the native selector paths. |
@@ -4,9 +4,9 @@ title: Own the terminal frontend and keep Pi as the session runtime
4
4
  status: accepted
5
5
  created: 2026-10-17
6
6
  updated: 2026-10-17
7
- related: []
7
+ related: [ADR-0083]
8
8
  supersedes: null
9
- superseded_by: null
9
+ superseded_by: ADR-0083 (runtime only)
10
10
  ---
11
11
 
12
12
  # ADR-0001 — Own the terminal frontend and keep Pi as the session runtime
@@ -85,3 +85,10 @@ had been reached. The isolated credentials were deleted, the source sign-in rema
85
85
  and terminal restoration passed; see the [live evidence](../evidence/terminal-2026-10-17-followup/live-codex/README.md).
86
86
  Successful live streaming/tool acceptance and App renderer acceptance remain open. This evidence
87
87
  does not establish a released package or exhaustive platform/visual parity.
88
+
89
+ ## Partly superseded — 2026-11-03
90
+
91
+ [ADR-0083](ADR-0083-one-live-session-runtime.md) moved the terminal's persistent Pi runtime into
92
+ the harness as a live session that every front end reaches through server methods; the client
93
+ terminal is the default. The owned OpenTUI presentation decided here stays. The in-process runtime
94
+ this record describes remains available with `XINGULAR_TERMINAL_RUNTIME=inprocess`.
@@ -0,0 +1,256 @@
1
+ ---
2
+ id: ADR-0083
3
+ title: One live session runtime in the harness
4
+ status: accepted
5
+ created: 2026-11-03
6
+ updated: 2026-11-03
7
+ related: [ADR-0001, ADR-0035, ADR-0056, INV-RUN-1, INV-SRV-1, INV-SRV-4, INV-TUI-2, INV-RC-5, INV-PAR-1, INV-PAR-2, PAR-008, PAR-018]
8
+ supersedes: null
9
+ superseded_by: null
10
+ ---
11
+
12
+ # ADR-0083 — One live session runtime in the harness
13
+
14
+ ## Context
15
+
16
+ The harness runs a conversation in two different runtimes:
17
+
18
+ - **Structured sessions** (`xagent serve`, the App sidecar): `Sessions.run` opens a Pi session for
19
+ one run and disposes it when the run settles (`sessions/runner.ts`, `piSessionFactory` and the
20
+ `finally` of `runTurn`).
21
+ - **The terminal**: a Bun child keeps one Pi `AgentSessionRuntime` alive across turns
22
+ (`terminal/tui/runtime.ts`). Its gate, telemetry and run accounting go through
23
+ `terminal/piExtension.ts`, the session socket and `TerminalController`.
24
+
25
+ ADR-0001 chose the second runtime because calling `sessions.run` per prompt "recreates and disposes
26
+ the runtime per turn, losing persistent plugin UI/queue state". That reason still holds for the
27
+ structured path as it is built today.
28
+
29
+ Two runtimes mean many lifecycle features are built twice and drift: plan-outcome repair (D-22),
30
+ the queued-message rules, work slices (a headless run continues as a new run, a terminal reply in
31
+ place), stall guards (`terminal/stallWatch.ts`, now `sessions/stallWatch.ts`), `get_capabilities`, and about fifteen private
32
+ `terminal.*` routes that mirror public methods. A terminal session cannot be reached from the App
33
+ (PAR-018) and the two keep separate session catalogs (PAR-008). A run ends when its window closes.
34
+
35
+ Codex keeps the agent loop in one server process (`codex app-server`) and makes every front end a
36
+ client of it: threads, turns and items over JSON-RPC, approvals as server-to-client requests.
37
+ Claude Code keeps the loop inside the CLI process and lets other front ends spawn that process.
38
+ The harness's server protocol (page 10) and run events (page 02) already have Codex's shape
39
+ (session ≈ thread, run ≈ turn, run events ≈ items). The missing part is a live session the server
40
+ owns across runs.
41
+
42
+ ## Decision
43
+
44
+ The harness owns one session runtime. A *live session* is a Pi session the harness keeps open
45
+ across runs of the same native conversation. Every front end, including the terminal, reaches it
46
+ through server methods. The terminal keeps its owned OpenTUI presentation (the screen half of
47
+ ADR-0001); only its private runtime goes away.
48
+
49
+ Work proceeds in phases; each lands with its spec pages and checks.
50
+
51
+ 1. **Live sessions.** A `LiveSessions` service in `src/sessions/` keeps a Pi session open between
52
+ runs. `Sessions.run` reuses it when the new run's pins match the open session, and opens a fresh
53
+ one otherwise. Unattended triggers (`probe`, `improver`, `reflection`) and refinement keep the
54
+ per-run open and dispose. A caller opts in with `sessions.run { live: true }`; the first such run
55
+ opens the live session. Additive methods: `sessions.live`, `sessions.close`, `sessions.followUp`,
56
+ `sessions.followUp.clear`. Steering stays `runs.steer`, and replay stays `runs.since`, which
57
+ already exist. The App adopts this first.
58
+ 2. **The terminal as a client.** The OpenTUI child talks to the harness over the protocol on a
59
+ local socket and renders from run events. The private `terminal.*` mirrors give way to the
60
+ public methods, scoped by the caller identity the control key proves. The old path stays behind
61
+ a flag until production PTY acceptance at 40x20, 80x24 and 100x30, then is removed.
62
+ 3. **Optional daemon.** Decided after phase 2: a long-lived `xagent serve --daemon` on a Unix
63
+ socket under the state directory, with one writer per data root, so a run outlives its window,
64
+ and remote control belongs to a live session rather than a terminal (PAR-018).
65
+
66
+ ### Phase 1 design: what a run binds
67
+
68
+ A run pins its account, variant version and capability set (INV-RUN-1), so a live session may only
69
+ serve a run whose pins equal the session's. The reuse key is: account, variant version, frozen
70
+ capability fingerprint, working folder and workspace root, plan flag, allowed tool names, the
71
+ stable system prompt, and the loaded plugin set. Model and effort changes go through Pi's own
72
+ setters. Any other difference closes the live session at the turn boundary and opens a new one on
73
+ the same native history, as `/variant` already does (ADR-0065).
74
+
75
+ Today these are created per run and baked into the Pi session when it opens
76
+ (`sessions/sessions.ts`, the `params` and `factory` call; `sessions/runner.ts`,
77
+ `piSessionFactory`). Each must read a *current run binding* that `LiveSessions` swaps at the start
78
+ of every run:
79
+
80
+ | Bound per run today | Where | Becomes |
81
+ |---|---|---|
82
+ | The gate (`checkDetailed`, workspace scope requests, plan refusals) | `gatedExtension` in `piSessionFactory` | A session-level `tool_call` handler that asks the current binding's gate |
83
+ | Custom tool specs bound to the run (`spec.execute`) | `customTools` in `piSessionFactory` | Session-level tool definitions that call the current binding's spec of the same name |
84
+ | The extension host (`ask`, `notice`) | `bindExtensions` in `piSessionFactory` | A host that forwards to the current run, and refuses between runs |
85
+ | `runLineageJoin(root, runId)`, `subagents.extension(runId)` | `params.inline` | Extensions that read the current run id |
86
+ | Compaction checkpoint (`initialTodo`, plan instructions) | `params.inline` | Reads the current run's todo and plan flag |
87
+ | Work-slice guard and goal loop (their state and controller) | `params.inline` | Installed once, enabled and fed by the current binding |
88
+ | `onRefusal`, `activatedPlugins`, the heartbeat and `settle` | `params`, `runTurn` | Already per run in `runTurn`; unchanged |
89
+
90
+ `runTurn` stops disposing the session; `LiveSessions` disposes it on `sessions.close`, on a pin
91
+ change, after an idle timeout, and at harness shutdown. A live session runs one run at a time; a
92
+ second `sessions.run` while one is active is refused, or delivered as a steer or follow-up when the
93
+ caller asks for that.
94
+
95
+ ## Alternatives and rejection reasons
96
+
97
+ - **Keep two runtimes.** Every lifecycle feature keeps its second copy, and the drift and parity
98
+ lists keep growing.
99
+ - **Claude Code's model** (the terminal process is the engine, hosts spawn it). Simple for one front
100
+ end, but the App, the phone surface and evaluation would each drive a terminal process, and the
101
+ structured path would remain a second runtime.
102
+ - **Make the terminal call today's `sessions.run`.** Rejected by ADR-0001 for the same reason it
103
+ gives: a per-run runtime loses queues and persistent plugin UI. Phase 1 removes that reason first.
104
+ - **A rewrite in one release.** Too large to verify; each phase must pass the full checks and, for
105
+ phase 2, production PTY acceptance.
106
+
107
+ ## Consequences and risks
108
+
109
+ - This is a 2.0-level change: it adds methods and a socket transport (INV-SRV-1, INV-SRV-4),
110
+ changes who owns execution in the terminal (INV-TUI-2), and moves remote control's lifetime
111
+ (INV-RC-5). Pages 02, 10, 11, 14 and 15 change as each phase lands.
112
+ - Every inline extension must be safe to keep across runs. A missed per-run closure would leak one
113
+ run's state into the next; phase 1 tests run two turns on one live session and check each run's
114
+ events, refusals and records belong to that run only.
115
+ - Streaming over IPC adds latency to the terminal; phase 2 measures first-delta latency before and
116
+ after.
117
+ - Live checks may use real model requests only with the owner's permission (granted for this work
118
+ with a Codex account on Luna 6, or the OpenCode account on DeepSeek V4.1 Flash), never against
119
+ `~/.codex`, `~/.claude` or `~/.pi`.
120
+
121
+ ## Evidence — phase 1, 2026-11-03
122
+
123
+ Implemented in `src/sessions/live.ts`, `src/sessions/runner.ts` (`rebind`, `release`, run slots),
124
+ `src/sessions/sessions.ts` (`live`, follow-ups) and `src/server/methods.ts`. `src/sessions/live.test.ts`
125
+ covers reuse across runs, a pin change, the per-run default, busy refusal, queued, idle and parked
126
+ follow-ups, close and shutdown, refusal for unattended runs, and a rebind of a real Pi session (an
127
+ `input` handler and a bound tool replaced by the next run's) with no model request. Harness:
128
+ 1,803 tests passed, `npm run prove` passed. App sidecar typecheck and 89 tests passed against this
129
+ tree.
130
+
131
+ Live check with owner permission, account OpenCode, model `opencode-go/deepseek-v4.1-flash`, a
132
+ throwaway data root and the installed sign-in:
133
+
134
+ | Run | Live | Status | Native session | Session ready (ms) | Input tokens | Cache read |
135
+ |---|---|---|---|---|---|---|
136
+ | b1 | no | completed | `01a19f83-2ef3…` | 1002 (cold start) | 4796 | 0 |
137
+ | b2 | no | completed | same | 73 | 73 | 4736 |
138
+ | l1 | yes | completed | `01a19f83-408c…` | 75 | 4796 | 0 |
139
+ | l2 | yes | completed | same, reused | 52 | 73 | 4736 |
140
+ | l3 | yes, steered | completed | same, reused | 59 | 326 | 9472 |
141
+
142
+ The live session served three runs (`runs: 3`). Reuse keeps the provider cache exactly as the
143
+ per-run path does. A steer sent after the first delta was recorded as `message.steered` and the
144
+ model answered it in the same run. A reused session is ready slightly sooner (52–59 ms against 73 ms
145
+ warm); the larger gains are steering, follow-ups and plugin state that survive between runs.
146
+
147
+ ## Evidence — phases 2 and 3, 2026-11-03
148
+
149
+ **Phase 2, the terminal as a client.** `XINGULAR_TERMINAL_RUNTIME=harness` selects
150
+ `terminal/tui/harnessRuntime.ts`; the parent side is `terminal/liveTerminal.ts`. The in-process
151
+ runtime stays the default. Decision recorded with the owner: plugin presentation that needs an
152
+ in-process terminal is unavailable in client mode (INV-TUI-8), as it is in a host.
153
+
154
+ - Tests with real Pi and a scripted model: `terminal/liveTerminal.test.ts` (two turns on one live
155
+ session, the native event journal, a plan-mode `ask_user` answered through the decision routes,
156
+ busy refusal) and `terminal/tui/harnessRuntime.socket.test.ts` over the real session socket (the
157
+ mirrored transcript, one native session, the in-process tool set, `/todo` and `/trajectory`
158
+ lookup, the system prompt, a plan question, `/new` on a new file).
159
+ - Live, OpenCode account, `opencode-go/deepseek-v4.1-flash`, real OpenTUI frontend in a PTY at
160
+ 100x30, 80x24 and 40x20: five turns and a queued follow-up each, all completed on one native
161
+ session. Mean time from Enter to the reply on screen for turns 2–5: client 1.60 s, 1.79 s and
162
+ 1.37 s against in-process 1.59 s at 100x30, within the model's own variation. The fixed overhead
163
+ from Enter to the harness run starting is about 30 ms against about 5 ms in process. Startup to
164
+ ready: 2.2–2.9 s client, 2.6–2.8 s in process.
165
+ - Found and fixed on the way: the client first lacked the world tools and had `grep`, `find` and
166
+ `ls`, which the in-process terminal does not activate; a new conversation re-adopted the old file
167
+ from stale state; a mode with no saved model sent none; child runs inherited `live: true` (caught
168
+ by the App's sidecar test).
169
+ - Not passing yet: the production PTY suite stops early in client mode on a record-contract
170
+ difference (Known drift D-28).
171
+
172
+ **Phase 3, the long-lived harness.** `server/socket.ts`, `terminal/daemon.ts`, `Terminals.detach`
173
+ and `prepare { attach }`, CLI `xagent daemon start|stop|status`, `xagent --daemon`, `xagent attach`.
174
+
175
+ - Tests: `terminal/daemon.test.ts` (one lock per state folder and a stale lock replaced, the
176
+ protocol on an owner-only socket to several connections, a full daemon run that prepares over its
177
+ socket, reports and stops); `harnessRuntime.socket.test.ts` (a reply keeps running when its
178
+ window closes and a new window reattaches and sees it finish; a detached session ends when its
179
+ reply settles).
180
+ - Live, same account and model: `xagent daemon start`, a `--daemon` window asked for 600 numbers,
181
+ the window was hung up (SIGHUP) mid-reply; it printed the `xagent attach <id>` line, `daemon
182
+ status` reported one live session and one reply without a window, `xagent attach` showed the
183
+ reply finish 3.6 s later, and the run recorded `completed`. Afterwards no live or detached
184
+ session remained and `daemon stop` removed the lock and socket.
185
+ - Found and fixed: the daemon took its lock before the state folder existed.
186
+
187
+ Checks at the end: harness typecheck clean, 1,820 tests pass, `npm run prove` passes; App sidecar
188
+ typecheck and 89 tests pass against this tree, `check:layers` flags none of the new files. Open
189
+ and unrelated to this change: the App's `soloCommands.test.ts` expects Solo to handle `/goal`
190
+ (PAR-017), and the App's root typecheck reports button-prop errors in its UI.
191
+
192
+ ## The client terminal is the default — 2026-11-03
193
+
194
+ The owner asked to finish the client terminal and make it the default once it works. `xagent` now
195
+ runs the client terminal unless `XINGULAR_TERMINAL_RUNTIME=inprocess` is set. The in-process
196
+ runtime stays for plugins that need their own terminal components.
197
+
198
+ What finishing it took, each with a test:
199
+
200
+ - Parity of what a person sees: plugin notices, status lines, widgets and titles (`harness_notice`);
201
+ a failed reply's message; a settle marker when Pi's loop never started; plugin `select`,
202
+ `confirm`, `input` and `editor` shown with the window's own dialogs (`question.requested.dialog`);
203
+ plugin commands listed before the first turn and sent by their owner-qualified route; a plugin
204
+ that cannot load stops the window's start; `/goal` run as a harness goal with its state shown.
205
+ - Parity of what a turn does: plan runs carry only the plan's tools (`planTools: 'active'`); `!`
206
+ commands run as the turn's initial Bash on a resumed history; `/session` takes a file or a session
207
+ id; `/new` and steering ask the harness whether a reply really runs before refusing.
208
+ - Lifecycle: `/stop` and Esc return at once and Pi's abort finishes behind them; the window closes
209
+ cleanly when its harness is gone; an uncaught error in the host closes the window before exiting
210
+ 1; a frontend left after its terminal hangs up is ended after three seconds (this also leaked
211
+ in-process windows); the long-lived harness survives one plugin's uncaught error.
212
+ - A harness fix every host gets: a provider request that timed out and was retried and answered no
213
+ longer marks the run failed.
214
+
215
+ Evidence:
216
+
217
+ - Harness: typecheck clean, 1,829 tests pass, `npm run prove` passes; App sidecar typecheck and 89
218
+ tests pass; `check:layers` flags none of the new files.
219
+ - Production PTY suite (`scripts/verify-terminal.py`, its local provider, no real requests) in the
220
+ default client terminal: the full run passes at 40x20, 80x24, 100x30 and 120x40, and so do the
221
+ welcome, click-focus, composer, file-completion, history, resume, todo, questions, jump-latest,
222
+ help, command-completion, attachments, dialog-paste, thinking, queue and compaction suites. The
223
+ script now accepts what differs by design: either approval title, a question asked with or without
224
+ an approval, either `nativeSessionId` contract, a plugin overlay the client cannot draw, a size
225
+ shown in bytes, a varying usage-event count, and a long command name wrapped after its owner
226
+ prefix. Browser, palette, accounts at 120x40 and trajectory at 120x40 fail the same way on the
227
+ previous commit (Known drift D-30); markdown fails only with uncommitted work outside this change
228
+ (D-31). The full run's `/stop` step, which allows 0.8 seconds, failed twice while the machine's
229
+ load average was above 20 and passed every time below it.
230
+ - Live, OpenCode account, `opencode-go/deepseek-v4.1-flash`, real frontend at 100x30: five turns
231
+ and a queued follow-up on one native session; `/stop` and Esc recorded the reply interrupted in
232
+ 33–77 ms against 35–62 ms in process; turn latency within the provider's variation. One turn hit
233
+ a ten-minute provider timeout and its retry answered; the run now records `completed`. With
234
+ `--daemon`, a window hung up mid-reply left the reply running; `xagent attach` showed it complete.
235
+
236
+ ## Closing the migration — 2026-11-04
237
+
238
+ - **Stall guard for every harness run.** The terminal's stall rule (15 minutes without an event, 30
239
+ with a tool running; ADR-0054, ADR-0078) lived only in the in-process controller, so a client
240
+ terminal reply, a live session and an App turn had only Pi's own request timeout (about ten
241
+ minutes, retried). `runTurn` now applies it to every run (`sessions/stallWatch.ts`, moved from
242
+ `terminal/`): the run settles `interrupted` with `stalled`, the native turn is aborted, and a live
243
+ session is closed (queued follow-ups parked), or closed under the turn if the abort has not
244
+ unwound it after 30 seconds. A pending approval or question, and a wait for subagents (each child
245
+ has its own guard), restart the clock. Pi's request timeout is unchanged: a shorter one multiplies
246
+ with the retry policy and could cut a slow reasoning model's first token, so the progress rule is
247
+ the cap.
248
+ - **Heartbeat for every run.** `runTurn` emitted `heartbeat` only for work-slice runs, against
249
+ INV-RUN-14; a live reply with a quiet tool for 30 minutes could be settled `owner_gone`. It is now
250
+ unconditional (slice counters zero without a slice).
251
+ - **Verifier.** The four D-30 suites asserted stale layouts, not product faults, and are fixed; the
252
+ `/stop` step polls the run record up to 8 seconds instead of reading for 0.8; the in-process
253
+ fallback's full run passes at 80x24 (the deny/approval-cancel stop did not reproduce). D-31 (the
254
+ markdown suite) is the other change's fixture overflowing the screen. Tests: `sessions/stall.test.ts`
255
+ (8) cover the guard and heartbeat.
256
+ - Not done: PAR-018 (remote control bound to a live session). Notes in HANDOFF.md.