@xingular/harness 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,344 @@
1
+ # Harness terminal frontend
2
+
3
+ Status: implemented in the working tree; publication is separate. Last updated: 2026-10-18.
4
+ Decision: [ADR-0001](decisions/ADR-0001-owned-terminal.md).
5
+
6
+ ## Native subagents (2026-10-18)
7
+
8
+ Interactive terminal turns use the same `harness.subagents` service as SDK callers. The bridge
9
+ registers the current run's effective tools, resolved model, thinking level and pinned account
10
+ through its private control channel; a read-only terminal narrows child capabilities and broker
11
+ access. `spawn_subagent` returns immediately, `wait_subagents` joins selected children, and
12
+ `wait: true` on spawn returns an inline result. Native receipts deduplicate automatic delivery.
13
+ The terminal joins outstanding children before its final reply, projects child approvals/questions
14
+ with their original run IDs, and stops descendants when interrupted or disposed. Connected apps
15
+ need not be enabled. `TerminalPrepareInput.subagents: null` disables the capability.
16
+
17
+ Real scripted Pi execution over the actual terminal socket verifies result delivery and decision
18
+ routing. It does not establish live-provider behavior or new visual UI parity. See
19
+ [ADR-0003](decisions/ADR-0003-shared-asynchronous-subagents.md) for recovery and lifecycle details.
20
+
21
+ ## Ownership and process boundary
22
+
23
+ Harness owns its interactive terminal application in [`src/terminal/tui/`](../src/terminal/tui/).
24
+ [`TerminalApplication`](../src/terminal/tui/application.ts) owns terminal lifecycle, screen
25
+ composition, focus, input dispatch, scrolling, editor mounting, dialogs and keyboard actions.
26
+ It does not construct Pi's `InteractiveMode`. Pi 1.2.0 supplies the persistent agent session,
27
+ model and auth runtime, native JSONL history, tool execution and rich content components.
28
+
29
+ The parent process still calls `Terminals.prepare()`, spawns its returned specification in a
30
+ PTY or inherited terminal, and eventually calls `dispose()`. Its default command is the active
31
+ Node runtime followed by the existing Harness CLI and `terminal-frontend`; it requires no global
32
+ `pi` command. `TerminalAssets` remains `{ node, cli, piExtension }`, and the existing
33
+ `prepare`, `send`, `state`, `input` and `dispose` contracts remain in place. The `piExtension`
34
+ path remains the final prepared extension argument. The owned runtime recognizes that bridge
35
+ position and loads its trusted, bundled factory inline after the selected disk extensions.
36
+
37
+ ```text
38
+ Harness parent: account + compiled context + trusted resources + run writer
39
+ | launch specification / existing session socket
40
+ Harness terminal child: TerminalApplication + persistent Pi AgentSessionRuntime
41
+ | typed native events / plugin UI callbacks
42
+ Harness screen: transcript, with the product lockup above it until the first message, above a dock
43
+ (status, editor, footer), with dialogs and overlays
44
+ ```
45
+
46
+ [`runtime.ts`](../src/terminal/tui/runtime.ts) builds a public `AgentSessionRuntime` around
47
+ `createAgentSession`, `ModelRuntime`, `SessionManager` and `DefaultResourceLoader`. Events and
48
+ subscriptions survive session replacement. Before replacement it detaches extension UI; after
49
+ replacement it binds a fresh `ExtensionUIContext` in `tui` mode. Presentation reads frozen
50
+ `TerminalSnapshot` values; submission, interruption, navigation, decision polling and decision
51
+ responses pass through `TerminalRuntime`. Native components retain a scoped SDK adapter for
52
+ rich Markdown, tool and extension rendering. Native history remains owned by
53
+ Pi; Harness run records remain written through the existing controller/run store.
54
+
55
+ A persistent child retains queues, session-local extension state and interactive UI between
56
+ prompts. Reusing `sessions.run` for each prompt would recreate and dispose a session for every
57
+ turn and overlap the terminal controller's run lifecycle. That API remains the embedding and
58
+ unattended execution path.
59
+
60
+ ## Screen layout
61
+
62
+ [`screen.ts`](../src/terminal/tui/screen.ts) chooses the screen from Pi's `tuiMode` in the
63
+ account's `settings.json`; the default is `fullscreen`. Fullscreen uses Pi 1.2.0's
64
+ `TuiAltScreen` with a layout root: a `ScrollView` holds the transcript (with the product lockup
65
+ above it) and fills the terminal, and a `VStack` dock sits below it. The dock holds the working
66
+ line, queued messages, widgets, the editor with its status band, and the footer. Terminal notices
67
+ (mode changes, errors, the keyboard list) are rows in the transcript, so they scroll with the
68
+ conversation and clear with its session. The dock stays on the last rows as the transcript grows,
69
+ and scrolling moves only the transcript. When the screen is short, the prompt and status keep
70
+ their full height, and queued messages and widgets show their first rows and scroll for the rest. Pi's `tui.altScreen.*` bindings
71
+ (PgUp/PgDn, Ctrl+Home/End and the rest), mouse wheel, selection and search apply. The scrollbar,
72
+ wheel step and copy-on-select behaviour are read from Pi's `fullscreen*` settings. Submitting a
73
+ message and changing session return the transcript to its latest row. The product lockup
74
+ (`welcome.ts`) is the first row of the transcript only while the current chat has no user
75
+ message, so a resumed session with history starts without it and a new, cleared or switched-to
76
+ empty chat shows it again. A header set by a trusted extension with `setHeader` replaces the
77
+ lockup and is never hidden. Hiding the lockup shrinks the rendered rows, so the main screen clears
78
+ and reprints once when the first message is sent.
79
+
80
+ `regular` uses the main screen with the same components flowing in order, and the terminal keeps
81
+ the scrollback. Leaving fullscreen with `fullscreenExitOutput: "transcript"` (Pi's default) copies
82
+ the rendered conversation into the normal scrollback once the alternate screen is gone; a session
83
+ with no messages writes nothing, and a started chat copies it without the product lockup. Other
84
+ exit-output values are not implemented and write nothing.
85
+ The decision and its consequences are recorded in
86
+ [ADR-0004](decisions/ADR-0004-fullscreen-terminal-layout.md).
87
+
88
+ ## Plan mode and thinking keys
89
+
90
+ Shift+Tab switches the terminal between normal and plan mode. The switch is announced in the
91
+ transcript. Plan mode narrows the session to read-only inspection (`read`, `grep`, `find`, `ls`),
92
+ questions (`ask_user`, `ask_user_question`) and recall (history and Wiki reads, `memory_facts`). Edits,
93
+ writes, shell commands, memory writes, subagents and connected-app tools are withdrawn until plan mode
94
+ is switched off, and switching back restores the tools the session had. Plan mode belongs to the open
95
+ conversation: a new or resumed session starts in normal mode.
96
+
97
+ The model is not yet told about the mode through the prompt; it only sees the narrower tool set. That
98
+ instruction belongs in the prompt compiler, the one place model instructions are built. Shift+Up and
99
+ Shift+Down step the thinking level within the levels the model supports, and do not wrap.
100
+
101
+ ## Reference and reproduced presentation
102
+
103
+ The reference is **Oh My Pi 18.11.3**, identified from npm and GitHub on 2026-10-17; its release
104
+ publication timestamp was 2026-10-16T21:45:36Z. Inspection used the shipped npm package sources,
105
+ not an arbitrary checkout of upstream main. The source repository is
106
+ [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi).
107
+
108
+ | Inspected artifact | SHA-256 |
109
+ |---|---|
110
+ | `@oh-my-pi/pi-coding-agent@18.11.3` npm tarball | `b4a4eaf8057a7591f4ca83be293780489fd72b174e6a629d4704320165177f09` |
111
+ | `@oh-my-pi/pi-tui@18.11.3` npm tarball | `d28351b1309474eaf02f9be0fdd59c099ba9b8baeec811ef61349facf7a3565c` |
112
+ | Native `omp` artifact used for observation | `d518310d647d467a2c5c3f2d38e34489a761996d766395c03b37c61a2203d02b` |
113
+
114
+ In the extracted `pi-tui` package, `src/theme/defaults/titanium.json` establishes the titanium
115
+ palette and `src/prompt/welcome.ts` establishes the five-row Pi logo, exact diagonal RGB stops
116
+ and indexed-color fallback, centered lockup and narrow-width behavior. Harness adapts these
117
+ into [`theme.ts`](../src/terminal/tui/theme.ts) and [`welcome.ts`](../src/terminal/tui/welcome.ts),
118
+ with its own product name, package version and existing-command tips. The text-only status band in
119
+ [`status.ts`](../src/terminal/tui/status.ts) shows the project name, branch, account/model/effort,
120
+ the last completed response's output rate and current-session input/output totals. Input totals add
121
+ cache reads and exclude cache writes. The prompt
122
+ frame is implemented in [`editor.ts`](../src/terminal/tui/editor.ts): two full-width neutral
123
+ rules around the prompt, a dim `❯` glyph, a `Try "…"` placeholder before the first prompt of a
124
+ launch, and a `? for shortcuts` footer line above the band. The frame follows the observed
125
+ composer of Claude Code 2.1.302, reproduced from its visible values; no code is copied, and
126
+ `?` opens the same list as `/hotkeys`. Startup selects the Harness titanium presentation
127
+ without rewriting the user's saved theme settings. The shipped OMP light palette is also
128
+ available as `light`; native rich components and the status band switch together. Missing
129
+ Pi-specific color tokens use documented Harness fallbacks in `theme.ts`.
130
+
131
+ The frontend exposes capabilities already present in Harness/Pi. It does not import OMP's
132
+ agent runtime, tools, providers, MCP implementation, task system or other product features.
133
+ Harness-owned composition reuses public Pi Markdown, message, tree/model selector and rich
134
+ tool components. Tool diffs, expandable output, images and extension renderers therefore retain
135
+ the existing Pi interpretation. This is an implementation of the requested terminal presentation;
136
+ complete visual or behavioral parity with every OMP surface has not been established.
137
+
138
+ Full upstream copyright and MIT permission notices are in [NOTICE.md](../NOTICE.md).
139
+
140
+ ## Runtime compatibility adapter
141
+
142
+ Most integration uses public Pi exports. [`piAdapter.ts`](../src/terminal/tui/piAdapter.ts)
143
+ contains the remaining implementation dependencies and requires exactly Pi **1.2.0** before
144
+ loading them:
145
+
146
+ | Internal dependency | Reason |
147
+ |---|---|
148
+ | `dist/extensions/index.js` builtin inventory | Preserve selected `llama.cpp`, `codemode`, `tool-search` and MCP builtins with the CLI's replacement semantics |
149
+ | `dist/modes/interactive/theme/theme.js:setThemeInstance` | Keep public native rich components on the same active theme as the Harness screen |
150
+ | `dist/core/tools/renderers/index.js:withBuiltInRenderers` | Preserve existing read/edit/bash/write formatting without replacing tool execution |
151
+ | `dist/utils/clipboard.js` and `clipboard-image.js` read helpers | Preserve explicit user clipboard paste behavior; these are not called during inspection or startup |
152
+
153
+ The adapter is initialized once; its global theme setter and renderer lookup are synchronous
154
+ thereafter. A Pi dependency upgrade must update this adapter deliberately and rerun the
155
+ terminal compatibility tests. These module imports do not instantiate `InteractiveMode`.
156
+
157
+ ## Accounts, history and resource trust
158
+
159
+ The child requires the prepared absolute `PI_CODING_AGENT_DIR`; there is no fallback to a
160
+ personal Pi profile. Provider authentication and model catalogs use that account's files.
161
+ The selected account stays pinned. The existing `/accounts` command requests a parent-managed
162
+ restart to use a different account while resuming the conversation.
163
+
164
+ An initial `--session` or `/resume` target must resolve inside the prepared history directory,
165
+ including symlink targets, and its stored absolute working folder must match the terminal's
166
+ canonical working folder. `/import` may copy an explicitly selected external native JSONL file
167
+ only when its recorded working folder matches. A caller cannot override a foreign recorded
168
+ folder to borrow the current project's permissions. Lifecycle navigation and reload refuse an
169
+ active response; cancel it first. Opening a conversation from another working folder requires
170
+ opening that folder's terminal and preparing a fresh context/resource scope.
171
+
172
+ Navigation revalidates resource trust before tearing down the current runtime. The resource
173
+ loader suppresses its own discovery and uses only the prepared extensions,
174
+ skills, prompts and themes. Before **every** loader reload, including startup and native
175
+ session replacements, the child calls protected `terminal.resources`. The parent reruns
176
+ `Plugins.loading` for the original scope and compares selected paths and content/trust
177
+ identities with the preparation snapshot. Changed, detached or revoked code requires review
178
+ and a fresh terminal. Explicit `/reload` validates before invalidating the current session.
179
+ The Harness approval extension remains last, so it gates the final tool arguments.
180
+
181
+ ## Private control and user decisions
182
+
183
+ The environment contains a session socket address and bearer token. The frontend claims a
184
+ second key once with `control.claim`, before plugin code loads. [`control.ts`](../src/terminal/control.ts)
185
+ retains it in a process-local promise shared with the bridge; it is never written to disk or
186
+ placed in the environment. It survives `/new`, resume, fork and reload.
187
+
188
+ The private key is required for `terminal.policy`, `terminal.approval`, `terminal.pending`,
189
+ `terminal.approve`, `terminal.answer`, `terminal.draft`, `terminal.interrupt` and
190
+ `terminal.resources`, as well as existing account and plugin mutations. A model-spawned shell with
191
+ only the environment token cannot submit policy or approval decisions, change permissions, or
192
+ answer its own pending questions. The application renders decisions obtained from the existing
193
+ controller and returns the person's answer through these calls.
194
+
195
+ Approval prompts offer **Allow once**, **Always allow this tool everywhere** when the host policy
196
+ marks the decision rememberable, and **Deny**. A tool grant covers every call with that tool name
197
+ in Pi terminal sessions using this device's local Harness state directory; for example, Bash grants
198
+ cover every Bash command. `/permissions` lists those grants and lets the person revoke them. Its
199
+ Full Permission switch approves future eligible prompts across those terminal sessions, asks for
200
+ confirmation when enabled, and is off by default. Neither choice approves an already-pending
201
+ request. Hard denials and disabled tools remain enforced; human-only requests stay manual unless
202
+ the host explicitly permits Full Permission for them. Chat approvals keep their separate profile.
203
+
204
+ The editor sends exact draft state to `terminal.draft`; host delivery waits for that state
205
+ instead of overwriting a draft. Interruption resolves pending socket-backed questions and
206
+ approvals in the run registry before awaiting Pi abort. Disposal uses that order too, avoiding
207
+ a tool waiting forever on a question after the screen has closed. Native `session_shutdown`
208
+ clears controller readiness and stops that session's poller; the replacement's `session_start`
209
+ restores readiness while retaining the same process control claim.
210
+
211
+ The bridge exposes the shared `ask_user` batch tool alongside the existing single-question
212
+ `ask_user_question` contract. Registry events project the actual batch IDs, order, summaries,
213
+ choices and descriptions into the terminal inbox. The frontend stages answers locally and
214
+ submits them together through protected `terminal.answers`; the registry validates the entire
215
+ batch before resolving any question. Escape interrupts a pending question; cancelling optional
216
+ text entry returns to the choices and preserves selections. Disappearing requests abort their
217
+ open dialogs. Labels are numbered so a real choice cannot collide with a dialog action.
218
+
219
+ Plugin UI methods carry a generation guard, including methods a plugin retained before an
220
+ `await`. Session replacement, reload and disposal revoke the old generation. Outgoing native
221
+ event subscriptions and custom transcript components detach before native invalidation.
222
+ Cleanup failures cannot prevent terminal restoration. Fatal asynchronous errors restore the
223
+ terminal and exit with failure; startup failures and suspend/resume use the same lifecycle.
224
+
225
+ The command registry supplies help and completion for every pinned Pi built-in command name,
226
+ plus Harness commands supplied by the bridge. `/trust` opens the existing protected code-review
227
+ workflow in `/plugins`; trust changes require a freshly prepared terminal. `/bug` opens a
228
+ reviewable Harness issue draft after confirmation, without attaching history. `/changelog`
229
+ reads bundled Harness change notes. `/reload` also applies the isolated account's edited
230
+ keyboard bindings through the public TUI manager.
231
+
232
+ `/trajectory` opens the newest recorded run for the current native conversation; `/trajectory <run-id>`
233
+ opens a selected run from that conversation. Left/right changes runs, up/down selects recorded
234
+ events, PageUp/PageDown scrolls event details, and `c` copies the selected event. Active runs
235
+ refresh while the overlay is open. The protected terminal socket scopes both the run list and event
236
+ reads to the current terminal and native session file.
237
+
238
+ ## Verification and remaining limits
239
+
240
+ Fullscreen layout verification, 2026-10-18: [`screen.test.ts`](../src/terminal/tui/screen.test.ts)
241
+ covers the dock on the last rows as the transcript grows and scrolls, notice clipping, regular
242
+ flow, and the exit copy. The PTY verifier against the built CLI passed all 14 interaction groups at
243
+ 40×20, 80×24 and 120×40; the retained result is
244
+ [docs/evidence/terminal-2026-10-18-fullscreen](evidence/terminal-2026-10-18-fullscreen/verification.json).
245
+ The verifier's keyboard-list check now waits for the list's first line, because a long notice shows
246
+ its first rows in the dock and scrolls for the rest. The App sidecar's suite passes 30 of 30 and
247
+ `cargo test` passes 217 tests against this tree. The App's root Vitest suite has one failure, the
248
+ pre-existing `PendingRequestDock` expectation. The App sidecar's own `tsc` run reports four errors
249
+ in `agentRuns.ts` and `app.test.ts` that also occur on the checkpoint commit.
250
+
251
+ Earlier verification, before the fullscreen layout: Harness typecheck, **419 passing tests / two platform skips**, build and standalone proof
252
+ passed on 2026-10-17. A final cleanup-order adjustment was rechecked with all **91 frontend
253
+ tests**, typecheck, build and standalone proof. Coverage includes the real isolated persistent SDK/socket boundary,
254
+ exact history scope and symlink rejection, resource-trust refusal before new/reload, event and
255
+ UI rebinding, retained stale plugin callbacks, immutable snapshots, commands, keybinding reload,
256
+ modal/focus behavior, grouped answers, Unicode widths and cleanup failures. Tests make no
257
+ external provider request.
258
+
259
+ The controlled production PTY verifier exercises the real CLI, persistent SDK, native tools,
260
+ final gate/socket and run writer using a loopback OpenAI-compatible SSE fixture. See the
261
+ [retained result](evidence/terminal-2026-10-17-followup/verification.json) and its compressed
262
+ ANSI captures. The earlier [three-size result](evidence/terminal-2026-10-17/verification.json)
263
+ is retained separately. Fixture responses verify transport and actual runtime/tool behavior;
264
+ they do not establish commercial-provider or OAuth availability.
265
+
266
+ The final three-size acceptance run covers 40×20, 80×24 and 120×40, live resizing, streaming,
267
+ thinking, actual read/bash execution, approval allow/deny/Escape, grouped and single questions,
268
+ question cancellation, account switching/restart, new/resume, trusted plugin custom overlays,
269
+ widgets/status, light-theme switching, tool expansion and scrolling. It also checks
270
+ suspend/resume, fatal asynchronous plugin failure and plugin startup failure. Normal exits
271
+ restore terminal attributes, bracketed paste and cursor visibility; failure cases restore them
272
+ and exit 1. Each report binds the exact CLI and verifier SHA-256 digests.
273
+
274
+ After the user reported that the global command still displayed Pi, inspection found an older
275
+ installed 0.1.6 bundle despite the checkout sharing that version. The checkout build was packed
276
+ and installed locally, without publication or account-data changes. The installed CLI now matches
277
+ the tested build byte for byte and passed all 14 isolated PTY groups at 80×24. See the
278
+ [global installation evidence](evidence/terminal-2026-10-17-followup/global-install/README.md).
279
+ Already-running sessions need to exit and relaunch to load the replacement.
280
+
281
+ A direct REA comparison of the pinned OMP executable and Harness at 80×24 captures startup and
282
+ settings without output truncation; both exit 0. The [readable comparison](evidence/terminal-2026-10-17-followup/reference-comparison.md)
283
+ retains exact frame/evidence IDs. It established matching centered welcome/composer geometry,
284
+ rounded settings framing and the need for flat settings rows at 80 columns; the latter was
285
+ corrected using the shipped SettingsList's minimum editable-pane width. Product lockups, tips,
286
+ capability tabs, setting names, paths and unavailable-model guidance differ deliberately.
287
+ This is bounded visual review, not proof of equivalence for every OMP surface.
288
+
289
+ The App sidecar build passed, and its actual bundled `dist/cli.js` passed the same **14 PTY
290
+ interaction groups at 80×24**, including account restart, suspension and both failure cleanup
291
+ cases. Its [separate report](evidence/terminal-2026-10-17-followup/app-bundle/verification.json)
292
+ binds that bundle digest. App typecheck, layer/UI checks, all **29 sidecar tests**, and **215 Rust tests** passed (one Rust
293
+ test ignored). The full renderer run remains blocked by the existing untracked
294
+ `PendingRequestDock.test.ts:68` expectation for `Planner needs your answer`; those App UI files
295
+ were preserved. The combined App test script stops there, so sidecar tests were run separately.
296
+
297
+ The user-authorized personal Codex live attempt used a disposable Harness profile and only
298
+ an unexpired copied access token. Codex returned **“The usage limit has been reached”** before
299
+ a successful response or tool execution. Harness persisted the failed run and restored the
300
+ terminal; the source sign-in file was unchanged and the disposable credentials were deleted.
301
+ The [live report and capture](evidence/terminal-2026-10-17-followup/live-codex/README.md) retain
302
+ this quota failure explicitly. Successful live streaming/tool acceptance remains open until
303
+ account capacity is available. GitHub gist publication, actual OS clipboard/image transfer,
304
+ Windows behavior and every terminal emulator remain unverified. Rich image/diff rendering and clipboard adapters have
305
+ focused compatibility tests; that does not establish OS interaction. These working-tree changes
306
+ are not a published release and the complete plan's acceptance remains open while required
307
+ integration/provider evidence is outstanding.
308
+
309
+ To reproduce the controlled scenario after building:
310
+
311
+ ```sh
312
+ npm run build
313
+ python3 scripts/verify-terminal.py --save-evidence docs/evidence/terminal-2026-10-17-followup
314
+ ```
315
+
316
+ Complete REA records are retained as compressed JSON in the follow-up evidence directory; the
317
+ [evidence manifest](evidence/terminal-2026-10-17-followup/rea-artifacts.json) records their IDs,
318
+ digests, coverage and sizes. The earlier REA IDs below are inherited ledger references; only
319
+ new portable records in that manifest are independently available from this checkout.
320
+
321
+ ### REA evidence ledger
322
+
323
+ - `ev_f372e317bc5780ef895531421d5ec29e62fcea219ed9e8f792bc132805ac4f07`:
324
+ shipped JavaScript static analysis; 13 selected files, 935 unselected. Semantic limits were
325
+ 3,000 per file and 25,000 globally. This is bounded static evidence, not whole-program or runtime coverage.
326
+ - `ev_3f4ebe0f81050df8144f3fb02fb712753350635dffbe1f37c1606fc45cca610f`:
327
+ focused shipped welcome implementation evidence.
328
+ - `ev_5d7ec70517e2e0da4258e5236ab93b9f4c269086dd7664c8b760e0375fba512b`:
329
+ native OMP observation; animation caused output truncation, so it is incomplete runtime evidence.
330
+ - `ev_12f82d325a8a2cf48c1f1783b4737696caf3ec98c7d8063910c9ddece4ad4028`:
331
+ owned frontend 80×24 menu/startup capture described above; no output truncation.
332
+
333
+ Evidence IDs refer to the investigation's retained REA ledger. The package digests and source
334
+ paths above allow artifact identity to be re-established; a fresh session does not implicitly
335
+ inherit that retained ledger.
336
+
337
+ - `ev_4438bf7b94a78ee2d13d0fe3b7a22238671d48b29a68f8beafbf942b9cee930c`:
338
+ supplemental static inspection of nine selected shipped files: default band composer,
339
+ composer integration, light palette, selector, approval/question dialogs, errors and generic
340
+ tool rendering. 939 inventoried files remain outside scope; semantic limits were 2,500 per
341
+ file and 20,000 globally. Source parsing did not execute the package.
342
+ - The portable OMP/Harness runtime evidence IDs and selected frame numbers are recorded in
343
+ the readable comparison and manifest linked above. An earlier OMP capture sent input before
344
+ startup completed; it is retained as incomplete evidence and excluded from the comparison.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.6",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
6
  "repository": {
@@ -19,13 +19,16 @@
19
19
  },
20
20
  "scripts": {
21
21
  "build": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/cli.js && esbuild src/terminal/piExtension.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/terminal-pi.js",
22
+ "dev": "npm run build && node dist/cli.js",
22
23
  "prepack": "npm run build",
24
+ "ci": "bash scripts/ci.sh",
23
25
  "typecheck": "tsc --noEmit -p tsconfig.json",
24
26
  "test": "vitest run",
25
27
  "prove": "npm run build && node scripts/prove-standalone.mjs"
26
28
  },
27
29
  "dependencies": {
28
30
  "@earendil-works/pi-coding-agent": "1.2.0",
31
+ "@earendil-works/pi-tui": "1.2.0",
29
32
  "@modelcontextprotocol/client": "^2.2.0",
30
33
  "@modelcontextprotocol/server": "^2.2.0",
31
34
  "typebox": "^1.3.7",
@@ -34,9 +37,9 @@
34
37
  },
35
38
  "devDependencies": {
36
39
  "@types/node": "^22.19.1",
37
- "esbuild": "^0.25.10",
40
+ "esbuild": "^0.28.2",
38
41
  "typescript": "^5.9.3",
39
- "vitest": "^3.2.4"
42
+ "vitest": "^4.1.11"
40
43
  },
41
44
  "engines": {
42
45
  "node": ">=22.19"
@@ -45,6 +48,10 @@
45
48
  "src",
46
49
  "dist",
47
50
  "builtin",
48
- "README.md"
51
+ "README.md",
52
+ "NOTICE.md",
53
+ "CHANGELOG.md",
54
+ "docs",
55
+ "!docs/evidence/**"
49
56
  ]
50
57
  }
@@ -1,7 +1,8 @@
1
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmdirSync, statSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
2
  import { homedir, tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { expect, test } from 'vitest'
5
+ import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
5
6
 
6
7
  import { Bus, type Notice } from '../core/bus'
7
8
  import { Accounts, classifyMessage } from './accounts'
@@ -21,6 +22,100 @@ function service(dir = temp()) {
21
22
  return { dir, secrets, notices, accounts: new Accounts({ dir, secrets, bus }) }
22
23
  }
23
24
 
25
+ function loginService(login: (interaction: {
26
+ signal: AbortSignal
27
+ notify: (notification: Record<string, unknown>) => void
28
+ prompt: (prompt: { type: string; message: string; placeholder?: string; options?: unknown; signal?: AbortSignal }) => Promise<string>
29
+ }) => Promise<void>) {
30
+ const dir = temp()
31
+ const secrets = memorySecretStore()
32
+ const notices: Notice[] = []
33
+ const bus = new Bus()
34
+ bus.on((notice) => void notices.push(notice))
35
+ let loginOptions: { getDeviceId?: () => string } | undefined
36
+ let loginCalls = 0
37
+ const runtime = {
38
+ login: (_provider: string, _method: string, interaction: Parameters<typeof login>[0], options?: { getDeviceId?: () => string }) => {
39
+ loginCalls += 1
40
+ loginOptions = options
41
+ return login(interaction)
42
+ }
43
+ }
44
+ class LoginAccounts extends Accounts {
45
+ override runtime(account: Parameters<Accounts['runtime']>[0]): Promise<ModelRuntime> {
46
+ void account
47
+ return Promise.resolve(runtime as unknown as ModelRuntime)
48
+ }
49
+ }
50
+ const accounts = new LoginAccounts({ dir, secrets, bus })
51
+ const account = accounts.create('Login test', 'subscription').accounts[0]
52
+ return { accounts, account, notices, loginOptions: () => loginOptions, loginCalls: () => loginCalls }
53
+ }
54
+
55
+ async function waitFor(predicate: () => boolean): Promise<void> {
56
+ for (let attempt = 0; attempt < 100; attempt++) {
57
+ if (predicate()) return
58
+ await new Promise((resolve) => setTimeout(resolve, 5))
59
+ }
60
+ throw new Error('Timed out waiting for the account operation.')
61
+ }
62
+
63
+ test('OpenAI login uses one persisted Harness installation device ID across accounts', async () => {
64
+ const { accounts, account, loginOptions, loginCalls } = loginService(async () => {})
65
+ const deviceId = await accounts.installationDeviceId()
66
+ const second = accounts.create('Second account', 'subscription').accounts.find((item) => item.id !== account.id)!
67
+
68
+ await accounts.loginStart(account.id, 'openai', 'oauth')
69
+ await waitFor(() => loginCalls() === 1)
70
+ expect(loginOptions()?.getDeviceId?.()).toBe(deviceId)
71
+
72
+ await accounts.loginStart(second.id, 'openai', 'oauth')
73
+ await waitFor(() => loginCalls() === 2)
74
+ expect(loginOptions()?.getDeviceId?.()).toBe(deviceId)
75
+ expect(JSON.parse(readFileSync(join(sharedDir(accounts.dir), 'settings.json'), 'utf8')).deviceId).toBe(deviceId)
76
+ expect(deviceId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i)
77
+ })
78
+
79
+ test('device-code sign-in progress survives listener detachment and is available from status', async () => {
80
+ const { accounts, account, notices } = loginService((interaction) => new Promise<void>((_resolve, reject) => {
81
+ interaction.notify({ type: 'device_code', userCode: 'ABCD-EFGH', verificationUri: 'https://example.test/device' })
82
+ interaction.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
83
+ }))
84
+
85
+ const { loginId } = await accounts.loginStart(account.id, 'openai-codex', 'oauth')
86
+ await waitFor(() => (accounts.loginStatus(account.id)?.events.length ?? 0) > 0)
87
+ const detached = accounts.loginStatus(account.id)
88
+ expect(detached).toMatchObject({ loginId, status: 'running', events: [{ type: 'device_code', userCode: 'ABCD-EFGH' }] })
89
+ expect(notices.find((notice) => notice.type === 'accounts.loginEvent')).toMatchObject({ revision: 1, loginId })
90
+
91
+ // Asking again models a renderer returning to Accounts while the Harness stays alive.
92
+ expect(accounts.loginStatus(account.id)).toEqual(detached)
93
+ accounts.loginCancel(account.id, loginId)
94
+ await waitFor(() => accounts.loginStatus(account.id)?.status === 'cancelled')
95
+ const ended = accounts.loginStatus(account.id)
96
+ expect(ended).toMatchObject({ loginId, status: 'cancelled', events: [], prompt: null, success: false })
97
+ expect(JSON.stringify(ended)).not.toContain('ABCD-EFGH')
98
+ })
99
+
100
+ test('login status exposes the active prompt but never retains its response after completion', async () => {
101
+ const { accounts, account, notices } = loginService(async (interaction) => {
102
+ const value = await interaction.prompt({ type: 'secret', message: 'Enter the one-time value' })
103
+ expect(value).toBe('private-response')
104
+ })
105
+
106
+ const { loginId } = await accounts.loginStart(account.id, 'provider-with-prompt', 'oauth')
107
+ await waitFor(() => accounts.loginStatus(account.id)?.prompt !== null)
108
+ const waiting = accounts.loginStatus(account.id)
109
+ expect(waiting).toMatchObject({ loginId, status: 'running', prompt: { type: 'prompt', prompt: { type: 'secret' } } })
110
+ const promptId = String(waiting?.prompt?.promptId)
111
+ accounts.loginRespond(loginId, promptId, 'private-response')
112
+ await waitFor(() => accounts.loginStatus(account.id)?.status === 'completed')
113
+ const ended = accounts.loginStatus(account.id)
114
+ expect(ended).toMatchObject({ loginId, status: 'completed', success: true, events: [], prompt: null })
115
+ expect(JSON.stringify(ended)).not.toContain('private-response')
116
+ expect(notices.filter((notice) => notice.type === 'accounts.loginEvent' || notice.type === 'accounts.login').map((notice) => notice.revision)).toEqual([1, 2, 3])
117
+ })
118
+
24
119
  test('health reflects sign-in, errors and limits', () => {
25
120
  const state = { ...emptyState(), signedIn: true }
26
121
  expect(deriveHealth(state)).toBe('ok')
@@ -114,6 +209,92 @@ test('a terminal round-trips credentials through auth.json', async () => {
114
209
  expect((await accounts.readCredential(account.id, 'anthropic'))?.refresh).toBe('r')
115
210
  })
116
211
 
212
+ test('a key saved while a terminal runs is kept when that terminal ends', async () => {
213
+ const { dir, accounts } = service()
214
+ const account = addAccount(dir, 'A', 'apiKey')
215
+ ensureHome(dir, account)
216
+ await accounts.terminalBegin(account)
217
+ await accounts.writeCredential(account.id, 'deepseek', { type: 'api_key', key: 'k-late' })
218
+ await accounts.terminalEnd(account)
219
+ expect((await accounts.readCredential(account.id, 'deepseek'))?.key).toBe('k-late')
220
+ expect(loadState(dir, account.id).piProviders).toEqual(['deepseek'])
221
+ })
222
+
223
+ test('a terminal leaves auth.json alone while another live process has a terminal on the account', async () => {
224
+ const { dir, accounts } = service()
225
+ const account = addAccount(dir, 'A', 'apiKey')
226
+ ensureHome(dir, account)
227
+ await accounts.writeCredential(account.id, 'openai', { type: 'api_key', key: 'k1' })
228
+ const auth = join(configDir(dir, account), 'auth.json')
229
+ const leases = join(dir, account.id, 'terminal-leases')
230
+ mkdirSync(leases, { recursive: true })
231
+ // The parent process stands in for another Harness that is running.
232
+ const other = join(leases, String(process.ppid))
233
+ writeFileSync(other, '{}')
234
+ writeFileSync(auth, JSON.stringify({ openai: { type: 'oauth', access: 'fresh', refresh: 'r2', expires: 2 } }))
235
+ await accounts.terminalBegin(account)
236
+ expect(readFileSync(auth, 'utf8')).toContain('fresh')
237
+ await accounts.terminalEnd(account)
238
+ expect(existsSync(auth)).toBe(true)
239
+ rmSync(other)
240
+ await accounts.terminalBegin(account)
241
+ writeFileSync(auth, JSON.stringify({ openai: { type: 'oauth', access: 'fresh', refresh: 'r2', expires: 2 } }))
242
+ await accounts.terminalEnd(account)
243
+ expect(existsSync(auth)).toBe(false)
244
+ expect((await accounts.readCredential(account.id, 'openai'))?.refresh).toBe('r2')
245
+ })
246
+
247
+ test('a terminal that joins a running one gets the keys saved since, and keeps the tokens already in auth.json', async () => {
248
+ const { dir, accounts } = service()
249
+ const account = addAccount(dir, 'A', 'apiKey')
250
+ ensureHome(dir, account)
251
+ const auth = join(configDir(dir, account), 'auth.json')
252
+ const leases = join(dir, account.id, 'terminal-leases')
253
+ mkdirSync(leases, { recursive: true })
254
+ writeFileSync(join(leases, String(process.ppid)), '{}')
255
+ writeFileSync(auth, JSON.stringify({ openai: { type: 'oauth', access: 'fresh', refresh: 'r2', expires: 2 } }))
256
+ await accounts.writeCredential(account.id, 'openai', { type: 'api_key', key: 'k1' })
257
+ await accounts.writeCredential(account.id, 'deepseek', { type: 'api_key', key: 'k-late' })
258
+ await accounts.terminalBegin(account)
259
+ const merged = JSON.parse(readFileSync(auth, 'utf8'))
260
+ expect(merged.openai.access).toBe('fresh')
261
+ expect(merged.deepseek.key).toBe('k-late')
262
+ rmSync(join(leases, String(process.ppid)))
263
+ await accounts.terminalEnd(account)
264
+ expect(existsSync(auth)).toBe(false)
265
+ expect((await accounts.readCredential(account.id, 'deepseek'))?.key).toBe('k-late')
266
+ })
267
+
268
+ test('a refresh made by a terminal is saved when it ends, even while another process still uses auth.json', async () => {
269
+ const { dir, accounts } = service()
270
+ const account = addAccount(dir, 'A', 'apiKey')
271
+ ensureHome(dir, account)
272
+ await accounts.writeCredential(account.id, 'openai', { type: 'oauth', access: 'a1', refresh: 'r1', expires: 1 })
273
+ const auth = join(configDir(dir, account), 'auth.json')
274
+ const leases = join(dir, account.id, 'terminal-leases')
275
+ mkdirSync(leases, { recursive: true })
276
+ const other = join(leases, String(process.ppid))
277
+ writeFileSync(other, '{}')
278
+ await accounts.terminalBegin(account)
279
+ writeFileSync(auth, JSON.stringify({ openai: { type: 'oauth', access: 'a2', refresh: 'r2', expires: 2 } }))
280
+ await accounts.terminalEnd(account)
281
+ expect((await accounts.readCredential(account.id, 'openai'))?.refresh).toBe('r2')
282
+ expect(existsSync(auth)).toBe(true)
283
+ })
284
+
285
+ test('a lease left by a process that has ended is removed and does not keep the account busy', async () => {
286
+ const { dir, accounts } = service()
287
+ const account = addAccount(dir, 'A', 'apiKey')
288
+ ensureHome(dir, account)
289
+ const leases = join(dir, account.id, 'terminal-leases')
290
+ mkdirSync(leases, { recursive: true })
291
+ writeFileSync(join(leases, '2147483646'), '{}')
292
+ await accounts.terminalBegin(account)
293
+ expect(existsSync(join(leases, '2147483646'))).toBe(false)
294
+ await accounts.terminalEnd(account)
295
+ expect(existsSync(join(configDir(dir, account), 'auth.json'))).toBe(false)
296
+ })
297
+
117
298
  test('creating, renaming and removing an account is audited and announced', async () => {
118
299
  const { dir, accounts, secrets, notices } = service()
119
300
  const created = accounts.create(' Work ', 'apiKey').accounts[0]
@@ -157,6 +338,21 @@ test('provider errors are classified', () => {
157
338
  expect(classifyMessage('the model said no')).toBeNull()
158
339
  })
159
340
 
341
+ test('a rejected OAuth refresh requests reauthentication and keeps the credential', async () => {
342
+ const { dir, accounts } = service()
343
+ const account = accounts.create('OpenAI', 'subscription').accounts[0]
344
+ await accounts.writeCredential(account.id, 'openai', { type: 'oauth', access: 'old-access', refresh: 'old-refresh', expires: 0 })
345
+ accounts.runtime = async () => ({
346
+ listCredentials: async () => [{ providerId: 'openai', type: 'oauth' }],
347
+ getAvailable: async () => { throw new Error('OAuth refresh failed for openai: OpenAI OAuth token request failed (400): {"error":"invalid_grant"}') }
348
+ } as unknown as ModelRuntime)
349
+
350
+ await accounts.refreshOne(account)
351
+
352
+ expect(loadState(dir, account.id)).toMatchObject({ health: 'auth_expired', lastError: { category: 'auth_expired' } })
353
+ expect(await accounts.readCredential(account.id, 'openai')).toMatchObject({ refresh: 'old-refresh' })
354
+ })
355
+
160
356
  test('native config lists what is shared and what exists', () => {
161
357
  const dir = temp()
162
358
  const before = nativeConfig(dir)