@xingular/harness 0.1.5 → 0.1.7
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 +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -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 +130 -0
- 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 +95 -0
- 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 +27 -0
- 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 +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- 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 +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- 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 +57 -0
- 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 +58 -0
- 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 +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- 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 +6 -1
- 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 +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- 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 +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- 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 +68 -4
- 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 +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -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 +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -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/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -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/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -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 +92 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// The `/accounts` command of a terminal session, without Pi: what it lists, what it offers for
|
|
2
|
+
// each account, and the sign-in conversation. It asks through the interface it is given and
|
|
3
|
+
// changes accounts only through the session's `accounts.*` calls, so the same flow is tested
|
|
4
|
+
// without a terminal. The app's Accounts screen does the same things (see App/src/modes/accounts).
|
|
5
|
+
import type { AccountsOverview, AccountView, Limit } from '../accounts/store'
|
|
6
|
+
import type { MenuUi } from './pluginMenu'
|
|
7
|
+
|
|
8
|
+
export interface AccountsUi extends MenuUi {
|
|
9
|
+
input(title: string, placeholder?: string): Promise<string | undefined>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `accounts.*` over the session socket, with the session's control key. */
|
|
13
|
+
export type AccountsCall = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
14
|
+
|
|
15
|
+
/** The account this session is on, from `accounts.session`. */
|
|
16
|
+
export interface SessionAccount {
|
|
17
|
+
accountId: string
|
|
18
|
+
label: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One entry of `accounts.providers`. */
|
|
22
|
+
export interface ProviderOption {
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
oauth: boolean
|
|
26
|
+
oauthLabel: string | null
|
|
27
|
+
apiKey: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `accounts.loginStatus`. `events` and `prompt` are the harness's own login steps, passed through. */
|
|
31
|
+
interface LoginSnapshot {
|
|
32
|
+
loginId: string
|
|
33
|
+
revision: number
|
|
34
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled'
|
|
35
|
+
events: Record<string, unknown>[]
|
|
36
|
+
prompt: Record<string, unknown> | null
|
|
37
|
+
error: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The same words the app shows for each health state (`App/src/modes/accounts/accountModel.ts`). */
|
|
41
|
+
const HEALTH: Record<string, string> = {
|
|
42
|
+
ok: 'Ready',
|
|
43
|
+
limited: 'Limit reached',
|
|
44
|
+
auth_expired: 'Sign in again',
|
|
45
|
+
signed_out: 'Signed out',
|
|
46
|
+
error: 'Needs attention',
|
|
47
|
+
unknown: 'Not checked'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ADD = 'Add account'
|
|
51
|
+
const POOLS = 'Pools'
|
|
52
|
+
const CHECK_ALL = 'Check all accounts'
|
|
53
|
+
const DONE = 'Done'
|
|
54
|
+
const BACK = 'Back'
|
|
55
|
+
const USE_HERE = 'Use for this session (restarts it)'
|
|
56
|
+
const MAKE_DEFAULT = 'Make default for new sessions'
|
|
57
|
+
const RENAME = 'Rename'
|
|
58
|
+
const CHECK = 'Check'
|
|
59
|
+
const DETAILS = 'Details'
|
|
60
|
+
const ACTIVITY = 'Activity'
|
|
61
|
+
const SIGN_IN = 'Sign in'
|
|
62
|
+
const REMOVE_PROVIDER = 'Remove a provider'
|
|
63
|
+
const SIGN_OUT = 'Sign out'
|
|
64
|
+
const REMOVE = 'Remove account'
|
|
65
|
+
const NEW_POOL = 'New pool'
|
|
66
|
+
const EDIT = 'Edit'
|
|
67
|
+
const DELETE = 'Delete'
|
|
68
|
+
const SAVE_POOL = 'Save pool'
|
|
69
|
+
const CANCEL = 'Cancel'
|
|
70
|
+
const START_OVER = 'Start over'
|
|
71
|
+
const REMOVE_LAST = 'Remove the last one'
|
|
72
|
+
const CANCEL_SIGN_IN = 'Cancel sign-in'
|
|
73
|
+
const CHECK_STATUS = 'Check status'
|
|
74
|
+
|
|
75
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000
|
|
76
|
+
const POLL_MS = 1000
|
|
77
|
+
|
|
78
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
79
|
+
|
|
80
|
+
/** Runs one call, reports a failure through the menu, and says whether it worked. */
|
|
81
|
+
async function attempt(ui: MenuUi, action: () => Promise<unknown>): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
await action()
|
|
84
|
+
return true
|
|
85
|
+
} catch (error) {
|
|
86
|
+
ui.notify(message(error), 'error')
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A short "in 2h 5m" from an ISO time, or the time itself when it has passed. */
|
|
92
|
+
export function resetIn(resetsAt: string, now = Date.now()): string {
|
|
93
|
+
const minutes = Math.round((Date.parse(resetsAt) - now) / 60_000)
|
|
94
|
+
if (Number.isNaN(minutes)) return resetsAt
|
|
95
|
+
if (minutes <= 0) return 'now'
|
|
96
|
+
if (minutes < 60) return `in ${minutes}m`
|
|
97
|
+
if (minutes < 60 * 24) return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
98
|
+
return `in ${Math.floor(minutes / (60 * 24))}d`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One line per account, numbered so two accounts with the same name stay distinct in the menu. */
|
|
102
|
+
export function accountRows(overview: AccountsOverview, session: SessionAccount | null): { label: string; account: AccountView }[] {
|
|
103
|
+
return overview.accounts.map((account, index) => {
|
|
104
|
+
const mark = account.isDefault ? '●' : '○'
|
|
105
|
+
const here = session?.accountId === account.id ? ' · this session' : ''
|
|
106
|
+
return { account, label: `${index + 1}. ${mark} ${account.label} · ${HEALTH[account.state.health] ?? HEALTH.unknown}${here}` }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const limitLine = (limit: Limit): string => `${limit.label}: ${limit.usedPercent ?? '?'}% used${limit.resetsAt ? `, resets ${resetIn(limit.resetsAt)}` : ''}`
|
|
111
|
+
|
|
112
|
+
/** What `Details` shows for one account. */
|
|
113
|
+
export function accountDetails(account: AccountView): string {
|
|
114
|
+
const state = account.state
|
|
115
|
+
const lines = [
|
|
116
|
+
account.label,
|
|
117
|
+
`Health: ${HEALTH[state.health] ?? HEALTH.unknown}`,
|
|
118
|
+
`Default for new sessions: ${account.isDefault ? 'yes' : 'no'}`,
|
|
119
|
+
`Signed in: ${state.signedIn === null ? 'not checked' : state.signedIn ? 'yes' : 'no'}`,
|
|
120
|
+
`Providers: ${state.piProviders.length ? state.piProviders.join(', ') : 'none'}`,
|
|
121
|
+
state.plan ? `Plan: ${state.plan}` : null,
|
|
122
|
+
state.identity ? `Identity: ${state.identity}` : null,
|
|
123
|
+
`Last checked: ${state.lastCheckedAt ? new Date(state.lastCheckedAt).toLocaleString() : 'never'}`,
|
|
124
|
+
state.lastError ? `Last error: ${state.lastError.message}${state.lastError.resetsAt ? ` (resets ${resetIn(state.lastError.resetsAt)})` : ''}` : null,
|
|
125
|
+
...state.limits.map(limitLine),
|
|
126
|
+
state.limits.length ? null : 'Usage limits: Pi providers do not report them.',
|
|
127
|
+
state.capabilities.length ? `Detected: ${state.capabilities.join(', ')}` : null
|
|
128
|
+
]
|
|
129
|
+
return lines.filter((line): line is string => line !== null).join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The text shown while a sign-in waits for the person: a link, or a code to enter. */
|
|
133
|
+
function loginStep(step: Record<string, unknown>): string | null {
|
|
134
|
+
if (step.type === 'auth_url' && typeof step.url === 'string') return `Open this link to sign in:\n${step.url}${typeof step.instructions === 'string' ? `\n${step.instructions}` : ''}`
|
|
135
|
+
if (step.type === 'device_code' && typeof step.userCode === 'string') return `Enter the code ${step.userCode} at ${String(step.verificationUri ?? '')}`
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The whole `/accounts` conversation. Returns `'relaunch'` when the person chose to switch this session's account. */
|
|
140
|
+
export async function runAccountsMenu(ui: AccountsUi, call: AccountsCall, session: SessionAccount): Promise<'relaunch' | void> {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const overview = await call<AccountsOverview>('accounts.list')
|
|
143
|
+
const rows = accountRows(overview, session)
|
|
144
|
+
const options = [...rows.map((row) => row.label), ADD, POOLS, CHECK_ALL, DONE]
|
|
145
|
+
const picked = await ui.select('Accounts', options)
|
|
146
|
+
if (!picked || picked === DONE) return
|
|
147
|
+
if (picked === ADD) {
|
|
148
|
+
await addAccount(ui, call)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (picked === POOLS) {
|
|
152
|
+
await poolsMenu(ui, call)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (picked === CHECK_ALL) {
|
|
156
|
+
await attempt(ui, () => call('accounts.refresh'))
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const row = rows[options.indexOf(picked)]
|
|
160
|
+
if (!row) continue
|
|
161
|
+
if ((await accountMenu(ui, call, session, row.account.id)) === 'relaunch') return 'relaunch'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function addAccount(ui: AccountsUi, call: AccountsCall): Promise<void> {
|
|
166
|
+
const label = await ui.input('Name for the new account', 'Work, Personal, …')
|
|
167
|
+
if (!label?.trim()) return
|
|
168
|
+
if (await attempt(ui, () => call('accounts.create', { label: label.trim(), authKind: 'subscription' }))) ui.notify('Account added. Sign in to use it.', 'info')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function accountMenu(ui: AccountsUi, call: AccountsCall, session: SessionAccount, accountId: string): Promise<'relaunch' | void> {
|
|
172
|
+
const isSession = session.accountId === accountId
|
|
173
|
+
for (;;) {
|
|
174
|
+
const overview = await call<AccountsOverview>('accounts.list')
|
|
175
|
+
const account = overview.accounts.find((candidate) => candidate.id === accountId)
|
|
176
|
+
if (!account) return
|
|
177
|
+
const actions = [
|
|
178
|
+
...(isSession ? [] : [USE_HERE]),
|
|
179
|
+
...(account.isDefault ? [] : [MAKE_DEFAULT]),
|
|
180
|
+
RENAME,
|
|
181
|
+
CHECK,
|
|
182
|
+
DETAILS,
|
|
183
|
+
ACTIVITY,
|
|
184
|
+
SIGN_IN,
|
|
185
|
+
...(account.state.piProviders.length ? [REMOVE_PROVIDER] : []),
|
|
186
|
+
SIGN_OUT,
|
|
187
|
+
// The session's own account is in use, so it cannot be removed from inside the session.
|
|
188
|
+
...(isSession ? [] : [REMOVE]),
|
|
189
|
+
BACK
|
|
190
|
+
]
|
|
191
|
+
const action = await ui.select(`${account.label} · ${HEALTH[account.state.health] ?? HEALTH.unknown}`, actions)
|
|
192
|
+
if (!action || action === BACK) return
|
|
193
|
+
if (action === USE_HERE) {
|
|
194
|
+
if (!(await ui.confirm('Switch this session?', `This ends the terminal's Pi process and resumes the conversation on ${account.label}. Work in progress stops.`))) continue
|
|
195
|
+
if (await attempt(ui, () => call('accounts.relaunch', { accountId }))) {
|
|
196
|
+
ui.notify(`Switching to ${account.label}.`, 'info')
|
|
197
|
+
return 'relaunch'
|
|
198
|
+
}
|
|
199
|
+
} else if (action === MAKE_DEFAULT) {
|
|
200
|
+
if (await attempt(ui, () => call('accounts.setDefault', { accountId }))) ui.notify(`${account.label} is the default for new sessions.`, 'info')
|
|
201
|
+
} else if (action === RENAME) {
|
|
202
|
+
const label = await ui.input('New name', account.label)
|
|
203
|
+
if (label?.trim()) await attempt(ui, () => call('accounts.rename', { accountId, label: label.trim() }))
|
|
204
|
+
} else if (action === CHECK) {
|
|
205
|
+
if (await attempt(ui, () => call('accounts.refresh', { accountId }))) ui.notify('Checked.', 'info')
|
|
206
|
+
} else if (action === DETAILS) {
|
|
207
|
+
await ui.select(accountDetails(account), [BACK])
|
|
208
|
+
} else if (action === ACTIVITY) {
|
|
209
|
+
await showActivity(ui, call, accountId, account.label)
|
|
210
|
+
} else if (action === SIGN_IN) {
|
|
211
|
+
await signIn(ui, call, account)
|
|
212
|
+
} else if (action === REMOVE_PROVIDER) {
|
|
213
|
+
const provider = await ui.select('Remove which provider?', [...account.state.piProviders, BACK])
|
|
214
|
+
if (!provider || provider === BACK) continue
|
|
215
|
+
if (await ui.confirm(`Remove ${provider}?`, `The stored ${provider} sign-in for ${account.label} is deleted from this device.`)) await attempt(ui, () => call('accounts.removeProvider', { accountId, provider }))
|
|
216
|
+
} else if (action === SIGN_OUT) {
|
|
217
|
+
if (await ui.confirm(`Sign out of ${account.label}?`, 'Stored sign-ins are deleted. The account stays, so you can sign in again.')) await attempt(ui, () => call('accounts.signOut', { accountId }))
|
|
218
|
+
} else if (action === REMOVE) {
|
|
219
|
+
const last = overview.accounts.length === 1
|
|
220
|
+
const consequence = last
|
|
221
|
+
? 'This is the only account. New sessions will ask you to add one.'
|
|
222
|
+
: account.isDefault
|
|
223
|
+
? 'New sessions will use another account.'
|
|
224
|
+
: 'Sessions pinned to it will ask for another account next time they start.'
|
|
225
|
+
if (!(await ui.confirm(`Remove ${account.label}?`, `Its stored sign-in and its runtime folder are deleted. ${consequence}`))) continue
|
|
226
|
+
if (await attempt(ui, () => call('accounts.remove', { accountId }))) ui.notify(`${account.label} was removed.`, 'info')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function showActivity(ui: AccountsUi, call: AccountsCall, accountId: string, label: string): Promise<void> {
|
|
233
|
+
const entries = await call<{ at: string; action: string; detail: string }[]>('accounts.audit', { accountId, limit: 30 })
|
|
234
|
+
const lines = entries.map((entry) => `${new Date(entry.at).toLocaleString()} · ${entry.action} · ${entry.detail}`)
|
|
235
|
+
await ui.select(`${label}: activity\n${lines.join('\n') || 'Nothing has happened here yet.'}`, [BACK])
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function signIn(ui: AccountsUi, call: AccountsCall, account: AccountView): Promise<void> {
|
|
239
|
+
const providers = await call<ProviderOption[]>('accounts.providers')
|
|
240
|
+
if (!providers.length) {
|
|
241
|
+
ui.notify('No provider here can be signed in to.', 'warning')
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const labels = providers.map((provider, index) => `${index + 1}. ${provider.name}${account.state.piProviders.includes(provider.id) ? ' · signed in' : ''}`)
|
|
245
|
+
const pickedLabel = await ui.select(`Sign in ${account.label} to which provider?`, [...labels, BACK])
|
|
246
|
+
const index = labels.indexOf(pickedLabel ?? '')
|
|
247
|
+
const provider = providers[index]
|
|
248
|
+
if (!provider) return
|
|
249
|
+
const methods = [...(provider.oauth ? [provider.oauthLabel ?? 'Sign in with the browser'] : []), ...(provider.apiKey ? ['Enter an API key'] : [])]
|
|
250
|
+
if (!methods.length) {
|
|
251
|
+
ui.notify(`${provider.name} has no sign-in from a terminal.`, 'warning')
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
const method = methods.length === 1 ? methods[0] : await ui.select(`${provider.name}: how to sign in`, [...methods, BACK])
|
|
255
|
+
if (!method || method === BACK) return
|
|
256
|
+
if (method === 'Enter an API key') {
|
|
257
|
+
const key = await ui.input(`API key for ${provider.name}`, 'Paste the key')
|
|
258
|
+
if (!key?.trim()) return
|
|
259
|
+
if (await attempt(ui, () => call('accounts.setApiKey', { accountId: account.id, provider: provider.id, key: key.trim() }))) ui.notify(`Key saved for ${provider.name}.`, 'info')
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
await runLogin(ui, call, account.id, provider.id)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Starts a provider sign-in and follows it until it ends: shows the link or code, answers prompts, and stops on a timeout. */
|
|
266
|
+
async function runLogin(ui: AccountsUi, call: AccountsCall, accountId: string, provider: string): Promise<void> {
|
|
267
|
+
let loginId: string
|
|
268
|
+
try {
|
|
269
|
+
loginId = (await call<{ loginId: string }>('accounts.loginStart', { accountId, provider })).loginId
|
|
270
|
+
} catch (error) {
|
|
271
|
+
ui.notify(message(error), 'error')
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
return followLogin(ui, call, accountId, loginId)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function followLogin(ui: AccountsUi, call: AccountsCall, accountId: string, loginId: string): Promise<void> {
|
|
278
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS
|
|
279
|
+
let shownRevision = -1
|
|
280
|
+
for (;;) {
|
|
281
|
+
const snapshot = await call<LoginSnapshot | null>('accounts.loginStatus', { accountId })
|
|
282
|
+
// A newer sign-in on this account replaced ours; there is nothing left to follow.
|
|
283
|
+
if (!snapshot || snapshot.loginId !== loginId) return
|
|
284
|
+
if (snapshot.status !== 'running') return reportLogin(ui, snapshot)
|
|
285
|
+
if (snapshot.prompt) {
|
|
286
|
+
await answerPrompt(ui, call, loginId, snapshot.prompt)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
const step = [...snapshot.events].reverse().map(loginStep).find((text) => text !== null)
|
|
290
|
+
if (step && snapshot.revision !== shownRevision) {
|
|
291
|
+
shownRevision = snapshot.revision
|
|
292
|
+
const choice = await ui.select(`${step}\n\nFinish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN])
|
|
293
|
+
if (choice === CANCEL_SIGN_IN) {
|
|
294
|
+
await call('accounts.loginCancel', { accountId, loginId })
|
|
295
|
+
ui.notify('Sign-in cancelled.', 'warning')
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if (Date.now() > deadline) {
|
|
301
|
+
await call('accounts.loginCancel', { accountId, loginId })
|
|
302
|
+
ui.notify('Sign-in timed out and was cancelled.', 'error')
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function reportLogin(ui: AccountsUi, snapshot: LoginSnapshot): void {
|
|
310
|
+
if (snapshot.status === 'completed') ui.notify('Signed in.', 'info')
|
|
311
|
+
else if (snapshot.status === 'cancelled') ui.notify('Sign-in was cancelled.', 'warning')
|
|
312
|
+
else ui.notify(snapshot.error ?? 'Sign-in failed.', 'error')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function answerPrompt(ui: AccountsUi, call: AccountsCall, loginId: string, prompt: Record<string, unknown>): Promise<void> {
|
|
316
|
+
const promptId = String(prompt.promptId ?? '')
|
|
317
|
+
const detail = (prompt.prompt ?? {}) as { type?: string; message?: string; placeholder?: string | null; options?: unknown }
|
|
318
|
+
const text = detail.message ?? 'Sign-in needs an answer.'
|
|
319
|
+
// A secret is typed with `input` like any other answer: it is never echoed or logged here.
|
|
320
|
+
let value: string | undefined
|
|
321
|
+
if (detail.type === 'select' && Array.isArray(detail.options)) {
|
|
322
|
+
if (detail.options.every((option) => typeof option === 'string')) {
|
|
323
|
+
value = await ui.select(text, detail.options as string[])
|
|
324
|
+
} else if (detail.options.every((option) => typeof option === 'object' && option !== null && 'id' in option && typeof option.id === 'string' && 'label' in option && typeof option.label === 'string')) {
|
|
325
|
+
const options = detail.options as { id: string; label: string }[]
|
|
326
|
+
const selected = await ui.select(text, options.map((option) => option.label))
|
|
327
|
+
value = options.find((option) => option.label === selected)?.id
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
value = await ui.input(text, detail.placeholder ?? undefined)
|
|
331
|
+
}
|
|
332
|
+
await call('accounts.loginRespond', { loginId, promptId, value: value ?? null })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function poolsMenu(ui: AccountsUi, call: AccountsCall): Promise<void> {
|
|
336
|
+
for (;;) {
|
|
337
|
+
const overview = await call<AccountsOverview>('accounts.list')
|
|
338
|
+
const names = new Map(overview.accounts.map((account) => [account.id, account.label]))
|
|
339
|
+
const labels = overview.pools.map((pool, index) => `${index + 1}. ${pool.label} · ${pool.accountIds.map((id) => names.get(id) ?? 'removed account').join(' → ')}`)
|
|
340
|
+
const options = [NEW_POOL, ...labels, BACK]
|
|
341
|
+
const picked = await ui.select('Pools: new sessions use the first ready account in a pool, in this order', options)
|
|
342
|
+
if (!picked || picked === BACK) return
|
|
343
|
+
if (picked === NEW_POOL) {
|
|
344
|
+
await editPool(ui, call, overview, null)
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
const pool = overview.pools[options.indexOf(picked) - 1]
|
|
348
|
+
if (!pool) continue
|
|
349
|
+
const action = await ui.select(pool.label, [EDIT, DELETE, BACK])
|
|
350
|
+
if (action === EDIT) await editPool(ui, call, overview, pool)
|
|
351
|
+
else if (action === DELETE && (await ui.confirm(`Delete ${pool.label}?`, 'The accounts stay. Only the pool is removed.'))) await attempt(ui, () => call('accounts.removePool', { poolId: pool.id }))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function editPool(ui: AccountsUi, call: AccountsCall, overview: AccountsOverview, pool: { id: string; label: string; accountIds: string[] } | null): Promise<void> {
|
|
356
|
+
const label = await ui.input('Pool name', pool?.label)
|
|
357
|
+
if (!label?.trim()) return
|
|
358
|
+
const names = new Map(overview.accounts.map((account) => [account.id, account.label]))
|
|
359
|
+
const order: string[] = [...(pool?.accountIds ?? [])]
|
|
360
|
+
for (;;) {
|
|
361
|
+
const ordered = order.map((id, index) => `${index + 1}. ${names.get(id) ?? 'removed account'}`).join(' ')
|
|
362
|
+
const free = overview.accounts.filter((account) => !order.includes(account.id))
|
|
363
|
+
const addOptions = free.map((account) => `Add ${account.label}`)
|
|
364
|
+
const options = [...addOptions, ...(order.length ? [REMOVE_LAST, START_OVER] : []), SAVE_POOL, CANCEL]
|
|
365
|
+
const picked = await ui.select(`${label.trim()}: ${ordered || 'no accounts yet'}`, options)
|
|
366
|
+
if (!picked || picked === CANCEL) return
|
|
367
|
+
if (picked === SAVE_POOL) {
|
|
368
|
+
if (!order.length) {
|
|
369
|
+
ui.notify('A pool needs at least one account.', 'error')
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
if (await attempt(ui, () => call('accounts.savePool', { pool: { id: pool?.id, label: label.trim(), accountIds: order } }))) ui.notify('Pool saved.', 'info')
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
if (picked === START_OVER) order.length = 0
|
|
376
|
+
else if (picked === REMOVE_LAST) order.pop()
|
|
377
|
+
else {
|
|
378
|
+
const account = free[addOptions.indexOf(picked)]
|
|
379
|
+
if (account) order.push(account.id)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const calls = vi.hoisted(() => ({ socket: vi.fn(async (_connection: unknown, call: string) => call === 'control.claim' ? { key: 'lexical-secret' } : { done: true }), env: { socket: '/fake/socket', token: 'unique-control-test' } }))
|
|
4
|
+
vi.mock('./client', () => ({ connectionFromEnv: () => calls.env, socketCall: calls.socket }))
|
|
5
|
+
|
|
6
|
+
import { claimTerminalControl, controlCall } from './control'
|
|
7
|
+
|
|
8
|
+
describe('the process-lifetime terminal control claim', () => {
|
|
9
|
+
it('shares one concurrent claim with the bridge and frontend and passes the key only in protected calls', async () => {
|
|
10
|
+
const [first, second] = await Promise.all([claimTerminalControl(), claimTerminalControl()])
|
|
11
|
+
expect(first).toBe('lexical-secret')
|
|
12
|
+
expect(second).toBe(first)
|
|
13
|
+
await expect(controlCall('terminal.pending', { cursor: 'question' }, 321)).resolves.toEqual({ done: true })
|
|
14
|
+
expect(calls.socket.mock.calls.filter(([, call]) => call === 'control.claim')).toHaveLength(1)
|
|
15
|
+
expect(calls.socket).toHaveBeenLastCalledWith(calls.env, 'terminal.pending', { cursor: 'question' }, 321, 'lexical-secret')
|
|
16
|
+
expect(calls.env).toEqual({ socket: '/fake/socket', token: 'unique-control-test' })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps a different terminal grant separate', async () => {
|
|
20
|
+
await claimTerminalControl({ socket: '/fake/socket', token: 'different-terminal' })
|
|
21
|
+
expect(calls.socket.mock.calls.filter(([, call]) => call === 'control.claim')).toHaveLength(2)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { connectionFromEnv, socketCall, type SocketConnection } from './client'
|
|
2
|
+
|
|
3
|
+
const claims = new Map<string, Promise<string>>()
|
|
4
|
+
|
|
5
|
+
/** A process owns the claim across native session replacement; it never enters the environment. */
|
|
6
|
+
export function claimTerminalControl(connection: SocketConnection = connectionFromEnv()): Promise<string> {
|
|
7
|
+
const identity = JSON.stringify([connection.socket, connection.token])
|
|
8
|
+
let claim = claims.get(identity)
|
|
9
|
+
if (!claim) {
|
|
10
|
+
claim = socketCall<{ key: string }>(connection, 'control.claim', {}, 15_000).then(({ key }) => {
|
|
11
|
+
if (!key) throw new Error('The terminal controller returned no control key.')
|
|
12
|
+
return key
|
|
13
|
+
})
|
|
14
|
+
claims.set(identity, claim)
|
|
15
|
+
}
|
|
16
|
+
return claim
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function controlCall<T = unknown>(call: string, params: Record<string, unknown> = {}, timeoutMs = 120_000): Promise<T> {
|
|
20
|
+
const connection = connectionFromEnv()
|
|
21
|
+
const control = await claimTerminalControl(connection)
|
|
22
|
+
return socketCall<T>(connection, call, params, timeoutMs, control)
|
|
23
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
6
|
|
|
7
7
|
import { HarnessError } from '../core/errors'
|
|
8
|
-
import type {
|
|
8
|
+
import type { RunEvent } from '../sessions/contracts'
|
|
9
|
+
import type { ActiveRun, ApprovalDecision, QuestionAnswer, QuestionInput } from '../sessions/registry'
|
|
9
10
|
|
|
10
11
|
export interface PolicyReply {
|
|
11
12
|
allowed: boolean
|
|
@@ -23,6 +24,10 @@ export interface ControllerHooks {
|
|
|
23
24
|
/** The native conversation identity changed. */
|
|
24
25
|
native(nativeSessionId: string): void
|
|
25
26
|
telemetry(event: string): void
|
|
27
|
+
interrupt?(runId: string): Promise<void>
|
|
28
|
+
resolveApproval?(runId: string, requestId: string, decision: ApprovalDecision): void
|
|
29
|
+
answerQuestion?(runId: string, requestId: string, answer: QuestionAnswer): void
|
|
30
|
+
answerQuestions?(runId: string, batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): void
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
interface Delivery {
|
|
@@ -39,6 +44,7 @@ const ACK_TIMEOUT_MS = 25_000
|
|
|
39
44
|
|
|
40
45
|
export class TerminalController {
|
|
41
46
|
private run: ActiveRun | null = null
|
|
47
|
+
private readonly pending = new Map<string, Record<string, unknown>>()
|
|
42
48
|
private ready = false
|
|
43
49
|
private disposed = false
|
|
44
50
|
private dirty = false
|
|
@@ -90,6 +96,7 @@ export class TerminalController {
|
|
|
90
96
|
this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined)
|
|
91
97
|
this.run = null
|
|
92
98
|
this.seen.clear()
|
|
99
|
+
this.pending.clear()
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
/** Hands the native process an instruction and waits until it acknowledges the prompt. */
|
|
@@ -140,6 +147,52 @@ export class TerminalController {
|
|
|
140
147
|
this.finish('failed', 'The native decision callback disconnected. Inspect the terminal before continuing.')
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
/** Project registry-owned questions, including grouped requests, into the terminal inbox. */
|
|
151
|
+
observe(event: RunEvent): void {
|
|
152
|
+
if (this.disposed || event.runId !== this.activeRun?.runId) return
|
|
153
|
+
if (event.type === 'question.requested') {
|
|
154
|
+
const payload = event.payload as Record<string, unknown>
|
|
155
|
+
this.pending.set(String(payload.questionId), { ...payload, type: 'question', requestId: payload.questionId })
|
|
156
|
+
} else if (event.type === 'question.answered') {
|
|
157
|
+
this.pending.delete(String((event.payload as { questionId: string }).questionId))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The socket grants this interface only to the process that owns terminal input. */
|
|
162
|
+
control(call: string, data: Record<string, any>): unknown {
|
|
163
|
+
if (this.disposed) throw new HarnessError('terminal_not_ready', 'This terminal has ended.')
|
|
164
|
+
if (call === 'terminal.draft') {
|
|
165
|
+
this.dirty = data.dirty === true
|
|
166
|
+
this.multilineDraft = false
|
|
167
|
+
return {}
|
|
168
|
+
}
|
|
169
|
+
if (call === 'terminal.pending') return { requests: [...this.pending.values()] }
|
|
170
|
+
if (call === 'terminal.interrupt') return this.activeRun && this.hooks.interrupt ? this.hooks.interrupt(this.activeRun.runId) : {}
|
|
171
|
+
if (call === 'terminal.answers' && this.hooks.answerQuestions) {
|
|
172
|
+
const run = this.activeRun
|
|
173
|
+
const batchId = String(data.batchId ?? '')
|
|
174
|
+
const requests = [...this.pending.values()].filter(request => request.batchId === batchId)
|
|
175
|
+
if (!run || !batchId || !requests.length || !Array.isArray(data.answers)) throw new HarnessError('invalid_request', 'This question batch is no longer pending.')
|
|
176
|
+
this.hooks.answerQuestions(run.runId, batchId, data.answers)
|
|
177
|
+
return {}
|
|
178
|
+
}
|
|
179
|
+
const requestId = String(data.requestId ?? '')
|
|
180
|
+
const request = this.pending.get(requestId)
|
|
181
|
+
const run = this.activeRun
|
|
182
|
+
if (!request || !run) throw new HarnessError('invalid_request', 'This terminal decision is no longer pending.')
|
|
183
|
+
if (call === 'terminal.approve' && request.type === 'approval' && this.hooks.resolveApproval) {
|
|
184
|
+
if (data.decision !== 'approved' && data.decision !== 'denied') throw new HarnessError('invalid_request', 'Choose approve or deny.')
|
|
185
|
+
this.hooks.resolveApproval(run.runId, requestId, data.decision)
|
|
186
|
+
return {}
|
|
187
|
+
}
|
|
188
|
+
if (call === 'terminal.answer' && request.type === 'question' && this.hooks.answerQuestion) {
|
|
189
|
+
if (!data.answer || !Array.isArray(data.answer.selected)) throw new HarnessError('invalid_request', 'A question answer is required.')
|
|
190
|
+
this.hooks.answerQuestion(run.runId, requestId, data.answer)
|
|
191
|
+
return {}
|
|
192
|
+
}
|
|
193
|
+
throw new HarnessError('unknown_method', `Unknown terminal control ${call}.`)
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
async accept(event: string, data: Record<string, any>): Promise<unknown> {
|
|
144
197
|
if (this.disposed) throw new Error('This terminal controller was disposed.')
|
|
145
198
|
if (event !== 'poll') this.hooks.telemetry(event)
|
|
@@ -182,14 +235,20 @@ export class TerminalController {
|
|
|
182
235
|
const run = this.run
|
|
183
236
|
if (!run) throw new Error('Native telemetry arrived without an active turn.')
|
|
184
237
|
if (event === 'policy') return this.hooks.policy(run, String(data.tool), data.args ?? {})
|
|
238
|
+
if (event === 'questions') {
|
|
239
|
+
if (!Array.isArray(data.questions) || !data.questions.length || data.questions.length > 5) throw new HarnessError('invalid_request', 'Ask between one and five questions.')
|
|
240
|
+
const questions = (data.questions as QuestionInput[]).map(question => ({ ...question, requestId: randomUUID() }))
|
|
241
|
+
return run.requestAnswers(questions)
|
|
242
|
+
}
|
|
185
243
|
if (event === 'approval' || event === 'question') {
|
|
186
|
-
const key = this.requestId(String(data.requestId ?? JSON.stringify(data)))
|
|
244
|
+
const key = this.requestId(`${event}:${String(data.requestId ?? JSON.stringify(data))}`)
|
|
187
245
|
const existing = this.seen.get(key)
|
|
188
246
|
if (existing) return event === 'approval' ? { decision: await existing } : existing
|
|
189
247
|
if (event === 'question') {
|
|
190
248
|
const response: Promise<QuestionAnswer> = run.requestAnswer({ requestId: key, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect, allowText: true })
|
|
249
|
+
this.pending.set(key, { type: 'question', requestId: key, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect })
|
|
191
250
|
this.seen.set(key, response)
|
|
192
|
-
return response
|
|
251
|
+
return response.finally(() => this.pending.delete(key))
|
|
193
252
|
}
|
|
194
253
|
const response: Promise<ApprovalDecision> = run.requestApproval({
|
|
195
254
|
requestId: key,
|
|
@@ -198,8 +257,13 @@ export class TerminalController {
|
|
|
198
257
|
operation: { tool: String(data.tool ?? data.target), args: data.args ?? {} },
|
|
199
258
|
rememberable: false
|
|
200
259
|
})
|
|
260
|
+
this.pending.set(key, { type: 'approval', requestId: key, target: String(data.target ?? 'Action'), action: String(data.action ?? '{}') })
|
|
201
261
|
this.seen.set(key, response)
|
|
202
|
-
|
|
262
|
+
try {
|
|
263
|
+
return { decision: await response }
|
|
264
|
+
} finally {
|
|
265
|
+
this.pending.delete(key)
|
|
266
|
+
}
|
|
203
267
|
}
|
|
204
268
|
if (event === 'usage') run.emit('usage.updated', data)
|
|
205
269
|
else if (event === 'file') run.emit('file.changed', data)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HubOverview, HubReceipt } from '../hub/schema'
|
|
2
|
+
import type { MenuCall, MenuUi } from './pluginMenu'
|
|
3
|
+
|
|
4
|
+
/** The terminal's catalog uses the same exact-version installer as embedding applications. */
|
|
5
|
+
export async function runHubMenu(ui: MenuUi, call: MenuCall): Promise<void> {
|
|
6
|
+
for (;;) {
|
|
7
|
+
const catalog = await call<HubOverview>('hub.list')
|
|
8
|
+
const labels = catalog.entries.map((entry) => `${entry.title} · ${entry.category} · ${entry.id}@${entry.version}`)
|
|
9
|
+
const picked = await ui.select('Hub', [...labels, 'Refresh catalog', 'Done'])
|
|
10
|
+
if (picked === 'Refresh catalog') {
|
|
11
|
+
const refreshed = await call<HubOverview>('hub.list', { refresh: true })
|
|
12
|
+
if (refreshed.error) ui.notify(`${refreshed.error} Using ${refreshed.origin} content.`, 'warning')
|
|
13
|
+
continue
|
|
14
|
+
}
|
|
15
|
+
const entry = catalog.entries[labels.indexOf(picked ?? '')]
|
|
16
|
+
if (!entry) return
|
|
17
|
+
const summary = `${entry.description}\n\nPublisher: ${entry.publisher}\nLicense: ${entry.license}\nSource: ${entry.sourceUrl}\n\n${entry.delivery.kind === 'document' ? 'Saved as data for a compatible application. Nothing is created or enabled.' : 'Installed switched off. Code remains untrusted and approvals remain unchanged.'}`
|
|
18
|
+
if (!await ui.confirm(`Install ${entry.title} ${entry.version}?`, summary)) continue
|
|
19
|
+
try {
|
|
20
|
+
const receipt = await call<HubReceipt>('hub.install', { id: entry.id, version: entry.version })
|
|
21
|
+
ui.notify(receipt.document ? 'Saved in this library for a compatible application.' : 'Installed, switched off. Use /plugins to review and attach it. Changes apply after restarting the session.', 'info')
|
|
22
|
+
} catch (error) { ui.notify(error instanceof Error ? error.message : String(error), 'error') }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -1,26 +1,17 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process'
|
|
2
1
|
import { describe, expect, it } from 'vitest'
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { resolvePiCommand, terminalCommandFiles } from './launch'
|
|
5
4
|
|
|
6
5
|
describe('terminal launch commands', () => {
|
|
7
|
-
it('runs the
|
|
8
|
-
const launch = resolvePiCommand({ platform
|
|
6
|
+
it.each(['darwin', 'linux', 'win32'] as const)('runs the owned Harness frontend with the active Node runtime on %s', (platform) => {
|
|
7
|
+
const launch = resolvePiCommand({ platform, node: 'C:\\Program Files\\nodejs\\node.exe', cli: '/bundle/cli.js' })
|
|
9
8
|
|
|
10
9
|
expect(launch.program).toBe('C:\\Program Files\\nodejs\\node.exe')
|
|
11
|
-
expect(launch.argsPrefix
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
it('runs the package CLI through Node for a no-model help smoke', () => {
|
|
15
|
-
const result = spawnSync(process.execPath, [bundledPiCli(), '--help'], { encoding: 'utf8', timeout: 20_000 })
|
|
16
|
-
|
|
17
|
-
expect(result.error).toBeUndefined()
|
|
18
|
-
expect(result.status).toBe(0)
|
|
19
|
-
expect(`${result.stdout}${result.stderr}`).toMatch(/usage|options|pi/i)
|
|
10
|
+
expect(launch.argsPrefix).toEqual(['/bundle/cli.js', 'terminal-frontend'])
|
|
20
11
|
})
|
|
21
12
|
|
|
22
13
|
it('keeps an explicitly configured executable even on Windows', () => {
|
|
23
|
-
expect(resolvePiCommand({ platform: 'win32', node: 'node.exe', programOverride: () => 'C:\\Tools\\pi.exe' }))
|
|
14
|
+
expect(resolvePiCommand({ platform: 'win32', node: 'node.exe', cli: '/bundle/cli.js', programOverride: () => 'C:\\Tools\\pi.exe' }))
|
|
24
15
|
.toEqual({ program: 'C:\\Tools\\pi.exe', argsPrefix: [] })
|
|
25
16
|
})
|
|
26
17
|
|