@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- 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.
|
|
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.
|
|
40
|
+
"esbuild": "^0.28.2",
|
|
38
41
|
"typescript": "^5.9.3",
|
|
39
|
-
"vitest": "^
|
|
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)
|