@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.
- package/CHANGELOG.md +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -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 +100 -10
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/index.ts +7 -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/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- 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 +6 -0
- 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/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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 +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +2 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- 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.
|
package/docs/terminal-tui.md
CHANGED
|
@@ -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-
|
|
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:
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
126
|
-
`terminal.
|
|
127
|
-
plugin mutations. A model-spawned shell with
|
|
128
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
+
"esbuild": "^0.28.2",
|
|
41
41
|
"typescript": "^5.9.3",
|
|
42
|
-
"vitest": "^
|
|
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]
|
package/src/accounts/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
chmodSync(path, 0o600)
|
|
621
|
+
this.terminalSnapshots.set(account.id, data)
|
|
580
622
|
} catch (error) {
|
|
581
623
|
this.terminals.delete(account.id)
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
689
|
+
names = readdirSync(folder)
|
|
599
690
|
} catch {
|
|
600
|
-
|
|
691
|
+
return false
|
|
601
692
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
logLine(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}`)
|
|
34
49
|
}
|