@xingular/harness 0.1.7 → 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 (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -0,0 +1,49 @@
1
+ ---
2
+ id: ADR-0004
3
+ title: Fullscreen terminal layout with a fixed dock
4
+ status: accepted
5
+ created: 2026-10-18
6
+ updated: 2026-10-18
7
+ supersedes: none
8
+ ---
9
+ # ADR-0004 — Fullscreen terminal layout with a fixed dock
10
+
11
+ ## Context and decision
12
+
13
+ The owned terminal drew its transcript and composer as one flowing list on the main screen. With a
14
+ short transcript the composer sat under the last line and moved down as the transcript grew. Harness
15
+ also kept its own scroll offset, mouse regions and paging, which duplicated the viewport Pi already
16
+ provides in fullscreen.
17
+
18
+ Harness now uses Pi's fullscreen renderer by default. The welcome and transcript form a scroll view
19
+ that fills the terminal; the dock (working line, queue and notices, widgets, editor and status band,
20
+ footer) sits fixed below it. Pi's `tuiMode`, `fullscreenScrollbar`, `fullscreenWheelScrollLines`,
21
+ `fullscreenCopyOnSelect` and `fullscreenExitOutput` settings are read from the account and honoured.
22
+ `tuiMode: "regular"` keeps the flowing layout on the main screen. When fullscreen ends with
23
+ `fullscreenExitOutput: "transcript"`, the rendered conversation is copied into the normal scrollback.
24
+
25
+ ## Consequences
26
+
27
+ - While fullscreen is running, the transcript is not in the terminal's scrollback. Mouse capture is
28
+ on, so terminal drag-selection gives way to the application's selection, which copies on release.
29
+ These are Pi's defaults and remain changeable in settings.
30
+ - Harness no longer owns scroll state, wheel handling or paging. Those come from Pi's viewport and its
31
+ `tui.altScreen.*` bindings, and the keyboard list shows the scroll keys only in fullscreen.
32
+ - Regular mode has no Harness-managed scroll position; scrolling there belongs to the terminal.
33
+ - When the screen is short, a long notice shows its first rows and scrolls for the rest. The prompt
34
+ and status are never clipped; the head of a notice stays readable, so the newest line of a long
35
+ list may need a scroll.
36
+ - Only `transcript` exit output is implemented. Other values write nothing on exit.
37
+
38
+ ## Alternatives considered
39
+
40
+ Padding the main-screen output to the terminal height also keeps the composer on the last rows for a
41
+ short transcript. It keeps native scrollback, but Harness would still own its scrolling and mouse
42
+ handling, and the composer would scroll away with the terminal's scrollback once the transcript
43
+ overflowed. That was rejected because it keeps the duplicated code this change removes.
44
+
45
+ ## Evidence
46
+
47
+ The layout is covered by `src/terminal/tui/screen.test.ts`: the dock stays on the last rows as the
48
+ transcript grows and scrolls, regular mode flows in order, and the exit copy is written with its styles
49
+ reset. The PTY verification of the built CLI is recorded in [the terminal reference](../terminal-tui.md).
@@ -0,0 +1,54 @@
1
+ ---
2
+ id: ADR-0005
3
+ title: Application-provided OAuth clients for connected apps
4
+ status: accepted
5
+ created: 2026-10-18
6
+ updated: 2026-10-18
7
+ supersedes: none
8
+ ---
9
+ # ADR-0005 — Application-provided OAuth clients for connected apps
10
+
11
+ ## Context and decision
12
+
13
+ Every remote connected app so far authenticated one of two ways: the server registers this app
14
+ dynamically (RFC 7591), or the person registers a client with the provider and pastes its ID and
15
+ secret into Connections. Google's Workspace MCP servers support neither in practice: they have no
16
+ dynamic registration, and requiring every person to create a Google Cloud project before connecting
17
+ Gmail is not a product.
18
+
19
+ A connection's `oauthClient` now has a third value, `app`: **the application supplies the client**.
20
+ The host does so through one extension point on the harness:
21
+
22
+ ```ts
23
+ export interface AppOAuthClients {
24
+ ids(): string[]
25
+ get(catalogId: string): Promise<{ clientId: string; clientSecret?: string } | null>
26
+ }
27
+ ```
28
+
29
+ `HarnessOptions.oauthClients` carries it. Only a connection whose `catalogId` appears in `ids()` can
30
+ use it — a hand-typed connection, a Hub connector recipe, and a plugin template cannot, because none
31
+ of them can name a client the application actually holds. `connections.appClients` reports the ids so
32
+ an application can offer only the connectors it can actually sign in for.
33
+
34
+ The client is resolved inside `Connections.oauthLoad` and placed in the OAuth state as
35
+ `staticClient`, exactly where a user-registered client goes. `oauthSave` already strips
36
+ `staticClient`, so the client is never written to `connections.json` or the secret store; it is
37
+ re-resolved on each load. Refresh tokens continue to be stored per connection.
38
+
39
+ ## Consequences
40
+
41
+ - The harness learns no product concept: it knows a catalog entry id, a client, and a sign-in. Which
42
+ entries an application can serve is entirely the host's answer.
43
+ - A build without a client is a first-class state, not an error: `ids()` is empty, `signIn` refuses
44
+ with "This connector has no app sign-in in this build", and the application hides those catalog
45
+ entries instead of offering a dead end.
46
+ - A client secret in a distributed desktop build is extractable, so it is treated as public. The real
47
+ controls stay Google's consent screen, the requested scopes, and the tool approval gate.
48
+ - One client can serve several catalog entries (five Google servers, one client). Each entry is still
49
+ its own connection with its own tokens, so signing in to one does not authorize another.
50
+ - The per-user path is untouched: `oauthClient: 'user'` still stores a person's own client in
51
+ Keychain, and it remains the only route for providers or accounts an application cannot pre-register
52
+ for.
53
+ - Google's Developer Preview terms, app verification and CASA assessment for restricted scopes are
54
+ outside this decision; they decide who can use the shipped client, not how it is delivered.
@@ -1,8 +1,23 @@
1
1
  # Harness terminal frontend
2
2
 
3
- Status: implemented in the working tree; publication is separate. Last updated: 2026-10-17.
3
+ Status: implemented in the working tree; publication is separate. Last updated: 2026-10-18.
4
4
  Decision: [ADR-0001](decisions/ADR-0001-owned-terminal.md).
5
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
+
6
21
  ## Ownership and process boundary
7
22
 
8
23
  Harness owns its interactive terminal application in [`src/terminal/tui/`](../src/terminal/tui/).
@@ -24,7 +39,8 @@ Harness parent: account + compiled context + trusted resources + run writer
24
39
  | launch specification / existing session socket
25
40
  Harness terminal child: TerminalApplication + persistent Pi AgentSessionRuntime
26
41
  | typed native events / plugin UI callbacks
27
- Harness screen: welcome, transcript, status band, editor, dialogs and overlays
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
28
44
  ```
29
45
 
30
46
  [`runtime.ts`](../src/terminal/tui/runtime.ts) builds a public `AgentSessionRuntime` around
@@ -41,6 +57,47 @@ prompts. Reusing `sessions.run` for each prompt would recreate and dispose a ses
41
57
  turn and overlap the terminal controller's run lifecycle. That API remains the embedding and
42
58
  unattended execution path.
43
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
+
44
101
  ## Reference and reproduced presentation
45
102
 
46
103
  The reference is **Oh My Pi 18.11.3**, identified from npm and GitHub on 2026-10-17; its release
@@ -58,9 +115,15 @@ In the extracted `pi-tui` package, `src/theme/defaults/titanium.json` establishe
58
115
  palette and `src/prompt/welcome.ts` establishes the five-row Pi logo, exact diagonal RGB stops
59
116
  and indexed-color fallback, centered lockup and narrow-width behavior. Harness adapts these
60
117
  into [`theme.ts`](../src/terminal/tui/theme.ts) and [`welcome.ts`](../src/terminal/tui/welcome.ts),
61
- with its own product name, package version and existing-command tips. The status band and
62
- curved `╰─` input framing are implemented in [`status.ts`](../src/terminal/tui/status.ts) and
63
- [`editor.ts`](../src/terminal/tui/editor.ts). Startup selects the Harness titanium presentation
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
64
127
  without rewriting the user's saved theme settings. The shipped OMP light palette is also
65
128
  available as `light`; native rich components and the status band switch together. Missing
66
129
  Pi-specific color tokens use documented Harness fallbacks in `theme.ts`.
@@ -122,12 +185,22 @@ second key once with `control.claim`, before plugin code loads. [`control.ts`](.
122
185
  retains it in a process-local promise shared with the bridge; it is never written to disk or
123
186
  placed in the environment. It survives `/new`, resume, fork and reload.
124
187
 
125
- The private key is required for `terminal.pending`, `terminal.approve`, `terminal.answer`,
126
- `terminal.draft`, `terminal.interrupt` and `terminal.resources`, as well as existing account and
127
- plugin mutations. A model-spawned shell with only the environment token cannot answer its own
128
- pending questions or approvals. The application renders decisions obtained from the existing
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
129
193
  controller and returns the person's answer through these calls.
130
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
+
131
204
  The editor sends exact draft state to `terminal.draft`; host delivery waits for that state
132
205
  instead of overwriting a draft. Interruption resolves pending socket-backed questions and
133
206
  approvals in the run registry before awaiting Pi abort. Disposal uses that order too, avoiding
@@ -156,9 +229,26 @@ reviewable Harness issue draft after confirmation, without attaching history. `/
156
229
  reads bundled Harness change notes. `/reload` also applies the isolated account's edited
157
230
  keyboard bindings through the public TUI manager.
158
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
+
159
238
  ## Verification and remaining limits
160
239
 
161
- Harness typecheck, **419 passing tests / two platform skips**, build and standalone proof
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
162
252
  passed on 2026-10-17. A final cleanup-order adjustment was rechecked with all **91 frontend
163
253
  tests**, typecheck, build and standalone proof. Coverage includes the real isolated persistent SDK/socket boundary,
164
254
  exact history scope and symlink rejection, resource-trust refusal before new/reload, event and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.7",
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": {
@@ -37,9 +37,9 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22.19.1",
40
- "esbuild": "^0.25.10",
40
+ "esbuild": "^0.28.2",
41
41
  "typescript": "^5.9.3",
42
- "vitest": "^3.2.4"
42
+ "vitest": "^4.1.11"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=22.19"
@@ -51,6 +51,7 @@
51
51
  "README.md",
52
52
  "NOTICE.md",
53
53
  "CHANGELOG.md",
54
- "docs"
54
+ "docs",
55
+ "!docs/evidence/**"
55
56
  ]
56
57
  }
@@ -1,4 +1,4 @@
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'
@@ -209,6 +209,92 @@ test('a terminal round-trips credentials through auth.json', async () => {
209
209
  expect((await accounts.readCredential(account.id, 'anthropic'))?.refresh).toBe('r')
210
210
  })
211
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
+
212
298
  test('creating, renaming and removing an account is audited and announced', async () => {
213
299
  const { dir, accounts, secrets, notices } = service()
214
300
  const created = accounts.create(' Work ', 'apiKey').accounts[0]
@@ -1,7 +1,7 @@
1
1
  // The accounts service: everything that needs the secret store, the Pi runtime, or a notice.
2
2
  // Credentials go to the embedding application's secret store; nothing here writes one to the
3
3
  // harness's own files, except a terminal session's `auth.json` while that terminal runs.
4
- import { chmodSync, rmSync } from 'node:fs'
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { join } from 'node:path'
7
7
 
@@ -38,6 +38,36 @@ export interface ModelOption {
38
38
 
39
39
  type Credential = Record<string, unknown> & { type: 'api_key' | 'oauth' }
40
40
 
41
+ /** The lease files of an account: one per process that has a terminal on it. */
42
+ const leaseFolder = (dir: string, accountId: string): string => join(dir, accountId, 'terminal-leases')
43
+
44
+ /** Whether a process with this id exists. One that exists but belongs to another user still counts. */
45
+ function processAlive(pid: number): boolean {
46
+ try {
47
+ process.kill(pid, 0)
48
+ return true
49
+ } catch (error) {
50
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
51
+ }
52
+ }
53
+
54
+ /** The credentials in an auth.json, or null when there is no readable file. */
55
+ function readAuth(path: string): Record<string, Credential> | null {
56
+ try {
57
+ const parsed = readJson<Record<string, Credential>>(path)
58
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** Compares two credentials by their fields, not their key order, which Pi may change when it rewrites the file. */
65
+ function sameCredential(a: Credential | undefined, b: Credential | undefined): boolean {
66
+ if (a === b) return true
67
+ if (!a || !b) return false
68
+ return JSON.stringify(a, Object.keys(a).sort()) === JSON.stringify(b, Object.keys(b).sort())
69
+ }
70
+
41
71
  function validProvider(provider: string): string {
42
72
  if (provider && provider.length <= 64 && /^[A-Za-z0-9._-]+$/.test(provider)) return provider
43
73
  throw invalid('Invalid provider name.')
@@ -90,7 +120,10 @@ export class Accounts {
90
120
  private readonly runtimes = new Map<string, Promise<ModelRuntime>>()
91
121
  private readonly logins = new Map<string, PendingLogin>()
92
122
  private readonly latestLogins = new Map<string, AccountLoginSnapshot>()
123
+ /** How many of this process's terminals use each account's auth.json. */
93
124
  private readonly terminals = new Map<string, number>()
125
+ /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
126
+ private readonly terminalSnapshots = new Map<string, Record<string, Credential>>()
94
127
  private installationDeviceIdPromise: Promise<string> | null = null
95
128
 
96
129
  constructor(options: AccountsOptions) {
@@ -564,26 +597,44 @@ export class Accounts {
564
597
  return this.terminals.get(accountId) ?? 0
565
598
  }
566
599
 
600
+ /**
601
+ * Starts a terminal's use of the account's auth.json. The file is written from the store when the
602
+ * first terminal of the account begins. While another process's terminal still uses it, the tokens
603
+ * already in the file stay, because that terminal may have refreshed them; only a provider the file
604
+ * lacks is added from the store, so a key saved after the other terminal began still reaches this one.
605
+ */
567
606
  async terminalBegin(account: Account): Promise<void> {
568
607
  const count = (this.terminals.get(account.id) ?? 0) + 1
569
608
  this.terminals.set(account.id, count)
570
609
  if (count > 1) return
571
610
  const path = join(env.configDir(this.dir, account), 'auth.json')
611
+ let shared = false
572
612
  try {
573
- const data: Record<string, unknown> = {}
574
- for (const provider of store.loadState(this.dir, account.id).piProviders) {
575
- const credential = await this.readCredential(account.id, provider).catch(() => null)
576
- if (credential) data[provider] = credential
613
+ shared = this.otherTerminalProcesses(account.id)
614
+ this.claimLease(account.id)
615
+ const existing = shared && existsSync(path) ? readAuth(path) : null
616
+ const data = await this.storedCredentials(account.id, existing ?? {})
617
+ if (!existing || Object.keys(data).length > Object.keys(existing).length) {
618
+ atomicWrite(path, JSON.stringify(data, null, 2))
619
+ chmodSync(path, 0o600)
577
620
  }
578
- atomicWrite(path, JSON.stringify(data, null, 2))
579
- chmodSync(path, 0o600)
621
+ this.terminalSnapshots.set(account.id, data)
580
622
  } catch (error) {
581
623
  this.terminals.delete(account.id)
582
- rmSync(path, { force: true })
624
+ this.terminalSnapshots.delete(account.id)
625
+ this.releaseLease(account.id)
626
+ if (!shared) rmSync(path, { force: true })
583
627
  throw error
584
628
  }
585
629
  }
586
630
 
631
+ /**
632
+ * Ends a terminal's use of the auth.json. Each terminal saves what its Pi changed since it began (a
633
+ * refreshed or new sign-in, or a provider it signed out of), even while another process still uses the
634
+ * file, so no refresh is lost when the other terminal ends first. A credential saved to the store after
635
+ * the terminal began is in neither the file nor the snapshot, so it stays. The last terminal of the
636
+ * account to end, in every process, removes the file.
637
+ */
587
638
  async terminalEnd(account: Account): Promise<void> {
588
639
  const remaining = Math.max(0, (this.terminals.get(account.id) ?? 1) - 1)
589
640
  if (remaining > 0) {
@@ -591,21 +642,72 @@ export class Accounts {
591
642
  return
592
643
  }
593
644
  this.terminals.delete(account.id)
645
+ const snapshot = this.terminalSnapshots.get(account.id) ?? {}
646
+ this.terminalSnapshots.delete(account.id)
647
+ this.releaseLease(account.id)
594
648
  const path = join(env.configDir(this.dir, account), 'auth.json')
595
- let data: Record<string, Credential> | null = null
649
+ const data = readAuth(path)
650
+ if (data) {
651
+ let changed = false
652
+ for (const provider of new Set([...Object.keys(snapshot), ...Object.keys(data)])) {
653
+ if (provider in data) {
654
+ if (sameCredential(data[provider], snapshot[provider])) continue
655
+ await this.writeCredential(account.id, provider, data[provider]).catch(() => undefined)
656
+ changed = true
657
+ } else if (provider in snapshot) {
658
+ await this.writeCredential(account.id, provider, null).catch(() => undefined)
659
+ changed = true
660
+ }
661
+ }
662
+ if (changed) {
663
+ this.invalidate(account.id)
664
+ store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.')
665
+ }
666
+ }
667
+ if (!this.otherTerminalProcesses(account.id)) rmSync(path, { force: true })
668
+ }
669
+
670
+ /** The account's stored credentials, with `existing` kept as it is for every provider it already has. */
671
+ private async storedCredentials(accountId: string, existing: Record<string, Credential>): Promise<Record<string, Credential>> {
672
+ const data = { ...existing }
673
+ for (const provider of store.loadState(this.dir, accountId).piProviders) {
674
+ if (provider in data) continue
675
+ const credential = await this.readCredential(accountId, provider).catch(() => null)
676
+ if (credential) data[provider] = credential
677
+ }
678
+ return data
679
+ }
680
+
681
+ /**
682
+ * Whether another live process has a terminal on this account. Lease files of processes that have
683
+ * ended are removed, so a crash never leaves the account looking busy.
684
+ */
685
+ private otherTerminalProcesses(accountId: string): boolean {
686
+ const folder = leaseFolder(this.dir, accountId)
687
+ let names: string[]
596
688
  try {
597
- const parsed = readJson<Record<string, Credential>>(path)
598
- data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
689
+ names = readdirSync(folder)
599
690
  } catch {
600
- data = null
691
+ return false
601
692
  }
602
- if (data) {
603
- for (const provider of store.loadState(this.dir, account.id).piProviders) if (!(provider in data)) await this.writeCredential(account.id, provider, null).catch(() => undefined)
604
- for (const [provider, credential] of Object.entries(data)) await this.writeCredential(account.id, provider, credential).catch(() => undefined)
605
- this.invalidate(account.id)
606
- store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.')
693
+ let others = false
694
+ for (const name of names) {
695
+ const pid = Number(name)
696
+ if (!/^\d+$/.test(name) || pid <= 0 || pid === process.pid) continue
697
+ if (processAlive(pid)) others = true
698
+ else rmSync(join(folder, name), { force: true })
607
699
  }
608
- rmSync(path, { force: true })
700
+ return others
701
+ }
702
+
703
+ private claimLease(accountId: string): void {
704
+ const folder = leaseFolder(this.dir, accountId)
705
+ mkdirSync(folder, { recursive: true, mode: 0o700 })
706
+ atomicWrite(join(folder, String(process.pid)), JSON.stringify({ pid: process.pid, startedAt: now() }))
707
+ }
708
+
709
+ private releaseLease(accountId: string): void {
710
+ rmSync(join(leaseFolder(this.dir, accountId), String(process.pid)), { force: true })
609
711
  }
610
712
 
611
713
  // ---- Health from runs ---------------------------------------------------------------------
@@ -0,0 +1,22 @@
1
+ import { afterEach, expect, test, vi } from 'vitest'
2
+
3
+ import { log, logLine, setLogSink } from './bus'
4
+
5
+ afterEach(() => setLogSink(null))
6
+
7
+ test('diagnostics go to the sink when one is set, and to stderr otherwise', () => {
8
+ const lines: string[] = []
9
+ setLogSink((text) => void lines.push(text))
10
+ log('maintenance', 'paused')
11
+ logLine('[index] reindex failed: boom')
12
+ expect(lines).toEqual(['[harness] maintenance paused', '[index] reindex failed: boom'])
13
+
14
+ setLogSink(null)
15
+ const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
16
+ try {
17
+ logLine('plain')
18
+ expect(write).toHaveBeenCalledWith('plain\n')
19
+ } finally {
20
+ write.mockRestore()
21
+ }
22
+ })
package/src/core/bus.ts CHANGED
@@ -28,7 +28,22 @@ export class Bus {
28
28
  }
29
29
  }
30
30
 
31
- /** Diagnostics go to stderr; stdout may be carrying a protocol. */
31
+ let logSink: ((text: string) => void) | null = null
32
+
33
+ /**
34
+ * Sends diagnostics somewhere other than stderr, or back to stderr with `null`. A host whose stderr is
35
+ * the terminal a frontend is drawing on must not write into it, so it sets a sink.
36
+ */
37
+ export function setLogSink(sink: ((text: string) => void) | null): void {
38
+ logSink = sink
39
+ }
40
+
41
+ /** One line of diagnostics. Diagnostics go to stderr by default; stdout may be carrying a protocol. */
42
+ export function logLine(text: string): void {
43
+ if (logSink) logSink(text)
44
+ else process.stderr.write(`${text}\n`)
45
+ }
46
+
32
47
  export function log(...parts: unknown[]): void {
33
- process.stderr.write(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}\n`)
48
+ logLine(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}`)
34
49
  }